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