2. 命令与查询分离原则

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 被应用于两个层面:

  1. 模型层面:写模型(领域模型)只负责业务逻辑和状态变更;读模型(投影模型)只负责数据呈现和查询优化。两者各自独立演化。
  2. 处理层面:命令处理器只处理写操作,查询处理器只处理读操作。一个处理器内部不会同时包含读写逻辑。
实践建议:在 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 架构中的完整职责边界和交互流程:

命令与查询职责边界与交互流程 命令侧(写) 命令对象(Command) 命令处理器(Command Handler) 领域模型(聚合/实体) 事件存储 / 写数据库 输出:无返回值 / 仅返回 ID 查询侧(读) 查询对象(Query) 查询处理器(Query Handler) 读模型(投影/物化视图) 读数据库 / 缓存 / ES 输出:DTO / 视图模型 / 列表 事件同步

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 的读写模型分离:
  1. 读操作需要返回与领域模型完全不同的数据结构。
  2. 写操作的性能瓶颈与读操作不同(如写需要事务一致性,读需要高并发)。
  3. 团队需要独立扩展读侧或写侧的计算资源。
  4. 业务复杂度高,领域模型与查询模型难以共用同一套对象。