Yaegi,讓你用標準 Go 語法開發可熱插拔的指令碼和外掛

amc發表於2021-10-28

導語

Go 作為一種編譯型語言,經常用於實現後臺服務的開發。由於 Go 初始的開發大佬都是 C 的老牌使用者,因此 Go 中保留了不少 C 的程式設計習慣和思想,這對 C/C++ 和 PHP 開發者來說非常有吸引力。作為編譯型語言的特性,也讓 Go 在多協程環境下的效能有不俗的表現。

但指令碼語言則幾乎都是解釋型語言,那麼 Go 怎麼就和指令碼扯上關係了?請讀者帶著這個疑問,“聽” 本文給你娓娓道來~~

本文章採用 知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議 進行許可。

什麼樣的語言可以作為指令碼語言?

程式設計師們都知道,高階程式語言從執行原理的角度來說可以分成兩種:編譯型語言、解釋型語言。Go 就是一個典型的編譯型語言。

  • 編譯型語言就是需要使用編譯器,在程式執行之前將程式碼編譯成作業系統能夠直接識別的機器碼檔案。執行時,作業系統直接拉起該檔案,在 CPU 中直接執行
  • 解釋型語言則是在程式碼執行之前,需要先拉起一個解釋程式,使用這個程式在執行時就可以根據程式碼的邏輯執行

編譯型語言的典型例子就是 組合語言、C、C++、Objective-C、Go、Rust 等等。

解釋型語言的典型例子就是 JavaScript、PHP、Shell、Python、Lua 等等。

至於 Java,從 JVM 的角度,它是一個編譯型語言,因為編譯出來的二進位制碼可以直接在 JVM 上執行。但從 CPU 的角度,它依然是一個解釋型語言,因為 CPU 並不直接執行程式碼,而是間接地通過 JVM 解釋 Java 二進位制碼從而實現邏輯執行。

所謂的 “指令碼語言” 則是另外的一個概念,這一般指的是設計初衷就是用來開發一段小程式或者是小邏輯,然後使用預設的直譯器解釋這段程式碼並執行的程式語言。這是一個程式語言功能上的定義,理論上所有解釋型語言都可以很方便的作為指令碼語言,但是實際上我們並不會這麼做,比如說 PHPJS 就很少作為指令碼語言使用。

可以看到,解釋型語言天生適合作為指令碼語言,因為它們原本就需要使用執行時來解釋和執行程式碼。將執行時稍作改造或封裝,就可以實現一個動態拉起指令碼的功能。

但是,程式設計師們並不信邪,ta們從來就沒有放棄把編譯型語言變成指令碼語言的努力。

為什麼需要用 Go 寫指令碼?

首先回答一個問題:為什麼我們需要嵌入指令碼語言?答案很簡單,編譯好的程式邏輯已經固定下來了,這個時候,我們需要新增一個能力,能夠在執行時調整某些部分的功能邏輯,實現這些功能的靈活配置。

在這方面,其實專案組分別針對 Go 和 Lua 都有了比較成熟的應用,使用的分別是 yaegigopher。關於後者的文章已經很多,本文便不再贅述。這裡我們先簡單列一下使用 yaegi 的優勢:

  • 完全遵從官方 Go 語法(1.161.17),因此無需學習新的語言。不過泛型暫不支援;
  • 可呼叫 Go 原生庫,並且可擴充套件第三方庫,進一步簡化邏輯;
  • 與主調方的 Go 程式可以直接使用 struct 進行引數傳遞,大大簡化開發

可以看到,yaegi 的三個優勢中,都有 “簡” 字。便於上手、便於對接,就是它最大的優勢。

快速上手

這裡,我們寫一段最簡單的程式碼,程式碼的功能是斐波那契數:

package plugin

func Fib(n int) int {
    return fib(n, 0, 1)
}

func fib(n, a, b int) int {
    if n == 0 {
        return a
    } else if n == 1 {
        return b
    }
    return fib(n-1, b, a+b)
}

令上方的程式碼成為一個 string 常量:const src = ...,然後使用 yaegi 封裝並在程式碼中呼叫:

package main 

import (
    "fmt"

    "github.com/traefik/yaegi/interp"
    "github.com/traefik/yaegi/stdlib"
)

func main() {
    intp := interp.New(interp.Options{})  // 初始化一個 yaegi 直譯器
    intp.Use(stdlib.Symbols)  // 允許指令碼呼叫(幾乎)所有的 Go 官方 package 程式碼

    intp.Eval(src)  // src 就是上面的 Go 程式碼字串
    v, _ := intp.Eval("plugin.Fib")
    fu := v.Interface().(func(int) int)

    fmt.Println("Fib(35) =", fu(35))
}

// Output:
// Fib(35) = 9227465

const src = `
package plugin

func Fib(n int) int {
    return fib(n, 0, 1)
}

func fib(n, a, b int) int {
    if n == 0 {
        return a
    } else if n == 1 {
        return b
    }
    return fib(n-1, b, a+b)
}`

我們可以留意到 fu 變數,這直接就是一個函式變數。換句話說,yaegi 直接將指令碼中定義的函式,解釋後向主調方程式直接暴露成同一結構的函式,呼叫方可以直接像呼叫普通函式一樣呼叫它,而不是像其他指令碼庫一樣,需要呼叫一個專門的傳參函式、再獲得返回值、最後再將返回值進行轉換。

從這一點來說就顯得非常非常的友好,這意味著執行時,和指令碼之間可以直接傳遞引數,而不需要中間轉換。

自定義資料結構傳遞

前文說到,yaegi 的一個極大的優勢,是可以直接傳遞自定義 struct 格式。

這裡,我先丟擲如何傳遞自定義資料結構的方法,然後再更進一步講 yaegi 對第三方庫的支援。

比如說,我定義了一個自定義的資料結構,並且希望在 Go 指令碼中進行傳遞:

package slice

// github.com/Andrew-M-C/go.util/slice

// ...

type Route struct {
    XIndexes []int
    YIndexes []int
}

那麼,在對 yaegi 直譯器進行初始化的時候,我們可以在 intp 變數初始化完成之後,呼叫以下程式碼進行符號表的初始化:

    intp := interp.New(interp.Options{})

    intp.Use(stdlib.Symbols)
    intp.Use(map[string]map[string]reflect.Value{
        "github.com/Andrew-M-C/go.util/slice/slice": {
            "Route": reflect.ValueOf((*slice.Route)(nil)),
        },
    })

這樣,指令碼在呼叫的時候,除了原生庫之外,也可以使用 github.com/Andrew-M-C/go.util/slice 中的 Route 結構體。這就實現了 struct 的原生傳遞。

這裡需要注意的是:Use 函式傳入的 map,其 key 並不是 package 的名稱,而是 package 路徑 + package 名稱的組合。比如說引入一個 package,路徑是: github.com/A/B,那麼它的 package 路徑就是 “github.com/A/B”,package 名稱是 B,連在一起的 key 就是: github.com/A/B/B,注意後面被重複了兩次的 “B” —— 筆者就被這坑過,卡了好幾天。

Yaegi 支援第三方庫

原理

我們可以留意一下上文的例子中 intp.Use(stdlib.Symbols) 這一句,這可以說是 yaegi 區別於其他 Go 指令碼庫的實現之一。這一句的含義是:使用標準庫的符號表。

Yaegi 直譯器分析了 Go 指令碼的語法之後,會將其中的符號呼叫與符號表中的目標進行連結。而 stdlib.Symbols 就匯出了 Go 中幾乎所有的標準庫的符號。不過從安全形度,yaegi 禁止了諸如 poweroff、reboot 等的高許可權系統呼叫。

因此,我們自然而然地就可以想到,我們也可以把自定義的符號表定義進去——這也就是 Use 函式的作用,將各符號的原型定義給 yaegi 就能夠實現第三方庫的支援了。

當然,這種方法只能對指令碼所能引用的第三方庫進行預先定義,而不支援在指令碼中動態載入未定義的第三方庫。即便如此,這也極大地擴充套件了 yaegi 指令碼的功能。

符號解析

前文中,我們手動在程式碼中指定了需要引入的第三方符號表。但是對於很長的程式碼,一個符號一個符號地敲,實在是太麻煩了。其實 yaegi 提供了一個工具,能夠分析目標 package 並輸出符號列表。我們可以看看 yaegi 的 stdlib 庫作為例子,它就是對 Go 原生的 package 檔案進行了解釋,並找到符號表,所使用的 package 就是 yaegi 附帶開發的一個工具。

因此,我們就可以借用這個功能,結合 go generate,在程式碼中動態地生成符號表配置程式碼。

還是以上面的 github.com/Andrew-M-C/go.util/slice 為例子,在引用 yaegi 的位置,新增以下 go generate:

//go:generate go install github.com/traefik/yaegi/cmd/yaegi@v0.10.0
//go:generate yaegi extract github.com/Andrew-M-C/go.util/slice

工具會在當前目錄下,生成一個 github_com-Andrew-M-C-go_util-slice.go 檔案,檔案的內容就是符號表配置。這樣一來,我們就不用費時間去一個一個匯出符號啦。

與其他指令碼方案的對比

功能對比

我們在調研了 yaegi 之外,也另外調研和對比了 tengo 和使用 Lua 的 gopher-lua。其中後者也是團隊應用得比較成熟的庫。

筆者需要特別強調的是:tengo 的標題雖然說自己用的是 Go,但實際上是掛羊頭賣狗肉。它使用是自己的一套獨立語法,與官方 Go 完全不相容,甚至乎連相似都稱不上。我們應當把它當作另一種指令碼語言來看。

這三種方案的對比如下:

yaegitengogopher
程式語言GotengoLua
社群活躍1天內1個月內5個月前注:截至 2021-10-19
複雜型別直接傳遞不支援table 傳遞
正式版本注:gopher 沒有正式的 release 版,但已經相對穩定
標準庫Go 標準庫tengo 標準庫Lua 標準庫
三方庫Go 三方庫Lua 三方庫注:yaegi 暫不支援 cgo
效能較低注:參見下文 “效能對比”

總而言之:

  • gopher 的優勢在於效能
  • yaegi 的優勢在於 Go 原生語法,以及可以接受的效能
  • tengo 的優勢?對於筆者的這一使用場景來說,不存在的

但是 yaegi 也有很明顯的不足:

  • 它依然處於 0.y.z 版本的階段,也就是說這只是 beta 版本,後續的 API 可能會有比較大的變化
  • Go 官方語法的大方向是支援泛型,而 yaegi 目前是不支援泛型的。後續需要關注 yaegi 在這方便的迭代情況

效能對比

下文的表格比較多,這裡先拋這三個庫的對比結論吧:

  • 從純算力效能上看,gopher 擁有壓倒性的優勢
  • yaegi 的效能很穩定,大約是 gopher 的 1/5 ~ 1/4 之間
  • 非計算密集型的場景下,tengo 的效能比較糟糕。平均場景也是最差的

簡單的 a + b

這是一個簡單的邏輯封裝,就是普通的 res := a + b,這是一個極限情況的測試。測試結果如下:

包名指令碼語言每迭代耗時記憶體佔用alloc數
Go 原生Go1.352 ns0 B0
yaegiGo687.8 ns352 B9
tengotengo19696 ns90186 B6
gopherlua171.2 ns40 B2

結果讓人大跌眼鏡,對於特別簡單的指令碼,tengo 的耗時極高,很可能是在進入和退出 tengo VM 時,消耗了過多的資源。
而 gopher 則表現出了優異的效能。讓人印象非常深刻。

條件判斷

該邏輯也很簡單,判斷輸入數是否大於零。測試結果與簡單加法類似,如下:

包名指令碼語言每迭代耗時記憶體佔用alloc數
Go 原生Go1.250 ns0 B0
yaegiGo583.1 ns280 B7
tengotengo18195 ns90161 B3
gopherLua116.2 ns8 B1

斐波那契數

前面兩個效能測試過於極限,只能作參考用。在 tengo 的 README 中,聲稱其擁有非常高的效能,可與 gopher 和原生 Go 相比,並且還能壓倒 yaegi。既然 tengo 這麼有信心,並且還給出了其使用的 Fib 函式,那麼我就來測一下。測試結果如下:

包名指令碼語言每迭代耗時記憶體佔用alloc數
Go 原生Go104.6 ns0 B0
yaegiGo21091 ns14680 B321
tengotengo25259 ns90714 B73
gopherLua5042 ns594 B1

這麼說吧:tengo 號稱與原生 Go 相當,但是實際上整整差了兩個數量級,並且還是這幾個競爭者之間的效能是最低的。

這個測試結果與 tengo 的 README 上宣稱的 benchmark 資料出入也很大,如果讀者知道 tengo 的測試方法是什麼,或者是我的測試方法哪裡有問題,也希望不吝指出~~

工程應用注意要點

在實際工程應用中,針對 yaegi,筆者鎖定這樣的一個應用場景:使用 Go 執行時程式,呼叫 Go 指令碼。我需要限制這個指令碼完成有限的功能(比如資料檢查、過濾、清洗)。因此,我們應該限制指令碼可呼叫的能力。我們可以通過刪除 stdlib.Symbols 表中的部分 package 來實現,筆者在實際應用中,刪除了以下的 package 符號:

  • os/xxx
  • net/xxx
  • log
  • io/xxx
  • database/xxx
  • runtime

此外,雖然 yaegi 直接將指令碼函式暴露出來可以直接呼叫,但是主程式不能對指令碼的可靠性做任何的假設。換句話說,指令碼可能會 panic,或者是修改了主程式的變數,從而導致主程式 panic。為了避免這一點,我們要將指令碼放在一個受限的環境裡執行,除了前面通過限制 yaegi 可呼叫的 package 的方式之外,還應該限制呼叫指令碼的方式。包括但不限於以下幾個手段:

  1. 將呼叫邏輯放在獨立的 goroutine 中呼叫,並且通過 recover 函式捕獲異常
  2. 不直接將主程式的變數等記憶體資訊暴露給指令碼,傳參時候,需要考慮將引數複製後再傳遞,或者是指令碼非法返回的可能性
  3. 如無必要,可以禁止指令碼開啟新的 goroutine。由於 go 是一個關鍵字,因此全文匹配一下正則 “\sgo ” 就行(注意空格字元)。
  4. 指令碼的執行時間也需要進行限制,或者是監控。如果指令碼有 bug 出現了無限迴圈,那麼主調方應能夠脫離這個指令碼函式,回到主流程中。

當然,文中充滿了對 tengo 的不推崇,也只是在筆者的這種使用場景下,tengo 沒有任何優勢而已,請讀者辯證閱讀,也歡迎補充和指正~~


本文章採用 知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議 進行許可。

原作者: amc,原文釋出於雲+社群,也是本人的部落格。歡迎轉載,但請註明出處。

原文標題:《Yaegi,讓你用標準 Go 語法開發可熱插拔的指令碼和外掛》

釋出日期:2021-10-20

原文連結:https://cloud.tencent.com/developer/article/1890816

相關文章