Еще одна реализация DI на PHP

Здравствуйте. В этой статье я буду рассказывать про свой взгляд на альтернативу 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" описывает зависимые провайдеры.

Спасибо за внимание, жду любых комментариев.

Ссылка на мой профиль.

Tags:
контейнер, опции, dependency injection, dependency management

You can't comment this post because its author is not yet a full member of the community. You will be able to contact the author only after he or she has been invited by someone in the community. Until then, author's username will be hidden by an alias.