系统设计中的命令和事件
最近和一个同事在讨论基于事件的系统设计,他认为命令和事件是一个系统消息的两个名字,都是脱胎于观察者模式,没有什么不同。
其实,在不久之前,我也觉得这两者在系统中扮演的角色没什么不一样,都是触发系统产生响应的载体。
难道这两者真的只是一个事物的两个名字吗?显然不是的。
在软件上,有一种事件溯源(EventSourcing)的架构模式,其思想很简单,就是系统现在的状态都是由一个个事件演化而来。例如
// x代表我们当前的状态
let x=1+2+3+4
// add 方法模拟我们的系统操作
function add(a,b){
console.log("= "+ a +"+"+ b)
return a+b
}
let y=add(add(add(1,2),3),4)
//= 1+2
//= 3+3
//= 6+4
上面的例子中 x 的状态由 初始状态 1,经过了 (+ 2) (+ 3)(+ 4) 事件演化成了现在的状态10,这就是一个事件溯源的思想,描述了系统状态一步步怎么演化过来的,在很系统中,需要不仅记录单据当前的状态,也需要记录单据变更日志,如果我们以事件溯源的方法去构建系统,尤其是对数据安全性要求很高的系统,我们天然的有两个记录对数据进行校验了。我们记录当前状态的那一行数据记录和事件记录状态发生不匹配的时候,很容易找到系统的bug。最简单的方式 ,就是把事件重放一遍,状态就恢复成正常的了。
是不是觉得这种方案很美好?
但是这个方案目前为止有个缺点,用代码表示一下
let y=add(add(add(1,2),3),4)
//= 1+2
//= 3+3
//= 6+4
y=add(add(add(1,2),3),4)
//= 1+2
//= 3+3
//= 6+4
不是我手滑,复制了两遍,只是我把代码重复执行了两次,模拟事件回放的过程,y的值是没变,但是我们的日志却输出了两遍,没问题?那如果我把console.log 换成函数调用呢?调用了两次其他的服务,问题就比较严重了!
那么如何解决这个问题呢?
在给出答案之前,我们再看一个例子:
function buyCoffee(creditCard){ // 调用外部系统支付 charging(creditCard,1.00) let cup=new Coffee() return cup }
这里简单的模拟了购买一杯咖啡的过程,客户给了我们一张信用卡,我们先从这张信用卡上扣掉了一块钱,然后做了一杯咖啡,返回给客户。这是最自然的故事节奏。这个过程中,发生了两件事,“扣款成功”,“生产了一杯咖啡”,而命令则是“买一杯咖啡”。在我们日常编写代码的过程中,如果有人需要监听这两个事件,
则可能是下面的程序了
function buyCoffee(creditCard){ //调用外部系统了 charging(creditCard,1.00) eventBus.publish(new ChargingEvent(creditCard,1.00)) let cup=new Coffee() eventBus.publist(new SaleCoffeeEvent(cup)) return cup }
我们重构一下这个程序
function charging(creditCard,amount) { charging(creditCard,amount) eventBus.publish(new ChargingEvent(creditCard,amount)) } function saleCoffee(){ let cup=new Coffee() eventBus.publist(new SaleCoffeeEvent(cup)) return cup } function buyCoffee(creditCard){ charging(creditCard,1.00) return saleCoffee() }
就目前来说,这个程序 没有什么优化余地了,看起来也比较“漂亮”了。但是到这里就结束了吗?
如果客户同时买两杯咖啡怎么办?(可以看一次买多件(种) 商品)
function buySomeCoffee(creditCard,count){ let array=new Array() for(int i=0;i<count;i++){ array.push(buyCoffee(creditCard)) } return array }
这样处理可以吗?似乎不行吧。在现实生活中,去超市买东西,收银员会跟你每件商品都结一次账吗?就算会多次结账,这里用的是信用卡,每刷一次卡都有一笔手续费,显然是合并收费来的更划算。退一步讲,如果第n次刷卡失败了,前面每次刷卡的钱要退回去吗?
让我们看看如何合适的处理这个问题
function saleCoffee(){ let cup=new Coffee() return new SaleCoffeeEvent(cup) } function charging(creditCard,amount) { let charge=new Charging(creditCard,amount) return new ChargingEvent(charge) } function buyCoffee(creditCard){ const coffee=saleCoffee() const fee=charging() const charge={creditCard,fee} return {coffee,charge} } function buyCoffees(creditCard,count){ const turples=new Array(count).fill(buyCoffee(creditCard)) const coffees=turples.map({coffeeEvent}=>coffeeEvent.coffee) const charges=turples.map({chargeEvent}=>chargeEvent.charge) const charge=charges.reduce((a1,a2)=>{creditCard:a1.creditCard,fee:a1.fee+a2.fee}) return {coffees,charge} } const {coffees,charge}=buyCoffees('1233445',12) // 费用 const {creditCard,fee}=charge //这里调用外部 charging(creditCard,fee)
要理解这个写法,我们首先要明白一个概念——副作用。副作用指的是调用函数时对外部系统产生了影响,由于这种影响可以被传播,所以函数调用者并不知道调用函数会产生多大的代价。我们这个需求中,信用卡扣款就是一种副作用,如果不能控制这种副作用的影响范围,我们的组件是不能被组合和复用,系统中就会充斥着各种“过程”。
而更好的办法就是,推迟副作用。我们可以在内存中先计算好结果,由过程控制器去对结果进行合并后再保存起来。
public interface Handler{
<T extends Command,R extends DomainEvent> List<R > process(T command);
<T extends DomainEvent> void apply(T event)
}
在processor中我们调用领域模型进行计算,在apply中对具体领域事件进行操作,比如转换成数据库对象,保存数据库或者调用MQ,把领域事件发布出去。
而handler上面还有一层,是我们的Application层,就是我们的系统功能层了。
事实上很多软件框架都对命令和事件进行了区分,最常见的例子是mvvm框架vue,确切的说是vue之上的vuex,将系统过程分成了两部分MUTATION 和ACTION ,action纯粹的修改状态,mutation负责函数调用。我们上面的例子中process就是mutation, apply就是action。
命令和事件在系统设计中的不同大概就介绍到这里了,那么问题来了,到底如何进行安全的状态重建呢?这个留给诸君思考吧。