# 前言
最近项目中一个模块出现因业务变化可能经常需要扩展功能的情况,为了避免经常性的修改主程序,于是就需要能够像 C/C 动态库那样,动态加载或更换业务功能。虽然 Golang 可以通过 system 包或者 CGO, 轻松的加载 C/C 编写的动态库,但是却有:
- Golang 与 C 进行数据交换时需要做复杂的转换
- 受 Golang 的垃圾回收机制影响,使用指针时需要特别注意,否则很可能在传入 C 函数时就已经被回收,成为空指针
- 不管是用 CGO 编写,还是用 C 编写,业务逻辑处理起来都没有直接用 Golang 方便
等等诸多问题
索性,Go 在 1.8 版本之后提供了另一种动态加载功能的方式: Plugin。Plugin 完全由 Golang 编写,通过 go build --buildmode=plugin 命令编译成 so 文件后,在 Go 程序中使用 plugin 包即可轻松加载使用。
# 第一个插件
首先创建项目 test_plugin, 目录结构如下:
test_plugin/
- main.go
- plugins/
- helloworld/
- main.go
# 编写插件
接下来为插件 helloworld 添加一个导出方法:
// helloworld/main.go
// [1]
package main
import (
"fmt"
)
// [2]
func init() {
fmt.Println("plugin welcom has been loaded")
}
// [3]
func Welcom(name string) {
fmt.Println("Welcom " + name)
}
然后将该插件编译为 so 文件,只需执行命令:
go build -o lib/welcom.so --buildmode=plugin test_plugin/plugins/helloworld
要点:
- 首先,如 [1] 所见,插件必须属于 main 包
- 其次,如 [3] 所见,如果某个方法或者变量需要被外部程序使用,则必须为导出变量,即首字母大写
- 另外,插件不需要 main 方法
# 使用插件
接下来就可以使用生成的 welcom.so 文件了:
// main.go
package main
import (
"fmt"
"plugin"
)
func main() {
fmt.Println("begin main")
// [1]
plg, err := plugin.Open("./lib/welcom.so")
if nil != err {
fmt.Println(err.Error())
return
}
// [2]
symbol, err := plg.Lookup("Welcom")
if nil != err {
fmt.Println(err.Error())
return
}
// [3]
welcom := symbol.(func(name string))
welcom("World")
}
###
编译 main.go 并运行后,可以看到输出:
begin main
plugin welcom has been loaded
Welcom World
# plugin 包
Golang 的 plugin 包很简单,只提供了两个方法:
func Open(path string) (*Plugin, error)
func (P *Plugin) Lookup(symName string) (Symbol, error)
Open 方法接受一个 string 类型的参数,表示要加载的插件的路径,可以为相对路径或绝对路径。其返回一个 plugin.Plugin 对象,当成功加载插件后,就可以使用该对象的 Lookup 方法来尝试获取插件中的指定方法,如上例中的 [2] 所示。通常,插件的方法或变量要能被外部使用,则必须被导出,否则 Lookup 方法将返回错误。
当使用 Lookup 方法成功获取到要使用的变量 / 方法后,其返回一个 指针,指向被导出变量 / 方法,通常我们要像上例中 [3] 将之转为我们需要的类型来使用。
# 插件加载顺序
从上面的输出可以看出,当插件被加载后首先会执行插件的 init () 方法
# 坑点
# 同一插件只会被加载一次 <sup></sup>
# 现象
重复加载完全相同的插件 (路径,文件名,内容完全一致) 两次,实际上第二次会直接返回第一次的对象
首先来修改一下 main.go 文件,在 main 方法里,我们调用两次 Open 方法来加载 welcom 插件看看
package main
import (
"fmt"
"plugin"
)
func getPluginMethod(pluginPath, method string) (interface{}, error) {
plugin, err := plugin.Open(pluginPath)
if nil != err {
return nil, err
}
fmt.Println(plugin)
return plugin.Lookup(method)
}
func main() {
fmt.Println("begin main")
_, err := getPluginMethod("./lib/welcom.so", "Welcom")
if nil != err {
fmt.Println(err.Error())
return
}
_, err = getPluginMethod("./lib/welcom.so", "Welcom")
if nil != err {
fmt.Println(err.Error())
return
}
}
运行后,输出如下:
[root@localhost plugin]# go run main.go
begin main
plugin welcom has been loaded
&{test_plugin/plugins/helloworld 0xc0000240c0 map[Welcom:0x7f87780a1e80]}
&{test_plugin/plugins/helloworld 0xc0000240c0 map[Welcom:0x7f87780a1e80]}
可以看到两次加载返回的 Plugin 对象实际指向同一地址,并且 init 方法实际只执行了一次
# 不能重复加载
该问题分为两种情况:
-
# 插件源码路径及文件名完全相同,但内容不同 (同一插件的不同版本)
我们首先来修改 helloworld/main.go 文件,并编译生成 hello.so 文件
// helloworld/main.go
package main
import (
"fmt"
)
func init() {
fmt.Println("plugin hello has been loaded")
}
func Hello(name string) {
fmt.Println("Hello " + name)
}
最终目录结构:
test_plugin/
- main.go
- plugins/
- helloworld/
- main.go
- lib/
- hello.so
- welcom.so
然后我们在 main 方法中同时加载 hello.so 和 welcom.so
package main
import (
"fmt"
"plugin"
)
func getPluginMethod(pluginPath, method string) (interface{}, error) {
plugin, err := plugin.Open(pluginPath)
if nil != err {
return nil, err
}
return plugin.Lookup(method)
}
func main() {
fmt.Println("begin main")
helloSymbol, err := getPluginMethod("./lib/hello.so", "Hello")
if nil != err {
fmt.Println(err.Error())
return
}
welcomSymbol, err := getPluginMethod("./lib/welcom.so", "Welcom")
if nil != err {
fmt.Println(err.Error())
return
}
hello, ok := helloSymbol.(func(name string))
if !ok {
fmt.Println("hello must be func(string)")
return
}
welcom, ok := welcomSymbol.(func(name string))
if !ok {
fmt.Println("welcom must be func(string)")
return
}
hello("World")
welcom("World")
}
运行后输出:
[root@localhost plugin]# go run main.go
begin main
plugin hello has been loaded
plugin.Open("./lib/welcom"): plugin already loaded
这里我们明明加载的是不同插件,为什么会报 plugin already loaded 错误呢?
首先,Golang 插件并不是通过文件名来判断插件是否一样的,而是通过 pluginpath 来判断的,默认情况下,插件的 pluginpath 是由内部算法生成,格式为: "plugin/unnamed-" + root.Package.Internal.BuildID。在上面的例子中,我们只是修改了 helloworld/main.go 文件,其 pluginpath 并没有因为内容的改变而变化,所以被判断为同一插件。又由于插件内容发生了变化,所以抛出了错误。
# 解决方案
1.12 及以前版本
我们可以在编译时指定 --ldflags="-pluginpath=xxx" 来修改 pluginpath。
go build -o lib/hello.so --buildmode=plugin --ldflags="-pluginpath=hello" test_plugin/plugins/helloworld
1.13 及以上版本
1.13 及以上版本修改 pluginpath 的话,会在载入 plugin 时,报 could not find symbol 错误,目前有两种解决方法:
-
在编译脚本中,添加移动命令,使源码路径不同
-
我们在例子中都是按照包来编译的,事实上如果直接使用 main.go 编译,则最终插件的 pluginpath 中计算 BuildID 时会将源文件 hash, 如:
go build -o lib/hello.so plugins/helloworld/main.go
go build -o lib/welcom.so plugins/helloworld/main.go这样编译出的两个插件可以同时加载
运行 test_plugin/main.go 后,输出如下:[root@localhost plugin]# go run main.go begin main plugin hello has been loaded &{plugin/unnamed-afaca2345a9b922ec7f099641931847c502569ca 0xc0000240c0 map[Hello:0x7fce554c7f40]} plugin hello has been loaded &{plugin/unnamed-0a49c149f030bba7f1e3ff737985365cb997b9e2 0xc000024120 map[Welcom:0x7fce55032f40]}
-
# 插件内容完全相同,仅文件名不同,同时加载报错
首先复制一份 welcom.so, 并命名为 welcom@v2.so, 然后修改 main.go, 如下:
package main
import (
"fmt"
"plugin"
)
func getPluginMethod(pluginPath, method string) (interface{}, error) {
plugin, err := plugin.Open(pluginPath)
if nil != err {
return nil, err
}
fmt.Println(plugin)
return plugin.Lookup(method)
}
func main() {
fmt.Println("begin main")
_, err := getPluginMethod("./lib/welcom.so", "Welcom")
if nil != err {
fmt.Println(err.Error())
return
}
_, err = getPluginMethod("./lib/welcom@v2.so", "Welcom")
if nil != err {
fmt.Println(err.Error())
return
}
}
输出如下:
[root@localhost plugin]# go run main.go
begin main
plugin welcom has been loaded
&{test_plugin/plugins/helloworld 0xc0000240c0 map[Welcom:0x7f7b919b8e80]}
plugin.Open("./lib/welcom@v2"): plugin already loaded
# 多个 plugin 中相同的依赖包只会被导入一次
如标题所述,如果多个 plugin 中导入了相同的依赖包,那么该依赖只会在第一个插件载入的同时进行加载,随后的所有相关插件载入时都不会重复导入该依赖,并且即使各个插件在编译时实际依赖的包的版本不同,只要该依赖的导入路径没有变化,就不会重复导入,甚至如果主程序中已经导入过该依赖,挂在所有插件都不会重新导入该依赖。所以,在公共依赖中应尽可能不去使用全局变量,编译插件时,应尽可能保证所用依赖的版本相同。
# 复杂对象传值问题
# 现象
首先修改 plugins/helloworld/main.go 文件
package main
import (
"fmt"
)
func init() {
fmt.Println("plugin welcom has been loaded")
}
func Welcom(name string) {
fmt.Println("Welcom " + name)
}
type User struct {
Name string
}
func Hello(user *User) {
fmt.Println("Hello " + user.Name)
}
test_plugin/main.go 修改为
package main
import (
"fmt"
"plugin"
)
type User struct {
Name string
}
func getPluginMethod(pluginPath, method string) (interface{}, error) {
plugin, err := plugin.Open(pluginPath)
if nil != err {
return nil, err
}
fmt.Println(plugin)
return plugin.Lookup(method)
}
func callHello(pluginPath string) error {
symbol, err := getPluginMethod(pluginPath, "Hello")
if nil != err {
return err
}
hello, ok := symbol.(func(*User))
if !ok {
return fmt.Errorf("Hello must be func(*main.User), not %T", symbol)
}
hello(&User{
Name: "World",
})
return nil
}
func main() {
fmt.Println("begin main")
err := callHello("./lib/hello.so")
if nil != err {
fmt.Println(err.Error())
return
}
}
运行后输出:
[root@localhost plugin]# go run main.go
begin main
plugin welcom has been loaded
&{test_plugin/plugins/helloworld 0xc0000240c0 map[Hello:0x7f711d7c62c0 Welcom:0x7f711d7c61e0]}
Hello must be func*(main.User), not func(*main.User)
# 解决方案
-
方案 1: 在主程序与插件之间进行数据交换时应尽可能使用接口,如:
将 test_plugin/plugins/helloworld/main.go 修改为
package main import ( "fmt" ) func init() { fmt.Println("plugin welcom has been loaded") } func Welcom(name string) { fmt.Println("Welcom " + name) } type User interface { Name() string } func Hello(u interface{}) { user, ok := u.(User) if !ok { fmt.Println("not a valid user") return } fmt.Println("Hello " + user.Name()) }
test_plugin/main.go 修改为
package main import ( "fmt" "plugin" ) type User struct { name string } func (u *User) Name() string { return u.name } func getPluginMethod(pluginPath, method string) (interface{}, error) { plugin, err := plugin.Open(pluginPath) if nil != err { return nil, err } fmt.Println(plugin) return plugin.Lookup(method) } func callHello(pluginPath string) error { symbol, err := getPluginMethod(pluginPath, "Hello") if nil != err { return err } hello, ok := symbol.(func(interface{})) if !ok { return fmt.Errorf("Hello must be func(*main.User), not %T", symbol) } hello(&User{ name: "World", }) return nil } func main() { fmt.Println("begin main") err := callHello("./lib/hello.so") if nil != err { fmt.Println(err.Error()) return } }
运行后输出为:
[root@localhost plugin]# go run main.go begin main plugin welcom has been loaded &{test_plugin/plugins/helloworld 0xc0000240c0 map[Hello:0x7f05b2f7d2c0 Welcom:0x7f05b2f7d1e0]} Hello World
-
方案 2: 在公共库中定义需要交换的数据结构
# 内存泄漏
这个很好理解,主要原因还是 Golang 插件只能打开而不能卸载,在实现热加载功能时,稍一不注意就会出现内存泄漏