Здравствуйте. В этой статье я буду рассказывать про свой взгляд на альтернативу dependency контейнеров для PHP и про библиотеку, которою я построил как реализацию этой идеи. Я начал размышлять про то, как можно сделать DI удобным, еще 2 года назад. Время от времени я добавлял новый функционал и исправлял ошибки. Кому интересно - буду рад рассказать более детально.
Почему мне вообще пришла мысль переделывать уже всем известный шаблон (или технику) DI. На это есть своя причина. И не потому, что я считаю, что изобрел, что-то новое. Может, удобное - да, но не новое. Причина в том, что мне нравится делать что-то свое в мире программирования. Я работал не только над этой библиотекой, у меня есть и другие велосипеды. Есть даже свой фреймворк на JS. Это пишу для того, чтобы читатель не ожидал чего-то необычного. Также: в этой статье я не претендую на истину, я допускаю (знаю), что могу ошибаться. Обратите, пожалуйста, на это внимание.
Немного истории. Изначально, для работы с опциями класса я использовал OptionsResolver от Symfony: нужна была какая-то структура, так как случалось, что было много опций. Требовалось проводить валидацию опций, исключить возможность ошибки в названии опции, проверить тип. В общем все, для чего используют это устаревший Resolver. Почему не конструкторы? Слишком громоздко. Особенно, если, опять же, много опций. Нужно постоянно соблюдать очередность параметров конструктора. В реальном мире все постоянно меняется, и если изменяется сигнатура конструктора - надо обходить все клиенты. Поэтому, обычно, конструктор создается раз и если меняется, то разве что несколько раз. Здесь можно еще напомнить о дефолтных опциях, которые также могут жить в конструкторе. Но настоящая проблема для меня начиналась при наследовании. В этом случае я даже не смог придумать что-то толковое.
Но - есть DI. Кто-то скажет. Контейнер сам определяет, что нужно классу по типах его параметрах в конструкторе. Для меня лично это значит, что я не могу передать дополнительную опцию при вызове нужного класса. Для этого есть настройка контейнера, где можно передать нужные дополнительные данные. У Symfony это "services.yaml". Но все же - есть ситуаций, когда надо передать опцию из класса в класс. Для этого можно использовать сеттеры. Насколько удобно? Не знаю, мне не удобна ни настройка контейнера, ни сами сеттеры.
Идея, которую я собираюсь описать, теперь уже не новая. Насколько я знаю, в Symfony 5 немного упростили этот процесс - https://symfony.com/doc/current/servicecontainer/servicesubscribers_locators.html#service-subscriber-trait. Но я думаю, что это только подтверждение того, что с обычным DI что-то не так.
Моя идея в том, что сам контейнер скрытый и для создания экземпляров класса нужен только сам класс. Зависимости определяет сам класс, а не контейнер.
Для начала, добавим нужный трейт к своему классу:
class ClassWithOptions
{
use \Mrself\Options\WithOptionsTrait;
}
Далее, можно добавить свойства:
class ClassWithOptions {
use WithOptionsTrait;
/**
* @Option
* @var \DateTime
*
* Make it public just to assert that option was set
*/
public $option1;
}
$instance = ClassWithOptions::make([
'option1' => new \DateTime()
]);
($instance->option1 instanceof \DateTime) === true;
Для обозначения опций (или зависимостей) класса используется аннотация "@Option". Свойство класса с этой аннотацией также может иметь свой тип, указанный с помощью "@var". Если тип указан, проводится валидация опции: имеет ли она нужный тип. То есть, в следующем примере будет брошено исключение:
ClassWithOptions::make([
'option1' => 'invalidStringType'
]);
Кроме этого, тип свойства (если это не примитив) также используется как идентификатор зависимости в контейнере, если опция не была передана при создании класса.
Также, кроме простого способа инициализации классов из опциями, эта библиотека имеет свою систему контейнеров. Как это работает.
Для создания контейнеров и добавления зависимостей, используется такой подход:
$container = \Mrself\Container\Container::make();
$dependency = new SharedDependency();
$container->set(SharedDependency::class, $dependency);
Здесь ничего необычного. Но что важно заметить, название зависимости в контейнере должен быть namespace самой зависимости. В этом случае, зависимость будет извлекаться по типу свойства класса, как у предыдущем примере. Но это ещё не все. Библиотека также определяет такую сущность, как регистр контейнеров. Это такой себе контейнер контейнеров. Чтобы сделать контейнер видимым для поиска зависимости, нужно добавить контейнер в регистр контейнеров.
Регистр контейнеров (ContainerRegistry) управляет контейнерами и видим из любого места благодаря тому, что использует только статические методы и свойства. Итак, как добавить контейнер:
$appContainer = Container::make();
\Mrself\Container\Registry\ContainerRegistry::add('App', $appContainer);
Почему "App"? Здесь предполагается, что корень namespace любого класса в вашем приложении как раз "App". Этот идентификатор будет использоваться для получении нужного контейнера, который, в свою очередь, нужен для извлечения зависимости по типу, определенным в "@var". И все в месте:
namespace App;
$container = \Mrself\Container\Container::make();
$dependency = new SharedDependency();
$container->set(SharedDependency::class, $dependency);
$appContainer = Container::make();
\Mrself\Container\Registry\ContainerRegistry::add('App', $appContainer);
class ClassWithOptions {
use WithOptionsTrait;
/**
* @Option
* @var SharedDependency
*/
public $option1;
};
$instance = ClassWithOptions::make();
($instance->option1 instanceof SharedDependency) === true;
В основном - это все. Может, очень просто, но сама библиотека вышла немалой. Здесь даже не одна библиотека, а две: https://github.com/mrself/php-options и https://github.com/mrself/php-container. Изначально я думал, что есть смысл делать их отдельными. Но сейчас задумываюсь о том, как их объединить. Так будет правильнее, так как они "нуждаются друг в друге".
Здесь уместно сказать, что моя реализация контейнера далеко не лучшая. Я это вполне понимаю, и для этого создал библиотеку для Symfony - https://github.com/mrself/symfony-container.
Еще пару слов о контейнерах. Для удобства я создал ServiceProvider (https://github.com/mrself/php-container/blob/master/src/ServiceProvider.php), который управляет созданием и инициализацией контейнеров для отдельных библиотек. Как пример, https://github.com/mrself/php-sync/blob/master/src/SyncProvider.php :
class SyncProvider extends ServiceProvider
{
protected function getContainer(): Container
{
return Container::make();
}
protected function getNamespace(): string
{
return 'Mrself\\Sync';
}
protected function getDependentProviders(): array
{
return [
PropertyProvider::class
];
}
}
Здесь создается пустой контейнер, но, конечно, его можно настроить под свои нужды. Например, добавить зависимости. "getNamespace" - этот метод возвращает namespace самой библиотеки. И "getDependentProviders" описывает зависимые провайдеры.
Спасибо за внимание, жду любых комментариев.