如何基于 Golang 標準庫實現(xiàn)插件功能
什么是插件
簡單來說,插件就是可以被其他軟件加載的軟件,通常用于擴展應(yīng)用程序的功能和外觀,插件的開發(fā)人員甚至可以不直接修改基礎(chǔ)應(yīng)用程序。
你很可能在日常生活中使用過插件,也許用的是其他名稱,如擴展(extensions)或附加組件(add-ons)。最常見的例子就是 VSCode 擴展,你應(yīng)該用過 VSCode,對吧?畢竟這是最受程序員歡迎的文本編輯器。如果你用過,一定會同意 VSCode 本身就是一個文本編輯器,而不是集成開發(fā)環(huán)境。其基本功能非常簡單,幾乎不支持集成開發(fā)環(huán)境中常見的功能,如調(diào)試、自動完成和測試導航等。不過,通過編輯器的擴展市場,可以找到支持這些功能以及其他更多功能的各種插件。事實上,插件已成為編輯器的主要賣點之一,促使工具開發(fā)人員集中精力為編輯器制作專用插件,有時甚至超越了編碼本身的范疇,就像 Figma 所做的那樣[2]。
對于 VSCode 而言,插件是用 JavaScript 編寫的,但也有基于 Go 編寫插件的情況。例如 Terraform(云提供商基礎(chǔ)設(shè)施即代碼服務(wù)),它允許用戶為其工具編寫插件[3],從而與多個云供應(yīng)商(AWS、GCP、Azure......)進行交互。
另一個例子是 API 網(wǎng)關(guān)服務(wù) Kong,它允許開發(fā)人員使用不同語言(包括 Go)編寫插件,這些插件[4]可以在將請求轉(zhuǎn)發(fā)給底層服務(wù)之前,對接收到的請求進行處理。
免責聲明
- 本文假設(shè)你至少對 Go 語言有基本的了解。如果還不了解,建議先了解一下 Go[5],然后再來閱讀。
- 本文示例代碼中的某些功能要求至少使用 Go 1.21.0 版本。
- Windows 機器尚未支持 Go 的插件功能。如果你用的是 Windows,建議使用 WSL[6]。
- 本文生成的代碼可在 Github 代碼庫[7]中找到。
插件的基礎(chǔ)設(shè)施
我們將插件基礎(chǔ)架構(gòu)分為三個部分:協(xié)議/API、實現(xiàn)和插件加載器。請注意,這種劃分不是官方標準,也不是紙上談兵,而是在實際應(yīng)用中的通常做法。
協(xié)議/API
協(xié)議是我們?nèi)我庠O(shè)置的定義和默認值,這樣就可以在各組件之間進行簡潔的通信。和任何協(xié)議一樣,需要設(shè)定插件和基礎(chǔ)應(yīng)用程序之間的通信方式。為此,我們可以使用不同的方法,既可以通過簡單的文檔解釋期望的方法,也可以定義接口庫(編程接口,如 class foo implements bar)。只要插件的實現(xiàn)遵循這些準則,應(yīng)用就能調(diào)用插件代碼。
實現(xiàn)
我們需要編碼來實現(xiàn)協(xié)議設(shè)定的功能。也就是說,需要在插件代碼中實現(xiàn)預期的函數(shù)和變量,以便主應(yīng)用程序可以調(diào)用。
提醒一下,插件代碼并不局限于這些實現(xiàn)方式。
插件加載器
這是需要由主應(yīng)用程序執(zhí)行的部分,有兩個職責:查找插件并在代碼中加載其功能。
插件是主程序項目的外部組件,因此需要一種方法來查找該程序的所有插件。我們可以簡單的在文件系統(tǒng)中定義一個固定的文件夾來存放所有插件,但最好是允許應(yīng)用程序用戶通過配置文件來指向他們的插件,或者兩種方式同時支持。
安裝所有插件后,需要在應(yīng)用程序中訪問它們的應(yīng)用程序接口。這通常是通過鉤子實現(xiàn)的:運行時調(diào)用插件(或插件的一部分)的部分。以 VSCode 為例,"文件加載時"就是這樣一個鉤子,因此插件可以使用這個鉤子捕捉加載的文件并據(jù)此運行。實現(xiàn)哪些鉤子以及何時實現(xiàn)鉤子與應(yīng)用程序的邏輯有內(nèi)在聯(lián)系,只能具體問題具體分析。
我們在構(gòu)建什么
學習編程的最佳方式莫過于動手實踐。因此我們來創(chuàng)建一個使用插件的簡單應(yīng)用程序。
我們要構(gòu)建的是一個基于插件的 HTTP 重定向服務(wù)。這是一個簡單的 HTTP 服務(wù),監(jiān)聽端口中的請求并將其重定向到另一個服務(wù)器,同時將響應(yīng)傳遞給原始客戶端。有了這項服務(wù),我們就可以接入請求并對其進行修改。在本例中,我們將通過插件獲取請求并打印。
至于插件加載部分,我們使用Go庫作為協(xié)議,并通過配置文件來定位插件。
1.開發(fā)插件協(xié)議
我們首先定義插件協(xié)議。為此,我們定義一個 go 庫組件。
在定義該模塊之前,我們先定義應(yīng)用程序組件:
# From a folder you want to keep the project:
mkdir http-redirect
cd http-redirect
go work init
go mod init github.com/<your_github_username>/http-redirect
go work use .
當然,你可以自行決定應(yīng)用名稱。因為需要多個模塊進行交互,因此我們決定使用 go 工作區(qū)。要了解更多相關(guān)信息,請查看文檔[8]。
接下來可以創(chuàng)建庫組件了:
# From http-redirect
mkdir protocol
cd protocol
go mod init github.com/<your_github_username>/http-redirect/protocol
go work use . # Add new module to workspace
接下來創(chuàng)建一些文件,整個文件樹應(yīng)該是這樣的:
我們將在 protocol.go 中開展工作。我們希望在協(xié)議中為每個請求調(diào)用函數(shù)。因此,我們要為插件實現(xiàn)一個名為 PreRequestHook 的函數(shù),看起來是這樣的:
// protocol.go
package protocol
import "net/http"
// Plugins should export a variable called "Plugin" which implements this interface
type HttpRedirectPlugin interface {
PreRequestHook(*http.Request)
}
代碼很簡單,我們只需獲取指向 http.Request 類型的指針(因為可能更改請求),然后將每個 HTTP 請求傳遞給我們的服務(wù)器。我們使用的是標準庫定義的類型,但請注意,也可以根據(jù)應(yīng)用需求使用不同的類型。
就是這樣!但不要被例子的簡單性所迷惑。對于大型應(yīng)用來說,這可能是一個相當大的文件,其中包含不同的接口、默認實現(xiàn)、配置和其他亂七八糟的東西。
2.實現(xiàn)插件
現(xiàn)在有了一個可遵循的協(xié)議,就可以創(chuàng)建并實現(xiàn)插件了。
同樣,我們?yōu)椴寮?chuàng)建一個新組件,并為其創(chuàng)建一個文件。
# From http-redirect
mkdir log-plugin
cd log-plugin
go mod init github.com/<your_github_username>/http-redirect/log-plugin
go work use . # Add new module to workspace
touch plugin.go
現(xiàn)在的文件樹應(yīng)該是這樣的:
我們來編寫插件!首先,創(chuàng)建一個函數(shù)來打印請求。
// log-plugin/plugin.go
package main
import (
"log/slog"
"net/http"
"net/http/httputil"
)
func logRequest(req *http.Request) {
result, err := httputil.DumpRequest(req, true)
if err != nil {
slog.Error("Failed to print request", "err", err)
}
slog.Info("Request sent:", "req", result)
}
func logRequestLikeCUrl(req *http.Request) {
panic("Unimplemented!")
}
func main() { /*empty because it does nothing*/ }
這里的未實現(xiàn)函數(shù)只是為了顯示我們可以為更復雜的協(xié)議添加更多功能,只是目前還無法正確配置,因此不會使用。
我們要用到的是 logRequest 函數(shù),它通過 go 標準庫的結(jié)構(gòu)化日志組件打印請求。這就完成了我們的功能,但現(xiàn)在需要導出插件,使其滿足協(xié)議要求。
你可能注意到了,有一個什么也不做的 main 函數(shù)。這是 go 編譯器的要求,因為某些功能需要一個入口點。雖然這個編譯包中存在 main 函數(shù),但不會作為可執(zhí)行文件被調(diào)用。
我們需要導入庫。一般情況下,可以使用 go get 來恢復這個庫,但由于我們是在本地機器上開發(fā),因此只需在 go.mod 文件中添加庫路徑即可:
replace github.com/profusion/http-redirect/protocol => ../protocol
接下來我們創(chuàng)建一個實現(xiàn) HttpRedirectPlugin 接口的結(jié)構(gòu)體,并調(diào)用日志函數(shù)。
// log-plugin/plugin.go
package main
import (
//…
"github.com/<your_github_username>/http-redirect/protocol"
)
// … previous code …
type PluginStr struct{}
// Compile time check for
// PreRequestHook implements protocol.HttpRedirectPlugin.
var _ protocol.HttpRedirectPlugin = PluginStr{}
// PreRequestHook implements protocol.HttpRedirectPlugin.
func (p PluginStr) PreRequestHook(req *http.Request) {
logRequest(req)
}
var Plugin = PluginStr{}
這就是需要的所有代碼。我們只需將其作為插件構(gòu)建即可。為此,我們只需向 go 編譯器傳遞 buildmode 標志:
# From http-redirect/log-plugin
go build -buildmode=plugin -o plugin.so plugin.go
瞧!我們有了一個插件!現(xiàn)在只需將其加載到應(yīng)用程序就行了。
3.加載插件
我們需要一個應(yīng)用程序來加載插件。這不是本文的重點,但以下是 Go 中 HTTP 重定向服務(wù)器代碼,我們可以對其進行修改。
// cmd/main.go
package main
import (
"flag"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
)
var from int
var to string
func init() {
flag.IntVar(&from, "from", 5555, "Local port to get requests")
flag.StringVar(&to, "to", "", "Target server to redirect request to")
}
func main() {
flag.Parse()
Listen()
}
type proxy struct{}
func Listen() {
p := &proxy{}
srvr := http.Server{
Addr: fmt.Sprintf(":%d", from),
Handler: p,
}
if err := srvr.ListenAndServe(); err != nil {
slog.Error("Server is down", "Error", err)
}
}
// ServeHTTP implements http.Handler.
func (p *proxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
// Remove original URL for redirect
req.RequestURI = ""
// Set URL accordingly
req.URL.Host = to
if req.TLS == nil {
req.URL.Scheme = "http"
} else {
req.URL.Scheme = "https"
}
// Remove connection headers
// (will be replaced by redirect client)
DropHopHeaders(&req.Header)
// Register Proxy Request
SetProxyHeader(req)
// Resend request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
http.Error(rw, "Server Error: Redirect failed", http.StatusInternalServerError)
}
defer resp.Body.Close()
// Once again, remove connection headers
DropHopHeaders(&resp.Header)
// Prepare and send response
CopyHeaders(rw.Header(), &resp.Header)
rw.WriteHeader(resp.StatusCode)
if _, err = io.Copy(rw, resp.Body); err != nil {
slog.Error("Error writing response", "error", err)
}
}
func CopyHeaders(src http.Header, dst *http.Header) {
for headingName, headingValues := range src {
for _, value := range headingValues {
dst.Add(headingName, value)
}
}
}
// Hop-by-hop headers. These are removed when sent to the backend.
// http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html
var hopHeaders = []string{
"Connection",
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"Te", // canonicalized version of "TE"
"Trailers",
"Transfer-Encoding",
"Upgrade",
}
func DropHopHeaders(head *http.Header) {
for _, header := range hopHeaders {
head.Del(header)
}
}
func SetProxyHeader(req *http.Request) {
headerName := "X-Forwarded-for"
target := to
if prior, ok := req.Header[headerName]; ok {
// Not first proxy, append
target = strings.Join(prior, ", ") + ", " + target
}
req.Header.Set(headerName, target)
}
首先需要找到插件的位置。為此,我們將用 JSON 定義配置文件,在里面定義路徑列表,在本文中列表里只有一項,但請注意,這是一個為插件定義配置的機會。
// config.json
[
"log-plugin/plugin.so"
]
這就足夠了。然后我們編寫讀取該文件內(nèi)容的代碼,為了保持整潔,將在另一個文件中進行插件加載。
// cmd/plugin.go
package main
import (
"encoding/json"
"os"
)
// global but private, safe usage here in this file
var pluginPathList []string
func LoadConfig() {
f, err := os.ReadFile("config.json")
if err != nil {
// NOTE: in real cases, deal with this error
panic(err)
}
json.Unmarshal(f, &pluginPathList)
}
然后加載插件本身,為此我們將使用標準庫中的 golang 插件組件[9]。
// cmd/plugin.go
package main
import (
//…
"plugin"
)
// ...previous code...
var pluginList []*plugin.Plugin
func LoadPlugins() {
// Allocate a list for storing all our plugins
pluginList = make([]*plugin.Plugin, 0, len(pluginPathList))
for _, p := range pluginPathList {
// We use plugin.Open to load the plugin by path
plg, err := plugin.Open(p)
if err != nil {
// NOTE: in real cases, deal with this error
panic(err)
}
pluginList = append(pluginList, plg)
}
}
// Let's throw this here so it loads the plugins as soon as we import this module
func init() {
LoadConfig()
LoadPlugins()
}
插件加載后,就可以訪問其符號了,包括我們在協(xié)議中定義的變量 Plugin。我們修改之前的代碼,保存這個變量,而不是整個插件。現(xiàn)在,我們的文件看起來是這樣的:
// cmd/plugin.go
import (
//…
"protocol"
"net/http"
)
//…
// Substitute previous code
var pluginList []*protocol.HttpRedirectPlugin
func LoadPlugins() {
// Allocate a list for storing all our plugins
pluginList = make([]*protocol.HttpRedirectPlugin, 0, len(pluginPathList))
for _, p := range pluginPathList {
// We use plugin.Open to load plugins by path
plg, err := plugin.Open(p)
if err != nil {
// NOTE: in real cases, deal with this error
panic(err)
}
// Search for variable named "Plugin"
v, err := plg.Lookup("Plugin")
if err != nil {
// NOTE: in real cases, deal with this error
panic(err)
}
// Cast symbol to protocol type
castV, ok := v.(protocol.HttpRedirectPlugin)
if !ok {
// NOTE: in real cases, deal with this error
panic("Could not cast plugin")
}
pluginList = append(pluginList, &castV)
}
}
// …
很好,現(xiàn)在 pluginList 中的所有變量都是正常的 golang 變量,可以直接訪問,就好像從一開始就是代碼的一部分。然后,我們構(gòu)建鉤子函數(shù),在發(fā)送請求前調(diào)用所有插件鉤子。
// cmd/plugin.go
//…
func PreRequestHook(req *http.Request) {
for _, plg := range pluginList {
// Plugin is a list of pointers, we need to dereference them
// to use the proper function
(*plg).PreRequestHook(req)
}
}
最后,在主代碼中調(diào)用鉤子:
// cmd/main.go
//…
// ServeHTTP implements http.Handler.
func (p *proxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
PreRequestHook(req)
// …
就是這樣!我們創(chuàng)建了一個應(yīng)用程序和一個插件,將插件加載到應(yīng)用中,然后針對收到的每個請求運行插件代碼,并記錄這些請求。
想要測試?直接運行就行:
# From http-redirect
go run cmd/*.go -from <port> -to <url>
結(jié)論
我們在本文中討論了什么是插件、插件的用途,以及如何基于 Go 標準庫創(chuàng)建支持插件的應(yīng)用程序的能力。在未來的工作中,請考慮通過這種基礎(chǔ)架構(gòu)為解決方案提供更好的可擴展性,從而幫助其他開發(fā)人員可以更廣泛的使用我們的工具和應(yīng)用。