197 lines
11 KiB
Markdown
197 lines
11 KiB
Markdown
# [Go Plugin(go 动态库)](https://blog.csdn.net/m0_38132420/article/details/68496881)
|
||
|
||
- [Go Plugin(go 动态库)](#go-plugingo-动态库)
|
||
- [1. 简介](#1-简介)
|
||
- [2. 使用](#2-使用)
|
||
- [3. 常见问题](#3-常见问题)
|
||
- [3.1. 不一致的标准库版本](#31-不一致的标准库版本)
|
||
- [3.2. 不一致的第三方库版本](#32-不一致的第三方库版本)
|
||
- [3.2.1. Case 1. 版本不一致](#321-case-1-版本不一致)
|
||
- [3.2.2. case 2. 版本号一致,代码不一致](#322-case-2-版本号一致代码不一致)
|
||
- [3.2.3. case 3. 路径不一致](#323-case-3-路径不一致)
|
||
- [3.3. 不一致的 Go 版本](#33-不一致的-go-版本)
|
||
- [3.4. 统一解决方案](#34-统一解决方案)
|
||
- [4. 注意](#4-注意)
|
||
|
||
## 1. 简介
|
||
|
||
在 Go 1.8 出现以前,一直觉得 Go 语言不能像 C/C++一样可以使用动态库的方式动态修改服务。每次升级操作都不得不重新编译整个工程,重新部署服务。这对于很多比较重型的服务来说是一个很致命的弱点。
|
||
目前在 Go 1.8 只在 Linux 和 Darwin 系统下支持 Plugin。从 Go 1.8 源码中 plugin 包中 plugin.go 文件开头中有对应的说明。在 Go 1.8 中 plugin 包在操作系统的支持并不十分完善,即使在 Linux 系统下也需要特定 gcc 的编译器及连接器的支持。后续版本应该会有做相应的改进。
|
||
|
||
## 2. 使用
|
||
|
||
创建一个提供方法的文件 print.go
|
||
|
||
```go
|
||
package main
|
||
|
||
import (
|
||
"fmt"
|
||
)
|
||
|
||
func PrintTest(strInput string) {
|
||
fmt.Println("string in print.so is:", strInput)
|
||
}
|
||
```
|
||
|
||
编译 Go 动态库命令:
|
||
|
||
```bash
|
||
go build -trimpath -buildmode=plugin -ldflags="-w -s"
|
||
```
|
||
|
||
其中 -trimpath 用于从二进制文件中删除文件系统路径信息,如果不增加该参数,主程序加载插件时会去匹配 GOPATH,如果二者的 GOPATH 不一致则不进行加载。
|
||
|
||
指定文件编译 Go 动态库命令:
|
||
|
||
```bash
|
||
go build -trimpath -buildmode=plugin -ldflags="-w -s" -o print.so print.go
|
||
```
|
||
|
||
go 动态库方法的使用(main.go):
|
||
|
||
```go
|
||
package main
|
||
|
||
import (
|
||
"plugin"
|
||
)
|
||
|
||
func main() {
|
||
// 打开动态库
|
||
pdll, err := plugin.Open("print.so")
|
||
if err != nil {
|
||
//...
|
||
return
|
||
}
|
||
// 获取动态库方法
|
||
funcPrint, err := pdll.Lookup("PrintTest")
|
||
if err != nil {
|
||
//...
|
||
return
|
||
}
|
||
// 动态库方法调用
|
||
funcPrint.(func(string))("hello go plugin")
|
||
return
|
||
}
|
||
```
|
||
|
||
编译:
|
||
|
||
```bash
|
||
go build -trimpath -ldflags="-w -s"
|
||
```
|
||
|
||
Go 1.8 中 plugin 包只提供 Open 和 Lookup 方法,对应打开动态库和获取动态库中的方法。注意获取到的动态动态库方法其实是一个 interface{} 类型,需要将其进行转换后才可以使用。
|
||
|
||
## 3. 常见问题
|
||
|
||
### 3.1. 不一致的标准库版本
|
||
|
||
错误信息:
|
||
|
||
```bash
|
||
plugin was built with a different version of package internal/abi
|
||
```
|
||
|
||
从这个报错的文本可以得知,具体有问题的库是 runtime/internal/sys ,很显然这是一个 go 的内置标准库。看到这里,你可能会有很大的疑惑:我明明用的是同一个本地环境编译主程序和插件,为什么报标准库不是一个版本?
|
||
|
||
答案是,go 的 error 日志描述不准确。而这个报错出现的根本原因可以归结为:主程序和插件的某些关键编译 flag 不一致,跟“版本”没啥关系。
|
||
|
||
比如,你使用下面的命令编译插件:
|
||
|
||
```bash
|
||
go build -buildmode=plugin
|
||
```
|
||
|
||
但是你使用 goland 的 debug 模式调试主程序(比如 VSCode 等 IDE 调试 Go 程序),此时,goland 会帮你把 go build 命令按下面的例子组装好:
|
||
|
||
```bash
|
||
go test -c -o /private/var/folders/gy/2zv22t710sd7m0x9bcfzq23r0000gp/T/GoLand/___Test_TaskC_in_github_com_fdingiit_mpl_test.test -gcflags="all=-N -l" github.com/fdingiit/mpl/test
|
||
```
|
||
|
||
注意,goland 组装的编译命令里包含关键的 -gcflags="all=-N -l" 参数,但是插件编译的命令里没有。此时,你在尝试拉起插件时就会得到一个有关 runtime/internal/sys 的报错。
|
||
|
||
解决这一类标准库版本不一致问题的方案比较简单:尽可能对齐主程序和插件编译的 flag。事实上,有一些 flag 是不影响插件加载的,你可以在具体的实践中慢慢摸索。
|
||
|
||
### 3.2. 不一致的第三方库版本
|
||
|
||
如果你使用 vendor 来管理 Go 的依赖库,那么当解决 [3.1. 不一致的标准库版本](#31-不一致的标准库版本) 的问题之后,你百分百会立即遇到以下这个报错:
|
||
|
||
```bash
|
||
plugin was built with a different version of package xxxxxxxx
|
||
```
|
||
|
||
其中,xxxxxxxx 指的是某一个具体的三方库,比如 http://github.com/stretchr/testify 。这个报错有几个非常典型的原因,如果没有相关的排查经验,其中几个可能会烧掉开发人员不少时间。
|
||
|
||
#### 3.2.1. Case 1. 版本不一致
|
||
|
||
如报错所示,似乎原因很明确,即主程序和插件所共同依赖的某个第三方库版本不一致,报错中会明确告诉你哪一个库有问题。此时,你可以对比排查主程序和插件的 go.mod 文件,分别找到问题库的版本,看看他们是否一致。如果这时候你发现主程和插件确实有 commitid 或 tag 的不一致问题,那解决的方法也很简单:对齐它们。
|
||
|
||
但是在很多场景下,你只会用到三方库的一部分:如一个 package,或者只是引了一个 interface。这一部分的代码在不同的版本里根本就没有变更;但其他没用到的代码的变更,同样会导致整个三方库版本号的变更,进而导致你成为那个“版本不一致”的无辜受害者。
|
||
|
||
而且,此时你可能立即会遇到另一个问题:以谁为基准对齐?主程序?还是插件?
|
||
|
||
从常理上来说,以主程序为基线进行对齐是一个比较好的策略,毕竟插件是新添加的“附属品”,且主程序与插件通常是1对多的关系。但是,如果插件的三方库依赖因为任何原因就是不能和主程序对齐怎么办?在尝试了很久以后,我暂时没有找到一个完美解决这个问题的办法。
|
||
|
||
如果版本无法对齐,就只能从根本上放弃走插件这条路。
|
||
|
||
Go 语言的这种对三方库的、几乎无脑的强一致性约束,从一方面来说,避免了运行时因为版本不一致带来的潜在问题;从另一方面来说,这种刻意不给程序员灵活度的设计,对插件化、定制化、扩展化开发非常的不友好。
|
||
|
||
#### 3.2.2. case 2. 版本号一致,代码不一致
|
||
|
||
当你按照 [3.2.1. Case 1. 版本不一致](#321-case-1-版本不一致) 的思路排查 go.mod 文件,但是惊讶的发现报错的库版本是一致的时候,事情就会变得复杂起来。你可能会拿出世界上最先进的文本查验工具,并花掉一个上午去diff 三方库的 commitid,但它们就是一模一样,似乎陷入了薛定谔的版本。
|
||
|
||
出现这个问题可能的一个不是原因的原因是:有人直接修改了 vendor 目录下的代码,Go 插件机制会对代码内容的一致性进行校验。
|
||
|
||
这真的是一个非常令人头大,并难以排查的原因。除了修改代码的那个人,和已经在其他 case 中被“坑”过的那些人,没人会知道这件事情。如果修改的 vendor 代码出现在主程序里,你就几乎没有任何靠谱的办法让它们正常工作起来。
|
||
|
||
不要直接在 vendor 里改代码,回馈开源社区,或者 fork-replace。
|
||
|
||
好消息是,你不需要解决这个问题。因为即使解决了,也还会有更大的问题等着你。
|
||
|
||
#### 3.2.3. case 3. 路径不一致
|
||
|
||
当按照 [3.2.1. Case 1. 版本不一致](#321-case-1-版本不一致) 和 [3.2.2. case 2. 版本号一致,代码不一致](#322-case-2-版本号一致代码不一致) 的思路都把问题排查、解决完,但它还是报 different version of package 的时候,可能你就会开始对 Go 的插件机制口吐芬芳了:版本真的一毛一样,代码真的一行没动,为什么还报不同版本???
|
||
|
||
原因是:插件机制会校验依赖库源码的「路径」,因此不能使用 vendor 管理依赖。
|
||
|
||
举个例子:你的主程序源码放在 /path/to/main 目录下,因此,你的某个三方库依赖的目录应该是 /path/to/main/vendor/some/thrid/part/lib;同理,你的插件源码放在 /path/to/plugin 目录下,因此,同一个三方库依赖的目录应该是 /path/to/plugin/vendor/some/thrid/part/lib。这些「文件路径」数据会被打包到二进制可执行文件里并用于校验,当主程序加载插件时,Go 的“运行时”“聪明的”通过「文件路径」的差异认定它和插件用的不是同一份代码,然后报了个 different version of package。
|
||
|
||
同样的问题也可能会出现在使用不同机器/用户,分别编译主程序、插件的场景下:用户名不同,Go 代码的路径应该也会不一样。
|
||
|
||
解决这类问题的方法很暴力直接:删掉主程序和插件的 vendor 目录,或者使用 -mod=readonly 编译 flag。
|
||
|
||
到这里,如果你是使用同一台机器进行主程序和插件的编译,那么常见的问题应该都基本解决了,插件机制理应能够正常工作。另一方面,由于不再使用 vendor 管理依赖,因此 [3.2.2. case 2. 版本号一致,代码不一致](#322-case-2-版本号一致代码不一致) 的问题也会在这里被强制解决:要么提 PR 给社区,要么 fork-replace。
|
||
|
||
### 3.3. 不一致的 Go 版本
|
||
|
||
错误信息:
|
||
|
||
```bash
|
||
fatal error: runtime: no plugin module data
|
||
```
|
||
|
||
除了上面的那些问题以外,还有一个在多机器分别编译主程/插件场景下的常见报错。这个报错的一个可能原因是 Go 版本不一致,对齐它们即可。(如果从机器层面就是不能对齐怎么办?)
|
||
|
||
### 3.4. 统一解决方案
|
||
|
||
我所在的团队由于重点依赖 Go 的插件机制做定开,因此必须拿出一个系统化的方案把这些问题统统解决掉。在尝试直接修改 Go 源码无果以后(吐槽:Go 插件机制源码写的令人略感遗憾),我重点从以下几个方面入手开展了相关工作:
|
||
|
||
- 统一编译环境:
|
||
- 提供一个标准的 docker image 用来编译主程序和插件,规避任何 Go 版本、gopath 路径、用户名等不一致所带来的问题
|
||
- 预制 go/pkg/mod,尽可能减少因为没有使用 vendor 模式导致每次编译都要重新下载依赖的问题
|
||
- 统一Makefile:
|
||
- 提供一套主程序和插件的编译 Makefile,规避任何因为 go build 命令带来的问题
|
||
- 统一插件开发脚手架:
|
||
- 由脚手架,而不是开发者拉齐插件与主程序的依赖版本。并由脚手架解决其他相关问题
|
||
- ACI 化:
|
||
- 将编译流程 ACI 化,进一步避免出现错误
|
||
|
||
## 4. 注意
|
||
|
||
1. 插件实现和主应用程序都必须使用完全相同的 Go 工具链版本构建。
|
||
2. 除非指定 -trimpath 参数,否则插件实现和主应用程序都必须使用完全相同的 GOPATH 构建。
|
||
3. 插件中的任何依赖项应该与主应用程序中的依赖项版本相同。
|
||
4. 你无法将插件编译为静态二进制文件。
|