DDD實戰與進階 - 值對象

来源:https://www.cnblogs.com/uoyo/archive/2019/11/28/11951840.html
-Advertisement-
Play Games

DDD實戰與進階 值對象 [toc] 概述 作為領域驅動設計戰術模式中最為核心的一個部分 值對象。一直是被大多數願意嘗試或者正在使用DDD的開發者提及最多的概念之一。但是在學習過程中,大家會因為受到傳統開發模式的影響,往往很難去運用值對象這一概念,以及在對值對象進行持久化時感到非常的迷惑。本篇文章會 ...


目錄

DDD實戰與進階 - 值對象

概述

作為領域驅動設計戰術模式中最為核心的一個部分-值對象。一直是被大多數願意嘗試或者正在使用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所提供的AddDaysAddHours等方法)。

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

該案例的需求是:將一個時間段內的一部分時間段扣除,並且返回剩下的小時數。比如有一個時間段 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。這對於建模值對象來說可能是一個很正常的事情,但是在進行映射的時候確非常困難。
對於方式二來說,建模中存在了大量的值對象,我們在持久化時不得不對他們都一一建立一個數據表來保存,這樣造成資料庫表的無限增多,並且對於習慣了資料庫驅動開發的人員來說,這可能是一個噩夢,當嘗試通過資料庫來還原業務關係時這是一項非常艱難的任務。
總之,還是那句話,目前依舊沒有一個完美的解決方案,你只能通過自己的自身條件和從業經驗來進行對以上問題的規避,從而達到一個折中的效果。

總結

總結可能就是沒有總結了吧。有時間的話繼續擴充戰術模式中其它關鍵概念(實體,倉儲,領域服務,工廠等)的文章。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 1.python之logger日誌通用配置文件 2.放入項目即可直接使用 ...
  • Intellj IDEA快捷鍵入門 之 Ctrl+Space(空格) 時間 :2019/11/28 系統 :Win10系統 背景 : 步驟 : 1.win 設置 時間和語言 語言 添加語言 2.這裡選最基本的就好,不想選亂七八糟的,點擊"安裝" 3.安裝完成後(重啟完成後),這裡就會有兩種語言,把E ...
  • 正則匹配並且可以捕獲到()這個裡面的子表達式的值,linux的grep命令沒辦法捕獲子表達式的值,只能獲取到整條正則匹配的內容 上面的正則中驗證了.*是貪婪 .*?是非貪婪 ,下麵匹配的字元串切片第一條是整條數據,後面的每一個對應正則括弧里捕獲的內容 tao@tao-PC:/var/www/html ...
  • 上一篇說瞭如何使用 Topshelf 組件快速創建Windows服務,接下來介紹如何使用 Quartz.net 關於Quartz.net的好處,網上搜索都是一大把一大把的,我就不再多介紹。 先介紹需要用到的插件: Quartz版本我用的 2.6.2的, 沒有用3.0以上的,因為你用了就會知道,會列印 ...
  • static void GetNetVersionDemo() { using (RegistryKey ndpKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32).OpenSubKey(@... ...
  • 畢業一年半了,從net framework過渡到net core,感覺擴開了自己的眼界,聽說了docker、微服務、CI/CD等等技術。也跟著學了一些,感覺對技術的世界還是充滿了興趣。也有過一絲迷茫,看到那麼多新穎的技術卻無從下手。所以有想法開始嘗試寫博客,記錄自己的點滴和學習記錄吧。 在這工作的一 ...
  • 之前實現的那版依賴註入框架基本可用,但是感覺還是不夠靈活,而且註冊服務和解析服務在同一個地方感覺有點彆扭,有點職責分離不夠。於是借鑒 Autofac 的做法,增加了一個 `ServiceContainerBuilder` 來負責註冊服務,`ServiceContainer`負責解析服務,並且增加了一... ...
  • 1.添加 SignalR 客戶端庫 右鍵點擊項目->然後選擇“添加” >“客戶端庫” 提供程式選擇:unpkg ,庫選擇:@aspnet/[email protected] 選擇“選擇特定文件” ,展開“dist/browser” 文件夾,然後選擇“signalr.js” 和“signalr.min.js” ...
一周排行
    -Advertisement-
    Play Games
  • C#TMS系統代碼-基礎頁面BaseCity學習 本人純新手,剛進公司跟領導報道,我說我是java全棧,他問我會不會C#,我說大學學過,他說這個TMS系統就給你來管了。外包已經把代碼給我了,這幾天先把增刪改查的代碼背一下,說不定後面就要趕鴨子上架了 Service頁面 //using => impo ...
  • 委托與事件 委托 委托的定義 委托是C#中的一種類型,用於存儲對方法的引用。它允許將方法作為參數傳遞給其他方法,實現回調、事件處理和動態調用等功能。通俗來講,就是委托包含方法的記憶體地址,方法匹配與委托相同的簽名,因此通過使用正確的參數類型來調用方法。 委托的特性 引用方法:委托允許存儲對方法的引用, ...
  • 前言 這幾天閑來沒事看看ABP vNext的文檔和源碼,關於關於依賴註入(屬性註入)這塊兒產生了興趣。 我們都知道。Volo.ABP 依賴註入容器使用了第三方組件Autofac實現的。有三種註入方式,構造函數註入和方法註入和屬性註入。 ABP的屬性註入原則參考如下: 這時候我就開始疑惑了,因為我知道 ...
  • C#TMS系統代碼-業務頁面ShippingNotice學習 學一個業務頁面,ok,領導開完會就被裁掉了,很突然啊,他收拾東西的時候我還以為他要旅游提前請假了,還在尋思為什麼回家連自己買的幾箱飲料都要叫跑腿帶走,怕被偷嗎?還好我在他開會之前拿了兩瓶芬達 感覺感覺前面的BaseCity差不太多,這邊的 ...
  • 概述:在C#中,通過`Expression`類、`AndAlso`和`OrElse`方法可組合兩個`Expression<Func<T, bool>>`,實現多條件動態查詢。通過創建表達式樹,可輕鬆構建複雜的查詢條件。 在C#中,可以使用AndAlso和OrElse方法組合兩個Expression< ...
  • 閑來無聊在我的Biwen.QuickApi中實現一下極簡的事件匯流排,其實代碼還是蠻簡單的,對於初學者可能有些幫助 就貼出來,有什麼不足的地方也歡迎板磚交流~ 首先定義一個事件約定的空介面 public interface IEvent{} 然後定義事件訂閱者介面 public interface I ...
  • 1. 案例 成某三甲醫預約系統, 該項目在2024年初進行上線測試,在正常運行了兩天後,業務系統報錯:The connection pool has been exhausted, either raise MaxPoolSize (currently 800) or Timeout (curren ...
  • 背景 我們有些工具在 Web 版中已經有了很好的實踐,而在 WPF 中重新開發也是一種費時費力的操作,那麼直接集成則是最省事省力的方法了。 思路解釋 為什麼要使用 WPF?莫問為什麼,老 C# 開發的堅持,另外因為 Windows 上已經裝了 Webview2/edge 整體打包比 electron ...
  • EDP是一套集組織架構,許可權框架【功能許可權,操作許可權,數據訪問許可權,WebApi許可權】,自動化日誌,動態Interface,WebApi管理等基礎功能於一體的,基於.net的企業應用開發框架。通過友好的編碼方式實現數據行、列許可權的管控。 ...
  • .Net8.0 Blazor Hybird 桌面端 (WPF/Winform) 實測可以完整運行在 win7sp1/win10/win11. 如果用其他工具打包,還可以運行在mac/linux下, 傳送門BlazorHybrid 發佈為無依賴包方式 安裝 WebView2Runtime 1.57 M ...