运费规则变了:原来订单满 99 元包邮,现在某个活动要求满 79 元。对一套正在运行的 Go 服务,最直接的办法是修改配置或者重新发布程序。如果规则逐渐包含商品类别、配送区域和活动日期,一张配置表可能开始难以表达,团队才会考虑在运行时加载代码。
Yaegi 可以解释执行 Go 代码,也能嵌入已编译的 Go 程序。宿主保留订单、支付和数据库访问,把一小段计算交给解释器处理。这个用法足够具体,可以从一个运费函数开始评估;没有必要先搭建一整套插件平台。
本文的程序使用 Yaegi v0.16.1,在 Go 1.27.1、macOS arm64 环境运行过。验证范围是下面这个纯计算函数的加载和调用。它没有覆盖完整 Go 语言兼容性、并发、性能或不可信代码隔离。项目资料与核查日期为 2026 年 9 月 23 日。
先让一个函数跑起来
约定金额以分为单位,满 9900 分免运费,其余收取 800 分。规则接受一个整数,返回一个整数,不访问文件,也不发送网络请求。
新建一个目录,初始化模块并固定依赖版本:
go mod init example.com/shipping-rules
go get github.com/traefik/yaegi@v0.16.1
把以下代码保存为 main.go:
package main
import (
"fmt"
"log"
"github.com/traefik/yaegi/interp"
)
func main() {
i := interp.New(interp.Options{})
_, err := i.Eval(`package rules
func ShippingFee(total int) int {
if total >= 9900 {
return 0
}
return 800
}`)
if err != nil {
log.Fatal(err)
}
value, err := i.Eval("rules.ShippingFee")
if err != nil {
log.Fatal(err)
}
fee, ok := value.Interface().(func(int) int)
if !ok {
log.Fatal("ShippingFee must have signature func(int) int")
}
for _, total := range []int{9800, 9900, 12000} {
fmt.Printf("total=%d fee=%d\n", total, fee(total))
}
}
运行 go run .,本次得到的输出是:
total=9800 fee=800
total=9900 fee=0
total=12000 fee=0
宿主先创建解释器,再让它加载 rules 包,随后查找 ShippingFee 符号,检查函数签名并调用。最后一步采用带 ok 的类型断言,是为了让签名变化得到明确错误;如果直接强制断言,规则作者将参数改成 string 后,宿主可能在装载阶段直接 panic。
这段代码不需要向解释器注册标准库,因为规则本身只用了语言内建能力。宿主导入的 fmt、log 用于宿主输出和错误处理,与解释器中的可用导入是两回事。Yaegi 的 interp API 文档说明了创建解释器、执行代码和注册符号的接口。
这里也有一个小而有用的业务约束:金额使用整数。给解释器接入动态规则,不应顺便改变金额表示方式。若原服务用最小货币单位存储,规则函数继续使用同一单位,至少可以避免宿主传“分”、脚本理解成“元”这种错误。
三条输出只证明了三次计算
看到程序跑通后,很容易把下一步理解成“将规则字符串换成数据库字段”。实际需要先明确输入范围。
负数订单总额应该拒绝、按零处理,还是允许计算退款场景?运费上限是多少?不同币种是否使用同样的最小单位?这些判断不应该由每个临时脚本自行发挥。宿主在调用前检查输入,调用后检查结果,才能给订单流程提供稳定的约定。
例如,对普通人民币正向订单,宿主可以要求总额非负、返回运费在允许区间内。具体区间由业务决定。规则返回负数时,宿主记录规则版本和输入,再走约定的失败处理;不要静默把负数改成零,否则一次规则错误可能变成全站免运费。
测试也应该围绕规则变化写。把 9900 改为 7900 时,至少检查 7899、7900 和 7901,以及活动开始前后的选择结果。若规则在不同地区收费,还要检查未知地区。测试的价值在于暴露遗漏的条件,不在于证明那三个演示输入一直能通过。
更新规则时,先装载,再切换
线上宿主可以分两步更新规则:先验证新版本,再切换后续请求。
新版本先进入一个独立的解释器实例。宿主检查语法、寻找约定函数、核对签名,然后运行这次发布要求的用例。通过后,再把新版本作为后续请求的规则。任一步失败,线上仍使用旧版本。
这个设计把“脚本能否装载”和“哪个版本接收流量”分开处理。规则作者写错包名,或者返回类型不符合要求,不应该影响正在使用旧规则的请求。至于解释器和导出函数能否按你的方式并发使用,还要查所用版本的约束并测试,不能从一个单线程示例推导出线程安全。
每次计算最好保存规则版本或内容摘要。两周后有人质疑一笔运费,运维人员需要知道订单当时使用的是哪份代码。只保存“当前规则”,无法重现历史计算。
这也会影响回滚。假设活动期间新规则开始执行,十分钟后发现错误,切回旧规则只改变之后的请求。已经计费的订单是否补差价、退款或由商家承担,需要业务处理。规则版本管理能帮助定位受影响订单,却不会替财务决定补偿。
以上是宿主可以采用的更新方案,并非 Yaegi 提供了一个开箱即用的发布控制台。解释器负责执行,版本记录、审批和回滚由应用实现。
注册标准库之前,看一眼你开放了什么
很多入门例子会调用:
i.Use(stdlib.Symbols)
这样可以让解释器使用注册的标准库符号,适合演示和受信脚本。但对上面的运费计算,脚本没有理由访问操作系统或网络。为了方便未来可能出现的需求,先把一大批能力交进去,会增加后续审查负担。
注册符号能影响脚本可调用什么,不能据此宣称已经拥有安全沙箱。宿主暴露的函数也可能间接执行文件读写或数据库操作。一个名字叫 LookupCustomer 的函数,如果接受任意查询字符串,仍可能给脚本过大的访问范围。
项目 README说明,默认不导出 unsafe 和 syscall;同时它也列出了运行方式与实现限制。这个默认行为有用,但对于来自陌生用户的代码,CPU、内存、阻塞调用和宿主进程权限仍需要单独约束。
如果产品允许公众提交脚本,应先设计进程或容器隔离、资源限制及执行身份,再选择执行器。本文的同进程示例适用于可以信任规则来源的实验,不应直接变成面向公众的在线 Go 执行服务。
解释器 API 中也有带上下文的执行入口。使用它们可以安排取消,但取消能力不等于操作系统隔离,尤其不能假定任意宿主原生函数都会按时响应。具体行为要按 API 文档核对,并拿实际暴露的函数做超时测试。
宿主能使用 Go modules,不代表脚本自动会下载依赖
上面的 go get 发生在宿主项目构建阶段:Go 工具链把 Yaegi 加进宿主依赖。它没有证明解释器内部的 import 会复用宿主的整个模块解析流程。
评估时,应把一段接近真实业务的规则交给解释器,看它实际依赖什么。纯计算规则可能只要几十行代码;接入某个 SDK 后,依赖可能一路扩展到 cgo、生成代码或其他构建要求。失败通常出现在这些依赖中,而不是第一行 interp.New。
项目列出的限制包括汇编文件、C 调用以及部分编译器、链接器和嵌入文件指令。它还提醒,解释执行的计算密集代码可能显著慢于编译代码。这些信息支持的结论是逐项验证依赖,不能据此给出一个适用于所有项目的性能倍数。
另一个值得留意的地方是文档时效。核查时,仓库 README 的 Go 支持描述仍列出旧版本号。即使小例子在本机新版本 Go 上运行成功,也只能作为这个例子的结果,不能扩写成“完整支持最新 Go”。生产选型应固定 Yaegi 和 Go 版本,把项目真正需要的语言特性、导出类型及库调用纳入测试。
什么时候保持普通配置更划算
回到最初的运费问题。如果规则始终是“满多少免运费,否则收多少钱”,两个配置字段就能表达。读取配置、验证数值和记录变更,比引入解释器省事,审核人员也能直接看懂。
当规则需要组合条件,但仍能整理成表格时,可以先评估规则表。只有当表达需求已经超出配置,又值得承担运行时代码的维护成本时,解释器才有明确用途。
Go 自带的 plugin 包是另一种扩展方式,但构建和发布要求不同。官方文档列出平台支持限制、宿主与插件构建条件不一致带来的风险,并提醒打开的插件不能关闭。采用它之前,需要确认团队能控制整套构建环境。Go plugin 文档
如果扩展模块需要独立升级、由另一个团队维护,或者必须隔离崩溃和权限,那么独立进程加明确协议值得考虑。它增加了通信和部署工作,却能让两边分别发布与限制资源。具体到一个十行运费函数,这些成本可能过高;具体到用户上传的数千行脚本,隔离可能就是首要要求。
Yaegi 可以先从一个小扩展点引入:固定函数签名,输入只含必要数据,加载失败保留旧版本,计算结果接受宿主校验。等这个扩展点经历过几次实际修改,再决定要不要扩大范围。











