目錄
- 概述
- 何為值物件
- 值物件是基于背景關系的
- 當前背景關系的值物件可能是另一個背景關系的物體
- 怎么運用值物件
- 盡量避免使用基元型別
- 值物件是內聚并且可以具有行為
- 來看一個例子
- 值物件的持久化
- 總結
概述
作為領域驅動設計戰術模式中最為核心的一個部分-值物件,一直是被大多數愿意嘗試或者正在使用DDD的開發者提及最多的概念之一,但是在學習程序中,大家會因為受到傳統開發模式的影響,往往很難去運用值物件這一概念,以及在對值物件進行持久化時感到非常的迷惑,本篇文章會從值物件的概念出發,解釋什么是值物件以及怎么運用值物件,并且給出相應的代碼片段(本教程的代碼片段都使用的是C#,后期的實戰專案也是基于 DotNet Core 平臺),
何為值物件
首先讓我們來看一看原著 《領域驅動設計:軟體核心復雜性應對之道》 對值物件的解釋:
很多物件沒有概念上的表示,他們描述了一個事務的某種特征,
用于描述領域的某個方面而本身沒有概念表示的物件稱為Value Object(值物件),
此時作者是這樣的:
而我們是這樣的:
然后作者用“地址”這一概念給大家擴充了一下什么是值物件,我們應該怎么去發現值物件,所以你會發現現在很多的DDD文章中都是用這個例子給大家來解釋,當然讀懂了的人就會有一種醍醐灌頂的感覺,而像我這種菜雞,以后運用的時候感覺除了地址這個東西會給他抽象出來之外,其他的還是該咋亂寫咋寫,
For Example :
public class DemoClass
{
public Address Address { get; set; }
//…………
}
OK,現在我們來仔細理解和分析一下值物件,雖然概念有一點抽象,但是至少有一關鍵點我們能夠很清晰的捕捉到,那就是值物件沒有標識,也就是說這個叫做Value Object的東西他沒有ID,這一點也十分關鍵,他方便后面我們對值物件的深入理解,
既然值物件是沒有ID的一個事物(東西),那么我們來考慮一下什么情況下我們不需要通過ID來辨識一個東西:
-
“在超市購物的時候:我有五塊錢,你也有五塊錢” 這里會關心我的錢和你的錢是同一張,同一個編碼,同一個組合方式(一張五塊,五張一塊)嗎? 顯然不會,因為它們的價值是一樣的,就購買東西來說,所以它是不需要ID的,
-
“去上廁所的時候:同時有兩個空位,都是一樣的馬桶,都一樣的干凈” 這里你會關心你要上的馬桶是哪一個生產規格,哪一個編碼嗎?顯然不會,你只關心它是否結構完好,能夠使用, 當然有的人可能要說:“我上廁所的時候,我每次都認準要上第一排的第一號廁所,” 那么,反思一下,當十分內急的時候,你還會考慮這個問題嗎? 雖然這個例子舉的有點奇葩,但卻值得我們反思,在開發程序中我們所發現的一些事物(類),它是否真的需要一個身份ID,
通過上面的兩個例子,相信你一個沒有身份ID的事物(類)已經在你腦袋里面留下了一點印象,那么讓我們再來看一下原著中所提供給我們的一個案例:
- 當一個小孩畫畫的時候,他注意的是畫筆的顏色和筆尖的粗細,但如果有兩只顏色和粗細相同的畫筆,他可能不會在意使用哪一支,如果有一支筆弄丟了,他可以從一套新筆中拿出一支同顏色的筆來繼續畫畫,根本不會在意已經換了一支筆,
值物件是基于背景關系的
請注意,這是一個非常重要的前提,你會發現在上面的三個案例中,都有一個同樣的前綴:“???的時候”,也就是說,我們考慮值物件的時候,是基于實際環境因素和語境條件(背景關系)的,這個問題非常好理解:比如你是一個孩子的爸爸,當你在家里面的時候,聽到了有孩子叫“爸爸”,哪怕你沒有看到你的孩子,你也知道這個爸爸指的是你自己;當你在地鐵上的時候,突然從旁邊車廂傳來了一聲“爸爸”,你不會認為這個是在叫你,所以,在實作領域驅動的時候,所有的元素都是基于背景關系所考慮的,一切脫離了背景關系的值物件是沒有作用的,
當前背景關系的值物件可能是另一個背景關系的物體
物體是戰術模式中同樣重要的一個概念,但是現在我們先不做討論,我們只需要明白物體是一個具有ID的事物就行了,也就是說一個同樣的東西在當前環境下可能沒有一個獨有的標識,但可能在另一個環境下它就需要一個特殊的ID來識別它了,考慮上面的例子:
-
同樣的五塊錢,此時在一個貨幣生產的環境下,它會考慮這同樣的一張五塊錢是否重號,顯然重號的貨幣是不允許發行的,所以每一張貨幣必須有一個唯一的標識作為判斷,
-
同樣的馬桶,此時在一個物管環境中,它會考慮該馬桶的出廠編碼,如果馬桶出現故障,它會被返廠維修,并且通過唯一的id進行跟蹤,
顯然,同樣的東西,在不同的語境中居然有著不同的意義,
怎么運用值物件
此時,你應該可以根據你自己的所在環境和語境(背景關系)捕獲出屬于你自己的值物件了,比如貨幣呀,姓名呀,顏色呀等等,下面我們來考慮如何將它放在實際代碼中,
以第一個五塊錢的值物件例子來作為說明,此時我們在超市購物的背景關系中,我們可能已經捕獲倒了一個叫做“錢”(Money)的值物件,按照以往我們的寫法,來看一看會有一個什么樣的代碼:
public class MySupmarketShopping
{
public decimal Money { get; set; }
public int MoneyCurrency { get; set;}
}
盡量避免使用基元型別
仔細看上面的代碼,你會發現,這沒有問題呀,表明的很正確,我在超市購物中,我所具有的錢通過了一個屬性來表明,這也很符合我們以往寫類的風格,
當然,這個寫法也并不能說明它是錯的,只是說沒有更好的表明我們當前環境所要表明的事物,
這個邏輯可能很抽象,特別是我們寫了這么多年的代碼,已經養成了這樣的定性思維,那么,來考慮下面的一個問卷:
| 運動調查表(1) | |
|---|---|
| 姓名 | ________ |
| 性別 | ________ (字串) |
| 周運動量 | ________(整型) |
| 常用運動器材 | ________(整型) |
| 運動調查表(2) | |
|---|---|
| 姓名 | ________ |
| 性別 | ________ (男\女) |
| 周運動量 | ________(0~1000cal\1000-1000cal) |
| 常用運動器材 | ________(跑步機\啞鈴\其他) |
現在應該比較清晰的能夠理解該要點了吧,從運動表1中,仿佛出了性別之外,我們都不知道后面的空需要表達什么意思,而運動表2加上了該環境特有的名稱和選項,一下就能讓人讀懂,如果將運動表1轉換為我們熟悉的代碼,是否類似于上面的MySupmarketShopping類呢,所謂的基元型別,就是我們熟悉的(int,long,string,byte…………),而多年的編碼習慣,讓我們認為他們是表明事物屬性再正常不過的單位,但是就像兩個調查表所給出的答案一樣,這樣的代碼很迷惑,至少會給其他讀你代碼的人造成一些小障礙,
值物件是內聚并且可以具有行為
接下來是實作我們上文那個Money值物件的時候了,這是一個生活中很常見的一個場景,所以有可能我們建立出來的值物件是這樣的:
class Money
{
public int Amount { get; set; }
public Currency Currency { get; set; }
public Money(int amount,Currency currency)
{
this.Amount = amount;
this.Currency = currency;
}
}
Money物件中我們還引入了一個叫做幣種(Currency)的物件,它同樣也是值物件,表明了金錢的種類,
接下來我們更改我們上面的MySupmarketShopping,
public class MySupmarketShopping
{
public Money Amountofmoney { get; set; }
}
你會發現我們將原來MySupmarketShopping類中的幣種屬性,通過轉換為一個新的值物件后給了money物件,因為幣種這個概念其實是屬于金錢的,它不應該被提取出來從而干擾我的購物,
此時,Money值物件已經具備了它應有的屬性了,那么就這樣就完成了嗎?
還是一個問題的思考,也許我在國外的超市購物,我需要將我的人民幣轉換成為美元,這對我們編碼來說它是一個行為動作,因此可能是一個方法,那么我們將這個轉換的方法放在哪兒呢? 給MySupmarketShopping? 很顯然,你一下就知道如果有Money這個值物件在的話,轉換這個行為就不應該給MySupmarketShopping,而是屬于Money,然后Money類就理所當然的被擴充為了這個樣子:
class Money
{
public int Amount { get; set; }
public Currency Currency { get; set; }
public Money(int amount,Currency currency)
{
this.Amount = amount;
this.Currency = currency;
}
public Money ConvertToRmb(){
int covertAmount = Amount / 6.18;
return new Money(covertAmount,rmbCurrency);
}
}
請注意:在這個行為完成后,我們是回傳了一個新的Money物件,而不是在當前物件上進行修改,這是因為我們的值物件擁有一個很重要的特性,不可變性,
值物件是不可變的:一旦創建好之后,值物件就永遠不能變更了,相反,任何變更其值的嘗試,其結果都應該是創建帶有期望值的整個新實體,
來看一個例子
其實我們在平時的編碼程序中,有些型別就是典型的值物件,只是我們當時并沒有這個完整的概念體系去發現,
比如在.NET中,DateTime類就是一個經典的例子,有的編程語言,他的基元型別其實是沒有日期型這種說法的,比如Go語言中是通過引入time的包實作的,
嘗試一下,如果不用DateTime類你會怎么去表示日期這一個概念,又如何實作日期之間的相互轉換(比如DateTime所提供的AddDays,AddHours等方法),
這是一個現實專案中的一個案例,也許你能通過它加深值物件概念在你腦海中的印象,
該案例的需求是:將一個時間段內的一部分時間段扣除,并且回傳剩下的小時數,比如有一個時間段 12:00 - 14:00.另一個時間段 13:00 - 14:00, 回傳小時數1,
//代碼片段 1
string StartTime_ = Convert.ToDateTime(item["StartTime"]).ToString("HH:mm");
string EndTime_ = Convert.ToDateTime(item["EndTime"]).ToString("HH:mm");
string CurrentStart_ = Convert.ToString(item["CurrentStart"]);
string CurrentEnd_ = Convert.ToString(item["CurrentEnd"]);
//計算開始時間
string[] s = StartTime_.Split(':');
double sHour = double.Parse(s[0]);
double sMin = double.Parse(s[1]);
//計算結束時間
string[] e = EndTime_.Split(':');
double eHour = double.Parse(e[0]);
double eMin = double.Parse(e[1]);
DateTime startDate_ = hDay.AddHours(sHour).AddMinutes(sMin);
DateTime endDate_ = hDay.AddHours(eHour).AddMinutes(eMin);
TimeSpan ts = new TimeSpan();
if (StartDate <= startDate_ && EndDate >= endDate_)
{
ts = endDate_ - startDate_;
}
else if (StartDate <= startDate_ && EndDate >= startDate_ && EndDate < endDate_)
{
ts = EndDate - startDate_;
}
else if (StartDate > startDate_ && StartDate <= endDate_ && EndDate >= endDate_)
{
ts = endDate_ - StartDate;
}
else if (StartDate > startDate_ && StartDate < endDate_ && EndDate > startDate_ && EndDate < endDate_)
{
ts = EndDate - StartDate;
}
if (OverTimeUnit == "minute")
{
Duration_ = Duration_ > ts.TotalMinutes ? Duration_ - ts.TotalMinutes : 0;
}
else if (OverTimeUnit == "hour")
{
Duration_ = Duration_ > ts.TotalMinutes ? Duration_ - ts.TotalMinutes : 0;
}
//代碼片段 2
DateTimeRange oneRange = new DateTimeRange(oneTime,towTime);
DateTimeRange otherRange = new DateTimeRange(oneTime,towTime);
var resultHours = oneRange.GetRangeHours() - oneRange.GetAlphalRange(otherRange);
首先來看一看代碼片段1,使用了傳統的方式來實作該功能,但是里面使用大量的基元型別來描述問題,可讀性和代碼量都很復雜,
接下來是代碼片段2,在實作該程序時,我們先嘗試尋找該問題模型中的共性,因此提取出了一個叫做時間段(DateTimeRange)類的值物件出來,而賦予了該值物件應有的行為和屬性,
//展示了DateTimeRange代碼的部分內容
public class DateTimeRange
{
private DateTime _startTime;
public DateTime StartTime
{
get { return _startTime; }
}
private DateTime _endTime;
public DateTime EndTime
{
get { return _endTime; }
}
public DateTimeRange GetAlphalRange(DateTimeRange timeRange)
{
DateTimeRange reslut = null;
DateTime bStartTime = _startTime;
DateTime oEndTime = _endTime;
DateTime sStartTime = timeRange.StartTime;
DateTime eEndTime = timeRange.EndTime;
if (bStartTime < eEndTime && oEndTime > sStartTime)
{
// 一定有重疊部分
DateTime sTime = sStartTime >= bStartTime ? sStartTime : bStartTime;
DateTime eTime = oEndTime >= eEndTime ? eEndTime : oEndTime;
reslut = new DateTimeRange(sTime, eTime);
}
return reslut;
}
}
通過尋找出的該值物件,并且豐富值物件的行為,為我們編碼帶來了大量的好處,
值物件的持久化
有關值物件持久化的問題一直是一個非常棘手的問題,這里我們提供了目前最為常見的兩種實作思路和方法供參考,而該方法都是針對傳統的關系型資料庫的,(因為Nosql的特性,所以無需考慮這些問題)
將值物件映射在表的欄位中
該方法也是微軟的官方案例Eshop中提供的方案,通過EFCore提供的固有物體型別形式來將值物件存盤在依賴的物體表欄位中,具體的細節可以參考 EShop實作值物件,通過該方法,我們最后持久化出來的結果比較類似于這樣:

將值物件單獨用作表來存盤
該方式在持久化時將值物件單獨存為一張表,并且以依賴物件的ID主為自己的主鍵,在獲取時用Join的方式來與依賴的物件形成關聯,
可能持久化出來的結果就像這樣:

可能沒有完美的持久化方式
正如這個小標題一樣,目前可能并沒有完美的一個持久化方式來供關系型資料庫持久化值物件,方式一的方式可能會造成資料大量的冗余,畢竟對值物件來說,只要值是一樣的我們就認為他們是相等的,假如有一個地址值物件的值是“四川”,那么有100w個用戶都是四川的話,那么我們會將該內容保存100w次,而對于一些文本資訊較大的值物件來說,這可能會損耗過多的記憶體和性能,并且通過EFCore的映射獲取值物件也有一個問題,你很難獲取倒組合關系的值物件,比如值物件A中有值物件B,值物件B中有值物件C,這對于建模值物件來說可能是一個很正常的事情,但是在進行映射的時候確非常困難,
對于方式二來說,建模中存在了大量的值物件,我們在持久化時不得不對他們都一一建立一個資料表來保存,這樣造成資料庫表的無限增多,并且對于習慣了資料庫驅動開發的人員來說,這可能是一個噩夢,當嘗試通過資料庫來還原業務關系時這是一項非常艱難的任務,
總之,還是那句話,目前依舊沒有一個完美的解決方案,你只能通過自己的自身條件和從業經驗來進行對以上問題的規避,從而達到一個折中的效果,
總結
總結可能就是沒有總結了吧,有時間的話繼續擴充戰術模式中其它關鍵概念(物體,倉儲,領域服務,工廠等)的文章,
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/89777.html
標籤:.NET Core
