第4章 值对象 - 描述特征的对象 (Value Objects - Descriptive Characteristics)
一、导读
4.1 本章学习目标
通过本章的学习,读者将能够:
理解值对象(Value Object)在 DDD 中的定义与核心特征
掌握值对象与实体的本质区别
学会设计不可变的值对象
理解值对象相等性判断的语义
掌握值对象的组合与验证技巧
能够在实际项目中正确识别和设计值对象
理解值对象在性能优化中的作用
掌握处理值对象集合的最佳实践
4.2 为什么本章如此重要
值对象是 DDD 中另一种基本的对象类型,它与实体共同构成了领域模型的基础。在许多业务系统中,存在大量用于描述特征、度量或概念的对象,如地址、金额、日期范围、颜色等。这些对象没有唯一标识,它们通过属性值来区分。这就是值对象的典型应用场景。
正确理解和使用值对象对于构建简洁、清晰的领域模型至关重要。值对象可以帮助我们将复杂的概念封装成有意义的单元,使代码更具表达力。同时,值对象的不可变性使得它们天生线程安全,易于测试和推理。
在实际开发中,值对象常常被忽视或误用。很多开发者习惯于使用基本类型(如 String、Integer)来表示业务概念,导致业务规则散落在各处,难以维护。例如,用 String 表示邮箱地址,邮箱格式的验证逻辑就会出现在多个地方;用两个独立的 Date 表示时间范围,范围的有效性检查就需要重复编写。将这些概念建模为值对象,可以将相关的验证和行为集中在一起。
值对象还与函数式编程的理念高度契合。不可变性、基于值的相等性、无副作用的操作,这些都是函数式编程的核心原则。掌握值对象的设计不仅有助于 DDD 实践,也有助于培养函数式思维。
此外,值对象在性能优化方面也发挥着重要作用。由于值对象是不可变的且基于值比较,它们可以安全地缓存和复用,减少内存分配和垃圾回收的压力。在高并发场景下,值对象的不可变性避免了同步开销。
4.3 核心问题引导
在学习本章之前,请思考以下问题:
什么是值对象?它与普通的数据结构有什么区别?
值对象和实体的根本区别是什么?如何判断一个概念应该是值对象而不是实体?
为什么值对象应该是不可变的?不可变性带来了哪些好处?
值对象的相等性应该如何判断?与实体的相等性有什么不同?
当值对象的验证失败时,应该如何处理?
值对象可以包含行为吗?如果可以,应该包含哪些类型的行为?
如何处理值对象的集合?在集合中使用值对象需要注意什么?
值对象是否应该持久化到数据库?如果需要,应该如何处理?
二、核心概念详解
4.1 值对象的定义与本质
值对象(Value Object)是领域模型中一种通过属性值来定义的对象,它没有概念上的唯一标识。两个值对象如果所有属性都相同,则被认为是相等的,可以互相替换。
值对象的本质在于它描述的是"什么"(what),而不是"哪个"(which)。当我们关心一个对象的特征或度量时,我们使用值对象;当我们关心一个对象的身份和连续性时,我们使用实体。
以货币金额为例,当我们说"100 元"时,我们关心的是金额的值,而不是这个金额对象的身份。任何代表"100 元"的对象都是等价的,可以互相替换。这就是值对象的典型特征。
再以地址为例,如果我们只是用地址来描述一个人的居住地,那么地址就是值对象。两个完全相同的地址(街道、城市、邮编等都相同)是没有区别的,可以互相替换。但如果我们需要跟踪某个特定地址的历史变化(如邮寄记录),那么地址可能就需要被建模为实体。
值对象的关键特征包括:
无标识:值对象没有唯一标识。它们的相等性完全由属性值决定。
不可变性:值对象一旦创建就不能修改。如果需要改变值,必须创建新的值对象实例。
基于值的相等性:两个值对象如果所有相关属性都相同,则被认为相等。
可替换性:相等的值对象可以互相替换,不会影响业务逻辑。
自验证:值对象在创建时应该验证其有效性,确保始终处于合法状态。
行为丰富:值对象不仅仅是数据容器,它们可以包含与自身相关的业务行为。
4.2 值对象的核心特征
不可变性(Immutability)
不可变性是值对象最重要的特征。一旦值对象被创建,它的状态就不能被改变。这带来了多方面的好处:
首先,不可变性使得值对象天生线程安全。在多线程环境中,多个线程可以安全地共享同一个值对象实例,无需担心并发修改问题。
其次,不可变性简化了推理。由于值对象不会变化,我们可以确信它的值在任何时候都是创建时设定的值。这减少了 bug 的可能性。
第三,不可变性支持安全的共享和缓存。相等的值对象可以复用同一个实例,节省内存。
实现不可变性的关键是:将所有字段设为 final(Java)或使用不可变数据结构(Python 的 frozen dataclass),不提供修改状态的方法。如果需要"修改"值对象,应该创建一个新的实例。
基于值的相等性(Value-based Equality)
值对象的相等性判断基于其所有相关属性。如果两个值对象的属性值都相同,它们就是相等的。这与实体形成鲜明对比,实体的相等性基于唯一标识。
在 Java 中,需要重写 equals() 和 hashCode() 方法,基于所有相关属性进行比较。在 Python 中,可以使用 @dataclass(frozen=True) 自动生成正确的相等性实现。
基于值的相等性使得值对象可以在集合中正确使用,如 HashSet、HashMap 等。
自包含的验证(Self-validation)
值对象应该在创建时验证自己的有效性。构造函数或工厂方法是进行验证的理想位置。如果输入无效,应该抛出异常,阻止非法值对象的创建。
例如,邮箱地址值对象应该在创建时验证格式是否正确;金额值对象应该验证不能为负数。通过将验证逻辑放在值对象内部,可以确保值对象始终处于合法状态。
丰富的行为(Rich Behavior)
值对象不仅仅是数据容器,它们可以包含与自身相关的业务行为。这些行为通常是对值对象本身的转换或计算,不涉及外部依赖。
例如,金额值对象可以有加法、减法、乘法等方法;日期范围值对象可以有判断是否包含某日期的方法;地址值对象可以有格式化输出的方法。
将行为放在值对象内部有几个好处:一是行为与数据放在一起,符合面向对象的原则;二是行为可以复用,避免重复代码;三是行为可以利用值对象的不变量,简化实现。
4.3 值对象与实体的区别
值对象和实体是 DDD 中两种最基本的对象类型,正确区分它们对于领域建模至关重要。
| 特征 | 实体 | 值对象 |
|---|---|---|
| 标识 | 有唯一标识 | 无标识,通过值区分 |
| 可变性 | 通常可变 | 不可变 |
| 相等性 | 基于标识 | 基于属性值 |
| 生命周期 | 有明确生命周期 | 无生命周期,瞬时存在 |
| 共享 | 可以被共享引用 | 通常复制传递 |
| 复杂度 | 通常较复杂 | 通常较简单 |
| 关注点 | "哪个"对象 | "什么"值 |
判断一个概念应该是实体还是值对象,可以问以下问题:
- 我们是否关心这个对象的身份?如果是,可能是实体。
- 两个属性完全相同的对象是否代表不同的事物?如果是,可能是实体。
- 这个对象是否需要跟踪变化历史?如果是,可能是实体。
- 这个对象是否可以被另一个具有相同属性的对象替换而不影响业务?如果是,可能是值对象。
- 这个对象是否只是一个度量或描述?如果是,可能是值对象。
需要注意的是,同一个概念在不同场景下可能是实体也可能是值对象。例如,地址在大多数情况下是值对象,但如果需要跟踪地址的变化历史(如物流追踪),地址可能需要被建模为实体。上下文决定了对象的类型。
4.4 值对象的设计原则
设计良好的值对象应该遵循以下原则:
保持小型和专注
值对象应该只包含相关的属性,不要试图在一个值对象中塞入太多内容。小型的值对象更容易理解、测试和复用。
例如,将地址拆分为街道地址、城市、州、邮编等单独的字段,或者进一步拆分为更小的值对象如 ZipCode、PhoneNumber。
确保不可变性
这是值对象的核心要求。所有字段应该是 final 的(Java)或等效的不可变形式。不提供任何修改状态的方法。如果需要改变值,创建新的实例。
实现正确的相等性
重写 equals() 和 hashCode() 方法,基于所有相关属性进行比较。确保相等的对象有相同的哈希码,这对于在哈希集合中正确使用值对象至关重要。
提供有意义的行为
值对象应该包含与其职责相关的行为。常见的行为类型包括:
- 转换:将一个值对象转换为另一个形式,如
Money.toString()。 - 计算:基于值对象进行计算,如
Money.add()、DateRange.duration()。 - 验证:检查值对象是否满足某些条件,如
Email.isValid()。 - 比较:比较两个值对象,如
Money.compareTo()。
处理空值
值对象不应该允许 null 值。对于可选的值对象,可以使用 Optional 类型(Java)或 None(Python)。对于值对象内部的字段,也应该避免 null,可以使用空字符串、零值或专门的 Null 对象。
考虑性能
虽然值对象通常是小型的,但在高频使用的场景下,频繁创建值对象可能带来性能开销。可以考虑使用工厂方法来缓存常用实例,如 Money.zero()、Color.white()。
4.5 值对象的组合
值对象可以组合使用,形成更复杂的结构。这种组合是构建丰富领域模型的重要手段。
例如,一个 Address 值对象可以由 StreetAddress、City、State、ZipCode 等值对象组成:
public class Address {
private final StreetAddress street;
private final City city;
private final State state;
private final ZipCode zipCode;
public Address(StreetAddress street, City city, State state, ZipCode zipCode) {
this.street = street;
this.city = city;
this.state = state;
this.zipCode = zipCode;
}
}这种组合有几个好处:
首先,每个组成部分都可以有自己的验证逻辑。ZipCode 可以验证格式,City 可以验证名称合法性等。
其次,组合提高了代码的可读性和表达力。address.getCity().getName() 比 address.getCityName() 更清晰地表达了结构。
第三,小组件可以复用。ZipCode 可以用在 Address 中,也可以用在其他需要邮编的地方。
在组合值对象时,注意保持合理的粒度。过度拆分可能导致复杂性增加,而拆分不足则失去了组合的优势。
4.6 值对象的持久化
值对象是否需要持久化取决于它们的使用场景。如果值对象是实体的属性,那么当实体被持久化时,值对象的数据也需要被保存。
在关系数据库中,值对象通常有以下几种持久化策略:
嵌入同一表
将值对象的属性作为列存储在实体所在的表中。例如,Customer 表的列包括 street、city、state、zip_code,这些列共同组成 Address 值对象。
这种方式简单直接,适合值对象与实体紧密绑定的场景。缺点是值对象不能独立查询。
单独表
为值对象创建单独的表,通过外键与实体关联。这种方式适合值对象可能被多个实体共享的场景。
序列化存储
将值对象序列化为 JSON 或其他格式,存储在单个列中。这种方式灵活但牺牲了查询能力。
在 JPA 中,可以使用 @Embeddable 和 @Embedded 注解来映射值对象。在 MyBatis 中,可以使用 TypeHandler 进行自定义映射。
重要的是,持久化细节不应该影响值对象的设计。值对象应该保持对持久化的无知,持久化策略是基础设施层的关注点。
三、程序员的视角
4.1 在实际项目中应用值对象
作为程序员,在日常工作中识别和设计值对象可以从以下几个方面入手:
首先,警惕基本类型迷恋(Primitive Obsession)。如果发现代码中大量使用 String、Integer 等基本类型来表示业务概念,如邮箱、电话、金额、日期等,考虑将它们重构为值对象。
其次,寻找重复的验证逻辑。如果同一种验证在多处出现,如邮箱格式验证、电话号码格式验证,将这些概念建模为值对象,将验证逻辑集中在构造函数中。
第三,注意成对出现的字段。如果发现某些字段经常一起出现,如 startDate 和 endDate、latitude 和 longitude、min 和 max,考虑将它们组合成值对象。
第四,利用语言特性简化实现。Java 16+ 的 record、Python 的 frozen dataclass、Kotlin 的 data class 都可以方便地创建不可变值对象。
第五,为值对象添加有意义的行为。不要只把值对象当作数据容器,思考它可以提供哪些有用的操作。例如,金额可以加减乘除,日期范围可以判断重叠,坐标可以计算距离。
第六,在测试中充分利用值对象的特性。由于值对象是不可变的且基于值比较,它们很容易进行单元测试。编写全面的测试覆盖各种边界情况。
4.2 常见陷阱与误区
在使用值对象时,常见的陷阱包括:
陷阱一:忘记实现不可变性。提供了 setter 方法或可变的集合字段,导致值对象实际上可变。这会破坏值对象的许多优势。
陷阱二:相等性实现错误。没有重写 equals() 和 hashCode(),或者只基于部分属性比较。这会导致值对象在集合中行为异常。
陷阱三:允许 null 值。值对象字段为 null,导致运行时 NPE。应该在构造函数中验证并拒绝 null。
陷阱四:值对象过大。试图在一个值对象中包含太多内容,使其变得复杂难用。应该保持值对象小型和专注。
陷阱五:忽视行为。只把值对象当作数据容器,不添加任何行为。这浪费了值对象的潜力。
陷阱六:在需要实体的场景使用值对象。例如,需要跟踪变化历史的对象被建模为值对象,导致无法正确表达业务需求。
陷阱七:可变集合作为值对象字段。即使值对象本身不可变,如果它包含可变集合的引用,仍然可能被间接修改。应该使用不可变集合或防御性复制。
4.3 与其他模式的协作
值对象与其他 DDD 模式紧密协作:
与实体协作:值对象常作为实体的属性。实体持有值对象的引用,通过值对象来表达复杂的概念。
与聚合协作:值对象可以在聚合内部自由传递,因为它们是不可变的,不会破坏聚合的一致性边界。
与领域服务协作:领域服务可以使用值对象作为参数和返回值。值对象使得服务的接口更加清晰和类型安全。
与仓储协作:仓储负责将值对象与实体一起持久化。值对象本身不需要仓储,因为它们没有独立的生命周期。
与工厂协作:工厂可以创建复杂的值对象,特别是当创建逻辑涉及验证或计算时。
四、代码实战
4.1 Java 示例:金额值对象
/**
* 金额值对象
* 不可变,基于值比较,包含丰富的行为
*/
public final class Money {
private final BigDecimal amount;
private final Currency currency;
private static final int DEFAULT_SCALE = 2;
private static final RoundingMode DEFAULT_ROUNDING = RoundingMode.HALF_UP;
/**
* 构造金额
*/
public Money(BigDecimal amount, Currency currency) {
if (amount == null) {
throw new IllegalArgumentException("Amount cannot be null");
}
if (currency == null) {
throw new IllegalArgumentException("Currency cannot be null");
}
this.amount = amount.setScale(DEFAULT_SCALE, DEFAULT_ROUNDING);
this.currency = currency;
}
public Money(double amount, Currency currency) {
this(BigDecimal.valueOf(amount), currency);
}
public Money(String amount, Currency currency) {
this(new BigDecimal(amount), currency);
}
/**
* 零金额
*/
public static Money zero(Currency currency) {
return new Money(BigDecimal.ZERO, currency);
}
/**
* 加法
*/
public Money add(Money other) {
checkSameCurrency(other);
return new Money(this.amount.add(other.amount), this.currency);
}
/**
* 减法
*/
public Money subtract(Money other) {
checkSameCurrency(other);
return new Money(this.amount.subtract(other.amount), this.currency);
}
/**
* 乘法
*/
public Money multiply(int multiplier) {
return new Money(this.amount.multiply(BigDecimal.valueOf(multiplier)), this.currency);
}
public Money multiply(BigDecimal multiplier) {
return new Money(this.amount.multiply(multiplier).setScale(DEFAULT_SCALE, DEFAULT_ROUNDING), this.currency);
}
/**
* 除法
*/
public Money divide(int divisor) {
if (divisor == 0) {
throw new ArithmeticException("Division by zero");
}
return new Money(this.amount.divide(BigDecimal.valueOf(divisor), DEFAULT_SCALE, DEFAULT_ROUNDING), this.currency);
}
/**
* 是否为零
*/
public boolean isZero() {
return this.amount.compareTo(BigDecimal.ZERO) == 0;
}
/**
* 是否为正
*/
public boolean isPositive() {
return this.amount.compareTo(BigDecimal.ZERO) > 0;
}
/**
* 是否为负
*/
public boolean isNegative() {
return this.amount.compareTo(BigDecimal.ZERO) < 0;
}
/**
* 取绝对值
*/
public Money abs() {
return new Money(this.amount.abs(), this.currency);
}
/**
* 比较
*/
public int compareTo(Money other) {
checkSameCurrency(other);
return this.amount.compareTo(other.amount);
}
public boolean isGreaterThan(Money other) {
return compareTo(other) > 0;
}
public boolean isLessThan(Money other) {
return compareTo(other) < 0;
}
private void checkSameCurrency(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException(
"Cannot operate on different currencies: " + this.currency + " vs " + other.currency
);
}
}
/**
* 格式化输出
*/
public String format() {
NumberFormat formatter = NumberFormat.getCurrencyInstance(Locale.US);
formatter.setCurrency(currency);
return formatter.format(amount);
}
// 基于值的相等性
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Money money = (Money) o;
return amount.compareTo(money.amount) == 0 && currency.equals(money.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
@Override
public String toString() {
return amount + " " + currency.getCurrencyCode();
}
// Getters
public BigDecimal getAmount() { return amount; }
public Currency getCurrency() { return currency; }
}4.2 Python 示例:地址值对象
from dataclasses import dataclass
import re
@dataclass(frozen=True)
class ZipCode:
"""值对象:邮政编码"""
value: str
def __post_init__(self):
# 美国邮编格式:12345 或 12345-6789
if not re.match(r'^\d{5}(-\d{4})?$', self.value):
raise ValueError(f"Invalid ZIP code format: {self.value}")
def __str__(self):
return self.value
@dataclass(frozen=True)
class City:
"""值对象:城市"""
name: str
def __post_init__(self):
if not self.name or len(self.name.strip()) == 0:
raise ValueError("City name cannot be empty")
if len(self.name) > 100:
raise ValueError("City name too long")
def __str__(self):
return self.name
@dataclass(frozen=True)
class State:
"""值对象:州/省"""
code: str # 如 CA, NY
name: str
def __post_init__(self):
if len(self.code) != 2:
raise ValueError("State code must be 2 characters")
if not self.code.isupper():
raise ValueError("State code must be uppercase")
def __str__(self):
return f"{self.code} ({self.name})"
@dataclass(frozen=True)
class StreetAddress:
"""值对象:街道地址"""
line1: str
line2: str = "" # 可选的第二行
def __post_init__(self):
if not self.line1 or len(self.line1.strip()) == 0:
raise ValueError("Street address line 1 cannot be empty")
def __str__(self):
if self.line2:
return f"{self.line1}, {self.line2}"
return self.line1
@dataclass(frozen=True)
class Address:
"""值对象:完整地址
由多个更小的值对象组合而成
"""
street: StreetAddress
city: City
state: State
zip_code: ZipCode
def format_single_line(self) -> str:
"""单行格式"""
parts = [
str(self.street),
str(self.city),
str(self.state.code),
str(self.zip_code)
]
return ", ".join(parts)
def format_multi_line(self) -> str:
"""多行格式"""
lines = [str(self.street)]
city_state_zip = f"{self.city}, {self.state.code} {self.zip_code}"
lines.append(city_state_zip)
return "\n".join(lines)
def __str__(self):
return self.format_single_line()
# 使用示例
def address_example():
"""演示地址值对象的使用"""
# 创建地址
address = Address(
street=StreetAddress("123 Main St", "Apt 4B"),
city=City("Springfield"),
state=State("IL", "Illinois"),
zip_code=ZipCode("62701")
)
print("Single line:", address.format_single_line())
print("\nMulti line:")
print(address.format_multi_line())
# 尝试创建无效地址(应该失败)
try:
invalid_address = Address(
street=StreetAddress(""), # 空街道地址
city=City("Test"),
state=State("CA", "California"),
zip_code=ZipCode("12345")
)
except ValueError as e:
print(f"\nValidation error: {e}")
# 值对象相等性
address2 = Address(
street=StreetAddress("123 Main St", "Apt 4B"),
city=City("Springfield"),
state=State("IL", "Illinois"),
zip_code=ZipCode("62701")
)
print(f"\nAddresses equal: {address == address2}") # True
@dataclass(frozen=True)
class DateRange:
"""值对象:日期范围"""
start_date: date
end_date: date
def __post_init__(self):
if self.end_date < self.start_date:
raise ValueError("End date cannot be before start date")
def contains(self, d: date) -> bool:
"""判断日期是否在范围内"""
return self.start_date <= d <= self.end_date
def overlaps(self, other: 'DateRange') -> bool:
"""判断是否与另一个日期范围重叠"""
return self.start_date <= other.end_date and other.start_date <= self.end_date
def duration_days(self) -> int:
"""计算天数"""
return (self.end_date - self.start_date).days
def __str__(self):
return f"{self.start_date} to {self.end_date}"4.3 组合值对象示例:地理坐标
/**
* 纬度值对象
*/
public final class Latitude {
private final double value;
public Latitude(double value) {
if (value < -90 || value > 90) {
throw new IllegalArgumentException("Latitude must be between -90 and 90");
}
this.value = value;
}
public double getValue() { return value; }
public boolean isNorthern() {
return value > 0;
}
public boolean isSouthern() {
return value < 0;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Latitude latitude = (Latitude) o;
return Double.compare(value, latitude.value) == 0;
}
@Override
public int hashCode() {
return Double.hashCode(value);
}
}
/**
* 经度值对象
*/
public final class Longitude {
private final double value;
public Longitude(double value) {
if (value < -180 || value > 180) {
throw new IllegalArgumentException("Longitude must be between -180 and 180");
}
this.value = value;
}
public double getValue() { return value; }
public boolean isEastern() {
return value > 0;
}
public boolean isWestern() {
return value < 0;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Longitude longitude = (Longitude) o;
return Double.compare(value, longitude.value) == 0;
}
@Override
public int hashCode() {
return Double.hashCode(value);
}
}
/**
* 地理坐标值对象
* 由纬度和经度组合而成
*/
public final class GeoCoordinate {
private final Latitude latitude;
private final Longitude longitude;
public GeoCoordinate(Latitude latitude, Longitude longitude) {
this.latitude = latitude;
this.longitude = longitude;
}
public GeoCoordinate(double lat, double lon) {
this(new Latitude(lat), new Longitude(lon));
}
/**
* 计算与另一个坐标的距离(Haversine 公式)
*/
public double distanceTo(GeoCoordinate other) {
double earthRadiusKm = 6371.0;
double lat1Rad = Math.toRadians(this.latitude.getValue());
double lat2Rad = Math.toRadians(other.latitude.getValue());
double deltaLatRad = Math.toRadians(other.latitude.getValue() - this.latitude.getValue());
double deltaLonRad = Math.toRadians(other.longitude.getValue() - this.longitude.getValue());
double a = Math.sin(deltaLatRad / 2) * Math.sin(deltaLatRad / 2) +
Math.cos(lat1Rad) * Math.cos(lat2Rad) *
Math.sin(deltaLonRad / 2) * Math.sin(deltaLonRad / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return earthRadiusKm * c;
}
public Latitude getLatitude() { return latitude; }
public Longitude getLongitude() { return longitude; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
GeoCoordinate that = (GeoCoordinate) o;
return latitude.equals(that.latitude) && longitude.equals(that.longitude);
}
@Override
public int hashCode() {
return Objects.hash(latitude, longitude);
}
@Override
public String toString() {
return String.format("(%.6f, %.6f)", latitude.getValue(), longitude.getValue());
}
}五、深入思考
5.1 历史背景
值对象的概念源于面向对象设计的早期。在 Design Patterns(GoF)一书中,值对象被描述为一种常见的设计模式。然而,当时的值对象主要被视为数据传输的载体,缺乏丰富的业务含义。
Eric Evans 在《领域驱动设计》中重新定义了值对象的角色,将其提升为领域模型的一等公民。他强调值对象不仅仅是数据容器,而是可以包含行为的完整对象。这一观点与 Martin Fowler 在《Analysis Patterns》中的思想相呼应。
随着函数式编程的复兴,值对象的重要性进一步提升。不可变性、基于值的相等性、无副作用的操作,这些值对象的特征与函数式编程的核心理念高度一致。在现代软件设计中,值对象成为了连接面向对象和函数式编程的桥梁。
5.2 前沿方向
值对象的实践正在向以下几个方向发展:
首先是记录类型(Record Types)的普及。Java 16 引入的 record、Kotlin 的 data class、Python 的 dataclass 等语言特性,使得创建不可变值对象变得更加简单。这些记录类型自动生成了构造函数、相等性判断、toString 等样板代码。
其次是值对象在事件溯源中的应用。在事件溯源模式中,事件通常被建模为值对象,因为事件是不可变的、基于值比较的。值对象的特性完美契合事件的需求。
第三是与领域事件的结合。值对象可以作为领域事件的载荷,因为它们的不可变性保证了事件发布后不会被修改。
第四是在分布式系统中的应用。值对象的不可变性使得它们在分布式环境中更安全地传递,无需担心并发修改问题。
5.3 延伸阅读
Eric Evans, 《领域驱动设计》第5章 - 原始论述
Vaughn Vernon, 《实现领域驱动设计》第6章 - 实用指南
Martin Fowler, 《Value Object》- Patterns of Enterprise Application Architecture
Joshua Bloch, 《Effective Java》第10条 - 遵守通用约定
六、实验笔记
实验一:重构基本类型迷恋
实验目的:通过重构练习,将基本类型替换为值对象。
实验步骤:
选择一个现有项目或代码片段,找出其中使用基本类型表示业务概念的地方,如 String 表示邮箱、Integer 表示数量等。
为每个概念设计对应的值对象,包括属性和验证逻辑。
重构代码,将基本类型替换为值对象。
更新相关的测试,确保功能正常。
比较重构前后的代码,分析改进之处。
预期收获:体验值对象带来的代码质量提升,掌握重构技巧。
实验二:设计一组金融值对象
实验目的:练习值对象的组合和行为设计。
实验步骤:
设计以下值对象:Currency(货币)、Money(金额)、ExchangeRate(汇率)、Percentage(百分比)。
为每个值对象添加合适的行为,如金额的加减乘除、汇率转换、百分比计算等。
实现值对象之间的协作,如使用汇率将一种货币的金额转换为另一种货币。
编写全面的单元测试。
将这些值对象应用到一个简单的场景中,如货币兑换计算器。
预期收获:掌握值对象的组合设计和行为封装。
七、本章小结
本章详细介绍了 DDD 中的值对象模式。我们首先定义了值对象的本质:通过属性值来定义、没有唯一标识的对象。然后,我们探讨了值对象的核心特征:不可变性、基于值的相等性、自验证和丰富的行为。
值对象与实体的区别是本章的重点内容。我们通过多个维度对比了这两种对象类型,并提供了判断标准。正确区分值对象和实体对于构建清晰的领域模型至关重要。
值对象的设计原则包括:保持小型和专注、确保不可变性、实现正确的相等性、提供有意义的行为、处理空值、考虑性能。遵循这些原则可以设计出高质量的值对象。
通过代码实战,我们展示了如何实现金额、地址、地理坐标等值对象。这些示例体现了值对象的最佳实践:不可变性、基于值的相等性、丰富的行为、组合使用。
最后,我们回顾了值对象的历史背景,展望了前沿方向,并提供了两个实验任务帮助读者实践所学内容。
值对象是领域模型的重要组成部分。正确使用值对象可以使代码更具表达力、更易于维护、更安全。希望读者能够在实际项目中应用本章的知识,设计出优雅的值对象。
八、习题
概念理解题:用自己的话解释什么是值对象,它的核心特征是什么?
辨析题:以下概念哪些应该是值对象,哪些应该是实体?说明理由。
- 颜色
- 用户会话
- 温度
- 发票
- 尺寸(长宽高)
- IP 地址
设计题:设计一个 EmailAddress 值对象,包括验证逻辑和常用行为。写出完整的 Java 或 Python 代码。
代码分析题:以下 Java 代码有什么问题?如何修正?
public class Money {
private double amount;
private String currency;
public void setAmount(double amount) {
this.amount = amount;
}
public void setCurrency(String currency) {
this.currency = currency;
}
}思考题:为什么值对象应该是不可变的?不可变性带来了哪些具体好处?
实践题:在你当前参与的项目中,找出三个可以用值对象替换的基本类型,设计对应的值对象并说明理由。
设计题:设计一个 TimeRange 值对象,表示时间范围。包括开始时间、结束时间,以及判断是否包含某个时间点、计算持续时间、判断是否与另一个时间范围重叠等行为。
论述题:值对象和 DTO(Data Transfer Object)有什么区别?它们可以互换使用吗?
开放题:在高并发场景下,值对象的不可变性如何帮助提高性能?请举例说明。
综合题:为一个电商系统设计一组值对象,至少包括:价格、折扣、税率、配送地址。展示它们如何协作完成订单价格计算。