蒼時弦也
蒼時弦也
資深軟體工程師
發表於

基於 Unix Pipeline 的 Golang Plugin 系統

最近在規劃工作中的一個服務重構,目標是要讓團隊在擴充新的資料源時可以更加快速,以及允許其他團隊貢獻新的資料源。剛好在讀軟體架構原理|工程方法時裡面介紹的 Microkernel 架構剛好就很適合用來處理這類問題。

基本概念

Microkernel 的架構通常是以 IPC(Inter-process Communication)的方式進行溝通,在許多 Unix 應用大多會選擇建立一個 Unix Socket 讓其他 Process(行程)進行連線,在 Golang 建立 Socket 連線時我們會得到一個實作 io.ReadWriteCloser 介面的物件(net.Conn)來進行互動。

若是改為 Unix 的 Pipeline 的話,我們就可以利用 Standard I/O 來組合出相同的 io.ReadWriteCloser 介面,以呼叫 /bin/cat 作為 Plugin 來示範。

 1package main
 2
 3import (
 4	"io"
 5	"log"
 6	"os/exec"
 7)
 8
 9type conn struct {
10	io.Reader
11	io.WriteCloser
12}
13
14func main() {
15	cmd := exec.Command("/bin/cat")
16	stdin, err := cmd.StdinPipe()
17	if err != nil {
18		log.Panic(err)
19	}
20
21	stdout, err := cmd.StdoutPipe()
22	if err != nil {
23		log.Panic(err)
24	}
25
26	c := conn{stdout, stdin}
27	defer c.Close()
28
29	err = cmd.Start()
30	if err != nil {
31		log.Panic(err)
32	}
33
34	go func() {
35		_, err := c.Write([]byte("Hello, World!\n"))
36		if err != nil {
37			log.Panic(err)
38		}
39	}()
40
41	buf := make([]byte, 1024)
42	n, err := c.Read(buf)
43	if err != nil {
44		log.Panic(err)
45	}
46
47	log.Printf("Read %d bytes: %s", n, buf[:n])
48}

我們可以想像 /bin/cat 是一種會把 Standard Input 的內容完整複製到 Standard Output 的 Plugin,那麼我們只需要利用 exec.Cmd 提供的 Pipeline 將某段字串寫入,就可以達到類似 Echo Plugin 的效果。

Plugin 介面

當我們可以 Standard I/O 跟其他 Process 互動後,會發現需要處理大量的字串解析才能根據不同需求提供不同功能,這個時候就可以使用 RPC(Remote Procedure Call) 來簡化解析的設計,利用 Golang 的 net/rpc 標準函式庫就可以很輕鬆的完成。

像是 Terraform 或者 protoc 都是採用 Protobuf 作為編碼(Encoding),本文會使用 net/rpc 預設的 gob 方式編碼。

我們重新調整上述的範例,改為以 net/rpc 的方式進行呼叫,因為 /bin/cat 會給予相同的回傳內容,因此我們可以預期輸入跟輸出的資料結構會完全一樣。

 1// ...
 2
 3type Echo struct {
 4	Msg string
 5}
 6
 7func main() {
 8	// ...
 9
10	rpc := rpc.NewClient(&conn{stdout, stdin})
11	defer rpc.Close()
12
13	err = cmd.Start()
14	if err != nil {
15		log.Panic(err)
16	}
17
18	var reply Echo
19	err = rpc.Call("Echo.Echo", Echo{"Hello, World!"}, &reply)
20	if err != nil {
21		log.Panic(err)
22	}
23
24	log.Println(reply.Msg)
25}

跟前一個版本相比,主要是將比較低階的 Write()Read()rpc.Client 取代,由 net/rpc 來幫我們管理讀寫上的行為。

在這個階段,我們可以透過替換不同的執行檔來得到不同的結果,就能夠利用抽換檔案來達到 Plugin 的效果。

Plugin 實作

呼叫 Plugin 的機制設計完畢後,我們還需要能夠使用 Golang 來製作新的 Plugin 提供各種不同的行為,此時就會用到 os.Stdinos.Stdout 來幫助我們實現一個單執行緒的 Plugin Server 給我們剛剛實現的 Plugin Client 使用。

 1package main
 2
 3import (
 4	"fmt"
 5	"io"
 6	"net/rpc"
 7	"os"
 8)
 9
10type conn struct {
11	io.Reader
12	io.WriteCloser
13}
14
15type Input struct {
16	Msg string
17}
18
19type Output struct {
20	Msg string
21}
22
23type Echo struct{}
24
25func (e *Echo) Echo(input Input, output *Output) error {
26	output.Msg = fmt.Sprintf("Echo: %s", input.Msg)
27	return nil
28}
29
30func main() {
31	server := rpc.NewServer()
32	server.Register(&Echo{})
33	server.ServeConn(&conn{os.Stdin, os.Stdout})
34}

因為 I/O 本身是 Blocking 的,因此 rpc.ServerServeConn() 方法會一直嘗試從 os.Stdin 讀取資料,直到我們將將 os.Stdin 關閉,這樣就可以確保我們能多次跟 Plugin 互動直到我們關閉這個 Plugin 的連線。

那麼,我們將原本的 exec.Command("/bin/cat") 替換成編譯好的 Plugin(如:go-echo)來執行。

1// ...
2
3// go build -o go-echo ./plugins/echo/main.go
4func main() {
5	cmd := exec.Command("./go-echo")
6	// ...
7}

因為我們自製的 Plugin 會將原始訊息前面加入 Echo: 再回傳,這時就跟 /bin/cat 的結果會有所不同,之後只需要以相同的方式實作不一樣的 Plugin 我們就可以依照需求支援不同的功能。

至於為什麼不使用 plugin 標準函式庫呢?這是因為原生的 Plugin 機制只能夠在 Unix 環境使用,在泛用性來說非常侷限,因此 Protobuf 也採用了類似本文的方式實現 Plugin 提供擴充不同語言的 Protobuf / gRPC 支援。

這種架構在效能上勢必會有一定的損失,因為我們無法直接在記憶體中完成所有操作以及需要額外的編碼處理,但是相比以遠端 API 呼叫來說又更穩定和快速,適合用在呼叫頻率不高但是需要能夠彈性擴充的情境(如:Code Generation、Terraform 的第三方雲端支援擴充等等)

更加完整的實作可以參考這個 Repository 裡面的實作,以簡單的 Command Line 輸入介面新增不同商品資訊,並且根據選擇的 Plugin 輸出成不同的格式(JSON、XML)但不需要重新編譯主程式。