基于AST的公式字符串配置方法
概述
Formula 命名空间实现了一个轻量级公式引擎,支持四则运算、变量引用和内置函数调用。整体流程分为三个阶段:
表达式字符串
↓ FormulaTokenizer(词法分析)
Token 序列
↓ FormulaParser(语法分析 → AST)
抽象语法树(AST)
↓ FormulaValidator(语义校验)
已校验 AST
↓ FormulaCompiler 常量折叠(TryFoldAst)
折叠后 AST(CompiledFormula)
↓ FormulaEvaluator(运行时求值)
FormulaResult(double)
文件与类职责
FormulaToken.cs
FormulaTokenType(枚举)
定义所有词法单元类型:
| 值 | 含义 |
|---|---|
Number |
数字字面量,如 3.14 |
Identifier |
标识符,如 actor.atk、max |
Plus / Minus |
+ / - |
Star / Slash |
* / / |
LeftParen / RightParen |
( / ) |
Comma |
, |
End |
流结束标记(哨兵) |
FormulaToken(结构体)
每个 token 携带:
Type:词法类型Text:原始文本NumberValue:数字字面量的double值(非数字 token 忽略)Position:在原始表达式中的字符起始索引(用于错误定位)
工厂方法:ForSymbol()、ForNumber()、ForIdentifier()
FormulaTokenizer.cs
FormulaTokenizer(静态类)
职责:将表达式字符串切分为 Token 序列(词法分析)。
TryTokenize(source, formulaId, out tokens, out error)
主入口,逐字符扫描:
- 跳过空白字符
- 数字识别(
IsNumberStart判断):调用TryReadNumber,支持整数和小数(.5、3.14) - 标识符识别(
IsIdentifierStart判断):首字符为字母或_,后续可含字母、数字、_、.(支持actor.atk这类点分标识符) - 运算符和括号:
+-*/(),各自生成对应 token - 末尾追加
End哨兵 token,供 Parser 判断结束
识别到不支持的字符时返回 SyntaxError。
FormulaAst.cs
定义 AST 节点体系:
FormulaAst(容器类)
包裹整棵语法树的根节点 Root,是编译产物 CompiledFormula 的一部分。
FormulaAstNode(抽象基类)
所有节点的基类,持有 Position(在源表达式中的位置,用于调试)。
节点类型
| 类 | 描述 | 字段 |
|---|---|---|
FormulaNumberNode |
数字常量叶子节点 | Value: double |
FormulaVariableNode |
变量引用叶子节点 | Name: string |
FormulaUnaryNode |
一元表达式节点(正/负号) | Operator、Operand |
FormulaBinaryNode |
二元表达式节点(四则运算) | Operator、Left、Right |
FormulaFunctionCallNode |
函数调用节点 | FunctionName、Arguments |
枚举:
FormulaUnaryOperator:Plus、MinusFormulaBinaryOperator:Add、Subtract、Multiply、Divide
FormulaParser.cs
FormulaParser(递归下降解析器)
职责:将 Token 序列解析为 AST(语法分析)。
使用经典的递归下降结构,优先级从低到高依次为:
Expression(加减,最低优先级)
└── Term(乘除)
└── Factor(一元正负,最高优先级)
└── Primary(原子:数字 / 变量 / 函数调用 / 括号表达式)
TryParse(tokens, formulaId, out ast, out error)
静态入口,构建 FormulaParser 实例后调用 TryParseExpression,最后断言所有 token 已消费(否则报”意外 token”错误)。
TryParseExpression
处理加减法:
expression → term (('+' | '-') term)*
循环匹配 + / -,每次匹配后将当前节点与右侧 term 合并为一个 FormulaBinaryNode(左结合)。
TryParseTerm
处理乘除法:
term → factor (('*' | '/') factor)*
与 TryParseExpression 结构相同,但匹配 * / /,优先级更高。
TryParseFactor
处理一元正负号:
factor → ('+' | '-') factor | primary
递归调用自身(支持 --x 这类多重符号),生成 FormulaUnaryNode。若无符号,直接委托给 TryParsePrimary。
TryParsePrimary
解析原子表达式:
- 数字 token →
FormulaNumberNode - 标识符 token:
- 若下一个是
(:进入函数调用解析,循环读取参数(每个参数递归调用TryParseExpression),遇到,继续,遇到)结束,生成FormulaFunctionCallNode - 否则生成
FormulaVariableNode
- 若下一个是
(token:递归调用TryParseExpression,期望闭合),实现括号分组
辅助方法:
Match(type):当前 token 匹配时消费并前进Consume(type, msg):强制消费指定 token,不匹配则报错Advance():移动读取指针Current/Previous:访问当前/上一个 token
FormulaValidator.cs
FormulaValidator
职责:对 AST 进行语义校验(编译期),在折叠前执行。
校验规则:
FormulaVariableNode:若配置了变量白名单(allowedVariables),变量名必须在白名单中,否则报UnknownVariableFormulaFunctionCallNode:函数名必须在内置函数注册表中(FormulaBuiltInFunctions.TryGet),且参数数量必须在[MinArgCount, MaxArgCount]范围内- 其他节点递归校验子节点
FormulaCompiler.cs
CompiledFormula
编译产物载体,包含:
FormulaId、Expression(原始表达式文本)SourceHash(SHA256,用于缓存键)Version(版本号,用于缓存键)Ast(折叠后的 AST)
FormulaCompiler
职责:串联词法分析 → 语法分析 → 语义校验 → 常量折叠,产出 CompiledFormula。
TryCompile(definition, version, out compiled, out error)
完整编译流程:
TryTokenize → TryParse → TryValidate → TryFoldAst → CompiledFormula
TryFoldAst / TryFoldNode(常量折叠)
递归遍历 AST,对能在编译期确定的子树提前求值并替换为 FormulaNumberNode:
| 节点类型 | 折叠策略 |
|---|---|
FormulaNumberNode / FormulaVariableNode |
直接返回,不折叠 |
FormulaUnaryNode |
递归折叠操作数;若操作数折叠后是数字,立即计算(-3 → FormulaNumberNode(-3))并替换 |
FormulaBinaryNode |
递归折叠左右子树;若两边都是数字,调用 TryEvaluateBinary 计算并替换为数字节点 |
FormulaFunctionCallNode |
递归折叠所有参数;若所有参数都是数字,查内置函数表并立即调用,结果替换为数字节点 |
TryEvaluateBinary
安全执行常量二元运算,对除法检查除数是否接近零(< 1e-12),否则报 DivideByZero 编译错误。
ComputeSourceHash
用 SHA256 对表达式原文计算哈希,作为缓存键的一部分,防止同 ID 不同内容的公式命中旧缓存。
FormulaBuiltInFunctions.cs
FormulaFunctionDefinition
内置函数元数据:Name、MinArgCount、MaxArgCount、Evaluate(委托)。
FormulaBuiltInFunctions(静态类,内置函数白名单)
注册的内置函数:
| 函数名 | 参数数量 | 行为 |
|---|---|---|
abs |
1 | Math.Abs(x) |
clamp |
3 | clamp(value, min, max) |
min |
2~∞ | args.Min() |
max |
2~∞ | args.Max() |
函数名匹配大小写不敏感(StringComparer.OrdinalIgnoreCase)。
FormulaEvaluator.cs
FormulaEvaluator
职责:运行时对折叠后的 AST 求值,代入变量实际值。
Evaluate(formula, variables)
接收编译好的 CompiledFormula 和变量字典(IReadOnlyDictionary<string, double>),调用 TryEvaluateNode 递归求值,返回 FormulaResult。
TryEvaluateNode
| 节点类型 | 行为 |
|---|---|
FormulaNumberNode |
直接返回 Value |
FormulaVariableNode |
从 variables 字典查找,未找到报 RuntimeUnknownVariable |
FormulaUnaryNode |
递归求操作数,按符号取正/负 |
FormulaBinaryNode |
递归求左右,按运算符计算;除法检查零除 |
FormulaFunctionCallNode |
查函数表,检查参数数量,递归求所有参数,调用函数委托 |
FormulaCache.cs
FormulaCacheKey(结构体)
缓存键由 (FormulaId, SourceHash, Version) 三元组构成,实现 IEquatable。
FormulaCache
维护两张字典:
_cacheByKey:FormulaCacheKey → CompiledFormula_latestKeyById:int(formulaId)→ FormulaCacheKey(快速按 ID 查最新版本)
Set:写入或覆盖(先删旧 key)。TryGetById:按 ID 取最新编译结果。RemoveById:按 ID 删除。
FormulaEngine.cs
FormulaEngine(门面类,实现 IFormulaEngine)
职责:对外暴露的唯一入口,组合编译器、缓存、求值器。
CompileAll(definitions):批量编译所有公式定义,Enabled=false的从缓存移除,成功的存入缓存,失败的收集错误列表返回。Evaluate(formulaId, variables):按 ID 从缓存取编译结果,调用求值器执行。
FormulaDefinition.cs
公式配置数据载体:Id(唯一 I D,对应表配置 ID)、Expression(表达式文本)、Enabled(是否启用)。
FormulaResult.cs
求值结果:Success: bool、Value: double?(成功时)、ErrorCode: ushort(失败时)。
FormulaErrorCodes.cs
集中定义编译期错误码(string常量)和运行时错误码(ushort 常量)。
FormulaCompileError.cs
编译错误模型:FormulaId、ErrorCode、Message、Position?(错误在表达式中的位置)。
完整数据流
FormulaDefinition { Id=1, Expression="max(actor.atk, 10) * 1.5 + base" }
│
▼ FormulaTokenizer.TryTokenize
[Identifier:"max", LeftParen, Identifier:"actor.atk", Comma,
Number:10, RightParen, Star, Number:1.5, Plus, Identifier:"base", End]
│
▼ FormulaParser.TryParse
BinaryNode(Add)
├── BinaryNode(Multiply)
│ ├── FunctionCallNode("max", [VariableNode("actor.atk"), NumberNode(10)])
│ └── NumberNode(1.5)
└── VariableNode("base")
│
▼ FormulaValidator.TryValidate
(校验 max 在函数白名单中,actor.atk/base 在变量白名单中)
│
▼ FormulaCompiler.TryFoldAst
BinaryNode(Add) ← 右边 base 是变量,不能折叠,整体不折叠
├── BinaryNode(Multiply) ← 左边:max 的参数含变量,不能折叠
│ ├── FunctionCallNode("max", [VariableNode("actor.atk"), NumberNode(10)])
│ └── NumberNode(1.5)
└── VariableNode("base")
│
▼ FormulaCache.Set(以 formulaId=1 存入)
│
运行时调用 FormulaEngine.Evaluate(1, {"actor.atk":50, "base":20})
│
▼ FormulaEvaluator.TryEvaluateNode
max(50, 10) = 50
50 * 1.5 = 75
75 + 20 = 95
│
▼ FormulaResult { Success=true, Value=95 }
常量折叠示例
示例 1:纯常量表达式
输入:"2 * 3 + max(1, 4)"
折叠过程:
BinaryNode(Add)
├── BinaryNode(Multiply)
│ ├── NumberNode(2)
│ └── NumberNode(3)
└── FunctionCallNode("max", [NumberNode(1), NumberNode(4)])
↓ 折叠 BinaryNode(Multiply): 2*3=6 → NumberNode(6)
↓ 折叠 FunctionCallNode: max(1,4)=4 → NumberNode(4)
↓ 折叠 BinaryNode(Add): 6+4=10 → NumberNode(10)
最终 AST:NumberNode(10)
运行时求值只需直接返回 10,无需任何计算。
示例 2:含变量的混合表达式
输入:"-1 * actor.hp"
折叠过程:
BinaryNode(Multiply)
├── UnaryNode(Minus, NumberNode(1))
└── VariableNode("actor.hp")
↓ 折叠 UnaryNode(Minus, 1): -1 → NumberNode(-1)
↓ 右边是变量,不能折叠
最终 AST:BinaryNode(Multiply, NumberNode(-1), VariableNode("actor.hp"))
运行时只需执行一次乘法,避免了每次都要计算 -1。
示例 3:函数调用含变量参数
输入:"clamp(actor.atk, 0, 100)"
折叠过程:
FunctionCallNode("clamp", [VariableNode("actor.atk"), NumberNode(0), NumberNode(100)])
↓ 参数中有变量(actor.atk),allConstant=false
↓ 不能整体折叠,保留原节点(参数中的 NumberNode 已是最简)
最终 AST:FunctionCallNode("clamp", [VariableNode("actor.atk"), NumberNode(0), NumberNode(100)])
示例 4:一元符号叠加
输入:"--5"
解析过程(Parser):
TryParseFactor → 匹配 '-'
TryParseFactor → 匹配 '-'
TryParsePrimary → NumberNode(5)
→ UnaryNode(Minus, NumberNode(5))
→ UnaryNode(Minus, UnaryNode(Minus, NumberNode(5)))
折叠过程:
折叠内层 UnaryNode(Minus, 5): -5 → NumberNode(-5)
折叠外层 UnaryNode(Minus, -5): -(-5)=5 → NumberNode(5)
最终 AST:NumberNode(5)
错误处理设计
整个流程采用 TryXxx(out result, out error) 模式,不使用异常:
- 编译期错误:返回
FormulaCompileError(含ErrorCode字符串、Message、Position) - 运行时错误:返回
FormulaResult { Success=false, ErrorCode: ushort }
这样可以在不抛异常的前提下精确定位错误位置和类型,适合游戏运行时环境。