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


image


В принципе, это не обязательно, поскольку из созданных файлов нам в итоге пригодится не так уж много, но мне удобнее почистить всё не нужное, нежели руками создавать нужное.


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

Получаем такой вот красивый вывод:


image


Наконец стукнемся на наш роут http://127.0.0.1:8000/health и получим менее красивый, но тоже вывод:


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

Вот и всё! Надеюсь этот незамысловатый туториал поможет кому-то разобраться в основах написания бандлов для Symfony 4.


P.S. Исходный код доступен здесь.

Комментарии (23)


  1. BoShurik
    06.08.2018 23:26

    Вместо `CompiledPass` проще использовать `!tagged`: symfony.com/blog/new-in-symfony-3-4-simpler-injection-of-tagged-services


    1. HEKET313 Автор
      07.08.2018 18:53

      Действительно, не знал про этот способ. Спасибо.


  1. BoShurik
    06.08.2018 23:34

    Раздел с созданием скелета бандла — спорный. Гораздо проще использовать стандартный composer init, т.к. в вашем случае удалить придется гораздо больше, чем создать. К тому же вы добавляете лишние зависимости (по сути бандл должен зависеть только от symfony/framework-bundle, как минимум symfony/flex, sensio/framework-extra-bundle и symfony/lts — лишние)


    Ну а в остальном создание ничем не отличается от других версий Symfony


    1. HEKET313 Автор
      07.08.2018 18:55

      На счёт `symfony/flex` и `symfony/lts` в принципе могу согласиться, тем более, что они автоматом ставятся в любой проект, который использует Symfony 4, а вот `sensio/framework-extra-bundle` тут действительно нужен. Он необходим для роутинга через аннотации.


      1. BoShurik
        07.08.2018 19:20

        тем более, что они автоматом ставятся в любой проект, который использует Symfony 4

        Речь идет о новых проектах, использующих flex. Если кто-то обновил свое приложение с 3.4 до 4 и захочет поставить ваш бандл, он очень не обрадуется появившемуся flex, который внезапно переопределит все конфиги :)


        а вот sensio/framework-extra-bundle тут действительно нужен. Он необходим для роутинга через аннотации.

        Не рекомендуется использовать такого рода "магию" в бандлах. Равно как и использование автовайринга и автоконфигрирования: Best Practices for Reusable Bundles


        Services should not use autowiring or autoconfiguration. Instead, all services should be defined explicitly.


      1. Fesor
        08.08.2018 00:18

        extra bundle для роутинга не нужен. там добавляется лишь возможность лепить аннотацию на сервис что не очень актуально.


      1. VolCh
        09.08.2018 10:33

        С некоторых пор для аннотаций роутинга extra не нужен.


  1. springimport
    07.08.2018 17:02

    Как надо делить бандлы: по теме (пользователи, новости) или по функциональности (аутентификация, работа с файлами). Или допустимы оба варианта?


    1. HEKET313 Автор
      07.08.2018 18:53

      Моё мнение — по функциональности, хотя и по теме тоже можно. Например, у нас есть самописный бандл, отвечающий за отправку сообщений в Kafka, у каждого микросервиса, к которому обращаются другие микросервисы, есть свой клиентский бандл, есть бандл авторизации, который содержит кастомную аннотацию, которая добавляет авторизацию к тому или иному action'у контроллера — это что касается функционального разделения. В то же время есть и тематический бандл, который отвечает за определение, с какого именно интернет-магазина (у нас их несколько) пришёл запрос и содержит общие константы, URL'ы и кучу разной статической информации.

      Моё мнение — как удобно, так и делите, другое дело, что бандл не должен быть монструозным и содержать чрезмерное количество разрозненного функционала, это не должен быть мини-монолит, который вы таскаете между проектами.


      1. springimport
        08.08.2018 16:06

        Тоже к этому пришел: оба варианта.

        бандл не должен быть монструозным

        Зависит от виртуозности разработчика :) К этому надо стремиться, в этом случае правило unix очень кстати: «сделай мало, но хорошо», главное чтобы не получилось как в npm.


        1. Fesor
          09.08.2018 01:24

          главное чтобы не получилось как в npm

          с npm проблема только одна — разработчики npm-а которые… ну я могу долго тут их ругать за то что у них странные приоритеты и странное отношение к понятию lock файл. Но главный их косяк — отсутствие возможностти подписывать пакеты, дабы исключить возможность подмены как это случалось не однократно (что приводило к веселым новостям о том что eslint слил чьито ключи в сеть).


          Что до "сделай мало но хорошо" — посмотрите на штуки типа grep, less и т.д. Я не могу сказать что они мало делают. Ну то есть… проблема этого "мало" в том что это относительное понятие и оно для каждого свое.


          Суть не в том что бы там чего-то было "мало" или "много" а в том, что бы причины для изменений штуки были одни и те же. Если причины разные — рано или поздно они начнут конфликтовать между собой и придется идти на компромисы. так магенты появляются (там еще избыточное обобщение и неверная стратегия "расширяемости" в стиле вротпресса).


    1. Fesor
      08.08.2018 00:29

      Вопервых проясним вопрос с терминами «темы» и «функциональность». По сути это должны быть синонимы. Во вторых ваши «пользователи» являются совокупностью разных функциональностей (и возможно должны быть разбиты), в частности это аутентификация, профили (иногда это разные профили, представьте себе убер где есть профиль пассажира и профиль водителя), есть сквозная функциональность (например у тех же профилей есть рейтинг, который неплохо выделяется в свой модуль) ну и т.д.

      Во вторых на тему разделения проекта на модули (а бандлы это вид модуля) пишут книги еще с 70-х, и все сводится к тому что бы уменьшать связанность между ними и повышать cohesion. Лучше с этими вопросами попробуйте разобраться.

      Скажем… типичная проблема людей, которую я наблюдал на проектах — желание разделить на бандлы и потом юзать нутро этих бандлов в других бандлах. Типа есть бандл «пользователи» и во всех остальных бандлах идут отсылки на сущность `User` из этого бандла, хотя в целом достаточно лишь айдишки (хотя логика тут ясна — так намного проще, особенно по началу, в плане выплевывания данных в шаблоны). Ну и всякие CommonBundle, CoreBundle и прочие UtilsBundle свидетельствуют о проблемах с декомпозицией на проекте.

      Ну и в заключение — не надо делить проект на бандлы. Вообще. Бандлы нужны для реюза кода между проектами, что бы можно было их подключить к проекту и все. Та же аутентификация или восстановление пароля в целом неплохо обобщаются до бандла, но мне не известны примеры где это сделано хорошо (хотя скажем есть вариант подключить какой AuthN-server и не париться с бандлами, но это больше вообще к вопросу о расширении кругозора к тому как не писать код а юзать готовое).

      Если вы хотите разделить проект — сделайте нэймспейс. Этого более чем достаточно.


      1. springimport
        08.08.2018 16:22

        Я считаю что нужно смотреть реалистично. В теории можно сделать идеальный проект с двадцаткой бандлов всех мастей которые делают «мало, но хорошо». На практике редко когда удается что-то отличное (ну да, квалификация и все такое). И самое главное, не факт что эта нарезанная картошка найдет свой суп в то время когда нужно приготовить пиццу. Пожалуй, такие универсальные штуки во всем может позволить себе делать разве что sensiolab.
        Я бы наверное сделал бы этот самый общий User.

        уменьшать связанность между ними и повышать cohesion
        Вспоминаю модули в magento 2, всегда думал что это не правильно все :)

        желание разделить на бандлы и потом юзать нутро этих бандлов в других бандлах
        Понятно что наследование лучше избежать.

        не надо делить проект на бандлы. Вообще. Бандлы нужны для реюза кода между проектами, что бы можно было их подключить к проекту и все
        всякие CoreBundle
        Не понял, можно иметь CoreBundle, только называть так нельзя?


        1. apapacy
          08.08.2018 17:08

          Это интересный вопрос. Чаще всего приложение пишут в бандле (AppBundle) хотя насколько я понимаю разработчик продукта это не приветствует
          symfony.com/doc/current/best_practices/creating-the-project.html


          1. HEKET313 Автор
            08.08.2018 17:49

            До Symfony 4 писали внутри бандла, в 4 от этого отказались. Теперь все файлы проекта находятся внутри директории `src` без всяких бандлов.


            1. apapacy
              08.08.2018 18:24

              Насколько я понимаю так можно делать было и раньше см. knpuniversity.com/blog/AppBundle
              Я конечно не сторонник там всякой отсебятины и наоборот сторонник все делать по мануалам. И меня немного удивило в одной конторе когда я обнаружил AppKernel там где его наверное никто больше не держит. Но это бесконечное bundle -bundle которое слышишь целый день немного утомляло. Интересно как реальные разработчики восприняли такое новшество. Боюсь что все по привычке держат в бандле да ещё и не в одном


              1. Fesor
                09.08.2018 01:19

                ходят легенды что изначально план был дать возможность людям дробить проекты на кернелы, потому была папка app и src. В этом случае удобно было бы отдельные модули подключать в свои кернелы бандлами и т.д.


                Люди неправильно поняли и начали воспринимать бандлы как модули. и дробить проект именно таким образом. Причина для этого очень и очень проста — автоконфигурация. Вы создали папку Entity внутри бандла и доктрина автоматически подтянула оттуда сущности. Вы создали папку Resources и… ну вы поняли идею.


                Жить без бандлов вообще можно было в целом с самого начала. Но ключевой момент распространенных заблуждений — автоконфигурация и неудобные конфиги.


                С выходом symfony 3.3 по сути конфиги симфони стали куда более удобны для "безбандальной структуры". Но проблема того, что люди структурируют проект под автоконфигурацию, все еще никуда не делась.


                что до мануалов — best practice симфони появился с выходом symfony 2.8 вроде, то есть люди уже успели набомбить проектов и привыкнуть. В этом бэст практис покрывался вопрос что нет смысла дробить на бандлы но опять же автоконфигурация а потому "если хотите бандл — не делите ничего а юзайте AppBundle".


                1. VolCh
                  09.08.2018 10:44
                  +1

                  Скорее заготовки под кернелы/приложения — наследие symfony 1.*, фича которой часто пользовались для, например, разделения публичной части и админки. Но особо востребованности не было и забили.


            1. VolCh
              09.08.2018 10:40

              По-моему, уже в 3.3 отказались от бандла, причём по тихому так, уже после релиза, ближе к 3.4


        1. Fesor
          09.08.2018 01:12

          В теории можно сделать идеальный проект с двадцаткой бандлов всех мастей которые делают «мало, но хорошо»

          еще раз — не надо делить проект на бандлы. Бандлы для реюза кода между проектами, инфраструктура какая-то, обобщенный функционал. Например вам надо быстро прикрутить websockets — ставим centrifugo bundle. Бизнес логики в бандлах быть не должно.


          Я считаю что нужно смотреть реалистично.

          То есть забить просто и не пытаться даже делать нормально декомпозицию? ну ладно.


          Вспоминаю модули в magento 2

          магенту это вообще страх и слезы.


          Понятно что наследование лучше избежать.

          причем тут наследование? В целом когда у вас есть модуль Users и еще десяток и все юзают сущность из этого бедного модуля Users, хотя им только айдишка нужна. И это самый лайтовый пример факапов.


          Не понял, можно иметь CoreBundle, только называть так нельзя?

          Суть не в названии, вы же это понимаете. Я сейчас говорю о разделении приложения на модули. То есть вы можете сделать какой-нибудь модуль Infrastructure, но там не должно быть сущностей и бизнес логики.


  1. oxidmod
    08.08.2018 16:35

    По сути бандлы — независимые части. Значит для взаимодействия между ними нужно пилить адаптеры. Пилить адаптеры для собственного кода… Это странно, имхо. Лучше свою БЛ вообще вне бандла держать. В бандлы выносить только вещи, которые сами в себе. К примеру какойто кеш (которому пофиг что кешить), работу с очередями, хранилищами и прочее.


    1. springimport
      08.08.2018 17:25

      Точно, это то что я писал выше в комментарии про применимость бандлов.


  1. VolCh
    09.08.2018 10:47
    +1

    Сейчас хорошее использование непереиспользуемых бандлов — подготовка к разделению монолита на (микро)сервисы.