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, to generate skeletons of bundles was 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 team.
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 everything that is not necessary than to create the necessary one with my hands.
composer.json
The next step will be editing composer.json
for our needs. First of all, you need to change the type of project type
to symfony-bundle
help Symfony Flex determine when adding a bundle to a 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 fields name
and description
. name
it is also important because it determines in which folder the vendor
bundle will be placed inside .
"name": "niklesh/health-check",
"description": "Health check bundle",
The next important step is to edit the section autoload
that is responsible for loading the bundle classes. autoload
for the working environment autoload-dev
- for the worker.
"autoload": {
"psr-4": {
"niklesh\\HealthCheckBundle\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"niklesh\\HealthCheckBundle\\Tests\\": "tests"
}
},
Section scripts
can be deleted. It contains scripts for assembling assets and clearing the cache after executing commands composer install
and composer update
, however, our bundle does not contain assets or cache, therefore these commands are useless.
The last step is to edit the sections require
and require-dev
. 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 will note that dependencies from require
will be installed when the bundle is connected to the working project.
We start composer update
- dependences are established.
Cleaning unnecessary
So, from the received files you can safely delete the following folders:
- bin - contains the file
console
needed to run symfony commands - config - contains configuration files for routing, connected bundles,
services, etc. - public - contains
index.php
- application entry point - var - logs are stored here and
cache
Just remove the files src/Kernel.php
, .env
, .env.dist
All of this we do not need, because we design bundle and not an app.
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 folder src
create a file HealthCheckBundle.php
with the following contents:
<?phpnamespaceniklesh\HealthCheckBundle;
useSymfony\Component\HttpKernel\Bundle\Bundle;
classHealthCheckBundleextendsBundle{
}
This class should be in every bundle that you create. That he will be connected to the config/bundles.php
main project file . In addition, he can influence the "build" of the bundle.
The next necessary component of the bundle is a section DependencyInjection
. Create a folder with 2 files of the same name:
src/DependencyInjection/Configuration.php
<?phpnamespaceniklesh\HealthCheckBundle\DependencyInjection;
useSymfony\Component\Config\Definition\Builder\TreeBuilder;
useSymfony\Component\Config\Definition\ConfigurationInterface;
classConfigurationimplementsConfigurationInterface{
publicfunctiongetConfigTreeBuilder(){
$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
<?phpnamespaceniklesh\HealthCheckBundle\DependencyInjection;
useSymfony\Component\Config\FileLocator;
useSymfony\Component\DependencyInjection\ContainerBuilder;
useSymfony\Component\HttpKernel\DependencyInjection\Extension;
useSymfony\Component\DependencyInjection\Loader;
classHealthCheckExtensionextendsExtension{
/**
* {@inheritdoc}
*/publicfunctionload(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 a file src/Resources/services.yaml
that 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 an interface HealthInterface
with 2 methods: getName
and getHealthInfo
. The latter should return an object implementing the interface HealthDataInterface
.
First, let's create an interface for the data entity src/Entity/HealthDataInterface.php
:
<?phpnamespaceniklesh\HealthCheckBundle\Entity;
interfaceHealthDataInterface{
publicconst STATUS_OK = 1;
publicconst STATUS_WARNING = 2;
publicconst STATUS_DANGER = 3;
publicconst STATUS_CRITICAL = 4;
publicfunctiongetStatus(): int;
publicfunctiongetAdditionalInfo(): array;
}
The data must contain integer status and additional information (which, by the way, may be empty).
Since the implementation of this interface will most likely be typical for most of the heirs, I decided to add it to the bundle src/Entity/CommonHealthData.php
:
<?phpnamespaceniklesh\HealthCheckBundle\Entity;
classCommonHealthDataimplementsHealthDataInterface{
private $status;
private $additionalInfo = [];
publicfunction__construct(int $status){
$this->status = $status;
}
publicfunctionsetStatus(int $status){
$this->status = $status;
}
publicfunctionsetAdditionalInfo(array $additionalInfo){
$this->additionalInfo = $additionalInfo;
}
publicfunctiongetStatus(): int{
return$this->status;
}
publicfunctiongetAdditionalInfo(): array{
return$this->additionalInfo;
}
}
Finally, add an interface for data collection services src/Service/HealthInterface.php
:
<?phpnamespaceniklesh\HealthCheckBundle\Service;
useniklesh\HealthCheckBundle\Entity\HealthDataInterface;
interfaceHealthInterface{
publicfunctiongetName(): string;
publicfunctiongetHealthInfo(): HealthDataInterface;
}
Controller
To give 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 that implement HealthInterface
, respectively, the controller must keep references to each of these services. For adding services to the controller will answer methodaddHealthService
Add a controller src/Controller/HealthController.php
:
<?phpnamespaceniklesh\HealthCheckBundle\Controller;
useniklesh\HealthCheckBundle\Service\HealthInterface;
useSymfony\Bundle\FrameworkBundle\Controller\AbstractController;
useSymfony\Component\HttpFoundation\JsonResponse;
useSymfony\Component\Routing\Annotation\Route;
classHealthControllerextendsAbstractController{
/** @var HealthInterface[] */private $healthServices = [];
publicfunctionaddHealthService(HealthInterface $healthService){
$this->healthServices[] = $healthService;
}
/**
* @Route("/health")
* @return JsonResponse
*/publicfunctiongetHealth(): 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 a constant TAG
to the interface:
interfaceHealthInterface{
publicconst TAG = 'health.service';
}
Next you need to add this tag to each service. In the case of project configuration, this can be
implemented in the file config/services.yaml
in the section _instanceof
. In our case, this
entry 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 file src/HealthCheckBundle.php
and override the method build
:
<?phpnamespaceniklesh\HealthCheckBundle;
useniklesh\HealthCheckBundle\Service\HealthInterface;
useSymfony\Component\DependencyInjection\ContainerBuilder;
useSymfony\Component\HttpKernel\Bundle\Bundle;
classHealthCheckBundleextendsBundle{
publicfunctionbuild(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 define which classes will be services, put arguments to them, whether they will be public.
Open the file src/Resources/config/services.yaml
and add the following contents.
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 compilation of the container and bundles, we can operate only with the definitions of the services. At this stage, we need to take the definition 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 this class src/DependencyInjection/Compiler/HealthServicePath.php
:
<?phpnamespaceniklesh\HealthCheckBundle\DependencyInjection\Compiler;
useniklesh\HealthCheckBundle\Controller\HealthController;
useniklesh\HealthCheckBundle\Service\HealthInterface;
useSymfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
useSymfony\Component\DependencyInjection\ContainerBuilder;
useSymfony\Component\DependencyInjection\Reference;
classHealthServicesPathimplementsCompilerPassInterface{
publicfunctionprocess(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 findDefinition
take the controller using the method , then all the services for the tag and then, in a loop, for each found service we add a method call addHealthService
to which we pass the link to this service.
Using CompilerPath
The final step will be to add ours HealthServicePath
to the compilation process of the bundle. Let's go back to the class HealthCheckBundle
and change the method a little more build
. As a result, we get:
<?phpnamespaceniklesh\HealthCheckBundle;
useniklesh\HealthCheckBundle\DependencyInjection\Compiler\HealthServicesPath;
useniklesh\HealthCheckBundle\Service\HealthInterface;
useSymfony\Component\DependencyInjection\ContainerBuilder;
useSymfony\Component\HttpKernel\Bundle\Bundle;
classHealthCheckBundleextendsBundle{
publicfunctionbuild(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 him the opportunity not only to send information on request, but also to provide the possibility of sending this information somewhere 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 insrc/Service/HealthSenderInterface
<?phpnamespaceniklesh\HealthCheckBundle\Service;
useniklesh\HealthCheckBundle\Entity\HealthDataInterface;
interfaceHealthSenderInterface{
/**
* @param HealthDataInterface[] $data
*/publicfunctionsend(array $data): void;
publicfunctiongetDescription(): string;
publicfunctiongetName(): string;
}
As you can see, the method send
will in some way handle the resulting data array from all classes implementing HealthInterface
and then send it where it needs.
Methods getDescription
and getName
need just to display information when you run the console command.
SendDataCommand
To start sending data to third-party resources will be a console command SendDataCommand
. Its task is to collect data for mailing, and then call the method send
of each of the mailing services. Obviously, this command will partially repeat the logic of the controller, but not in everything.
<?phpnamespaceniklesh\HealthCheckBundle\Command;
useniklesh\HealthCheckBundle\Entity\HealthDataInterface;
useniklesh\HealthCheckBundle\Service\HealthInterface;
useniklesh\HealthCheckBundle\Service\HealthSenderInterface;
useSymfony\Component\Console\Command\Command;
useSymfony\Component\Console\Input\InputInterface;
useSymfony\Component\Console\Output\OutputInterface;
useSymfony\Component\Console\Style\SymfonyStyle;
useThrowable;
classSendDataCommandextendsCommand{
publicconst COMMAND_NAME = 'health:send-info';
private $senders;
/** @var HealthInterface[] */private $healthServices;
/** @var SymfonyStyle */private $io;
publicfunction__construct(HealthSenderInterface... $senders){
parent::__construct(self::COMMAND_NAME);
$this->senders = $senders;
}
publicfunctionaddHealthService(HealthInterface $healthService){
$this->healthServices[] = $healthService;
}
protectedfunctionconfigure(){
parent::configure();
$this->setDescription('Send health data by senders');
}
protectedfunctioninitialize(InputInterface $input, OutputInterface $output){
parent::initialize($input, $output);
$this->io = new SymfonyStyle($input, $output);
}
protectedfunctionexecute(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());
}
}
privatefunctionoutputInfo(HealthSenderInterface $sender){
if ($name = $sender->getName()) {
$this->io->writeln($name);
}
if ($description = $sender->getDescription()) {
$this->io->writeln($description);
}
}
}
Modify HealthServicesPath
, write the addition of data collection services to the team.
<?phpnamespaceniklesh\HealthCheckBundle\DependencyInjection\Compiler;
useniklesh\HealthCheckBundle\Command\SendDataCommand;
useniklesh\HealthCheckBundle\Controller\HealthController;
useniklesh\HealthCheckBundle\Service\HealthInterface;
useSymfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
useSymfony\Component\DependencyInjection\ContainerBuilder;
useSymfony\Component\DependencyInjection\Reference;
classHealthServicesPathimplementsCompilerPassInterface{
publicfunctionprocess(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:
<?phpnamespaceniklesh\HealthCheckBundle\DependencyInjection;
useSymfony\Component\Config\Definition\Builder\TreeBuilder;
useSymfony\Component\Config\Definition\ConfigurationInterface;
classConfigurationimplementsConfigurationInterface{
publicfunctiongetConfigTreeBuilder(){
$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 node health_check
that will contain an array node senders
, 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:
<?phpnamespaceniklesh\HealthCheckBundle\DependencyInjection;
useniklesh\HealthCheckBundle\Command\SendDataCommand;
useSymfony\Component\Config\FileLocator;
useSymfony\Component\DependencyInjection\ContainerBuilder;
useSymfony\Component\DependencyInjection\Definition;
useSymfony\Component\HttpKernel\DependencyInjection\Extension;
useSymfony\Component\DependencyInjection\Loader;
useSymfony\Component\DependencyInjection\Reference;
classHealthCheckExtensionextendsExtension{
/**
* {@inheritdoc}
*/publicfunctionload(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 bin/console
we call, we will see a list of commands, including ours: health:send-info
you can call it the same way: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 freshly baked bundle to it, for this we add to the composer.json
section repositories
:
"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, the 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
<?phpnamespaceApp\Service;
useniklesh\HealthCheckBundle\Entity\CommonHealthData;
useniklesh\HealthCheckBundle\Entity\HealthDataInterface;
useniklesh\HealthCheckBundle\Service\HealthInterface;
classDataCollectorimplementsHealthInterface{
publicfunctiongetName(): string{
return'Data collector';
}
publicfunctiongetHealthInfo(): HealthDataInterface{
$data = new CommonHealthData(HealthDataInterface::STATUS_OK);
$data->setAdditionalInfo(['some_data' => 'some_value']);
return $data;
}
}
src/Service/Sender.php
And it's even easier
<?phpnamespaceApp\Service;
useniklesh\HealthCheckBundle\Entity\HealthDataInterface;
useniklesh\HealthCheckBundle\Service\HealthSenderInterface;
classSenderimplementsHealthSenderInterface{
/**
* @param HealthDataInterface[] $data
*/publicfunctionsend(array $data): void{
print"Data sent\n";
}
publicfunctiongetDescription(): string{
return'Sender description';
}
publicfunctiongetName(): string{
return'Sender name';
}
}
Done! Clean the cache and start the server
bin/console cache:clearbin/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 uncomplicated tutorial will help someone understand the basics of writing bundles for Symfony 4.