Pull to refresh

Популяризация JSON-RPC (часть 1)

Reading time 8 min
Views 21K

Для передачи данных по сети есть хорошо зарекомендовавшие себя стандарты. Среди основных: SOAP, gRPC, AMQP, REST, GraphQL.

При создании вебсайтов малой, средней и большой сложности с потоками данных к бэкенду и обратно в JSON формате обычно используются последние два с их вариантами. Верней, только варианты, потому что REST и GraphQL - ресурсо-ориентированные стандарты. Это как бы просто перенос элементарной работы с базой данных на клиента (хотя под "ресурсом" может пониматься и абстракция). Обычно таких запросов не больше трети от всего бэкенд API.

Попытка сделать весь API максимально RESTful страшно раздувает код и грузит сеть. Потому что остальные две трети запросов - в форме команд на бэкенд проделать какие-то действия, слабо отображающиеся на CRUD над некими ресурсами. И вариантов послать такие запросы достаточно много. Даже, чересчур.

GET, POST, PUT, PATCH, HTTP заголовки, куки, body, данные форм, GET query параметры, json, content-type, HTTP коды... Когда в команде несколько программеров, и у каждого свой взгляд на мироустройство, довольно быстро это превращается в винегрет. Даже один фулстэк разработчик часто оказывается в тупике перед всем этим месивом параметров, глаголов и существительных RESTlike API в непонимании как дальше с этим жить.

Всё это привело к созданию простой и понятной, но с моей точки зрения сильно недооцененной спецификации JSON-RPC (https://www.jsonrpc.org/specification), которая отделяет бизнес-логику клиент-серверного запроса  от самого сетевого протокола (HTTP) с его богатым, но не всегда нужным внутренним миром.

В JSON-RPC все запросы стандартизовано идут через HTTP POST в форме JSON объекта (в принципе, JSON-RPC 2.0 - это транспорто-независимый протокол, но мы рассматриваем наиболее частое его употребление). Обмен данными строгий и понятный. В запросе есть method и params. Method играет роль эндпойнта/команды, params - параметров. Ответы сервера приходят примерно в таком же виде.

RPC означает Remote procedure call, то есть, на бэкенд посылается команда о выполнении некоего кода. Команда по смыслу и предназначению может быть любой. В этом отличие RPC от REST, ограниченного четырьмя действиями CRUD на неких ресурсах.

JSON в названии означает, что обмен информацией между клиентом и сервером (микросервисами) идет через данные JSON формате.

Подробней о JSON-RPC можно прочитать, например, тут - https://habr.com/ru/post/441854/ или во многих других местах. Ничего сложного. В данной статье я хотел бы сконцентрироваться именно на его плюсах, а не описании.

Также, для задумывающихся о грамотном дизайне networked API рекомендую статьи Google на эту тему - https://cloud.google.com/apis/design. Архитектура для API не менее важна, как и для самой программной системы. Есть даже такое направление - API Driven Architecture, хотя по-моему опыту сперва обычно всё-таки делается какая-то альфа версия приложения, а потом уже рефакторится и унифицируется его API.

Плюсы

JSON-RPC отделяет бизнес логику от сетевого протокола. На этом, на самом деле, можно было бы запустить фейерверк и закончить. Да, он облегчает коммуникацию между разработчиками фронта и бэка, даёт лучшую структуру и понимание данных, простоту в разработке, протоколо-независимость, но, главное, с моей точки зрения, именно вышеуказанное отделение.

Что значит это для фронтэндера? Я обычно делаю модуль доступа к API и работаю через него:

import api from '@/api';

api.products.list();
api.users.update(userId, {"balance", 100});

Внутри list() и update() запросы через fetch() или axios() к нужным эндпойнтам backend API. Переход на стандарт JSON-RPC несложен, может быть проведен постепенно и особо профита в кодинге не приносит, кроме отсутствия необходимости задумываться, что использовать - POST, PUT или PATCH, и как передавать параметры, как обрабатывать приходящий результат, ошибки и т.п.

Совсем другое дело бэкенд. Я большой фанат CodeIgniter 4 (далее CI), считаю его лучшим PHP фреймворком для малых и средних по размеру API, и буду говорить на его примере, но Laravel, Sping Boot, Django работают примерно по тому же принципу.

На бэке каждый запрос обрабатывается контроллером (которые сейчас по сути являются уже рудиментом MVC архитектурного шаблона времён генерации контента на сервере), в который передаются (условно) HttpRequest и HttpMessage. Один контроллер может обрабатывать несколько эндпойнтов, это прописывается в роутинге фреймворка. В контроллерах есть доступ к деталям транспортного (HTTP) протокола. Зачастую в контроллерах лежит вся бизнес логика, включая работу с БД и другими внутренними сервисами.

Что происходит, когда вы решаете сменить фреймворк, потому что нашли лучше? Вы переписываете контроллеры. Что происходит, когда ваш фреймворк радикально обновился, и вам нравятся эти новшества, вы хотите их использовать, но там много breaking change изменений? Вы переписываете контроллеры. А это бОльшая часть кода. И перейти на новую версию постепенно довольно сложно, только разом. Всё потому что ваша логика встроена в фреймворк.

А что с JSON-RPC? Там есть только один контроллер, который обрабатывает все запросы и перенаправляет их на нужные модули своим внутренним роутингом. Вот весь HTTP роутинг в CI:

$routes->post('rpc', 'JsonRpcController::index');

Вот файл внутреннего роутинга, используемый JsonRpcController-ом:

JsonRpcRoutes.php
<?php

namespace App\Controllers;

class JsonRpcRoutes
{

    public static $basePath = "App\src\\";

    public static $routes = [
        "users" => [
            "transactions:list" => "Users\Transactions::list",
            "withdrawal:create" => "Users\Transactions::createWithdrawal",
        ],
        "utils" => [
            "resources:list" => "Utils\Resources::list",
            "resources:update" => "Utils\Resources::update",
            "resources:getByKey" => "Utils\Resources::getByKey",
            "resources:updateByKey" => "Utils\Resources::updateByKey",
            "resourceCache:clear" => "Utils\Resources::clearCache",
        ],
    ];


    public static function route($method) {
        $path = explode('.' , $method);
        $route = self::$routes;
        foreach ($path as $step) {
            $route = $route[$step];            
        }
        return $route;
    }


    
}

Иерархическая модель в $routes задана для удобства. Функция route() берет метод запроса (method - "плоский" вариант роута, например, users.transactions:list) и выдает соответствующий ему класс и функцию на нем.

Выбор нотации названия метода полностью за разработчиком, но мне нравятся рекомендации Google из вышеприведенной ссылки.

Utils\Resources и Users\Transactions - просто PHP классы, отвечающие за бизнес логику. Им приходят данные в виде объекта, и они отдают результат в виде объекта. Никакой связи с HTTP протоколом. Если потребуется сменить фреймворк, то нужно будет переписать только один файл - JsonRpcController.php

Ну и он сам:

JsonRpcController.php
<?php

namespace App\Controllers;

use CodeIgniter\Controller;
use stdClass;

class JsonRpcController extends Controller
{
    public function index()
    {
        try {
            $payloadData = $this->request->getJSON();
        } catch (\Throwable $th) {
            return $this->response->setJSON($this->errorResponse(-32700));
        }        
        $response = null;
        
        try {            
            // batch payload
            if (is_array($payloadData)) {
                if (count($payloadData) == 0) {
                    return $this->response->setJSON($this->errorResponse(-32600));
                }
                $response = [];
                foreach ($payloadData as $payload) {
                    $singleResponse = $this->processRequest($payload);
                    if ($singleResponse != null) {
                        $response[] = $singleResponse;
                    }
                }
                if (count($response) > 0) {
                    return $this->response->setJSON($response);
                }
            // single request
            } else if (is_object($payloadData)) {
                $response = $this->processRequest($payloadData);
                return $this->response->setJSON($response);
            } else {
                return $this->response->setJSON($this->errorResponse(-32700));
            }
        } catch (\Throwable $th) {
            return $this->response->setJSON($this->errorResponse(-32603, null, [
                "msg" => $th->getMessage(),
                "trace" => $th->getTrace()
            ]));
        }
    }

    /**
     * Process single JSON-RPC request.
     *
     * @param object    $paylod Request object
     * @return object   Response object
     */
    private function processRequest($payload) {
        if (!is_object($payload)) {
            return $this->errorResponse(-32700);
        }            
        if (!property_exists($payload, "jsonrpc") && !property_exists($payload, "method")) {
            return $this->errorResponse(-32600);
        }      
        $payload->context = new stdClass();
        if ($this->request->currentUser ?? NULL) {
            $payload->context->user = $this->request->currentUser;
        }

        $route = JsonRpcRoutes::route($payload->method);
        if (!$route) {
            return $this->errorResponse(-32601, $payload->id);
        }

        list($className, $methodName) = explode("::", $route);
        $className = JsonRpcRoutes::$basePath . $className;
        $outcome = (new $className())->$methodName($payload);

        if (!property_exists($payload, "id") || !$outcome) {
            return null;
        }
        
        $data = [
            "jsonrpc" => "2.0",
            "id" => $payload->id
        ];
        return array_merge($data, $outcome);
    }

    /**
     * Used for generic failures.
     *
     * @param int       $errorCode   according to JSON-RPC specification
     * @return Object   Response object for this error
     */
    private function errorResponse($errorCode, $id = null, $data = null) {
        $response = [
            "jsonrpc" => "2.0",
            "error" => [
                "code" => $errorCode,
                "message" => ''
            ],
            "id" => $id
        ];
        if ($data) {
            $response["error"]["data"] = $data;
        }
        switch ($errorCode) {
            case '-32600':
                $response["error"]["message"] = "Invalid Request";
                break;            
            case '-32700':
                $response["error"]["message"] = "Parse error";
                break;            
            case '-32601':
                $response["error"]["message"] = "Method not found";
                break;            
            case '-32602':
                $response["error"]["message"] = "Invalid params";
                break;            
            case '-32603':
                $response["error"]["message"] = "Internal error";
                break;            
            default:
                $response["error"]["message"] = "Internal error";
                break;
        }
        return $response;
    }

}

Контроллер мог бы быть в три-четыре раза меньше, но мне захотелось реализовать требования спецификации JSON-RPC 2.0 по максимуму, в итоге половина кода - корректная обработка всевозможных ошибок. По сути же он просто берет роут из JsonRpcRoutes и вызывает соответствующий метод на нужном классе, передавая ему параметры.

Да, в бэкэнд фреймворках есть другие часто используемые сервисы - например, для упрощения доступа к данным в БД (Active records (не ORM) в CodeIgniter 4 - очень эффектное решение, генерирующее эффективный SQL, возможно, единственное, ради чего стоит использовать CI, а не чистый rpc.php как вход с вебсервера), и их надо будет адаптировать при возможном переезде, но при желании модуль бизнес-логики (Utils\Resources и Users\Transactions) можно написать на чистом PHP/Java/Python, либо самостоятельно использовать сторонние библиотеки и быть полностью независимым от фреймворка. А ведь фреймворки очень хотят привязать разработчиков к себе.

А насколько проще тестировать свои классы, а не контроллеры, насколько проще строить архитектуру приложения без ограничений фреймворка, его контекста, потока исполнения и магии.

Я уж не говорю о переиспользовании кода: вызвать из одного контроллера метод другого, чтобы применить его ответ для выдачи клиенту - задача не тривиальная. Потому что вход и выход в контроллерах - через протоколозависимые шлюзы фреймворка. Да, можно вытащить всю логику из контроллеров и использовать их как пустые обертки/прокси - но в чем тогда будет их смысл? Да и именно это и делает JSON-RPC, - в том числе.

Нынешние бэкенд фреймворки создавались когда всё еще генерировалось на сервере и клиенту посылался готовый html. Для всего этого нужны были серверные сессии, шаблонизаторы и прочие штуки серверных фреймворков. Для современных SPA-ориентированных бэкенд APIs всё это не нужно, это тяжелый груз, который часто как кандалы мешает работать с современными технологиями.

Вывод

JSON-RPC дарит разработчикам свободу.

К сожалению JSON-RPC всё еще относится к эзотерическому знанию, крутость которого понимаешь только после того как его попробуешь, а попробовать решаются только разработчики, достигшие определенного уровня усталости от жизни такой. Большинство продолжает самоделить каждый раз корявые рестоподобные велосипеды.

Я бы даже сказал, что JSON-RPC - единственный стандарт, который технически полностью можно реализовать при построении коммуникации с бэкендом в 90% современных web app и мобильных приложений (если не смотреть в сторону gRPC и экзотики). И который реально приносит пользу в DX. Попытка реализации других стандартов будет большим компромиссом (просто оставлю здесь слово "HATEOAS").

Во второй части я остановлюсь на нескольких особенностях при работе с JSON-RPC, а именно: зачем нужны batch пакеты, аутентификация и авторизация, как видеть в DevTools / Network и логах вебсервера семантически понятную информацию о запросах, а не просто /rpc, и как стать господином бэкэнд фреймворка, а не его рабом.

Tags:
Hubs:
+5
Comments 36
Comments Comments 36

Articles