上一篇介绍了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