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 值对象建模的视觉结构
下图展示了值对象在领域模型中的位置及其与实体的关系:
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。 - 值对象是实体的“构建块”,用于描述实体的属性。
- 测试值对象时,重点验证构造、不可变性、相等性和行为。