Про сервис-контейнеры

Про сервис-контейнеры
turkishjoe, php5 лет назад

Всем привет!

Сегодня, наверное, мой первый технический длиннопост, если не считать моего диплома. Посвящен он теме создания объектов. А именно применения паттерна фабричный метод с IOC контейнером.

Рассмотрим задачу. Есть пользователи. Необходимо реализовать отправку сообщений по определенному каналу связи(в нашем примере будут телеграмм и слак). При этом пользователи хотят иметь свои bot_api_token, chatId.

Начнем решение. Создадим таблицу users: id, provider, providerKey, chatId. Где:

  • Id - id пользователя
  • Provider - строка, характеризующая канал связи. telegram/slack.
  • ProviderKey - строка, Bot api token. В рамках данной статьи это некоторое упрощение, что доступ к каналу связи будет осуществляться по одному ключу, как это можно сделать в slack и telegram. В общем случае это поле будет providerOptions(например, json)
  • chatId - id чата.

Таким образом имея NoNameORM(Объект User), будет какой-то такой интерфейс.

/**
* Interface SenderInterface
* @package App\Service\Sender
*/
interface SenderInterface
{
   /**
    * @param User $user
    * @param string $message
    * @return mixed
    */
   public function send(User $user, string $message);
}

И примерно такая фабрика(где TelegramService и SlackService реализуют SenderInterface)

class SimpleFactory
{
   private array $providerMap = [
       'telegram'=>TelegramService::class,
       'slack'=>SlackService::class
   ] ;

   /**
    * @param User $user
    * @param string $message
    */
   public function create(User $user): SenderInterface {
       //TODO: 100500 валидации и exception

       return new $this->providerMap[$user->provider];
   }
}

И далее где-нибудь в контролере/консольной команде будет что-то вроде

$user = User::find($this->argument('user'));
$factory = new SimpleFactory();
$factory->create($user)->send($user, $this->argument('message'));

Все хорошо до того момента, пока в нашем приложений не появляется DI-контейнер(в дальнейшем будет использоваться пример laravel-приложения, но он был взят исключительно потому что был “под рукой”), который используется во всех крупных приложениях. А именно в том, что объекты создаются через new. Чем это плохо? Нарушается концепция DI. То есть фабрика отвечает за создание объекта, хотя по идее мы должны обязанность передать контейнеру. И если у объектов, реализующих SenderInterface не будет зависимостей или они будут однотипны, это еще не так критично. Эти завиcимости можно передать в фабрику и инизилизовать объекты.

class SecondFactory
{
   private array $providerMap = [
       'telegram'=>TelegramService::class,
       'slack'=>SlackService::class
   ] ;

   private $dependency1;
   private $dependency2;

   public function __construct($dependency1, $dependency2)
   {
       $this->dependency1 = $dependency1;
       $this->dependency2 = $dependency2;
   }

   /**
    * @param User $user
    * @param string $message
    */
   public function create(User $user): SenderInterface {
       //TODO: 100500 валидации и exception

       return new $this->providerMap[$user->provider]($dependecy1, $dependecy2);
   }
}

Но что если зависимости разнотипные? Например, РКН заблокировал телеграмм и пытается убивать прокси по одному. Для того чтобы ваши боты работали, надо выносить на иностранные сервера, как и весь ваш бизнес надо создать объект ProxyManager, который будет находить “здоровые” прокси и отправлять через них запрос. Вот ProxyManager уже прокидывать в фабрику не стоит. Если бы речь шла об этом или фиксированном количестве аккаунтов и сервисов, мы бы просто прописали бы их в конфиге и помощью подобных умений(https://laravel.com/docs/7.x/container#binding-interfaces-to-implementations), без труда бы решили эту проблему, а ключи прописали в конфигурации сервиса.

$this->app->bind(
    SenderInterface::class,
    TelegramService::class,
);

Но в нашем случае, ключи берутся из БД. То есть заранее неизвестно какой ключ будет. C подобной проблемой я столкнулся на работе. Ну и недолго думая, плюнув на все, написал что-то подобное.

class StupidFactory
{
   private array $providerMap = [
       'telegram'=>TelegramService::class,
       'slack'=>SlackService::class
   ] ;

   private $container;

   public function __construct(ContainerInterface $container)
   {
       $this->container = $container;
   }

   /**
    * @param User $user
    * @param string $message
    */
   public function create(User $user): SenderInterface{
       //TODO: 100500 валидации и exception

       return $this->container->get($this->providerMap[$user->provider]);
   }
}

На что мой тимлид небезосновательно спросил, "не охерел ли я прокидывать контейнер в сервис?" и был прав. На что я сказал “У нас такой бизнес-кейс, грех не засчитывается”, но тимлида, опять же справедливо, такой ответ не устроил. И он предложил подобие следующего варианта.

class NotSooStupidFactory
{
   /**
    * @var TelegramService $telegramService
    */
   private $telegramService;

   /**
    * @var SlackService $slackService
    */
   private $slackService;

   public function __construct(
       TelegramService $telegramService,
       SlackService $slackService
   )
   {
       $this->telegramService = $telegramService;
       $this->slackService = $slackService;
   }

   /**
    * @param User $user
    * @param string $message
    */
   public function create(User $user, string $message):  SenderInterface{
       //TODO: 100500 валидации и exception

       if($user->provider == 'telegram'){
           $service = $this->telegramService;
       }else{
           $service = $this->slackService;
       }

       return $service;
   }
}

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

  • Пришлось бы добавлять для каждого провайдера, новое свойство.
  • Теряется ленивая инициализация в контейнере
  • Про всякие if я промолчу так как это допущение иллюстрации.

И если с 1 и 3 можно что-то решить. Добавить провайдеры в коллекцию, как сделано, например, вот тут https://www.codeproject.com/Articles/1206764/Clean-Factory-Design-Pattern-with-IoC-Container (c#), то что делать с lazy инициализацией непонятно. Потому, я начал топить, что я лучше в сервис прокину контейнер, чем пожертвую этим. На момент дискусcии с тимлидом, выбор между этими двумя стульями особо не стоял. В целом, оба варианта годились, но мне не хотелось так это оставлять, да и к единому решению мы так и не пришли. А что если завтра будет задача, которую я описал? Да и в целом это читерство. Начав гуглить, сходу не пришел к ответу, и чтобы прийти к компромиcсу, мы решили применить паттерн “инкапсуляция зашкварного кода”. И сделали следующее.

class SubContainer
{
   private $container;

   public function __construct(ContainerInterface $container)
   {
       $this->container = $container;
   }

   /**
    * @param string $message
    */
   public function get(string $id): SenderInterface{
       return $this->container->get($id);
   }
}


class IsolatedStupidFactory
{
   private array $providerMap = [
       'telegram'=>TelegramService::class,
       'slack'=>SlackService::class
   ] ;

   private $container;

   public function __construct(SubContainer $container)
   {
       $this->container = $container;
   }

   /**
    * @param User $user
    * @param string $message
    */
   public function get(User $user){
       //TODO: 100500 валидации и exception

      return $this->container->get($this->providerMap[$user->provider]);
   }
}

Коллегу такой вариант устроил, хоть понятно, что он далек от идеала. Чем этот вариант лучше:

  • Теперь контейнер прокидывается не в бизнес код. То есть условный “зашквар” не там, а некоторой вспомогательной сущности SubContainer
  • Сохранена lazy инициализация.

И это меня устраивало, хотя понятно, что это решение тоже “попахивает”. Спрашивая спецов, я либо не находил решение, либо ребята делали что-то примерно тоже самое(что как бы говорило о том, что это не только у меня такая проблема). И тут сегодня, я решил попробовать сделать следующее(кстати laravel был взят сначала, потому что лень было пилить подобие LazyCollection, которые и не пригодились, так что смело можно копипасть в php-di)

class NotificationServiceProvider extends ServiceProvider
{
   /**
    * Register any application services.
    *
    * @return void
    */
   public function register()
   {
       $this->app->singleton(SenderFactory::class, function (){
           $collection = collect();
           $collection->put('slack', function (){
               return $this->app->make(SlackService::class);
           });
           $collection->put('telegram', function (){
               return $this->app->make(TelegramService::class);
           });

           return new SenderFactory($collection);
       });
   }

   /**
    * Bootstrap any application services.
    *
    * @return void
    */
   public function boot()
   {
       //
   }
}

И тогда фабрика приобретет вид.

/**
* Class SenderFactory
* @package App\Service\Sender
*/
class SenderFactory
{
   /**
    * @var Collection
    */
   private Collection $serviceClosureCollection;

   /**
    * SenderFactory constructor.
    * @param Collection $senderCollection
    */
   public function __construct(Collection $senderCollection)
   {
       $this->serviceClosureCollection = $senderCollection;
   }


   /**
    * @param User $user
    * @param string $message
    */
   public function send(User $user, string $message):  SenderInterface{
       return $this->serviceClosureCollection->get($user->provider)();
   }
}

Итого:

  • Решилась проблема lazy инициализации
  • Не пришлось никуда прокидывать контейнер(в провайдере не считается, мы заранее задаем то как инициализируются объекты, когда как в SubContainer мы полноценно используем контейнер как сервис).

P.S. У меня нет никаких бечмарков, а Lazy инициалиализация, за которую так боролся, может быть нафиг никому не сдалась и все обертки, кложура заберут куда больше. Но данный код скорее демонстрация концепции, которую я либо предлагаю довести до ума, либо скиньте ваши варианты, которые вы знаете?

https://github.com/turkishjoe/laravel-ioc-factory-method - репозиторий с кодом. В ветке master только нужный код(слак не тестировался), в ветке demo весь код c примерами не относящийся к решению проблемы(StupidFactory и прочие).

UPD(25-08-2020): По многочисленным просьбам(и правильным просьбам) убрал send в фабриках. В репозотиории пока не убрал, там старый вариант.

Comments
turkishjoe      5 лет назад

Справделивое замечание коллег. Фабрика нужна для создания объектов. Потому метод send возможно не стоит там выполнять(хотя я видел и такие вариации)

  /**
    * @param User $user
    * @param string $message
    */
   public function send(User $user, string $message): SenderInterface {
       /**
        * @var $sender
        */
      return $this->serviceClosureCollection->get($user->provider)();
   }