
Symfony и Command Bus
Уже больше года использую паттерн Command Bus в своих Symfony-проектах и наконец решил поделиться опытом. В концев концов обидно, что в Laravel это есть «из коробки», а в Symfony, из которого Laravel во многом вырос — нет, хотя самому понятию Command/Query Separation уже не менее 10 лет. И если с буквой «Q» из аббревиатуры «CQRS» еще понятно что делать (лично меня вполне устраивают custom repositories), то куда приткнуть букву «C» — неясно.
На самом деле, даже в банальных CRUD-приложениях Command Bus дает очевидные преимущества:

Предположим, у нас приложение, в котором можно регистрировать некие проекты. Проект как сущность включает в себя:
Код, реализованный по родной документации Symfony, мог бы выглядеть как-то так:
Я привел этот контроллер больше для сравнения — именно так он выглядит с точки зрения Symfony-документации. На самом же деле «веб 2.0» давно победил, сосед по команде ваяет «фронтэнд» проекта на Angular, а формы конечно же прилетают в AJAX-запросах.
Существует множество реализаций Command Bus для PHP — от «thephpleague» до откровенных NIH-велосипедов. Лично мне понравилась версия от Matthias Noback (у него в блоге есть серия статей, посвященных Command Bus) — SimpleBus. Библиотека не зависит от конкретного фреймворка и ее можно использовать в любом PHP-проекте. Для облегчения интеграции библиотеки с Symfony есть готовый bundle от того же автора, его и поставим:
Любая команда — не более, чем структура входных данных, обработка которых находится в отдельном обработчике. Бандл добавляет новый сервис
Попробуем «отрефакторить» наш «экшен» создания нового проекта. HTML-форма — не единственный возможный источник входных данных (проект можно создать через API, или соответствующим сообщением в SOA-системе, или… да мало ли как еще), поэтому я намеренно переношу валидацию данных поближе к самой бизнес-логике (частью которой валидация и является), т.е. из формы в обработчик команды. В общем случае при любом количестве точек входа мы идем в один и тот же обработчик, который и выполняет валидацию. В случае ошибок валидации (да и любых других) мы эскалируем ошибки обратно в виде исключений. В итоге любой «экшен» — это короткий try-catch, в котором мы преобразуем данные из запроса в команду, вызываем обработчик, а затем возвращаем «200 OK»; секция
Тут мы просто выбрасываем валидацию, в остальном форма никак не изменилась.
А вот тут валидация наоборот появляется.
Чтобы
Функция
Если посчитать, то мы увидим, что прежняя версия «экшена» содержала 12 строк кода, в то время как новая содержит 11 строк. Но во-первых, мы только начали (дальше будет короче и изящнее), а во-вторых, у нас сферический пример в вакууме. В реальной жизни усложнение бизнес-логики будет «раздувать» контроллер в первом случае, и совершенно никак его не затронет во втором.
Есть еще один интересный ньюанс. Допустим, пользователь ввел название уже существующего проекта. В entity-классе у нас есть соответствующая аннотация, но форма-то при этом остается корректной. Из-за этого в первом случае нередко приходится городить дополнительную обработку ошибок.
В нашей же command-версии при вызове
Кстати, на хабре (и не только) уже было сломано немало копий на тему «исключения против ошибок». Например, в одной из последних подобных статей AlexLeonov предложил нечто близкое к моему подходу (ошибки валидации через исключения), и, судя по комментариям к его статье, мне тоже достанется. Я призываю на этот раз не холиварить, а принять как данность мою слабость к простоте кода, и простить мне ее, если сможете (тут был смайлик, но он испугался модераторов и исчез).
Если присмотреться к функции
К счастью, SimpleBus поддерживает «middlewares» — промежуточные функции, которые будут автоматически вызываться при обработке любой команды. Middleware-функций может быть сколько угодно, вы можете заставить одни из них вызываться до команд, а другие — после, вы даже можете назначать им приоритеты, если последовательность выполнения каких-то middleware-функций важна. Очевидно, имеет смысл обернуть валидацию команд в middleware-функцию и забыть о ней вовсе.
Регистрируем наш middleware:
Упрощаем обработчик команды (не забываем убрать ненужную зависимость от валидатора):
Многие из вас уже наверное задались вопросом, как же быть, если результатом валидации является не одна ошибка, а целый набор. Действительно, не самая удачная идея возвращать их пользователю по одной — хотелось бы отметить все некорректные поля формы за один раз.
Это, наверное, единственное «узкое» место подхода. Я не придумал ничего лучше, кроме как кидать специальное исключение с массивом ошибок. Мой внутренний перфекционист очень страдает от этого, но возможно он не прав, буду рад успокоительным комментариям. Также приветствуется, если кто-то предложит более удачное решение.
А пока — наше собственное исключение валидации:
Слегка поправим наш валидирующий middleware:
Ну и конечно же сам контроллер (появилась дополнительная секция
Теперь в случае ошибки валидации «экшен» вернет JSON-структуру, где ключами будут имена HTML-элементов, а значениями — сообщения об ошибке для соответствующих полей. Например, если не указать название проекта и одновременно ввести слишком длинное описание:
На самом деле ключами конечно будут имена свойств в классе команды, но мы же не случайно назвали их идентично полям формы. Впрочем, способ связи свойств класса с полями формы может быть абсолютно произвольной — это вам решать, как вы будете привязывать прилетевшие сообщения к элементам «фронтэнда». Для «затравки» вот вам пример моего error-обработчика подобного AJAX-запроса:
Каждый наш «экшен» начинается с запроса, из которого мы каждый раз копируем данные в команду, чтобы затем передать ее на обработку. После первых пяти «экшенов» это копирование начинает раздражать и требовать автоматизации. Напишем trait, который будет добавлять в наши команды конструктор-инициализатор:
Готово. «Лишние» значения будут игнорироваться, недостающие — оставлять соответствующие свойства объекта в NULL-состоянии.
Теперь «экшен» может выглядеть так:
А что, если нам понадобится добавить какие-то дополнительные данные помимо значений из объекта запроса? Например, в
Давайте добавим второй массив с альтернативными значениями:
Теперь наш гипотетический
Почти все хорошо, но есть ньюанс — если пользователь оставит описание проекта пустым, к нам прилетит пустая строка, которая в итоге и будет сохранена в базу, хотя в подобных случаях хотелось бы писать в базу
Здесь мы просто добавили анонимную функцию (чтобы не плодить сущностей), которая рекурсивно (массив может быть вложенным) проходит по исходным значениям и меняет пустые строки на
Помимо команд SimpleBus умеет также и события. Строго говоря, разница между ними невелика. Реализованы они идентично — вы точно также создаете класс-событие, но обработчиков (точнее — подписчиков) у него может быть много (или вообще ни одного). Регистрируются подписчики аналогично обработчикам (лишь с немного другим тэгом), а управляет ими другой специальный сервис, реализованный в SimpleBus —
Поскольку и
Помимо того, что мы получили «тощие» контроллеры, мы также упростили себе unit-тестирование. Действительно, гораздо проще оттестировать отдельно взятый класс-обработчик, причем можно «замокать» его зависимости, а можно внедрить настоящие, если ваш unit-тест наследует от
Еще одним неоспоримым преимуществом является то, что мы по сути оторвали нашу бизнес-логику от фреймворка (насколько это возможно, конечно). Необходимые зависимости внедряются в обработчики, а из какого фреймворка они приходят — уже не важно. Однажды FIG закончит стандартизацию всех ключевых интерфейсов, и мы сможем брать наши обработчики и просто переносить их из-под капота одного фреймворка под капот другого. Даже раздробленность бизнес-логики по обработчикам окажется плюсом, если однажды вас или ваш проект укусит SOA.
Кстати, если вы (как и я) никогда не писали на Java, и большое количество коротких классов не ассоциируется для вас со словом «гармония», то вы даже не обязаны держать каждый обработчик в отдельном классе (хотя лично мне нравится). SimpleBus позволяет объединять обработчики в один класс, так что вы вполне можете иметь по классу обработчиков на каждый entity, функции которых будут обработчиками конкретных операций.
На самом деле, даже в банальных CRUD-приложениях Command Bus дает очевидные преимущества:
- контроллеры становятся «худыми» (редкий «экшен» занимает более 15 строк),
- бизнес-логика покидает контроллеры и становится максимально независимой от фреймворка (в результате ее несложно повторно использовать в других проектах, даже если они написаны не на Symfony),
- упрощается unit-тестирование бизнес-логики,
- сокращается дублирование кода (когда, например, необходимо реализовать «фичу» как через Web UI, так и через API).

Декорации
Предположим, у нас приложение, в котором можно регистрировать некие проекты. Проект как сущность включает в себя:
- обязательное название,
- необязательное описание.
Код, реализованный по родной документации Symfony, мог бы выглядеть как-то так:
Entity
namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints;
/**
* @ORM\Table(name="projects")
* @ORM\Entity
* @Constraints\UniqueEntity(fields={"name"}, message="Проект с таким названием уже существует.")
*/
class Project
{
const MAX_NAME = 25;
const MAX_DESCRIPTION = 100;
/**
* @var int ID.
*
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
/**
* @var string Название проекта.
*
* @ORM\Column(name="name", type="string", length=25)
*/
private $name;
/**
* @var string Описание проекта.
*
* @ORM\Column(name="description", type="string", length=100, nullable=true)
*/
private $description;
}
Форма
namespace AppBundle\Form;
use AppBundle\Entity\Project;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints;
use AppBundle\Entity\Project;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints;
class ProjectForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('name', TextType::class, [
'label' => 'Название проекта',
'required' => true,
'attr' => ['maxlength' => Project::MAX_NAME],
'constraints' => [
new Constraints\NotBlank(),
new Constraints\Length(['max' => Project::MAX_NAME]),
],
]);
$builder->add('description', TextType::class, [
'label' => 'Описание проекта',
'required' => false,
'attr' => ['maxlength' => Project::MAX_DESCRIPTION],
'constraints' => [
new Constraints\Length(['max' => Project::MAX_DESCRIPTION]),
],
]);
}
/**
* {@inheritdoc}
*/
public function getBlockPrefix()
{
return 'project';
}
}
Контроллер (создание проекта)
namespace AppBundle\Controller;
use AppBundle\Entity\Project;
use AppBundle\Form\ProjectForm;
use Sensio\Bundle\FrameworkExtraBundle\Configuration;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
class ProjectController extends Controller
{
/**
* Отображает страницу с формой, а также обрабатывает ее "сабмит".
*
* @Configuration\Route("/new")
* @Configuration\Method({"GET", "POST"})
*/
public function newAction(Request $request)
{
$project = new Project();
$form = $this->createForm(ProjectForm::class, $project);
$form->handleRequest($request);
if ($form->isValid()) {
$this->getDoctrine()->getManager()->persist($project);
$this->getDoctrine()->getManager()->flush();
return $this->redirectToRoute('projects');
}
return $this->render('project/form.html.twig', [
'form' => $form->createView(),
]);
}
}
Я привел этот контроллер больше для сравнения — именно так он выглядит с точки зрения Symfony-документации. На самом же деле «веб 2.0» давно победил, сосед по команде ваяет «фронтэнд» проекта на Angular, а формы конечно же прилетают в AJAX-запросах.
Поэтому контроллер выглядит иначе.
namespace AppBundle\Controller;
use AppBundle\Entity\Project;
use AppBundle\Form\ProjectForm;
use Sensio\Bundle\FrameworkExtraBundle\Configuration;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
class ProjectController extends Controller
{
/**
* Возвращает HTML-код формы.
*
* @Configuration\Route("/new", condition="request.isXmlHttpRequest()")
* @Configuration\Method("GET")
*/
public function showNewFormAction()
{
$form = $this->createForm(ProjectForm::class, null, [
'action' => $this->generateUrl('new_project'),
]);
return $this->render('project/form.html.twig', [
'form' => $form->createView(),
]);
}
/**
* Обрабатывает "сабмит" формы.
*
* @Configuration\Route("/new", name="new_project", condition="request.isXmlHttpRequest()")
* @Configuration\Method("POST")
*/
public function newAction(Request $request)
{
$project = new Project();
$form = $this->createForm(ProjectForm::class, $project);
$form->handleRequest($request);
if ($form->isValid()) {
$this->getDoctrine()->getManager()->persist($project);
$this->getDoctrine()->getManager()->flush();
return new JsonResponse();
}
else {
$error = $form->getErrors(true)->current();
return new JsonResponse($error->getMessage(), JsonResponse::HTTP_BAD_REQUEST);
}
}
}
«Вью» формы
{{ form_start(form) }}
{{ form_row(form.name) }}
{{ form_row(form.description) }}
{{ form_end(form) }}
SimpleBus
Существует множество реализаций Command Bus для PHP — от «thephpleague» до откровенных NIH-велосипедов. Лично мне понравилась версия от Matthias Noback (у него в блоге есть серия статей, посвященных Command Bus) — SimpleBus. Библиотека не зависит от конкретного фреймворка и ее можно использовать в любом PHP-проекте. Для облегчения интеграции библиотеки с Symfony есть готовый bundle от того же автора, его и поставим:
composer require simple-bus/symfony-bridge
Любая команда — не более, чем структура входных данных, обработка которых находится в отдельном обработчике. Бандл добавляет новый сервис
command_bus
, который и вызывает предварительно зарегистрированные обработчики.Попробуем «отрефакторить» наш «экшен» создания нового проекта. HTML-форма — не единственный возможный источник входных данных (проект можно создать через API, или соответствующим сообщением в SOA-системе, или… да мало ли как еще), поэтому я намеренно переношу валидацию данных поближе к самой бизнес-логике (частью которой валидация и является), т.е. из формы в обработчик команды. В общем случае при любом количестве точек входа мы идем в один и тот же обработчик, который и выполняет валидацию. В случае ошибок валидации (да и любых других) мы эскалируем ошибки обратно в виде исключений. В итоге любой «экшен» — это короткий try-catch, в котором мы преобразуем данные из запроса в команду, вызываем обработчик, а затем возвращаем «200 OK»; секция
catch
возвращает HTTP-код «4xx» с конкретным сообщением об ошибке. Посмотрим, как это выглядит в деле:Форма
Тут мы просто выбрасываем валидацию, в остальном форма никак не изменилась.
class ProjectForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('name', TextType::class, [
'label' => 'Project name',
'required' => true,
'attr' => ['maxlength' => Project::MAX_NAME],
]);
$builder->add('description', TextType::class, [
'label' => 'Project description',
'required' => false,
'attr' => ['maxlength' => Project::MAX_DESCRIPTION],
]);
}
public function getBlockPrefix()
{
return 'project';
}
}
Команда
А вот тут валидация наоборот появляется.
namespace AppBundle\SimpleBus\Project;
use Symfony\Component\Validator\Constraints;
/**
* Create new project.
*
* @property string $name Project name.
* @property string $description Description.
*/
class CreateProjectCommand
{
/**
* @Constraints\NotBlank()
* @Constraints\Length(max = "25")
*/
public $name;
/**
* @Constraints\Length(max = "100")
*/
public $description;
}
Обработчик команды
namespace AppBundle\SimpleBus\Project\Handler;
use AppBundle\Entity\Project;
use AppBundle\SimpleBus\Project\CreateProjectCommand;
use Symfony\Bridge\Doctrine\RegistryInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class CreateProjectCommandHandler
{
protected $validator;
protected $doctrine;
/**
* Dependency Injection constructor.
*
* @param ValidatorInterface $validator
* @param RegistryInterface $doctrine
*/
public function __construct(ValidatorInterface $validator, RegistryInterface $doctrine)
{
$this->validator = $validator;
$this->doctrine = $doctrine;
}
/**
* Creates new project.
*
* @param CreateProjectCommand $command
* @throws BadRequestHttpException
*/
public function handle(CreateProjectCommand $command)
{
$violations = $this->validator->validate($command);
if (count($violations) != 0) {
$error = $violations->get(0)->getMessage();
throw new BadRequestHttpException($error);
}
$entity = new Project();
$entity
->setName($command->name)
->setDescription($command->description);
$this->doctrine->getManager()->persist($entity);
$this->doctrine->getManager()->flush();
}
}
Регистрация команды
Чтобы
command_bus
нашел наш обработчик, его надо зарегистрировать как сервис, пометив специальным тэгом.services:
command.project.create:
class: AppBundle\SimpleBus\Project\Handler\CreateProjectCommandHandler
tags: [{ name: command_handler, handles: AppBundle\SimpleBus\Projects\CreateProjectCommand }]
arguments: [ "@validator", "@doctrine" ]
Контроллер
Функция
showNewFormAction
никак не изменилась (для краткости опустим ее), поменялся лишь newAction
.class ProjectController extends Controller
{
public function newAction(Request $request)
{
try {
// Наша форма имеет префикс "project". Иначе достаточно "$request->request->all()".
$data = $request->request->get('project');
$command = new CreateProjectCommand();
$command->name = $data['name'];
$command->description = $data['description'];
$this->container->get('command_bus')->handle($command);
return new JsonResponse();
}
catch (\Exception $e) {
return new JsonResponse($e->getMessage(), $e->getStatusCode());
}
}
}
Если посчитать, то мы увидим, что прежняя версия «экшена» содержала 12 строк кода, в то время как новая содержит 11 строк. Но во-первых, мы только начали (дальше будет короче и изящнее), а во-вторых, у нас сферический пример в вакууме. В реальной жизни усложнение бизнес-логики будет «раздувать» контроллер в первом случае, и совершенно никак его не затронет во втором.
Есть еще один интересный ньюанс. Допустим, пользователь ввел название уже существующего проекта. В entity-классе у нас есть соответствующая аннотация, но форма-то при этом остается корректной. Из-за этого в первом случае нередко приходится городить дополнительную обработку ошибок.
В нашей же command-версии при вызове
persist($entity)
в обработчике команды возникнет exception — его создаст сама ORM, добавив в него то самое сообщение, которое мы указали в аннотации класса Project
(«Проект с таким названием уже существует»). В результате сам «экшен» никак не изменился — мы просто ловим любое исключение, на каком бы уровне оно не произошло, и превращаем его в «HTTP 400».Кстати, на хабре (и не только) уже было сломано немало копий на тему «исключения против ошибок». Например, в одной из последних подобных статей AlexLeonov предложил нечто близкое к моему подходу (ошибки валидации через исключения), и, судя по комментариям к его статье, мне тоже достанется. Я призываю на этот раз не холиварить, а принять как данность мою слабость к простоте кода, и простить мне ее, если сможете (тут был смайлик, но он испугался модераторов и исчез).
Автовалидация команд
Если присмотреться к функции
handle
в обработчике команды, можно заметить, что валидация и обработка ее результата:- составляет примерно половину кода функции,
- явно будет повторяться из команды в команду,
- легко может быть забыта в очередном обработчике.
К счастью, SimpleBus поддерживает «middlewares» — промежуточные функции, которые будут автоматически вызываться при обработке любой команды. Middleware-функций может быть сколько угодно, вы можете заставить одни из них вызываться до команд, а другие — после, вы даже можете назначать им приоритеты, если последовательность выполнения каких-то middleware-функций важна. Очевидно, имеет смысл обернуть валидацию команд в middleware-функцию и забыть о ней вовсе.
namespace AppBundle\SimpleBus\Middleware;
use Psr\Log\LoggerInterface;
use SimpleBus\Message\Bus\Middleware\MessageBusMiddleware;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class ValidationMiddleware implements MessageBusMiddleware
{
protected $logger;
protected $validator;
/**
* Dependency Injection constructor.
*
* @param LoggerInterface $logger
* @param ValidatorInterface $validator
*/
public function __construct(LoggerInterface $logger, ValidatorInterface $validator)
{
$this->logger = $logger;
$this->validator = $validator;
}
/**
* {@inheritdoc}
*/
public function handle($message, callable $next)
{
$violations = $this->validator->validate($message);
if (count($violations) != 0) {
$error = $violations->get(0)->getMessage();
$this->logger->error('Validation exception', [$error]);
throw new BadRequestHttpException($error);
}
$next($message);
}
}
Регистрируем наш middleware:
services:
middleware.validation:
class: AppBundle\SimpleBus\Middleware\ValidationMiddleware
public: false
tags: [{ name: command_bus_middleware }]
arguments: [ "@logger", "@validator" ]
Упрощаем обработчик команды (не забываем убрать ненужную зависимость от валидатора):
class CreateProjectCommandHandler
{
protected $doctrine;
/**
* Dependency Injection constructor.
*
* @param RegistryInterface $doctrine
*/
public function __construct(RegistryInterface $doctrine)
{
$this->doctrine = $doctrine;
}
/**
* Creates new project.
*
* @param CreateProjectCommand $command
*/
public function handle(CreateProjectCommand $command)
{
$entity = new Project();
$entity
->setName($command->name)
->setDescription($command->description);
$this->doctrine->getManager()->persist($entity);
$this->doctrine->getManager()->flush();
}
}
Множественные ошибки валидации
Многие из вас уже наверное задались вопросом, как же быть, если результатом валидации является не одна ошибка, а целый набор. Действительно, не самая удачная идея возвращать их пользователю по одной — хотелось бы отметить все некорректные поля формы за один раз.
Это, наверное, единственное «узкое» место подхода. Я не придумал ничего лучше, кроме как кидать специальное исключение с массивом ошибок. Мой внутренний перфекционист очень страдает от этого, но возможно он не прав, буду рад успокоительным комментариям. Также приветствуется, если кто-то предложит более удачное решение.
А пока — наше собственное исключение валидации:
class ValidationException extends BadRequestHttpException
{
protected $messages = [];
/**
* {@inheritdoc}
*/
public function __construct(array $messages, $code = 0, \Exception $previous = null)
{
$this->messages = $messages;
parent::__construct(count($messages) ? reset($this->messages) : '', $previous, $code);
}
/**
* @return array
*/
public function getMessages()
{
return $this->messages;
}
}
Слегка поправим наш валидирующий middleware:
class ValidationMiddleware implements MessageBusMiddleware
{
public function handle($message, callable $next)
{
$violations = $this->validator->validate($message);
if (count($violations) != 0) {
$errors = [];
foreach ($violations as $violation) {
$errors[$violation->getPropertyPath()] = $violation->getMessage();
}
$this->logger->error('Validation exception', $errors);
throw new ValidationException($errors);
}
$next($message);
}
}
Ну и конечно же сам контроллер (появилась дополнительная секция
catch
):class ProjectController extends Controller
{
public function newAction(Request $request)
{
try {
$data = $request->request->get('project');
$command = new CreateProjectCommand();
$command->name = $data['name'];
$command->description = $data['description'];
$this->container->get('command_bus')->handle($command);
return new JsonResponse();
}
catch (ValidationException $e) {
return new JsonResponse($e->getMessages(), $e->getStatusCode());
}
catch (\Exception $e) {
return new JsonResponse($e->getMessage(), $e->getStatusCode());
}
}
}
Теперь в случае ошибки валидации «экшен» вернет JSON-структуру, где ключами будут имена HTML-элементов, а значениями — сообщения об ошибке для соответствующих полей. Например, если не указать название проекта и одновременно ввести слишком длинное описание:
{
"name": "Значение не может быть пустым.",
"description": "Значение не должно превышать 100 символов."
}
На самом деле ключами конечно будут имена свойств в классе команды, но мы же не случайно назвали их идентично полям формы. Впрочем, способ связи свойств класса с полями формы может быть абсолютно произвольной — это вам решать, как вы будете привязывать прилетевшие сообщения к элементам «фронтэнда». Для «затравки» вот вам пример моего error-обработчика подобного AJAX-запроса:
$.ajax({
// ...
error: function(xhr) {
var response = xhr.responseJSON ? xhr.responseJSON : xhr.responseText;
if (typeof response === 'object') {
$.each(response, function(id, message) {
var name = $('form').prop('name');
var $control = $('#' + name + '_' + id);
if ($control.length === 0) {
alert(message);
}
else {
$control.after('<p class="form-error">' + message + '</p>');
}
});
}
else {
alert(response);
}
},
beforeSend: function() {
$('.form-error').remove();
}
});
Автозаполнение команды
Каждый наш «экшен» начинается с запроса, из которого мы каждый раз копируем данные в команду, чтобы затем передать ее на обработку. После первых пяти «экшенов» это копирование начинает раздражать и требовать автоматизации. Напишем trait, который будет добавлять в наши команды конструктор-инициализатор:
trait MessageTrait
{
/**
* Инициализирует объект значениями из указанного массива.
*
* @param array $values Массив с начальными значениями объекта.
*/
public function __construct(array $values = [])
{
foreach ($values as $property => $value) {
if (property_exists($this, $property)) {
$this->$property = $value;
}
}
}
}
Готово. «Лишние» значения будут игнорироваться, недостающие — оставлять соответствующие свойства объекта в NULL-состоянии.
Теперь «экшен» может выглядеть так:
class ProjectController extends Controller
{
public function newAction(Request $request)
{
try {
$data = $request->request->get('project');
$command = new CreateProjectCommand($data);
$this->container->get('command_bus')->handle($command);
return new JsonResponse();
}
catch (ValidationException $e) {
return new JsonResponse($e->getMessages(), $e->getStatusCode());
}
catch (\Exception $e) {
return new JsonResponse($e->getMessage(), $e->getStatusCode());
}
}
}
А что, если нам понадобится добавить какие-то дополнительные данные помимо значений из объекта запроса? Например, в
editAction
очевидно будет еще один параметр — ID проекта. И очевидно, что соответствующая команда будет на одно свойство больше:/**
* Update specified project.
*
* @property int $id Project ID.
* @property string $name New name.
* @property string $description New description.
*/
class UpdateProjectCommand
{
/**
* @Constraints\NotBlank()
*/
public $id;
/**
* @Constraints\NotBlank()
* @Constraints\Length(max = "25")
*/
public $name;
/**
* @Constraints\Length(max = "100")
*/
public $description;
}
Давайте добавим второй массив с альтернативными значениями:
trait MessageTrait
{
/**
* Инициализирует объект значениями из указанного массива.
*
* @param array $values Массив с начальными значениями объекта.
* @param array $extra Массив с дополнительными значениями объекта.
* В случае конфликта ключей этот массив переписывает значение из предыдущего.
*/
public function __construct(array $values = [], array $extra = [])
{
$data = $extra + $values;
foreach ($data as $property => $value) {
if (property_exists($this, $property)) {
$this->$property = $value;
}
}
}
}
Теперь наш гипотетический
editAction
мог бы выглядеть следующим образом:class ProjectController extends Controller
{
/**
* Возвращает HTML-код формы.
*
* @Configuration\Route("/edit/{id}", requirements={"id"="\d+"}, condition="request.isXmlHttpRequest()")
* @Configuration\Method("GET")
*/
public function showEditFormAction($id)
{
$project = $this->getDoctrine()->getRepository(Project::class)->find($id);
if (!$project) {
throw $this->createNotFoundException();
}
$form = $this->createForm(ProjectForm::class, $project, [
'action' => $this->generateUrl('edit_project'),
]);
return $this->render('project/form.html.twig', [
'form' => $form->createView(),
]);
}
/**
* Обрабатывает "сабмит" формы.
*
* @Configuration\Route("/edit/{id}", name="edit_project", requirements={"id"="\d+"}, condition="request.isXmlHttpRequest()")
* @Configuration\Method("POST")
*/
public function editAction(Request $request, $id)
{
try {
$project = $this->getDoctrine()->getRepository(Project::class)->find($id);
if (!$project) {
throw $this->createNotFoundException();
}
$data = $request->request->get('project');
$command = new UpdateProjectCommand($data, ['id' => $id]);
$this->container->get('command_bus')->handle($command);
return new JsonResponse();
}
catch (ValidationException $e) {
return new JsonResponse($e->getMessages(), $e->getStatusCode());
}
catch (\Exception $e) {
return new JsonResponse($e->getMessage(), $e->getStatusCode());
}
}
}
Почти все хорошо, но есть ньюанс — если пользователь оставит описание проекта пустым, к нам прилетит пустая строка, которая в итоге и будет сохранена в базу, хотя в подобных случаях хотелось бы писать в базу
NULL
. Расширим наш trait еще немного:trait MessageTrait
{
public function __construct(array $values = [], array $extra = [])
{
$empty2null = function ($value) use (&$empty2null) {
if (is_array($value)) {
foreach ($value as &$v) {
$v = $empty2null($v);
}
return $value;
}
return is_string($value) && strlen($value) === 0 ? null : $value;
};
$data = $empty2null($extra + $values);
foreach ($data as $property => $value) {
if (property_exists($this, $property)) {
$this->$property = $value;
}
}
}
}
Здесь мы просто добавили анонимную функцию (чтобы не плодить сущностей), которая рекурсивно (массив может быть вложенным) проходит по исходным значениям и меняет пустые строки на
NULL
.События
Помимо команд SimpleBus умеет также и события. Строго говоря, разница между ними невелика. Реализованы они идентично — вы точно также создаете класс-событие, но обработчиков (точнее — подписчиков) у него может быть много (или вообще ни одного). Регистрируются подписчики аналогично обработчикам (лишь с немного другим тэгом), а управляет ими другой специальный сервис, реализованный в SimpleBus —
event_bus
.Поскольку и
simple_bus
, и event_bus
— обычные Symfony-сервисы, вы можете внедрять их в качестве зависимостей куда угодно, в том числе и в ваши обработчики. Например, чтобы команда создания проекта послала событие о том, что был создан новый проект.Вместо заключения
Помимо того, что мы получили «тощие» контроллеры, мы также упростили себе unit-тестирование. Действительно, гораздо проще оттестировать отдельно взятый класс-обработчик, причем можно «замокать» его зависимости, а можно внедрить настоящие, если ваш unit-тест наследует от
Symfony\Bundle\FrameworkBundle\Test\WebTestCase
. При этом в любом случае нам больше не нужно использовать Symfony crawler (который, к слову, заметно замедляет тесты), чтобы вызвать тот или иной «экшен». Честно говоря, теперь я порой вообще не покрываю «экшены» тестами, разве что проверяю их на доступность, как рекомендует документация Symfony.Еще одним неоспоримым преимуществом является то, что мы по сути оторвали нашу бизнес-логику от фреймворка (насколько это возможно, конечно). Необходимые зависимости внедряются в обработчики, а из какого фреймворка они приходят — уже не важно. Однажды FIG закончит стандартизацию всех ключевых интерфейсов, и мы сможем брать наши обработчики и просто переносить их из-под капота одного фреймворка под капот другого. Даже раздробленность бизнес-логики по обработчикам окажется плюсом, если однажды вас или ваш проект укусит SOA.
Кстати, если вы (как и я) никогда не писали на Java, и большое количество коротких классов не ассоциируется для вас со словом «гармония», то вы даже не обязаны держать каждый обработчик в отдельном классе (хотя лично мне нравится). SimpleBus позволяет объединять обработчики в один класс, так что вы вполне можете иметь по классу обработчиков на каждый entity, функции которых будут обработчиками конкретных операций.
Comments 121
Only users with full accounts can post comments. Log in, please.