【DDD】持久化領域對象的方法實踐

概述

在實踐領域驅動設計(DDD)的過程當中,咱們會根據項目的所在領域以及需求狀況捕獲出必定數量的領域對象。設計得足夠好的領域對象便於咱們更加透徹的理解業務,方便系統後期的擴展和維護,不至於隨着需求的擴展和代碼量的累積,系統逐漸演變爲大泥球(Big Ball of Mud)。html

雖然領域驅動設計的思想很誘人,但咱們依然會面臨各類隱藏的困難,就好比今天咱們要講的主題「持久化」:即便前期咱們設計了足夠完整的領域對象,可是依然須要持久化它們到數據庫中,而普通的關係型數據庫可能很難維持領域對象的原有結構,因此咱們必需要使用一些特有的手段來處理它。git

開篇

本篇文章屬於《如何運用領域驅動設計》系列的一個補充,若是您閱讀過該系列的其它文章,您就會發現關於「持久化」的這個問題已經不止在一篇博文中說起到了。github

那麼,究竟是什麼緣由讓咱們面臨這個問題呢? 是的!值對象! 若是您認真的瞭解過值對象的話(若是還不瞭解值對象,您能夠參考 如何運用領域驅動設計 - 值對象),您會發現值對象是由許多基元類型構成的(好比string,int,double等),因此咱們能夠理解它爲對細粒度基元類型的包裹,構成咱們所在領域中的一個基礎類型,好比說下面這個例子:sql

public sealed class City : ValueObject
{
    public string Name { get; }
    public int Population { get; }

    public City(string name, int population)
    {
        Name = name;
        Population = population;
    }
}

咱們假設如今有一個叫作City的值對象,它是由名稱(Name)和人口數量(Population)構成。一般咱們這樣創建值對象的緣由很簡單,在該領域中咱們一聯繫到「人口」數量就會和「城市」連同在一塊兒(你不會說我想知道人口數量,而你會說我想知道紐約的人口數量),因此「城市」這一律念成爲咱們該領域中的小顆粒對象,而該對象在代碼實現中是由多個小基元類型構成的,好比該例子就是由一個string和一個int。mongodb

這樣建模的好處之一就是咱們考慮的問題是一個總體,將零碎的點構建爲一個總體對象,若是該對象的行爲須要發生改變,只須要修改該對象自己就能夠了,而不是代碼散落在各處須要處處查找(這也是滾成大泥球的緣由之一)。數據庫

若是您喜歡捕獵有關DDD的知識,您可能不止一次會看到這樣一條建議規則:編程

In the world of DDD, there’s a well-known guideline that you should prefer Value Objects over Entities where possible. If you see that a concept in your domain model doesn’t have its own identity, choose to treat that concept as a Value Object.json

該建議的內容就是提倡DDD實踐者多使用值對象。固然也不是說不管什麼東西都創建成值對象,只是要咱們多去發現領域中的值對象。c#

可是這每每給持久化帶來了難度,先來想一下傳統的編碼持久化方式:一個對象(或者POCO)裏面包含了各個基元類型的屬性,當須要持久化時,每一個屬性都對應數據庫的一個字段,而該對象就成爲了一個表。 可是這在領域驅動設計中就很差使用了,值對象成了咱們考慮問題的小顆粒,而它在代碼中成了一個類,若是直接持久化它是什麼樣子呢?表,使用它的實體或者聚合根也是一個表,兩個表經過主外鍵關係連接。

那麼這樣持久化方式好很差呢? 答案是不肯定的,可能瞭解了下文的這些方案後,您會有本身的看法。

本篇文章的持久化方案都是基於關係型數據庫,若是您是非關係型數據庫(好比mongodb),那麼您應該不會面臨這樣的問題。

字段 Or 表

將值對象持久化成字段好呢?仍是將值對象持久化爲表好呢? 這個問題其實也有不少普遍的討論,就比如.NET好仍是Java好(好吧,我php天下**),目前其實也沒有個明確的結果:

  • 以爲持久化爲表字段的緣由是 若是持久化爲表,必須給表添加一個ID供引用的實體或者聚合關聯,這就不知足值對象不該該有ID的準則了。
  • 以爲持久化爲表的緣由是 數據表模型並不表明代碼層面的模型,代碼裏面的值對象其實並無ID的說法,因此它是符合值對象的,而持久化爲字段的話,同一個值對象數據會被複製爲多份致使數據冗餘。

固然哈,各有各的道理,咱們也不用特別偏向於使用哪一個結論。應該站在客觀的角度,實際的項目須要哪一種手段就根據切實的狀況來選擇。

來講一下持久化爲字段的狀況

該手段其實在近期來講比較流行,特別是在EFCore2.0以後,爲何呢?由於EF Core2.0提供了一個叫作 從屬實體類型 的概念,其實這個技術手段在EF中很早就有了,在EF中有一個叫作Complex的東西,只是在EF Core 1.x時代沒有引入而已。

在EFCore引入了Owned以後,微軟那個最著名的微服務教程 eShopOnContainers 也順勢推出了用於該特性來持久化值對象的方案:

x

因此這也是爲何你們都在使用Owned持久化值對象的緣由。(固然,你們項目中只有Address被創建爲值對象的習慣不知道是否是從這兒養成的 😜)。

來看看Owned好很差使:

首先是一個實體中包含一個值對象的狀況,該狀況在微軟的那個案例中已經實現了,因此咱們不用糾結它的功能,確定是可以實現的。

可是有其它的狀況,一個實體包含了一個值對象,該值對象中又包含了另一個值對象。 您可能會問,怎麼可能會有這麼複雜。可是若是您按照上面那個多使用值對象的準則的話,這種狀況在您的項目中很是的常見。我引用了《如何運用領域驅動設計》中的案例來測試這種實現,代碼大體是這樣:

public class Itinerary : AggregateRoot<Guid>
{
    public ItineraryNote Note { get; private set; }
}

public class ItineraryNote : ValueObject
{
    public string Content { get; private set; }
    public DateTime NoteTime { get; private set; }
    public NotePerson NotePerson { get; private set; }
}

public class NotePerson
{
    public string FirstName { get; private set; }
    public string LastName { get; private set; }
}

爲了達到演示效果,我剔除了有關聚合根的其它屬性和行爲方法。咱們能夠清楚的看到聚合根Itinerary 包含了值對象 ItineraryNoteItineraryNote 又包含了值對象 NotePerson。 接下來咱們來使用EF Core的Owned來看它可否完成這種映射關係:

modelBuilder.Entity<Itinerary>().OwnsOne(s => s.Note).OwnsOne(s => s.NotePerson);

當可以連續打出兩個Owns**的時候我就以爲這事兒應該成了,結果看數據庫的關係圖吧:

x

是的,它能夠!而EFCore對於該持久化的格式是:Entity_Valueobject1_Valueobject2。也就是說咱們的值對象能夠一直嵌套下去,只是字段名也會跟着一直嵌套而已。

此時,使用其它orm框架的同窗們可能就要說了:我沒有使用EF,那麼我怎麼映射,好比是Dapper,對於這種嵌套多層值對象的我怎麼辦? 別慌哈,後文的另外的方案可能適合您。

來講一下持久化爲表的狀況

其實這種狀況很簡單了,若是您不配置Owned的話,EF會爲您默認生成表,這種場景我想您可能深有體會,我這裏就再也不過多闡述了。

怎麼持久化集合值對象

是的,若是值對象是一個集合呢?咱們又將如何處理它呢?

對了,說到這裏還有一個DDD的準則:「儘可能少用集合值對象。」 固然,這個觀點我以爲頗有爭議,該觀點在 《領域驅動設計模式、原理與實踐》 這本權威DDD教材中有被說起。該觀點認爲咱們須要仔細的捕獲領域中的值對象,教程中用了「電話號碼」來舉例,一我的可能有多個號碼好比移動電話、座機、傳真等,咱們可能須要將電話號碼創建爲值對象,而後創建一個集合值對象,可是教程中認爲這樣並很差,而是單獨將各個類別創建爲了值對象,好比移動電話值對象,傳真值對象等。

這種作法雖然更貼近於現實建模,可是某些時刻咱們真的須要創建一個集合值對象,好比開篇提到的City,若是我在某個場景會用到多個城市信息呢?還有ItineraryNote 裏面的 NotePerson 呢,若是是多我的呢? 因此咱們的領域或多或少會遇到集合值對象。

將集合值對象存爲字段

這種手段很是的常見,最切實的實踐方案就是…………………………!對 json! 將集合序列化成json,特別是如今新sqlserver等數據庫已經支持json格式的字段了,因此序列化和反序列化的手段也很是容易讓咱們去持久化值對象。

可是……個人數據庫不支持json呢?不要緊,還有辦法用string,存爲strng格式進行反序列化操做也不會消耗太多性能。

還有一種方式:制定屬於本身的格式,下面將你們舉例爲你們說明,用開頭的那個City吧:

public sealed class City : ValueObject
{
    public string Name { get; }
    public int Population { get; }
 
    public City(string name, int population)
    {
        Name = name;
        Population = population;
    }
}

假如咱們有一個實體中存在一個集合值對象:

public class User : Entity
{
    public List<City> Cities { get; set; }
}

第一步,抽象咱們的City爲另一個可迭代對象,好比CityList:

public class CityList : ValueObject<CityList>, IEnumerable<City>
{
    private List<City> _cities { get; }

    public CityList(IEnumerable<City> cities)
    {
        _cities = cities.ToList();
    }

    protected override bool EqualsCore(CityList other)
    {
        return _cities
            .OrderBy(x => x.Name)
            .SequenceEqual(other._cities.OrderBy(x => x.Name));
    }

    protected override int GetHashCodeCore()
    {
        return _cities.Count;
    }

    public IEnumerator<City> GetEnumerator()
    {
        return _cities.GetEnumerator();
    }

    IEnumeratorIEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
}

第二步:讓CityList可以轉換成爲string的能力,這個能力怎麼來呢? C#爲咱們提供了explicitimplicit的關鍵字,方便咱們對強類型進行互轉(若是您還不瞭解該關鍵字,戳這裏)。

public static explicit operator CityList(string cityList)
{
    List<City> cities = cityList.Split(';')
        .Select(x => (City)x)
        .ToList();
 
    return new CityList(cities);
}
 
public static implicit operator string(CityList cityList)
{
    return string.Join(";", cityList.Select(x => $"{(string)x.Name}|{(string)x.Population}"));
}

最後,外層的User實體改寫爲醬紫:

public class User : Entity
{
    private string _cities = string.Empty;
    public virtual CityListCities
    {
        get { return (CityList)_cities; }
        set { _cities = value; }
    }
}

這樣提供給ORM的映射的話,可能就會獲得像下面的結果:

#Table User
UserID: 1,
CityList: "City1|10;City2|20;"

這種方法的缺點:

固然這種方法雖然可以持久化值對象,可是依然有些很顯著的缺點:

  • 沒法在集合中的單個項中執行有效搜索
  • 若是集合中有不少項,這種方法可能會影響性能
  • 不支持多層值對象

固然這也並非說咱們就徹底不能使用它,在某些簡單的值對象場合,該方法可能也是個好的方案。

將集合值對象存爲表

這種方案和直接將值對象存爲表是同樣的,那麼仍是來看看用EFCore是什麼效果吧。EFCore爲這種狀況推出了OwnsMany的方法,若是咱們將上面OwnsOne的案例改成一個值對象集合是什麼樣子呢?

public class ItineraryNote : ValueObject
{
    public string Content { get; private set; }
    public DateTime NoteTime { get; private set; }
    //改成一個集合
    public List<NotePerson> NotePersons { get; private set; }
}

而後將映射的OwnsOne改寫爲OwnsMany:

modelBuilder.Entity<Itinerary>().OwnsOne(s => s.Note).OwnsMany(s => s.NotePersons);

最後數據庫的結果是這樣的:

x

用您的EFCore動手試試吧!

基於快照的數據存儲對象

前面的幾種方案都是經過EFCore這種重量框架來完成,那麼若是使用輕量的ORM框架要本身完成映射配置的如何處理呢?若是本身去配置這種關係很是繁瑣,不管是sql操做仍是映射操做,都無疑加大了不少的工做量。因此,咱們能夠嘗試引入專門的數據存儲對象來供持久化。

回顧一下咱們在之前的文章《如何運用領域驅動設計 - 存儲庫》提到過的一句話:

「領域模型是問題域的抽象,富含行爲和語言;數據模式是一種包含指定時間領域模型狀態的存儲結構,ORM能夠將特定的對象(C#的類)映射到數據模型。」

因此當時我就在考慮,既然數據模型是專用於儲存的,而領域模型的結構複雜讓它難以完成原樣持久化,那爲何不在持久化的時候將領域模型轉換爲專用的數據存儲模型呢?這樣對數據庫也友好,並且也不會破壞領域模型的結構。

仍是看那個 Itinerary 例子:

public class Itinerary : AggregateRoot<Guid>
{
    public ItineraryNote Note { get; private set; }
}

public class ItineraryNote : ValueObject
{
    public string Content { get; private set; }
    public DateTime NoteTime { get; private set; }
}

這時咱們構建一個專用的數據存儲對象,供ORM框架使用:

public class ItinerarySnapshotModel
{
    public Guid ID { get; set; }
    public string Content { get; set; }
    public DateTime NoteTime { get; set; }
}

這個結構您可能再熟悉不過了。是的,它對ORM框架超級友好,這也是面向數據庫編程的結構。

這時您可能會問了:「怎麼我寫DDD,寫了半天又回去了?」 這個問題,待會來嚴肅回答!😝

先來看看領域對象和數據存儲對象的互轉:

public class Itinerary : AggregateRoot<Guid>, IEntityHasSnapshot<ItinerarySnapshotModel>
{
    public ItineraryNote Note { get; private set; }

    //must have this ctor
    public Itinerary(ItinerarySnapshotModel snapshot)
    {
        Note = new ItineraryNote(snapshot.Content);
        Id = snapshot.ID;
    }

    public ItinerarySnapshotModel GetSnapshot()
    {
        return new ItinerarySnapshotModel()
        {
            Content = Note.Content,
            ID = Id,
            NoteTime = Note.NoteTime
        };
    }
}

/// <summary>
/// Provides the ability for entities to create snapshots
/// </summary>
/// <typeparam name="TEntity"><see cref="IEntity"/></typeparam>
public interface IEntityHasSnapshot<TSnapshot>
{
    /// <summary>
    /// Get a entity snapshot
    /// </summary>
    TSnapshot GetSnapshot();
}

這樣就完成了兩種模型的互轉。每當ORM須要持久化時,調用aggregateRoot.GetSnapshot()就能獲得持久化模型了。而持久化模型的設計在於您本身,您能夠根據數據庫的狀況任意更改,而您只需保證它能和真正的領域對象完成映射就能夠了。

好了,來談談這種方案的優缺點,以及上面的回到原始面向數據庫編程的問題:

先來考慮咱們爲何使用領域驅動設計,爲的是讓項目設計的更加清晰和乾淨。而領域模型的設計是在設計的前期,甚至領域模型的基本肯定還超越了編碼開始的時候。咱們只捕獲領域中重要的對象,而不考慮其它問題(好比持久化、映射框架選擇等基礎問題),因此這樣考慮出來的領域對象纔是足夠乾淨和更符合業務實際狀況的。

而考慮持久化是在何時作的呢?須要與基礎構件(好比ORM框架)交互的時期,這時領域對象編碼幾乎已經完成。其實在持久化以前咱們已經完成了領域驅動設計的過程,因此並不是是咱們退回去使用面向數據庫的設計。若是在設計領域對象的時候又考慮數據庫等交互,那麼想象一下這個打着領域驅動設計旗號的項目最後會成爲何樣呢?

那麼這種基於快照的數據存儲對象方式的優勢是什麼呢?

  • 它解決了持久化的問題。
  • 甚至能夠將實體OR聚合根的屬性徹底私有化,這樣外界根本沒法破壞它的數據。而外界是經過快照的這個數據結構來訪問的。
  • 您能夠隨意設計您的數據庫結構,哪怕有一天您切換了數據庫或者ORM框架,只要您保證轉換正確以後,領域的行爲是不會被破壞的。

可是它也有個顯著的缺點:增大編碼量。每個聚合根都須要增長一個數據儲存對象與之對應,並且還須要配置映射規則。可是!!!! 請您相信,這些代碼與您項目中的其它代碼比起來微不足道,而且它後期爲您帶來的好處可能更加明顯。

比較

上面爲你們提供了多種持久化的方案,那麼到底哪一種更好呢?就比如最初的問題,持久化爲字段好仍是表好? 依然沒有答案,可是我相信您看了上面的內容後,可以找到屬於您項目的特有方案,它也會讓您落地DDD項目邁出重要的一步。

Table 1

方案 優勢 缺點
持久值對象到表字段 數據依附於某條實體或者聚合根 數據冗餘、會讓表擁有太多字段
持久化值對象到表 數據量不冗餘 會存在許多表、從數據庫層面很難看出它和實體的區別

Table 2

方案 優勢 缺點
須要轉換對象用做持久化 領域對象和數據對象徹底獨立,對數據對象的操做不會影響到領域對象 增大編碼量
不須要轉換對象用做持久化 直接將領域對象供給ORM持久化,簡單且不須要增長額外的東西 配置規則可能比較繁瑣,有時候爲了讓領域模型適配數據而改動領域模型

總結

該篇文章文字比較多,也許花費了您太長的時間閱讀,但但願本文的這些方案可以對您持久化領域對象有所幫助。這篇博文沒有攜帶GitHub的源碼,若是您須要的話能夠在下方留言,我寫一份上傳至Github。哦對了,關於正在寫的MiCake(米蛋糕),它也將支持上面所講的全部方案。

該篇文章屬於《如何運用領域驅動設計》的補充篇,爲了便於您查看該系列文章和了解文章的更新計劃,我在博客首頁置頂了該系列的 彙總目錄文章(點擊跳轉),若是您有興趣的話能夠跳轉至該文章查看。

對了,該系列的下次更新可能會到下個月了,畢竟仍是要過年的嘛。在這兒提早祝你們新年快樂(好像有些太早了哈( ̄▽ ̄)")。可是如今我新增了一個系列博文叫《五分鐘的.NET》,是一些關於.NET的小知識,定於每週一和週五在博客園更新,若是您有興趣的話能夠關注喲。

相關文章
相關標籤/搜索