在EntityFramework6中管理DbContext的正確方式(1)【考慮的關鍵點】

風靈使發表於2018-05-28

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

關於DbContext

這不是第一篇寫關於如何管理基於EntityFramework應用程式的DbContext的生命週期的文章。實際上,這兒已經有非常多的討論這個話題的文章了

對於許多應用程式,上面這些文章呈現的解決方案(基本上都是利用DI容器對每一個Web請求注入一個DbContext的例項)能夠很好的工作。它們的優點也是非常簡單——至少第一眼看上去是這樣的。

然而,對於某些特定的應用程式,這些解決方案天生的缺陷產生了問題。那就是一些功能變得無法實現或者需要求助於增加複雜的結構或增加醜陋的演算法來處理DbContext的建立和管理。

下面是一些真實世界的應用程式的示例,它們促使我重新思考管理DbContext的方式:

  • 一個應用程式包含 ASP.NET MVCWebAPI構建的Web應用程式。它也可能包含ConsoleWindows Services構建的後臺服務,包含任務排程服務以及那些處理來自於MSMQRabbitMQ的訊息的服務。我在上面連結的大部分文章都假定所有服務執行在Web請求的上下文裡面,但這裡涉及的情況顯然不是這樣。
  • 它從多個資料庫讀寫資料,這些資料庫包括一個主資料庫,一個從資料庫,一個報表資料庫和一個日誌資料庫。它的領域模型被分為幾個獨立的組,每一個組有自己的DbContext型別,假定只有一個DbContext型別在這兒無論如何也是行不通的。
  • 它非常依賴第三方的遠端API,比如Facebook,Twitter或者LinkedIn的API,然而這些API並不支援事務。許多使用者操作要求在返回結果給使用者之前要先進行幾個遠端API呼叫。我在上面連結的大部分文章都假定“一個Web請求只包含一個業務事務”,它應該要麼被提交要麼被回滾,很顯然這條規則在這兒不適用。因為一個遠端API呼叫失敗並不意味著我們能神奇的“回滾”之前任何一個已經完成的遠端API呼叫的結果。(例如:當你使用Facebook APIFacebook提交一個狀態更新時,你不能因為後續操作失敗而回滾向Facebook的狀態更新)。因此,在這類應用程式中,一個使用者操作經常要求我們執行多個業務事務,每個事務都能獨立的持久化。(你可能會爭辯說也許有某種方式去重新設計整個系統以避免我們遇到這種情況——當然這是可能的。但如果程式原本就設計成那樣,並且執行得很好而且我們不得不處理這種情況呢?)
  • 許多服務都嚴重並行化,要麼藉助於非同步IO或者(更常見地)通過TPL庫提供Task.Run()或者Parallel.Invoke()方法將任務分發到多個執行緒上去執行。在這種場景下,管理DbContext的大部分常見方法都不管用了。

在這篇文章中,我將深入介紹關於DbContext生命週期管理的各個部分。我們將看到解決這個問題的幾種常見策略的優缺點。最後,我們將總結出一個管理DbContext生命週期的策略,它能應對上面提到的各種挑戰。

當然,世上沒有完美的解決方案。但是在文章的最後,你將擁有為你特定的應用程式做出明智決定的工具和知識。

這是一篇非常長而且詳細的文章,它需要一定的時間去閱讀和消化。對於基於EntityFramework的應用程式,你選擇用來管理DbContext生命週期的策略將是一個重要的決定——它將對你的程式的正確性、可維護性、擴充套件性產生重大影響。因此,這值得我們多花一些時間認真考慮後再做出選擇。

本文中要用到的術語

在這篇文章中,我將多次提到“服務”這個詞,它不是指的遠端服務(REST或者其它),相反,它是指服務物件——也就是你經常放置業務邏輯實現的地方——這些物件負責執行業務規則和定義業務事務邊界。

當然,你的程式碼基可能用的不同的名字,這取決於你建立應用程式架構所使用的設計模式。因此,我所說的“服務”對你來說也可能叫做“工作流(workflow)”,“協調器(orchestrator)”,“執行者(executor)”,“interactor”,“命令(command)”,“處理者(handler)”或者其它一些名字。

更不用說還有很多應用程式根本就沒有定義一個合適的地方來存放業務邏輯,而是隨便放在一個地方,比如說MVC應用程式裡面的控制器(controllers)。

但是這些都和我們討論的話題無關——當我說“服務”的時候,就是指存放業務邏輯的地方,它可以是一個隨便的控制器(controller)方法或者是一個分層架構中的服務類。

考慮的關鍵點

當制定或者評估一個管理DbContext生命週期策略的時候,記住它要支援的關鍵場景和功能是非常重要的。

下面是一些我認為對大部分程式都很重要的東西。
你的服務必須控制業務服務的邊界(但不是必需控制DbContext的生命週期)

可能管理DbContext的主要難點是理解DbContext的生命週期與業務事務的生命週期這二者之間的差異和關聯。

DbContext實現了工作單元模式:

 維護受業務影響的物件列表,並協調變化和併發問題的解決。

在實踐中,當你用DbContext例項去載入,更新,新增和刪除可持久化的實體時,它會在記憶體中跟蹤這些變化。除非你呼叫SaveChanges()方法,否則它不會將這些變化持久化到底層資料庫。

一個服務方法,就像上面定義的,它將負責定義業務事務的邊界。

這樣的結果就是:

●一個服務方法必須用同一個DbContext例項貫穿整個業務事務,這樣才能跟蹤對可持久化物件的所有修改,並且將這些修改以原子的方式要麼提交到底層資料庫要麼回滾。

●你的服務必須是系統中唯一負責呼叫DbContext.SaveChanges()方法的元件。如果系統的其它模組(比如倉儲(repsitory)方法)呼叫SaveChanges()方法會產生什麼結果呢?它將導致提交部分變化,使你的資料處於一種不一致的狀態。

●SaveChanges()方法必需是在每個業務事務的最後剛好呼叫一次。如果在業務事務的中間呼叫也可能會導致不一致的,部分提交的狀態。

一個DbContext例項是可以跨越多個(順序的)業務事務的。一旦一個業務事務已經完成並且呼叫DbContext.SaveChanges()方法持久化了所有的修改,那麼我們在下一個業務事務中重用同一個DbContext是完全可能的。

也就是說,DbContext例項的生命週期沒有必要和一個單獨的業務事務生命週期繫結在一起。

獨立於業務事務的生命週期來管理DbContext例項的生命週期的優缺點

示例

獨立於業務事務生命週期來管理DbContext例項的生命週期的一個常見場景就是Web應用。常見的處理方式是:DbContext例項在每一個請求開始的時候就被建立,然後在這個Web請求的執行過程中被所有的服務呼叫,並且在請求結束時被釋放掉。

優點

下面是關於你為什麼要將DbContex例項的生命周管理從業務事務生命週期管理分離開的兩個重要原因。

  • 潛在的效能提升。每一個DbContext例項都維護了一個從資料庫載入的物件的一級快取。當你通過主鍵查詢一個實體時,DbContext將優先從一級快取獲取它,在獲取不到時,才會嘗試從資料庫查詢。取決於你的資料查詢模式,在多個順序的業務事務中重用同一個DbContext將會因為一級快取而導致更少的資料庫查詢。

  • 更多使用延遲載入的場景。如果服務返回可持久化物件(而不是view models或者其它DTOs)那麼你就可以利用這些物件的延遲載入功能,載入這些實體的DbContext例項的生命週期必須超越業務事務的範圍。如果服務在返回實體物件之前就釋放掉了DbContext例項,那麼在這些返回物件上觸發的任何延遲載入屬性都將失敗(是否使用延遲載入功能是另一種爭論,本文不做深入討論)。在我們的Web應用示例中,延遲載入常用於服務層返回到控制器動作方法(controller action method)的實體上。這種情況下,服務方法用來載入實體的DbContext例項的生命週期將在整個Web請求過程中(或者至少持續到動作方法的結束)保持啟用狀態。

保持DbContext超越業務事務範圍都處於啟用狀態帶來的問題

雖然跨越多個業務事務重用同一個DbContext是可以的,但是DbContext的生命週期還是應該保持得短一些。它的一級快取最終會過時,並導致併發問題。如果你的應用程式使用樂觀併發策略的話,那麼業務事務將會因為DbUpdateConcurrencyException而失敗。在Web應用中使用“一個web請求一個DbContext例項”這種策略通常是可以的——因為Web請求時間通常很短。但是在桌面應用中,經常被建議使用的策略是使用“一個視窗(form)一個DbContext例項”,但這會經常出現問題——因此在採納前應多考慮。

需要注意的是如果你用悲觀併發策略的話,那麼你不能跨越多個業務事務重用同一個DbContext例項。正確地實現悲觀併發策略牽涉到在整個DbContext例項的生命週期中都要以正確的資料庫隔離級別保持一個啟用的資料庫事務——這將防止你在獨立的業務事務中獨立的提交或者回滾資料。

在超過一個業務事務中重用同一個DbContext例項也可能會導致災難性的bug——服務方法可能意外的提交了來自上一個失敗的業務事務的修改。

最後,在你的服務方法的外面來管理DbContext例項的生命週期會傾向於把你的應用程式和一個制定的基礎架構繫結在一起——從長遠來看,這使你的應用程式更加不靈活並且更難演進和維護。

例如,對於一個剛開始很簡單的Web應用程式來說,它依賴於“一個Web請求建立一個DbContext例項”的策略來管理DbContext的生命週期時,這很容易掉進一個圈套——在控制器(controller)或者檢視(view)中使用延遲載入功能或者在服務方法之間傳遞可持久化物件——假定這些場景都會使用同一個DbContext例項。當不可避免地要引入多執行緒或者轉移這些操作到後臺WindowsService去的時候,這些精心設計的沙堡就崩塌了——因為這兒沒有更多的Web請求來繫結DbContext例項了。

因此,建議避免獨立於業務事務來管理DbContext例項的生命週期。應當在每一個服務方法內部建立它們自己的DbContext例項,並在業務事務結束的時候釋放該例項。

這將防止在服務外面使用延遲載入(也可以讓服務方法返回傳輸物件而非可持久化物件來防止在服務外面使用延遲載入)。另外,最好不要傳遞可持久化物件給服務——因為這些物件沒有依附在服務將要使用的DbContext上。雖然有這麼多限制,但從長遠來看,它將給我們帶來很好的靈活性和可維護性。

你的服務必須控制資料庫事務的範圍和隔離級別

如果你的應用程式使用的資料庫提供的事務支援ACID四個要素(如果你用的是EntityFramework,那麼肯定就是了),那麼你的服務控制資料庫事務的範圍和隔離級別就很有必要了,否則你就不能寫出正確的程式碼。

我們將在後面看到,EntityFramework將所有的寫操作用一個顯示資料庫事務打包在一起——預設情況下,將用READ COMMITTED 隔離級別——也就是SQL Server的預設設定——這能適合大部分業務事務。尤其是你依賴於樂觀併發策略去檢測和避免”更新衝突“的情況,更是如此。

無論如何,大部分應用程式仍然需要為某些特定的操作使用其它的隔離級別。

比如,執行報表查詢的場景,你可能會覺得髒讀不是問題從而選擇使用READ UNCOMMITTED隔離級別——這樣可以消除鎖競爭。

並且有的業務規則可能要去使用REPEATABLE READ 或者 SERIALIZABLE 隔離級別(尤其是你的專案使用悲觀併發策略的話)。這些場景就需要服務顯示控制事務範圍了。

管理DbContext的方式應當獨立於系統架構

軟體系統架構和設計模式會隨著時間進化以適應新的業務規則和負載升級。

你肯定不想因為選擇的管理DbContext生命週期的策略繫結在一個特定的架構而限制你對其進行升級。

管理DbContext的方式應當獨立於應用程式型別

雖然現在大部分應用程式都開始於一個Web應用程式,但是你選擇用來管理DbContext生命週期的策略不應當假定你的服務方法只會在基於Web請求的上下文中被呼叫。一般來說,你的服務層(如果有的話)應當獨立於使用它的應用程式的型別。

在應用程式啟動不久後,你就可能需要建立命令列工具去執行運維任務或者建立Windows服務來處理定時任務或者需要長時間執行的後臺操作。當這種情況發生的時候,你期望能夠為你的控制檯應用程式或者Windows服務程式引用你的服務所在的程式集。你肯定不願看到需要完全重構管理DbContext例項的方式後你的服務才能被不通的應用程式型別使用。

管理DbContext的方式應當支援多種DbContext派生類

如果你的應用程式需要訪問多個資料庫(比如報表資料庫,日誌/審計資料庫)或者你將你的領域模型分離成多個聚合組,那麼你就將有多個DbContext派生類。

對於NHibernate使用者來說,這就相當於管理多個SessionFactory例項。

無論你選擇哪種策略都應當能讓服務選擇需要的DbContext型別。

管理DbContext的方式應當能支援EF6提供的非同步工作流

.NET 4.5中,ADO.NET引入了支援非同步資料庫查詢的功能。隨後非同步支援也被包括在EntityFramework6中——它允許你使用一個完整的非同步工作流來讀寫資料。

無需多說,無論你選擇什麼來管理DbContext,但它必須能很好地與EF非同步功能協調工作。

(譯者注:下一篇,我們將看到DbContext的一些預設行為)

相關文章