Улучшенная сериализация с Symfony

Если вы когда-либо создавали сценарий экспорта или API, то вам наверняка приходилось форматировать свой контент и иметь дело с сериализацией.

В Symfony этой задачей зачастую занимается JMS Serializer (так указано в документации Symfony).

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

Возможно, JMS Serializer подойдет для крупных бекендов и тех API, в которых вы хотите сериализовать объекты «автоматически».

Но если вы работаете над бизнес-логикой для небольших/средних проектов (чем я преимущественно и занимаюсь), вам нужны более гибкие варианты.

Но знаете что?

У Symfony есть отличный компонент сериализации!

Symfony уже решила проблему сериализации контента с Serializer Component.

Пусть он не такой готовый к использованию, как JMS Serializer, но зато он расширяемый и гибкий.

Обратите внимание: В компоненте сериализации Symfony сериализатор состоит из двух частей:

  • Нормализатор: отвечает за преобразование исходного объекта в массив (нормализировать/денормализировать).
  • Кодировщик: отвечает за преобразование нормализированных данных в отформатированную строку (закодировать/декодировать).

Чтобы сериализатор смог выполнить больше задач сериализации, вы можете оснастить его несколькими нормализаторами и кодировщиками.

Если вы не знакомы с этим компонентом, то перед тем как двигаться дальше, я рекомендую вам почитать документацию.

Ваша бизнес-логика определяется нормализатором

Компонент Serializer имеет несколько кодировщиков (JSON и XML), но вы без труда можете написать кодировщик для любого нужного вам формата: CSV, XML,…

Но суть задачи сериализации заключается в том, чтобы преобразовать ваш объект в массив (также это называется нормализацией). Именно это вы и делаете, когда при использовании JMS пишете аннотации с указанием того, какое свойство нужно включить и как.

Это и есть самое главное, и именно в этом направлении следует прилагать свое время и усилия.

Если вы хотите сериализировать какой-либо объект определенным образом, используйте нормализатор, который поддерживает именно эту модель!

Чтобы создать пользовательский нормализатор, вам необходимо реализовать NormalizerInterface, который описывает два метода:

  • supportsNormalization: Отвечает на вопрос «Можно ли нормализовать этот объект?»
  • normalize: Осуществляет преобразование объекта в массив.

Вот пример:

<?php
namespace Acme\Serializer\Normalizer;
use Acme\Model\User;
use Acme\Model\Group;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
 * User normalizer
 */
class UserNormalizer implements NormalizerInterface
{
    /**
     * {@inheritdoc}
     */
    public function normalize($object, $format = null, array $context = array())
    {
        return [
            'id'     => $object->getId(),
            'name'   => $object->getName(),
            'groups' => array_map(
                function (Group $group) {
                    return $group->getId();
                },
                $object->getGroups()
            )
        ];
    }
    /**
     * {@inheritdoc}
     */
    public function supportsNormalization($data, $format = null)
    {
        return $data instanceof User;
    }
}

Примечание: Вы можете добавить сюда логики/сложности, вы отделили модель от сериализации модели.

Результат нормализации будет выглядеть следующим образом:

<?php
[
    'id'     => 1,
    'name'   => 'Foo Bar',
    'groups' => [1, 2]
]

Работа с ассоциациями объекта

В процессе нормализации объекта вы можете обнаружить его связи с другими объектами, которые не поддерживаются нормализатором. В этом случае вам поможет SerializerAwareNormalizer: Когда ваш нормализатор расширит SerializerAwareNormalizer, то в качестве зависимости он получит родительский сериализатор. Так что вы можете поручить нормализацию другого объекта сериализатору (у которого могут иметься другие нормализаторы для этого объекта).

Давайте обновим наш предыдущий пример:

<?php
namespace Acme\Serializer\Normalizer;
// ...
use Symfony\Component\Serializer\Normalizer\SerializerAwareNormalizer;
/**
 * User normalizer
 */
class UserNormalizer extends SerializerAwareNormalizer implements NormalizerInterface
{
    /**
     * {@inheritdoc}
     */
    public function normalize($object, $format = null, array $context = array())
    {
        return [
            // ...
            'groups' => array_map(
                function ($object) use ($format, $context) {
                    return $this->serializer->normalize($object, $format, $context);
                },
                $object->getGroups()
            ),
        ];
    }
}

Теперь вам лишь нужно написать нормализатор, который поддерживает объекты Group!

<?php
// ...
/**
 * Group normalizer
 */
class GroupNormalizer extends SerializerAwareNormalizer implements NormalizerInterface
{
    /**
     * {@inheritdoc}
     */
    public function normalize($object, $format = null, array $context = array())
    {
        return [
            'id'   => $object->getId(),
            'name' => $object->getName(),
        ];
    }
    /**
     * {@inheritdoc}
     */
    public function supportsNormalization($data, $format = null)
    {
        return $data instanceof Group;
    }
}

Результат нормализации:

<?php
[
    'id'        => 1,
    'firstname' => 'Foo',
    'lastname'  => 'Bar',
    'groups'    => [
        [
            'id'   => 1,
            'name' => 'FooFighters'
        ],
        [
            'id'   => 2,
            'name' => 'BarFighters'
        ],
    ],
]

Примечание: В отношении метода supportsNormalization можно смело сказать, что вы работаете с конкретным интерфейсом, а не с объектом. Так вы получаете нормализатор, работающий со всеми моделями, которые демонстрируют определенный тип поведения.

Контекст

Компонент Serializer предлагает переменную $context, которая присутствует на протяжении всего процесса сериализации.

Вы можете использовать ее для хранения любой информации, которая понадобится вашему нормализатору, управляя его работой.

<?php
namespace Acme\Serializer\Normalizer;
// ...
use Symfony\Component\Serializer\Normalizer\SerializerAwareNormalizer;
/**
 * User normalizer
 */
class UserNormalizer extends SerializerAwareNormalizer implements NormalizerInterface
{
    /**
     * {@inheritdoc}
     */
    public function normalize($object, $format = null, array $context = array())
    {
        return [
            // ...
            'groups' => array_map(
                function ($object) use ($format, $context) {
                    if ($context['include_relations']) {
                        return $this->serializer->normalize($object, $format, $context);
                    } else {
                        return $object->getId();
                    }
                },
                $object->getGroups()
            ),
        ];
    }
}

Видите, как наш сериализатор становится все более гибким и функциональным?

Вот еще несколько пользовательских нормализаторов, которые я написал для API REST:

  • Doctrine’s Collection: похоже на то, что мы делали с группами в примере выше.
  • DateTime: единственный класс в моем приложении, отвечающий за форматирование Дат API.
  • Form Error: возвращает простой массив с именами полей в качестве ключей и сообщениями об ошибке в качестве значений.

Также вы можете расширить ObjectNormalizer, идущий вместе с компонентом Symfony Serializer. Он обрабатывает все свойства объекта, который необходимо сериализировать, и передает все нескалярные величины обратно сериализатору (то есть он может работать с вашими пользовательскими нормализаторами!). Но поскольку он не делает предположения о том, как обрабатывать циклические зависимости, над ним нужно немного попотеть. Но об этом в другой статье.

Сериализатор как сервис

Когда у вас есть все необходимые нормализаторы, следует объявить их сервисами. Предоставляя этим нормализаторам доступ к возможностям сервиса, вы добавляете зависимости, которые поставляют дополнительную информацию в процессе нормализации (база данных, веб-сервис, файловая система и т.д.).

Для этого объявите сервис для каждого кодировщика, который вы будете использовать:

services:
    # JSON Encoder
    acme.encoder.json:
        class: 'Symfony\Component\Serializer\Encoder\JsonEncoder'
    # XML Encoder
    acme.encoder.xml:
        class: 'Symfony\Component\Serializer\Encoder\XmlEncoder'
Объявите сервисами свои пользовательские нормализаторы:
services:
    # User Normalizer
    acme.normalizer.user:
        class: 'Acme\Serializer\Normalizer\UserNormalizer'
    # Group Normalizer
    acme.normalizer.group:
        class: 'Acme\Serializer\Normalizer\GroupNormalizer'

Наконец, составьте столько сериализаторов с разными нормализаторами и кодировщиками, сколько вам понадобится:

services:
    # Serializer
    acme.serializer.default:
        class: 'Symfony\Component\Serializer\Serializer'
        arguments:
            0:
                - '@acme.normalizer.user'
                - '@acme.normalizer.group'
                - '@serializer.normalizer.object'
            1:
                - '@acme.encoder.json'
                - '@acme.encoder.xml'

Примечание: Если вы включили сервисы сериализатора, как сделал в данном случае я, вы можете использовать сервис serializer.normalizer.object в качестве резервного нормализатора для всех объектов, для работы с которыми вы не использовали пользовательский нормализатор.

Преимущества:

Структурируя таким образом свой сериализатор, вы получаете модульную и гибкую систему:

Гибкость: при создании своих собственных нормализаторов вам не нужно соответствовать структуре, навязываемой библиотекой. Просто напишите код, необходимый для вашего проекта.

Тестируемость: нормализаторы являются тестируемымы классами PHP; вы можете осуществить модульное тестирование результата нормализации.

Разделение задач: может получиться так, что разным частям вашего приложения нужно будет сериализовать один и тот же объект разными способами. Знать, как это сделать – это их задача, а не задача самого объекта: а это значит, что вы можете забыть о сложных наборах групп в аннотациях объекта.

Эффективность работы: Исходя из собственного опыта, могу сказать, что Symfony Serializer с пользовательскими нормализаторами работает в 3 раза быстрее, чем JMS Serializer.