1. 程式人生 > 其它 >揭祕!用標準Go語言能寫指令碼嗎?

揭祕!用標準Go語言能寫指令碼嗎?

揭祕!用標準Go語言能寫指令碼嗎? https://mp.weixin.qq.com/s/NTA-Mf14gj-6nDTDekGNVA

揭祕!用標準Go語言能寫指令碼嗎?

導語|Go作為一種編譯型語言,經常用於實現後臺服務的開發。由於Go初始的開發大佬都是C的老牌使用者,因此Go中保留了不少C的程式設計習慣和思想,這對C/C++ 和PHP開發者來說非常有吸引力。作為編譯型語言的特性,也讓Go在多協程環境下的效能有不俗的表現。但指令碼語言則幾乎都是解釋型語言,那麼Go怎麼就和指令碼扯上關係了?請讀者帶著這個疑問,“聽” 本文給你娓娓道來~

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

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

  • 編譯型語言就是需要使用編譯器,在程式執行之前將程式碼編譯成作業系統能夠直接識別的機器碼檔案。執行時,作業系統直接拉起該檔案,在CPU中直接執行。

  • 解釋型語言則是在程式碼執行之前,需要先拉起一個解釋程式,使用這個程式在執行時就可以根據程式碼的邏輯執行。

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

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

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

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

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

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

二、為什麼需要用GO寫指令碼?

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

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

  • 完全遵從官方Go語法(1.16 和 1.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對第三方庫的支援。

比如說,我定義了一個自定義的資料結構(https://github.com/Andrew-M-C/go.util/blob/master/slice/lcs.go#L91),並且希望在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,但實際上是掛羊頭賣狗肉。tengo使用是自己的一套獨立語法,與官方Go完全不相容,甚至乎連相似都稱不上。我們應當把它當作另一種指令碼語言來看。

這三種方案的對比如下:

總而言之:

  • 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,這是一個極限情況的測試。測試結果如下:

結果讓人大跌眼鏡,對於特別簡單的指令碼,tengo的耗時極高,很可能是在進入和退出tengo VM時,消耗了過多的資源。

而gopher則表現出了優異的效能。讓人印象非常深刻。

  • 條件判斷

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

  • 斐波那契數

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

七、工程應用注意要點

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

  • os/xxx

  • net/xxx

  • log

  • io/xxx

  • database/xxx

  • runtime

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

  • 將呼叫邏輯放在獨立的goroutine中呼叫,並且通過recover函式捕獲異常。

  • 不直接將主程式的變數等記憶體資訊暴露給指令碼,傳參時候,需要考慮將引數複製後再傳遞,或者是指令碼非法返回的可能性。

  • 如無必要,可以禁止指令碼開啟新的goroutine。由於go是一個關鍵字,因此全文匹配一下正則“\sgo”就行(注意空格字元)。

  • 指令碼的執行時間也需要進行限制,或者是監控。如果指令碼有bug出現了無限迴圈,那麼主調方應能夠脫離這個指令碼函式,回到主流程中。

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

(轉載須取得作者同意,未經許可,禁止二次轉載)

作者簡介

張敏

騰訊高階後臺工程師