Жизненный цикл разрешения
Вот что на самом деле происходит при вызове make() — прочитано из исходников, а не
по памяти. Понимание порядка объясняет правила кэширования, ошибку циклической зависимости и то,
почему overrides ведут себя именно так.
Конвейер make()
Каждый make($abstract, $overrides) проходит одну и ту же последовательность:
make($abstract, $overrides)
│
1. кэш resolved? если $overrides пуст И $abstract есть в resolved → вернуть
│ (синглтоны и set()-значения живут здесь)
2. scopeOf($abstract) scope привязки → атрибут класса → 'transient'
│
3. кэш request? если scope 'request' И $overrides пуст
│ И контекст корутины содержит его → вернуть
4. защита от цикла если $abstract уже в стеке ЭТОЙ единицы работы
│ → бросить ContainerException с цепочкой
5. ждать синглтон? если scope 'singleton' И его строит другая корутина
│ → дождаться и вернуть её экземпляр
6. отметить сборку положить $abstract в стек единицы работы
│
7. doResolve() построить экземпляр (см. ниже)
8. injectProperties() заполнить свойства #[Autowired] / #[Inject] / #[Lazy]
9. cache() если $overrides пуст: сохранить по scope
│ finally: убрать $abstract из стека
└─ вернуть экземпляр1 · Кэш resolved
Если overrides нет и абстракт уже в карте resolved, он возвращается сразу — без рефлексии.
Эта карта хранит реализованные синглтоны, set()-значения и саморегистрированный
контейнер (Container / ContainerInterface оба указывают на $this).
2 · Определение scope
scopeOf() определяет время жизни, по порядку: побеждает scope явной привязки; иначе
scope-атрибут класса (#[Singleton] / #[Request] / #[Transient]); иначе transient.
3 · Кэш request-scope
Для request-scoped абстракта без overrides контейнер проверяет контекст корутины Swoole
(Coroutine::getContext()['__di']) перед построением. Вне корутины такого контекста нет, и
проверка проваливается насквозь — см. Request-scope и Swoole.
4 · Защита от циклических зависимостей
Перед построением абстракт кладётся в стек разрешения текущей единицы работы — под Swoole
это контекст корутины, вне корутины обычное свойство контейнера. Если разрешение снова входит в
make() для абстракта, который уже есть в этом стеке, — это цикл, и контейнер бросает
исключение, называя всю цепочку:
ContainerException: Circular dependency detected while resolving
[App\SmsSendService] → [App\FakeSendService] → [App\SmsSendService].Стек — именно свой, а не общий на воркер: соседний запрос, собирающий тот же класс в тот же момент, не считается циклом. Почему так и что было бы иначе — на странице Конкурентное разрешение.
Запись убирается в finally, поэтому неудачное разрешение никогда не оставляет устаревшую
защиту. #[Lazy] обходит цикл, внедряя proxy вместо рекурсии — см.
Разрыв циклических зависимостей.
5 · Ожидание синглтона
Если запрошен singleton, которого ещё нет в кэше, и его прямо сейчас строит другая корутина —
make() дожидается её результата и возвращает тот же экземпляр вместо того, чтобы собирать
второй. Так фабрика синглтона выполняется один раз на воркер даже при одновременном обращении.
Ожидание возможно только на первом разрешении: дальше срабатывает шаг 1.
6 · Отметка сборки
Абстракт попадает в стек текущей единицы работы — именно эту запись увидит шаг 4, если разрешение вложенной зависимости вернётся к тому же классу.
7 · doResolve — конструирование
привязка есть?
├─ concrete — callable → concrete($container) (фабрика-замыкание)
└─ concrete — класс → resolver->resolve(concrete) (автовайринг конструктора)
привязки нет?
├─ class_exists → resolver->resolve($abstract) (автовайринг напрямую)
└─ иначе → бросить NotFoundExceptionresolve() читает параметры конструктора (через ReflectionCache,
мемоизированно) и строит каждый аргумент. Конструктор без параметров — это просто new $class().
8 · Внедрение в свойства
После конструирования injectProperties() сканирует непродвинутые (non-promoted) свойства
класса на #[Autowired], #[Inject] или #[Lazy] и заполняет каждое. Свойства,
продвинутые через конструктор, пропускаются — они уже установлены при конструировании.
9 · Кэширование
Только когда $overrides пуст:
| Scope | Кэшируется в |
|---|---|
singleton |
карте resolved на уровне процесса |
request |
контексте корутины (Swoole) или карте resolved (FPM / CLI) |
transient |
не кэшируется |
Почему overrides никогда не кэшируются
Шаги 1, 3 и 9 все проверяют, что $overrides пуст. Экземпляр, построенный с overrides, — не
канонический объект для этого абстракта, поэтому его кэширование отравило бы каждое последующее
разрешение. Это намеренное правило: overrides всегда строят свежее и никогда не разделяются.
Как разрешается один аргумент
Внутри resolve() / call() каждый параметр определяется первым подходящим правилом — это тот
приоритет, на который вы полагаетесь, смешивая overrides, атрибуты и значения по умолчанию:
для каждого параметра:
1. имя в $overrides? → использовать значение override
2. есть #[Inject]? → разрешить id (или объявленный тип); lazy → proxy
3. типизирован (не builtin)? → makeContextual(type)
└─ NotFoundException + есть default → использовать default
└─ #[Lazy] → внедрить proxy вместо этого
4. есть значение по умолчанию? → использовать его
5. опциональный? → пропустить
6. иначе → бросить ContainerExceptionДва следствия, которые стоит усвоить:
- Разрешимая зависимость со значением по умолчанию всё равно внедряется. Значение по умолчанию — это запасной вариант, используемый только когда тип разрешить нельзя, а не повод пропускать внедрение.
makeContextual()— точка входа внедрения, а неmake(). Именно это позволяет фабрикеcontextual()видеть потребителя при внедрении, тогда как прямойmake()— нет.
Связанное
- Кэш рефлексии — как мемоизируются метаданные параметров
- Request-scope и Swoole — кэш корутины на шаге 3
- Ленивые proxy — ветка proxy на шагах 7–8
- Справочник API — сигнатуры
make()/call()