Библиотека для внедрения зависимостей (PSR-11)
Реализация стандарта PSR-11 для работы с контейнером зависимостей в PHP.
🛠 Как это работает
-
Конфигурация
Большая часть зависимостей конфигурируется автоматически с помощью autowiring в constructor.
Вы конфигурируете по минимуму лишь в исключительных случаях и переиспользуете эту конфигурацию дальше. -
Build - генерация На основе вашей конфигурации библиотека создаёт PHP-класс, реализующий
Psr\Conainer\ContainerInterface. -
Использование
Вы используете сгенерированный класс у себя в проекте для разрешения зависимостей.
Если в конфигурации есть ошибки или зависимости не могут быть разрешены - библиотека сообщит об этом на этапе build.
✅ Преимущества
-
Простота
Работаете с готовым классом через стандартный интерфейс. -
Оптимизация
Не требует дополнительного кэширования - всё работает с opcache. -
Скорость Нет runtime-накладных расходов: контейнер готов к использованию.
Все накладные расходы только на этапе build. -
Гибкость
Можно генерировать код при деплое, во время сборки или при первом запросе.
Вы сами определяете как вам удобней.
⚠️ Что стоит учесть
- Требуется сборка
Нужно организовать этап генерации кода, что необычно для типичного PHP-проекта.
Начало работы
Пример проекта
Давайте рассмотрим простейший проект у которого все исходники в src и namespace App с классами Example и A (зависимости, чтобы продемонстрировать autowiring в конструктор).
src/Example.php
<?php
declare(strict_types=1);
namespace App;
class Example {
public function __construct(
private A $a,
) {
}
}
src/A.php
<?php
declare(strict_types=1);
namespace App;
class A {
}
index.php - Usage (Использование)
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
$example = new \App\Example(new \App\A());
var_dump($example);
Естественно будет настроенна автозагрузка классов с помощью composer и psr4.
composer.json
{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}
Установка
composer require cekta/di
Минимальная настройка проекта.
1. Создаем скрипт
bin/build.php
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
$fqcn = 'App\Container';
$filename = __DIR__ . '/../src/Container.php';
file_put_contents(
$filename,
(new \Cekta\DI\ContainerBuilder(
fqcn: $fqcn,
entries: [\App\Example::class],
// you configuration here, like entries, params, alias, etc.
))->build()
);
В этом скрипте вы можете осуществлять основную конфигурацию.
2. Генерируем Container (build)
php bin/build.php
Эту команду мы будем запускать каждый раз когда изменится наши зависимости и мы захотим актуализировать наш Container.
3. Используем Container
В вашей основной точке входа теперь все зависимости создаем через наш созданный контейнер
index.php
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
$params = []; // you current params
$container = new \App\Container($params);
var_dump($container->get(\App\Example::class));
⚠️ Если какие-то параметры ИСПОЛЬЗОВАЛИСЬ во время build, то эти параметры необходимо передать при создании Container.
Использовались это не значит что были объявлены, а значит что реально были использованы для разрешения entries, такие параметры запоминаются.
Container гарантирует все что было указано в entries на этапе build будет доступно при использовании.
Полезные рекомендации.
Получение параметров из одного места.
Так как параметры нужны как на этапе build так и во время использования, лучше чтобы они генерировались в одном месте и их можно было получать в разных местах.
src/Config.php
<?php
declare(strict_types=1);
namespace App;
class Config
{
public function __construct(private readonly array $env = [])
{
}
public function load(): array
{
$json = [];
$config = __DIR__ '/../config.json';
if (file_exists($config)) {
$json = json_decode(file_get_contents($config), true);
}
return [
'db_username' => $this->env['DB_USERNAME'] ?? $json['db']['username'] ?? 'default username',
// etc
];
}
}
Наличие такого конфига решает 2 основные проблемы:
- Позволяет получать параметры на этапе build и usage.
- Позволяет управлять конфигурацией проекта.
Для примера реализованная простейшая конфигурация, параметр db_username либо берется из env DB_USERNAME
если там задан, в противном случае он читается из конфигурационного файла в формате json, в остальных случаях используется
значение по умолчанию.
Естественно в каждом проекте своя конфигурация, свое расположение конфигурационных файлов, свои форматы конфигурации и приоритет их определения, но задавать в одном месте очень удобно.
Сгенерированные файлы в отдельной папке
Лучше не мешать файлы что пишутся людьми с файлами что были сгенерированными скриптами, например для сгенерированных файлов можно создать папку runtime в корне с проектом.
mkdir runtime
Внутри этой папки можно разместить readme.md что это для сгенерированных файлов, чтобы папка с этим файлом была в git.
echo "# For generated files" > runtime/readme.md
git add runtime/readme.md
Можно выделить отдельный namespace, например App\Runtime\ для сгенерированных файлов.
composer.json:
{
"autoload": {
"psr-4": {
"App\\": "src/",
"App\\Runtime\\": "runtime/"
}
}
}
Сгенерированные файлы в .gitignore
Нет смысла добавлять сгенерированные файлы в систему контроля версий (git), лучше их внести в .gitignore чтобы они случайно не добавились
.gitignore
runtime # в случае если сгенерированные файлы в отдельной папке (предварительно добавленный readme.md останется)
src/Container.php # в случае минимальной конфигурации
Skeleton для проектов.
Имеется проект cekta/skeleton в котором сложенны лучшие практик, в том числе по cekta/di.
Вы можете использовать данный проект для ваших новых проектов или посмотреть лучшие применения в нем.
Конфигурация ContainerBuilder
Основная конфигурация хранится, в скрипте по генерации контейнера.
bin/build.php
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
$fqcn = 'App\Container';
$filename = __DIR__ . '/../src/Container.php';
file_put_contents(
$filename,
(new \Cekta\DI\ContainerBuilder(
fqcn: $fqcn,
entries: [\App\Example::class],
// you configuration here, like entries, params, alias, etc.
))->build()
);
Здесь вы можете задать следующие параметры
FQCN
Полное имя класса (namespace + class_name). Из FQCN эти параметры будут извлечены.
FILENAME
Полный путь до файла где он будет сохранен Container.
Крайне желательно чтобы файл располагался вместе где он будет доступен с помощью автозагрузки классов composer, с учетом его fqcn и физического расположения.
entries
Массив строк, где каждая строка это основная точки входа которая должна быть разрешима с помощью Container.
На этапе build, Container использует эти зависимости и пытается их разрешить с помощью конфигурации (других параметров) или autowiring в конструктор.
Вы можете сгенерировать список этих точек входа, на основе содержимого вашего проекта, например все классы реализующие Psr\Http\Server\RequestHandlerInterface или имеющие php attribute.
params
Основные параметры которые можно внедрять в зависимости.
params: [
'Имя зависимости в локальном или глобальном именование' => 'Значение',
\PDO::class . '$dsn' => 'sqlite:./db.sqlite', // локальное имя, только для PDO аргумент с именем dsn будет иметь значение ...
'username' => 'root', // глобальное имя, всем кому потребуется username, будет использовано это значение.
]
Параметры можно задавать глобально и локально.
Рекомендую задавать параметры для конкретной зависимости(локально).
Например можно задавать dsn, username, password и тд.
alias
С помощью данного параметра можно заменить одну зависимость на другую.
alias: [
I::class => R2::class, // Глобальное имя
// Есть возможность задавать и локальные имена!
]
Вместо I::class будет использоваться R2::class, например так можно регистрировать реализацию интерфейса для всего проекта.
Alias можно задавать глобально и локально.
Чаще всего их задают глобально для всего проекта.
singletons
Позволяет управлять жизненным циклом зависимостей.
В singletons указываются зависимости, которые должны иметь жизненный цикл singleton, а именно создаваться в единичном экземпляре за все время жизни скрипта.
factories
Позволяет управлять жизненным циклом зависимости.
В factories указываются зависимости, которые должны иметь жизненный цикл factories, а именно создается каждый раз новый экземпляр.
Params (Параметры)
Параметры позволяют задавать конкретные значения для аргументов зависимостей:
- Встроенные типы (
string,int,arrayи т.д.) - Переопределять значение по умолчанию
- Конкретные экземпляры объектов (например если вы создали
$loggerвы можете использовать его и дальше)
Некоторые параметры могут разрешаться в момент использования, а не во время build Container, такие параметры называются Lazy (ленивые). С помощью таких параметров можно реализовать свою callback функцию, которая будет генерировать зависимость в особо сложных случаях. Подробней о Lazy (ленивых) параметрах.
Пример
src/Example.php
<?php
namespace App;
class Example
{
public function __construct(public string $username, public string $password){}
}
bin/build.php
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
$fqcn = 'App\Container';
$filename = __DIR__ . '/../src/Container.php';
file_put_contents(
$filename,
(new \Cekta\DI\ContainerBuilder(
fqcn: $fqcn,
entries: [\App\Example::class],
params: [
'username' => 'some username', // глобальное имя, всем кому потребуется username, будет использовано это значение.
\App\Example::class . '$password' => 'some password', // локальное имя, только для Example, аргумент с именем password будет иметь значение ...
]
))->build()
);
Параметры можно задавать глобально и локально.
Рекомендую задавать параметры для конкретной зависимости(локально).
Генерируем Container - build
php bin/build.php
index.php - usage
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
$params = []; // you current params
$container = new \App\Container([
'username' => 'actual username',
\App\Example::class . '$password' => 'actual password',
]);
$example = $container->get(\App\Example::class);
assert($example->username === 'actual username');
assert($example->password === 'actual password');
Обратите внимание что используется актуальное значение параметра которое передается при создании контейнера, а не то что было на этапе build.
Параметры, что использовались на этапе build считаются обязательными для создания Container, если вы их не передадите, получите соответствующее исключение.
Использовались != были объявлены. Использовались это значит они применялись для разрешения entries.
Alias (Псевдонимы)
Механизм псевдонимов позволяет заменять одну зависимость другой. Это полезно для:
- Выбора реализации интерфейса - указание, какой конкретный класс использовать для интерфейса.
- Выбора наследника абстрактного класса - определение конкретной реализации абстрактного класса.
- Замены зависимостей - использование подклассов вместо родительских классов.
- Сокращения имен - создание коротких псевдонимов для длинных имен классов
Пример
src/Example.php
<?php
namespace App;
class Example {
public function __construct(
public I $i,
public Base $base,
) {}
}
src/I.php
<?php
namespace App;
interface I {}
src/R2.php
<?php
namespace App;
class R2 implements I {}
src/Base.php
<?php
namespace App;
class Base {}
src/E1.php
<?php
namespace App;
class E1 extends Base {}
bin/build.php - build
<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
$fqcn = 'App\Container';
$filename = __DIR__ . '/../src/Container.php';
file_put_contents(
$filename,
(new \Cekta\DI\ContainerBuilder(
fqcn: $fqcn,
entries: [\App\Example::class],
alias: [
I::class => R2::class, // Для I используем R2
Base::class => E1::class, // Для Base используем E1
],
))->build()
);
Генерируем Container
php bin/build.php
index.php - использование (use)
<?php
declare(strict_types=1);
namespace App;
$container = new \App\Container([]);
$example = $container->get(Example::class);
$example->i instanceof R2; // true
$example->base instanceof E1; // true
Важно: Псевдонимы устанавливаются на этапе компиляции. Для их изменения требуется повторная генерация контейнера.
🕐 Lazy-параметры (отложенные значения)
Некоторые параметры могут разрешаться в момент использования, а не во время build Container, такие параметры называются Lazy (ленивые). С помощью таких параметров можно реализовать свою callback функцию, которая будет генерировать зависимость в особо сложных случаях:
- Нужно внедрять зависимости не только в конструктор, но и например в методы после создания объекта.
- Нужно получить текущий Container, для различных service locator которым требуется текущая реализация ContainerInterface.
- Можно генерировать различные значения на основе других, например dsn строку подключения к бд, на основе db_type, db_host, db_name и тд.
Если параметр реализует Lazy interface, то он будет загружаться в runtime.
Пример внедрения зависимостей через метод.
Может быть ситуация когда требуется внедрить зависимость не через конструктор, а через метод или другими более сложными способами.
src/Example.php
<?php
namespace App;
class Example
{
private \PDO $connection;
public function setPdo(\PDO $connection)
{
// inject via method
$this->connection = $connection;
}
}
src/Config.php
<?php
namespace App;
class Config
{
public function __construct(private array $env)
{
}
public function __invoke(): array
{
return [
\App\Example::class => new \Cekta\DI\Lazy\Closure(
function (\Psr\Container\ContainerInterface $c) {
$example = new \App\Example();
$example->setPdo($c->get(\PDO::class));
return $example;
}
),
\PDO::class . '$dsn' => $this->env['DB_DSN'] ?? 'sqlite:./db.sqlite',
];
}
}
bin/build.php
<?php
require_once __DIR__ . '/../vendor/autoload.php';
(new \Cekta\DI\ContainerBuilder(
entries: [\App\Example::class, \PDO::class],
fqcn: '\App\Container',
params: (new \App\Config(getenv() + $_ENV))(),
))->build();
index.php
<?php
reqire_once __DIR__ . '/vendor/autoload.php';
$container = new \App\Container((new Config(getenv() + $_ENV))());
var_dump($container->get(\App\Example::class));
\Cekta\DI\Lazy\Closure использую callback функцию создает любое значение. Вы можете задать любую callback функцию и внедрять любым способом.
Вы можете управлять жизненном циклом данных зависимостей.
Рекомендация:
- Если параметры используются внутри LazyClosure, добавьте их в entries для гарантии доступности.
- Передавайте зависимости через конструктор.
Пример передачи ContainerInterface в Service Locator.
Service Locator для своей работы может требовать ContainerInterface, чтобы разрешать зависимости.
src/Example.php
<?php
namespace App;
class Example
{
public function __construct(private \Psr\Container\ContainerInterface $container) {
}
}
src/Config.php
<?php
namespace App;
class Config
{
public function __construct(private array $env)
{
}
public function __invoke(): array
{
return [
\Psr\Container\ContainerInterface::class => new \Cekta\DI\Lazy\Container(),
];
}
}
bin/build.php
<?php
require_once __DIR__ . '/../vendor/autoload.php';
(new \Cekta\DI\ContainerBuilder(
entries: [\App\Example::class],
fqcn: '\App\Container',
params: (new \App\Config(getenv() + $_ENV))(),
))->build();
index.php
<?php
reqire_once __DIR__ . '/vendor/autoload.php';
$container = new \App\Container((new Config(getenv() + $_ENV))());
var_dump($container->get(\App\Example::class));
Внедрить текущую реализацию Container можно с помощью Lazy параметра \Cekta\DI\Lazy\Container.
Управление жизненным циклом зависимостей
Библиотека предоставляет три типа жизненного цикла для зависимостей, что особенно полезно в долгоживущих процессах:
- Приложениях на RoadRunner или FrankenPHP
- Фоновых workers
- В консольных командах, обрабатывающих множество задач
Вы можете управлять жизненным циклом любых зависимостей: entries, params, alias и autowiring.
📋 Демонстрация разницы.
<?php
declare(strict_types=1);
namespace App;
class Scoped {}
class Singleton {}
class Factory {}
new \Cekta\DI\ContainerBuilder(
entries: [
\App\Scoped::class,
\App\Singleton::class,
\App\Factory::class,
],
fqcn: 'App\\Container',
singletons: [\App\Singleton::class], // Singleton-зависимости
factories: [\App\Factory::class], // Factory-зависимости
// Scoped-зависимости не указываются явно (используются по умолчанию)
)->build();
index.php - Usage (Использование)
<?php
declare(strict_types=1);
namespace App;
function testLifecycle(string $className) {
$container1 = new \App\Container();
$container2 = new \App\Container();
$a = $container1->get($className);
$b = $container1->get($className); // Второй запрос к тому же контейнеру
$c = $container2->get($className); // Запрос к другому контейнеру
echo "Внутри одного контейнера: " . ($a === $b ? "одинаковый" : "разный") . "\n";
echo "Между разными контейнерами: " . ($a === $c ? "одинаковый" : "разный") . "\n";
echo "---\n";
}
echo "Scoped (по умолчанию):\n";
testLifecycle(Scoped::class);
echo "Singleton:\n";
testLifecycle(Singleton::class);
echo "Factory:\n";
testLifecycle(Factory::class);
Результат:
Scoped (по умолчанию):
Внутри одного контейнера: одинаковый
Между разными контейнерами: разный
---
Singleton:
Внутри одного контейнера: одинаковый
Между разными контейнерами: одинаковый
---
Factory:
Внутри одного контейнера: разный
Между разными контейнерами: разный
🎯 Сравнение жизненных циклов
| Тип | Внутри одного контейнера | Между разными контейнерами | Когда использовать |
|---|---|---|---|
| Scoped ⭐ | Один объект | Разные объекты | Обработка запросов, пользовательские сессии |
| Singleton | Один объект | Один объект | Конфигурация, подключения к БД, кеши |
| Factory | Разные объекты | Разные объекты | Stateless-сервисы, DTO, временные данные |
⚠️ Важные замечания
- Scoped по умолчанию - если не указан другой тип, используется Scoped
- Конфликты приоритетов - нельзя указать один класс одновременно как Singleton и Factory
- Производительность - Factory создаёт наибольшую нагрузку, Singleton - наименьшую
- Потокобезопасность - Singleton должен быть потоко-безопасным в многопоточных средах
🚀 Пример для долгоживущего приложения
<?php
new \Cekta\DI\Compiler(
entries: [
HttpController::class,
UserRepository::class,
EmailService::class,
],
singletons: [
Database::class, // Одно подключение
RedisCache::class, // Общий кеш
Config::class, // Конфигурация
],
factories: [
HttpRequest::class, // Новый для каждого запроса
UserSession::class, // Новый для каждого пользователя
],
);
Правильное управление жизненным циклом позволяет:
- Экономить ресурсы (Singleton)
- Изолировать данные (Scoped)
- Предотвращать утечки памяти (Factory)
- Легко масштабировать приложение
Как разрешается зависимости
Во время build, берется каждая зависимость указанная в entries и пытаются построить полную карту зависимостей любой вложенности.
Построение карты зависимостей осуществляется только на этапе build, в дальнейшем используется уже созданная структура зависимостей.
Разрешение зависимости
Если зависимость указана в params или alias, то она разрешается согласно данной инструкции. В остальных случаях пытаемся сделать autowiring в конструктор.
Autowiring в конструктор.
Механизм с помощью которого анализируется аргументы конструктора класса.
В случае если вы делаете autowiring в несуществующий класс или экземпляр класса нельзя создать (абстрактный класс), то будет выброшено исключение с деталями проблемы.
На основе аргументов конструктора формируются имена зависимостей которые необходимо передавать, чтобы создать экземпляр данного класса.
Если для конкретного аргумента конкретной зависимости указаны конфигурация как создавать эти его создавать с помощью params или alias, то используются эта конфигурация.
Если аргумент имеет значение по умолчанию, то используется значение указанное по умолчанию.
В остальных случаях каждая найденная зависимость разрешается сначала.
Приоритет загрузки зависимостей.
Библиотека предоставляет несколько способов определения зависимостей. Важно понимать, в каком порядке они применяются - это помогает избежать неожиданного поведения и правильно настроить контейнер.
<?php
namespace App;
class Example
{
public function __construct(
public \stdClass $std_class,
) {
}
}
$class1 = new \stdClass();
$class1->id = 1;
$class2 = new \stdClass();
$class2->id = 2;
$class3 = new \stdClass();
$class3->id = 3;
$class4 = new \stdClass();
$class4->id = 4;
(new Cekta\DI\ContainerBuilder(
entries: [Example::class],
params: [
Example::class . '$' . \stdClass::class => $class1,
\stdClass::class => $class3,
'class2' => $class2,
'class4' => $class4,
],
alias: [
Example::class . '$' . \stdClass::class => 'class2',
\stdClass::class => 'class4',
],
))->build();
При запросе Example::class контейнер ищет зависимость в следующем порядке:
| Приоритет | Способ | В нашем примере |
|---|---|---|
| 1 | Локальное имя из params | Example::class . '$' . \stdClass::class резульатат $class1 |
| 2 | Локальное имя из alias | Example::class . '$' . \stdClass::class резульатат $class2 |
| 3 | Глобальное имя из params | \stdClass::class результат $class3 |
| 4 | Глобальное имя из alias | \stdClass::class результат $class4 |
| 5 | Значение по умолчанию | значение по умолчанию не заданно в конструкторе и будет пропущенно |
| 6 | Autowiring в конструктор | Создает \stdClass через Autowiring |
Как определяется имя зависимости
Как формируются имена зависимостей
Лучше рассмотреть конкретный пример.
<?php
namespace App;
class Example
{
public function __construct(
int $arg_1,
Foo $arg_2,
string|int $arg_3,
int|string $arg_4,
Foo|string $arg_5,
string|Foo $arg_6,
?int $arg_7,
?Foo $arg_8,
$arg_9,
I $arg_10
Foo|int ...$arg_11,
) {
}
}
class Foo {}
interface I {}
| Номер аргумента | Имя аргумента | Глобавльное имя | Локальное имя |
|---|---|---|---|
| 1 | arg_1 | “arg_1” | “App\Example$arg_1 |
| 2 | arg_2 | “App\Foo” | “App\Example$arg_2” |
| 3 | arg_3 | “string|int” | “App\Example$arg_3” |
| 4 | arg_4 | “string|int” | “App\Example$arg_4” |
| 5 | arg_5 | “App\Foo|string” | “App\Example$arg_5” |
| 6 | arg_6 | “App\Foo|string” | “App\Example$arg_6” |
| 7 | arg_7 | “arg_7” | “App\Example$arg_7 |
| 8 | arg_8 | “?App\Foo” | “App\Example$arg_8” |
| 9 | arg_9 | “arg_9” | “App\Example$arg_9” |
| 10 | arg_10 | “App\I” | “App\Example$arg_10” |
| 11 | arg_11 | “…App\Foo|int” | “…App\Example$arg_11” |
Имя аргумента != имени зависимости
Необходимо разделять имя аргумента и имя зависимости, это разные вещи.
Имя зависимости это более сложная вещь, которая может в себя включать имя аргумента.
Глобальное vs Локальное имена зависимостей.
Глобальное имя зависимости используется на уровне всего проекта, использовать такие имена удобно чтобы один раз зарегистрировать значение и использовать его в разных зависимостях, например реализацию интерфейса или абстрактного класса.
Локальное имя это возможность переопределить зависимость в конкретном ОДНОМ месте, точечно.
С помощью таких имен удобно задавать примитивные параметры вроде логинов или паролей и тд.
Правила формирования локальных имен.
В общем виде именование выглядит следующим образом:
$prefix . $fqcn . '$' . $argument_name
$prefix = '...' если аргумент является variadic, в остальных случаях $prefix = ''.
$fqcn - это полное имя куда внедряется зависимость.
$argument_name это имя аргумента.
Правила формирования глобальных имен.
Если у аргумента не указан тип или он является primitive (string, int, bool, array и тд), то:
$prefix . $argument_name
В остальных случаях:
$prefix . (string)$type
$prefix = '...' если аргумент является variadic, в остальных случаях $prefix = ''.
$argument_name - имя аргумента.
Самое интересное тут как PHP приводит тип к строке.
Если тип является nullable то в начале ему указывается ?.
Если тип является составным (Union, или Intersection, или DNF) тогда тип сортируется в “определенной” последовательности, мало кому известной и нигде не зафиксированной.
В приведенном выше примере посмотрите глобальные имена для аргумента 3 и 4, в конструкторе порядок у них разный, а php приводит их к одинаковому имени, а также посмотрите на аргумент 5 и 6 из таблицы, ситуация аналогичная.
Поддержка
Если у вас остались вопросы по текущей документации, или есть проблема с настройкой в вашем проекте вы можете:
- Оставить issue https://github.com/cekta/di/issues с вашим вопросом на EN или RU языке.
- Русскоязычный чат https://t.me/dev_ru
Если есть и предложения по улучшению, можно создавать issue или озвучивать их чате.
Проект открыт для приема MR по улучшению!!!