如何运用领域驱动设计 - 工作单元
目录
新年伊始,祝大家喜乐如意,爱和幸福“鼠”不尽!♫. ♪~♬.♩♫~
概述
在上一篇 《如何运用领域驱动设计 - 存储库》 的文章中,我们讲述了有关仓储的概念和使用规范。仓储为聚合提供了持久化到本地的功能,但是在持久化的过程中,有时一个聚合根中的各个领域对象会分散到不同的数据库表里面;又或者是一个用例操作需要操作多个仓储;而这些操作都应该要么同时成功,要么同时失败,因此就需要为这一系列操作提供事务的支持,而事务管理就是由工作单元来提供的。在上一篇中,可能已经提到了工作单元,但是仅仅是一笔带过,现在我们就来详细的探究该如何更好的来实现工作单元。(文章的代码片段都使用的是c#,案例项目也是基于 dotnet core 平台)。
直接看东西
在上一篇文章中,已经为大家提供了一个github的demo。如果已经下载过该demo的同学,您现在直接进行pull就可以获得最新的版本了;如果还没有下载该demo的同学也可以戳下方的跳转链接获取。
在这里我们可以先来看一下,该项目的应用代码是什么样子:
[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风格( ̄▽ ̄)",还有就是那些奥特曼。。。)。
您可能会说,这里没有做操作,那肯定是在 itineraryrepository 里面做了手脚。好吧,下面我们来看看该仓储的实现。
public class itineraryrepository : efrepository<uowappdbcontext, itinerary, guid> { public void add(itinerary itinerary) => dbcontext.set<itinerary>().add(itinerary); }
是的,它也只有这么一点点代码。而作为后期的业务扩展和维护,我们只需要完善我们的itinerary聚合(为它扩展行为和增加实体或值对象)以及itineraryrepository仓储(为它添加对外检索意图的方法)就可以了。
这种做法的好处可能您很快就能发现:在我们代码中处处都是关于领域对象的操作,尽可能的避免其它基础构建或功能支持组件来干扰程序。除了代码量的减少之外,它也让可读性有着明显的提高,如果在此基础上能够构建出明确而干净的聚合根,那么您的程序将具备更高的可扩展性。
好吧,回到我们今天的主题:工作单元。其实上面的代码就是对仓储中工作单元的巧妙运用,它其实在后面默默的支持着程序的正常运转,这是在调用层面上我们完全感觉不到它的存在而已。下面就为您介绍它是怎么工作和实现的。
什么是工作单元
按照国际管理呢,这一章节都是解读有关原著 中的解释。但是!!!有关工作单元的概念在书里并没有被明确的提及到。所以为了证明我们确确实实是在前人的基础理念上来实践,而不是胡编乱造自己随便弄了一个概念出来。我特地去找了另外一本较为权威的领域驱动设计教材: 。在该书中对工作单元的解释如下:
事务管理主要与应用程序服务层有关。存储库只与使用聚合根的单一集合的管理有关,而业务用例可能会造成对多个类型聚合的更新。事务管理是由工作单元处理的。工作单元模式的作用是保持追踪业务任务期间聚合的所有变化。一旦所有的变化都已发生,则之后工作单元会协调事务中持久化存储的更新。如果在将变更提交到数据存储的中途出现了问题,那么要确保不损坏数据完整性的话,就要回滚所有的变更以确保数据保持有效的状态。
其实上文的话真的很好理解(相对于原著而言( ̄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步,但总结下来,就是将具有事务的对象放置到工作单元中,让它去负责提交。对!就是这么简单,该方法与上面那种从工作单元中获取仓储的方法想法,它是往工作单元中提交。所以,我们此时可以构造出一个伪代码出来,大致理解它的实现:
//1、使用工作单元管理器创建一个工作单元 using (var uow = unitofworkmanager.create()) { //2、构造事务特征对象,开启事务并注册到工作单元 registetransactonfeature(dbcontext); //3、执行仓储中的内容 dbcontext.set<itinerary>().add(itinerary) //4、工作单元保存提交 uow.savechanges(); //5、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应用落地了呢。( ̄︶ ̄)↗醒醒!少年。)为了您不错过下一篇文章的内容,您也可也点击博客园右上角的关注,这样就能及时收到更新了哟。
米蛋糕>