REST与DDD

13-07-30 banq
之前在为什么要使用MVC+REST+CQRS架构我曾经提出DDD是核心,REST是壳的观点,我想在这里详细谈谈我的思路。

今天正好看看到老外一篇博文Why REST is so important:按这里,他认为,REST的核心概念应该是Representional State Transfer,中文意思是将状态转移显现出来,该文举例:

Marcus是一个农民。他有一个牧场,有4头猪,12只鸡和3头牛。

那么模拟客户端与之交谈,那么我肯定首先询问牧场的状态:“状态?”

Marcus 回答:“有4头猪,12只鸡和3头牛”。

这是最简单的将状态显现的案例,Marcus用语句“有4头猪,12只鸡和3头牛”将他的牧场状态转给了我。

那么如何以REST方式让Marcus加两头牛到它的牧场呢?

我们经常会范的错误是,你会说:“Marcus, 请加两头牛到你的牧场”。

请注意,我们在这里转换了状态吗?没有,我们这里表达的是动词,有面向函数风格,但是这种表述方式其实是RPC( remote procedure call 远程过程调用),这个过程就是:加两头牛到牧场。

Marcus会悲伤地回答: "400错误, Bad Request. 你是什么意思?"

那么让我们以REST方式请求,原来状态是:4头猪,12只鸡和3头牛,增加了两头牛的状态是:4头猪,12只鸡和3头牛。

那我就会说:“Marcus, 4头猪,12只鸡和5头牛, Please ”

Marcus: "正确!".

我: "Marcus, ...那么你现在状态是什么?".

Marcus: " 4头猪,12只鸡和5头牛".

我: "Ahh, 很好"

这才是真正REST。

原文还提到,如果你希望以RPC调用,那么SOAP是一种RPC方式,但是很重量,性能差。

这里,我想补充的是,这个案例让我们明白REST是如何显现状态的,那么在通用需求中,我们如何表达显现状态呢?

也就是说,REST是将什么状态转移显现出来?首先,我们想到的是应该是将业务逻辑的状态显现出来,而DDD领域驱动设计就是分析设计业务逻辑的,那么,推理结果是,REST应该是将领域层聚合根实体的状态显现出来。

首先,我们看看DDD是如何对需求分析设计的,大致步骤如下:

1.找出需求中的上下文边界。

2.根据上下文切分成模块。

3.从每个上下文中划出聚合边界

4.确定每个聚合边界内的聚合根

其中聚合根的状态是业务逻辑状态的核心所在,REST作为一个接口壳,应该透明地将聚合根实体的状态显现给客户端,客户端通过接受用户发出的命令,透过REST来修改聚合根的实体状态,从而达到实现业务功能的结果。如下图:

下面以转账案例来说明REST+DDD的结合:

客户端向REST发出转账请求的REST应该怎么写?

1.先发帐号A的扣除命令

2.再发帐号B的增加命令

这种方式其实不是状态表达,而是函数方法调用,是动词,是一种RPC,而REST方式应该是针对状态进行发出命令。

那么关键问题是,在这里寻找什么状态?有一种方式:

1.查询帐号A余额状态是10元

2.发出请求帐号A余额状态是5元(扣除5元,剩余5元)

3.查询帐号B余额状态是20元

4.发出请求帐号B余额状态是25元(增加5元)

这样很符合上面的牧场的REST对话方式。但是问题来了:

如果客户端扣除了A帐号钱后,不申请B帐号,从第三步以后没有了,那么我们后端服务器业务逻辑就不一致了。

所以,根据DDD的聚合根用来维护聚合边界内一致性这个原则,显然,A帐号借出和B帐号贷入应该是借贷平衡的,这是基本财务规则约束,也就是一致性要求,是逻辑要求 。

如果我们这时进行DDD建模,会发现这里有一个聚合根实体:转账Transaction。

下面是REST+DDD:

1. 客户端先GET获得当前帐号余额状态

2.客户端发出转账的POST命令:

POST /transactions

注意,需要加入参数 from=1&to=2&amount=500.00

见:http://www.jdon.com/41716

3.第二步的POST命令直接递交到转账服务,transactionService.

在转账服务中,有二种实现方式:

1. DCI+面向函数风格,直接在转账服务的方法中实现,将源账户和目标帐号看成两个角色TransferMoneySourceAccount和TransferMoneyDestinationAccount,需要通过事务机制。见:http://www.jdon.com/37976

2.DDD聚合体+EventSourcing方式, 转账服务委托聚合根实现,TransactionAggregate作为聚合根实体,转账是这个实体的一个行为方法,转账命令到激活这个方法,在这个方法内部将产生一个转账事件。见:

http://simon-says-architecture.com/2013/03/07/modelling-accounting-ledger-event-driven/

http://simon-says-architecture.com/2013/03/22/modelling-accounting-ledger-event-driven-2/

一个REST的转账命令POST一般对应一个上游事件,一旦上游事件进入聚合根内部,变成很多事件流分支,这些事件流分支是改变了状态后发出的,因此必须被记录下来,出错回放才能重现状态转变历史。

var tx = new Transaction();
tx.Post(amount, fromAccount, toAccount);
transactionRepository.Store(tx);
<p>

这是调用Transaction这个聚合根的post方法。方法内部代码:

public void Post(decimal amount, string fromAccount, string toAccount)
{
   this.Apply(new AccountDebited(amount, fromAccount));
   this.Apply(new AccountCredited(amount, toAccount));
}
<p>

将转账命令(转账上游事件)分为两个事件,AccountDebited和AccountCredited,账户借款和账户贷款,这样保证借贷平衡。然后根据借贷状态切换,还有更多子状态,EventStore应该是这些和状态直接有关的事件,间接有关事件没有必要记录,这样在事件回放时才能重现状态真实改变历史。

总结,上面总体架构涉及到是:REST+CQRS+DDD Aggregate + Domain Events + EventSourcing

[该贴被banq于2013-07-30 17:12修改过]

12
tangxuehua
2013-07-30 23:37
多谢banq提供的这两篇关于转账的实现。

通过设计一个Transaction聚合根,然后利用在一个聚合根内同时产生如下两个事件的方式确实比较新颖。

public void Post(decimal amount, string fromAccount, string toAccount)
{
   this.Apply(new AccountDebited(amount, fromAccount));
   this.Apply(new AccountCredited(amount, toAccount));
}
<p>

但是AccountDebited, AccountCredited这两个事件后面是会被Source Account, Destination Account响应的。我认真看了作者的设计,他的思路是在业务层面实现类似2pc的方式。就是Transaction聚合根先通知两个账号准备转账(Prepare),然后再收到这两个账号的Prepared事件后,再同时产生AccountDebited, AccountCredited事件,然后参与转账的这两个Account再最后做一次确认操作;

我的疑问是,这里有一些细节,作者没有交代清楚。

1. 比如Account的余额信息的扣除和增加是在哪一步做的呢?是在Prepare完成后就立即做还是在AccountDebited, AccountCredited事件产生后的confirm阶段做?

2.从作者的描述中,我猜测是在confirm阶段在做余额的变动,那要是这两个账号中任意一个扣钱或加钱失败了呢?作者貌似没有给出后续的不就措施,比如,需要考虑回滚吗?还是只是通过错误日志监控?等待后续人工排查?

3.作者的设计方式虽然做到了AccountDebited, AccountCredited这两个事件同时产生,且能做到在一个事务内原子的方式持久化事件;但是source account, destination account余额的真正变化我觉得并没有做到原子操作。那即便AccountDebited, AccountCredited这两个事件同时产生了,那意义又在哪呢?

我的个人理解,转账操作本质上最终是要改变两个账号的余额,既然我们引入了source account, destination account这两个独立的聚合根来参与转账。也就是说,转账这个场景必定涉及两个聚合根的修改。作者虽然引入第三个聚合根Transaction来尝试解决转入和转出的原子事务性,但我觉得貌似这样的设计只是停留在表象,因为这两个事件并不是直接导致source account, destination account余额修改的事件;我们业务上“希望”转账是一个原子操作,但实际上可能吗?毕竟涉及到两个账号聚合跟的状态修改。既然我们做不到原子事务性,那就是只能采用最终一致性,那最终一致性很重要的一方面就是要考察是否有可靠的回滚或补救机制,保证流程中某一步出现异常时能回滚或发送补救命令来将状态恢复到原始状态。

tangxuehua
2013-07-31 09:46
对了,再补充一点,如果余额的变化是在confirm的时候做的,那Prepared的时候account做了什么呢?难道是在Prepared时就做了预扣款或预加款,然后这笔数字的款项相当于已经扣除了,这样才能保证confirm的时候,扣款才一定够扣;

如果是这样,要要是prepared执行了,但是confirm一直未执行,那又该如何处理呢?呵呵

实际上问题很多的,只要细细去想的话。

duanguangwei
2013-07-31 10:14
REST的修改操作,为啥不能RPC呢?

根据您文章的说法,状态要在客户端计算好,然后发送到服务器,这样会不会导致业务逻辑散布在UI中。

我记得REST只规定了修改操作要使用:POST、PUT和DELETE。

banq
2013-07-31 11:41
2013-07-31 10:14 "@tecentIDE5B12

"的内容

根据您文章的说法,状态要在客户端计算好,然后发送到服务器,这样会不会导致业务逻辑散布在UI中 ...

牧场这个案例给人感觉好像状态要在客户端计算,实际上再看看转账这个案例,转账是A帐号扣除钱,B账户增加钱,这么复杂的逻辑肯定不是在客户端计算的,会造成逻辑不一致性:

1.查询帐号A余额状态是10元

2.发出请求帐号A余额状态是5元(扣除5元,剩余5元)

3.查询帐号B余额状态是20元

4.发出请求帐号B余额状态是25元(增加5元)

这种方式就是逻辑在客户端计算,是不行的,被否决了。

应该是POST一个Transaction转账。Transaction转账在REST中是一个资源,而在DDD中是一个聚合根Aggregate root。

理解REST有两种角度,先有资源,然后是POST/PUT/GET/DELETE这些动作;还是先有这四个动作,才有资源。正如面向对象和面向函数的区别一样,第一考虑是先有类,才有类的方法函数,还是第一考虑先有函数,才有被函数操作的数据类。

这两种思维将程序员划分为两种世界。

banq
2013-07-31 12:04
2013-07-31 11:41 "@banq

"的内容

这两种思维将程序员划分为两种世界。 ...

其实编程经验也会将程序员划分为这两种世界。

有过Linux或一些纯平台开发经验的人,往往倾向于面向函数,因为这类应用几乎没有强烈的业务规则约束,数据就代表业务,包括大数据Big data等等。

面向函数的视角打个比喻:

如同一套机械加工设备,输入的是碎散草料,输出的是打包好成捆的草料。

好处就不多说了,无副作用,单纯职责等等。但是这里有一个前提,那就是输入的碎散草料是可以被投入到机械设备入口,如果投入的原料是一种有强烈内聚规则的业务模型,比如转账,转账有很强的内聚规则,那就是A帐号转出的必须等于B帐号转入的,否则就不叫转账。

如果将这样一个转账作为原料投入机械加工设备,机械是无法打散解构的,如同投入一个大石头,或者是金刚石,机械加工几乎无法完成自身的函数功能:打散扎捆。如同人吃进去石头,拉出来也只能石头,无法消化分解一样,函数和数据在这里是棋逢对手。

所以,在需求分析这块,数据代表领域模型,代表有一定业务约束的内聚模型,应当以数据结构为主要,函数是次要;而在大数据分析这块,因为数据本来就是松散碎的,无结构化,那么我们通过Hadoop之类加工机械找出数据自己的某种关系,结构的或其他方面的。

banq
2013-07-31 12:14
2013-07-31 09:46 "@tangxuehua

"的内容

如果是这样,要要是prepared执行了,但是confirm一直未执行,那又该如何处理呢?呵呵 ...

prepared并不是真正扣款,而是坚持余额是否能够被扣款,如果余额只有5元,要转账出去10元,那就没有prepared,下一步就不会进行;如果准备好了,表示余额足够,而且B帐号也准备好能够被转入,不存在冻结等情况,再进行转出和转入,但confirm事件发生时,转出和转入已经发生。

我把原英文的过程大意翻译如下:

首先,聚合根Transaction转账这个实体发出prepared这个事件,实际是检查是否准备好的事件,如下图

prepared这个事件发往A和B两个账户,变成它们的命令command。图中黄色部分代表命令,从两个账户出来变成蓝色的事件了。

两个帐号检查自己余额等条件,认为具备转账条件,上图发出的蓝色事件,表示准备好了,变成向聚合根转账实体发出的命令,如下图的黄色:

同理,上图聚合根发出总攻命令,进行真正转账,余额扣除和增加,变成下图的黄色命令,下图两个账户在余额变动后,发出确认完成的事件,如下图蓝色部分,这两个事件发回聚合根实体转账,转账回复REST客户端200 OK。

hbbbs
2013-08-01 10:40
DCI+面向函数风格:

有点像统治阶级管理它国土上的各种资源,啥都是它说了算,结果啥事情它都得考虑,它都得做。各种判断、各种操作都集中在一起了,形成了一个大函数。

当统治阶级要换了(技术更新),整个国家就跨了,代码基本无法重用,或需要费大力气重新组织。

效率来说,命令式应该会高点,毕竟一个人说了算嘛。稳定性就不那么行了。

DDD聚合体+EventSourcing方式:

有点像自由联邦,各种资源分散到各个联邦中自己管理,大家遵守一套共同的规范和道德准则。它们之间没有命令,只有请求(回想现实世界,说到底,哪个事情不是请求别人做呢,还真能命令别人一定这么做?)。

当国家变革了,可以保证各个联邦内部的稳定。即使变革不可逆转,也可一个个联邦慢慢来进行,不会一次崩溃。

效率感觉会低点,但国家会比较稳定。

自己的一些胡思乱想(对DCI 、DDD、Rest 不太了解)

sinotao1
2013-08-22 09:42
不错,听这些故事,感觉对这些技术名词有感性多了,谢谢Banq

sinaID32754
2016-10-26 10:08
我就想问如果我部署了分布式,有多个Actor在同一时刻同时通过分布式的消息,再经过分布式的数据库让Transfer同时执行到了prepared方法时,是不是失效了?

banq
2016-10-26 10:45
2016-10-26 10:08 "@sinaID32754"的内容
如果我部署了分布式,有多个Actor在同一时刻同时通过分布式的消息 ...

可使用Eventuate:基于操作CRDT的服务框架实现分布式EventSourcing最终一致性。

猜你喜欢