天天看點

如何運用DDD(一):值對象

作為領域驅動設計戰術模式中最為核心的一個部分——值對象,一直是被大多數願意嘗試或者正在使用DDD的開發者提及最多的概念之一。但是在學習過程中,大家會因為受到傳統開發模式的影響,往往很難去運用值對象這一概念,以及在對值對象進行持久化時感到非常的迷惑。本篇文章會從值對象的概念出發,解釋什麼是值對象以及怎麼運用值對象,并且給出相應的代碼片段(本教程的代碼片段都使用的是C#,後期的實戰項目也是基于DotNet Core平台)。

何為值對象

如何運用DDD(一):值對象

首先讓我們來看一看原著 《領域驅動設計:軟體核心複雜性應對之道》 對值對象的解釋:很多對象沒有概念上的表示,他們描述了一個事務的某種特征。用于描述領域的某個方面而本身沒有概念表示的對象稱為Value Object(值對象)。此時作者是這樣的:

如何運用DDD(一):值對象

而我們是這樣的:

如何運用DDD(一):值對象

然後作者用“位址”這一概念給大家擴充了一下什麼是值對象,我們應該怎麼去發現值對象。是以你會發現現在很多的DDD文章中都是用這個例子給大家來解釋。當然讀懂了的人就會有一種醍醐灌頂的感覺,而像我這種菜雞,以後運用的時候感覺除了位址這個東西會給他抽象出來之外,其他的還是該咋亂寫咋寫。

For Example :

<code>public class DemoClass</code>

<code>{</code>

<code>   public Address  Address { get; set; }</code>

<code>   //…………</code>

<code>}</code>

OK,現在我們來仔細了解和分析一下值對象,雖然概念有一點抽象,但是至少有一關鍵點我們能夠很清晰的捕捉到,那就是值對象沒有辨別,也就是說這個叫做Value Object的東西他沒有ID。這一點也十分關鍵,他友善後面我們對值對象的深入了解。

既然值對象是沒有ID的一個事物(東西),那麼我們來考慮一下什麼情況下我們不需要通過ID來辨識一個東西:

“在超市購物的時候:我有五塊錢,你也有五塊錢” 這裡會關心我的錢和你的錢是同一張,同一個編碼,同一個組合方式(一張五塊,五張一塊)嗎?顯然不會。因為它們的價值是一樣的,就購買東西來說,是以它是不需要ID的。

“去上廁所的時候:同時有兩個空位,都是一樣的馬桶,都一樣的幹淨” 這裡你會關心你要上的馬桶是哪一個生産規格,哪一個編碼嗎?顯然不會,你隻關心它是否結構完好,能夠使用。當然有的人可能要說:“我上廁所的時候,我每次都認準要上第一排的第一号廁所。” 那麼,反思一下,當十分内急的時候,你還會考慮這個問題嗎?雖然這個例子舉的有點奇葩,但卻值得我們反思,在開發過程中我們所發現的一些事物(類),它是否真的需要一個身份ID。

通過上面的兩個例子,相信你一個沒有身份ID的事物(類)已經在你腦袋裡面留下了一點印象。那麼讓我們再來看一下原著中所提供給我們的一個案例:

當一個小孩畫畫的時候,他注意的是畫筆的顔色和筆尖的粗細。但如果有兩隻顔色和粗細相同的畫筆,他可能不會在意使用哪一支。如果有一支筆弄丢了,他可以從一套新筆中拿出一支同顔色的筆來繼續畫畫,根本不會在意已經換了一支筆。

值對象是基于上下文的

請注意,這是一個非常重要的前提。你會發現在上面的三個案例中,都有一個同樣的字首:“???的時候”。也就是說,我們考慮值對象的時候,是基于實際環境因素和語境條件(上下文)的。這個問題非常好了解:比如你是一個孩子的爸爸,當你在家裡面的時候,聽到了有孩子叫“爸爸”,哪怕你沒有看到你的孩子,你也知道這個爸爸指的是你自己;當你在地鐵上的時候,突然從旁邊車廂傳來了一聲“爸爸”,你不會認為這個是在叫你。是以,在實作領域驅動的時候,所有的元素都是基于上下文所考慮的,一切脫離了上下文的值對象是沒有作用的。

目前上下文的值對象可能是另一個上下文的實體

實體是戰術模式中同樣重要的一個概念,但是現在我們先不做讨論,我們隻需要明白實體是一個具有ID的事物就行了。也就是說一個同樣的東西在目前環境下可能沒有一個獨有的辨別,但可能在另一個環境下它就需要一個特殊的ID來識别它了。考慮上面的例子:

同樣的五塊錢,此時在一個貨币生産的環境下。它會考慮這同樣的一張五塊錢是否重号,顯然重号的貨币是不允許發行的。是以每一張貨币必須有一個唯一的辨別作為判斷。

同樣的馬桶,此時在一個物管環境中。它會考慮該馬桶的出廠編碼,如果馬桶出現故障,它會被返廠維修,并且通過唯一的ID進行跟蹤。

顯然,同樣的東西,在不同的語境中居然有着不同的意義。

怎麼運用值對象

如何運用DDD(一):值對象

此時,你應該可以根據你自己的所在環境和語境(上下文)捕獲出屬于你自己的值對象了,比如貨币呀,姓名呀,顔色呀等等。下面我們來考慮如何将它放在實際代碼中。

以第一個五塊錢的值對象例子來作為說明,此時我們在超市購物的上下文中,我們可能已經捕獲倒了一個叫做“錢”(Money)的值對象。按照以往我們的寫法,來看一看會有一個什麼樣的代碼:

<code>public class MySupmarketShopping</code>

<code>   public decimal Money { get; set; }</code>

<code>   public int MoneyCurrency { get; set;}</code>

盡量避免使用基元類型

仔細看上面的代碼,你會發現,這沒有問題呀,表明的很正确。我在超市購物中,我所具有的錢通過了一個屬性來表明。這也很符合我們以往寫類的風格。

當然,這個寫法也并不能說明它是錯的。隻是說沒有更好的表明我們目前環境所要表明的事物。

這個邏輯可能很抽象,特别是我們寫了這麼多年的代碼,已經養成了這樣的定性思維。那麼,來考慮下面的一個問卷:

運動調查表(1)

姓名

________

性别

________(字元串)

周運動量

________(整型)

常用運動器材

運動調查表(2)

________(男\女)

________(0~1000cal\1000-1000cal)

________(跑步機\啞鈴\其他)

現在應該比較清晰的能夠了解該要點了吧。從運動表1中,仿佛除了性别之外,我們都不知道後面的空需要表達什麼意思,而運動表2加上了該環境特有的名稱和選項,一下就能讓人讀懂。如果将運動表1轉換為我們熟悉的代碼,是否類似于上面的MySupmarketShopping類呢。所謂的基元類型,就是我們熟悉的(int,long,string,byte……)。而多年的編碼習慣,讓我們認為他們是表明事物屬性再正常不過的機關,但是就像兩個調查表所給出的答案一樣,這樣的代碼很迷惑,至少會給其他讀你代碼的人造成一些小障礙。

值對象是内聚并且可以具有行為

接下來是實作我們上文那個Money值對象的時候了。這是一個生活中很常見的一個場景,是以有可能我們建立出來的值對象是這樣的:

<code>class  Money</code>

<code>   public int Amount { get; set; }</code>

<code>   public Currency Currency { get; set; }</code>

<code>   public Money(int amount,Currency currency)</code>

<code>   {</code>

<code>       this.Amount = amount;</code>

<code>       this.Currency = currency;</code>

<code>   }</code>

Money對象中我們還引入了一個叫做币種(Currency)的對象,它同樣也是值對象,表明了金錢的種類。

接下來我們更改我們上面的MySupmarketShopping。

你會發現我們将原來MySupmarketShopping類中的币種屬性,通過轉換為一個新的值對象後給了Money對象。因為币種這個概念其實是屬于金錢的,它不應該被提取出來進而幹擾我的購物。

此時,Money值對象已經具備了它應有的屬性了,那麼就這樣就完成了嗎?

還是一個問題的思考,也許我在國外的超市購物,我需要将我的人民币轉換成為美元。這對我們編碼來說它是一個行為動作,是以可能是一個方法。那麼我們将這個轉換的方法放在哪兒呢?給MySupmarketShopping?很顯然,你一下就知道如果有Money這個值對象在的話,轉換這個行為就不應該給MySupmarketShopping,而是屬于Money。然後Money類就理所當然的被擴充為了這個樣子:

<code>   public Money ConvertToRmb(){</code>

<code>       int covertAmount = Amount / 6.18;</code>

<code>       return new Money(covertAmount,rmbCurrency);</code>

請注意:在這個行為完成後,我們是傳回了一個新的Money對象,而不是在目前對象上進行修改。這是因為我們的值對象擁有一個很重要的特性,不可變性。

值對象是不可變的:一旦建立好之後,值對象就永遠不能變更了。相反,任何變更其值的嘗試,其結果都應該是建立帶有期望值的整個新執行個體。

來看一個例子

如何運用DDD(一):值對象

其實我們在平時的編碼過程中,有些類型就是典型的值對象,隻是我們當時并沒有這個完整的概念體系去發現。

比如在.NET中,DateTime類就是一個經典的例子。有的程式設計語言,他的基元類型其實是沒有日期型這種說法的,比如Go語言中是通過引入time的包實作的。

嘗試一下,如果不用DateTime類你會怎麼去表示日期這一個概念,又如何實作日期之間的互相轉換(比如DateTime所提供的AddDays,AddHours等方法)。

這是一個現實項目中的一個案例,也許你能通過它加深值對象概念在你腦海中的印象。

該案例的需求是:将一個時間段内的一部分時間段扣除,并且傳回剩下的小時數。比如有一個時間段12:00 - 14:00,另一個時間段13:00 - 14:00。傳回小時數1。

代碼片段1:

<code>   string StartTime_ = Convert.ToDateTime(item["StartTime"]).ToString("HH:mm");</code>

<code>   string EndTime_ = Convert.ToDateTime(item["EndTime"]).ToString("HH:mm");</code>

<code>   string CurrentStart_ = Convert.ToString(item["CurrentStart"]);</code>

<code>   string CurrentEnd_ = Convert.ToString(item["CurrentEnd"]);</code>

<code>   //計算開始時間</code>

<code>   string[] s = StartTime_.Split(':');</code>

<code>   double sHour = double.Parse(s[0]);</code>

<code>   double sMin = double.Parse(s[1]);</code>

<code>   //計算結束時間</code>

<code>   string[] e = EndTime_.Split(':');</code>

<code>   double eHour = double.Parse(e[0]);</code>

<code>   double eMin = double.Parse(e[1]);</code>

<code>   DateTime startDate_ = hDay.AddHours(sHour).AddMinutes(sMin);</code>

<code>   DateTime endDate_ = hDay.AddHours(eHour).AddMinutes(eMin);</code>

<code>   TimeSpan ts = new TimeSpan();</code>

<code>   if (StartDate &lt;= startDate_ &amp;&amp; EndDate &gt;= endDate_)</code>

<code>       ts = endDate_ - startDate_;</code>

<code>   else if (StartDate &lt;= startDate_ &amp;&amp; EndDate &gt;= startDate_ &amp;&amp; EndDate &lt; endDate_)</code>

<code>       ts = EndDate - startDate_;</code>

<code>   else if (StartDate &gt; startDate_ &amp;&amp; StartDate &lt;= endDate_ &amp;&amp; EndDate &gt;= endDate_)</code>

<code>       ts = endDate_ - StartDate;</code>

<code>   else if (StartDate &gt; startDate_ &amp;&amp; StartDate &lt; endDate_ &amp;&amp; EndDate &gt; startDate_ &amp;&amp; EndDate &lt; endDate_)</code>

<code>       ts = EndDate - StartDate;</code>

<code>   if (OverTimeUnit == "minute")</code>

<code>       Duration_ = Duration_ &gt; ts.TotalMinutes ? Duration_ - ts.TotalMinutes : 0;</code>

<code>   else if (OverTimeUnit == "hour")</code>

代碼片段2:

首先來看一看代碼片段1,使用了傳統的方式來實作該功能。但是裡面使用大量的基元類型來描述問題,可讀性和代碼量都很複雜。

接下來是代碼片段2,在實作該過程時,我們先嘗試尋找該問題模型中的共性,是以提取出了一個叫做時間段(DateTimeRange)類的值對象出來,而賦予了該值對象應有的行為和屬性。

<code>//展示了DateTimeRange代碼的部分内容</code>

<code>public class DateTimeRange</code>

<code>   private DateTime _startTime;</code>

<code>   public DateTime StartTime</code>

<code>       get { return _startTime; }</code>

<code>   private DateTime _endTime;</code>

<code>   public DateTime EndTime</code>

<code>       get { return _endTime; }</code>

<code>   public DateTimeRange GetAlphalRange(DateTimeRange timeRange)</code>

<code>       DateTimeRange reslut = null;</code>

<code>       DateTime bStartTime = _startTime;</code>

<code>       DateTime oEndTime = _endTime;</code>

<code>       DateTime sStartTime = timeRange.StartTime;</code>

<code>       DateTime eEndTime = timeRange.EndTime;</code>

<code>       if (bStartTime &lt; eEndTime &amp;&amp; oEndTime &gt; sStartTime)</code>

<code>       {</code>

<code>           // 一定有重疊部分</code>

<code>           DateTime sTime = sStartTime &gt;= bStartTime ? sStartTime : bStartTime;</code>

<code>           DateTime eTime = oEndTime &gt;= eEndTime ? eEndTime : oEndTime;</code>

<code>           reslut = new DateTimeRange(sTime, eTime);</code>

<code>       }</code>

<code>       return reslut;</code>

通過尋找出的該值對象,并且豐富值對象的行為。為我們編碼帶來了大量的好處。

值對象的持久化

如何運用DDD(一):值對象

有關值對象持久化的問題一直是一個非常棘手的問題。這裡我們提供了目前最為常見的兩種實作思路和方法供參考。而該方法都是針對傳統的關系型資料庫的。(因為NoSQL的特性,是以無需考慮這些問題)

将值對象映射在表的字段中

該方法也是微軟的官方案例Eshop中提供的方案,通過eShop提供的固有實體類型形式來将值對象存儲在依賴的實體表字段中。具體的細節可以參考:《eShop實作值對象[2]》。通過該方法,我們最後持久化出來的結果比較類似于這樣:

如何運用DDD(一):值對象

将值對象單獨用作表來存儲

該方式在持久化時将值對象單獨存為一張表,并且以依賴對象的ID主為自己的主鍵。在擷取時用Join的方式來與依賴的對象形成關聯。

可能持久化出來的結果就像這樣:

如何運用DDD(一):值對象

可能沒有完美的持久化方式

正如這個小标題一樣,目前可能并沒有完美的一個持久化方式來供關系型資料庫持久化值對象。方式一的方式可能會造成資料大量的備援,畢竟對值對象來說,隻要值是一樣的我們就認為他們是相等的。假如有一個位址值對象的值是“四川”,那麼有100w個使用者都是四川的話,那麼我們會将該内容儲存100w次。

而對于一些文本資訊較大的值對象來說,這可能會損耗過多的記憶體和性能。并且通過EFCore的映射擷取值對象也有一個問題,你很難擷取倒組合關系的值對象,比如值對象A中有值對象B,值對象B中有值對象C。這對于模組化值對象來說可能是一個很正常的事情,但是在進行映射的時候确非常困難。

對于方式二來說,模組化中存在了大量的值對象,我們在持久化時不得不對他們都一一建立一個資料表來儲存,這樣造成資料庫表的無限增多,并且對于習慣了資料庫驅動開發的人員來說,這可能是一個噩夢,當嘗試通過資料庫來還原業務關系時這是一項非常艱難的任務。

總之,還是那句話,目前依舊沒有一個完美的解決方案,你隻能通過自己的自身條件和從業經驗來進行對以上問題的規避,進而達到一個折中的效果。

相關連結:

https://book.douban.com/subject/5344973/

https://docs.microsoft.com/zh-cn/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/implement-value-objects

原文連結:https://www.cnblogs.com/uoyo/p/11951840.html

DDD

繼續閱讀