• 设为首页
  • 点击收藏
  • 手机版
    手机扫一扫访问
    迪恩网络手机版
  • 关注官方公众号
    微信扫一扫关注
    公众号

揭秘!用标准Go语言能写脚本吗?

原作者: [db:作者] 来自: [db:来源] 收藏 邀请

揭秘!用标准Go语言能写脚本吗? https://mp.weixin.qq.com/s/NTA-Mf14gj-6nDTDekGNVA

2021-11-03

 

导语 | 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没有任何优势而已,请读者辩证阅读,也欢迎补充和指正~

 

( 转载须取得作者同意,未经许可,禁止二次转载 )

 

 

 作者简介

 

张敏

腾讯高级后台工程师

 


鲜花

握手

雷人

路过

鸡蛋
该文章已有0人参与评论

请发表评论

全部评论

专题导读
上一篇:
Go语言环境安装&搭建(Linux)发布时间:2022-07-10
下一篇:
GO语言测试发布时间:2022-07-10
热门推荐
热门话题
阅读排行榜

扫描微信二维码

查看手机版网站

随时了解更新最新资讯

139-2527-9053

在线客服(服务时间 9:00~18:00)

在线QQ客服
地址:深圳市南山区西丽大学城创智工业园
电邮:jeky_zhao#qq.com
移动电话:139-2527-9053

Powered by 互联科技 X3.4© 2001-2213 极客世界.|Sitemap