Taro beta

1. Taro简介

Taro是一个非常轻量的微型领域事件框架,目标是以尽可能小的侵入性为应用加入领域事件。代码非常简单,没具体算过,可能都没上千行。

下载:在NuGet中搜索Taro,第一个便是(注意要 Include Prerelases)
源码:https://github.com/mouhong/Taro

PS: 关于领域事件,Google中有很多文章介绍,比如daxnet的这篇文章就很不错:http://www.cnblogs.com/daxnet/archive/2012/12/27/2836372.html

2. Taro特性

领域事件的触发没有太多可挖掘空间,所以大部分时间都是花在事件处理器这边。主要特性:

- 支持用AwaitCommitted标签将一个事件处理器延迟到UnitOfWork提交后再执行;
- 支持用HandleAsync标签将一个事件处理器标记为异步执行;
- 支持事件继承,如果某处理器订阅了某事件基类,则所有子类事件的触发都会使该处理器得以执行;

3. Get Started

至于如何使用,这里就不多说了,请参考源代码中的Samples项目以及这里:https://github.com/mouhong/Taro

4. 设计思路

在领域事件的道路上,做过很多种尝试,下面是碰到的一些问题以及想到的处理方案,欢迎讨论:

(1) 事件处理器的延迟执行

最早时是最简单的,在领域模型中触发事件,然后有一个自己写的小组件负责把订阅了该事件的处理器都执行一遍,但这种做法有一个比较明显的限制,比如订单状态变更后发邮件的逻辑,订单状态的变更发生在领域模型中,但当变更提交到数据库之前,我们都不应该发邮件,订单处理失败但邮件又发出去了,这会让收邮件的客户很困惑。

事件在触发时就已经发生,所以事件无所谓延不延迟,而事件触发后,事件处理器监听到了事件的发生,处理器可以决定它自己在什么时候执行,所以,延迟执行一定是在处理器上进行设置的。在Taro中通过在对应的事件处理器类上加AwaitCommitted标签来表示该处理器要在数据库事务提交后再执行(默认在触发时立即执行),对于前面发邮件的逻辑,它就应该写在一个标记为AwaitComitted的事件处理器中。类似AwaitCommitted,事件处理器中还可以加上HandleAsync标签来标记该处理器要异步执行(默认为同步执行)。

为了事件的延迟处理,需要对事件处理器的生命周期做一定管理,为了最小化影响,Taro中引入了UnitOfWorkScope(同时引入的还有UnitOfWork,但这个应该大家都明白是什么东西),UnitOfWork、领域事件、事件处理器,这些东西都被“包”在了UnitOfWorkScope中,在Scope的外部是不可见的。每个业务操作都要打开一个UnitOfWorkScope,UnitOfWorkScope会绑定到一个UnitOfWork,“Scope”结束时,开发人员需要手工调用scope.Complete()来提交UnitOfWork,伪代码如下:

using (var scope = new UnitOfWorkScope()) {
    // 获取聚合根
    var account1 = ...;
    var account2 = ...;

    // 业务逻辑处理
    var moneyTransferService = new MoneyTransferService();
    moneyTransferService.Transfer(account1, account2, 50);

    // 提交,延迟执行的事件处理器在数据库事务提交后便开始执行
    scope.Complete();
}

为了业务逻辑的可组装,领域模型中不出现任何Commit操作,Commit操作全部移至最外层(上面代码中的Complete方法内部调用了UnitOfWork.Commit())。如果在MVC项目中,上面的代码可能会出现在某个Action中,如果在ASP.NET WebForms中,上面的代码可能会出现在aspx.cs文件中,如果在CQRS项目中,上面的代码可能会出现在某个CommandHandler中。

(2) 嵌套的Scope

在事件处理器中,有可能会触发新的事件,并提交UnitOfWork,这有时需要不和当前执行该事件处理器的UnitOfWorkScope混起来,因此,UnitOfWorkScope支持嵌套。如上文,所有的领域事件在对应的UnitOfWorkScope外部是不可见的,因此,如果事件处理器(通常是AwaitCommitted的事件处理器)中创建了一个新的UnitOfWorkScope,并且其中触发了新的领域事件,这都不会影响外部的UnitOfWorkScope。

(3) Repository

既然引入了UnitOfWork,就要同时考虑与之相配套的一些模式,比如常见的Repository,但最后决定剔除Repository,原则上奉行:把时间花在项目的核心,即领域模型中(用Evans的话说,Domain Model是The Heart of Software),而不把时间花在构建非必要的技术基础设施层。而想来想去,实在想不到Repository存在的意义,比如下面两段代码,个人认为前者未必优于后者:

// 第一段:使用Repository
using (var unitOfWork = ...) {
    var repository = ...;
    var account1 = repository.Get(accountId);
    ...
    unitOfWork.Commit();
}

// 第二段,不使用Repository,查询等操作全部附加在UnitOfWork上
using (var unitOfWork = ...) {
    var account1 = unitOfWork.Get<Account>(accountId);
    ...
    unitOfWork.Commit();
}

于是就决定去掉Repository,所有CRUD通过UnitOfWork走。

(4) 和存储层的集成

一开始时想在IUnitOfWork中抽象出Get(object id)、Save(object entity)和Query<TEntity>()这几个方法,但后来发现这有很多问题。

首先是按Id来取的Get方法,在NHibernate可以方便做到,在Linq to SQL中也算可以,但要写一些代码,在RavenDB中可能也可以,但要做相当重的封装,总之,两个字,麻烦。

Query()方法则更麻烦,比如RavenDB在写入后,索引在后台执行,如果直接查询,则可能查到脏数据(如果想查询时返回最新的数据,在RavenDB中也有相应的API,但这会对性能造成很大影响),这就让人无法定义IUnitOfWork接口中Query方法的职责,究竟这个方法可不可以返回脏数据呢?如果没法明确定义职责,那接口的使用者就无法忽略实现细节,那这个抽象还有意义吗?因此,最后的想法是,既然我定义不了职责,那就把它留给框架使用者。因此,最后决定IUnitOfWork中只放一个Commit方法。

既然IUnitOfWork没有了Query和Get等方法,那要怎么存取数据呢?现在的想法是使用者自己在中间“插一层”。首先从API分层的角度来看,从Taro的角度,Taro不处理数据查询,所以Taro站在IUnitOfWork这一较高的层次,只关注一个Commit方法,而对于具体的应用,则站在稍低一点且面向具体存储的层次,因此,如果使用Taro,那开发人员首先要实现一个自己的UnitOfWork,从UnitOfWorkBase继承,添加上Get、Save、Query方法,然后整个项目中的所有代码都直接使用这个UnitOfWork,如果想要更好单元测试,可以加一个自己的UnitOfWork接口。例如,如果使用Linq to SQL中,我可以这样写:

public interface ILinqUnitOfWork : Taro.IUnitOfWork {
    IQueryable<T> Query<T>();
    void Add(object entity);
    void Delete(object entity);
}

public class LinqUnitOfWork : Taro.UnitOfWorkBase, ILinqUnitOfWork {
    // ...
}

考虑到在具体项目中,99%的时间里面对的都是上面的ILinqUnitOfWork,所以Taro中的UnitOfWorkScope加了一个泛型参数TUnitOfWork以方便使用,因此,在使用Taro的项目中,一般要再添加一个空类:

public class UnitOfWorkScope : UnitOfWorkScope<ILinqUnitOfWork> {
    //...
}

有了这个类,那在MVC的Action中使用UnitOfWorkScope时,就可以是这样:

using (var scope = new UnitOfWorkScope()) {
    // 如果没有自己添加一个空的UnitOfWorkScope类,就要像下面这行注释代码那么写,麻烦
    // var unitOfWork = (ILinqUnitOfWork)scope.UnitOfWork;
    var unitOfWork = scope.UnitOfWork;
    var query = unitOfWork.Query();
}

(5) CQRS, IoC容器?

领域事件经常和CQRS一起讲,但我只想在Taro中处理好领域事件,所以和CQRS无关,这样也会降低侵入性。同时,我也希望Taro只有一个dll,所以也不想有任何IoC容器的影子,其实个人也不是很喜好IoC容器,也可能是我没掌握好它,总感觉用了IoC容器后,有些问题尤其是对象的生命周期,会变得复杂化,除非项目本身的复杂度已经远超过IoC容器带来的复杂度。

(6) 分布式?

一开始时,是将事件的分发交给一个命名为Event Bus的组件来处理,所以自然的就想到,是不是这个Event Bus可以把事件分发到分布式环境的其它节点上?比如现在实现了一个进程内事件分发的DirectEventBus,而将来集成NServiceBus来实现分布式事件消息的分发?但现在想想觉得其实是我想太多了,这也是我的一个坏习惯,老是喜欢去考虑可能并不需要的所谓扩展性,这种扩展性加进去后,一开始时往往看起来很漂亮,但实际扩展时,就会碰到各种没考虑到的问题,最后变得不伦不类。

另外,现在觉得Taro中的Event Bus和分布式Event Bus应该要放在两个层次来考虑,因为,比如一个很明显的问题,如何保证本地数据库操作和事件分发的事务性(比如,订单标记为已支付后,要保证订单成功的事件能同时成功分发,而不是前者成功但后者失败了)?进程内的Event Bus基本不存在这个问题,但分布式Event Bus就不同了(不考虑2PC)。

这个问题有请教过别人,综合了一下,大体有两种办法,一种是通过“补偿命令”的方式,也就是说是在业务流程的处理上做手脚,比如银行转帐,如果汇款失败,退款是通过往转出帐户中打款的方式完成的,而不是利用“分布式事务”。另一种是通过保证消息的发送来完成,先在本地数据库中保存消息,然后在数据库事务提交后,再通过其它服务将数据库中的消息发送出去。在这种方式中,事件处理器要保证满足“幂等性”,也就是执行一次和执行多次的结果是一样的,这应该是比较容易保证的,比如我们做支付的时候,如果订单已经标记为“已支付”,但仍收到支付网关支付成功的重复通知,则简单忽略掉即可。

UPDATE (2013-10-07): Jimmy Bogard有一篇短文把这个问题说的很清楚,赞叹这些人的表达能力: http://lostechies.com/jimmybogard/2013/05/09/ditching-two-phased-commits/

所以,Taro中不再考虑任何分布式的情形,如果一定要有的话,我想可能是,在某个事件处理器中订阅本地事件,然后再分发到其它节点,但这和Taro已经无关了。

欢迎讨论。