DDD實戰與進階 - 值對象
整體思路:
在實現此效果之前,我們先來捋一下思路,用思維導圖來設計一下我們的實現步驟,如下:
你可以審查元素,下載數字背景圖片,複製圖片地址,或者使用其他背景圖片、背景顏色
然後作者用“地址”這一概念給大家擴充了一下什麼是值對象,我們應該怎麼去發現值對象。所以你會發現現在很多的DDD文章中都是用這個例子給大家來解釋。當然讀懂了的人就會有一種醍醐灌頂的感覺,而像我這種菜雞,以後運用的時候感覺除了地址這個東西會給他抽象出來之外,其他的還是該咋亂寫咋寫。
何爲值對象
首先讓我們來看一看原著 《領域驅動設計:軟件核心複雜性應對之道》 對值對象的解釋:
很多對象沒有概念上的表示,他們描述了一個事務的某種特徵。
用於描述領域的某個方面而本身沒有概念表示的對象稱爲Value Object(值對象)。
頁面渲染完畢後,我們來讓數字滾動起來,設計如下兩個方法,其中 increaseNumber
需要在 Vue
生命週期 mounted
函數中調用
思考:背景框中有了數字以後,我們現在來思考一下,背景框中的文字,一定是 0-9
之前的數字,要在不打亂以上 html
結構的前提下,如何讓數字滾動起來呢?這個時候我們的魔爪就伸向了一個 CSS
屬性: writing-mode
,下面是它屬性的介紹:
- horizontal-tb:默認值,表示水平排版,從上到下。
- vertical-lr:表示垂直排版,從左到右。
- vertical-rl:表示垂直排版,從右到左。
For Example :
// CSS代碼
<style>
.box-item {
position: relative;
display: inline-block;
width: 54px;
height: 82px;
/* 背景圖片 */
background: url(./number-bg.png) no-repeat center center;
background-size: 100% 100%;
font-size: 62px;
line-height: 82px;
text-align: center;
}
</style>
// htm代碼
<div class="box">
<p class="box-item">
<span>1</span>
</p>
</div>
OK,現在我們來仔細理解和分析一下值對象,雖然概念有一點抽象,但是至少有一關鍵點我們能夠很清晰的捕捉到,那就是值對象沒有標識,也就是說這個叫做Value Object的東西他沒有ID。這一點也十分關鍵,他方便後面我們對值對象的深入理解。
既然值對象是沒有ID的一個事物(東西),那麼我們來考慮一下什麼情況下我們不需要通過ID來辨識一個東西:
// html部分
<p class="box-item">
<span>0123456789</span>
</p>
// style部分
.box-item {
display: inline-block;
width: 54px;
height: 82px;
background: url(./number-bg.png) no-repeat center center;
background-size: 100% 100%;
font-size: 62px;
line-height: 82px;
text-align: center;
position: relative;
writing-mode: vertical-lr;
text-orientation: upright;
/* overflow: hidden; */
}
.box-item span {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
letter-spacing: 10px;
}
通過上面的兩個例子,相信你一個沒有身份ID的事物(類)已經在你腦袋裏面留下了一點印象。那麼讓我們再來看一下原著中所提供給我們的一個案例:
- 當一個小孩畫畫的時候,他注意的是畫筆的顏色和筆尖的粗細。但如果有兩隻顏色和粗細相同的畫筆,他可能不會在意使用哪一支。如果有一支筆弄丟了,他可以從一套新筆中拿出一支同顏色的筆來繼續畫畫,根本不會在意已經換了一支筆。
值對象是基於上下文的
計算滾動
如果我們想讓數字滾動到 5
,那麼滾動的具體到底是多少?
答案是:向下滾動 -50%
那麼其他的數字呢?
得益於我們特殊的實現方法,每一位數字的滾動距離有一個通用的公式:
transform: `translate(-50%,-${number * 10}%)
有了以上公式,我們讓數字滾動到 5
,它的效果如下:
當前上下文的值對象可能是另一個上下文的實體
實體是戰術模式中同樣重要的一個概念,但是現在我們先不做討論,我們只需要明白實體是一個具有ID的事物就行了。也就是說一個同樣的東西在當前環境下可能沒有一個獨有的標識,但可能在另一個環境下它就需要一個特殊的ID來識別它了。考慮上面的例子:
-
同樣的五塊錢,此時在一個貨幣生產的環境下。它會考慮這同樣的一張五塊錢是否重號,顯然重號的貨幣是不允許發行的。所以每一張貨幣必須有一個唯一的標識作爲判斷。
-
同樣的馬桶,此時在一個物管環境中。它會考慮該馬桶的出廠編碼,如果馬桶出現故障,它會被返廠維修,並且通過唯一的id進行跟蹤。
顯然,同樣的東西,在不同的語境中居然有着不同的意義。
怎麼運用值對象
以第一個五塊錢的值對象例子來作爲說明,此時我們在超市購物的上下文中,我們可能已經捕獲倒了一個叫做“錢”(Money)的值對象。按照以往我們的寫法,來看一看會有一個什麼樣的代碼:
儘量避免使用基元類型
仔細看上面的代碼,你會發現,這沒有問題呀,表明的很正確。我在超市購物中,我所具有的錢通過了一個屬性來表明。這也很符合我們以往寫類的風格。
此時,你應該可以根據你自己的所在環境和語境(上下文)捕獲出屬於你自己的值對象了,比如貨幣呀,姓名呀,顏色呀等等。下面我們來考慮如何將它放在實際代碼中。
運動調查表(1) | |
---|---|
姓名 | ________ |
性別 | ________ (字符串) |
周運動量 | ________(整型) |
常用運動器材 | ________(整型) |
運動調查表(2) | |
---|---|
姓名 | ________ |
性別 | ________ (男\女) |
周運動量 | ________(0~1000cal\1000-1000cal) |
常用運動器材 | ________(跑步機\啞鈴\其他) |
值對象是內聚並且可以具有行爲
接下來是實現我們上文那個Money值對象的時候了。這是一個生活中很常見的一個場景,所以有可能我們建立出來的值對象是這樣的:
現在應該比較清晰的能夠理解該要點了吧。從運動表1中,彷彿出了性別之外,我們都不知道後面的空需要表達什麼意思,而運動表2加上了該環境特有的名稱和選項,一下就能讓人讀懂。如果將運動表1轉換爲我們熟悉的代碼,是否類似於上面的MySupmarketShopping類呢。所謂的基元類型,就是我們熟悉的(int,long,string,byte…………)。而多年的編碼習慣,讓我們認爲他們是表明事物屬性再正常不過的單位,但是就像兩個調查表所給出的答案一樣,這樣的代碼很迷惑,至少會給其他讀你代碼的人造成一些小障礙。
.box-item span {
position: absolute;
top: 10px;
left: 50%;
transform: translate(-50%,-50%);
letter-spacing: 10px;
}
Money對象中我們還引入了一個叫做幣種(Currency)的對象,它同樣也是值對象,表明了金錢的種類。
接下來我們更改我們上面的MySupmarketShopping。
你會發現我們將原來MySupmarketShopping類中的幣種屬性,通過轉換爲一個新的值對象後給了money對象。因爲幣種這個概念其實是屬於金錢的,它不應該被提取出來從而干擾我的購物。
public class MySupmarketShopping
{
public Money Amountofmoney { get; set; }
}
此時,Money值對象已經具備了它應有的屬性了,那麼就這樣就完成了嗎?
還是一個問題的思考,也許我在國外的超市購物,我需要將我的人民幣轉換成爲美元。這對我們編碼來說它是一個行爲動作,因此可能是一個方法。那麼我們將這個轉換的方法放在哪兒呢? 給MySupmarketShopping? 很顯然,你一下就知道如果有Money這個值對象在的話,轉換這個行爲就不應該給MySupmarketShopping,而是屬於Money。然後Money類就理所當然的被擴充爲了這個樣子:
setInterval(() => {
let number = document.getElementById('Number')
let random = getRandomNumber(0,10)
number.style.transform = `translate(-50%, -${random * 10}%)`
}, 2000)
function getRandomNumber (min, max) {
return Math.floor(Math.random() * (max - min + 1) + min)
}
請注意:在這個行爲完成後,我們是返回了一個新的Money對象,而不是在當前對象上進行修改。這是因爲我們的值對象擁有一個很重要的特性,不可變性。
值對象是不可變的:一旦創建好之後,值對象就永遠不能變更了。相反,任何變更其值的嘗試,其結果都應該是創建帶有期望值的整個新實例。
來看一個例子
其實我們在平時的編碼過程中,有些類型就是典型的值對象,只是我們當時並沒有這個完整的概念體系去發現。
比如在.NET中,DateTime類就是一個經典的例子。有的編程語言,他的基元類型其實是沒有日期型這種說法的,比如Go語言中是通過引入time的包實現的。
嘗試一下,如果不用DateTime類你會怎麼去表示日期這一個概念,又如何實現日期之間的相互轉換(比如DateTime所提供的AddDays,AddHours等方法)。
這是一個現實項目中的一個案例,也許你能通過它加深值對象概念在你腦海中的印象。
該案例的需求是:將一個時間段內的一部分時間段扣除,並且返回剩下的小時數。比如有一個時間段 12:00 - 14:00.另一個時間段 13:00 - 14:00。 返回小時數1。
//代碼片段 1
// 定時增長數字
increaseNumber () {
let self = this
this.timer = setInterval(() => {
self.newNumber = self.newNumber + getRandomNumber(1, 100)
self.setNumberTransform()
}, 3000)
},
// 設置每一位數字的偏移
setNumberTransform () {
let numberItems = this.$refs.numberItem
let numberArr = this.computeNumber.filter(item => !isNaN(item))
for (let index = 0; index < numberItems.length; index++) {
let elem = numberItems[index]
elem.style.transform = `translate(-50%, -${numberArr[index] * 10}%)`
}
}
//代碼片段 2
接下來是代碼片段2,在實現該過程時,我們先嚐試尋找該問題模型中的共性,因此提取出了一個叫做時間段(DateTimeRange)類的值對象出來,而賦予了該值對象應有的行爲和屬性。
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);
}
}
首先來看一看代碼片段1,使用了傳統的方式來實現該功能。但是裏面使用大量的基元類型來描述問題,可讀性和代碼量都很複雜。
通過尋找出的該值對象,並且豐富值對象的行爲。爲我們編碼帶來了大量的好處。
值對象的持久化
將值對象映射在表的字段中
該方法也是微軟的官方案例Eshop中提供的方案,通過EFCore提供的固有實體類型形式來將值對象存儲在依賴的實體表字段中。具體的細節可以參考 EShop實現值對象。通過該方法,我們最後持久化出來的
有關值對象持久化的問題一直是一個非常棘手的問題。這裏我們提供了目前最爲常見的兩種實現思路和方法供參考。而該方法都是針對傳統的關係型數據庫的。(因爲Nosql的特性,所以無需考慮這些問題)
將值對象單獨用作表來存儲
該方式在持久化時將值對象單獨存爲一張表,並且以依賴對象的ID主爲自己的主鍵。在獲取時用Join的方式來與依賴的對象形成關聯。
正如這個小標題一樣,目前可能並沒有完美的一個持久化方式來供關係型數據庫持久化值對象。方式一的方式可能會造成數據大量的冗餘,畢竟對值對象來說,只要值是一樣的我們就認爲他們是相等的。假如有一個地址值對象的值是“四川”,那麼有100w個用戶都是四川的話,那麼我們會將該內容保存100w次。
可能持久化出來的結果就像這樣:
可能沒有完美的持久化方式
總之,還是那句話,目前依舊沒有一個完美的解決方案,你只能通過自己的自身條件和從業經驗來進行對以上問題的規避,從而達到一個折中的效果。
而對於一些文本信息較大的值對象來說,這可能會損耗過多的內存和性能。並且通過EFCore的映射獲取值對象也有一個問題,你很難獲取倒組合關係的值對象,比如值對象A中有值對象B,值對象B中有值對象C。這對於建模值對象來說可能是一個很正常的事情,但是在進行映射的時候確非常困難。
對於方式二來說,建模中存在了大量的值對象,我們在持久化時不得不對他們都一一建立一個數據表來保存,這樣造成數據庫表的無限增多,並且對於習慣了數據庫驅動開發的人員來說,這可能是一個噩夢,當嘗試通過數據庫來還原業務關係時這是一項非常艱難的任務。
//展示了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;
}
}
總結
總結可能就是沒有總結了吧。有時間的話繼續擴充戰術模式中其它關鍵概念(實體,倉儲,領域服務,工廠等)的文章。