去年接手一个工单系统的规则配置模块时,我被一个看起来特别朴素的需求卡了整整三天:现场实施人员希望自己在后台页面上写下发判定条件,比如“客户等级大于 3 且逾期天数超过 15 就自动升级工单”,改完保存立刻生效,不用等我们发版。说白了就是要给系统装一个C# 脚本引擎,把硬编码的 if-else 挪到配置里去。技术选型的时候我圈定了两个候选:Flee和AScript。前者在 .NET 圈子里资历老、讨论多,后者是一个走完整语言路线的轻量脚本引擎。两个都试完之后我的结论有点反直觉——它们根本不是同一个东西的两个版本,而是两个“物种”,硬拿来对比就像拿计算器和 Excel 比谁更适合算账。下面把我这两周折腾出来的东西摊开讲:两者的能力边界在哪、接进 C# 项目的手感差多少、性能差几个数量级、用户能写脚本之后安全上要堵哪些口子,以及最后我为什么选了一个“两个都用”的混合方案。
1. 别急着比 API:先看清 Flee 和 AScript 属于两个物种
选型最容易犯的错,就是把两个库拉到一起逐项打勾,谁勾多选谁。我第一轮就是这么干的,结果对比表做出来两边都是半对半错,越看越糊涂。后来想明白一件事:Flee 是一个表达式求值器,AScript 是一个脚本语言运行时,它们解决的是两类问题,只不过在“让用户配置逻辑”这个场景下产生了重叠。
1.1 Flee 的本质是一个表达式求值器
Flee 的全称是 Fast Lightweight Expression Evaluator,名字里那个 “Expression” 就是它的天花板和地板。你给它一个字符串,它解析成表达式树,再编译成一个委托,调用时把上下文里的变量塞进去算出一个值。它的输入必须是一个表达式——有返回值、没有过程、没有语句块。
这意味着 Flee 能做的事情非常聚焦:算数、比较、逻辑运算、三目运算符、调用函数、访问对象成员。它的输出永远是一个值——布尔、数字、字符串、日期都行。但你不能在里面“先干这个再干那个”,因为表达式语言里压根没有“再”这个概念。我第一次写规则的时候下意识写了三行:
var bonus = amount * 0.1 if (level > 3) bonus = bonus * 2 return bonusFlee 直接给我抛了语法错误。这不是 bug,是我用错了工具。
Flee 的价值恰恰来自它的“小”:语法面窄,解析和编译的路径就短,编译后的表达式执行起来极快;没有语句,就没有循环和递归,天然不会写出死循环;变量必须由宿主预先放进上下文,脚本拿不到任何你没给它的东西。在一个“规则表达式”的场景里,这三点全是优点。
1.2 AScript 走的是完整语言实现路线
AScript 的定位完全不同。它自己实现了词法分析、语法分析、AST 解释执行这一整条链路,是一门可以被嵌入到 .NET 应用里的脚本语言。变量声明、赋值、if/else、while、for、函数定义、甚至类与对象,这些都是它语法的一部分。
用一个更直白的说法:Flee 是“表达式”,AScript 是“程序”。你在 AScript 里可以写多行语句、可以中途改变量、可以循环遍历一个集合、可以把一段逻辑抽成函数反复调用。它更像是一个缩小版的、可以直接跟 C# 对象打交道的脚本环境。
代价也同步来了。语言能力越强,宿主需要操心的事情越多:脚本可以卡住不返回、可以无限递归、可以申请大量内存、可以调用你暴露出去的每一个方法。这些在 Flee 那儿因为语法本身不支持,根本不需要考虑;在 AScript 这儿必须由宿主一层层兜住。
1.3 判断标准:你的规则里有没有“过程”
试完两个之后我总结出一个特别简单的判断标准,两分钟就能定方向:看你的需求里有没有“过程”。
只有“过程”才需要语句。判断条件本身、字段换算、阈值的动态计算、状态码映射,这些都是“求一个值”,属于表达式范畴,Flee 就是为此而生的。而一旦出现下面这些描述,就是“过程”信号,必须上 AScript:
- “先查一下这个工单的历史记录,如果同类型超过 3 条就……”
- “遍历明细行,把金额加起来,超过阈值就……”
- “这一步做完之后,把中间结果写到另一个字段,然后继续判断……”
我当时的规则配置里,80% 是纯条件判断,20% 是带循环的复杂规则。这个比例直接决定了后面的架构选择——不是二选一,而是分层。
2. 语法能力逐项对照:条件、循环、函数与对象访问
概念说完了,落地还是得看具体能写什么。我把两边在真实需求里会碰到的语法点拉了一张对照表,这张表基本决定了我后面的架构。
2.1 运算符与内置类型支持
Flee 的运算符覆盖面比我预想的全:算术+ - * / %、幂运算、比较、逻辑and/or/not与&&/||/!两套写法都认、位运算& | ^ << >>、三目? :,字符串拼接也是+。集合的索引访问list[0]、字典取值这些也能写。日常的规则判断基本够用,不需要为它做任何语法上的妥协。
类型上 Flee 是走隐式转换路线的。你在上下文里放进去一个decimal,表达式里跟整数2混着算,它会自己往上提升。这里有个我踩过的坑:decimal和double混用的时候,结果类型容易被提升成double,而金额字段一旦变成double,后面就容易出现0.1 + 0.2 != 0.3这类精度问题。我的做法是规则里涉及金额的部分全部统一成 decimal,上下文注入的时候就把类型摆正,别指望求值器帮你兜。
AScript 这边是动态类型,变量声明即赋值,数字走自己的数值模型。它的运算符集跟 C# 高度接近,写惯了 C# 的人基本可以零成本上手。但要注意它毕竟是解释执行,整数除法和浮点除法的行为、字符串与数字相加时的隐式转换规则,最好在项目里先写几条测试用例钉死,避免版本升级时行为漂移。
2.2 流程控制:分水岭就在这里
这是两者差距最大的一块,也是我最终没有二选一的根本原因。
Flee 里没有if语句、没有while、没有for、没有变量声明和赋值。你只能用三目运算符做条件分支,而且嵌套两层就已经读不动了。想在 Flee 里表达“循环”,唯一的办法是在宿主 C# 里写一个函数注册进去,让表达式调用它——本质上你只是把循环搬回了 C#。
AScript 这边是完整的语句集合:if/else if/else、while、for、break、continue,函数定义,以及块级作用域。下面这段在 AScript 里是能跑的(结构示意,具体关键字以你实际用的版本为准):
function calcScore(orders) { total = 0; i = 0; while (i < orders.Count) { if (orders[i].Amount > 1000) { total = total + orders[i].Amount * 1.2; } else { total = total + orders[i].Amount; } i = i + 1; } return total; }同样一段逻辑放到 Flee 里,你只能写成CalcScore(orders),而CalcScore的实现是 C# 代码。这就产生了一个很实际的后果:Flee 场景下,逻辑的“可配置性”是有上限的,循环结构、遍历顺序、累加方式这些全部固化成代码,实施人员改不了。如果你能接受这个上限,Flee 没问题;如果你承诺给用户“随便写逻辑”,那就得选 AScript 这条路。
2.3 自定义函数与宿主类型导入
两者都支持扩展,但扩展方式反映了各自的思路。
Flee 扩展一个函数,需要实现它的函数接口,然后注册到上下文的函数集合里:
public class ClampFunction : IFunction { public string Name => "Clamp"; public object Invoke(ExpressionContext context, FunctionInfo functionInfo, object[] parameters) { double value = Convert.ToDouble(parameters[0]); double min = Convert.ToDouble(parameters[1]); double max = Convert.ToDouble(parameters[2]); return Math.Min(Math.Max(value, min), max); } } context.Functions.Add(new ClampFunction()); // 之后表达式里就可以直接写 Clamp(score, 0, 100)除了自定义函数,Flee 还支持把整个类型的静态成员“导入”进来,省得每次都写全名:
context.Imports.AddType(typeof(Math)); // 导完之后表达式里可以写 Abs(x)、Max(a, b)、Round(v, 2)这个特性在做公式计算时特别好用。我见过有人把整个业务工具类的静态方法导入进上下文,规则里就能直接写GetCustomerLevel(id),配置页面瞬间变得很清爽。但要克制——导入的每一个方法都是用户脚本能调用的入口,导之前先问自己一句:这个方法被恶意参数调用会不会出事。
AScript 那边因为支持函数定义,用户可以在脚本内部自己写函数复用,宿主只需要把对象和方法暴露出去。它的桥接粒度是对象级的:你把一个 C# 实例挂进脚本环境,脚本里就能直接点出它的属性和方法。这比 Flee 逐项导入灵活,但也意味着你得为“暴露面”负更大责任——暴露一个对象,等于暴露了它所有的公开成员。
3. 接入 C# 项目的真实手感:上下文绑定、缓存与热更新
语法只是纸面能力,真正决定开发体验的是接入过程。我在这块花的时间,比读文档多得多。
3.1 Flee 的 ExpressionContext 到底装了什么
Flee 的接入模型非常清晰:ExpressionContext是一个容器,你把变量和函数塞进去,然后拿着它去编译表达式。核心操作就三步——建上下文、填变量、编译执行:
var context = new ExpressionContext(); context.Variables["amount"] = 1280m; context.Variables["level"] = 4; context.Variables["overdueDays"] = 20; context.Imports.AddType(typeof(Math)); IDynamicExpression expr = context.CompileDynamic("amount > 1000 && overdueDays > 15"); bool hit = (bool)expr.Evaluate();如果追求更快的执行,可以用泛型版本直接把表达式编译成强类型委托:
var typed = new Expression<Func<decimal, int, bool>>("amount > 1000 && level >= 3", context); bool result = typed.Invoke(1280m, 4);泛型版本的好处是类型在编译期就确定了,调用时没有装箱拆箱,也没有object转换的开销,用作高频规则判断非常合适。
这里有个必须注意的点:ExpressionContext里的变量是共享状态。如果你把同一个 context 交给多个线程同时改Variables再Evaluate,等着你的就是数据错乱。正确姿势是“一个 context 一个执行单元”,或者干脆用泛型版本把参数从外部传进去,绕开共享变量。
3.2 AScript 的宿主桥接方式
AScript 的接入流程和 Flee 不同,它的粒度更大:初始化引擎、把宿主对象挂进去、把脚本解析成可复用的执行单元、每次执行时传入上下文、拿回结果。结构上大致是这样(下面的类名是结构示意,以你实际拉到的包为准):
// 初始化引擎 var engine = new ScriptEngine(); // 把宿主能力挂进去,脚本里就能直接访问 engine.SetGlobal("repo", orderRepository); engine.SetGlobal("logger", logger); // 解析一次,拿到可复用的执行单元 var unit = engine.Compile(scriptText); // 每次执行传入本次的上下文 var ctx = new ScriptContext(); ctx.Set("orderId", 10086); object result = unit.Run(ctx);和 Flee 最大的手感差异是:AScript 多了一层“编译单元”,这层抽象必须用起来。每次执行都重新解析一遍脚本字符串,是新手最常犯的性能错误,我在压测阶段亲眼看着 QPS 从四位数掉到两位数就是栽在这个点上。
另一个差异是错误处理。Flee 的异常里基本能拿到出错位置信息,做配置页面的时候可以直接把位置映射成编辑器里的红线标注,用户改起来很直观。AScript 因为语法更复杂,报错信息通常更丰富,但也更需要你在页面侧做好分类展示——用户写错个括号,报错动辄几行,不处理直接弹给用户看是很糟糕的体验。
3.3 热更新时最容易翻车的地方
规则引擎的核心诉求是“改完立刻生效”,所以热更新是绕不开的。两边都有坑,但坑的形状不一样。
Flee 这边的坑是编译缓存的失效粒度。很多人为了性能把表达式编译结果缓存在一个字典里,key 用规则 ID。改规则的时候只更新了数据库里的文本,忘了清缓存,结果现场改了半小时发现没生效。我的做法是把规则文本一起纳入缓存 key 的计算,文本变了 key 就变,天然失效,不用手动清理。
AScript 这边的坑是运行中替换脚本单元的时序。unit.Run()正在执行的时候,你把全局对象repo换成了一个新实例,正在跑的那一轮可能一半用旧对象一半用新对象。严格的做法是引用计数——替换时先标记旧单元退役,等正在执行的调用全部返回再释放。如果你的规则执行时间很短(毫秒级),退一步的简化方案是“串行替换加短暂等待”,但心里要清楚这不是严格安全的。
4. 性能别只看单次跑分:解析成本、编译缓存与并发
性能这块我花了整整两天做测试,结论比想象中有意思:单次执行的差距没有想象中大,但解析/编译成本的差距非常显著,而真正的性能分水岭在缓存策略上。
4.1 一次解析、多次执行才是正确姿势
表达式和脚本的解析都是相对昂贵的操作。Flee 要把它编译成表达式树甚至 IL,AScript 要走词法加语法分析生成 AST。这些成本如果摊在每一次执行上,性能会难看得离谱。
正确姿势是分两段:解析阶段在规则保存时触发,把结果缓存起来,解析失败就直接拒绝保存,顺带把语法错误提前暴露给用户;执行阶段只拿缓存好的对象来跑,不碰字符串。
我实测过一个很典型的场景:一条中等复杂度的规则,同一台开发机、Release 配置、.NET 6 环境。首次解析加执行是毫秒级的开销,而缓存之后的执行是微秒级。两者差了三个数量级。这也解释了为什么很多人第一次试脚本引擎会觉得“这东西怎么这么慢”——他们大概率写了这样的代码:
// 反例:每次执行都重新解析 foreach (var order in orders) { var ctx = new ExpressionContext(); ctx.Variables["amount"] = order.Amount; bool hit = (bool)ctx.CompileDynamic(ruleText).Evaluate(); }循环一万次就是一万次解析,慢是必然的。
4.2 量级参考与影响因子
下面这张表是我在自己机器上测出来的量级感受,不是严谨跑分,只能用来判断数量级差异,别拿去当性能报告:
| 场景 | Flee(量级) | AScript(量级) |
|---|---|---|
| 首次解析 + 单次执行(简单算术) | 毫秒级 | 毫秒级 |
| 已缓存对象执行(简单算术) | 亚微秒到微秒级 | 微秒到十微秒级 |
| 已缓存对象执行(含成员访问、字符串处理) | 微秒级 | 十微秒级 |
| 含循环的复杂逻辑 | 不适合,需宿主函数 | 数十到数百微秒级 |
| 纯手写 C# 等价逻辑 | 纳秒到亚微秒级 | 同左 |
结论有几条。第一,纯表达式热路径上 Flee 明显占优,因为它的执行模型更接近“算一个值”,中间没有解释器的调度开销。第二,AScript 的绝对速度不快,但对规则配置这种“每次请求跑几条到几十条”的场景完全够用——你一次请求里跑 50 条规则,总耗时也就毫秒级。第三,无论选哪个,手写 C# 永远快一到三个数量级,脚本引擎买的是灵活性,不是性能。
还有个容易被忽略的影响因子:上下文注入本身的开销。往上下文里塞一个复杂对象,如果引擎在注入时做了深拷贝或者属性反射扫描,代价可能比表达式执行本身还高。所以我后来改成注入轻量参数——只放基本类型和 ID,复杂数据在脚本里通过宿主方法按需取。这个改动让单次执行耗时稳稳降了一截。
4.3 线程安全与上下文复用的边界
并发场景下两边的表现也不同。
Flee 的泛型表达式编译成委托之后是可以跨线程复用的,因为它本身没有状态,参数从外部传。这是它在大并发下最大的优势。但带Variables的ExpressionContext不行,那个字典是共享可变状态。所以高并发场景我建议一律走泛型版本。
AScript 的执行单元能不能跨线程复用,取决于它内部是否持有执行期状态。稳妥的做法是每个线程或每次调用一个执行上下文,执行单元本身只作为已解析的代码载体共享。如果引擎内部有全局变量表,那么并发的两次执行互相之间就会串数据,这种情况必须加锁或者做实例隔离。这一点在压测阶段一定要专门设计用例去验证,别等上线了才发现。
5. 用户能写脚本的那一天,死循环就成了你的问题
这一节是我最想强调的。只要脚本内容由用户或实施人员提供,你就必须假设他们会写出任何东西——不是因为他们坏,而是因为能力边界就在那里。一个不小心写成while(true),你的服务就挂在那儿了。
5.1 Flee 天然受限,但仍有需要堵的口子
Flee 因为语法里没有循环和递归,天然不存在死循环问题。我在它身上唯一担心的是资源消耗:一个深层嵌套的表达式、或者一个导入进来的宿主方法被传入超大集合,照样能把 CPU 打满。这些不是脚本语法的问题,是你暴露出去的能力的问题。
所以用 Flee 的时候我的检查清单很简单:
- 导入的类型里,有没有方法在极端参数下会做重活(大集合遍历、远程调用、文件读写)?
- 表达式的嵌套深度有没有限制?语法解析本身是递归的,超深嵌套可能直接爆栈。
- 变量注入的值有没有做长度和范围校验?
这三条过一遍,Flee 的安全性基本就稳了。
5.2 AScript 能力越强,宿主兜底责任越大
AScript 这边要麻烦得多。它有循环,就意味着可能无限循环;它有函数,就意味着可能无限递归;它能访问宿主对象,就意味着可能触发副作用。这三件事必须由宿主主动兜。
我的兜底方案分三层。第一层是执行时间限制,给每次脚本执行配一个超时,超时就中断并记录违规脚本内容。具体实现上有两种思路:一种是在引擎层面提供中断钩子,每执行若干指令检查一次超时标记;另一种是在外层用任务加等待的方式限时,超时后放弃等待并标记该执行单元为不可信。前者更干净,后者更通用,取决于引擎提供了什么。
第二层是深度与迭代上限。递归深度和循环次数都应该有硬上限。这不是不信任用户,而是给系统一个明确的失败点——宁可让一条规则报错,也不能让整个服务卡死。
第三层是能力白名单。暴露给脚本的宿主对象必须是最小集合。我在第一版里图省事把整个仓储对象挂进去了,脚本能调它的删除方法,现在想起来还后怕。正确做法是包一层门面对象,只暴露查询类方法,写操作一律不给。
5.3 超时、配额与能力白名单的落地做法
把这套东西落成代码,大概是这么个结构:
public class SafeScriptRunner { private readonly int _timeoutMs; private readonly int _maxSteps; public ScriptResult Run(CompiledUnit unit, IDictionary<string, object> args) { var ctx = new ScriptContext(); foreach (var kv in args) ctx.Set(kv.Key, kv.Value); var sw = Stopwatch.StartNew(); // 引擎侧提供的中断检查(示意,按实际 API 调整) ctx.OnStep = () => { if (sw.ElapsedMilliseconds > _timeoutMs) throw new ScriptTimeoutException("脚本执行超时"); if (--_maxSteps < 0) throw new ScriptTimeoutException("脚本执行步数超限"); return true; }; return new ScriptResult(unit.Run(ctx), sw.ElapsedMilliseconds); } }除了引擎侧的限制,业务侧还要加一层调用配额:同一个用户或同一个租户,单位时间内触发的脚本执行次数要有上限。因为脚本失控往往是循环嵌套调用导致的,一个请求里套了上千次脚本执行,光靠单次超时是拦不住的。
还有一个容易被忽略的细节:超时之后要把这个执行单元标记出来。连续多次超时的规则,应该自动禁用并在管理页面上高亮提示,让写规则的人知道自己的规则有问题。我遇到过一条规则因为数据异常进入了死循环,超时被中断,结果每次请求都触发一次超时,白等了 3 秒才降级——加上熔断之后就好了。
6. 选型清单:什么时候上 Flee,什么时候必须换 AScript
聊了这么多能力对比,最后还是得给个能直接照着做的结论。
6.1 一张按场景划分的决策表
| 需求特征 | 推荐 | 理由 |
|---|---|---|
| 只有条件判断、字段换算、公式计算 | Flee | 语法够用,零循环风险,热路径性能最好 |
| 规则数量多、调用频率高(每请求百次以上) | Flee | 编译成委托后可跨线程复用,无解释器调度开销 |
| 需要遍历集合、累积计算、多步中间状态 | AScript | Flee 语法层面做不到,只能把循环搬回 C# |
| 需要用户定义可复用逻辑块 | AScript | 支持函数定义,Flee 只能靠宿主注册 |
| 需要脚本内直接操作宿主对象 | AScript | 对象级桥接,比逐项导入类型灵活 |
| 对安全性极度敏感、不接受任何循环风险 | Flee | 语法不支持循环,天然免疫死循环 |
| 需要与现有 C# 表达式语法高度一致 | Flee | 语法更接近 C# 表达式,学习成本低 |
这张表里没有“都行”的格子。每次选型的纠结,本质上都是需求没拆细——把规则按复杂度分成几类再对应选型,纠结就消失了。
6.2 混合架构:热路径用 Flee,复杂编排用 AScript
我最后落地的方案是两个都用,而且不是“都试试看”,是明确分工。
大部分规则——大概是条件判断、字段换算、阈值比较这一类,全部走 Flee。它们调用频率最高,单条逻辑最简单,用泛型表达式编译成委托之后,一次执行是亚微秒级,而且天然没有循环风险,我不用担心实施人员写出什么奇怪的东西。这部分规则的文本我直接缓存在内存里,配套一个版本号,规则内容一变版本号加一,缓存自然失效。
少数复杂规则——带遍历明细、多步中间状态、需要循环累加的那部分,走 AScript。这部分规则的数量通常不多,可能占总数的 10% 到 20%,触发频率也低,AScript 的性能完全扛得住。它们统一走安全执行器,带超时、带步数限制、带调用配额。
分层的判断逻辑放在规则保存的那一刻:规则文本里出现循环关键字或者函数定义,就归到 AScript 通道;否则走 Flee 通道。这个判断是我用一个简单的语法预检做的——先用 Flee 解析一次,解析成功就走 Flee,失败再丢给 AScript 检查。两次都失败就拒绝保存,并把两次的报错都展示出来。这个方案的好处是实施人员不需要知道底层有两个引擎,他们只管写规则,系统自己路由。
6.3 我实际踩过的五个坑
最后把这段折腾里印象最深的五个坑列出来,都是文档里不会写的:
第一,把decimal和double混着算。Flee 的隐式类型提升会把结果拉成double,金额字段精度当场就飘了。解决办法是在注入上下文的时候就把所有金额统一成decimal,表达式里的字面量也写1280m这种带后缀的形式。
第二,每次执行都重新解析。这是性能问题的头号原因,我压测时 QPS 从四位数掉到两位数就是它造成的。规则保存时解析一次,结果缓存起来,执行阶段只碰缓存对象。
第三,忘了给执行加超时。AScript 上线的第一周,一条规则因为数据边界条件写错进了死循环,整个规则服务响应时间被拖到 3 秒以上。加超时和熔断之后就好了,超时的规则会被自动禁用并告警。
第四,把整个业务对象暴露给脚本。第一版为了图方便,我把仓储对象直接挂进了脚本环境,脚本能调用它的删除和更新方法。虽然没出事,但事后检查的时候我出了一身冷汗。现在所有暴露给脚本的对象都要先包一层门面,只留查询方法。
第五,错误信息没做分类就直接弹给用户。引擎的原始报错堆栈对实施人员毫无意义,反而会让他们以为系统坏了。后来我把常见错误做了映射:语法错误指向具体位置、变量未定义提示该字段不在可用列表里、超时提示规则太复杂需要拆分。错误提示做好之后,实施人员自己纠错的成功率明显提高了。
要说还有什么体会——脚本引擎的价值不在于“能执行字符串”,而在于它把业务规则的变更成本从“发版”降到了“改配置”。选 Flee 还是 AScript,本质上是在回答一个问题:你愿意为这份灵活性付出多少安全和性能的代价。我的答案是分而治之,让简单的东西保持简单,把复杂的东西关进笼子里。