產品程式碼都給你看了,可別再說不會DDD(七):實體與值物件

2023-10-15 18:01:05

這是一個講解DDD落地的文章系列,作者是《實現領域驅動設計》的譯者滕雲。本文章系列以一個真實的並已成功上線的軟體專案——碼如雲https://www.mryqr.com)為例,系統性地講解DDD在落地實施過程中的各種典型實踐,以及在面臨實際業務場景時的諸多取捨。

本系列包含以下文章:

  1. DDD入門
  2. DDD概念大白話
  3. 戰略設計
  4. 程式碼工程結構
  5. 請求處理流程
  6. 聚合根與資源庫
  7. 實體與值物件(本文)
  8. 應用服務與領域服務
  9. 領域事件
  10. CQRS

案例專案介紹

既然DDD是「領域」驅動,那麼我們便不能拋開業務而只講技術,為此讓我們先從業務上了解一下貫穿本文章系列的案例專案 —— 碼如雲(不是馬雲,也不是碼雲)。如你已經在本系列的其他文章中瞭解過該案例,可跳過。

碼如雲是一個基於二維條碼的一物一碼管理平臺,可以為每一件「物品」生成一個二維條碼,並以該二維條碼為入口展開對「物品」的相關操作,典型的應用場景包括固定資產管理、裝置巡檢以及物品標籤等。

在使用碼如雲時,首先需要建立一個應用(App),一個應用包含了多個頁面(Page),也可稱為表單,一個頁面又可以包含多個控制元件(Control),比如單選框控制元件。應用建立好後,可在應用下建立多個範例(QR)用於表示被管理的物件(比如機器裝置)。每個範例均對應一個二維條碼,手機掃碼便可對範例進行相應操作,比如檢視範例相關資訊或者填寫頁面表單等,對錶單的一次填寫稱為提交(Submission);更多概念請參考碼如雲術語

在技術上,碼如雲是一個無程式碼平臺,包含了表單引擎、審批流程和資料包表等多個功能模組。碼如雲全程採用DDD完成開發,其後端技術棧主要有Java、Spring Boot和MongoDB等。

碼如雲的原始碼是開源的,可以通過以下方式存取:

碼如雲原始碼:https://github.com/mryqr-com/mry-backend

實體與值物件

在本系列的上一篇聚合根與資源庫中,我們講到了聚合根的設計與實現,事實上聚合根本身即是一種實體(Entity),在本文中我們將對實體以及與之相對立的值物件(Value Object)做展開講解。

在對聚合根的深入分析中,我們發現其中存在兩種型別的物件,一種是具有生命週期的物件(比如成員(Member)),另一種是隻起描述作用的物件(比如地址(Address)),前者稱為實體,後者稱為值物件,充分認識這兩種物件之間的區別,對DDD落地有著舉足輕重的作用。我們希望達到的目的是,將盡量多的概念建模為值物件,因為值物件比實體更加簡單。

實體的生命週期意味著實體具有從產生到消亡的整個過程,這個過程往往比較漫長。比如,在碼如雲中,成員(Member)物件的生命週期可能超過幾年甚至幾十年的時間。相比之下,值物件不存在生命週期可言。為了講解更加直觀,讓我們來分別看看值物件和實體的例子。在碼如雲中,地址(Address)即是一個值物件:

//Address

@Value
@Builder
@AllArgsConstructor(access = PRIVATE)
public class Address {
    private final String province; //省份
    private final String city; //城市
    private final String district; //區縣
    private final String address; //詳細地址

    //......此處省略更多程式碼

}   

原始碼出處:com/mryqr/core/common/domain/Address.java

聚合根成員(Member)則是一個實體物件:

//Member

@Getter
@Document(MEMBER_COLLECTION)
@TypeAlias(MEMBER_COLLECTION)
@NoArgsConstructor(access = PRIVATE)
public class Member extends AggregateRoot {
    private String name;//名字
    private Role role;//角色
    private String mobile;//手機號
    private String email;//郵箱
    private IdentityCard identityCard;//身份證
    
    //...此處省略更多程式碼

}

原始碼出處:com/mryqr/core/member/domain/Member.java

咋一看,實體和值物件似乎沒有什麼區別,都是Java物件而已,但事實上,實體和值物件在唯一標識、相等性和可變性等方面均存在很大的區別。

唯一標識

值物件的「描述性作用」也意味著它無需唯一標識(即ID)即可完成其使命,而實體則恰恰相反。在本例中,值物件Address沒有ID,而實體Member的唯一標識則存在於其父類別AggregateRootid欄位中:

//AggregateRoot

@Getter
public abstract class AggregateRoot implements Identified {
    private String id;//聚合根ID
    private String tenantId;//租戶ID

    //...此處省略更多程式碼

}

原始碼出處:com/mryqr/core/common/domain/AggregateRoot.java

更多關於AggregateRoot的內容,請參考本系列的聚合根與資源庫一文。

在DDD中,所有的聚合根都是實體物件,但並不是所有的實體都是聚合根,不過從實踐上來看了,絕大多數的實體物件都是聚合根。因此,在DDD專案中最常見的情況是:作為實體物件的聚合根包含了大量的值物件。

對於聚合根而言,由於已經是領域模型中的頂層物件,其唯一標識應該是全域性唯一的;而對於聚合根下的其他實體而言,由於其作用範圍被限制在了聚合根內部,因此對應的唯一標識在聚合根下唯一即可。比如,在碼如雲中,一個應用(App)包含了多個頁面(Page),App是聚合根,PageApp下的實體,App的ID必須全域性唯一,而Page的ID在其所屬的App下唯一即可。

實體的唯一標識可以有多種方式生成,有些業務資料天然即是唯一標識,比如對於人員來說,身份證號即可直接用於唯一標識。不過需要注意的是,只有那些不變的業務欄位才能用於唯一標識,否則,當這些業務欄位發生更新時,所有參照它的地方都需要做相應更新。更多的時候,我們建議採用一個無業務含義的ID作為唯一標識,比如UUID或者通過雪花演演算法生成的ID等,又由於UUID的無序性在巨量資料量場景下可能存在效能問題,因此我們更偏向於雪花演演算法ID。

有些技術框架可以設定延後對實體ID的生成,比如Hibernate和資料庫自增ID等,在DDD中,我們強烈建議不要採用這些方式,因為這些方式所建立出來的實體物件直到儲存到資料庫的最後一刻都是非法的,更好的方式是在新建實體時即為之設定ID。

在碼如雲中,我們通過雪花演演算法為聚合根生成ID,並且在建構函式中完成了對ID的賦值,以達到在新建時即為ID賦值的目的。比如,在Member物件的其中一個建構函式中,我們呼叫了newMemberId()為新成員生成ID:

//Member

//建立Member
public Member(String name, String mobile, User user) {
    super(newMemberId(), user);
    this.name = name;
    this.mobile = mobile;
    
    //...此處省略更多程式碼
}

//通過雪花演演算法生成成員ID
public static String newMemberId() {
    return "MBR" + newSnowflakeId();
}

原始碼出處:com/mryqr/core/member/domain/Member.java

有時,為了一些純技術上原因,我們需要為值物件設定ID。比如,如果採用通過ORM框架持久化租戶(Tenant),則需要將Tenant中的發票地址(invoiceAddress)儲存到一張單獨的資料庫表中,由於資料庫表之間需要有外來鍵關聯,因此需要將Address繼承自一個層超類IdentifiedValueObject,在IdentifiedValueObject中包含有用於資料庫表外來鍵關聯的id欄位。

此時的Tenant實現如下:

//Tenant

@Getter
@Document(TENANT_COLLECTION)
@TypeAlias(TENANT_COLLECTION)
@NoArgsConstructor(access = PRIVATE)
public class Tenant extends AggregateRoot {
    private String name;//租戶名稱
    private InvoiceTitle invoiceTitle;//發票擡頭
    private Address invoiceAddress;//發票地址

    //...此處省略更多程式碼

}

原始碼出處:com/mryqr/core/tenant/domain/Tenant.java

層超類IdentifiedValueObject實現如下:

//IdentifiedValueObject

public abstract class IdentifiedValueObject {
    private String id;
}

此時的Address繼承自IdentifiedValueObject

//Address

public class Address extends IdentifiedValueObject {
    private final String province;

    //...此處省略更多程式碼

}

需要強調的是,以上「為值物件設定ID」的做法僅僅是一種技術上的實踐,不能將其與業務相混淆,為此我們引入了一個層超類IdentifiedValueObject將與技術相關的內容作為一個單獨的關注點來處理,從而實現了技術與業務的隔離。不過,在碼如雲,由於我們採用了MongoDB,從而避開了ORM,因此不存在本例中的問題。

相等性判斷

實體物件通過ID進行相等性判斷,而值物件通過其自身攜帶的屬性進行相等性判斷。舉個例子,對於一對雙胞胎而言,每人都是一個實體物件,由於二人的身份證號(唯一標識)是不同的,因此無論二人長得多麼的相像,均不能認為是同一個人;相反,對於其中某一人來說,哪怕是整容到面目全非,也依然是同一個人,因為其ID始終沒變。又比如,對於常見的值物件貨幣(Currency)而言,其價值通過其面值決定,因此一張剛從印鈔廠出來的嶄新百元大鈔和一張沾滿了細菌的百元紙幣是可以等值互換的,因為它們所攜帶的面值是相同的。

在編碼實踐上,最顯著的區別是值物件需要實現equals()hashCode()方法,而實體則不需要。在碼如雲中,我們通過Lombok為值物件自動生成equals()hashCode()方法,比如對於儲存身份證資訊的IdentityCard,其實現為:

//IdentityCard

@Value
@Builder
@AllArgsConstructor(access = PRIVATE)
public class IdentityCard {
    private String number;
    private String name;
}

原始碼出處:com/mryqr/core/member/domain/IdentityCard.java

其中的@Value註解隱式地為IdentityCard物件實現了equals()hashCode()方法。

可變性

實體和值物件的另一個區別是:實體物件是可變的(Mutable),而值物件是不可變的(Immutable)。對於實體物件而言,我們可以通過呼叫其上的方法直接更改其狀態;而對於值物件而言,如果需要改變其狀態,我們只能建立一個新的值物件,然後在新物件中包含改變後的狀態。

對實體物件的直接狀態變更比較好理解,這裡重點講一講對值物件的不可變性的編碼處理。對於值物件Address,如果我們需要修改其下的詳細地址,具體的實現如下:

//Address

//修改詳細地址
public Address changeTo(String detailAddress) {
    return Address.builder()
            .province(this.province)
            .city(this.city)
            .district(this.district)
            .address(detailAddress)
            .build();
}

原始碼出處:com/mryqr/core/common/domain/Address.java

這裡,我們並未直接修改Address物件的address屬性,而是新建了一個Address物件,然後將無需修改的欄位(比如provice)原封不動地拷貝到新物件中,而將需要修改的欄位(address)在新物件中設定為傳入的最新值,最後返回這個新建的物件。

不可變性要求值物件必須滿足以下約束:

  • 不能有共有的setter方法,否則外界可以直接修改其內部的狀態
  • 不能有導致內部狀態變化的共有方法

值物件的好處

本文一開始就提到我們應該將盡量多的物件建模為值物件,因為它比實體更加的簡單,事實上值物件有多種好處。

首先,因為值物件是不可變的,所以不可變物件所擁有的好處值物件都有,比如使得對程式的偵錯和推理更加的簡單,執行緒安全等。

其次,值物件作為一個概念上的整體(Conceptual Whole),它將與之相關的業務邏輯包含在其內部,不僅體現了內聚性,也增加了業務表達力,而這正是DDD所提倡的,比如對於本文中的Address,你是希望直接操作4個原始欄位(provincecitydistrictaddress)呢,還是操作一個Address物件呢?

另外,值物件由於也包含了業務邏輯,因此可以完成自我驗證,這樣無論何時我們拿到一個值物件時,都可以相信這是一個合法的物件,而不用在值物件之外再做驗證。

例如,在碼如雲中,定位資訊被存放在Geopoint值物件中:

@Value
@Builder
@AllArgsConstructor(access = PRIVATE)
public class Geopoint {
    private static final float EARTH_RADIUS_METERS = 6371000;
    private final Float longitude;//經度
    private final Float latitude;//緯度

    public float distanceFrom(Geopoint that) {
        return distanceBetween(this.longitude, this.latitude, that.longitude, that.latitude);
    }

    private float distanceBetween(float lng1, float lat1, float lng2, float lat2) {
        double dLat = Math.toRadians(lat2 - lat1);
        double dLng = Math.toRadians(lng2 - lng1);
        double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
                Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) *
                        Math.sin(dLng / 2) * Math.sin(dLng / 2);
        return (float) (EARTH_RADIUS_METERS * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)));
    }

    public boolean isPositioned() {
        return longitude != null && latitude != null;
    }
}

原始碼出處:com/mryqr/core/common/domain/Geopoint.java

可以看到,Geopoint將經度longitude和緯度latitude封裝在一起,成為一個概念上的整體。外部呼叫方無需單獨處理經度和緯度資料,而是直接通過這個整體性的Geopoint物件即可完成對定位資訊的操作。此外,distanceFrom()distanceBetween都是包含業務邏輯的方法,符合「行為飽滿的領域物件」原則。再則,通過isPositioned()方法使得Geopoint可以自行完成業務驗證。

角色可變

實體和值物件的劃分並不是固定不變的,而是根據其所處的限界上下文決定的。一個概念在一個上下文中是一個實體物件,但是在另外的上下文中則可能是一個值物件。比如,對於上文中的貨幣Currency,在日常的的交易活動中,貨幣很明顯應該被建模為一個值物件,因為在對其抽象之後我們忽略了貨幣的顏色,編號,新舊程度等屬性,而只關注其面值。但是,如果哪天央行要做一個系統來管理每一張貨幣(比如對每張貨幣進行位置跟蹤),那麼則需要根據貨幣的編號進行管理,此時的貨幣則變成了一個實體物件。

總結

實體和值物件是領域物件中的兩種不同型別的物件,它們在唯一標識、相等性和可變性等方面均存在不同。在DDD專案中,所有的聚合根均是實體,但是在實際建模過程中,由於值物件在不變性等方面的好處,我們應該儘量將業務概念建模為值物件。在下文應用服務與領域服務中,我們將對應用服務和領域服務做詳細講解。