一文搞懂Go語言的plugin
要歷數Go語言中還有哪些我還沒用過的特性,在[Go 1.8版本]中引入的[go plugin]算一個。近期想給一個閘道器類平臺設計一個外掛系統,於是想起了go plugin_。
Go plugin支援將Go包編譯為共享庫(.so)的形式單獨釋出,主程式可以在執行時動態載入這些編譯為動態共享庫檔案的go plugin,從中提取匯出(exported)變數或函式的符號並在主程式的包中使用。Go plugin的這種特性為Go開發人員提供更多的靈活性,我們可以用之實現支援熱插拔的外掛系統。
但不得不提到的一個事實是:go plugin自誕生以來已有4年多了,但它依舊沒有被廣泛地應用起來。究其原因,(我猜)一方面Go自身支援靜態編譯,可以將應用編譯為一個完全不需要依賴作業系統執行時庫(一般為libc)的可執行檔案,這是Go的優勢,而支援go plugin則意味著你只能對主程式進行動態編譯,與靜態編譯的優勢相悖;而另外一方面原因佔比更大,那就是Go plugin自身有太多的對使用者的約束,這讓很多Go開發人員望而卻步。
只有親歷,才能體會到其中的滋味。在這篇文章中,我們就一起來看看go plugin究竟是何許東東,它對使用者究竟有著怎樣的約束,我們究竟要不要使用它。
1. go plugin的基本使用方法
截至[Go 1.16版本],Go官方文件明確說明go plugin只支援Linux, FreeBSD和macOS,這算是go plugin的第一個約束。在處理器層面,go plugin以支援amd64(x86-64)為主,對arm系列晶片的支援似乎沒有明確說明(我翻看各個Go版本release notes也沒看到,也許是我漏掉了),但我在華為的泰山伺服器(鯤鵬arm64晶片)上使用Go 1.16.2(for arm64)版本構建plugin包以及載入動態共享庫.so檔案的主程式都順利透過編譯,執行也一切正常。
主程式透過plugin包載入.so並提取.so檔案中的符號的過程與C語言應用執行時載入動態連結庫並呼叫庫中函式的過程如出一轍。下面我們就來看一個直觀的例子。
下面是這個例子的結構佈局:
// github.com/bigwhite/experiments/tree/master/go-plugin
├── demo1
│ ├── go.mod
│ ├── main.go
│ └── pkg
│ └── pkg1
│ └── pkg1.go
└── demo1-plugins
├── Makefile
├── go.mod
└── plugin1.go
其中demo1代表主程式工程,demo1-plugins是主程式的plugins工程。下面是外掛工程的程式碼:
// github.com/bigwhite/experiments/tree/master/go-plugin/demo1-plugins/plugin1.go
package main
import (
"fmt"
"log"
)
func init() {
log.Println("plugin1 init")
}
var V int
func F() {
fmt.Printf("plugin1: public integer variable V=%dn", V)
}
type foo struct{}
func (foo) M1() {
fmt.Println("plugin1: invoke foo.M1")
}
var Foo foo
plugin包和普通的Go包沒太多區別,只是plugin包有一個約束:其包名必須為main,我們使用下面命令編譯該plugin:
$go build -buildmode=plugin -o plugin1.so plugin1.go
如果plugin原始碼沒有放置在main包下面,我們在編譯plugin時會遭遇如下編譯器錯誤:
-buildmode=plugin requires exactly one main package
接下來,我們來看主程式(demo1):
package main
import (
"fmt"
"github.com/bigwhite/demo1/pkg/pkg1"
)
func main() {
err := pkg1.LoadAndInvokeSomethingFromPlugin("../demo1-plugins/plugin1.so")
if err != nil {
fmt.Println("LoadAndInvokeSomethingFromPlugin error:", err)
return
}
fmt.Println("LoadAndInvokeSomethingFromPlugin ok")
}
下面是主程式demo1工程中的關鍵程式碼:
// github.com/bigwhite/experiments/tree/master/go-plugin/demo1/main.go
package main
import (
"fmt"
"github.com/bigwhite/demo1/pkg/pkg1"
)
func main() {
err := pkg1.LoadAndInvokeSomethingFromPlugin("../demo1-plugins/plugin1.so")
if err != nil {
fmt.Println("LoadAndInvokeSomethingFromPlugin error:", err)
return
}
fmt.Println("LoadAndInvokeSomethingFromPlugin ok")
}
我們在main函式中呼叫pkg1包的LoadAndInvokeSomethingFromPlugin函式,該函式會載入main函式傳入的go plugin、查詢plugin中相應符號並透過這些符號使用plugin中的匯出變數、函式等。下面是LoadAndInvokeSomethingFromPlugin函式的實現:
// github.com/bigwhite/experiments/tree/master/go-plugin/demo1/pkg/pkg1/pkg1.go
package pkg1
import (
"errors"
"plugin"
"log"
)
func init() {
log.Println("pkg1 init")
}
type MyInterface interface {
M1()
}
func LoadAndInvokeSomethingFromPlugin(pluginPath string) error {
p, err := plugin.Open(pluginPath)
if err != nil {
return err
}
// 匯出整型變數
v, err := p.Lookup("V")
if err != nil {
return err
}
*v.(*int) = 15
// 匯出函式變數
f, err := p.Lookup("F")
if err != nil {
return err
}
f.(func())()
// 匯出自定義型別變數
f1, err := p.Lookup("Foo")
if err != nil {
return err
}
i, ok := f1.(MyInterface)
if !ok {
return errors.New("f1 does not implement MyInterface")
}
i.M1()
return nil
}
在LoadAndInvokeSomethingFromPlugin函式中,我們透過plugin包提供的Plugin型別提供的Lookup方法在載入的.so中查詢相應的匯出符號,比如上面的V、F和Foo等。Lookup方法返回plugin.Symbol型別,而Symbol型別定義如下:
// $GOROOT/src/plugin/plugin.go
type Symbol interface{}
我們看到Symbol的底層型別(underlying type)是interface{},因此它可以承載從plugin中找到的任何型別的變數、函式(得益於)的符號。而plugin中定義的型別則是不能被主程式查詢的,通常主程式也不會依賴plugin中定義的型別。
一旦Lookup成功,我們便可以將符號透過型別斷言(type assert)獲取到其真實型別的例項,透過這些例項(變數或函式),我們可以呼叫plugin中實現的邏輯。編譯plugin後,執行上述主程式,我們可以看到如下結果:
$go run main.go
2021/06/15 10:05:22 pkg1 init
try to LoadAndInvokeSomethingFromPlugin...
2021/06/15 10:05:22 plugin1 init
plugin1: public integer variable V=15
plugin1: invoke foo.M1
LoadAndInvokeSomethingFromPlugin ok
那麼,主程式是如何知道匯出的符號究竟是函式還是變數呢?這取決於主程式外掛系統的設計,因為主程式與plugin間必然要有著某種“契約”或“約定”。就像上面主程式定義的MyInterface介面型別,它就是一個主程式與plugin之間的約定,plugin中只要暴露實現了該介面的型別例項,主程式便可以透過MyInterface介面型別例項與其建立關聯並呼叫plugin中的實現 。
2. plugin中包的初始化
在上面的例子中我們看到,外掛的初始化(plugin1 init)發生在主程式open .so檔案時。按照官方文件的說法:“當一個外掛第一次被open時,plugin中所有不屬於主程式的包的init函式將被呼叫,但一個外掛只被初始化一次,而且不能被關閉”。
我們來驗證一下在主程式中多次載入同一個plugin的情況,這次我們將程式升級為demo2和demo2-plugins:
主程式程式碼如下:
// github.com/bigwhite/experiments/tree/master/go-plugin/demo2/main.go
package main
import (
"fmt"
"github.com/bigwhite/demo2/pkg/pkg1"
)
func main() {
fmt.Println("try to LoadPlugin...")
err := pkg1.LoadPlugin("../demo2-plugins/plugin1.so")
if err != nil {
fmt.Println("LoadPlugin error:", err)
return
}
fmt.Println("LoadPlugin ok")
err = pkg1.LoadPlugin("../demo2-plugins/plugin1.so")
if err != nil {
fmt.Println("Re-LoadPlugin error:", err)
return
}
fmt.Println("Re-LoadPlugin ok")
}
package pkg1
import (
"log"
"plugin"
)
func init() {
log.Println("pkg1 init")
}
func LoadPlugin(pluginPath string) error {
_, err := plugin.Open(pluginPath)
if err != nil {
return err
}
return nil
}
由於僅是驗證初始化,我們去掉了查詢符號和呼叫的環節。plugin的程式碼如下:
// github.com/bigwhite/experiments/tree/master/go-plugin/demo2-plugins/plugin1.go
package main
import (
"log"
_ "github.com/bigwhite/common"
)
func init() {
log.Println("plugin1 init")
}
在demo2的plugin中,我們同樣僅保留初始化相關的程式碼,這裡我們在demo2的plugin1中還增加了一個外部依賴:github.com/bigwhite/common。
執行上述程式碼:
$go run main.go
2021/06/15 10:50:47 pkg1 init
try to LoadPlugin...
2021/06/15 10:50:47 common init
2021/06/15 10:50:47 plugin1 init
LoadPlugin ok
Re-LoadPlugin ok
透過這個輸出結果,我們驗證了兩點說法:
- 重複載入同一個plugin,不會觸發多次plugin包的初始化,上述結果中僅輸出一次:“plugin1 init”;
- plugin中依賴的包,但主程式中沒有的包,在載入plugin時,這些包會被初始化,如:“commin init”。
如果主程式也依賴github.com/bigwhite/common包,我們在主程式的main包中增加一行:
// github.com/bigwhite/experiments/tree/master/go-plugin/demo2/main.go
import (
"fmt"
_ "github.com/bigwhite/common" // 增加這一行
"github.com/bigwhite/demo2/pkg/pkg1"
)
那麼我們再執行demo2,輸出如下結果:
2021/06/15 11:00:00 common init
2021/06/15 11:00:00 pkg1 init
try to LoadPlugin...
2021/06/15 11:00:00 plugin1 init
LoadPlugin ok
Re-LoadPlugin ok
我們看到common包在demo2主程式中已經做了初始化,這樣當載入plugin時,common包不會再進行初始化了。
3. go plugin的使用約束
開篇我們就提到了,go plugin應用不甚廣泛的一個主因是其約束較多,這裡我們來看一下究竟go plugin都有哪些約束:
1) 主程式與plugin的共同依賴包的版本必須一致
在上面demo2中,主程式和plugin依賴的github.com/bigwhite/common包是一個本地module,我們在go.mod中使用replace指向本地路徑:
// github.com/bigwhite/experiments/tree/master/go-plugin/demo2/go.mod
module github.com/bigwhite/demo2
replace github.com/bigwhite/common => /Users/tonybai/go/src/github.com/bigwhite/experiments/go-plugin/common
require github.com/bigwhite/common v0.0.0-20180202201655-eb2c6b5be1b6 // 這個版本號是自行“偽造”的
go 1.16
如果我clone一份common包,將其放在common1目錄下,並在plugin的go.mod中將replace github.com/bigwhite/common語句指向common1目錄,我們重新編譯主程式和plugin後,執行主程式,我們將得到如下結果:
$go run main.go
2021/06/15 14:09:07 common init
2021/06/15 14:09:07 pkg1 init
try to LoadPlugin...
LoadPlugin error: plugin.Open("../demo2-plugins/plugin1"): plugin was built with a different version of package github.com/bigwhite/common
我們看到因common的版本不同,plugin載入失敗,這是plugin使用的一個約束:主程式與plugin的共同依賴包的版本必須一致。
我們再來看一個主程式與plugin有共同以來包的例子。我們建立demo3,在這個版本中,主程式和plugin都依賴了logrus日誌包,但主程式使用的是logrus 1.8.1版本,而plugin使用的是logrus 1.8.0版本,分別編譯後,我們執行主程式:
// github.com/bigwhite/experiments/tree/master/go-plugin/demo3
2021/06/15 14:18:35 pkg1 init
try to LoadPlugin...
LoadPlugin error: plugin.Open("../demo3-plugins/plugin1"): plugin was built with a different version of package github.com/sirupsen/logrus
我們看到主程式執行報錯,和前面的例子提示一樣,都是因為使用了版本不一致的第三方包。要想解決這個問題,我們只需讓兩者使用的logrus包版本保持一致即可,比如將主程式的logrus從v1.8.1降級為v1.8.0:
$go get github.com/sirupsen/logrus@v1.8.0
go get: downgraded github.com/sirupsen/logrus v1.8.1 => v1.8.0
$go run main.go
2021/06/15 14:19:09 pkg1 init
try to LoadPlugin...
2021/06/15 14:19:09 plugin1 init
LoadPlugin ok
我們看到降級logrus版本後,主程式便可以正常載入plugin了。
還有一種情況,那就是主程式與plugin使用了同一個module的不同major版本的包,由於major版本不同,雖然是同一module,但實則是兩個不同的包,這不會影響主程式對plugin的載入。但問題在於這個被共同依賴的module也會有自己的依賴包,當其不同major版本所依賴的某個包的版本不同時,同樣會導致主程式載入plugin出現問題。 比如:主程式依賴go-redis/redis的v6.15.9+incompatible版本,而plugin依賴的是go-redis/redis/v8版本,當我們使用這樣的主程式去載入plugin時,我們會遇到如下錯誤:
// github.com/bigwhite/experiments/tree/master/go-plugin/demo3
$go run main.go
2021/06/15 14:32:11 pkg1 init
try to LoadPlugin...
LoadPlugin error: plugin.Open("../demo3-plugins/plugin1"): plugin was built with a different version of package golang.org/x/sys/unix
我們看到redis版本並未出錯,但問題出在redis與redis/v8所依賴的golang.org/x/sys的版本不同,這種間接依賴的module的版本的不一致同樣會導致go plugin載入失敗,這同樣是go plugin的使用約束之一。
2) 如果採用mod=vendor構建,那麼主程式和plugin必須基於同一個vendor目錄構建
基於vendor構建是[go 1.5版本]引入的特性,[go 1.11版本]引入go module構建模式後,vendor構建的方式得以保留。那麼問題來了,如果主程式或plugin採用vendor構建或同時採用vendor構建,那麼主程式是否可以正常載入plugin呢?我們來用示例demo4驗證一下。(demo4和demo3大同小異,這裡就不列出具體程式碼了)。
首先我們分別為主程式(demo4)和plugin(demo4-plugins)生成vendor目錄:
// github.com/bigwhite/experiments/tree/master/go-plugin/demo4
$go mod vendor
// github.com/bigwhite/experiments/tree/master/go-plugin/demo4-plugins
$go mod vendor
我們測試如下三種情況(go 1.16版本預設在有vendor的情況下,優先使用vendor構建。所以要基於mod構建需要顯式的傳入-mod=mod):
- 主程式基於mod構建,外掛基於vendor構建
// github.com/bigwhite/experiments/tree/master/go-plugin/demo4-plugins
$go build -mod=vendor -buildmode=plugin -o plugin1.so plugin1.go
// github.com/bigwhite/experiments/tree/master/go-plugin/demo4
$go build -mod=mod -o main.mod main.go
$main.mod
2021/06/15 15:41:21 pkg1 init
try to LoadPlugin...
LoadPlugin error: plugin.Open("../demo4-plugins/plugin1"): plugin was built with a different version of package golang.org/x/sys/unix
- 主程式基於vendor構建,外掛基於mod構建
// github.com/bigwhite/experiments/tree/master/go-plugin/demo4-plugins
$go build -mod=mod -buildmode=plugin -o plugin1.so plugin1.go
// github.com/bigwhite/experiments/tree/master/go-plugin/demo4
$go build -mod=vendor -o main.vendor main.go
$./main.vendor
2021/06/15 15:44:15 pkg1 init
try to LoadPlugin...
LoadPlugin error: plugin.Open("../demo4-plugins/plugin1"): plugin was built with a different version of package golang.org/x/sys/unix
- 主程式和外掛分別基於各自的vendor構建
// github.com/bigwhite/experiments/tree/master/go-plugin/demo4-plugins
$go build -mod=vendor -buildmode=plugin -o plugin1.so plugin1.go
// github.com/bigwhite/experiments/tree/master/go-plugin/demo4
$go build -mod=vendor -o main.vendor main.go
$./main.vendor
2021/06/15 15:45:11 pkg1 init
try to LoadPlugin...
LoadPlugin error: plugin.Open("../demo4-plugins/plugin1"): plugin was built with a different version of package golang.org/x/sys/unix
從上面的測試,我們看到無論是哪一方採用vendor構建,或者雙方都基於各自vendor構建,主程式載入plugin都會失敗。如何解決這一問題呢?讓主程式和plugin基於同一個vendor構建!
我們將plugin1.go複製到demo4中,然後分別用vendor構建構建主程式和plugin1.go:
// github.com/bigwhite/experiments/tree/master/go-plugin/demo4
$go build -mod=vendor -o main.vendor main.go
// github.com/bigwhite/experiments/tree/master/go-plugin/demo4
$go build -mod=vendor -buildmode=plugin -o plugin1.so plugin1.go
將編譯生成的plugin1.so複製到demo4-plugins中,然後執行main.vendor:
// github.com/bigwhite/experiments/tree/master/go-plugin/demo4
$cp plugin1.so ../demo4-plugins
$main.vendor
2021/06/15 15:48:56 pkg1 init
try to LoadPlugin...
2021/06/15 15:48:56 plugin1 init
LoadPlugin ok
我們看到基於同一vendor的主程式與plugin是可以相容的。下面的表格總結了主程式與plugin採用不同構建模式時是否相容:
外掛構建方式主程式構建方式 | 基於mod | 基於自己的vendor |
---|---|---|
基於mod | 載入成功 | 載入失敗 |
基於基於自己的vendor | 載入失敗 | 載入失敗 |
在vendor構建模式下,只有基於同一個vendor目錄構建時,plugin才能被主程式載入成功!
3) 主程式與plugin使用的編譯器版本必須一致
如果我們使用不同版本的Go編譯器分別編譯主程式以及plugin,那麼這兩者是否能相容呢?我們還拿demo4來驗證一下。我在主機上準備了go 1.16.5和go 1.16兩個版本的Go編譯器,go 1.16.5是go 1.16的patch維護版本,其區別與go 1.16與go 1.15相比則不是一個量級的,我們用go 1.16編譯主程式,用go 1.16.5編譯plugin:
// github.com/bigwhite/experiments/tree/master/go-plugin/demo4-plugins
$go version
go version go1.16.5 darwin/amd64
$go build -buildmode=plugin -o plugin1.so plugin1.go
// github.com/bigwhite/experiments/tree/master/go-plugin/demo4
$go version
go version go1.16 darwin/amd64
$go run main.go
2021/06/15 15:58:44 pkg1 init
try to LoadPlugin...
LoadPlugin error: plugin.Open("../demo4-plugins/plugin1"): plugin was built with a different version of package runtime/internal/sys
我們看到即便用patch版本編譯,plugin與主程式也是不相容的。我們將demo4升級到用go 1.16.5版本編譯:
$go version
go version go1.16.5 darwin/amd64
$go run main.go
2021/06/15 15:59:05 pkg1 init
try to LoadPlugin...
2021/06/15 15:59:05 plugin1 init
LoadPlugin ok
我們看到只有主程式與plugin採用完全相同的版本(patch版本也要相同)編譯時,它們才是相容的,主程式才能正常載入plugin。
那麼作業系統版本是否影響主程式和plugin的相容性呢?這個沒有官方說明,我親測了一下。我在centos 7.6(amd64, go 1.16.5)上構建了demo4-plugin(基於mod=mod),然後將其複製到一臺ubuntu 18.04(amd64, go1.16.5)的主機上,ubuntu主機上的demo4主程式可以與centos上編譯出來的plugin相容。
4) 使用plugin的主程式僅能使用動態連結
Go以靜態編譯便於分發和部署著稱,但使用plugin的主程式僅能使用動態連結。不信?那我們來挑戰一下靜態編譯demo4中的主程式。
先來看看預設編譯的情況:
// github.com/bigwhite/experiments/tree/master/go-plugin/demo4
$go build main.go
$ldd main
linux-vdso.so.1 (0x00007ffc05b73000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f6a9fa3f000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f6a9f820000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f6a9f42f000)
/lib64/ld-linux-x86-64.so.2 (0x00007f6a9fc43000)
我們看到預設編譯的情況下,demo4主程式被編譯為一個需要在執行時動態連結的可執行檔案,它依賴諸多linux系統執行時庫,比如:libc等。
這一切的原因都是我們在demo4中使用了一些透過cgo實現的標準庫,比如plugin包:
// $GOROOT/src/plugin/plugin_dlopen.go
// +build linux,cgo darwin,cgo freebsd,cgo
package plugin
/*
#cgo linux LDFLAGS: -ldl
#include
#include
#include
#include
#include
static uintptr_t pluginOpen(const char* path, char** err) {
void* h = dlopen(path, RTLD_NOW|RTLD_GLOBAL);
if (h == NULL) {
*err = (char*)dlerror();
}
return (uintptr_t)h;
}
... ...
*/
我們看到plugin_dlopen.go的頭部有build指示符,它僅在cgo開啟的前提下才會被編譯,如果我們去掉cgo,比如利用下面這行命令:
// github.com/bigwhite/experiments/tree/master/go-plugin/demo4
$ CGO_ENABLED=0 go build main.go
$ ldd main
not a dynamic executable
我們確實編譯出一個靜態連結的可執行檔案,但當我們執行該檔案時,我們得到如下結果:
$ ./main
2021/06/15 17:01:51 pkg1 init
try to LoadPlugin...
LoadPlugin error: plugin: not implemented
我們看到由於cgo被關閉,plugin包的一些函式並沒有被編譯到最終可執行檔案中,於是報了"not implemented"的錯誤!
在CGO開啟的情況下,我們依舊可以讓外部連結器使用靜態連結,我們再來試一下:
// github.com/bigwhite/experiments/tree/master/go-plugin/demo4
$ go build -o main-static -ldflags '-linkmode "external" -extldflags "-static"' main.go
# command-line-arguments
/tmp/go-link-638385712/000001.o: In function `pluginOpen':
/usr/local/go/src/plugin/plugin_dlopen.go:19: warning: Using 'dlopen' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking
$ ldd main-static
not a dynamic executable
我們的確得到了一個靜態編譯的二進位制檔案,但編譯器也給出了warning。
執行這個檔案:
$ ./main-static
2021/06/15 17:02:35 pkg1 init
try to LoadPlugin...
fatal error: runtime: no plugin module data
goroutine 1 [running]:
runtime.throw(0x5d380a, 0x1e)
/usr/local/go/src/runtime/panic.go:1117 +0x72 fp=0xc000091b50 sp=0xc000091b20 pc=0x435712
plugin.lastmoduleinit(0xc000076210, 0x1001, 0x1001, 0xc000010040, 0x24db1f0)
/usr/local/go/src/runtime/plugin.go:20 +0xb50 fp=0xc000091c48 sp=0xc000091b50 pc=0x466750
plugin.open(0x5d284c, 0x18, 0xc0000788f0, 0x0, 0x0)
/usr/local/go/src/plugin/plugin_dlopen.go:77 +0x4ef fp=0xc000091ec0 sp=0xc000091c48 pc=0x4dad8f
plugin.Open(...)
/usr/local/go/src/plugin/plugin.go:32
github.com/bigwhite/demo4/pkg/pkg1.LoadPlugin(0x5d284c, 0x1b, 0xc000091f48, 0x1)
/root/test/go/plugin/demo4/pkg/pkg1/pkg1.go:13 +0x35 fp=0xc000091ef8 sp=0xc000091ec0 pc=0x4dbbb5
main.main()
/root/test/go/plugin/demo4/main.go:12 +0xa5 fp=0xc000091f88 sp=0xc000091ef8 pc=0x4ee805
runtime.main()
/usr/local/go/src/runtime/proc.go:225 +0x256 fp=0xc000091fe0 sp=0xc000091f88 pc=0x438196
runtime.goexit()
/usr/local/go/src/runtime/asm_amd64.s:1371 +0x1 fp=0xc000091fe8 sp=0xc000091fe0 pc=0x46a841
warning最終演變為執行時的panic,看來使用plugin的主程式只能編譯為動態連結的可執行程式了。目前go專案有多個issue與此有關:
- github.com/golang/go/issues/33072
- github.com/golang/go/issues/17150
- github.com/golang/go/issues/18123
4. plugin版本管理
使用動態連結實現外掛系統,一個更大的問題就是外掛的版本管理問題。
linux上的動態連結庫採用soname的方式進行版本管理。soname的關鍵功能是它提供了相容性的標準,當要升級系統中的一個庫時,並且新庫的soname和老庫的soname一樣,用舊庫連結生成的程式使用新庫依然能正常執行。這個特性使得在Linux下,升級使得共享庫的程式和定位錯誤變得十分容易。
什麼是soname呢? 在/lib和/usr/lib等集中放置共享庫的目錄下,你總是會看到諸如下面的情況:
2019-12-10 12:28 libfoo.so -> libfoo.so.0.0.0*
2019-12-10 12:28 libfoo.so.0 -> libfoo.so.0.0.0*
2019-12-10 12:28 libfoo.so.0.0.0*
關於libfoo.so居然有三個檔案入口,其中libfoo.so.0.0.0是真正的共享庫檔案,而其他兩個檔案入口則是指向libfoo.so.0.0.0的符號連結。為何會出現這個情況呢?這與共享庫的命名慣例和版本管理不無關係。
共享庫的慣例中每個共享庫都有多個名字屬性,包括real name、soname和linker name:
- real name
real name指的是實際包含共享庫程式碼的那個檔案的名字(如上面例子中的libfoo.so.0.0.0),也是在共享庫編譯命令列中-o後面的那個引數;
- soname
soname則是shared object name的縮寫,也是這三個名字中最重要的一個,無論是在編譯階段還是在執行階段,系統連結器都是透過共享庫的soname(如上面例子中的libfoo.so.0)來唯一識別共享庫的。即使real name相同但soname不同,也會被連結器認為是兩個不同的庫。共享庫的soname可在編譯期間透過傳給連結器的引數來指定,如我們可以透過"gcc -shared -Wl,-soname -Wl,libfoo.so.0 -o libfoo.so.0.0.0 libfoo.o"來指定libfoo.so.0.0.0的soname為libfoo.so.0。ldconfig -n directory_with_shared_libraries命令會根據共享庫的soname自動生成一個名為soname的符號連結指向real name檔案,當然你也可以透過ln命令自己來建立這個符號連結。另外在linux下我們可透過readelf -d檢視共享庫的soname,ldd輸出的ELF檔案依賴的共享庫列表中顯示的也是共享庫的soname及所在路徑。
- linker name
linker name是編譯階段提供給編譯器的名字(如上面例子中的libfoo.so)。如果你構建的共享庫的real name是類似於上例中libfoo.so.0.0.0那樣的帶有版本號的樣子,那麼你在編譯器命令中直接使用-L path -lfoo是無法讓連結器找到對應的共享庫檔案的,除非你為libfoo.so.0.0.0提供了一個linker name(如libfoo.so,一個指向libfoo.so.0.0.0的符號連結)。linker name一般在共享庫安裝時手工建立。
那麼go plugin是否可以用soname的方式來做版本管理呢?基於demo1我們建立demo5,並來做一下試驗。
在demo5-plugins中,我們為構建出的.so增加版本資訊:
// github.com/bigwhite/experiments/tree/master/go-plugin/demo5-plugins
$go build -buildmode=plugin -o plugin1.so.1.1 plugin1.go
$ln -s plugin1.so.1.1 plugin1.so.1
$ls -l
lrwxr-xr-x 1 tonybai staff 14 7 16 05:42 plugin1.so.1@ -> plugin1.so.1.1
-rw-r--r-- 1 tonybai staff 2888408 7 16 05:42 plugin1.so.1.1
我們透過ln命令為構建出的plugin1.so.1.1建立了一個符號連結plugin1.so.1,plugin1.so.1作為我們外掛的soname傳給demo5:
// github.com/bigwhite/experiments/tree/master/go-plugin/demo5/main.go
func main() {
fmt.Println("try to LoadAndInvokeSomethingFromPlugin...")
err := pkg1.LoadAndInvokeSomethingFromPlugin("../demo5-plugins/plugin1.so.1")
if err != nil {
fmt.Println("LoadAndInvokeSomethingFromPlugin error:", err)
return
}
fmt.Println("LoadAndInvokeSomethingFromPlugin ok")
}
執行demo5:
// github.com/bigwhite/experiments/tree/master/go-plugin/demo5
$go run main.go
2021/07/16 05:58:33 pkg1 init
try to LoadAndInvokeSomethingFromPlugin...
2021/07/16 05:58:33 plugin1 init
plugin1: public integer variable V=15
plugin1: invoke foo.M1
LoadAndInvokeSomethingFromPlugin ok
我們看到以soname傳入的外掛被順利載入並提取符號。
後續如果plugin發生變更,比如打了patch,我們只需要升級plugin為plugin1.so.1.2,然後soname依舊保持不變,主程式也無需變動。
注意:如果外掛名相同,內容相同,主程式多次載入不會出現問題;但外掛名相同,但內容不同,主程式執行時多次load會導致runtime panic,並且是無法恢復的panic。所以務必做好外掛的版本管理。
5. 小結
go plugin是go語言原生提供的一種go外掛方案(非go外掛方案,可以使用c shared library等)。但經過上面的實驗和學習,我們我們看到了plugin使用的諸多約束,這的確給go plugin的推廣使用造成的很大障礙,導致目前go plugin應用不甚廣泛。
根據上面看到的種種約束,如果要應用go plugin,必須要做到:
- 構建環境一致
- 對第三方包的版本一致。
因此,業內在使用go plugin時多利用builder container(用來構建程式的容器)來保證主程式和plugin使用相同的構建環境。
Go技術專欄“”正在慕課網火熱熱銷中!本專欄主要滿足廣大gopher關於Go語言進階的需求,圍繞如何寫出地道且高質量Go程式碼給出50條有效實踐建議,上線後收到一致好評!歡迎大家訂
閱!
我的網課“”在慕課網熱賣中,歡迎小夥伴們訂閱學習!
講師主頁:
講師部落格:
專欄:
實戰課:
免費課:
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/854/viewspace-2796916/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- 一文搞懂 ZigZag 演算法及 Go 語言的實現演算法Go
- Go語言————1、初識GO語言Go
- Go語言的”坑“Go
- go語言的介面Go
- go語言與c語言的相互呼叫GoC語言
- GO語言————2、GO語言環境安裝Go
- 如何封裝一個flutter的多語言plugin封裝FlutterPlugin
- 【Go 語言入門專欄】Go 語言的起源與發展Go
- 一文搞懂如何實現 Go 超時控制Go
- Go語言版本的forgeryGo
- Go語言的前景分析Go
- Go語言的那些坑Go
- 【Go語言入門系列】(八)Go語言是不是面嚮物件語言?Go物件
- Go_go語言初探Go
- Go語言mapGo
- go 語言切片Go
- go 語言常量Go
- go語言使用Go
- Go 1.8 的 plugin 使用GoPlugin
- Go是Google的語言,而不是我們的語言Go
- GO語言————8.5 map 的排序Go排序
- Go語言的互斥鎖MutexGoMutex
- Go 語言的組合之道Go
- go語言的31個坑Go
- go語言json的使用技巧GoJSON
- go語言的初體驗Go
- 什麼是Go語言?Go語言有什麼特點?Go
- 【譯】Go語言宣告語法Go
- Go語言運算子Go
- Go語言————7.2 切片Go
- GO語言————4.6 字串Go字串
- GO語言————4.3常量Go
- go語言變數Go變數
- Go語言介紹Go
- go語言學習Go
- Go 語言效能分析Go
- Go語言簡史Go
- Go 語言函式Go函式