使用 Yaegi 在 Go 语言项目中嵌入 Go 作为脚本

背景

目前(2023年2月)维护着公司的对象存储系统,需要一套用于多机房流量调度与缓存的系统。目前由若干套自运维的 NGINX+ATS 集群实现,由于年久失修,存在一些问题,比如:日志信息不足、监控缺失、配置管理困难等,因此需要重构。

理论上可以研究研究 OpenResty 等基于 NGINX 的扩展方案,但由于我们不想为这个项目投入长期人力,能够较为简单的对接公司监控、日志等基础设施,并承载部分 dirty 的业务逻辑,因此决定使用 Go 语言开发。

开发初期,使用 YAML 描述转发规则,但随着规则的增多,发现由于建模不当,规则文件越发冗长繁琐;受限于领域知识的匮乏,难以设计一个简单灵活的 DSL,因此决定使用脚本语言描述转发规则。

尝试了 Go 生态中的 Lua、JS、Python 等方案,但考虑到开发体验和团队成员的技术背景,对于长期维护来说,有些不好接受。恰巧发现了 Yaegi 这个项目,它可以将 Go 语言作为脚本语言嵌入到其他 Go 项目中,并能较为简单的调用宿主程序代码,IDE 体验良好,不需要团队成员学习新的语言。

上手

Yaegi 缺少对外部使用者的文档支持,可能因为它主要是为 Traefik 项目开发,因此在使用过程中遇到了一些问题,这里记录一下。

官方文档示例代码有使用 Use(stdlib.Symbols) 方法导入符号表,但当我尝试导入自己项目的符号到解释器环境中时,却总是报错,最后在这篇文章里发现原因:

这里需要注意的是: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 文档中没提到的,它还提供了一个代码生成工具,用于生成指定 package 的符号表,以下是我使用过程中遇到的坑。

创建一个名为 learn_yaegi 的项目,目录结构如下:

.
├── go.mod
├── go.sum
├── main.go
└── pkg
    ├── a
    │   ├── a.go
    └── b
        ├── b.go

我尝试为 a 包生成符号表,于是创建文件 ./pkg/a/symbols.go,内容如下:

//go:generate go install github.com/traefik/yaegi/cmd/[email protected]
//go:generate yaegi extract learn_yaegi/a

package a

然后执行 go generate ./...,生成文件 ./pkg/a/learn_yaegi-a.go,内容如下:

// Code generated by 'yaegi extract learn_yaegi/a'. DO NOT EDIT.

//go:build go1.20
// +build go1.20

package a

import (
	"learn_yaegi/a"
	"reflect"
)

func init() {
	Symbols["learn_yaegi/a/a"] = map[string]reflect.Value{
		// function, constant and variable definitions
		"Foo": reflect.ValueOf(a.Foo),
	}
}

生成的代码无法通过编译,因为 learn_yaegi-a.go 中又导入了 learn_yaegi/a 包,会导致循环依赖。

实际项目中引用关系更复杂,最后我摸索的实现方式是:创建独立的 package 用于生成符号表,然后在解释器调用处引入该 package 中的 Symbols 变量,目录结构如下:

.
├── go.mod
├── go.sum
├── main.go
├── pkg
│   ├── a
│   │   ├── a.go
│   └── b
│       ├── b.go
└── symbols
    └── symbols.go

其中,./symbols/symbols.go的文件内容如下:

//go:generate go install github.com/traefik/yaegi/cmd/[email protected]
//go:generate yaegi extract learn_yaegi/pkg/a
//go:generate yaegi extract learn_yaegi/pkg/b

package symbols

import "reflect"

var Symbols = map[string]map[string]reflect.Value{}

PS:写了个简单的 Demo 项目,提供了一个相对完整的示范,用于演示 Yaegi 的实际使用方式,见DCjanus/yaegi_demo