- PVSM.RU - https://www.pvsm.ru -
За 6 лет опыта работы в разных IT-компаниях — ни разу не встречал проекты на Laravel, где использовался бы CQRS. Да и погуглив немного, если честно, не нашел ничего стоящего (касательно примеров), поэтому решил сам написать статью на данную тему.
CQRS (Command Query Responsibility Segregation) — это по сути архитектурный паттерн, который позволяет разделить операции с данными на две категории: команды и запросы.
Command (команда) — операция, которая изменяет состояние системы, но не возвращает данных (кроме, возможно, результата успеха/ошибки).
Query (запрос) — операция, которая возвращает данные и не изменяет состояние системы. Она не должна иметь никаких побочных эффектов и лучше всего подходит для параллельного выполнения.
Разделять команды и запросы нужно для предотвращения побочных эффектов при чтении, улучшения масштабируемости и четкого распределения ответственности. Запросы — только для получения данных, команды — для их изменения. Это ключ к пониманию и правильной реализации CQRS.
Если с этим все понятно (чтобы потом не путать, что делает query, а что command), то переходим к примерам реализации CQRS на Laravel.
В своих проектах я обычно использую пакет 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, то в этом случае они ОЧЕНЬ сильно пригодятся.
Помним, да, пятый принцип 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;
}
В 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
);
}
}
В конечном итоге структурно это будет выглядеть примерно так:
Вот и все, наши шины готовы к работе. Не забудьте только зарегистрировать сервис-провайдер в bootstrap/providers.php.
Чтобы было поинтереснее, я покажу реальные примеры из своего проекта на Laravel, где используется гексагональная архитектура и DDD, а также Action-Domain-Responder и, конечно, CQRS.
За основу будут взяты операции CheckMe и Login, т. к. та же регистрация у меня используется с пайплаными и джобами, это сложно будет для понимания, поэтому пока на простых операциях. Но если вам все же интересно, то вот тут [2] можно глянуть пример.
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.
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
Нажмите здесь для печати.