一文搞懂Go語言的plugin

w39發表於2021-09-09

圖片描述

要歷數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/,如需轉載,請註明出處,否則將追究法律責任。

相關文章