揭秘!用標准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沒有任何優勢而已,請讀者辯證閱讀,也歡迎補充和指正~
( 轉載須取得作者同意,未經許可,禁止二次轉載 )
作者簡介
張敏
騰訊高級后台工程師