专栏首页后台全栈之路Yaegi,让你用标准 Go 语法开发可热插拔的脚本和插件
原创

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

导语

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/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 的方式之外,还应该限制调用脚本的方式。包括但不限于以下几个手段:

  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

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

我来说两句

0 条评论
登录 后参与评论

相关文章

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

    ? 导语 | Go作为一种编译型语言,经常用于实现后台服务的开发。由于Go初始的开发大佬都是C的老牌使用者,因此Go中保留了不少C的编程习惯和思想,这对C/C...

    腾小云
  • Go 插件功能的实现方式

    golang 1.8 及以上版本提供了一个创建共享库(shared object)的新工具,称为 Plugins。目前 Plugins 仅在 Linux、Fre...

    寻寻觅觅的Gopher
  • 存在即合理!“伪需求”的VR背包电脑其实当前市场广阔

    VRPinea
  • golang插件化方案

    业务线的活动,每一次新活动都做独立项目开发,有大量重复代码,并且浪费数据服务的连接资源;排序服务也许要经常添加业务代码,目前是停服务发布……这些场景为了开发维护...

    李海彬
  • golang插件化方案

    业务线的活动,每一次新活动都做独立项目开发,有大量重复代码,并且浪费数据服务的连接资源;排序服务也许要经常添加业务代码,目前是停服务发布……这些场景为了开发维护...

    李海彬
  • 今日说“法”:是谁动了我的JTAG口?

    大侠好,欢迎来到FPGA技术江湖,江湖偌大,相见即是缘分。大侠可以关注FPGA技术江湖,在“闯荡江湖”、"行侠仗义"栏里获取其他感兴趣的资源,或者一起煮酒言欢。...

    FPGA技术江湖
  • 这么多的编程语言为何选择Go

    在你阅读以下内容时,我不得不告诉你一个事实,编程语言Go正在成为一颗冉冉升起的新星,为什么这样说,出身于Google,它是名门出身,它的作者可以说称得上是神级一...

    陌无崖
  • 嵌入式Linux开发板_WIFI无线网卡驱动移植

    有线就插上网线,没什么好说的;无线的话一种是将WIFI模块集成焊接在板子上,另一种是WIFI模块以USB的方式接到板子上。

    韦东山
  • 【Vue CLI】手把手教你撸插件

    现如今 Vue 作为主流的前端框架之一,其健全的配套工具,活跃的开源社区,让广发码农热衷追捧。Vue CLI 作为其官方的开发构建工具,目前已更新迭代到 4.x...

    2020labs小助手
  • Traefik Plugins 全面解析

    Traefik v2.3 及以上版本允许开发人员使用 Plugins 插件向 Traefik 添加新功能或定义新行为。例如,可以修改请求或标头、重定向、添加身份...

    寻寻觅觅的Gopher
  • 漫谈gRPC

    本文概括性的介绍gRPC,包括gRPC的起源,核心特性,生态体系,以及一些知名开源软件对gRPC的使用,最后总结gRPC与netty、dubbo等框架的区别,目...

    田守枝
  • 干货!交换机常用的光模块及光接口

    SFP:Small Form-factor Pluggabletransceiver ,小封装可插拔收发器

    用户8611941
  • 基于HDMI的视频流输入输出

    HDMI高清多媒体界面(英语:High Definition Multimedia Interface)是一种全数字化视频和声音发送接口,可以发送未压缩的音频及...

    FPGA开源工作室
  • 区块链BaaS云服务(15)复杂美chain33

    【摘要】 1. 整体架构 采用模块插件化的设计,模块可插拔,适应多种场景。 应用层 EVM虚拟机, WASM虚拟机,GO语言原生合约以及JVM虚拟机(研发完成,...

    用户7358413
  • 重磅消息:正式开源高性能异步Soul网关

    java进阶架构师
  • Golang指南:顶级Golang框架、IDE和工具列表

    自推出以来,Google的Go编程语言(Golang)越来越受主流用户的欢迎。在2016年12月的一份调研中,3,595名受访者中有89%表明他们在工作中或工作...

    李海彬
  • Python+Excel数据分析实战:军事体能考核成绩评定(一)项目概况

    Excel(Microsoft office)是现在最常用的办公软件,主要涉及电子表格制作、数据处理、报表输出展示以及更高端的还有金融建模等;我们知道,在需要批...

    张国平
  • 深入理解Android Instant Run运行机制

    Instant Run Instant Run,是android studio2.0新增的一个运行机制,在你编码开发、测试或debug的时候,它都能显著减少你对...

    xiangzhihong
  • Python+Excel数据分析实战:军事体能考核成绩评定(一)项目概况

    Excel(Microsoft office)是现在最常用的办公软件,主要涉及电子表格制作、数据处理、报表输出展示以及更高端的还有金融建模等;我们知道,在需要批...

    张国平

扫码关注云+社区

领取腾讯云代金券