2. 命令与查询分离原则:Command 与 Query 的职责划分
CQRS 的核心思想源于 Bertrand Meyer 在《面向对象软件构造》中提出的“命令-查询分离”(Command-Query Separation, CQS)原则。该原则指出:一个方法要么是命令(改变状态,不返回值),要么是查询(返回结果,不改变状态),但不应同时兼具两者。CQRS 将这一原则从方法级别提升到架构级别,使读写两侧拥有独立的模型、数据存储和伸缩策略。
2.1 命令(Command)的职责
命令代表用户或外部系统发起的意图,它描述“想要系统做什么”,而非“如何做”。命令是写操作的入口,具有以下特征:
- 改变系统状态:执行后必然导致聚合、实体或事件存储的变更。
- 无返回值(或仅返回标识/状态码):调用者不期望通过命令获取业务数据。
- 以命令对象形式传递:包含执行操作所需的全部参数,通常使用
struct定义。 - 可验证:在进入领域层之前,命令应经过基础校验(字段非空、格式正确等)。
Go 中的命令定义示例:
// CreateOrderCommand 表示“创建订单”这一用户意图
type CreateOrderCommand struct {
CustomerID string
Items []OrderItemDTO
ShippingAddress AddressDTO
}
// CancelOrderCommand 表示“取消订单”这一用户意图
type CancelOrderCommand struct {
OrderID string
Reason string
}
命令命名采用动词过去分词 + 名词(如 CreateOrder、CancelOrder),清晰表达已完成的操作意图。
2.2 查询(Query)的职责
查询代表对系统当前状态的读取请求,它描述“用户想看到什么”,而非“系统应该做什么”。查询的特征如下:
- 不改变系统状态:纯读取操作,无副作用。
- 返回数据:返回 DTO、视图模型或投影数据,而非领域实体。
- 可针对读模型优化:查询可以绕过领域层,直接访问为读取优化的数据存储(如物化视图、缓存、Elasticsearch)。
- 支持分页、排序、过滤:查询对象通常包含这些通用参数。
Go 中的查询定义示例:
// GetOrderByIDQuery 表示“根据ID查询订单”这一读取请求
type GetOrderByIDQuery struct {
OrderID string
}
// ListOrdersQuery 表示“分页列出订单”这一读取请求
type ListOrdersQuery struct {
CustomerID string
Page int
PageSize int
Status string // 可选过滤条件
}
查询命名采用动词 + 名词(如 GetOrderByID、ListOrders),明确表达读取目标。
2.3 命令与查询的核心对比
| 维度 | 命令(Command) | 查询(Query) |
|---|---|---|
| 目的 | 改变系统状态 | 获取系统状态 |
| 返回值 | 无 / 仅标识符或状态码 | DTO、视图模型、列表 |
| 副作用 | 有(写入事件、更新聚合) | 无 |
| 模型 | 领域模型(聚合、实体) | 读模型(投影、物化视图) |
| 数据源 | 写数据库(主库) | 读数据库(从库、缓存、搜索引擎) |
| 验证方式 | 业务规则 + 权限校验 | 参数格式校验 + 权限校验 |
| 伸缩策略 | 垂直扩展 / 一致性优先 | 水平扩展 / 可用性优先 |
2.4 单一职责原则在 CQRS 中的应用
单一职责原则(SRP)要求一个模块或类只应有一个引起它变化的原因。在 CQRS 架构中,SRP 被应用于两个层面:
- 模型层面:写模型(领域模型)只负责业务逻辑和状态变更;读模型(投影模型)只负责数据呈现和查询优化。两者各自独立演化。
- 处理层面:命令处理器只处理写操作,查询处理器只处理读操作。一个处理器内部不会同时包含读写逻辑。
实践建议:在 Go 项目中,将命令处理器和查询处理器放在不同的目录或包中。例如:
internal/
application/
command/
create_order_handler.go
cancel_order_handler.go
query/
get_order_by_id_handler.go
list_orders_handler.go
这种结构强制开发者遵循 SRP,避免在同一个文件中混合读写逻辑。
2.5 命令与查询的边界划分流程
下图展示了命令与查询在 CQRS 架构中的完整职责边界和交互流程:
2.6 边界划分的常见陷阱与应对
⚠ 常见陷阱:命令中混入查询逻辑
在命令处理器内部执行查询操作(如“创建订单前先查询用户是否存在”),这本身是合理的业务校验。但不要将查询结果作为返回值返回给调用方。正确的做法是:命令只返回成功/失败状态,调用方如需查看结果,应单独发起查询请求。
✅ 正确实践:
// 命令处理器:只返回错误,不返回业务数据
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) error {
// 内部可以调用查询服务进行校验
customer, err := h.customerRepo.FindByID(ctx, cmd.CustomerID)
if err != nil {
return err
}
// 创建订单逻辑...
return nil
}
// 查询处理器:返回业务数据
func (h *GetOrderHandler) Handle(ctx context.Context, query GetOrderQuery) (*OrderDTO, error) {
return h.orderReadModel.FindByID(ctx, query.OrderID)
}
2.7 何时不应严格分离
并非所有场景都适合严格的命令-查询分离。以下情况可以考虑适度放宽:
- CRUD 为主的简单业务:如果系统主要是增删改查,没有复杂的业务逻辑,CQRS 带来的架构复杂度可能超过收益。
- 原型或 MVP 阶段:快速验证阶段可以先使用单一模型,后续再根据读写压力进行分离。
- 读写负载均衡的场景:如果读写比例接近 1:1 且数据量不大,单一模型配合读写分离的数据库连接池可能更简单。
决策指南:当系统出现以下信号时,考虑引入 CQRS 的读写模型分离:
- 读操作需要返回与领域模型完全不同的数据结构。
- 写操作的性能瓶颈与读操作不同(如写需要事务一致性,读需要高并发)。
- 团队需要独立扩展读侧或写侧的计算资源。
- 业务复杂度高,领域模型与查询模型难以共用同一套对象。