介绍
我在设计和落地基于Go原生插件机制的扩展开发产品时踩了很多坑。因为这方面的相关资料比较少,借此机会做一个非常粗浅的总结,希望对大家有所帮助。

本文只谈问题和解决方法,不看代码。
一些背景知识
2.1运行时
一般来说,在计算机编程语言领域,“运行时”的概念是和一些需要使用vm的语言相关的。程序的运行由两部分组成:目标代码和虚拟机。比如最典型的JAVA就是Java Class+JRE。对于一些看起来不需要“虚拟机”的编程语言来说,“运行时”这个概念不太可能。程序的运行只需要一部分,即目标代码。但实际上,即使是C/C++也有“运行时”,即运行于其上的平台的OS/Lib。
Go也是一样,因为不需要预先部署类似JRE的运行时来运行Go程序,所以看起来和虚拟机或者运行时都没有关系。但实际上,Go语言的“运行时”是被编译器编译成二进制目标代码的一部分。
图2-1。Java程序、运行时和操作系统之间的关系
图2-2。C/c++程序、运行时和操作系统之间的关系
图2-3。Go程序、运行时和操作系统之间的关系
2.2 Go原生插件机制
作为一种看似更接近C/C++技术栈的Go语言,一直以来社区都强烈要求支持类似动态链接库的扩展。如图2-5所示,Go专门在标准库中提供了一个插件包,作为插件的语言级编程接口。src/plugin包的本质是利用cgo机制调用unix标准接口:dlopen和dlsym。所以给有C/C++背景的程序员一种“我知道这个问题”的错觉。
图2-4。C/C++程序加载动态链接库
图2-5。Go程序加载动态链接库
典型问题解决
遗憾的是,与C/C++技术栈相比,Go的插件虽然输出的也是动态链接库文件,但对插件的开发和使用有一系列复杂的内置约束。更何况Go语言不仅没有系统的引入这些约束,甚至还写了一些很差的设计和实现,导致插件相关问题的调试非常反人类。本章将重点介绍开发和使用Go插件时的一些常见问题及相应的解决方法,主要是编译和加载插件,但必须定位Go标准库的源代码才能全面理解。
简而言之,当Go主程序加载插件时,会在“运行时”检查两者的约束,包括但不限于:
Go版本一致、go路径一致、交集一致、代码一致、go构建一致、某些标志一致。
3.1不一致的标准库版本
主程序加载插件时出错:
插件是用不同版本的包runtime/internal/sys构建的
从这个错误文本中我们可以知道,出现问题的库是runtime/internal/sys,这显然是go内置的标准库。看到这里,你可能会有很大的疑惑:我明明是用同一个本地环境来编译主程序和插件的。为什么标准库不是版本?
是的,go的错误日志描述不准确。这个错误的根本原因可以归结为:主程序和插件的某些关键编译标志不一致,与“版本”无关。
例如,您可以使用以下命令来编译插件:
go 111 module = on go build-build mode = plugin-mod readonly-o ./codec . so。/codec.go
但是你用goland的调试模式调试主程序。此时,goland将根据以下示例帮助您组装go build命令:
/usr/local/go/bin/go Test-c-o/private/var/folders/gy/2 zv 22T 710 SD 7m 0x 9 BCF zq 23 r 0000 gp/T/GoLand/_ _ _ Test _ TaskC _ in _ github _ com _ fding it _ mpl _ Test . Test-GC flags all =-N-l github.com/fdingiit/mpl/test # go setup
注意,key -gcflags all=-N -l参数包含在goland汇编的编译命令中,而不包含在插件编译的命令中。此时,当您尝试打开插件时,会得到一个关于runtime/internal/sys的错误。
图3-1。编译标志不一致导致加载失败
解决这个问题的方法很简单:尽可能对齐主程序和插件编译的标志。事实上,有一些标志并不影响插件的加载。可以在具体实践中慢慢摸索。
3.2第三方库的不一致版本
如果你用vendor来管理Go的依赖库,在解决了3.1的问题后,你会100%立刻遇到如下错误:
插件是用不同版本的包xxxxxxxx构建的
其中,XXXXXX是指特定的第三方库,如http://github.com/stretchr/testify.有几个典型的原因导致这一错误。如果没有相关的故障排除经验,他们几个可能会耗费开发者的时间。
3.2.1案例1。版本不一致
如错误报告所示,似乎原因很清楚,就是主程序和插件所依赖的某个第三方库的版本不一致。错误报告将清楚地告诉您哪个库有问题。此时可以对比主程序和插件的go.mod文件,分别找到问题库的版本,看是否一致。这时候,如果你发现主进程和插件确实有commitid或者tag不一致的地方,解决方法很简单:对齐它们。
但是,在很多场景下,你只会用到第三方库的一部分:比如一个包,或者只是引用一个接口。这部分代码在不同版本中根本没有变化;但是,其他未使用代码的更改也会导致整个第三方库的版本号发生变化,从而导致你成为“版本不一致”的无辜受害者。
而且,此时你可能马上会遇到另一个问题:对齐的基准是谁?主程序?还是外挂?
一般来说,以主程序为基线对齐是一个很好的策略。毕竟插件是新添加的“附件”,主程序和插件之间通常是一对多的关系。但是,如果插件的三方库依赖因为任何原因无法与主程序对齐怎么办?尝试了很久,暂时还没有找到解决这个问题的完美方案。
如果版本无法对齐,就只能从根本上放弃外挂这条路。
一方面,Go语言对三方库近乎无脑的强一致性约束,避免了运行时版本不一致带来的潜在问题;另一方面,这种故意不给程序员灵活性的设计,对插件、定制、扩展开发都非常不友好。
图3-2。由通常相互依赖的三方库的不一致版本导致的加载失败
3.2.2案例2。版本号一致,代码不一致。

当你按照3.2.1的思路去查go.mod文件,却惊讶地发现错误的库版本都是一致的,事情就变得复杂了。你可能会拿出世界上最先进的文本检查工具,花一个上午去diff三方库的commitid,但是它们完全一样,好像卡在薛定谔版本里。
出现这个问题的一个可能原因是有人直接修改了厂商目录中的代码,Go插件机制会检查代码内容的一致性。
这真的是一个非常大的原因,很难追究。除了修改代码的人和其他案例中被“坑”的人,没有人会知道这件事。如果修改后的供应商代码出现在主程序中,几乎没有任何可靠的方法让它们正常工作。
不要直接在vendor中修改代码,还给开源社区,或者fork-replace。
好消息是你不需要解决这个问题。因为就算解决了,还有更大的问题等着你。
图3-2。相互依赖的三方库的代码的就地修改导致加载失败。
3.2.3案例3。路径不一致。
当问题已经按照3.2.1和3.2.2的思路检查解决了,但还是报给了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的“运行时”和“智能”通过“文件路径”的差异,判定它和插件不是同一代码,然后报出不同版本的包。
图3-3。使用供应商机制管理第三方库导致加载失败
当使用不同的机器/用户编译主程序和插件时,也可能出现同样的问题:不同的用户名,go代码的路径应该是不同的。
这类问题的解决方法暴力而直接:删除主程序和插件的厂商目录,或者使用-mod=readonly编译flag。
此时,如果用同一台机器编译主程序和插件,那么常见的问题应该就基本解决了,插件机制应该也能正常工作了。另一方面,由于vendor不再习惯管理依赖关系,3.2.2的问题将在这里被强制解决:要么PR给社区,要么fork-replace。
图3-4。成功加载
3.3不一致的Go版本
致命错误:运行时:没有插件模块数据
除了以上问题,多台机器分别编译主程序/插件的场景还有一个常见的错误。这个错误的一个可能原因是Go版本不一致。把它们对齐就行了。
图3-5。Go版本不一致导致加载失败
统一解决方案
从3.1到3.3,我们看了一些难以排除故障和不容易处理的问题。除此之外,其实还有一些问题没有凸显出来。作为一种编程语言官方支持的扩展机制,对用户如此不友好,实在是出乎意料。
因为我的团队非常依赖围棋的外挂机制,所以必须拿出一个系统的方案来解决所有这些问题。在尝试直接修改Go源代码没有成功之后,我重点做了以下几个方面:
统一的编译环境:提供标准的docker镜像用于编译主程序和插件,避免任何go版本、gopath、用户名等不一致带来的问题。预制go/pkg/mod,由于不使用厂商模式,尽量减少每次编译时重新下载依赖的问题。统一Makefile:为主程序和插件提供一套编译Makefile。避免由go build命令引起的任何问题。统一插件开发脚手架:脚手架,而不是开发者,应该把插件和主程序的依赖版本拉在一起。并通过搭建aci: ACI编译过程解决其他相关问题,进一步避免错误。
图4-1。统一解决方案
至此,Go插件常见问题及解决方案介绍告一段落。希望对你有帮助。
奖金
如果你真的想从根本上搞清楚插件验证的机制,这里有几个入口让你快速进入源代码阅读状态。我用的Go源代码是1.15.2版本。
转到源位置:
compiler go/src/cmd/compile/* link ergo/src/cmd/link/internal/LD/*包loader go/src/cmd/go/internal/load/* runtime go/src/runtime/*
5.1 GoBuild在做什么?
您可以在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行,这里将记录与路径相关的数据。
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:检查链接时散列和运行时散列值的点

go/src/cmd/link/internal/LD/symtab . go:621:链接周期的哈希分配点
go/src/cmd/link/internal/LD/symtab . go:521:运行时哈希分配点
作者|丁飞
原文链接:http://click.aliyun.com/m/1000350176/
本文为阿里云原创内容,未经允许不得转载。


