DDD | 01-什么是值对象?
一、什么是值对象?
值对象(Value Object)是一种重要的领域模型元素,它用于描述没有唯一标识符但具有明确值的领域概念。
主要特点
-
无唯一标识:与实体(Entity)不同,值对象没有独立的身份标识,如果两个值对象的所有属性值都相等,那么这两个对象就被认为是等价的。
-
不可变性:为了保持数据的一致性和减少出错的可能性,值对象通常设计为不可变的。一旦创建,其属性就不能被修改,如果需要改变值,通常会创建一个新的值对象实例。
-
传递性:值对象可以被整体传递,作为参数传递给方法或者赋值给另一个值对象时,实际上是复制了一份值,而不是引用传递。这有助于保持数据的隔离性和安全性。
-
表达领域概念:值对象用于封装领域中的某些概念或度量,比如货币金额、地理位置、颜色等,这些概念通常与实体一起工作,帮助完成业务逻辑。
-
简化设计:相比于实体,值对象更易于创建、测试和使用,因为它们不需要复杂的生命周期管理。在很多情况下,优先考虑使用值对象可以减少系统的复杂度。
-
支持领域逻辑:尽管值对象本身不包含业务逻辑,但它可以参与领域逻辑的计算和验证过程,比如判断两个地理位置的距离是否在一定范围内,或者计算货币金额的加减等。
-
促进领域模型的纯净性:通过将不变的属性组合成值对象,可以使得领域模型更加清晰,减少实体的复杂性,帮助领域专家和开发者更好地理解和沟通业务规则。
设计原则
值对象(Value Object,简称VO)在领域驱动设计(DDD)中常被用作数据的容器,它们主要用于封装一组相关且通常是不可变的属性,用来描述领域中的概念或者度量。
不可变性(Immutability)
- 确保值对象一旦创建后其状态不再改变。这有助于维护数据的一致性,减少并发问题,并使得值对象可以在多线程环境下安全使用。
- 构造器中设置所有属性,并避免提供修改属性的方法(setter)。
相等性(Equality)
- 基于值的相等性,而不是基于引用。重写equals()和hashCode()方法,使得具有相同属性值的不同实例被视为相等。
相等性比较所有关键属性,确保逻辑上相同的数据被视为等价。
精确的职责(Precise Responsibility)
- 值对象应该只包含描述其概念所需的数据和行为,不应承担过多的业务逻辑。它们通常用于封装和验证数据。
封装(Encapsulation)
- 隐藏内部实现细节,仅暴露必要的访问方法(getter)。这有助于保护数据的完整性,并减少外界对值对象内部结构的依赖。
简洁性(Simplicity)
- 保持结构简单直观,避免不必要的复杂性。值对象应易于理解和使用,反映领域概念的本质。
可序列化(Serializability)
- 如果需要在进程间传递或存储值对象,确保它们可以被序列化和反序列化。这在分布式系统中尤为重要。
构造函数和工厂方法
- 提供清晰的构造函数或静态工厂方法来创建值对象实例,确保创建过程中数据的有效性得到验证。
嵌套值对象(Nested Value Objects)
- 当值对象内部包含其他值对象时,确保它们的结构清晰,避免深层次嵌套导致的复杂性。
代码示例
创建一个地址值对象(AddressVO)
/**
* 地址信息的不可变视图对象。
* AddressVO是一个不可变的值对象,它封装了一个地址的所有部分。它提供了只读访问器,并且重写了equals和hashCode、方法以确保基于属性值的等价性。
* 这样的设计有助于确保地址的一致性,并且可以在不同的实体之间重复使用,例如用户和商店都可能有地址。
* 同时,通过正则表达式验证了街道、城市和邮政编码的格式,以确保数据的有效性。
*/
public final class AddressVO {
private final String street;
private final String city;
private final String state;
private final String postalCode;
private final String country;
// 定义街道名的正则表达式,允许字母、数字、空格及特定符号
// 允许字母、数字、空格及特定符号
private static final Pattern STREET_PATTERN = Pattern.compile("^[a-zA-Z0-9\\s.,'-]+$");
// 定义城市名的正则表达式,允许字母、空格及特定符号
// 城市名通常不包含数字
private static final Pattern CITY_PATTERN = Pattern.compile("^[a-zA-Z\\s.'-]+$");
// 定义中国邮政编码的正则表达式,为6位数字
// 中国邮政编码为6位数字
private static final Pattern POSTAL_CODE_CN_PATTERN = Pattern.compile("\\d{6}");
/**
* 验证街道名称的有效性。
* 街道名不能为空且必须匹配预定义的正则表达式。
*
* @param street 街道名称
* @return 验证通过后返回修剪过的街道名称
* @throws IllegalArgumentException 如果街道名无效
*/
private String validateStreet(String street) {
if (street == null || street.trim().isEmpty()) {
throw new IllegalArgumentException("Street cannot be null or empty");
}
if (!STREET_PATTERN.matcher(street).matches()) {
throw new IllegalArgumentException("Invalid characters in street name");
}
return street.trim();
}
/**
* 验证城市名称的有效性。
* 城市名不能为空且必须匹配预定义的正则表达式。
*
* @param city 城市名称
* @return 验证通过后返回修剪过的城市名称
* @throws IllegalArgumentException 如果城市名无效
*/
private String validateCity(String city) {
if (city == null || city.trim().isEmpty()) {
throw new IllegalArgumentException("City cannot be null or empty");
}
if (!CITY_PATTERN.matcher(city).matches()) {
throw new IllegalArgumentException("Invalid characters in city name");
}
return city.trim();
}
/**
* 验证州/省名称的有效性。
* 州/省名不能为空。
* 注意:此处假设州/省名的验证较为简单,实际应用中可能需要更复杂的验证逻辑。
*
* @param state 州/省名称
* @return 验证通过后返回修剪过的州/省名称
* @throws IllegalArgumentException 如果州/省名无效
*/
private String validateState(String state) {
// 状态验证可能依据具体国家,这里假设为简化的验证
if (state == null || state.trim().isEmpty()) {
throw new IllegalArgumentException("State cannot be null or empty");
}
// 根据实际情况,可能需要检查是否为合法的州/省名
return state.trim();
}
/**
* 验证邮政编码的有效性。
* 邮政编码不能为空且必须匹配预定义的中国邮政编码正则表达式。
*
* @param postalCode 邮政编码
* @return 验证通过后返回修剪过的邮政编码
* @throws IllegalArgumentException 如果邮政编码无效
*/
private String validatePostalCode(String postalCode) {
if (postalCode == null || postalCode.trim().isEmpty()) {
throw new IllegalArgumentException("Postal code cannot be null or empty");
}
// 验证是否为中国邮政编码格式
if (!POSTAL_CODE_CN_PATTERN.matcher(postalCode).matches()) {
throw new IllegalArgumentException("Invalid Chinese postal code format");
}
return postalCode.trim();
}
/**
* 地址信息的构造器。
* 通过验证传入的每个参数的有效性后,创建一个新的AddressVO实例。
* 注意:此处假设国家名称已经是一个经过验证的值对象,因此不对其进行额外验证。
*
* @param street 街道名称
* @param city 城市名称
* @param state 州/省名称
* @param postalCode 邮政编码
* @param country 国家名称
*/
// TODO 注意:Country假设已经是一个经过验证的值对象,无需在此处再次验证
//这里可以添加验证逻辑以确保地址的有效性
public AddressVO(String street, String city, String state, String postalCode, String country) {
this.street = validateStreet(street);
this.city = validateCity(city);
this.state = validateState(state);
this.postalCode = validatePostalCode(postalCode);
// 确保Country不为null
this.country = Objects.requireNonNull(country, "Country cannot be null");
}
// 只读访问器
public String getStreet() {
return street;
}
public String getCity() {
return city;
}
public String getState() {
return state;
}
public String getPostalCode() {
return postalCode;
}
public String getCountry() {
return country;
}
/**
* 重写equals方法以支持基于属性值的比较。
* 使用Objects.equals()方法安全地比较对象属性,能够处理null值。
*
* @param obj 另一个对象,用于比较
* @return 如果两个对象相等,则返回true;否则返回false
*/
/*
* 重写equals和hashCode方法以确保基于属性值的等价性
* Objects.equals()方法用于比较对象属性,它能处理null值,避免了空指针异常
*/
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
AddressVO other = (AddressVO) obj;
return Objects.equals(street, other.street) &&
Objects.equals(city, other.city) &&
Objects.equals(state, other.state) &&
Objects.equals(postalCode, other.postalCode) &&
Objects.equals(country, other.country);
}
/**
* 重写hashCode方法以支持基于属性值的哈希码生成。
* 使用Objects.hash()方法基于所有属性值生成哈希码,该方法可接受任意数量的参数。
*
* @return 该对象的哈希码
*/
/*
* 重写hashCode方法以确保基于属性值的等价性
* Objects.hash()方法用于生成基于所有属性值的哈希值,它接受任意数量的参数,并返回一个整数值
*/
@Override
public int hashCode() {
return Objects.hash(street, city, state, postalCode, country);
}
/**
* 重写toString方法以提供基于属性值的字符串表示。
* 使用Objects.toString()方法基于所有属性值生成字符串表示,该方法可接受任意数量的参数。
*
* @return 该对象的字符串表示
*/
/*
* 重写toString方法以确保基于属性值的等价性
* Objects.toString()方法用于生成基于所有属性值的字符串表示,它接受任意数量的参数,并返回一个字符串值
*/
@Override
public String toString() {
return "AddressVO{" +
"street='" + street + '\'' +
", city='" + city + '\'' +
", state='" + state + '\'' +
", postalCode='" + postalCode + '\'' +
", country='" + country + '\'' +
'}';
}
}
创建一个enmu枚举类型的值对象OrderStatusVO
public enum OrderStatusVO {
// 订单状态枚举值,包括下单、支付、发货、完成和取消
PLACED(0, "下单"),
PAID(1, "支付"),
DELIVERED(2, "发货"),
COMPLETED(3, "完成"),
CANCELED(4, "取消");
// 通过代码查找订单状态的映射表,用于快速根据代码获取订单状态枚举值
private static final Map<Integer, OrderStatusVO> codeToStatus = new HashMap<>();
// 静态初始化块,用于填充codeToStatus映射表
static {
for (OrderStatusVO status : OrderStatusVO.values()) {
codeToStatus.put(status.code, status);
}
}
// 订单状态代码
private final int code;
// 订单状态描述
// 考虑国际化,这里应避免直接使用硬编码的字符串
private final String description;
/**
* 构造函数,用于初始化订单状态枚举值。
* @param code 订单状态代码
* @param description 订单状态描述
*/
OrderStatusVO(int code, String description) {
this.code = code;
this.description = description;
}
/**
* 获取订单状态代码。
* @return 订单状态代码
*/
public int getCode() {
return code;
}
/**
* 获取订单状态描述。
* @return 订单状态描述
*/
public String getDescription() {
// 国际化实现可以在这里进行,例如通过资源文件获取description
// return ResourceBundle.getBundle("messages").getString(description);
// 保持当前实现,作为示例
return description;
}
/**
* 根据订单状态代码获取订单状态枚举值。
* @param code 订单状态代码
* @return 对应的订单状态枚举值
* @throws IllegalArgumentException 如果代码无效,则抛出此异常
*/
// 优化后的查找方法,通过Map实现
public static OrderStatusVO getByCode(int code) {
OrderStatusVO status = codeToStatus.get(code);
if (status == null) {
throw new IllegalArgumentException("Invalid order status code: " + code);
}
return status;
}
}
为什么OrderStatusVO
可以被认为是一种值对象(Value Object)?
值对象是一种设计模式,主要用于封装某些特定的值,并且这些值具有某些与之关联的行为。在领域驱动设计(DDD
)中,值对象通常用来标识那些没有唯一标识符(ID
)但具有业务意义的数据集合,它们的重点在于描述信息而非标识身份。
OrderStatusVO
枚举不仅定义了一系列订单状态(如"下单"、"支付"等),还为每个状态提供了Code和描述,这正是值对象的典型特征:它携带业务价值并可能包含一些简单的业务逻辑(如通过代码查找枚举值)。尽管它是一个枚举类型,而不是一个常规的类,但它依然符合值对象的设计理念,因为它代表了一组不可变的值,并且这些值具有明确的业务含义。
OrderStatusVO
中的getCode()
方法有什么意义?
getCode()
给OrderStatusVO
提供了一个访问器(Accessor)方法,用于获取枚举实例所对应的订单状态代码(Code)。这里的Code
是一个整型数值,与每个订单状态枚举值相关联。
在实际应用中,这些代码值通常用于数据库存储、网络传输或业务逻辑处理中,作为一个紧凑且易于处理的标识来代表具体的订单状态,而不是直接使用状态名称。这样设计的好处是:
- 效率:整数相比字符串占用更少的存储空间和传输带宽
- 兼容性:在某些只支持基本数据类型而不直接支持枚举类型的旧系统或数据库中,使用代码值能更好地兼容
- 易于比较与判断:在编程逻辑中直接比较证书代码比字符串更高效
因此,当你需要根据订单的某个状态码执行特定操作时,就可以调用OrderStatusVO.getByCode(int coe)
来获取到对应的枚举实例,然后进一步使用getCode()
方法来验证或处理这个状态,尽管直接从枚举实例中获取代码不如直接使用其属性那么常见,但在某些日志记录、状态转换逻辑中可能回用到。
public class OrderStatusVOTest {
public static void main(String[] args) {
System.out.println(OrderStatusVO.getByCode(1));
System.out.println("getCode()方法的使用:" + OrderStatusVO.PAID.getCode());
}
}