📜 ⬆️ ⬇️

Symfony and command bus

For more than a year I have been using the Command Bus pattern in my symfony projects and finally decided to share my experience. In the ends, it’s a shame that in Laravel it is “out of the box”, and in Symfony, from which Laravel has grown in many ways - no, although the very concept of Command / Query Separation has been at least 10 years old. And if with the letter “Q” from the abbreviation “CQRS” it is still clear what to do (personally I am quite satisfied with custom repositories), then where to stick the letter “C” is unclear.

In fact, even in banal CRUD applications, the Command Bus has obvious advantages:


KDPV

Scenery


Suppose we have an application in which you can register certain projects. The project as an entity includes:
')

Code implemented using native symfony documentation might look something like this:

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; } 


The form
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;

 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'; } } 


Controller (project creation)
 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(), ]); } } 


I brought this controller more for comparison - this is how it looks from the point of view of symfony documentation. In fact, “web 2.0” won long ago, the teammate sculpts the “frontend” of the project on Angular, and the forms of course arrive in AJAX requests.

Therefore, the controller looks different.
 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); } } } 


"View" form
 {{ form_start(form) }} {{ form_row(form.name) }} {{ form_row(form.description) }} {{ form_end(form) }} 

Simplebus


There are many implementations of the Command Bus for PHP — from “thephpleague” to straightforward NIH bikes. Personally, I liked the version from Matthias Noback (he has a series of articles on Command Bus on his blog) - SimpleBus . The library does not depend on a specific framework and can be used in any PHP project. To facilitate the integration of the library with symfony there is a ready bundle from the same author, and we will install it:

 composer require simple-bus/symfony-bridge 

Any command is nothing more than the structure of input data, the processing of which is in a separate handler. Bundle adds a new service command_bus , which calls the previously registered handlers.

Let's try to "refactor" our "action" to create a new project. The HTML form is not the only possible source of input data (the project can be created through the API, or the appropriate message in the SOA system, or ... but how else), so I deliberately move the data validation closer to the business logic itself (part of which is validation and is), i.e. from form to command handler. In general, for any number of entry points, we go to the same handler, which performs validation. In case of validation errors (and any others), we will escalate the errors back as exceptions. As a result, any “action” is a short try-catch, in which we convert the data from the request into a command, call the handler, and then return “200 OK”; The catch section returns the HTTP code "4xx" with a specific error message. Let's see how it looks in business:

The form


Here we just throw away the validation, otherwise the form has not changed.

 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'; } } 

Team


And here the validation appears on the contrary.

 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; } 

Command handler


 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(); } } 

Team Registration


In order for the command_bus found by our handler, it must be registered as a service, having marked with a special tag.

 services: command.project.create: class: AppBundle\SimpleBus\Project\Handler\CreateProjectCommandHandler tags: [{ name: command_handler, handles: AppBundle\SimpleBus\Projects\CreateProjectCommand }] arguments: [ "@validator", "@doctrine" ] 

Controller


The showNewFormAction function showNewFormAction not changed at all ( showNewFormAction omit it for short), only newAction changed.

 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()); } } } 

If we calculate, we will see that the previous version of the “action” contained 12 lines of code, while the new version contains 11 lines. But first, we just started (further it will be shorter and more elegant), and secondly, we have a spherical example in a vacuum. In real life, the complication of business logic will “inflate” the controller in the first case, and it will in no way affect it in the second.

There is another interesting nuance. Suppose a user has entered the name of an existing project. In entity-class, we have a corresponding annotation, but the form still remains correct. Because of this, in the first case, it is often necessary to fence additional error handling.

In our command-version, when invoking persist($entity) , an exception will occur in the command handler - it will be created by the ORM itself, adding the message that we specified in the Project class annotation ( “A project with this name already exists” ). As a result, the action itself has not changed at all - we just catch any exception, no matter what level it occurs, and turn it into an “HTTP 400 ″.

By the way, on Habré (and not only) a lot of copies on the subject of “exception against errors” have already been broken. For example, in one of the last similar articles, AlexLeonov suggested something close to my approach (validation errors through exceptions), and judging by the comments on his article, I will also get it. I urge this time not to holivarit, but to take for granted my weakness to the simplicity of the code, and forgive me if you can (there was a smiley face, but he was frightened by the moderators and disappeared).

Command autovalidation


If you look at the handle function in the command handler, you will notice that validation and processing of its result:


Fortunately, SimpleBus supports “middlewares” - intermediate functions that will be automatically called when processing any command. There may be any number of middleware functions, you can force some of them to be called before commands, and others after, you can even assign priorities to them if the sequence of performing some middleware functions is important. Obviously, it makes sense to wrap command validation into a middleware function and forget about it altogether.

 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); } } 

We register our middleware:

 services: middleware.validation: class: AppBundle\SimpleBus\Middleware\ValidationMiddleware public: false tags: [{ name: command_bus_middleware }] arguments: [ "@logger", "@validator" ] 

Simplify the command handler (remember to remove the unnecessary dependency on the 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(); } } 

Multiple validation errors


Many of you have probably wondered how to be if the result of validation is not one error, but a whole set. Indeed, it is not the best idea to return them to the user one by one - I would like to mark all the incorrect form fields at once.

This is probably the only “narrow” place of the approach. I didn’t think of anything better than throwing a special exception with an array of errors. My internal perfectionist suffers greatly from this, but maybe he is wrong, I will be glad to have soothing comments. Also welcome if someone offers a better solution.

For now, our own validation exception:

 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; } } 

Slightly fix our validating 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); } } 

And of course, the controller itself (there is an additional catch section):

 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()); } } } 

Now in case of a validation error, the action returns a JSON structure, where the keys are the names of the HTML elements, and the values ​​are the error messages for the corresponding fields. For example, if you do not specify a project name and at the same time enter a too long description:

 { "name": "    .", "description": "    100 ." } 

Actually, the keys will of course be the names of the properties in the command class, but we did not accidentally name them identically to the form fields. However, the method of associating class properties with form fields can be completely arbitrary — it's up to you how you will bind incoming messages to frontend elements. For the “seed”, here is an example of my error-handler for such an AJAX request:

 $.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(); } }); 

Autocomplete team


Each of our "action" begins with a request, from which we copy data to the team each time, in order to transfer it to processing. After the first five actions, this copying starts to annoy and demand automation. Let's write a trait that will add a constructor initializer to our commands:

 trait MessageTrait { /** *      . * * @param array $values     . */ public function __construct(array $values = []) { foreach ($values as $property => $value) { if (property_exists($this, $property)) { $this->$property = $value; } } } } 

Is done. The “extra” values ​​will be ignored, the missing ones will leave the corresponding properties of the object in the NULL state.

Now the "action" may look like this:

 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()); } } } 

But what if we need to add some additional data besides the values ​​from the request object? For example, in editAction will obviously be another parameter - the project ID. And it is obvious that the corresponding command will be one more property:

 /** * 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; } 

Let's add a second array with alternative values:

 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; } } } } 

Now our hypothetical editAction might look like this:

 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()); } } } 

Almost everything is fine, but there is a nuance - if the user leaves the project description empty, an empty string will fly to us, which will eventually be saved to the database, although in such cases I would like to write to the NULL database. Expand our trait a little more:

 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; } } } } 

Here we simply added an anonymous function (so as not to produce entities), which recursively (an array can be nested) passes through the original values ​​and changes empty lines to NULL .

Developments


In addition to SimpleBus commands, events are also able. Strictly speaking, the difference between them is small. They are implemented identically - you create the event class in the same way, but it can have many (or none at all) handlers (more precisely, subscribers). Subscribers are registered in the same way as handlers (only with a slightly different tag), and they are managed by another special service implemented in SimpleBus - event_bus .

Since both simple_bus and event_bus are normal symfony services, you can embed them as dependencies anywhere, including in your handlers. For example, for a project creation team to send an event that a new project was created.

Instead of conclusion


In addition to the fact that we received “skinny” controllers, we also simplified our unit-testing. Indeed, it is much easier to test a single class handler, and you can “lock” its dependencies, and you can implement real ones if your unit test inherits from Symfony\Bundle\FrameworkBundle\Test\WebTestCase . At the same time, in any case, we no longer need to use a symfony crawler (which, by the way, slows down the tests noticeably) in order to trigger this or that action. Honestly, now sometimes I don’t even cover “actions” with tests, unless I check them for availability, as recommended by the Symfony documentation.

Another indisputable advantage is that we essentially cut off our business logic from the framework (as far as possible, of course). Necessary dependencies are implemented in handlers, and from which framework they come, it does not matter anymore. One day, the FIG will finish standardizing all the key interfaces, and we will be able to take our handlers and simply transfer them from under the hood of one framework under the hood of the other. Even fragmentation of business logic by handlers will be a plus if one day the SOA bites you or your project.

By the way, if you (like me) never wrote in Java, and a large number of short classes are not associated for you with the word “harmony”, then you are not even obliged to keep each handler in a separate class (although I personally like it). SimpleBus allows you to combine handlers into one class, so you can easily have class handlers for each entity whose functions will be handlers for specific operations.

Source: https://habr.com/ru/post/280512/


All Articles