---
title: "基於 Unix Pipeline 的 Golang  Plugin 系統"
date: 2024-12-18T00:00:00+08:00
publishDate: 2024-12-18T00:00:00+08:00
lastmod: 2024-12-15T17:26:06+08:00
tags: ["Golang","經驗","Plugin"]
toc: true
permalink: "https://blog.aotoki.me/posts/2024/12/18/build-unix-pipeline-based-golang-plugin-system/"
language: "zh-tw"
---


最近在規劃工作中的一個服務重構，目標是要讓團隊在擴充新的資料源時可以更加快速，以及允許其他團隊貢獻新的資料源。剛好在讀[軟體架構原理｜工程方法](https://www.tenlong.com.tw/products/9789865026615)時裡面介紹的 [Microkernel](https://en.wikipedia.org/wiki/Microkernel) 架構剛好就很適合用來處理這類問題。

<!--more-->
## 基本概念{#basic-idea}

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

若是改為 Unix 的 [Pipeline](https://zh.wikipedia.org/zh-tw/%E7%AE%A1%E9%81%93_(Unix)) 的話，我們就可以利用 Standard I/O 來組合出相同的 `io.ReadWriteCloser` 介面，以呼叫 `/bin/cat` 作為 Plugin 來示範。

```go
package main

import (
	"io"
	"log"
	"os/exec"
)

type conn struct {
	io.Reader
	io.WriteCloser
}

func main() {
	cmd := exec.Command("/bin/cat")
	stdin, err := cmd.StdinPipe()
	if err != nil {
		log.Panic(err)
	}

	stdout, err := cmd.StdoutPipe()
	if err != nil {
		log.Panic(err)
	}

	c := conn{stdout, stdin}
	defer c.Close()

	err = cmd.Start()
	if err != nil {
		log.Panic(err)
	}

	go func() {
		_, err := c.Write([]byte("Hello, World!\n"))
		if err != nil {
			log.Panic(err)
		}
	}()

	buf := make([]byte, 1024)
	n, err := c.Read(buf)
	if err != nil {
		log.Panic(err)
	}

	log.Printf("Read %d bytes: %s", n, buf[:n])
}
```

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

## Plugin 介面{#plugin-interface}

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

> 像是 [Terraform](https://www.terraform.io/) 或者 [protoc](https://grpc.io/docs/protoc-installation/) 都是採用 Protobuf 作為編碼（Encoding），本文會使用 `net/rpc` 預設的 [gob](https://pkg.go.dev/encoding/gob) 方式編碼。

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

```go
// ...

type Echo struct {
	Msg string
}

func main() {
	// ...

	rpc := rpc.NewClient(&conn{stdout, stdin})
	defer rpc.Close()

	err = cmd.Start()
	if err != nil {
		log.Panic(err)
	}

	var reply Echo
	err = rpc.Call("Echo.Echo", Echo{"Hello, World!"}, &reply)
	if err != nil {
		log.Panic(err)
	}

	log.Println(reply.Msg)
}
```

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

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

## Plugin 實作{#implement-plugin}

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

```go
package main

import (
	"fmt"
	"io"
	"net/rpc"
	"os"
)

type conn struct {
	io.Reader
	io.WriteCloser
}

type Input struct {
	Msg string
}

type Output struct {
	Msg string
}

type Echo struct{}

func (e *Echo) Echo(input Input, output *Output) error {
	output.Msg = fmt.Sprintf("Echo: %s", input.Msg)
	return nil
}

func main() {
	server := rpc.NewServer()
	server.Register(&Echo{})
	server.ServeConn(&conn{os.Stdin, os.Stdout})
}
```

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

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

```go
// ...

// go build -o go-echo ./plugins/echo/main.go
func main() {
	cmd := exec.Command("./go-echo")
	// ...
}
```

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

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

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

更加完整的實作可以參考這個 [Repository](https://github.com/elct9620/demo-stdio-go-plugin) 裡面的實作，以簡單的 Command Line 輸入介面新增不同商品資訊，並且根據選擇的 Plugin 輸出成不同的格式（JSON、XML）但不需要重新編譯主程式。

