编程 Expr 深度拆解:Go 表达式引擎如何在不 eval 的前提下把动态规则写进生产——从词法分析到字节码 VM 全链路实战

2026-08-18 05:12:23 +0800 CST views 12

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 代码片段,用 plugingo: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 不会自动把 osnetioexec 之类的标准库塞进作用域。

换句话说,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),类型检查器会:

  1. 从 env 的类型(结构体或 map 的 value 类型)出发,建立「标识符 → 类型」的映射;
  2. 自底向上遍历 AST,为每个节点推导类型;
  3. 校验运算符两侧类型是否合法(比如 == 两边是否可比、+ 两边是否同数值类型或同字符串);
  4. 校验方法/函数调用时,参数个数和类型是否匹配;
  5. ?. 这种「安全导航」运算符,正确处理可能为 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 也能让编译器在编译期校验 greetname 是否存在、能否拼接。

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 在编译期就能确认 UserLevel 字段且是 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 内置了一组高阶函数:filtermapallanyonenonelensortreduce 等。这让它足以表达绝大多数业务规则,而不必退化成「写代码」。

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 解决的本质问题不是「怎么求值一段字符串」,而是**「如何把业务逻辑从代码里解放出来,让它变成可存储、可流转、可被人编辑的数据」**。

它用三个设计决策把这件事做对了:

  1. 非图灵完备(表达式而非语言) → 天然免疫死循环 DoS;
  2. 白名单环境 + 静态类型 → 既安全(访问不到系统资源)又可靠(类型错误编译期现形);
  3. 编译/执行分离 + 字节码 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.Envexpr.AllowUndefinedVariablesexpr.AsFloat64expr.Optimize 等选项进一步定制你的规则引擎。

推荐文章

给Go程序加个沙箱:go-landlock
2026-07-03 06:32:08 +0800 CST
跟着 IP 地址,我能找到你家不?
2024-11-18 12:12:54 +0800 CST
批量导入scv数据库
2024-11-17 05:07:51 +0800 CST
MCP 测试文章 16250
2026-08-13 06:21:47 +0800 CST
程序员茄子在线接单