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

в 19:33, , рубрики: json, php, rest api, serialization, symfony, Программирование, метки: , , , ,

Уже вот-вот выйдет версия 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:

MyBundleModelOrder
    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’у — вы действительно классные ребята, и мы не любим изобретать велосипед, но у нас не было выхода.

Автор: fightmaster

Поделиться

* - обязательные к заполнению поля