系統(tǒng)設(shè)計中的命令和事件

最近和一個同事在討論基于事件的系統(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)過程分成了兩部分MUTATIONACTION ,action純粹的修改狀態(tài),mutation負(fù)責(zé)函數(shù)調(diào)用。我們上面的例子中process就是mutation, apply就是action。

命令和事件在系統(tǒng)設(shè)計中的不同大概就介紹到這里了,那么問題來了,到底如何進(jìn)行安全的狀態(tài)重建呢?這個留給諸君思考吧。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容