工作单元层超类型
上一篇介绍了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