Кликбейтненько? Да, но не совсем. Речь в статье действительно пойдёт о событийно-ориентированном фреймворке "Дуайлер" (производное от Do While), с возможностью использовать неблокирующий API для работы с I/O. Но перед тем как начать, небольшой…
Дисклеймер
Данная статья посвящена PHP-фреймворку, разрабатываемому мной вот уже более шести лет. Важно сразу обозначить: фреймворк, о котором пойдёт речь, не претендует ВООБЩЕ ни на что! Ни на звание "продакшн решения", ни на "элегантность", ни на "соответствие общепринятым практикам" или чего-то ещё. Цель была не в том, чтобы угодить всем или изобрести велосипед, а в том, чтобы опробовать идеи, удовлетворить собственное любопытство и получить удовольствие от процесса создания. Многие решения сознательно спорны, а идеи — зачастую провокационны, что может вызвать подгорание пятой точки у части читателей. Ключевое здесь: это прежде всего мой эксперимент длиною в 6+ лет, а не попытка «перевернуть» мир PHP.
Ну что, начнём? Тут нас сразу поджидает ловушка: чтобы заинтересовать вас, нужны примеры, но чтобы понять примеры, нужно уже знать, что это такое, а для этого нужны... примеры! Для начала я расскажу об основных составляющих фреймворка, а затем, постепенно перейдём к коду, где создадим небольшой проект, по ходу которого я постараюсь объяснить, что же всё-таки происходит. Но обо всём по порядку.
Что такое Duyler?
Duyler - это событийно-ориентированный, «неумирающий» и неблокирующий PHP-фреймворк. Приложение в Duyler строится из множества небольших действий, каждое из которого выполняется в отдельном файбере и в изолированном DI-контейнере. Duyler позволяет создавать сложные цепочки действий с явным или неявным указанием зависимостей действий друг от друга. Область применения Duyler весьма обширна: консольные приложения, веб-приложения, воркеры, инструменты для тестирования, создание low-code\no-code систем и т. п. Всё это можно эффективно реализовать с помощью Duyler.
Репозиторий проекта на Github.
Всё это становится возможным, благодаря главному компоненту фреймворка - шине событий (Event bus). Шина событий реализует кооперативную многозадачность и контролирует выполнение в ней действий. Управлять выполнением можно несколькими способами:
с помощью обработчиков состояний
внешних событий
триггеров на генерируемые действиями внутренние события
а также доступными для самих действий свойствами и параметрами
Давайте рассмотрим каждую составляющую подробнее.
Типы состояний
Состояния основного контекста (Main context):
MainBegin
- наступает единожды перед стартом шины событийMainCyclic
- наступает хотя бы раз и повторяется пока очередь задач не пуста в режимеMode::Queue
, или циклично еслиMode::Loop
MainBefore
- наступает перед сборкой и запуском действияMainSuspend
- наступает если действие вызываетFiber::suspend()
. Если в очереди есть другие действия, они будут запущены, в то время как само действие будет возвращено в очередь.MainResume
- наступает перед возвратом управления в действие. Если обработчики для состояния не определены и вFiber::suspend()
была передана callback-функция, она будет запущена, а возвращаемое callback-функцией значение будет резюмировано в контекст действия.MainAfter
- наступает после выполнения действияMainEmpty
- наступает когда очередь задач пустаMainUnresolved
- наступает если задачу не удалось разрешить к выполнениюMainEnd
- наступает когда очередь задач пуста и все действия завершены (достижимо только в режимеMode::Queue
)
Состояния в контексте действия (Fiber context):
ActionBefore
- наступает перед выполнением действияActionAfter
- наступает после выполнения действияActionThrowing
- наступает в случае если действие выбросило исключение
Каждое состояние может быть обработано с помощью внешних обработчиков состояний. Для каждого типа состояния, предусмотрен определённый набор функций, доступных для взаимодействия с шиной из обработчика состояния.
Обзор свойств и параметров для действий
Свойство | Тип | Описание |
---|---|---|
| string|UnitEnum | Уникальный идентификатор действия. Например: 'MyAction.DoWork'. |
| string | Описание действия |
| string|Closure | Обработчик действия. Анонимная функция или вызываемый класс. |
| array | Массив идентификаторов действий. Целевое действие может затребовать выполнения других действий, которые будут являться условием для выполнения целевого действия. Результат выполнения затребованных действий может быть передан в целевое действие. В случае если хотя бы одно из затребованных действий вернуло результат со статусом |
| array | Массив типов, описываемых через фасад |
| array | Действие будет запущено после того, как события с указанными ID будут переданы в шину. Если в событие передан объект, он может быть передан в действие. |
| array | Маппинг интерфейсов для DI-контейнера. Например: |
| array | Массив сервис-провайдеров для DI-контейнера. |
| array | Массив значений для конструкторов классов. Например: |
| string | Тип аргумента действия. |
| string|Closure | Анонимная функция или выполняемый класс фабрики для аргумента действия. |
| string | Тип, возвращаемый действием. |
| string | Указывает, что действие возвращает коллекцию типов. |
| bool | Устанавливает возможность использования мутабельных типов возвращаемых значений. |
| string|Closure | Анонимная функция или выполняемый класс, вызываемый для отката действия в случае, если было выброшено исключение в любом месте программы. |
| bool | Разрешение доступа к результату выполнения действия из |
| bool | Разрешение повторно выполнять действие. |
| bool | Если действие использует параллельное выполнение внутри |
| string | Предоставляет возможность указать пользовательский класс контекста для действия, если в качестве обработчика используется анонимная функция. |
| bool | Если установлено значение |
| array | Массив идентификаторов действий, которые могут затребовать это действие и получать его результат. |
| bool | Если установлено значение |
| array | Массив идентификаторов действий, результат которых может заменить результат выполнения целевого действия. Результат текущего целевого действия будет заменён, если действие вернёт результат со статусом |
| int | Количество повторов, если действие возвращает результат со статусом |
| DateInterval | Время задержки между повторами. |
| array | Любые произвольные данные. Может быть полезно при реализации обработчиков состояний. |
Действия могут быть как статическими, так и динамическими. Статические действия описываются декларативно, с помощью билдера и инициализируются один раз перед стартом приложения. Динамические действия можно добавлять и удалять из обработчиков состояний.
События
События, как и действия, имеют собственные ID и могут «диспатчиться» как из действий, используя EventDispatcherInterface
, так и из обработчиков состояний. Действия, которые слушают переданное в шину событие, будут помещены в очередь на выполнение при соблюдении прочих условий. Объекты, переданные в событии, могут быть переданы как аргумент действия-слушателя или в фабрику аргументов.
Триггеры
Каждое действие, в явном или неявном виде, возвращает объект класса Result
, со статусом Success
или Fail
. На каждый такой результат можно установить триггер, который при возвращении результата указанным в триггере действием, запускает другое действие. Стоит помнить, что триггер не подразумевает передачу результата в действие, запускаемое им. Для получения результата другим действием следует указать это в свойстве require
.
Общая схема работы:
Пакеты, входящие в состав фреймворка
Разумеется, одной шиной событий сыт не будешь, поэтому в состав фреймворка входит несколько жизненно важных пакетов, которые существенно расширяют его возможности. Давайте бегло их посмотрим и пойдём писать код.
EventBus - по своей сути, шина событий является ядром фреймворка, но при этом может использоваться и без него, как самостоятельный пакет.
DI - контейнер зависимостей. Используется как для каждого действия по отдельности, так и для инициализации компонентов фреймворка. Поддерживает автовайринг, а также указание сервис-провайдеров для конкретных классов. Помимо управления зависимостями, контейнер так же отвечает за сброс состояния объектов приложения, используя для этого атрибуты классов или анонимные функции.
Http - реализует возможность работать с приложением по HTTP. В качестве сервера используется RoadRunner.
Web - расширяет пакет Http и предоставляет возможность работать с контроллерами, роутами и рендерингом представлений. В качестве шаблонизатора задействован Twig.
Aspect - реализует базовый функционал AOP, предоставляя точки присоединения (Join points) для действий, такие как Before, After, Around и Throwing.
IO - предоставляет набор интерфейсов для работы с I/O. Для реализации неблокирующего I/O задействовано расширение Parallel. На данный момент реализована работа с SQL-запросами, чтением-записью файлов и работой по HTTP.
ORM - адаптер для CycleORM. Предоставляет основные возможности CycleORM, а также реализует консольные команды для работы с миграциями и фикстурами.
Scenario - позволяет декларативно описывать порядок выполнения действий.
Builder - предоставляет удобные фасады для декларативного описания приложения (создание действий, триггеров и т.д.), а также интерфейсы для подключения пакетов фреймворка.
Думаю, пришло время «немного» покодить. В качестве примера мы реализуем API получения корзины товаров. Данный пример я выбрал не случайно. Мне кажется этот пример будет понятен большенству читателей и к тому же позволит охватить как можно больше возможностей Duyler. Так же заранее стоит сказать, что данный пример не нужно рассматривать с точки зрения архитектуры проекта, всё это останется за скобками, так как пример призван показать какие подходы и механизмы используются в Duyler для построения приложений. Начнём?
Создание API получения корзины пользователя
Первым делом создадим и опишем все необходимые для работы сущности:
Cart
CartItem
Discount
Product
User
Подробно приводить классы всех сущностей я здесь не буду, они ничем не примечательны по содержанию. Но стоит упомянуть одну важную деталь - в них не используются атрибуты. Все сущности описываются декларативно через билдер. Давайте посмотрим пример такого описания.
Класс сущности Product:
Класс сущности Product:
// src/Domain/Entity/Product.php
<?php
declare(strict_types=1);
namespace Market\Domain\Entity;
use DateTimeImmutable;
use DateTimeInterface;
use Money\Currency;
use Money\Money;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
class Product
{
private UuidInterface $id;
private string $title;
private string $price;
private string $currency;
private string $image;
private DateTimeInterface $createdAt;
private DateTimeInterface $updatedAt;
public function __construct()
{
$this->id = Uuid::uuid7();
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = $this->createdAt;
}
public function getId(): UuidInterface
{
return $this->id;
}
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): self
{
$this->title = $title;
return $this;
}
public function getPrice(): Money
{
return new Money($this->price, new Currency($this->currency));
}
public function setPrice(Money $price): self
{
$this->price = $price->getAmount();
$this->currency = $price->getCurrency()->getCode();
return $this;
}
public function getImage(): string
{
return $this->image;
}
public function setImage(string $image): self
{
$this->image = $image;
return $this;
}
public function getCreatedAt(): DateTimeInterface
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): self
{
$this->createdAt = $createdAt;
return $this;
}
public function getUpdatedAt(): DateTimeInterface
{
return $this->updatedAt;
}
public function setUpdatedAt(DateTimeImmutable $updatedAt): self
{
$this->updatedAt = $updatedAt;
return $this;
}
}
Описание в билдере:
// build/entities.php
Entity::declare(Product::class)
->database('default')
->table('products')
->primaryKey('id')
->repository(ProductRepository::class)
->columns([
'id' => 'id',
'title' => 'title',
'price' => 'price',
'currency' => 'currency',
'image' => 'image',
'createdAt' => 'created_at',
'updatedAt' => 'updated_at',
])
->typecast([
'id' => 'uuid',
'title' => 'string',
'price' => 'string',
'currency' => 'string',
'image' => 'image',
'createdAt' => 'datetime',
'updatedAt' => 'datetime',
])
->typecastHandler([
Typecast::class,
UuidTypecast::class,
]);
Остальные сущности описываются аналогичным способом.
Для реализации API мы выделим несколько основных действий. Для идентификаторов действий мы будем использовать перечисления (enum):
Cart::GetCartId
Cart::GetCartItems
Cart::GetCartProducts
Cart::GetCart
Product::GetCartItemsProducts
Sales::GetUserDiscount
Давайте создадим наши действия. В качестве обработчиков действий (handler
) будем использовать анонимные функции. Файлы расположим в каталоге build/actions
.
Для наглядности, после создания каждого действия, я буду давать пояснения о том, что в них происходит.
Cart::GetCartId
// build/actions/cart.php
Action::declare(Cart::GetCartId)
->description('Get cart id from request')
->handler(function (ActionContext $context): CartId {
/** @var ServerRequestInterface $request */
$request = $context->argument();
$cartId = Uuid::fromString($request->getAttribute('cartId'));
return new CartId($cartId);
})
->type(CartId::class)
->require(Request::GetRequest)
->argument(ServerRequestInterface::class);
Что здесь происходит?
В метод handler
мы передаём анонимную функцию, в аргументе которой ожидается объект класса ActionContext
. Контекст передаётся по-умолчанию в случае, если в качестве обработчика применяется анонимная функция. С помощью контекста можно получить нужный нам аргумент или вызвать метод call
и передать в него другую анонимную функцию, аргументы для которой будут получены из DI-контейнера.
Метод type
указывает на класс или интерфейс, объект которого будет возвращён действием. Стоит помнить, что указание типа обязательно, если действие что-то возвращает. В противном случае будет выброшено исключение. И это работает в обе стороны, т.е. если тип указан, но обработчик ничего не возвращает, то вас так же ждёт исключение.
В require
мы передаём id действия, которое необходимо для выполнения целевого действия. Указанные в require
действия будут помещены в очередь на выполнение. Если действие(я) были выполнены ранее, то целевое действие будет помещено в очередь на выполнение, иначе, оно будет ожидать в отложенных, пока все затребованные действия не будут выполнены. В нашем случае это Request::GetRequest
, которое предоставляется пакетом Http.
В метод argument
мы передаём класс или интерфейс, объект которого мы ожидаем получить при обращении к $context->argument()
. В случае использования класса в качестве обработчика действия, аргумент будет передан в __invoke()
без использования ActionContext
.
Cart::GetCartItems
// build/actions/cart.php
Action::declare(Cart::GetCartItems)
->description('Get items from cart')
->handler(function (ActionContext $context): Collection {
/** @var CartId $cartId */
$cartId = $context->argument();
return DB::connection()
->query('SELECT * FROM cart_items WHERE cart_id = :cart_id')
->setParams(['cart_id' => $cartId->id->toString()])
->fetchAll(CartItem::class)
->await();
})
->type(CartItem::class, Collection::class)
->dependsOn(Type::of(CartId::class))
->argument(CartId::class);
И вот тут становится интереснее. В аргументе явно указано, что действие зависит от типа CartId
, но мы не затребовали никакого действия для получения этого типа. Вместо этого используется dependsOn
, где и определяется зависимость. В отличие от require
, dependsOn
не затребует какого-либо действия для своего выполнения, вместо этого он будет зависеть только от конкретного типа и ожидать, пока другое действие его предоставит. В этом случае мы убираем явную зависимость от другого действия, но теперь нам нужно будет контролировать выполнение и обеспечить действие всеми зависимостями. Но об этом мы поговорим позже.
Давайте пройдёмся по обработчику. Здесь используется фасад DB
из пакета IO, который обеспечит неблокирующий запрос к базе данных. Для реализации параллельного выполнения задействовано расширение ext-parallel
. Думаю, тут стоит рассказать подробнее о том, как это работает под капотом.
Внутри метода fetchAll
происходит вызов Fiber::suspend()
, в который будут переданы данные для запроса. После чего управление вернётся из файбера в основной поток выполнения, где в обработчике состояния MainSuspend
будет создан новый поток, в котором и будет выполнен запрос к БД. Само действие при этом будет возвращено в очередь на выполнение, и когда оно снова будет из неё извлечено, шина резюмирует файбер, передав ему объект Future. Вызов await()
будет опрашивать Future на предмет готовности выполнения параллельного потока и вызывать пустой Fiber::suspend()
в цикле до тех пор, пока параллельный поток не вернёт данные. Таким образом, во время выполнения запроса может выполняться полезная работа не только внутри обработчика действия, но и запускаться другие действия из очереди, если таковые есть.
Конечно, применение параллельных потоков больше подходит для более тяжеловесных запросов, нежели выборка элементов корзины, но для примера подойдёт и такой.
Product::GetCartItemsProducts
Теперь, когда у нас есть действие, возвращающее элементы из корзины, которые содержат id продуктов, нам нужно действие, которое получит продукты по этим id, чтобы в дальнейшем обогатить информацию о содержимом корзины. Давайте напишем такое действие.
// build/actions/product.php
Action::declare(Product::GetCartItemsProducts)
->description('Get products for cart items')
->handler(function (GetCartItemsProductsContext $context) {
return $context->argument()->whenNotEmpty(
function (Collection $cartItems) use ($context) {
$ids = $cartItems->keyBy('productId')->keys()->toArray();
$products = $context
->repository()
->findAll(['id' => ['in' => new Parameter($ids)]]);
return $products->map(function (Entity\Product $product) {
return new \Market\Application\Type\Product(
id: $product->getId(),
title: $product->getTitle(),
image: $product->getImage(),
price: $product->getPrice(),
);
});
},
fn () => Collection::empty()
);
})
->dependsOn(Type::collectionOf(CartItem::class))
->context(GetCartItemsProductsContext::class)
->type(\Market\Application\Type\Product::class, Collection::class)
->argument(CartItem::class, Collection::class);
Здесь для получения продуктов корзины используется репозиторий и пользовательский контекст. Работа с репозиториями реализована синхронно и не подразумевает применение параллельных потоков.
Пользовательский контекст может быть полезен для вынесения в него получения каких-либо зависимостей, тем самым разгружая обработчик.
// src\Application\Context\GetCartItemsProductsContext.php
<?php
declare(strict_types=1);
namespace Market\Application\Context;
use Cycle\ORM\ORMInterface;
use Duyler\EventBus\Action\Context\ActionContext;
use Duyler\EventBus\Action\Context\CustomContextInterface;
use Illuminate\Support\Collection;
use Market\Application\Type\CartItem;
use Market\Domain\Entity\Product;
use Market\Domain\Repository\ProductRepositoryInterface;
class GetCartItemsProductsContext implements CustomContextInterface
{
public function __construct(
private ActionContext $actionContext,
) {}
public function repository(): ProductRepositoryInterface
{
return $this->actionContext->call(
fn (ORMInterface $orm) => $orm->getRepository(Product::class)
);
}
/** @return Collection<CartItem> */
public function argument(): Collection
{
return $this->actionContext->argument();
}
}
Cart::GetCartProducts
Теперь, когда у нас есть коллекция продуктов и коллекция элементов корзины, давайте соберём их вместе, чтобы впоследствии использовать для ответа.
// build/actions/cart.php
Action::declare(Cart::GetCartProducts)
->description('Fill cart items from products data')
->handler(function (ActionContext $context) {
/** @var CartProductsData $cartProductsData */
$cartProductsData = $context->argument();
return $cartProductsData->cartItems->map(
function (CartItem $cartItem) use ($cartProductsData) {
$product = $cartProductsData->products
->keyBy('id')
->get($cartItem->productId->toString());
return new CartProduct(
productId: $product->id,
image: $product->image,
title: $product->title,
quantity: $cartItem->quantity,
price: $product->price,
);
});
}
)
->dependsOn(
Type::collectionOf(CartItem::class),
Type::collectionOf(Product::class),
)
->type(CartProduct::class, Collection::class)
->argument(CartProductsData::class)
->argumentFactory(fn (FactoryContext $context) => new CartProductsData(
products: $context->getTypeCollection(Product::class),
cartItems: $context->getTypeCollection(CartItem::class),
));
Как видно из кода, здесь применяется фабрика, для объединения двух коллекций типов и передачи их в аргумент. Двигаемся дальше?
Sales::GetUserDiscount
Что за магазин без скидок, верно? Давайте получим скидку для пользователя, которую будем показывать в корзине.
// build/actions/sales.php
Action::declare(Sales::GetUserDiscount)
->description('Get discount for user')
->handler(function (ActionContext $context) {
/** @var AuthData $authData */
$authData = $context->argument();
return DB::connection()
->query('SELECT * FROM discount WHERE user_id = :user_id')
->setParams(['user_id' => $authData->userId->toString()])
->fetch(\Market\Application\Type\Discount::class)
->await();
})
->require(\Market\Domain\Action\Auth::GetAuthData)
->type(\Market\Application\Type\Discount::class)
->argument(\Market\Application\Type\AuthData::class);
Теперь, когда у нас есть все необходимые данные для формирования корзины, давайте соберём их вместе.
Cart::GetCart
// build/actions/cart.php
Action::declare(Cart::GetCart)
->description('Get cart with full data')
->handler(function (ActionContext $context) {
/** @var CartData $cartData */
$cartData = $context->argument();
return new \Market\Application\Type\Cart(
products: $cartData->cartProducts,
discount: $cartData->discount,
);
})
->argument(\Market\Application\Argument\CartData::class)
->type(\Market\Application\Type\Cart::class)
->dependsOn(
Type::collectionOf(CartProduct::class),
Type::of(Discount::class),
)
->argumentFactory(fn (FactoryContext $context) => new CartData(
cartProducts: $context->getTypeById(Cart::GetCartProducts),
discount: $context->getTypeById(Sales::GetUserDiscount)
));
Тип Cart
выглядит так:
// src/Application/Type/Cart.php
<?php
declare(strict_types=1);
namespace Market\Application\Type;
use Illuminate\Support\Collection;
use Money\Money;
readonly class Cart
{
public Money $total;
public Money $totalWithDiscount;
public Money $totalDiscount;
public int $totalQuantity;
public function __construct(
/** @var Collection <CartProduct > */
public Collection $products,
public Discount $discount,
) {
$total = Money::USD(0);
$totalQuantity = 0;
foreach ($this->products as $cartProduct) {
$productPrice = $cartProduct->price->multiply($cartProduct->quantity);
$total = $total->add($productPrice);
$totalQuantity += $cartProduct->quantity;
}
$totalDiscount = ($total->divide(100))->multiply($this->discount->percentage);
$this->total = $total;
$this->totalQuantity = $totalQuantity;
$this->totalDiscount = $totalDiscount;
$this->totalWithDiscount = $total->subtract($totalDiscount);
}
}
Теперь возникает резонный вопрос - а как всё это запустить и получить ожидаемый результат?
Есть несколько способов «привязки» действий к роутам и формирования ответов. Давайте рассмотрим их.
С помощью контроллера.
Для этого нам необходимо создать сам контроллер:
// src/Application/Controller/GetCartController.php
<?php
declare(strict_types=1);
namespace Market\Application\Controller;
use Market\Application\Type\Cart;
use Duyler\Web\Controller\BaseController;
use Psr\Http\Message\ResponseInterface;
final class GetCartController extends BaseController
{
public function __invoke(Cart $cart): ResponseInterface
{
return $this->json($cart);
}
}
и объявить его в билде, привязав к нему действия и роут.
// build/controllers.php
Controller::build(GetCartController::class)
->actions(
Cart::GetCart,
Cart::GetCartId,
Cart::GetCartProducts,
Cart::GetCartItems,
Product::GetCartItemsProducts,
Sales::GetUserDiscount
)
->attributes(
new Route(
method: HttpMethod::Get,
pattern: '/api/carts/{$cartId}',
where: ['cartId' => Type::String],
)
);
В метод __invoke
контроллера можно указать тип как одного, так и всех указанных в actions
действий. Это может быть полезно в случае, если рендеринг происходит на стороне сервера и нужно передать в представление несколько переменных. В нашем случае достаточно только типа, возвращаемого действием Cart::GetCart
.
Сами же контроллеры в Duyler мало чем отличаются от привычных нам контроллеров в других PHP-фреймворках, и ничего не мешает писать приложения «по старинке», используя сервисы или любые другие подходы. При этом иметь возможность постепенно переходить на действия шины. Более подробно об этом будет доступно в документации. А пока давайте посмотрим, как ещё можно запустить наши действия.
С помощью сценариев
Сценарии в Duyler дают возможность декларативно описывать последовательность выполнения действий и задавать дополнительные условия для их выполнения. Давайте напишем такой сценарий для нашего API.
id: GetCart
description: 'Get cart data by id'
reason:
route:
method: !php/enum Duyler\Web\Enum\HttpMethod::Get
path: '/carts/{$cartId}'
where:
cartId: !php/enum Duyler\Router\Enum\Type::String
scenario:
start:
- !php/enum Market\Domain\Action\Cart::GetCartId
success:
next:
- !php/enum Market\Domain\Action\Sales::GetUserDiscount
- !php/enum Market\Domain\Action\Cart::GetCartItems
success:
next:
- !php/enum Market\Domain\Action\Product::GetCartItemsProducts
success:
next:
- !php/enum Market\Domain\Action\Cart::GetCartProducts
success:
next:
- !php/enum Market\Domain\Action\Cart::GetCart
success:
end:
- !php/enum Market\Domain\Action\Cart::SendCartResponse
Здесь мы видим, что в конце сценария появилось ещё одно действие Cart::SendCartResponse
, давайте его реализуем:
// build/actions/cart.php
Action::declare(Cart::SendCartResponse)
->description('Send cart response')
->handler(function (ActionContext $context) {
EventDispatcher::dispatch(
new \Duyler\EventBus\Dto\Event(
id: Response::ResponseCreated,
data: new JsonResponse($context->argument()),
),
);
})
->argument(\Market\Application\Type\Cart::class)
->dependsOn(Type::of(\Market\Application\Type\Cart::class));
Данное действие напрямую отправляет событие, которое задекларировано в пакете Http
. Конечно, можно создать универсальное действие без привязки к корзине и ожидать в нём в аргумент что-то вроде ResponseData
, но думаю, для демонстрации работы пусть будет более конкретным.
В сценариях можно указывать next-действия как для Success
статусов действий, так и для Fail
, таким образом реализуя ветвления. Как я уже писал выше, все действия, явно или неявно, возвращают ResultStatus
, который и является определением для ветвления в сценариях.
Запуск сценария в Duyler может быть инициирован тремя (на данный момент) способами:
по конкретному роуту (как в нашем случае)
по консольной команде (необходимо указать имя команды в секцию reason)
как часть перехода конечного автомата, о реализации которого мы поговорим в следующих статьях.
Давайте выполним заранее подготовленные миграции и накинем в базу фикстуры.
./bin/do orm:migrations:up
./bin/do orm:fixtures:load
Теперь запустим наш сценарий, перейдя по url.
Результат выполнения будет таким:
{
"total": {
"amount": "67076",
"currency": "USD"
},
"totalWithDiscount": {
"amount": "60366",
"currency": "USD"
},
"totalDiscount": {
"amount": "6710",
"currency": "USD"
},
"totalQuantity": 95,
"products": [
{
"productId": "0197eea0-0d83-7069-a0b8-1a7f104f80c6",
"image": "/img/product.png",
"title": "Gutmann LLC",
"quantity": 3,
"price": {
"amount": "664",
"currency": "USD"
}
},
{
"productId": "0197eea0-0d85-7388-bb8c-9dbe9b848d34",
"image": "/img/product.png",
"title": "Bartell Inc",
"quantity": 1,
"price": {
"amount": "679",
"currency": "USD"
}
},
{
"productId": "0197eea0-0d85-7388-bb8c-9dbe9c482fa4",
"image": "/img/product.png",
"title": "Reynolds, Dare and Hermiston",
"quantity": 5,
"price": {
"amount": "865",
"currency": "USD"
}
},
{
"productId": "0197eea0-0d85-7388-bb8c-9dbe9c74de86",
"image": "/img/product.png",
"title": "Erdman-Denesik",
"quantity": 1,
"price": {
"amount": "796",
"currency": "USD"
}
},
{
"productId": "0197eea0-0d85-7388-bb8c-9dbe9d09855c",
"image": "/img/product.png",
"title": "Price, Bergstrom and Runolfsdottir",
"quantity": 9,
"price": {
"amount": "898",
"currency": "USD"
}
},
{
"productId": "0197eea0-0d85-7388-bb8c-9dbe9ded5223",
"image": "/img/product.png",
"title": "Harvey PLC",
"quantity": 9,
"price": {
"amount": "645",
"currency": "USD"
}
},
{
"productId": "0197eea0-0d85-7388-bb8c-9dbe9eb16632",
"image": "/img/product.png",
"title": "Veum, Ernser and Beier",
"quantity": 5,
"price": {
"amount": "589",
"currency": "USD"
}
},
{
"productId": "0197eea0-0d85-7388-bb8c-9dbe9eb4bafe",
"image": "/img/product.png",
"title": "Wiza, Kihn and Lindgren",
"quantity": 5,
"price": {
"amount": "513",
"currency": "USD"
}
},
{
"productId": "0197eea0-0d85-7388-bb8c-9dbe9ec49401",
"image": "/img/product.png",
"title": "Mills, Stokes and Brown",
"quantity": 4,
"price": {
"amount": "887",
"currency": "USD"
}
},
{
"productId": "0197eea0-0d85-7388-bb8c-9dbe9f0d1d85",
"image": "/img/product.png",
"title": "Baumbach, Fritsch and Parisian",
"quantity": 9,
"price": {
"amount": "869",
"currency": "USD"
}
},
{
"productId": "0197eea0-0d85-7388-bb8c-9dbe9f93650c",
"image": "/img/product.png",
"title": "Price PLC",
"quantity": 10,
"price": {
"amount": "994",
"currency": "USD"
}
},
{
"productId": "0197eea0-0d85-7388-bb8c-9dbea01e2114",
"image": "/img/product.png",
"title": "Goldner Group",
"quantity": 3,
"price": {
"amount": "674",
"currency": "USD"
}
},
{
"productId": "0197eea0-0d85-7388-bb8c-9dbea072aaae",
"image": "/img/product.png",
"title": "Wilderman Ltd",
"quantity": 6,
"price": {
"amount": "174",
"currency": "USD"
}
},
{
"productId": "0197eea0-0d85-7388-bb8c-9dbea1124c31",
"image": "/img/product.png",
"title": "Wisoky LLC",
"quantity": 10,
"price": {
"amount": "933",
"currency": "USD"
}
},
{
"productId": "0197eea0-0d85-7388-bb8c-9dbea136c00d",
"image": "/img/product.png",
"title": "VonRueden-Wiza",
"quantity": 2,
"price": {
"amount": "690",
"currency": "USD"
}
},
{
"productId": "0197eea0-0d85-7388-bb8c-9dbea200bd33",
"image": "/img/product.png",
"title": "Balistreri-Raynor",
"quantity": 5,
"price": {
"amount": "596",
"currency": "USD"
}
},
{
"productId": "0197eea0-0d85-7388-bb8c-9dbea2283df8",
"image": "/img/product.png",
"title": "Schmeler and Sons",
"quantity": 1,
"price": {
"amount": "989",
"currency": "USD"
}
},
{
"productId": "0197eea0-0d85-7388-bb8c-9dbea2ba86c3",
"image": "/img/product.png",
"title": "Carroll-Turcotte",
"quantity": 7,
"price": {
"amount": "119",
"currency": "USD"
}
}
],
"discount": {
"percentage": 10
}
}
Наш API работает!
Как я уже писал выше, реализовать API-корзины выбрано не случайно. Этот кейс позволит охватить больше различных аспектов работы с Duyler. И далее мы продолжим улучшать наше API, и что-то мне подсказывает, что там, где есть API, должна быть и спецификация? Давайте ещё немного покодим и подключим OpenAPI для нашего API. Реализуем обработчики состояний а для валидации воспользуемся пакетом league/openapi-psr7-validator
.
Для этого нам потребуется реализовать два обработчика состояния, которые будут валидировать запросы и ответы, используя спецификацию.
RequestValidationStateHandler
// src/Application/StateHandler/RequestValidationStateHandler.php
<?php
declare(strict_types=1);
namespace Market\Application\StateHandler;
use Duyler\EventBus\Contract\State\MainAfterStateHandlerInterface;
use Duyler\EventBus\State\Service\StateMainAfterService;
use Duyler\EventBus\State\StateContext;
use Duyler\Http\Action\Request;
use League\OpenAPIValidation\PSR7\OperationAddress;
use League\OpenAPIValidation\PSR7\ServerRequestValidator;
use League\OpenAPIValidation\PSR7\ValidatorBuilder;
use Market\Application\Config\OpenApiConfig;
use Override;
use Psr\Http\Message\ServerRequestInterface;
final class RequestValidationStateHandler implements MainAfterStateHandlerInterface
{
private ServerRequestValidator $requestValidator;
public function __construct(
OpenApiConfig $openApiConfig,
ValidatorBuilder $validatorBuilder,
) {
$this->requestValidator = $validatorBuilder
->fromYamlFile($openApiConfig->pathToOpenApiSpec)
->getServerRequestValidator();
}
#[Override]
public function handle(StateMainAfterService $stateService, StateContext $context): void
{
/** @var ServerRequestInterface $request */
$request = $stateService->getResultData();
if (false === $this->requestValidator->getSchema()->paths->hasPath($request->getUri()->getPath())) {
return;
}
$body = clone $request->getBody();
$this->requestValidator->validate($request->withBody($body));
$context->write(
'operationAddress',
new OperationAddress(
$request->getUri()->getPath(),
strtolower($request->getMethod())
)
);
}
#[Override]
public function observed(StateContext $context): array
{
return [Request::GetRequest];
}
}
Что здесь происходит? Метод observed
возвращает массив идентификаторов действий, на которые будет запускаться обработчик состояния. В нашем случае это Request::GetRequest
, а так как обработчик имплементирует интерфейс MainAfterStateHandlerInterface
, то и запускаться он будет после того, как действие Request::GetRequest
будет выполнено.
В самом обработчике, с помощью сервиса, который предоставляется шиной, достаётся результат работы действия, валидируется и кладётся в контекст, который доступен из других обработчиков состояний.
ResponseValidationStateHandler
// src/Application/StateHandler/ResponseValidationStateHandler.php
<?php
declare(strict_types=1);
namespace Market\Application\StateHandler;
use Duyler\EventBus\Contract\State\MainCyclicStateHandlerInterface;
use Duyler\EventBus\State\Service\StateMainCyclicService;
use Duyler\EventBus\State\StateContext;
use Duyler\Http\Event\Response;
use League\OpenAPIValidation\PSR7\OperationAddress;
use League\OpenAPIValidation\PSR7\ResponseValidator;
use League\OpenAPIValidation\PSR7\ValidatorBuilder;
use Market\Application\Config\OpenApiConfig;
use Override;
use Psr\Http\Message\ResponseInterface;
final class ResponseValidationStateHandler implements MainCyclicStateHandlerInterface
{
private ResponseValidator $responseValidator;
public function __construct(
OpenApiConfig $openApiConfig,
ValidatorBuilder $validatorBuilder,
) {
$this->responseValidator = $validatorBuilder
->fromYamlFile($openApiConfig->pathToOpenApiSpec)
->getResponseValidator();
}
#[Override]
public function handle(StateMainCyclicService $stateService, StateContext $context): void
{
if (false === $stateService->resultIsExists(Response::ResponseCreated)) {
return;
}
/** @var OperationAddress $operationAddress */
$operationAddress = $context->read('operationAddress');
if (null === $operationAddress) {
return;
}
/** @var ResponseInterface $response */
$response = $stateService->getResult(Response::ResponseCreated)->data;
$this->responseValidator->validate($operationAddress, $response);
}
}
Здесь мы имплементируем обработчик для циклического состояния и проверяем наличие результата для Response::ResponseCreated
. В случае успеха валидируем его, используя данные из контекста.
Теперь нам нужно подключить обработчики к проекту, а также создать спецификацию и скормить её валидатору.
Подключаем обработчики состояний:
// build/states.php
StateHandler::add(RequestValidationStateHandler::class);
StateHandler::add(ResponseValidationStateHandler::class);
StateContext::add([
RequestValidationStateHandler::class,
ResponseValidationStateHandler::class
]);
Для создания спецификации предлагаю попросить LLM сгенерировать её для нас, на основе ответа API, полученного ранее. С этим LLM прекрасно справляется. Пусть наш openapi.yaml
лежит в каталоге docs
.
Теперь в конфиг OpenApiConfig
, используемый в RequestValidationStateHandler
, укажем путь до спецификации:
// config/openapi.php
/**
* @var ConfigInterface $config
*/
return [
OpenApiConfig::class => [
'pathToOpenApiSpec' => $config->path('docs/openapi.yaml'),
],
];
Теперь API имеет спецификацию и валидируется. Но чего-то не хватает, не так ли? Да, не хватает «веб-морды» для спецификации. Давайте это исправим. Для этого создадим представление и подключим библиотеку, которая отресует Swagger UI.
Шаблон
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Swagger UI</title>
<link rel="stylesheet" type="text/css" href="/swagger/swagger-ui.css" />
<link rel="stylesheet" type="text/css" href="/swagger/index.css" />
<link rel="icon" type="image/png" href="/swagger/favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/swagger/favicon-16x16.png" sizes="16x16" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="/swagger/swagger-ui-bundle.js" charset="UTF-8"> </script>
<script src="/swagger/swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
<script src="/swagger/swagger-initializer.js" charset="UTF-8"> </script>
</body>
</html>
Теперь нам нужно перегнать спецификацию из .yaml
в .json
. Для этого создадим консольную команду, при запуске которой спецификация будет пересобираться в .json
. Создать консольную команду в Duyler довольно просто, для этого напишем новое действие:
// build/actions/console.php
Action::declare(OpenAPI::GenerateUI)
->description('Generate Swagger UI json file')
->handler(function (ActionContext $context) {
/** @var OpenApiConfig $oaConfig */
$oaConfig = $context->call(fn (OpenApiConfig $oaConfig): OpenApiConfig => $oaConfig);
$yaml = Yaml::parseFile($oaConfig->pathToOpenApiSpec);
File::write(
$oaConfig->pathToJsonForUI,
json_encode($yaml, JSON_UNESCAPED_UNICODE),
)->await();
});
и добавим в конфиг CommandConfig
название команды и действие, которое она запускает:
// config/console.php
/**
* @var ConfigInterface $config
*/
return [
CommandConfig::class => [
'commands' => [
'openapi:ui:generate' => OpenAPI::GenerateUI,
],
]
];
А в конфиг OpenApiConfig
добавим путь до openapi.json
// config/openapi.php
/**
* @var ConfigInterface $config
*/
return [
OpenApiConfig::class => [
'pathToOpenApiSpec' => $config->path('docs/openapi.yaml'),
'pathToJsonForUI' => $config->path('public/swagger/openapi.json'),
],
];
Теперь можно перегенерировать спецификацию, когда это необходимо, выполнив:
./bin/do openapi:ui:generate
Осталось лишь отрендерить наше представление. Давайте создадим для этого ещё одно действие:
// build/actions/page.php
Action::declare()
->description('Render Swagger UI')
->attributes(
new Route(
method: HttpMethod::Get,
pattern: '/swagger',
),
new View(
name: 'swagger',
),
);
Как видно из описания действия, у него нет ни id
, ни handler
. Такое действие будет анонимным, а его id - динамическим.
В метод attributes
передаётся роут и имя представления, которое отрендерится при переходе по указанному роуту. Стоит отметить, что так можно сделать для любых публичных действий, а во View
можно передать не только имя представления, но и ключ переменной, в которую будут переданы данные, которые вернёт действие. Более того, если в других действиях указать ключ переменной, используя атрибут View
, данные из них будут проброшены в одно и то же представление.
На этом моменте можно было бы и закончить, но, допустим, мы не хотим, чтобы наша спека торчала наружу в продакшне. Давайте сделаем так, чтобы она была доступна только в окружении dev
. Для этого воспользуемся возможностями пакета Aspect, который входит в Duyler по-умолчанию.
Создадим Advice, который будет проверять, какое окружение используется, и в случае ‘prod’ выкидывать исключение до того, как действие будет запущено.
// src/Application/Aspect/DeniedForProdEnv.php
<?php
declare(strict_types=1);
namespace Market\Application\Aspect;
use Duyler\Config\ConfigInterface;
use Duyler\Http\Exception\NotFoundHttpException;
final class DeniedForProdEnv
{
public function __construct(
private ConfigInterface $config,
) {}
/**
* @throws NotFoundHttpException
*/
public function __invoke(): void
{
if ($this->config->env('APP_ENV') === 'prod') {
throw new NotFoundHttpException();
}
}
}
и подключим его к действию, которое отрисовывает UI:
// build/actions/page.php
Action::declare()
->description('Render Swagger UI')
->attributes(
new Route(
method: HttpMethod::Get,
pattern: '/swagger',
),
new View(
name: 'swagger',
),
new Before(
DeniedForProdEnv::class,
),
);
Теперь при запросе страницы с UI в продакшне мы увидим исключение или страничку 404, если сделаем соответствующий обработчик для NotFoundHttpException
.
Вот теперь точно всё. API работает, к нему есть спецификация и валидация.
Конечно, реализовать подобное API можно разными способами, и действия можно организовать иначе. Но в рамках этой статьи был выбран подход максимального разделения, чтобы охватить как можно больше аспектов работы фреймворка.
В следующих статьях мы реализуем более интересный пример - воркер для обработки сообщений из RabbitMQ и рассмотрим поближе остальные возможности Duyler.
Исходные коды сего велосипеда, лежат тут.
Заключение
Состояние проекта
На данный момент пакеты фреймворка не имеют релизных версий. Так как некоторые пакеты были написаны уже достаточно давно, они требуют рефакторинга и покрытия тестами тех мест, которые ещё не покрыты. Помимо этого, идёт работа над доработкой уже реализованных фичей и добавлением новых. Пишется документация. Но, разумеется, всё это идёт достаточно медленно, так как делается одним человеком в свободное от работы время.
Так, например, в работе находится пакет Dashboard, который позволяет просмотреть все имеющиеся в приложении действия, события и другие компоненты в удобном представлении всех связей в виде узлов.
Существенно расширяется пакет Scenario для предоставления возможности строить сложные конечные автоматы, а также добавлять и редактировать сценарии по API.
Для пакета IO ведётся работа по расширению функционала, а также реализации драйверов для работы с блокирующими операциями, отличными от ext-parallel
.
В планах реализовать работу с gRPC и Centrifugo. Ну и много чего ещё, о чём мы поговорим в следующих статьях.
Несмотря на то, что проект не готов к продакшну, он хорошо себя показал на различных проектах, где работает по сей день в качестве реализации Telegram-ботов, разного рода воркеров и т.д.
Потыкать палочкой Duyler можно уже сейчас, в репозитории есть небольшой гайд, как его поднять, используя уже настроенный для него Docker контейнер.
P.S.: желающих высказать конструктивную критику, хейт и бугурт - прошу в комментарии. Спасибо за внимание!