导语
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 二进制码从而实现逻辑运行。
所谓的“脚本语言”则是另外的一个概念,这个别指的是设计初衷就是用来开发一段小程序或者是小逻辑,而后应用预设的解释器解释这段代码并执行的程序语言。这是一个程序语言性能上的定义,实践上所有解释型语言都能够很不便的作为脚本语言,然而实际上咱们并不会这么做,比如说 PHP
和 JS
就很少作为脚本语言应用。
能够看到,解释型语言天生适宜作为脚本语言,因为它们本来就须要应用运行时来解释和运行代码。将运行时稍作革新或封装,就能够实现一个动静拉起脚本的性能。
然而,程序员们并不信邪,ta 们素来就没有放弃把编译型语言变成脚本语言的致力。
为什么须要用 Go 写脚本?
首先答复一个问题:为什么咱们须要嵌入脚本语言?答案很简略,编译好的程序逻辑曾经固定下来了,这个时候,咱们须要增加一个能力,可能在运行时调整某些局部的性能逻辑,实现这些性能的灵便配置。
在这方面,其实项目组别离针对 Go 和 Lua 都有了比拟成熟的利用,应用的别离是 yaegi 和 gopher。对于后者的文章曾经很多,本文便不再赘述。这里咱们先简略列一下应用 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 对第三方库的反对。
比如说,我定义了一个自定义的数据结构,并且心愿在 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 齐全不兼容,甚至乎连类似都称不上。咱们该当把它当作另一种脚本语言来看。
这三种计划的比照如下:
yaegi | tengo | gopher | ||
---|---|---|---|---|
编程语言 | Go | tengo | Lua | |
社区沉闷 | 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 原生 | Go | 1.352 ns | 0 B | 0 |
yaegi | Go | 687.8 ns | 352 B | 9 |
tengo | tengo | 19696 ns | 90186 B | 6 |
gopher | lua | 171.2 ns | 40 B | 2 |
后果让人大跌眼镜,对于特地简略的脚本,tengo 的耗时极高,很可能是在进入和退出 tengo VM 时,耗费了过多的资源。
而 gopher 则体现出了优异的性能。让人印象十分粗浅。
条件判断
该逻辑也很简略,判断输出数是否大于零。测试后果与简略加法相似,如下:
包名 | 脚本语言 | 每迭代耗时 | 内存占用 | alloc 数 |
---|---|---|---|---|
Go 原生 | Go | 1.250 ns | 0 B | 0 |
yaegi | Go | 583.1 ns | 280 B | 7 |
tengo | tengo | 18195 ns | 90161 B | 3 |
gopher | Lua | 116.2 ns | 8 B | 1 |
斐波那契数
后面两个性能测试过于极限,只能作参考用。在 tengo 的 README 中,宣称其领有十分高的性能,可与 gopher 和原生 Go 相比,并且还能压倒 yaegi。既然 tengo 这么有信念,并且还给出了其应用的 Fib
函数,那么我就来测一下。测试后果如下:
包名 | 脚本语言 | 每迭代耗时 | 内存占用 | alloc 数 |
---|---|---|---|---|
Go 原生 | Go | 104.6 ns | 0 B | 0 |
yaegi | Go | 21091 ns | 14680 B | 321 |
tengo | tengo | 25259 ns | 90714 B | 73 |
gopher | Lua | 5042 ns | 594 B | 1 |
这么说吧: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 的形式之外,还应该限度调用脚本的形式。包含但不限于以下几个伎俩:
- 将调用逻辑放在独立的 goroutine 中调用,并且通过
recover
函数捕捉异样 - 不间接将主程序的变量等内存信息裸露给脚本,传参时候,须要思考将参数复制后再传递,或者是脚本非法返回的可能性
- 如无必要,能够禁止脚本开启新的 goroutine。因为
go
是一个关键字,因而全文匹配一下正则“\sgo
”就行(留神空格字符)。 - 脚本的运行工夫也须要进行限度,或者是监控。如果脚本有 bug 呈现了有限循环,那么主调方应可能脱离这个脚本函数,回到主流程中。
当然,文中充斥了对 tengo 的不推崇,也只是在笔者的这种应用场景下,tengo 没有任何劣势而已,请读者辩证浏览,也欢送补充和斧正~~
本文章采纳 常识共享署名 - 非商业性应用 - 雷同形式共享 4.0 国内许可协定 进行许可。
原作者:amc,原文公布于云 + 社区,也是自己的博客。欢送转载,但请注明出处。
原文题目:《Yaegi,让你用规范 Go 语法开发可热插拔的脚本和插件》
公布日期:2021-10-20
原文链接:https://cloud.tencent.com/developer/article/1890816。