应用程序中的插件功能可以通过 4 个基本概念来描述[1]

  1. Discovery(发现):用于正在运行的程序找出它可以使用哪些插件的机制,需要给应用程序指定查看什么位置(工作路径)以及需要查找什么;
  2. Registration(注册):用于插件告知应用程序 - I’m here, ready to do work 的一种机制,将插件载入到应用程序中;
  3. Hooks(挂载点):插件可以将自身”附加”到应用程序的位置,表明它可以获取信息并参与流的范围,挂载点的确切性很大程度上取决于应用程序的设计。这个过程时插件注册自己的功能到应用程序,再由应用程序调用插件的方法(应用程序 -> 插件);
  4. Extension API(扩展 API):应用程序需要公开插件可以使用的 API,用于授予它们对应用程序的访问、调用权限。这个过程是应用程序提供一些方法给插件,插件再运行时进行调用(插件 -> 应用程序);

Golang 中的插件可以根据实现方式划分为三种:编译时插件、运行时插件、远程调用插件

远程调用插件通常通过 IPC、RPC 或 TCP 通信的方式来实现,插件作为一个独立的进程在本地或者远程运行,相比于编译时插件和运行时插件,这种插件的运行效率会低两个数量级

示例代码仓库:https://github.com/Decision2016/go-plugins-example

  • caller:插件的调用代码
  • implement:插件的实现代码
  • interfaces:插件接口定义,部分插件不需要定义接口

编译时插件

编译时插件是通过在编写代码时选用插件包的方式来实现插件的调用,这样的插件包是具有一定规范的,一个例子是 database/sql 包的调用,可以参考 Design patterns in Go’s database/sql package 的介绍

这样的模式是在主程序中提供调用插件包的函数,同时插件包需要满足调用规范(Hooks),编译时插件对应的四个基本概念:

  1. Discovery:直接通过 import 的方式来导入插件包,插件在自身的 init 函数中执行注册;
  2. Registration:插件被编译到主应用程序中,所以它可以直接从插件的调用注册函数;
  3. Hooks:插件包中实现应用程序(主程序)中所提供的接口,主程序可以调用这些接口来完成挂载;
  4. Extension API:编译时插件被编译为二进制,所以只能被主程序通过 import 的方式来使用插件包;

编译时插件本质上是一个通过调用接口类来实现功能的方式,插件包需要实现接口所提供的方法。这种插件的实现方式并不能很好提高程序的扩展性,在每一次需要更换插件的时候都需要修改代码并重新编译。如果需要在应用程序中载入较多的插件,那么这些插件也会作为程序的一部分编码到输出的二进制程序中

运行时插件

与编译时插件不同,运行时插件是没有被编译到主程序的二进制文件,由应用程序在运行过程中加载,在编译语言下通常可以通过共享库的方式来实现

plugin

Golang 在 1.8 版本开始提供了一个 plugin 包用于提供插件功能,它实现了插件的加载和符号解析功能用于支持插件的实现

首先将它对应到插件的四个基本概念下:

  1. Discovery:plugin 提供了 plugin.Open(path string) 方法加载指定路径下的插件,那么在实现的时候就可以指定目录,并尝试从中通过 Open 方法加载目录下的 .so 文件来加载插件;
  2. Registration:所有的插件都需要实现一个初始化函数,用于在主程序载入插件后进行插件的初始化以完成注册;
  3. Hooks:插件内部可以通过初始化时传入的相关对象,例如一个插件的管理器 PluginManager,提供一个方法用于将插件的方法注册到应用程序;
  4. Extension API:应用程序可以在初始化插件时将自身的一些对象或函数传入到插件内部,插件调用这些方法获取信息或实现功能;

具体地,Golang 下的插件通过定义 interface 来定义插件的接口,在一个 package main 中实现接口方法来实现一个插件。但是目前的插件功能只支持在 Linux、FreeBSD 和 MacOS 上运行,暂时不支持 Windows 系统上执行

例如定义一个插件接口,可以将字符串转换为不同的格式:

1
2
3
4
type IConverter interface {
// 传入一个字符串,返回另外一种格式的字符串
Run(string) interface{}
}

实现一种插件的实现,将字符串转换为 hex 形式:

1
2
3
4
5
6
7
8
9
10
11
12
package main

import "encoding/hex"

type HexConverter struct{}

func (c *HexConverter) Run(s string) interface{} {
return hex.EncodeToString([]byte(s))
}

// 注意,一定要是大写,否则在载入插件后找不到暴露出的对象
var Converter HexConverter

然后对 package 进行编译,在正常的编译指令下添加 -buildmode=plugin,即:

1
go build -buildmode=plugin -o hex.so

在主程序中可以载入该 .so 文件来加载插件,并且执行对应的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
p, err := plugin.Open("hex.so")
if err != nil {
panic(err)
}

symbol, err := p.Lookup("Converter")
if err != nil {
panic(err)
}

c := symbol.(IConverter)
hexString :=c.Run("test plugin")
fmt.Println(hexString)
}

plugin 作为一个共享库插件实现,可以提供比较灵活的插件功能。但是它所存在的限制是:

  • 只能在类 Linux 系统下使用,不能提供很强的适配;
  • 插件和主程序的编译版本必须一致以保证安全性,如果不一致则不能正确地载入插件;

wazero

wazero 是一个用 Golang 运行 WebAssembly 模块(Wasm)的运行时,它不需要其他的依赖就可以运行 Wasm 模块(零依赖)

Wasm 是一种偏汇编的编程语言,它被设计用于提供比 JavaScript 更快的编译和执行。开发者可以通过熟悉的编译语言编译为 Wasm 模块, 再由虚拟机引擎在浏览器中执行模块。

在 Golang 下,可以将插件编译为 Wasm 模块,然后再运行时引入模块以执行功能,它可以提供类似于原生 plugin 所提供的功能。相比 plugin 来说,wazero 所提供的功能较为灵活,它不需要指定 Golang 的版本,甚至可以不指定使用 Golang 语言。但是在实际的使用下,整个 Wasm 模块是单独在其虚拟运行环境下运行,所以一些传参和读取的操作需要编写代码来手动完成。同样地,以实现一个字符串转换的插件为例

首先需要下载 tinygo 和 binaryen:

在配置环境变量后,在运行之前还需要指定环境变量:

1
2
set TINYGOROOT=<tingygo 解压后所在目录>
set set WASMOPT=<binaryen 解压目录下的/bin/wasm-opt 路径>

插件的实现,需要在插件内部通过读取指针内存的方式来读取参数,然后调用函数。返回值以返回指针的方式来完成[4],注意注释 //export convert 是一个注解,TinyGo 通过这个注释来暴露函数

这里还可以参考官方示例:https://github.com/tetratelabs/wazero/tree/main/examples/allocation/tinygo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import (
"encoding/hex"
"unsafe"
)

func convert(s string) string {
return hex.EncodeToString([]byte(s))
}

func main() {}

//export convert
func _convert(ptr, size uint32) (ptrSize uint64) {
s := ptrToString(ptr, size)
result := convert(s)

p, size := stringToPtr(result)
return (uint64(p) << uint64(32)) | uint64(size)
}

func ptrToString(ptr uint32, size uint32) string {
return unsafe.String((*byte)(unsafe.Pointer(uintptr(ptr))), size)
}

func stringToPtr(s string) (uint32, uint32) {
ptr := unsafe.Pointer(unsafe.StringData(s))
return uint32(uintptr(ptr)), uint32(len(s))
}

编译该插件(这里是 Windows 下的执行指令)

1
2
3
set GOOS=js
set GOARCH=wasm
tinygo build -o wasm.wasm -scheduler=none --no-debug -target=wasi main.go

然后在调用代码中,使用 wazero.NewRuntime 初始化 Wasm 运行环境,并读取上插件的字节码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 读取插件字节码
wasmBytes, err := os.ReadFile("wasm.wasm")
if err != nil {
panic(err)
}

ctx := context.Background()
// 初始化 Wasm 运行环境
r := wazero.NewRuntime(ctx)
defer r.Close(ctx)

wasi_snapshot_preview1.MustInstantiate(ctx, r)
// 载入插件字节码
mod, err := r.Instantiate(ctx, wasmBytes)
if err != nil {
panic(err)
}

通过在载入的 Wasm 模块中查找字符串的方式可以查询到所要调用的函数,这里得到暴露出的 convert 函数和默认暴露的内存分配与释放函数

1
2
3
fn := mod.ExportedFunction("convert")
malloc := mod.ExportedFunction("malloc")
free := mod.ExportedFunction("free")

下面定义需要传入的参数,并在 Wasm 虚拟环境中开辟内存空间以传入参数和调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 传入的参数及分配内存空间
param := "wasm"
results, err := malloc.Call(ctx, uint64(len(param)))
if err != nil {
panic(err)
}
// 分配内存空间的返回值,分别是内存指针和长度
paramPtr, paramSize := results[0], uint64(len(param))
defer free.Call(ctx, paramPtr)

// 将参数写入到 Wasm 的内存
if !mod.Memory().Write(uint32(paramPtr), []byte(param)) {
panic(fmt.Errorf("write memory pointer %d failed", paramPtr))
}

// 调用函数
res, err := fn.Call(ctx, paramPtr, paramSize)
if err != nil {
panic(err)
}

resPtr := uint32(res[0] >> 32)
resSize := uint32(res[0])
if resString, ok := mod.Memory().Read(resPtr, resSize); !ok {
panic(fmt.Errorf("read memory pointer %d failed", resPtr))
} else {
fmt.Println(string(resString))
}

在前面插件中,函数的返回值是一个 64 bits 的无符号整数(函数 stringToPtr 的返回值),它将内存指针和长度压缩在一起,前 32 bits 是长度,后 32 bits 是内存指针,所以需要解析后在内存指针中读取返回值

1
2
3
4
5
6
7
resPtr := uint32(res[0] >> 32)
resSize := uint32(res[0])
if resString, ok := mod.Memory().Read(resPtr, resSize); !ok {
panic(fmt.Errorf("read memory pointer %d failed", resPtr))
} else {
fmt.Println(string(resString))
}

远程调用插件

远程调用插件的实现很对得上它的名字,即通过远程调用的方法来实现,例如另外一个名为 go-plugin 的包基于 gRPC 的方式来完成插件的实现。插件作为一个独立的进程在本地或远程运行,相比于上面两种插件,远程调用插件与主程序的耦合度更低,但是带来的是额外的通信开销,所以这种插件的运行速度是明显不如前两种实现的。

(待续)