Сериализация объектов в json формат для реализации REST API

    Уже вот-вот выйдет версия Symfony 2.1, а в сообществе до сих пор нельзя реализовать «без костылей» полноценный REST, и, по-моему, здесь что-то не так. Недавно вышла статья с громким названием REST API’s with Symfony2: The Right Way, но, по существу, она лишь подтверждает мои слова. Вся проблема упирается в сериализацию и десериализацию объектов. Казалось бы, простейшая задача и решений должно быть много, но, к сожалению, нет. Давайте обо всем по порядку.

    JMSSerializerBundle, пожалуй, является законодателем моды в сериализации на данный момент (его же рекламирует и FOSRestBundle). Он многофункциональный, умеет хранить правила сериализации в различных форматах, сериализовать и десериализовать данные в различные форматы, использует кеш. Но у него есть несколько маленьких нерешенных проблем, они многократно затрагивались, и решение их не предвидется. Как мне кажется, в погоне за многофункциональностью архитектура приложения зашла в тупик.

    Первая проблема — это бандл, который тянет кучу зависимостей, соответственно, его нельзя использовать вне Symfony 2. Это очень странно, ведь вопрос изначально касался сериализации, и непонятно, почему не сделать это библиотекой.

    Вторая проблема — это невозможность сериализации null значения. Применительно к нашему REST API — это недопустимо. Есть очень большая issue, но я более чем уверен, что решения там не будет. На самом деле, ситуация очень странная. Если взять json_decode и json_encode, они корректно обрабатывают эту задачу. Для формата xml я решал эту проблему год назад при реализации doctrine-oxm.

    Третья проблема — это невозможность производить десериализацию в существующий объект с данными. Это делает невозможным использовать POST/PATCH запросы, которые производят частичное обновление данных. Главный вопрос — как все это время FOSRestBundle “морочит голову” людям о легкой реализации REST в Symfony 2.

    Мы долго не могли поверить в то, что никто и никогда не делал полноценную реализацию REST в Symfony2 и не сталкивался с этими проблемами. Просмотрели кучу решений, и, как пел бессмертный Цой, “все не то, и все не так”. В один прекрасный день нас порадовал Benjamin Eberlei, обративший внимание на третью проблему, создав свой бандл SimpleThingsFormSerializerBundle. Но, к сожалению, как и все Symfony2 сообщество, он “помешался” на версии 2.1, которая даже бета версии не имела. Но это другая история, да и, к нашему счастью, нашелся человек, сделавший совместимость с 2.0.

    Итак, кажется, счастье уже близко, и мы сможем обновить свои модели. Как это ни тяжело, но мы готовы пойти против своей воли и создать к своим DTO (Data Transfer Object) объектам еще и FormType, заменить полностью JMSSerializerBundle на SimpleThingsFormSerializerBundle. Сделали это, но счастья не обрели. Как оказалось, новый бандл конвертирует всю информацию в строки, т.е. наш клиент никогда не увидит ни числовые, ни булевые значения. Ответа на вопрос, зачем так сделано, мы не получили, лишь было предложение для сериализации использовать JMSSerializer, для десериализации — FormSerializer. Но мне кажется, здесь что-то не так. Вдобавок, symfony form в версии 2.0 могут производить bind только с GET или POST запросов, игнорируя остальные. Да и у меня есть много “фи”, по поводу использования форм для REST, оставим это за рамками статьи. Я уехал в отпуск на две недели с надеждой, что что-то изменится. Но…

    Задача кажется простой, в своем REST API мы гарантированно предоставляем информацию в json формате. Другие форматы нас не интересуют (мне кажется, как и большинство современных проектов). Мы предполагаем, что у нас есть DTO объекты, которые всегда имеют set/get методы для своих атрибутов. Эти DTO мы хотим конвертировать в json и обратно (с решением проблем, описанных выше). Нам очень нравится возможность хранения правил сериализации в yml формате и из всего многообразия настроек JMSSerializerBundle нам необходимо лишь задание маппинга поля (“serialized_name” и “type”) и флаг “expose”.

    Перед тем как приступить к реализации своей библиотеки, я еще раз произвел поиск текущих решений. Было найдено два интересных проекта:
    FbsSerializer — как мне кажется, «предшественник» JMSSerializerBundle. Возможности и заложенные идеи по реализации очень схожи, присутствуют те же проблемы, а также неподходящим образом сериализует коллекции;
    ObjectSerializer — очень простая реализация, практически один в один совпала с тем, как я себе видел свою библиотеку. Основной недостаток — маппинг классов передается через конструктор, что недопустимо для нас.

    Последнее время я стал поклонником простоты реализации. Чем проще код, чем меньше он берет на себя ответственности — тем он лучше. Я не хочу писать код, в котором закладывается функционал, который никому никогда не будет нужен, при этом ломается архитектура приложения.

    <?php
    class Serializer
    {
        public function serialize($object)
        {
            $array = $this->arrayAdapter->toArray($object);
            
            return $this->adapter->serialize($array);
        }
        
        public function unserialize($data, $object)
        {
            $array = $this->serializerAdapter->unserialize($data);
    
            return $this->arrayAdapter->toObject($array, $object);
        }
    }
    ?>
    


    Основная идея в том, чтобы преобразовать сложный объект с различными правилами сериализации в массив. Этим занимается ArrayAdapter, в нем инкапсулирована вся логика преобразования данных. Сформированный массив легко конвертируется в json формат, думаю, сложностей нет и для xml. Во время десериализации все наоборот: мы конвертируем входные данные в массив, затем ArrayAdapter конвертирует данные в объект согласно своим правилам.

    serializerAdapter представляет в нашем случае простую обертку над функциями json_encode, json_decode. Вся интересующая нас логика находится в ArrayAdapter. Здесь мы обнаружим идеологическую разницу в обработке конфигурационных данных и сериализации этой библиотеки и FbsSerializer, JMSSerializerBundle.

    В моем представлении, у нас есть некоторый набор правил для сериализации объектов, расположенный в некоторых файлах в некотором пространстве. Сперва нам необходимо достать нашу конфигурацию из этого пространства. Но форматов, в которых хранится конфигурация, может быть много, и логично трансформировать это в объектное представление для удобства использования. Johannes Schmitt делает это с помощью библиотеки metadata. Она очень хороша, но тесно связана с Reflection объекта. Поэтому позднее, во время сериализации, вся работа происходит от сериализуемого объекта и его Reflection. В двух словах, мы делаем итерацию по всему, что возможно в объекте, и смотрим, какие настройки у нас есть для этого атрибута или метода. Я считаю, что этот подход в переплетении со сложной обработкой значений (можно было сделать проще) является одной из причин нерешенных проблем. И в своей реализации я делаю все наоборот. Мы просто конвертируем конфигурацию в объектное представление, которое ничего не знает о сериализуемом объекте. Далее мы итерируем по нашей метадате и смотрим, можем ли сериализовать текущий атрибут, какого типа он и как его обрабатывать, под каким именем его сериализовать. В процессе обработки данных мы вызываем необходимый нам getter/setter. Таким образом, мы исходим не от объекта, а от заданной конфигурации. Как говорится, что задали — то и получили. Ниже приведен обрывок кода:

    <?php
    class ArrayAdapter
    {
        public function toArray($object)
        {
            $result = array();
            $className = $this->getFullClassName($object);
            $metadata = $this->metadataFactory->getMetadataForClass($className);
            foreach ($metadata->getProperties() as $property) {
                //handle value
                $result[$property->getSerializedName()] = $value;
            }
            
            return $result;
        }
    
        public function toObject(array $data, $object)
        {
            $className = $this->getFullClassName($object);
            $metadata = $this->metadataFactory->getMetadataForClass($className);
            foreach ($metadata->getProperties() as $property) {
                //handle value and set it to object
            }
    
            return $object;
        }
    }
    ?>
    
    

    Для обработки значений для сериализации/десериализации на данный момент используется приватный метод handleValue (около 60 строк).

    Изначально для конвертации настроек из yml файла в объект я хотел написать что-то свое, так как задача очень проста. Вам нужно абстрагироваться от двух вещей: местоположение конфигурационных файлов (они могут храниться где угодно: файловая система, память, база данных) и от их формата (yml, json, ini, xml). При этом, как заметил тимлид, “мы же не будем каждый раз парсить *.yml файл”, поэтому нужен кеш. Его интерфейс всем известен: put, get, remove. Но я вовремя остановился и вспомнил про библиотеку metadata, и вновь уперся в ненужный мне Reflection. Поэтому, с небольшими исправлениями, эту часть кода взял от туда, отдав честь Johannes.

    В итоге, я был потерян для команды на 33 часа, но зато была написана библиотека simple-serializer и бандл OpensoftSimpleSerializerBundle.

    Зависимости библиотеки:
    Компонент symfony/yaml

    Требования:
    — для сериализуемого объекта должны быть описаны правила сериализации;
    — сериализуемый объект должен иметь сеттеры и геттеры для атрибутов.

    Достоинства по сравнению с JMSSerializerBundle:
    — не имеет ряда наболевших проблем, таких как: обработка атрибутов с null значением, десериализация данных в существующий объект;
    — задание форматирования даты в конфигурации;
    — не имеет привязки к фреймворку symfony 2.

    Естественно, это не идеал. JMSSerializerBundle более функционален и имеет меньше ограничений. Но, в нашем случае, существует явный уклон на использование при реализации REST API. Когда мы имеем дело с REST в крупных приложениях, мы не сможем обойтись без реализации паттерна DTO, так как никогда наши модели не будут явным образом отражаться в API. А, соответственно, Assembler при конвертации DTO в DomainObject будет, скорее всего, использовать setter/getter методы. И второе требование отпадает само собой. Первое же, я думаю, также все выполняют. Ведь, предоставляя API клиентам, мы говорим им, что и в каком формате принимаем или отдаем. И кажется нелогичным, если у нас будут отсутствовать эти правила. Что же касается отдачи ответа (response) в разных форматах, то в нашем проекте мы имеем дело с json. Он очень простой и удобный. XML, на мой взгляд, уже немного устарел для использования в REST. HTML формат, который предлагает FOSRestBundle, слишком надуманный, я не могу представить его применение. За достоверность REST API отвечают behat тесты (привет и спасибо Константину aka everzet), надеюсь, davert не обидится, так как codeception реально крут.

    Стоит отдельно поговорить о возможности конфигурации сериализации. Опции сведены к минимуму, хоть и дополнить их совсем не трудно. Файл с конфигами в целом аналогичен JMSSerializerBundle:

    MyBundle\Model\Order
        properties:
            id:
                expose: true
                serialized_name: id
                type: integer
    


    Опция “expose” по умолчанию равна “false”, поэтому для всех атрибутов, которые хотим сериализовать, ее необходимо указать. Это мое личное предпочтение, что не разрешено — то запрещено. Возможно, сказалось увлечение ACL в молодости. В принципе, можно внедрить полноценный exlude/expose, если будут желающие. “serialized_name” — необязательный параметр, если он не указан, атрибут будет сериализован согласно своему имени. Последняя опция “type” — также необязательна. Если она не указана — то никакие действия со значением атрибута не будут произведены. В противном случае, будет приведение к указанному типу. Возможные типы: integer, boolean, double, string, array, T, array<T>, DateTime, DateTime<format>.
    Основные отличия от JMSSerializerBundle: отсутствие ArrayCollection и присутствие DateTime<format>. Первое отсутствует принципиально, так как ее наличие добавляет зависимость библиотеки от Doctrine/Orm. Если вы используете фреймворк symfony 2, то в 90% она будет удовлетворена. А что делать другим 10 процентам, или тем, кто не использует symfony? Тем более, если мы говорим о применении к DTO, то там практически наверняка не будет никаких коллекций, в них нет смысла. Но эта проблема решаема в будущем. Вторая особенность — это возможность указывать, как форматировать дату. Напоминаю, что запись DateTime предполагает, что у вас есть объект DateTime. Если я не ошибаюсь, в JMSSerializerBundle вы можете создать кастомный обработчик (handler) даты и указать в нем формат. Здесь все проще, непосредственно в конфигурации можно указать константу класса DateTime, например “ISO8601”, или строку с необходимым форматированием времени.

    Можно составить TODO list, хотя, честно скажу, не вижу в этом острой необходимости:
    1) добавить возможность хранить конфигурацию сериализации в других форматах (annotation, xml);
    2) добавить возможность сериализовать данные в форматы, отличные от json;
    3) добавить разнообразные опции в конфигурацию;
    4) произвести рефакторинг кода в handleValue, внедрив паттерн Visitor, или Chain of Responsobility, или еще что-то, тем самым добавив возможность создавать кастомный обработчик значений;
    5) еще что-то.

    Благодарности: особенно хочется поблагодарить своего тимлида за разрешение “уйти в себя” и за терпение в трудное, предрелизное для проекта время, а также команду, которая уничтожала баги в то время, как я кайфовал :). Спасибо Johannes’у, Benjamin’у — вы действительно классные ребята, и мы не любим изобретать велосипед, но у нас не было выхода.
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 12

      0
      Классная статья, и даже меня упомянули :) Спасиб!

      Если честно, с REST в Symfony2 не работал, но реализация REST серверов на той же ноде в свое время просто поразила своей простотой. Может я не прав, но учитывая, что json это нативный формат для js, то таких проблем с сериализацией там впринципе нет. Не смотрели ли вы в сторону ноды?
        0
        Если откровенно, много слышал и немного читал о ноде, но не имел опыта работы с ней. Учитывая сложность проекта и отсутствие опыта, сроки и так далее, ее внедрение было бы авантюрой. Но за хороший совет — спасибо.
          0
          На больших я и сам бы не рискнул, но как-то товарищ показал полноценный REST-сервер (обработка, валидация и сохранение в БД нескольких ресурсов) в один файл и я офигел. Не знаю как там со сложными штуками типа Hypermedia, или HATEOS, но для простых json выглядит круто.
        0
        > он “помешался” на версии 2.1, которая даже бета версии не имела
        уже RC2 вышла даже
          0
          на данный момент да, но не несколько месяцев назад.
          0
          А кто-нибудь пользуется ORM Designer для описания моеделей?
            0
            «Проблемы» реально спорные.
            1) Про зависимости в целом верно. Неплохо было бы разделить бандл и саму реализацию. Это проблема многих йоханесовских бандлов.
            2) Подозреваю, что проблема с null и частичным обновлением как раз тесно связаны: в виде юзкейза обнуления nullable поля.

            Однако с точки зрения библиотеки сериализации это 2 разных вопроса и, имхо, первое должно быть реализовано в каком-то виде (мало ли какие юзкейзы могут быть). Однако, это поведение должно быть опциональным и настраиваемым, что указанный PR не выполнял (что мешало написать нормальный — хз).

            Что касается «частичного» обновления — это в общем стандартный вопрос наличия метода типа «merge». И вопрос нужен ли он в самой библиотеке сериализации или нет тоже хороший. Но, что-то PR я не вижу на эту функциональность…
              0
              Кирилл, в твоих словах есть доля истины.
              >Что касается «частичного» обновления — это в общем стандартный вопрос наличия метода типа «merge». И вопрос нужен ли >он в самой библиотеке сериализации или нет тоже хороший. Но, что-то PR я не вижу на эту функциональность…
              Было большое обсуждение этого вопроса, и все свелось к тому, что Бенжамин создал новый бандл.

              Мы пробовали использовать merge, но это крайне неудобно было. Когда имеется десяток моделей, еще больше DTO, это слишком проблематично. Не покидает мысль, что ты пишешь рутинный и малополезный код, а в довесок, ты должен написать к нему еще и тесты. Я хочу писать код с удовольствием, а не вымучивать строчку за строчкой.

              Так что действительно, проблемы маленькие, а последствия ощущаются значительные.
              0
              И все-таки что мешает использовать нативный парсер json_encode/json_decode и поверх него наворачивать валидации, маппинги и прочее?
                0
                Возможно, все эти библиотеки и делают это, или я вас неправильно понимаю.

                У вас есть некоторый экземпляр класса Object, что вы с ним будете делать? А если у него есть ссылки на другие объекты. Или есть некоторая строка {«title»: «Greate article»}.
                0
                REST на Symfony2 ровно так же ка и реализация REST в Zend Framework и 1 и 2 версии — это очень большие тормоза, на которых теряется вся прелесть REST и поедает ресурсы вашего хостинга.

                Безусловно, ваш код может быть полезен, когда по ТЗ в качестве фреймворка указан-а Symfony2

                Но если это не обязательное условие посмотрите сюда, простой REST будет работать быстрее, приблизительно в 5-10 раз :):
                luracast.com/products/restler/
                www.slimframework.com/

                Ну а если хочется достичь Дао, то как советовали раньше смотрите в сторону NodeJS:
                mcavage.github.com/node-restify/
                github.com/danwrong/restler
                  0
                  Можно и phalcon использовать, он будет еще быстрее. А еще лучше java. В php гнаться за скоростью отработки скрипта не всегда правильно, больше вероятность, что упретесь в работу с базой.

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

                Only users with full accounts can post comments. Log in, please.