Внедрение зависимостей
Объекты в приложении собирает контейнер. Вы объявляете, что нужно классу —
типом, — а он создаёт зависимость, её собственные зависимости и передаёт готовое
дерево. Слова new в прикладном коде почти не остаётся.
Что такое внедрение зависимостей и зачем
Зависимость — любой объект, без которого класс не работает: контроллеру нужен сервис, сервису — репозиторий, репозиторию — подключение к базе.
Проблема. Когда класс создаёт зависимости сам, он обязан знать, как собирается каждая из них:
$service = new UserService(
new UserRepository(new Connection(env('DB_DSN'), env('DB_USER'), env('DB_PASS'))),
new Logger('user'),
);Эта цепочка повторяется в каждом месте, где нужен UserService. Добавили
репозиторию аргумент — правите все такие места. Подменить реализацию (кешем в
проде, заглушкой в тесте) можно только правкой кода. И каждый вызов создаёт всё
заново, включая то, что должно быть одним на процесс.
Решение. Класс объявляет что ему нужно, а как это построить — забота контейнера:
class UserService
{
public function __construct(
private UserRepository $repo,
private LoggerInterface $logger,
) {}
}Дальше UserService берётся у контейнера, и он сам построит репозиторий,
соединение и логгер. Класс перестаёт зависеть от способа сборки, а подмена
реализации становится вопросом настройки, а не правки.
Эта страница — про использование
Здесь то, что нужно, чтобы писать приложение. Полный разбор контейнера — разрешение, прокси, кеш рефлексии, провайдеры — в документации пакета winter-di.
Как контейнер строит объекты
При запуске приложения сканер обходит проект и запоминает классы, которым задана область видимости, а также классы-конфигурации. Это тот же обход, что находит контроллеры и маршруты, — отдельного шага нет.
Дальше, когда кто-то просит объект, контейнер:
- смотрит, есть ли для этого типа привязка или готовый экземпляр;
- если нет — читает конструктор класса;
- по типу каждого аргумента рекурсивно строит зависимость;
- создаёт объект и заполняет свойства, помеченные
#[Autowired]; - кеширует результат, если область видимости это предполагает.
Просить объект вручную обычно не нужно: контроллеры, middleware, обработчики консольных команд и фоновых задач фреймворк создаёт сам.
Скан — при запуске, не на каждый запрос
Обход проекта происходит один раз при старте. При DEBUG=false список найденных
классов дополнительно сохраняется в di.php, чтобы не ходить по файловой системе
повторно; на поведение это не влияет, только на скорость запуска. Новый класс
подхватывается перезапуском — в разработке это делает php call run dev.
Как объявить зависимость
Способов четыре, и они не равнозначны: первый — основной, остальные решают частные задачи.
Через конструктор
Базовый способ, никакого атрибута не требуется. Достаточно объявить тип:
<?php
namespace Main;
use Psr\Log\LoggerInterface;
class UserService
{
public function __construct(
private UserRepository $repo,
private LoggerInterface $logger,
) {}
public function find(int $id): User
{
$this->logger->info('lookup', ['id' => $id]);
return $this->repo->findOrFail($id);
}
}Так следует писать везде, где конструктор в вашем распоряжении: зависимости видны
в сигнатуре, класс можно собрать руками в тесте, а readonly-свойства остаются
возможными.
Через свойство — #[Autowired]
Когда конструктор занят или недоступен, зависимость внедряется прямо в свойство:
use Flytachi\Winter\DI\Attribute\Autowired;
class PostController extends Controller
{
#[Autowired] private PostService $service;
#[Autowired] private LoggerInterface $logger;
}Свойство может быть private — контейнер заполняет его сразу после создания
объекта, до того как управление дойдёт до ваших методов.
В контроллерах — только так
Controller::__construct() объявлен final и без аргументов, поэтому
конструкторная инъекция там невозможна и #[Autowired] остаётся единственным
вариантом. В сервисах, репозиториях и middleware выбор за вами.
Уточнение — #[Inject]
По типу контейнер понимает не всё: интерфейс сам по себе не говорит, какую
реализацию взять, а число или строку по типу не разрешить вовсе. Для таких случаев
есть #[Inject] — работает и на аргументе конструктора, и на свойстве.
use Flytachi\Winter\DI\Attribute\Inject;
class ReportService
{
public function __construct(
// 1. по типу — то же, что без атрибута, просто явно
#[Inject] private CacheInterface $cache,
// 2. конкретная реализация в обход общей привязки
#[Inject(FileCache::class)] private CacheInterface $fallback,
// 3. значение по ключу, положенное в контейнер заранее
#[Inject('config.timeout')] private int $timeout,
) {}
}Вторая форма нужна, когда одному классу требуется не та реализация, что всем остальным. Третья — единственный способ получить скаляр: числа и строки контейнер по типу не различает.
Отложенное — #[Lazy]
#[Lazy] подставляет вместо объекта прокси, а настоящий экземпляр создаётся при
первом обращении к нему.
Главное применение — разорвать круг. Если A нужен B, а B нужен A, обычное
разрешение уйдёт в бесконечную рекурсию и упадёт. Достаточно пометить #[Lazy]
одну сторону:
use Flytachi\Winter\DI\Attribute\Lazy;
class SmsSendService
{
public function __construct(
#[Lazy] private PeerService $peer, // создастся при первом использовании
) {}
}Прокси нужен конкретный класс
Прокси подменяет собой класс, поэтому проксировать голый интерфейс не выйдет —
подменять нечего. Указывайте конкретный тип или сочетайте с уточнением:
#[Inject(RedisCache::class), Lazy].
Круг в зависимостях — почти всегда признак того, что двум классам нужен третий.
#[Lazy] решает проблему, но стоит сначала посмотреть, не разделяются ли классы.
Подробный разбор — в
документации пакета.
Области видимости
Область видимости отвечает на вопрос, сколько экземпляров класса существует и как долго они живут.
| Атрибут | Сколько экземпляров | Для чего |
|---|---|---|
| нет (по умолчанию) | Новый на каждое внедрение | Всё, что хранит состояние |
#[Singleton] |
Один на процесс-воркер | Классы без состояния: репозитории, клиенты, фабрики |
#[Request] |
Один на запрос; в Swoole — на корутину | Данные текущего запроса |
#[Transient] |
Новый на каждое внедрение — явно | Когда хочется сказать это в коде |
По умолчанию — не синглтон
Класс без атрибута строится заново при каждом внедрении. Это безопасное
умолчание — общий объект не может случайно утечь между запросами, — но и цена
видна: сервис, открывающий соединение или держащий прогретый кеш, будет делать это
снова и снова. Такие классы помечайте #[Singleton] осознанно.
Если вы пришли из Spring или Laravel, где сервисы по умолчанию общие, это поведение противоположное привычному.
Данные запроса — #[Request]
Экземпляр живёт ровно один запрос и виден всем, кто его попросит внутри этого запроса. Параллельные запросы получают каждый свой — в Swoole изоляция идёт по корутинам, под PHP-FPM запрос и так равен процессу.
<?php
namespace Main;
use Flytachi\Winter\DI\Attribute\Request;
#[Request]
class AuthContext
{
private ?User $user = null;
public function setUser(User $user): void { $this->user = $user; }
public function user(): User
{
return $this->user ?? throw new \LogicException('No authenticated user');
}
}Типичное применение: middleware аутентификации кладёт сюда пользователя, а контроллеры и сервисы читают. Разбор этого приёма по шагам — на странице Middleware.
Опасное сочетание ловится при старте
Синглтон, внутри которого лежит #[Request]-зависимость, — скрытая ошибка:
объект первого запроса застрял бы в нём на всё время жизни воркера и отдавался бы
всем остальным. Winter обходит граф зависимостей при загрузке и отказывается
подниматься, показывая цепочку A → $prop: B.
Конфигурация — #[Configuration] и #[Bean]
Автоматической сборки хватает, пока речь о конкретных классах. Но интерфейс сам по себе не говорит, какую реализацию взять; объекту из чужой библиотеки может понадобиться нестандартная сборка; строку подключения нужно достать из окружения. Всё это описывается в классе-конфигурации.
<?php
namespace Main;
use Flytachi\Winter\Kernel\App\Attribute\Bean;
use Flytachi\Winter\Kernel\App\Attribute\Configuration;
use Flytachi\Winter\Kernel\App\Attribute\Value;
#[Configuration]
final class AppConfig
{
#[Bean]
public function cache(#[Value('REDIS_URL')] string $url): CacheInterface
{
return new RedisCache($url);
}
#[Bean]
public function mailer(
#[Value('MAIL_HOST', 'localhost')] string $host,
LoggerInterface $logger,
): MailerInterface {
return new SmtpMailer($host, $logger);
}
}Регистрировать класс нигде не нужно — его находит тот же скан. Дальше любой класс,
запросивший CacheInterface, получит собранный здесь RedisCache.
Как это устроено:
| Элемент | Что делает |
|---|---|
#[Configuration] на классе |
Объявляет класс сборником фабрик; сам он становится синглтоном |
#[Bean] на методе |
Делает метод фабрикой; ключ привязки — возвращаемый тип |
| Обычный параметр метода | Автовайрится из контейнера, как в конструкторе |
#[Value('KEY', $default)] |
Подставляет значение из .env |
Метод обязан возвращать объект и объявлять тип возврата — по нему и строится привязка.
Область видимости бина
По умолчанию бин — синглтон: метод отработает один раз, результат переиспользуется. Обратите внимание на разницу с обычными классами, где умолчание противоположное.
use Flytachi\Winter\Kernel\App\Scope;
#[Bean(scope: Scope::Transient)] // новый на каждое обращение
public function query(): QueryBuilder
{
return new QueryBuilder();
}
#[Bean(scope: Scope::Request)] // один на запрос
public function correlation(): CorrelationId
{
return new CorrelationId(bin2hex(random_bytes(8)));
}Несколько бинов одного типа
Когда одного типа нужно две штуки, привязку по типу различают именем:
#[Bean(name: 'cache.redis')]
public function redis(): CacheInterface { /* ... */ }
#[Bean(name: 'cache.file')]
public function file(): CacheInterface { /* ... */ }Забирается такой бин уточнением по ключу:
public function __construct(
#[Inject('cache.redis')] private CacheInterface $cache,
) {}Привязки из кода
Иногда привязку удобнее описать императивно — например, собрать её в цикле или выбрать по условию. Для этого есть провайдер: класс, которому контейнер даёт себя, чтобы тот записал в него привязки.
$c->singleton(CacheInterface::class, RedisCache::class); // один на процесс
$c->request(AuthContext::class); // один на запрос
$c->transient(QueryBuilder::class); // новый каждый раз
$c->bind(MailerInterface::class, fn(Container $c) => new SmtpMailer(env('MAIL_HOST')));
$c->set('config.timeout', (int) env('APP_TIMEOUT', 30)); // готовое значение| Метод | Что делает |
|---|---|
singleton($abstract, $concrete) |
Привязка с одним экземпляром на процесс |
request($abstract, $concrete) |
Привязка с экземпляром на запрос |
transient($abstract, $concrete) |
Новый экземпляр при каждом разрешении |
bind($abstract, $concrete) |
Класс или замыкание-фабрика |
set($id, $value) |
Готовое значение по ключу — то, что достаётся #[Inject('ключ')] |
contextual($abstract, $factory) |
Разная реализация в зависимости от того, кто просит |
make($abstract, $overrides) |
Создать объект вручную |
register($providerClass) |
Подключить провайдер с группой привязок |
contextual() — самый нестандартный из них: фабрика получает имя класса-потребителя
и может вернуть разное. Именно так устроен логгер — #[Autowired] LoggerInterface
приходит уже названным по классу, в который его внедрили.
Что выбрать
Для большинства задач достаточно #[Configuration] с #[Bean]: привязки видны в
одном файле, типы проверяются, аргументы автовайрятся. Императивные методы нужны
там, где набор привязок вычисляется во время выполнения.
Когда контейнер не может собрать
Все шесть отказов приходят как исключения PSR-11, и сообщение называет настоящую причину — чинятся они в шести разных местах:
| Что попросили | Исключение | Что делать |
|---|---|---|
| имени нет нигде | NotFoundException |
класс не найден — проверить автозагрузку и что пакет установлен |
| интерфейс без привязки | NotFoundException |
привязать реализацию: bind(), singleton() или #[Bean] |
| трейт | NotFoundException |
просить у контейнера класс, который его использует |
| абстрактный класс | ContainerException |
привязать к конкретному |
| enum | ContainerException |
отдавать конкретный case через фабрику |
| приватный конструктор | ContainerException |
отдавать через фабрику — bind() с замыканием или #[Bean] |
Деление ровно по PSR-11: NotFoundException — «строить нечего», ContainerException —
«нашли, но собрать нельзя». Поэтому и ловить их можно по отдельности:
try {
$gateway = $container->get(PaymentGateway::class);
} catch (NotFoundExceptionInterface $e) {
// строить нечего — нет привязки, либо класс не установлен
} catch (ContainerExceptionInterface $e) {
// нашли, но собрать нельзя — абстрактный, enum, приватный конструктор, цикл
}Самая дорогая ошибка чтения
Class [Dep\Main\ClientRepository] not found — это не проблема контейнера. Класса нет
на диске: опечатка в имени, пакет не установлен, или автозагрузчик до него не дотягивается
(частый случай — path-репозиторий с симлинком, который не переехал внутрь контейнера
Docker).
Внедрение через #[Autowired] тут ни при чём: контейнер строит любой класс рефлексией,
включая сторонние из vendor/, и никакого предварительного сканирования для этого не
требуется.
Ошибка конструктора остаётся вашей: если тело __construct() бросило исключение, оно
долетит как есть — контейнер не переклеивает на него свою этикетку, иначе единственное
сообщение, говорящее где именно сломалось, потерялось бы.
Инструменты — call di
Посмотреть, что контейнер знает о проекте, и управлять кешем:
php call di show # все классы в кеше контейнера
php call di show Main\Service # только совпадающие с фрагментом имени
php call di build # обойти проект один раз и собрать кеш
php call di clean # удалить кеш и сгенерированные прокси
php call di async # методы #[Async] и состояние их проксиКоманда пригождается, когда класс «не находится»: если его нет в выводе show,
значит он не попал в скан — проверьте, что файл лежит не в resources/ или
storage/ и что класс не абстрактный.
Дальше
- Winter DI — полный reference — разрешение, прокси, провайдеры
- Контроллеры — где
#[Autowired]обязателен - Middleware — как наполняется
#[Request]-контекст - Конфигурация — прочие настройки приложения