digital-craftsman / cqrs
通过 CQS 在 Symfony 中降低变更成本
Requires
- php: 8.2.*|8.3.*
- symfony/framework-bundle: ^6.4|^7.0
- symfony/serializer: ^6.4|^7.0
Requires (Dev)
- digital-craftsman/ids: 0.13.*
- friendsofphp/php-cs-fixer: ^3.15
- phpunit/phpunit: ^10.5
- symfony/property-access: ^7.0
- symfony/property-info: ^7.0
- symfony/yaml: ^7.0
- vimeo/psalm: ^5.17
- dev-main
- v1.0.0
- v0.13.2
- v0.13.1
- v0.13.0
- v0.12.0
- v0.11.0
- v0.10.0
- v0.9.0
- v0.8.1
- v0.8.0
- v0.7.0
- v0.6.0
- v0.5.0
- v0.4.0
- v0.3.0
- v0.2.0
- v0.1.0
- v0.1.0-beta.4
- v0.1.0-beta.3
- v0.1.0-beta.2
- v0.1.0-beta.1
- v0.1.0-alpha.11
- v0.1.0-alpha.10
- v0.1.0-alpha.9
- v0.1.0-alpha8
- v0.1.0-alpha7
- v0.1.0-alpha.6
- v0.1.0-alpha.5
- v0.1.0-alpha.4
- 0.1.0-alpha3
- 0.1.0-alpha.2
- 0.1.0-alpha.1
This package is auto-updated.
Last update: 2024-08-24 13:53:54 UTC
README
安装和配置
通过 composer 安装包
composer require digital-craftsman/cqs-routing
然后,将以下 cqs-routing.php
文件添加到您的 config/packages
中,并用您的接口实例替换它
<?php declare(strict_types=1); use DigitalCraftsman\CQSRouting\DTOConstructor\SerializerDTOConstructor; use DigitalCraftsman\CQSRouting\RequestDecoder\JsonRequestDecoder; use DigitalCraftsman\CQSRouting\ResponseConstructor\EmptyResponseConstructor; use DigitalCraftsman\CQSRouting\ResponseConstructor\SerializerJsonResponseConstructor; // Automatically generated by Symfony though a config builder (see https://symfony.com.cn/doc/current/configuration.html#config-config-builder). use Symfony\Config\CqsRoutingConfig; return static function (CqsRoutingConfig $cqsRoutingConfig) { $cqsRoutingConfig->queryController() ->defaultRequestDecoderClass(JsonRequestDecoder::class) ->defaultDtoConstructorClass(SerializerDTOConstructor::class) ->defaultResponseConstructorClass(SerializerJsonResponseConstructor::class); $cqsRoutingConfig->commandController() ->defaultRequestDecoderClass(JsonRequestDecoder::class) ->defaultDtoConstructorClass(SerializerDTOConstructor::class) ->defaultResponseConstructorClass(EmptyResponseConstructor::class); };
您可以在以下位置找到完整的配置(包括用 yaml 配置的示例):完整配置
该软件包包含请求解码器、DTO 构造函数和响应构造函数的实例。有了这些,您就可以立即使用它。当您想使用这些实例时,只需要创建自己的 DTO 验证器、请求数据转换器和处理器包装器。
在哪里以及如何使用这些实例,以下将进行说明。
原因
使用 Symfony 构建CRUD和REST API非常容易。有像参数转换器这样的组件,都是为了将数据快速传入控制器以处理逻辑。遗憾的是,尽管以REST思维构建端点非常快,但处理业务逻辑使得变更变得容易、独立和安全却非常困难。简而言之,我们以较低的引入成本为代价,换取了较低的变更成本。
使用CQS意味着端点和数据模型之间的依赖性更少,因此在处理另一个端点时破坏一个端点的表面积也更小。Symfony最近增加了对在控制器中直接构造DTO的支持,但仍有一些缺失的部分。
CQS路由软件包填补了这一空白,并且通过仅略微增加引入成本,大大降低了变更成本。
总体目标
该软件包使使用CQS变得更加容易,并具有以下目标
- 从业务逻辑的角度来看,使了解正在发生的事情(非常快且易于理解)。
- 通过广泛使用值对象来提高代码的安全性。
- 通过广泛使用类型来提高重构的安全性。
- 在业务逻辑和应用/基础设施逻辑之间添加清晰的边界。
方法
该软件包由两个起点组成,即 CommandController
和 QueryController
,以及以下组件
- 请求验证器 (示例)
在应用级别验证请求。 - 请求解码器 (示例)
解码请求并将其转换为数组结构的请求数据。 - 请求数据转换器 (示例)
转换先前生成的请求数据。 - DTO构造函数 (示例)
从请求数据生成命令或查询。 - DTO验证器 (示例)
验证创建的命令或查询。 - 处理器 (示例)
包含业务逻辑的命令或查询处理器。 - 处理器包装器 (示例)
将处理器包装起来,以执行准备/尝试/捕获逻辑。 - 响应构造器 (示例)
将处理器收集到的数据转换为响应。
控制器处理请求的过程以及何时使用哪个组件可以在这里描述。
路由
通过Symfony路由,我们定义哪些组件的实例(如果相关)用于哪个路由。我们使用PHP文件而不是默认的YAML来定义路由,以提高类型安全性,并使得通过IDE重命名组件更容易。
一个路由可能看起来像这样
return static function (RoutingConfigurator $routes) { RouteBuilder::addCommandRoute( $routes, path: '/api/news/create-news-article-command', dtoClass: CreateNewsArticleCommand::class, handlerClass: CreateNewsArticleCommandHandler::class, dtoValidatorClasses: [ UserIdValidator::class => null, ], ); };
您只需要定义与在cqs-routing.php
配置中配置的默认值不同的组件。有关路由的更多信息。
命令示例
命令和查询是强类型值对象,它们已经验证了它们能做的事情。以下是一个用于创建新闻文章的示例命令
<?php declare(strict_types=1); namespace App\Domain\News\WriteSide\CreateNewsArticle; use App\Helper\HtmlHelper; use App\ValueObject\UserId; use Assert\Assertion; use DigitalCraftsman\CQSRouting\Command\Command; final readonly class CreateNewsArticleCommand implements Command { public function __construct( public UserId $userId, public string $title, public string $content, public bool $isPublished, ) { Assertion::notBlank($this->title); Assertion::maxLength($this->title, 255); Assertion::notBlank($this->content); Assertion::maxLength($this->content, 1000); HtmlHelper::assertValidHtml($this->content); } }
因此,结构验证已经通过命令的创建完成,命令处理器只需处理业务逻辑验证。一个命令处理器可能看起来像这样
<?php declare(strict_types=1); namespace App\Domain\News\WriteSide\CreateNewsArticle; use App\DomainService\UserCollection; use App\Entity\NewsArticle; use App\Time\Clock\ClockInterface; use App\ValueObject\NewsArticleId; use DigitalCraftsman\CQSRouting\Command\Command; use DigitalCraftsman\CQSRouting\Command\CommandHandlerInterface; use Doctrine\ORM\EntityManagerInterface; final readonly class CreateNewsArticleCommandHandler implements CommandHandlerInterface { public function __construct( private ClockInterface $clock, private UserRepository $userRepository, private EntityManagerInterface $entityManager, ) { } public function __invoke(CreateProductNewsArticleCommand $command): void { $commandExecutedAt = $this->clock->now(); // Validate $requestingUser = $this->userRepository->getOne($command->userId); $requestingUser->mustNotBeLocked(); $requestingUser->mustHavePermissionToWriteArticle(); // Apply $this->createNewsArticle( $command->title, $command->content, $command->isPublished, $commandExecutedAt, ); } private function createNewsArticle( string $title, string $content, bool $isPublished, \DateTimeImmutable $commandExecutedAt, ): void { $newsArticle = new NewsArticle( NewsArticleId::generateRandom(), $title, $content, $isPublished, $commandExecutedAt, ); $this->entityManager->persist($newsArticle); $this->entityManager->flush(); } }