导言
我在设计和落地基础上Go当原插件机制的扩展和开发产品时,我踩到了很多坑。由于这方面的相关信息很少,我借此机会做了一个非常肤浅的总结,希望能对您有所帮助。
本文只说问题和解决方案,不读代码。
一些背景知识
2.1 运行时
一般来说,运行时的概念和一些需要在计算机编程语言领域使用vm语言相关。该程序由目标代码和虚拟机两部分组成。比如最典型的JAVA,即Java Class JRE。对于一些似乎不需要虚拟机的编程语言,不太可能有运行的概念,程序只需要目标代码的一部分。但事实上,即使是C/C ,也有运行时,即其运行平台OS/Lib。
Go也一样,因为操作Go类似于程序不需要预部署JRE因此,它看起来与虚拟机或运行时无关。但事实上,Go编译器将语言的运行编译成二进制目标代码的一部分。
图2-1. Java程序、runtime和OS关系
图2-2. C/C 程序、runtime和OS关系
图2-3. Go程序、runtime和OS关系
2.2 Go原插件机制
作为一个人,它看起来更近C/C 技术栈的Go在语言方面,支持类似动态链接库的扩展一直是社区的强烈需求。如图2-5,Go专门在标准库中提供plugin 作为插件的语言编程界面,src/plugin 包的本质是使用cgo机制调用unix标准接口:dlopen() 和dlsym() 。因此,它给C/C 背景程序员有一种这个问题我会的错觉。
图2-4. C/C 加载动态链接库的程序
图2-5. Go加载动态链接库的程序
解决典型问题
很遗憾,与C/C 与技术栈相比,Go虽然插件的输出也是一个动态链接库文件,但它对插件的开发和使用有一系列复杂的内置限制。更让人头大的是,Go语言不仅没有系统地介绍这些约束,甚至写了一些相对较差的设计和实现,导致插件相关问题的错误非常反人类。本章的重点是和大家一起Go插件主要用于编译和加载插件,这是最常见的,但必须定位Go标准库(主要包括编译器、链接器、包装器和操作部分)的源代码可以完全理解几个问题,以及相应的解决方案。
简而言之,Go加载主程序plugin时,会在“runtime对两者进行一堆约束检查,包括但不限于:
- go version一致
- go path一致
- go dependency的交集一致
- 代码一致
- path一致
- go build 某些flag一致
3.1 标准库版本不一致
主程序加载插件时报错:
plugin was built with a different version of package runtime/internal/sys
从这篇报错的文本可以看出,具体问题的库是runtime/internal/sys ,显然这是一个go内置标准库。看到这里,你可能会有很多疑问:我显然使用相同的本地环境编译主程序和插件。为什么标准库不是一个版本?
答案是,go的error日志描述不准确。报错的根本原因可以归因于主程序和插件的一些关键编译flag与版本无关。
例如,您使用以下命令编译插件:
GO111MODULE=on go build --buildmode=plugin -mod readonly -o ./codec.so ./codec.go
但是你使用goland的debug此时,模式调试主程序,goland会帮你把go build命令按例子组装命令:
/usr/local/go/bin/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 #gosetup
注意,goland组装的编译命令包含关键-gcflags all=-N -l 但是插件编译的命令中没有参数。这时,当你试图拉起插件时,你会得到一个相关的runtime/internal/sys的报错。
图3-1. 编译flag不一致导致加载失败
解决这类标准库版本不一致的问题相对简单:尽可能对齐主程序和插件编译flag。其实有些flag不影响插件加载,可以在具体实践中慢慢探索。
3.2 不一致的第三方库版本
如果你使用vendor来管理Go依赖库,然后解决3.问题1后,你会立即遇到以下错误:
plugin was built with a different version of package xxxxxxxx
其中,xxxxxxxx 指特定的三方库,如http://github.com/stretchr/testify。这个报错有几个典型的原因。如果没有相关的调查经验,其中一些可能会烧掉开发人员很多时间。
如错误报告所示,原因似乎非常明确,即主程序和插件共同依赖的第三方库版本不一致,错误报告将清楚地告诉您哪个库有问题。此时,您可以比较主程序和插件go.mod 找出问题库的版本,看看它们是否一致。如果此时你发现主程和插件确实存在commitid或tag解决不一致问题的方法也很简单:对齐它们。
但是在很多情况下,你只会使用三方库的一部分比如一个package,或者只是引了一个interface。这部分代码在不同版本中根本没有变化;但其他无用代码的变化也会导致整个三方库版本号的变化,从而导致你成为版本不一致的无辜受害者。
此外,您可能会立即遇到另一个问题:基于谁的对齐?主程序?还是插件?
一般来说,以主程序为基线对齐是一个更好的策略。毕竟,插件是新添加的附件,主程序通常与插件有一对多的关系。但是,如果插件的三方库因为任何原因不能与主程序对齐呢?经过长时间的尝试,我还没有找到一个完美的解决方案。
如果版本不能对齐,只能从根本上放弃插件。
Go语言对三方库的强烈一致性约束,几乎无脑,一方面避免了版本不一致带来的潜在问题;另一方面,故意不给程序员灵活性的设计对插件、定制和扩展开发非常不友好。
图3-2. 共同依赖的三方库版本不一致导致加载失败
当你按照3.2.1的思路排查go.mod 文件,但惊讶地发现错误的库版本是一致的,事情会变得复杂。你可能会拿出世界上最先进的文本检查工具,花一个上午diff 三方库的commitid,但它们完全一样,似乎陷入了薛定谔的版本。
造成这个问题的可能原因之一是有人直接修改了vendor目录下的代码,Go插件机制将验证代码内容的一致性。
这真的是一个很大很难调查的原因。除了修改代码的人和已经在其他地方的人case没有人会知道被坑的人。如果修改vendor代码出现在主程序里,你就几乎没有任何靠谱的办法让它们正常工作起来。
不要直接在vendor更改代码,回馈开源社区或fork-replace。
好消息是,你不需要解决这个问题。因为即使解决了,也会有更大的问题等着你。
图3-2. 共同依赖的三方库代码因当地修改而加载失败
当按照3.2.1和3.2.2.所有的想法都被调查和解决了,但它仍然被报告different version of package也许你会开始对Go插件机制口吐芬芳:版本真的一毛钱,代码真的一动不动。为什么要报不同版本?
原因是:插件机制会校验依赖库源码的「路径」,因此不能使用vendor管理依赖。
例如:您的主程序源码放在/path/to/main所以,你的某个三方库依赖的目录应该是/path/to/main/vendor/some/thrid/part/lib;同样,您的插件源码放在/path/to/plugin所以,同一个三方库依赖的目录应该是/path/to/plugin/vendorsome/thrid/part/lib。这些「文件路径」数据会被打包到二进制可执行文件里并用于校验,当主程序加载插件时,Go的“运行时”“聪明的”通过「文件路径」的差异认定它和插件用的不是同一份代码,然后报了个different version of package。
图3-3. 使用vendor机制管理第三方库导致的加载失败
同样的问题也可能会出现在使用不同机器/用户,分别编译主程序、插件的场景下:用户名不同,go代码的路径应该也会不一样。
解决这类问题的方法很暴力直接:删掉主程序和插件的vendor目录,或者使用-mod=readonly 编译flag。
到这里,如果你是使用同一台机器进行主程序和插件的编译,那么常见的问题应该都基本解决了,插件机制理应能够正常工作。另一方面,由于不再使用vendor管理依赖,因此3.2.2的问题也会在这里被强制解决:要么提PR给社区,要么fork-replace。
图3-4. 成功加载
3.3 不一致的Go版本
fatal error: runtime: no plugin module data
除了上面的那些问题以外,还有一个在多机器分别编译主程/插件场景下的常见报错。这个报错的一个可能原因是Go版本不一致,对齐它们即可。(如果从机器层面就是不能对齐怎么办?)
图3-5. Go版本不一致导致的加载失败
统一解决方案
从3.1到3.3,我们看了一些很难排查,也不是很好处理的问题。除此之外,其实还有一些问题没有被重点介绍进来。作为一个编程语言官方支持的扩展机制,做的如此用户不友好确实出人意料。
我所在的团队由于重点依赖Go的插件机制做定开,因此必须拿出一个系统化的方案把这些问题统统解决掉。在尝试直接修改Go源码无果以后(吐槽:Go插件机制源码写的令人略感遗憾),我重点从以下几个方面入手开展了相关工作:
- 统一编译环境:
- 提供一个标准的docker image用来编译主程序和插件,规避任何go版本、gopath路径、用户名等不一致所带来的问题
- 预制go/pkg/mod,尽可能减少因为没有使用vendor模式导致每次编译都要重新下载依赖的问题
- 统一Makefile:
- 提供一套主程序和插件的编译Makefile,规避任何因为go build命令带来的问题
- 统一插件开发脚手架:
- 由脚手架,而不是开发者拉齐插件与主程序的依赖版本。并由脚手架解决其他相关问题
- ACI化:
- 将编译流程aci化,进一步避免出现错误
图4-1. 统一解决方案
至此,关于Go插件的常见问题及解决方法介绍就暂告段落了,希望对你有所帮助。
Bonus
如果真的想从根本上搞清楚插件校验的机制,那这里为你提供一些快速进入源码阅读状态的入口。我使用的Go源码为1.15.2版本。
相关Go源码位置:
- compiler
- go/src/cmd/compile/*
- linker
- go/src/cmd/link/internal/ld/*
- package loader
- go/src/cmd/go/internal/load/*
- runtime
- go/src/runtime/*
5.1 go build到底在做啥
你可以在go build 命令里添加-x 参数,以显式的打印出Go程序编译、链接、打包的全流程,例如:
go build -x -buildmode=plugin -o ../calc_plugin.so calc_plugin.go
5.2 目标代码生成
go/src/cmd/compile/internal/gc/obj.go:55 :注意第67和第72行,这里是两个入口
go/src/cmd/compile/internal/gc/iexport.go:244 :注意280行,这里会记录path相关数据
5.3 库哈希生成算法
go/src/cmd/link/internal/ld/lib.go:967 :注意第995~1025行,这里计算pkg的hash
5.4 库哈希校验
go/src/runtime/symtab.go:392 :关键数据结构
go/src/runtime/plugin.go:52 :链接期hash与运行时hash值校验点
go/src/cmd/link/internal/ld/symtab.go:621 :链接期hash赋值点
go/src/cmd/link/internal/ld/symtab.go:521 :运行时hash赋值点
作者 | 丁飞