Около года назад наша компания взяла курс на разделение огромного монолита на 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 со следующей структурой:

В принципе, это не обязательно, поскольку из созданных файлов нам в итоге пригодится не так уж много, но мне удобнее почистить всё не нужное, нежели руками создавать нужное.
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
Получаем такой вот красивый вывод:

Наконец стукнемся на наш роут http://127.0.0.1:8000/health и получим менее красивый, но тоже вывод:
[{"name":"Data collector","info":{"status":1,"additional_info":{"some_data":"some_value"}}}]
Вот и всё! Надеюсь этот незамысловатый туториал поможет кому-то разобраться в основах написания бандлов для Symfony 4.
