Go原生插件使用問題全解析
一、導言
本人在設計和落地基于Go原生插件機制的擴展開發(fā)產(chǎn)品時踩到了很多坑,由于這方面相關資料很少,因而借此機會做一個非常粗淺的總結,希望能對大家有所幫助。
本文只說問題和解決方案,不讀代碼。
二、一些背景知識
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語言來說,支持類似動態(tài)鏈接庫的擴展一直是社區(qū)中較為強烈的訴求。如圖2-5,Go在標準庫中專門提供了一個plugin 包,作為插件的語言級編程界面,src/plugin 包的本質是使用cgo機制調用unix的標準接口:dlopen() 和dlsym() 。因此,它給C/C++背景的程序員一種“這題我會”的錯覺。
圖2-4. C/C++程序加載動態(tài)鏈接庫
圖2-5. Go程序加載動態(tài)鏈接庫
典型問題解決
很遺憾,與C/C++技術棧相比,Go的插件的產(chǎn)出物雖然也是一個動態(tài)鏈接庫文件,但它對于插件的開發(fā)、使用有一系列很復雜的內置約束。更令人頭大的是,Go語言不但沒有對這些約束進行系統(tǒng)性的介紹,甚至寫了一些比較差的設計和實現(xiàn),導致插件相關問題的排錯非常反人類。本章節(jié)重點跟大家一起看下,在開發(fā)、使用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的內置標準庫??吹竭@里,你可能會有很大的疑惑:我明明用的是同一個本地環(huán)境編譯主程序和插件,為什么報標準庫不是一個版本?
答案是,go的error日志描述不準確。而這個報錯出現(xiàn)的根本原因可以歸結為:主程序和插件的某些關鍵編譯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 參數(shù),但是插件編譯的命令里沒有。此時,你在嘗試拉起插件時就會得到一個有關runtime/internal/sys的報錯。?
圖3-1. 編譯flag不一致導致的加載失敗
解決這一類標準庫版本不一致問題的方案比較簡單:盡可能對齊主程序和插件編譯的flag。事實上,有一些flag是不影響插件加載的,你可以在具體的實踐中慢慢摸索。
3.2 不一致的第三方庫版本
如果你使用vendor來管理Go的依賴庫,那么當解決3.1的問題之后,你100%會立即遇到以下這個報錯:
plugin was built with a different version of package xxxxxxxx
其中,xxxxxxxx 指的是某一個具體的三方庫,比如github.com/stretchr/testify 。這個報錯有幾個非常典型的原因,如果沒有相關的排查經(jīng)驗,其中幾個可能會燒掉開發(fā)人員不少時間。
3.2.1 Case 1. 版本不一致
如報錯所示,似乎原因很明確,即主程序和插件所共同依賴的某個第三方庫版本不一致,報錯中會明確告訴你哪一個庫有問題。此時,你可以對比排查主程序和插件的go.mod 文件,分別找到問題庫的版本,看看他們是否一致。如果這時候你發(fā)現(xiàn)主程和插件確實有commitid或tag的不一致問題,那解決的方法也很簡單:對齊它們。
但是在很多場景下,你只會用到三方庫的一部分:如一個package,或者只是引了一個interface。這一部分的代碼在不同的版本里根本就沒有變更;但其他沒用到的代碼的變更,同樣會導致整個三方庫版本號的變更,進而導致你成為那個“版本不一致”的無辜受害者。
而且,此時你可能立即會遇到另一個問題:以誰為基準對齊?主程序?還是插件?
從常理上來說,以主程序為基線進行對齊是一個比較好的策略,畢竟插件是新添加的“附屬品”,且主程序與插件通常是1對多的關系。但是,如果插件的三方庫依賴因為任何原因就是不能和主程序對齊怎么辦?在嘗試了很久以后,我暫時沒有找到一個完美解決這個問題的辦法。?
如果版本無法對齊,就只能從根本上放棄走插件這條路。
Go語言的這種對三方庫的、幾乎無腦的強一致性約束,從一方面來說,避免了運行時因為版本不一致帶來的潛在問題;從另一方面來說,這種刻意不給程序員靈活度的設計,對插件化、定制化、擴展化開發(fā)非常的不友好。?
?
圖3-2. 共同依賴的三方庫版本不一致導致的加載失敗
3.2.2 case 2. 版本號一致,代碼不一致
當你按照3.2.1的思路排查go.mod 文件,但是驚訝的發(fā)現(xiàn)報錯的庫版本是一致的時候,事情就會變得復雜起來。你可能會拿出世界上最先進的文本查驗工具,并花掉一個上午去diff 三方庫的commitid,但它們就是一模一樣,似乎陷入了薛定諤的版本。?
出現(xiàn)這個問題可能的一個不是原因的原因是:有人直接修改了vendor目錄下的代碼,Go插件機制會對代碼內容的一致性進行校驗。
這真的是一個非常令人頭大,并難以排查的原因。除了修改代碼的那個人,和已經(jīng)在其他case中被“坑”過的那些人,沒人會知道這件事情。如果修改的vendor代碼出現(xiàn)在主程序里,你就幾乎沒有任何靠譜的辦法讓它們正常工作起來。
不要直接在vendor里改代碼,回饋開源社區(qū),或者fork-replace。
好消息是,你不需要解決這個問題。因為即使解決了,也還會有更大的問題等著你。?
?
圖3-2. 共同依賴的三方庫代碼被就地修改導致的加載失敗
3.2.3 case 3. 路徑不一致
當按照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/vendor/some/thrid/part/lib。這些「文件路徑」數(shù)據(jù)會被打包到二進制可執(zhí)行文件里并用于校驗,當主程序加載插件時,Go的“運行時”“聰明的”通過「文件路徑」的差異認定它和插件用的不是同一份代碼,然后報了個different version of package。
圖3-3. 使用vendor機制管理第三方庫導致的加載失敗
同樣的問題也可能會出現(xiàn)在使用不同機器/用戶,分別編譯主程序、插件的場景下:用戶名不同,go代碼的路徑應該也會不一樣。
解決這類問題的方法很暴力直接:刪掉主程序和插件的vendor目錄,或者使用-mod=readonly 編譯flag。
到這里,如果你是使用同一臺機器進行主程序和插件的編譯,那么常見的問題應該都基本解決了,插件機制理應能夠正常工作。另一方面,由于不再使用vendor管理依賴,因此3.2.2的問題也會在這里被強制解決:要么提PR給社區(qū),要么fork-replace。?
圖3-4. 成功加載
3.3 不一致的Go版本
fatal error: runtime: no plugin module data
除了上面的那些問題以外,還有一個在多機器分別編譯主程/插件場景下的常見報錯。這個報錯的一個可能原因是Go版本不一致,對齊它們即可。(如果從機器層面就是不能對齊怎么辦?)?
圖3-5. Go版本不一致導致的加載失敗
統(tǒng)一解決方案
從3.1到3.3,我們看了一些很難排查,也不是很好處理的問題。除此之外,其實還有一些問題沒有被重點介紹進來。作為一個編程語言官方支持的擴展機制,做的如此用戶不友好確實出人意料。
我所在的團隊由于重點依賴Go的插件機制做定開,因此必須拿出一個系統(tǒng)化的方案把這些問題統(tǒng)統(tǒng)解決掉。在嘗試直接修改Go源碼無果以后(吐槽:Go插件機制源碼寫的令人略感遺憾),我重點從以下幾個方面入手開展了相關工作:
- 統(tǒng)一編譯環(huán)境:
提供一個標準的docker image用來編譯主程序和插件,規(guī)避任何go版本、gopath路徑、用戶名等不一致所帶來的問題
預制go/pkg/mod,盡可能減少因為沒有使用vendor模式導致每次編譯都要重新下載依賴的問題
- 統(tǒng)一Makefile:
提供一套主程序和插件的編譯Makefile,規(guī)避任何因為go build命令帶來的問題
- 統(tǒng)一插件開發(fā)腳手架:
由腳手架,而不是開發(fā)者拉齊插件與主程序的依賴版本。并由腳手架解決其他相關問題
- ACI化:
將編譯流程aci化,進一步避免出現(xiàn)錯誤
?
圖4-1. 統(tǒng)一解決方案
?至此,關于Go插件的常見問題及解決方法介紹就暫告段落了,希望對你有所幫助。
Bonus
如果真的想從根本上搞清楚插件校驗的機制,那這里為你提供一些快速進入源碼閱讀狀態(tài)的入口。我使用的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 參數(shù),以顯式的打印出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相關數(shù)據(jù)
5.3 庫哈希生成算法
go/src/cmd/link/internal/ld/lib.go:967 :注意第995~1025行,這里計算pkg的hash
5.4 庫哈希校驗
- go/src/runtime/symtab.go:392 :關鍵數(shù)據(jù)結構
- 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賦值點