Go Plugin 浅析
go plugin
支持将 go包
编译为共享库
的形式单独发布,主程序可以在运行时动态加载这些编译为动态共享库文件的 go plugin
,从中提取导出 变量
或 函数
的符号并在主程序的包中使用
go plugin
的这种特性为Go开发人员提供更多的灵活性,我们可以用之实现支持热插拔的插件系统。
基本使用
go官方文档明确说明 go plugin只支持Linux, FreeBSD和macOS ,这算是go plugin的第一个约束。
主程序通过 plugin
包加载 动态库
并提取 动态库文件
中的符号的过程与C语言应用运行时加载动态链接库并调用库中函数的过程如出一辙。下面我们就来看一个直观的例子。
下面是例子的结构布局
代码语言:javascript复制1└─demo01
2 ├─main.go 主程序
3 ├─pkg 主程序
4 │ └─pkg.go
5 ├─plugin 插件包
6 │ └─plugin.go
插件代码示例
代码语言:javascript复制 1package main
2
3import (
4 "fmt"
5 "log"
6)
7
8func init() {
9 log.Println("plugin init")
10}
11
12var PluginInt int
13
14func F() {
15 fmt.Printf("plugin: public integer variable PluginInt=%dn", PluginInt)
16}
17
18type private struct{}
19
20func (private) M1() {
21 fmt.Println("plugin: invoke private.M1")
22}
plugin包
和普通的go包
没太多区别,只是 plugin包
有一个约束:其包名必须为 main
,我们使用下面命令编译该plugin:
1go build -buildmode=plugin -o plugin.so plugin.go
如果 plugin
源代码没有放置在 main包
下面,我们在编译plugin时会遭遇如下编译器错误:
1-buildmode=plugin requires exactly one main package
接下来,我们来看主程序
代码语言:javascript复制 1package main
2
3import (
4 "log"
5 "plugins/demo1/pkg"
6)
7
8func init() {
9 log.Println("main")
10}
11
12func main() {
13 if err := pkg.LoadPlugin("./plugin/plugin.so"); err != nil {
14 panic(err)
15 }
16}
其中 pkg/pkg.go
文件内容如下
1package pkg
2
3import (
4 "errors"
5 "log"
6 "plugin"
7)
8
9type MyInterface interface {
10 M1()
11}
12
13func init() {
14 log.Println("pkg init")
15}
16
17func LoadPlugin(pluginPath string) error {
18 p, err := plugin.Open(pluginPath)
19 if err != nil {
20 return err
21 }
22
23 // 导出整型变量
24 pluginInt, err := p.Lookup("PluginInt")
25 if err != nil {
26 return err
27 }
28 *pluginInt.(*int) = 15
29
30 // 导出函数变量
31 f, err := p.Lookup("F")
32 if err != nil {
33 return err
34 }
35 f.(func())()
36
37 // 导出自定义类型变量
38 f1, err := p.Lookup("Public")
39 if err != nil {
40 return err
41 }
42 i, ok := f1.(MyInterface)
43 if !ok {
44 return errors.New("f1 does not implement MyInterface")
45 }
46 i.M1()
47 return nil
48}
通过plugin
包提供的 Plugin
类型提供的 Lookup
方法在加载的动态库中查找相应的导出符号,比如上面的 PluginInt
、F
和 Public
等。Lookup
方法返回plugin.Symbol
类型,而 Symbol
类型定义如下
1// $GOROOT/src/plugin/plugin.go
2type Symbol interface{}
我们看到 Symbol
的底层类型是interface{}
,因此它可以承载从 plugin
中找到的任何类型的变量
、函数
的符号。而 plugin
中定义的类型则是不能被主程序查找的,通常主程序也不会依赖 plugin
中定义的类型。
一旦 Lookup
成功,我们便可以将符号通过 类型断言
获取到其真实类型的实例,通过这些 实例
(变量
或 函数
),我们可以调用 plugin
中实现的逻辑。编译plugin
后,运行上述主程序,我们可以看到如下结果:
12023/04/03 14:14:48 pkg init
22023/04/03 14:14:48 main
32023/04/03 14:14:48 plugin init
4plugin: public integer variable PluginInt=15
5plugin: invoke private.M1
主程序是如何知道导出的符号究竟是函数还是变量呢? 取决于主程序插件系统的设计,因为主程序与
plugin
间必然要有着某种契约
或约定
。 就像上面主程序定义的MyInterface
接口类型,它就是一个主程序与plugin
之间的约定,plugin
中只要暴露实现了该接口的类型实例,主程序便可以通过MyInterface
接口类型实例与其建立关联并调用plugin
中的实现 。
包的初始化
上面的例子中我们看到,插件的初始化发生在主程序 open
动态库文件时。
按照官方文档的说法:“当一个插件第一次被open时,plugin中所有不属于主程序的包的init函数将被调用,但一个插件只被初始化一次,而且不能被关闭”。
我们来验证一下在主程序中多次加载同一个 plugin
的情况
其中 main.go
修改为
1package main
2
3import (
4 "log"
5 "plugins/demo1/pkg"
6)
7
8func init() {
9 log.Println("main")
10}
11
12func main() {
13 if err := pkg.LoadPlugin("./plugin/plugin.so"); err != nil {
14 panic(err)
15 }
16 log.Println("LoadPlugin ok")
17
18 if err := pkg.LoadPlugin("./plugin/plugin.so"); err != nil {
19 panic(err)
20 }
21 log.Println("ReLoadPlugin ok")
22}
pkg/pkg.go
添加包的依赖
1package main
2
3import (
4 "fmt"
5 "log"
6
7 _ "plugins/demo1/pkg"
8)
9// ....
运行上述代码:
代码语言:javascript复制12023/04/03 14:17:46 pkg init
22023/04/03 14:17:46 main
32023/04/03 14:17:46 plugin init
4plugin: public integer variable PluginInt=15
5plugin: invoke private.M1
62023/04/03 14:17:46 LoadPlugin ok
7plugin: public integer variable PluginInt=15
8plugin: invoke private.M1
92023/04/03 14:17:46 ReLoadPlugin ok
通过这个输出结果,我们验证了两点说法:
- 重复加载同一个plugin,不会触发多次plugin包的初始化,上述结果中仅输出一次:`plugin init
- plugin中依赖的包,但主程序中没有的包,在加载plugin时,这些包会被初始化,如:
pkg init
使用约束
go plugin
应用不甚广泛的一个主因是其约束较多,这里我们来看一下究竟 go plugin
都有哪些约束
- 主程序与plugin的共同依赖包的版本必须一致
- 如果采用mod=vendor构建,那么主程序和plugin必须基于同一个vendor目录构建
- 主程序与plugin使用的编译器版本必须一致
- 使用plugin的主程序仅能使用动态链接
版本管理
使用动态链接实现插件系统,一个更大的问题就是插件的版本管理问题。
linux上的动态链接库采用soname的方式进行版本管理。soname的关键功能是它提供了兼容性的标准,当要升级系统中的一个库时,并且新库的soname和老库的soname一样,用旧库链接生成的程序使用新库依然能正常运行。这个特性使得在Linux下,升级使得共享库的程序和定位错误变得十分容易。
什么是soname呢?在 /lib
和 /usr/lib
等集中放置共享库的目录下,你总是会看到诸如下面的情况:
1lrwxrwxrwx 1 root root 19 11月 15 2021 /usr/lib64/libXxf86vm.so -> libXxf86vm.so.1.0.0
2lrwxrwxrwx 1 root root 19 6月 18 2021 /usr/lib64/libXxf86vm.so.1 -> libXxf86vm.so.1.0.0
3-rwxr-xr-x 1 root root 23696 8月 2 2017 /usr/lib64/libXxf86vm.so.1.0.0
共享库的惯例中每个共享库都有多个名字属性,包括real name
、soname
和linker name
:
- real name 指的是实际包含共享库代码的那个文件的名字(如上面例子中的
libXxf86vm.so.1.0.0
),也是在共享库编译命令行中-o后面的那个参数 -
soname
是shared object name
的缩写,也是这三个名字中最重要的一个,无论是在编译阶段还是在运行阶段,系统链接器都是通过共享库的soname
(如上面例子中的libXxf86vm.so.1)来唯一识别共享库的。 即使real name
相同但soname
不同,也会被链接器认为是两个不同的库。 -
linker name
是编译阶段提供给编译器的名字(如上面例子中的libXxf86vm
)。如果你构建的共享库的real name
是类似于上例中libXxf86vm.so.1.0.0
那样的带有版本号的样子,那么你在编译器命令中直接使用-L path -lXxf86vm
是无法让链接器找到对应的共享库文件的,除非你为libXxf86vm.so.1.0.0
提供了一个linker name
。linker name
一般在共享库安装时手工创建。