在现有的需求中,因业务需求还在扩充,使得前期搜集需求的过程中,很难完整的穷尽所有的处理逻辑。此时,需要一种技术,能够做到后期补充操作逻辑。在完成整体开发后,由其他开发人员进行扩展,由此,其他开发人员将无需更改应用程序的代码,更不至于重新编译整个应用程序。

在Go语言中,已知的插件系统中,可有三种选项:Hashicorp go-plugin,内置plugins软件包,以及Go Javascript解释器。

Hashicorp插件

地址:go-plugin

此项目不同Go本身自带的plugin机制,go-plugin是基于RPC的Go插件系统。其项目发展比较长,已被大量使用于Hashicorp自家的各个项目中。(PackerTerraformNomadVaultstar数量都算是比较高,用于生产可以说没有什么特别的问题。

其必然可以使用可扩展性,但囿于其本身的原理,在实际的使用中,需要必备的几个二进制文件:主体程序的二进制文件、插件的二进制文件。

看一个简单的例子来明白其原理:

主机和插件之间通过一个interface接口来约定方法。

1
2
3
type Hello interface {
Greet() string
}

接下来,调用插件:

1
2
3
4
5
6
// We're a host! Start by launching the plugin process.
client := plugin.NewClient(&plugin.ClientConfig{
HandshakeConfig: handshakeConfig,
Plugins: pluginMap,
Cmd: exec.Command("./plugin/hello"),
})

handshakeConfig为一个结构体,作为host与plugin之间一个简单的通信确认。

1
2
3
4
5
var handshakeConfig = plugin.HandshakeConfig{
ProtocolVersion: 1,
MagicCookieKey: "BASIC_PLUGIN",
MagicCookieValue: "hello",
}

该库还支持使用gRPC通信,其还可以使用其他语言来编写插件。由于是使用的网络通信,其本体还是单独的程序,所以编程上除开框架的复杂性,其可以正常使用语言所有特性。

该插件的缺点:

  • 主程序与插件之间强耦合;(主程序定义接口,插件实现接口。)
  • 开发者需要了解简单的rpc以及接口知识;
  • 改动插件即需要重新编译;若主程序需要扩展插件逻辑也需要重新改动代码;

Go插件模块

Go本身是支持插件系统的,但在实际使用上来看,可用性不是很高。其具体原因还需要在具体的使用上来谈。

虽然在宣传上,Go插件模块不仅背靠原生支持这块大旗,还具有低于go-plugin的复杂性(不需要借助rpc)。理论上,主程序与插件之间没有直接连接。

但是,使用plugin时,plugin经常要和主程序同时(更确切的说是同一环境下)build才行。如果主程序有改动或者build的路径更换,plugin不同时更新的话,加载plugin时就会报某个package版本错误的问题,导致加载失败。在Go Modules解决方案还未完全普及前,此问题就一直是个问题,所以编译环境需要一直保持统一。

在插件中,实现函数方法:

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

import "fmt"

type greeting string

func (g greeting) Greet() {
fmt.Println("Hello World!")
}

// Greeter - 导出这个变量 函数一样可以导出
var Greeter greeting

在main函数中,为了方便访问,定义插件方法的接口,使用包方法plugin.Open来解析特定插件。

1
2
3
4
5
6
7
8
type Greeter interface {
Greet()
}

plug, err := plugin.Open("plugin/implementation.so")
if err != nil {
log.Println(err)
}

然后,解析插件导出的符号,将其转换为接口类型,然后运行该Greet()方法。

1
2
3
4
5
6
7
8
9
10
11
12
symGreeter, err := plug.Lookup("Greeter")
if err != nil {
log.Println(err)
}

var greeter Greeter
greeter, ok := symGreeter.(Greeter)
if !ok {
log.Println("unexpected type from module symbol")
}

greeter.Greet()

此方法使用上比go-plugin简单,程序开销也小一些。

该插件的缺点:

  • 必须先针对特定平台进行编译,才能在运行时加载插件;
  • 当前的实现仅支持类似Unix的平台,例如Linux和macOS;
  • 只能使用Go,虽然原生支持使得其变得方便了许多,但也没有了go-plugin支持多语言的特性;

Go JavaScript

在Go程序运行JavaScript,将JS文件作为Go程序的延申,使其作为主程序的插件来使用。需要使用到库otto

最关键的是,插件无需进行编译,单纯的JS文件即可。

简单的使用:

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

import "github.com/robertkrimen/otto"

func main() {

vm := otto.New()
vm.Run(`
console.log("Hello World!");
`)
}

借助此库,我们能运行时解释和运行纯JavaScript(到目前为止,仅限于ECMAScript 5)。

The following are some limitations with otto:

1
2
3
4
> * "use strict" will parse, but does nothing.
> * The regular expression engine (re2/regexp) is not fully compatible with the ECMA5 specification.
> * Otto targets ES5. ES6 features (eg: Typed Arrays) are not supported.
>

但其强大之处在于,Otto可以使用外部Go函数扩展JS API,还可以双向交换数据。

func (Value) Export

1
func (self Value) Export() (interface{}, error)

数据转换表

1
2
3
4
5
6
7
undefined   -> nil (FIXME?: Should be Value{})
null -> nil
boolean -> bool
number -> A number type (int, float32, uint64, ...)
string -> string
Array -> []interface{}
Object -> map[string]interface{}

JS中运行GO函数

在实际使用中,我们可以将Go上实现的函数传递到JS中运行:

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

import (
"log"
"github.com/robertkrimen/otto"
)

func main() {
vm := otto.New()

err := vm.Set("log", logJS)
if err != nil {
panic(err)
}

vm.Run(`
console.log("logging with JS!");
log("logging with Golang!");
`)
}

func logJS(content string) {
log.Println(content)
}

数据交换

将Go程序中的数据注入JavaScript,或从JavaScript函数接收结果也非常简单:

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package main

import (
"log"
"github.com/robertkrimen/otto"
)

func main() {
vm := otto.New()

// jsData contains the result of `date`
jsDate, err := vm.Run(`
(function(){
date = new Date();
return date;
})();
`)

if err != nil {
panic(err)
}
log.Printf("jsDate: %s", jsDate)

dataMap := make(map[string]interface{})
dataMap["foo"] = "bar"
dataMap["one"] = "1"
dataMap["two"] = "2"

err = vm.Set("dataMap", dataMap)
if err != nil {
panic(err)
}

value, err := vm.Run(`
(function(){
var keys = [];
for(k in dataMap) {
console.log(k + ": " + dataMap[k]);
keys.push(k);
}
return keys;
})();
`)

keys, err := value.Export()
if err != nil {
panic(err)
}

keyArray := keys.([]string)
log.Printf("keys: %s", keyArray)
}

// 2021/02/04 16:57:28 jsDate: Thu, 04 Feb 2021 16:57:28 CST
// 2021/02/04 16:57:28 foo: bar
// 2021/02/04 16:57:28 one: 1
// 2021/02/04 16:57:28 two: 2
// 2021/02/04 16:57:28 keys: [foo one two]

Run函数返回两个参数:

1
func (self Otto) Run(src interface{}) (Value, error)

Value结构默认实现了string()方法,当我们只需要使用返回值的字符串形式,或者确定返回值为string时,可以直接使用。否则,需要Export(),它将尝试从JS的类型转换成Go类型。

该插件的缺点

  • 调试JS代码困难

由于代码写在JS中,Go执行JS代码,所以在调试时会相应的比较麻烦,可以借助在OTTO JAVASCRIPT解释器中使用调试器语句。当然可以现在其他IDE中写完代码后再移植过来会方便一点。

  • Otto仅支持ES v5版本。

之前学习过Manba的代码(微服务网关),在最近的版本中,已经使用otto作为插件引擎,可以参考借鉴一下。Here

总结

go-pluginotta均在实际生产中有应用,可使用的场合较之Go内置插件更多。两者有着实质的区别,大体区别如下。

跨平台 通信 语言 编译使用
Hashicorp插件 多平台 RPC Go,支持gRPC 单独编译
Go插件模块 *nix平台 内置 Go 单独编译
(需与主程序环境统一)
Go JavaScript 多平台 第三方支持 仅JS v5 无需编译