在EntityFramework6中管理DbContext的正確方式(3)【環境上下文DbContext vs 顯式DbContext vs 注入DbContext】

風靈使發表於2018-05-28

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

環境上下文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世界裡面管理NHibernateSession(它相當於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(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的方式,你最好限制你自己只使用單執行緒程式碼或者至少是一個單一的邏輯執行流。這對於大部分應用程式都是完美的,但是在特定情況下,它將變成一個很大的限制。

相關文章