Go Plugin 浅析

2023-06-10 17:45:49 浏览数 (1)

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:

代码语言:javascript复制
1go build -buildmode=plugin -o plugin.so plugin.go

如果 plugin 源代码没有放置在 main包 下面,我们在编译plugin时会遭遇如下编译器错误:

代码语言:javascript复制
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文件内容如下

代码语言:javascript复制
 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 方法在加载的动态库中查找相应的导出符号,比如上面的 PluginIntFPublic等。Lookup 方法返回plugin.Symbol 类型,而 Symbol 类型定义如下

代码语言:javascript复制
1// $GOROOT/src/plugin/plugin.go
2type Symbol interface{}

我们看到 Symbol 的底层类型是interface{},因此它可以承载从 plugin 中找到的任何类型的变量函数 的符号。而 plugin 中定义的类型则是不能被主程序查找的,通常主程序也不会依赖 plugin 中定义的类型。

一旦 Lookup 成功,我们便可以将符号通过 类型断言 获取到其真实类型的实例,通过这些 实例 (变量函数),我们可以调用 plugin 中实现的逻辑。编译plugin 后,运行上述主程序,我们可以看到如下结果:

代码语言:javascript复制
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 修改为

代码语言: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	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添加包的依赖

代码语言:javascript复制
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 等集中放置共享库的目录下,你总是会看到诸如下面的情况:

代码语言:javascript复制
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 namesonamelinker name

  • real name 指的是实际包含共享库代码的那个文件的名字(如上面例子中的 libXxf86vm.so.1.0.0),也是在共享库编译命令行中-o后面的那个参数
  • sonameshared 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 namelinker name一般在共享库安装时手工创建。

0 人点赞