应手写递归下降解析器,构建AST节点(BinaryExpr、CallExpr等)并实现Eval接口,通过白名单函数表和变量表沙箱执行,缓存Compiled对象避免重复解析,全程记录token位置以支持精准错误定位。

如何用 Go 解析并安全执行公式字符串
不能直接 eval,Go 没有内置 eval;也不能靠正则硬拆——括号嵌套、运算符优先级、函数调用(如 sin(2+3))会让正则迅速失控。必须走 AST 路线:先词法分析(lexer),再语法分析(parser),最后遍历求值(evaluator)。
核心工具链选 goyacc 或手写递归下降 parser。对多数业务公式(支持四则、括号、常见数学函数、变量代入),推荐手写——更可控、调试方便、无外部依赖。
- 词法阶段用
bufio.Scanner或strings.FieldsFunc拆出 token,但要注意12.5、-sin、pi这类边界;建议用状态机或现成的go/ast不适用(它是为 Go 代码设计的,不支持自定义语法) - parser 必须处理左递归(如
a + b + c),避免栈溢出;加减和乘除要分层处理,否则1+2*3会算错 - AST 节点类型至少包括:
BinaryExpr、UnaryExpr、NumberLit、Ident(变量)、CallExpr(函数调用)
变量和函数怎么注入且不破坏沙箱
公式里出现的 x、y 或 sqrt、log,不能从全局作用域读取——那等于开了任意代码执行后门。必须显式传入一个 map[string]interface{} 变量表 + 一个 map[string]func([]float64) float64 函数表。
关键约束:函数参数强制转 []float64,返回值只接受 float64;变量查不到就报 undefined identifier: "z",不 fallback 到环境变量或 global。
立即学习“go语言免费学习笔记(深入)”;
- 函数名校验必须白名单制,比如只允许
["abs", "sin", "cos", "log", "pow"],禁止os/exec相关符号 - 变量名限制为 ASCII 字母+数字+下划线,且不能以数字开头,防止
1abc被误判为数字字面量 - 所有浮点运算使用
math包,避免NaN/Inf传播失控;可在 evaluator 中统一检查math.IsNaN并提前返回错误
Parse("2 * (x + sin(pi/2))") 返回什么结构才好遍历
不要返回裸指针或嵌套过深的 struct。推荐定义清晰的接口:
type Expr interface {
Eval(vars map[string]float64, funcs map[string]func(...float64) float64) (float64, error)
}
type BinaryExpr struct {
Op token.Token // token.ADD, token.MUL 等
X, Y Expr
}
type CallExpr struct {
Name string
Args []Expr
}这样每个节点自己知道怎么算,不用在 evaluator 里写一堆 switch reflect.TypeOf(e) ——既慢又难测。递归调用 e.Eval() 即可,天然支持任意嵌套。
-
token.Token建议用 iota 枚举,别用字符串比较,避免拼写错误 -
CallExpr的Args是[]Expr而非[]float64,保证延迟求值(比如if(x>0, sqrt(x), 0)中sqrt(x)不会被无条件执行) - 数字字面量统一存为
float64,整数也转成float64,省去类型判断分支
性能瓶颈在哪?怎么让千次计算不卡住
90% 的开销不在计算,而在重复解析:每次 Evaluate("x+1", env) 都重新 lexer → parser → build AST,浪费严重。必须缓存 AST。
方案是把公式字符串 → AST 编译成可复用对象:
type Compiled struct {
ast Expr
}
func Compile(src string) (*Compiled, error) { ... }
func (c *Compiled) Eval(vars map[string]float64) (float64, error) { return c.ast.Eval(vars, builtins) }- 缓存用
sync.Map存src → *Compiled,注意 key 是原始字符串,不是 trim 后的——"x +1"和"x+1"语义相同,但 AST 可能不同,建议标准化(空格归一化)再存 - 避免在
Eval中做任何内存分配:复用varsmap,函数调用栈深度限制(防sin(sin(sin(...)))无限递归),可用runtime.NumGoroutine()监控异常增长 - 如果公式含时间相关函数(如
now()),就不能完全缓存 AST,得把“动态部分”抽出来作为func() float64注入
真正难的是错误定位——ParseError 要带行号列号,而手写 lexer 很容易丢位置信息。从 lexer 开始每个 token 就得记录 Pos,哪怕只是个 int 偏移量,否则用户面对 syntax error near 'sin(' 根本没法改。


















