[Paul Hiles: 3 ways to avoid an anemic domain model in EF Core :https://www.devtrends.co.uk/blog/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:http://idior.cnblogs.com/articles/325036.html])使用領域服務,但它們的優勢,特別是簡單性方面的優勢遠不是那麼明顯。
通常DDD概念中可以簡化程式碼的是我們將在下麵討論的值物件的使用。
3.3.引入值物件
[值物件:https://martinfowler.com/bliki/ValueObject.html]是不可變的(實體化後不允許更改)沒有身份標識的物件。值物件通常可以用來代替領域物件中的一個或多個屬性。
值物件的經典示例包括貨幣,地址和坐標,但也可以使用值型別替換單個屬性,而不是使用字串或整型。例如,不是將電話號碼儲存為字串,而是可以建立一個帶有內建驗證的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確保我們的模型在其整個生命週期內保持有效狀態。在領域模型上內部執行驗證和引入更改狀態的方法使我們能夠集中業務邏輯並簡化呼叫程式碼。最後,我們考察了值物件的使用,並解釋了他們如何進一步推進了這種簡化和邏輯封裝。