如何运用领域驱动设计 - 工做单元

新年伊始,祝你们喜乐如意,爱和幸福“鼠”不尽!♫. ♪~♬.♩♫~github

概述

在上一篇 《如何运用领域驱动设计 - 存储库》 的文章中,咱们讲述了有关仓储的概念和使用规范。仓储为聚合提供了持久化到本地的功能,可是在持久化的过程当中,有时一个聚合根中的各个领域对象会分散到不一样的数据库表里面;又或者是一个用例操做须要操做多个仓储;而这些操做都应该要么同时成功,要么同时失败,所以就须要为这一系列操做提供事务的支持,而事务管理就是由工做单元来提供的。在上一篇中,可能已经提到了工做单元,可是仅仅是一笔带过,如今咱们就来详细的探究该如何更好的来实现工做单元。(文章的代码片断都使用的是C#,案例项目也是基于 DotNet Core 平台)。数据库

直接看东西

在上一篇文章中,已经为你们提供了一个Github的Demo。若是已经下载过该Demo的同窗,您如今直接进行Pull就能够得到最新的版本了;若是尚未下载该Demo的同窗也能够戳下方的跳转连接获取。c#

GitHub 地址,点击直达哟设计模式

在这里咱们能够先来看一下,该项目的应用代码是什么样子:api

[HttpPost]
public ActionResult<string> Add()
{
    //使用仓储来处理聚合
    _itineraryRepository.Add(
        new Itinerary(
            "奥特曼",
            "赛文奥特曼",
            "杰克奥特曼",
            "佐菲奥特曼",
            "泰罗奥特曼"));

    _itineraryRepository.Add(
        new Itinerary(
            "盖亚奥特曼",
            "戴拿奥特曼",
            "阿古茹奥特曼",
            "迪迦奥特曼", ""));

    return "success";
}

[HttpGet]
public ActionResult<long> Get()
{
    var count = _itineraryRepository.GetCount();
    return count;
}

这是在Aspnet Core的Controller中的代码,也就是对外提供的Api。能够看到咱们仅仅只是经过仓储的调用就完成了全部的操做。(ps:原谅我该演示api没有遵循restful风格( ̄▽ ̄)",还有就是那些奥特曼。。。)。restful

您可能会说,这里没有作操做,那确定是在 ItineraryRepository 里面作了手脚。好吧,下面咱们来看看该仓储的实现。app

public class ItineraryRepository
        : EFRepository<UowAppDbContext, Itinerary, Guid>
{
    public void Add(Itinerary itinerary) => DbContext.Set<Itinerary>().Add(itinerary);
}

是的,它也只有这么一点点代码。而做为后期的业务扩展和维护,咱们只须要完善咱们的Itinerary聚合(为它扩展行为和增长实体或值对象)以及ItineraryRepository仓储(为它添加对外检索意图的方法)就能够了。框架

这种作法的好处可能您很快就能发现:在咱们代码中到处都是关于领域对象的操做,尽量的避免其它基础构建或功能支持组件来干扰程序。除了代码量的减小以外,它也让可读性有着明显的提升,若是在此基础上可以构建出明确而干净的聚合根,那么您的程序将具有更高的可扩展性。ide

好吧,回到咱们今天的主题:工做单元。其实上面的代码就是对仓储中工做单元的巧妙运用,它其实在后面默默的支持着程序的正常运转,这是在调用层面上咱们彻底感受不到它的存在而已。下面就为您介绍它是怎么工做和实现的。

什么是工做单元

按照国际管理呢,这一章节都是解读有关原著《领域驱动设计:软件核心复杂性应对之道》 中的解释。可是!!!有关工做单元的概念在书里并无被明确的说起到。因此为了证实咱们确确实实是在前人的基础理念上来实践,而不是胡编乱造本身随便弄了一个概念出来。我特意去找了另一本较为权威的领域驱动设计教材:《领域驱动设计模式、原理与实践》 。在该书中对工做单元的解释以下:

事务管理主要与应用程序服务层有关。存储库只与使用聚合根的单一集合的管理有关,而业务用例可能会形成对多个类型聚合的更新。事务管理是由工做单元处理的。工做单元模式的做用是保持追踪业务任务期间聚合的全部变化。一旦全部的变化都已发生,则以后工做单元会协调事务中持久化存储的更新。若是在将变动提交到数据存储的中途出现了问题,那么要确保不损坏数据完整性的话,就要回滚全部的变动以确保数据保持有效的状态。

其实上文的话真的很好理解(相对于原著而言( ̄y▽, ̄)╭ )。首先咱们能够获得的第一个结论:事务管理实际上是应用服务层干的事。第二个结论:事务的协调管理都是由工做单元来负责的

因此,咱们千万不能由于工做单元和仓储有联系就将它放置在领域层里面:事务的提供每每是由数据库管理程序来提供的,而这一类组件咱们通常将它们放置在基础构架层,而领域层能够依赖于基础构架层,因此千万要注意,保持您的领域层足够干净,不要让其它的东西干扰它,也更不要将事务处理这类东西放到了您的领域层来。(这一点,您会在后期MiCake <米蛋糕> 的使用中看到详细的案例)。

如何实现工做单元

实现工做单元,就是要实现仓储中的事务操做。您可能已经看到过有些实现Repository的框架,它的写法是注入一个unitOfWork,而后从uow中提取一个仓储,而后再用仓储来完成聚合根的持久化操做。相似的代码就像这样:

var yourRepository = uow.GetRepository<yourRepository>();
yourRepository.Add(yourEntity);

uow.Commit();

这样作没有一点点的问题,并且是对工做单元和仓储模式的完美实现。uow工做单元中维持了一个事务,从该工做单元中建立的每个仓储均可以得到该事务,仓储完成了本身的操做以后,工做单元使用Commit方法告诉事务管理器,该事务完成。

夏目去参加了妖怪的聚会,一回到家,猫咪老师就发现了它沾染了妖怪的味道

夏目友人账
当仓储的操做沾染上了工做单元的事务,它也就受到了事务的管理

若是您喜欢这种实现模式,能够参考 threenine的Threenine.Data项目

懒的模式

其实在刚开始,为 MiCake(米蛋糕) 选取工做单元实现方案的时候,我也打算采用这种方式。可是在思考了一天以后,我仍是放弃了。由于我发现这种模式在完成每一次仓储操做的时候,必需要从工做单元中去获取。在Aspnet Core中,不得不在Controller中注入工做单元对象,而后再从该对象里面去获取仓储。这显然削弱了依赖注入所为咱们提供的依赖阅读性(本来在构造函数中,我能看出我须要注入的是A仓储,可是如今我看到的只有工做单元)。

其实最重要的一点就是:我太懒啦 o_o ....。 为何每次都要去多写一个uow.GetXXXXX()。每使用一个仓储就要多写一次获取语句,我就不能好好的只使用仓储吗? 因此在这个想法的强烈刺激下,我选取了另外的实现方法。

接下来,就让咱们来实现最开始演示代码中的工做单元吧。哦,对了,忘记说了,不管是演示的Github Demo仍是本次的博文,咱们都选取了Entity Framework Core来做为数据持久组件。因此有些小伙伴会说,那我使用Dapper或者原生的ADO怎么办? 其实思路都是同样的,您也能够在看了EFCore的版本后,本身写出对应的工做单元版本。若是有机会的话,欢迎在Github的Demo上直接添加,就能够提交供更多的同窗参考啦。

实现思路

  • 找出当前数据库持久组件中具备事务特征的对象(好比在EF中就是DbContext)
  • 建立一个容器去容纳这些对象
  • 工做单元就是该容器的实现,它掌管了这些事务对象,并对外公布了提交事务的方法
  • 工做单元管理器负责了对工做单元的建立工做

脑壳里有了这些还比较模糊的交互对象以后,咱们能够来想一下一个仓储完成添加聚合根的操做是怎么样的:

  • 在访问该API以前:使用工做单元管理器建立一个工做单元
  • 访问API中的仓储时候:构造一个事务特征对象,并开启一个事务
  • 事务开启完成以后:将该事务特征对象尝试放入到当前工做单元
  • 仓储事务操做完成后:调用工做单元的提交方法,完成事务的提交,保证仓储的数据一致。
  • 事务完成后:释放上面的各个对象

虽然步骤好像有5步,但总结下来,就是将具备事务的对象放置到工做单元中,让它去负责提交。对!就是这么简单,该方法与上面那种从工做单元中获取仓储的方法想法,它是往工做单元中提交。因此,咱们此时能够构造出一个伪代码出来,大体理解它的实现:

//一、使用工做单元管理器建立一个工做单元
    using (var uow = unitOfWorkManager.Create())
    {
        //二、构造事务特征对象,开启事务并注册到工做单元
        RegisteTransactonFeature(DbContext);
        //三、执行仓储中的内容
        DbContext.Set<Itinerary>().Add(itinerary)
        //四、工做单元保存提交
        uow.SaveChanges();
        //五、dispose
    }

至少到目前,咱们能够抽象出上面的各个对象了。
您也能够先本身尝试着想想,每一个对象接口应该实现什么功能(方法)。

//首先是事务特征对象,它提供了事务的基本Commit和Rollback方法
public interface ITransactionFeature
{
    public bool IsCommit { get; }
    public bool IsRollback { get; }

    void Commit();
    Task CommitAsync(CancellationToken cancellationToken = default);
    void Rollback();
    Task RollbackAsync(CancellationToken cancellationToken = default);
}

//而后是事务特征容器,它具备增长删除事务特征对象的方法
public interface ITransactionFeatureContainer
{
    void RegisteTranasctionFeature(string key, ITransactionFeature TransactionFeature);
    ITransactionFeature GetOrAddTransactionFeature(string key, ITransactionFeature TransactionFeature);
    ITransactionFeature GetTransactionFeature(string key);
    void RemoveTransaction(string key);
}

//接下来是工做单元,它实现了事务特征容器,而且对外提供提交的方法
public interface IUnitOfWork : ITransactionFeatureContainer
{
    Guid ID { get; }
    bool IsDisposed { get; }

    void SaveChanges();
    Task SaveChangesAsync(CancellationToken cancellationToken = default);
    void Rollback();
    Task RollbackAsync(CancellationToken cancellationToken = default);
}

//最后是工做单元管理器,它提供了建立工做单元的方法
public interface IUnitOfWorkManager : IUnitOfWokrProvider, IDisposable
{
    IUnitOfWork Create();
}

落地代码

在构建出接口以后,咱们就能够写出具体的实现类了。首先是实现工做单元(UnitOfWork)对象。(因为具体代码实现较多,讲解部分只选取了核心部分,完整代码能够参考Github的项目)

public class UnitOfWork : IUnitOfWork
{
    private readonly Dictionary<string, ITransactionFeature> _transactionFeatures;

    public UnitOfWork()
    {
        _transactionFeatures = new Dictionary<string, ITransactionFeature>();
    }

    //往容器中添加事物特征对象
    public virtual ITransactionFeature GetOrAddTransactionFeature(
        [NotNull]string key,
        [NotNull] ITransactionFeature transcationFeature)
    {
        if (_transactionFeatures.ContainsKey(key))
            return _transactionFeatures.GetValueOrDefault(key);

        _transactionFeatures.Add(key, transcationFeature);
        return transcationFeature;
    }

    //对外提供的保存方法,执行该方法时调用容器内全部事物特征对象的Commit方法
    public virtual void SaveChanges()
    {
        foreach (var transactionFeature in _transactionFeatures.Values)
        {
            transactionFeature.Commit();
        }
    }
}

接下来就是与ORM框架关联最深的事务特征对象的实现了,因为咱们选取了EF,因此此处应该实现EF版本的事务特征对象:

public class EFTransactionFeature : ITransactionFeature
{
    private IDbContextTransaction _dbContextTransaction;
    private DbContext _dbContext;

    public EFTransactionFeature(DbContext dbContext)
    {
        _dbContext = dbContext;
    }

    //设置事务
    public void SetTransaction(IDbContextTransaction dbContextTransaction)
    {
        _isOpenTransaction = true;
        _dbContextTransaction = dbContextTransaction;
    }

    public void Commit()
    {
        if (IsCommit)
            return;

        IsCommit = true;

        //EF 事务的提交
        _dbContext.SaveChanges();
        _dbContextTransaction?.Commit();
    }
}

创建好了这两个对象以后,其实咱们只须要一个流转过程就能够实现工做单元了。这个流程就是将事务特征对象添加到工做单元中,可是咱们应该在何时将它添加进去呢?看过初版Github代码的小伙伴可能知道,在仓储调用的时候就能够完成该操做。当时在初版中,咱们的实现代码是这样的:

public class EFRepository
{
    protected IUnitOfWorkManager UnitOfWorkManager { get; private set; }
    protected DbContext DbContext { get; private set; }

    public EFRepository(IUnitOfWorkManager unitOfWorkManager, DbContext dbContext)
    {
        UnitOfWorkManager = unitOfWorkManager;
        DbContext = dbContext;
    }

    public void Add(TAggregateRoot aggregateRoot)
    {
        RegistUnitOfWork(DbContext);

        DbContext.Set<TAggregateRoot>().Add(aggregateRoot);
    }

    private void RegistUnitOfWork(DbContext dbContext)
    {
        string key = $"EFTransactionFeature - {dbContext.ContextId.InstanceId.ToString()}";
        unitOfWork.ResigtedTransactionFeature(key, new EFTransactionFeature(DbContext));
    }
}

在每一次进行仓储操做的时候,都调用了一个RegistUnitOfWork的方法,来完成事务特征对象和工做单元的流转工做。可是很快您就能发现问题:EFRepository是咱们实现的一个基类,之后全部的仓储操做都继承该类来完成操做,那不是每扩展一个方法,我都要在该方法中写一句注册代码?若是我忘记写了怎么办。还有一点,该注册过程并无开启一个事务,那么事务是怎么来的呢?

那么怎么才能避免用户每一次都要去显示调用注册呢,而是让用户在不知不觉中就完成了该操做。因此咱们得思考在每个方法中,用户都必定会写的代码是什么,而后在该代码上下手。可能您已经想到了,DbContext!!!是的,每个方法里,用户都会去写DbContext,因此咱们能够在他获取DbContext的时候就完成注册操做。因此,优化后的代码就是这样的:

public class EFRepository
{
    public virtual TDbContext DbContext
    {
        get => _dbContextFactory.CreateDbContext();
    }

    public void Add(TAggregateRoot aggregateRoot)
    {
        DbContext.Set<TAggregateRoot>().Add(aggregateRoot);
    }
}

而该_dbContextFactory的实现就更简单了,他要完成的任务就是注册到工做单元而且开启事务。

internal class UowDbContextFactory<TDbContext>
{
    private readonly IUnitOfWorkManager _uowManager;

    public UowDbContextFactory(IUnitOfWorkManager uowManager)
    {
        _uowManager = uowManager;
    }

    public TDbContext CreateDbContext()
    {
        AddDbTransactionFeatureToUow(currentUow, DbContext);

        return wantedDbContext;
    }

    private void AddDbTransactionFeatureToUow(IUnitOfWork uow, TDbContext dbContext)
    {
        string key = $"EFCore - {dbContext.ContextId.InstanceId.ToString()}";
        var efFeature = uow.GetOrAddTransactionFeature(key, new EFTransactionFeature(dbContext));

        if (IsFeatureNeedOpenTransaction(uow, efFeature))
        {
            var dbcontextTransaction = dbContext.Database.BeginTransaction();
            efFeature.SetTransaction(dbcontextTransaction);
        }
    }

    private bool IsFeatureNeedOpenTransaction(IUnitOfWork uow, EFTransactionFeature efFeature)
    {
        return !efFeature.IsOpenTransaction;
    }
}

dbContext.Database.BeginTransaction是EF为咱们提供的手动开启事务的方法。若是您尝试实现另外ORM版本的工做单元,想一下在该ORM中是怎么开启的事务。

此时,咱们就已经实现了工做单元的流转了,那么还有一个问题就是:咱们怎么默认去实现一个工做单元,而不是每一次都须要手动去开启并提交。

AspNet Core为咱们提供了很好的拦截方法。第一种方法: 咱们能够在中间件中完成,由于全部的请求都要穿过中间件,咱们能够在方法到API以前就开启事务,等API访问结束后就提交事务。第二种方法: 经过IActionFilter等周期接口来完成。本案例选取了第一种实现方法,您也能够根据您本身的爱好选取本身的实现方式。

缺陷

到这里咱们已经实现了像上面Demo版本的工做单元,可是该工做单元其实还有许多特性没有实现:

  • 一个业务操做(一个API)中没有建立多个工做单元的能力
  • 目前事务的操做来源于EF Core的支持,若是项目存在多种数据访问方式(好比一个EF,一个ADO),它们之间如何依靠工做单元来完成事务
  • 没有识别何时须要开启工做单元,若是一个操做仅仅须要获取数据,其实咱们是不须要开启工做单元的

不过若是您的项目仅仅使用了一种ORM框架而且只须要开启一个工做单元,那么能够尝试使用该实现。

在实现MiCake真正的工做单元中,我尝试了不少方法来解决上面的问题。在后面的文章中,您也会看到MiCake真正的工做单元。

附上一个当时写工做单元的手记( ̄︶ ̄)↗
手记

总结

原本这篇文章原本不打算写在《如何运用领域驱动设计》这个系列的,可是后来纠结了一下,仍是归入了该系列。因为该篇文章是实现工做单元的,因此代码量就比较大,但愿不会给您形成阅读上的困难。下一篇的文章,是一个谈了好久的问题————持久化值对象,如今终因而时候该解决它了。在本次Demo中您看到的聚合根Itinerary全部的属性都是string,很显然这是不符合常理的,因此在下一次就要让它成为真正的领域对象。(ps:改为真正的领域对象后,感受均可以单体DDD应用落地了呢。( ̄︶ ̄)↗醒醒!少年。)为了您不错过下一篇文章的内容,您也可也点击博客园右上角的关注,这样就能及时收到更新了哟。

相关文章
相关标签/搜索