十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SkeletonFlow:面向对象的流程编排框架设计与实践

SkeletonFlow:面向对象的流程编排框架设计与实践 1. SkeletonFlow 的整体设计思路1.1 为什么流程编排代码总是写着写着就烂掉了先说一个我自己的感受。做了几年后端业务开发最怕的不是写新功能而是改一条历史流程。订单流程、审批流程、对账流程这类代码最初都挺清爽的无非是一个方法调下一个方法。但业务一旦复杂起来每个步骤都要加分支判断都要在不同阶段透传不同的上下文数据代码就会迅速变成一锅粥。最常见的一种写法是一个大方法从头写到尾里面十几个 if-else每个分支里夹着几段业务逻辑中间还穿插着各种状态判断。这种代码不是不能跑但它有非常典型的三个问题第一可读性差。一个新同学接手一条流程要从头到尾把一个几百行甚至上千行的方法读完才知道这一步到底在干什么。第二复用困难。订单流程和售后流程可能都有“校验用户状态”这一步但因为写在一个大方法内部提取不出来只能拷贝一份改一改。第三变更风险高。加一个新步骤要读懂原有逻辑之后小心翼翼地插入中间稍微一个判断条件写反线上就出问题。我尝试过很多种解法包括把每一步拆成独立方法再写一个编排方法按顺序调用。这种做法前期有效但到后期会面临新问题这些方法之间没有统一的约定参数的传递全靠编写者自觉时间一长方法签名五花八门有人传对象有人传多个参数有人直接在内部改了全局状态流程本身依然不透明。SkeletonFlow 这个项目就是在这样的背景下产生的。它参考了业界常见的流程编排思路把“流程”本身抽象成一个骨架每个业务环节抽象成骨架上的节点节点与节点之间由一个统一的引擎负责调度。核心思想其实就一句话把流程中的每个环节变成显式的对象让流程结构本身成为可描述、可控制、可扩展的一等公民。1.2 SkeletonFlow 面向对象设计的核心目标在设计 SkeletonFlow 时我给这个框架定了四个目标后续所有的接口设计、类结构设计都是围绕这四个目标展开的。第一个目标是解耦。流程中的每个节点必须可以被独立开发、独立测试。订单流程里的“风控校验”节点拿掉之后不能影响“库存预占”节点的正常运行节点之间只能通过统一的上下文通信不允许直接互相调用。第二个目标是复用。业务里存在大量跨流程复用的场景例如“校验用户是否实名”“校验商品是否在售”“写入操作日志”等等。这些能力必须作为独立的节点沉淀下来在一个流程里用了换个流程还能直接挂上去。第三个目标是可观测。生产环境排查问题最痛苦的就是不知道流程执行到哪一步了是哪个节点失败了失败时上下文里的数据是什么。所以框架必须能记录节点级别的执行状态、耗时和异常信息甚至能还原一条完整执行链路。第四个目标是可测试。业务流程的测试成本往往很高因为一个流程依赖很多外部服务。SkeletonFlow 必须让使用者能够轻松地组装一个“测试专用流程”把外部依赖替换成 Mock 节点而不影响其他节点运行。这四个目标决定了我的设计方向不是又一个函数管道而是一个面向对象的、状态可管理的、流程结构可感知的编排框架。1.3 核心组件概览流程、节点、上下文、引擎SkeletonFlow 的核心组件不算多但每一个都承担明确的职责。我用一个表格先做个快速说明后续章节再逐个拆开讲。组件职责类比FlowContext在流程节点间传递数据的容器生产线上的托盘SkeletonNode单个业务环节的抽象基类生产线上的一个工位CompositeNode组合节点负责编排子节点一个包含多个工位的车间FlowEngine流程执行引擎驱动整体调度生产线的控制中枢FlowStateTracker状态跟踪与日志记录器生产线的监控摄像头FlowContext 解决的是“数据怎么传递”的问题。SkeletonNode 解决的是“业务环节怎么抽象”的问题。CompositeNode 解决的是“复杂的嵌套流程怎么组装”的问题。FlowEngine 解决的是“整条流程怎么被驱动起来”的问题。FlowStateTracker 解决的是“流程跑得怎么样”的问题。这五个组件之间有一个基本的依赖关系FlowEngine 拿到一个入口节点和 FlowContext启动执行。入口节点可以是单个 SkeletonNode也可以是一个 CompositeNode。执行过程中每个节点都可以读写 FlowContextFlowStateTracker 会记录每个节点的状态变化。从这个结构能看出SkeletonFlow 不是一个“银弹”它不会帮你写业务逻辑它只做一件事让你的业务逻辑以清晰、可控、可观测的方式组织起来。2. 核心模型拆解节点、上下文与状态流转2.1 SkeletonNode 节点把业务环节变成“对象”SkeletonNode 是整个框架里最核心的抽象。它不是一个普通的方法而是一个可以被实例化、可以被继承、可以被组合的对象。我设计它的接口时参考了很多流程引擎的做法最终保留了三个最核心的方法。第一个是doCheck(FlowContext context)负责前置校验。每个节点在执行前都应该检查自己的前置条件是否满足比如库存节点要检查商品 ID 是否传了支付节点要检查订单金额是否合法。前置校验不通过时节点可以直接中断流程也可以跳过自己不做处理这个逻辑由节点的返回类型来控制。第二个是doExecute(FlowContext context)负责真正的业务逻辑。这个方法是节点的核心外部依赖的调用、业务规则的计算、数据的写库操作都在这里完成。执行完成后节点把结果数据写入 FlowContext交给后续节点使用。第三个是doFinally(FlowContext context)负责清理工作。无论节点执行成功还是发生异常这个方法都会执行。典型的用途是释放资源、记录日志、恢复线程变量。这三个方法的执行顺序由引擎控制使用者不需要自己调用只需要关心业务逻辑本身。这里还有一个比较关键的设计我把“节点是否继续执行”的控制权从节点内部抽出来了。节点执行完后返回一个NodeResult对象里面包含状态和可选的跳转指令引擎根据这个结果决定是进入下一个节点、跳过某个节点、还是终止整条流程。这样做的好处是流程的控制逻辑可以被外部感知。传统的大方法写法里流程跳转是隐式的藏在某个 if 分支里。而 SkeletonFlow 中跳转是一个显式的结果可以被记录、被监控、被可视化展示。2.2 可变 vs 不可变为什么 FlowContext 选择共享可变状态谈到 FlowContext这里有一个绕不开的话题函数式编程里处处强调不可变数据而 SkeletonFlow 的 FlowContext 恰恰是一个共享的可变对象。是不是设计倒退我自己的理解是不可变数据和共享可变状态之间不是谁更高级的问题而是谁更匹配场景的问题。流程编排这个场景里各节点的核心诉求是“能在执行过程中把数据传递给后续节点而且传递成本要低”。如果用不可变数据每个节点执行完都要产生一个新的上下文对象然后在引擎层做引用替换。这个模型不是不行但它会让代码变得啰嗦。尤其是业务流程里经常需要在前一个节点的处理结果上做增量修改用不可变方式就需要频繁地做对象拷贝。FlowContext 的定位是一个“流程工作台”。节点从里面读取自己需要的数据处理完把结果放回去。它更像一个背包而不是一个函数参数。所以我没有纠结不可变性的问题而是在设计上做了一些约束来弥补可变带来的风险。第一个约束是上下文内部采用命名空间隔离每个节点写入数据时必须指定一个命名空间前缀例如order.finalAmount、risk.rejectReason。这样不同节点之间即使出现同名 key也不会互相覆盖。第二个约束是引擎会在每个节点执行前给上下文打一个快照标记节点出异常时可以回滚到进入节点前的状态避免半截数据污染后续逻辑。第三个约束是上下文对象不允许被节点保存为成员变量节点只能在使用时从方法参数拿到它。这些约束在实际使用中非常管用。它保留了可变对象的高效性和灵活性又通过管理手段规避了大部分共享状态带来的隐患。2.3 流程编排的核心坐标顺序、条件、并行、循环有了节点和上下文接下来要解决的是“节点怎么组合”的问题。SkeletonFlow 采用组合模式把流程编排的方式分成四种基本形态。第一种是顺序编排一个个节点按声明顺序依次执行前一个节点完成后再执行后一个节点。这是最常用的编排方式订单履约、数据清洗、内容审核都是典型的顺序流程。第二种是条件编排根据上下文中的数据判断走哪个分支。我在框架里提供了一个ConditionalNode它内部维护一张映射表key 是条件表达式value 是子节点。引擎执行到这个节点时先计算条件再路由到对应的子节点。这比在业务代码里写 if-else 要清晰得多因为所有分支的入口和出口都是显式的。第三种是并行编排多个节点同时执行全部完成后再进入下一个节点。并行在很多性能敏感的业务里很有用比如一个内容审核流程先并行调用文本审核、图片审核、作者信誉查询三个节点三个结果全部返回后再汇总决策。第四种是循环编排同一组节点在满足条件时反复执行。这个主要用于多轮审批、批量处理、重试补偿等场景。这四种能力分别对应 CompositeNode 的几种子类使用者在配置流程时按需组合。值得强调的是这四种形态不是互斥的它们可以互相嵌套。一个并行节点里可以包着条件节点条件节点里又可以包着顺序节点。这种嵌套组合能力是面向对象组合模式天然的优势。函数式管道里当然也能实现这些逻辑但嵌套复杂了之后类型签名会变得非常难读而对象树的结构则要直观得多。2.4 流程状态机让每一步执行都有据可查流程编排框架如果没有状态管理等于失去了灵魂。SkeletonFlow 里内置了一个轻量的状态机用来跟踪流程和节点的执行状态。流程的顶层状态包括PENDING、RUNNING、COMPLETED、FAILED、TERMINATED。节点的状态则更细一些WAITING、EXECUTING、SUCCEEDED、SKIPPED、FAILED、COMPENSATED。为什么需要这一层状态机直接执行节点、出异常就抛错不行吗如果是一个一次性脚本确实可以。但在生产业务中流程往往需要被重启、被重试、被补偿。比如一个分布式事务里的对账流程某个节点失败了整条流程不能简单地终止可能需要进入补偿节点把前面已经执行成功的步骤回滚掉。状态机负责记录和推进这种复杂的生命周期。具体实现上每个节点执行前后FlowStateTracker 都会收到事件把状态变更写入日志。日志里至少包含流程实例 ID、节点 ID、上一状态、当前状态、变更时间、变更原因。配合链路追踪系统基本可以实现一条流程从开始到结束的全程回放。这部分设计极大地提升了线上排查问题的效率。以前排查一笔订单为什么卡住了要去看各种分散的业务日志拼凑出执行路径。现在直接查流程实例的状态变更记录哪个节点执行的执行了多久成功还是失败一目了然。3. 实操用 SkeletonFlow 落地一个订单履约流程3.1 场景定义与流程拆分前面讲了不少设计理念这一节我们直接落一个实例。我选了一个相对完整的业务场景订单履约主流程。这个流程在电商类系统里非常典型足够复杂也能展示出面向对象设计的优势。流程拆成几个阶段参数校验检查入参是否合法。风控校验调用风控服务判断当前用户和订单是否存在风险。库存预占锁定商品库存防止超卖。订单创建写入订单主表和明细表。支付请求生成支付单请求支付网关。通知发送支付成功后发送消息通知用户。实际业务里流程可能更复杂这里我刻意简化重在演示框架的使用方式。3.2 定义节点类从接口到实现的完整代码先看一个节点的完整写法。拿“库存预占”节点举例它是订单履约里最核心也最容易出问题的一步。public class InventoryOccupyNode extends AbstractSkeletonNode { private final InventoryService inventoryService; public InventoryOccupyNode(InventoryService inventoryService) { this.inventoryService inventoryService; } Override protected NodeResult doCheck(FlowContext context) { Long skuId context.get(order.skuId); Integer quantity context.get(order.quantity); if (skuId null || quantity null || quantity 0) { return NodeResult.terminate(INVALID_PARAM, skuId或quantity不合法); } return NodeResult.continueFlow(); } Override protected NodeResult doExecute(FlowContext context) { Long skuId context.get(order.skuId); Integer quantity context.get(order.quantity); InventoryResult result inventoryService.occupy(skuId, quantity); if (!result.isSuccess()) { return NodeResult.terminate(STOCK_NOT_ENOUGH, 库存不足); } context.set(inventory.occupyNo, result.getOccupyNo()); return NodeResult.continueFlow(); } Override protected void doFinally(FlowContext context) { // 释放资源比如清除线程变量或者记录审计日志 } }这里有几个细节值得说明。第一个是AbstractSkeletonNode这个基类。它不是接口而是一个抽象类内部实现了SkeletonNode接口并且按照doCheck - doExecute - doFinally的顺序统一调度。使用者只需要继承它并实现三个钩子方法不需要自己处理调用顺序。第二个是NodeResult.terminate(...)的用法。当节点发现业务数据不满足条件时可以选择直接终止流程。终止时返回业务码和描述信息引擎会把这些信息写入 FlowContext供上层捕获。第三个是context.get(order.skuId)这类命名空间式的取值方式。我在 2.2 节提过FlowContext 用命名空间前缀来避免 key 冲突。这里order前缀下的数据一般由流程入口设置inventory前缀下则是当前节点写入的产物。3.3 组装流程从节点树到 FlowEngine 驱动节点定义好之后下一步是把它们组装成一条完整的流程。SkeletonFlow 支持两种组装方式编程式组装和配置式组装。编程式适合代码里直接控制配置式适合接入配置中心后热更新。我先把编程式写出来。FlowContext context FlowContext.create(); context.set(order.skuId, 100234L); context.set(order.quantity, 2); SkeletonNode flow FlowBuilder.sequence() .then(new ParamValidateNode(orderValidator)) .then(new RiskControlNode(riskService)) .then(new InventoryOccupyNode(inventoryService)) .then(new OrderCreateNode(orderRepository)) .then(new PaymentRequestNode(paymentGateway)) .then(new NotifyUserNode(notificationClient)) .build(); FlowEngine engine new FlowEngine(flow, context); FlowReport report engine.run();这段代码的逻辑非常直白用FlowBuilder.sequence()创建一个顺序编排容器依次挂上六个节点最后交给FlowEngine的run方法执行。执行完成后得到一个FlowReport里面包含流程最终状态、每个节点的执行状态和耗时。如果想要加入条件分支可以这样写SkeletonNode flow FlowBuilder.sequence() .then(new ParamValidateNode(orderValidator)) .then(new RiskControlNode(riskService)) .then(new ConditionalNode() .addCase(risk.level HIGH, new ManualReviewNode()) .otherwise(new AutoPassNode())) .then(new InventoryOccupyNode(inventoryService)) .build();ConditionalNode的addCase方法接收两个参数一个条件表达式和一个子节点。引擎执行到这里时会从上到下依次求值条件命中后执行对应的子节点。otherwise是兜底分支。从组装代码能看出来整条流程的结构在代码层面是完全可读的不需要阅读每个节点内部实现就能了解流程全景。这个特性在团队协作中特别有价值新同学接一个复杂业务时先看流程定义文件再按需进入节点内部学习成本会低很多。3.4 执行链路与关键状态解析流程跑起来之后核心要关注几个地方。第一个是FlowReport。我建议在流程调用方统一打印这个对象。它包含流程实例 ID、总耗时、最终状态和每个节点的状态列表。线上排查问题时一条日志就能还原整个执行过程。第二个是节点的执行状态。正常流程里所有节点都是SUCCEEDED被跳过的节点是SKIPPED导致流程终止的节点是FAILED。有一次线上出现订单创建失败我查流程日志发现OrderCreateNode是FAILED报错信息是主键冲突立刻定位到是参数校验节点没有拦截重复的订单号。如果没有这种节点级别的状态记录定位这种问题要翻好几套系统的日志。第三个是耗时分析。FlowReport 里记录了每个节点的耗时如果某个阶段特别慢能直接看出来。我们曾经发现RiskControlNode平均耗时 800 毫秒占了整个流程耗时的一半以上后来优化成异步预加载方案才把整体性能提上来。为了让流程执行信息可用性更高SkeletonFlow 还支持 SPI 方式扩展状态上报。你可以实现一个FlowListener接口在节点状态变更时把数据上报到日志平台或监控系统。这个功能在生产部署时几乎是必选项。4. SkeletonFlow 与函数式编程的异同剖析4.1 两者的“相同基因”组合与声明式表达聊完了实操回到标题里最核心的命题SkeletonFlow 这种面向对象的流程编排框架和函数式编程之间到底有什么关系先说相同点这两者在很多底层理念上惊人地一致。第一个共同点是组合优先。函数式编程的核心操作之一就是函数组合f.g.h把多个函数组合成一个新函数。SkeletonFlow 做的是同样的事情sequence().then(a).then(b)本质上也是把多个节点组合成一个更大的编排节点。组合模式让系统具备无穷的扩展性而这种扩展不依赖修改已有代码。第二个共同点是声明式表达。函数式编程里你描述的是“数据经过哪些变换”而不是“每一步怎么循环怎么赋值”。SkeletonFlow 里你描述的是“流程由哪些节点组成节点之间如何连接”而不是“先调用这个方法再判断结果再调用那个方法”。两者都在把意图和实现分离让代码更偏向表达“做什么”而不是“怎么做”。第三个共同点是关注点分离。函数式编程用纯函数和高阶函数把副作用隔离在系统边界SkeletonFlow 把业务节点按职责切分每个节点只关心自己的逻辑节点与节点之间通过 FlowContext 通信。本质上都是为了让代码的内聚性更高、耦合度更低。这些共同点并不是巧合。优秀的设计往往殊途同归。面向对象也好函数式也罢最终都在回答同一个问题如何把复杂问题拆解成简单模块再以清晰的方式组合起来。4.2 核心差异对比状态、副作用与控制流相同点是底层理念差异点则体现在具体的编程模型和工程实现上。我整理了一张对比表方便读者快速理解。对比维度SkeletonFlow面向对象函数式编程数据流共享的可变上下文节点间通过 FlowContext 传递函数的入参和返回值强调不可变数据状态管理节点对象可以拥有自己的状态流程有显式状态机无共享状态状态通过参数传递或闭包捕获副作用业务副作用显式发生在节点内部由引擎统一调度纯函数避免副作用副作用被隔离在 IO 层控制流引擎驱动支持条件、并行、循环、分支跳转通过高阶函数和单子抽象实现类型层面可控但难读扩展方式继承节点类、组合子节点、配置流程定义组合函数、柯里化、部分应用可测试性依赖注入友好每个节点可单独 Mock 测试纯函数天然易测不需要 Mock 外部依赖学习曲线概念少贴近业务开发直觉需要理解函子、单子等抽象曲线较陡我重点解释几个差异点在真实工程里的影响。数据流方面。函数式编程里一个函数处理完数据后把结果返回给调用方数据流是显式的类型系统可以追踪每个数据的来源。SkeletonFlow 里数据放进 FlowContext 后后续节点取用什么类型、取用哪个 key编译器是无法帮你检查的。这是面向对象方案一个实打实的短板。我在框架里用命名空间规范、在读取出错时给出明确报错信息来缓解但和函数式类型安全相比仍有差距。控制流方面。函数式编程有一种做法是用Either或Option来表示可能失败的计算然后通过 flatMap 串起来。这种写法对简单流程很优雅但一旦出现多个分支、循环、并行、补偿逻辑类型嵌套会让代码的可读性急剧下降。SkeletonFlow 把控制流的实现收归到引擎业务层不需要关心 flatMap 的嵌套只需声明节点的连接关系这对复杂业务更友好。状态方面。函数式编程排斥共享状态这一点在并发场景下是巨大的优势。SkeletonFlow 的并行节点如果操作同一个 FlowContext 中的 key就可能产生线程安全问题。框架能做的只是建议并行节点的写入 key 互为隔离但并不能从语言层面阻止错误。工程上我遇到过几次这类问题后面会在第五节详细讲排查方法。4.3 各自的优势场景不是谁替代谁而是谁适合谁基于上面的差异我总结一下实际选型时我的判断标准。函数式编程特别适合以下场景数据清洗与转换管线、流式批处理、无状态计算服务、算法逻辑密集的模块或者团队本身函数式功底很强且代码以数据加工为主的系统。在这些场景里纯函数的高确定性、易测试性、易推理性可以发挥到极致业务状态的缺失反而不是问题。SkeletonFlow 这类面向对象编排框架则更适合长链路业务流、有状态的多阶段流程、需要人工介入或补偿机制的业务、依赖多个外部服务且需要领域对象承载数据与行为的系统。订单履约、审批流、风控决策、内容审核、迁移任务编排这类业务的共同特点是流程长、状态多、参与方广、失败需要补偿。把这些逻辑全部抽象成纯函数链说实话有点为难人。还有一点是团队协作层面。大多数后端团队对面向对象的直觉理解远强于函数式抽象用 SkeletonFlow 排出来的流程定义即使是不熟悉框架的人也能很快看明白。而函数式管道一旦抽象层级深了理解和维护的成本会直线上升。选型不仅要考虑技术优劣还要考虑团队平均水平和长期维护成本。4.4 混合实践在编排框架内部保留函数式思维前面讲了这么多对比但我实际工作中最受益的反而是两者的混合使用。SkeletonFlow 并不排斥函数式思想恰恰相反我在设计节点内部实现时大量使用了函数式风格的写法。一个典型例子是数据校验。在ParamValidateNode内部我会定义一个字段校验规则列表每条规则都是一个纯函数输入 FlowContext 里的一部分数据输出校验结果。然后用 stream 操作把这些规则组合起来执行。这样节点内部分支逻辑是函数式、声明式的节点外部的流程结构是面向对象、命令式的各取所长。再比如风控结果的决策判断。风控服务返回的是一组策略命中列表我可以在节点内部用模式匹配、集合变换、归约聚合这些函数式操作把复杂决策浓缩成短小精悍的代码。这样写出来的代码既能享受函数式表达的简洁性又不会让流程控制权失控。我自己给团队的编码规范里写了一条流程骨架用 SkeletonFlow 定义节点内部优先使用函数式方式表达纯逻辑有副作用的操作显式写在节点方法里。这个规范执行了大半年效果不错。业务代码既保持了流程的清晰可控又兼顾了局部逻辑的简洁优雅。5. 常见问题与排查技巧实录5.1 FlowContext 里的 key 被覆盖或者类型错乱这是使用 SkeletonFlow 后最容易踩的坑而且通常不会立即暴露。最常见的原因是两个节点模块用了一样的 key比如节点 A 写入了result.code节点 B 也写入了result.code后面的节点读到的值就是被覆盖后的新值。排查方法比较粗暴但有效在本地开发环境开启 FlowContext 的访问日志每次 get 和 set 都打印 key、value 和调用节点的类名。跑一遍流程重点看那些“本不该出现”的 key 是否被中途插入。这能快速定位到是哪个节点污染了上下文。更好的做法是在编码阶段就立好规范每个节点在写入上下文时统一使用自己功能的命名空间前缀并且这个前缀在节点类里定义成常量。例如库存节点用INVENTORY前缀订单节点用ORDER前缀跨节点通信读取对方数据时必须明确指定对方的完整 key。这个规范看起来是小事但它能从根源上消灭大部分上下文冲突问题。5.2 并行节点共享 FlowContext 导致线程安全问题并行编排是性能利器也是并发 bug 的温床。我遇到过一个真实案例一个并行节点里两个子节点都往 FlowContext 里写一个“统计总数”的字段用的是Integer类型。运行一段时间后这个字段偶尔会偏小但单测和压测都很难复现。最终定位出来是get后执行set这个复合操作不是原子的两个线程同时读到旧值各自加一后写回导致一次更新被覆盖。这个问题最好的解决办法就是绕开它并行子节点之间禁止写入同一个上级 context 的 key。每个并行子节点只写自己的命名空间汇总逻辑放到并行节点之后的顺序节点里做。因为并行节点的子节点各自有不同的写入区域天然消除了竞争条件。如果确实需要所有子节点都更新一个共享计数那就用AtomicInteger作为 FlowContext 里的 value或者把汇总动作放到所有并行子节点完成之后串行执行。此外给 FlowContext 增加“单节点独占写”的校验也能预防问题允许一个 key 被多个节点读取但只允许被一个节点写入。框架层面做这个校验不复杂收益却不小。5.3 流程编排层次过深代码变得难以追踪面向对象组合模式有一个天然风险当嵌套层次太多时阅读代码的认知负担会指数级上升。SkeletonFlow 的 CompositeNode 可以无限嵌套如果你不加节制最后可能得到一个“套娃”式流程。流程本身是清晰的但跳转关系非常多阅读时还是要来回在多个类之间切换。我的经验是三条原则。第一流程定义文件里只做两到三层的编排更深层级的组合逻辑收进节点类内部。第二一个 CompositeNode 的子节点数量控制在五个以内超过五个就要考虑拆分子流程。第三给流程里的每个 CompositeNode 起一个业务含义明确的名字比如OrderCreateStage、RiskControlStage而不是让它是一个匿名节点。另外推荐一个实践把流程定义代码集中放在独立的包或模块里命名为flow-definition。业务实现放在flow-node包下。这样团队同学想了解业务全景时只需要去流程定义包不需要翻遍所有业务代码。5.4 节点异常处理与补偿机制的设计SkeletonFlow 的执行模型下节点抛出异常时引擎会捕获并终止流程。但企业级业务通常不能容忍“失败就结束”这么简单。比如订单流程中库存已经预占了如果支付环节失败库存就必须释放。如果流程直接终止而不做补偿就会造成库存悬空。我建议在设计节点时把“正常逻辑”和“补偿逻辑”一并考虑进去。SkeletonFlow 里可以在AbstractSkeletonNode上增加一个可选的doCompensate(FlowContext context)方法。引擎在执行失败时会从当前节点往前回溯调用所有已经成功执行且实现了补偿方法的节点的doCompensate。补偿逻辑要特别注意幂等性。因为进程崩溃、重试等原因补偿方法可能被调用多次。以释放库存为例释放接口必须支持重复调用且结果一致。这不是框架能替你做好的业务侧需要配合设计幂等表单或状态位。我在项目中已经因为这个坑吃过亏补偿逻辑没做幂等第一次释放成功第二次重试竟然报了“库存不足”导致补偿链路中断。5.5 让流程跑得既快又稳的三点经验最后分享三点偏运维侧的经验都是线上实战换来的。第一给每一条流程实例生成一个全局唯一的 traceId。这个 traceId 要贯穿流程引擎、业务日志、第三方调用链路。排查问题时拿着 traceId 能把散落在不同系统中的日志串起来效率会高很多。第二在关键节点接入耗时告警。SkeletonFlow 的 FlowReport 已经给了节点粒度耗时可以直接对接监控平台。给正常耗时设置一个基线比如某节点平均 50 毫秒超过 300 毫秒就告警能提前发现外部依赖变慢或代码性能退化的问题。第三把流程定义做成可配置的。SkeletonFlow 支持把流程描述序列化成 JSON这样一来流程的调整就可以不经过发版。不过配置化是一把双刃剑配置发布和验证同样要严谨。我的建议是流程变更走“配置平台 预发布环境验证 灰度执行”的流程不要直接在生产改配置。写在最后的一点个人体会SkeletonFlow 这个项目从设计到落地最大的收获并不是“面向对象更好用”或者“函数式不好用”这种非此即彼的结论。更准确的体会是面向对象流程编排所解决的问题是让复杂流程的结构透明化、可控化函数式编程所擅长的是让数据处理逻辑精炼化、纯净化。两者不在同一层强行对立其实是伪命题。如果你手头有一个流程正在变成烂摊子不妨试着用 SkeletonFlow 的思路重构一遍把每个环节定义成节点把节点间传递的数据收拢到上下文让引擎统一控制流转。你会发现流程变得可以看见、可以说话、可以改进。等到节点内部的逻辑需要精炼时再引入函数式写法也不迟。我个人的编码习惯是面向对象负责“架骨架”函数式负责“填血肉”。这套组合在我参与的多个中后台项目里都跑得比较稳。希望这篇内容能给正在做流程编排或者纠结技术选型的你一些参考。
返回列表