什么是插件
简单来说,插件就是可以被其他软件加载的软件,通常用于扩展应用程序的功能和外观,插件的开发人员甚至可以不直接修改基础应用程序。
你很可能在日常生活中使用过插件,也许用的是其他名称,如扩展(extensions)或附加组件(add-ons)。最常见的例子就是 VSCode 扩展,你应该用过 VSCode,对吧?毕竟这是最受程序员欢迎的文本编辑器。如果你用过,一定会同意 VSCode 本身就是一个文本编辑器,而不是集成开发环境。其基本功能非常简单,几乎不支持集成开发环境中常见的功能,如调试、自动完成和测试导航等。不过,通过编辑器的扩展市场,可以找到支持这些功能以及其他更多功能的各种插件。事实上,插件已成为编辑器的主要卖点之一,促使工具开发人员集中精力为编辑器制作专用插件,有时甚至超越了编码本身的范畴,就像 Figma 所做的那样[2]。
对于 VSCode 而言,插件是用 JavaScript 编写的,但也有基于 Go 编写插件的情况。例如 Terraform(云提供商基础设施即代码服务),它允许用户为其工具编写插件[3],从而与多个云供应商(AWS、GCP、Azure......)进行交互。
另一个例子是 API 网关服务 Kong,它允许开发人员使用不同语言(包括 Go)编写插件,这些插件[4]可以在将请求转发给底层服务之前,对接收到的请求进行处理。
免责声明
- 本文假设你至少对 Go 语言有基本的了解。如果还不了解,建议先了解一下 Go[5],然后再来阅读。
- 本文示例代码中的某些功能要求至少使用 Go 1.21.0 版本。
- Windows 机器尚未支持 Go 的插件功能。如果你用的是 Windows,建议使用 WSL[6]。
- 本文生成的代码可在 Github 代码库[7]中找到。
插件的基础设施
我们将插件基础架构分为三个部分:协议/API、实现和插件加载器。请注意,这种划分不是官方标准,也不是纸上谈兵,而是在实际应用中的通常做法。
协议/API
协议是我们任意设置的定义和默认值,这样就可以在各组件之间进行简洁的通信。和任何协议一样,需要设定插件和基础应用程序之间的通信方式。为此,我们可以使用不同的方法,既可以通过简单的文档解释期望的方法,也可以定义接口库(编程接口,如 class foo implements bar)。只要插件的实现遵循这些准则,应用就能调用插件代码。
实现
我们需要编码来实现协议设定的功能。也就是说,需要在插件代码中实现预期的函数和变量,以便主应用程序可以调用。
提醒一下,插件代码并不局限于这些实现方式。
插件加载器
这是需要由主应用程序执行的部分,有两个职责:查找插件并在代码中加载其功能。
插件是主程序项目的外部组件,因此需要一种方法来查找该程序的所有插件。我们可以简单的在文件系统中定义一个固定的文件夹来存放所有插件,但最好是允许应用程序用户通过配置文件来指向他们的插件,或者两种方式同时支持。
安装所有插件后,需要在应用程序中访问它们的应用程序接口。这通常是通过钩子实现的:运行时调用插件(或插件的一部分)的部分。以 VSCode 为例,"文件加载时"就是这样一个钩子,因此插件可以使用这个钩子捕捉加载的文件并据此运行。实现哪些钩子以及何时实现钩子与应用程序的逻辑有内在联系,只能具体问题具体分析。
我们在构建什么
学习编程的最佳方式莫过于动手实践。因此我们来创建一个使用插件的简单应用程序。
我们要构建的是一个基于插件的 HTTP 重定向服务。这是一个简单的 HTTP 服务,监听端口中的请求并将其重定向到另一个服务器,同时将响应传递给原始客户端。有了这项服务,我们就可以接入请求并对其进行修改。在本例中,我们将通过插件获取请求并打印。
至于插件加载部分,我们使用Go库作为协议,并通过配置文件来定位插件。
1.开发插件协议
我们首先定义插件协议。为此,我们定义一个 go 库组件。
在定义该模块之前,我们先定义应用程序组件:
# 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 .
当然,你可以自行决定应用名称。因为需要多个模块进行交互,因此我们决定使用 go 工作区。要了解更多相关信息,请查看文档[8]。
接下来可以创建库组件了:
# 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
接下来创建一些文件,整个文件树应该是这样的:
我们将在 protocol.go 中开展工作。我们希望在协议中为每个请求调用函数。因此,我们要为插件实现一个名为 PreRequestHook 的函数,看起来是这样的:
// 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 请求传递给我们的服务器。我们使用的是标准库定义的类型,但请注意,也可以根据应用需求使用不同的类型。
就是这样!但不要被例子的简单性所迷惑。对于大型应用来说,这可能是一个相当大的文件,其中包含不同的接口、默认实现、配置和其他乱七八糟的东西。
2.实现插件
现在有了一个可遵循的协议,就可以创建并实现插件了。
同样,我们为插件创建一个新组件,并为其创建一个文件。
# 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
现在的文件树应该是这样的:
我们来编写插件!首先,创建一个函数来打印请求。
// 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*/ }
这里的未实现函数只是为了显示我们可以为更复杂的协议添加更多功能,只是目前还无法正确配置,因此不会使用。
我们要用到的是 logRequest 函数,它通过 go 标准库的结构化日志组件打印请求。这就完成了我们的功能,但现在需要导出插件,使其满足协议要求。
你可能注意到了,有一个什么也不做的 main 函数。这是 go 编译器的要求,因为某些功能需要一个入口点。虽然这个编译包中存在 main 函数,但不会作为可执行文件被调用。
我们需要导入库。一般情况下,可以使用 go get 来恢复这个库,但由于我们是在本地机器上开发,因此只需在 go.mod 文件中添加库路径即可:
replace github.com/profusion/http-redirect/protocol => ../protocol
接下来我们创建一个实现 HttpRedirectPlugin 接口的结构体,并调用日志函数。
// 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{}
这就是需要的所有代码。我们只需将其作为插件构建即可。为此,我们只需向 go 编译器传递 buildmode 标志:
# From http-redirect/log-plugin
go build -buildmode=plugin -o plugin.so plugin.go
瞧!我们有了一个插件!现在只需将其加载到应用程序就行了。
3.加载插件
我们需要一个应用程序来加载插件。这不是本文的重点,但以下是 Go 中 HTTP 重定向服务器代码,我们可以对其进行修改。
// 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"
]
这就足够了。然后我们编写读取该文件内容的代码,为了保持整洁,将在另一个文件中进行插件加载。
// 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()
}
插件加载后,就可以访问其符号了,包括我们在协议中定义的变量 Plugin。我们修改之前的代码,保存这个变量,而不是整个插件。现在,我们的文件看起来是这样的:
// 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)
}
}
// …
很好,现在 pluginList 中的所有变量都是正常的 golang 变量,可以直接访问,就好像从一开始就是代码的一部分。然后,我们构建钩子函数,在发送请求前调用所有插件钩子。
// 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)
}
}
最后,在主代码中调用钩子:
// cmd/main.go
//…
// ServeHTTP implements http.Handler.
func (p *proxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
PreRequestHook(req)
// …
就是这样!我们创建了一个应用程序和一个插件,将插件加载到应用中,然后针对收到的每个请求运行插件代码,并记录这些请求。
想要测试?直接运行就行:
# From http-redirect
go run cmd/*.go -from <port> -to <url>
结论
我们在本文中讨论了什么是插件、插件的用途,以及如何基于 Go 标准库创建支持插件的应用程序的能力。在未来的工作中,请考虑通过这种基础架构为解决方案提供更好的可扩展性,从而帮助其他开发人员可以更广泛的使用我们的工具和应用。