事件驅動架構設計

liuqing_hu發表於2018-08-01

這是一篇譯文,譯文首發於 事件驅動架構設計,轉載請註明出處!

這篇文章是 軟體架構演進 一個有關 軟體架構 系列文章中的一篇。這些文章,主要是我學習軟體架構、對軟體架構的思考及使用方法的記錄。相比於這個系列的前幾篇文章,本篇文章可能看來更有意義。

採用設計驅動開發應用程式的實踐,可以追溯到 1980 年左右。我們可以在前端或者後端採用事件驅動模型。比如點選一個按鈕、資料變更或者某些後端服務被執行。

但是究竟什麼才是事件驅動呢?何時使用事件驅動?它有沒有缺陷?

是什麼、什麼時候用、為什麼用(What / When / Why)

就像類和元件一樣我們應當在編碼時實現高內聚低耦合。當需要組合使用元件時,比如 元件 A 需要觸發 元件 B 中的某些邏輯,我們自然而然的會想到在 元件 A 中去直接呼叫 元件 B 例項中的方法。然而,如果 A 需要明確知道 B 的存在,那麼它們之間是耦合的,A 依賴於 B,這使得系統難以維護和迭代。事件驅動可以 解決耦合 的問題。

此外,採用事件驅動的另外一個好處是,如果我們有一個獨立的團隊開發 元件 B,他們可以直接修改 元件 B 的業務邏輯而無需事先和研發 元件 A 的團隊進行溝通。各個元件可以單獨迭代:我們的系統更變得有組織性

甚至,在同一個組建內,有時我們的程式碼需要在一個 request 和 response 週期內,作為某個操作的結果被執行,但是又不需要立即被執行的類似處理。一個常見示例就是傳送電子郵件。此時,我們可以直接響應使用者結果,然後以非同步方式延遲傳送一個電子郵件給使用者,這樣就避免了使用者等待傳送郵件的時間。

不過,即使這樣處理依然存在風險。如果我們胡亂使用事件驅動設計,我們就有可能要承擔中斷業務邏輯的風險,因為這些業務邏輯具有概念上的高度內聚,卻採用瞭解耦機制將它們聯絡在一起。換句話說,就是將原本需要組織在一起的程式碼強行分離,並且這樣難於定位處理流程(比如使用 goto 語句),來理解業務處理:這就變成了 麵條式的程式碼[1]。

為了防止我們的程式碼變成一堆複雜的邏輯,我們應當在某些明確場景下使用事件驅動架構。就我的經驗來講,在以下 3 種場景下可以使用事件驅動開發:

  1. 實現元件的解耦
  2. 執行非同步任務
  3. 跟蹤狀態的變化(審計日誌(audit log))

1 實現元件的解耦(To decouple components)

當元件 A 需要執行元件 B 中的業務邏輯,相比於直接呼叫,我們可以向事件分發器中傳送一個事件。元件 B 通過監聽分發器中的特殊事件型別,然後當這類事件被觸發時去執行它。

這意味著元件 A 和元件 B 都依賴於事件分發器和事件,而無需關注彼此實現:即完成它們的解耦。

理論上,分發器和事件應該處在不同的元件中:

  • 分發器應當是獨立於應用的元件庫,然後使用依賴管理工具安裝到系統中。在 PHP 裡,我們使用 Composer 將其安裝到 vendor 目錄。

  • 對於事件來說,它是我們應用的一部分,但需要獨立於這兩個元件之外,這樣使得元件之間相互獨立。並且事件在元件之間實現共享,它是應用核心的不可分割的一部分。事件就是 DDD(領域驅動設計) 呼叫 共享核心(Shared Kernel) 的一部分。這樣,這些元件就依賴於共享核心,而無需知道彼此的存在。不過在單個系統中,為了方便我們也可以在元件內去觸發事件。

共享核心
[...] 用明確的邊界指定團隊同意共享的域模型的某些子集。保持這個核心很小。[...] 這個擁有特殊狀態的明確的共享機制,不得在未經團隊協商情況下隨意修改。
Eric Evans 2014, Domain-Driven Design Reference

2 執行非同步任務(To perform async tasks)

有時我們會有一系列需要執行的業務邏輯,但是由於它們需要耗費相當長的執行時間,所以我們不想看到使用者耗費時間去等待這些邏輯處理完成。在這種情況下,最好將它們作為非同步任務來執行,並立即向使用者返回一條資訊,通知其稍後繼續處理相關操作。

比如,在網店下訂單可以採用同步執行處理,但是傳送通知郵件則採用非同步任務去處理。

在這種情況下,我們所要做的是觸發一個事件,將事件加入到任務佇列中,直到一個 worker 程式能夠獲取並執行這個任務。

此時,相關的業務邏輯是否處在同一個上下文中環境中並不重要,不管怎麼說,業務邏輯都是被執行了。

3. 跟蹤狀態的變化(審計日誌(audit log))

在傳統的資料儲存的方式中,我們通過實體模型(entities)儲存資料。當這些實體模型中的資料發生變化時,我們只需更新資料庫中的行記錄來表示新的值。

這裡的問題是我們無法準確儲存資料的變更和修改時間。

我們可以通過審計日誌模型將包含修改的內容存入到事件裡。

在關於事件來源的知識,我們會做進一步的闡述。

監聽器 vs 訂閱者(Listeners Vs Subscribers)

在實現事件驅動的架構時,一個常見的爭議是究竟是使用 監聽器(listener) 還是 訂閱者(Subscriber),這裡談談我的看法:

  1. 事件監聽器 僅對一種事件作出響應,同時能夠使用多種方法處理事件。因此,我們應該依據事件名來命令監聽器,比如,假設我們定義一個「UserRegisteredEvent」事件,我們就應當實現一個「UserRegisteredEventListener」監聽器,這樣我們就能夠很輕易的知道監聽器在監聽什麼事件,而無需通過檢視檔案內的實現。然後就是對事件的處理方法(反應)應該正確反映方法的功能,比如「notifyNewUserAboutHisAccount()」和「notifyAdminThatNewUserHasRegistered()」。這種模式能夠應付大多數的使用場景,因為這樣不僅能夠保證監聽器足夠小巧,而且滿足專注於響應特定事件的單個職能原則。此外,如果我們是一個組合架構,每個元件(如有有必要)都需要定義一個可以在不同位置觸發的事件監聽器。

  2. 事件訂閱者(Event Subscriber) 支援多種事件和事件處理方法。訂閱者模式命名會更麻煩一點,因為它不僅僅處理一種事件,不過訂閱者依然需要遵循單一職責原則,所以訂閱者命名也需要能夠反映其意圖。使用事件訂閱者並不常見,特別是在元件中,因為它能夠輕易的打破單一職責原則。實現訂閱者的一個非常適合的使用場景是管理事務,具體來講我們有個名為「RequestTransactionSubscriber」訂閱者,它等待諸如「RequestsReceivedEvent」、「ResponseSentEvent」和「KernelExceptionEvent」事件,並將其繫結到事務的啟動、提交和回滾處理,通過在它們內部定義「startTransaction()」、「finishTransaction()」和「rollbackTransaction()」方法。這裡雖然一個訂閱者能夠對多個事件作出響應,但依然僅關注管理請求事務中的某一個職能。

模式

Martin Fowler 定義了 3 種事件模式:

  • 事件通知
  • 事件承載狀態轉移
  • 事件溯源

這三種模式核心是一樣的:

  1. 事件發生則表示發生了一些事情(事件發生在這些事情後);
  2. 事件被廣播到它的監聽程式碼中(多個監聽程式可以共同處理一個事件)。

事件通知(Event Notification)

假設,有一個應用在核心(core)中定義了一些元件。理想情況下,這些元件是完全分離的,但是它們的一些功能需要在其他元件中去執行一些邏輯。

這是最典型的應用場景,前面已經講過:當元件 A 執行時,需要觸發元件 B 中的邏輯時,這裡可以去觸發一個事件將其傳送到事件分發器中,而不是直接呼叫。元件 B 通過監聽分發器中的這類事件,當有事件觸發時去執行這個事件。

需要注意的是,這個模式的一個特徵是 事件本身攜帶的資料非量常少。它只攜帶足夠的資料,以便監聽器知道發生了什麼,並執行它們的程式碼,資料通常是實體模型的 ID,可能還有事件建立的日期和時間。

  • 優點

    • 更健壯(Greater resilience),如果加入佇列的事件能夠在源元件中執行,但在其它元件中由於 bug 導致其無法執行(由於將其加入到佇列任務中,它們可以在 bug 修復後再執行);
    • 減少延遲,當使用者無需等待所有的邏輯都執行完成時,可以將這類工作加入到事件佇列;
    • 能夠讓元件的研發團隊獨立開發,加快專案進度、降低功能難度、減少問題發生並且更有組織性;
  • 缺點
    • 如果沒有合理使用,可能時我們的程式碼變成苗條式程式碼。

事件承載狀態轉移(Event-Carried State Transfer)

還是之前那個在核心中定義了一些元件的應用。這次,多於一些功能需要使用其它元件中的資料。獲取資料的最自然方式是從其它元件中查詢出資料,但是這也意味著這個元件知道被查詢元件的存在:這樣兩個元件就偶合在一起了!

實現資料共享的另一種方法是,當資料在所屬元件中被變更時,觸發一個事件。這個事件攜帶新版本中的所有資料。對該資料感興趣的元件可以監聽這類事件,並依據資料儲存中的資料進行處理。這樣當元件之間需要外部資料時,他們也能夠獲取本地副本,而無需從其它元件中查詢。

  • 優點

    • 更健壯(Greater resilience),因為查詢元件在被查詢元件不可用情況下(或者由於 bug 或遠端伺服器不可用時)依然可用;
    • 減少延遲,因為無需遠端呼叫(當被查詢元件為遠端服務時)來獲取資料;
    • 無需擔心被查詢元件的負載(尤其是遠端元件)
  • 缺點
    • 儘管現在資料儲存已經不再是問題根源,依然會儲存多個只讀的資料副本;
    • 增加查詢元件的複雜度,即使處理邏輯符合規範它也需要額外處理和維護外部資料的本地副本業務邏輯。

如果兩個元件都在同一個程式中,能夠快速的實現元件間通訊,那麼實現這種設計模式可能就沒那麼必要了。不過為了實現元件分離或可維護性,或在未來的計劃中將元件封裝進不同的微服務中使用這種模式。所有的一切取決於現有需求和計劃,以及我們希望(或需要)將系統解耦到什麼程度。

事件溯源(Event-Sourcing)

假設,現在有一個剛剛初始化的實體(Entity)。作為實體,它有自己的標識(identity),它對應現實世界中的某一事物,在程式中就是模型。在整個生命週期內,資料庫僅僅簡單的儲存實體的當前狀態。

事務日誌(Transaction log)

多數場景下,這種儲存方式是可行的,但如果我們需要知道實體究竟如何到達當前這個狀態(比如,我們想知道銀行賬戶的貸方和借方)。這時候由於我們僅儲存當前狀態,可能就無法實現這種需求了。

使用事件溯源模式替代實體狀態儲存,我們關注例項狀態的 變更依據變更計算出實體狀態。每個狀態的變化都是一個事件,被儲存到事件流中(如 RDBMS 中的表)。當我們需要獲取實體的當前狀態是,我們通過計算這個事件的所有事件流來完成。

事件儲存作為結果的主要來源,系統狀態也單純的轉變成了它的派生結果。對程式設計師來說,最好的例子是版本控制系統。所有的提交日誌就是事件儲存,當前原始碼樹的工作副本就是系統的狀態。
Greg Young 2010, CQRS Documents

刪除(Deletions)

如果現在存在一個錯誤的狀態變更(event),我們不能簡單的將其刪除因為這樣會改變狀態的歷史記錄,這就與事件溯源的設計初衷背道而馳了。替代的方法是,我們在事件流裡建立一個新的事件,我們將希望刪除的事件回退(reverses)到之前的狀態。這個過程稱之為事務回退,這個操作不僅將實體恢復到期望的狀態,還留下記錄表名這個實體在給定的時間節點所處的狀態。

不刪除資料也有架構上的收益。儲存系統成為一種僅新增的架構,眾所周知,僅新增的架構比起可更新架構更容易部署,因為它要處理的鎖要少得多。
Greg Young 2010, CQRS Documents

快照(Snapshots)

不過,當在一個事件流中包含很多的事件時,計算實體狀態則會變的代價高昂,還會嚴重影響效能。為了解決這個問題,每當產生 X 條事件時,我們將在那個時間點建立實體狀態的快照。甚至,我們可以儲存這個實體的永久更新過的快照,這樣我們就能同時擁有兩個最優的平行世界。

event snapshots

投影(Projections)

在事件溯源中我們還引入了 投影(projection) 的概念,它是一定時間範圍內基於事件流計算後的事件結果。這就是快照,或者說實體的當前狀態,這就是投影的定義。但是在 投影 這個概念中最有價值的是,我們可以通過分析特定時間內的實體「行為」,實現對未來的行為作出預測(比如,在過去 5 年裡實體模型都在 8 月份增加了活動量,那麼它很有可能在明年 8 月份產生同樣的行為)。這對企業來說是一個很有價值的能力。

贊成 vs 反對(Pros and cons)

事件溯源在商業和軟體開發過程這兩方面非常有用:

  • 通過查詢這些事件,有助於商業和開發時理解使用者和系統行為(除錯);
  • 我們還可以使用事件日誌來重建過去的狀態,這對商業和開發都很有用;
  • 自動調整狀態以追溯變更情況,在商業上意義重大;
  • 在回放(replay)時,通過輸入預設事件探索已有歷史記錄,在商業上同樣有意義。

然而,並非一切都如此美好,警惕如下問題:

  • 外部更新(External updates)

當事件在外部系統中觸發更新時,我們不希望在回放事件以建立投影時重新觸發這些事件。此時,我們只需在 「回放模式」中禁用外部更新,可以將這個邏輯封裝到閘道器裡實現。

另一種解決方案依賴於實際的問題,可以將更新快取(buffer)到外部系統,在一段時間後執行更新,這時可以安全地假設事件不會回放。

  • 外部查詢(External Queries)

當在外部系統中使用查詢來檢索我們的事件時,比如獲取股票債券評級,當我們回放事件來建立投影時會發生什麼呢? 我們可能想要得到與事件第一次執行時相同的評分,這也許是幾年前生成的。因此,遠端應用可以給我們這些值,或者我們需要將它們儲存在我們的系統中,這樣我們就可以通過封裝閘道器中的邏輯來模擬遠端查詢。

  • 程式碼變更(Code Changes)

Martin Fowler 定義了 3 種型別的程式碼變更:新特性(new features),bug 修復和臨時邏輯。真正的問題出現在回放事件時,這些事件應該在不同的時間點使用不同的業務邏輯規則,比如,去年的稅收計算就與今年的不同。通常情況下,可以使用條件語句,但是這回使邏輯變得混亂,所以建議使用策略模式。

我的建議是謹慎使用這個模式,一般我會盡量遵循如下原則:

  • 讓事情保持沉默,僅需讓它知道狀態發生變化,無需使其知道如何處理業務。這樣,即使業務規則同時發生了更改,我們也可以安全地回放任何事件並獲取期望的結果(但是我們需要保留之前的業務規則,以便在回放過去的事件時使用它們);
  • 與外部系統的互動不應依賴於這些事件,這樣我們就可以安全地回放事件,而不會導致回放時觸發外部邏輯引發的危險,也無需保證外部系統的響應與事件最初回放時的響應相同。

當然,和其它模式一樣,並非任何時候都可以使用它,當使用比不適用帶來更多收益時,我們應該去使用這種模式。

結論

事件驅動架構核心在於封裝、高內聚和低耦合。

事件驅動可以提升程式碼的可維護性、效能和業務增長的需求,但是,通過事件溯源模式,還能提高系統資料的可靠性。

不過,事件驅動同樣存在弊端,因為無論是概念上的複雜度還是技術上的複雜度都增加了,當它被濫用時將導致災難性的後果。

資料

2005 • Martin Fowler • Event Sourcing

2006 • Martin Fowler • Focusing on Events

2010 • Greg Young • CQRS Documents

2014 • Greg Young • CQRS and Event Sourcing – Code on the Beach 2014

2014 • Eric Evans • Domain-Driven Design Reference

2017 • Martin Fowler • What do you mean by “Event-Driven”? 中譯 中譯2

2017 • Martin Fowler • The Many Meanings of Event-Driven Architecture

補充資料

什麼是事件溯源

淺談命令查詢職責分離 (CQRS) 模式

Command 與 Query 分離(CQS)

註解

[1] 麵條式程式碼(Spaghetti code)是軟體工程中反面模式的一種 (1),是指一個原始碼的控制流程複雜、混亂而難以理解 (2),尤其是用了很多 GOTO、例外、執行緒、或其他無組織的分支。其命名的原因是因為程式的流向就像一盤面一樣的扭曲糾結。麵條式程式碼的產生有許多原因,例如沒有經驗的程式設計師,及已經過長期頻繁修改的複雜程式。結構化程式設計可避免麵條式程式碼的出現。這樣,當我們需要獲取實體狀態時,只需要計算最後一個快照即可。

原文

Event-Driven Architecture

相關文章