先说结论:ruflo是我这段时间一直在维护的一个轻量级流程编排内核,名字就是 rule flow 的缩拼。它的核心模型只有三样东西:节点、边、上下文。因为陆续有朋友问这项目到底解决什么问题、怎么在业务里落地,我把设计思路、实操步骤和踩过的坑整理成这篇文章。它不是一个 BPMN 2.0 的完整实现,也不打算跟 Activiti、Flowable 这类重型流程引擎抢位置;它更适合那些不想背一整套流程协议,只想用少量有向图把业务规则、审批链路、数据加工管道串起来的场景。如果你正在做风控规则链、审批流、订单状态流转、任务编排这类需求,这篇文章应该能给你一个可以直接抄作业的框架。
1. ruflo 到底是什么:先把这个项目定位说清楚
1.1 一个被重型工作流引擎逼出来的小工具
我之前在项目里接触过不少流程引擎,最直观的感受是:功能和复杂度是绑在一起的。Activiti、Flowable 这类产品确实强大,有完整的 BPMN 规范、流程引擎服务、管理后台、历史表,但问题是,大部分业务根本用不到这么重的底座。
比如一个信贷申请进来,要做数据补全、规则判断、人工审批、结果通知,整条链路可能也就五六个节点。如果为了这几个节点专门部署一套流程引擎服务,团队要学习流程协议,要维护流程表结构,要处理引擎版本升级带来的兼容问题,往往得不偿失。ruflo的想法很简单:把流程编排的核心能力做成一个可以嵌入应用的轻量内核,不用单独部署,不用引入复杂协议,用一张图和一个上下文就能跑完一条业务链路。
我也给它划了一条边界:它管“流程怎么走”,不管“业务怎么做”。节点里的真实逻辑、数据读取、外部接口调用,都留给业务代码自己实现。引擎只负责把节点串起来、把条件判断好、把并行分支合并好、把状态和结果在上下文中传递好。
1.2 规则流和常规工作流的边界在哪
很多人容易把规则流、工作流、状态机混在一起,实际用的时候会发现它们解决的是不同层面的问题。
- 工作流通常强调“人工任务 + 流程审批 + 表单流转”,比如 OA 审批,需要任务分配、代办、驳回、会签,这个领域 BPMN 是标准。
- 状态机强调“业务对象的状态迁移”,比如订单从待付款到已付款,必须经过合法的事件,非法迁移直接拒绝。
- 规则流强调“让数据沿着一条有向路径流动”,路径中的分支通过规则表达式判断,路径的终点是某个结果。
ruflo偏向这一类。
我的结论是:如果你的流程里大量出现“等待某个业务事件”、“跨系统、跨部门、多表单、多人工”,那就该认真评估重型工作流引擎;如果核心诉求是把一组规则、任务、接口调用按顺序和分支串起来,并且希望代码还能保持调试方便、性能可控,那ruflo这类轻量内核会更顺手。
1.3 适合谁来用
ruflo不是银弹,适合它的场景主要有几类:
- 风控规则链:一个请求进来,按顺序走反欺诈、征信评分、额度判断、人工复核。
- 数据加工管道:从消息队列拿数据,做清洗、转换、校验、落库,需要在中间某个环节“出错时走旁路”。
- 服务编排聚合:并行调用多个下游接口,全部完成后汇总结果。
- 轻量审批复核:不需要完整 BPMN 能力,只需要“按顺序经过几个审批人”的简单审批流。
适合的团队最好是有一定 Java/Spring 底子,愿意把流程定义当作代码一样管理。它不适合刚入门的同学一上来就做特别复杂的编排,也不适合需要图形化流程设计器开箱即用的项目。
2. 核心设计拆解:节点、边、上下文这三件事怎么组织
2.1 一张有向图足够描述绝大多数业务
ruflo的底层模型是一张有向无环图。每个业务动作是一个节点,节点之间的箭头表示“下一步去哪”。如果你把它想象成地铁换乘图,节点是站点,边是线路,引擎就是那个帮你按最短路径把乘客送到目的地的人。
为什么用有向图而不是写死if-else?因为图结构有几个天然优势:
- 可配置:流程变更时只改 DSL,不用改业务代码。
- 可观察:每个节点的进入、退出、耗时都可以被监控。
- 可组合:流程之间可以复用公共子流程。
- 可控并发:并行分支在图上表达清晰,引擎能同时触发多个节点。
ruflo没有要求必须是无环图,但实际使用时我会强调尽量避免环。因为一旦出现环,业务里很容易出现“循环审批直到某个条件满足”这种需求,而这类需求放到流程引擎里最容易引发死循环和资源配置问题。如果确实要循环,建议把循环次数上限显式写在 DSL 里,并且加上全局超时控制。
2.2 节点类型设计:任务、条件、并行、聚合
在ruflo的 DSL 里,节点类型不需要很多,够用就行。我最初设计时定义了四类核心节点:
start:流程入口,必须有且只有一个,负责初始化上下文。task:执行一个业务方法,比如查征信、发短信、写订单表。condition:根据上下文变量走then或else分支,等价于if-else。parallel:把一个节点变成多个并行子分支,所有分支结束后进入join节点进行聚合。end:流程终点,输出最终结果。
早期我还考虑过wait节点,用来表达“等待外部回调”,但后来发现这种需求会牵扯到流程持久化和状态恢复,复杂度成倍上升。单机内存里做wait也容易丢失状态。所以ruflo第一版明确不支持阻塞等待,需要外部事件的场景建议拆成多个流程实例,用业务主键关联。
一个好的引擎设计,不是功能越多越好,而是每个功能都能被准确控制。加节点类型很容易,但每一种类型都意味着执行器、序列化、监控、异常处理都要相应扩展。能从四类节点解决的问题,不要一开始就上第十类。
2.3 DSL 怎么设计才能既好写又好看
ruflo的流程定义我用 YAML。选择 YAML 而不是 JSON,是因为在流程定义里有很多缩进层级,YAML 读起来比 JSON 清爽很多。之所以不直接用 Java 代码表达流程,是为了让流程定义和实现代码分离,这样运营或后端同学在 Review 流程变更时,能看到一份清晰的“流程图文本”,而不是在代码里翻来翻去。
一个典型的 DSL 长这样:
id: credit_review_v1 name: 信贷申请风控流程 version: 1 start: start nodes: - id: start type: start next: collect_data - id: collect_data type: task handler: collectCreditDataHandler next: risk_rule - id: risk_rule type: condition expression: riskScore >= 600 && creditLimit <= 20000 then: parallel_approve else: reject - id: parallel_approve type: parallel branches: - manager_approve - legal_approve join: combine_result - id: manager_approve type: task handler: managerApproveHandler - id: legal_approve type: task handler: legalApproveHandler - id: combine_result type: task handler: combineApproveResultHandler next: notify_result - id: notify_result type: task handler: notifyResultHandler next: end - id: reject type: task handler: rejectHandler next: end - id: end type: end这份 DSL 基本不需要额外解释:从start开始,到collect_data收集数据,然后判断risk_rule,满足进并行审批,不满足直接拒绝。每个task通过handler指定业务实现,condition通过expression指定判断逻辑。
可能有人会问,为什么条件不直接写在 Java 里,反而要写进 DSL?答案是:可观测和可热更新。当一个流程在线上出了问题,你能直接看到 DSL 里条件的当前版本,而不是去代码里猜测条件被改成了什么。另一个好处是后续可以做一个简单的规则配置后台,把表达式从数据库读出来,实现不发布代码就调整规则。
2.4 执行上下文与变量作用域
流程引擎最重要的东西不是图,而是“数据怎么在节点之间传”。ruflo把数据放在RufloContext里,本质是一个带层级作用域的Map。
具体规则是:
- 全局变量:整个流程实例共享,比如订单号、用户ID、风险评分。
- 节点局部变量:只在当前节点内可见,防止多个节点因为变量名冲突互相覆盖。
- 并行分支变量:每个分支有独立上下文,分支结束后由
join节点手动把结果合并回主上下文。
为什么要做作用域隔离?我见过很多没有作用域设计的流程引擎,所有人都在同一个Map里写变量,时间一长根本分不清这个字段是谁写的、什么时候写的。并行分支更危险,两个节点同时写同一个 key,后写的人会覆盖先写的人,最终结果不可预期。
ruflo的做法是:进入parallel时,为每个分支创建子上下文;子上下文可以读全局变量,但写操作默认只落在子上下文;进入join节点时,父上下文合并所有子上下文里显式标记为“需要回写”的变量。这样既灵活又不容易污染。
3. 实操:在一套 Java 应用里把 ruflo 流程跑起来
3.1 引入依赖和初始化
假设项目用的是 Maven 或 Gradle,依赖坐标按实际仓库为准,核心模块只需要一个ruflo-core:
<dependency> <groupId>io.github.ruflo</groupId> <artifactId>ruflo-core</artifactId> <version>0.1.0</version> </dependency>初始化也比较简单:
RufloEngine engine = RufloEngine.builder() .scanPackage("com.example.ruflo") .build();scanPackage用于扫描业务侧写的节点处理器。这些处理器通常是一个个 Spring Bean,ruflo在启动时把它们按注解注册到内部的 handler 表里。这样 DSL 里的handler: collectCreditDataHandler才能定位到具体的 Java 方法。
在 Spring Boot 项目里,我更推荐把RufloEngine声明成一个 Bean,全局复用。引擎内部会维护线程池和 handler 容器,不要每次执行都 new 一个,不然线程资源很快就耗尽。
3.2 用 DSL 定义一条风控流程
流程文件我一般放在src/main/resources/flows目录下,和代码一起走 Git。文件名就用 DSL 里的id,方便查找。
上面那份信贷审核 DSL 就是一个完整例子。这里要注意一个细节:parallel节点里的branches只是分支入口,分支内部的next关系仍然要展开写。我最初设计时想把branches简化为直接写叶子节点,后来发现一旦分支里再套分支,缩进表达就乱套了。最终决定回到“扁平的图定义”,每个节点都是一个平铺对象,通过next、then、else连接。这样最笨,但最不容易出错。
3.3 实现业务节点与规则条件
在ruflo中,业务节点就是普通类加注解。例如数据收集节点:
@Component public class CollectCreditDataHandler { @RufloTask("collectCreditDataHandler") public void handle(RufloContext ctx) { String orderId = ctx.getString("orderId"); CreditData data = creditClient.query(orderId); ctx.put("riskScore", data.getRiskScore()); ctx.put("creditLimit", data.getCreditLimit()); ctx.put("creditReport", data.getReport()); } }这个节点做的事情非常纯粹:从上下文拿订单号,调外部接口,把结果写回上下文。它不关心下一步是谁,不关心流程怎么走。
条件节点则像这样:
@Component public class RiskRuleCondition { @RufloCondition("risk_rule") public boolean evaluate(RufloContext ctx) { int riskScore = ctx.getInt("riskScore"); int creditLimit = ctx.getInt("creditLimit"); return riskScore >= 600 && creditLimit <= 20000; } }@RufloCondition指定的是 DSL 里的节点id。引擎在执行risk_rule节点时,会自动找到这个 Bean 方法,拿到布尔结果决定走then还是else。
这样的好处也很明显:业务逻辑和流程结构彻底解耦。你要调整审批阈值,改的是 Java 方法里的数字,但流程长什么样、分支怎么连,还是看 DSL 就够了。
3.4 执行、传参和拿到结果
执行流程入口非常短:
Flow flow = engine.loadFlow("classpath:flows/credit_review_v1.yaml"); RufloContext ctx = engine.start(flow, input -> input .var("orderId", "A123456") .var("applyAmount", 15000) .timeout(5000) ); String finalDecision = ctx.getString("finalDecision");engine.start会同步阻塞到流程结束。如果并行节点里有两个 500ms 的接口调用,那整个流程在理想情况下大概是 500ms 到 600ms,而不是两个接口串行的一秒多。这也是编排引擎最直接的价值:把可以并行的部分真正并行起来。
如果流程比较长,不想让 HTTP 请求一直占着线程,可以改成异步:
CompletableFuture<RufloContext> future = engine.startAsync(flow, input);异步模式下,引擎会在内部线程池里跑完整条链路,调用方可以按需get或注册回调。我建议接口层还是用同步方式,简单可控;只有在非 HTTP 场景,比如 MQ 消费后再执行长流程,才考虑startAsync。
3.5 几个值得关注的调度参数
ruflo提供几个关键参数,直接影响稳定性和性能:
| 参数 | 默认值 | 说明 |
|---|---|---|
maxConcurrency | CPU 核数 × 2 | 并行节点的最大线程数 |
nodeTimeout | 5000ms | 单个节点超时时间 |
retry | 0 | 节点失败自动重试次数 |
maxLoopCount | 100 | 防止循环节点死循环 |
线程池大小不要照抄默认值。我常用的估算思路是:假设并行分支平均耗时为T秒,上游峰值 QPS 是Q,那至少需要Q × T个线程才能不排队。比如下游平均耗时 0.2 秒,上游 QPS 20,那么理论最小线程数是20 × 0.2 = 4,再留 2 到 3 倍缓冲,设置maxConcurrency=8到12是比较合理的。如果单节点依赖外部接口,节点超时一定要设,不然第三方服务慢吞吞的时候,整个线程池都会被打满。
4. 常见问题与排查技巧:从“跑不通”到“不敢上线”
4.1 流程卡死:先怀疑回路和并行分支
ruflo我实际用下来,最常见的线上问题不是代码写错,而是流程根本没结束。这时第一件事就是看是不是出现了循环。
比如有人在 DSL 里配了一个审批驳回后回到“人工初审”的边,逻辑上没错,但没限制退回次数。当审批一直被驳回时,流程就会在同一个区域转圈。这时候即使代码逻辑正常,输出会延迟,线程池也可能被占满。
排查思路是三步:
- 看监控里的活跃实例数,是不是只增不减。
- 直接查当前流程实例停在哪一个
nodeId,配一张节点状态表。 - 检查该节点是否被反复进入,记录进入次数。
我之前加了一个调试 API:engine.dumpGraph(flowId),会打印每个节点的进入次数和最后执行时间。这比看日志强得多,因为流程图是静态的,实例是动态的,只有把“静态节点”和“动态执行痕迹”放在一起才能定位问题。
4.2 数据重复处理:幂等设计不能靠运气
流程引擎里面最容易让人掉坑的是重试。一个请求超时后重试,可能上游已经处理成功了,再次执行节点就会造成重复扣款、重复发短信、重复建单。
ruflo的retry参数很好用,但必须配合业务幂等。引擎可以保证“至少一次”,做不到“恰好一次”。每个task节点最好都处理这样一个问题:这个节点被第二次执行时,数据还是对的状态吗?
我常用的手段是给业务表加唯一键,或者用“操作记录表 + 状态字段”实现幂等。例如审批节点:
@RufloTask("managerApproveHandler") public void handle(RufloContext ctx) { String flowInstanceId = ctx.getFlowInstanceId(); String orderId = ctx.getString("orderId"); ApprovalRecord record = approvalMapper.selectByFlowAndOrder(flowInstanceId, orderId); if (record != null && record.getStatus() == ApprovalStatus.APPROVED) { return; } approvalService.approve(orderId); approvalMapper.insert(ApprovalRecord.builder() .flowInstanceId(flowInstanceId) .orderId(orderId) .status(ApprovalStatus.APPROVED) .build()); }这样即使同一个节点被重试两次,第二次会因为记录已存在而直接跳过。幂等不是引擎层能替你解决的,必须由写业务代码的人负责。
4.3 事务边界:流程引擎不要替你开事务
设计ruflo时我明确决定:引擎不管理数据库事务。原因很简单,一个流程里可能既有数据库操作,又有外部 RPC 调用,如果引擎把所有节点包在一个事务里,只要有一个远程调用慢,数据库连接就会被长期占用。
正确做法是每个业务节点自己决定事务边界。只有涉及本地数据库写操作的节点才加@Transactional,外部调用尽量放在事务外面。
一个实际案例是支付回调流程:节点 A 更新数据库,节点 B 调用短信服务,节点 C 再更新状态。如果引擎包大事务,节点 B 网络抖动 3 秒,数据库连接就被占用 3 秒,QPS 一高直接连接池耗尽。改成节点 A 独立事务提交后,再进入节点 B,问题就消失了。
4.4 线上问题排查日志怎么打
流程引擎的日志和平常接口日志不一样,它天然是“多节点跨方法”的,所以日志里一定要带两个关键 ID:flowInstanceId和nodeId。我在ruflo的执行器里强制在进入节点时打印一条log.info,格式固定:
[ruflo][flow=credit_review_v1][instance=8f2ab1][node=collect_data] start [ruflo][flow=credit_review_v1][instance=8f2ab1][node=collect_data] finish cost=132ms有了这种日志,排查问题时直接 grep 一个instance就能拼出完整执行链路。变量内容不建议全量打印,尤其涉及手机号、身份证这种敏感信息时,只打印业务主键和结果状态。
4.5 版本升级后老实例怎么办
线上流程不可能永远不变。今天风控规则阈值调了,明天审批节点多了一个,这是常态。ruflo处理版本的原则是:流程定义带版本号,新实例用新版本,老实例继续用旧版本。
这个策略成本最低,也符合业务直觉。实现上,engine.loadFlow可以指定版本,或者让引擎按“当前启用版本”加载。我建议把流程定义快照和流程实例绑定:
- 流程实例表里存一份
flow_snapshot_id。 - 实例执行时加载该快照对应的 DSL 内容。
- 后续无论流程定义怎么改,老实例的步骤始终不变。
如果确实希望老实例迁移到新版本,我建议写一个显式迁移工具,而不是在引擎里偷偷替换。流程的可追溯性比开发省事更重要。
5. ruflo 后续可以怎么扩展
5.1 可视化设计器怎么接
ruflo本身不带图形化设计器,但 DSL 是结构化数据,接入前端并不难。最简单的做法是后端把Flow对象序列化成 JSON,前端用现成的图编辑库渲染,拖拽改完之后再转换回 DSL。
如果你不想做完整设计器,可以先做一个“只读拓扑图”页面,把流程节点和边渲染出来。这对于排查线上问题帮助极大。我个人的经验是,只读视图可以先做,编辑功能放到第二期,因为编辑牵扯到节点属性表单、校验、版本提交和一键灰度,工作量远超想象。
5.2 持久化和监控
目前ruflo的默认执行态都在内存里,适合流程量不大、节点耗时短的场景。如果要支持更多流程,需要把三类数据持久化:
- 流程定义表:存 DSL 内容、版本、状态。
- 流程实例表:存当前执行到哪个节点、整体状态。
- 节点执行日志表:存每个节点的开始、结束、耗时、重试次数。
有了这些表,就能做出很实用的监控。我最常用的是三个指标:ruflo_execution_total(执行总量)、ruflo_execution_duration(耗时分布)、ruflo_node_failure_count(节点失败数)。当失败数突然上升,通常不是引擎问题,而是某个下游接口不稳定。
5.3 从单机到分布式
需要说清楚,ruflo目前的定位是嵌入式单机引擎,不是分布式工作流平台。如果你有超过几十个节点的长流程、需要跨服务恢复状态、需要调度器保证高可用,那应该去考虑更完整的分布式工作流产品。
不过,如果项目已经用ruflo跑了一段时间,想平滑过渡,可以从“状态外置”开始:把RufloContext序列化到 Redis,节点执行前从 Redis 反序列化上下文,执行完再写回。这样即使应用重启,也能从最近一个节点恢复。但这么做要考虑序列化版本、上下文大小、超时清理等问题,复杂度不低,不要轻易在核心链路上试水。
5.4 给新手的接入建议
如果团队之前没用过流程引擎,我强烈建议不要一上来就设计一个大而全的流程平台。从一个小流程开始,比如“订单退款审批”,跑通以后再逐步加规则分支、并行节点。最开始只把 DSL、执行器、日志搞清楚,后面的事情都会顺很多。
还有一个判断标准,是我这几天反复跟人说的:如果一张流程图的节点数超过 20 个,那你该先考虑拆业务,而不是升级引擎。流程编排工具再强,也不能把一个本来就绕的业务“编排”得清晰。ruflo的价值是帮你把合理的流程执行得更稳、更透明,而不是替你把混乱的流程理顺。