应用程序框架实战十九:工作单元层超类型

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

  下载地址:https://files.cnblogs.com/xiadao521/Util.2014.12.6.1.rar

 

posted @ 2014-12-06 21:10  何镇汐  阅读(4919)  评论(6编辑  收藏  举报