Уже больше года использую паттерн Command Bus в своих Symfony-проектах и наконец решил поделиться опытом. В концев концов обидно, что в Laravel это есть «из коробки», а в Symfony, из которого Laravel во многом вырос — нет, хотя самому понятию Command/Query Separation уже не менее 10 лет. И если с буквой «Q» из аббревиатуры «CQRS» еще понятно что делать (лично меня вполне устраивают custom repositories), то куда приткнуть букву «C» — неясно.
На самом деле, даже в банальных CRUD-приложениях Command Bus дает очевидные преимущества:
- контроллеры становятся «худыми» (редкий «экшен» занимает более 15 строк),
- бизнес-логика покидает контроллеры и становится максимально независимой от фреймворка (в результате ее несложно повторно использовать в других проектах, даже если они написаны не на Symfony),
- упрощается unit-тестирование бизнес-логики,
- сокращается дублирование кода (когда, например, необходимо реализовать «фичу» как через Web UI, так и через API).
Декорации
Предположим, у нас приложение, в котором можно регистрировать некие проекты. Проект как сущность включает в себя:
- обязательное название,
- необязательное описание.
Код, реализованный по родной документации Symfony, мог бы выглядеть как-то так:
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; }
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) }}
Simple Bus
Существует множество реализаций 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, функции которых будут обработчиками конкретных операций.
ссылка на оригинал статьи https://habrahabr.ru/post/280512/
Добавить комментарий