87384496 2019-07-01
值对象虽然经常被掩盖在实体的阴影之下,但它却是非常重要的 DDD 概念。
值对象不具有身份,它纯粹用于描述实体的特性。处理不具有身份的值对象是很容易的,尤其是不变性与可组合性是支持易用性的两个特征。
一个值对象,或者更简单的说,值,是对一个不变的概念整体建立的模型。在这个模型中,值就真的只有一个值。和实体不一样,他没有唯一标识,而是通过封装属性的对比来决定相等性。一个值对象不是事物,而是用来描述、量化或测量实体的。
当你关系某个对象的属性时,该对象便是一个值对象。为其添加有意义的属性,并赋予相应的行为。我们需要将值对象看成不变对象,不要给他任何身份标识,还应该尽量避免像实体对象一样的复杂性。
即使一个领域概念必须建模成实体,在设计时也应该更偏向于将其作为值对象的容器。当决定一个领域概念是否应该建模成值对象时,需要考虑是否拥有一些特性:
在使用这个特性分析模型时,你会发现很多领域概念都应该建模成值对象,而非实体。
值对象的特征汇总如下:
比较典型的例子便是 Money,大多数情况下,我们只关心它所代表的实际金额,为其分配标识是一个没有意义的操作。
@Data @Setter(AccessLevel.PRIVATE) @Embeddable public class Money implements ValueObject { public static final String DEFAULT_FEE_TYPE = "CNY"; @Column(name = "total_fee") private Long totalFee; @Column(name = "fee_type") private String feeType; ... }
比如邮箱可以使用字符串进行描述,但会丢失很多邮箱的特性,此时,需要将其建模成值对象。
@Embeddable @Data @Setter(AccessLevel.PRIVATE) public class Email implements ValueObject { @Column(name = "email_name") private String name; @Column(name = "email_domain") private String domain; private Email() { } private Email(String name, String domain) { Preconditions.checkArgument(StringUtils.isNotEmpty(name), "name can not be null"); Preconditions.checkArgument(StringUtils.isNotEmpty(domain), "domain can not be null"); this.setName(name); this.setDomain(domain); } public static Email apply(String email) { Preconditions.checkArgument(StringUtils.isNotEmpty(email), "email can not be null"); String[] ss = email.split("@"); Preconditions.checkArgument(ss.length == 2, "not Email"); return new Email(ss[0], ss[1]); } @Override public String toString() { return this.getName() + "@" + this.getDomain(); } }
此时,邮箱是一个明确的领域概念,相比字符串方案,其拥有验证逻辑,同时享受编译器类型校验。
由于值对象没有身份,且描述了领域中重要的概念,通常,我们会先定义实体,然后找出与实体相关的值对象。一般情况下,值对象需要实体提供上下文相关性。
如果两个 Money 对象表示相等的金额,他们就被认为是相等的。而不管他们是指向同一个实例还是不同的实例。
在 Money 类中使用 lombok 插件自动生成 hashCode 和 equals 方法,查看 Money.class 可以看到。
// // Source code recreated from a .class file by IntelliJ IDEA // (powered by Fernflower decompiler) // public class Mobile implements ValueObject { public boolean equals(final Object o) { if (o == this) { return true; } else if (!(o instanceof Mobile)) { return false; } else { Mobile other = (Mobile)o; if (!other.canEqual(this)) { return false; } else { Object this$dcc = this.getDcc(); Object other$dcc = other.getDcc(); if (this$dcc == null) { if (other$dcc != null) { return false; } } else if (!this$dcc.equals(other$dcc)) { return false; } Object this$mobile = this.getMobile(); Object other$mobile = other.getMobile(); if (this$mobile == null) { if (other$mobile != null) { return false; } } else if (!this$mobile.equals(other$mobile)) { return false; } return true; } } } protected boolean canEqual(final Object other) { return other instanceof Mobile; } public int hashCode() { int PRIME = true; int result = 1; Object $dcc = this.getDcc(); int result = result * 59 + ($dcc == null ? 43 : $dcc.hashCode()); Object $mobile = this.getMobile(); result = result * 59 + ($mobile == null ? 43 : $mobile.hashCode()); return result; } public String toString() { return "Mobile(dcc=" + this.getDcc() + ", mobile=" + this.getMobile() + ")"; } }
在 Money 值对象中,可以看到暴露的方法:
方法 | 含义 |
---|---|
apply | 创建 Money |
add | Money 相加 |
subtract | Money 相减 |
multiply | Money 相乘 |
split | Money 切分,将无法查分的误差汇总到最后的 Money 中 |
@Data @Setter(AccessLevel.PRIVATE) @Embeddable public class Money implements ValueObject { public static final String DEFAULT_FEE_TYPE = "CNY"; @Column(name = "total_fee") private Long totalFee; @Column(name = "fee_type") private String feeType; private static final BigDecimal NUM_100 = new BigDecimal(100); private Money() { } private Money(Long totalFee, String feeType) { Preconditions.checkArgument(totalFee != null); Preconditions.checkArgument(StringUtils.isNotEmpty(feeType)); Preconditions.checkArgument(totalFee.longValue() > 0); this.totalFee = totalFee; this.feeType = feeType; } public static Money apply(Long totalFee){ return apply(totalFee, DEFAULT_FEE_TYPE); } public static Money apply(Long totalFee, String feeType){ return new Money(totalFee, feeType); } public Money add(Money money){ checkInput(money); return Money.apply(this.getTotalFee() + money.getTotalFee(), getFeeType()); } private void checkInput(Money money) { if (money == null){ throw new IllegalArgumentException("input money can not be null"); } if (!this.getFeeType().equals(money.getFeeType())){ throw new IllegalArgumentException("must be same fee type"); } } public Money subtract(Money money){ checkInput(money); if (getTotalFee() < money.getTotalFee()){ throw new IllegalArgumentException("money can not be minus"); } return Money.apply(this.getTotalFee() - money.getTotalFee(), this.getFeeType()); } public Money multiply(int var){ return Money.apply(this.getTotalFee() * var, getFeeType()); } public List<Money> split(int count){ if (getTotalFee() < count){ throw new IllegalArgumentException("total fee can not lt count"); } List<Money> result = Lists.newArrayList(); Long pre = getTotalFee() / count; for (int i=0; i< count; i++){ if (i == count-1){ Long fee = getTotalFee() - (pre * (count - 1)); result.add(Money.apply(fee, getFeeType())); }else { result.add(Money.apply(pre, getFeeType())); } } return result; } }
当然,并不局限于此,对于拥有概念整体性的对象,都具有很强的内聚性。比如,英文名称,由 firstName,lastName 组成。
@Data @Setter(AccessLevel.PRIVATE) public class EnglishName{ private String firstName; private String lastName; private EnglishName(String firstName, String lastName){ Preconditions.checkArgument(StringUtils.isNotEmpty(firstName)); Preconditions.checkArgument(StringUtils.isNotEmpty(lastName)); setFirstName(firstName); setLastName(lastName); } public static EnglishName apply(String firstName, String lastName){ return new EnglishName(firstName, lastName); } }
如果需要改变值对象,应该创建新的值对象,并由新的值对象替换旧值对象。
比如,Money 的 subtract 方法。
public Money subtract(Money money){ checkInput(money); if (getTotalFee() < money.getTotalFee()){ throw new IllegalArgumentException("money can not be minus"); } return Money.apply(this.getTotalFee() - money.getTotalFee(), this.getFeeType()); }
只会创建新的 Money 对象,不会对原有对象进行修改。
在技术实现上,对于一个不可变对象,需要将所有字段设置为 final,并通过构造函数为其赋值。但,有时为了迎合一些框架需求,需求进行部分妥协,及将 setter 方法设置为 private,从而对外隐藏修改方法。比如 Money 的 add 方法,Money 加上 Money 会得到一个新的 Money。
public Money add(Money money){ checkInput(money); return Money.apply(this.getTotalFee() + money.getTotalFee(), getFeeType()); }
通常情况下,在创建一个值对象实例时,如果参数与业务规则不一致,则构造函数应该抛出异常。
还是看我们的 Money 类,需要进行如下检验:
private Money(Long totalFee, String feeType) { Preconditions.checkArgument(totalFee != null); Preconditions.checkArgument(StringUtils.isNotEmpty(feeType)); Preconditions.checkArgument(totalFee.longValue() > 0); this.totalFee = totalFee; this.feeType = feeType; }
当然,如果值对象的构建过程过于复杂,可以使用 Factory 模式进行构建。此时,应该在 Factory 中对值对象的有效性进行验证。
还是看我们的 Money 对象的测试类。
public class MoneyTest { @Test public void add() { Money m1 = Money.apply(100L); Money m2 = Money.apply(200L); Money money = m1.add(m2); Assert.assertEquals(300L, money.getTotalFee().longValue()); Assert.assertEquals(m1.getFeeType(), money.getFeeType()); Assert.assertEquals(m2.getFeeType(), money.getFeeType()); } @Test public void subtract() { Money m1 = Money.apply(300L); Money m2 = Money.apply(200L); Money money = m1.subtract(m2); Assert.assertEquals(100L, money.getTotalFee().longValue()); Assert.assertEquals(m1.getFeeType(), money.getFeeType()); Assert.assertEquals(m2.getFeeType(), money.getFeeType()); } @Test public void multiply() { Money m1 = Money.apply(100L); Money money = m1.multiply(3); Assert.assertEquals(300L, money.getTotalFee().longValue()); Assert.assertEquals(m1.getFeeType(), money.getFeeType()); } @Test public void split() { Money m1 = Money.apply(100L); List<Money> monies = m1.split(33); Assert.assertEquals(33, monies.size()); monies.forEach(m -> Assert.assertEquals(m1.getFeeType(), m.getFeeType())); long total = monies.stream() .mapToLong(m->m.getTotalFee()) .sum(); Assert.assertEquals(100L, total); } }
比如 java 中的 Instant 的静态工厂方法。
public static Instant now() { ... } public static Instant ofEpochSecond(long epochSecond) { ... } public static Instant ofEpochMilli(long epochMilli){ ... }
通过方法签名就能很清楚的了解其含义。
典型的就是 Mobile 封装,其本质是一个 String。通过 Mobile 封装,使其具有字符串无法表达的含义。
@Setter(AccessLevel.PRIVATE) @Data @Embeddable public class Mobile implements ValueObject { public static final String DEFAULT_DCC = "0086"; @Column(name = "dcc") private String dcc; @Column(name = "mobile") private String mobile; private Mobile() { } private Mobile(String dcc, String mobile){ Preconditions.checkArgument(StringUtils.isNotEmpty(dcc)); Preconditions.checkArgument(StringUtils.isNotEmpty(mobile)); setDcc(dcc); setMobile(mobile); } public static Mobile apply(String mobile){ return apply(DEFAULT_DCC, mobile); } public static Mobile apply(String dcc, String mobile){ return new Mobile(dcc, mobile); } }
使用值对象集合通常意味着需要使用某种形式来取出特定项,这就相当于为值对象添加了身份。
比如 List<Email> 第一个代表是主邮箱,第二个表示是副邮箱,最佳的表达方式是直接用属性进行表式,如:
@Data @Setter(AccessLevel.PRIVATE) public class Person{ private Email primary; private Email second; public void updateEmail(Email primary, Email second){ Preconditions.checkArgument(primary != null); Preconditions.checkArgument(second != null); setPrimary(primary); setSecond(second); } }
持久化过程即将对象序列化成文本格式或二进制格式,然后保存到计算机磁盘中。
在面向文档数据存储时,问题会少很多。我们可以在同一个文档中存储实体和值对象;然而,使用 SQL 数据库就麻烦的多,这将导致很多变化。
在 NoSQL 中,整个实体都可以作为一个文档来建模。在 SQL 中的表连接、规范化数据和 ORM 延迟加载等相关问题都不存在了。在值对象上下文中,这就意味着他们会与实体一起存储。
@Data @Setter(AccessLevel.PRIVATE) @Document public class PersonAsMongo { private Email primary; private Email second; public void updateEmail(Email primary, Email second){ Preconditions.checkArgument(primary != null); Preconditions.checkArgument(second != null); setPrimary(primary); setSecond(second); } }
面向文档的 NoSQL 数据库会将文档持久化为 JSON,上例中 Person 的 primary 和 second 会作为 JSON 文档的属性进行存储。
多数情况下,持久化值对象时,我们都是通过一种非范式的方式完成,即所有的属性和实体都保存在相同的数据库表中。有时,值对象需要以实体的身份进行持久化。比如聚合中维护一个值对象集合时。
这种方式,是最常见的值对象序列化方式,也是冲突最小的方式,可以在查询中使用连接语句进行查询。
Jpa 提供 @Embeddable 和 @Embedded 两个注解,以支持这种方式。
首先,在值对象上添加 @Embeddable 注解,以标注其为可嵌入对象。
@Embeddable @Data @Setter(AccessLevel.PRIVATE) public class Email implements ValueObject { @Column(name = "email_name") private String name; @Column(name = "email_domain") private String domain; private Email() { } private Email(String name, String domain) { Preconditions.checkArgument(StringUtils.isNotEmpty(name), "name can not be null"); Preconditions.checkArgument(StringUtils.isNotEmpty(domain), "domain can not be null"); this.setName(name); this.setDomain(domain); } public static Email apply(String email) { Preconditions.checkArgument(StringUtils.isNotEmpty(email), "email can not be null"); String[] ss = email.split("@"); Preconditions.checkArgument(ss.length == 2, "not Email"); return new Email(ss[0], ss[1]); } @Override public String toString() { return this.getName() + "@" + this.getDomain(); } }
然后,在实体对于属性上添加 @Embedded 注解,标注该属性将展开存储。
@Data @Entity public class Person1 { @Embedded private Email primary; }
一般情况下,会涉及以下几个操作:
如,对于 Email 值对象,我们采用 JSON 作为持久化格式:
public class EmailSerializer { public static Email toEmail(String json){ if (StringUtils.isEmpty(json)){ return null; } return JSON.parseObject(json, Email.class); } public static String toJson(Email email){ if (email == null){ return null; } return JSON.toJSONString(email); } }
JPA 中提供了 Converter 扩展,以完成值对象到数据、数据到值对象的转化:
public class EmailConverter implements AttributeConverter<Email, String> { @Override public String convertToDatabaseColumn(Email attribute) { return EmailSerializer.toJson(attribute); } @Override public Email convertToEntityAttribute(String dbData) { return EmailSerializer.toEmail(dbData); } }
Converter 完成后,需要将其配置在对应的属性上:
@Data @Setter(AccessLevel.PRIVATE) public class PersonAsJpa { @Convert(converter = EmailConverter.class) private Email primary; @Convert(converter = EmailConverter.class) private Email second; public void updateEmail(Email primary, Email second){ Preconditions.checkArgument(primary != null); Preconditions.checkArgument(second != null); setPrimary(primary); setSecond(second); } }
此时,就完成了单个值对象的持久化。
需要考虑的问题:
如,对于 List<Email> 选择 JSON 作为持久化格式:
public class EmailListSerializer { public static List<Email> toEmailList(String json){ if (StringUtils.isEmpty(json)){ return null; } return JSON.parseArray(json, Email.class); } public static String toJson(List<Email> email){ if (email == null){ return null; } return JSON.toJSONString(email); } }
扩展 JPA 的 Converter:
public class EmailListConverter implements AttributeConverter<List<Email>, String> { @Override public String convertToDatabaseColumn(List<Email> attribute) { return EmailListSerializer.toJson(attribute); } @Override public List<Email> convertToEntityAttribute(String dbData) { return EmailListSerializer.toEmailList(dbData); } }
属性配置:
@Data @Setter(AccessLevel.PRIVATE) public class PersonEmailListAsJpa { @Convert(converter = EmailListConverter.class) private List<Email> emails; }
我们可以使用委派主键的方式,使用两层的层超类型。在上层隐藏委派主键。
这样我们可以自由的将其映射成数据库实体,同时在领域模型中将其建模成值对象。
首先,定义 IdentitiedObject 用以隐藏数据库 ID。
@MappedSuperclass public class IdentitiedObject { @Setter(AccessLevel.PRIVATE) @Getter(AccessLevel.PRIVATE) @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; }
然后,从 IdentitiedObject 派生出 IdentitiedEmail 类,用以完成值对象建模。
@Data @Setter(AccessLevel.PRIVATE) @Entity public class IdentitiedEmail extends IdentitiedObject implements ValueObject { @Column(name = "email_name") private String name; @Column(name = "email_domain") private String domain; private IdentitiedEmail() { } private IdentitiedEmail(String name, String domain) { Preconditions.checkArgument(StringUtils.isNotEmpty(name), "name can not be null"); Preconditions.checkArgument(StringUtils.isNotEmpty(domain), "domain can not be null"); this.setName(name); this.setDomain(domain); } public static IdentitiedEmail apply(String email) { Preconditions.checkArgument(StringUtils.isNotEmpty(email), "email can not be null"); String[] ss = email.split("@"); Preconditions.checkArgument(ss.length == 2, "not Email"); return new IdentitiedEmail(ss[0], ss[1]); } @Override public String toString() { return this.getName() + "@" + this.getDomain(); } }
此时,就可以使用 JPA 的 @OneToMany 特性存储多个值:
@Data @Entity public class PersonOneToMany { @OneToMany private List<IdentitiedEmail> emails = Lists.newArrayList(); }
大多持久化框架都提供了对枚举类型的支持。要么使用枚举值得 String,要么使用枚举值得 Index,其实都不是最佳方案,对以后得重构不太友好,建议使用自定义 code 进行持久化处理。
定义枚举:
public enum PersonStatus implements CodeBasedEnum<PersonStatus> { ENABLE(1), DISABLE(0); private final int code; PersonStatus(int code) { this.code = code; } @Override public int getCode() { return this.code; } public static PersonStatus parseByCode(Integer code){ for (PersonStatus status : values()){ if (code.intValue() == status.getCode()){ return status; } } return null; } }
扩展枚举 Converter:
public class PersonStatusConverter implements AttributeConverter<PersonStatus, Integer> { @Override public Integer convertToDatabaseColumn(PersonStatus attribute) { return attribute != null ? attribute.getCode() : null; } @Override public PersonStatus convertToEntityAttribute(Integer dbData) { return dbData == null ? null : PersonStatus.parseByCode(dbData); } }
配置属性:
@Data @Setter(AccessLevel.PRIVATE) public class Person{ @Embedded private Email primary; @Embedded private Email second; @Convert(converter = PersonStatusConverter.class) private PersonStatus status; public void updateEmail(Email primary, Email second){ Preconditions.checkArgument(primary != null); Preconditions.checkArgument(second != null); setPrimary(primary); setSecond(second); } }
此时,通过枚举对象中的 code 进行持久化。
当面临阻抗时,我们应该从领域模型角度,而不是持久化角度去思考问题。
Java 的枚举时实现标准类型的一种简单方法。枚举提供了一组有限数量的值对象,它是非常轻量的,并且无副作用。
一个共享的不变值对象,可以从持久化存储中获取,此时可以使用标准类型的领域服务和工厂来获取值对象。我们应该为每组标准类型创建一个领域服务或工厂。
如果打算使用常规值对象来表示标准类型,可以使用领域服务或工厂来静态的创建值对象实例。