• 工作单元层超类型


    工作单元层超类型

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

    版权所有,转载请注明出处 何镇汐的技术博客
     
  • 相关阅读:
    python的模块future用法实例解析
    strcmp函数和memcmp函数的用法区别及联系
    esp8266 smartconfig-智能配网分析和使用及注意事项
    ubuntu 18.04 安装并配置adb
    Markdown的常用方法总结
    mac下使用minicom几个注意事项
    最强Linux shell工具Oh My Zsh 指南
    ESP8266源码分析--打印的基本用法
    atom 在Ubuntu 18.04 上安装及基本使用
    ubuntu 查看端口被占用并删除端口
  • 原文地址:https://www.cnblogs.com/Leo_wl/p/4149273.html
Copyright © 2020-2023  润新知