做爲領域驅動設計戰術模式中最爲核心的一個部分-值對象。一直是被大多數願意嘗試或者正在使用DDD的開發者說起最多的概念之一。可是在學習過程當中,你們會由於受到傳統開發模式的影響,每每很難去運用值對象這一律念,以及在對值對象進行持久化時感到很是的迷惑。本篇文章會從值對象的概念出發,解釋什麼是值對象以及怎麼運用值對象,而且給出相應的代碼片斷(本教程的代碼片斷都使用的是C#,後期的實戰項目也是基於 DotNet Core 平臺)。數據庫
首先讓咱們來看一看原著 《領域驅動設計:軟件核心複雜性應對之道》 對值對象的解釋:編程
不少對象沒有概念上的表示,他們描述了一個事務的某種特徵。
用於描述領域的某個方面而自己沒有概念表示的對象稱爲Value Object(值對象)。c#
此時做者是這樣的:
而咱們是這樣的:編程語言
而後做者用「地址」這一律念給你們擴充了一下什麼是值對象,咱們應該怎麼去發現值對象。因此你會發現如今不少的DDD文章中都是用這個例子給你們來解釋。固然讀懂了的人就會有一種醍醐灌頂的感受,而像我這種菜雞,之後運用的時候感受除了地址這個東西會給他抽象出來以外,其餘的仍是該咋亂寫咋寫。性能
For Example :學習
public class DemoClass { public Address Address { get; set; } //………… }
OK,如今咱們來仔細理解和分析一下值對象,雖然概念有一點抽象,可是至少有一關鍵點咱們可以很清晰的捕捉到,那就是值對象沒有標識,也就是說這個叫作Value Object的東西他沒有ID。這一點也十分關鍵,他方便後面咱們對值對象的深刻理解。
既然值對象是沒有ID的一個事物(東西),那麼咱們來考慮一下什麼狀況下咱們不須要經過ID來辨識一個東西:this
「在超市購物的時候:我有五塊錢,你也有五塊錢」 這裏會關心個人錢和你的錢是同一張,同一個編碼,同一個組合方式(一張五塊,五張一塊)嗎? 顯然不會。由於它們的價值是同樣的,就購買東西來講,因此它是不須要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。這對於建模值對象來講多是一個很正常的事情,可是在進行映射的時候確很是困難。
對於方式二來講,建模中存在了大量的值對象,咱們在持久化時不得不對他們都一一創建一個數據表來保存,這樣形成數據庫表的無限增多,而且對於習慣了數據庫驅動開發的人員來講,這多是一個噩夢,當嘗試經過數據庫來還原業務關係時這是一項很是艱難的任務。
總之,仍是那句話,目前依舊沒有一個完美的解決方案,你只能經過本身的自身條件和從業經驗來進行對以上問題的規避,從而達到一個折中的效果。
總結可能就是沒有總結了吧。有時間的話繼續擴充戰術模式中其它關鍵概念(實體,倉儲,領域服務,工廠等)的文章。