用 GoLang 編寫類似 Apache Camel 路由引擎

banq發表於2024-06-03


在本文中我們將討論的是:

  • 用 GoLang 編寫的類似 Apache Camel 的路由引擎
  • 嵌入式 WebAssembly 引擎,用於可擴充套件且安全的訊息路由和轉換
  • Actors 模型
  • OCI 工件

背景

  • Apache Camel是一個開源的整合框架,提供了一套工具和模式,以簡化不同應用程式、系統和技術之間的整合。
  • WebAssembly(Wasm)是一種低階位元組碼格式,設計為C、C++和Rust等高階語言的可移植編譯目標。
  • Actor模型(Actors Model)是一種用於開發並行和分散式系統的併發計算模型。
  • OCI Artifacts是一種使用符合Open Container Initiative規範的容器登錄檔來儲存任意檔案的方式。

我們來定義一下首字母縮略詞的含義:

  • EIP:企業整合模式 
  • DSL:領域特定語言

讓我們從一個簡單的例子開始:

- route:
   from:
     uri: 'kafka:iot'
     steps:
       - to: 'log:in'
       - choice:
           when:
             - jq: 'header(<font>"kafka.KEY") == "sensor-1"'
               steps:
               - transform:
                   jq: '.foo'
               - to: 'log:s1'
             - jq: 'header(
"kafka.KEY") == "sensor-2"'
               steps:
                 - transform:
                     jq: '.bar'
                 - to: 'log:s2
           otherwise:
             steps:
               - to:
"log:unknown"


在 Apache Camel 中,從高層次上講,每條路由都被轉換為一系列函式( Camel 術語中的處理器),每個函式都實現一個特定的 EIP,形成一個執行管道。當事件觸發路由的執行時,管道會將每個函式提交給內部執行器引擎,以便線上程池中執行。 

由於新引擎是用 Go 編寫的,我們現在可以利用 Go 的併發構建塊(goroutines、channels)來實現類似的模型。我們甚至可以更進一步,將路由引擎實現為一個 Actor 系統(在這個階段,引擎利用優秀的Proto.Actor庫作為基礎)。

Actor 模型
因為Actor 有一些特點,使得他們在我們的用例中非常適合和有趣,舉一些例子:

  • 狀態:表示參與者的內部狀態,取決於具體的參與者;有些參與者是無狀態的,但有些可能需要儲存某些狀態(即節流訊息)
  • 行為:在某一時間點針對訊息做出反應而採取的行動。
  • 郵箱:連線傳送者和接收者;每個參與者都有一個郵箱,所有傳送者都會將他們的訊息放入其中
  • 子級:演員可以建立子級來委派子任務,在這種情況下,演員將自動監督他們。
  • 主管:  主管將任務委託給下屬,因此必須對其故障做出響應。當下屬檢測到故障(即恐慌)時,它會向其主管傳送一條訊息,表示故障**。**預設情況下,子 Actor 會將錯誤轉發給他們的主管,直到到達全域性主管。

除了 Actor 的常見功能外,Prto.Actor 庫還提供了一些附加功能(尚未成為 POC 的一部分):
  • 中介軟體:允許攔截傳入和傳出的訊息並新增一些特定的行為,例如跟蹤、指標和日誌記錄。
  • 位置透明性:參與者的所有互動都使用純訊息傳遞,並且一切都是非同步的,因此在單個參與者系統或機器叢集中執行時,所有功能都應該平等可用,從而可以根據資料親和性規則安排一些處理邏輯 
  • 永續性:在某些情況下,需要或非常希望永久儲存參與者的狀態,以便可以在參與者重新啟動時恢復,從而使系統能夠從其離開的位置進行恢復。

當在 Camel Go 執行時中載入路由時,執行時會建立一個根參與者,該參與者通常由from定義來描述,然後它會生成其所有子參與者,而這些子參與者本身又可以生成其他子參與者。 

這棵Actor 演員樹被稱為Actor 演員系統。

每個父級 Actor 都知道子級 Actor,並可以訪問子級 Actor 的地址,因此父級 Actor 可以向子級 Actor 傳送訊息。子級 Actor 知道他們收到的每條訊息的傳送者是誰,並且會在訊息處理完畢後以訊息的形式回覆傳送者。

參與者系統的一個重要特性是,由於參與者只能透過訊息進行通訊,並且它們依賴於郵箱來確保一次只處理一條訊息,因此參與者可以在單執行緒假象中執行,從而保護參與者的狀態免受任何正常的併發問題的影響。

Wasm 用於擴充套件
我一直在致力於為 Apache Kafka 提供託管連線服務,其中最關鍵但最困難的部分之一是如何以更簡單、更安全的方式執行非平凡的處理邏輯。隨著時間的推移,我們嘗試了許多選項,例如函式、指令碼語言、自定義影像等,但沒有一個真正令人滿意,即 

  • 指令碼語言:
    • 要求使用者最終學習一門新語言
    • 由於指令碼可以訪問檔案、環境和其他主機資源,因此執行可能會損害主機系統
  • 功能:
    • 使應用程式的部署更加複雜,因為它需要與其他資源一起部署(功能)
    • 成本通常較高,因為必須在主應用程式和功能之間傳輸資料,這會導致 I/O 並且經常脫離資料區域性性  
    • 故障處理變得更加複雜 

因此,我決定嘗試一下 Wasm,因為 Wasm 的初衷是:
  • 多語言:許多語言都支援 Wasm 作為編譯目標,這吸引了更多可能不熟悉執行時語言的開發人員。
  • 安全:WebAssembly 的主要目標之一是在沙箱內安全地執行不受信任的程式碼,只有主機可以配置在沙箱中執行的程式碼可以訪問的內容,這使其非常適合外掛/擴充套件。 
  • 可嵌入: Wasm 執行時可以嵌入到主機應用程式中,從而可以使用任何可以編譯為 Wasm 的語言進行安全地擴充套件,而無需離開應用程式的額外基礎設施或資料。

目前有許多 Wasm 執行時,但由於 Go 是此 POC 的首選語言,因此我利用Wazero,因為它是唯一零依賴的 WebAssembly 執行時(即它不需要任何本機庫繫結)。 

在此階段,執行時期望的訊息處理的偽簽名是:
func (inOut Message) error
儘管這是一個非常簡單的函式,但從主機程式呼叫它並不簡單,因為您需要跨越主機/客戶機記憶體邊界,這可以透過多種方式完成,其中包括:

  1. 透過手動處理 WASM 線性記憶體中的記憶體分配和釋放
  2. 透過使用 STDIN/OUT 作為交換資料的方式(CGI 任何人)

我的第一次嘗試是使用選項A,這導致我進行了非常長時間的研究,以瞭解如何安全地管理主機和客戶機之間的記憶體,特別是與具有垃圾收集器的語言(例如 Go)相關的記憶體。透過檢視wazero repo 上的分配示例可以看到一些結果(感謝 Adrian Cole 和 Edoardo Vacchi 的耐心和指導),但為了簡單起見(請記住,這只是一個 POC)和可移植性,我決定轉向選項B,在主機端,它最終與此處的示例類似:

func (p *Plugin) invoke(in any, out any) error {
   fn := p.lookupFunction(<font>"process")
   if fn == nil {
       return nil, errors.New(
"process is not exported")
   }

   data, err := json.Marshal(in)
   if err != nil {
       return err
   }

   
// clean up the buffer<i>
   p.stdin.Reset()
   p.stdout.Reset()

   defer func() {
       
// clean up the buffer when the method<i>
       p.stdin.Reset()
       p.stdout.Reset()
   }()

   ws, err := p.stdin.Write(data)
   if err != nil {
       return err
   }

   
// invoke the function with the size of the message<i>
   
// so the guest knows how many bites have to be read<i>
   
// from STDIN<i>
   ptrSize, err := fn.Call(context.Background(), uint64(ws))
   if err != nil {
       return err
   }

   
// since WASM virtual machine supports only 32 bits<i>
   
// we can use 32 bit to hold the response data size<i>
   
// and the remaining for flags, i.e. to indicate<i>
   
// that an error has occurred<i>
   resFlag := uint32(ptrSize[0] >> 32)
   resSize := uint32(ptrSize[0])

   bytes := make([]byte, resSize)
   _, err = p.stdout.Read(bytes)
   if err != nil {
       return err
   }

   switch resFlag {
   case 1:
       return errors.New(string(bytes))
   default:
       return json.Unmarshal(bytes, &out)
   }
}

為了簡化編寫處理器的過程,已經實現了一個小型 SDK:

type Processor func(context.Context, *Message) (*Message, error)

var processor Processor

func RegisterProcessors(p Processor) {
   processor = p
}

<font>//export process<i>
func _process(size uint32) uint64 {
   b := make([]byte, size)

   _, err := io.ReadAtLeast(os.Stdin, b, int(size))
   if err != nil {
       return 0
   }

   req := Message{}
   if err := json.Unmarshal(b, &req); err != nil {
       return 0
   }
   res, err := processor(context.Background(), &req)
   if err != nil {
       n, err := os.Stdout.WriteString(err.Error())
       if err != nil {
           return 0
       }

       
// Indicate that this is the error string<i>
       return (uint64(1) << uint64(32)) | uint64(n)
   }

   b, err = json.Marshal(res)
   if err != nil {
       return 0
   }

   n, err := os.Stdout.Write(b)
   if err != nil {
       return 0
   }

   return uint64(n)
}

然後可以利用它來編寫處理器,而不必處理序列化/反序列化和/或分配: 

func main() {
   <font>// register the processor function<i>
   RegisterProcessors(Process)
}

func Process(_ context.Context, r *Message) (*Message, error) {
   request.Data = []byte(strings.ToLower(string(r.Data)))
   return request, nil
}

要在路由引擎中使用 Wasm 函式,我們可以利用wasm 語言。例如:

- route:
   from:
     uri: 'timer:foo?period=1s'
     steps:
       - transform:
           wasm: 'etc/wasm/fn/to_upper.wasm'
       - to:
           uri: <font>"log:info"


Wasm 分發版的 OCI Artifacts
現在我們有了一個可以以某種方式執行的路由引擎(注意: 現階段僅支援少數 EIP),它支援 Wasm 作為實現轉換邏輯的一種方式,我們必須定義如何讓使用者輕鬆運送和使用 Wasm 工件。

當然,有很多方法可以做到這一點,例如使用已編譯的 Wasm 模型構建自定義容器映象,或者使 Wasm 模組在引擎可以讀取的檔案系統中可用,但是,由於幾乎每個雲原生系統都必須處理容器映象登錄檔,我們可以利用 OCI 登錄檔和 OCI Artifacts。 

那麼,什麼是 OCI Artifact?
這是一項正在進行的開放容器倡議,旨在定義允許 OCI Registry 儲存任意檔案的規範。這並不是什麼新鮮事,許多專案已經開始使用 OCI Artifacts,例如:

  • Web 程式集中心
  • 使用基於 OCI 的登錄檔
  • GitHub Packages 容器登錄檔現已正式釋出
  • sigstore/cosign:容器簽名
  • OCI 備忘單 | Flux

在我們的案例中,我們利用ORAS專案輕鬆地將 Wasm 模組打包為 OCI 工件,因此我們需要做的就是設定正確的媒體型別,讓路由引擎識別提供 Wasm 模組的層,例如:

oras push \
    quay.io/lburgazzoli/camel-go-wasm:latest \
    etc/wasm/fn/to_upper.wasm:application/vnd.module.wasm.content.layer.v1+wasm

此命令將使用 Go Routing Engine 所需的媒體型別將simple_process.wasm模組檔案推送到 quay.io(相容的 OCI 登錄檔)。然後,儲存 wasm 模組的層將以檔案路徑命名,因此它將是etc/wasm/fn/simple_process.wasm。

要使用 OCI Artifact,只需指示wasm 語言從影像中查詢模組即可:

- route:
   from:
     uri: 'timer:foo?period=1s'
     steps:
       - transform:
           wasm:
             image: 'quay.io/lburgazzoli/camel-go-wasm:latest'
             path: 'etc/wasm/fn/yo_upper.wasm'
       - to:
           uri: <font>"log:info"

此時,Camel Go 的wasm語言會檢查已配置的容器映象,然後下載並載入包含已配置的 Wasm 模組的層。

結論
在第一部分中,我詳細介紹了用 GoLang 編寫的類似 Apache Camel 的路由引擎的實現,該引擎利用 Wasm 實現可擴充套件性。在下一篇文章中,我將提供更多實現細節和部署選項。    

程式碼可以在我的camel-go儲存庫中找到

 

相關文章