ruflo:轻量级流程编排内核的设计与实践

ruflo:轻量级流程编排内核的设计与实践
先说结论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-coredependency groupIdio.github.ruflo/groupId artifactIdruflo-core/artifactId version0.1.0/version /dependency初始化也比较简单RufloEngine engine RufloEngine.builder() .scanPackage(com.example.ruflo) .build();scanPackage用于扫描业务侧写的节点处理器。这些处理器通常是一个个 Spring Beanruflo在启动时把它们按注解注册到内部的 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 请求一直占着线程可以改成异步CompletableFutureRufloContext future engine.startAsync(flow, input);异步模式下引擎会在内部线程池里跑完整条链路调用方可以按需get或注册回调。我建议接口层还是用同步方式简单可控只有在非 HTTP 场景比如 MQ 消费后再执行长流程才考虑startAsync。3.5 几个值得关注的调度参数ruflo提供几个关键参数直接影响稳定性和性能参数默认值说明maxConcurrencyCPU 核数 × 2并行节点的最大线程数nodeTimeout5000ms单个节点超时时间retry0节点失败自动重试次数maxLoopCount100防止循环节点死循环线程池大小不要照抄默认值。我常用的估算思路是假设并行分支平均耗时为T秒上游峰值 QPS 是Q那至少需要Q × T个线程才能不排队。比如下游平均耗时 0.2 秒上游 QPS 20那么理论最小线程数是20 × 0.2 4再留 2 到 3 倍缓冲设置maxConcurrency8到12是比较合理的。如果单节点依赖外部接口节点超时一定要设不然第三方服务慢吞吞的时候整个线程池都会被打满。4. 常见问题与排查技巧从“跑不通”到“不敢上线”4.1 流程卡死先怀疑回路和并行分支ruflo我实际用下来最常见的线上问题不是代码写错而是流程根本没结束。这时第一件事就是看是不是出现了循环。比如有人在 DSL 里配了一个审批驳回后回到“人工初审”的边逻辑上没错但没限制退回次数。当审批一直被驳回时流程就会在同一个区域转圈。这时候即使代码逻辑正常输出会延迟线程池也可能被占满。排查思路是三步看监控里的活跃实例数是不是只增不减。直接查当前流程实例停在哪一个nodeId配一张节点状态表。检查该节点是否被反复进入记录进入次数。我之前加了一个调试 APIengine.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 线上问题排查日志怎么打流程引擎的日志和平常接口日志不一样它天然是“多节点跨方法”的所以日志里一定要带两个关键 IDflowInstanceId和nodeId。我在ruflo的执行器里强制在进入节点时打印一条log.info格式固定[ruflo][flowcredit_review_v1][instance8f2ab1][nodecollect_data] start [ruflo][flowcredit_review_v1][instance8f2ab1][nodecollect_data] finish cost132ms有了这种日志排查问题时直接 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的价值是帮你把合理的流程执行得更稳、更透明而不是替你把混乱的流程理顺。

最新新闻

日新闻

周新闻

月新闻