2

Yaegi,让你用标准 Go 语法开发可热插拔的脚本和插件

 2 years ago
source link: https://segmentfault.com/a/1190000040875946
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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/[email protected]
//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 B0yaegiGo687.8 ns352 B9tengotengo19696 ns90186 B6gopherlua171.2 ns40 B2

结果让人大跌眼镜,对于特别简单的脚本,tengo 的耗时极高,很可能是在进入和退出 tengo VM 时,消耗了过多的资源。
而 gopher 则表现出了优异的性能。让人印象非常深刻。

该逻辑也很简单,判断输入数是否大于零。测试结果与简单加法类似,如下:

包名脚本语言每迭代耗时内存占用alloc数Go 原生Go1.250 ns0 B0yaegiGo583.1 ns280 B7tengotengo18195 ns90161 B3gopherLua116.2 ns8 B1

斐波那契数

前面两个性能测试过于极限,只能作参考用。在 tengo 的 README 中,声称其拥有非常高的性能,可与 gopher 和原生 Go 相比,并且还能压倒 yaegi。既然 tengo 这么有信心,并且还给出了其使用的 Fib 函数,那么我就来测一下。测试结果如下:

包名脚本语言每迭代耗时内存占用alloc数Go 原生Go104.6 ns0 B0yaegiGo21091 ns14680 B321tengotengo25259 ns90714 B73gopherLua5042 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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK