Hace aproximadamente un año, nuestra compañía se dirigió a la separación del enorme monolito en Magento 1 en microservicios. Como base, eligieron solo Symfony 4, que se lanzó en el lanzamiento. Durante este tiempo desarrollé varios proyectos en este marco, pero especialmente me pareció interesante desarrollar paquetes, componentes reutilizados para Symfony. Under the cat, una guía paso a paso sobre el desarrollo del paquete HealthCheck para obtener el estado / salud de un microservicio bajo Syfmony 4.1, en el que intenté tocar los momentos más interesantes y complejos (para mí una vez).
En nuestra empresa, este paquete se usa, por ejemplo, para obtener el estado de una reindexación de producto en ElasticSearch: cuántos productos contiene Elastic con datos actuales y cuántos requieren indexación.
Crea un esqueleto de paquete
En Symfony 3, había un paquete conveniente para generar esqueletos de paquetes, pero en Symfony 4 ya no es compatible y, por lo tanto, debe crear el esqueleto usted mismo. Comienzo el desarrollo de cada nuevo proyecto lanzando un equipo
composer create-project symfony/skeleton health-check
Tenga en cuenta que Symfony 4 es compatible con PHP 7.1+, por lo que si ejecuta este comando en la versión siguiente, obtendrá el esqueleto del proyecto para Symfony 3.
Este comando crea un nuevo proyecto de Symfony 4.1 con la siguiente estructura:

En principio, esto no es necesario, debido a los archivos creados que no necesitamos tanto al final, pero es más conveniente para mí limpiar todo lo que no es necesario que crear lo necesario con mis manos.
composer.json
El siguiente paso es editar composer.json
a nuestras necesidades. En primer lugar, debe cambiar el tipo del tipo de proyecto a symfony-bundle
esto ayudará a Symfony Flex a determinar al agregar un paquete al proyecto que realmente es un paquete de Symfony, conectarlo automáticamente e instalar la receta (pero más sobre eso más adelante). A continuación, asegúrese de agregar los campos de name
y description
. name
también es importante porque determina en qué carpeta dentro del vendor
se colocará vendor
paquete.
"name": "niklesh/health-check", "description": "Health check bundle",
El siguiente paso importante es editar la sección de autoload
, que es responsable de cargar las clases de paquete. autoload
para el entorno de trabajo, autoload-dev
para el entorno de trabajo.
"autoload": { "psr-4": { "niklesh\\HealthCheckBundle\\": "src" } }, "autoload-dev": { "psr-4": { "niklesh\\HealthCheckBundle\\Tests\\": "tests" } },
La sección de scripts
se puede eliminar. Contiene scripts para ensamblar activos y borrar el caché después de ejecutar los comandos de composer install
y composer update
, sin embargo, nuestro paquete no contiene ningún activo o caché, por lo tanto, estos comandos son inútiles.
El último paso es editar las secciones require
y require-dev
. Como resultado, obtenemos lo siguiente:
"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" }
Observo que las dependencias de require
se instalarán cuando el paquete se conecte al borrador de trabajo.
Comenzamos la composer update
: se instalan las dependencias.
Limpieza innecesaria
Entonces, de los archivos recibidos puede eliminar de forma segura las siguientes carpetas:
- bin: contiene el archivo de
console
necesario para ejecutar comandos de Symfony - config: contiene archivos de configuración para enrutamiento, paquetes conectados,
servicios, etc. - public - contiene
index.php
- punto de entrada a la aplicación - var: los registros y el
cache
se almacenan aquí
También .env.dist
src/Kernel.php
, .env
, .env.dist
No necesitamos todo esto porque estamos desarrollando un paquete, no una aplicación.
Crear una estructura de paquete
Entonces, agregamos las dependencias necesarias y limpiamos todo lo que no era necesario de nuestro paquete. Es hora de crear los archivos y carpetas necesarios para conectar con éxito el paquete al proyecto.
En primer lugar, en la carpeta src
, cree el archivo HealthCheckBundle.php
con el siguiente contenido:
<?php namespace niklesh\HealthCheckBundle; use Symfony\Component\HttpKernel\Bundle\Bundle; class HealthCheckBundle extends Bundle { }
Tal clase debería estar en cada paquete que cree. Será config/bundles.php
en el config/bundles.php
proyecto principal. Además, puede influir en la "construcción" del paquete.
El siguiente componente del paquete necesario es la sección DependencyInjection
. Cree la carpeta del mismo nombre con 2 archivos:
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; } }
Este archivo es responsable de analizar y validar la configuración del paquete a partir de archivos Yaml o xml. Lo modificaremos más tarde.
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 { 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'); } }
Este archivo es responsable de cargar archivos de configuración de paquetes, crear y registrar servicios de "definición", cargar parámetros en un contenedor, etc.
Y el último paso en esta etapa es agregar el archivo src/Resources/services.yaml
, que contendrá una descripción de los servicios de nuestro paquete. Déjalo en blanco por ahora.
Interfaz de salud
La tarea principal de nuestro paquete será devolver datos sobre el proyecto en el que se utiliza. Pero la recopilación de información es el trabajo del servicio en sí, nuestro paquete solo puede indicar el formato de la información que el servicio debe transmitirle, y el método que recibirá esta información. En mi implementación, todos los servicios (y puede haber varios) que recopilan información deben implementar la interfaz HealthInterface
con 2 métodos: getName
y getHealthInfo
. Este último debe devolver un objeto que implemente la interfaz HealthDataInterface
.
Primero, cree la interfaz de datos 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; }
Los datos deben contener un estado entero e información adicional (que, por cierto, puede estar vacía).
Como lo más probable es que la implementación de esta interfaz sea típica de la mayoría de los descendientes, decidí agregarla al 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; } }
Finalmente, agregue la interfaz para los src/Service/HealthInterface.php
datos src/Service/HealthInterface.php
:
<?php namespace niklesh\HealthCheckBundle\Service; use niklesh\HealthCheckBundle\Entity\HealthDataInterface; interface HealthInterface { public function getName(): string; public function getHealthInfo(): HealthDataInterface; }
Controlador
El controlador entregará datos sobre el proyecto en una sola ruta. Pero esta ruta será la misma para todos los proyectos que usen este paquete: /health
Sin embargo, la tarea de nuestro controlador no es solo proporcionar datos, sino también sacarlos de los servicios que implementan HealthInterface
; en consecuencia, el controlador debe almacenar enlaces a cada uno de estos servicios. El método addHealthService
será responsable de agregar servicios al controlador
Agregue el controlador 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 { private $healthServices = []; public function addHealthService(HealthInterface $healthService) { $this->healthServices[] = $healthService; } 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)); } }
Compilación
Symfony puede realizar ciertas acciones con servicios que implementan una interfaz específica. Puede llamar a un método específico, agregar una etiqueta, pero no puede tomar e inyectar todos esos servicios en otro servicio (que es el controlador). Este problema se resuelve en 4 etapas:
HealthInterface
a cada uno de nuestros HealthInterface
implementación de la etiqueta HealthInterface
.
Agregue la constante TAG
a la interfaz:
interface HealthInterface { public const TAG = 'health.service'; }
A continuación, debe agregar esta etiqueta a cada servicio. En el caso de una configuración de proyecto, esto puede ser
implementar en el archivo config/services.yaml
en la sección _instanceof
. En nuestro caso, esto
la entrada se vería así:
serivces: _instanceof: niklesh\HealthCheckBundle\Service\HealthInterface: tags: - !php/const niklesh\HealthCheckBundle\Service\HealthInterface::TAG
Y, en principio, si confía la configuración del paquete al usuario, funcionará, pero en mi opinión este no es el enfoque correcto, el paquete en sí debe estar conectado correctamente y configurado con una mínima intervención del usuario cuando se agrega al proyecto. Alguien puede recordar que tenemos nuestros propios services.yaml
Yaml dentro del paquete, pero no, no nos ayudará. Esta configuración solo funciona si está en el archivo del proyecto, no en el paquete.
No sé si esto es un error o una función, pero ahora tenemos lo que tenemos. Por lo tanto, tendremos que infiltrarnos en el proceso de compilación del paquete.
Vaya al src/HealthCheckBundle.php
y redefina el método de 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); } }
Ahora cada clase que implementa HealthInterface
será etiquetada.
Registrar el controlador como un servicio
En el siguiente paso, necesitaremos contactar al controlador como un servicio, en la etapa de compilación del paquete. En el caso de trabajar con el proyecto, todas las clases están registradas como servicios de forma predeterminada, pero en el caso de trabajar con el paquete debemos determinar explícitamente qué clases serán servicios, presentar argumentos para ellos e indicar si serán públicos.
Abra el archivo src/Resources/config/services.yaml
y agregue los siguientes contenidos
services: niklesh\HealthCheckBundle\Controller\HealthController: autoconfigure: true
Registramos explícitamente el controlador como un servicio, ahora se puede acceder en la etapa de compilación.
Agregar servicios al controlador.
En la etapa de compilación del contenedor y los paquetes, solo podemos operar con definiciones de servicio. En esta etapa, necesitamos tomar la definición del HealthController
e indicar que después de su creación es necesario agregarle todos los servicios que están marcados con nuestra etiqueta. Para tales operaciones en paquetes, las clases que implementan la interfaz son responsables
Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface
Cree la siguiente 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)]); } } }
Como puede ver, primero usamos el método findDefinition
tomar el controlador, luego, todos los servicios por etiqueta y luego, en un bucle, agregamos una llamada al método addHealthService
a cada servicio encontrado, donde pasamos el enlace a este servicio.
Usando CompilerPath
El último paso es agregar nuestro HealthServicePath
al proceso de compilación del paquete. Volvamos a la clase HealthCheckBundle
y cambiemos el método de build
un poco más. Como resultado, obtenemos:
<?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); } }
Básicamente, en esta etapa nuestro paquete está listo para usar. Puede encontrar servicios de recopilación de información, trabajar con ellos y dar una respuesta al contactar /health
(solo necesita agregar la configuración de enrutamiento al conectarse), pero decidí ponerle la capacidad no solo de enviar información a pedido, sino también de enviar esta información a o, por ejemplo, utilizando una solicitud POST o mediante un gestor de colas.
HealthSenderInterface
Esta interfaz está destinada a describir las clases responsables de enviar datos a alguna parte. Créelo en src/Service/HealthSenderInterface
<?php namespace niklesh\HealthCheckBundle\Service; use niklesh\HealthCheckBundle\Entity\HealthDataInterface; interface HealthSenderInterface { public function send(array $data): void; public function getDescription(): string; public function getName(): string; }
Como puede ver, el método de send
procesará de alguna manera la matriz de datos recibida de todas las clases que implementan HealthInterface
y luego la enviará a donde sea necesario.
Los getName
getDescription
y getName
son necesarios simplemente para mostrar información cuando se ejecuta un comando de consola.
Senddatacommand
Comenzar a enviar datos a recursos de terceros será el comando de consola SendDataCommand
. Su tarea es recopilar datos para su distribución y luego llamar al método de send
en cada uno de los servicios de distribución. Obviamente, este comando repetirá parcialmente la lógica del controlador, pero no en todo.
<?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; private $healthServices; 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
, escribimos la adición de servicios de recopilación de datos al equipo.
<?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)]); } } }
Como puede ver, el comando en el constructor acepta una matriz de remitentes. En este caso, no podrá utilizar la función de enlace automático de dependencia; necesitamos crear y registrar un equipo nosotros mismos. La única pregunta es qué servicios de remitente agregar a este comando. Indicaremos su identificación en la configuración del paquete de esta manera:
health_check: senders: - '@sender.service1' - '@sender.service2'
Nuestro paquete aún no sabe cómo manejar tales configuraciones, lo enseñaremos. Vaya a Configuration.php
y agregue el árbol de configuración:
<?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; } }
Este código determina que el nodo raíz será el nodo health_check
, que contendrá el senders
matriz de senders
, que a su vez contendrá un cierto número de líneas. Eso es todo, ahora nuestro paquete sabe cómo manejar la configuración, que describimos anteriormente. Es hora de registrar el equipo. Para hacer esto, vaya a HealthCheckExtension
y agregue el siguiente código:
<?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 { 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');
Eso es todo, nuestro equipo está decidido. Ahora, después de agregar el paquete al proyecto, cuando se llama
bin/console
veremos una lista de comandos, incluido el nuestro: health:send-info
, puede llamarlo de la misma manera: bin/console health:send-info
Nuestro paquete está listo. Es hora de probarlo en el proyecto. Crea un proyecto vacío:
composer create-project symfony/skeleton health-test-project
Agregue nuestro paquete recién horneado, para esto agregamos la sección de repositories
en composer.json
:
"repositories": [ { "type": "vcs", "url": "https://github.com/HEKET313/health-check" } ]
Y ejecuta el comando:
composer require niklesh/health-check
Y también, para el inicio más rápido, agregue un servidor sinfónico a nuestro proyecto:
composer req
El paquete está conectado, Symfony Flex lo conecta automáticamente a config/bundles.php
, pero para crear automáticamente archivos de configuración, debe crear una receta. Acerca de las recetas está bellamente pintado en otro artículo aquí: https://habr.com/post/345382/ , así que pinta cómo crear recetas, etc. No estaré aquí, y todavía no hay una receta para este paquete.
Sin embargo, se necesitan archivos de configuración, así que créelos con identificadores:
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'
Ahora debe implementar las clases de envío de información para el equipo y la clase de recopilación de información.
src/Service/DataCollector.php
Aquí todo es extremadamente simple.
<?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; } }
Y aquí es aún más fácil.
<?php namespace App\Service; use niklesh\HealthCheckBundle\Entity\HealthDataInterface; use niklesh\HealthCheckBundle\Service\HealthSenderInterface; class Sender implements HealthSenderInterface { public function send(array $data): void { print "Data sent\n"; } public function getDescription(): string { return 'Sender description'; } public function getName(): string { return 'Sender name'; } }
Hecho Limpia el caché e inicia el servidor
bin/console cache:clear bin/console server:start
Ahora puedes probar nuestro equipo:
bin/console health:send-info
Tenemos una conclusión tan hermosa:

Finalmente, tocamos nuestra ruta http://127.0.0.1:8000/health
y obtenemos una conclusión menos hermosa, pero también:
[{"name":"Data collector","info":{"status":1,"additional_info":{"some_data":"some_value"}}}]
Eso es todo! Espero que este sencillo tutorial ayude a alguien a comprender los conceptos básicos de la escritura de paquetes para Symfony 4.
El código fuente de PS está disponible aquí .