领域驱动设计(DDD)【20】之值对象(Value Object):入门
文章目录
- 一 值对象
- 1.1 值对象 vs 实体
- 1.2 为什么使用值对象?
- 1.3 值对象的设计技巧
- 二 值对象的核心特征
- 2.1 无标识性
- 2.2 不可变性
- 2.3 自包含行为
- 三 值对象的实现模式
- 3.1 基本实现
- 3.2 微类型(Tiny Types)
- 3.3 复合值对象
- 四 实战案例:电商系统中的值对象
- 4.1 产品价格值对象(ProductPrice)
- 4.2 订单项值对象(OrderItem)
- 4.3 配送时间段值对象(DeliveryTimeWindow)
- 五 常见问题与解决方案
- Q1: 什么时候应该使用值对象而不是实体?
- Q2: 值对象可以引用实体吗?
- Q3: 如何持久化值对象?
- Q4: 值对象可以有行为吗?
一 值对象
- 值对象(Value Object)是领域驱动设计(DDD)中的一种核心构建块,它代表领域中的某个概念,但不通过标识(ID)来区分,而是通过其属性值来定义。举个生活中的例子:当你去咖啡店点一杯咖啡时,店员会问"要大杯、中杯还是小杯?“。这里的"大杯"就是一个值对象——它没有唯一的ID,任何容量为16oz的杯子都是"大杯”,无论这个杯子是红色还是蓝色,是今天生产的还是昨天生产的。
1.1 值对象 vs 实体
- 理解值对象最好的方式是与实体(Entity)进行对比:
特性 | 值对象 | 实体 |
---|---|---|
标识 | 无唯一标识,由属性值定义 | 有唯一标识 |
相等性 | 基于所有属性值比较 | 基于ID比较 |
可变性 | 通常不可变 | 通常可变 |
生命周期 | 可随意创建和丢弃 | 有明确的生命周期跟踪 |
例子 | 颜色、金额、地址 | 用户、订单、产品 |
1.2 为什么使用值对象?
- 减少认知负担:将复杂的概念封装为值对象,使代码更易于理解和维护。例如,使用
Money
类比使用原始的BigDecimal
金额和String
币种组合更直观。 - 避免重复代码:将相关行为和验证逻辑封装在值对象中,避免在应用各处重复相同的代码。
- 增强类型安全:使用具体的值对象类型而非基本类型,可以避免"原始类型偏执",编译器可以在编译时捕获更多错误。
- 提高代码表现力:值对象可以使代码更接近业务语言,提高代码的可读性和表现力。
1.3 值对象的设计技巧
- 保持小巧:值对象应该小巧且专注于单一职责。如果一个值对象变得太大,考虑将其拆分为多个更小的值对象。
- 验证构造函数:在构造函数中进行严格的参数验证,确保创建的值对象始终处于有效状态。
- 提供完整的方法:为值对象的所有可能操作提供方法,避免业务逻辑泄漏到值对象外部。
- 考虑性能:对于频繁创建和销毁的值对象,可以考虑对象池或缓存策略,但要谨慎使用,以免破坏不可变性。
- 谨慎使用继承:值对象之间的继承关系可能导致复杂性问题,优先考虑组合而非继承。
二 值对象的核心特征
- 值对象的核心特征包括无标识性、不可变性、自包含行为。
2.1 无标识性
- 值对象没有唯一标识符(与实体的最大区别)。两个值对象如果所有属性相同,则被认为是同一个值对象。
// 地址值对象示例
public class Address {private final String street;private final String city;private final String zipCode;// 构造函数、getter等方法...@Overridepublic boolean equals(Object o) {if (this == o) return true;if (o == null || getClass() != o.getClass()) return false;Address address = (Address) o;return Objects.equals(street, address.street) &&Objects.equals(city, address.city) &&Objects.equals(zipCode, address.zipCode);}@Overridepublic int hashCode() {return Objects.hash(street, city, zipCode);}
}
2.2 不可变性
- 值对象一旦创建,其状态不应改变。如果需要修改,应该创建一个新的值对象实例。
// 不可变的金额值对象
public class Money {private final BigDecimal amount;private final Currency currency;public Money(BigDecimal amount, Currency currency) {this.amount = amount;this.currency = currency;}public Money add(Money other) {if (!this.currency.equals(other.currency)) {throw new IllegalArgumentException("不同币种不能相加");}return new Money(this.amount.add(other.amount), this.currency);}// getter等方法...
}
2.3 自包含行为
- 值对象可以包含与其属性相关的行为,这使得业务逻辑更加内聚。
// 温度值对象包含转换行为
public class Temperature {private final double value;private final Unit unit;public enum Unit { CELSIUS, FAHRENHEIT }public Temperature(double value, Unit unit) {this.value = value;this.unit = unit;}public Temperature toFahrenheit() {if (this.unit == Unit.FAHRENHEIT) {return this;}double fahrenheit = (value * 9/5) + 32;return new Temperature(fahrenheit, Unit.FAHRENHEIT);}// 其他方法和getter...
}
三 值对象的实现模式
3.1 基本实现
- 最简单的值对象就是封装一组相关属性的类,并实现正确的相等性比较。
public class RGBColor {private final int red;private final int green;private final int blue;public RGBColor(int red, int green, int blue) {// 验证逻辑if (red < 0 || red > 255 || green < 0 || green > 255 || blue < 0 || blue > 255) {throw new IllegalArgumentException("颜色值必须在0-255之间");}this.red = red;this.green = green;this.blue = blue;}// 相等性比较和hashCode...// 业务方法public RGBColor mixWith(RGBColor other, double ratio) {int newRed = (int) (this.red * (1 - ratio) + other.red * ratio);int newGreen = (int) (this.green * (1 - ratio) + other.green * ratio);int newBlue = (int) (this.blue * (1 - ratio) + other.blue * ratio);return new RGBColor(newRed, newGreen, newBlue);}
}
3.2 微类型(Tiny Types)
- 为简单的概念创建专门的类型,即使它只包装一个基本值。
public class EmailAddress {private final String value;public EmailAddress(String value) {if (value == null || !value.contains("@")) {throw new IllegalArgumentException("无效的邮箱地址");}this.value = value;}public String getValue() {return value;}// 相等性比较和hashCode...
}// 使用处
public class User {private EmailAddress email;// ...
}
3.3 复合值对象
- 将多个简单的值对象组合成更复杂的值对象。
public class ShippingInfo {private final Address address;private final TimeWindow deliveryWindow;private final ContactPerson contact;public ShippingInfo(Address address, TimeWindow deliveryWindow, ContactPerson contact) {this.address = address;this.deliveryWindow = deliveryWindow;this.contact = contact;}// 业务方法和getter...
}
四 实战案例:电商系统中的值对象
- 通过一个电商系统的例子来看看值对象如何应用。
4.1 产品价格值对象(ProductPrice)
- 在电商系统中,产品价格不是简单的数字,而是包含:基础价格(basePrice)、折扣信息(discount)、可能的税费、会员价等复杂计算。
设计要点:
- 组合使用值对象:ProductPrice本身是值对象,又组合了Money和Discount两个值对象
- 业务逻辑内聚:价格计算逻辑完全封装在值对象内部
- 不可变性:所有字段都是final,确保线程安全
public class ProductPrice {// 使用Money值对象表示基础价格(金额+币种)private final Money basePrice;// 使用Discount值对象表示折扣策略private final Discount discount;// 构造函数进行严格验证public ProductPrice(Money basePrice, Discount discount) {if (basePrice == null || discount == null) {throw new IllegalArgumentException("价格和折扣不能为null");}this.basePrice = basePrice;this.discount = discount;}// 计算最终价格的核心业务逻辑public Money getFinalPrice() {// 委托Money和Discount对象完成计算return basePrice.subtract(discount.calculateDiscount(basePrice));}// 验证价格有效性public boolean isValid() {// 确保基础价格和最终价格都是正数return basePrice.isPositive() && getFinalPrice().isPositive();}// 省略equals()和hashCode()...
}
- 测试用例
// 创建价格对象
Money basePrice = new Money(new BigDecimal("100.00"), Currency.getInstance("USD"));
Discount discount = new PercentageDiscount(new BigDecimal("0.1")); // 10%折扣
ProductPrice productPrice = new ProductPrice(basePrice, discount);// 获取最终价格
Money finalPrice = productPrice.getFinalPrice(); // $90.00
4.2 订单项值对象(OrderItem)
场景分析,订单项需要明确:
- 购买的是哪个产品(productId)
- 购买数量(quantity)
- 购买时的价格快照(price)
设计要点
- 数量专门化:使用Quantity值对象而非基本类型int
- 价格快照:存储下单时的价格而非实时查询,保证订单不变性
public class OrderItem {// 产品ID(实体引用,但只存储ID保持轻量)private final ProductId productId;// 购买数量(使用专门的Quantity值对象而非基本类型)private final Quantity quantity;// 价格快照(值对象)private final ProductPrice price;public OrderItem(ProductId productId, Quantity quantity, ProductPrice price) {if (productId == null || quantity == null || price == null) {throw new IllegalArgumentException("参数不能为null");}if (!price.isValid()) {throw new IllegalArgumentException("无效的价格");}this.productId = productId;this.quantity = quantity;this.price = price;}// 计算单项总价public Money calculateSubtotal() {// 委托Money完成金额乘法计算return price.getFinalPrice().multiply(quantity.getValue());}// 省略equals()和hashCode()...
}
- 测试用例
// 创建订单项
ProductId productId = new ProductId("prod-123");
Quantity quantity = new Quantity(2);
Money basePrice = new Money(new BigDecimal("100.00"), Currency.getInstance("USD"));
Discount discount = new PercentageDiscount(new BigDecimal("0.1")); // 10%折扣
ProductPrice productPrice = new ProductPrice(basePrice, discount);OrderItem item = new OrderItem(productId, quantity, price);// 计算单项总价
Money subtotal = item.calculateSubtotal(); // $180.00 (2件×$90.00)
4.3 配送时间段值对象(DeliveryTimeWindow)
场景分析,配送时间需要:
- 明确的开始和结束时间
- 验证时间窗口的有效性
- 提供时间重叠检查等业务方法
设计要点
- 业务约束内聚:将配送时间的所有约束条件放在构造函数中
- 丰富的业务方法:提供时间重叠检查等实用方法
- 不可变设计:确保时间窗口创建后不会被意外修改
public class DeliveryTimeWindow {private final LocalDateTime start;private final LocalDateTime end;public DeliveryTimeWindow(LocalDateTime start, LocalDateTime end) {if (start.isAfter(end)) {throw new IllegalArgumentException("开始时间不能晚于结束时间");}if (Duration.between(start, end).toHours() > 4) {throw new IllegalArgumentException("配送窗口不能超过4小时");}this.start = start;this.end = end;}public boolean overlapsWith(DeliveryTimeWindow other) {return !this.end.isBefore(other.start) && !this.start.isAfter(other.end);}// 相等性比较和hashCode...
}
- 测试用例
// 创建配送时间窗口
LocalDateTime now = LocalDateTime.now();
DeliveryTimeWindow window1 = new DeliveryTimeWindow(now.plusHours(1), now.plusHours(3)
);DeliveryTimeWindow window2 = new DeliveryTimeWindow(now.plusHours(2),now.plusHours(4)
);// 检查时间重叠
if (window1.overlapsWith(window2)) {System.out.println("配送时间有冲突");
}// 获取持续时间
Duration duration = window1.duration(); // PT2H (2小时)
五 常见问题与解决方案
Q1: 什么时候应该使用值对象而不是实体?
当满足以下条件时,使用值对象更合适:
- 概念上没有唯一标识的需求
- 可以通过属性比较相等性
- 对象是不可变的或变化时会创建新实例
- 生命周期由父实体管理
Q2: 值对象可以引用实体吗?
- 通常不建议。值对象应该是自包含的,引用实体会引入复杂性并可能破坏不变性。如果确实需要,确保引用是不可变的。
Q3: 如何持久化值对象?
常见的持久化策略包括:
- 嵌入方式:将值对象的属性作为父实体的列存储在同一个表中
- 序列化方式:将值对象序列化为JSON/XML存储在单个列中
- 单独表:为值对象创建单独的表,但通常不推荐
Q4: 值对象可以有行为吗?
- 当然可以!值对象应该包含与其数据相关的行为。这是DDD的一个重要原则——将数据和行为放在一起。