用AST替代eval:低代码平台条件表达式解析与安全求值 在低代码平台的规则配置页面里经常见到“当订单金额大于1000且用户等级等于VIP时执行特定审批流程”这样的条件表达式。用户在界面上拖拽几个字段、选几个运算符后端就得把这套配置翻译成可执行的判断逻辑。我刚接手类似需求时第一反应是写一堆if-else或者用eval硬解析结果很快被现实教育了——嵌套括号、优先级、字段类型校验、表达式安全随便一个坑都能让评审会开成事故现场。后来改用Python的抽象语法树AST去解析和求值这类条件表达式整个方案的稳定性直接上了一个台阶。这篇东西就围绕“AST在低代码平台条件表达式解析里怎么落地”展开想省掉eval那些糟心事、或者正在设计规则引擎的同学可以按这条线路走。1. 低代码条件表达式是怎么被解析出来的1.1 低代码平台里的条件表达式到底长什么样低代码平台的规则配置和写代码时候的if条件本质上是一回事只是换了一种更贴近业务的表达方式。比如积分商城要配置一个自动发放优惠券的规则业务人员会在界面上填满足“用户等级为黄金且近30天消费金额不小于2000或者参加了特定活动”就给用户发一张满减券。这个条件拆开看就是由字段引用用户等级、消费金额、字面量黄金、2000、比较运算符等于、大于等于、逻辑连接符且、或以及括号组成的表达式。平台的表单设计器收集这些配置后前端会生成一份JSON结构后端拿到这份JSON之后不能直接拿来当判断条件用必须把它解析成实际的内存代码逻辑。这个过程就是条件表达式的解析与求值。如果你用Python做后端最自然的念头可能是直接拼一个字符串然后用eval执行。eval确实快但低代码平台的条件往往来自前端用户输入用eval等于把服务器当成了公共草稿纸谁都能往上写代码。而且前端生成的条件JSON千奇百怪有的带多层嵌套有的混着中文括号有的字段名里带下划线eval根本兜不住。AST方案在这里的核心价值就体现出来了它能把表达式先变成一棵结构化的语法树然后再对这棵树做安全的解释执行。1.2 自己写解析器不如站在语言的肩膀上早期的低代码平台很多是直接写正则或者逐字符解析“字段 运算符 值”这种三元组。简单的等值判断还能扛住一旦条件变成“A等于1且B大于2或C小于3”这种组合逻辑手写解析器的状态机复杂度会立刻失控。我见过一个项目用正则去匹配括号层级括号套三层就开始出bug后来干脆规定业务人员只能用简单的且/或条件不许用括号。这种限制对于业务方来说非常难受审批流、营销规则、风控策略哪个不是需要自由组合的。用正则或者字符串切片去解表达式的本质问题在于你试图用线性思维去处理树形结构。AST方案等于完全回避了这个矛盾。Python自带的ast模块能把字符串形式的表达式编译成真正的语法树括号、优先级、嵌套都被编译器处理得明明白白。你拿到手的是一棵结构化的树要做的事只是遍历这棵树、把节点映射成低代码平台的字段和运算符然后递归求值。2. 为什么选AST——核心思路拆解2.1 把表达式当成一棵树来理解Python里有个内置模块ast全称Abstract Syntax Tree也就是抽象语法树。它能把一段Python代码或表达式解析成树形结构。比如表达式“score 60 and name 小明”会被解析成一颗根节点为BoolOp逻辑运算的树左子树是score 60右子树是name 小明。为什么强调“抽象”因为它忽略了很多源码层面的细节比如空格、注释只保留语法层面的关键信息。抽象语法树非常适合做低代码条件表达式的中间表示它既有完整结构又不过度纠缠源码格式。你不需要自己设计中间表示格式也不需要从零写词法分析器Python的编译器帮你完成了最枯燥的部分。低代码平台后端拿到一份条件的JSON之后我会先根据JSON拼出一个形如“user.level gold and order.amount 2000”的表达式字符串。注意这里的字段名不是直接用数据库列名而是用低代码平台的字段标识符中间层的映射关系后面再说。拼好字符串之后调用ast.parse或者ast.parse(modeeval)来生成语法树。2.2 相比eval和规则引擎框架AST好在哪选技术方案之前我把eval、自研解析器、Drools之类规则引擎、以及AST方案都摆在一起比过一轮。eval是最先被否掉的原因不只是安全问题还在于它没法做细粒度的控制查询字段的权限校验、类型强校验、错误定位eval全都给不了你。自研解析器的工程量太大词法分析、语法分析、错误恢复、运算符优先级表一套下来没个把月搞不定。而且自研的东西测试覆盖很难到位边缘情况根本想不全。引入外部规则引擎框架在大型企业级应用里也不错但对中小型低代码平台来说太重了学习成本、部署成本、定制成本都不低。综合下来AST是最合适的中间路线语言自带零依赖解析能力经过编译器验证你只需要写一个Visitor去遍历节点做求值就行。提示如果项目团队普遍不熟悉AST概念也别急着让全员上手。可以把AST解析封装成一个独立的工具模块对外只暴露一个analyze(condition_json, context)接口。其他人只管调用不需要理解内部实现。3. 实现AST条件解析的核心步骤3.1 环境准备与模块划分用Python实现这个功能其实不需要装任何第三方库ast是标准库。但为了工程化落地建议按三个模块来组织代码表达式构建器、AST求值器、字段映射与校验器。表达式构建器负责把前端传过来的条件JSON拼装成表达式字符串。这是最容易出细节问题的地方运算符、括号、引号、中文字段名都要处理好。AST求值器核心模块定义继承自ast.NodeVisitor的子类实现visit_Compare、visit_BoolOp、visit_UnaryOp等节点方法递归求值并返回布尔结果。字段映射与校验器把低代码字段标识符与实际的Python对象属性或字典键对应起来同时做类型检查。比如字段“order.amount”在上下文里实际是个float那条件里的字面量“2000”也应该转成float再比较。调用的入口函数大概长这样import ast from typing import Any, Dict def evaluate_condition(expr: str, context: Dict[str, Any]) - bool: tree ast.parse(expr, modeeval) evaluator ConditionEvaluator(context) result evaluator.visit(tree) return bool(result)这个模块设计简单却把AST方案的核心流程体现出来了解析、遍历、求值三步。3.2 用ast.parse把字符串变成树有些资料里会用compile然后再用eval利用compile之后生成的code对象去过滤不安全的节点。但我更推荐直接操作AST再自行求值因为你可以完全控制哪些节点允许出现。在condition表达式里你只需要允许布尔运算、比较运算、一元运算、字段名、字面量其他像函数调用、属性赋值、下标访问、lambda之类统统拒绝。ast.parse解析出来的树根节点是Expression节点。遍历的时候要判断根节点类型是否允许如果不允许就直接抛异常。这一步是安全边界第一道防线。import ast def parse_condition(expr: str) - ast.AST: try: tree ast.parse(expr, modeeval) except SyntaxError as e: raise ValueError(f表达式语法错误: {e}) if not isinstance(tree, ast.Expression): raise ValueError(只支持表达式) return treeparse的mode参数有两个常用取值exec和eval。modeeval会强制要求输入是单个表达式不能包含语句。这一参数选择从底层限制了使用者的操作范围。3.3 自定义NodeVisitor做安全求值Python的ast模块里有个NodeVisitor类它提供了遍历语法树的框架遇到不同类型节点时会自动调用对应的visit_xxx方法。你只需要覆写自己关心的节点类型方法就行了。我的求值器里核心覆写了这些方法visit_BoolOp处理And和Orvisit_Compare处理比较运算visit_UnaryOp处理Not和取负visit_Constant处理字面量visit_Name和visit_Attribute处理字段引用。其他节点一概抛异常拒绝。class ConditionEvaluator(ast.NodeVisitor): def __init__(self, context: Dict[str, Any]): self.context context def _resolve_field(self, node): if isinstance(node, ast.Name): return node.id if isinstance(node, ast.Attribute): obj self._resolve_field(node.value) return f{obj}.{node.attr} raise ValueError(f不支持的字段引用: {ast.dump(node)}) def visit_BoolOp(self, node): if isinstance(node.op, ast.And): return all(self.visit(v) for v in node.values) if isinstance(node.op, ast.Or): return any(self.visit(v) for v in node.values) raise ValueError(未知逻辑运算符) def visit_Compare(self, node): left self.visit(node.left) for op, comparator in zip(node.ops, node.comparators): right self.visit(comparator) if isinstance(op, ast.Eq): if left ! right: return False elif isinstance(op, ast.GtE): if not left right: return False elif isinstance(op, ast.LtE): if not left right: return False # 其他运算逻辑省略 return True def visit_Constant(self, node): return node.value def visit_Name(self, node): name node.id if name not in self.context: raise KeyError(f上下文缺少字段: {name}) return self.context[name] def visit_Attribute(self, node): field self._resolve_field(node) if field not in self.context: raise KeyError(f上下文缺少字段: {field}) return self.context[field]注意visit_Compare的实现里有个小技巧Python的表达式“a b c”在AST里是一个Compare节点ops里有两个比较运算符comparators里有两个比较值。这种链式比较在业务条件里很常见比如“start_date create_time end_date”用这个写法能一并处理。3.4 从AST还原成业务可读的描述低代码平台不光是让后端计算条件是否成立前端通常还要把配置好的条件用自然语言展示给用户看。比如管理员配置了一条复杂规则过几天想复查一下前端要把JSON渲染成一行可读的说明。AST在这里还有个意想不到的用处你不光要求值还可以反向从AST里提取出可读性强的描述文本。自定义一个继承NodeVisitor的Describer类重写visit_BoolOp、visit_Compare、visit_Name等方法把节点信息拼接成“用户等级 等于 VIP 并且 消费金额 大于 2000”这样的中文文案。这个方案比从JSON直接拼接要稳得多因为括号位置和运算优先级已经被AST固定了不会再出现从JSON拼接时括号错位的问题。描述生成器和求值器共用同一棵AST等于同一份解析结果服务了两个场景。这一点在设计之初我也没想到后来做规则复述功能时才意识到AST这个中间表示的价值比单纯求值要大多了。3.5 权限与安全过滤不能省低代码平台最怕的就是用户随便拼一段Python表达式然后传到后端执行。虽然你用的是ast而不是直接eval但树枝上结的果实可不一定都是安全的。防护要做到三层第一层在构建表达式字符串时直接把字段名白名单卡死。字段名只能来自平台定义的数据模型任何不在模型里的标识符都不允许进入表达式。第二层在AST遍历时遇到不允许的节点类型直接抛异常。默认策略是白名单制只允许满足条件表达式需求的节点其他节点一律拒绝。千万别在黑名单里枚举谢绝名单永远列不全。第三层别忘了NodeVisitor本身也存在覆写不完全的问题。比如ast.Call节点如果没覆写visit_Call默认的generic_visit会把它的子节点遍历一遍并不会报错。这意味着你亲手放走了一个函数调用节点。所以一定要在兜底的visit方法里直接报错而不是依赖默认行为。def visit(self, node): raise ValueError(f非法节点: {ast.dump(node)})我把这行代码写在了ConditionEvaluator的基类位置等于给所有没覆写的节点类型统一判了死刑。兜底优先白名单明确这样才敢真正放到生产环境。4. 实操过程与核心场景示例4.1 字段映射与上下文绑定低代码平台里的字段标识符与用户填写的条件值通常不在一个维度上。前端的“订单金额”字段在后端上下文里可能是order_info[amount]。所以表达式字符串里我统一用“order_info.amount”这样的点分格式然后在求值器的visit_Attribute里做映射。上下文的构造方式也很关键。我一般会先从数据库查出对应实体然后包装成一个扁平的字典键为“实体.字段”的点分格式值为对应的Python对象。这样visit_Attribute解析出来的键能直接命中不用再在求值器里做复杂的对象导航。一个实际场景是订单风控规则新用户首单并且金额大于500自动标记为“可信用支付”。前端的条件JSON大概是{and: [ {field: user.is_new, op: eq, value: true}, {field: order.amount, op: gt, value: 500} ]}构建表达式的函数会拼成user.is_new True and order.amount 500然后调用evaluate_condition上下文传入context { user.is_new: True, order.amount: 680.00 }返回结果是True这个订单就会被标记。整个链路看起来简单但拼装表达式与解析AST这一步中间藏了不少细节下一节逐个拆开看。4.2 常见几种条件的表达式拼装方式实际业务里条件不会总是“字段 运算符 值”这么规矩还有字段对字段、集合包含、时间范围、null判断等。按我总结的经验大致分以下几种模式。基础等值与范围比较前端选择了“会员等级等于VIP”就拼成“member.level VIP”。字符串字面量在拼装的时候需要加引号而且要小心值是中文的情况加引号不会出错。数值比较取“订单金额大于等于100”时拼成“order.amount 100”。要注意字面量不要加引号否则会变成字符串比较。集合包含关系像“用户所在城市属于上海、北京、广州”前端会传一个数组。拼表达式时可以转成“city in (上海, 北京, 广州)”在visit_Compare里需要额外支持ast.In和ast.NotIn节点。elif isinstance(op, ast.In): if right is None or left not in right: return False elif isinstance(op, ast.NotIn): if right is None or left in right: return False字段对字段的比较有些条件会比较两个字段比如“累计消费金额大于历史最高单笔金额”。表达式拼成“user.total_amount user.max_single_amount”。此时比较的右侧不是字面量而是字段引用AST里comparator对应的是Attribute节点而不是Constant节点所以visit_Visit里要区分类型进行处理。先看一下我前面visit_Compare的写法它调用了self.visit(comparator)这个visit分派机制会在遇到Attribute时自动调visit_Attribute所以字段对字段天然是支持的。null与空值判断低代码平台里业务人员会配置“字段为空”这种条件对应运营商通常是Is和IsNot。在AST里None字面量对应Constant节点其value是None。所以要判断字段是否为空表达式拼成“user.phone is None”或者“user.phone is not None”。elif isinstance(op, ast.Is): if left is not right: return False elif isinstance(op, ast.IsNot): if left is right: return False这里有个需要注意的类型陷阱空字符串和None不是一回事。业务上“手机号为空”通常意味着手机号是None或者空字符串。这种语义要放在表达式构建器里去处理不要放到求值器里。我往往在构建器里直接拼成“user.phone is None or user.phone ”把多个基础条件组合成复合条件就能保证求值器逻辑足够纯粹。4.3 运算符优先级与括号处理表达式拼装中最容易错的就是运算符优先级。假设前端条件是“用户等级为VIP或者用户等级为白银且消费金额大于2000”业务语义有两种理解。第一种是“VIP或者白银且金额大于2000”第二种是“VIP或者白银且金额大于2000”。AST方案下优先级问题在解析表达式字符串时就被编译器正确处理了但前提是你拼装的字符串本身要符合预期。所以表达式构建器里拼接括号时要有清醒的上下文感知。我的处理技巧是每个条件子节点在拼接时如果它的顶层节点是BoolOp且外层父亲节点的op优先级不同就自动给这个子节点加括号。def _compose_bool(self, op, children): operator and if op and else or parts [] for child in children: child_expr self._compose_node(child) if child[type] and and op or: child_expr f({child_expr}) parts.append(child_expr) return operator.join(parts)这个拼装逻辑配合字符串格式能保证前后端语义一致。后续踩坑部分我还会专门讲一个真实案例就是优先级漏加括号导致规则判断反了的教训。5. 常见问题排查与踩坑记录5.1 前端JSON里的运算符映射对不上前端传过来的运算符可能是中文名称比如“等于”“大于”“包含”后端不清楚映射规则表达式直接拼出来就是错的。我的做法是定义一份不变量映射表前端标识AST节点拼装符号eqast.Eqneqast.NotEq!gtast.Gtgteast.GtEltast.Ltlteast.LtEcontainsast.Ininnot_containsast.NotInnot inis_nullast.Isis Noneis_not_nullast.IsNotis not Nonebetween拆成两个比较 and 映射表最好统一维护在同一个常量文件里前端、后端、测试用例三处共用来避免分叉。真到排查问题阶段这表就是第一现场。5.2 类型不一致导致比较结果诡异低代码平台里字段有明确的类型定义但前端传值有时候会传字符串。比如“订单金额大于100”里的100前端传到JSON里可能是字符串“100”也可能因为金额字段本身被格式化过变成了“100.00”。如果不做类型转换表达式“order.amount 100”在Python 3里会直接抛TypeError因为float和str不能直接比较。我的做法是在字段映射器里做类型对齐。每个字段从数据模型里读取它的标准类型然后和条件值做类型转换。转换逻辑放在表达式构建器拼接字面量之前保证生成的表达式字符串右侧字面量的引号是合理选择的数值型字段直接去掉引号字符串字段加引号布尔值处理成True或False。def _format_literal(field_type, value): if field_type number: return str(float(value)) if field_type string: return repr(str(value)) if field_type boolean: return True if value else False return repr(value)这一步做完字符串和数值比较的坑基本被挡在门外了。5.3 None值的比较行为与业务语义矛盾Python里任何值跟None做比较都是False除了is和is not。所以条件“user.phone ”和“user.phone is None”在语义上完全不是一回事。业务人员往往不区分这两者但在低代码配置里它们可能被混用。我踩过最惨的一次坑有个规则配置为“未填写手机号用户不能参加活动”前端生成的表达式是“user.phone None”。结果一个用户手机号是空字符串被判断为不满足条件直接不能参加活动。排查了半天才发现是None语义的问题。后来我在表达式构建器里做了统一翻译把“等于None”转换成“is None”把“不等于None”转换成“is not None”同时规定空字符串需要用“ ”显式表达。这样语义不清晰的问题就提前解决了。5.4 AST节点未覆盖导致非法节点被放过写NodeVisitor子类时最隐蔽的一个坑就是没覆写到的节点会在generic_visit的默认逻辑下被当作合法节点遍历。比如visit_Attribute没实现而表达式里出现了“user.obj.attr”它的子节点会被正常遍历但父节点本身被跳过了等于你根本没检查这个字段的合法性。这种漏洞比直接报错更可怕因为平台会静默产生错误结果。兜底def visit(self, node)必须无条件抛异常这是一道保命墙。全部节点类型都要过一遍白名单不知道怎么处理的一律拒绝。这也是我前面反复强调兜底方法的原因。还有一个相关的问题是ast.dump的输出可读性。排查非法节点时直接调ast.dump(node)能看到这个树的完整结构比你自己打印node.__class__要直观得多。调试条件表达式解析问题掌握ast.dump基本等于多了一双眼睛。5.5 性能优化成百上千条规则的并发求值低代码平台边界场景一次批量操作可能要同时判定几千条规则。每条规则都从字符串开始重新parseAST解析开销会累积。实测下来同样的表达式字符串反复parse会浪费不少CPU时间。优化手段也很直接加一层LRU缓存键为表达式字符串值为解析后的AST树。因为表达式字符串是拼装出来的相同条件配置必然产生相同字符串缓存命中率很高。from functools import lru_cache lru_cache(maxsize2048) def parse_cached(expr: str) - ast.AST: return ast.parse(expr, modeeval)除了AST缓存求值过程也要尽量避免重复解析字段映射。上下文字典构建一次之后复用不要每条规则都重新查数据库。我见过团队在批量规则场景下每条规则都查一次数据库导致接口耗时成倍增长。把数据加载和规则判定分开性能一下就上来了。6. 一些实操体会做了几个低代码条件解析的需求之后我对“君子生非异也善假于物也”这句话理解特别深。Python编译器早就把表达式解析这种复杂问题解决好了非要从零手写解析器等于放着电梯不坐非要爬楼梯。但AST方案不是银弹它对你自身代码的规范性要求也很高NodeVisitor覆写要全面、白名单机制要兜底、类型映射要先行。把这些基本功扎扎实实做对条件解析模块会变成一个非常稳定、安全的组件。这条路走通以后低代码平台的规则引擎、审批流条件、风控策略都可以往外扩展可复用的东西比自己预想的多得多。