1. 领域驱动设计基础:DDD 核心概念
领域驱动设计(Domain-Driven Design,DDD)是一种应对复杂业务系统的软件设计方法论,由 Eric Evans 在其经典著作《领域驱动设计》中系统提出。DDD 的核心思想是:软件系统的复杂性与业务领域的复杂性直接相关,因此设计应当以领域模型为中心,而非以数据表或技术框架为中心。
在《报表环境回放 Go 领域边界》课程中,DDD 是我们构建报表回放系统的理论基石。本节将系统梳理 DDD 的四大核心构造块:实体与值对象、聚合与聚合根、领域事件,并辅以 Go 语言的实现示例。
1.1 核心概念总览
| 概念 | 定义 | 核心特征 | Go 实现要点 |
|---|---|---|---|
| 实体(Entity) | 具有唯一标识、生命周期可追踪的对象 | ID 决定身份,属性可变 | struct + 唯一 ID 字段 |
| 值对象(Value Object) | 由属性值定义、无唯一标识的不可变对象 | 值相等性,不可变,可替换 | struct + 值比较方法 |
| 聚合(Aggregate) | 一组相关对象的集群,保证数据一致性边界 | 事务边界,内部强一致 | 聚合根作为入口 |
| 聚合根(Aggregate Root) | 聚合中唯一能被外部引用的实体 | 全局标识,外部只能通过它操作聚合 | 对外暴露的方法集合 |
| 领域事件(Domain Event) | 领域中发生的、对业务有意义的过往事实 | 不可变,携带上下文,触发副作用 | 事件结构体 + 事件总线 |
关键认知:DDD 不是一套代码框架,而是一种建模思维。在 Go 中实现 DDD 时,我们更关注接口设计、包结构和领域逻辑的显式表达,而非依赖注入或 ORM 映射。
1.2 实体与值对象
实体与值对象是领域模型中最基本的两种对象类型,它们的区分直接决定了模型的质量。
实体(Entity)
实体拥有唯一标识,即使其他属性全部相同,只要 ID 不同就是不同的对象。实体的属性可以随时间变化,但身份保持不变。
Go 示例:报表回放系统中的“回放任务”实体
// TaskID 是回放任务的唯一标识
type TaskID string
// ReplayTask 是回放任务实体
type ReplayTask struct {
ID TaskID
ReportID string
Status TaskStatus
CreatedAt time.Time
UpdatedAt time.Time
}
// 实体比较:只比较 ID
func (t *ReplayTask) Equals(other *ReplayTask) bool {
return t.ID == other.ID
}
值对象(Value Object)
值对象没有唯一标识,完全由属性值定义其含义。两个值对象如果所有属性值都相等,则视为同一个对象。值对象必须是不可变的——任何修改都应返回一个新实例。
Go 示例:报表查询条件值对象
// QueryCriteria 是报表查询条件值对象
type QueryCriteria struct {
StartTime time.Time
EndTime time.Time
Filters map[string]string
}
// 值对象比较:比较所有字段
func (q QueryCriteria) Equals(other QueryCriteria) bool {
if !q.StartTime.Equal(other.StartTime) {
return false
}
if !q.EndTime.Equal(other.EndTime) {
return false
}
if len(q.Filters) != len(other.Filters) {
return false
}
for k, v := range q.Filters {
if other.Filters[k] != v {
return false
}
}
return true
}
// 不可变:返回新实例
func (q QueryCriteria) WithFilter(key, value string) QueryCriteria {
newFilters := make(map[string]string, len(q.Filters)+1)
for k, v := range q.Filters {
newFilters[k] = v
}
newFilters[key] = value
return QueryCriteria{
StartTime: q.StartTime,
EndTime: q.EndTime,
Filters: newFilters,
}
}
实践建议:在 Go 中,值对象通常使用值接收者方法,实体使用指针接收者方法。这不仅是语法习惯,更是对两种对象语义的显式表达。
1.3 聚合与聚合根
聚合是 DDD 中最重要的一致性边界。一个聚合是一组相关对象的集群,外部只能通过聚合根访问聚合内部的任何对象。聚合保证了内部数据变更的原子性和一致性。
聚合设计原则
- 边界明确:聚合内部的对象可以互相引用,但聚合之间只能通过聚合根 ID 引用。
- 事务范围:一次请求只修改一个聚合,跨聚合的修改通过领域事件异步协调。
- 根实体唯一:聚合根是聚合中唯一拥有全局标识的实体。
Go 示例:报表回放聚合
// ReplayAggregate 是回放聚合根
type ReplayAggregate struct {
task *ReplayTask // 聚合根实体
snapshots []*Snapshot // 聚合内部的值对象集合
events []DomainEvent // 未提交的领域事件
}
// 外部只能通过聚合根的方法操作
func (a *ReplayAggregate) StartReplay() error {
if err := a.task.CanStart(); err != nil {
return err
}
a.task.Status = TaskStatusRunning
a.events = append(a.events, &ReplayStartedEvent{
TaskID: a.task.ID,
StartedAt: time.Now(),
})
return nil
}
// 获取未提交事件(用于持久化后发布)
func (a *ReplayAggregate) UncommittedEvents() []DomainEvent {
return a.events
}
常见误区:不要将数据库的外键关系直接映射为聚合之间的引用。聚合之间应通过ID 引用而非对象引用,否则会破坏聚合边界,导致级联修改失控。
1.4 领域事件
领域事件是领域中发生的、对业务有意义的过往事实。它通常以“过去时”命名,例如 ReplayStarted、ReportGenerated。领域事件的核心作用是解耦聚合之间的副作用,实现最终一致性。
领域事件的特征
| 特征 | 说明 | Go 实现方式 |
|---|---|---|
| 不可变性 | 事件一旦发生,数据不可修改 | struct 所有字段为只读 |
| 携带上下文 | 包含事件发生时所需的业务数据 | 字段包含 ID、时间戳、关键属性 |
| 发布与订阅 | 事件发布者不关心谁消费 | 事件总线或消息队列 |
| 幂等消费 | 同一事件被多次消费结果一致 | 消费端做去重或幂等检查 |
Go 示例:领域事件定义与发布
// DomainEvent 是领域事件接口
type DomainEvent interface {
EventName() string
OccurredAt() time.Time
}
// ReplayStartedEvent 回放开始事件
type ReplayStartedEvent struct {
TaskID TaskID
StartedAt time.Time
}
func (e *ReplayStartedEvent) EventName() string {
return "replay.started"
}
func (e *ReplayStartedEvent) OccurredAt() time.Time {
return e.StartedAt
}
// 事件总线(简化版)
type EventBus struct {
handlers map[string][]func(event DomainEvent)
}
func (b *EventBus) Publish(event DomainEvent) {
for _, handler := range b.handlers[event.EventName()] {
handler(event) // 同步或异步执行
}
}
1.5 核心概念关系图
下图展示了 DDD 核心构造块之间的结构关系,以及它们在报表回放系统中的映射位置。
1.6 在报表回放系统中的映射
将上述 DDD 概念映射到报表回放系统,可以得到清晰的领域模型边界:
| DDD 概念 | 报表回放系统映射 | 说明 |
|---|---|---|
| 实体 | ReplayTask、Report、User | 每个都有唯一 ID,状态随时间变化 |
| 值对象 | QueryCriteria、ReplayConfig、TimeRange | 不可变,完全由属性定义 |
| 聚合根 | ReplayAggregate、ReportAggregate | 事务边界,外部只能通过根操作 |
| 领域事件 | ReplayStarted、ReplayCompleted、ReportGenerated | 解耦回放流程与通知、日志等副作用 |