Value Object'ы у Symfony формах

DecompositionSymfony разработчики часто задаются вопросом, как заставить Symfony формы работать с value-object'ами. Давайте, для примера, представим тип Money как объект с двумя полями $amount и $currency:

class Money
{
    private $amount;
    private $currency;

    public function __construct($amount, $currency)
    {
        $this->amount = $amount;
        $this->currency = $currency;
    }

    public function getAmount() // ...
    public function getCurrency() // ...
}

Можете ли вы создать form type для этого класса без методов setAmount() и setCurrency()? В этой заметке вы научитесь этому.

Value Object'ы

Давайте сначала определим, что такое Value Object'ы. Мартин Фаулер определяет их так:

Равенство Value Object'ов не определяется их идентификаторами. Два Value Object'ы равны тогда, когда равны все их поля.

Для нашего объекта Money это значит, что два объекты равны тогда и только тогда, когда величины всех их свойств ($amount и $currency) равны. Другие примеры - это классы Integer, Float или String в языках, хранящих скалярные значения в виде объектов.

В PHP мы можем использовать оператор == для сравнения двух Value Object'ов. Этот оператор возвращает true тогда и только тогда, когда все свойства объектов равны: 

$m1 = new Money(100, 'EUR');
$m2 = new Money(100, 'EUR');
$m3 = new Money(100, 'USD');

var_dump($m1 == $m2); // => true
var_dump($m1 == $m3); // => false

Как насчет типичных классов Order? У Order обычно есть ID или номер заказа, который гарантирует уникальность. Другими словами, два Order объекта считаются равными тогда и только тогда, когда их ID равны, независимо от значения других полей, таких как $customerName или $items. Такие объекты называют сущностями.

Изменяемость

В общем случае, Value Object'ы должны быть неизменяемыми. Это значит, что вы никогда не должны изменять свойства объектов после их создания. Если вы это сделаете, это может привести к странным и даже иногда опасным побочным эффектам! Представьте, что два объекта Order ссылаются на один и тот же объект Money, хранящий стоимость. На практике это выглядит очень просто:

// 2kg of carrots
$total = $carrots->getKiloPrice()->multiply(2);

$order1->setTotal($total);
$order2->setTotal($total);

Если вы поменяете поле $amount объекта Money в одного из двух заказов, например, для учета налога, цена заказа (Order) так же изменится! Обычно, это не то, что вам нужно. Вот что говорит Фаулер по этому поводу:

Если вы хотите изменить Value Object, вы должны заменить объект новым

Созданием новых объектов мы удостоверяемся, что Value Object всегда представляет одно значение.

(Некоторые Value Object'ы могут содержать всего лишь несколько разных значений. Класс Currency, например, может принимать ограниченный список значений, например, EUR или USD. Если ваше приложение использует много подобных Value Object'ов и потребляет много памяти, рассмотрите внедрение шаблона проектирования Приспособленец. )

Формы и Value Object'ы

Давайте перейдем к главной задачи заметки: Мы хотим создать форм тайп MoneyType для сабмита объекта Money с использованием Symfony форм. Как мы можем это сделать?

Первая интуитивная попытка приведет вас к следующей форме:

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class MoneyType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('amount', 'money', array(
                'divisor' => 100,
            ))
            ->add('currency', 'choice', array(
                'choices' => array('EUR', 'USD'),
            ))
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'Webmozart\Money',
        ));
    }
}

(Установка значения 100 опции divisor MoneyType гарантирует, что мы всегда сохраняем деньги как int. Например, если пользователь введет 23.10, мы сохраним 2310 (центов) (детали реализации). Использование int в место float предотвращает хранение навелидных денежных значений, таких как 23.1092.

Попытка создания такой формы потерпит феерическую неудачу со следующим исключением:

Warning: Missing argument 1 for Webmozart\Money::__construct()

Когда мы отправляем форму, Symfony пытается создать новый экземпляр Money. А так как конструктор нашего класса требует передачи аргументов, приложение валится. Это естественно: Symfony не может знать, какие значения передать конструктору. Но можем ли мы научить ее?

Опция empty_data

Symfony empty_data опция может быть использована как раз для этого. Передадим анонимную функцию, которая создает новый экземпляр Value Object'а. Анонимная функция получает инстанс засабмиченного FormInterface в качестве аргумента. Используйте эту форму для получения полей вашей формы:

use Symfony\Component\Form\FormInterface;

public function configureOptions(OptionsResolver $resolver)
{
    $resolver->setDefaults(array(
        'data_class' => 'Webmozart\Money',
        'empty_data' => function (FormInterface $form) {
            return new Money(
                $form->get('amount')->getData(),
                $form->get('currency')->getData()
            );
        },
    ));
}
 

Если вновь засабмитить форму, новый экземпляр Money будет успешно создан. Но что случится, если попытаться обновить существующий экземпляр Money

Neither the property "amount" nor one of the methods "addAmount()"/"removeAmount()", "setAmount()", "amount()", "__set()" or "__call()" exist and have public access in class "Webmozart\Money".

Опять получаем исключение. Symfony пытается найти сеттер, для того, чтобы обновить поле $amount, но такого сеттера не существует. Раньше мы уже узнали, что мы должны всегда менять Value Object путем создания нового экземпляра. Как можно это сделать в контексте Symfony форм?

Data Mapper'ы

Вместо использования empty_data опции - которая вызывается только если мы создаем объект, но не когда обновляем - мы создадим свой Data Mapper.

Symfony Data Mapper'ы отвечают за преобразование данных с формы в ее поля и наоборот. Для нашего MoneyType, Symfony Data Mapper по умолчанию вызовет следующие методы когда мы выведем форму с существующим экземпляром Money

$form->get('amount')->setData($money->getAmount());
$form->get('currency')->setData($money->getCurrency());

Свойства объекта Money будут скопированы у форму простым вызовом соответствующих геттеров. Когда мы отправляем форму, Data Mapper делает обратное:

$money->setAmount($form->get('amount')->getData());
$money->setCurrency($form->get('currency')->getData());

Это то самое место, где Data Mapper падает.  Сеттеров setAmount() и setCurrency() не существует.

Для того, чтобы создать Data Mapper, нужно реализовать DataMapperInterface. Давайте посмотрим на этом интерфейс:

namespace Symfony\Component\Form;

interface DataMapperInterface
{
    /**
     * Maps properties of some data to a list of forms.
     *
     * @param mixed           $data  Structured data.
     * @param FormInterface[] $forms A list of {@link FormInterface} instances.
     */
    public function mapDataToForms($data, $forms);

    /**
     * Maps the data of a list of forms into the properties of some data.
     *
     * @param FormInterface[] $forms A list of {@link FormInterface} instances.
     * @param mixed           $data  Structured data.
     */
    public function mapFormsToData($forms, &$data);
}

Эти два метода соответствуют блоку кода выше. Метод mapDataToForms() вызывает setData() на всех переданных формах читая переданную $data. И наоборот, mapFormsToData() обновляет $data, читая данные с переданных форм.

Data Mapper'ы для Value Object'ов

Реализуем DataMapperInterface у нашем MoneyType для того, чтобы изменить процесс чтения, создания и обновления Money. Мы можем переопределить стандартный Data Mapper путем вызова метода setDataMapper() форм build'ера:

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\FormBuilderInterface;

class MoneyType extends AbstractType implements DataMapperInterface
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            // ...
            ->setDataMapper($this)
        ;
    }
}

Дальше нужно реализовать методы, определенные в DataMapperInterface. Начнем с mapDataToForms():

public function mapDataToForms($data, $forms)
{
    $forms = iterator_to_array($forms);
    $forms['amount']->setData($data ? $data->getAmount() : 0);
    $forms['currency']->setData($data ? $data->getCurrency() : 'EUR');
}

Этот метод обновляет поля amount и currency значениями с экземпляра Money. Если такого экземпляра нету, заполняем стандартными значениями.

Дальше добавим mapFormsToData() для создания нового экземпляра Money при сабмите формы:

public function mapFormsToData($forms, &$data)
{
    $forms = iterator_to_array($forms);
    $data = new Money(
        $forms['amount']->getData(), 
        $forms['currency']->getData()
    );
}

Вместо того, чтобы менять существующую $data, мы просто создаем новый экземпляр Money, содержащий данные с формы.

И в конце установим null значение empty_data, чтобы не создавать объект Money дважды:

public function configureOptions(OptionsResolver $resolver)
{
    $resolver->setDefaults(array(
        // ...
        'empty_data' => null,
    ));
}

На этом все! Наш MoneyType готов к использованию.

Источник: https://webmozart.io/blog/2015/09/09/value-objects-in-symfony-forms/