本文將介紹領域驅動設計(DDD)戰術模式中另一個常見且非常重要的概念 - 實體。相對戰術模式中其他的一些概念(例如 值對象、領域服務等)來說,實體應該比較容易讓人理解和運用。但是我們如何去發現所在領域中的實體呢?如何保證建立的實體是富含行為的?實體運用時又有那些註意的細節呢?本文將從不同的角度來帶大... ...
目錄
如何運用DDD - 實體
概述
本文將介紹領域驅動設計(DDD)戰術模式中另一個常見且非常重要的概念 - 實體。相對戰術模式中其他的一些概念(例如 值對象、領域服務等)來說,實體應該比較容易讓人理解和運用。但是我們如何去發現所在領域中的實體呢?如何保證建立的實體是富含行為的?實體運用時又有那些註意的細節呢?本文將從不同的角度來帶大家重新認識一下“實體”這個概念,並且給出相應的代碼片段(本教程的代碼片段都使用的是C#,後期的實戰項目也是基於 DotNet Core 平臺)。
何為實體
按照國際慣例呢,我們先吹牛。直接來看看原著《領域驅動設計:軟體核心複雜性應對之道》 中對實體的解釋:
- 實體(Entity,又稱為Reference Object) 很多對象不是通過他們的屬性定義的,而是通過一連串的連續事件和標識定義的。
- 主要由標識定義的對象被稱為ENTITY。
上面的兩句話多讀了幾遍,好像這個定義還是能夠理解嘛。不像上一篇文章 如何運用DDD - 值對象 中的概念那麼深奧。說白了,上面就是說明瞭一個問題,只要你所發現的事物/對象有一個唯一的標識,那麼它可能就是實體了。而唯一的標識就是我們代碼中快寫爛了的那個ID。
似曾相識
來想一下,我們在以傳統的設計思路和開發過程中,我們會在什麼情況下為一個對象賦予一個ID呢?給它賦予這個ID的作用呢?一般來說我們的目的無非就是 1、為了區分本對象,如果是在資料庫中,那就是為了區分本條數據和另外一條數據,而這個ID也往往作為主鍵而存在 2、加個索引吧,來提升關聯查找速度。所以我們如果將資料庫中的表映射到我們的代碼中以類的形式呈現的時候,它可能就是這個樣子:
//旅行的行程
public class Itinerary
{
public int ID { get; set; }
//參加本次旅行的人員
public List<Person> Participants { get; set; }
//旅行的地點
public List<string> Places { get; set; }
//關於該行程的備註筆記信息
public string Note { get; set; }
//旅行開始時間
public DateTime StartTime { get; set; }
//旅行開始時間
public DateTime? EndTime { get; set; }
//旅行的狀態(進行中 or 已完成)
public int Status { get; set; }
}
上面的代碼對我們來說應該絲毫都不陌生,我們建立了一個旅行行程的類,至於為什麼我們會選取旅行行程,而不是各個博客都出現的以訂單啊電商平臺作為案例。那是因為在後期我們會一起動手來實現一個旅行記賬的微信小程式,並且藉助於我們慢慢所學習到的DDD理論作為基礎,開發屬於我們自己的領域驅動框架,當然項目也是基於 DotNet Core(版本應該是3.x)。
好了,還是回到我們這個例子,來思考一下ID出現的目的。你可能會說:“這還不簡單嗎?老夫縱橫代碼界多年,你現在還來問我這個問題!ID肯定是用來區分的呀,行程千千萬萬,我要找出這一條行程肯定需要這個ID了呀。” 是的,這是一個毫無爭議的問題。我們需要一個唯一的身份標識來區別對象之間的差異。DDD中實體的這一點與我們平時所接觸的類的ID有異曲同工之妙,所以本文開頭也說了實體可能是相對其他戰術概念最為讓人理解的。
你確定它真的需要ID嗎
還記得我們在上一篇文章 如何運用DDD - 值對象 中所提到過的一個問題嗎? “當前上下文的值對象可能是另一個上下文的實體”。所以說,當前你所判定的實體一定是基於領域當前環境(上下文)的。脫離了該環境之後,一切都將存在變數。同樣的事物(對象),在當前環境需要一個唯一標識來識別它,而在另一個環境中可能這個唯一標識對它來說是沒有意義的,則實體就有可能成為了值對象。請考慮下麵的這個例子:
在一個銀行業應用程式中,一位顧客可能會在她的銀行賬戶中放入100美元。當她未來某一天提取她這100美元時,相較於她存進銀行的錢,她可能會收到不同的鈔票或硬幣。不過,這一差異是無關緊要的,因為資金的身份不重要;顧客只關心資金的價值。所以在這個領域中,資金無疑是一個值對象。但在另一個領域中,比如涉及鈔票印刷製作或鈔票可追溯性的行業,個體鈔票或硬幣的身份實際上可能就是一個重要的領域概念了。所以每一張鈔票都會是一個具有唯一標識符的實體
運用實體
結合值對象
千萬不要忘記了我們上一章所學習到了的值對象:在實體的內部,除了它自己的唯一標識ID之外,也許還有許許多多表明它屬性的東西,而這些東西往往可以通過使用值對象來標識。
接下來讓我們來改寫一下上面的Itinerary類:
public class Itinerary
{
public int ID { get; set; }
public List<Person> Participants { get; set; }
public List<Address> Places { get; set; }
public ItineraryNote Note { get; set; }
public ItineraryTime TripTime { get; set; }
public ItineraryStatus Status { get; set; }
}
public class ItineraryNote
{
public string Content { get; set; }
public DateTime NoteTime { get; set; }
public ItineraryNote(string content)
{
Content = content;
NoteTime = DateTime.Now;
}
}
為實體賦予它的行為
當對象建立好了之後,為了實現我們的業務邏輯處理,我們需要對實例化的對象進行操作。現在我們為該系統提出第一個需求:用戶可以修改行程中的備註信息。
回到我們的第一版代碼中,如果我們需要處理這個操作,我們會怎麼做呢?
itineraryInstance.Note = "this is my new note info";
是不是會像上面這樣,將需要添加的值賦予實例化的對象呢。 這種操作,對我們現在正在進行的編程習慣來說,是再正常不過了。
那麼我們來思考,如果我們的項目有多處需要對“備註信息”處理呢。則對該屬性的變更將被散落在代碼各處。而當我們對該需求進行了一個增強驗證時,比如此時我們需要增加:用戶修改行程中的備註信息時,只允許用戶錄入200個字以內的文本。 OMG,此時我們需要去查找所有散落的片段,並且為他加上驗證。
從另外個角度來看,第一個版本我們所建立的類,我們無法通過僅僅查看它本身就能讀懂有關旅行行程有關的業務,我們僅僅知道它具有起始時間,備註信息等,而對他們應該如何相互作用無從所知。
所以這種僅僅具有類的屬性,或者說以POCO呈現的類型,我們稱之為“貧血模型”。
接下來,我們回到第二版代碼中,我們為它賦予屬於它的行為。從需求中我們得知了,行程的備註信息是可以修改的,而備註信息是屬於行程的,因此修改備註信息改行為理應屬於行程本身。我們稍微改動代碼:
public class Itinerary
{
public int ID { get; set; }
public List<Person> Participants { get; set; }
public List<Address> Places { get; set; }
public ItineraryNote Note { get; set; }
public ItineraryTime TripTime { get; set; }
public ItineraryStatus Status { get; set; }
//ctor
public void ChangeNote(string content)
{
if(content.Length > 200 )
throw new NoteIsOverlengthException();
Note = new ItineraryNote(content);
}
}
此時我們為Itinerary賦予了一個ChangeNote的行為,當外界需要更改備註時,則只需通過調用改方法既可以實現,而且當展開其他開發人員閱讀此類時,也會清楚的明白,業務上允許用戶更改200字以內的備註。
但是,我們依然有一個地方美中不足,我想你可能也發現了:屬性還是對外暴露的! 對,也就是說,我們除了通過類公開的行為修改類自身的屬性外,我們還可以在外界隨意更改。這顯然不符合我們設計的初衷。因此我們可以將所有屬性的set私有化。所以,一定要註意,我們在考慮實體的時候,一定要知道“實體是高度內聚和自治的”(敲重點!!!!!)。
當然,有的開發者還會嘗試另外的寫法,讓實體完全自治,將上面的代碼中的屬性,全部轉變為私有的欄位,外界只能通過公開的行為來對實體進行處理。
public class Itinerary
{
public int ID { get; set; }
private List<Person> participants;
private List<Address> places;
private ItineraryNote note;
private ItineraryTime tripTime;
private ItineraryStatus status;
//ctor
public void ChangeNote(string content)
{
if(content.Length > 200 )
throw new NoteIsOverlengthException();
note = new ItineraryNote(content);
}
}
但是當外界需要獲取該實體的值,或者需要ORM映射的時候可能就不是很友好了,不過你可以使用類似於像 備忘錄模式 的快照方法來處理。後期我們也會採用這種模式來實現部分案例。
通過將實體賦予它應用的行為所建立出來的實體我們稱為“充血模型”。那麼貧血模型好還是充血模型好呢? 很多同學肯定會說,這還用問嗎,肯定是充血模型啦。 其實這個答案並沒有一個真正的答案,實體自身的行為是通過我們對領域的慢慢分析(可能是通過與領域專家溝通)得來的,如果因為為了使用充血模型而盲目的將一些不屬於實體的行為賦予給它,只會讓實體變的更加混亂,從而得不償失。所以,此時的貧血模型並不意味著一直是貧血模型,後期隨著領域的深入它可能會不斷豐富屬於自身的行為。
嘗試轉移一部分行為給值對象
保持實體專註於身份這一職責很重要,因為這樣會避免它們變得臃腫————這是它們將許多相關行為拉到一起時容易掉入的陷阱。實現這一專註需要將相關行為委托給值對象和領域服務(領域服務也將在後期的文章中進行介紹)。
來考慮一下最近一版的代碼,我們已經將行為劃分給了Itinerary了,但是仔細看一看,我們在後期增加需求時增加了一條驗證的規則,那麼這個規則我們可以轉移給值對象嗎? 答案是,可以的。而且轉移是有必要的,因為對備註的效驗這一行為往往應該屬於它自身。就好比機器啟動時的自我效驗,這一行為是屬於操作者還是機器自己呢?
所以我們來將部分行為轉移給值對象,優化後的代碼可能是這樣的:
public class Itinerary
{
public int ID { get; set; }
public List<Person> Participants { get; set; }
public List<Address> Places { get; set; }
public ItineraryNote Note { get; set; }
public ItineraryTime TripTime { get; set; }
public ItineraryStatus Status { get; set; }
//ctor
public void ChangeNote(string content)
{
Note = new ItineraryNote(content);
}
}
public class ItineraryNote
{
public string Content { get; set; }
public DateTime NoteTime { get; set; }
public ItineraryNote(string content)
{
if(content.Length > 200 )
throw new NoteIsOverlengthException();
Content = content;
NoteTime = DateTime.Now;
}
}
願景是美好的 現實是殘酷的
到這裡,我們仿佛真的一帆風順:建立了屬於自己的實體,並且融合了該有的值對象,實體的行為也被高度內聚在了其中。那是不是我們直接就可以將DDD落地了呢? 不好意思,就如同這個小標題一樣,現實真的是非常殘酷的。如果單單從代碼閱讀和業務處理上來說,我們可能確實已經成功了,但是!!!我們需要保存我們的數據,也就是持久化。因為實體中包含了大量的值對象,所有值對象持久化所面臨的問題,它都會遇到,甚至是讓難度翻倍!有關值對象持久化的難點可以參考上一篇文章 如何運用DDD - 值對象 。
回看我們最後一版代碼,我們有兩個集合的屬性(Participants、Places)。單一的值對象的持久化已經讓我們頭痛了,現在我們不得不面對持久化值對象集合的問題。假如你通過使用EF Core這類的ORM框架來進行持久化操作,你會發現我們不得不為List中的值對象加上一個ID,此時擁有了唯一標示的值對象顯然已經成為了實體,這是非常可怕的一件事。我們辛辛苦苦建立的領域模型在最後一步落地時居然成為改變了,這往往也是DDD落地困難的一個重要原因,被ORM框架或者關係型資料庫所限制,導致領域模型不斷被打亂,重構領域模型變得越來越四不像,最終又寫回了傳統的三層架構或者面向資料庫建模。
但是至少在現在,請相信自己的所見,認真考慮和發現你項目領域所擁有的值對象和實體,不要因為知道持久化的問題而放棄和妥協,這也是我們開發者應有的勇氣。在後面的文章中,我們會關於值對象和實體的一些問題提出解決辦法,當然包括持久化的問題。
總結
本文我們介紹了實體的概念以及怎麼去運用實體到實際代碼中,請牢記前人為我們提供的有關實體的經驗:比如“實體一定是基於領域當前環境(上下文)的”、“實體是高度內聚和自治的”、“應該專註於實體的行為而非數據”等等。後面的文章會為大家帶來實體和值對象的一些註意事項以及領域服務的內容。