📜 ⬆️ ⬇️

Step-by-step creating a symfony 4 bundle

About a year ago, our company headed for the division of a huge monolith on Magento 1 into microservices. As a basis, I chose only released in the Symfony 4 release. During this time, I developed several projects on this framework, but I found development of bundles, reusable components for Symfony, to be particularly interesting. Under the cat, a step-by-step guide to the development of the HealthCheck bundle for obtaining the status / health of microservice under Syfmony 4.1, in which I tried to touch on the most interesting and difficult (for me once) moments.


In our company, this bundle is used, for example, to obtain the re-index status of products in ElasticSearch - how many products are contained in Elastic with relevant data, and how many require indexation.


Build a skeleton bundle


In symfony 3, for generating skeletons, bundles were a convenient bundle, but in symfony 4 it is no longer supported and therefore the skeleton has to be created by itself. I start developing every new project with the launch of the command.


composer create-project symfony/skeleton health-check 

Please note that Symfony 4 supports PHP 7.1+, so if you run this command on the version below, you will get the skeleton of the project on Symfony 3.


This command creates a new symfony 4.1 project with the following structure:


image


In principle, this is not necessary, because of the created files we don’t need much in the end, but it’s more convenient for me to clean all unnecessary things, than to create the necessary ones with my hands.


composer.json


The next step is to edit composer.json for our needs. First of all, you need to change the type of the project type to symfony-bundle This will help Symfony Flex determine when adding a bundle to the project that it is really a symfony bundle, automatically connect it and install the recipe (but more on that later). Next, be sure to add the name and description fields. name is also important because it determines in which folder inside the vendor bundle will be placed.


 "name": "niklesh/health-check", "description": "Health check bundle", 

The next important step is to edit the autoload section, which is responsible for loading the bundle classes. autoload for the desktop, autoload-dev for the desktop.


 "autoload": { "psr-4": { "niklesh\\HealthCheckBundle\\": "src" } }, "autoload-dev": { "psr-4": { "niklesh\\HealthCheckBundle\\Tests\\": "tests" } }, 

Section scripts can be deleted. There are scripts for assembling assets and clearing the cache after executing the composer install and composer update commands, however, our bundle does not contain assets or cache, therefore these commands are useless.


The last step is to edit the require and require-dev sections. As a result, we obtain the following:


 "require": { "php": "^7.1.3", "ext-ctype": "*", "ext-iconv": "*", "symfony/flex": "^1.0", "symfony/framework-bundle": "^4.1", "sensio/framework-extra-bundle": "^5.2", "symfony/lts": "^4@dev", "symfony/yaml": "^4.1" } 

I note that the dependencies of require will be installed when the bundle is connected to the working project.


Run composer update - dependencies are installed.


Cleaning unnecessary


So, from the received files you can safely delete the following folders:



Also delete the files src/Kernel.php , .env , .env.dist
We do not need all this, since we are developing a bundle, not an application.


Creating a bundle structure


So, we added the necessary dependencies and cleaned out all unnecessary from our bundle. It's time to create the necessary files and folders to successfully connect the bundle to the project.


First of all, in the src folder, create the HealthCheckBundle.php file with the following contents:


 <?php namespace niklesh\HealthCheckBundle; use Symfony\Component\HttpKernel\Bundle\Bundle; class HealthCheckBundle extends Bundle { } 

This class should be in every bundle that you create. He will be connected in the main project's config/bundles.php file. In addition, he can influence the build of the bundle.


The next required component of the bundle is the DependencyInjection section. Create a folder with 2 files of the same name:



 <?php namespace niklesh\HealthCheckBundle\DependencyInjection; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; class Configuration implements ConfigurationInterface { public function getConfigTreeBuilder() { $treeBuilder = new TreeBuilder(); $treeBuilder->root('health_check'); return $treeBuilder; } } 

This file is responsible for parsing and validating the configuration of a bundle from Yaml or xml files. We still modify it later.



 <?php namespace niklesh\HealthCheckBundle\DependencyInjection; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\DependencyInjection\Loader; class HealthCheckExtension extends Extension { /** * {@inheritdoc} */ public function load(array $configs, ContainerBuilder $container) { $configuration = new Configuration(); $this->processConfiguration($configuration, $configs); $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); $loader->load('services.yaml'); } } 

This file is responsible for loading the configuration files of the bundle, creating and registering the "definition" of services, loading parameters into the container, etc.


And the last step at this stage is to add the src/Resources/services.yaml file, which will contain the description of our bundle services. For now, leave it empty.


HealthInterface


The main task of our bundle will be to return data about the project in which it is used. But gathering information is the work of the service itself, our bundle can only indicate the format of the information that the service should transmit to it, and the method that will receive this information. In my implementation, all services (and there may be several of them) that collect information must implement the HealthInterface interface with 2 methods: getName and getHealthInfo . The latter should return an object implementing the HealthDataInterface interface.


First, let's create an interface to the src/Entity/HealthDataInterface.php data src/Entity/HealthDataInterface.php :


 <?php namespace niklesh\HealthCheckBundle\Entity; interface HealthDataInterface { public const STATUS_OK = 1; public const STATUS_WARNING = 2; public const STATUS_DANGER = 3; public const STATUS_CRITICAL = 4; public function getStatus(): int; public function getAdditionalInfo(): array; } 

The data must contain an integer status and additional information (which, by the way, may be empty).


Since most likely the implementation of this interface will be typical for most of the heirs, I decided to add it to the src/Entity/CommonHealthData.php :


 <?php namespace niklesh\HealthCheckBundle\Entity; class CommonHealthData implements HealthDataInterface { private $status; private $additionalInfo = []; public function __construct(int $status) { $this->status = $status; } public function setStatus(int $status) { $this->status = $status; } public function setAdditionalInfo(array $additionalInfo) { $this->additionalInfo = $additionalInfo; } public function getStatus(): int { return $this->status; } public function getAdditionalInfo(): array { return $this->additionalInfo; } } 

Finally, we will add an interface for src/Service/HealthInterface.php data collection services:


 <?php namespace niklesh\HealthCheckBundle\Service; use niklesh\HealthCheckBundle\Entity\HealthDataInterface; interface HealthInterface { public function getName(): string; public function getHealthInfo(): HealthDataInterface; } 

Controller


Give the data about the project will be the controller in just one route. But this route will be the same for all projects using this bundle: /health


However, the task of our controller is not only to give the data, but also to pull it out of the services implementing HealthInterface , respectively, the controller must keep references to each of these services. The addHealthService method will be responsible for adding services to the controller addHealthService


Add the src/Controller/HealthController.php controller:


 <?php namespace niklesh\HealthCheckBundle\Controller; use niklesh\HealthCheckBundle\Service\HealthInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Annotation\Route; class HealthController extends AbstractController { /** @var HealthInterface[] */ private $healthServices = []; public function addHealthService(HealthInterface $healthService) { $this->healthServices[] = $healthService; } /** * @Route("/health") * @return JsonResponse */ public function getHealth(): JsonResponse { return $this->json(array_map(function (HealthInterface $healthService) { $info = $healthService->getHealthInfo(); return [ 'name' => $healthService->getName(), 'info' => [ 'status' => $info->getStatus(), 'additional_info' => $info->getAdditionalInfo() ] ]; }, $this->healthServices)); } } 

Compilation


Symfony can perform certain actions on services that implement a specific interface. You can call a particular method, add a tag, but you cannot take and inject all such services into another service (which is the controller). This problem is solved in 4 stages:


Add to each of our service that implements the HealthInterface tag.


Add the TAG constant to the interface:


 interface HealthInterface { public const TAG = 'health.service'; } 

Next you need to add this tag to each service. In the case of project configuration, this can be
implement in the config/services.yaml file in the _instanceof section. In our case, this
the record would look like this:


 serivces: _instanceof: niklesh\HealthCheckBundle\Service\HealthInterface: tags: - !php/const niklesh\HealthCheckBundle\Service\HealthInterface::TAG 

And, in principle, if you entrust the configuration of the bundle to the user, it will work, but in my opinion this is not the right approach, the bundle itself, when added to the project, must correctly connect and be configured with minimal user intervention. Someone may remember that we have our own services.yaml inside the bundle, but no, it will not help us. This setting only works if it is in the project file, not the bundle.
I do not know if this is a bug or a feature, but now we have what we have. Therefore, we will have to infiltrate the process of compiling the bundle.


Go to the src/HealthCheckBundle.php and override the build method:


 <?php namespace niklesh\HealthCheckBundle; use niklesh\HealthCheckBundle\Service\HealthInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; class HealthCheckBundle extends Bundle { public function build(ContainerBuilder $container) { parent::build($container); $container->registerForAutoconfiguration(HealthInterface::class)->addTag(HealthInterface::TAG); } } 

Now every class that implements HealthInterface will be tagged.


Register controller as a service


In the next step, we will need to refer to the controller as a service at the compilation stage of the bundle. In the case of working with a project, there all classes are registered as services by default, but in the case of working with a bundle, we must explicitly determine which classes will be services, put arguments to them, whether they will be public.


Open the src/Resources/config/services.yaml file and add the following content


 services: niklesh\HealthCheckBundle\Controller\HealthController: autoconfigure: true 

We explicitly registered the controller as a service, now it can be accessed at the compilation stage.


Adding services to the controller.


At the stage of compiling the container and bundles, we can operate only with the definitions of the services. At this stage, we need to take the definition of HealthController and indicate that after its creation, it is necessary to add all the services that are marked with our tag. For such operations in bundles, classes that implement the interface are responsible.
Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface


Create such a class src/DependencyInjection/Compiler/HealthServicePath.php :


 <?php namespace niklesh\HealthCheckBundle\DependencyInjection\Compiler; use niklesh\HealthCheckBundle\Controller\HealthController; use niklesh\HealthCheckBundle\Service\HealthInterface; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; class HealthServicesPath implements CompilerPassInterface { public function process(ContainerBuilder $container) { if (!$container->has(HealthController::class)) { return; } $controller = $container->findDefinition(HealthController::class); foreach (array_keys($container->findTaggedServiceIds(HealthInterface::TAG)) as $serviceId) { $controller->addMethodCall('addHealthService', [new Reference($serviceId)]); } } } 

As you can see, we first use the findDefinition method findDefinition take the controller, then all services by tag and then, in a loop, for each found service we add a call to the addHealthService method, where we pass the link to this service.


Using CompilerPath


The final step is to add our HealthServicePath to the bundle compilation process. Let's HealthCheckBundle back to the HealthCheckBundle class and change the build method a bit more. As a result, we get:


 <?php namespace niklesh\HealthCheckBundle; use niklesh\HealthCheckBundle\DependencyInjection\Compiler\HealthServicesPath; use niklesh\HealthCheckBundle\Service\HealthInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; class HealthCheckBundle extends Bundle { public function build(ContainerBuilder $container) { parent::build($container); $container->addCompilerPass(new HealthServicesPath()); $container->registerForAutoconfiguration(HealthInterface::class)->addTag(HealthInterface::TAG); } } 

In principle, at this stage, our bundle is ready for use. He can find information gathering services, work with them and give an answer when contacting /health (you only need to add routing settings when connecting), but I decided to lay in it the opportunity not only to send information on request, but also to provide the possibility of sending this information to or, for example, using a POST request or through a queue manager.


HealthSenderInterface


This interface is intended to describe the classes responsible for sending data somewhere. Create it in src/Service/HealthSenderInterface


 <?php namespace niklesh\HealthCheckBundle\Service; use niklesh\HealthCheckBundle\Entity\HealthDataInterface; interface HealthSenderInterface { /** * @param HealthDataInterface[] $data */ public function send(array $data): void; public function getDescription(): string; public function getName(): string; } 

As you can see, the send method will somehow process the received data array from all classes implementing HealthInterface and then send it where it needs.
The getDescription and getName are needed simply to display information when a console command is run.


SendDataCommand


To start sending data to third-party resources will be the console command SendDataCommand . Its task is to collect data for mailing, and then call the send method of each of the mailing services. Obviously, this command will partially repeat the logic of the controller, but not in everything.


 <?php namespace niklesh\HealthCheckBundle\Command; use niklesh\HealthCheckBundle\Entity\HealthDataInterface; use niklesh\HealthCheckBundle\Service\HealthInterface; use niklesh\HealthCheckBundle\Service\HealthSenderInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use Throwable; class SendDataCommand extends Command { public const COMMAND_NAME = 'health:send-info'; private $senders; /** @var HealthInterface[] */ private $healthServices; /** @var SymfonyStyle */ private $io; public function __construct(HealthSenderInterface... $senders) { parent::__construct(self::COMMAND_NAME); $this->senders = $senders; } public function addHealthService(HealthInterface $healthService) { $this->healthServices[] = $healthService; } protected function configure() { parent::configure(); $this->setDescription('Send health data by senders'); } protected function initialize(InputInterface $input, OutputInterface $output) { parent::initialize($input, $output); $this->io = new SymfonyStyle($input, $output); } protected function execute(InputInterface $input, OutputInterface $output) { $this->io->title('Sending health info'); try { $data = array_map(function (HealthInterface $service): HealthDataInterface { return $service->getHealthInfo(); }, $this->healthServices); foreach ($this->senders as $sender) { $this->outputInfo($sender); $sender->send($data); } $this->io->success('Data is sent by all senders'); } catch (Throwable $exception) { $this->io->error('Exception occurred: ' . $exception->getMessage()); $this->io->text($exception->getTraceAsString()); } } private function outputInfo(HealthSenderInterface $sender) { if ($name = $sender->getName()) { $this->io->writeln($name); } if ($description = $sender->getDescription()) { $this->io->writeln($description); } } } 

Modify the HealthServicesPath , write the addition of data collection services to the team.


 <?php namespace niklesh\HealthCheckBundle\DependencyInjection\Compiler; use niklesh\HealthCheckBundle\Command\SendDataCommand; use niklesh\HealthCheckBundle\Controller\HealthController; use niklesh\HealthCheckBundle\Service\HealthInterface; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; class HealthServicesPath implements CompilerPassInterface { public function process(ContainerBuilder $container) { if (!$container->has(HealthController::class)) { return; } $controller = $container->findDefinition(HealthController::class); $commandDefinition = $container->findDefinition(SendDataCommand::class); foreach (array_keys($container->findTaggedServiceIds(HealthInterface::TAG)) as $serviceId) { $controller->addMethodCall('addHealthService', [new Reference($serviceId)]); $commandDefinition->addMethodCall('addHealthService', [new Reference($serviceId)]); } } } 

As you can see, the command in the constructor takes an array of senders In this case, it will not be possible to use the auto-binding feature of dependencies, we need to create and register the command ourselves. The only question is which services of the senders to add to this command. We will specify their id in the configuration of the bundle like this:


 health_check: senders: - '@sender.service1' - '@sender.service2' 

Our bundle is not able to handle such configurations yet, we will teach it. Go to Configuration.php and add the configuration tree:


 <?php namespace niklesh\HealthCheckBundle\DependencyInjection; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; class Configuration implements ConfigurationInterface { public function getConfigTreeBuilder() { $treeBuilder = new TreeBuilder(); $rootNode = $treeBuilder->root('health_check'); $rootNode ->children() ->arrayNode('senders') ->scalarPrototype()->end() ->end() ->end() ; return $treeBuilder; } } 

This code determines that the root node will have a health_check node that will contain the senders array, which in turn will contain some number of lines. Everything, now our bandl knows how to process a configuration that we designated above. It's time to register a team. To do this, go to HealthCheckExtension and add the following code:


 <?php namespace niklesh\HealthCheckBundle\DependencyInjection; use niklesh\HealthCheckBundle\Command\SendDataCommand; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\DependencyInjection\Loader; use Symfony\Component\DependencyInjection\Reference; class HealthCheckExtension extends Extension { /** * {@inheritdoc} */ public function load(array $configs, ContainerBuilder $container) { $configuration = new Configuration(); $config = $this->processConfiguration($configuration, $configs); $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); $loader->load('services.yaml'); //    $commandDefinition = new Definition(SendDataCommand::class); //        foreach ($config['senders'] as $serviceId) { $commandDefinition->addArgument(new Reference($serviceId)); } //       $commandDefinition->addTag('console.command', ['command' => SendDataCommand::COMMAND_NAME]); //     $container->setDefinition(SendDataCommand::class, $commandDefinition); } } 

Everything, our team is defined. Now, after adding the bundle to the project, when calling
bin/console we will see a list of commands, including ours: health:send-info , you can also call it: bin/console health:send-info


Our bundle is ready. It's time to test it in the project. Create an empty project:


 composer create-project symfony/skeleton health-test-project 

Add our newly-made bundle to it, for this we will add the repositories section to composer.json :


 "repositories": [ { "type": "vcs", "url": "https://github.com/HEKET313/health-check" } ] 

And execute the command:


 composer require niklesh/health-check 

And also, for the fastest launch we will add a symphony server to our project:


 composer req --dev server 

The bundle is connected, Symfony Flex automatically connects it to config/bundles.php , but to automatically create configuration files you need to create a recipe. Pro recipes are beautifully described in another article here: https://habr.com/post/345382/ - therefore, describe how to create recipes, etc. I will not be here, and there is no recipe for this bundle yet.


However, configuration files are needed, so create them with pens:



 health_check: resource: "@HealthCheckBundle/Controller/HealthController.php" prefix: / type: annotation 


 health_check: senders: - 'App\Service\Sender' 

Now you need to implement the send classes for the team and the collection class



It's all very simple


 <?php namespace App\Service; use niklesh\HealthCheckBundle\Entity\CommonHealthData; use niklesh\HealthCheckBundle\Entity\HealthDataInterface; use niklesh\HealthCheckBundle\Service\HealthInterface; class DataCollector implements HealthInterface { public function getName(): string { return 'Data collector'; } public function getHealthInfo(): HealthDataInterface { $data = new CommonHealthData(HealthDataInterface::STATUS_OK); $data->setAdditionalInfo(['some_data' => 'some_value']); return $data; } } 


And it's even easier


 <?php namespace App\Service; use niklesh\HealthCheckBundle\Entity\HealthDataInterface; use niklesh\HealthCheckBundle\Service\HealthSenderInterface; class Sender implements HealthSenderInterface { /** * @param HealthDataInterface[] $data */ public function send(array $data): void { print "Data sent\n"; } public function getDescription(): string { return 'Sender description'; } public function getName(): string { return 'Sender name'; } } 

Done! Clean the cache and start the server


 bin/console cache:clear bin/console server:start 

Now you can try our team:


 bin/console health:send-info 

We get such a beautiful conclusion:


image


Finally, knock on our route http://127.0.0.1:8000/health and get less beautiful, but also the conclusion:


 [{"name":"Data collector","info":{"status":1,"additional_info":{"some_data":"some_value"}}}] 

That's all! I hope this simple tutorial will help someone to understand the basics of writing bundles for symfony 4.


PS Source code is available here .


')

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


All Articles