# 前言

最近项目中一个模块出现因业务变化可能经常需要扩展功能的情况,为了避免经常性的修改主程序,于是就需要能够像 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 插件只能打开而不能卸载,在实现热加载功能时,稍一不注意就会出现内存泄漏

此文章已被阅读次数:正在加载...更新于

请我喝[茶]~( ̄▽ ̄)~*

Linsan Zhu 微信支付

微信支付

Linsan Zhu 支付宝

支付宝