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.
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:
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.
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.
So, from the received files you can safely delete the following folders:
console
file needed to run symfony commandsindex.php
- entry point to the applicationcache
are stored hereAlso delete the files src/Kernel.php
, .env
, .env.dist
We do not need all this, since we are developing a bundle, not an application.
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:
src/DependencyInjection/Configuration.php
<?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.
src/DependencyInjection/HealthCheckExtension.php
<?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.
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; }
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)); } }
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.
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.
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 callingbin/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:
config/routes/niklesh_health.yaml
health_check: resource: "@HealthCheckBundle/Controller/HealthController.php" prefix: / type: annotation
config/packages/hiklesh_health.yaml
health_check: senders: - 'App\Service\Sender'
Now you need to implement the send classes for the team and the collection class
src/Service/DataCollector.php
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; } }
src/Service/Sender.php
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:
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.
Source: https://habr.com/ru/post/419451/
All Articles