Paul Hiles: 3 ways to avoid an anemic domain model in EF Core
1.引言
在使用ORM中(比如Entity Framework)貧血領(lǐng)域模型十分常見 。本篇文章將先探討貧血模型的問題站辉,再去探究在EF Core中使用Code First時如何使用簡單的方法來避免貧血模型感论。
2.什么是貧血模型
在對領(lǐng)域建模后,輸出一系列類中僅包含一些簡單屬性聲明而不包含業(yè)務(wù)邏輯的模型,就屬于貧血模型坪圾。當使用Entity Framework時垮衷,它們不僅僅是簡單的數(shù)據(jù)持有者而且包含有一堆public getter和public setters:
public class BlogPost
{
public int Id { get; set; }
[Required]
[StringLength(250)]
public string Title { get; set; }
[Required]
[StringLength(500)]
public string Summary { get; set; }
[Required]
public string Body { get; set; }
public DateTime DateAdded { get; set; }
public DateTime? DatePublished { get; set; }
public BlogPostStatus Status { get; set; }
...
}
由于其完全缺乏面向?qū)ο缶幊痰脑瓌t,因此貧血模型通常被描述為反模式又活。他們需要調(diào)用者來完善驗證和其他業(yè)務(wù)邏輯苔咪。由于缺乏相應(yīng)的抽象,就會導(dǎo)致代碼重復(fù)柳骄、較差的數(shù)據(jù)完整性团赏,以及增加高層模塊的復(fù)雜性。
貧血模型是十分常見的耐薯。從我的經(jīng)驗來看舔清,EF中超過80%的領(lǐng)域模型都是貧血模型。這并不奇怪曲初。幾乎所有的文檔和其他博客文章都以最簡單的方式展示了EF体谒。他們專注于盡可能快地開始工作,而不是主張最佳實踐臼婆。
3.改造為更豐富的領(lǐng)域模型(充血模型)
下面我們將討論三種簡單的方式去豐富你的貧血模型抒痒。這幾種方法都非常簡單,僅需要最小的改動颁褂。
3.1.移除無參公共構(gòu)造函數(shù)
除非你指定一個構(gòu)造函數(shù)故响,否則你的類將有一個默認的無參數(shù)構(gòu)造函數(shù)。這意味著你可以用下面的方式實例化你的類:
var blogPost = new BlogPost();
在大多數(shù)情況下颁独,這是沒有意義的彩届。領(lǐng)域?qū)ο笸ǔV辽傩枰恍?shù)據(jù)才能使其有效。創(chuàng)建沒有任何數(shù)據(jù)(如標題或URL)的BlogPost實例是沒有意義的誓酒,因為其僅僅是一個實例化對象樟蠕,但對象卻不包含狀態(tài)和行為,不滿足數(shù)據(jù)有效性。有些人不同意坯墨,但是DDD社區(qū)普遍認為確保領(lǐng)域?qū)ο笫冀K有效是有意義的寂汇。為了解決這個問題,我們可以像處理其他OO類一樣對待我們的域類捣染,并引入一個參數(shù)化的構(gòu)造函數(shù):
public BlogPost(string title, string summary, string body)
{
if (string.IsNullOrWhiteSpace(title))
{
throw new ArgumentException("Title is required");
}
...
Title = title;
Summary = summary;
Body = body;
DateAdded = DateTime.UtcNow;
}
現(xiàn)在在調(diào)用代碼必須提供最少的數(shù)據(jù)來滿足約束(構(gòu)造函數(shù))骄瓣。這一變化提供了兩個積極成果:
- 任何新實例化的BlogPost對象現(xiàn)在都保證有效。作用于BlogPost的任何代碼都無需檢查其有效性耍攘。領(lǐng)域?qū)ο笤趯嵗瘯r自動校驗自身的有效性榕栏。
- 任何調(diào)用代碼都知道實例化對象所需的內(nèi)容。使用無參數(shù)的構(gòu)造函數(shù)蕾各,很容易構(gòu)造對象扒磁,但卻不知道必須要構(gòu)建的數(shù)據(jù)才能保證數(shù)據(jù)有效性。
但不幸的是式曲,在進行此更改后妨托,您將發(fā)現(xiàn)在從數(shù)據(jù)庫中檢索實體時,您的EF代碼不再有效:
InvalidOperationException:在實體類型'BlogPost'上找不到無參數(shù)的構(gòu)造函數(shù)吝羞。為了創(chuàng)建'BlogPost'的實例兰伤,EF需要聲明一個無參數(shù)的構(gòu)造函數(shù)。
EF需要一個無參數(shù)的構(gòu)造函數(shù)來查詢該做什么钧排?幸運的是敦腔,盡管EF確實需要無參數(shù)構(gòu)造函數(shù),但它并不要求構(gòu)造函數(shù)必須為public恨溜,所以我們可以為EF增加一個無參private構(gòu)造函數(shù)符衔,同時強制調(diào)用代碼使用參數(shù)化構(gòu)造函數(shù)。擁有額外的構(gòu)造函數(shù)顯然并不理想糟袁,但這些妥協(xié)通撑凶澹可以時ORM與OO代碼更好地配合。
private BlogPost()
{
// just for EF
}
public BlogPost(string title, string summary, string body)
{
...
}
3.2. 刪除公共屬性中的set方法
上面介紹的參數(shù)化構(gòu)造函數(shù)確保在實例化時對象處于有效狀態(tài)项戴。盡管如此形帮,這并沒有阻止您將屬性值更改為無效值。要解決這個問題肯尺,我們有兩個選擇:
- 將驗證邏輯添加到屬性設(shè)置器
- 防止直接修改屬性,改為使用與用戶操作相對應(yīng)的方法
向?qū)傩栽O(shè)置器添加驗證是完全可以接受的躯枢,但意味著我們不能再使用自動屬性并且必須引入一個后臺字段则吟。顯然這不是什么大問題:
private string title;
public string Title
{
get { return title; }
set
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Title must contain a value");
}
title = value;
}
}
第二種方式更受歡迎的主要原因在于它更接近地模擬了現(xiàn)實世界中發(fā)生的事情。用戶不是孤立地更新單個屬性锄蹂,而是傾向于執(zhí)行一組已知操作(由UI或API接口確定)氓仲。這些操作可能會導(dǎo)致一個或多個屬性被更新,但通常情況下更多。業(yè)務(wù)邏輯依賴于上下文的場景是非常普遍的敬扛,這將會導(dǎo)致對屬性進行賦值的set中的驗證邏輯變得復(fù)雜而難以理解晰洒。作為基本示例,請考慮以下博客文章發(fā)布流程:
public void Publish()
{
if (Status == BlogPostStatus.Draft || Status == BlogPostStatus.Archived)
{
if (Status == BlogPostStatus.Draft)
{
DatePublished = DateTime.UtcNow;
}
Status = BlogPostStatus.Published;
}
}
在這個例子中啥箭,我們有一個Publish()方法谍珊,它有一些簡單的邏輯和兩個可以更新的屬性。我們也可以將其作為一個屬性的setter來實現(xiàn)急侥,但它不太清晰砌滞,尤其是從另一個類中調(diào)用它時:
blogPost.Status = BlogPostStatus.Published;
VS
blogPost.Publish();
第一種方式的副作用是不能清晰的表達業(yè)務(wù)用例。
當然坏怪,你在大多數(shù)代碼庫中看到的是根本不在領(lǐng)域?qū)ο笾羞M行驗證贝润。相反,這種類型的邏輯可以在下一層找到铝宵。這可能導(dǎo)致:
- 更長的方法將領(lǐng)域特定的邏輯與編排打掘、持久性和其他關(guān)注點混合在一起。
- 不同動作之間重復(fù)的驗證邏輯鹏秋。
- 由于外部依賴性(需要使用Mock)而難以測試純領(lǐng)域邏輯尊蚁。
正如我們現(xiàn)在所期望的那樣,如果我們從每個屬性中徹底移除setter拼岳,EF將無法正常運行枝誊,但將訪問級別更改為private就可以很好地解決問題:
public class BlogPost
{
public int Id { get; private set; }
...
}
這樣,所有屬性在類之外都是只讀的惜纸。為了允許更新我們的領(lǐng)域類叶撒,我們引入了相應(yīng)類型動作的方法,如上面所示的Publish方法耐版。
通過刪除無參數(shù)構(gòu)造函數(shù)和公共屬性設(shè)置器并添加動作類型的方法祠够,我們現(xiàn)在擁有了始終有效的領(lǐng)域?qū)ο螅伺c所討論的實體直接相關(guān)的所有業(yè)務(wù)邏輯粪牲,這是一個很大的改進古瓤。我們已經(jīng)使我們的代碼同時更加健壯和簡單。
雖然我們可以討論其他DDD概念腺阳,例如領(lǐng)域事件以及通過雙派遣模式(double-dispatch pattern)使用領(lǐng)域服務(wù)落君,但它們的優(yōu)勢,特別是簡單性方面的優(yōu)勢遠不是那么明顯亭引。
通常DDD概念中可以簡化代碼的是我們將在下面討論的值對象的使用绎速。
3.3.引入值對象
值對象是不可變的(實例化后不允許更改)沒有身份標識的對象。值對象通潮候荆可以用來代替領(lǐng)域?qū)ο笾械囊粋€或多個屬性纹冤。
值對象的經(jīng)典示例包括貨??幣洒宝,地址和坐標,但也可以使用值類型替換單個屬性萌京,而不是使用字符串或整型雁歌。例如,不是將電話號碼存儲為字符串知残,而是可以創(chuàng)建一個帶有內(nèi)置驗證的PhoneNumber值類型以及提取撥號代碼的方法等靠瞎。
下面的代碼顯示了一個實現(xiàn)為EF類使用的貨幣值對象:
public class Money
{
[StringLength(3)]
public string Currency { get; private set; }
public int Amount { get; private set; }
private Money()
{
// just for EF
}
public Money(string currency, int amount)
{
// todo validation
Currency = currency;
Amount = amount;
}
}
貨幣和金額是內(nèi)在聯(lián)系的。為了使數(shù)據(jù)有效橡庞,這兩條信息都是必需的较坛。因此,對它們進行建模是有道理的扒最。請注意丑勤,參數(shù)化的構(gòu)造函數(shù)和私有屬性設(shè)置器的使用方式與我們在建模領(lǐng)域?qū)ο髸r所使用的完全相同。實體框架也需要一個私有無參數(shù)構(gòu)造函數(shù)吧趣。
在(RDBMS)數(shù)據(jù)持久性的上下文中法竞,值類型不存在于單獨的數(shù)據(jù)庫表中。為了讓我們在實體框架中使用值對象强挫,需要一個小的改動岔霸。這取決于您使用的EF版本。
在EF6中俯渤,我們只需用[ComplexType]屬性修飾值對象:
[ComplexType]
public class Money
{
...
}
在EF Core中呆细,從版本2開始,我們可以使用Fluent API中不常用的OwnsOne方法:
public class BlogContext : DbContext
{
...
public DbSet<BlogPost> BlogPosts { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<BlogPost>().OwnsOne(x => x.AdvertisingFee);
}
}
這里假定在我們的BlogPost實體上使用Money值對象八匠,如下所示:
public class BlogPost
{
...
public Money AdvertisingFee { get; private set; }
...
}
創(chuàng)建并運行遷移后絮爷,我們會發(fā)現(xiàn)我們的數(shù)據(jù)庫表現(xiàn)在包含兩個額外的列:
AdvertisingFee_Currency
AdvertisingFee_Amount
使用值對象的好處與向富領(lǐng)域模型的轉(zhuǎn)變非常相似。豐富的領(lǐng)域模型不需要調(diào)用代碼來驗證領(lǐng)域模型梨树,并提供了一個定義良好的抽象來進行編程坑夯。一個值對象進行自我驗證,因此包含值對象屬性的領(lǐng)域模型本身不需要知道如何驗證值類型抡四。所有非常清晰和簡單柜蜈。
4. 溫馨提示
當您打算從貧血域模型轉(zhuǎn)移到更豐富的領(lǐng)域模型時,您將立即體會到將領(lǐng)域級的業(yè)務(wù)邏輯封裝在領(lǐng)域?qū)ο笾械暮锰幹秆病U堊⒁馐缏模M管如此,嘗試并不是件容易的事藻雪。在您的領(lǐng)域?qū)ο笊蟿?chuàng)建一個方法來執(zhí)行驗證秘噪,然后更新多個屬性無疑是件好事。但從領(lǐng)域?qū)ο蟀l(fā)送電子郵件或保存到數(shù)據(jù)庫并不是您可能想要做的事情阔涉。重要的是要意識到缆娃,擁有豐富的領(lǐng)域模型并不否定另一層的需求來安排這些更高層次的關(guān)注。這是應(yīng)用服務(wù)或命令處理程序的工作瑰排,具體取決于您的體系結(jié)構(gòu)贯要。
5.關(guān)于單元測試的說明
一個豐富的、自我驗證的領(lǐng)域模型的一個負面影響是它可以使測試變得更加困難椭住。通過public setter崇渗,您可以簡單地將各個值分配給任何領(lǐng)域?qū)ο蟮膶傩浴_@使您可以直接指定您需要的確切值京郑,以便將對象置于特定狀態(tài)以進行測試宅广。如果你鎖定你的屬性和構(gòu)造函數(shù),那么這種方法是不可能的些举。但這也不是一件壞事跟狱,它使單元測試變得稍微困難??一點,但你所做的是確保你的測試是有效的户魏。
另一方面驶臊,它也使得測試領(lǐng)域?qū)ο蟊旧淼倪壿嫹浅:唵巍1M管你的應(yīng)用服務(wù)/命令處理程序的單元測試幾乎肯定會需要一定程度的模擬叼丑,但你應(yīng)該發(fā)現(xiàn)大部分領(lǐng)域?qū)ο鬁y試的構(gòu)建要簡單得多关翎,并且通常不需要依賴模擬。
6. 總結(jié)
本文介紹了三種非常簡單的技術(shù)鸠信,您可以使用Entity Framework和EF Core從貧血域模型轉(zhuǎn)換為更為豐富的領(lǐng)域模型纵寝。使用參數(shù)化的構(gòu)造函數(shù)可以確保我們的領(lǐng)域模型在實例化時有效。清除公共屬性setter確保我們的模型在其整個生命周期內(nèi)保持有效狀態(tài)星立。在領(lǐng)域模型上內(nèi)部執(zhí)行驗證和引入更改狀態(tài)的方法使我們能夠集中業(yè)務(wù)邏輯并簡化調(diào)用代碼爽茴。最后,我們考察了值對象的使用贞铣,并解釋了他們?nèi)绾芜M一步推進了這種簡化和邏輯封裝闹啦。