低代码平台动态引擎设计与实践:Liquor规则配置化与热更新
2026/9/10 0:40:51 网站建设 项目流程

做低代码平台这几年,我最大的体会是:平台好不好用,不看你拖拽组件做了多少,而看业务逻辑能不能“动”起来。我说的“动”,不是改个字段、换张表单,而是不重新发版、不重启服务,就能动态修改一段计算规则、一个审批分支、一条外部接口对接逻辑。这套东西在 Java 技术栈里尤其难做,因为 Java 天生偏静态、强类型,跑起来以后想再改行为,绕不开类加载、表达式解析、安全沙箱这一堆问题。我手上这套内部项目的代号叫 Liquor,它就是我们低代码平台里的那层“动态引擎”。

Liquor 解决的核心问题很简单:把平台里最容易变的规则、脚本、数据映射、接口编排,全部配置化、可热更新,让开发者和实施顾问不用碰代码就能调业务。它适合谁?适合那些正在自研低代码平台、规则引擎、或者想把现有 Java 项目里硬编码逻辑抽出去做成可配置能力的团队。这篇文章我会从为什么需要动态引擎、Liquor 的整体设计、如何落地一个动态规则场景,以及踩坑排查这四块展开,把我实际运行这个引擎的经验全部倒出来。

1. 为什么低代码平台需要一个“动态引擎”

1.1 低代码平台的核心矛盾

低代码平台的卖点本来是“快”,但真正到业务现场就会发现,快只是第一层。第二层是“改起来也得快”。业务方今天说要调运费规则,明天说过滤逻辑要加一个条件,如果你每次都得改 Java 代码、走发布流程,那这个低代码平台本质上就是个“低效率代码平台”。

做久了你会发现,平台里的大部分变更都集中在少量逻辑点上:费用计算、状态流转、数据校验、消息内容拼接、外部接口路由。这些逻辑的特点是:变化频繁、逻辑不复杂、但每个租户/每个业务线都有自己的版本。如果把它们写死在 Java 里,你维护的就是一大堆 if-else 和策略类;如果把它们做成配置,你就需要一个能安全、高效、可观测地执行动态逻辑的引擎。

Liquor 就是在这个矛盾里长出来的。它不是要取代平台的主流程,而是给主流程开一个口子,让“该变的”能变,“不该变的”保持稳定。

1.2 Liquor 引擎在平台中的定位

我习惯把低代码平台从底往上分四层:数据层、能力层、编排层、交互层。Liquor 的位置在能力层和编排层之间,它可以被理解为“逻辑执行底座”。

具体点说,当平台上的一个动作被触发时,比如用户提交了一张订单,平台会先走数据校验,再走费用计算,然后走审批流,最后写库发通知。原来的做法是每个环节都调不同的 Java 服务,现在改成了每个环节都先查一下 Liquor 配置中心,看看这个租户有没有自定义规则,如果有,就动态执行;没有,就走默认实现。

这样设计有个明显的好处:接入方可以做到“默认逻辑开箱即用,特殊逻辑按需配置”。而且 Liquor 自己不感知业务,它只管拿到一段规则配置、一批输入参数,然后返回执行结果。业务语义全在配置里,引擎不碰。

1.3 选型前必须先想清楚的三个问题

在做 Liquor 之前,我们内部其实争论过好几轮。最核心的三个问题,如果你也想自研动态引擎,我建议先想明白:

第一,动态到什么程度?只支持简单表达式判断,还是需要完整的脚本流程?这决定了你要引入的是 SpEL、Groovy、还是干脆嵌一个 JVM 语言运行时。提这个是因为很多团队一开始只想做规则判断,后来需求一变,又得支持循环、中间变量、调用外部服务,架构推倒重来非常痛。

第二,谁来写规则?如果是研发写,那可以用 Groovy、Java 语法;如果是产品经理或实施顾问写,那必须提供可视化规则编辑器,底层的脚本由引擎生成。Liquor 的定位是两种都支持,既有面向研发的脚本模式,也有面向业务的规则集模式。

第三,安全边界怎么收?动态执行代码必然涉及类加载和反射,配置错了可能影响主流程,甚至被注入恶意逻辑。这个问题不能等上线后再说,必须在设计阶段就定好沙箱机制、资源隔离、权限控制。

这三个问题想清楚,你才不会把动态引擎做成一个“能跑就行”的工具,而是做成一个“跑得稳、出问题能查、性能扛得住”的底座。

2. Liquor 的整体设计与核心能力拆解

2.1 引擎的分层架构

Liquor 的整体结构我按“元模型、执行、治理”三个维度来组织。元模型层负责定义规则和脚本的结构,比如一个规则由条件、动作、优先级、版本、生效时间组成;执行层负责把元模型翻译成可执行逻辑,比如把 JSON 规则编译成 SpEL 表达式链,或者把 Groovy 脚本编译成 Class 对象缓存起来;治理层负责配置管理、版本灰度、日志追踪、熔断降级。

这个分层带来的直接好处是,三块可以独立演进。元模型层是纯数据定义,不依赖 Spring;执行层只依赖配置接口,不关心配置存在数据库还是文件里;治理层是 Web 管理端,通过 API 和引擎交互。这样拆开以后,引擎核心可以单独打成一个 jar 包,任何 Java 项目都能嵌入使用,不用把整个低代码平台都引进来。

在实际运行中,Liquor 每次执行请求会走这样一条链路:接收执行请求 → 从缓存取元模型 → 校验输入参数 → 执行条件匹配 → 执行动作逻辑 → 记录执行日志 → 返回结果。如果某一步发生异常,引擎会捕获并落日志,同时支持降级到默认行为,避免一个动态规则配置错误拖垮整个平台。

2.2 表达式与脚本的执行模型

Liquor 的执行模型是混合的。简单判断用 SpEL(Spring Expression Language),复杂逻辑用 Groovy,数据映射用 JSONPath。为什么这么选?我一个个说。

SpEL 的好处是 Spring 生态自带,不需要额外引入脚本引擎,安全性相对好控制,适合做“条件表达式”。比如amount > 100 && region == 'CN'这种,解析快,也不怕用户写出无限循环。Groovy 则适合做完整的一段逻辑,比如多步骤计算、循环处理列表、调用平台内部服务。Groovy 脚本可以编译成 Class,性能比逐行解释高很多,但代价是要自己做类缓存和沙箱。

在 Liquor 里,我定义了一套配置协议。规则的执行单位叫Rule,由多个Condition和多个Action组成。条件部分支持 SpEL,动作部分可以是 Groovy 脚本、HTTP 调用、或者数据映射表达式。执行时引擎先跑所有条件,按优先级排序后执行命中的动作,动作结果会合并到上下文里,供后续规则使用。

这种模型最大的价值在于:规则可以拆得很细,每个规则只负责一小块逻辑,组合起来却可以完成一个完整流程。比如运费计算可以拆成“基础运费规则”“重量加价规则”“区域优惠规则”,三个规则独立配置、独立测试、按顺序执行。业务方想调价,只需要改其中一条规则,不影响其他逻辑。

2.3 安全沙箱与资源隔离

动态执行代码,最怕的就是“脚本里写死循环,把 CPU 打满”或者“脚本里反射调用系统命令”。在这方面 Liquor 吃过亏,所以现在的沙箱设计是认真做的。

第一层是访问控制。Groovy 脚本执行前会经过一个GroovyClassLoader的白名单校验,禁止解析SystemRuntimeClassLoaderThread等关键类。同时通过SecureASTCustomizer限制语法,比如禁止 import 任意类、限制方法调用范围,只允许调用 Liquor 主动暴露的 API 和上下文对象。

第二层是资源限制。每次脚本执行都必须带着超时时间,默认 3 秒,超时就中断线程并记录告警。执行线程池有独立的队列容量和拒绝策略,脚本执行并发太高时,快速失败而不是无限排队。这个设计在高峰期非常关键,不然一个脚本卡住,整个平台的请求线程都会被拖住。

第三层是数据隔离。Liquor 的上下文对象是一个Map,脚本只能访问到引擎主动放入的数据,拿不到应用的其他类。所有写操作都被拦截,脚本里不能直接改外部变量,只能通过引擎提供的out对象返回结果。这样一来,即使脚本有 bug,影响范围也被控制在一次执行内部,不会污染公共状态。

2.4 规则编排与流程编排

光有单个规则还不够,真实业务往往是多个规则串起来。Liquor 的编排能力分两层:规则集(RuleSet)和流程(Flow)。

规则集解决“同一步逻辑的多种策略”问题。比如不同的客户等级适用不同的折扣计算,规则集按优先级从上往下匹配,命中即停。这个类似 Drools 的 agenda,但比 Drools 轻很多,因为我们是纯表达式匹配,不引入完整规则引擎。

流程解决“多步逻辑的顺序串联”问题。流程可以配置节点列表,每个节点引用一个规则集、一个脚本、或一个外部服务调用。节点之间通过上下文传参,支持分支条件、并行执行、失败重试。流程引擎本身很薄,不做复杂的 BPMN 建模,只做简单的 DAG 执行,因为低代码平台里太重的工作流应该交给专门的流程引擎,Liquor 负责的是“逻辑自动化的最后一公里”。

实际配置一个流程时,我会把它设计成 JSON 结构。一个流程由节点数组组成,每个节点有typeruleIdnexterrorHandler等字段。这种表达方式有个好处:和前端可视化编辑器天然契合,前端画布上的一个方块就是一个节点,连线就是 next 指针,整个流程的定义可以直接落库,也可以导出导入做环境迁移。

3. 实操落地:从零接入一个动态规则场景

3.1 环境准备与依赖引入

Liquor 的核心是一个 Spring Boot Starter,接入方式很简单。我在项目里只需要引入两个依赖,一个是核心引擎,一个是管理端 API。核心引擎负责执行,管理端 API 负责读写规则配置。版本上我建议和 Spring Boot 的主版本对齐,避免类冲突。

<dependency> <groupId>com.yourorg</groupId> <artifactId>liquor-core</artifactId> <version>1.4.0</version> </dependency> <dependency> <groupId>com.yourorg</groupId> <artifactId>liquor-admin-api</artifactId> <version>1.4.0</version> </dependency>

引入依赖后,需要在 application.yml 里打开引擎开关和配置存储方式。Liquor 支持多种配置存储,本地开发用文件,测试环境用数据库,生产环境建议走配置中心。我通常的做法是:本地用liquor.store=local,测试和线上用liquor.store=db,并接一个 Redis 做缓存失效通知。

liquor: enabled: true store: db cache: type: redis ttl: 300 executor: core-pool-size: 8 max-pool-size: 32 queue-capacity: 500 sandbox: timeout-ms: 3000 max-script-size-kb: 32

这些参数看着简单,但每个都值得单独说。core-pool-sizemax-pool-size决定并发执行能力,不是越大越好,因为脚本执行吃 CPU,线程太多反而增加上下文切换。我压测下来的经验是:单机 8 核的容器,核心线程 8、最大 16 就已经很稳了。timeout-ms是安全底线,设太短会把正常慢脚本误杀,设太长又起不到保护作用,3 秒是平衡值。max-script-size-kb限制脚本体积,防止有人塞入超大脚本拖垮编译性能。

3.2 用 Liquor 实现一个动态运费计算规则

我拿一个实际场景来演示:电商订单的运费计算。需求是:订单金额满 99 免运费;不满 99 按重量计价,首重 1kg 8 元,续重每 500g 加 2 元;如果用户是会员,在结果上再打 8 折。

这个需求如果用 Java 写死,就是一段 if-else。但业务方跟我说,过两天可能满减门槛要调成 199,会员还要分等级,区域不同续重价格也不同。所以我把这段逻辑完全交给 Liquor。

先在管理端配置规则集。第一条规则的名称是“满额免运费”,条件是order.amount >= 99,动作是设置shippingFee = 0并终止后续规则。第二条规则名称是“会员折扣”,条件是user.isMember == true,动作是设置baseFee = baseFee * 0.8。第三条规则是“重量计价”,不设条件,优先级最低,计算逻辑走 Groovy 脚本:

def baseWeight = 1.0 def baseFee = 8.0 def extraWeight = order.weight - baseWeight def extraUnits = Math.ceil(extraWeight / 0.5) def shippingFee = baseFee + extraUnits * 2.0 out.set("shippingFee", shippingFee)

这里有个细节:三条规则的执行顺序不能靠录入顺序,而是靠priority字段。我在第一条规则上设置了priority = 10terminate = true,第二条priority = 20,第三条priority = 30。如果只设置顺序不设置终止标识,命中满额免运费后还会向下继续执行,把运费又从 0 算回去了,这是大多数规则引擎使用者的理解偏差。

接入代码很简单。在订单结算服务里,我注入RuleEngine,构造上下文,然后调用一次执行就能拿到运费结果。

RuleContext ctx = new RuleContext(); ctx.setParam("order", order); ctx.setParam("user", user); RuleResult result = ruleEngine.execute("shipping_fee_rule_set", ctx); BigDecimal shippingFee = (BigDecimal) result.getData().get("shippingFee");

关键点在于业务代码完全不感知运费逻辑的细节。以后业务方把满额门槛从 99 改成 129,我只需要改配置,不需要动代码、不需要发布。这听起来简单,实际价值非常大。我在几个公司都见过这种场景:一个算价逻辑分布在多个服务里,每次调价都要协调一堆人改代码,用 Liquor 之后,至少这类业务变更从“研发任务”变成了“配置任务”。

3.3 动态数据源与动态接口的扩展

运费计算只是最简单的例子。Liquor 里我做得比较重的能力是动态数据源和动态接口。

动态数据源解决的是“不同租户的数据结构不一样但查询逻辑相似”的问题。比如平台要展示订单列表,A 租户的订单有门店字段,B 租户没有。原来的做法是给表加一堆冗余字段,或者做成 EAV 结构,查询时拼 SQL。用 Liquor 的做法是:在流程里加一个“数据查询节点”,配置好 SQL 模板和 JSONPath 映射,执行时根据租户标识动态拼出 SQL,查询结果再映射成统一结构返回。

这样做要注意两个坑。第一个是 SQL 模板不能用字符串拼接,要用预编译?占位符加白名单校验,防止注入。第二个是动态查询结果不能直接塞回业务对象,要先转成Map,再由数据映射节点转换,避免结构不匹配导致ClassCastException

动态接口是面向外部对接场景的。低代码平台经常要把数据推给第三方,或者从第三方拉数据。每个第三方的报文格式都不一样,鉴权方式也不一样。Liquor 支持配置一个“接口节点”,里面填请求地址、请求头、鉴权参数模板、报文映射规则。执行时引擎根据配置动态发起 HTTP 调用,再用 JSONPath 从响应里提取字段,写入上下文。

我见过一个很折腾的对接案例:某个物流接口要求先调一个 token 接口拿凭证,凭证有效期二十分钟,然后调下单接口时要把 token 放在 header 里,报文里还要按车牌号散列取签名。这套逻辑如果用 Java 写,每个新对接方都要开发联调;用 Liquor 配置两个接口节点加一个 Groovy 脚本,实施人员在后台点几下就能上线。当然,能这样做的前提是引擎把幂等、超时、重试、熔断这些通用能力都做好了,配置的人只需要关心业务映射本身。

3.4 性能调优与缓存策略

动态引擎最容易被诟病的就是性能。Liquor 在这方面做了三件事:脚本编译缓存、规则集缓存、表达式预解析。

Groovy 脚本的编译开销很大,所以绝对不能每次执行都重新编译。Liquor 内部维护了一个 ConcurrentHashMap,key 是脚本内容的哈希值,value 是编译后的Class。同一个版本、同一段脚本,只要内容不变,第二次执行直接走类实例化,不再编译。配合版本发布接口,脚本升级时自动清掉旧缓存,新版本流量再自然切换。

规则集缓存也不是简单的全量缓存。我的策略是双层:第一层是本地 Caffeine 缓存,TTL 设置 5 分钟;第二层是 Redis 缓存,TTL 设置 1 小时。配置变更时,管理端主动发一个版本号递增事件,各节点收到事件后清除本地缓存,重新加载最新版本。这套机制能保证最多 5 分钟的最终一致性,对绝大多数业务场景足够。如果你要做秒级生效,可以把 Caffeine TTL 调短,或者直接用 Redis Pub/Sub 通知。

压测方面,我做过一组对比。同样一段运费计算逻辑,纯 Java 实现单线程执行一万次大约 120ms,Groovy 脚本编译缓存后执行一万次大约 350ms,SpEL 表达式执行一万次大约 220ms。动态执行的性能是静态代码的 2 到 3 倍,但对单个请求来说,几百微秒的差距用户根本感知不到。真正的性能瓶颈往往不在引擎,而在脚本里有没有写数据库查询、有没有调外部接口、有没有在循环里做耗时的 IO 操作。所以我在引擎里加了一个链路追踪能力:脚本执行耗时超过 100ms 就记录慢日志,超过 1 秒直接报警,定位到具体规则和节点。

4. 常见问题与排查技巧实录

4.1 问题和排查速查表

Liquor 上线这段时间,我总结了一张问题排查速查表,覆盖了我遇到的大部分线上问题。

现象可能原因排查手段
规则不生效本地缓存未过期,或版本号未递增检查版本号、刷新缓存、看执行日志
脚本执行超时脚本里有死循环或耗时 IO看慢日志、终止执行、优化脚本逻辑
表达式解析异常SpEL 语法错误或上下文缺字段使用管理端自带的表达式测试工具
规则命中顺序不对优先级设置错误或终止标识缺失检查 priority 和 terminate 配置
发版后被旧规则覆盖多环境配置未同步用规则导入导出工具做环境迁移
脚本里拿不到用户对象上下文字段名不一致检查上下文 setParam 的 key
并发高峰执行排队线程池配置过小调整 core-pool-size 和 queue-capacity
脚本修改后不生效编译缓存未失效检查脚本内容哈希是否变化

这张表不复杂,但排查价值很高。很多时候线上出问题,不是引擎 bug,而是缓存、版本、字段名这些“低级但隐蔽”的原因。

4.2 三个典型坑的复盘

第一个坑是 Groovy 沙箱逃逸。早期版本的白名单校验不够严,有用户在脚本里用反射拿到了Runtime并执行了系统命令。虽然只是内部测试,但吓得我连夜把所有脚本全部禁用,重新设计沙箱。现在的规则是:Groovy 脚本不允许import任何类,所有需要的能力通过上下文对象公开方法暴露,编译时做 AST 校验,运行时做调用拦截。不要迷信单一校验机制,编译期和运行期要双重把关。

第二个坑是规则命中后的“僵尸逻辑”。有一次业务方反馈某个租户的订单总是算错运费,查了半天发现是旧版本规则没有下线,新规则和旧规则同时在执行列表里,由于旧优先级高,一直命中旧逻辑。后来我加了一条铁律:任何规则变更必须走版本发布,旧版本自动标记为失效,不能只编辑不发布。管理端也要有“生效规则预览”功能,规则集上线前可以查看当前环境的完整规则快照。

第三个坑是执行日志的 IO 压力。引擎每执行一次规则就写一条日志,高峰时每秒上万条,直接把数据库打爆了。现在的方案是日志先写内存环形缓冲,异步批量落库;执行结果和耗时指标走 Prometheus 监控,只有异常和慢日志才会写详细链路。监控和执行日志要分离,不能把日志系统当业务系统用。

4.3 调试动态逻辑的实用技巧

Liquor 的管理端我专门做了一个“调试台”功能。在里面可以选一个规则集,填测试参数 JSON,然后单步执行,查看每一步的上下文变化。这个功能看着简单,却是排查问题效率最高的工具。没有它,你只能靠加日志和猜,有它,你可以像 debug Java 一样 debug 动态逻辑。

除了调试台,我还分享几个小技巧。第一,规则集里每个节点都应该设置description,写在业务上是什么意思、开发者是谁、什么时候改的。动态配置的维护成本不在写,而在半年之后没人看得懂当初为什么这么配。第二,表达式测试要写边界用例,空字符串、负数、超大金额、缺失字段,都要在调试台里跑一遍。第三,执行结果建议打印一份“规则命中摘要”,包含命中了哪些规则、每个规则耗时多少、输出了什么值。这段摘要日志平时不落库,只在用户反馈异常时手动开启,能省去大量抓包排查的时间。

还有一个容易被忽视的点:上下文里的字段命名要有约定,统一用snake_case还是camelCase,必须在团队里定死。我见过一个项目,有的脚本用orderNo,有的用order_no,排查时人直接崩溃。Liquor 的配置协议里我把标准字段名做了映射层,命名不规范时管理端会给出校验警告,但根本上还是靠团队规范约束。

最后说一个我在实际运行 Liquor 后才明白的道理:动态引擎的代码复杂度并不高,真正的复杂度在于它让“谁都可以改逻辑”之后,怎么保证改了不闯祸。我的经验是两条腿走路:技术上做好沙箱、超时、缓存、监控,管理上做好版本、预览、审批、审计。两条腿都齐了,Liquor 才能从一个“会跑的引擎”变成一个“跑得让人放心的引擎”。

如果你也在折腾低代码平台的动态化,我建议别一上来就搞特别重的规则引擎或者自研脚本语言,先用表达式加轻量脚本把 80% 的配置化需求覆盖掉,跑通了再逐步加编排、加治理。Liquor 这套玩意儿的核心设计思路就是:能不做的事坚决不做,但做出来就一定要稳、要可查、要可控。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询