- PVSM.RU - https://www.pvsm.ru -

Laravel и CQRS: как разделить логику чтения и записи?

За 6 лет опыта работы в разных IT-компаниях — ни разу не встречал проекты на Laravel, где использовался бы CQRS. Да и погуглив немного, если честно, не нашел ничего стоящего (касательно примеров), поэтому решил сам написать статью на данную тему.

CQRS (Command Query Responsibility Segregation) — это по сути архитектурный паттерн, который позволяет разделить операции с данными на две категории: команды и запросы.

  • Command (команда) — операция, которая изменяет состояние системы, но не возвращает данных (кроме, возможно, результата успеха/ошибки).

  • Query (запрос) — операция, которая возвращает данные и не изменяет состояние системы. Она не должна иметь никаких побочных эффектов и лучше всего подходит для параллельного выполнения.

Разделять команды и запросы нужно для предотвращения побочных эффектов при чтении, улучшения масштабируемости и четкого распределения ответственности. Запросы — только для получения данных, команды — для их изменения. Это ключ к пониманию и правильной реализации CQRS.

Если с этим все понятно (чтобы потом не путать, что делает query, а что command), то переходим к примерам реализации CQRS на Laravel.

Реализация командных шин CQRS

1. Базовые классы Command и Query

В своих проектах я обычно использую пакет wendelladriel/laravel-validated-dto [1] для базовых классов Command и Query, но т. к. это ознакомительная статья, то обойдемся пока без него.

Базовый класс Command:

<?php declare(strict_types=1);

namespace AppShared;

abstract class Command {}

Базовый класс Query:

<?php declare(strict_types=1);

namespace AppShared;

abstract class Query {}

Можно было бы обойтись и без них даже, но если вы решите использовать какой-либо пакет DTO, то в этом случае они ОЧЕНЬ сильно пригодятся.

2. Интерфейсы командных шин

Помним, да, пятый принцип SOLID — Dependency Inversion Principle?

Интерфейс CommandBusContract:

<?php declare(strict_types=1);

namespace AppContracts;

use AppSharedCommand;

interface CommandBusContract
{
    /**
     * Dispatches a command and returns the result.
     *
     * @param Command $command
     * @return mixed|null
     */
    public function send(Command $command): mixed;

    /**
     * Registers a mapping of commands to their handlers.
     *
     * @param array<string, string> $map
     */
    public function register(array $map): void;
}

Интерфейс QueryBusContract:

<?php declare(strict_types=1);

namespace AppContracts;

use AppSharedQuery;

interface QueryBusContract
{
    /**
     * Executes a query and returns the result.
     *
     * @param Query $query
     * @return mixed
     */
    public function ask(Query $query): mixed;

    /**
     * Registers a mapping of queries to their handlers.
     *
     * @param array<string, string> $map
     */
    public function register(array $map): void;
}

3. Реализация интерфейсов командных шин

В Laravel есть интерфейс IlluminateContractsBusDispatcher — он по сути является фундаментальной частью системы обработки команд и запросов. Основное назначение этого интерфейса — определить контракт для диспетчеризации (отправки) команд и запросов к соответствующим обработчикам. К нему мы и прибегнем, дабы не изобретать велосипед.

Класс командной шины CommandBus:

<?php declare(strict_types=1);

namespace AppBuses;

use AppContractsCommandBusContract;
use IlluminateContractsBusDispatcher;
use AppSharedCommand;

final class CommandBus implements CommandBusContract
{
    /**
     * Constructs a new CommandBus instance.
     *
     * @param Dispatcher $commandBus
     */
    public function __construct(
        private Dispatcher $commandBus
    ) {}

    /**
     * Dispatches a command and returns the result.
     *
     * @param Command $command
     * @return mixed|null
     */
    public function send(Command $command): mixed
    {
        return $this->commandBus->dispatch(
            command: $command
        );
    }

    /**
     * Registers a mapping of commands to their handlers.
     *
     * @param array<string, string> $map
     */
    public function register(array $map): void
    {
        $this->commandBus->map(map: $map);
    }
}

Класс командной шины QueryBus:

<?php declare(strict_types=1);

namespace AppBuses;

use AppContractsQueryBusContract;
use IlluminateContractsBusDispatcher;
use AppSharedQuery;

final class QueryBus implements QueryBusContract
{
    /**
     * Constructs a new QueryBus instance.
     *
     * @param Dispatcher $queryBus
     */
    public function __construct(
        private Dispatcher $queryBus
    ) {}

    /**
     * Executes a query and returns the result.
     *
     * @param Query $query
     * @return mixed
     */
    public function ask(Query $query): mixed
    {
        return $this->queryBus->dispatch(command: $query);
    }

    /**
     * Registers a mapping of queries to their handlers.
     *
     * @param array<string, string> $map
     */
    public function register(array $map): void
    {
        $this->queryBus->map(map: $map);
    }
}

Сервис-провайдер:

<?php declare(strict_types=1);

namespace AppProviders;

use IlluminateSupportServiceProvider;

final class BusServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        $this->app->singleton(
            abstract: AppContractsCommandBusContract::class,
            concrete: AppBusesCommandBus::class
        );

        $this->app->singleton(
            abstract: AppContractsQueryBusInterface::class,
            concrete: AppBusesQueryBus::class
        );
    }
}

В конечном итоге структурно это будет выглядеть примерно так:

Пример "самопальной" структуры с использованием CQRS на Laravel

Пример "самопальной" структуры с использованием CQRS на Laravel

Вот и все, наши шины готовы к работе. Не забудьте только зарегистрировать сервис-провайдер в bootstrap/providers.php.

Примеры реализации операций с Command и Query

Чтобы было поинтереснее, я покажу реальные примеры из своего проекта на Laravel, где используется гексагональная архитектура и DDD, а также Action-Domain-Responder и, конечно, CQRS.

За основу будут взяты операции CheckMe и Login, т. к. та же регистрация у меня используется с пайплаными и джобами, это сложно будет для понимания, поэтому пока на простых операциях. Но если вам все же интересно, то вот тут [2] можно глянуть пример.

Реализация операции Login с использованием Command

1. Класс LoginCommand:

<?php declare(strict_types=1);

namespace AppAccountApplicationAuthLogin;

use AppSharedApplicationCommandCommand;
use WendellAdrielValidatedDTOCastingStringCast;
use WendellAdrielValidatedDTOCastingBooleanCast;
use WendellAdrielValidatedDTOAttributesCast;

final class LoginCommand extends Command
{
    /**
     * The email address of the user to register.
     *
     * @var string
     */
    #[Cast(type: StringCast::class, param: null)]
    public string $email;

    /**
     * The password for the new user account.
     *
     * @var string
     */
    #[Cast(type: StringCast::class, param: null)]
    public string $password;

    /**
     * Whether to remember the user (i.e. keep them logged in).
     *
     * @var bool
     */
    #[Cast(type: BooleanCast::class, param: null)]
    public bool $rememberMe = false;
    
    /**
     * Maps properties to data keys.
     *
     * @return array<string, string>
     */
    protected function mapData(): array
    {
        return [
            'remember_me' => 'rememberMe'
        ];
    }
}

2. Обработчик LoginHandler:

<?php declare(strict_types=1);

namespace AppAccountApplicationAuthLogin;

use AppSharedApplicationHandler;
use AppAccountDomainProviderAuthProviderInterface;
use IlluminateSupportFacadesLog;

final class LoginHandler extends Handler
{
    /**
     * Constructs a new LoginHandler instance.
     *
     * @param AuthProviderInterface $auth
     */
    public function __construct(
        private AuthProviderInterface $auth
    ) {}

    /**
     * Handler to process user login and return a JWT token.
     *
     * @param LoginCommand $command
     * @return array<string, string>|null
     *
     * @throws RuntimeException
     */
    public function handle(LoginCommand $command): ?array
    {
        try {
            return $this->auth->getTokenByCredentials(
                credentials: $command->toArray()
            );
        }

        catch (Throwable $e) {
            $message = trim(string: <<<MSG
                Login handler error: {$e->getMessage()}
                in {$e->getFile()}:{$e->getLine()}
            MSG);
            
            Log::error(message: $message, context: [
                'exception' => $e
            ]);

            throw new RuntimeException(
                message: 'Login failed. Please try again',
                code: (int) $e->getCode(),
                previous: $e
            );
        }
    }
}

3. Сервис-провайдер:

<?php declare(strict_types=1);

namespace AppAccountInfrastructureDispatching;

use IlluminateSupportServiceProvider;
use AppAccountApplicationAuthLoginLoginCommand;
use AppAccountApplicationAuthLoginLoginHandler;
use AppSharedDomainBusCommandBusInterface;

final class CommandDispatcher extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     */
    public function boot(CommandBusInterface $commandBus): void
    {
        $commandBus->register(map: [
            LogoutCommand::class => LogoutHandler::class,
        ]);
    }
}

4. Экшен:

<?php declare(strict_types=1);

namespace AppAccountPresentationActionAuth;

use AppSharedPresentationController as Action;
use AppSharedDomainBusCommandBusInterface;
use AppAccountPresentationRequestLoginRequest;
use AppAccountPresentationResponderAuthLoginResponder;
use AppAccountApplicationAuthLoginLoginCommand;
use AppAccountPresentationResponseTokenResponse;
use SpatieRouteAttributesAttributesRoute;
use SpatieRouteAttributesAttributesPrefix;

#[Prefix(prefix: 'v1')]
final class LoginAction extends Action
{
	/**
	 * Handles formatting and returning the login response.
	 * 
     * @var LoginResponder
     */
	private readonly LoginResponder $responder;

    /**
     * Constructs a new LoginAction instance.
     *
     * @param CommandBusInterface $commandBus
     */
	public function __construct(
		private readonly CommandBusInterface $commandBus
	) {
		$this->responder = new LoginResponder();
	}

    /**
     * Handles the login HTTP POST request.
     *
     * @param LoginRequest $request
     * @return TokenResponse
     */
    #[Route(methods: 'POST', uri: '/login')]
    public function __invoke(LoginRequest $request): TokenResponse
    {
        /** @var array<string, string>|null $result */
        $result = $this->commandBus->send(
            command: LoginCommand::fromRequest(request: $request)
        );
        
        return $this->responder->respond(result: $result);
    }
}

Респондер тут не стану демонстрировать, т. к. по сути уже понятно, как используется шина в сочетании с Command. Теперь перейдем к Query.

Реализация операции CheckMe с использованием Query

1. Класс CheckMeQuery:

<?php declare(strict_types=1);

namespace AppAccountApplicationAuthCheckMe;

use AppSharedApplicationQueryQuery;
use IlluminateHttpRequest;

final class CheckMeQuery extends Query
{
    /**
     * Constructs a new CheckMeQuery instance.
     *
     * @param Request $request
     */
	public function __construct(
		public private(set) Request $request
	) {}
}

2. Обработчик CheckMeHandler:

<?php declare(strict_types=1);

namespace AppAccountApplicationAuthCheckMe;

use AppSharedApplicationHandler;
use AppAccountDomainUser;
use IlluminateSupportFacadesLog;

final class CheckMeHandler extends Handler
{
    /**
     * Handler to retrieve the currently authenticated user.
     *
     * @param CheckMeQuery $query
     * @return User|null
     *
     * @throws RuntimeException
     */
    public function handle(CheckMeQuery $query): ?User
    {
        try {
            $auth = $query->request->user();
            $user = $auth->user;

            return $user ?? null;
        }

        catch (Throwable $e) {
            $message = trim(string: <<<MSG
                CheckMe handler error: {$e->getMessage()}
                in {$e->getFile()}:{$e->getLine()}
            MSG);

            Log::error(message: $message, context: [
                'exception' => $e
            ]);

            throw new RuntimeException(
                message: 'Failed to retrieve authenticated user.',
                code: (int) $e->getCode(),
                previous: $e
            );
        }
    }
}

3. Сервис-провайдер:

<?php declare(strict_types=1);

namespace AppAccountInfrastructureDispatching;

use IlluminateSupportServiceProvider;
use AppAccountApplicationAuthCheckMeCheckMeHandler;
use AppAccountApplicationAuthCheckMeCheckMeQuery;
use AppSharedDomainBusQueryBusInterface;

final class QueryDispatcher extends ServiceProvider
{
    /**
     * Bootstrap any application services.
     */
    public function boot(QueryBusInterface $queryBus): void
    {
        $queryBus->register(map: [
            CheckMeQuery::class => CheckMeHandler::class,
        ]);
    }
}

4. Экшен:

<?php declare(strict_types=1);

namespace AppAccountPresentationActionAuthCheck;

use AppSharedPresentationController as Action;
use AppSharedDomainBusQueryBusInterface;
use AppSharedPresentationResponseResourceResponse;
use AppAccountPresentationResponderAuthCheckCheckMeResponder;
use AppAccountApplicationAuthCheckMeCheckMeQuery;
use SpatieRouteAttributesAttributesPrefix;
use SpatieRouteAttributesAttributesMiddleware;
use SpatieRouteAttributesAttributesRoute;
use IlluminateHttpRequest as CheckMeRequest;

#[Prefix(prefix: 'v1')]
#[Middleware(middleware: 'auth:api')]
final class CheckMeAction extends Action
{
    /**
     * Handles the authenticated user's identity check request.
     *
     * @var CheckMeResponder
     */
	private readonly CheckMeResponder $responder;

    /**
     * Constructs a new CheckMeAction instance.
     *
     * @param QueryBusInterface $queryBus
     */
	public function __construct(
		private readonly QueryBusInterface $queryBus
	) {
		$this->responder = new CheckMeResponder();
	}

    /**
     * Processes the GET request to verify the current authenticated user.
     *
     * @param CheckMeRequest $request
     * @return ResourceResponse
     */
    #[Route(methods: 'GET', uri: '/check-me')]
    public function __invoke(CheckMeRequest $request): ResourceResponse
    {
        $result = $this->queryBus->ask(
            query: new CheckMeQuery(request: $request)
        );
        
        return $this->responder->respond(result: $result);
    }
}

Видите? По сути ничего сложного, единственное, структурно изначально собрать проект правильно, чтобы потом не упереться в стену.

Заключение

CQRS в связке с Laravel — это мощный подход, который позволяет четко разделить логику чтения и записи, сделать код более читаемым и поддерживаемым.

Несмотря на то, что на практике проекты с Laravel редко используют CQRS, этот паттерн отлично вписывается в современные архитектуры, особенно при применении DDD с гексагоналкой.

Если статья вызвала вопросы или захочется глубже погрузиться в детали — буду рад помочь и поделиться опытом.

Автор: a0xh

Источник [3]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/laravel/434043

Ссылки в тексте:

[1] wendelladriel/laravel-validated-dto: https://github.com/WendellAdriel/laravel-validated-dto

[2] тут: https://github.com/initialstacker/laravel-ddd

[3] Источник: https://habr.com/ru/articles/958310/?utm_source=habrahabr&utm_medium=rss&utm_campaign=958310