Начало работы

Внедрение зависимостей

Объекты в приложении собирает контейнер. Вы объявляете, что нужно классу — типом, — а он создаёт зависимость, её собственные зависимости и передаёт готовое дерево. Слова new в прикладном коде почти не остаётся.

Внедрение по типуНастройка #[Configuration] / #[Bean]Пакет winter-di

Что такое внедрение зависимостей и зачем

Зависимость — любой объект, без которого класс не работает: контроллеру нужен сервис, сервису — репозиторий, репозиторию — подключение к базе.

Проблема. Когда класс создаёт зависимости сам, он обязан знать, как собирается каждая из них:

php
$service = new UserService(
  new UserRepository(new Connection(env('DB_DSN'), env('DB_USER'), env('DB_PASS'))),
  new Logger('user'),
);

Эта цепочка повторяется в каждом месте, где нужен UserService. Добавили репозиторию аргумент — правите все такие места. Подменить реализацию (кешем в проде, заглушкой в тесте) можно только правкой кода. И каждый вызов создаёт всё заново, включая то, что должно быть одним на процесс.

Решение. Класс объявляет что ему нужно, а как это построить — забота контейнера:

php
class UserService
{
  public function __construct(
      private UserRepository $repo,
      private LoggerInterface $logger,
  ) {}
}

Дальше UserService берётся у контейнера, и он сам построит репозиторий, соединение и логгер. Класс перестаёт зависеть от способа сборки, а подмена реализации становится вопросом настройки, а не правки.

Эта страница — про использование

Здесь то, что нужно, чтобы писать приложение. Полный разбор контейнера — разрешение, прокси, кеш рефлексии, провайдеры — в документации пакета winter-di.

Как контейнер строит объекты

При запуске приложения сканер обходит проект и запоминает классы, которым задана область видимости, а также классы-конфигурации. Это тот же обход, что находит контроллеры и маршруты, — отдельного шага нет.

Дальше, когда кто-то просит объект, контейнер:

  1. смотрит, есть ли для этого типа привязка или готовый экземпляр;
  2. если нет — читает конструктор класса;
  3. по типу каждого аргумента рекурсивно строит зависимость;
  4. создаёт объект и заполняет свойства, помеченные #[Autowired];
  5. кеширует результат, если область видимости это предполагает.

Просить объект вручную обычно не нужно: контроллеры, middleware, обработчики консольных команд и фоновых задач фреймворк создаёт сам.

Скан — при запуске, не на каждый запрос

Обход проекта происходит один раз при старте. При DEBUG=false список найденных классов дополнительно сохраняется в di.php, чтобы не ходить по файловой системе повторно; на поведение это не влияет, только на скорость запуска. Новый класс подхватывается перезапуском — в разработке это делает php call run dev.

Как объявить зависимость

Способов четыре, и они не равнозначны: первый — основной, остальные решают частные задачи.

Через конструктор

Базовый способ, никакого атрибута не требуется. Достаточно объявить тип:

main/UserService.php
<?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]

Когда конструктор занят или недоступен, зависимость внедряется прямо в свойство:

php
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] — работает и на аргументе конструктора, и на свойстве.

php
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] одну сторону:

php
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 запрос и так равен процессу.

main/AuthContext.php
<?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]

Автоматической сборки хватает, пока речь о конкретных классах. Но интерфейс сам по себе не говорит, какую реализацию взять; объекту из чужой библиотеки может понадобиться нестандартная сборка; строку подключения нужно достать из окружения. Всё это описывается в классе-конфигурации.

main/AppConfig.php
<?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

Метод обязан возвращать объект и объявлять тип возврата — по нему и строится привязка.

Область видимости бина

По умолчанию бин — синглтон: метод отработает один раз, результат переиспользуется. Обратите внимание на разницу с обычными классами, где умолчание противоположное.

php
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)));
}

Несколько бинов одного типа

Когда одного типа нужно две штуки, привязку по типу различают именем:

php
#[Bean(name: 'cache.redis')]
public function redis(): CacheInterface { /* ... */ }

#[Bean(name: 'cache.file')]
public function file(): CacheInterface { /* ... */ }

Забирается такой бин уточнением по ключу:

php
public function __construct(
  #[Inject('cache.redis')] private CacheInterface $cache,
) {}

Привязки из кода

Иногда привязку удобнее описать императивно — например, собрать её в цикле или выбрать по условию. Для этого есть провайдер: класс, которому контейнер даёт себя, чтобы тот записал в него привязки.

php
$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 — «нашли, но собрать нельзя». Поэтому и ловить их можно по отдельности:

php
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

Посмотреть, что контейнер знает о проекте, и управлять кешем:

bash
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/ и что класс не абстрактный.

Дальше