3. 值对象(Value Object)建模:不可变性、相等性比较与 Go 实现模式

在领域驱动设计中,值对象(Value Object)是描述领域某个方面、没有唯一标识符的对象。与实体(Entity)不同,值对象通过其属性值来定义,而非通过身份标识。本章将深入探讨值对象的核心特征——不可变性与相等性比较,并给出在 Go 语言中的最佳实现模式。

3.1 值对象的核心特征

值对象是 DDD 中不可或缺的建模元素。理解其核心特征,是正确建模的前提。

特征 描述 与实体的区别
无唯一标识 值对象没有 ID 字段,完全由属性值定义 实体有唯一标识(如 UserID)
不可变性 创建后属性不可修改,任何“修改”都产生新实例 实体属性可变
相等性基于值 两个值对象所有属性相等,则视为同一个对象 实体基于标识相等
自包含 值对象封装了与其概念相关的行为和验证逻辑 实体通常包含业务逻辑
可替换性 一个值对象可以被另一个值相等的对象替换 实体不能被替换,只能修改
建模提示: 当你发现一个概念“没有生命周期、只关心它是什么、不关心它是谁”时,就应该建模为值对象。例如:Money、Address、Email、PhoneNumber、Color。

3.2 不可变性(Immutability)

不可变性是值对象的基石。一旦创建,值对象的内部状态就不能被外部修改。任何“修改”操作都必须返回一个新的值对象实例。

3.2.1 为什么需要不可变性?

  • 线程安全: 不可变对象天然适合并发访问,无需加锁。
  • 防止副作用: 避免函数意外修改传入的参数。
  • 可缓存性: 相同的值对象可以安全地共享和缓存。
  • 领域完整性: 确保值对象始终处于有效状态。

3.2.2 Go 中的不可变实现模式

Go 语言没有 final 或 readonly 关键字,但可以通过以下模式实现不可变性:

// Money 值对象:不可变实现
type Money struct {
    amount   decimal.Decimal
    currency string
}

// 构造函数:返回指针,但内部字段不导出
func NewMoney(amount decimal.Decimal, currency string) (Money, error) {
    if amount.IsNegative() {
        return Money{}, errors.New("amount must not be negative")
    }
    if currency == "" {
        return Money{}, errors.New("currency must not be empty")
    }
    return Money{amount: amount, currency: strings.ToUpper(currency)}, nil
}

// 访问器:只读,不暴露内部字段的指针
func (m Money) Amount() decimal.Decimal {
    return m.amount
}

func (m Money) Currency() string {
    return m.currency
}

// 加法操作:返回新实例,不修改原对象
func (m Money) Add(other Money) (Money, error) {
    if m.currency != other.currency {
        return Money{}, errors.New("currency mismatch")
    }
    newAmount := m.amount.Add(other.amount)
    return Money{amount: newAmount, currency: m.currency}, nil
}

// 乘法操作:返回新实例
func (m Money) Multiply(factor decimal.Decimal) Money {
    newAmount := m.amount.Mul(factor)
    return Money{amount: newAmount, currency: m.currency}
}
关键模式:
  • 字段使用小写(包内私有),不导出。
  • 只提供值接收者(Value Receiver)方法,不提供指针接收者修改方法。
  • 所有“修改”操作返回新实例。
  • 构造函数执行验证,确保值对象始终有效。

3.3 相等性比较(Equality)

值对象的相等性基于其所有属性的值。两个值对象如果所有字段都相等,则视为同一个对象。

3.3.1 Go 中的相等性实现

Go 的 == 运算符只能比较可比较类型(struct 中所有字段都是可比较的)。对于包含 slice、map 或 interface{} 字段的值对象,需要手动实现 Equals 方法。

// Money 的相等性比较
func (m Money) Equals(other Money) bool {
    return m.amount.Equal(other.amount) && m.currency == other.currency
}

// 对于更复杂的值对象,如 Address
type Address struct {
    street   string
    city     string
    state    string
    zipCode  string
    country  string
}

func (a Address) Equals(other Address) bool {
    return a.street == other.street &&
        a.city == other.city &&
        a.state == other.state &&
        a.zipCode == other.zipCode &&
        a.country == other.country
}

// 在测试或集合中使用
func TestMoneyEquality(t *testing.T) {
    m1, _ := NewMoney(decimal.NewFromFloat(100.00), "USD")
    m2, _ := NewMoney(decimal.NewFromFloat(100.00), "USD")
    m3, _ := NewMoney(decimal.NewFromFloat(200.00), "USD")

    if !m1.Equals(m2) {
        t.Error("m1 and m2 should be equal")
    }
    if m1.Equals(m3) {
        t.Error("m1 and m3 should not be equal")
    }
}

3.3.2 相等性比较的注意事项

场景 推荐做法 避免
简单 struct(所有字段可比较) 直接使用 == 或实现 Equals 依赖 reflect.DeepEqual(性能差)
包含 slice/map 字段 手动实现 Equals,逐字段比较 使用 ==(编译错误)
浮点数比较 使用 decimal.Decimal 或精度比较 直接比较 float64(精度问题)
nil 值对象 在 Equals 中处理 nil 情况 假设值对象永不为 nil

3.4 值对象的 Go 实现模式

以下是在 Go 中实现值对象的完整模式总结:

// 1. 定义不可变 struct(字段私有)
type Email struct {
    localPart  string
    domain     string
    full       string
}

// 2. 构造函数:验证 + 创建
func NewEmail(address string) (Email, error) {
    if !strings.Contains(address, "@") {
        return Email{}, errors.New("invalid email format")
    }
    parts := strings.Split(address, "@")
    if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
        return Email{}, errors.New("invalid email format")
    }
    return Email{
        localPart: parts[0],
        domain:    parts[1],
        full:      address,
    }, nil
}

// 3. 只读访问器
func (e Email) LocalPart() string { return e.localPart }
func (e Email) Domain() string    { return e.domain }
func (e Email) Full() string      { return e.full }

// 4. 相等性比较
func (e Email) Equals(other Email) bool {
    return e.full == other.full
}

// 5. 行为方法(返回新实例)
func (e Email) IsPersonal() bool {
    personalDomains := map[string]bool{
        "gmail.com": true, "outlook.com": true, "yahoo.com": true,
    }
    return personalDomains[e.domain]
}

// 6. 实现 Stringer 接口(便于调试)
func (e Email) String() string {
    return e.full
}
常见陷阱:
  • 不要导出字段: 导出字段会破坏不可变性,外部代码可以直接修改。
  • 不要使用指针接收者修改字段: 指针接收者暗示可变性,值对象应始终使用值接收者。
  • 不要嵌入可变对象: 如果值对象内部包含 slice 或 map,确保在构造函数中深拷贝。
  • 不要忽略验证: 构造函数必须验证所有输入,确保值对象永远处于有效状态。

3.5 值对象建模的视觉结构

下图展示了值对象在领域模型中的位置及其与实体的关系:

值对象建模结构图 值对象(Value Object)建模结构 Order(实体) Money(值对象) Address(值对象) Money 内部 - amount: decimal.Decimal - currency: string + Add(other): Money + Equals(other): bool 不可变性 创建后不可修改 修改返回新实例 相等性比较 基于所有属性值 无唯一标识 值对象在领域模型中的角色 • 描述实体的属性(如订单的金额、用户的地址) • 封装领域概念和验证逻辑(如 Email 的格式验证) • 通过不可变性保证领域完整性,避免意外修改

3.6 值对象与实体的协作模式

在实际建模中,值对象通常作为实体的属性存在。以下是常见的协作模式:

// Order 实体包含多个值对象
type Order struct {
    id          OrderID      // 实体标识
    customerID  CustomerID   // 实体标识
    shippingAddress Address  // 值对象
    billingAddress  Address  // 值对象
    totalAmount Money        // 值对象
    items       []OrderItem  // 实体集合
    status      OrderStatus  // 值对象(枚举)
}

// 创建订单时,值对象通过构造函数创建
func NewOrder(
    customerID CustomerID,
    shippingAddress Address,
    billingAddress Address,
    items []OrderItem,
) (Order, error) {
    // 计算总金额(值对象操作)
    total := NewMoney(decimal.Zero, "USD")
    for _, item := range items {
        itemTotal, _ := item.Price().Multiply(decimal.NewFromInt(int64(item.Quantity())))
        total, _ = total.Add(itemTotal)
    }
    
    return Order{
        id:              NewOrderID(),
        customerID:      customerID,
        shippingAddress: shippingAddress,
        billingAddress:  billingAddress,
        totalAmount:     total,
        items:           items,
        status:          OrderStatusPending,
    }, nil
}

// 修改地址时,创建新的值对象,而不是修改原有对象
func (o *Order) UpdateShippingAddress(newAddress Address) {
    o.shippingAddress = newAddress
}
最佳实践:
  • 实体中嵌入值对象时,使用值类型(非指针),确保不可变性。
  • 值对象可以包含其他值对象(组合),如 Address 包含 ZipCode 值对象。
  • 值对象可以包含枚举(如 OrderStatus),但枚举本身也应该是不可变的。
  • 在持久化时,值对象通常被序列化为实体的字段(如 JSON 嵌入或数据库列)。

3.7 值对象的测试策略

值对象的测试相对简单,主要关注以下方面:

测试类型 测试内容 示例
构造测试 验证有效输入创建成功,无效输入返回错误 NewEmail("invalid") 应返回错误
不可变性测试 验证“修改”操作返回新实例,原对象不变 m1.Add(m2) 不改变 m1
相等性测试 验证相同值相等,不同值不等 m1.Equals(m2) 为 true
行为测试 验证值对象的方法逻辑正确 email.IsPersonal() 返回正确结果
func TestMoney_Immutability(t *testing.T) {
    m1, _ := NewMoney(decimal.NewFromFloat(100), "USD")
    m2, _ := NewMoney(decimal.NewFromFloat(50), "USD")
    
    m3, _ := m1.Add(m2)
    
    // m1 和 m2 保持不变
    if !m1.Equals(NewMoney(decimal.NewFromFloat(100), "USD")) {
        t.Error("m1 should remain unchanged")
    }
    if !m2.Equals(NewMoney(decimal.NewFromFloat(50), "USD")) {
        t.Error("m2 should remain unchanged")
    }
    // m3 是新实例
    if !m3.Equals(NewMoney(decimal.NewFromFloat(150), "USD")) {
        t.Error("m3 should be 150 USD")
    }
}

3.8 总结

值对象是 DDD 中简单但强大的建模工具。通过遵循不可变性、基于值的相等性和自包含行为三大原则,可以构建出健壮、可测试且易于理解的领域模型。在 Go 中,通过私有字段、值接收者方法和构造函数验证,可以完美实现值对象的这些特性。

核心要点回顾:
  • 值对象没有 ID,通过属性值定义。
  • 不可变性通过私有字段 + 值接收者 + 返回新实例实现。
  • 相等性比较需要手动实现 Equals 方法,避免使用 reflect.DeepEqual。
  • 值对象是实体的“构建块”,用于描述实体的属性。
  • 测试值对象时,重点验证构造、不可变性、相等性和行为。