Зависимости для сущностей и команд
Продолжаем беспощадный цикл статей про работу с зависимостями. После знакомства с сущностями и сервисами и рассмотрения основных способов внедрения зависимостей сегодня мы применим всё это на практике. И выберем что, куда и когда нам будет более удобно внедрять.
В этот вечер уютного пятничного деплоя заваривайте себе чего-нибудь погорячее. И мы начинаем.
Примерное время вдумчивого чтения: 62 минуты
Начнём с классических сервисов, где всё уже более-менее понятно. Но помимо сервисов затронем хранимые в базе данных сущности, а потом отдельно поговорим про использование контейнера при работе с командами и событиями.
Внедрение для сущностей и сервисов
Итак, мы уже поняли из предыдущей части, что у нас могут быть функции, которым для работы нужно что-то передать:
$function = createFunc($a, $b, $c); echo $function($d, $e, $f);
Или вместо процедур и функций у нас будут классы, которыми мы программируем не только сервисы, но и всевозможные сущности и объекты-значения. И в случае классов что-то одно мы можем также передать в конструктор, а что-то другое в метод:
$object = new Class($a, $b, $c); echo $object->method($d, $e, $f);
А передаём мы обычно пользовательские данные, параметры конфигурации и другие сервисы. Для наглядности будущих примеров мы можем обозначить их более понятными именами:
$config
– фиксированные параметры из конфигурации, которые не меняются во время работы приложения$service
– другие сервисы-зависимости, которые тоже не меняются в процессе работы$data
– обрабатываемые пользовательские данные из запросов, которые всегда разные
Как мы сказали, передавать эти элементы мы можем в конструктор и в методы. Но как именно их передавать и какой подход будет удобнее? Рассмотрим разные комбинации передачи и выберем удобные варианты.
Всё в метод
Здесь мы просто без разбора все значения передаём аргументами в функцию или метод:
class A { function method($config, $service, $data) } $a = new A(); echo $a->method($config, $service, $data1); echo $a->method($config, $service, $data2);
Это самый простой вариант. Мы помним, что это не очень удобно из-за необходимости копипасты передачи повторяющихся аргументов при каждом вызове.
И есть противоположная этой крайность.
Всё в конструктор
В этом варианте мы можем, наоборот, абсолютно всё передавать в конструктор. И уже после этого вызывать метод без передачи аргументов:
class A { function __construct($config, $service, $data) function method() } $a1 = new A($config, $service, $data1); echo $a1->method(); $a2 = new A($config, $service, $data2); echo $a2->method(); $a3 = new A($config, $service, $data3); echo $a3->method();
Объект сохраняет в себе переданные ему данные и вспомогательные зависимости, а потом внутри метода их использует.
Как вариант, можно после создания объекта передавать в метод дополнительные данные:
class A { function __construct($config, $service, $data) function method($otherData) } $a1 = new A($config, $service, $data1); $a1->method($otherData1); $a2 = new A($config, $service, $data2); $a2->method($otherData2); $a3 = new A($config, $service, $data3); $a3->method($otherData3);
Это нужно, если мы захотим поменять или добавить какое-то значение. Или если хотим посчитать что-то ещё на основе дополнительных данных.
Для чего удобно использовать вариант передачей всего в конструктор?
Как мы рассматривали в прошлой части, в проекте чаще всего мы пишем как сущности для хранения данных с записью в БД, так и сервисы для выполнения вспомогательных операций, которым не нашлось места в сущностях.
Удобно ли в таком стиле работать с хранимыми в БД сущностями?
Вспомним наш пример с User
. Раньше мы передавали в сущность готовый хэш пароля:
$user = new User($id, $email, $passwordHash); ... $user->changePassword($passwordHash);
Но у такого подхода есть риск, что другой программист вместо хэша может передать туда исходный пароль. Тогда этот пароль в открытом виде сохранится в БД, что небезопасно.
Чтобы у нас не было такого риска, мы можем принимать в конструктор исходный пароль и сам хэшер:
$user = new User($id, $email, $password, $passwordHasher);
и уже внутри высчитывать хэш:
class User { ... public function __construct($id, $email, $password, $hasher) { $this->id = $id; $this->email = $email; $this->passwordHash = $hasher->hash($password); } }
А если этот хэшер нам понадобится и дальше в методе changePassword
, то мы как раз по рассматриваемому подходу можем этот хэшер присвоить внутрь объекта в приватное поле:
class User { private PasswordHasher $hasher; ... public function __construct($id, $email, $password, $hasher) { … $this->hasher = $hasher; } }
и потом уже использовать $this->hasher
в нужном методе:
class User { private PasswordHasher $hasher; ... public function changePassword($password) { $this->passwordHash = $this->hasher->hash($password); } }
Да и даже без риска записи исходного пароля такой подход имеет смысл, если смена пароля более сложная. Например, мы можем дополнить метод changePassword
проверкой текущего пароля перед присваиванием нового:
class User { ... public function changePassword($current, $new): void { if (!$this->hasher->validate($current, $this->hash)) { throw new DomainException(‘Incorrect current password.’); } $this->hash = $this->hasher->hash($password); } }
Такой подход надёжно прячет хэш, так как работа с хэшами производится внутри объекта. То есть так мы полностью соблюдаем инкапсуляцию с сокрытием.
Теперь попробуем создать несколько пользователей, передав всё в конструктор, и попробуем их сохранить в базу данных через какой-либо объект-хранилище $users
:
$user1 = new User($id1, $email1, $password1, $hasher); $users->save($user1); $user2 = new User($id2, $email2, $password2, $hasher); $users->save($user2);
А потом пробуем достать из БД первого пользователя, изменить его пароль и сохранить обратно:
$user = $users->get($id1); $user->changePassword($password); $users->save($user);
В итоге у нас получился класс User
, содержащий пользовательские данные и зависимость $hasher
:
class User { private string $id; private string $email; private string $passwordHash; private PasswordHasher $hasher; public function __construct($id, $email, $password, $hasher): void { $this->id = $id; $this->email = $email; $this->hasher = $asher; $this->passwordHash = $this->hasher->hash($assword); } public function changePassword($password): void { $this->passwordHash = $this->hasher->hash($password); } }
Всё вроде бы хорошо. Но есть два нюанса:
Во-первых, если рассматривать сохранение такого объекта, то нам не очень подойдут готовые ORM. Выбранную или свою ORM нужно научить тому, что поле $hasher
не надо сохранять в БД, так как там находится не простое значение, а сервис. И что потом при восстановлении объекта после доставания из БД нужно достать из контейнера и поместить обратно в это поле сервис хэшера.
Во-вторых, если у сущности будет несколько методов, использующих разные сервисы, то нам придётся передавать в конструктор и сохранять в приватные поля их все. Это не очень удобно. И это не очень производительно создавать десяток сервисов, если для какого-то метода нужен всего один из них.
Так что такой подход с передачей данных, параметров и сервисов в конструктор сущности можно использовать, но возможны проблемы с ORM.
Да и просто нам не хотелось бы смешивать сущности с сервисами. Хранение зависимых сервисов внутри других сервисов и хранение сущностей и объектов-значений в других сущностях нам понятно, а вот хранение сервисов в сущностях выглядит не очень логично.
Эти неудобства касаются использования этого подхода с передачей данных и зависимостей в конструктор для сущностей. Для них нужно поискать какой-то более удобный вариант.
Но что для сервисов?
Например, если наш PasswordHasher
будет принимать всё в конструктор:
class PasswordHasher { function __construct($cost, $password) function hash() }
То чтобы захэшировать им три пароля, нам нужно будет создать три хэшера, каждый раз передавая одни и те же параметры конфигурации:
$hasher1 = new PasswordHasher(16, $password1); echo $hasher1->hash(); $hasher1 = new PasswordHasher(16, $password2); echo $hasher2->hash(); $hasher1 = new PasswordHasher(16, $password3); echo $hasher2->hash();
Сервис получился одноразовый. Нельзя для экономии создать (или достать из сервис-контейнера) один хэшер и вычислить им все три значения.
Нам мешает смешивание в конструкторе фиксированных конфигурационных параметров с изменяемыми пользовательскими данными.
Так что этот подход не очень удобен для сущностей и вообще неудобен для сервисов.
Чтобы можно было создать хэшер один раз и вызывать его повторно нам нужно избавить конструктор от изменяемых данных. Для этого можно просто переместить пользовательские данные в метод.
Параметры в конструктор и данные в метод
В конструктор будем принимать все неизменяемые параметры конфигурации и вспомогательные сервисы, а изменяемые пользовательские данные будем принимать в метод:
class A { function __construct($config, $service) function method($data) }
Если мы так спрограммируем наш хэшер, то работа будет удобнее и экономнее:
$hasher = new PasswordHasher(16); echo $hasher->hash($password1); echo $hasher->hash($password2); echo $hasher->hash($password3);
Так мы можем создать один экземпляр сервиса и использовать его повторно.
Если внутри такого объекта данные из $data
при вызове метода никуда не сохраняются, то эти повторные вызовы мешать друг другу не будут.
Так что этот подход удобен для сервисов. Можно спокойно зарегистрировать такой сервис в DI-контейнере:
$container->set(PasswordHasher::class, function () { return new PasswordHasher(16); });
Теперь контейнер создаст всего один экземпляр этого сервиса и будет автоматически инжектить его во все наши классы сервисов вроде SignUp\Handler
, которым он нужен.
Получаем удобство и экономию производительности.
Но при запуске PHP-скриптов в PHP-FPM эта экономия не сильно будет видна, так как при каждом HTTP-запросе PHP-FPM получает из пула (или создаёт) новый процесс и в нём с нуля запускает index.php
. Там у нас создаётся новый пустой контейнер и все сервисы конструируются заново.
Экономия будет видна в должгоживущих асинхронных приложениях, которые одним запущенным экземпляром приложения обрабатывают несколько запросов. Там однажды созданный контейнером хэшер может жить вечно. Но об этом поговорим позже, когда будем рассматривать сервисы с состоянием и контексты запроса.
А пока рассмотрим четвёртый вариант.
Данные в конструктор и зависимости в метод
Здесь мы, наоборот, неизменяемые зависимости передаём в метод, а начальные пользовательские данные передаём в конструктор:
class A { function __construct($data) function method($config, $service) }
Здесь нам для каждого набора данных нужно создать новый экземпляр:
$a1 = new A($data1); echo $a1->method($config, $service); $a2 = new A($data2); echo $a2->method($config, $service); $a3 = new A($data3); echo $a3->method($config, $service);
И если данные внутри объекта нужно будет менять, то мы можем передавать дополнительные данные тоже в метод:
class A { __construct($data) method($otherData, $config, $service) } $a1 = new A($data1); echo $a1->method($otherData1, $config, $service); $a2 = new A($data2); echo $a2->method($otherData2, $config, $service); $a3 = new A($data3); echo $a3->method($otherData3, $config, $service);
Где это может быть полезно?
Вспомним наш пример с сущностью User
, когда мы передаём в конструктор данные и хэшер и потом используем этот хэшер внутри:
$user = new User($id, $email, $password, $hasher); ... $user->changePassword($password);
Там мы сказали, что хранение $hasher
внутри сущности будет мешать работе ORM при сохранении сущности и извлечении её из БД.
И при необходимости использования нескольких сервисов это захламляет конструктор. Это будет неудобно в юнит-тестах, так как придётся делать много стабов.
Можно ли эти неудобства решить?
Если нам мешает сохранение сервисов внутри сущности, мы можем сервисы там не хранить. Вместо этого мы можем передавать сервисы снаружи непосредственно только в нужный метод.
То есть если в нашей сущности хэшер нужен только в конструкторе и методе changePassword
, то туда хэшер и передаём:
$user = new User($id, $email, $password, $hasher); ... $user->changePassword($password, $hasher);
И внутри просто сразу используем переданный объект, никуда его не присваивая:
class User { private string $id; private string $email; private string $passwordHash; public function __construct($id, $email, $password, $hasher): void { $this->id = $id; $this->email = $email; $this->passwordHash = $hasher->hash($password); } public function changePassword($current, $new, $hasher): void { if (!$hasher->validate($current, $this->hash)) { throw new DomainException(‘Incorrect current password.’); } $this->hash = $hasher->hash($password); } ... }
В итоге в полях сущности у нас хранятся только данные. А все нужные сервисы и конфигурационные параметры мы передаём в методы непосредственно из вызывающего кода:
class ChangePasswordCommandHandler { public function __construct( private UserRepository $users; private PasswordHasher $hasher; ) {} public function __invoke(Command $command): void { $user = $this->users->get(new Id($command->id)); $user->changePassword( $command->current, $command->new, $this->hasher ); // Saving... } }
Если в объекте будет несколько методов, которым нужны другие сервисы, то они нам никак не помешают в работе и в тестах. Сущность внутри не хранит никаких сервисов и в конструктор все эти сервисы не принимает. Весьма удобно.
Так мы избавились от двух наших неудобств путём использования инъекции зависимости в метод.
Используя инъекцию в метод сущности мы можем работать с любой ORM и делать сколько угодно методов, не захламляя конструктор и не расходуя ресурсы на создание ненужных в данный момент сервисов.
Это удобно. Можно подвести промежуточный итог.
Какой подход использовать
Мы разобрали варианты и поняли, что у нас есть сущности с хранимыми в каждом экземпляре пользовательскими данными, которые мы заполняем через конструктор и потом отправляем на сохранение в базу данных. И есть сервисы, которые мы достаём из контейнера в одном экземпляре и вызываем как процедуры без сохраняющегося в них изменяемого состояния.
Для сущностей с ORM удобно передавать в конструктор только пользовательские сохраняемые в сущности данные, а конфигурацию, зависимые сервисы и дополнительные данные передавать по требованию только в нужные методы:
$entity = new Entity($data); $entity->one(); $entity->two($data); $entity->three($config); $entity->four($data, $service); $storage->save($entity);
Аналогичный подход удобно использовать и для объектов-значений, если им вдруг понадобятся зависимости. Но обычно им никакие зависимости не нужны.
А для многократно вызываемых сервисов, создаваемых в контейнере, удобно в конструктор принимать параметры конфигурации и другие сервисы, а в вызываемый нами метод уже передавать наши изменяемые пользовательские данные:
$service = new Service($config, $service); $result1 = $service->method($data1); $result2 = $service->method($data2); $result3 = $service->method($data3);
При этом такие передаваемые данные внутрь сервиса желательно не присваивать.
В сервисе желательно не делать общего изменяемого в процессе работы состояния. Чтобы не было такого, что данные, присвоенные в приватное поле сервиса после первого вызова, нечаянно повредили результату второго вызова.
Это будет актуально, если вместо PHP-FPM мы будем запускать код нашего приложения в асинхронном движке, где работа PHP не завершается после обработки запроса. Там однажды созданный сервис может работать целый день, обрабатывая тысячи запросов. И любое неосторожно присвоенное в общее поле значение будет видно всем. Как бороться с такими конфликтами мы поговорим в отдельной статье.
В итоге сервисы, которые написаны по такому подходу, легко регистрировать в контейнере внедрения зависимостей в конфигурации приложения.
Но нужно учесть то, что не все сторонние сервисы так спрограммированы.
Работа с неудобными сервисами
Почти все современные сторонние компоненты написаны так, чтобы с ними можно было сразу работать как с сервисом в контейнере. Настройки и другие сервисы они принимают в конструктор, а пользовательские данные в сам метод.
Но есть такие, у которых всё смешано.
Как пример можно рассмотреть довольно старую библиотеку MobileDetect для определения по HTTP-заголовкам вроде User-Agent
с какого устройства к нам пришёл HTTP-запрос. Это полезно, если нам хочется на сайте выводить мобильную тему для посетителей с мобильных устройств.
Нам было бы удобнее один раз создать экземпляр детектора в контейнере и потом использовать его как функцию, определяющую устройство по HTTP-заголовкам из запроса:
$detect = new MobileDetect(); $isMobile1 = $detect->isMobile($request1->getHeaders()); $isMobile2 = $detect->isMobile($request2->getHeaders());
Но вместо этого она принимает заголовки в конструктор. Поэтому нам приходится создавать новый экземпляр для каждого запроса:
$detect = new Mobile_Detect($request1->getHeaders()); $isMobile1 = $detect->isMobile(); $detect = new Mobile_Detect($request2->getHeaders()); $isMobile2 = $detect->isMobile();
Это не очень удобно. Такой сторонний компонент поместить в контейнер не получится, так как там не будет объекта $request
.
В сервис-контейнере мы обычно не регистрируем сам входящий
$request
, ведь$request
– это объект-значение с пользовательскими данными, а не сервис.
И как решить такую проблему с сервисом, который принимает пользовательские данные в конструктор? Как его подружить с нашим контейнером?
Свой сервис как адаптер
Мы можем просто для нашего контейнера написать свой более удобный для нас сервис-адаптер, который уже внутри себя будет использовать оригинальный неудобный объект:
class Detect { public function isMobile(array $headers): bool { $detect = new \Mobile_Detect($headers); return $detect->isMobile(); } }
И если оригинальному объекту нужны другие зависимости, то наш сервис-адаптер может их подставлять:
class Adapter { private B $b; public function __construct(B $b) { $this->b = $b; } public function method($value) { $a= new A($value, $this->b); return $a->a(); } }
Теперь в своём HTTP-контроллере или посреднике (middleware) вместо оригинального класса используем свой адаптер:
use App\Detect; class HelloController { private Detect $detect; public function __construct(Detect $detect) { $this->detect = $detect; } public function action(Request $request): Response { $isMobile = $this->detect->isMobile($request->getHeaders()); … } }
Проблема решена.
Если сторонний сервис из-за неудобного конструктора не получается поместить в контейнер, то в контейнер мы можем поместить свой адаптер, использующий внутри оригинальный объект.
Но что если вместо своего адаптера мы всё-таки хотим использовать в коде оригинальный класс?
Инъекция фабрики
Сервис-контейнер не рассчитан на создание новых экземпляров сервисов с передаваемыми аргументами. Так что класс, принимающий пользовательские данные в конструктор, туда поместить не получится.
Но в контейнер мы можем поместить фабрику, через которую потом сможем создавать новые экземпляры оригинального класса с нужными данными:
class DetectFactory { public function create(array $headers): Mobile_Detect { return new Mobile_Detect($headers); } }
И при желании также такая фабрика может подставлять другие зависимости:
class AFactory { private B $b; public function __construct(B $b) { $this->b = $b; } public function create($value): A { return new A($value, $b); } }
Теперь в контроллер или посредник мы инъектим эту фабрику, а потом уже её методом create
создаём экземпляр детектора с данными из нашего запроса:
class HelloController { private DetectFactory $factory; public function __construct(DetectFactory $factory) { $this->factory = $factory; } public function action(Request $request): Response { $detector = $this->factory->create($request->getHeaders()); $isMobile = $detector->isMobile(); … } }
Это вполне рабочий вариант, если мы всё же хотим в своём коде использовать оригинальный класс из чужой библиотеки без своего адаптера.
Если сторонний сервис из-за неудобного конструктора не получается поместить в контейнер, то в контейнер мы можем поместить свою фабрику, конструирующую и возвращающую оригинальный объект.
Так что в итоге с сущностями и сервисами разобрались. Одноразовым сущностям мы передаём данные в конструктор, а зависимости в метод. А многократно используемым сервисам мы передаём параметры конфигурации и другие сервисы в конструктор, а потом изменяемые данные передаём методам. Но не всегда бывает так просто.
Зависимости для команд
Не всё у нас в коде может быть сервисом или сущностью. Бывают и другие вещи, способные нас запутать.
Например, те же команды Command вроде нашей команды регистрации пользователя. Или задачи Job для выполнения в очереди, которые работают примерно так же, как другие команды. С ними возможны разные варианты передачи зависимостей. Об этом мы и поговорим.
Паттерн Команда
Давным-давно все программировали консольные скрипты и десктопные программы вроде редакторов Notepad, MS Word и Photoshop. Веба тогда ещё особо не было, но паттерны уже придумывали.
При разработке визуальных редакторов нужно было в рабочей области делать много кнопок и пунктов меню с разными мелкими операциями. Но это ещё полбеды. Помимо этого большим инженерным вызовом было дать возможность отменять операции по Ctrl+Z и возвращать по Ctrl+Y.
Как сделать визуальный редактор с тысячей кнопок и с историей операций и не сойти с ума? Для этого тогда мудрые умы изобрели паттерн Команда.
Просто каждую операцию придумали делать в виде отдельного объекта с методом execute()
. И если нужна возможность отмены, то ещё и с методом undo()
:
interface Command { function execute() function undo() }
Потом при нажатии кнопки нужная операция попадает в историю (коллекцию операций), где потом можно эти операции откатывать назад по undo()
и возвращать по redo()
.
Например, если нужно сделать кнопку подчёркивания текста, то сначала программируем саму операцию выполнения подчёркивания:
class MakeUnderline implements Command { function __construct($textBox, $selection) { … } function execute() { … } function undo() { … } }
Ей можем передать рабочее поле текстового редактора и координаты выделенного фрагмента текста.
И теперь в меню программируем кнопку, в обработчике события нажатия которой отправляем на выполнение эту команду:
$button = new Button(‘underline.png’, ‘Underline’); $button->onClick(function () use ($editor, $history) { $history->execute(new MakeUnderline( $editor->getTextBox(), $editor->getSelection() )); }); $menu->add($button);
И там внутри история у переданной команды вызывает execute()
.
Теперь для отмены по Ctrl+Z
или повтора по Ctrl+Y
достаточно вызывать $history->undo()
и $history->redo()
у объекта истории. И он будет двигаться по массиву объектов команд назад или вперёд и у каждого вызывать execute()
или undo()
.
Проблема гениально решена. В десктопном софте это оказалось очень удобным.
А на бэкенде история операций с отменой нам не особо нужна. Там тоже применяют команды, но обычно без истории и отмены. Это может быть наша операция регистрации, которую мы вызываем из контроллера:
$command = new SignUpCommand($email, $password); $commandBus->execute($command);
У нас при желании может быть исполнитель команд в виде некой шины (так называемый паттерн Command Bus). Эта шина выполняет команду сразу или отправляет в очередь для её выполнения в фоне.
Даже просто в проектах с очередями командой у нас является сама задача для выполнения в очереди. Например, нам надо уведомить о чём-то пользователя по электронной почте. Мы создаём задачу уведомления нужного пользователя:
$job = new NotifyJob($userId);
После этого мы должны сериализовать команду в JSON-формат каким-нибудь сериалайзером и отправить в очередь Redis или RabbitMQ:
$job = new NotifyJob($userId); $json = $serializer->serialize($job); $queue->push(‘jobs’, $json);
И где-то в фоне скрипт-исполнитель должен эту задачу достать из очереди, десериализовать обратно в объект и запустить выполнение отправки уведомления. Например, можем для этого добавить метод execute
прямо в наш объект:
class Notify { private $userId; public function __consctruct($userId) { $this->userId = $userId; } public function execute() { … } }
И фоновый скрипт-слушатель для этой очереди будет после десериализации его запускать:
$queue->listen(‘jobs’, function ($json) { $job = $serializer->unserialize($json); $job->execute(); });
Так у нас задача будет успешно передаваться через очередь. И метод execute
будет успешно запускаться. Но это будет работать только в случае самодостаточного объекта, так как мы здесь:
class Notify { function __consctruct($userId) { … } function execute() { … } }
забыли про зависимости. И здесь у нас и возникает вопрос, как эти зависимости получить, если для отправки уведомления этой задаче нужны сервисы вроде $mailer
и $db
.
Зависимости команды и очередь
Мы в команду передаём только идентификатор:
class Notify { private $userId; function __consctruct($userId) { … } function execute() { … } }
Но коду в методе execute
потребуется получить пользователя из БД для доставания его электронного адреса. И потом понадобится почтовый клиент для отправки самого письма.
И здесь уже есть несколько вариантов внедрения.
Если мы все данные передаём в конструктор, то туда же можем передать сервис для доставания пользователя UserRepository
или UserFetcher
и мэйлер Mailer
:
class Notify { private string $userId; private Malier $mailer; private UserFetcher $users function __consctruct(string $userId, Malier $mailer, UserFetcher $users) { … } function execute() { … } }
Теперь если мы создадим такую задачу и сразу её запустим, то проблем не возникнет:
$job = new Notify($userId, $mailer, $users); $job->execute();
Но если мы её передаём в очередь, то теперь возникнут проблемы с сериализацией команды в JSON и десериализацией обратно из JSON в объект .
Нам нужно будет теперь более сложно сериализовать команду в JSON. Раньше, когда у нас было только поле $userId
, мы могли сериализовать задачу через простой вызов json_encode
:
$job = new Notify($userId); $json = [ 'type' => get_class($job), 'payload' => json_encode($job), ] $queue->push('jobs', $json);
Но сейчас у нас при этом будут проблемы, так как скалярное поле $userId
легко сериализуется, а сервисы из полей $mailer
и $repository
уже нет.
Как осуществить такую сериализацию и десериализацию?
У нас несколько вариантов.
Восстановление сервисов через Injector
При работе с очередью мы можем также передавать все параметры в конструктор команды и использовать ту же нативную функцию json_encode
:
$job = new Notify($userId, $mailer, $users); $message = [ 'type' => get_class($job), 'payload' => json_encode($job), ] $queue->push('jobs', $message);
Но при этом можем вручную изменить сериализацию через переопределение JsonSerializable
:
class Notify implements JsonSerializable { public function jsonSerialize() { return [ 'userId' => $this->userId, ]; } }
Тогда сообщение после вызова json_encode
у нас будет содержать только значение userId
:
{ "type": "App\Jobs\Notify", "payload": "{\"userId\": \"UUID-XXX\"}" }
А поля $mailer
и $users
мы при этом пропускаем. Их нужно будет просто доставать из контейнера при десериализации.
Вместо примитивного вызова json_encode($job)
мы можем использовать какую-нибудь продвинутую библиотеку вроде symfony/serializer
:
$job = new Notify($userId, $mailer, $users); $message = [ 'type' => get_class($job), 'payload' => $serializer->serialize($job, 'json'), ] $queue->push('jobs', $message);
Но это нам сейчас не очень важно. Главное то, что мы записываем только данные, а сервисы пропускаем.
И если мы так делаем, то теперь в скрипте, который будет читать эти сообщения из очереди, нужно воссоздать наш объект, взяв $userId
из сообщения и подтянув сервисы $mailer
и $users
обратно из контейнера.
Этот процесс можно автоматизировать. Например, можно проанализировать параметры конструктора класса команды через рефлексию и для недостающих параметры достать сервисы по их типу из контейнера. Такой парсер можно либо спрограммировать самому, либо взять готовый.
И с одним из готовых парсеров консольный consumer.php
может выглядеть примерно так:
use Laminas\Di\Injector; require __DIR__ . '/../vendor/autoload.php'; $container = require __DIR__ . '/../config/container.php'; $injector = new Injector(null, $container); $queue = $container->get(Queue::class); $queue->listen('jobs', function (array $message) use ($injector) { $class = $message['type']; $params = json_decode($message['payload']); $job = $injector->create($class, $params); $job->execute(); }
Здесь мы используем сервис Injector из пакета laminas-di
. Мы передаём ему контейнер со всеми нашими сервисами::
$injector = new Injector(null, $container);
И далее просто вызываем метод create
, передавая класс команды и имеющиеся у нас параметры:
$job = $injector->create($class, $params);
Он автоматически проанализирует рефлексией конструктор класса и подтянет из контейнера недостающие для него сервисы. И после этого мы можем спокойно вызвать $job->execute()
.
Мы можем все данные и сервисы передавать в конструктор команды, а потом при передаче через очередь производить через более умную сериализацию, чтобы передавались только данные. А сервисы подтягивать из контейнера при десериализации через Injector.
Но у такого подхода есть ещё одно небольшое неудобство. Некая избыточность.
А именно, когда мы создаём задачу и сразу запускаем execute
:
$job = new Notify($userId, $mailer, $users); $job->execute();
у нас всё это сразу используется. Но если мы создаём задачу и кидаем в очередь:
$job = new Notify($userId, $mailer, $users); $queue->push(‘jobs’, $job);
То сервисы в момент создания команды здесь оказываются незадействованными, так как они не используются в конструкторе при создании команды. Они используются не здесь, а уже в другом консольном скрипте, который получает и выполняет команды из очереди. То есть нет смысла передавать сервисы здесь, если они всё равно проигнорируются при отправке в очередь.
Для решения этого неудобства есть альтернативный вариант.
Передача сервисов в метод через Invoker
Чтобы избавиться от избыточности передачи сервисов в конструктор мы можем перенести зависимости из конструктора в сам метод, как мы делали в примере с сущностью:
class Notify { function __consctruct(string $userId) function execute(Mailer $mailer, UserRepo $users) }
Тогда мы сможем при создании команды передавать только пользовательские данные:
$job = new Notify($userId); $queue->push(‘jobs’, $job);
Так избыточность пропадёт.
Но при таком подходе мы не сможем в принимающем скрипте вызвать просто:
$job->execute();
так как в этот метод теперь нужно передать нужные ему сервисы.
Вместо ручной передачи сервисов в каждый метод мы это тоже можем автоматизировать. Теперь уже проанализировать рефлексией не конструктор, а метод execute
. И достать из контейнера нужные ему сервисы.
Для этих целей вместо Inflector мы можем воспользоваться инструментом Invoker:
use Invoker\Invoker; $invoker = new Invoker(null, $container); $queue->listen('jobs', function ($message) use ($invoker) { $class = $message['type']; $params = json_decode($message['payload']); $job = new $class(...$params); $invoker->call($job->execute(...)); }
Здесь мы используем пакет PHP-DI/invoker, передавая ему свой контейнер с сервисами:
$invoker = new Invoker(null, $container);
И потом вызываем метод execute
команды уже через его метод call
без дополнительных параметров:
$invoker->call([$job, ‘execute’]);
Или начиная с версии PHP 8.1 синтаксисом с многоточием:
$invoker->call($job->execute(...));
Этот код вызывает переданную ему функцию или метод, подтягивая недостающие сервисы из контейнера.
И как говорили выше, вместо нативного json_decode
можно подключить более продвинутый настраиваемый сериалайзер:
$queue->listen('jobs', function ($message) { $job = $serializer->unserialize($message['type'], $message['payload'], 'json'); $invoker->call($job->execute(...)); }
В итоге конструктор команды упростился.
Мы создаём команду как сущность или объект-значение, передавая через конструктор в её поля только пользовательские данные. А зависимые сервисы принимаем уже в сам метод
execute
.
Так мы избавились от избыточности при создании и вернулись к простой сериализации. Но нужно лишь использовать Invoker, чтобы подтягивать зависимости в метод.
Альтернативный вариант с передачей зависимостей в конструктор и данных в метод:
class Notify { function __consctruct(Malier $mailer, UserRepo $users) function execute(string $userId) }
для команды не подходит, так как в нём $userId
нигде не сохраняется. Так что этот вариант пропустим.
Но помимо таких компромиссов с Inflector и Invoker есть третий вариант.
Разделение на команду и обработчик
Если у нас есть данные для сериализации и сервисы для выполнения, то у нас может появиться идея отделить данные от поведения.
Сделать отдельную структуру для данных:
class Notify { public string $userId; }
Как мы говорили в прошлой части, в PHP нет отдельной конструкции для структур, поэтому приходится её делать тоже в виде класса.
К такой структуре можем добавить конструктор:
class Notify { public string $userId; public function __consctruct(string $userId) { $this->userId = $userId; } }
Либо упростить запись, воспользовавшись новым синтаксисом из PHP 8.0 и дополнительно указать readonly
из PHP 8.1:
class Notify { public function __consctruct( public readonly string $userId ) {} }
И теперь к этой структуре делаем отдельную процедуру для выполнения:
class NotifyHandler { function __consctruct(Malier $mailer, UserFetcher $users) function __invoke(Notify $job) }
Метод __invoke
при желании можно переименовать в handle
или execute
.
В итоге у нас есть отдельная задача-структура с данными:
$job = new Notify($userId);
Её удобно сериализовать любыми способами в любой формат. И есть её исполняющий обработчик-процедура в виде сервиса с зависимостями, который удобно доставать из контейнера.
Нам нужно просто для каждой команды искать и доставать из контейнера её конкретный обработчик. Если обработчики мы будем помещать в папку с командой, то можем просто к имени класса команды добавлять суффикс Handler
:
$class = get_class($job); $handler = $container->get($class . ‘Handler’); $handler($job);
В итоге в слушателе задач из очереди у нас может быть такой код десериализации и выполнения задачи:
$queue->listen('jobs', function ($message) { $class = $message['type']; $params = json_decode($message['payload']); $job = new $class(...$params); $handler = $container->get($class . 'Handler'); $handler($job); }
Код команды упростился, но теперь у нас два класса.
При раздельном подходе у нас есть простая сериализуемая структура-команда в виде DTO с данными и процедура-обработчик в виде сервиса для контейнера.
Код стал процедурным, но при этом не потребуется подключать Injector или Invoker. Именно такой подход мы использовали в предыдущих статьях для команды регистрации SignUp
, где у нас были классы Command
и Handler
.
Про команды и события
В итоге первый вариант с передачей всего в конструктор при написании команд удобно использовать только если мы эту команду запускаем сразу же, а не отправляем в очередь.
При запуске в очереди удобен подход с инъекцией зависимостей в метод execute
через Invoker и подход с разделением на DTO-команду и сервис-обработчик.
У команды всегда один обработчик, так что можно как совмещать данные и поведение в одном объекте, так и разделять.
Но если мы в проекте программируем не только команды, но и события с обработкой, то, в отличие от команды, у одного события может быть сколько угодно слушателей. Поэтому поместить несколько обработок прямо в класс события не получится. Там удобно сразу разделять код на сериализуемые события-структуры и на отдельные сервисы-слушатели.
Подведение итогов
В итоге сегодня мы разобрали применение различных подходов внедрения зависимостей и передачи конфигурационных параметров и пользовательских данных на практике. И поняли, какой подход удобнее использовать для сервисов из сервис-контейнера, сохраняемых в БД сущностей и выполняемых сразу или в очереди команд событий.
А в следующем эпизоде мы уже познакомимся с неудобством сервисов с изменяемым состоянием в долгоживущих асинхронных приложениях. И рассмотрим пути решения этих проблем.
Материалы, упоминаемые в статье:
- Структуры с поведением или объекты
- Способы внедрения зависимостей
- Реализация регистрации пользователей
- Вывод мобильной темы через тестирование
Подписывайтесь на @elisdnru и @deworkerpro, чтобы не пропустить следующую статью и скринкасты.
Статья - огонь. Спасибо за прекрасный материал.
Представим, что я покупатель. Взаимодействую с кассиром. В реальной жизни интерфейс для взаимодействия с кассиром выглядит примерно так:
Я, как покупатель, во время взаимодействия с кассиром не передаю ему сканер штрих-кодов, кассу и pos-терминал в момент взаимодействия ним. Я передаю ему корзину товаров, он озвучивает мне её стоимость. Я передаю ему деньги - он выдает мне чек и сдачу. Всё.
По логике из статьи, в метод calculateCostOfBasket() мне, как покупателю, помимо корзины, по хорошему бы ещё передать сканер штрих-кодов. А в момент передачи суммы для оплаты - еще и кассовый аппарат или, в зависимости от метода оплаты - pos-терминал или QR-генератор.
Как-то криво на этот кейс ложится пример из статьи. По идее, у кассира все зависимости должны быть внедрены ещё до того, как он начнет обслуживать покупателей.
Получается, что перед тем, как кассир начнёт сможет обрабатывать покупателей - он должен заступить на смену. Авторизоваться на рабочем месте, оприходовать деньги в кассе, получить сканер штрих-кодов для работы. А тут как-то не получается.
> Получается, что перед тем, как кассир сможет обрабатывать покупателей - он должен заступить на смену. Авторизоваться на рабочем месте, оприходовать деньги в кассе, получить сканер штрих-кодов для работы.
Верно. Если делать прямо так, то утром берём кассира:
и сажаем за кассу:
> Я, как покупатель, во время взаимодействия с кассиром не передаю ему сканер штрих-кодов, кассу и pos-терминал в момент взаимодействия ним. Я передаю ему корзину товаров, он озвучивает мне её стоимость. Я передаю ему деньги - он выдает мне чек и сдачу. Всё.
Верно. Кассира уже посадили на место и дали сканнер. Теперь вы даёте ему деньги:
И вечером у кассира завершаем смену.
А если посмотреть ещё глубже. То, как по мне, здесь должен быть всё такие StoreCheckoutService.
В статье много примеров и думаю для вашего кейса подойдёт вот этот:
А для многократно вызываемых сервисов, создаваемых в контейнере, удобно в конструктор принимать параметры конфигурации и другие сервисы, а в вызываемый нами метод уже передавать наши изменяемые пользовательские данные.
Но сударь, постойте. Мы говорим о богатой бизнес-логикой модели Кассира (Cashier), а не о каком-то сервисе CashierService. Понятное дело, что для CashierService проблем никаких и при его создании через сервисный контейнер всё в порядке с внедрением зависимостей.
Но нынче в моде rich-модели. Вот и я и спрашиваю, про такую банальную рич-модель, как кассир, с таким банальным интерфейсом. Но чет эта рич-модель никак не реализуема по всей видимости и опять прибегаем к обвязке анемичной модели сервис-классами?
У нас есть сущность кассира. У которой есть ФИО, табельный номер. По которой ведутся её рабочие смены. Которая регулярно принимает и сдаёт кассу в конце рабочей смены. Которая должна пробивать товары с помощью выданного ей оборудования. Выходит, что доктриновская сущность не про это что ли?
Тогда я не понимаю все эти дефирамбы про то, что анемичная модель зло. И впору делать рич.модели. Вот на банальном примере объясните мне как?
Прочитал про все четыре метода и не соображаю как элегантно применить к моему собственному простому сервису-фабрике, которая выдает объекты-недели. Наверное его можно назвать value-object.
Так как он должен возвращать себя, исходя из входных параметров статических методов, то извне ему желательно выглядеть так:
У класса Week есть зависимость, которая нужна многим методам внутри него. Так что её вроде логично передавать в конструктор, чтобы PHP-DI подсовывал её через autowiring.
Но как это реализовать? В лоб не работает, так как статическим фабричным методам нужно одновременно при создании нового объекта self передать ему и зависимость(сервис) и параметр (int || DateTime ..).
Использовать же для этого публичные сеттеры внутри статических методов - как то уж совсем неэлегантно.
К тому же, даже сеттеры не помогут использовать PHP-DI autowiring для внедрения сервиса-зависимости при использовании фабричого статического метода.
Помогите плз: где ошибся, куда копать?
Мне кажется вы зря смешали фабрику и VO.
Это вроде не совсем VO.
Если бы у объекта "неделя" была только продолжительность, то да - VO. Как и сумма в случае "денег".
А так как у недели есть уникальность и ID, то это формально уже сущность.
Как была бы сущностью денежная банкнота с уникальным номером.
Или я не понимаю концепции VO ?