到处都有人谈论 CQRS,但你可能仍然好奇:不动用 Symfony 或 Laravel 这样的框架,该怎么实践?好消息是,我们可以在纯 PHP 环境下体验 Command Query Responsibility Segregation。只需要清晰的结构和几段精心设计的类。打开编辑器,一起来搭好基础。
前置条件:PHP 8.4 与最小化配置
- 已安装 PHP 8.4(CLI 或 Web 服务器均可)。使用
php -v 检查版本,以便使用 readonly 属性、严格类型以及原生的 first-class callable。
- Composer(可选,但有助于自动加载)以及一个轻量级 Web 服务器(实验时
php -S 127.0.0.1:8000 -t public 即可)。
如果必须停留在更低版本,请确认这里用到的特性(构造函数提升、readonly 属性等)是否可用,或按需调整示例代码。
1. 两分钟看懂 CQRS
CQRS 的核心是将读取(Query)与写入(Command)分离,从而形成两条优化路径:
- 命令负责修改状态(创建账号、发布文章),不会返回应用层数据。
- 查询负责读取状态(文章列表、账号详情),不会产生副作用。
这种分离源自 DDD 的理念,但完全可以在不引入繁琐形式的情况下采用。立竿见影的好处包括:
- 测试更简单:可以对每个处理器做单元测试。
- 代码更清晰:每个处理器只做一件事。
- 逐步扩展:读取侧与写入侧可以独立演进。
需要注意,CQRS 会带来一定的结构复杂度。对于简单的 CRUD,可能会显得过度设计。请在业务规则或扩展需求值得的情况下再采用。
2. 原生 PHP 的最小目录结构
下面的简单目录结构就能满足需求:
src/
Domain/
Article.php
ArticleRepository.php
Application/
Command/
CreateArticle/
CreateArticleCommand.php
CreateArticleHandler.php
Query/
ListArticles/
ListArticlesQuery.php
ListArticlesHandler.php
Bus/
CommandBus.php
QueryBus.php
Infrastructure/
InMemoryArticleRepository.php
public/
index.php
这能清晰地划分 Domain(业务规则)、Application(用例)以及 Infrastructure(具体实现)。
3. 极简领域模型
我们创建一个 Article 聚合及其仓储:
1<?php
2// src/Domain/Article.php
3
4final class Article
5{
6 public function __construct(
7 private readonly string $id,
8 private string $title,
9 private string $content,
10 private readonly \DateTimeImmutable $publishedAt,
11 ) {
12 }
13
14 public function id(): string
15 {
16 return $this->id;
17 }
18
19 public function title(): string
20 {
21 return $this->title;
22 }
23
24 public function content(): string
25 {
26 return $this->content;
27 }
28
29 public function publishedAt(): \DateTimeImmutable
30 {
31 return $this->publishedAt;
32 }
33}
1<?php
2// src/Domain/ArticleRepository.php
3
4interface ArticleRepository
5{
6 public function save(Article $article): void;
7
8 /** @return Article[] */
9 public function all(): array;
10}
4. 命令侧:创建文章
命令承载意图,处理器协调业务逻辑。
1<?php
2// src/Application/Command/CreateArticle/CreateArticleCommand.php
3
4final class CreateArticleCommand
5{
6 public function __construct(
7 public readonly string $id,
8 public readonly string $title,
9 public readonly string $content,
10 public readonly string $publishedAt,
11 ) {
12 }
13}
1<?php
2// src/Application/Command/CreateArticle/CreateArticleHandler.php
3
4final class CreateArticleHandler
5{
6 public function __construct(private ArticleRepository $repository)
7 {
8 }
9
10 public function __invoke(CreateArticleCommand $command): void
11 {
12 $article = new Article(
13 $command->id,
14 $command->title,
15 $command->content,
16 new \DateTimeImmutable($command->publishedAt),
17 );
18
19 $this->repository->save($article);
20 }
21}
重点:处理器不会返回任何内容。如需再次读取文章,需要通过 Query。
5. 查询侧:列出文章
1<?php
2// src/Application/Query/ListArticles/ListArticlesQuery.php
3
4final class ListArticlesQuery
5{
6}
1<?php
2// src/Application/Query/ListArticles/ListArticlesHandler.php
3
4final class ListArticlesHandler
5{
6 public function __construct(private ArticleRepository $repository)
7 {
8 }
9
10 /** @return array<int, array{id: string, title: string, publishedAt: string}> */
11 public function __invoke(ListArticlesQuery $query): array
12 {
13 return array_map(
14 static fn (Article $article) => [
15 'id' => $article->id(),
16 'title' => $article->title(),
17 'publishedAt' => $article->publishedAt()->format('Y-m-d H:i:s'),
18 ],
19 $this->repository->all()
20 );
21 }
22}
这里返回的是可以直接序列化的数组,避免展示层与领域模型耦合。
6. 入门用的内存仓储
1<?php
2// src/Infrastructure/InMemoryArticleRepository.php
3
4final class InMemoryArticleRepository implements ArticleRepository
5{
6 /** @var array<string, Article> */
7 private array $storage = [];
8
9 public function save(Article $article): void
10 {
11 $this->storage[$article->id()] = $article;
12 }
13
14 public function all(): array
15 {
16 return array_values($this->storage);
17 }
18}
后续可以替换为 Doctrine、PDO 或外部 API,实现不会影响处理器。
7. 接好超轻量的总线
当你把处理器解析集中管理后,CQRS 的优势会更明显:
1<?php
2// src/Application/Bus/CommandBus.php
3
4final class CommandBus
5{
6 /** @param array<class-string, callable> $handlers */
7 public function __construct(private array $handlers)
8 {
9 }
10
11 public function dispatch(object $command): void
12 {
13 $handler = $this->handlers[$command::class] ?? null;
14
15 if ($handler === null) {
16 throw new \RuntimeException('没有可用的处理器:' . $command::class);
17 }
18
19 $handler($command);
20 }
21}
1<?php
2// src/Application/Bus/QueryBus.php
3
4final class QueryBus
5{
6 /** @param array<class-string, callable> $handlers */
7 public function __construct(private array $handlers)
8 {
9 }
10
11 public function ask(object $query): mixed
12 {
13 $handler = $this->handlers[$query::class] ?? null;
14
15 if ($handler === null) {
16 throw new \RuntimeException('没有可用的处理器:' . $query::class);
17 }
18
19 return $handler($query);
20 }
21}
这些总线虽然朴素,但足以理解其机制。真实项目中,你可以把它们接入依赖注入容器(PHP-DI、Symfony DI 等)。
8. public/index.php 中的启动示例
1<?php
2require_once __DIR__ . '/../vendor/autoload.php';
3
4$repository = new InMemoryArticleRepository();
5
6$createHandler = new CreateArticleHandler($repository);
7$listHandler = new ListArticlesHandler($repository);
8
9$commandBus = new CommandBus([
10 CreateArticleCommand::class => $createHandler(...),
11]);
12
13$queryBus = new QueryBus([
14 ListArticlesQuery::class => $listHandler(...),
15]);
16
17$commandBus->dispatch(new CreateArticleCommand(
18 id: uniqid('article_', true),
19 title: 'CQRS 初体验',
20 content: '我们轻松地分离命令与读取……',
21 publishedAt: '2025-06-05 10:00:00',
22));
23
24$articles = $queryBus->ask(new ListArticlesQuery());
25
26header('Content-Type: application/json');
27echo json_encode($articles, JSON_PRETTY_PRINT);
(...) 运算符(first-class callable)自 PHP 8.1 起可用,PHP 8.4 中完全支持。若要兼容 8.0 及以下版本,可替换为 fn ($command) => $createHandler($command)。
9. 按部就班地进阶
打好基础后,可以逐步增强方案:
- 校验:增加 Value Object(Title、Content),或在创建聚合前使用 Symfony Validator。
- 真实持久化:实现 MySQL(PDO)或 PostgreSQL 仓储,无需改动处理器。
- 领域事件:命令成功后发布
ArticlePublished,在其他层消费它。
- 专用投影:为复杂查询(分页、全文检索等)建立优化的读取表。
- 测试:为每个处理器编写单元测试,并为总线编写功能测试。
10. 首次上线前的检查清单
借助这个骨架,你可以在现有项目中循序渐进地引入 CQRS,也能在无框架的情况下启动新服务。关键是保持读写边界清晰,让架构逐步迭代演进。祝你玩得开心!