應用程序框架實戰十九:工做單元層超類型

  上一篇介紹了DDD聚合以及與併發相關的各類鎖機制,本文將介紹另外一個核心元素——工做單元,它是實現倉儲的基礎。程序員

什麼是工做單元                                                  

  維護受業務事務影響的對象列表,並協調變化的寫入和併發問題的解決。數據庫

  這是《企業應用架構模式》中給出的定義,不過看上去有點抽象。它大概的意思是說,對多個操做進行打包,記錄對象上的全部變化,並在最後提交時一次性將全部變化經過系統事務寫入數據庫架構

  固然,工做單元不必定是針對數據庫的,不過大部分程序員仍是工做在關係數據庫中,因此我默認你也在使用關係數據庫,由此產生的不許確性你就不要再計較了。併發

  初步看上去,工做單元與事務頗爲相像,一個事務也會包裝多個數據庫操做,並在最後提交更改。不過工做單元與事務具備更多的不一樣,事務的關鍵特徵是支持ACID原則,工做單元並不須要實現得這麼複雜,工做單元只是將全部修改狀態保存下來,在提交時委託給事務完成。因此工做單元自己不具備隔離性,這意味着工做單元只能在單線程中工做,若是同時讓多個線程訪問工做單元,就會致使數據錯亂。框架

  工做單元對併發的協調,是依靠聚合根上的樂觀離線鎖,以及數據庫事務的併發控制能力來共同完成的,對併發控制更具體的討論,請參考本系列的前一篇。性能

  .Net從出山以來,就提供了一個強大的工做單元,這就是DataTable。回想當年使用GridView控件的情形,直接把GridView綁定到一個DataTable,而後在GridView上任意編輯,最後調用DataTable的AcceptChanges方法,全部修改就保存到數據庫了。學習

  .Net數據訪問技術不斷推陳出新,特別是推出Entity Framework Code First以後,新一代的工做單元DbContext成爲數據訪問的中心。部分懼怕學習新技術的.Net程序員,還在吃着老本,不過面向對象開發大勢所趨,DataTable已退居二線。優化

工做單元的做用                                                  

減小數據庫調用次數

  若是沒有工做單元,那麼每次對數據的新增、修改、刪除操做,都須要實時提交到數據庫,從而形成頻繁調用數據庫而下降性能。特別是對同一個對象屢次更新,將形成更多的沒必要要浪費。ui

避免數據庫長事務

  對於一個複雜的業務過程,爲了保證數據一致性,能夠將其放入一個數據庫事務中。但因爲操做步驟繁多,且有可能須要與外界進行交互(好比須要調用第三方系統的一個遠程接口),從而致使一個須要很長時間才能完成的長事務。this

  以前已經提過,事務的使用要點是執行要儘可能快,由於在事務開啓後,會鎖定大量資源,特別是可能獲取到獨佔鎖而致使讀寫阻塞,因此開啓事務後必須迅速結束戰鬥。

  使用工做單元之後,全部的操做都和事務無關,只在最後一步提交時與事務打交道,因此事務的執行時間很是短,從而大幅提高性能。

工做單元的要點與注意事項                                                  

在單線程中使用工做單元

  若是將工做單元實例設置爲靜態,讓全部線程同時操做該工做單元,會發生什麼狀況?

  一種狀況是多我的同時修改一個對象,當提交工做單元時,一部分人的數據被另外一部分人覆蓋,形成丟失更新,而且不會觸發樂觀併發異常,由於是在同一個事務中進行修改。

  另外一種狀況,有人在操做工做單元,正操做到一半,另一位老兄忽然提交了工做單元,一半數據被保存到數據庫了,致使很嚴重的數據不一致。

  工做單元通常經過Ioc框架注入到倉儲中,若是把工做單元的生命週期設爲單例,就有可能發生上面的狀況。

爲多個倉儲注入相同的工做單元實例

  當同時操做多個聚合時,最簡單的辦法是把它們做爲一個數據庫事務提交。每一個聚合擁有一個倉儲,若是爲不一樣倉儲注入不一樣的工做單元實例,而且沒有用TransactionScope控制,那麼每一個倉儲將提交獨立的事務,這將致使數據的不一致。

  咱們使用Entity Framework,會爲每一個數據庫建立一個DbContext的工做單元子類。當多個倉儲操做同一個數據庫時,只須要把同一個工做單元實例注入到多個倉儲中,在每一個倉儲中操做的都是同一個工做單元,這保證了在同一個事務中提交全部更新,甚至TransactionScope都不是必須的。

  以Autofac依賴注入框架爲例,爲Mvc環境下配置Ioc,須要先引入Autofac.Integration.Mvc程序集,並設置工做單元的生命週期爲InstancePerLifetimeScope,這樣就保證了每次Http請求都可以建立新的工做單元實例,而且在本次請求中共享同一個。

工做單元層超類型實現                                                  

  咱們使用Entity Framework Code First,工做單元已經被DbContext實現了,不過爲了讓倉儲用起來更方便一些,須要定義本身的工做單元接口。下面將介紹工做單元層超類型是如何演化出來的。

  如今假定DbContext有一個子類TestContext,TestContext的實例爲context。

  添加一個用戶的代碼以下。

userRepository.Add( user );
context.SaveChanges();

  上面兩行代碼的主要問題是,哪怕你只執行一個操做,好比Add,也須要寫兩行代碼,SaveChanges在這種狀況下是不必的。

  爲了解決這個問題,一些兄臺在全部更新數據的方法上,加一個bool參數,以指示是否當即提交工做單元,好比Add(TEntity entity, bool isSave = true),默認狀況下,你不加bool參數,說明須要當即提交,這樣就能夠省掉SaveChanges。

  這種方法我也採用了一段時間,發現有兩個問題。

  第一,致使醜陋的API

  若是我如今要添加三個用戶,代碼以下。

userRepository.Add( user1,false ); userRepository.Add( user2,false ); userRepository.Add( user3,false ); context.SaveChanges();

  能夠看見,雖然解決了可能多寫一行SaveChanges代碼的問題,卻增長了一個額外的參數,這簡直是拆東牆補西牆。不過這個問題還不算嚴重,長得醜仍是能夠忍受,看久了就行了,但短胳膊少腿就要命了。

  第二,可能致使提交多個事務,從而破壞數據一致性。

  如今要添加10個用戶,代碼以下。

userRepository.Add( user1,false ); userRepository.Add( user2,false ); userRepository.Add( user3,false ); userRepository.Add( user4,false ); userRepository.Add( user5 ); userRepository.Add( user6,false ); userRepository.Add( user7,false ); userRepository.Add( user8,false ); userRepository.Add( user9,false ); userRepository.Add( user10,false ); context.SaveChanges();

  注意看user5,false參數忘了,因此運行到user5的時候,事務已經提交了,若是在執行最後的SaveChanges失敗,而前面成功,則致使數據不一致,這是致命的錯誤,並且這樣的錯誤很難查找。若是像我上面同樣,所有寫到一個方法中,而且沒有其它代碼,可能很容易找到問題。但這些操做可能分散到多個方法,並且夾雜其它代碼,查找問題就很困難了。另外這段代碼只有在特定輸入條件下才會失敗,因此你不會立刻發現Bug所在,最終你花了大半天把問題找到,用了10秒就修復了,你笑一笑「一個小Bug」。注意,大部分難搞的Bug都是很不起眼的,若是很容易就想到它,反而容易解決,因此可以從框架上避免的低級錯誤,你應該儘可能上移,以避免你隨時提心吊膽。

  解決這個問題的一個更好辦法是模擬一個事務操做,回想一下Ado.Net的Transaction是怎麼使用的。

var transaction = con.BeginTransaction(); //執行Sql
transaction. Commit();

  分析Add(TEntity entity, bool isSave = true),能夠發現bool參數用於標識是否須要當即提交工做單元,因此咱們能夠把bool標識移到工做單元內部,並模擬一個事務操做。從這裏能夠看出,一個好的設計,不是你一步就能想到的,這是一個長期思考和優化的過程,而且是你們共同討論的結果。

  下面的代碼演示了設計最新的變化。

context.BeginTransaction();
userRepository.Add( user1);
userRepository.Add( user2);
userRepository.Add( user3);
context.SaveChanges();

  還有一個值得重構的地方,就是命名,由於並不真正開啓一個事務,可能產生誤導,再把名字改得高大上一些。

unitOfWork.Start();
userRepository.Add( user1);
userRepository.Add( user2);
userRepository.Add( user3);
unitOfWork.Commit();

  工做單元Api的設計,以及對倉儲的影響介紹完了,下面開始實現代碼。

  新建一個Util.Datas.Ef的程序集,引用相關依賴,我這裏使用的是Entity Framework 6.1.1。

  在Util程序集中建立一個Datas文件夾,添加一個IUnitOfWork接口,代碼以下。 

using System; namespace Util.Datas { /// <summary>
    /// 工做單元 /// </summary>
    public interface IUnitOfWork : IDisposable { /// <summary>
        /// 啓動 /// </summary>
        void Start(); /// <summary>
        /// 提交更新 /// </summary>
        void Commit(); } }

  爲了實現工做單元,還須要添加兩個異常類,一個用於樂觀併發處理,另外一個用於獲取Entity Framework驗證異常消息。

  在Util程序集中建立Exceptions文件夾,添加ConcurrencyException類,添加它的緣由是,我不想在領域層中捕獲DbUpdateConcurrencyException,由於須要引用EntityFramework程序集,另一個緣由是能夠添加一些本身須要的異常屬性。代碼以下。 

using System; using Util.Logs; namespace Util.Exceptions { /// <summary>
    /// 併發異常 /// </summary>
    public class ConcurrencyException : Warning{ /// <summary>
        /// 初始化併發異常 /// </summary>
        /// <param name="exception">異常</param>
        public ConcurrencyException( Exception exception ) : this( "", exception ) { } /// <summary>
        /// 初始化併發異常 /// </summary>
        /// <param name="message">錯誤消息</param>
        /// <param name="exception">異常</param>
        public ConcurrencyException( string message, Exception exception ) : this( message, exception,"" ) { } /// <summary>
        /// 初始化併發異常 /// </summary>
        /// <param name="message">錯誤消息</param>
        /// <param name="exception">異常</param>
        /// <param name="code">錯誤碼</param>
        public ConcurrencyException( string message, Exception exception ,string code) : this( message,exception, code, LogLevel.Error ) { } /// <summary>
        /// 初始化併發異常 /// </summary>
        /// <param name="message">錯誤消息</param>
        /// <param name="exception">異常</param>
        /// <param name="code">錯誤碼</param>
        /// <param name="level">日誌級別</param>
        public ConcurrencyException( string message, Exception exception,string code, LogLevel level ) : base( message, code,level, exception ) { } } }

  在Util.Datas.Ef程序集中建立Exceptions文件夾,添加EfValidationException類,添加它的緣由是,DbEntityValidationException類的驗證錯誤消息藏得很深,我用EfValidationException將異常獲取出來,並添加到異常的Data鍵值對中。 

using System.Data.Entity.Validation; namespace Util.Datas.Ef.Exceptions { /// <summary>
    /// Entity Framework實體驗證異常 /// </summary>
    public class EfValidationException : DbEntityValidationException { /// <summary>
        /// 初始化Entity Framework實體驗證異常 /// </summary>
        /// <param name="exception">實體驗證異常</param>
        public EfValidationException( DbEntityValidationException exception ) : base( "驗證失敗:", exception ) { SetExceptionDatas( exception ); } /// <summary>
        /// 設置異常數據 /// </summary>
        private void SetExceptionDatas( DbEntityValidationException exception ) { foreach ( var errors in exception.EntityValidationErrors ) { foreach ( var error in errors.ValidationErrors ) { Data.Add( string.Format( "{0}屬性驗證失敗", error.PropertyName ), error.ErrorMessage ); } } } } }

  在Util.Datas.Ef中建立EfUnitOfWork類,該類從DbContext繼承,並實現了IUnitOfWork接口。我增長了一個TraceId屬性,這個跟蹤號用於讓你在某些時候肯定注入的工做單元是否是同一個,若是是同一個實例,TraceId應該相等。IsStart私有屬性用來標識是否應該自動提交工做單元。Start方法將IsStart標識設爲true,表示開啓工做單元。CommitByStart方法基於IsStart標識進行提交,若是IsStart標識設爲true,該方法就不會提交工做單元,惟一的方法是調用Commit,同時,它被標識爲internal,這意味着只對Util.Datas.Ef程序集可見,它實際上是給倉儲使用的。Commit方法會調用SaveChanges方法,在發現併發或驗證異常時,將從新觸發自定義異常。代碼以下。 

using System; using System.Data.Entity; using System.Data.Entity.Infrastructure; using System.Data.Entity.Validation; using Util.Datas.Ef.Exceptions; using Util.Exceptions; namespace Util.Datas.Ef { /// <summary>
    /// Entity Framework工做單元 /// </summary>
    public abstract class EfUnitOfWork : DbContext, IUnitOfWork { /// <summary>
        /// 初始化Entity Framework工做單元 /// </summary>
        /// <param name="connectionName">鏈接字符串的名稱</param>
        protected EfUnitOfWork( string connectionName ) : base( connectionName ) { TraceId = Guid.NewGuid().ToString(); } /// <summary>
        /// 啓動標識 /// </summary>
        private bool IsStart { get; set; } /// <summary>
        /// 跟蹤號 /// </summary>
        public string TraceId { get; private set; } /// <summary>
        /// 啓動 /// </summary>
        public void Start() { IsStart = true; } /// <summary>
        /// 提交更新 /// </summary>
        public void Commit() { try { SaveChanges(); } catch ( DbUpdateConcurrencyException ex ) { throw new ConcurrencyException( ex ); } catch ( DbEntityValidationException ex ) { throw new EfValidationException( ex ); } finally { IsStart = false; } } /// <summary>
        /// 經過啓動標識執行提交,若是已啓動,則不提交 /// </summary>
        internal void CommitByStart() { if ( IsStart ) return; Commit(); } } }

  .Net應用程序框架交流QQ羣: 386092459,歡迎有興趣的朋友加入討論。

  謝謝你們的持續關注,個人博客地址:http://www.cnblogs.com/xiadao521/

  下載地址:http://files.cnblogs.com/xiadao521/Util.2014.12.6.1.rar

相關文章
相關標籤/搜索