digital-craftsman / cqs-routing
通过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-09-24 14:03: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(); } }