当业务规则开始以“改一行代码、发一次版本”的方式驱动业务时,说明规则治理已经出了问题。我在核心链路上维护过佣金计算、营销活动、风控校验这类强规则系统,最深的感受是:规则本身不是问题,规则散落和规则不可见才是问题。这篇文章想聊聊规则DSL——为什么需要它、怎么设计一套能落地的规则DSL,以及它背后真正的难点:规则治理。如果你正在做规则平台、营销中台、风控引擎,或者单纯被一堆if-else折磨得想重构,这篇文章应该能给你一些可参考的思路。
1. 当业务规则散落在代码里:治理问题的起点
1.1 我见过的三种"隐性规则"形态
先说结论:大多数系统的规则治理问题,不是规则太少,而是规则太隐蔽。按我排查过的项目,隐性规则大致有三种形态。
第一种是散落的if-else条件分支。这是最常见的形态。一个订单金额判断散落在下单入口、结算页、支付回调三个地方,每个地方都判断一次"满300减50",但三处的边界条件略有不同——有的判断是否含运费,有的判断是否用了优惠券。业务方以为自己在执行同一套规则,实际上系统里跑着同一规则的三个变体。最麻烦的是,这类规则没有名字、没有版本、没有负责人,出了线上问题只能靠git blame找人。
第二种是配置中心里的开关组合。团队发展一段时间后会开始做配置化,把规则拆成布尔开关:newUserDiscountEnabled、couponStackEnabled这类。表面上看规则"可配置"了,但真正的业务规则往往是多个开关的组合:"新人首单立减10元,且可与平台券叠加,且不可与会员折扣同享"。当这个组合逻辑需要调整时,配置中心里几十个开关之间如何联动、哪个开关影响哪个规则,没人能完整说清楚。配置化只解决了"变了不用发版",没解决"规则可理解、可治理"。
第三种是策略模式包装的规则类。这种相对体面一些,用设计模式把规则封装成策略类,每个策略一个类、一个文件。好处是代码结构清晰了,坏处是——规则文件会持续膨胀。我见过一个项目里有一百多个策略类,类名从PromotionStrategyV1排到PromotionStrategyV96,每个类里是几十行甚至上百行的条件判断。规则间的优先级、互斥关系、生效时间线,全部隐含在Spring的Bean加载顺序和代码注释里。
这三种形态的共同问题是:规则和业务代码耦合在运行时,无法被单独评审、单独测试、单独灰度、单独回滚。业务方提规则,开发写规则,测试验规则,全流程围绕"代码变更"展开,而不是围绕"规则本身"展开。这就是治理问题的起点。
1.2 治理的本质:把"决策"从"流程"中剥离
我在复盘那些规则失控的项目时,发现一个关键认知:业务系统本质上由两部分组成——决策和流程。决策是"该不该做、做哪个、做到什么程度",流程是"下单、支付、发货这些动作怎么串起来"。
大多数系统的问题在于,把决策淹没在流程里。订单服务里既写着"什么时候允许下单"(决策),也写着"下单后扣库存、生成订单号"(流程)。当决策逻辑频繁变化(这是业务常态)而流程逻辑相对稳定时,它们耦合在一起的结果就是:每次调规则都要动流程代码,每次动流程代码都要全量回归。
规则DSL的核心价值,就是把决策部分从流程中显性剥离出来。剥离之后,决策变成一段可读的、独立的、可版本化的描述文本。这段文本可以被业务人员审查、可以被测试用例覆盖、可以按流量灰度、可以一键回滚。流程代码只管执行固定的动作,而不需要理解"为什么这么做"。
注意:这里说的剥离不是物理上的"抽个方法",而是逻辑上的"让决策拥有独立生命周期"。抽方法只是重构,规则DSL加上配套的管理机制才是治理。
明白这一点之后,才谈得上选择什么工具、怎么设计语法。很多团队一开始就纠结"DSL语法怎么写",其实应该先回答:你希望规则以什么形态存在、由谁来维护、怎么变更和发布。这些问题的答案,直接决定了DSL需要什么能力。
2. 为什么是规则DSL:规则引擎、表达式库与自研DSL的取舍
2.1 三个可选方案的对比
聊规则DSL绕不开一个前置问题:市面上不是没有现成的规则引擎和表达式库,为什么还要自研一套DSL?我把常见的三种方案放在一起对比过,各有各的适用场景。
| 方案 | 表达能力 | 上手成本 | 治理配套 | 典型问题 |
|---|---|---|---|---|
| 开源规则引擎(RETE类) | 强,支持复杂推理 | 高,学习曲线陡 | 自带部分能力 | 重的框架与轻量规则场景不匹配 |
| 通用表达式引擎 | 中,擅长单条布尔表达式 | 低 | 弱,几乎没有 | 复合规则、优先级、动作联动要自己写 |
| 自研规则DSL | 可控,按需裁剪 | 中 | 需要自己建设 | 需要持续投入设计成本维护成本 |
开源规则引擎的问题不是不好用,而是"杀鸡用牛刀"。我曾经在项目里引入过一个基于RETE算法的重型规则引擎,把规则塞进它的规则文件、用它的工作内存跑推理。结果发现:我们的大部分规则是简单的条件判断,根本不需要复杂的模式匹配和推理;但引擎本身的学习成本、调试成本、运行时开销,全都实打实背上了。团队成员要额外学一套规则语法、一套API,出了问题还得翻引擎源码。后来这个项目果断换掉了。
通用表达式引擎(即一个能解析并求值表达式字符串的库)轻巧、易接入,适合"在代码里临时算一个布尔值"的场景。但单条表达式只能解决"一个条件判断",解决不了"一组规则之间的优先级、互斥、默认行为"——而这些恰恰是业务规则最常见的形态。一个促销系统至少有几十条规则,几百条规则很正常,如何组织这些规则、如何编排它们的执行顺序,表达式库不关心,需要自己实现,实现到最后就是重新发明一个简易DSL。
自研DSL这条路,代价最明确,收益也最明确。代价是:你需要设计语法、写解析器、建执行引擎、做配套的测试和发布机制。收益是:DSL可以长得完全像业务语言,可以约束规则作者的表达边界,可以内置治理需要的元信息(版本、生效时间、优先级、负责人)。
2.2 自研DSL的成本边界
自研DSL不是所有团队都适合,我建议用三个条件来判断。
第一,规则数量是否足够多。规则少于20条、一个月变不了一次,直接在代码里写if-else完全没问题。但规则一旦超过50条,变更频率每周都有,散落在代码里的规则就很难靠人肉维护了。
第二,规则是否需要有非技术角色参与。如果规则只能由开发改,那做DSL的沟通收益不大。但现实中,运营、产品、风控策略师才是规则的真正提出者,他们需要一个能看懂、能review的规则表达形式。DSL的可读性在这里比执行效率重要得多。
第三,规则变更是否需要独立于应用发布。如果规则调整永远和版本发布绑在一起,DSL的价值就少了一半。只有规则能独立发布、独立灰度时,前期的设计投入才划算。
用这三个条件自检一下:如果你的团队已经有成型的规则平台、规则中心,或者下一步明确要做,自研规则DSL是合理方向。如果规则还处于"在代码里放常量"的阶段,先别上DSL,先把规则收敛到独立模块再说。
2.3 DSL的粒度:只做条件,还是连动作一起做
自研DSL最核心的决策之一是粒度。我见过两类做法:
第一类只做条件描述(when部分)。DSL只表达"在什么条件下触发规则",触发后执行什么动作,仍然由宿主代码决定。好处是DSL简单、解析容易、副作用可控;坏处是"条件-动作"被拆成两部分,规则在DSL里、动作在代码里,要理解一条完整规则需要跨两个地方看。
第二类是条件+动作一起做(when+then)。DSL表达完整的规则:满足条件后做什么。动作也不是任意代码,而是平台预定义的动作函数,比如"减固定金额""打折扣""拒绝请求""转人工",DSL里只写动作名称和参数。
我最终选择了第二种,但做了严格约束:动作必须是平台注册的有限集合,规则作者不能凭空发明动作。原因很简单——只有动作受限,规则才可能被静态分析、被安全执行、被测试覆盖。如果DSL允许嵌入任意代码逻辑,那和把代码写进数据库没有本质区别,治理就无从谈起。
提示:粒度决策的优先级高于语法设计。先定清楚"规则能做什么",再讨论"规则长什么样"。我见过团队花两周设计了一套优雅的DSL语法,结果没想清楚动作边界,上线后规则作者开始往里写各种伪代码,很快就失控了。
3. 一套规则DSL的设计:从语法、模型到执行引擎
3.1 先定一个业务场景:下单优惠计算
为了让整篇讨论不悬空,我用一个具体场景贯穿设计过程:下单优惠计算。假设业务规则是"新用户首单满50减10,限APP和小程序渠道,不与会员折扣叠加"。这个规则小但有代表性,包含的事实字段(用户属性、订单金额、渠道)、条件组合、动作(减10元)、限制(互斥),足够展示DSL设计的关键点。
3.2 语法设计:面向业务的可读声明
DSL语法设计的核心原则是:贴近领域语言,不贴近编程语言。我不建议直接把DSL设计成简化版的Java或Python,那样只是换了个壳,业务人员照样看不懂。真正有效的DSL,读起来应该像业务同学写在PRD里的那条规则描述。
以优惠规则为例,我会这样设计:
rule "新用户首单满减" status: enabled priority: 100 effective_time: "2025-01-01 00:00:00" ~ "2025-12-31 23:59:59" condition: user.is_new == true and order.amount >= 50 and order.channel in ["APP", "MINI_APP"] action: discount.fixed(amount = 10, scene = "first_order") mutually_exclusive_with: "会员折扣" end这里有几个刻意的设计:
字段路径(user.is_new、order.amount)直接映射到事实对象的属性,规则作者不需要理解数据模型,只需要知道业务字段叫什么。condition块是条件表达式,支持and、or、not,操作符限定在==、!=、>=、<=、in、between等业务常用范围。action块引用平台注册的动作函数discount.fixed,参数是语义化的键值对。mutually_exclusive_with声明该规则与哪些其他规则互斥。
这些设计背后都有理由。比如priority必须显式写出,而不是默认按文件顺序执行——规则一旦多起来,顺序隐式依赖是最大的坑,谁先谁后看不出来,一改就错。effective_time是让规则带时间边界,避免"这条规则还活着吗"要靠猜。这些元信息不是为了好看,都是治理必需的基础设施。
3.3 AST与执行模型
写解析器并不需要把DSL做成正式的编程语言。我在这类项目里的标准做法是:分词器(Tokenizer) + 递归下降解析器,产出AST,AST再编译成条件树和动作描述两个部分。
条件树是一棵布尔表达式树,叶子节点是字段路径、操作符、值的比较单元,非叶子节点是AND、OR、NOT逻辑节点。执行时,运行时把当前请求的事实对象(Fact)传入条件树,树从根开始短路求值。短路求值不是优化,是必须——OR 右边不满足时不能继续执行,AND 左边不满足时右边根本不用看。
编译时还有一个重要步骤:静态校验。解析完DSL后,我会做三层校验:
第一层是语法校验,字段路径能否解析、操作符是否合法、参数类型是否匹配。第二层是引用校验,DSL里引用的字段是否在事实模型注册表里存在,动作函数是否在动作注册表里存在。第三层是语义校验,比如effective_time格式对不对、互斥声明是否引用了真实存在的规则ID。静态校验做得越完善,运行时出的错就越少。这一层是治理威力的体现,工业级规则系统必须做到"错误规则根本发布不出去"。
3.4 优先级与冲突处理
业务规则有一个天然的属性:规则之间会重叠,重叠时会冲突。两个条件都命中的规则,到底执行哪个?
我用三个机制组合来解决这个问题。
优先级(priority):数值大的先执行。优先级相同的情况下,用规则ID做稳定排序,保证结果可复现。
命中策略(strategy):可以配置first_match(首条命中即返回)或者all_match(所有命中规则都要执行)。优惠促销通常用first_match,因为一个订单不能叠加所有优惠;风控规则通常用all_match,因为每一层风险都要累计。
显式互斥(mutually_exclusive_with):这条规则命中了,被互斥的规则就算条件满足也不执行。互斥声明必须双向可见——我在规则详情页里会展示"谁和谁互斥",避免两个规则作者各自声明互斥,结果关系不一致。
这套机制不是说每次设计都必须一模一样,但"优先级 + 命中策略 + 显式互斥"三件套是规则集有序运行的底线。没有这三个机制,规则一多必然出现"灵异现象"——业务方发现两条规则同时生效,但代码里看不出为什么。
4. 规则治理落地:全生命周期管理
4.1 规则的版本与发布机制
规则DSL只是规则治理的表达层,真正让治理运转起来的是配套的生命周期管理。我把规则的生命周期设计成:草稿 → 评审 → 测试 → 发布 → 灰度 → 生效 → 下线。
每条规则在存储层都有一个独立记录,包含DSL文本、版本号、状态、创建人、更新时间。任何修改都不直接改线上生效的规则,而是生成一个新版本,走发布流程。这个机制和代码发布的git分支模型是对齐的——规则的每次变更是有记录的、可回溯的、可回滚的。
发布流程的关键动作是"规则评审"。评审不是走形式,而是让两个角色参与:业务负责人确认规则语义符合预期,技术负责人确认规则没有性能隐患和副作用。平台要提供规则变更对比功能——旧版本DSL和新版本DSL放在一起diff,一眼能看出改了哪里。我在实际运营中发现,规则变更的评审效率,直接决定规则平台能否被业务接纳。如果每次变更评审都要花两天,业务方就会绕过平台直接改代码——这是我踩过的真实教训。
4.2 规则测试和预发验证
规则是代码的近亲,规则就必须像代码一样有测试。我给规则平台设计了"规则用例集"的能力:每条规则必须绑定一组测试用例,用例由输入数据(事实对象样例)和期望输出(命中结果、动作参数)组成。
这里有个容易忽略的细节:规则的用例不只是验证"这条规则对不对",更重要的是验证"规则改了之后有没有破坏其他规则"。我把全量规则用例集做成一个回归基线,任何一条规则变更,发布前必须跑完整个基线。基线里既有"新用户首单满减"应该命中的正向用例,也有"会员叠加新人规则"应该被互斥逻辑拦截的负向用例。
没有规则测试基线,改动一条规则就像在黑屋子里打靶,谁也不知道哪些流量会受影响。有了基线之后,规则重构、字段调整这类操作才真正变得可做——跑一次回归,结果全绿,就可以放心发布。
4.3 上线后的灰度与监控
规则生效和代码上线一样不能一把梭。我强烈建议规则DSL的执行引擎支持规则的灰度发布,至少要有按流量比例的灰度能力。实现方式不复杂:规则发布时带一个灰度比例参数,执行引擎根据请求ID的hash决定这条流量是否走新规则,灰度期结束后再全量。
灰度之外,监控是规则治理的护城河。每条规则至少有三个监控项:
命中率的趋势变化。规则命中率发生突变(比如从5%暴增到50%),大概率是规则条件写错了,或者上游字段含义发生了变化,必须告警。
执行耗时的分位数。DSL解析和条件求值如果出现性能退化(比如条件里出现10层嵌套and),耗时升高会拖垮主链路,需要设置超时和熔断。
动作执行的成功率。如果规则触发了动作,动作本身可能失败(比如调用外部服务),需要有后续的重试和补偿。
4.4 规则的下线与归档
规则治理常常被忽视但是最该管的环节是:规则的下线。我见过太多系统里堆着大量僵尸规则——条件里引用的字段早就下线了,生效时间过了但没人清理,规则优先级混乱得一塌糊涂但谁也不敢动。
我的建议是给每条规则加两个机制:自动过期提醒和定期清理巡检。规则配置了effective_time的,到期前7天自动提醒负责人确认:续期还是下线。没有配置有效期的规则,巡检任务每季度跑一次,找出"近90天命中率为0"或"近90天未变更"的规则,逐个确认处置。下线不是删除,而是归档。归档后的规则保留版本历史,随时可以追溯"当时线上跑的规则到底长什么样"。
重要:上线管理做得再好,只要缺了下线机制,规则库终将变成垃圾场。垃圾场里的规则越多,每一笔线上请求要检查的规则就越多,性能越差,误命中概率越高。治理是全周期的事,缺任何一环都不叫治理。
5. 避坑实录:规则DSL落地中最容易翻车的五个细节
5.1 坑一:让DSL"图灵完备"的诱惑
设计DSL时,最大的诱惑是让它变得"强大"——支持变量声明、支持循环、支持函数调用。每一条"为了让场景更通用"的扩展,都在把你引向深渊。一旦DSL图灵完备,规则作者可以写出有状态的计算过程,解析器无法静态分析,测试覆盖几乎不可能,执行结果依赖执行顺序导致不可复现。
我的底线是:DSL只允许声明,不允许计算。条件部分只允许比较布尔值,动作部分只允许调用平台注册的动作函数,禁止变量赋值、禁止循环、禁止任意函数调用。如果需要复杂的计算逻辑(比如复杂的风控评分),那应该是一个被平台管理的"计算节点",而不是写在DSL里。用户忍不住写复杂逻辑时,平台要做的是识别需求并提供一个受管的能力入口,而不是放开DSL的限制。
5.2 坑二:动作的副作用不受控
规则DSL的执行必然产生副作用——无论如何它都要改订单金额、发优惠券、拦截请求。但副作用必须被控制,控制手段就是前面说的动作注册表。平台维护一个"动作白名单",每个动作函数有明确的入参类型和语义说明,规则作者只能引用白名单内的动作。
这套机制能避免一半以上的安全事故。事故往往不是故意作恶,而是无意越界——规则作者在DSL里写了向外部服务打招呼的逻辑,结果该服务时好时坏,把核心链路拖垮了。动作注册表配合静态校验,直接从发布环节切断这类风险。
5.3 坑三:性能上的过度自信
规则DSL性能问题的根源,大多不在解析器本身,而在于把每条规则当成一个孤立执行单元。请求来了,把全量规则遍历一遍,每条规则做一次条件求值。规则量小时没问题,规则量上千后,一次请求的规则匹配耗时可能从0.2ms涨到20ms,主链路明显变慢。
解法通常是规则索引与规则分组。规则分组的意思是:上线前先对规则集做静态分析,把规则按事实字段(比如按场景、按用户类型、按渠道)分组。执行时先根据请求里的事实快速定位候选规则组,只对该组规则做条件求值,而不是全量扫描。条件求值本身也要做编译优化——把AST编译成可执行判定树、节点缓存、比较结果复用,这些是成熟执行引擎的标准操作,不展开讲,但规划性能基线时一定要做余量设计,规则量增长是必然趋势。
5.4 坑四:可读性与可表达性的失衡
DSL设计里最微妙的是尺度。太像代码的DSL,程序员的命保住了,业务人员完全看不懂;太像自然语言的DSL,业务人员读得舒服,解析器却很难处理,语义边界模糊,容易产生歧义。
我的经验是用"结构化自然语言"去平衡:字段路径、操作符、值这些可枚举的可视元素保留代码形式,但整体condition块用接近自然语言的组合方式。user.is_new == true比isNewUser()好——前者让业务人员能猜到含义,后者是程序员的表述。order.channel in ["APP", "MINI_APP"]比渠道等于APP或小程序更精确、更利于解析。宁可牺牲一点"口语感",也要保证可枚举性和确定性。
5.5 坑五:没有测试基线的规则是升级版债务
最后这个坑,是我最想强调的。很多团队做DSL的时候兴致勃勃,语法设计了、解析器写了、执行引擎能跑通了,上线三个月后开始出问题——原因是每次改规则,没有足够的信心确认不影响存量。没有测试基线的规则DSL,只是把代码的债务翻译成了规则的债务,而且更难发现。
所以我建议,规则测试基线要在DSL语法方案确定的时候就同步设计,而不是等规则多了再补。平台提供用例管理界面,每次规则变更强制关联用例,发布流水线里必须有"跑全量规则用例集"这一步。这条约束坚持一年,规则库的质量会比同期没有测试的平台高出一个量级。投入产出比最高的治理动作,不是更强大的引擎,而是更不容妥协的测试门槛。
写到这里,把个人经验做个小结。我做规则DSL最大的体会是:不要为了DSL而DSL,DSL的语法设计永远只是表面,真正值钱的是配套的治理机制。一套成功的规则DSL,一定是有清晰动作边界、有完备元信息、有测试基线、有发布和灰度机制的。它让规则从"代码里的一团逻辑"变成了"平台上的一个资产"——有生命周期、有负责人、有质量度量。如果你的系统也正在被散落规则折磨,建议先别急着设计语法,先花两三天梳理清楚:你们的规则现在有多少条、谁在改、每次改动的流程是什么、出了线上问题靠什么排查。把这些问题想透了,再回头设计规则DSL,你会发现原来语法反而是最简单的部分。