DI и сервисный контейнер

Dependency Injection и сервисный контейнер

В уроке 9 про PDO был совет: «передавай $pdo как аргумент, не пиши global $pdo». Это и есть Dependency Injection (DI) - мы внедряем зависимости извне, а не достаём их из глобального состояния. Сегодня доведём идею до контейнера, который сам собирает граф зависимостей.

Проблема без DI

Без DI код стартового пути выглядит так:

<?php
class UserController
{
    public function show(int $id): string
    {
        global $pdo;                                    // ← скрытая зависимость
        $logger = new \Monolog\Logger('app');           // ← фабрика внутри
        $logger->pushHandler(new \Monolog\Handler\StreamHandler('php://stdout'));

        $logger->info('show user', ['id' => $id]);
        $stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
        $stmt->execute(['id' => $id]);

        return json_encode($stmt->fetch());
    }
}

Что не так:

  • global $pdo - нельзя протестировать с другой БД
  • Создание логгера - копипаста в каждом контроллере
  • Чтобы добавить почтовый сервис, придётся опять копипастить инициализацию

DI через конструктор

<?php
final class UserController
{
    public function __construct(
        private readonly \PDO $pdo,
        private readonly \Psr\Log\LoggerInterface $log,
    ) {}

    public function show(int $id): string
    {
        $this->log->info('show user', ['id' => $id]);
        $stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = :id');
        $stmt->execute(['id' => $id]);
        return json_encode($stmt->fetch());
    }
}

// в одном месте, обычно в bootstrap.php:
$pdo = new PDO(...);
$logger = new \Monolog\Logger('app');
$logger->pushHandler(new \Monolog\Handler\StreamHandler('php://stdout'));

$controller = new UserController($pdo, $logger);

Все зависимости - в сигнатуре конструктора. Тестовый код просто передаст моки. Никаких global.

Это constructor injection - самый предсказуемый вид DI. Бывает ещё setter injection ($obj->setLogger(...)) и property injection (через атрибуты/рефлексию), но для бизнес-кода используй конструктор.

Простой свой контейнер

Граф зависимостей через DI-контейнер

С ростом числа сервисов конструировать всё вручную в bootstrap.php устаёшь. Контейнер - это «словарь фабрик»: ты регистрируешь способы создания, а доставать сервисы можно по идентификатору.

<?php
final class Container
{
    /** @var array<string, callable> */
    private array $factories = [];

    /** @var array<string, object> */
    private array $instances = [];

    public function set(string $id, callable $factory): void
    {
        $this->factories[$id] = $factory;
    }

    public function get(string $id): object
    {
        if (!isset($this->instances[$id])) {
            if (!isset($this->factories[$id])) {
                throw new \RuntimeException("Service $id not registered");
            }
            $this->instances[$id] = ($this->factories[$id])($this);
        }
        return $this->instances[$id];
    }
}
<?php
$container = new Container();

$container->set(PDO::class, fn() => new PDO('sqlite::memory:', null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]));

$container->set(\Psr\Log\LoggerInterface::class, function () {
    $log = new \Monolog\Logger('app');
    $log->pushHandler(new \Monolog\Handler\StreamHandler('php://stdout'));
    return $log;
});

$container->set(UserController::class, fn(Container $c) => new UserController(
    $c->get(PDO::class),
    $c->get(\Psr\Log\LoggerInterface::class),
));

// Использование:
$controller = $container->get(UserController::class);
echo $controller->show(42);

Singleton по умолчанию: один и тот же PDO будет переиспользоваться при каждом get. Это правильно для большинства сервисов (PDO держит соединение, Logger держит handlers). Если нужны новые экземпляры - не кешируй в $instances.

PSR-11: ContainerInterface

PSR-11 (см. также обзор PSR) описывает стандартный интерфейс для контейнеров:

<?php
namespace Psr\Container;

interface ContainerInterface
{
    public function get(string $id): mixed;
    public function has(string $id): bool;
}

Если твой контейнер реализует PSR-11, ты можешь использовать его с любой PSR-15 совместимой инфраструктурой и любым кодом, который ожидает контейнер.

composer require psr/container
<?php
use Psr\Container\ContainerInterface;

final class Container implements ContainerInterface
{
    // ... как выше
    public function has(string $id): bool
    {
        return isset($this->factories[$id]) || isset($this->instances[$id]);
    }
}

Зачем это, если есть Symfony DI

Свой контейнер на 30 строк полезен для понимания. В реальных проектах никто его не пишет - берут готовый (подробнее про связку с фреймворком - в уроке про Symfony). Самые ходовые:

  • Symfony DependencyInjection - флагман, с конфигом в YAML/PHP, autowiring, autoconfigure
  • PHP-DI - лёгкий контейнер с autowiring через рефлексию
  • Laravel Container - встроен в Laravel, использует магию

Все они реализуют PSR-11, так что твой бизнес-код от выбора не зависит.

Symfony DI: autowiring на пальцах

Установим только компонент:

composer require symfony/dependency-injection symfony/config symfony/yaml

Минимальный YAML-конфиг сервисов:

# config/services.yaml
services:
  _defaults:
    autowire: true       # по типу аргумента контейнер сам подберёт сервис
    autoconfigure: true  # классы с известными интерфейсами регистрируются автоматом
    public: false        # сервисы только для DI, не для $container->get() напрямую

  App\:
    resource: '../src/*'
    exclude: '../src/{Entity,Tests}'

  # Явная регистрация PDO с DSN из окружения
  PDO:
    arguments:
 - '%env(DATABASE_DSN)%'
 - null
 - null
 - { '%env(int:PDO_ERRMODE)%': 2 }

  Psr\Log\LoggerInterface:
    class: Monolog\Logger
    factory: ['App\Factory\LoggerFactory', 'create']

Bootstrap, который собирает контейнер из YAML:

<?php
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;

$container = new ContainerBuilder();
(new YamlFileLoader($container, new FileLocator(__DIR__ . '/config')))
 ->load('services.yaml');
$container->compile();

$controller = $container->get(App\Controller\UserController::class);

Что произошло:

  • В UserController::__construct(PDO $pdo, LoggerInterface $log) контейнер увидел типы аргументов
  • Для PDO нашёл явное определение
  • Для LoggerInterface нашёл явное определение
  • Сам подставил при создании

Тебе не пришлось регистрировать UserController руками - App\: + resource: автоматически нашёл все классы в src/, а autowiring разрешил их зависимости. Это и есть «магия» Symfony DI.

Attribute injection (PHP 8.1+ / Symfony 6+)

<?php
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final class PriceCalculator
{
    public function __construct(
        #[Autowire('%env(VAT_RATE)%')]
        private readonly float $vatRate,

        #[Autowire(service: 'monolog.logger.pricing')]
        private readonly LoggerInterface $log,
    ) {}
}

Конкретные значения и адресные ссылки на сервисы - прямо в коде, в атрибутах.

Когда сервис, а когда - фабрика на лету

ЧтоСервис в контейнереСоздавать на лету
PDO
LoggerInterface
UserController
User (entity)new User('Иван')
Money✅ value-object
DTO для запроса✅ создаётся per-request

Правило: сервисы - это синглтоны с поведением (без бизнес-данных). Сущности и value-objects - с данными, создаются всегда заново.

Типичные ошибки

  1. «Сервис» с состоянием. class UserService хранит $currentUserId - это не сервис, а DTO + работа над ним. Сервисы должны быть stateless.
  2. Service Locator. Передавать Container в конструктор и тянуть из него по требованию ($this->container->get(...)) - антипаттерн: зависимости снова скрыты. Передавай конкретные сервисы.
  3. Циклические зависимости. A требует B, B требует A. Контейнер бросит ошибку. Разрывай через интерфейс, события или ленивые сервисы (Symfony\Contracts\Service\ServiceSubscriberInterface).
  4. Слишком много в конструкторе (5+ зависимостей). Сигнал, что класс делает слишком много. Раздели по ответственности.
  5. new внутри сервиса. Если сервис создаёт другие сервисы через new - он мог бы получить их через DI. new оставь для value-объектов и DTO.

Best practices

  • Все зависимости через конструктор (constructor injection) - сигнатура конструктора это документация того, что нужно классу.
  • Сервисы должны быть stateless: никаких $this->currentUser в полях - состояние передаётся аргументами методов.
  • Не передавай контейнер как зависимость (Service Locator anti-pattern) - скрываешь реальные зависимости и усложняешь тесты.
  • Больше четырёх-пяти аргументов в конструкторе - сигнал, что класс делает слишком много; раздели по Single Responsibility.
  • new внутри сервиса оставляй только для value objects и DTO, не для других сервисов - иначе зависимости снова скрыты.

Мини-задание

  • Реализуй Container implements ContainerInterface с методами set, get, has, кэшированием экземпляров
  • Зарегистрируй PDO, LoggerInterface (Monolog), UserRepository, UserController - последние два должны автоматически получать зависимости через factory-замыкания
  • Установи symfony/dependency-injection и symfony/yaml, повтори настройку через services.yaml с autowire: true
  • Убедись, что $container->get(UserController::class) возвращает работающий экземпляр без ручной регистрации (благодаря App\: resource:)
  • Напиши MailerInterface и две реализации (SmtpMailer, NullMailer). В тестовом окружении подменяй на NullMailer через alias в YAML

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