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:


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 everything that is not necessary than to create the necessary one with my hands.


composer.json


The next step will be editing composer.jsonfor our needs. First of all, you need to change the type of project typeto symfony-bundlehelp 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 nameand description. nameit is also important because it determines in which folder the vendorbundle will be placed inside .


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

The next important step is to edit the section autoloadthat is responsible for loading the bundle classes. autoloadfor the working environment autoload-dev- for the worker.


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

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


The last step is to edit the sections requireand 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 requirewill 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 consoleneeded 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 srccreate a file HealthCheckBundle.phpwith 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.phpmain 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.yamlthat 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 HealthInterfacewith 2 methods: getNameand 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 HealthInterfacetag.


Add a constant TAGto 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.yamlin 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.yamlinside 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.phpand 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 HealthInterfacewill 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.yamland 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 HealthControllerand 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 findDefinitiontake 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 addHealthServiceto which we pass the link to this service.


Using CompilerPath


The final step will be to add ours HealthServicePathto the compilation process of the bundle. Let's go back to the class HealthCheckBundleand 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 sendwill in some way handle the resulting data array from all classes implementing HealthInterfaceand then send it where it needs.
Methods getDescriptionand getNameneed 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 sendof 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.phpand 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_checkthat 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 HealthCheckExtensionand 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/consolewe call, we will see a list of commands, including ours: health:send-infoyou 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.jsonsection 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:


image


Finally, knock on our route http://127.0.0.1:8000/healthand 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.


PS Source code is available here .


Also popular now: