Search
Write a publication
Pull to refresh

Duyler — Событийно-ориентированный, неблокирующий PHP-фреймворк

Level of difficultyMedium
Reading time27 min
Views498

Кликбейтненько? Да, но не совсем. Речь в статье действительно пойдёт о событийно-ориентированном фреймворке "Дуайлер" (производное от 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 - наступает в случае если действие выбросило исключение

Каждое состояние может быть обработано с помощью внешних обработчиков состояний. Для каждого типа состояния, предусмотрен определённый набор функций, доступных для взаимодействия с шиной из обработчика состояния.

Обзор свойств и параметров для действий

Свойство

Тип

Описание

id

string|UnitEnum

Уникальный идентификатор действия. Например: 'MyAction.DoWork'.

description

string

Описание действия

handler

string|Closure

Обработчик действия. Анонимная функция или вызываемый класс.

require

array

Массив идентификаторов действий. Целевое действие может затребовать выполнения других действий, которые будут являться условием для выполнения целевого действия. Результат выполнения затребованных действий может быть передан в целевое действие. В случае если хотя бы одно из затребованных действий вернуло результат со статусом ResultStatus::Fail, целевое действие выполнено не будет.

dependsOn

array

Массив типов, описываемых через фасад Type, например: Type::of(User::class). Действие неявно зависит от результатов других действий, которые предоставляют типы, указанные в dependsOn.

listen

array

Действие будет запущено после того, как события с указанными ID будут переданы в шину. Если в событие передан объект, он может быть передан в действие.

bind

array

Маппинг интерфейсов для DI-контейнера. Например: [MyInterface::class => MyClass::class].

providers

array

Массив сервис-провайдеров для DI-контейнера.

definitions

array

Массив значений для конструкторов классов. Например: [MyClass::class => ['argName' => 'value']]

argument

string

Тип аргумента действия.

argumentFactory

string|Closure

Анонимная функция или выполняемый класс фабрики для аргумента действия.

type

string

Тип, возвращаемый действием.

typeCollection

string

Указывает, что действие возвращает коллекцию типов.

immutable

bool

Устанавливает возможность использования мутабельных типов возвращаемых значений.

rollback

string|Closure

Анонимная функция или выполняемый класс, вызываемый для отката действия в случае, если было выброшено исключение в любом месте программы.

externalAccess

bool

Разрешение доступа к результату выполнения действия из BusInterface или обработчиков состояний.

repeatable

bool

Разрешение повторно выполнять действие.

lock

bool

Если действие использует параллельное выполнение внутри Fiber::suspend() (напр. ext-parallel), это гарантирует последовательность выполнения повторяемых действий.

context

string

Предоставляет возможность указать пользовательский класс контекста для действия, если в качестве обработчика используется анонимная функция.

private

bool

Если установлено значение true, это действие не может быть затребовано в других действиях.

sealed

array

Массив идентификаторов действий, которые могут затребовать это действие и получать его результат.

silent

bool

Если установлено значение true, действие не будет генерировать внутреннее событие выполнения. Подписка на такое действие сгенерирует исключение.

alternates

array

Массив идентификаторов действий, результат которых может заменить результат выполнения целевого действия. Результат текущего целевого действия будет заменён, если действие вернёт результат со статусом ResultStatus::Fail.

retries

int

Количество повторов, если действие возвращает результат со статусом ResultStatus::Fail.

retryDelay

DateInterval

Время задержки между повторами.

labels

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.: желающих высказать конструктивную критику, хейт и бугурт - прошу в комментарии. Спасибо за внимание!

Tags:
Hubs:
+3
Comments0

Articles