Expr 深度拆解:Go 表达式引擎如何在不 eval 的前提下把动态规则写进生产——从词法分析到字节码 VM 全链路实战
关键词:Go、表达式引擎、动态配置、规则引擎、DSL、字节码虚拟机、静态类型
一、背景介绍:你迟早会想要「不改代码就能改逻辑」
先说一个所有后端工程师都绕不开的场景。
你的系统上线了,业务逻辑写死在 Go 代码里:
func shouldDiscount(user User, order Order) bool {
if user.Level == "VIP" && order.Amount > 1000 {
return true
}
if user.TotalSpent > 100000 && order.Category == "electronics" {
return true
}
return false
}
前三个月岁月静好。第四个月,运营说:「VIP 满 1000 打 9 折,这个规则对华东区的用户要改成满 800。」第五个月,风控插话:「新注册 7 天内的用户,金额超过 5000 一律不打折。」第六个月,产品经理拿来了 23 条叠加规则,每条都带地域、渠道、时间窗口、人群包……
于是你发现,业务规则的本质是「会变的配置」,但你却用「不会变的代码」去承载它。每一次规则变更都要走一遍:改代码 → 提 MR → code review → CI → 发版 → 等灰度。一条运营临时活动,从提需求到生效要两天。这在今天是不可接受的。
更糟的是,这些规则往往不归工程师所有。运营、风控、分析师才是规则的真正作者,但他们不会写 Go,也不会提 PR。他们手里只有一张 Excel 或者一个后台表单。
我们试过的所有「野路子」
面对这个需求,团队通常会尝试下面几种方案,每一种都有明显硬伤:
1. 直接把规则存成 Go 代码片段,用 plugin 或 go:generate 动态编译。
太重了。编译一个 .so 要 3 秒起步,还要维护编译环境,热更新基本等于发版。
2. 用 eval 风格的库,把用户输入当代码执行。
Go 语言本身没有 eval(这是 Go 的设计选择,不是缺陷)。如果你为了「动态」去引入一个能执行任意代码的沙箱(比如往容器里塞一个 Lua VM 或者 wasm),那你就把一个图灵完备的、能访问系统资源的执行环境暴露给了运营填的表单。一次配置错误就是一次 RCE。安全团队会和你聊一整天的。
3. 自己写一套 if-else 的 JSON 配置 DSL。
「字段名 + 操作符 + 值」的三元组数组,后端循环匹配。听起来简单,写出来就是灾难:不支持组合逻辑(AND/OR/NOT 嵌套)、不支持函数(「最近 7 天的订单数」怎么表达?)、不支持数组聚合(「购物车里有没有含酒精的商品?」)。最后你发明了一个阉割版 SQL,然后花半年修它的 bug。
4. 引入 Drools / 规则引擎全家桶。
Java 世界的成熟方案,但它是为 JVM 生的。在 Go 服务里塞一个规则引擎,要么走 HTTP 调一个 Java 服务(多一跳延迟、多一个故障点),要么接受它和 Go 类型系统格格不入。
我们真正想要的是这么一个东西:
- 规则是一段文本字符串,可以存数据库、存配置中心、存运营后台;
- 文本能被安全地求值,访问不到文件系统、网络、任意 Go 函数;
- 文本能表达完整的布尔逻辑 + 函数 + 数组处理;
- 它能复用 Go 的类型和结构体,最好编译期就能查出类型错误;
- 求值的性能要足够高,能在请求热路径上跑(不是「离线批处理才敢用」)。
expr-lang/expr 就是为这个需求而生的。它不是又一个「玩具 DSL」,而是一个已经悄悄活在生产环境里、被 Grafana、Woodpecker、Cilium 生态工具、大量 SaaS 定价引擎使用的表达式引擎。今天我们就把它从词法分析到字节码 VM 完整地拆一遍,并用可运行的代码把它落到生产级实战里。
二、核心概念:Expr 到底「是什么」,又「不是什么」
先用一句话定义:Expr 是一个为 Go 设计的、表达式层面的 DSL 与求值引擎。表达式是一行由变量、运算符、函数组成的代码,求值后返回一个值(通常是布尔值或任意类型)。
这句话里藏着几个关键的设计决策,理解它们你才不会用错:
2.1 它是「表达式」,不是「语句」,更不是「图灵完备语言」
Expr 语言里没有 if 语句、for 循环、变量赋值、函数定义。你只能写一个「表达式」——一个求值后产出结果的东西。比如:
user.Level == "VIP" && order.Amount > 1000 ? 0.9 : 1.0
注意这里的三元运算符 cond ? a : b 是表达式(有值),而传统的 if/else 是语句(没有值,是控制流)。这个限制不是功能缺失,而是安全模型的基石:
因为语言里没有循环和递归,任何 Expr 表达式的执行步数在编译期就是有上界的。你不可能写出一个让 CPU 跑满的死循环。这是对抗「配置即代码」DoS 攻击的最硬的一道防线——很多图灵完备的脚本沙箱,最怕的就是用户填一个
while(true){}。
2.2 它是一个「白名单环境」,而非「黑名单沙箱」
这是 Expr 安全哲学的第二个支柱。当你求值时,能访问的标识符(变量、字段、方法、函数)完全由你传入的 env 决定。Expr 不会自动把 os、net、io、exec 之类的标准库塞进作用域。
换句话说,Expr 的安全性不是「我列出了 100 个禁止调用的危险函数」,而是「我只给了你这 5 个我能看见的变量和 3 个函数,除此之外一律不存在」。白名单天然比黑名单更难被绕过——你无法调用一个「作者没想到要禁」的 API,因为它根本不在环境里。
2.3 它做静态类型检查,而不是「运行时再 panic」
这是 Expr 区别于大多数嵌入式表达式引擎(比如 govaluate)的地方。你可以用一个 Go 结构体或 map 作为「环境类型」,Expr 在 Compile 阶段就会基于这个类型推导出表达式里每个节点的类型,并提前报错:
// 下面这行在 Compile 时直接报错,而不是 Run 时才 panic
// string 不能和 int 相加
code := `user.Name + user.Age` // 编译错误:invalid operation: + (mismatched types string and int)
这在生产里意味着什么?意味着一条配置错了,你在保存它的那一刻(甚至在前端校验时)就能拦住,而不是等到它在一个深夜的请求里 panic 把服务打挂。规则的可测试性直接上了一个台阶。
2.4 它先编译成字节码,再交给 VM 执行
Expr 不是「边解析边执行」的解释器。它的工作流是:源码字符串 → 词法分析 → 语法分析(AST) → 类型检查 → 编译成字节码(opcode 数组) → 虚拟机顺序执行字节码。
这个「编译/执行分离」的设计带来两个直接收益:
- 一次编译,多次执行:你把编译产物(一个
program)缓存起来,每个请求只支付「VM 跑一遍字节码」的代价,而不是每次都重新解析。这是它能上请求热路径的根本原因。 - 可观测、可优化:字节码是线性的、无副作用的指令流,编译器可以在生成阶段做常量折叠、死代码消除等优化。
三、架构分析:从一行字符串到一个值,中间发生了什么
我们把 Expr 的内部管线拆成五个阶段,逐段看它的工程取舍。
3.1 阶段一:词法分析(Lexer)
Lexer 把源码字符串切成一个个 token:标识符、数字、字符串、运算符、括号、点号……
Expr 的 Lexer 有几个值得一提的细节:
- 数字字面量:同时支持整数(
42)、浮点(3.14)、十六进制(0xff)、科学计数法(1e3)。 - 字符串:支持单引号、双引号,支持常见的转义(
\n、\t、\"),并且——这在写规则模板时很关键——支持字符串插值("Hello, #{user.Name}")。 - 运算符优先级:Expr 内置了一套符合直觉的优先级表(和 C/Go 一致:
*高于+,&&高于||)。
3.2 阶段二:语法分析(Parser → AST)
Parser 依据运算符优先级和结合性,把线性的 token 流构建成一棵抽象语法树(AST)。例如 a + b * c 会被解析成:
(+)
/ \
a (*)
/ \
b c
而不是错误的 ((a+b)*c)。AST 是后续一切处理(类型检查、编译、优化)的基础数据结构。Expr 的 AST 节点都实现了统一的接口,便于遍历和重写。
3.3 阶段三:类型检查(Type Checker)
这是 Expr 的「护城河」。当你在 Compile 时传入 expr.Env(env),类型检查器会:
- 从 env 的类型(结构体或 map 的 value 类型)出发,建立「标识符 → 类型」的映射;
- 自底向上遍历 AST,为每个节点推导类型;
- 校验运算符两侧类型是否合法(比如
==两边是否可比、+两边是否同数值类型或同字符串); - 校验方法/函数调用时,参数个数和类型是否匹配;
- 对
?.这种「安全导航」运算符,正确处理可能为 nil 的中间节点。
类型检查失败会返回一个带精确位置信息的错误,告诉你是第几个字符附近、哪个类型不匹配。这比运行时 interface{} 反射失败的报错体验好太多了。
3.4 阶段四:编译(Compile → Bytecode)
类型检查通过后,编译器把 AST 翻译成一串字节码指令(opcode)。你可以把它想象成一种极简的栈式虚拟机指令集:
OpFetch:从环境里取一个变量压栈;OpConst:把一个常量压栈;OpAdd/OpSub/OpMul:弹出栈顶两个操作数,计算结果压回栈;OpEqual/OpGreater:弹出两个操作数做比较,压入布尔;OpJumpIfFalse:条件跳转(实现&&、||、?:的短路);OpCall/OpMethod:函数/方法调用;- ……
编译阶段还会做常量折叠:2 + 3 * 4 会被直接算成 14 然后变成一个 OpConst。表达式越长,这种优化省下的运行时开销越可观。
3.5 阶段五:执行(VM Run)
最后,expr.Run(program, env) 把字节码交给一个栈式虚拟机顺序执行。VM 维护一个操作数栈和一条程序计数器(PC),逐条取指令、执行、移动 PC,直到遇到 OpReturn 把栈顶值作为结果返回。
因为字节码是线性的、无分支预测的复杂跳转、无函数调用的栈帧开销(Expr 的调用是内联展开或轻量调用),VM 的执行速度非常接近手写 Go 代码的同等逻辑——这是 Expr 能在请求热路径上跑的底气。
一张图总结管线:
源码 → [Lexer] → token → [Parser] → AST → [TypeChecker] → 带类型的AST → [Compiler] → 字节码 → [VM] → 结果
其中Lexer+Parser+TypeChecker+Compiler只在Compile时发生一次;VM Run每次求值都发生,但极快。
四、代码实战:从 hello world 到生产级规则引擎
光讲架构没意思,直接上手。先安装:
go get github.com/expr-lang/expr
要求 Go 1.21+(Expr 持续跟进 Go 新版特性)。
4.1 实战一:最基础的两路求值
Expr 提供两种入口。expr.Eval 一步到位(适合跑一次就扔),expr.Compile + expr.Run 适合「编译一次、多次跑」:
package main
import (
"fmt"
"github.com/expr-lang/expr"
)
func main() {
// 方式 A:一行 Eval,内部自动编译+执行
out, err := expr.Eval(`1 + 2 * 3`, nil)
if err != nil {
panic(err)
}
fmt.Println(out) // 7
// 方式 B:先编译成 program 再 Run(生产推荐)
program, err := expr.Compile(`greet + " " + name`, expr.Env(map[string]any{
"greet": "Hello",
"name": "World",
}))
if err != nil {
panic(err)
}
result, err := expr.Run(program, map[string]any{
"greet": "Hello",
"name": "World",
})
if err != nil {
panic(err)
}
fmt.Println(result) // Hello World
}
注意方式 B 里 expr.Env(...) 的作用是告诉编译器环境里有哪些变量、什么类型。即使你用 map[string]any,传入 Env 也能让编译器在编译期校验 greet、name 是否存在、能否拼接。
4.2 实战二:对接真实 Go 结构体(这才是生产用法)
实际项目里,规则要读的是你的领域模型。Expr 可以直接复用 Go 结构体,连字段都不用重新声明:
type User struct {
ID int
Name string
Level string // "normal" | "VIP" | "SVIP"
Age int
TotalSpent float64
Region string
IsNew bool
}
type Order struct {
Amount float64
Category string
Items []Item
}
type Item struct {
SKU string
Name string
Qty int
}
// 作为 env 传入的结构体
type Env struct {
user User
order Order
}
func main() {
// 折扣判定规则:SVIP 无条件 85 折;VIP 满 1000 打 9 折;新用户不打折
code := `
user.Level == "SVIP" ? 0.85 :
user.Level == "VIP" && order.Amount > 1000 ? 0.9 :
user.IsNew ? 1.0 : 1.0
`
program, err := expr.Compile(code, expr.Env(Env{}), expr.AsFloat64())
if err != nil {
// 编译期就拦住类型错误,而不是运行时 panic
panic(err)
}
env := Env{
user: User{Level: "VIP", IsNew: false},
order: Order{Amount: 1500},
}
discount, err := expr.Run(program, env)
if err != nil {
panic(err)
}
fmt.Printf("折扣系数: %.2f\n", discount.(float64)) // 0.90
}
几个要点:
expr.Env(Env{})用空结构体值告诉编译器字段类型(编译器只看类型,不看值),运行时再传真实数据。expr.AsFloat64()显式声明「这个程序的结果应当是 float64」,编译器会据此检查三元表达式的每个分支是否都返回兼容类型。- 字段访问用
.,嵌套结构体一路点下去就行。user.Level在编译期就能确认User有Level字段且是string。
4.3 实战三:在表达式里调用自定义函数
光有字段不够,规则经常要算「派生量」。比如「购物车里有没有违禁品」「用户最近 N 天的订单数」。Expr 支持两种注入函数的方式。
方式一:把函数放进 env 的 map
env := map[string]any{
"user": User{ /* ... */ },
"hasAlcohol": func(items []Item) bool {
for _, it := range items {
if isAlcoholSKU(it.SKU) {
return true
}
}
return false
},
}
code := `user.Level == "VIP" && !hasAlcohol(order.Items)`
program, _ := expr.Compile(code, expr.Env(env))
方式二:在结构体上定义方法(更符合 Go 习惯,类型也更严格)
type Env struct {
user User
order Order
}
// 方法会自动成为表达式里可调用的函数
func (e Env) HasForbidden() bool {
for _, it := range e.order.Items {
if isAlcoholSKU(it.SKU) {
return true
}
}
return false
}
// 使用方法(注意调用写法就是普通函数调用)
func (e Env) DaysSince(ts int64) int {
return int((time.Now().Unix() - ts) / 86400)
}
func main() {
code := `user.Level == "VIP" && !HasForbidden() && DaysSince(user.RegTime) > 7`
program, err := expr.Compile(code, expr.Env(Env{}))
if err != nil {
panic(err)
}
// ...
}
用方法的好处是:函数签名被纳入了静态类型检查。如果你在表达式里传错参数个数,编译期就会报错,而不会在运行时反射失败。
4.4 实战四:数组与聚合——Expr 的隐藏大招
规则引擎最有价值的能力,往往是对集合做判断。Expr 内置了一组高阶函数:filter、map、all、any、one、none、len、sort、reduce 等。这让它足以表达绝大多数业务规则,而不必退化成「写代码」。
type Env struct {
user User
order Order // Items []Item
}
func main() {
// 规则:VIP 用户,且购物车里「所有」商品单价都低于 5000,且「至少一件」是电子产品
code := `
user.Level == "VIP"
&& all(order.Items, {.Price < 5000})
&& any(order.Items, {.Category == "electronics"})
`
program, err := expr.Compile(code, expr.Env(Env{}))
if err != nil {
panic(err)
}
env := Env{
user: User{Level: "VIP"},
order: Order{Items: []Item{
{Name: "键盘", Category: "electronics", Price: 399},
{Name: "书", Category: "book", Price: 59},
}},
}
ok, _ := expr.Run(program, env)
fmt.Println(ok.(bool)) // true
}
这里的 all(order.Items, {.Price < 5000}) 是 Expr 的闭包/lambda 语法:{ ... } 内部 . 代表集合的当前元素。这种「集合 + 谓词」的组合,正是把业务语言翻译成表达式语言的关键桥梁。
再举一个真实的风控例子——「同一设备 1 小时内下单超过 5 次则拦截」:
code := `
len(filter(user.RecentOrders, {.Ts > Now - 3600})) > 5
`
filter + len 就把「时间窗口内计数」这个看似要写循环的逻辑,用一行表达式干净地表达了。而由于 Expr 没有循环,这段表达式的执行复杂度是 O(N) 一次性的,不会失控。
4.5 实战五:把 Expr 做成「可热更新的规则引擎」
现在把上面所有的拼起来,做一个运营能在后台改、改完秒级生效、不需要发版的折扣规则引擎:
package rule
import (
"sync"
"github.com/expr-lang/expr"
)
// RuleEngine 持有编译后的规则程序,支持热替换
type RuleEngine struct {
mu sync.RWMutex
program expr.Program // 编译产物
rawCode string
}
// Compile 在「保存规则」时调用:编译期校验能拦住 99% 的错误配置
func NewRuleEngine(code string) (*RuleEngine, error) {
program, err := expr.Compile(code,
expr.Env(Env{}),
expr.AsFloat64(),
// 允许表达式引用 env 中未声明的变量(用于未来扩展,配合默认值)
expr.AllowUndefinedVariables(),
)
if err != nil {
return nil, fmt.Errorf("规则编译失败: %w", err)
}
return &RuleEngine{program: program, rawCode: code}, nil
}
// Evaluate 在「每次请求」时调用:只跑 VM,纳秒级开销
func (e *RuleEngine) Evaluate(env Env) (float64, error) {
e.mu.RLock()
program := e.program
e.mu.RUnlock()
out, err := expr.Run(program, env)
if err != nil {
return 1.0, err
}
return out.(float64), nil
}
// HotReload 运营改了规则后,编译新规则,校验通过再原子替换
func (e *RuleEngine) HotReload(newCode string) error {
newEngine, err := NewRuleEngine(newCode)
if err != nil {
return err // 编译失败:旧规则继续生效,绝不灰度进坏配置
}
e.mu.Lock()
e.program = newEngine.program
e.rawCode = newCode
e.mu.Unlock()
return nil
}
这个设计的精髓在于:编译(可能失败、需要校验)和热路径执行(必须快、不能 panic)被彻底分开了。运营在后台点「保存」,后端先 Compile 校验;校验通过才 HotReload 原子替换。哪怕新规则有语法错误,线上跑的还是旧规则,不会出现「一条坏配置把全站折扣打挂」的事故。
而且注意:编译产物 expr.Program 是并发安全的,Evaluate 只加读锁,多个请求可以并行跑同一个 program,没有锁竞争。这在高 QPS 服务里至关重要。
五、性能优化:为什么它能上请求热路径
「动态规则」最大的心智负担是性能:「每次请求都解析一段文本,不慢吗?」答案是:只要你编译一次、缓存 program,就不慢。我们拆开讲 Expr 的几条性能命门和对应的优化手段。
5.1 命门一:永远不要每次请求都 Compile
expr.Eval 内部是「编译 + 执行」,如果你在请求处理函数里直接 expr.Eval(code, env),那每次请求都要重新走一遍 Lexer→Parser→TypeCheck→Compiler。对于一行简单表达式这也要几十微秒,在百万 QPS 下就是灾难。
正解:把 code 字符串(来自配置中心/数据库)在加载和变更时 Compile 一次,把 program 缓存进内存,请求里只调 expr.Run(program, env)。Run 只是 VM 跑字节码,通常亚微秒到几微秒级别,和手写等价逻辑差距很小。
5.2 命门二:env 用结构体,别用 map[string]any
map[string]any 在类型检查阶段,编译器面对的是 any(即 interface{}),很多类型信息丢失,运行时要靠反射取值,慢且容易 panic。而传入具体结构体 expr.Env(Env{}),编译器在编译期就把字段偏移、类型都解析好了,VM 执行时直接按偏移取字段,接近原生 Go 字段访问速度,且全程零反射 panic 风险。
经验法则:env 优先用结构体(或结构体 + 少量必要的 map)。哪怕你的数据来源是 JSON,也先 json.Unmarshal 进结构体再传给 Expr,这步反序列化的成本会被后续省下的反射开销盖过。
5.3 命门三:关掉你不需要的优化开关(反过来也成立)
Expr 默认会做常量折叠等优化。如果你在极端性能敏感且表达式很短的场景,可以用 expr.Optimize(false) 跳过优化阶段,换来略快的编译。但一般情况下保持默认(开启优化),因为优化后的字节码运行时更快,而 Compile 是一次性的。
5.4 命门四:用 expr.AsXXX 固定返回类型,省掉类型断言
实战里我们经常 result.(float64) 做类型断言。如果你在 Compile 时声明了 expr.AsFloat64(),编译器会保证输出类型,运行期断言几乎不会失败。更进一步,Expr 提供了 expr.Output 之类的选项让你直接拿到强类型结果,避免 interface{} 装箱拆箱。
5.5 一个直观的性能对照
| 方案 | 每次请求开销量级 | 能否上热路径 | 安全风险 |
|---|---|---|---|
每次 expr.Eval | 数十 µs | 勉强(低 QPS) | 低 |
Compile 一次 + Run | 亚 µs ~ 几 µs | ✅ 完全可 | 低 |
自己写 map[string]any env | 同上但反射更重 | ✅ 但慢一截 | 中(易 panic) |
| Lua/wasm 沙箱 | 数十~数百 µs + 序列化 | ⚠️ 需评估 | 高(图灵完备) |
| 重发版改 Go 代码 | 0(编译期) | ✅ | 极低 |
结论很清楚:Expr 的正确姿势是「编译一次、结构体 env、Run 上热路径」,此时它的性能画像和「手写 Go 判断」在同一个数量级,而灵活性却是指数级提升。
5.6 防 DoS:没有循环就是最好的限流
再强调一遍 Expr 安全模型里最被低估的一点——语言不支持循环和递归,意味着任何表达式的执行步数都有编译期上界。你不需要像防 Lua 那样去给脚本套 CPU 时间片、指令数配额。一条再离谱的规则,最多也就是「算得慢一点」,绝不可能是「占满一个核 forever」。对于「配置由人填写」的系统,这比任何运行时配额都可靠。
六、总结与展望:当「逻辑」成为可流动的数据
回过头看,Expr 解决的本质问题不是「怎么求值一段字符串」,而是**「如何把业务逻辑从代码里解放出来,让它变成可存储、可流转、可被人编辑的数据」**。
它用三个设计决策把这件事做对了:
- 非图灵完备(表达式而非语言) → 天然免疫死循环 DoS;
- 白名单环境 + 静态类型 → 既安全(访问不到系统资源)又可靠(类型错误编译期现形);
- 编译/执行分离 + 字节码 VM → 一次编译多次高速执行,能上请求热路径。
在落地形态上,它最适合这些场景:
- 定价/折扣/营销规则引擎:运营自助配置,秒级热更新;
- 风控与准入策略:用
all/any/filter/len表达复杂条件,且不会因配置错误打挂服务; - 数据过滤与权限(row-level security):「当前用户能看哪些行」用表达式描述,存库即策略;
- 可观测性与告警阈值:Grafana 等工具内部就在用类似思路做表达式求值;
- 工作流/低代码平台的判定节点:非工程师也能写「当 X 且 Y 则执行 Z」。
当然它也有边界,得心里有数:
- 不适合复杂多步骤流程:没有循环和赋值,超过一定复杂度的逻辑还是写 Go 更清晰;
- 不是通用脚本语言:别指望用它替代 Lua 去写游戏逻辑或插件系统;
- 调试体验有限:表达式一旦很长,出错时的定位靠编译器报的位置,建议把复杂规则拆成多个命名子表达式、分字段校验。
展望一下:随着 AI Agent 和低代码平台的普及,「把逻辑当数据」的需求只会越来越强。我们已经看到 MCP、工作流引擎、策略中心都在往「声明式 + 表达式化」方向走。Expr 这类「小而安全、编译型、强类型」的表达式引擎,很可能成为未来 Go 后端架构里的一个标准件——就像 JSON 解析、HTTP 路由一样,默默躺在每个服务的依赖里,承载那些「今天生效、明天就变」的业务判定。
最后给一句工程师视角的建议:能提前进代码的判断,就别用表达式;但凡需要「不改代码就能改逻辑」的地方,Expr 是目前 Go 生态里把安全和性能平衡得最好的一档选择。 把它用在刀刃上——那些频繁变化、由非工程师拥有、却又跑在生产热路径上的判定逻辑——你会感谢自己当初没去手搓一个阉割版 SQL。
本文所有代码均基于 github.com/expr-lang/expr 的公开 API 与稳定行为撰写,可直接复制到 Go 1.21+ 工程中运行。建议结合官方文档的 expr.Env、expr.AllowUndefinedVariables、expr.AsFloat64、expr.Optimize 等选项进一步定制你的规则引擎。