领域驱动设计战术模式--值对象
值對象雖然經常被掩蓋在實體的陰影之下,但它卻是非常重要的 DDD 概念。
值對象不具有身份,它純粹用于描述實體的特性。處理不具有身份的值對象是很容易的,尤其是不變性與可組合性是支持易用性的兩個特征。
1 理解值對象
值對象用于度量和描述事物,我們可以非常容易的對值對象進行創建、測試、使用、優化和維護。
一個值對象,或者更簡單的說,值,是對一個不變的概念整體建立的模型。在這個模型中,值就真的只有一個值。和實體不一樣,他沒有唯一標識,而是通過封裝屬性的對比來決定相等性。一個值對象不是事物,而是用來描述、量化或測量實體的。
當你關系某個對象的屬性時,該對象便是一個值對象。為其添加有意義的屬性,并賦予相應的行為。我們需要將值對象看成不變對象,不要給他任何身份標識,還應該盡量避免像實體對象一樣的復雜性。
即使一個領域概念必須建模成實體,在設計時也應該更偏向于將其作為值對象的容器。
當決定一個領域概念是否應該建模成值對象時,需要考慮是否擁有一些特性:
- 度量或描述領域中的一件東西。
- 可以作為不變對象。
- 將不同的相關屬性組合成一個概念整體。
- 當度量或描述改變時,可以使用另一個值對象予以替換。
- 可以與其他值對象進行相等性比較。
- 不對對協作對象造成負面影響。
在使用這個特性分析模型時,你會發現很多領域概念都應該建模成值對象,而非實體。
值對象的特征匯總如下:
2 何時使用值對象
值對象是實體的狀態,它描述與實體相關的概念。
2.1 表示描述性的、缺失身份的概念
當一個概念缺乏明顯的身份時,基本可以斷定它大概率是一個值對象。
比較典型的例子便是 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;... }復制代碼2.2 增強確定性
領域驅動設計的一切都是為了明確傳遞業務規則和領域邏輯。像整數和字符串這樣的技術單元并不適合這種情況。
比如郵箱可以使用字符串進行描述,但會丟失很多郵箱的特性,此時,需要將其建模成值對象。
@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]);}@Overridepublic String toString() {return this.getName() + "@" + this.getDomain();} }復制代碼此時,郵箱是一個明確的領域概念,相比字符串方案,其擁有驗證邏輯,同時享受編譯器類型校驗。
3 實現值對象
值對象是不可變的、無副作用并且易于測試的。
3.1 欠缺身份
缺失身份是值對象和實體最大的區別。
由于值對象沒有身份,且描述了領域中重要的概念,通常,我們會先定義實體,然后找出與實體相關的值對象。一般情況下,值對象需要實體提供上下文相關性。
3.2 基于屬性的相等性
如果實體具有相同的類型和標識,則會認為是相等的。相反,值對象要具有相同的值才會認為是相等的。
如果兩個 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() + ")";} }復制代碼3.3 富含行為
值對象應該盡可能多的暴露面向領域概念的行為。
在 Money 值對象中,可以看到暴露的方法:
| apply | 創建 Money |
| add | Money 相加 |
| subtract | Money 相減 |
| multiply | Money 相乘 |
| split | Money 切分,將無法查分的誤差匯總到最后的 Money 中 |
3.4 內聚
通常情況下,值對象會內聚封裝度量值和度量單位。在 Money 中可以看到這一點。
當然,并不局限于此,對于擁有概念整體性的對象,都具有很強的內聚性。比如,英文名稱,由 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);} } 復制代碼3.5 不變性
一旦創建完成后,值對象就永遠不能改變。
如果需要改變值對象,應該創建新的值對象,并由新的值對象替換舊值對象。 比如,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,從而對外隱藏修改方法。
3.6 可組合性
對于用于度量的值對象,通常會有數值,此時,可以將其組合起來以創建新的值。
比如 Money 的 add 方法,Money 加上 Money 會得到一個新的 Money。
public Money add(Money money){checkInput(money);return Money.apply(this.getTotalFee() + money.getTotalFee(), getFeeType()); } 復制代碼3.7 自驗證性
值對象作為一個概念整體,決不應該變成無效狀態,它自身就應該負責對其進行驗證。
通常情況下,在創建一個值對象實例時,如果參數與業務規則不一致,則構造函數應該拋出異常。
還是看我們的 Money 類,需要進行如下檢驗:
當然,如果值對象的構建過程過于復雜,可以使用 Factory 模式進行構建。此時,應該在 Factory 中對值對象的有效性進行驗證。
3.8 可測試性
不變性、內聚性和可組合性使值對象變的可測試。
還是看我們的 Money 對象的測試類。
public class MoneyTest {@Testpublic 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());}@Testpublic 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());}@Testpublic void multiply() {Money m1 = Money.apply(100L);Money money = m1.multiply(3);Assert.assertEquals(300L, money.getTotalFee().longValue());Assert.assertEquals(m1.getFeeType(), money.getFeeType());}@Testpublic 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);} } 復制代碼4 值對象建模模式
通過一些常用的值對象建模模式,可以提高值對象的處理體驗。
4.1 靜態工廠方法
靜態工廠方法是更簡單、更具有表達性的一種技巧。
比如 java 中的 Instant 的靜態工廠方法。
public static Instant now() {... } public static Instant ofEpochSecond(long epochSecond) {... } public static Instant ofEpochMilli(long epochMilli){... } 復制代碼通過方法簽名就能很清楚的了解其含義。
4.2 微類型
通過使用更具體的領域模型類型封裝技術類型,使其更具表達能力。
典型的就是 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);}} 復制代碼4.3 避免集合
通常情況下,需要盡量避免使用值對象集合。這種表達方式無法正確的表達領域概念。
使用值對象集合通常意味著需要使用某種形式來取出特定項,這就相當于為值對象添加了身份。 比如 List 第一個代表是主郵箱,第二個表示是副郵箱,最佳的表達方式是直接用屬性進行表式,如:
@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);} } 復制代碼5 持久化
處理值對象最難的點就在他們的持久化。一般情況下,不會直接對其進行持久化,值對象會作為實體的屬性,一并進行持久化處理。
持久化過程即將對象序列化成文本格式或二進制格式,然后保存到計算機磁盤中。
在面向文檔數據存儲時,問題會少很多。我們可以在同一個文檔中存儲實體和值對象;然而,使用 SQL 數據庫就麻煩的多,這將導致很多變化。
5.1 NoSQL
許多 NoSQL 數據庫都使用了數據反規范化,為我們提供了很大便利。
在 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 文檔的屬性進行存儲。
5.2 SQL
在 SQL 數據庫中存儲值對象,可以遵循標準的 SQL 約定,也可以使用范模式。
多數情況下,持久化值對象時,我們都是通過一種非范式的方式完成,即所有的屬性和實體都保存在相同的數據庫表中。有時,值對象需要以實體的身份進行持久化。比如聚合中維護一個值對象集合時。
5.2.1 多列存儲單個值對象
基本思路就是將值對象與其所在的實體對象保存在同一張表中,值對象的每個屬性保存為一列。
這種方式,是最常見的值對象序列化方式,也是沖突最小的方式,可以在查詢中使用連接語句進行查詢。
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]);}@Overridepublic String toString() {return this.getName() + "@" + this.getDomain();} } 復制代碼然后,在實體對于屬性上添加 @Embedded 注解,標注該屬性將展開存儲。
@Data @Entity public class Person1 {@Embeddedprivate Email primary; } 復制代碼5.2.2 單列存儲單個值對象
值對象的所有屬性保存為一列。當不希望在查詢中使用額外語句來連接他們時,這是一個很好的選擇。
一般情況下,會涉及以下幾個操作:
如,對于 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> {@Overridepublic String convertToDatabaseColumn(Email attribute) {return EmailSerializer.toJson(attribute);}@Overridepublic 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);} } 復制代碼此時,就完成了單個值對象的持久化。
5.2.3 多個值對象序列化到單個列中
這種應用是前種方案的擴展。將整個集合序列化成某種形式的文本,然后將該文本保存到單個數據庫列中。
需要考慮的問題:
如,對于 List 選擇 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> {@Overridepublic String convertToDatabaseColumn(List<Email> attribute) {return EmailListSerializer.toJson(attribute);}@Overridepublic List<Email> convertToEntityAttribute(String dbData) {return EmailListSerializer.toEmailList(dbData);} }復制代碼屬性配置:
@Data @Setter(AccessLevel.PRIVATE) public class PersonEmailListAsJpa {@Convert(converter = EmailListConverter.class)private List<Email> emails;} 復制代碼5.2.4 使用數據庫實體保存多個值對象
我們應該首先考慮將領域概念建模成值對象,而不是實體。
我們可以使用委派主鍵的方式,使用兩層的層超類型。在上層隱藏委派主鍵。 這樣我們可以自由的將其映射成數據庫實體,同時在領域模型中將其建模成值對象。
首先,定義 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 IdentitiedObjectimplements 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]);}@Overridepublic String toString() {return this.getName() + "@" + this.getDomain();} }復制代碼此時,就可以使用 JPA 的 @OneToMany 特性存儲多個值:
@Data @Entity public class PersonOneToMany {@OneToManyprivate List<IdentitiedEmail> emails = Lists.newArrayList(); } 復制代碼5.2.5 ORM 與 枚舉狀態對象
大多持久化框架都提供了對枚舉類型的支持。要么使用枚舉值得 String,要么使用枚舉值得 Index,其實都不是最佳方案,對以后得重構不太友好,建議使用自定義 code 進行持久化處理。
定義枚舉:
public enum PersonStatus implements CodeBasedEnum<PersonStatus> {ENABLE(1),DISABLE(0);private final int code;PersonStatus(int code) {this.code = code;}@Overridepublic 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> {@Overridepublic Integer convertToDatabaseColumn(PersonStatus attribute) {return attribute != null ? attribute.getCode() : null;}@Overridepublic PersonStatus convertToEntityAttribute(Integer dbData) {return dbData == null ? null : PersonStatus.parseByCode(dbData);} }復制代碼配置屬性:
@Data @Setter(AccessLevel.PRIVATE) public class Person{@Embeddedprivate Email primary;@Embeddedprivate 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 進行持久化。
5.2.6 阻抗
在使用 DB 進行值對象持久化時,經常遇到阻抗。
當面臨阻抗時,我們應該從領域模型角度,而不是持久化角度去思考問題。
- 根據領域模型來來設計數據模型,而不是通過數據模型來設計領域模型。
- 報表和商業智能應該由專門的數據模型進行處理,而不是生產環境的數據模型。
6 值對象其他用途
6.1 用值對象表示標準類型
標準類型是用于表示事物類型的描述性對象。
Java 的枚舉時實現標準類型的一種簡單方法。枚舉提供了一組有限數量的值對象,它是非常輕量的,并且無副作用。
一個共享的不變值對象,可以從持久化存儲中獲取,此時可以使用標準類型的領域服務和工廠來獲取值對象。我們應該為每組標準類型創建一個領域服務或工廠。 如果打算使用常規值對象來表示標準類型,可以使用領域服務或工廠來靜態的創建值對象實例。
6.2 最小集成
當模型概念從上游上下文流入下游上下文中,盡量使用值對象來表示這些概念。在有可能的情況下,使用值對象完成上下文之間的集成。
7 小結
- 值對象是 DDD 建模結構體,它用于表示像度量這樣的描述概念。
- 值對象沒有身份,比實體要簡單得多。
- 建議將數字和字符串封裝成值對象,以更好的表示領域概念。
- 值對象是不可變的,他們的值在創建后,就不在發生變化。
- 值對象是內聚的,將多個特征封裝成一個完整的概念。
- 可以通過組合值對象來創建新的值對象,而不改變原始值。
- 值對象是自驗證的,它不應該處于無效狀態。
- 可以使用靜態工廠、微類型等模式提高值對象的易用性。
- 對于 NoSQL 的存儲,直接使用反規范持久化值對象,面向文檔數據庫是首選。
- 對于 SQL 存儲,相對要麻煩下,存在大量的阻抗。
總結
以上是生活随笔為你收集整理的领域驱动设计战术模式--值对象的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: JavaScript —— this、闭
- 下一篇: 数据科学家十年后彻底消失?25年行业元老