Теперь, когда мы можем работать с данными в безопасном типизированном и прозрачном виде, нам нужно начать что-то делать с ними.
Точно так же, как мы не хотим работать со случайными массивами, полными данных, мы также не хотим, чтобы самая важная часть нашего проекта, Бизнес-функциональность, распространялась на случайные функции и классы.
Вот пример: одна из пользовательских историй в вашем проекте может быть для “администратора, чтобы создать счет-фактуру”. Это означает сохранение счета-фактуры в базе данных, но и многое другое:
- Во-первых: рассчитать цену каждой отдельной строки счета-фактуры и общую цену
- Сохранить счет-фактуру в базе данных
- Создать платеж через платежного провайдера
- Создать PDF файл со всей необходимой информацией
- Отправить этот PDF-файл клиенту
Распространенной практикой в Laravel является создание “жирных моделей”, которые будут обрабатывать всю эту функциональность. В этой статье мы рассмотрим другой подход к добавлению такого поведения в нашу кодовую базу.
Вместо того чтобы смешивать функциональность в моделях или контроллерах, мы будем рассматривать эти пользовательские истории как отдельные классы. Я склонен называть это “действиями”.
Терминология
Прежде чем рассматривать их использование, мы должны обсудить, как структурированы действия. Во-первых, они живут в домене.
Во-вторых, это простые классы без каких-либо абстракций или интерфейсов. Действие-это класс, который принимает входные данные, что-то делает и выдает выходные данные. Вот почему действие обычно имеет только один публичный метод, а иногда и конструктор.
В качестве условности в наших проектах мы решили добавлять Action
суффиксы для всех наших классов. Конечно, CreateInvoice
звучит неплохо, но как только вы имеете дело с несколькими сотнями или тысячами классов, вы захотите убедиться, что никаких коллизий имен не может произойти. Вполне возможно, что CreateInvoice
это также имя вызываемого контроллера, команды, задания или реквеста. Мы предпочитаем устранить как можно больше путаницы, отсюда и будет название CreateInvoiceAction
.
Очевидно, это означает, что имена классов становятся длиннее. Реальность такова, что если вы работаете над большими проектами, вы не можете избежать выбора более длинных имен, чтобы убедиться, что путаница невозможна. Вот экстремальный пример из одного из наших проектов, я не шучу: CreateOrUpdateHabitantContractUnitPackageAction
.
Сначала мы ненавидели это имя. Мы отчаянно пытались придумать что-нибудь покороче. В конце концов мы должны были признать, что ясность того, что это за класс, является более важным. Автокомплит нашей IDE в любом случае позаботится о неудобстве длинных имен.
Когда мы определились с именем класса, следующее препятствие, которое нужно преодолеть, – это присвоение имени общедоступному методу для использования нашего действия. Один из вариантов-сделать его вызываемым, например:
class CreateInvoiceAction
{
public function __invoke(InvoiceData $invoiceData): Invoice
{
// …
}
}
Однако с этим подходом есть практическая проблема. Позже в этой статье мы поговорим о составлении действий из других действий и о том, какой это мощный паттерн. Это будет выглядеть примерно так:
class CreateInvoiceAction
{
private $createInvoiceLineAction;
public function __construct(
CreateInvoiceLineAction $createInvoiceLineAction
) { /* … */ }
public function __invoke(InvoiceData $invoiceData): Invoice
{
foreach ($invoiceData->lines as $lineData) {
$invoice->addLine(
($this->createInvoiceLineAction)($lineData)
);
}
}
}
Можете ли вы определить проблему? PHP не позволяет напрямую вызывать вызываемый объект, когда это свойство класса, так как PHP ищет метод класса вместо этого. Вот почему вам придется заключить действие в скобки, прежде чем вызывать его.
Хотя это всего лишь незначительное неудобство, есть дополнительная проблема с PhpStorm: он не может обеспечить автоматическое заполнение параметров при вызове действия таким образом. Лично я считаю, что правильное использование IDE является неотъемлемой частью разработки проекта и не должно игнорироваться. Вот почему на этот раз наша команда решила не делать действия призывными.
Другим вариантом является использование handle
, которое часто используется Laravel в качестве имени по умолчанию в таких случаях. Опять же, с этим есть проблема, особенно потому, что Laravel использует его.
Всякий раз , когда Laravel позволяет вам использовать handle
, например. задания или команды, он также будет обеспечивать инъекцию метода из контейнера зависимостей. В наших действиях мы хотим, чтобы только конструктор имел возможности DI. И снова мы подробно рассмотрим причины этого позже в этой статье.
Так что handle
тоже отпадает. Когда мы начали использовать действия,мы действительно много думали об именовании. В конце концов мы остановились на execute
. Однако имейте в виду, что вы вольны придумывать свои собственные соглашения об именовании: здесь речь идет скорее о шаблоне использования действий, чем об их названиях.
На практике
Теперь, когда вся терминология оговорена, давайте поговорим о том, почему действия полезны и как их на самом деле использовать.
Сначала поговорим о повторном использовании. Хитрость при использовании действий состоит в том, чтобы разбить их на достаточно мелкие кусочки, чтобы некоторые вещи можно было использовать повторно, сохраняя их достаточно большими, чтобы не перегружать их. Возьмем наш пример счета-фактуры: генерация PDF-файла из счета-фактуры-это то, что, скорее всего, произойдет в нескольких контекстах нашего приложения. Конечно, есть PDF-файл, который генерируется при фактическом создании счета-фактуры, но администратор также может захотеть увидеть предварительный просмотр или черновик этого документа, прежде чем отправлять его.
Эти две пользовательские истории: “создание счета “и” предварительный просмотр счета”, очевидно, требуют двух точек входа, двух контроллеров. Однако, с другой стороны, генерация PDF-файла на основе счета-фактуры-это то, что делается в обоих случаях.
Когда вы начнете тратить время на размышления о том, что на самом деле будет делать приложение, вы заметите, что есть много действий, которые можно использовать повторно. Конечно, мы также должны быть осторожны, чтобы не слишком абстрагироваться от нашего кода. Часто лучше скопировать и вставить немного кода, чем делать преждевременные абстракции.
Хорошее эмпирическое правило-думать о функциональности при создании абстракций, а не о технических свойствах кода. Когда два действия могут делать похожие вещи, хотя они делают это в совершенно разных контекстах, вы должны быть осторожны, чтобы не начать абстрагировать их слишком рано.
С другой стороны, есть случаи, когда абстракции могут быть полезны. Возьмем еще раз наш пример PDF-файла счета: скорее всего, вам нужно уметь создавать и другие PDF файлы, помимо счетов-фактур. Возможно, имеет смысл иметь общий интерфейс GeneratePdfAction
, который затем реализует Invoice
.
Но, давайте будем честны, скорее всего, большинство наших действий будет довольно специфичным для их пользовательских историй и не будет повторно использоваться. Вы можете подумать, что действия в этих случаях являются ненужными накладными расходами. Однако повторное использование-это не единственная причина их использования. На самом деле, самая важная причина не имеет ничего общего с техническими преимуществами вообще: действия позволяют программисту думать способами, которые ближе к реальному миру, а не коду.
Допустим, вам нужно внести изменения в способ создания счетов-фактур. Типичное приложение Laravel, вероятно, будет иметь эту логику создания счета-фактуры, распространенную на контроллер и модель, возможно, задание, которое генерирует PDF-файл, и, наконец, слушатель событий для отправки почты счета-фактуры. Это много мест, о которых вам нужно знать. И снова наш код разбросан по кодовой базе, сгруппированной по техническим свойствам, а не по смыслу.
Действия снижают когнитивную нагрузку, которую несет такая система. Если вам нужно поработать над тем, как создаются счета-фактуры, вы можете просто перейти в класс действий и начать оттуда.
Действия хорошо подходят для использования в асинхронных заданиях и слушателях событий; хотя сами задания и слушатели просто обеспечивают инфраструктуру для работы действий, а не саму бизнес-логику. Это хороший пример того, почему нам нужно разделить доменный и прикладной уровни: каждый из них имеет свою собственную цель.
Таким образом, мы получили повторное удобство использования и снижение когнитивной нагрузки, но это еще не все!
Поскольку действия – это небольшие фрагменты программного обеспечения, которые живут почти сами по себе, их очень легко тестировать. В ваших тестах вам не нужно беспокоиться об отправке поддельных HTTP-запросов, настройке фасадных подделок и т. д. Вы можете просто сделать новое действие, возможно, предоставить некоторые фиктивные зависимости, передать ему необходимые входные данные и сделать утверждения на его выходе.
Например CreateInvoiceLineAction
: он возьмет данные о том, какую статью он выставит, а также сумму и период; и вычислит общую цену и цены с НДС и без него. Это вещи, для которых вы можете написать надежные, но простые модульные тесты.
Если все ваши действия должным образом проверены модульными тестами, вы можете быть уверены, что основная часть функциональности, которая должна быть предоставлена приложением, действительно работает так, как задумано. Теперь остается только использовать эти действия способами, которые имеют смысл для конечного пользователя, и написать некоторые интеграционные тесты для этих частей.
Составление действий
Одна из важных характеристик действий, о которой я уже кратко упоминал, заключается в том, как они используют инъекцию зависимостей. То есть мы можем передавать в конструктор другие действия и использовать их внутри метода execute
.
Вы поняли идею? Но глубокая цепочка зависимостей-это то, чего вы хотите избежать — она делает код сложным и сильно зависимым друг от друга, но есть несколько случаев, когда наличие DI очень полезно.
Возьмем еще раз пример нашего CreateInvoiceLineAction
, где приходится рассчитывать цены НДС. Теперь, в зависимости от контекста, строка счета-фактуры может иметь цену, включающую или исключающую НДС. Расчет НДС-это что-то тривиальное, но мы не хотим, чтобы наш CreateInvoiceLineAction
беспокоили детали этой функциональности.
Итак, представьте себе, что у нас есть простой класс \Support\VatCalculator
, который мы используем в нашем действии:
class CreateInvoiceLineAction
{
private $vatCalculator;
public function __construct(VatCalculator $vatCalculator)
{
$this->vatCalculator = $vatCalculator;
}
public function execute(
InvoiceLineData $invoiceLineData
): InvoiceLine {
// …
}
}
И вы бы использовали его вот так:
public function execute(
InvoiceLineData $invoiceLineData
): InvoiceLine {
$item = $invoiceLineData->item;
if ($item->vatIncluded()) {
[$priceIncVat, $priceExclVat] =
$this->vatCalculator->vatIncluded(
$item->getPrice(),
$item->getVatPercentage()
);
} else {
[$priceIncVat, $priceExclVat] =
$this->vatCalculator->vatExcluded(
$item->getPrice(),
$item->getVatPercentage()
);
}
$amount = $invoiceLineData->item_amount;
$invoiceLine = new InvoiceLine([
'item_price' => $item->getPrice(),
'total_price' => $amount * $priceIncVat,
'total_price_excluding_vat' => $amount * $priceExclVat,
]);
}
CreateInvoiceLineAction
в свою очередь будет передан внутрь CreateInvoiceAction
. И у этого опять же есть другие зависимости CreatePdfAction
, SendMailAction
например.
Вы можете увидеть, как композиция может помочь вам сохранить отдельные действия небольшими, но при этом обеспечить четкое и доступное кодирование сложных бизнес-функций.
Альтернативы действиям
Есть две парадигмы, о которых я должен упомянуть на этом этапе, два способа, с помощью которых вам не понадобится такая концепция, как действия.
Командная шина
Первый будет известен людям, знакомым с DDD: командами и обработчиками. Действия – это их упрощенная версия. Основное различие команд и обработчиков в том, что команды это что должно произойти, а обработчики – как это должно произойти. Действия объединяют эти две обязанности в одну. Правда командная шина обеспечивает большую гибкость, чем действия, но это также потребует, чтобы вы написали больше кода.
Для масштаба наших проектов разделение действий на команды и обработчики это было бы перебором. Нам почти никогда не понадобится дополнительная гибкость, но на написание кода уйдет гораздо больше времени.
Событийная модель
Вторая альтернатива, о которой стоит упомянуть, – это событийная система (event driven systems). Событийная модель предлагает большую гибкость, но делает код более сложным для понимания.
Итог
Для нас действия являются правильным выбором, потому что они обеспечивают необходимую гибкость, повторное использование и значительно снижают когнитивную нагрузку. Они инкапсулируют суть приложения. На самом деле их можно рассматривать вместе с DTO и моделями как ядро проекта.
Перевод https://stitcher.io/blog/laravel-beyond-crud-03-actions
А как делать Action на update. Передавать модель или Id модели?
Правильнее всего передавать id модели и в экшене перезапрашивать более свежую версию данного объекта. Иначе можете нарваться на не совсем свежую, с устаревшими данными и словите рассинхрон сохраняемых данных.