Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Введение

Реализация PSR-11 с компиляцией графа зависимостей на этапе сборки.

Требования: PHP 8.2+

Преимущества

  • Скорость — нет накладных расходов в рантайме: контейнер готов сразу
  • Оптимизация — работает напрямую с opcache, не требует кэширования
  • Безопасность — ошибки конфигурации обнаруживаются на этапе сборки
  • Гибкость — генерируйте код при деплое, в CI/CD или при первом запуске, как вам удобно

Важно

Сборка обязательна — необходимо организовать этап генерации кода. Это нетипично для PHP, но именно это обеспечивает высокую производительность.

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

Этот раздел — пошаговое руководство, которое покажет, как настроить и использовать Cekta\DI.

Без DI-контейнера

Давайте представим простейший проект с классом Example, зависящим от подключения к базе данных (PDO).

src/Example.php

<?php
declare(strict_types=1);

namespace App;

class Example
{
    public function __construct(
        private \PDO $db,
    ) {
    }

    public function payload(): void
    {
        // ... используйте $db ...
    }
}

index.php — ручное создание зависимости:

<?php
declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

$pdo = new \PDO(getenv('DB_DSN') ?? 'sqlite:' . __DIR__ . '/../mydb.sqlite');
$example = new \App\Example($pdo);
$example->payload();

Проблема: каждый, кто использует Example, должен вручную создавать и передавать \PDO. При росте проекта это быстро становится хаосом.

С DI-контейнером

Теперь то же самое, но зависимости управляются централизованно через Cekta\DI.

1. Установка

composer require cekta/di

2. Конфигурация проекта

src/Project.php

<?php
declare(strict_types=1);

namespace App;

class Project extends \Cekta\DI\AbstractProject
{
    public function __construct(public readonly array $env = getenv())
    {
        parent::__construct(
            filename: __DIR__ . '/../Container.php',
            fqcn: 'App\Container',
            params: [
                \PDO::class . '$dsn' => $env['DB_DSN'] ?? 'sqlite:' . __DIR__ . '/../mydb.sqlite',
            ],
        );
    }

    public function definition(): array
    {
        return [
            'entries' => [\App\Example::class],
            'alias' => [],
            'singletons' => [],
            'factories' => [],
        ];
    }
}

3. Компиляция контейнера

bin/compile.php — скрипт для генерации сконфигурированного контейнера:

<?php
declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

$project = new \App\Project();
$compiler = new \Cekta\DI\ContainerCompiler();
file_put_contents($project->filename, $compiler->compile($project));
echo "{$project->filename} was generated" . PHP_EOL;

Запустите скрипт один раз, чтобы создать Container.php:

php bin/compile.php

Скрипт создаётся один раз и переиспользуется при необходимости перегенерации.
Он читает конфигурацию из Project и генерирует готовый файл контейнера.

4. Использование контейнера

Скомпилированный контейнер можно использовать в приложении:

<?php
declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

$project = new \App\Project();
$factory = new \Cekta\DI\ContainerFactory();
$container = $factory->create($project);

$example = $container->get(\App\Example::class);
$example->payload();

ContainerFactory создаёт экземпляр контейнера и подставляет параметры из проекта.

Зависимости разрешаются автоматически — \PDO будет передан в конструктор Example без ручной настройки.

Преимущества

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

Допустим, у вас есть Example, ReportGenerator и ConsoleCommand, всем нужен \PDO. Вам не нужно настраивать подключение к базе каждый раз: достаточно описать параметр один раз в конфигурации, а контейнер сам внедрит его в любые классы, которым он требуется.

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

Проект

Проект конфигурируется в классе, наследуемом от AbstractProject.

В конструкторе передаются данные (путь генерации, параметры), а в definition() — зависимости и их поведение.

src/Project.php

<?php

declare(strict_types=1);

namespace App;

class Project extends \Cekta\DI\AbstractProject
{
    public function __construct(public readonly array $env = getenv())
    {
        parent::__construct(
            filename: __DIR__ . '/../Container.php',
            fqcn: 'App\Container',
            params: [
                \PDO::class . '$dsn' => $env['DB_DSN'] ?? 'sqlite:' . __DIR__ . '/../mydb.sqlite',
            ],
        );
    }

    public function definition(): array
    {
        return [
            'entries' => [
                \App\Example::class,
            ],
            'alias' => [
                \Psr\Log\LoggerInterface::class => \Monolog\Logger::class,
            ],
            'singletons' => [
                \App\Service\Cache::class,
            ],
            'factories' => [
                \App\Service\RequestHandler::class,
            ],
        ];
    }
}

Конструктор

ПараметрНазначениеПодробнее
filenameПуть к файлу сгенерированного контейнера—
fqcnПолное квалифицированное имя класса контейнера—
paramsСкалярные значения для аргументов конструкторовПараметры

Зависимости

В методе definition() задаются зависимости и их поведение:

КлючНазначениеПодробнее
aliasПривязка интерфейсов и абстракций к реализациямАлиасы
singletonsОдин экземпляр на весь PHP-процессЖизненный цикл
factoriesНовый экземпляр при каждом get()Жизненный цикл

💡 Классы, указанные в entries, по умолчанию ведут себя как scoped — один экземпляр на контейнер. Полное сравнение жизненных циклов — в разделе «Управление жизненным циклом».

Параметры

Параметры — механизм задания конкретных значений для аргументов конструктора. Они позволяют:

  • передавать встроенные типы (string, int, array и т.д.)
  • переопределять значения по умолчанию
  • передавать конкретные экземпляры объектов

Значения параметров задаются в конструкторе класса, наследуемого от AbstractProject.

Имена параметров

Каждый параметр имеет имя — ключ в конфигурации.
Имена могут быть глобальными и локальными.

💡 Рекомендация. По умолчанию используйте глобальные имена. Они делают конфигурацию предсказуемой — одна настройка работает для всех потребителей. Локальные имена — для точечного переопределения.

Пример

src/Example.php

<?php
declare(strict_types=1);

namespace App;

class Example
{
    public function __construct(
        public string $firstName,
        public string $lastName,
    ) {}
}

src/Project.php

<?php
declare(strict_types=1);

namespace App;

class Project extends \Cekta\DI\AbstractProject
{
    public function __construct()
    {
        parent::__construct(
            filename: __DIR__ . '/../Container.php',
            fqcn: 'App\Container',
            params: [
                \App\Example::class . '$firstName' => 'admin',
                \App\Example::class . '$lastName' => 'secret',
            ],
        );
    }

    public function definition(): array
    {
        return [
            'entries' => [\App\Example::class],
            'alias' => [],
            'singletons' => [],
            'factories' => [],
        ];
    }
}

Полный пример с компиляцией и использованием контейнера описан в Начале работы.

Отложенные значения

Параметры вычисляются каждый раз в runtime (значения runtime переопределяют значения параметров с compile).

Если нужно отложить вычисление до рантайма — см. Отложенные значения.

Алиасы

Алиасы — маппинг абстрактных типов (интерфейсов, абстрактных классов) на конкретные реализации.

Когда использовать

  • Привязка интерфейса к реализации — контейнер видит тип Logger и создаёт ConsoleLogger.
  • Выбор наследника — вместо базового класса подставляется конкретный наследник.
  • Замена зависимостей — можно подменить реализацию, не трогая код потребителя.

Пример

src/Example.php

<?php
declare(strict_types=1);

namespace App;

class Example
{
    public function __construct(
        public Logger $logger,
    ) {
    }
}

src/Project.php

<?php
declare(strict_types=1);

namespace App;

class Project extends \Cekta\DI\AbstractProject
{
    public function __construct()
    {
        parent::__construct(
            filename: __DIR__ . '/../Container.php',
            fqcn: 'App\Container',
        );
    }

    public function definition(): array
    {
        return [
            'entries' => [Example::class],
            'alias' => [
                // Глобальный алиас — для всех, кто просит Logger
                Logger::class => ConsoleLogger::class,
            ],
            'singletons' => [],
            'factories' => [],
        ];
    }
}

Использование скомпилированного контейнера описано в Начале работы.

⚠️ Алиасы определяются на этапе компиляции. Для изменения маппинга потребуется пересобрать контейнер.

Отложенные значения (Lazy)

Большинство параметров вычисляются на этапе компиляции контейнера. Однако есть сценарии, когда значение нужно получить только в момент использования. Для таких случаев в библиотеке предусмотрен интерфейс Lazy, позволяющий отложить вычисление до рантайма.

Lazy-параметры полезны, когда:

  1. Нужно создать объект через callback, например, внедряя зависимости в методы после создания.
  2. Нужно получить ссылку на текущий контейнер для реализации паттерна Service Locator.
  3. Нужно сгенерировать значение на основе других параметров, например, DSN-строку подключения к БД из db_type, db_host, db_name и т. д.

Подробнее о параметрах — в разделе Параметры.


Интерфейс Lazy

<?php

declare(strict_types=1);

namespace Cekta\DI;

use Psr\Container\ContainerInterface;

/**
 * @external
 */
interface Lazy
{
    public function load(ContainerInterface $container): mixed;
}

Интерфейс Lazy помечен как external — он предназначен для использования пользователями библиотеки. Вы можете создать собственную реализацию, если стандартные решения не покрывают ваши потребности.

Все реализации Lazy вызывают метод load() в момент разрешения зависимости — уже внутри скомпилированного контейнера. Это позволяет получить доступ к экземпляру ContainerInterface и выполнить произвольную логику.

В библиотеке есть две готовые реализации.

Closure

Исходный код

Принимает callback-функцию и вызывает её в момент разрешения зависимости. Позволяет реализовать любую логику создания и возврата значения.

Container

Исходный код

Возвращает сам контейнер при вызове load(). Используется, когда классу нужен доступ к контейнеру для разрешения зависимостей, например, при реализации паттерна Service Locator.


Примеры

Пример 1: Внедрение зависимостей через метод

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

src/Example.php

<?php
declare(strict_types=1);

namespace App;

class Example
{
    private \PDO $connection;

    public function setPdo(\PDO $connection)
    {
        $this->connection = $connection;
    }
}

src/Project.php

<?php
declare(strict_types=1);

namespace App;

class Project extends \Cekta\DI\AbstractProject
{
    public function __construct(private array $env)
    {
        parent::__construct(
            filename: __DIR__ . '/../Container.php',
            fqcn: 'App\Container',
            params: [
                \App\Example::class . '$connection' => new \Cekta\DI\Lazy\Closure(
                    function (\Psr\Container\ContainerInterface $c) {
                        $example = new \App\Example();
                        $example->setPdo($c->get(\PDO::class));
                        return $example;
                    }
                ),
            ],
        );
    }

    public function definition(): array
    {
        return [
            'entries' => [\App\Example::class, \PDO::class],
            'alias' => [],
            'singletons' => [],
            'factories' => [],
        ];
    }
}

Пример 2: Внедрение ContainerInterface

Иногда классу требуется доступ к контейнеру для разрешения зависимостей. Например, при реализации паттерна Service Locator.

src/Example.php

<?php
declare(strict_types=1);

namespace App;

class Example
{
    public function __construct(
        private \Psr\Container\ContainerInterface $container
    ) {
    }
}

src/Project.php

<?php
declare(strict_types=1);

namespace App;

class Project extends \Cekta\DI\AbstractProject
{
    public function __construct()
    {
        parent::__construct(
            filename: __DIR__ . '/../Container.php',
            fqcn: 'App\Container',
            params: [
                \Psr\Container\ContainerInterface::class => new \Cekta\DI\Lazy\Container(),
            ],
        );
    }

    public function definition(): array
    {
        return [
            'entries' => [\App\Example::class],
            'alias' => [],
            'singletons' => [],
            'factories' => [],
        ];
    }
}

Рекомендации

  1. Если параметры используются внутри Closure, добавляйте их в entries для гарантии доступности
  2. Предпочтительный способ внедрения зависимостей — конструктор. Используйте Lazy только когда это невозможно

Жизненный цикл

В долгоживущих процессах — приложениях на RoadRunner, FrankenPHP, фоновых workers или консольных командах, обрабатывающих множество задач — важно контролировать, сколько экземпляров сервиса создаётся и где они живут.

Для этого в библиотеке предусмотрены три типа жизненного цикла. Их можно применять к любым зависимостям: entries, params, alias и autowiring.

Обзор

ТипОписаниеКогда использовать
ScopedОдин экземпляр на контейнерОбработка запросов, пользовательские сессии, изоляция данных
SingletonОдин экземпляр на весь PHP-процессКонфигурация, подключения к БД, кеши
FactoryНовый экземпляр при каждом вызове get()Stateless-сервисы, DTO, временные данные

💡 Классы, указанные в entries, по умолчанию ведут себя как scoped. Указывать их в отдельных разделах не нужно.

Конфигурация

Жизненный цикл определяется в методе definition() класса AbstractProject:

<?php
declare(strict_types=1);

namespace App;

class Project extends \Cekta\DI\AbstractProject
{
    public function definition(): array
    {
        return [
            'entries' => [
                \App\HttpController::class,  // Scoped
            ],
            'singletons' => [
                \App\Database::class,         // Singleton
            ],
            'factories' => [
                \App\HttpRequest::class,      // Factory
            ],
        ];
    }
}

Пример

Рассмотрим, как ведёт себя каждый тип при вызове get() на одном и том же и на разных контейнерах:

<?php
$container1 = new \App\Container();
$container2 = new \App\Container();

$scoped1 = $container1->get(\App\Scoped::class);
$scoped2 = $container1->get(\App\Scoped::class);
// $scoped1 === $scoped2

$singleton1 = $container1->get(\App\Singleton::class);
$singleton2 = $container2->get(\App\Singleton::class);
// $singleton1 === $singleton2

$factory1 = $container1->get(\App\Factory::class);
$factory2 = $container2->get(\App\Factory::class);
// $factory1 !== $factory2
// $factory1 !== $factory2

Сравнение

ТипВнутри одного контейнераМежду разными контейнерамиКогда использовать
ScopedОдин объектРазные объектыОбработка запросов, пользовательские сессии, изоляция данных
SingletonОдин объектОдин объектКонфигурация, подключения к БД, кеши
FactoryРазные объектыРазные объектыStateless-сервисы, DTO, временные данные

Важно

  • Конфликты — один и тот же класс нельзя указать одновременно как Singleton и Factory
  • Производительность — Factory создаёт наибольшую нагрузку, Singleton — наименьшую
  • Потокобезопасность — Singleton должен быть потокобезопасным в многопоточных средах

Полное описание параметров конфигурации — Проект.

Имена зависимостей

Каждая зависимость в конфигурации DI идентифицируется по уникальному имени. Знание правил формирования этих имён — ключ к правильной настройке params и alias.

Формирование имени

Имя зависимости строится из сигнатуры конструктора потребляющего класса. Разберём основные случаи на примере:

<?php
namespace App;

class Example
{
    public function __construct(
        string $locale,        // примитивный тип
        Logger $logger,        // тип-класс
        string|Logger $mix,    // объединённый тип
        ?Logger $optional,     // nullable
        Logger ...$logs,       // вариативный аргумент
    ) {}
}

Для каждого аргумента генерируются два имени:

Глобальное имя

Используется для регистрации значения во всём проекте.

АргументГлобальное имя
string $locale"locale"
Logger $logger"App\Logger"
string|Logger $mix"string|App\Logger"
?Logger $optional"?App\Logger"
Logger ...$logs"...App\Logger"

Правило: тип аргумента, взятый как есть — с полным namespace, модификаторами ? и .... Для скалярных типов (string, int, bool, float, array, object, callable, iterable) — имя равно имени типа в кавычках.

Локальное имя

Используется для точечной настройки одного конкретного аргумента.

АргументЛокальное имя
string $localeApp\Example::class . '$locale'
Logger $loggerApp\Example::class . '$logger'
string|Logger $mixApp\Example::class . '$mix'
?Logger $optionalApp\Example::class . '$optional'
Logger ...$logs'...' . App\Example::class . '$logs'

Правило: ClassName::class . '$argName'. Для вариативных аргументов — с префиксом '...' ..

Глобальное или локальное?

Глобальное имя

Применяется ко всем потребителям сразу. Удобно для привязки интерфейсов и общих параметров:

'params' => [
    'locale' => 'ru_RU',  // все locale-параметры во всём проекте
],
'alias' => [
    Logger::class => Monolog\Logger::class,  // все, кто просит Logger, получат Monolog
],

Локальное имя

Применяется точечно, только к одному аргументу одного класса. Удобно для переопределения:

'params' => [
    App\Example::class . '$locale' => 'en_GB',  // только для Example::$locale
],

Что выбрать?

СитуацияРекомендуемый подход
Маппинг интерфейса → реализацияГлобальный алиас
Общий параметр (locale, версия API)Глобальный параметр
Переопределение для конкретного классаЛокальное имя
Примитивный параметр уникального сервисаЛокальное имя

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

Как разрешается зависимость

Во время сборки для каждой зависимости из entries строится полный граф вложенных зависимостей.

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

Autowiring в конструктор

Механизм автоматического анализа аргументов конструктора для определения требуемых зависимостей.

На основе аргументов конструктора формируются имена зависимостей. Если для аргумента найдено локальное имя в params или alias, оно используется вместо глобального. Если аргумент имеет значения по умолчанию, необязательные аргументы без явного значения пропускаются.

Остальные аргументы рекурсивно разрешаются по тем же правилам — через params, alias или снова через autowiring.

Если аргумент указывает на несуществующий класс или создать экземпляр невозможно (абстрактный класс, интерфейс без алиаса), будет выброшено исключение с детальным описанием проблемы.

Приоритет разрешаемых зависимостей

Когда в конфигурации указаны разные значения для одного и того же имени, система применяет следующий порядок приоритета (от высшего к низшему):

  1. Локальное имя из params
  2. Глобальное имя из params
  3. Локальное имя из alias
  4. Глобальное имя из alias
  5. Значение по умолчанию
  6. Autowiring в конструктор

Рекомендация: используйте глобальные имена для большинства случаев. Они делают конфигурацию предсказуемой — один алиас или параметр применяется ко всем потребителям. Локальные имена доступны для точечного переопределения, но их стоит применять осознанно, когда глобальная настройка не подходит конкретному потребителю.

Поддержка

Если у вас остались вопросы по текущей документации или есть проблема с настройкой в вашем проекте, вы можете:

  1. Создать issue на русском или английском языке.
  2. Написать в русскоязычный чат.

Также здесь можно оставить предложения по улучшению библиотеки.

💡 Проект открыт для pull request’ов с улучшениями!