概述
在實踐領域驅動設計(DDD)的過程中,我們會根據項目的所在領域以及需求情況捕獲出一定數量的領域對象。設計得足夠好的領域對象便於我們更加透徹的理解業務,方便系統后期的擴展和維護,不至於隨着需求的擴展和代碼量的累積,系統逐漸演變為大泥球(Big Ball of Mud)。
雖然領域驅動設計的思想很誘人,但我們依然會面臨各種隱藏的困難,就比如今天我們要講的主題“持久化”:即使前期我們設計了足夠完整的領域對象,但是依然需要持久化它們到數據庫中,而普通的關系型數據庫可能很難維持領域對象的原有結構,所以我們必須要使用一些特有的手段來處理它。
開篇
本篇文章屬於《如何運用領域驅動設計》系列的一個補充,如果您閱讀過該系列的其它文章,您就會發現關於“持久化”的這個問題已經不止在一篇博文中提及到了。
那么,到底是什么原因讓我們面臨這個問題呢? 是的!值對象! 如果您認真的了解過值對象的話(如果還不了解值對象,您可以參考 如何運用領域驅動設計 - 值對象),您會發現值對象是由許多基元類型構成的(比如string,int,double等),所以我們可以理解它為對細粒度基元類型的包裹,構成我們所在領域中的一個基礎類型,比如說下面這個例子:
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。
這樣建模的好處之一就是我們考慮的問題是一個整體,將零碎的點構建為一個整體對象,如果該對象的行為需要發生改變,只需要修改該對象本身就可以了,而不是代碼散落在各處需要到處查找(這也是滾成大泥球的原因之一)。
如果您喜歡捕獵有關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.
該建議的內容就是提倡DDD實踐者多使用值對象。當然也不是說無論什么東西都建立成值對象,只是要我們多去發現領域中的值對象。
但是這往往給持久化帶來了難度,先來想一下傳統的編碼持久化方式:一個對象(或者POCO)里面包含了各個基元類型的屬性,當需要持久化時,每個屬性都對應數據庫的一個字段,而該對象就成為了一個表。 但是這在領域驅動設計中就不好使用了,值對象成了我們考慮問題的小顆粒,而它在代碼中成了一個類,如果直接持久化它是什么樣子呢?表,使用它的實體或者聚合根也是一個表,兩個表通過主外鍵關系鏈接。
那么這樣持久化方式好不好呢? 答案是不確定的,可能了解了下文的這些方案后,您會有自己的見解。
本篇文章的持久化方案都是基於關系型數據庫,如果您是非關系型數據庫(比如mongodb),那么您應該不會面臨這樣的問題。
字段 Or 表
將值對象持久化成字段好呢?還是將值對象持久化為表好呢? 這個問題其實也有很多廣泛的討論,就好比.NET好還是Java好(好吧,我php天下**),目前其實也沒有個明確的結果:
- 覺得持久化為表字段的原因是 如果持久化為表,必須給表添加一個ID供引用的實體或者聚合關聯,這就不滿足值對象不應該有ID的准則了。
- 覺得持久化為表的原因是 數據表模型並不代表代碼層面的模型,代碼里面的值對象其實並沒有ID的說法,所以它是符合值對象的,而持久化為字段的話,同一個值對象數據會被復制為多份導致數據冗余。
當然哈,各有各的道理,我們也不用特別偏向於使用哪個結論。應該站在客觀的角度,實際的項目需要哪種手段就根據切實的情況來選擇。
來說一下持久化為字段的情況
該手段其實在近期來說比較流行,特別是在EFCore2.0之后,為什么呢?因為EF Core2.0提供了一個叫做 從屬實體類型 的概念,其實這個技術手段在EF中很早就有了,在EF中有一個叫做Complex的東西,只是在EF Core 1.x時代沒有引入而已。
在EFCore引入了Owned之后,微軟那個最著名的微服務教程 eShopOnContainers 也順勢推出了用於該特性來持久化值對象的方案:
所以這也是為什么大家都在使用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 包含了值對象 ItineraryNote ,ItineraryNote 又包含了值對象 NotePerson。 接下來我們來使用EF Core的Owned來看它能否完成這種映射關系:
modelBuilder.Entity<Itinerary>().OwnsOne(s => s.Note).OwnsOne(s => s.NotePerson);
當能夠連續打出兩個Owns**的時候我就覺得這事兒應該成了,結果看數據庫的關系圖吧:
是的,它可以!而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#為我們提供了explicit和implicit的關鍵字,方便我們對強類型進行互轉(如果您還不了解該關鍵字,戳這里)。
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);
最后數據庫的結果是這樣的:
用您的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的小知識,定於每周一和周五在博客園更新,如果您有興趣的話可以關注喲。