HardПрактика5 min

Compiler passes и factories

CompilerPassInterface, фабрики, синтетические сервисы, lazy-сервисы

Compiler Passes

Compiler Pass -- механизм для модификации контейнера на этапе компиляции. Позволяет программно изменять определения сервисов, добавлять теги, модифицировать аргументы.

Жизненный цикл компиляции

1. Load config (YAML, PHP attributes)
2. Run Compiler Passes:
   a. BeforeOptimizationPass
   b. OptimizationPass (resolves parameters, aliases)
   c. BeforeRemovingPass
   d. RemovingPass (removes private unused services)
   e. AfterRemovingPass
3. Dump compiled container

Создание Compiler Pass

<?php

declare(strict_types=1);

namespace App\DependencyInjection\Compiler;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

final class MessageHandlerPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container): void
    {
        // Check if the main service exists
        if (!$container->has(MessageBus::class)) {
            return;
        }

        $definition = $container->findDefinition(MessageBus::class);

        // Find all services tagged with 'app.message_handler'
        $taggedServices = $container->findTaggedServiceIds('app.message_handler');

        foreach ($taggedServices as $id => $tags) {
            // Add each handler as a method call
            $definition->addMethodCall('addHandler', [new Reference($id)]);
        }
    }
}

Регистрация Compiler Pass

<?php

declare(strict_types=1);

namespace App;

use App\DependencyInjection\Compiler\MessageHandlerPass;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;

final class Kernel extends BaseKernel
{
    use MicroKernelTrait;

    protected function build(ContainerBuilder $container): void
    {
        // Default priority type: BeforeOptimizationPass
        $container->addCompilerPass(new MessageHandlerPass());

        // Or with explicit pass type and priority
        $container->addCompilerPass(
            new MessageHandlerPass(),
            PassConfig::TYPE_BEFORE_OPTIMIZATION,
            0 // priority
        );
    }
}

Типы Compiler Passes

Тип Константа Когда запускается
Before optimization TYPE_BEFORE_OPTIMIZATION До оптимизации (default)
Optimization TYPE_OPTIMIZE Фаза оптимизации
Before removing TYPE_BEFORE_REMOVING До удаления сервисов
Removing TYPE_REMOVE Фаза удаления
After removing TYPE_AFTER_REMOVING После удаления

Пример: сбор всех стратегий

<?php

declare(strict_types=1);

namespace App\DependencyInjection\Compiler;

use App\Pricing\PricingEngine;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

final class PricingStrategyPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container): void
    {
        if (!$container->has(PricingEngine::class)) {
            return;
        }

        $engineDefinition = $container->findDefinition(PricingEngine::class);
        $strategies = $container->findTaggedServiceIds('app.pricing_strategy');

        // Sort by priority
        $sortedStrategies = [];
        foreach ($strategies as $id => $tags) {
            foreach ($tags as $attributes) {
                $priority = $attributes['priority'] ?? 0;
                $sortedStrategies[$priority][] = $id;
            }
        }

        krsort($sortedStrategies);

        foreach ($sortedStrategies as $ids) {
            foreach ($ids as $id) {
                $engineDefinition->addMethodCall(
                    'addStrategy',
                    [new Reference($id)]
                );
            }
        }
    }
}

Фабрики (Factories)

Фабрика -- альтернативный способ создания сервисов, когда конструктор недоступен или недостаточен.

Статическая фабрика

<?php

declare(strict_types=1);

namespace App\Service;

final class DatabaseConnection
{
    private function __construct(
        private readonly \PDO $pdo,
    ) {
    }

    public static function create(string $dsn, string $user, string $pass): self
    {
        return new self(new \PDO($dsn, $user, $pass, [
            \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
        ]));
    }
}
services:
    App\Service\DatabaseConnection:
        factory: ['App\Service\DatabaseConnection', 'create']
        arguments:
            - '%env(DATABASE_DSN)%'
            - '%env(DATABASE_USER)%'
            - '%env(DATABASE_PASS)%'

Фабрика через отдельный класс

<?php

declare(strict_types=1);

namespace App\Factory;

use App\Service\CacheAdapter;

final class CacheAdapterFactory
{
    public function __construct(
        private readonly string $cacheDriver,
        private readonly string $cacheHost,
    ) {
    }

    public function create(): CacheAdapter
    {
        return match ($this->cacheDriver) {
            'redis' => new RedisCacheAdapter($this->cacheHost),
            'memcached' => new MemcachedCacheAdapter($this->cacheHost),
            default => new FileCacheAdapter('/tmp/cache'),
        };
    }
}
services:
    App\Factory\CacheAdapterFactory:
        arguments:
            $cacheDriver: '%env(CACHE_DRIVER)%'
            $cacheHost: '%env(CACHE_HOST)%'

    App\Service\CacheAdapter:
        factory: ['@App\Factory\CacheAdapterFactory', 'create']

Invokable фабрика

<?php

declare(strict_types=1);

namespace App\Factory;

final class LoggerFactory
{
    public function __invoke(string $channel): LoggerInterface
    {
        return new Logger($channel, [
            new StreamHandler('php://stderr'),
        ]);
    }
}
services:
    App\Factory\LoggerFactory: ~

    custom_logger:
        factory: '@App\Factory\LoggerFactory'
        arguments: ['app']

Синтетические сервисы

Синтетический сервис -- сервис, определение которого известно контейнеру, но экземпляр устанавливается извне (в runtime).

services:
    # Synthetic service -- container doesn't create it
    kernel:
        synthetic: true

    request:
        synthetic: true
<?php

declare(strict_types=1);

// In kernel boot or front controller
$container = $kernel->getContainer();
$container->set('some_external_service', $externalService);

Синтетические сервисы используются редко. Ядро Symfony (Kernel) -- пример синтетического сервиса, так как оно создаётся до контейнера.

Lazy-сервисы

Lazy-сервис -- сервис, который создаётся только при первом обращении к нему. Полезно для тяжёлых сервисов, которые не всегда нужны.

Через YAML

services:
    App\Service\HeavyReportGenerator:
        lazy: true

Через атрибут

<?php

declare(strict_types=1);

namespace App\Service;

use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;

#[Autoconfigure(lazy: true)]
final class HeavyReportGenerator
{
    public function __construct()
    {
        // Expensive initialization: DB connections, file loading, etc.
        sleep(2); // Simulating heavy init
    }

    public function generate(array $data): string
    {
        return 'Generated report';
    }
}

Lazy через #[Autowire]

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Service\HeavyReportGenerator;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final class ReportController
{
    public function __construct(
        // This service will be lazy-loaded
        #[Autowire(lazy: true)]
        private readonly HeavyReportGenerator $reportGenerator,
    ) {
    }

    public function index(): Response
    {
        // HeavyReportGenerator is NOT instantiated until this line
        $report = $this->reportGenerator->generate($data);

        return new Response($report);
    }
}

Как работает lazy

Symfony создаёт ghost-proxy объект. Этот прокси реализует тот же интерфейс, но конструктор реального сервиса вызывается только при первом обращении к методу.

Injection -> Ghost Proxy (lightweight) -> First method call -> Real instantiation

Абстрактные определения

Абстрактные сервисы служат шаблоном для других определений.

services:
    # Abstract definition -- template
    app.abstract_repository:
        abstract: true
        arguments:
            $entityManager: '@doctrine.orm.entity_manager'
            $logger: '@logger'

    # Concrete services inherit from abstract
    App\Repository\UserRepository:
        parent: app.abstract_repository
        arguments:
            $entityClass: App\Entity\User

    App\Repository\OrderRepository:
        parent: app.abstract_repository
        arguments:
            $entityClass: App\Entity\Order

Deprecation сервисов

services:
    App\Service\OldMailer:
        deprecated:
            package: 'app'
            version: '2.0'
            message: 'The "%service_id%" service is deprecated, use App\Service\NewMailer instead.'
<?php

declare(strict_types=1);

namespace App\Service;

use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;

#[Autoconfigure(
    deprecated: 'The "%service_id%" is deprecated, use NewMailer instead.',
)]
final class OldMailer
{
    // ...
}

Практический пример: Plugin System

<?php

declare(strict_types=1);

namespace App\Plugin;

use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

// Interface with auto-tagging
#[AutoconfigureTag('app.plugin')]
interface PluginInterface
{
    public function getName(): string;
    public function execute(array $context): mixed;
}
<?php

declare(strict_types=1);

namespace App\Plugin;

final class AnalyticsPlugin implements PluginInterface
{
    public function getName(): string
    {
        return 'analytics';
    }

    public function execute(array $context): mixed
    {
        return ['pageviews' => 1000];
    }
}
<?php

declare(strict_types=1);

namespace App\DependencyInjection\Compiler;

use App\Plugin\PluginManager;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

final class PluginPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container): void
    {
        if (!$container->has(PluginManager::class)) {
            return;
        }

        $manager = $container->findDefinition(PluginManager::class);
        $plugins = $container->findTaggedServiceIds('app.plugin');

        foreach ($plugins as $id => $tags) {
            $manager->addMethodCall('register', [new Reference($id)]);
        }
    }
}
<?php

declare(strict_types=1);

namespace App\Plugin;

final class PluginManager
{
    /** @var array<string, PluginInterface> */
    private array $plugins = [];

    public function register(PluginInterface $plugin): void
    {
        $this->plugins[$plugin->getName()] = $plugin;
    }

    public function execute(string $name, array $context = []): mixed
    {
        if (!isset($this->plugins[$name])) {
            throw new \RuntimeException("Plugin '$name' not found");
        }

        return $this->plugins[$name]->execute($context);
    }

    /** @return list<string> */
    public function getRegistered(): array
    {
        return array_keys($this->plugins);
    }
}

Итоги

  • Compiler Pass модифицирует контейнер при компиляции
  • findTaggedServiceIds() -- основной метод для сбора тегированных сервисов
  • Фабрики полезны, когда конструктор не подходит для создания объекта
  • Синтетические сервисы создаются снаружи контейнера
  • Lazy-сервисы создаются при первом использовании через ghost-proxy
  • Абстрактные определения -- шаблоны для наследования конфигурации