JSON-RPC 2.0 и PHP

    Если вы разработчик, и у вас есть проект на PHP, и ему наконец-то понадобилось реализовать собственное API — эта статья определенно для вас ;).

    JSON-RPC v1.0 появился в 2005 году, спустя 5 лет появилась и вторая версия. В век javascript'а и мобильных приложений многие разработчики до сих пор используют свои собственные велосипеды вместо готового простого стандарта.

    Почему JSON-RPC, да ещё и 2.0?


    Попытаюсь выделить ключевые особенности:
    • Является хоть каким-то стандартом. Богатый выбор библиотек для различных платформ.
    • Быстрый легкий парсинг. JSON-RPC не такой монструозный как XML-RPC или SOAP.
    • Встроенная обработка ошибок. При REST-велосипедах приходится придумывать, как правильно построить ответ и что с ним делать дальше.
    • Поддержка очереди вызовов. Теперь 5 HTTP-запросов можно обернуть в один :).
    • Единая точка для API. Например, /rpc-server.php сможет обрабатывать различные методы.
    • Поддержка именованных и опциональных параметров при вызове методов.

    Для тех, кто знаком с версией 1.0, значимыми нововведениями в 2.0 были именованные параметры и очередь вызовов.
    Простой запрос/ответ выглядит следующим образом:
    --> {"jsonrpc": "2.0", "method": "subtract", "params": {"subtrahend": 23, "minuend": 42}, "id": 3}
    <-- {"jsonrpc": "2.0", "result": 19, "id": 3}
    
    --> {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}
    <-- {"jsonrpc": "2.0", "result": 19, "id": 1}
    
    --> [
            {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"},
            {"jsonrpc": "2.0", "method": "foobar", "id": "2"}
        ]
    <-- [
            {"jsonrpc": "2.0", "result": 7, "id": "1"},
            {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found."}, "id": "2"}
        ]
    


    В этом сложном «творческом» мире программирования всегда встает вопрос: взять что-то готовое или написать что-то свое?

    Идеальный мир. Что нам нужно?


    Как обычно, определим критерии к инструменту, который хочется найти/получить:
    • Полное соответствие спецификации. С учетом этого фактора, скорее всего, не возникнет проблем с другими клиент/серверными решениями
    • Однофайловая независимая реализация. Не хочется тащить за собой какой-нибудь framework а-ля Zend или PEAR
    • Наличие тестов. Хоть какая-то гарантия, что open-source решение будет работать правильно ;)
    • Красивая реализация внутри. Все должно быть максимально просто, понятно и логично.
    • Наличие auto-discover механизма. JSON-RPC Server должен уметь отдавать мета-информацию о своих методах
    • Простое подключение. Подключил файл, вызвал пару методов, все работает.
    • Генерация прокси-классов клиента. Зачем писать клиент, когда у нас уже есть готовый сервер с метаданными? ;)

    Для начала хватит.

    Немного теории


    Т.к. JSON-RPC достаточно молодой протокол, то в нем ещё есть моменты, которые до конца не утверждены. Один из них — Service Mapping Description, предложенный Dojo. SMD полностью может описать веб-сервис, начиная от его методов, заканчивая развернутыми возвращаемыми типами. К сожалению, очень мало решений поддерживает его реализацию, например Zend_Json_Server, inputEx framework (генерация формочек для тестирования) и сам Dojo framework.

    Перейдем к поиску существующих решений.

    Существующие реализации для PHP


    Список клиентов я взял из таблички в википедии.
      php-json-rpc jsonrpc2php tivoka junior json-rpc-php JSONRpc2 Zend
    Json
    Server
    zoServices
    Сервер        
    Соответствие спецификации - Правильная поддержка Notification в Batch режиме.
    + Нет optional named params + + + Нет optional named params
    Кол-во файлов - 2 >7 3 6 1 >5 6
    SMD-схема - - - - - - + -
    Тесты - + - + - + + -
    Внутрення реализация (1..5) - 4
    Ручной маппинг экспортируемых функций
    3. Слишком сложная реализация для такой простой задачи 4 4. Сложно 4. Magic Inside! 4+. Zend 3.
    Клиент        
    Соответствие спецификации Нет Batch и Notification  - + + + Нет Batch - Notificaiton отсутствуют
    Кол-во файлов 1 - >7 4 2 1 - 6
    Тесты - - - + - + - -
    Внутренняя реализация (1..5) 4 - 3. Лишние шаги для вызова методов 4 4 4+. :) - 3
    Автоматическая генерация - - - - - - - -

    Умный читатель может возразить, какая разница, сколько файлов используется для реализации, все равно можно склеить все в один. Да, можно, но это дополнительное лишнее действие.

    Как мы видим, нет «идеального» решения, которое бы нам подошло и которое можно спокойной использовать без напильника. Надеюсь, у других платформ дело обстоит гораздо лучше :)
    Складывается общее ощущение недоделанности проектов, ни одно решение не может предложить полный цикл использования экспортируемого API (server, smd-schema, client-generation).
    Ещё я не очень понимаю тех разработчиков, которые пытаются сделать из PHP, скажем Java или C#. В большинстве своем — PHP используется в схеме запрос/ответ, а не в схеме application server со своими состояниями. Скрипт — это же не скомпилированная закрытая библиотека.

    Ответ на вопрос «использовать что-то готовое» или «написать свое» очевиден.

    Eazy JSON-RPC 2.0


    Проект на GitHub. Все требования, обозначенные ранее, реализованы :)

    Server

    Вариантов использования два: либо отнаследоваться от класса BaseJsonRpcServer, либо создать его экземпляр и передать в конструктор экспортируемый объект:
    <?php
        include 'BaseJsonRpcServer.php';
        include 'tests/lib/DateTimeRpcService.php'; // тестовый пример
    
        $server = new DateTimeRpcService(); // или  new BaseJsonRpcServer( new DateTimeService() );
        $server->Execute();
    

    Таким образом, мы открыли наружу все public-методы класса DateTimeRpcService. SMD-схему можно получить через GET-параметр smd (например eazyjsonrpc/example-server.php?smd). При построении схемы учитываются phpDoc-блоки.
    Что же там за схема такая...
    {"transport":"POST","envelope":"JSON-RPC-2.0","SMDVersion":"2.0","contentType":"application\/json","target":"\/example-server.php","services":{"GetTime":{"parameters":[{"name":"timezone","optional":true,"type":"string","default":"UTC"},{"name":"format","optional":true,"type":"string","default":"c"}],"description":"Get Current Time","returns":{"type":"string"}},"GetTimeZones":{"parameters":[],"description":"Returns associative array containing dst, offset and the timezone name","returns":{"type":"array"}},"GetRelativeTime":{"parameters":[{"name":"text","optional":false,"type":"string","description":"a date\/time string\r"},{"name":"timezone","optional":true,"type":"string","default":"UTC"},{"name":"format","optional":true,"type":"string","default":"c"}],"description":"Get Relative time","returns":{"type":"string"}},"Implode":{"parameters":[{"name":"glue","optional":false,"type":"string"},{"name":"pieces","optional":true,"type":"array","default":["1","2","3"]}],"description":"Implode Function","returns":{"type":"string","description":"string"}}},"description":"Simple Date Time Service"}



    Client

    Вариантов использования опять же два: либо создать экземпляр класса BaseJsonRpcClient и передать ему ссылку веб-сервиса в конструкторе, либо воспользоваться генератором:
    <?php
        include 'BaseJsonRpcClient.php';
    
        $client = new BaseJsonRpcClient( 'http://eazyjsonrpc/example-server.php' );
        $result = $client->GetRelativeTime( 'yesterday' );
    


    Generator

    На основе SMD-схемы мы можем сгенерировать класс для работы с сервером (см. пример DateTimeServiceClient.php). Для этого вызовем генератор:
    php JsonRpcClientGenerator.php http://eazyjsonrpc/example-server.php?smd DateTimeServiceClient
    

    Результатом выполнения команды будет файл DateTimeServiceClient.php с необходимыми нам методами.

    Ложка дёгтя

    Негласным правилом для вызова class->method() в JSON-RPC используется class.method в качестве имени метода (через точку).
    В текущей реализации такой функциональности не предусмотрено. Предполагается, что url — это экспортируемый класс, тогда вариант с точками отпадает :). Касательно клиентской части — тут всегда можно дописать, это всего-лишь PHP.

    Так же в SMD есть возможность описать возвращаемые типы в виде объектов с их свойствами, но в виду сложности реализации пока этот момент мы опустим.

    Тем, кто хочет найти подробную документацию, могу предложить прочитать ещё раз названия методов, phpDoc комменты к ним и исходный код Server или Client.

    Лайфхаки


    Что там у нас с аутентификацией?

    Вариантов реализации несколько:
    1. Использовать HTTP Basic Auth. В клиенте достаточно добавить логин и пароль в массив $CurlOptions :)
    2. Использовать токены через HTTP-заголовки. Для получения токенов можно написать необходимый метод.
    3. Использовать токены как параметры метода.

    Как быть с загрузкой файлов?

    Некоторые люди предлагают странный вариант с кодированием файла в base64 и его отправкой в каком-то поле.
    Более-менее нормальное решение заключается в реализации метода, который расскажет, по какому адресу можно начинать загружать файл.
    Пример
    --> {"jsonrpc": "2.0", "method": "send_image", "params": ..., "id": 1}
    <-- {"jsonrpc": "2.0", "result": {"URL": "/exampleurl?id=12345", "maxsize": 10000000, "accepted-format":["jpg", "png"]}, "id": 1}
    
    --> загрузим постом файл по заданному адресу.
    
    ну и в конце можно проверить, все ли хорошо
    --> {"jsonrpc": "2.0", "method": "send_done", "params": {"checksum": "1a5e8f13"}, "id": 2}
    <-- {"jsonrpc": "2.0", "result": "ok"}
    

    Обработка ошибок

    Сам протокол уже предусматривает наличие объекта error с полями code, message и data. При использовании BaseJsonRpcServer внутри вызываемого метода можно кинуть Exception, в котороый передать code и data. Свой message можно добавить в массив $errorMessages по определенному code.

    Экспорт объектов только с определенными полями

    Тут полностью все зависит от вас, как реализуете — так и будет. Могу лишь посоветовать создать какой-нибудь класс ObjectToJsonConverter, в котором реализовать преобразования объекта в требуемый массив.
    Пример
    <?php
        class ObjectToJsonConverter {
    
            /**
             * @param City $city
             * @return array
             */
            public static function GetCity( City $city ) {
                return array(
                    'id'       => $city->cityId
                    , 'name'   => $city->title
                    , 'region' => $city->region->title 
                );
            }
        }
    
           // где-то в конце экспортируемого метода
        return array_map( 'ObjectToJsonConverter::GetCity', $cities );
    


    Преобразование в объекты на стороне клиента

    Тут опять же, все зависит только от вас. Например, можно создать требуемые классы и написать какой-нибудь простой конвертер обратно (идеи по конвертации можно взять в MyShowsClient.php)

    Заключение


    Надеюсь, после прочтения статьи, JSON-RPC не будет обделен вниманием при выборе протокола взаимодействия.
    Даже после покрытия тестами > 89% кода, я могу лишь сказать:«Вроде должно работать» :)
    Поделиться публикацией
    Похожие публикации
    Ой, у вас баннер убежал!

    Ну. И что?
    Реклама
    Комментарии 25
    • 0
      Прям ностальгия. В 2005-м писал свою реализацию и в нескольких проектах она до сих пор используется.
      • 0
        Особенности json-rpc:
        — асинхронный, причём в одном соединении в обработке может висеть несколько запросов
        — работа в режиме запрос->ответ, либо подписка->уведомления, причём подписываться может как клиент, так и сервер
        • 0
          Про подписку сервером — это больше похоже на версию 1.0, когда он был peer-to-peer ориентированный, разве нет?
          • 0
            ну, сама возможность и в 2.0 сохранилась
        • НЛО прилетело и опубликовало эту надпись здесь
          • 0
            Отлично получилось расшифровать понятие «умного читателя» :)
          • 0
            Я так и не понял чем плох REST. Не вижу разницы между:
            host.ru/api + {«jsonrpc»: «2.0», «method»: «sum», «params»: [1,2,4], «id»: «1»}
            и
            get: host.ru/sum/1.json + {«params»:[1,2,4]}

            И если вы изначально пишите RESTfull приложение вопрос об изобретении API над рабочим приложением отпадает сам собой. Тестировать вам приходится только свое приложение и писать изменения только в приложение. И курить левую документацию к левой софтине нет никакой необходимости.

            Вы же предлагаете следующий сценарий:
            1. Подключить API
            2. обложить тестами API
            Когда требуются изменения:
            3. Писать тесты для app
            4. Писать код в app (или наоборот)
            5. Писать тесты для API
            6. Писать код в API (или наоборот)

            Когда в классической схеме RESTfull
            1. пишем тест
            2. пишем код
            (или наоборот)

            • 0
              Касательно тестов — для конечного клиента писать не нужно, тут имелось в виду тесты к базовой реализации клиента. Код конечного клиента генерируется на основе smd-файла.

              В чем отличие от REST: наличие какого-то стандартизированного механизма auto-discovery.

              Предположим, что у нас есть уже веб-сервис и сгенерированный клиент на основе smd-схемы.
              В веб-сервисе появились новые методы. В случае REST — нужно вручную написать к ним реализацию в клиенте.
              В случае с auto-discovery — нужно всего-лишь сгенерировать нового клиента — профит.

              Курить левую документацию не нужно, т.к. это JSON-RPC :)
            • 0
              В своем проекте (трекер, ссылка в профиле), использую Zend_Json_Server, но сам протокол немного модифицировал.

              Дело в том, что мне понравился вариант не только указания метода, который должен вызваться на сервере, но и наоборот, указание метода, который должен вызваться на клиенте.

              Вообщем, лучше объясню наглядно. Стандарт json-rpc подразумевает такие запросы-ответы:

              тут я ваш пример скопипастил
              --> {"jsonrpc": "2.0", "method": "subtract", "params": {"subtrahend": 23, "minuend": 42}, "id": 3}
              <-- {"jsonrpc": "2.0", "result": 19, "id": 3}
              
              --> {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}
              <-- {"jsonrpc": "2.0", "result": 19, "id": 1}
              
              --> [
                      {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"},
                      {"jsonrpc": "2.0", "method": "foobar", "id": "2"}
                  ]
              <-- [
                      {"jsonrpc": "2.0", "result": 7, "id": "1"},
                      {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found."}, "id": "2"}
                  ]
              



              Я же сделал так:
              1.
              --> {"jsonrpc": "2.0", "method": "subtract", "params": {"subtrahend": 23, "minuend": 42}, "id": 3}
              <-- {"result":[{"method":"object.method","params":555},{"method":"object2.method5","params":[1,2,3,4]}],"id":"1","jsonrpc":"2.0"}
              
              2.
              --> {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}
              <-- {"jsonrpc": "2.0", "result": [{"method":"object.method","params":555}, {"aaa":18}], "id": 1}
              
              3.
              --> {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}
              <-- {"jsonrpc": "2.0", "result": 19, "id": 1}
              
              


              Поясню что тут происходит:
              вызов такой (у меня плагин для jquery):
              $.jsonrpc(method, params, callback, options);
              

              Здесь
              method — «Class.Method». На сервере происходят свои обрабоки, для того, чтобы вызывать только те методы классов, которые можно.
              params — ну тут понятно.
              callback — callback-функция, о ней ниже
              options — опции, характерные для $.ajax

              1. При ответе должны вызваться методы object.method(555) и object2.method([1,2,3,4])
              2. При ответе вызывается метод object.method(555) и callback функция callback({"aaa".18})
              3. При ответе вызывается callback(18);

              Лично мне — очень удобно!

              Думал написать статью, чтобы расписать все подробно, но времени нету. Так что сжато написал в комментарии :)
              • +2
                Мысль понятна, но вам не кажется, что сервер не должен знать. что происходит на клиенте в общем случае? :)
                • 0
                  Мы с моим коллегой по предыдущему проекту очень много спорили об этом. Он, кстати, написал свою реализацию (довольно крутую) json-rpc для друпала. Он тоже скептически отнесся к моей реализации :)

                  Но, удобство, реально очевидное.
                  Например, чат.
                  Пользователь посылает текст в чат. И получает ответ, в котором перечислены следующие методы:
                  1. сообщение добавлено
                  2. Надо вывести 2 новых собщения в чат
                  3. Онлайн изменился
                  4. что-нибудь ещё.

                  Т.е. фронтенд-программист просто пишет эти методы. А бекенд — их вызывает. Круто :)

                  В целом, в моем плагине есть возможность им пользоваться в обычном асинхронном режиме — один запрос, один ответ в колбек. Но, я, использую только эту свою реализацию, уж ооочень мне удобно с ней работать.
              • 0
                Изящно.
                * auto-discovery — захватывает все public методы класса? куда девать public методы не используемые в API?
                * mass-assignment?
              • 0
                >* auto-discovery — захватывает все public методы класса? куда девать public методы не используемые в API?
                Вообще не делать их public) ну или добавить в $hiddenMethods, благо protected.
                >* mass-assignment?
                Тут не применимо, сигнатуры методов известны:)
                • 0
                  А насколько возможно и без костылей за один такой JSON-RPC запрос в этой реализации передать несколько методов разных классов со своими параметрами и от всех их получить единый ответ?
                  • 0
                    Сделать общий proxy-класс, в котором описать все эти методы и сделать из него RPC Server.
                    Казалось бы, костыль. Но! Предположим, что мы делаем апи для мобильного приложения. Набор методов уже заранее известен, они находятся в разных классах. Создаем MobileAppServer (отнаследованный от BaseJsonRpcServer), в котором собираем все методы воедино — ничего лишнего. В такой реализации доступна пост-обработка результатов этих методов, например сокрытие лишних переменных в результирующих объектах. Как-то так.
                    • 0
                      В общем, через дополнительный слой. Меня интересовал вопрос, возможно ли это силами «чистого» самого JSON-RPC.
                      Используется в одном месте велосипедная реализация, где в одном запросе могут приходить десятки действий.
                  • 0
                    В Зенде проблема еще, что для SMD не вытаскивает комментарий/описание метода, а это зачастую важно. Также неясно поведение, когда серверные методы реализуются множеством классов — если там есть одинаковые методы, будет использован только последний. Нет возможности использовать что-то типа неймспейсов (на базе имен классов) или переопределять название метода. В результате пришлось писать свою костыльную реализацию :)
                    • 0
                      Спасибо за статью!
                      Я тоже одно время присматривался к этому способу создания API (делал на Python/Django и php) для обмена данными на смену SOAP. Но основной вопрос возник — как сделать кроссплатформенную авторизацию, чтобы сервер был допустим на Django, а клиент на php?
                      В простейшем случае конечно можно сделать авторизацию через веб-сервер и передавать логин и пароль через url, но хотелось бы что-то более серьезное.
                      Подскажите идеи.
                      • 0
                        Использовать HTTP Basic Auth. В клиенте достаточно добавить логин и пароль в массив $CurlOptions :)
                        Использовать токены через HTTP-заголовки. Для получения токенов можно написать необходимый метод.
                        Использовать токены как параметры метода.

                        помимо этих идей или их раскрыть надо?
                        • 0
                          Спасибо.
                          Да, похожие идеи были, но из этого списка только первый вариант относительно стандартным можно считать.
                          Для 2 и 3 придется существенно допиливать библиотеки и если для себя это нормально, то для организации обмена данными с чужими системами уже создает неудобство — им тоже придется допиливать свои библиотеки.
                      • 0
                        Добавьте, пожалуйста, свой класс в Composer. В этом нет ничего сложного.

                        И конечно же хотелось бы привести форматирование к общепринятым стандартам.
                        • 0
                          если делать запрос через метод call (не магический __call, а обычный), то запросу не назначается id. Из-за этого processCall($call) возвращает null.
                          • 0
                            Да, все верно. Если использовать call напрямую, то нужно дополнительно передать id, иначе не сделать реализацию с notifications (возможность передачи пустого id)

                          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                          Самое читаемое