天天看點

基于Event Sourcing和DSL的積分規則引擎設計實作案例

架構設計模式(Architecture Patterns),是“從特殊到普遍”的、基于各種實際問題的解決方案而總結歸納出來的架構設計最佳實踐,是一種對典型的、局部的架構邏輯的高度抽象思維;在合理的場景下恰當使用它們,避免“重新發明車輪”,對技術解決方案有指導性作用,往往事半功倍。廣發證券IT研發團隊作為架構設計模式的堅定踐行者,在各類證券業務中經常運用。Event Sourcing就是這麼一個比較常用而重要的架構模式。本文介紹的雖然是金融業場景,但是“積分系統”相信對其他行業的開發者也不會陌生。技術團隊嘗試用Event Sourcing架構模式和基于Go建構的DSL“簡單而優雅”的解決一個問題。

在電商行業,積分幾乎已經成為了一個标配。 京東、淘寶都有自己的積分體系。 使用者通過購物或者完成指定任務來獲得積分。累積的積分可以給使用者帶來利益,比如增加使用者等級,換取禮品或者在購物時抵扣現金。

在廣發證券的金融電商營運平台中,積分同樣是一個不可或缺的基礎服務,很多應用都有和積分賬戶互動的場景。積分的用途也比較廣泛,除了用在面向客戶的服務中增加客戶粘性和忠誠度外,積分也被用來支援内部的“遊戲化”(gamification)營運,讓數字化經營成為可能。 例如:公司的投資顧問可以通過編輯高品質的理财知識條目和回答客戶問題獲得積分,最終被換算回個人績效收入。

一個場景,是客戶提了一個關于證券的問題, 如果投資顧問回答了這一問題,并且答案被其他使用者收藏,就可以獲得500積分作為獎勵。 實踐表明,積分的使用大大提升了投資顧問回答問題的積極性,提高了營運的效率。這其實是精細化營運、數字化經營的一個非常重要的基礎設施。這個積分體系的存在,甚至改變、颠覆了傳統企業對員工進行分派任務、管理、激勵、計算個人績效的機制。

從技術的角度,怎樣實作一個積分系統滿足各種應用程式的需求呢? 雖然使用積分的場景不同, 有的面向客戶,有的面向公司内部的理财顧問,進行抽象後的積分系統可以是相同的。 和銀行賬戶類似, 一個使用者的積分賬戶可以看作由賬戶類型, 表示餘額的數字和一系列引起積分變化的流水帳組成。 根據這些共性,我們把積分實作為一個獨立的服務,統一存儲管理積分資料。 在和應用程式互動的方式上,最初的想法是積分系統為應用程式提供增加/扣除積分的接口, 由應用程式決定增加/扣除積分的數量。

我們很快發現這種架構在應用程式中嵌入了積分規則邏輯,當積分規則改變時,應用程式需要随之改變。 比如,如果營運人員把上面例子中的500積分改變為1000分後,開發人員就需要更新應用程式。 在使用積分的應用程式數量多,營運需求變化快的情況下,這種應用程式和積分系統緊密耦合的架構增加了系統維護成本。

典型的“事件驅動”場景

無論是面向消費者客戶的電商平台、航空公司顧客的飛行裡數服務(mileage program)還是面向内部員工的“遊戲化營運”平台,很顯然,在技術層面都是一個典型的“事件驅動”場景 – 使用者通常通過在各種各樣的業務系統進行了一些活動,這些活動被記錄到一個積分系統中“映射”成一定的積分。是以,積分系統設施的“使用者”,往往是其他的一些各式其色的、事前甚至無法預估的應用程式。

技術架構的設計原則是這樣:由不可預知的應用程式自己負責判斷其使用者所進行的活動有無“價值”,對于有價值的活動則以發起事件的方式異步通知積分系統,積分系統則負責實時收集事件并基于各種可能由經營管理者随時修訂、配置、改變的積分規則對事件所包含的使用者活動進行“簿記”(book-keeping)。

我們采用了基于消息總線的架構設計, 應用程式和積分系統之間通過異步的消息總線關聯。應用程式不包含任何積分規則,隻負責向消息總線釋出事件。 積分系統被實作為一個獨立的服務,包含了所有的積分賬戶資料和積分規則。 積分系統向消息總線訂閱事件, 然後根據設定的積分規則處理事件, 記錄積分。這種架構使應用程式和積分系統呈松耦合關系,提升了系統的可維護性。 系統架構如下圖所示:

基于Event Sourcing和DSL的積分規則引擎設計實作案例

舉一個例子說明記錄積分的過程:某投資顧問在廣發證券知識庫的應用程式中回答了一個問題,并且該問題被一個客戶收藏。 知識庫應用程式向消息總線釋出一個答案被收藏的事件。 積分系統在監聽到這一事件後,根據事先配置的積分規則,向投資顧問的積分賬戶增加積分數量,記錄積分流水。

積分系統監聽的事件并不一定由應用程式直接産生。對于複雜的積分規則,可能由其他服務處理應用程式的事件流後,産生新的事件流,再由積分系統處理。例如,需要對7月份連續3天登入的使用者獎勵50分。應用程式沒有儲存曆史登入資料,隻産生簡單的登入事件。 大資料平台(對于積分系統而言是一個應用程式)可以根據儲存的曆史資料産生包含連續登入天數的事件, 釋出到消息總線上後由積分系統訂閱處理。 這展現了基于消息總線架構的優點,能把積分處理邏輯從應用程式中完全剝離出來,同時具有擴充性。

積分類似虛拟貨币,可最終換算成員工績效或者消費者的某些形式的獎勵,是以不能多記,也不能少記。為了達到這一目标,技術層面上需要解決消息被處理一次且僅被處理一次的問題。我們的消息總線采用的是分布式消息系統Kafka, 它具有比較好的容錯性和擴充性, 但不直接提供這樣的支援,需要在應用程式層面處理。 應用程式向kafka發送消息時可能因為網絡的原因發送失敗。

為了避免丢失使用者積分,我們要求應用程式在向Kafka發送消息失敗後進行重試。但這樣又有可能出現同一個積分事件被重複接收導緻多記積分的問題。 我們的解決辦法是應用程式在産生積分的事件中帶上一個對使用者唯一的uuid, 并且通過重發的機制確定事件最少被發送到Kafka一次。 在積分系統中根據uuid進行排重,丢掉uuid重複的積分事件,保證積分事件最多被處理一次。通過這樣一種應用程式之間的協定實作了一個積分事件被被處理一次且僅被處理一次的目标。

Event Sourcing 架構模式

在實踐中,我們有修正積分的需求。 比如, 由于bug, 應用程式錯誤的産生出了一些事件,需要減掉由這些事件而增加的積分。直接的方法是找出這些事件産生的積分,然後從賬戶中直接扣減。 但是這一方法在下面的場景中會導緻錯誤:

假設積分規則是使用者首次登入獎勵500分,當天内第2次登陸再獎勵1000分。

  1. 由于應用程式錯誤,産生了登入事件L1,導緻增加500積分
  2. 使用者登入産生登陸事件L2。 積分系統發現當天已經出現過1次登陸事件L1, 根據規則增加了1000積分。

管理者發現為L1不應該發生,直接扣除500積分,使用者實際得分1000分。 這是錯誤的。 在沒有事件L1的情況下,登陸事件L2隻應該獲得500分。産生這一錯誤的根本原因是積分的計算可能依賴于曆史事件。 曆史事件的變化将影響後續事件處理。

解決這種問題的一種方式是:當曆史事件發生了變化時, 復原到該時間點前的曆史狀态,然後按照時間順序重新處理之後的所有積分事件, 這類似于資料庫系統中使用checkpoint和日志來恢複資料庫狀态的方式。Event Sourcing 概括了這種軟體設計模式(詳細内容可參考軟體設計領域大師Martin Fowler的相關文章)。Event Sourcing 模式最核心的概念是程式的所有狀态改動都是由事件觸發并且這些事件被持久化到磁盤中。 當需要恢複程式狀态時,隻需把儲存的事件讀出來再重新處理一遍。

積分系統遵照Event Sourcing模式實作。 積分的所有變化都由積分事件觸發,所有積分事件都存儲在資料庫中。為了復原積分賬戶狀态,還需要儲存積分賬戶的曆史資料。我們實作的方法是在積分賬戶發生變化時,産生一條積分流水,儲存了積分變化數量,以及積分變化前和變化後的總額。當需要復原積分賬戶狀态時,找到離復原時間點最近的積分流水,恢複曆史積分賬戶的總額,然後按照時間順序逐一處理儲存的積分事件,恢複積分賬戶資料。 下圖展示了這一流程:

基于Event Sourcing和DSL的積分規則引擎設計實作案例

下面是用指令行工具把積分賬戶狀态恢複到2016-05-01之前,然後重新處理積分事件恢複積分的界面。

基于Event Sourcing和DSL的積分規則引擎設計實作案例

在生産環境的運維經驗表明,相對于手工直接修改積分賬戶資料, 這種修改曆史積分事件,復原賬戶狀态然後重新處理積分事件的方式不但提高了準确性,而且簡化了修正工作,節省了運維人員的時間。

用Go建構DSL實作靈活的積分規則引擎

由于接入的應用程式類型多樣,積分規則會随着營運的開展而頻繁變化。如果每次積分規則發生了變化,都要求對積分系統改動更新, 積分系統維護就會變成一項很繁瑣的工作。 我們的目标是讓積分系統保持足夠的靈活性,當積分業務規則變化時,在大多數情況下可以不用改動更新積分系統。最理想的情況是營運人員通過簡單教育訓練後自己就能配置積分規則,不需要開發人員修改積分系統軟體。

為此我們開發了一個積分規則引擎, 通過提供一個積分規則描述語言,把積分的業務邏輯從積分系統軟體中分離出去:

基于Event Sourcing和DSL的積分規則引擎設計實作案例

下面首先描述積分規則描述語言的文法表示和存儲方式, 然後描述規則引擎加載解釋積分規則的流程。

積分規則引擎首先需要提供一個讓營運人員描述積分規則的文法。抽象的看,積分規則可以表示為一個元組: (積分條件,積分數量), 表示當滿足設定的條件時,增加對應的積分數量。 很容易聯想到積分條件可以用程式設計語言中的布爾表達式表示,積分數量用數值表達式表示。

由于我們使用的是Go語言實作積分系統, 出于解析友善的考慮(Go自帶了自身的文法分析庫),我們采用了Go語言的表達式文法表示積分規則的條件和數量。 在積分規則的表達式中,Go語言的字元串、數字、布爾常量都可以直接使用。變量表示積分事件中的字段資料。比如,積分規則(event_type==“answer_is_liked”, 250) 表示目前積分事件類型(event_type)為answer_is_liked(答案被點贊) 時,積分條件比對, 記錄 250 個積分 。

在定義了積分規則的文法表示後,還需要決定在哪裡存儲積分規則。最初考慮存放在檔案中,很快發現如果把積分規則和積分資料存放在同一個資料庫中就可以友善的利用資料庫的一緻性檢查功能保證資料一緻性, 這是保證軟體系統長期正确運作的關鍵措施。 比如,通過資料庫的外鍵設定,我們能保證每條積分流水指向一個有效積分規則,杜絕因為規則被錯删,積分流水指向無效積分規則的情況。 下面是積分規則在資料庫中表示的例子:

基于Event Sourcing和DSL的積分規則引擎設計實作案例

上表第1行積分規則表示當積分事件是answer_is_liked時,增加250分;

第2行要複雜一些,表示當積分事件是answer_question(回答問題),并且屬于首次回答問題時增加積分, 如果是投資顧問,增加4000分,其他人員增加2500分。其中event_type是積分事件的字段; count_by_same_event_attr是在規則表達式中允許使用的函數,用來統計該使用者的具有相同字段值的積分事件數量;data.originator_type 也是積分事件的字段,表示使用者類型。

為了增強擴充性,規則引擎提供了一套插件機制,可以用Go語言編寫能用在規則表達式中使用的函數。比如上表第2行中的count_by_same_event_attr就是通過插件實作的,用來計算目前已經收到的具有相同屬性值的事件數量。在實踐中,當發現積分規則不能滿足業務需求時,我們往往通過編寫插件的方式來擴充積分規則的表達能力,而不是修改規則引擎的核心代碼。

在營運人員配置積分規則後,積分系統需要使用規則引擎解釋執行積分規則, 主要流程是:

1、積分系統在啟動時加載所有應用程式的積分規則

積分規則在被規則引擎加載後完成文法解析,在記憶體中解釋執行。 這避免了在運作中通路磁盤或資料庫引起的性能瓶頸。需要注意的是,雖然積分規則的文法和Go語言表達式相同,積分規則的語義卻有變化。對于會引起Go語言抛出異常的表達式(e.g. 除 0),積分規則引擎解釋為nil,避免了程式異常退出。

2、監聽消息總線,對于新收到的積分事件,逐個嘗試比對積分規則的條件。如果該積分事件能滿足某個積分規則的條件,則增加由積分規則中的積分。

下圖表示了運作規則引擎記錄積分的流程。

基于Event Sourcing和DSL的積分規則引擎設計實作案例

可以看出,我們實際上構造了一個DSL(Domain Specific Language), 文法和Go語言的表達式一樣,但是語義不同。積分規則其實是這一DSL編寫的程式, 作為資料儲存在資料庫中, 在被規則引擎裝載後又當作程式來執行。這裡展現了“代碼即資料”(code as data)的程式設計思想。

技術棧:Go + Postgres + Docker

1、Go 語言

Go語言是為大規模系統軟體的開發而設計的, 具有文法簡潔,靜态類型檢查,編譯快速,支援并發程式設計等特點。

和JavaScript等動态語言相比,我們感覺在某些場景下,由于Go的類型系統比較複雜并且不支援範型, 編寫的代碼量會多一些。一個典型的例子是排序,使用Go的排序庫時,一般需要實作一個sort.Interface, 包含有Len, Swap, Less 3個方法。 而使用JavaScript進行排序,往往隻需要1行代碼。

但是和動态語言相比,Go的靜态類型檢查減少了很多運作時bug,節約了調試時間, 并且Go提供的工具比較完善,自帶文檔,格式化,單元測試和包管理工具。 Go的生态系統也比較成熟,第3方軟體包豐富。綜合來看,使用Go的開發效率并不會低太多。

我們發現Go語言的靜态連結特性非常适合docker部署,積分系統用docker打包後隻有10M左右。相比于NodeJS打包後上百M的體積,采用Go語言大大節省了部署時間和資源。

總的來說,我們對Go語言是比較滿意的,将會繼續在關鍵的系統服務中使用。

2、Postgres

在使用了一段時間的MongoDB後,我們希望在關鍵業務中采用有嚴格schema檢查的關系型資料庫。 Postgres是一個成熟的開源資料庫,除了支援資料一緻性檢查和事務外,也支援JSON, 吸收了NoSQL的優點。

在積分系統中,應用程式需要在積分事件中儲存一些自定義的屬性, 在查詢積分流水時積分系統原樣傳回,由應用程式自行處理。 由于事先無法預知應用程式儲存的内容格式,我們把這樣的資料放在一個JSON字段中, 完全由應用程式控制。在資料存入之後, 通過Postgres的JSON操作符,我們可以友善的管理這些資料,比如,根據指定的JSON字段查詢。

除了使用Go、Postgres、Docker這些技術開發和部署服務,由于積分系統是為應用程式提供服務的,它天然需要通過API來支援其他開發者。 我們選擇了用工具slate來制作API文檔。下圖是使用markdown編寫,由slate轉換成html格式的 API文檔式樣。

基于Event Sourcing和DSL的積分規則引擎設計實作案例

總結

積分系統并不是一個技術架構上複雜的系統,但是它是借鑒“遊戲”實踐而進行的數字化精細化經營的重要業務環節,相信在越來越多進行“網際網路+”創新的垂直行業中會有類似的實踐。具體的技術實作手段也很多,在此為便于行業内外讀者的了解,我們對方案作了簡化和抽象。

然而,對相對簡單的問題作“教科書”式的簡練實作,遵循KISS(Keep It Simple,Stupid!)的原則,避免“過度工程”(over-engineering),也是我們的團隊文化和準則。本文所介紹的Event Sourcing架構模式和DSL規則引擎,可以幫助我們在很多場景“簡單而優雅”(simple but elegant)的解決問題。