DDD領域驅動設計實戰(三)- 理解實體 1 前言 2 爲什麼使用實體 3 唯一標識 4 各種狀態的實體 5 創建實體

1 前言

實體是領域模型中的領域對象。

傳統開發人員總將關注點放在數據,而不是領域。因爲在軟件開發中,DB佔據主導地位。首先考慮的是數據的屬性(即數據庫的列)和關聯關係(外鍵關聯),而不是富有行爲的領域概念。導致將數據模型直接反映在對象模型,那些表示領域模型的實體(Entity)被包含了大量getter/setter。雖然在實體模型中加入getter/setter並非大錯, 但這不是DDD的做法。

由於團隊成員起初過於強調實體的作用卻忽視了值對象。受到DB和持久化框架影響,實體被該團隊濫用,於是他們開始討論如何避免大範圍使用實體...

2 爲什麼使用實體

當我們需要考慮一個對象的個性特徵,或需要區分不同對象時,就引入實體這個領域概念。

一個實體是一個唯一的東西,可以在一段時間內持續變化。
這些對象重要的不是屬性,而是其延續性和標識,會跨越甚至超出軟件生命週期。

也正是 唯一身份標識和可變性(mutability) 特徵將實體對象區別於值對象。

實體建模並非總是完美方案。很多時候,一個領域概念應該建模成值對象,而非實體對象。這意味着DDD並不總能滿足業務需求,開發CRUD軟件系統時可能更適用。
若將CRUD應用在錯誤的系統——那些更復雜的,需採用DDD的系統一一就有我們後悔的了。由於只從數據出發,CRUD系統是不能創建出好的業務模型的。

在可以使用DDD時,我們會將數據模型轉變爲實體模型。

通過標識區分對象,而非屬性,此時應將標識作爲主要的模型定義。同時保持簡單類定義,關注對象在生命週期中的連續性和唯一標識性。不應該通過對象的狀態形式和歷史來區分不同的實體對象……對於什麼是相同的東西,模型應該給出定義。

那麼如何正確地使用和設計實體?

3 唯一標識

在實體設計早期,關注能體現實體身份唯一性的主要屬性和行爲及如何查詢實體,忽略次要的屬性和行爲。

設計實體時,首先考慮實體的本質特徵,特別是實體的唯一標識和對實體的查找,而不是一開始便關注實體的屬性和行爲。只有在對實體的本質特徵有用的情況下,才加入相應的屬性和行爲[Evans, p.93]。

找到多種能夠實現唯一標識性的方式,同時考慮如何在實體生命週期內維持唯一性。
實體的唯一標識不見得一定有助對實體的查找和匹配。將唯一標識用於實體匹配通常取決於標識的可讀性。
比如,若系統提供根據人名查找功能,但此時一個Person實體的唯一標識可能不是人名,因爲重名情況很多。若某系統提供根據公司稅號的查找功能,稅號便可作爲Company實體的唯一標識。

值對象可用於存放實體的唯一標識。值對象是不變(immutable)的,這就保證了實體身份的穩定性,並且與身份標識相關的行爲也可得到集中處理。便可避免將身份標識相關的行爲泄漏到模型的其他部分或客戶端中去。

3.1 創建實體身份標識的策略

通常來說,每種技術方案都存在副作用。比如將關係型DB用於對象持久化時,這樣的副作用將泄漏到領域模型。創建前需考慮標識生成的時間、關係型數據的引用標識和ORM在標識創建過程中的作用等,還會考慮如何保證唯一標識的穩定性。

3.2 標識穩定性

絕大多數場景不應修改實體的唯一標識,可在實體的整個生命週期中保持標識的穩定性。

可通過一些簡單措施確保實體標識不被修改。可將標識的setter方法向用戶隱藏。也可在setter方法種添加邏輯以確保標識在已經存在的情況下不會再被更新,比如可使用一些斷言:

  • username屬性是User實體的領域標識,該屬性只能進行一次修改,並且只能在User對象內修改。setter方法setUsername實現了自封裝性, 且對客戶端不可見。當實體的public方法自委派給該setter方法時,該方法將檢查username屬性,看是否已被賦值。若是,表明該User對象的領域標識已經存在,程序將拋異常。

    這個setter方法並不會阻礙Hibernate重建對象,因對象在創建時,它的屬性都是使用默認值,且採用無參構造器,因此username屬性的初始值爲null。然後,Hibernate將調用setter方法,由於username屬性此時爲null,該 setter方法得以正確地執行,username屬性也將被賦予正確的標識值。

4 各種狀態的實體

DDD的不同設計過程,實體的形態也不同。

4.1 業務形態

在戰略設計時,實體是領域模型的一個重要對象。領域模型中的實體是多個屬性、操作或行爲的載體。
事件風暴中,可以根據命令、操作或者事件,找出產生這些行爲的業務實體對象,進而按業務規則將依存度高和業務關聯緊密的多個實體對象和值對象進行聚類,形成聚合。
實體和值對象是組成領域模型的基礎單元。

4.2 代碼形態

實體的表現形式是實體類,該類包含了實體的屬性和方法,通過這些方法實現實體自身的業務邏輯。在DDD裏,這些實體類通常採用充血模型,與該實體相關的所有業務邏輯都在實體類的方法中實現,跨多個實體的領域邏輯則在領域服務中實現。

4.3 運行形態

實體以DO(領域對象)形式存在,每個實體對象都有唯一ID。可以對實體做多次修改,所以一個實體對象可能和它之前狀態存在較大差異。但它們擁有相同的身份標識(identity),所以始終是同一實體。

比如商品是商品上下文的一個實體,通過唯一的商品ID標識,不管這商品的數據(比如價格)如何變,商品ID不會變,始終是同一商品。

4.4 數據庫形態

DDD是先構建領域模型,針對實際業務場景構建實體對象和行爲,再將實體對象映射到數據持久化對象。

在領域模型映射到數據模型時,一個實體可能對應0個、1個或者多個數據庫持久化對象。大多數情況下實體與持久化對象是一對一。在某些場景中,有些實體只是暫駐靜態內存的一個運行態實體,它不需要持久化。比如,基於多個價格配置數據計算後生成的折扣實體。

有些複雜場景,實體與持久化對象可能是一對多或多對一:

  • 一對多:用戶user與角色role兩個持久化對象可生成權限實體,一個實體對應兩個持久化對象
  • 多對一:有時爲避免DB的聯表查詢,會將客戶信息customer和賬戶信息account兩類數據保存至同一張數據庫表,客戶和賬戶兩個實體可根據需要從一個持久化對象中生成

探索實體的本質
一開始團隊便遇到陷阱,在Java代碼中建模大量實體-關係。將太多關注點放在數據庫、表、列和對象映射上。導致所創建 的模型實際上只是含有大量getter/setter的貧血領域模型。他們應該在DDD 上有更多的思考。那時正值他們將安全處理機制從核心域中分離之際,他們學到了如何使用通用語言來更好地輔助建模。
但如果我們認爲對象就是一組命名的類和在類上定義的操作,除此之外並不包含其他內容,那就錯了。在領域模型中還可包含很多其他內容。團隊討論和規範文檔可以幫助我們創建更有意義的通用語言。到最後,團隊可以直接使用通用語言來進行對話,而此時的模型也能夠非常準確地反映通用語言。
如果一些特定的領域場景會在今後繼續使用,這時可以用一個輕量的文檔將它們記錄下來。簡單形式的通用語言可以是一組術語和一些簡單的用例場景。 但是,如果我們就此認爲通用語言只包含術語和用例場景,那麼我們又錯了。在最後,通用語言應該直接反映在代碼中,而要保持設計文檔的實時更新是非常困難的,甚至是不可能的。

5 創建實體

新建一個實體時,我們總是期望通過構造器就能初始化足夠多的實體狀態,因爲這有助於表明該實體的身份,也可幫助客戶端更容易查找該實體。

在使用及早生成唯一標識的策略時,構造器至少需接受一個唯一標識參數。若還有可能通過其他方式查找實體,比如名字或描述信息,那應該將這些參數也一併傳給構造器。

有時一個實體維護一或多個不變條件(Invariant,在整個實體生命週期中都必須保持事務一致性的一種狀態) 。

不變條件主要是聚合所關注的,但由於聚合根通常也是實體,故這裏我們也稍作提及。

如果實體的不變條件要求該實體所包含的對象都不能爲null狀態,或者由其他狀態計算所得,那麼這些狀態需要作爲參數傳遞給構造器。

public class User extends Entity {
    ...
    // 每一個User對象都必須包含tenantld、username, password和person屬性。
    // 即在User對象得到正確實例化後,這些屬性不能爲null
    // 由User對象的構造器和實例變量對應的setter方法保證這點
    protected User (Tenantld aTenantld, String aUsername,
        String aPassword, Person aPerson) (
        this();
        this.setPassword(aPassword);
        this.setPerson(aPerson);
        this.setTenantld(aTenantld);
        this.setUsername(aUsername);
        this.initialize();
    }
    ...
    protected void setPassword(String aPassword) { 
        if (aPassword == null) {
            throw new 11legalArgumentException(
                "The password may not be set to null.");
        )
        this.password = aPassword;
    )
    
    protected void setPerson(Person aPerson) (
        if (aPerson == null) ( 
            throw new IllegalArgumentException(
            "The person may not be set to null.");
        }
        this.person = aPerson;
    }
    
    protected void setTenantld(Tenantld aTenantld) ( 
        if (aTenantld == null)(
            throw new IllegalArgumentException(
                "The tenantld may not be set to null."); }
        this.tenantld = aTenantld;
    }
    protected void setUsername(String aUsername) (
        if (this.username != null) (
            throw new IIlegalStateException(
                "The username may not be changed.n);
        }
        if (aUsername == null) ( 
            throw new IllegalArgumentException(
                "The username may not be set to null."); 
        }
        this.username = aUsername;
    }   

User對象展示了一種自封裝性。在構造器對實例變量賦值時,它把操作委派給了實例變量所對應的setter方法,這樣便保證了實例變量的自封裝性。實例變量的自封裝性使用setter方法來決定何時給實例變量賦值。
每個setter方法都“代表着實體”對所傳進的參數做非null檢查,這裏的斷言稱爲守衛(Guard)。setter方法的自封裝性技術可能會變得非常複雜。對於那些非常複雜的創建實體的情況,我們可以使用工廠。
在上面的例子中,你是否注意到User對象的構造函數被聲明爲 protected? Tenant實體即爲User實體的工廠也是同一個模塊中唯一能夠訪問User 構造函數的類。這樣一來,只有Tenant能夠創建User實例。

public class Tenant extends Entity {
    // 該工廠簡化對User的創建,同時保證了Tenantld在User和Person對象中的正確性
    // 該工廠能夠反映通用語言。
    public User registerUser(
        String aUsername,
        String aPassword,
        Person aPerson) {
        aPerson.setTenantld(this.tenantld());
        
        User user = new User(this.tenantld(), aUsername, aPassword, aPerson);
        return user;
}

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章