当前位置: 首页 > 科技观察

Go原生插件使用全解析

时间:2023-03-12 12:42:52 科技观察

一、简介我在设计和实现基于Go原生插件机制的扩展开发产品时踩了很多坑。由于这方面的相关资料很少,我就借这个机会做一个很浅的。综上所述,希望能对大家有所帮助。本文只讲问题和解决方案,不讲代码。2.一些背景知识2.1运行时一般来说,在计算机程序设计语言领域,“运行时”的概念与一些需要使用vm的语言有关。程序的运行由两部分组成:目标代码和“虚拟机”。比如最典型的JAVA,即JavaClass+JRE。对于一些似乎不需要“虚拟机”的编程语言,“运行时”的概念并不多。程序的运行只需要一部分,即目标代码。但实际上,即使是C/C++也有一个“运行时”,即它所运行的平台的OS/Lib。Go也一样,因为运行Go程序不需要预先部署一个类似于JRE的“runtime”,所以它似乎与“虚拟机”或“runtime”无关。但实际上,Go语言的“运行时”是通过编译器编译成二进制目标代码的一部分。图2-1。Java程序、运行时和OS的关系如图2-2。C/C++程序、运行时和OS的关系图2-3。Go程序、运行时和OS的关系2.2Go原生插件机制作为一种对于似乎更接近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标准库中(主要包括compiler、linker、packager和runtime部分)源码可以完全看懂几个问题及相应的解决方案。简而言之,Go的主程序加载插件时,会在“运行时”对两者进行一堆约束检查,包括但不限于:一致的go版本,一致的go路径,一致的go依赖交集,consistentcode,consistentpathgobuildSomeflagsareconsistentwith3.1Inconsistentstandardlibraryversion主程序加载插件报错:pluginwasbuiltwithadifferentversionofpackageruntime/internal/sys从这个错误的文字来看,我们可以知道具体有问题的库是runtime/internal/sys,显然这是go内置的标准库。看到这里,你可能会有很大的疑问:我明明是在用同一个本地环境编译主程序和插件,为什么标准库不是一个版本呢?答案是go的错误日志描述不准确。这个错误的根本原因可以归结为:主程序和插件的一些关键编译标志不一致,这与“版本”无关。例如,您使用以下命令编译plugin:GO111MODULE=ongobuild--buildmode=plugin-modreadonly-o./codec.so./codec.go但是你使用goland的debug模式调试主程序,这时候goland会帮你组装根据以下示例执行gobuild命令:/usr/local/go/bin/gotest-c-o/private/var/folders/gy/2zv22t710sd7m0x9bcfzq23r0000gp/T/GoLand/___Test_TaskC_in_github_com_fdingiit_mpl_test.test-gcflagsall=-N-lgithub.com/fdingiit/mpl/test#gosetup注意golandassembly的编译命令包含关键的-gcflagsall=-N-l参数,而插件编译的命令没有。此时,当你尝试拉取插件时,你会得到关于runtime/internal/sys的错误。图3-1。编译标志不一致导致加载失败这类标准库版本不一致的解决方法比较简单:尽量对齐主程序和插件的编译标志。其实还有一些flag是不影响插件加载的,大家可以在实践中慢慢摸索。3.2第三方库版本不一致如果使用vendor来管理Go的依赖库,那么在解决了3.1的问题后,100%马上会遇到如下错误:pluginwasbuiltwithadifferentversionofpackagexxxxxxxx其中,xxxxxxxx是指的是具体的三方库,比如github.com/strethr/testify。此错误有几个非常典型的原因。如果没有相关的排错经验,其中一些可能会为开发者消耗大量的时间。3.2.1案例一、版本不一致从报错来看,似乎原因很明确,就是主程序和插件依赖的第三方库版本不一致,以及错误报告会清楚地告诉你哪个库有问题。此时可以对比主程序和插件的go.mod文件,找出问题库的版本是否一致。如果此时发现主程序和插件确实存在commitid或者tag不一致的情况,解决方法也很简单:对齐。但是在很多场景下,你只会用到三方库的一部分:比如一个包,或者只是引用一个接口。这部分代码在不同版本中完全没有变化;但是更改其他未使用的代码也会导致整个三方库的版本号发生变化,进而导致你成为“版本不一致”的无辜受害者。另外,此时你可能会马上遇到另一个问题:对齐的基线是谁?主程序?还是插件?一般来说,以主程序为基线对齐是更好的策略。插件毕竟是新加的“附属品”,主程序和插件通常是一对多的关系。但是,如果插件的第三方库依赖因故与主程序不对齐怎么办?尝试了很久,也没有找到完美解决这个问题的办法。如果版本不能对齐,就只能从根本上放弃插件这条路了。Go语言对三方库这种近乎无脑的强一致性约束,一方面避免了运行时版本不一致带来的潜在问题;另一方面,这种刻意不给程序员灵活性的设计,对插件化、定制化、扩展开发非常不友好。图3-2。常用依赖的三方库版本不一致导致加载失败库版本一致时,事情就复杂了。你可能拿出世界上最先进的文本检查工具,花一个上午diff三方库的commitid,结果一模一样,好像卡在了薛定谔的版本。一个可能的原因这个问题是有人直接修改了vendor目录下的代码,Go插件机制会校验代码内容的一致性。这确实是一个非常令人沮丧且难以解决的原因。除了修改代码的人,以及在其他情况下被“套牢”的人,没有人会知道。如果修改后的供应商代码出现在主程序中,您几乎没有可靠的方法使其正常工作。不要直接修改vendor中的代码,回馈开源社区,或者fork-replace。好消息是,你不需要解决这个问题。因为即使你解决了,还有更大的问题等着你。图3-2。原地修改公共依赖三方库代码导致加载失败报不同版本的package时,你可能会开始抱怨Go的插件机制:版本真的一样,代码真的没变。你为什么报告不同的版本???原因是:插件机制会校验依赖库源码的“路径”,所以不能使用vendor来管理依赖。比如:你的主程序源码放在/path/to/main目录下,那么你的某个第三方库依赖的目录应该是/path/to/main/vendor/some/thrid/part/库;同样,你的插件源码放在/path/to/plugin目录下,所以同一个三方库依赖的目录应该是/path/to/plugin/vendor/some/thrid/part/lib。这些“文件路径”数据将被打包成二进制可执行文件,用于校验。当主程序加载插件时,Go的“运行时”通过“文件路径”的区别“智能”地识别它和插件。代码不一样,然后报不同版本的包。图3-3。使用vendor机制管理第三方库加载失败同样的问题在主程序和插件使用不同机器/用户分别编译的场景下也可能出现:用户名不同,go代码的路径应该相同才会不同。这种问题的解决方法暴力直接:删除主程序和插件的vendor目录,或者使用-mod=readonly编译flag。此时,如果使用同一台机器编译主程序和插件,那么常见的问题应该基本解决,插件机制应该可以正常工作。另一方面,由于不再使用vendor来管理依赖,3.2.2的问题也将在这里得到解决:要么向社区提交PR,要么fork-replace。图3-4。成功加载3.3installedGoversionfatalerror:runtime:nopluginmoduledata除了上述问题外,在多机编译主程序/插件的场景下也有一个常见的错误。这个错误的一个可能原因是Go版本不一致,对齐即可。(机器层面无法对齐怎么办?)图3-5.Go版本不一致导致加载失败统一解决方案从3.1到3.3,我们看到了一些难以排查和不好处理的问题。此外,其实还有一些问题没有突出。作为一种编程语言官方支持的扩展机制,如此不友好,着实令人意外。由于我的团队主要是依赖Go的插件机制进行自定义开发,所以有必要想出一个系统的方案来解决所有这些问题。在尝试直接修改Go源码无果后(吐槽:Go插件机制的源码有点遗憾),我着重从以下几个方面开展了相关工作:统一编译环境:提供一个标准dockerimagefor编译主程序和插件,避免go版本、gopath路径、用户名等不一致导致的问题。预制go/pkg/mod,尽量减少每次编译重新下载依赖的问题,因为不使用供应商模式。Makefile:为主程序和插件提供一套编译好的Makefile,避免gobuild命令带来的任何问题统一的插件开发脚手架:脚手架,而不是开发者,对齐插件的依赖版本和主程序。并且脚手架解决了ACI的其他相关问题:使编译过程aci进一步避免错误图4-1。统一解决方案至此,Go插件常见问题及解决方案的介绍就告一段落了。你帮忙。Bonus如果你真的想从根本上了解插件验证的机制,那么这里有一些条目可以让你快速进入源代码阅读状态。我使用的Go源代码是1.15.2版本。相关Go源码位置:compilergo/src/cmd/compile/*linkergo/src/cmd/link/internal/ld/*packageloadergo/src/cmd/go/internal/load/*runtimego/src/runtime/*5.1gobuild在做什么?可以在gobuild命令中加上-x参数,显式打印出Go程序编译、链接、打包的全过程,例如:gobuild-x-buildmode=plugin-o../calc_plugin.socalc_plugin。go5.2目标代码生成go/src/cmd/compile/internal/gc/obj.go:55:注意第67行和72行,这里有两个入口go/src/cmd/compile/internal/gc/iexport.go:244:注意280行,这里会记录路径相关数据这里计算pkggo/src/runtime/symtab.go的hash5.4库哈希校验: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:运行时的哈希赋值点