Будучи активным в IRC, почти каждый день автор этой статьи видит поступающие вопросы про использование форм Symfony и сущностей в них. Это не только создает проблемы, но так же рискованно. Вы же не хотите сохранить сущность в невалидном состоянии!
Как и в прошлый раз, мне просто захотелось перевести еще пару статей в вольном стиле. И так как тут вторая статья является продолжением первой, я решил уместить их в один пост. Вот ссылка на оригинал первой: https://stovepipe.systems/post/avoiding-entities-in-forms
Избегание сущностей в формах Symfony
“Но испрользовать сущности в моих формах просто!”. Да, это просто. Вам не нужно писать никакого дополнительно кода, чтобы соединить ваши правила валидации и маппинга данных, не говоря уже о том, что вам нужно только выполнить flush() и готово. Применять этот метод особенно легко при реализации CRUD и можно выполнять разработку приложений быстрее, удовлетворяя RAD подход.
В чем же тогда проблема?
С чего же мне начать?
- Сущности всегда должны быть в валидном состоянии
- Сущностям нужна дополнительная угадывающая логика при заполнении значениями по умолчанию
- Сущности ограничивают структуру данных и переиспользование ваших типов форм
Что имеется в виду под валидным состоянием?
Это означает, что в тот момент, когда форма создана – она должна соблюдать вашу бизнес логику. Если для аутентификации требуется логин пользователя, он всегда должен присутствовать и это значит, что вам надо добавить параметр в конструктор. Это обычно не единственное правило, другое правило может быть, что логин пользователя должен быть как минимум 5 символов в длину.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
<?php // формы Symfony не поддерживают это по умолчанию, надо конфигурировать $user = new Authentication($username); // часть кода из класса public function setUsername($username) { if (strlen($username) < 5) { throw new \InvalidArgumentException('The username should be at least 5 characters.') } $this->username = $username; } |
Делая так, вы предотвращаете невалидное состояние формы авторизации, но компонент форм в Symfony “не любит” это. Внутренне компонент форм пытается вызвать setUsername() на указанном маппинге в форме.
1 2 3 4 5 |
<?php // привязывает username свойство и вызовет getUsername и setUsername $builder->add('username', TextType::class); |
Теперь юзер хочет изменить свое имя и пишет Foo и это вызовет исключение в момент, когда будет попытка вызвать setUsername(‘Foo’). Это ясно означает, что вы не можете быть уверенны в валидном состоянии сущности, т.к. вам придется удалить код с Exception чтобы использовать объект Authentication как объект данных.
Если же вы уберете исключение и позволите сущности быть в невалидном состоянии, любой вызов flush() сохранит эту сущность в БД в таком виде.
Что имеется в виду под дополнительной угадывающей логикой?
Я главным образом говорю про EntityType в формах Symfony. Требуется жестко связывать EntityRepository с формой. Я не буду углубляться подробнее, т.к. я ни разу еще не нашел причины использовать этот механизм до сих пор.
Что имеется в виду под ограничением структуры данных и переиспользования?
Когда ваша сущность является так же объектом данных, вы ограничены структурой вашей сущности при маппинге типа формы. Это значит, что если вы хотите выделить часть, которая совместно используется между формами, вы не можете просто переиспользовать подтипы, т.к. это будет требовать изменения структуры данных. Другим решением будет добавление временных свойств в сущность, которые будут использоваться только в форме. И это нарушит принцип Single Responsibility (прим. одна ответственность, S в SOLID).
Вы можете продвинутся достаточно глубоко с группами валидации, но это потребует конфигурацию груп валидации для каждой формы, которая использует эту сущность. Когда вы создаете пользователя, вы хотите чтобы логин пользователя был уникальным, но не когда вы редактируете пользователя, т.к. логин остается тот же.
Как можно добавить newUsername в вашу сущность? Если нету такого свойства, то вот так
1 2 3 4 5 |
<?php // не хватает свойств и переиспользования $builder->add('newUsername', RepeatedType::class, [/* options */]); |
Какое же будет решение?
Data Transfer Objects – DTO. Просто используйте Plain Old PHP Object – POPO для хранения ваших данных. Это потребует от вас написать дополнительный класс для ваших форм, но будет стоить того в долгой перспективе. Если вы возьмете предыдущие примеры, вы можете легко поправить их вот так:
Примите к сведению, это не рабочие примеры.
DTO
1 2 3 4 5 6 7 8 9 10 11 12 |
<?php // может быть приватным с get set class ChangeUsernameData { /** * @Some\Custom\Assert\Unique(entity="App\Entity\Authentication", field="username") * @Assert\Length(5, 16) **/ public $newUsername; } |
Создание типа
1 2 3 4 5 |
<?php // нет проблем теперь, т.к. можно привязать свойство $builder->add('newUsername', RepeatedType::class, [/* options */]); |
Создание и обработка формы
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
<?php public function changeUsernameAction(Request $request, Authentication $authentication) { $changeUsername = new ChangeUsernameData(); $form = $this->formFactory->create(ChangeUsernameType::class, $changeUsername); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $authentication->setUsername($changeUsername->newUsername); $this->em->flush($authentication); // redirect } // render form } |
Поздравляю, вы отделили ваши формы от сущностей!
Часть 2 от того же автора. Вот ссылка на оригинал второй: https://stovepipe.systems/post/rethinking-form-development
Переосмысление разработки форм Symfony
В одном из своих прошлых постов я показал вам как отделить ваши формы от ваших сущностей. После этого я получил отзывы и большинство из них были о недостатке примеров и процесса, когда заполнять ваши данные и как получить их обратно в сущность. Однако, часто я замечаю, что программисты проектируют формы на основе сущностей. Это может вести к усложненным формам потому, что вы связаны ограниченным набором свойств. Программисты часто сталкиваются с непривязанными полями и событиями форм только чтобы обойти эти ограничения.
С формами Symfony я рекомендую применять принцип Композиции, а не Наследования. Маленькие типы форм легче переиспользовать и они сделают построение форм менее сложным. Более того, это позволяет объектам данных иметь специфичные случаи валидации для специальных случаев использования, вместо сложных групп валидации.
История пользователя
История: Как автор постов в блоге, я хочу дать возможность людям писать комментарии к моим постам и таким образом я смогу получать отзывы и отвечать на вопросы.
Звучит не очень трудно, да?
Начинаем с начала
Как программист вы знаете, что ваша форма должна делать. У вас есть данные с запроса и вы хотите создать или обновить запись в базе данных. Эти записи обычно привязаны к сущности. Допустим у вас есть очень простая BlogComment сущность и у нее есть связь с BlogPost, заголовок, содержимое и емейл автора.
Имеет смысл написать форму для этой сущности и позволить форме сделать свою магию и сохранить сущность. Однако, как написано в моем другом посте, вы вероятно должны разделить все. И так, вы проверяете свою сущность и начинаете извлекать нужные поля и приходите к заключению, что это требует некоторой работы. Зачем извлекать объект данных идентичный сущности?
Не думайте как разработчик
Помните историю пользователя? Я не упомянул никакиз технических деталей, только цель. Что люди должны ввести когда они публикуют комментарий? Я могу сказать, что в данном случае необходимо и достаточно емейл адреса для верификации и содержимого; MVP (Minimal Viable Product). Что должна содержать страница? Наверное сам пост и существующие ответы. Ниже вы можете поместить пару инпутов для комментария.
Наконец-то форма!
Теперь вы можете применить все в контексте, т.к. у вас уже есть все детали.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// CommentFormType.php public function buildForm(FormBuilderInterface $builder, array $options) { $builder->add('email', EmailType::class); $builder->add('comment', TextareaType::class); } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefault('data_class', CommentData::class); } |
1 2 3 4 5 6 7 8 |
// CommentData.php /** @Assert\Email() */ private $email; /** @Assert\Length(min=25)) */ private $comment; |
Форма не слишком сложная и ее легко обрабатывать. Более того, она не привязана к сущности и определяет требования, а не дизайн базы данных. Все, что вам нужно сделать, написать в контроллере:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// SimpleReplyController.php public function viewPostAction(Request $request, Post $post) { $data = new CommentData(); $form = $this->formFactory->create(CommentFormType::class, $data); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $comment = new Comment($post, $data->getEmail(), $data->getComment()); $this->entityManager->persist($comment); $this->entityManager->flush(); return new RedirectResponse($request->getUri()); } return $this->templating->render('/simple_reply/view_post.html.twig', [ 'form' => $form->createView(), 'post' => $post, ]); } |
Бизнес логика поменялась…
Вы завершили историю пользователя и отправили изменения на сервер. Тем не менее, бизнес логика меняется со временем и кто-то создал новую историю пользователя.
История: Как автор постов, я хочу иметь чекбокс на другой странице для подтверждения комментария, таким образом пользователи будут явно соглашаться с нашими правилами.
Поле для подтверждения нуждается в чем-то чтобы хранить данные и обозначить да или нет. Это удача, что вы уже видели как использовать не сущности, а DTO для вашей формы, и добавить что-то новое не составит для вас труда!
Добавляем чекбокс для подтверждения
Как было сказано ранее, я поддерживаю композицию вместо наследования. Чтобы выполнить задачу, вы можете создать новый тип формы и объект данных в качестве обертки над CommentData и CommentType.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// ConfirmReplyFormType.php public function buildForm(FormBuilderInterface $builder, array $options) { $builder->add('confirm', CheckboxType::class, ['required' => true]); $builder->add('comment', CommentFormType::class); } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefault('data_class', ConfirmReplyData::class); } |
1 2 3 4 5 6 7 8 |
// ConfirmReplyData.php /** @Assert\IsTrue() */ private $confirm; /** @Assert\Valid() */ private $comment; |
Чтобы оставить оба контроллера функциональными можно добавить еще один. Однако этот контроллер содержит незначительные изменения и по существу работает так же; он передает данные из DTO в сущность и сохраняет ее.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
// ConfirmReplyController.php public function viewPostAction(Request $request, Post $post) { $data = new ConfirmReplyData(); $form = $this->formFactory->create(ConfirmReplyFormType::class, $data); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $commentData = $data->getComment(); $comment = new Comment($post, $commentData->getEmail(), $commentData->getComment()); $this->entityManager->persist($comment); $this->entityManager->flush(); return new RedirectResponse($request->getUri()); } return $this->templating->render('/confirm_reply/view_post.html.twig', [ 'form' => $form->createView(), 'post' => $post, ]); } |
Подводим итоги:
- Это хорошая идея использовать композицию вместо наследования в формах.
- Оказывается очень просто использовать DTO.
- Разделение ваших форм проще, если вы думаете сначала о том, что ваша форма должна делать и уже потом о моделировании.
Если вы хотите увидеть полные классы, вы можете проверить репозиторий статей автора, где хранятся его статьи опубликованные в блоге: https://github.com/iltar/blog-articles/tree/master/src/RethinkingFormDevelopment
ps. Да прибудет с вами сила DDD и Hexagonal architecture, т.к. для познавших эту силу все написанное выше – очевидно. :)