Crea un paquete para Symfony 4 paso a paso

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:


imagen


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 { /** * {@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'); } } 

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 { /** @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)); } } 

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 { /** * @param HealthDataInterface[] $data */ 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; /** @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 , 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 { /** * {@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); } } 

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 --dev server 

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

  • src/Service/Sender.php

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 { /** * @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'; } } 

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:


imagen


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í .

Source: https://habr.com/ru/post/es419451/


All Articles