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 (через атрибуты/рефлексию), но для бизнес-кода используй конструктор.
Простой свой контейнер
С ростом числа сервисов конструировать всё вручную в 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 - с данными, создаются всегда заново.
Типичные ошибки
- «Сервис» с состоянием.
class UserServiceхранит$currentUserId- это не сервис, а DTO + работа над ним. Сервисы должны быть stateless. - Service Locator. Передавать
Containerв конструктор и тянуть из него по требованию ($this->container->get(...)) - антипаттерн: зависимости снова скрыты. Передавай конкретные сервисы. - Циклические зависимости.
AтребуетB,BтребуетA. Контейнер бросит ошибку. Разрывай через интерфейс, события или ленивые сервисы (Symfony\Contracts\Service\ServiceSubscriberInterface). - Слишком много в конструкторе (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