用 Golang 實作 Event Store:微服務事件儲藏中心

Event Store 是一個基於 CQRS 與 Event Sourcing 理念所衍生出來的新概念並由 C# 撰寫。這是一個微服務事件儲藏中心,這可能很難懂,但別緊張,這些都會在本文中得到答案。

在微服務結構中,每個服務都是獨立的,這意味著沒有服務該依賴另一個服務(無相依性),那麼我們應該要怎麼在服務之間互相溝通?一但有了這個問題就會開始陷入死循環,最終甚至會做出一個比傳統式單體應用程式還要糟糕的結構。

首先你要知道的是微服務應該自主。需要什麼資料的時候就應該自己處理,而不是發送請求到另一個服務然後等待回應。

實際案例

讓我們假設你有個文章服務,這個服務會接收使用者帳號(Username)然後轉成使用者編號(User ID)才會繼續進行處理,那這個時候要怎麼辦?從文章服務呼叫使用者服務來幫我們進行轉換嗎?

不對。這樣一來微服務之間就會有相依性。我們要做的就是在文章服務和使用者服務各儲存一份使用者資料。然後我們就可以在各自的服務中直接進行轉換的動作。(先撇開這樣會有重複函式、跨服務理念的問題)

解決方案

現在問題來了,如果我們希望兩個服務都各有一份使用者資料,我們應該要怎麼處理?這問題很簡單,透過「事件廣播」,而這就是 Event Sourcing

(圖片來源:http://microservices.io/patterns/data/event-sourcing.html)

當新使用者註冊時,會發送請求到使用者服務,對吧?接著使用者服務會向 Event Store 廣播一個「新使用者註冊」事件,這個事件會參雜新使用者的資料。

接著我們的文章服務事先註冊了「新使用者註冊」事件,這意味著當有「新使用者註冊」事件發生的時候,文章服務就會知道。接著我們就能夠在文章服務中獲取這個新使用者的資料,然後存放在文章服務自己的資料庫內供日後轉換。

為什麼是 Event Store?

有個問題你可能沒想到,如果後來有一個新服務冒出來了,這個新服務要怎麼獲取先前那些註冊過的使用者資料?(這個問題我有在 StackOverflow 詢問

這正是我們要使用 Event Store 的原因,傳統的 Event Sourcing 只會在微服務中傳遞訊息而沒有「紀錄(Store)」的功能。Event Store 解決了這個問題,你的所有廣播事件還有參帶的資料都會被記錄到 Event Store 中,簡單說就像是一個事件資料庫一樣,這也被稱為「事件儲藏中心」。

一但你有新的服務要上線時,你就可以在這個新服務中重播(Replay)先前的所有事件,那麼你就會擁有先前所有的使用者資料了。


1. 安裝與啟動 Event Store

先進入 Event Store 的下載頁面,接著選取符合你系統的壓縮或安裝檔。

接著解壓縮,然後在解壓縮的目錄直接執行 eventstored 檔案即可。

$ ./eventstored --db .\ESData

接著就能夠看到像下面這樣執行的介面。

2. 網頁管理介面

在剛才解壓縮的目錄中有個 clusternode-web 資料夾,裡面是 Event Store 的網頁管理介面。

透過瀏覽器開啟該資料夾內的 index.html 即能看到下列畫面。在這個畫面中你會需要輸入帳號密碼,預設即是 adminchangeit

登入之後就會來到後台管理畫面,從這裡就能夠管理或是直接將 Event Store 關機。

3. 引用套件庫

在這裡我們會用到 go.geteventstore 套件。在終端機中透過 go get 指令取得。

$ go get github.com/jetbasrawi/go.geteventstore

接著在你的程式中引用該套件。

import "github.com/jetbasrawi/go.geteventstore"  

4. 連線到 Event Store

接著透過下列程式碼來開啟到 Event Store 的連線並配置帳號密碼。

// 配置連線
client, err := goes.NewClient(nil, "http://你的EventStore網址:2113")  
    if err != nil {
        log.Fatal(err)
    }

// 配置帳號密碼設定
client.SetBasicAuth("admin", "changeit")  

5. 在 Golang 中發送事件

現在我們要開始在 Golang 發送一個新的事件到 Event Store,一個事件帶有三個東西:名稱、內容、中繼資料。這些東西的作用分別是:

  • 名稱:例如 new_useredit_user 等。
  • 內容:事件所要夾帶的內容,假設是「新使用者事件」,那麼我們就可以在其中夾帶這個新使用者的內容,其他服務也就能從這個事件中獲取使用者資料。
  • 中繼資料:小型的內容,例如哪個服務發送了這個事件、這個事件的權限資料、壽命或過期時間等。

實際範例如下:

// 建立事件內容。
myEvent := &FooEvent{  
    FooField: "Lorem Ipsum",
    BarField: "Dolor Sit Amet",
    BazField: 42,
}

// 建立中繼資料。
myEventMeta := make(map[string]string)  
myEventMeta["Foo"] = "consectetur adipiscing elit"

// 將事件與中繼資料用 goes.Event 函式包覆著。
myGoesEvent := goes.NewEvent(goes.NewUUID(), "FooEvent", myEvent, myEventMeta)

// 建立一個串流撰寫者 StreamWriter,而 `FooStream` 是事件名稱。
writer := client.NewStreamWriter("FooStream")

// 將事件撰寫到該串流中,在這裡我們會把 expectedVersion 設為 nil,
// 因為我們不會有並發上資料重複的衝突。
err := writer.Append(nil, myGoesEvent)  
if err != nil {  
    // 處理錯誤。
}

6. 讀取事件

讀取事件分為兩種,一種是「讀取全部」,另一種是「讀取最新端」。

  • 讀取全部:這就像是重播一樣,你會接收到以往的所有事件,然後就結束。這意味著你並不會接收到在你之後所發生的事件。
  • 讀取最新端:你不會接收到以前的事件,但是你會接收到在你之後的新事件,並且持續監聽。

讀取全部

這很適合讀取以前所發生的事件。

// 建立一個讀取 FooStream 事件的串流讀取者 goes.StreamReader。
reader := client.NewStreamReader("FooStream")  
// 呼叫 Next 來取得下一個事件。
for reader.Next() {  
    // 檢查讀取的資料有沒有錯誤。
    if reader.Err() != nil {
        // 處理錯誤。
    }
    // 如果這份資料沒有問題,那麼我們就用一個符合這個事件資料形態
    // 的建構體來解析這個事件的內容和中繼資料。
    fooEvent := FooEvent{}
    fooMeta := make(map[string]string)
    // 呼叫 Scan 來將事件內容與中繼資料解析到該建構體中。
    err := reader.Scan(&fooEvent, &fooMeta)
    if err != nil {
        // 在此處理解析錯誤。
    }
}

讀取最新端

這會讓你持續地接收到最新的事件(在你啟動之後所發生的事件)。

reader := client.NewStreamReader("FooStream")  
for reader.Next() {  
    if reader.Err() != nil {
        // 當沒有新的事件時,我們就設置 LongPoll 來等待。
        // 伺服器會每 15 秒重新檢查一次,或是等待串流裡有新的事件。
        if e, ok := reader.Err().(*goes.ErrNoMoreEvents); ok {
            reader.LongPoll(15)
        }
    } else {
        fooEvent := FooEvent{}
        _ := reader.Scan(&fooEvent, &fooMeta)
    }
}