Laravel: Моделирование бизнес процессов
Всё начинается с рабочего процесса. Я написал в твиттере о написании этого руководства, чтобы узнать, будут ли какие-либо отзывы о бизнес процессах, которые люди сочтут полезными, но на самом деле, я получил только один ответ.
WMS (Warehouse Management System). Things like Sale Orders, Stock Orders, Inventory, Returns, Packaging, Picking, Packing, Shipping, etc.
WMS (система управления складом). Такие вещи, как заказы на продажу, заказы на складе, запасы, возвраты, упаковка, комплектация, упаковка, доставка и т. д.
Имея это в виду, давайте посмотрим на процесс Заказа/Доставки, в котором достаточно движущихся частей, чтобы донести идею, но я не буду вдаваться в подробности с точки зрения логики предметной области.
Представьте, что вы управляете интернет-магазином товаров, у вас есть интернет-магазин и служба дропшиппинга для отправки товаров по запросу при размещении заказа. Нам нужно подумать о том, как мог бы выглядеть бизнес процесс без какой-либо цифровой помощи — это позволит нам понять бизнес и его потребности.
- Запрошен товар (мы используем услугу печати по запросу, поэтому запас не является проблемой).
- Берём данные клиента.
- Создаём заказ для нового клиента.
- Принимаем оплату за заказ.
- Подтверждаем заказ и оплату клиенту.
- Размещаем заказ в службе печати по запросу.
Служба печати по запросу будет периодически информировать нас о статусе заказа, который мы можем сообщать нашим клиентам, но это будет другой бизнес процесс. Давайте сначала посмотрим на процесс заказа и представим, что всё делается на одном контроллере. Было бы довольно сложно им управлять и изменять его.
class PlaceOrderController
{
public function __invoke(PlaceOrderRequest $request): RedirectResponse
{
// Создаём запись клиента.
$customer = Customer::query()->create([]);
// Создаём заказ для нашего клиента.
$order = $customer->orders()->create([]);
try {
// Используем платёжную библиотеку для получения платежа.
$payment = Stripe::charge($customer)->for($order);
} catch (Throwable $exception) {
// Обрабатываем исключение, чтобы клиент знал, что оплата не удалась.
}
// Подтверждаем заказ и оплата клиенту.
Mail::to($customer->email)->send(new OrderProcessed($customer, $order, $payment));
// Отправляем заказ в службу печати по запросу.
MerchStore::create($order)->for($customer);
Session::put('status', 'Your order has been placed.');
return redirect()->back();
}
}
Итак, если пройдёмся по коду, то увидим, что мы создаём пользователя и заказываем, затем принимаем платёж и отправляем электронное письмо. Наконец, мы добавляем в сеанс сообщение о состоянии и перенаправляем клиента.
Итак, мы дважды записываем в базу данных, общаемся с платёжным API, отправляем электронное письмо и, наконец, пишем в сессию и перенаправляем. В одном синхронном потоке нужно обработать довольно много, что может привести к поломке. Логичным шагом будет перенести это в фоновое задание, чтобы у нас был уровень отказоустойчивости.
class PlaceOrderController
{
public function __invoke(PlaceOrderRequest $request): RedirectResponse
{
// Создаём запись клиента.
$customer = Customer::query()->create([]);
dispatch(new PlaceOrder($customer, $request));
Session::put('status', 'Your order is being processed.');
return redirect()->back();
}
}
Мы сильно почистили контроллер, однако всё, что мы сделали, это переместили проблему в фоновый процесс. Хотя перенос в фоновый процесс является правильным способом управиться с этим, нужно подойти к этому совершенно по-другому.
Во-первых, мы хотим создать или получить запись клиента — в случае, если он уже делал заказ ранее.
class PlaceOrderController
{
public function __invoke(PlaceOrderRequest $request): RedirectResponse
{
// Создаём запись клиента.
$customer = Customer::query()->firstOrCreate([], []);
dispatch(new PlaceOrder($customer, $request));
Session::put('status', 'Your order is being processed.');
return redirect()->back();
}
}
Следующий шаг — перенести создание клиента в общий класс — это один из многих случаев, когда мы хотели бы создать или получить запись клиента.
class PlaceOrderController
{
public function __construct(
private readonly FirstOrCreateCustomer $action,
) {}
public function __invoke(PlaceOrderRequest $request): RedirectResponse
{
// Создать запись клиента.
$customer = $this->action->handle([]);
dispatch(new PlaceOrder($customer, $request));
Session::put('status', 'Your order is being processed.');
return redirect()->back();
}
}
Давайте посмотрим на код фонового процесса, если бы переместили его прямо туда.
class PlaceOrder implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function _construct(
public readonly Customer $customer,
public readonly Request $request,
) {}
public function handle(): void
{
// Создаём заказ для нашего клиента.
$order = $this->customer->orders()->create([]);
try {
// Используем платёжную библиотеку для получения платежа.
$payment = Stripe::charge($this->customer)->for($order);
} catch (Throwable $exception) {
// Обрабатываем исключение, чтобы клиент знал, что оплата не удалась.
}
// Подтверждаем заказ и оплата клиенту.
Mail::to($this->customer->email)
->send(new OrderProcessed($this->customer, $order, $payment));
// Отправляем заказ в службу печати по запросу.
MerchStore::create($order)->for($this->customer);
}
}
Не так уж плохо, но что, если шаг завершится ошибкой, и мы повторим задание? В конечном итоге мы будем переделывать части этого процесса снова и снова, когда в этом нет необходимости. Сначала мы должны попытаться создать порядок в транзакциях базы данных.
class CreateOrderForCustomer
{
public function handle(Customer $customer, data $payload): Model
{
return DB::transaction(
callback: static fn () => $customer->orders()->create(
attributes: $payload,
),
);
}
}
Теперь мы можем обновить наш фоновый процесс, чтобы реализовать эту новую команду.
class PlaceOrder implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function _construct(
public readonly Customer $customer,
public readonly Request $request,
) {}
public function handle(CreateOrderForCustomer $command): void
{
// Создаём заказ для нашего клиента.
$order = $command->handle(
customer: $customer,
payload: $this->request->only([]),
);
try {
// Используем платёжную библиотеку для получения платежа.
$payment = Stripe::charge($this->customer)->for($order);
} catch (Throwable $exception) {
// Обрабатываем исключение, чтобы клиент знал, что оплата не удалась.
}
// Подтверждаем заказ и оплата клиенту.
Mail::to($this->customer->email)
->send(new OrderProcessed($this->customer, $order, $payment));
// Отправляем заказ в службу печати по запросу.
MerchStore::create($order)->for($this->customer);
}
}
Этот подход хорошо работает. Тем не менее он не идеален, и у вас не очень хорошая видимость в любой точке. Мы могли бы смоделировать это по-другому, чтобы мы моделировали бизнес процесс, а не разбивали его на части.
Всё начинается с фасада Pipeline, позволяющего правильно построить этот процесс. Мы по-прежнему хотим создать нашего клиента в контроллере, но мы будем обрабатывать остальную часть процесса в рамках фонового задания, используя бизнес процесс.
Для начала нам понадобиться абстрактный класс, который можно расширить нашими классами бизнес процессов, чтобы свести к минимуму дублирование кода.
abstract class AbstractProcess
{
public array $tasks;
public function handle(object $payload): mixed
{
return Pipeline::send(
passable: $payload,
)->through(
pipes: $this->tasks,
)->thenReturn();
}
}
Наш класс бизнес процесса будет иметь множество связанных задач, которые мы объявляем в реализации. Затем наш абстрактный процесс возьмёт переданную полезную нагрузку и отправить её через эти задачи — в конечном итоге вернув. К сожалению, я не могу придумать хороший способ вернуть фактический тип вместо mixed
, но иногда приходится идти на компромисс.
class PlaceNewOrderForCustomer extends AbstractProcess
{
public array $tasks = [
CreateNewOrderRecord::class,
ChargeCustomerForOrder::class,
SendConfirmationEmail::class,
SendOrderToStore::class,
];
}
Как видите, это супер чисто на вид и хорошо работает. Эти задачи можно повторно использовать в других бизнес процессах, где это целесообразно.
class PlaceOrder implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function _construct(
public readonly Customer $customer,
public readonly Request $request,
) {}
public function handle(PlaceNewOrderForCustomer $process): void
{
try {
$process->handle(
payload: new NewOrderForCustomer(
customer: $this->customer->getKey(),
orderPayload: $this->request->only([]),
),
);
} catch (Throwable $exception) {
// Обрабатываем потенциальные исключения, которые могут возникнуть.
}
}
}
Теперь наш фоновый процесс пытается обработать бизнес процесс, и если возникнут какие-либо исключения, мы можем потереть неудачу и повторить процесс позже. Поскольку Laravel будет использовать свой DI-контейнер для передачи того, что вам нужно, в метод handle
, мы можем передать наш класс процесса в этот метод и позволить Laravel решить это для нас.
class CreateNewOrderRecord
{
public function __invoke(object $payload, Closure $next): mixed
{
$payload->order = DB::transaction(
callable: static fn () => Order::query()->create(
attributes: [
$payload->orderPayload,
'customer_id' $payload->customer,
],
),
);
return $next($payload);
}
}
Задачи нашего бизнес процесса — это вызываемые классы, передаваемые путешественнику
, представляющему собой полезную нагрузку, через которую мы хотим пройти, и Замыканию, являющимся следующей задачей в конвейере. Это похоже на то, как функциональность middleware работает в Laravel, где мы можем связать столько, сколько нам нужно, и они просто вызываются последовательно.
Передаваемая полезная нагрузка может быть простым объектов PHP, который мы можем использовать для построения по мере его прохождения по конвейеру, расширяя его на каждом этапе, позволяя следующей задаче в конвейере получать доступ к любой необходимой информации без выполнения запроса к базе данных.
Используя этот подход, мы можем разбить наши бизнес процессы, которые не являются цифровыми, и создать их цифровое представление. Объединение их вместе таким образом добавляет автоматизации там, где это необходимо. На самом деле это довольно простой подход, но он очень мощный.