上一篇介紹了DDD聚合以及與並發相關的各種鎖機制,本文將介紹另一個核心元素——工作單元,它是實現倉儲的基礎。
什么是工作單元
維護受業務事務影響的對象列表,並協調變化的寫入和並發問題的解決。
這是《企業應用架構模式》中給出的定義,不過看上去有點抽象。它大概的意思是說,對多個操作進行打包,記錄對象上的所有變化,並在最后提交時一次性將所有變化通過系統事務寫入數據庫。
當然,工作單元不一定是針對數據庫的,不過大部分程序員還是工作在關系數據庫中,所以我默認你也在使用關系數據庫,由此產生的不准確性你就不要再計較了。
初步看上去,工作單元與事務頗為相像,一個事務也會包裝多個數據庫操作,並在最后提交更改。不過工作單元與事務具有更多的不同,事務的關鍵特征是支持ACID原則,工作單元並不需要實現得這么復雜,工作單元只是將所有修改狀態保存下來,在提交時委托給事務完成。所以工作單元本身不具有隔離性,這意味着工作單元只能在單線程中工作,如果同時讓多個線程訪問工作單元,就會導致數據錯亂。
工作單元對並發的協調,是依靠聚合根上的樂觀離線鎖,以及數據庫事務的並發控制能力來共同完成的,對並發控制更具體的討論,請參考本系列的前一篇。
.Net從出山以來,就提供了一個強大的工作單元,這就是DataTable。回想當年使用GridView控件的情形,直接把GridView綁定到一個DataTable,然后在GridView上任意編輯,最后調用DataTable的AcceptChanges方法,所有修改就保存到數據庫了。
.Net數據訪問技術不斷推陳出新,特別是推出Entity Framework Code First之后,新一代的工作單元DbContext成為數據訪問的中心。部分害怕學習新技術的.Net程序員,還在吃着老本,不過面向對象開發大勢所趨,DataTable已退居二線。
工作單元的作用
減少數據庫調用次數
如果沒有工作單元,那么每次對數據的新增、修改、刪除操作,都需要實時提交到數據庫,從而造成頻繁調用數據庫而降低性能。特別是對同一個對象多次更新,將造成更多的不必要浪費。
避免數據庫長事務
對於一個復雜的業務過程,為了保證數據一致性,可以將其放入一個數據庫事務中。但由於操作步驟繁多,且有可能需要與外界進行交互(比如需要調用第三方系統的一個遠程接口),從而導致一個需要很長時間才能完成的長事務。
之前已經提過,事務的使用要點是執行要盡量快,因為在事務開啟后,會鎖定大量資源,特別是可能獲取到獨占鎖而導致讀寫阻塞,所以開啟事務后必須迅速結束戰斗。
使用工作單元以后,所有的操作都和事務無關,只在最后一步提交時與事務打交道,所以事務的執行時間非常短,從而大幅提升性能。
工作單元的要點與注意事項
在單線程中使用工作單元
如果將工作單元實例設置為靜態,讓所有線程同時操作該工作單元,會發生什么情況?
一種情況是多個人同時修改一個對象,當提交工作單元時,一部分人的數據被另一部分人覆蓋,造成丟失更新,並且不會觸發樂觀並發異常,因為是在同一個事務中進行修改。
另一種情況,有人在操作工作單元,正操作到一半,另外一位老兄突然提交了工作單元,一半數據被保存到數據庫了,導致很嚴重的數據不一致。
工作單元一般通過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