最近和一個同事在討論基于事件的系統(tǒng)設(shè)計,他認(rèn)為命令和事件是一個系統(tǒng)消息的兩個名字,都是脫胎于觀察者模式,沒有什么不同。
其實,在不久之前,我也覺得這兩者在系統(tǒng)中扮演的角色沒什么不一樣,都是觸發(fā)系統(tǒng)產(chǎn)生響應(yīng)的載體。
難道這兩者真的只是一個事物的兩個名字嗎?顯然不是的。
在軟件上,有一種事件溯源(EventSourcing)的架構(gòu)模式,其思想很簡單,就是系統(tǒng)現(xiàn)在的狀態(tài)都是由一個個事件演化而來。例如
// x代表我們當(dāng)前的狀態(tài)
let x=1+2+3+4
// add 方法模擬我們的系統(tǒng)操作
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 的狀態(tài)由 初始狀態(tài) 1,經(jīng)過了 (+ 2) (+ 3)(+ 4) 事件演化成了現(xiàn)在的狀態(tài)10,這就是一個事件溯源的思想,描述了系統(tǒng)狀態(tài)一步步怎么演化過來的,在很系統(tǒng)中,需要不僅記錄單據(jù)當(dāng)前的狀態(tài),也需要記錄單據(jù)變更日志,如果我們以事件溯源的方法去構(gòu)建系統(tǒng),尤其是對數(shù)據(jù)安全性要求很高的系統(tǒng),我們天然的有兩個記錄對數(shù)據(jù)進(jìn)行校驗了。我們記錄當(dāng)前狀態(tài)的那一行數(shù)據(jù)記錄和事件記錄狀態(tài)發(fā)生不匹配的時候,很容易找到系統(tǒng)的bug。最簡單的方式 ,就是把事件重放一遍,狀態(tài)就恢復(fù)成正常的了。
是不是覺得這種方案很美好?
但是這個方案目前為止有個缺點,用代碼表示一下
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
不是我手滑,復(fù)制了兩遍,只是我把代碼重復(fù)執(zhí)行了兩次,模擬事件回放的過程,y的值是沒變,但是我們的日志卻輸出了兩遍,沒問題?那如果我把console.log 換成函數(shù)調(diào)用呢?調(diào)用了兩次其他的服務(wù),問題就比較嚴(yán)重了!
那么如何解決這個問題呢?
在給出答案之前,我們再看一個例子:
function buyCoffee(creditCard){ // 調(diào)用外部系統(tǒng)支付 charging(creditCard,1.00) let cup=new Coffee() return cup }這里簡單的模擬了購買一杯咖啡的過程,客戶給了我們一張信用卡,我們先從這張信用卡上扣掉了一塊錢,然后做了一杯咖啡,返回給客戶。這是最自然的故事節(jié)奏。這個過程中,發(fā)生了兩件事,“扣款成功”,“生產(chǎn)了一杯咖啡”,而命令則是“買一杯咖啡”。在我們?nèi)粘>帉懘a的過程中,如果有人需要監(jiān)聽這兩個事件,
則可能是下面的程序了
function buyCoffee(creditCard){ //調(diào)用外部系統(tǒng)了 charging(creditCard,1.00) eventBus.publish(new ChargingEvent(creditCard,1.00)) let cup=new Coffee() eventBus.publist(new SaleCoffeeEvent(cup)) return cup }我們重構(gòu)一下這個程序
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() }就目前來說,這個程序 沒有什么優(yōu)化余地了,看起來也比較“漂亮”了。但是到這里就結(jié)束了嗎?
如果客戶同時買兩杯咖啡怎么辦?(可以看一次買多件(種) 商品)
function buySomeCoffee(creditCard,count){ let array=new Array() for(int i=0;i<count;i++){ array.push(buyCoffee(creditCard)) } return array }這樣處理可以嗎?似乎不行吧。在現(xiàn)實生活中,去超市買東西,收銀員會跟你每件商品都結(jié)一次賬嗎?就算會多次結(jié)賬,這里用的是信用卡,每刷一次卡都有一筆手續(xù)費,顯然是合并收費來的更劃算。退一步講,如果第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 //這里調(diào)用外部 charging(creditCard,fee)要理解這個寫法,我們首先要明白一個概念——副作用。副作用指的是調(diào)用函數(shù)時對外部系統(tǒng)產(chǎn)生了影響,由于這種影響可以被傳播,所以函數(shù)調(diào)用者并不知道調(diào)用函數(shù)會產(chǎn)生多大的代價。我們這個需求中,信用卡扣款就是一種副作用,如果不能控制這種副作用的影響范圍,我們的組件是不能被組合和復(fù)用,系統(tǒng)中就會充斥著各種“過程”。
而更好的辦法就是,推遲副作用。我們可以在內(nèi)存中先計算好結(jié)果,由過程控制器去對結(jié)果進(jìn)行合并后再保存起來。
public interface Handler{
<T extends Command,R extends DomainEvent> List<R > process(T command);
<T extends DomainEvent> void apply(T event)
}
在processor中我們調(diào)用領(lǐng)域模型進(jìn)行計算,在apply中對具體領(lǐng)域事件進(jìn)行操作,比如轉(zhuǎn)換成數(shù)據(jù)庫對象,保存數(shù)據(jù)庫或者調(diào)用MQ,把領(lǐng)域事件發(fā)布出去。
而handler上面還有一層,是我們的Application層,就是我們的系統(tǒng)功能層了。
事實上很多軟件框架都對命令和事件進(jìn)行了區(qū)分,最常見的例子是mvvm框架vue,確切的說是vue之上的vuex,將系統(tǒng)過程分成了兩部分MUTATION 和ACTION ,action純粹的修改狀態(tài),mutation負(fù)責(zé)函數(shù)調(diào)用。我們上面的例子中process就是mutation, apply就是action。
命令和事件在系統(tǒng)設(shè)計中的不同大概就介紹到這里了,那么問題來了,到底如何進(jìn)行安全的狀態(tài)重建呢?這個留給諸君思考吧。