Всем привет!
Сегодня, наверное, мой первый технический длиннопост, если не считать моего диплома. Посвящен он теме создания объектов. А именно применения паттерна фабричный метод с 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 в фабриках. В репозотиории пока не убрал, там старый вариант.
Справделивое замечание коллег. Фабрика нужна для создания объектов. Потому метод send возможно не стоит там выполнять(хотя я видел и такие вариации)
/** * @param User $user * @param string $message */ public function send(User $user, string $message): SenderInterface { /** * @var $sender */ return $this->serviceClosureCollection->get($user->provider)(); }