Go 是一种静态编译语言。Go 运行时不能加载动态库,也不支持即时编译 Go 代码。然而,仍然有多种方法可以在 Go 中创建和使用插件。
Go 中插件的意义
插件对于扩展应用程序的功能列表非常有用——但在 Go 中,编译整个应用程序的源代码既快速又简单,那么为什么还要在 Go 中使用插件呢?
首先,在运行时加载插件可能是应用程序技术规范中的要求。
其次,快速编译与插件并不矛盾。Go 插件可以被创建为编译到二进制文件中——我们稍后会看一个示例。
本文将快速回顾 Go 中插件架构和技术。
插件标准
以下是理想插件架构的愿望清单:
- 速度:调用插件的方法必须快速。方法调用越慢,插件就越受限,只能实现那些大且运行时间长、调用频率低的方法。
- 可靠性:插件不应失败或崩溃,如果崩溃,必须能够快速、容易且完全恢复。
- 安全性:插件应防止篡改,例如通过代码签名。
- 易用性:插件程序员不应承担复杂、易错的插件 API。
理想的插件架构应满足上述所有标准,但在现实中,通常会做出某些妥协。当我们考虑决定插件架构的第一个问题时,这一点就会变得非常明显:
插件应该运行在主进程内,还是作为单独的进程运行?
内进程与独立进程
这两种方法各有优缺点,正如我们所见,一个方法的缺点可能是另一个方法的优点。
内进程插件的优点
- 速度:方法调用速度最快。
- 可靠性:只要主进程可用,插件就可用。内进程插件不会在运行时突然变得不可用。
- 易于部署:插件与二进制文件一起部署,可以直接嵌入,或者(仅在非 Go 语言中)作为动态共享库,在进程启动时或运行时加载。
- 易于运行时管理:不需要发现、启动或停止插件进程,不需要健康检查。(插件进程是否仍在运行?它是否挂起?是否需要重新启动?)
作为独立进程的插件优点
- 韧性:插件崩溃不会导致主进程崩溃。
- 安全性:独立进程中的插件不能篡改主进程的内部。
- 灵活性(第一部分):插件可以用任何语言编写,只要有插件协议的库可用。
- 灵活性(第二部分):插件可以在运行时激活和停用,甚至可以在不重启主进程的情况下部署并激活新的插件。
有了这些特性列表后,我们来看看 Go 语言中的几种不同插件解决方案。
Go 中的插件方法
如前所述,Go 缺乏运行时加载共享库的选项,因此创建了多种替代方法。通过两次快速搜索(分别在 GitHub 和 Google 上),我找到了以下几种方法,按顺序排列:
通过 stdin/stdout 使用外部进程和 RPC
描述
这是最直接的方法:
- 主进程启动插件进程
- 主进程和插件进程通过 stdin 和 stdout 连接
- 主进程通过 stdin/stdout 连接使用 RPC(Remote Procedure Call)
示例
博客文章 Go Plugins are as Easy as Pie 在 2015 年 5 月向 Go 引入了这个概念。随附的 pie 包可以在 这里找到,如果你问我,这可能是我最喜欢的插件方法,仅仅是因为 README 中那张美味的南瓜派图片!(剧透图片见下)。)
这基本上是 Pie 启动插件并与其通信的方式:
在 Pie 中,插件可以扮演两种角色之一:
- 作为提供者(Provider),它响应主程序的请求。
- 作为消费者(Consumer),它可以主动调用主程序并接收结果。
通过网络使用 RPC 的外部进程
描述
与前面的方法的主要区别在于 RPC 调用的实现方式。与使用 stdin/stdout 连接不同,RPC 调用也可以通过(本地)网络进行。
示例
HashiCorp 的 go-plugin 包使用 net/rpc
来连接插件进程。go-plugin 是一个相对重量级的插件系统,具有许多功能,显然能够吸引那些寻找完整且经过行业测试解决方案的企业软件开发者。
描述
消息队列系统,特别是无代理的消息队列系统,为创建插件系统提供了坚实的基础。我快速的研究没有找到任何基于消息队列的插件解决方案,但这很可能是因为将消息队列系统转变为插件架构其实并不需要太多工作。
示例
我没有找到任何基于消息队列的插件系统,但也许你还记得这篇博客的第一篇文章,在其中我介绍了 nanomsg 系统及其 Go 实现 Mangos。nanomsg 规范包括一组预定义的通信拓扑(在 nanomsg 行话中称为“可扩展性协议”),涵盖了多种不同的场景:Pair、PubSub、Bus、Survey、Pipeline 和 ReqRep。其中的两种非常适合与插件进行通信。
- ReqRep(请求-响应)协议可以用于模拟对特定插件的 RPC 调用。然而,这并不是真正的 RPC,因为插座只处理纯粹的
[]byte
数据。因此,主进程和插件必须负责序列化和反序列化请求和响应数据。 - Survey 协议有助于监控所有插件的状态。主进程向所有插件发送调查,插件如果能够响应就会回复。如果某个插件在截止时间内没有回应,主进程可以采取措施重启该插件。
描述
将一个包称为插件可能显得有些争议,尤其是当它像其他包一样被编译进主应用程序时。但只要定义了插件 API,并且插件包实现了该 API,同时构建过程能够识别并使用任何新增的插件,那么这没有什么问题。
内进程插件的优点——速度、可靠性、易用性——在上文中已有阐述。作为缺点,添加、移除或更新插件需要重新编译并部署整个主应用程序。
示例
从技术上讲,任何 Go 库包都可以作为插件包,只要它遵循你为项目定义的插件 API。
也许最常见的编译时插件类型是 HTTP 中间件。Go 的 net/http
使得向 HTTP 服务器中添加新的处理程序变得非常简单:
- 编写一个包,包含一个或多个实现
Handler
接口的函数,或者具有签名func(w http.ResponseWriter, r *http.Request)
的函数。 - 将该包导入到你的应用程序中。
- 分别调用
http.Handle(<pattern>, <yourpkg.yourhandler>)
或http.HandleFunc(<pattern>, <yourpkg.yourhandlefunc>)
来注册一个处理程序。
脚本插件:内进程但不编译
描述
脚本插件机制为内进程和外进程插件方法之间提供了一种有趣的中间方案。插件使用一种脚本语言编写,而该脚本语言的解释器被编译到应用程序中。通过这种技术,可以在运行时加载内进程插件——尽管有一个小缺点,即插件不是本地代码,而是需要解释执行。这种方法的性能通常会较低。
示例
“Awesome-go.com” 页面列出了一些适用于 Go 的可嵌入脚本语言。需要注意的是,其中有些包含解释器,而另一些则仅接受预编译的字节码。
以下是一些示例:
- Agora:一种具有类似 Go 语法的脚本语言。
- GopherLua:Lua 脚本语言的解释器。
- Otto:一个 JavaScript 解释器。
结论
尽管 Go 不支持共享的、可在运行时加载的库,但这并没有阻止 Go 社区创造和使用插件。现在有多种不同的方法可以选择,每种方法都满足特定的需求。
一个简单(单纯)插件概念
上述列出的所有示例都有很好的文档和/或示例可供参考。我在这里避免重复代码,而是采用了一个简化的方法,基于 net/rpc
(以及一些 os/exec
)来实现。
如果你不熟悉 Go 中的 RPC,在阅读代码时,你可能需要随时查阅 net/rpc
包的文档。
package main
import (
"fmt"
"log"
"net"
"net/rpc"
"os"
"os/exec"
"time"
)
type Plugin struct {
listener net.Listener
}
func (p Plugin) Revert(arg string, ret *string) error {
fmt.Println("Plugin: revert")
l := len(arg)
r := make([]byte, l)
for i := 0; i < l; i++ {
r[i] = arg[l-1-i]
}
*ret = string(r)
return nil
}
func (p Plugin) Exit(arg int, ret *int) error {
fmt.Println("Plugin: done.")
os.Exit(0)
return nil
}
func startPlugin() {
fmt.Println("Plugin start")
p := &Plugin{}
err := rpc.Register(p)
if err != nil {
log.Fatal("Cannot register plugin: ", err)
}
fmt.Println("Plugin: starting listener")
p.listener, err = net.Listen("tcp", "127.0.0.1:55555")
if err != nil {
log.Fatal("Cannot listen: ", err)
}
fmt.Println("Plugin: accepting requests")
rpc.Accept(p.listener)
}
func app() {
fmt.Println("App start")
p := exec.Command("./plugins", "true")
p.Stdout = os.Stdout
p.Stderr = os.Stderr
err := p.Start()
if err != nil {
log.Fatal("Cannot start ", p.Path, ": ", err)
}
time.Sleep(1 * time.Second)
fmt.Println("App: registering RPC client")
client, err := rpc.Dial("tcp", "127.0.0.1:55555")
if err != nil {
log.Fatal("Cannot create RPC client: ", err)
}
fmt.Println("App: calling Revert")
var reverse string
err = client.Call("Plugin.Revert", "Live on time, emit no evil", &reverse)
if err != nil {
log.Fatal("Error calling Revert: ", err)
}
fmt.Println("App: revert result:", reverse)
fmt.Println("App: stopping the plugin")
var n int
client.Call("Plugin.Exit", 0, &n)
p.Wait()
fmt.Println("App: done.")
}
main()
func main() {
if len(os.Args) > 1 && os.Args[1] == "true" {
time.AfterFunc(10*time.Second, func() {
fmt.Println("Plugin: idle timeout - exiting")
return
})
} else {
app()
}
}