基于AST的公式字符串配置方法

 

基于AST的公式字符串配置方法

概述

Formula 命名空间实现了一个轻量级公式引擎,支持四则运算、变量引用和内置函数调用。整体流程分为三个阶段:

表达式字符串
    ↓ FormulaTokenizer(词法分析)
Token 序列
    ↓ FormulaParser(语法分析 → AST)
抽象语法树(AST)
    ↓ FormulaValidator(语义校验)
已校验 AST
    ↓ FormulaCompiler 常量折叠(TryFoldAst)
折叠后 AST(CompiledFormula)
    ↓ FormulaEvaluator(运行时求值)
FormulaResult(double)

文件与类职责

FormulaToken.cs

FormulaTokenType(枚举)

定义所有词法单元类型:

含义
Number 数字字面量,如 3.14
Identifier 标识符,如 actor.atkmax
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)

主入口,逐字符扫描:

  1. 跳过空白字符
  2. 数字识别IsNumberStart 判断):调用 TryReadNumber,支持整数和小数(.53.14
  3. 标识符识别IsIdentifierStart 判断):首字符为字母或 _,后续可含字母、数字、_.(支持 actor.atk 这类点分标识符)
  4. 运算符和括号+ - * / ( ) , 各自生成对应 token
  5. 末尾追加 End 哨兵 token,供 Parser 判断结束

识别到不支持的字符时返回 SyntaxError


FormulaAst.cs

定义 AST 节点体系:

FormulaAst(容器类)

包裹整棵语法树的根节点 Root,是编译产物 CompiledFormula 的一部分。

FormulaAstNode(抽象基类)

所有节点的基类,持有 Position(在源表达式中的位置,用于调试)。

节点类型

描述 字段
FormulaNumberNode 数字常量叶子节点 Value: double
FormulaVariableNode 变量引用叶子节点 Name: string
FormulaUnaryNode 一元表达式节点(正/负号) OperatorOperand
FormulaBinaryNode 二元表达式节点(四则运算) OperatorLeftRight
FormulaFunctionCallNode 函数调用节点 FunctionNameArguments

枚举:

  • FormulaUnaryOperatorPlusMinus
  • FormulaBinaryOperatorAddSubtractMultiplyDivide

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

解析原子表达式:

  • 数字 tokenFormulaNumberNode
  • 标识符 token
    • 若下一个是 (:进入函数调用解析,循环读取参数(每个参数递归调用 TryParseExpression),遇到 , 继续,遇到 ) 结束,生成 FormulaFunctionCallNode
    • 否则生成 FormulaVariableNode
  • ( token:递归调用 TryParseExpression,期望闭合 ),实现括号分组

辅助方法:

  • Match(type):当前 token 匹配时消费并前进
  • Consume(type, msg):强制消费指定 token,不匹配则报错
  • Advance():移动读取指针
  • Current / Previous:访问当前/上一个 token

FormulaValidator.cs

FormulaValidator

职责:对 AST 进行语义校验(编译期),在折叠前执行。

校验规则:

  • FormulaVariableNode:若配置了变量白名单(allowedVariables),变量名必须在白名单中,否则报 UnknownVariable
  • FormulaFunctionCallNode:函数名必须在内置函数注册表中(FormulaBuiltInFunctions.TryGet),且参数数量必须在 [MinArgCount, MaxArgCount] 范围内
  • 其他节点递归校验子节点

FormulaCompiler.cs

CompiledFormula

编译产物载体,包含:

  • FormulaIdExpression(原始表达式文本)
  • 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 递归折叠操作数;若操作数折叠后是数字,立即计算(-3FormulaNumberNode(-3))并替换
FormulaBinaryNode 递归折叠左右子树;若两边都是数字,调用 TryEvaluateBinary 计算并替换为数字节点
FormulaFunctionCallNode 递归折叠所有参数;若所有参数都是数字,查内置函数表并立即调用,结果替换为数字节点

TryEvaluateBinary

安全执行常量二元运算,对除法检查除数是否接近零(< 1e-12),否则报 DivideByZero 编译错误。

ComputeSourceHash

用 SHA256 对表达式原文计算哈希,作为缓存键的一部分,防止同 ID 不同内容的公式命中旧缓存。


FormulaBuiltInFunctions.cs

FormulaFunctionDefinition

内置函数元数据:NameMinArgCountMaxArgCountEvaluate(委托)。

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

维护两张字典:

  • _cacheByKeyFormulaCacheKey → CompiledFormula
  • _latestKeyByIdint(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: boolValue: double?(成功时)、ErrorCode: ushort(失败时)。

FormulaErrorCodes.cs

集中定义编译期错误码(string常量)和运行时错误码(ushort 常量)。

FormulaCompileError.cs

编译错误模型:FormulaIdErrorCodeMessagePosition?(错误在表达式中的位置)。


完整数据流

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 字符串、MessagePosition
  • 运行时错误:返回 FormulaResult { Success=false, ErrorCode: ushort }

这样可以在不抛异常的前提下精确定位错误位置和类型,适合游戏运行时环境。