审批流条件分支:表达式求值的实现思路

公司内部系统的审批流越配越复杂后,我给平台补了一块能力:条件分支的表达式求值。这块能力现在成了公司所有审批流的底盘,这篇把思路摊开,供同样在低代码平台上深挖的同行参考。原来平台只支持单字段等于某值的简单判断,复杂的路由规则配不出来,只能拆成多条流程绕。这篇记录整个实现思路,重点讲设计取舍,代码只放骨架。先说背景:我们二十来人的创业公司,审批规则折腾了三轮,从简单判断绕到蜘蛛网流程,最后回到表达式这条正路。弯路也是路,走过才知道哪条直。

一、需求怎么长出来的

1.1 简单判断的天花板

公司采购审批流有个规矩:金额五万以上、或者涉及涉外合同的,要合伙人加签;金额二十万以上,还要财务顾问复核。这两条规则用单字段等于判断配不出来——它要的是大于、小于、并且、或者的组合逻辑。

当时的绕法是把流程拆成五条:五万以下走A线,五万到二十万走B线,二十万以上涉外走C线……流程图很快变成蜘蛛网,每加一条规则就再拆两条,维护的人自己都看不懂。有次我休假,一条流程要临时改审批人,代管的同事对着流程图研究了半小时不敢下手,最后等我回来改。系统离不开特定的人,这是设计失败的铁证。

1.2 绕路方案的成本

绕法的问题不只是难看。同一个审批节点在五条流程里重复出现,改一个审批人要改五处;统计审批数据要从五条流程里分别捞再合并;流程越拆越多,选线逻辑本身也成了出错点,走错线的单子每个月都有几笔。

复杂度不会消失,只会转移:配置界面省下的复杂度,全部转移到了流程图的维护上。这笔账不划算。更要命的是出错后的追责混乱:单子走错线,各方都说自己配的没问题,问题出在五条流程的交界处。设计埋的坑,最后都是协作的人来填。

二、设计思路

2.0 设计前的问题清单

动手前逼自己回答了三个问题:使用者是谁——不懂技术的业务主管,语法必须一眼能懂;错误怎么兜底——配置时校验加运行时防御双保险;将来怎么演进——运算符可能加,架构必须留口子。三个答案定调:小语法、强校验、可扩展。后面所有具体设计都是从这三条推出来的,先立宪法再立法条,顺序不能反。

2.1 一条规则一个表达式

新方案的思路:每条分支路由规则就是一个布尔表达式。金额大于五万、合同类型等于涉外,这两个条件用并且或者组合起来,就是一个表达式。表达式为真走这条线,为假看下一条。

表达式跟自然语言一一对应,业务规矩怎么说,表达式就怎么写。五条流程合并回一条,复杂度从流程图回到表达式里——而表达式是可读、可测、可复用的,比蜘蛛网流程图强太多。采购主管看懂自己的规则表达式后说了句:这不就是我们开会定的规矩吗。对,配置就该长成规矩本来的样子,让人认得出它。

2.2 表达式的语法裁剪

初版想直接支持完整语法,被自己劝住了。完整语法学起来贵,业务同学用不上,解析器的复杂度还翻倍。最终裁剪出的语法只有四种元素:字段引用、比较运算、逻辑组合、括号分组。

比较运算只留六个:等于、不等于、大于、小于、大于等于、小于等于。逻辑组合只有并且、或者、非三种。就这六加三,覆盖了实际业务的九成五需求。剩下半成?那半成本来就该用代码解决,不该塞给配置层。能力的边界画在哪,是配置类产品的核心决策:画大了,业务同学学不动;画小了,处处要技术救场。六加三这个点,是我们拿三个月的配置数据试出来的。

2.3 求值器的实现骨架

求值器分两步:先把表达式文本解析成结构树,再对树求值。核心骨架:

function parse(text):
  tokens = lex(text)          # 分词:字段、运算符、括号、字面量
  return buildAst(tokens)     # 构建抽象语法树

function evaluate(ast, ctx):
  match ast.type:
    case 'compare':
      left  = resolve(ast.left, ctx)
      right = resolve(ast.right, ctx)
      return compare(left, ast.op, right)
    case 'and':
      return evaluate(ast.l, ctx) && evaluate(ast.r, ctx)
    case 'or':
      return evaluate(ast.l, ctx) || evaluate(ast.r, ctx)
    case 'not':
      return !evaluate(ast.child, ctx)

function route(form, rules):
  ctx = { amount: form.amount, type: form.contractType }
  for r in rules:
    if evaluate(parse(r.expr), ctx): return r.target
  return defaultTarget

解析成树再求值,而不是直接对文本求值,好处是树可以缓存:同一个流程的规则集解析一次,之后每张单子只做树的求值,性能稳定在毫秒级。缓存这步开始没做,每张单子现解析,月底高峰求值偶尔抖到几十毫秒。加缓存后彻底平稳。量小的时候看不出设计差距,量一大,缓存、复用这些基本功全在成绩单上。

2.4 边界情况的防御

边界情况是求值器的生死关。金额是空的怎么比?类型字段被删了怎么引用?表达式里除以零怎么办?我们的处理:空值参与比较统一返回假,并记一条告警日志;引用不存在字段在保存表达式时就校验拦截,不给上线机会。

防御的思路是前置:能在配置时发现的错误,绝不留到运行时。运行时报错意味着某张单子卡在流程里,那是用户的事故;配置时报错只是配置者的返工,代价小一个量级。为此还做了个贴心功能:表达式保存前跑一遍全量单子的模拟路由,把会受影响的单子列出来。配置者改动之前就知道影响面,心里有底,手上不慌。

三、上线后的变化

3.00 上线的灰度策略

上线没搞一刀切:新旧两套并行两周,新单子走表达式引擎,老流程保留只读。并行期抓到两处规则翻译错误——都是业务规矩口头描述时的歧义,双语镜一照就现形。两周后老流程下线。重要逻辑的替换,值得用双轨买保险。

3.1 五条流程归一

新能力上线后,采购审批的五条流程合并成一条,分支规则五条表达式管到底。改审批人改一处,统计数据一份,走错线绝迹。流程图从蜘蛛网回到一条主干加分支,新人十分钟看懂。合并那天看着新流程图,同事感慨早该如此。我也在反思:为什么一开始选了拆流程这条路?因为拆是配置界面里现成的动作,而表达式求值要写代码。人天然倾向现成的路,哪怕它通向更远的地方。

3.2 配置权下放的意外收获

表达式可读性够好之后,配置权从技术侧下放给了业务侧:采购主管自己写采购规则,行政主管自己写报销规则。技术只负责评审表达式和测试用例。配置权的下放省下的不只是技术人力,还有需求传递的失真——规矩谁定的谁配,理解零损耗。下放一个月后业务侧提的改进建议质量明显变高,他们配着配着就摸到了规则的死角,主动来商量怎么补。参与感能催出实在的责任感,这话在配置权下放这事上再次应验。

3.3 测试用例成了知识库

每个表达式配好必须附测试用例:这三组数据应走A线,那两组应走B线。用例存在表单里,后来新同事接手审批配置,先看用例就懂规则。一年下来积累两百多条用例,成了公司审批规则的活文档。有次审计要查去年的审批规则,直接导出用例和表达式清单,十分钟交卷。要是还在蜘蛛网时代,这活至少两天。

四、设计复盘

4.00 复盘的方法

复盘时把三个月的配置记录翻了一遍,统计哪些运算符被用到、哪些从没人碰。数据显示大于和等于占了七成使用量,非运算符全公司只用过一次。这个统计反过来验证了语法裁剪的正确,也给下一次裁剪提供了数据底气。设计不能只靠当初的判断,要回头用数据验收。

4.1 能力边界要收窄

这轮最大的经验:给配置层加能力,边界要拼命收窄。语法裁剪到六加三,看着寒酸,实际够用;完整语法的半成需求,用代码兜底反而更干净。配置层的每一点能力,都要用十倍的维护成本去养,能不给就不给。

4.2 可读性是配置层的首要性能

求值器跑得快慢无所谓, milliseconds 级的事;表达式读不读得懂才是大事。我们在表达式编辑器上花的功夫比求值器本身还多:字段名自动补全、实时校验、规则说明栏。配置层的性能指标是人的理解速度,不是机器的执行速度。

4.3 演进要留口子

语法虽然裁得狠,解析器却留了扩展口:新运算符只需要注册词法和求值两个函数。半年后果然来了新需求——日期比较,按预留的口子两小时加完。裁剪当前能力,预留演进通道,两头都要占。这条经验不止适用于表达式引擎,凡是给业务配的能力都适用:克制是给用户的仁慈,口子是给未来的礼物。

posted @ 2026-10-13 14:41  人生404  阅读(8)  评论(0)    收藏  举报