Кликбейтненько? Да, но не совсем. Речь в статье действительно пойдёт о событийно-ориентированном фреймворке "Дуайлер" (производное от 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::LoopMainBefore- наступает перед сборкой и запуском действия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::GetCartIdCart::GetCartItemsCart::GetCartProductsCart::GetCartProduct::GetCartItemsProductsSales::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.: желающих высказать конструктивную критику, хейт и бугурт - прошу в комментарии. Спасибо за внимание!
