在EntityFramework6中管理DbContext的正確方式

在EntityFramework6中管理DbContext的正確方式——3環境上下文DbContext vs 顯式DbContext vs 注入DbContext(外文翻譯)

(譯者注:使用EF開發應用程序的一個難點就在於對其DbContext的生命週期管理,你的管理策略是否能很好的支持上層服務 使用獨立事務,使用嵌套事務,並行執行,異步執行等需求? Mehdi El Gueddari對此做了深入研究和優秀的工作並且寫了一篇優秀的文章,現在我將其翻譯爲中文分享給大家。由於原文太長,所以翻譯後的文章將分爲四篇。你看到的這篇就是是它的第三篇。原文地址:http://mehdi.me/ambient-dbcontext-in-ef6/)

 

mvc+ef+layui組合框架的視頻案例

 

環境上下文DbContext vs 顯式DbContext vs 注入DbContext

在任何基於EF項目之初的一個關鍵決定就是你的代碼如何傳遞DbContext實例到下面真正訪問數據庫的方法裏面。

就像我們在上面看到的,創建和釋放DbContext的職責屬於頂層服務方法。數據訪問代碼,就是那些真正使用DbContext實例的代碼,可能經常在一個獨立的部分裏面——可能深入在服務實現類的一個私有方法裏面,也可能在一個查詢對象裏面或者一個獨立的倉儲層裏面。

頂層服務方法創建的DbContext實例需要找到一個傳遞到這些方法的方式。

這兒有三個想法來讓數據訪問代碼訪問DbContext:環境上下文DbContext,顯式DbContext或者注入DbContext。每一種方式都有它們各自的優缺點,讓我們來逐個分析。

顯式DbContext

它看起來是怎麼樣的

使用顯式DbContext方法,頂層服務創建一個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)
    {
        using (var context = new MyDbContext())
        {
            var user = _userRepository.Get(context, userId);
            user.IsPremiumUser = true;
            context.SaveChanges();
        }
    }
}

public class UserRepository : IUserRepository
{
    public User Get(MyDbContext context, Guid userId)
    {
        return context.Set<User>().Find(userId);
    }
}
 

 

(在這個故意爲之的示例裏面,倉儲層的作用當然是完全無意義的。在一個真實的應用程序中,你將期望倉儲層更加飽滿。另外,如果你真的不想讓你的服務直接依賴EF,你可以抽象你的DbContext爲“IDbContext”之類的並且通過一個抽象工廠來創建它。)

優點

這種方式是到目前爲止而且永遠也是最簡單的方式。它使得代碼非常簡單易懂而且易於維護——即使對於那些對代碼不是很熟悉的開發人員來說也是這樣的。

這兒沒有任何神奇的地方。DbContext實例不會憑空創建。它是在一個清晰的明顯的地方被創建——如果你好奇DbContext來源於哪兒的話你也可以通過調用棧非常容易的找到。

缺點

這種方式最主要的缺點是它要求你去污染所有你的所有倉儲方法(如果你有一個倉儲層的話),同樣你的大多服務方法也會有一個強制的DbContext參數(或者某種類型的IDbContext抽象——如果你不希望綁定到具體實現的話——但是問題仍然存在)。所以你可能會看到某些方法注入模式的應用。

你的倉儲層方法要求提供一個顯式的DbContext作爲參數也不是什麼大問題。實際上,它甚至可以看着已經好事——因爲他消除了潛在的歧義——就是這些查詢究竟用的哪一個DbContext。

但是對於服務層情況就大不一樣了。因爲大部分你的服務方法都不會用DbContext,尤其是你將數據訪問代碼隔離在一個查詢對象或者倉儲層裏面的時候。因此,這些服務方法提供了一個DbContext參數的目的僅僅是爲了將他們傳遞到下層真正需要用到DbContext的方法裏面。

這很容易變得十分醜陋。尤其是你的應用程序需要使用多個DbContext的時候,將導致你的服務方法要求兩個甚至更多的DbContext參數。這將混淆你的方法的契約——因爲你的服務方法現在強制要求一個它們既不需要也不會用而僅僅是爲了滿足底層方法依賴的參數。

Jon Skeet寫了一篇關於顯式DbContext vs 隱式DbContext的文章,但沒有提供一個好的解決方案

然而,這種方法的超級簡單性還是很難被其它方法打敗的。

環境上下文DbContext

它看起來是怎麼樣的

NHibernate用戶應當是對這種方式非常熟悉——因爲環境上下文模式(ambient context pattern)是在NHibernate世界裏面管理NHibernate的Session(它相當於EF的DbContext)的主要方式。NHibernate甚至對該模式有內置支持,叫做上下文session(contextual session)

在.NET自身,這種模式也是用得相當廣泛。你可能已經用過HttpContext.Current或者TransactionScope,兩者都是依賴於環境上下文模式。

使用這種模式,頂層服務方法不僅創建用於當前業務事務的DbContext,而且還要將其註冊爲環境上下文DbContext。然後數據訪問代碼就可以在需要時候獲取這個環境上下文DbContext了。再也不需要傳遞DbContext實例。

Anders Abel已經寫過一篇文章——簡單實現環境上下文DbContext——它依賴ThreadStatic變量來存儲DbContext。去看看吧——它比聽起來都還要更簡單。

優點

這種方式的優點是顯然的。你的服務和倉儲方法現在對DbContext參數已經自由了(也就是說服務和倉儲方法不需要DbContext作爲參數了)——讓你的接口更加乾淨並且你的方法契約更加清晰——因爲它們現在只需要獲取他們真正需要使用的參數了。再也不需要遍地傳遞DbContext實例了。

缺點

無論如何這種方式引入了一定程度的魔法——它讓代碼更難理解和維護。當看到數據訪問代碼的時候,不一定容易發現環境上下文DbContext來自於哪兒。你不得不希望在調用數據訪問代碼之前某人已經將它註冊了。

如果你的應用程序使用多個DbContext派生類,比如,如果你連接多個數據庫或者如果你將領域模型劃分爲幾個獨立的組,那麼對於頂層服務來說就很難知道應當創建和註冊哪些DbContext了。使用顯式DbContext,數據訪問方法要求提供它們需要的DbContext作爲參數,因此就不存在歧義的可能。但是使用環境上下文方式,頂層服務方法必須知道下層數據訪問代碼需要哪種DbContext類型。我們將在後面看到一些解決這個問題的十分乾淨的方式。

最後,我在上面鏈接的環境上下文DbContext例子只能在單線程模型很好的工作。如果你打算使用EF的異步查詢功能的話,它就不能工作了。在一個異步操作完成後,你很可能發現你自己已經在另外一個線程——不再是之前創建DbContext的線程。在許多情況下,它意味着你的環境上下文DbContext將消失。這個問題可以解決,但是它要求一些深入的理解——在.NET世界裏面如何多線程編程,TPL和異步工作背後的原理。我們將在文章最後看到這些。

注入DbContext

它看起來是怎麼樣的

最後一種比較重要的方式,注入DbContext方式經常被各種文章和博客提及用來解決DbContext生命週期的問題。

使用這種方式,你可以讓DI容器管理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(context, userId);

            user.IsPremiumUser = true;
        }
    }

    public class UserRepository : IUserRepository
    {
        private readonly MyDbContext _context;


        public UserRepository(MyDbContext context)
        {
            if (context == null) throw new ArgumentNullException("context");

            _context = context;
        }

        public User Get(Guid userId)
        {
            return _context.Set<User>().Find(userId);
        }
    }
 

 

然後你需要配置你的DI容器以使用合適的生命週期策略來創建DbContext實例。你將發現一個常見的建議是對於Web應用程序使用一個PerWebRequest生命週期策略,對於桌面應用使用PerForm生命週期策略。

優點

好處與環境上下文DbContext策略相似:代碼不需要到處傳遞DbContext實例。這種方式甚至更進一步:在服務方法裏面根本看不到DbContext。服務方法完全不知道EF的存在——第一眼看起來可能很好,但很快就會發現這種策略會導致很多問題。

缺點

不管這種策略有多流行,它確切是有非常重大的缺陷和限制。在採納之前先了解它是非常重要的。

太多魔法

這種方式的第一個問題就是太依賴魔法。當需要保證你的數據——你最珍貴的資產的正確性和一致性的時候,“魔法”不是你想聽到太頻繁的一個詞。

這些DbContext實例來自於哪裏?業務事務的邊界如何定義和在哪兒定義?如果一個服務方法依賴兩個不同的倉儲類,那麼這兩個倉儲式都訪問同一個DbContext實例呢還是它們各自擁有自己的DbContext實例?

如果你是一個後端開發人員並且在開發基於EF的項目,那麼想要寫出正確代碼的話,就必須知道這些問題的答案。

答案並不明顯,它需要你詳細查看DI容器的配置代碼才能發現。就像我們前面看到的,要正確設置這些配置不是第一眼看上去那麼容易,相反,它可能是非常複雜而且容易出錯的。

不清晰的業務事務邊界

可能上面示例代碼最大的問題是:誰負責提交修改到數據庫?也就是誰調用DbContext.SaveChanges()方法?它是不清晰的。

你可以僅僅是爲了調用SaveChanges()方法而將DbContext注入你的服務方法。那將是更令人費解和容易出錯的代碼。爲什麼服務方法在一個既不是它創建的又不是它要使用的DbContext對象上調用SaveChanges()方法呢?它將保存什麼修改?

另外,你也可以在你的所有倉儲對象上定義一個SaveChanges()方法——它僅僅委託給底層的DbContext。然後服務方法在倉儲對象上調用SaveChanges()方法。這也將是非常具有誤導性的代碼——因爲他暗示着每一個倉儲對象實現了它們自己的工作單元並且可以獨立於其它倉儲對象持久化自己的修改——這顯然不是正確的,因爲他們實際上是用的都是同一個DbContext實例。

有些時候你會看到還有一種方式:讓DI容器在釋放DbContext實例之前調用SaveChanges()方法。這是一個災難的方法——值得一篇文章來描述。

簡短來說,DI容器是一種基礎設施組件——它對它管理的組件的業務邏輯一無所知。相反,DbContext.SaveChanges()方法定義了一個業務事務的邊界——也就是說它是以業務邏輯爲中心的。混合兩種完全不相關的概念在一起將會引起很多問題。

話雖如此,如果你知道“倉儲層已死(Repository is Dead)”運動。誰來調用DbContext.SaveChanges()方法根本不是問題——因爲你的服務方法將直接使用DbContext實例。它們因此很自然的成爲調用SaveChanges()方法的地方。

當你使用注入DbContext策略的時候,不管你的應用程序的架構模式,你將還會遇到一些其它的問題。

強制你的服務變成有狀態的

一個值得注意的地方是DbContext不是一個服務。它是一個資源,一個需要釋放的資源。通過將它注入到你的數據訪問層。你將使那一層的所有上層——很可能就是整個應用程序,都變成有狀態的。

這當然不是世界末日但它卻肯定會讓DI容器的配置變得更復雜。使用無狀態的服務將提供巨大的靈活性並且使得配置它們的生命週期變得不會出錯。一旦你引入狀態化的服務,你就得認真考慮你服務的生命週期了。

注入DbContext這種方式在項目剛開始的時候很容易使用(PerWebRequest或者Transient生命週期都能很好的適應簡單的web應用),但是控制檯應用程序,Window服務等讓它變得越來越複雜了。

阻止多線程

另外一個問題(相對前一個來說)將不可避免的狠咬你一口——注入DbContext將阻止你在服務中引入多線程或者某種並行執行流的機制。

請記住DbContext(就像NHibernate中的Session)不是線程安全的。如果你需要在一個服務中並行執行多個任務,你必須確保每個任務都使用它們自身的DbContext實例,否則應用程序將在運行時候崩潰。但這對於注入DbContext的方式來說是不可能的事情因爲服務不能控制DbContext實例的創建。

你怎麼修復這個缺陷呢?答案是不容易。

你的第一直覺可能是將你的服務方法修改爲依賴DbContext工廠而非直接依賴DbContext。這將允許它們在需要的時候創建它們自己的DbContext實例。但這樣做將會有效地推翻注入DbContext這種觀點。如果你的服務通過一個工廠創建它們自己的DbContext實例,這些實例再也不能被注入了。那將意味着服務將顯式傳遞這些DbContext實例到下層需要它們的地方(比如說倉儲層)。這樣你又回到了之前我們討論的顯式DbContext策略了。我可以想到一些解決這些問題的方法——但所有這些方法感覺起來像不尋常手段而不是乾淨並且優雅的解決方案。

另外一種解決這個問題的方式就是添加更多複雜的層,引入一個像RabbitMQ 的中間件並且讓它爲你分發任務。這可能行得通但也有可能行不通——完全取決於爲什麼你需要引入併發性。但是在任何情況下,你可能都不需要也不想要附加的開銷和複雜性。

使用注入DbContext的方式,你最好限制你自己只使用單線程代碼或者至少是一個單一的邏輯執行流。這對於大部分應用程序都是完美的,但是在特定情況下,它將變成一個很大的限制

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章