Paul Hiles: 3 ways to avoid an anemic domain model in EF Core
1.引言
在使用ORM中(比如Entity Framework)貧血領域模型十分常見 。本篇文章將先探討貧血模型的問題,再去探究在EF Core中使用Code First時如何使用簡單的方法來避免貧血模型。
2.什么是貧血模型
在對領域建模后,輸出一系列類中僅包含一些簡單屬性聲明而不包含業務邏輯的模型,就屬於貧血模型。當使用Entity Framework時,它們不僅僅是簡單的數據持有者而且包含有一堆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; }
...
}
由於其完全缺乏面向對象編程的原則,因此貧血模型通常被描述為反模式。他們需要調用者來完善驗證和其他業務邏輯。由於缺乏相應的抽象,就會導致代碼重復、較差的數據完整性,以及增加高層模塊的復雜性。
貧血模型是十分常見的。從我的經驗來看,EF中超過80%的領域模型都是貧血模型。這並不奇怪。幾乎所有的文檔和其他博客文章都以最簡單的方式展示了EF。他們專注於盡可能快地開始工作,而不是主張最佳實踐。
3.改造為更豐富的領域模型(充血模型)
下面我們將討論三種簡單的方式去豐富你的貧血模型。這幾種方法都非常簡單,僅需要最小的改動。
3.1移除無參公共構造函數
除非你指定一個構造函數,否則你的類將有一個默認的無參數構造函數。這意味着你可以用下面的方式實例化你的類:
var blogPost = new BlogPost();
在大多數情況下,這是沒有意義的。領域對象通常至少需要一些數據才能使其有效。創建沒有任何數據(如標題或URL)的BlogPost實例是沒有意義的,因為其僅僅是一個實例化對象,但對象卻不包含狀態和行為,不滿足數據有效性。有些人不同意,但是DDD社區普遍認為確保領域對象始終有效是有意義的。為了解決這個問題,我們可以像處理其他OO類一樣對待我們的域類,並引入一個參數化的構造函數:
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;
}
現在在調用代碼必須提供最少的數據來滿足約束(構造函數)。這一變化提供了兩個積極成果:
- 任何新實例化的BlogPost對象現在都保證有效。作用於BlogPost的任何代碼都無需檢查其有效性。領域對象在實例化時自動校驗自身的有效性。
- 任何調用代碼都知道實例化對象所需的內容。使用無參數的構造函數,很容易構造對象,但卻不知道必須要構建的數據才能保證數據有效性。
但不幸的是,在進行此更改后,您將發現在從數據庫中檢索實體時,您的EF代碼不再有效:
InvalidOperationException:在實體類型'BlogPost'上找不到無參數的構造函數。為了創建'BlogPost'的實例,EF需要聲明一個無參數的構造函數。
EF需要一個無參數的構造函數來查詢該做什么?幸運的是,盡管EF確實需要無參數構造函數,但它並不要求構造函數必須為public,所以我們可以為EF增加一個無參private構造函數,同時強制調用代碼使用參數化構造函數。擁有額外的構造函數顯然並不理想,但這些妥協通常可以時ORM與OO代碼更好地配合。
private BlogPost()
{
// just for EF
}
public BlogPost(string title, string summary, string body)
{
...
}
3.2. 刪除公共屬性中的set方法
上面介紹的參數化構造函數確保在實例化時對象處於有效狀態。盡管如此,這並沒有阻止您將屬性值更改為無效值。要解決這個問題,我們有兩個選擇:
- 將驗證邏輯添加到屬性設置器
- 防止直接修改屬性,改為使用與用戶操作相對應的方法
向屬性設置器添加驗證是完全可以接受的,但意味着我們不能再使用自動屬性並且必須引入一個后台字段。顯然這不是什么大問題:
private string title;
public string Title
{
get { return title; }
set
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Title must contain a value");
}
title = value;
}
}
第二種方式更受歡迎的主要原因在於它更接近地模擬了現實世界中發生的事情。用戶不是孤立地更新單個屬性,而是傾向於執行一組已知操作(由UI或API接口確定)。這些操作可能會導致一個或多個屬性被更新,但通常情況下更多。業務邏輯依賴於上下文的場景是非常普遍的,這將會導致對屬性進行賦值的set中的驗證邏輯變得復雜而難以理解。作為基本示例,請考慮以下博客文章發布流程:
public void Publish()
{
if (Status == BlogPostStatus.Draft || Status == BlogPostStatus.Archived)
{
if (Status == BlogPostStatus.Draft)
{
DatePublished = DateTime.UtcNow;
}
Status = BlogPostStatus.Published;
}
}
在這個例子中,我們有一個Publish()方法,它有一些簡單的邏輯和兩個可以更新的屬性。我們也可以將其作為一個屬性的setter來實現,但它不太清晰,尤其是從另一個類中調用它時:
blogPost.Status = BlogPostStatus.Published;
VS
blogPost.Publish();
第一種方式的副作用是不能清晰的表達業務用例。
當然,你在大多數代碼庫中看到的是根本不在領域對象中進行驗證。相反,這種類型的邏輯可以在下一層找到。這可能導致:
- 更長的方法將領域特定的邏輯與編排、持久性和其他關注點混合在一起。
- 不同動作之間重復的驗證邏輯。
- 由於外部依賴性(需要使用Mock)而難以測試純領域邏輯。
正如我們現在所期望的那樣,如果我們從每個屬性中徹底移除setter,EF將無法正常運行,但將訪問級別更改為private就可以很好地解決問題:
public class BlogPost
{
public int Id { get; private set; }
...
}
這樣,所有屬性在類之外都是只讀的。為了允許更新我們的領域類,我們引入了相應類型動作的方法,如上面所示的Publish方法。
通過刪除無參數構造函數和公共屬性設置器並添加動作類型的方法,我們現在擁有了始終有效的領域對象,並包含了與所討論的實體直接相關的所有業務邏輯,這是一個很大的改進。我們已經使我們的代碼同時更加健壯和簡單。
雖然我們可以討論其他DDD概念,例如領域事件以及通過雙派遣模式(double-dispatch pattern)使用領域服務,但它們的優勢,特別是簡單性方面的優勢遠不是那么明顯。
通常DDD概念中可以簡化代碼的是我們將在下面討論的值對象的使用。
3.3.引入值對象
值對象是不可變的(實例化后不允許更改)沒有身份標識的對象。值對象通常可以用來代替領域對象中的一個或多個屬性。
值對象的經典示例包括貨幣,地址和坐標,但也可以使用值類型替換單個屬性,而不是使用字符串或整型。例如,不是將電話號碼存儲為字符串,而是可以創建一個帶有內置驗證的PhoneNumber值類型以及提取撥號代碼的方法等。
下面的代碼顯示了一個實現為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;
}
}
貨幣和金額是內在聯系的。為了使數據有效,這兩條信息都是必需的。因此,對它們進行建模是有道理的。請注意,參數化的構造函數和私有屬性設置器的使用方式與我們在建模領域對象時所使用的完全相同。實體框架也需要一個私有無參數構造函數。
在(RDBMS)數據持久性的上下文中,值類型不存在於單獨的數據庫表中。為了讓我們在實體框架中使用值對象,需要一個小的改動。這取決於您使用的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; }
...
}
創建並運行遷移后,我們會發現我們的數據庫表現在包含兩個額外的列:
AdvertisingFee_Currency
AdvertisingFee_Amount
使用值對象的好處與向富領域模型的轉變非常相似。豐富的領域模型不需要調用代碼來驗證領域模型,並提供了一個定義良好的抽象來進行編程。一個值對象進行自我驗證,因此包含值對象屬性的領域模型本身不需要知道如何驗證值類型。所有非常清晰和簡單。
4. 溫馨提示
當您打算從貧血域模型轉移到更豐富的領域模型時,您將立即體會到將領域級的業務邏輯封裝在領域對象中的好處。請注意,盡管如此,嘗試並不是件容易的事。在您的領域對象上創建一個方法來執行驗證,然后更新多個屬性無疑是件好事。但從領域對象發送電子郵件或保存到數據庫並不是您可能想要做的事情。重要的是要意識到,擁有豐富的領域模型並不否定另一層的需求來安排這些更高層次的關注。這是應用服務或命令處理程序的工作,具體取決於您的體系結構。
5.關於單元測試的說明
一個豐富的、自我驗證的領域模型的一個負面影響是它可以使測試變得更加困難。通過public setter,您可以簡單地將各個值分配給任何領域對象的屬性。這使您可以直接指定您需要的確切值,以便將對象置於特定狀態以進行測試。如果你鎖定你的屬性和構造函數,那么這種方法是不可能的。但這也不是一件壞事,它使單元測試變得稍微困難一點,但你所做的是確保你的測試是有效的。
另一方面,它也使得測試領域對象本身的邏輯非常簡單。盡管你的應用服務/命令處理程序的單元測試幾乎肯定會需要一定程度的模擬,但你應該發現大部分領域對象測試的構建要簡單得多,並且通常不需要依賴模擬。
6. 總結
本文介紹了三種非常簡單的技術,您可以使用Entity Framework和EF Core從貧血域模型轉換為更為豐富的領域模型。使用參數化的構造函數可以確保我們的領域模型在實例化時有效。清除公共屬性setter確保我們的模型在其整個生命周期內保持有效狀態。在領域模型上內部執行驗證和引入更改狀態的方法使我們能夠集中業務邏輯並簡化調用代碼。最后,我們考察了值對象的使用,並解釋了他們如何進一步推進了這種簡化和邏輯封裝。