Crie um pacote para o Symfony 4 passo a passo

Há cerca de um ano, nossa empresa seguiu para a separação do enorme monólito do Magento 1 em microsserviços. Como base, eles escolheram apenas o Symfony 4. O lançamento foi lançado na versão 2. Durante esse período, desenvolvi vários projetos nessa estrutura, mas achei especialmente interessante desenvolver bundles e componentes reutilizados para o Symfony. Sob o gato, um guia passo a passo sobre o desenvolvimento do pacote HealthCheck para obter o status / integridade de um microsserviço sob o Syfmony 4.1, no qual tentei abordar os momentos mais interessantes e complexos (para mim uma vez).


Em nossa empresa, esse pacote é usado, por exemplo, para obter o status de uma reindexação de produtos no ElasticSearch - quantos produtos estão contidos no Elastic com dados atuais e quantos exigem indexação.


Criar um esqueleto de pacote


No Symfony 3, havia um pacote conveniente para gerar esqueletos de pacote, mas no Symfony 4 ele não é mais suportado e, portanto, você deve criar o esqueleto. Começo o desenvolvimento de cada novo projeto lançando uma equipe


composer create-project symfony/skeleton health-check 

Observe que o Symfony 4 suporta PHP 7.1+, portanto, se você executar este comando na versão abaixo, obterá o esqueleto do projeto para o Symfony 3.


Este comando cria um novo projeto do Symfony 4.1 com a seguinte estrutura:


imagem


Em princípio, isso não é necessário, por causa dos arquivos criados, no final, não precisamos muito, mas é mais conveniente limpar tudo o que não é necessário do que criar o necessário com as mãos.


compositer.json


O próximo passo é editar o composer.json com nossas necessidades. Primeiro, você precisa alterar o tipo do tipo de projeto para symfony-bundle isso ajudará o Symfony Flex a determinar ao adicionar um pacote ao projeto que ele realmente é um pacote Symfony, conectá-lo automaticamente e instalar a receita (mas mais sobre isso posteriormente). Em seguida, certifique-se de adicionar os campos de name e description . name também é importante porque determina em qual pasta do vendor pacote será colocado.


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

A próxima etapa importante é editar a seção de autoload , responsável por carregar as classes de pacote configurável. autoload para o ambiente de trabalho, autoload-dev para o ambiente de trabalho.


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

A seção de scripts pode ser excluída. Ele contém scripts para montagem de ativos e limpeza do cache após a execução dos comandos de composer install e composer update , no entanto, nosso pacote configurável não contém nenhum ativo ou cache, portanto esses comandos são inúteis.


A última etapa é editar as seções require e require-dev . Como resultado, obtemos o seguinte:


 "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 as dependências de require serão instaladas quando o pacote configurável estiver conectado ao rascunho de trabalho.


Iniciamos a composer update - as dependências estão instaladas.


Limpeza desnecessária


Portanto, a partir dos arquivos recebidos, você pode excluir com segurança as seguintes pastas:


  • bin - contém o arquivo do console necessário para executar os comandos do Symfony
  • config - contém arquivos de configuração para roteamento, pacotes configuráveis ​​conectados,
    serviços, etc.
  • public - contém index.php - ponto de entrada do aplicativo
  • var - logs e cache são armazenados aqui

Também .env.dist src/Kernel.php , .env , .env.dist
Não precisamos de tudo isso porque estamos desenvolvendo um pacote, não um aplicativo.


Criando uma estrutura de pacote configurável


Portanto, adicionamos as dependências necessárias e limpamos tudo o que não era necessário em nosso pacote. É hora de criar os arquivos e pastas necessários para conectar com êxito o pacote ao projeto.


Primeiro, na pasta src , crie o arquivo HealthCheckBundle.php com o seguinte conteúdo:


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

Essa classe deve estar em todos os pacotes que você criar. Ele será config/bundles.php no config/bundles.php projeto principal. Além disso, ele pode influenciar a "construção" do pacote.


O próximo componente do pacote necessário é a seção DependencyInjection . Crie a pasta com o mesmo nome com 2 arquivos:


  • 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 arquivo é responsável por analisar e validar a configuração do pacote configurável a partir de arquivos Yaml ou xml. Nós iremos modificá-lo mais 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'); } } 

Esse arquivo é responsável por carregar arquivos de configuração de pacote configurável, criar e registrar serviços de "definição", carregar parâmetros em um contêiner etc.


E a última etapa nesse estágio é adicionar o arquivo src/Resources/services.yaml , que conterá uma descrição dos serviços de nosso pacote configurável. Deixe em branco por enquanto.


HealthInterface


A principal tarefa do nosso pacote configurável será retornar dados sobre o projeto no qual ele é usado. Mas a coleta de informações é obra do próprio serviço, nosso pacote configurável pode indicar apenas o formato das informações que o serviço deve transmitir a ele e o método que receberá essas informações. Na minha implementação, todos os serviços (e pode haver vários) que coletam informações devem implementar a interface HealthInterface com 2 métodos: getName e getHealthInfo . O último deve retornar um objeto que implementa a interface HealthDataInterface .


Primeiro, crie a interface de dados 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; } 

Os dados devem conter um status inteiro e informações adicionais (que, a propósito, podem estar vazias).


Como provavelmente a implementação dessa interface será típica da maioria dos descendentes, decidi adicioná-la ao 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; } } 

Por fim, adicione a interface para os serviços de coleta de dados 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


O controlador fornecerá dados sobre o projeto em apenas uma rota. Mas essa rota será a mesma para todos os projetos que usam este pacote: /health


No entanto, a tarefa do nosso controlador não é apenas fornecer dados, mas também tirá-los dos serviços que implementam o HealthInterface ; portanto, o controlador deve armazenar links para cada um desses serviços. O método addHealthService será responsável por adicionar serviços ao controlador


Adicione o 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)); } } 

Compilação


O Symfony pode executar determinadas ações com serviços que implementam uma interface específica. Você pode chamar um método específico, adicionar uma tag, mas não pode usar e injetar todos esses serviços em outro serviço (que é o controlador). Este problema é resolvido em 4 etapas:


Adicionamos a cada um de nossos HealthInterface implementação da tag HealthInterface .


Adicione a constante TAG à interface:


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

Em seguida, você precisa adicionar essa tag a cada serviço. No caso da configuração do projeto, isso pode ser
implementar no arquivo config/services.yaml na seção _instanceof . No nosso caso, isso
a entrada ficaria assim:


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

E, em princípio, se você confiar a configuração do pacote configurável ao usuário, ele funcionará, mas, na minha opinião, essa não é a abordagem correta, o pacote configurável em si deve ser conectado corretamente e configurado com o mínimo de intervenção do usuário quando adicionado ao projeto. Alguém pode se lembrar de que temos nossos próprios services.yaml Yaml dentro do pacote, mas não, isso não nos ajudará. Essa configuração funciona apenas se estiver no arquivo do projeto, não no pacote.
Não sei se isso é um bug ou um recurso, mas agora temos o que temos. Portanto, teremos que nos infiltrar no processo de compilação do pacote.


Vá para o src/HealthCheckBundle.php e redefina o 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); } } 

Agora todas as classes que implementam HealthInterface serão marcadas.


Registrando o Controlador como um Serviço


Na próxima etapa, precisaremos entrar em contato com o controlador como um serviço, na fase de compilação do pacote. No caso de trabalhar com o projeto, todas as classes são registradas como serviços por padrão, mas no caso de trabalhar com o pacote configurável, devemos determinar explicitamente quais classes serão serviços, colocar argumentos para elas e indicar se serão públicas.


Abra o arquivo src/Resources/config/services.yaml e adicione o seguinte conteúdo


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

Registramos explicitamente o controlador como um serviço, agora ele pode ser acessado no estágio de compilação.


Adicionando serviços ao controlador.


Na fase de compilação do contêiner e dos pacotes configuráveis, só podemos operar com definições de serviço. Nesse estágio, precisamos tomar a definição do HealthController e indicar que após a sua criação, é necessário adicionar a ele todos os serviços marcados com a nossa tag. Por essas operações em pacotes configuráveis, as classes que implementam a interface são responsáveis
Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface


Crie a seguinte 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 você pode ver, primeiro pegamos o controlador usando o método findDefinition , então - todos os serviços por tag e, em um loop, adicionamos uma chamada ao método addHealthService a cada serviço encontrado, onde passamos o link para este serviço.


Usando CompilerPath


A etapa final é adicionar nosso HealthServicePath ao processo de compilação do pacote. Vamos voltar à classe HealthCheckBundle e alterar o método de build um pouco mais. Como resultado, obtemos:


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

Basicamente, nesta fase, nosso pacote está pronto para uso. Ele pode encontrar serviços de coleta de informações, trabalhar com eles e fornecer uma resposta ao entrar em contato com o estado de /health (você só precisa adicionar configurações de roteamento ao se conectar), mas eu decidi colocar nele a capacidade não apenas de enviar informações sob solicitação, mas também de enviar essas informações para ou, por exemplo, usando uma solicitação POST ou através de um gerenciador de filas.


HealthSenderInterface


Essa interface tem como objetivo descrever as classes responsáveis ​​pelo envio de dados para algum lugar. Crie-o em 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 você pode ver, o método send processará de alguma forma a matriz de dados recebidos de todas as classes que implementam HealthInterface e, em seguida, a envia para onde precisa.
Os getName getDescription e getName são necessários simplesmente para exibir informações ao executar um comando do console.


Senddatacommand


Iniciar o envio de dados para recursos de terceiros será o comando do console SendDataCommand . Sua tarefa é coletar dados para distribuição e, em seguida, chamar o método send em cada um dos serviços de distribuição. Obviamente, este comando repetirá parcialmente a lógica do controlador, mas não em tudo.


 <?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 , escrevemos a adição de serviços de coleta de dados à equipe.


 <?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 você pode ver, o comando no construtor aceita uma matriz de remetentes. Nesse caso, você não poderá usar o recurso de ligação automática de dependência; precisamos criar e registrar uma equipe. A única pergunta é quais serviços do remetente serão adicionados a este comando. Vamos indicar o seu ID na configuração do pacote, assim:


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

Nosso pacote ainda não sabe como lidar com essas configurações, vamos ensiná-lo. Vá para Configuration.php e adicione a árvore de configuração:


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

Esse código determina que o nó raiz será o nó health_check , que conterá o senders matriz senders , que por sua vez conterá um certo número de linhas. É isso, agora o nosso pacote sabe como lidar com a configuração, que descrevemos acima. É hora de registrar a equipe. Para fazer isso, vá para HealthCheckExtension e adicione o seguinte 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); } } 

É isso aí, nossa equipe está determinada. Agora, após adicionar o pacote configurável ao projeto, quando chamado
bin/console , veremos uma lista de comandos, incluindo o nosso: health:send-info , você pode chamá-lo da mesma maneira: bin/console health:send-info


Nosso pacote está pronto. É hora de testá-lo no projeto. Crie um projeto vazio:


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

Adicione nosso pacote recém-assado, para isso, adicionamos a seção de repositories no composer.json :


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

E execute o comando:


 composer require niklesh/health-check 

E também, para o início mais rápido, adicione um servidor symphony ao nosso projeto:


 composer req --dev server 

O pacote está conectado, o Symfony Flex o conecta automaticamente ao config/bundles.php , mas, para criar arquivos de configuração automaticamente, é necessário criar uma receita. Sobre receitas é lindamente pintado em outro artigo aqui: https://habr.com/post/345382/ - então pinte como criar receitas, etc. Não estarei aqui e ainda não há receita para este pacote.


No entanto, são necessários arquivos de configuração, portanto, crie-os com alças:


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

Agora você precisa implementar as classes de envio de informações para a equipe e a classe de coleta de informações


  • src/Service/DataCollector.php

Tudo é extremamente simples aqui.


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

E aqui é ainda mais 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'; } } 

Feito! Limpe o cache e inicie o servidor


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

Agora você pode experimentar nossa equipe:


 bin/console health:send-info 

Temos uma conclusão tão bonita:


imagem


Finalmente, batemos em nossa rota http://127.0.0.1:8000/health e obtemos uma conclusão menos bonita, mas também:


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

Isso é tudo! Espero que este tutorial direto ajude alguém a entender os conceitos básicos de criação de pacotes para o Symfony 4.


O código fonte do PS está disponível aqui .

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


All Articles