DI-контейнер

← DI-контейнер

Ashterix

docs(articles): Magento and Symfony examples side by side, request lifecycle as a Mermaid diagram

uk/articles/magento-to-symfony/core/di.md +24 −4

DI-контейнер

Найбільша частина знань, яка переноситься. Конструкторна ін'єкція та ж, але контейнер компілюється в PHP, усі сервіси приватні, а більшість зв'язків виводиться з типів без конфігурації.

Базова конфігурація

Symfony · config/services.yaml (за замовчуванням)

services:
    _defaults:
        autowire: true        # аргументи конструктора за типами
        autoconfigure: true   # теги за інтерфейсами й атрибутами

    App\:
        resource: '../src/'
        exclude:
            - '../src/DependencyInjection/'
            - '../src/Entity/'
            - '../src/Kernel.php'

Кожен клас у src/ автоматично стає сервісом з id, рівним FQCN. autoconfigure означає: якщо клас реалізує Command, EventSubscriberInterface чи має атрибут #[AsCommand], його зареєструють де треба без жодного XML. Порівняйте з Magento, де команду треба додати в масив commands у di.xml.

preference → alias

Magento · etc/di.xml

<preference
    for="Vendor\Shipping\Api\RateProviderInterface"
    type="Vendor\Shipping\Model\DhlRateProvider"/>

Symfony · атрибут на реалізації

use Symfony\Component\DependencyInjection\Attribute\AsAlias;

#[AsAlias(RateProviderInterface::class)]
final class DhlRateProvider implements RateProviderInterface
{
}

Magento · etc/di.xml

<preference
    for="Vendor\Shipping\Api\RateProviderInterface"
    type="Vendor\Shipping\Model\DhlRateProvider"/>

Symfony · атрибут на реалізації

use Symfony\Component\DependencyInjection\Attribute\AsAlias;

#[AsAlias(RateProviderInterface::class)]
final class DhlRateProvider implements RateProviderInterface
{
}

або в services.yaml:

  #  ...
  App\Shipping\RateProviderInterface: '@App\Shipping\DhlRateProvider'
  #  ...

Якщо інтерфейс має одну реалізацію в src/, alias не потрібен взагалі. Якщо реалізацій дві, контейнер впаде на етапі компіляції з повідомленням, яку обрати. Magento в такій ситуації мовчки бере останній preference за порядком модулів.

virtualType і аргументи → іменовані сервіси

Magento · окремий лог-файл через virtualType

<virtualType name="Vendor\Faq\Logger\Handler"
             type="Magento\Framework\Logger\Handler\Base">
    <arguments>
        <argument name="fileName" xsi:type="string">/var/log/faq.log</argument>
    </arguments>
</virtualType>
<virtualType name="Vendor\Faq\Logger"
             type="Magento\Framework\Logger\Monolog">
    <arguments>
        <argument name="name" xsi:type="string">faq</argument>
        <argument name="handlers" xsi:type="array">
            <item name="file" xsi:type="object">Vendor\Faq\Logger\Handler</item>
        </argument>
    </arguments>
</virtualType>
<type name="Vendor\Faq\Model\Importer">
    <arguments>
        <argument name="logger" xsi:type="object">Vendor\Faq\Logger</argument>
    </arguments>
</type>

Symfony · канал Monolog + автовайринг за іменем

# config/packages/monolog.yaml
monolog:
    channels: ['faq']
    handlers:
        faq:
            type: stream
            path: '%kernel.logs_dir%/faq.log'
            channels: ['faq']
// src/Faq/Importer.php
final class Importer
{
    public function __construct(
        private LoggerInterface $faqLogger,
    ) {}
}

Magento · окремий лог-файл через virtualType

<virtualType name="Vendor\Faq\Logger\Handler"
             type="Magento\Framework\Logger\Handler\Base">
    <arguments>
        <argument name="fileName" xsi:type="string">/var/log/faq.log</argument>
    </arguments>
</virtualType>
<virtualType name="Vendor\Faq\Logger"
             type="Magento\Framework\Logger\Monolog">
    <arguments>
        <argument name="name" xsi:type="string">faq</argument>
        <argument name="handlers" xsi:type="array">
            <item name="file" xsi:type="object">Vendor\Faq\Logger\Handler</item>
        </argument>
    </arguments>
</virtualType>
<type name="Vendor\Faq\Model\Importer">
    <arguments>
        <argument name="logger" xsi:type="object">Vendor\Faq\Logger</argument>
    </arguments>
</type>

Symfony · канал Monolog + автовайринг за іменем

# config/packages/monolog.yaml
monolog:
    channels: ['faq']
    handlers:
        faq:
            type: stream
            path: '%kernel.logs_dir%/faq.log'
            channels: ['faq']
// src/Faq/Importer.php
final class Importer
{
    public function __construct(
        private LoggerInterface $faqLogger,
    ) {}
}

Назва аргументу $faqLogger вибирає канал faq. Так само працюють scoped HTTP-клієнти (HttpClientInterface $erpClient), пули кешу та lock-фабрики. Загальний випадок, коли треба передати конкретні значення:

Symfony · явні аргументи атрибутами

use Symfony\Component\DependencyInjection\Attribute\Autowire;

final class QuestionSearch
{
    public function __construct(
        #[Autowire(service: 'app.search.faq_client')]
        private SearchClient $client,
        #[Autowire(env: 'int:FAQ_PAGE_SIZE')]
        private int $pageSize,
        #[Autowire('%kernel.project_dir%/var/import')]
        private string $importDir,
    ) {}
}

Список усіх типів, які можна підставити автовайрингом, і всіх іменованих варіантів: bin/console debug:autowiring, наприклад debug:autowiring logger.

Plugin → декоратор

Magento · after-плагін

<type name="Vendor\Catalog\Model\PriceCalculator">
    <plugin name="vendor_promo_discount"
            type="Vendor\Promo\Plugin\ApplyDiscount"
            sortOrder="10"/>
</type>
class ApplyDiscount
{
    public function afterCalculate(
        PriceCalculator $subject,
        float $result
    ): float 
    {
        return $result * 0.9;
    }
}

Symfony · декоратор сервісу

use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;

#[AsDecorator(decorates: PriceCalculatorInterface::class, priority: 10)]
final class DiscountedPriceCalculator implements PriceCalculatorInterface
{
    public function __construct(
        #[AutowireDecorated]
        
        private PriceCalculatorInterface $inner,
    ) {}

    public function calculate(Product $product): float
    {
        return $this->inner->calculate($product) * 0.9;
    }
}

Magento · after-плагін

<type name="Vendor\Catalog\Model\PriceCalculator">
    <plugin name="vendor_promo_discount"
            type="Vendor\Promo\Plugin\ApplyDiscount"
            sortOrder="10"/>
</type>
class ApplyDiscount
{
    public function afterCalculate(
        PriceCalculator $subject,
        float $result
    ): float 
    {
        return $result * 0.9;
    }
}

Symfony · декоратор сервісу

use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;

#[AsDecorator(decorates: PriceCalculatorInterface::class, priority: 10)]
final class DiscountedPriceCalculator implements PriceCalculatorInterface
{
    public function __construct(
        #[AutowireDecorated]
        
        private PriceCalculatorInterface $inner,
    ) {}

    public function calculate(Product $product): float
    {
        return $this->inner->calculate($product) * 0.9;
    }
}

Усі, хто просить PriceCalculatorInterface, отримають декоратор, а він викликає оригінал. Відмінності від плагінів:

  • Декорується сервіс, а не метод. Треба реалізувати весь інтерфейс, тому інтерфейси з багатьма методами декорувати незручно.
  • Сутності, DTO та об'єкти, створені через new, декорувати не можна: вони не сервіси.
  • Якщо споживач типізує конкретний final клас, декоратор не підставити. Тому в Symfony-коді важливо залежати від інтерфейсів.
Коли треба змінити чужу поведінку
  1. Пошукайте точку розширення бандла: опція конфігурації, подія, тег. Подивіться bin/console config:dump-reference назва_бандла.
  2. Декоруйте сервіс.
  3. Перевизначте сервіс: alias на ваш клас у services.yaml.
  4. Compiler pass, який змінює визначення сервісу. Це крайній випадок.

Правити код у vendor/ або копіювати класи бандла не можна.

Масиви в di.xml → теги

Magento · пул реалізацій

<type name="Vendor\Export\Model\ExportPool">
    <arguments>
        <argument name="formats" xsi:type="array">
            <item name="csv" xsi:type="object">Vendor\Export\Model\Format\Csv</item>
            <item name="xml" xsi:type="object">Vendor\Export\Model\Format\Xml</item>
        </argument>
    </arguments>
</type>

Symfony · тег + локатор

#[AutoconfigureTag('app.export_format')]
interface ExportFormat
{
    public function export(iterable $rows): string;
}

#[AsTaggedItem('csv')]
final class CsvFormat implements ExportFormat { /* … */ }

final class ExportPool
{
    public function __construct(
        #[AutowireLocator('app.export_format')]
        private ContainerInterface $formats,
    ) {}

    public function get(string $code): ExportFormat
    {
        return $this->formats->get($code);
    }
}

Magento · пул реалізацій

<type name="Vendor\Export\Model\ExportPool">
    <arguments>
        <argument name="formats" xsi:type="array">
            <item name="csv" xsi:type="object">Vendor\Export\Model\Format\Csv</item>
            <item name="xml" xsi:type="object">Vendor\Export\Model\Format\Xml</item>
        </argument>
    </arguments>
</type>

Symfony · тег + локатор

#[AutoconfigureTag('app.export_format')]
interface ExportFormat
{
    public function export(iterable $rows): string;
}

#[AsTaggedItem('csv')]
final class CsvFormat implements ExportFormat { /* … */ }

final class ExportPool
{
    public function __construct(
        #[AutowireLocator('app.export_format')]
        private ContainerInterface $formats,
    ) {}

    public function get(string $code): ExportFormat
    {
        return $this->formats->get($code);
    }
}

Новий формат — це лише новий клас, що реалізує інтерфейс. #[AutowireIterator] дає ітератор усіх реалізацій (з пріоритетами), #[AutowireLocator] — ліниву мапу за ключем.

Factory, Proxy, shared

Magento Symfony Примітка
QuestionFactory::create() new Question(...) Сутності й DTO створюють через new, у конструкторі передають обов'язкові поля.
Factory для сервісу з runtime-параметром Свій клас-фабрика як сервіс Фабрика отримує залежності через DI, а параметр — у метод create().
Foo\Proxy в аргументі #[Autowire(lazy: true)] або lazy: true Сервіс створиться при першому виклику методу.
shared="false" shared: false або #[Autoconfigure(shared: false)] Новий екземпляр на кожну ін'єкцію.
ObjectManager::getInstance() немає Сервіси приватні: $container->get() у коді працювати не буде. І не має.

Корисні команди: debug:container App\Faq\Importer (аргументи й теги сервісу), debug:container --tag=app.export_format, lint:container (перевірка типів аргументів).