zoukankan      html  css  js  c++  java
  • 应用程序框架实战十九:工作单元层超类型

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

     

  • 相关阅读:
    洛谷p1017 进制转换(2000noip提高组)
    Personal Training of RDC
    XVIII Open Cup named after E.V. Pankratiev. Grand Prix of Eurasia
    XVIII Open Cup named after E.V. Pankratiev. Grand Prix of Peterhof.
    Asia Hong Kong Regional Contest 2019
    XVIII Open Cup named after E.V. Pankratiev. Grand Prix of Siberia
    XVIII Open Cup named after E.V. Pankratiev. Ukrainian Grand Prix.
    XVIII Open Cup named after E.V. Pankratiev. GP of SPb
    卜题仓库
    2014 ACM-ICPC Vietnam National First Round
  • 原文地址:https://www.cnblogs.com/xiadao521/p/4148779.html
Copyright © 2011-2022 走看看