(譯者注:使用EF開發應用程序的一個難點就在於對其DbContext的生命周期管理,你的管理策略是否能很好的支持上層服務 使用獨立事務,使用嵌套事務,並行執行,異步執行等需求? Mehdi El Gueddari對此做了深入研究和優秀的工作並且寫了一篇優秀的文章,現在我將其翻譯為中文分享給大家。由於原文太長,所以翻譯后的文章將分為四篇。你看到的這篇就是是它的第四篇。原文地址:http://mehdi.me/ambient-dbcontext-in-ef6/)
DbContextScope:一個簡單的,正確的並且靈活的管理DbContext實例的方式
應當是來看看一種更好地管理這些DbContext實例方式的時候了。
在下面呈現的方式依賴於DbContextScope,它是一個定制的組件,實現了上面說到的環境上下文DbContext方式。DbContextScope和它依賴的相關類的源代碼都放到了GitHub上面。
如果你熟悉TransactionScope類,那么你就已經知道如何使用一個DbContextScope了。它們在本質上十分相似——唯一的不同是DbContextScope創建和管理DbContext實例而非數據庫事務。但是就像TransactionScope一樣,DbContextScope是基於環境上下文的,可以被嵌套,可以有嵌套行為被禁用,也可以很好地與異步工作流協作。
下面是DbContextScope的接口:
public interface IDbContextScope : IDisposable { void SaveChanges(); Task SaveChangesAsync(); void RefreshEntitiesInParentScope(IEnumerable entities); Task RefreshEntitiesInParentScopeAsync(IEnumerable entities); IDbContextCollection DbContexts { get; } }
DbContextScope的目的是創建和管理在一個代碼塊內使用的DbContext實例。一個DbContextScope因此有效的定義了一個業務事務的邊界。我將在后面解釋為什么我沒有將其命名為“工作單元(UnitOfWork)”或者“工作單元范圍(UnitOfWorkScope)”——它們擁有更廣泛的使用場景。
你可以直接實例化一個DbContextScope,你也可以依賴IDbContextScopeFactory——它提供一個方便的方法並使用最常見的配置來創建一個DbContextScope:
public interface IDbContextScopeFactory { IDbContextScope Create(DbContextScopeOption joiningOption = DbContextScopeOption.JoinExisting); IDbContextReadOnlyScope CreateReadOnly(DbContextScopeOption joiningOption = DbContextScopeOption.JoinExisting); IDbContextScope CreateWithTransaction(IsolationLevel isolationLevel); IDbContextReadOnlyScope CreateReadOnlyWithTransaction(IsolationLevel isolationLevel); IDisposable SuppressAmbientContext(); }
典型用法
使用DbContextScope,你的典型服務方法將看起來是這樣的:
public void MarkUserAsPremium(Guid userId) { using (var dbContextScope = _dbContextScopeFactory.Create()) { var user = _userRepository.Get(userId); user.IsPremiumUser = true; dbContextScope.SaveChanges(); } }
在一個DbContextScope里面,你可以用兩種方式訪問scope管理的DbContext實例。你可以像下面這樣通過DbContextScope.DbContexts屬性獲取它們:
public void SomeServiceMethod(Guid userId) { using (var dbContextScope = _dbContextScopeFactory.Create()) { var user = dbContextScope.DbContexts.Get<MyDbContext>.Set<User>.Find(userId); [...] dbContextScope.SaveChanges(); } }
但那當然也是DbContextScope在方法里面提供的唯一方式。如果你需要在其它地方(比如說倉儲類)訪問環境上下文DbContext實例,你可以依賴IAmbientDbContextLocator,像下面這樣使用:
public class UserRepository : IUserRepository { private readonly IAmbientDbContextLocator _contextLocator; public UserRepository(IAmbientDbContextLocator contextLocator) { if (contextLocator == null) throw new ArgumentNullException("contextLocator"); _contextLocator = contextLocator; } public User Get(Guid userId) { return _contextLocator.Get<MyDbContext>.Set<User>().Find(userId); } }
這些DbContext實例是延遲創建的並且DbContextScope跟蹤它們以確保在它的范圍內任何DbContext派生類只會被創建一個實例。
你將注意到服務方法在整個業務事務范圍內不需要知道究竟需要哪種DbContext派生類型。它僅僅需要創建一個DbContextScope並且在其范圍內的需要訪問數據庫的任何組件都能獲取到它們需要的DbContext。
嵌套范圍(Nesting Scopes)
一個DbContextScope當然可以被嵌套。讓我們假定你已經有一個服務方法,它將用戶標記為優質用戶,像下面這樣:
public void MarkUserAsPremium(Guid userId) { using (var dbContextScope = _dbContextScopeFactory.Create()) { var user = _userRepository.Get(userId); user.IsPremiumUser = true; dbContextScope.SaveChanges(); } }
你正在實現一個新的功能,它要求能在一個業務事務內標記一組用戶為優質用戶。你可以像下面這樣很容易的完成它:
public void MarkGroupOfUsersAsPremium(IEnumerable<Guid> userIds) { using (var dbContextScope = _dbContextScopeFactory.Create()) { foreach (var userId in userIds) { // 通過MarkUserAsPremium()創建的子范圍將加入我們的范圍, // 因此它能重用我們的DbContext實例,並且對SaveChanges() // 的調用將沒有任何作用。 MarkUserAsPremium(userId); } // 修改都將只有在這兒才能被保存,在頂層范圍內,以確保所有的修改 // 以原子的行為要么提交要么回滾。 dbContextScope.SaveChanges(); } }
(當然這是實現這個指定功能的一種非常不高效的方式,但是它說明了如何實現嵌套事務(范圍))
這使得創建一個能組合使用多個其它多個服務方法的服務成為可能。
只讀范圍(Read-only scopes)
如果一個服務方法是只讀的,那么在方法返回之前必須在DbContextScope上調用SaveChanges()方法將是痛苦的,但是如果不調用也有不妥,因為:
1.它將使代碼審查和維護更困難(你究竟是有意沒有調用SaveChanges()還是你忘了調用呢?)
2.如果你開啟一個顯式數據庫事務(我們將在后面看到如何這樣做),不調用SaveChanges()將導致事務被回滾。數據庫監控系統將通常認為事務回滾意味着應用程序錯誤。造成一種假的回滾不是一個好主意。
DbContextReadOnlyScope用來解決這個問題。下面是它的接口:
public interface IDbContextReadOnlyScope : IDisposable { IDbContextCollection DbContexts { get; } }
你可以像下面這樣使用它:
public int NumberPremiumUsers() { using (_dbContextScopeFactory.CreateReadOnly()) { return _userRepository.GetNumberOfPremiumUsers(); } }
異步支持
DbContextScope將如你期望的能很好的在異步執行流中工作:
public async Task RandomServiceMethodAsync(Guid userId) { using (var dbContextScope = _dbContextScopeFactory.Create()) { var user = await _userRepository.GetAsync(userId); var orders = await _orderRepository.GetOrdersForUserAsync(userId); [...] await dbContextScope.SaveChangesAsync(); } }
在上面的例子中,OrderRepository.GetOrdersForUserAsync()方法將能看到並且訪問環境上下文DbContext實例——盡管事實上它是在另一個線程而非DbContextScope最初被創建的線程上被調用。
使這一切成為可能的原因是DbContextScope將它自己存儲在CallContext上面的。CallContext通過異步點自動流轉。如果你對它背后的工作原理很好奇,Stephen Toub已經寫過一篇關於它的優秀文章。但是如果你想要的只是使用DbContextScope,你只需要知道:它就是能工作。
警告:當你在異步流中使用DbContextScope的時候,有一件事情你必須記住:就像TransactionScope,DbContextScope僅支持在一個單一的邏輯流中使用。
也就是說,如果你嘗試在一個DbContextScope范圍內開啟多個並行任務(比如說創建多個線程或者多個TPL任務),你將陷入大麻煩。這是因為環境上下文DbContextScope將流轉到你並行任務使用的所有線程。如果在這些線程中的代碼需要使用數據庫,它們就都將使用同一個環境上下文DbContext實例,導致多個線程同時使用同一個DbContext實例。
通常,在一個單獨的業務事務中並行訪問數據庫沒有什么好處除了增加復雜性。在業務事務中的任何並行操作都應當不要訪問數據庫。
無論如何,如果你針對需要在一個DbContextScope里面開啟一個並行任務(比如說你要通過業務事務的結果獨立的執行一些后台處理),你必須在開啟並行任務之前禁用環境上下文DbContextScope,你可以像下面這樣簡單處理:
public void RandomServiceMethod() { using (var dbContextScope = _dbContextScopeFactory.Create()) { // 使用環境上下文context執行一些代碼 [...] using (_dbContextScopeFactory.SuppressAmbientContext()) { // 在這兒,開啟的並行任務將不能使用環境上下文context. [...] } // 在這兒,環境上下文將再次變為可用。 // 可以像平常一樣執行更多的代碼 [...] dbContextScope.SaveChanges(); } }
創建一個非嵌套的DbContextScope
這是一個我期望大部分應用程序永遠不需要用到的高級功能。當使用它的時候要認真對待——因為它能導致一些詭異的問題並且很快導致維護的惡魔。
有些時候,一個服務方法可能需要將變化持久化到底層數據庫而不管整個業務事務的結果,就像下面這些情況:
1.需要在一個全局的地方記錄不應當回滾的信息——即使業務事務失敗。一個典型的例子就是日志或者審計記錄。
2.它需要記錄一個不能回滾的操作的結果。一個典型的例子就是服務方法和非事務性的遠程服務或者API交互。例如,如果你的服務方法使用Facebook API提交一個狀態更新然后在本地數據庫記錄新創建的狀態。這個記錄必須被持久化即使整個業務事務因為在調用Facebook API后出現一些錯誤而導致的失敗。Facebook API不是事務性的——你不可能去“回滾”一個Facebook API調用。那個API調用的結果將永遠不會回滾。
在那種情況下,當創建一個新的DbContextScope的時候,你可以傳遞DbContextScopeOption.ForceCreateNew的值作為joiningOption參數。這將創建一個不會加入環境上下文范圍(如果存在一個的話)的DbContextScope:
public void RandomServiceMethod() { using (var dbContextScope = _dbContextScopeFactory.Create(DbContextScopeOption.ForceCreateNew)) { // 我們將創建一個新的范圍(scope),即使這個服務方法 // 被另一個已經創建了它自己的DbContextScope的服務方 // 法調用。我們將不會加入它。 // 我們的范圍將創建新的DbContext實例並且不會重用 // 父范圍的DbContext實例。 //[...] // 由於我們強制創建了一個新的范圍,這個對SaveChanges() // 的調用將會持久化我們的修改——不管父范圍(如果有的話) // 是否成功保存了它的變化。 dbContextScope.SaveChanges(); } }
這樣處理的最大問題是服務方法將使用獨立的DbContext實例而非業務事務中的其它DbContext。為了避免詭異的bug和維護惡魔,下面列出了一些要服從的基本規則:
服務方法返回的持久化實體必須總是依附(Attach)在環境上下文DbContext上
如果你強制創建一個新的DbContextScope而非加入一個已經存在的上下文環境DbContextScope,你的服務方法必須不能返回它在新的范圍(scope)創建或者獲取的實體。否則將導致巨大的復雜性。
調用你服務方法的客戶端代碼可能是一個創建了它自己的DbContextScope的服務方法。因此它期望它調用的所有服務方法都使用相同的環境上下文DbContextScope(這是使用環境上下文Context的立足點)。很自然的期望通過你的服務方法返回的實體都依附(attach)在環境上下文DbContext上。
相反,你也可以采取下面兩種策略:
不要返回持久化實體。這是最容易,最干凈的方法。比如,如果你的服務方法創建一個新的領域對象,不要返回它,而是返回它的Id並且讓客戶端在它自己的DbContext上面加載這個實體(如果客戶端真的需要這個實體的話)。
如果你無論如何也要返回一個持久化實體的話,切換回環境上下文DbContext,加載實體並將其返回。
在退出時,一個服務方法必須確保對持久化對象的所有修改都已經在父范圍中重現
如果你的服務方法強制創建了一個新的DbContextScope並且在這個新的范圍里面修改了持久化對象,必須確保在返回的時候父范圍(如果存在的話)能“看到”這些修改。
也就是說,如果父范圍的DbContext實例已經加載了你修改過的實體在它的一級緩存中(ObjectStateManager),你的服務方法必須刷新這些實體以確保父范圍不會使用這些對象的過時版本。
DbContextScope提供了一個快捷方法來幫助處理這個問題:
public void RandomServiceMethod(Guid accountId) { // 強制創建一個新范圍(也就是說,我們將使用我們自己 // 的DbContext 實例) using (var dbContextScope = _dbContextScopeFactory.Create(DbContextScopeOption.ForceCreateNew)) { var account = _accountRepository.Get(accountId); account.Disabled = true; // 由於我們強制創建了一個新的范圍,這將持久化我 // 們的變化到數據庫而不管父范圍的處理成功與否。 dbContextScope.SaveChanges(); // 如果這個方法的調用者已經加載過account對象到 // 它們的DbContext實例中,它們的版本現在已經變 // 得過時了。它們將看不到這個account已經被禁用 // 並且可能因此執行一些錯誤的邏輯。 // 因此需要確保我們的調用者的版本要保持更新。 dbContextScope.RefreshEntitiesInParentScope(new[] { account }); } }
為什么命名為DbContextScope而不是UnitOfWork(工作單元)?
我寫的DbContextScope的第一個版本確實被命名為UnitOfWork,這可以說是這種類型的組件最常用的名稱。
但是當我嘗試在現實程序中使用那個UnitOfWork組件的時候,我一直很困惑——我應該如何使用它和它真的做了什么——盡管我是那個研究,設計和實現了它的人並且我還對它能做什么以及如何工作都了如指掌。然而,我仍然很困難並且不得不倒退一步去仔細回想這個“unit of work”怎樣關聯我要嘗試解決的實際問題:管理我的DbContext實例。
如果即使我——那個花了很多時間去研究,設計和實現了這個組件的人在嘗試使用的時候都變得很困惑的話,要讓其他人來容易的使用它——這恐怕是沒什么希望了。
因此我將其重命名為DbContextScope並且突然所有的事情都變得清晰明朗了。
使用UnitOfWork最主要的問題我相信是在應用程序級別的,它通常沒有什么意義。在一個更低的層次,比如數據庫級別,一個“unit of work”是一個非常清晰並且具體的概念。下面是Martin Fowler對unit of work的定義:
維護受業務影響的對象列表,並協調變化和並發問題的解決。
在數據庫級別,unit of work要表達的東西沒有二義性。
然而在一個應用程序級別,一個”unit of work”是一個非常模糊的概念——它可能指所有東西,但又可能什么都不是。並且這個“unit of work”如何關聯到EF是不清晰的——對於管理DbContext實例的問題,對於我們操作的持久化對象依附到正確的DbContext實例上的問題。
因此,任何開發人員在嘗試使用一個”UnitOfWork”的時候都會搜索它的源代碼去查看它究竟做了什么。工作單元(unit of work)模式的定義太過於模糊以至於在應用程序級別沒什么用處。
實際上,對大部分應用程序,一個應用程序級別的“unit of work”甚至沒有任何意義。許多應用程序在業務事務中不得不使用幾個非事務性的服務,比如遠程API或者非事務性的遺留組件。這些地方做出的修改不能被回滾。假裝這些不存在是反效率的,迷惑的並且甚至更難寫出正確的代碼。
相反,DbContextScope剛好完成了它需要完成的工作,不多,不少。它沒有假裝成別的東西。並且我發現這個簡單的更名有效的減少了使用這個組件的認知負荷和去驗證是否正確的使用了它。
當然,將這個組件命名為DbContextScope就再也不能掩蓋你的服務方法正在使用EF的事實。UnitOfWork是一個非常模糊的概念——它允許抽象在底層使用的持久化機制。從你的服務層中抽象EF是否是一件好事是一個另外爭論——我們在這兒就不深入它了。
直接去看看吧
放在GitHub上的源代碼包括了一個demo程序來演示大部分的使用場景
DbContextScope是如何工作的
源代碼已經做了很好的注釋並且我鼓勵你通讀它。另外,Stephen Toub寫的這篇放到ExecutionContext的優秀文章是必讀的——如果你想要完全理解DbContextScope中的環境上下文context模式是如何實現的話。
延伸閱讀
EF團隊的項目經理Rowan Miller,他的個人博客,對於用EF開發項目的任何開發人員來說都是必須要去讀的。
額外資料
哪些地方不能創建你的DbContext實例
在現實程序中經常看到的一個使用EF的反模式是將創建和釋放DbContext的代碼都放到數據訪問方法里面(也就是在傳統三層架構中的倉儲方法里面)。它通常看起來像這樣:
public class UserService : IUserService { private readonly IUserRepository _userRepository; public UserService(IUserRepository userRepository) { if (userRepository == null) throw new ArgumentNullException("userRepository"); _userRepository = userRepository; } public void MarkUserAsPremium(Guid userId) { var user = _userRepository.Get(userId); user.IsPremiumUser = true; _userRepository.Save(user); } } public class UserRepository : IUserRepository { public User Get(Guid userId) { using (var context = new MyDbContext()) { return context.Set<User>().Find(userId); } } public void Save(User user) { using (var context = new MyDbContext()) { // [...] // (要么將提供的實體依附在context上,要么從context加載它, // 並且從提供的實體更新它的字段) context.SaveChanges(); } } }
通過這樣處理,你基本上失去了EF通過DbContext提供的每一個功能,包括它的一級緩存,它的標識映射(Identity map),它的工作單元(unit-of-work),它的變更追蹤和延遲加載功能。因為在上面的場景中,對於每一個數據庫查詢都將創建一個新的DbContext實例並且隨后立即就被釋放掉,因此阻礙了DbContext實例去跟蹤你的整個業務事務范圍內的數據的狀態。
你有效的將EF簡化為一個簡單ORM框架:一個將你的對象與它在數據庫中的關系表現映射的工具。
這種架構對於一些應用程序是說得通的。如果你工作在這樣一個應用程序,無論如何你應當首先問你自己為什么要用EF。如果你要將它作為一個簡單ORM框架並且不用它提供的任何主要功能,你可能使用一個輕量級的ORM框架(比如Dapper)會更好。因為它將會簡化你的代碼並且由於沒有EF附加功能的開銷而提供更好的性能。
