Пошаговое создание бандла для Symfony 4

Около года назад наша компания взяла курс на разделение огромного монолита на Magento 1 на микросервисы. Как основу выбрали только вышедшую в релиз Symfony 4. За это время я разработал несколько проектов на этом фреймворке, но особо интересной мне показалась разработка бандлов, переиспользуемых компонентов для Symfony. Под катом пошаговое руководство по разработке HealthCheck бандла для получения статуса/здоровья микросервиса под Syfmony 4.1, в котором я постарался затронуть наиболее интересные и сложные (для меня когда-то) моменты.

В нашей компании этот бандл используется, например, для получения статуса реиндекса продуктов в ElasticSearch — сколько товаров содержится в Elastic с актуальными данными, а сколько требуют индексации.

Создание скелета бандла

В Symfony 3 для генерации скелетов бандлов был удобный бандл, однако в Symfony 4 он более не поддерживается и потому скелет приходится создавать самому. Разработку каждого нового проекта я начинаю с запуска команды

composer create-project symfony/skeleton health-check

Обратите внимание, что Symfony 4 поддерживает PHP 7.1+, соответственно если запустить эту команду на версии ниже, то вы получите скелет проекта на Symfony 3.

Эта команда создаёт новый проект Symfony 4.1 со следующей структурой:

image

В принципе, это не обязательно, поскольку из созданных файлов нам в итоге пригодится не так уж много, но мне удобнее почистить всё не нужное, нежели руками создавать нужное.

composer.json

Следующим шагом будет редактирование composer.json под наши нужды. В первую очередь, нужно изменить тип проекта type на symfony-bundle это поможет Symfony Flex определить при добавлении бандла в проект, что это действительно бандл Symfony, автоматически подключить его и установить рецепт (но об этом позже). Далее, обязательно добавляем поля name и description. name важно ещё и потому, что определяет в какую папку внутри vendor будет помещён бандл.

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

Следующий важный шаг отредактировать раздел autoload, который отвечает за загрузку классов бандла. autoload для рабочего окружения, autoload-dev — для рабочего.

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

Раздел scripts можно удалить. Там содержатся скрипты для сборки ассетов и очистки кэша после выполнения команд composer install и composer update, однако у нас бандл не содержит ни ассеты, ни кэш, поэтому и команды эти бесполезны.

Последним шагом отредактируем разделы require и require-dev. В итоге получаем следующее:

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

Отмечу, что зависимости из require будут установлены при подключении бандла к рабочему проекту.

Запускаем composer update — зависимости установлены.

Чистка не нужного

Итак, из полученных файлов можно смело удалять следующие папки:

  • bin — содержит файл console, необходимый для запуска команд Symfony
  • config — содержит конфигурационные файлы роутинга, подключенных бандлов,
    сервисов и т.д.
  • public — содержит index.php — точка входа в приложение
  • var — тут хранятся логи и cache

Так же удаляем файлы src/Kernel.php, .env, .env.dist
Всё это нам не нужно, поскольку мы разрабатываем бандл, а не приложение.

Создание структуры бандла

Итак, мы добавили необходимые зависимости и вычистили всё не нужное из нашего бандла. Пришло время создавать необходимые файлы и папки для успешного подключения бандла к проекту.

В первую очередь в папке src создадим файл HealthCheckBundle.php с следующим содержимым:

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

Такой класс должен быть в каждом бандле, который вы создаёте. Именно он будет подключаться в файле config/bundles.php основного проекта. Помимо этого он может влиять на "билд" бандла.

Следующий необходимый компонент бандла — это раздел DependencyInjection. Создаём одноимённую папку с 2 файлами:

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

Этот файл отвечает за парсинг и валидацию конфигурации бандла из Yaml или xml файлов. Его мы ещё модицифируем позже.

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

Этот файл отвечает за загрузку конфигурационных файлов бандла, создание и регистрацию "definition" сервисов, загрузку параметров в контейнер и т.д.

И последний на данном этапе шаг — это добавление файла src/Resources/services.yaml Который будет содержать описание сервисов нашего бандла. Пока оставим его пустым.

HealthInterface

Основной задачей нашего бандла будет отдача данных о проекте, в котором он используется. А вот сбор информации — это работа непосредственно самого сервиса, наш бандл может только указать формат информации, которую должен передать ему сервис, и метод, который эту информацию будет получать. В моей реализации все сервисы (а их может быть несколько), которые собирают информацию должны реализовывать интерфейс HealthInterface с 2 методами: getName и getHealthInfo. Последний должен вернуть объект реализующий интерфейс HealthDataInterface.

Для начала создадим интерфейс сущности (entity) данных 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; }

Данные должны содержать целочисленный статус и дополнительную информацию (которая, к слову, может быть и пустой).

Посколько вероятнее всего реализация этого интерфейса будет типична для большинства наследников, я решил добавить её в бандл 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;     } }

И наконец добавим интерфейс для сервисов сбора данных src/Service/HealthInterface.php:

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

Controller

Отдавать данные о проекте будет контроллер в всего одним роутом. Зато этот роут будет одинаков для всех проектов, использующих данный бандл: /health

Однако, задача нашего контроллера не только в том, чтобы отдать данные, но и в том, чтобы вытащить их из сервисов, реализующих HealthInterface, соответственно контроллер должен хранить в себе ссылки на каждый из этих сервисов. За добавление сервисов в контроллер будет отвечать метод addHealthService

Добавим контроллер src/Controller/HealthController.php:

<?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 может выполнять определённые действия с сервисами, реализующими определённый интерфейс. Можно вызвать определённый метод, добавить тэг, однако нельзя взять и проинжектить все такие сервисы в другой сервис (которым является контроллер). Такая задача решается в 4 этапа:

Добавим каждому нашему сервису, реализующему HealthInterface тэг.

Добавим константу TAG в интерфейс:

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

Далее необходимо добавить этот тэг каждому сервису. В случае конфигурации проекта это можно
реализовать в файле config/services.yaml в разделе _instanceof. В нашем случае эта
запись выглядела бы следующим образом:

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

И, в принципе, если возложить заботу о конфигурации бандла на пользователя, это сработает, но на мой взгляд это не правильный подход, бандл сам при добавлении в проект должен правильно подключиться и сконфигурироваться с минимальным вмешательством пользователя. Кто-то возможно вспомнит о том, что у нас же есть свой services.yaml внутри бандла, но нет, он нам не поможет. Эта настройка работает только если находится в файле проекта, а не бандла.
Не знаю, баг это или фича, но сейчас имеем то, что имеем. Поэтому придётся нам внедриться в процесс компиляции бандла.

Переходим в файл src/HealthCheckBundle.php и переопределяем метод build:

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

Теперь каждый класс, который реализует HealthInterface будет отмечен тэгом.

Регистрация контроллера, как сервиса

На следующем шаге нам необходимо будет обратиться к контроллеру, как к сервису, на этапе компиляции бандла. В случае работы с проектом, там все классы по умолчанию регистрируются как сервисы, однако в случае работы с бандлом мы должны явно определять, какие классы будут сервисами, проставлять им аргументы, обозначать будут ли они публичными.

Открываем файл src/Resources/config/services.yaml и добавляем следующее содержимое

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

Мы явно зарегистрировали контроллер как сервис, теперь к нему можно будет обратиться на этапе компиляции.

Добавление сервисов в контроллер.

На этапе компиляции контейнера и бандлов, мы можем оперировать только definition’ами (определениями) сервисов. На данном этапе нам необходимо взять definition HealthController и указать, что после его создания в него необходимо добавить все сервисы, которые отмечены нашим тэгом. За подобные операции в бандлах отвечают классы, реализующие интерфейс
Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface

Создадим такой класс 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)]);         }     } }

Как видно мы сначала с помощью метода findDefinition берём контроллер, далее — все сервисы по тегу и после, в цикле, на каждый найденный сервис добавляем вызов метода addHealthService, куда передаём ссылку на этот сервис.

Использование CompilerPath

Последним шагом будет добавление нашего HealthServicePath в процесс компиляции бандла. Вернёмся в класс HealthCheckBundle и ещё немного изменим метод build. В результате получим:

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

В принципе, на данном этапе наш бандл уже готов к использованию. Он может находить сервисы сбора информации, работать с ними и выдавать ответ при обращении на /health (нужно только добавить настройки роутинга при подключении), однако я решил заложить в него возможность не только отдавать информацию по запросу, но и предусмотреть возможность отправки этой информации куда-либо, например с помощью POST-запроса или через менеджера очередей.

HealthSenderInterface

Данный интерфейс предназначен для описания классов, ответственных за отправку данных куда-либо. Создадим его в 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; }

Как видно, метод send будет каким-либо образом обрабатывать полученный массив данных из всех классов имплементирующих HealthInterface и далее отправлять туда, куда ему нужно.
Методы getDescription и getName нужны просто для отображения информации при запуске консольной команды.

SendDataCommand

Запускать рассылку данных на сторонние ресурсы будет консольная команда SendDataCommand. Её задача собрать данные для рассылки, а дальше вызвать метод send у каждого из сервисов рассылки. Очевидно, что частично эта команда будет повторять логику работы контроллера, но не во всём.

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

Модифицируем HealthServicesPath, пишем добавление сервисов сбора данных в команду.

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

Как видно, команда в конструкторе принимает массив отправителей. В данном случае не получится воспользоваться фишкой автопривязки зависимостей, нам необходимо самим создать и зарегистрировать команду. Только вопрос ещё в том, какие именно сервисы отправителей добавить в эту команду. Будем указывать их id в конфигурации бандла вот так:

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

Наш бандл ещё не умеет обрабатывать подобные конфигурации, научим его. Переходим в 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();         $rootNode = $treeBuilder->root('health_check');         $rootNode             ->children()                 ->arrayNode('senders')                     ->scalarPrototype()->end()                 ->end()             ->end()         ;         return $treeBuilder;     } }

Данный код определяет, что корневым узлом у нас будет узел health_check, который будет содержать ноду-массив senders, которая в свою очередь будет содержать какое-то количество строк. Всё, теперь наш бандл знает, как обработать конфигурацию, что мы обозначили выше. Пришло время зарегистрировать команду. Для этого перейдём в HealthCheckExtension и добавим следующий код:

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

Всё, наша команда определена. Теперь, после добавления бандла в проект, при вызове
bin/console мы увидим список команд, в том числе и нашу: health:send-info, вызвать её можно так же: bin/console health:send-info

Наш бандл готов. Пришло время протестировать его в проекте. Создадим пустой проект:

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

Добавим в него наш свежеиспечённый бандл, для этого добавим в composer.json раздел repositories:

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

И выполним команду:

composer require niklesh/health-check

А ещё, для наиболее быстрого запуска добавим к нашему проекту сервер симфонии:

composer req --dev server

Бандл подключен, Symfony Flex автоматом подключит его в config/bundles.php, а вот для автоматического создания конфигурационных файлов необходимо создавать рецепт. Про рецепты прекрасно расписано в другой статье здесь: https://habr.com/post/345382/ — поэтому расписывать как создавать рецепты и т.д. я тут не буду, да и рецепта для этого бандла пока нет.

Тем не менее конфигурационные файлы нужны, поэтому создадим их ручками:

  • 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'

Теперь необходимо имплементировать классы отправки информации для команды и класс сбора информации

  • src/Service/DataCollector.php

Тут всё предельно просто

<?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

А тут ещё проще

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

Готово! Почистим кэш и запустим сервер

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

Теперь можно испытать нашу команду:

bin/console health:send-info

Получаем такой вот красивый вывод:

image

Наконец стукнемся на наш роут http://127.0.0.1:8000/health и получим менее красивый, но тоже вывод:

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

Вот и всё! Надеюсь этот незамысловатый туториал поможет кому-то разобраться в основах написания бандлов для Symfony 4.

P.S. Исходный код доступен здесь.

FavoriteLoadingДобавить в избранное
Posted in Без рубрики

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *