ARTICLE DETAIL

资讯详情

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

SkeletonFlow:面向对象流程编排框架的设计与函数式对比

SkeletonFlow:面向对象流程编排框架的设计与函数式对比 流程编排这词做后端的基本都绕不开。SkeletonFlow是我参与设计的一套面向对象的流程编排框架核心思路是把一条业务链路抽象成一串可插拔的节点对象每个节点负责一个动作节点之间通过共享的上下文对象传递数据。最近我在整理设计文档时顺手把它和函数式编程的写法做了个对比发现两类思路的差异比想象中要大并不是简单的“类 vs 函数”而是从状态管理、扩展方式到错误处理的全方位取舍。这篇博文就把SkeletonFlow的设计思路、与函数式编程的异同、以及实际落地时的坑一次性讲清楚适合正在选型流程引擎、或者纠结“到底用面向对象还是函数式”的团队参考。1. 项目缘起为什么需要一个面向对象的流程编排框架1.1 业务里流程编排的典型困境最早做流程编排大家都是从if-else开始的。订单流程要校验参数、查用户、扣库存、发通知于是写出几十行连在一起的if嵌套。刚开始能跑等业务复杂到某个程度问题就全冒出来了流程状态散落在各个局部变量里想全局兜底都不知道从哪下手改一个环节的逻辑哪怕只是调一下执行顺序也要把整段代码翻出来看一遍更别说加监控、加日志、做重试这些横切逻辑和业务逻辑全混在一起根本没法单独维护。后来团队尝试过把每个步骤拆成独立函数用函数式的方式串起来。效果有改善但很快发现另一个问题业务步骤之间的“数据交换”非常频繁有的步骤要读前几步的结果有的步骤要写一个中间状态纯函数式要求显式传参、返回新值这在简单场景很清爽一旦流程有十几个步骤、数据项有几十个参数的传递就会变得很痛苦而且为了保持“纯”每个函数都要处理一堆入参结构。所以当时我们定了一个方向流程编排框架需要有自己的“结构”而不是让开发者靠约定去维护。这个结构用什么表达最终选了面向对象。原因很直接业务流程天然分节点、有状态、可复用这和面向对象“封装数据与行为、对象之间协作”的心智模型高度吻合。1.2 选型时的两难面向对象还是函数式做框架设计最难的不是实现而是选型。我们内部其实花了不少时间争论现在函数式编程很流行Stream、Pipe、Monad的概念到处都在讲新项目用函数式显得“先进”而且测试确实好写。但落到流程编排这个具体场景面向对象的优势更实在。先说几个真实的考量点。第一编排框架要被多个业务线复用业务方接进来的时候不希望他们去理解高阶函数、柯里化这些概念直接继承一个Node类、实现一个execute方法心智负担最小。第二流程里的节点往往不只是“一个纯函数”它可能有自己的配置、有自己的依赖注入、有自己的预热逻辑这些天然是类才能承载的。第三也是最重要的一点流程编排需要强结构比如每个节点的入参出参声明、节点之间的依赖关系、前置后置钩子面向对象用接口和抽象类就能把这些结构做进类型系统里而函数式更多靠函数签名和约定。当然这不代表函数式不行。后面第3章我会专门讲它们到底哪里一样、哪里不一样。选面向对象是基于我们团队现状、业务类型和后续维护成本做出的选择它不是银弹只是在流程编排这个赛道上面向对象的表达力更强。2. SkeletonFlow的核心设计与实现思路2.1 核心抽象FlowNode、FlowContext、FlowPipelineSkeletonFlow的设计只有三个核心抽象理解这三个东西整个框架就用明白了。第一个是FlowNode所有流程节点的基类。每个节点只干一件事实现execute方法。这个方法接收一个FlowContext处理完之后把结果写回Context然后返回。节点内部可以有自己的状态和配置但对外暴露的只有execute框架不关心你是查了数据库还是调了接口。export abstract class FlowNode { abstract execute(ctx: FlowContext): Promisevoid; }第二个是FlowContext流程上下文。它本质上是一个线程安全的KV容器用来在节点之间传递数据。所有节点通过context.get(key)读取上游数据通过context.set(key, value)写入下游需要的数据。为什么不用函数参数传递因为流程节点数量不固定每个节点的输入输出也不同用一个统一的上下文对象可以让节点的接口保持一致同时天然支持并行节点写入不同key的场景。export class FlowContext { private data new Mapstring, unknown(); getT(key: string): T | undefined { return this.data.get(key) as T | undefined; } set(key: string, value: unknown): void { this.data.set(key, value); } has(key: string): boolean { return this.data.has(key); } }第三个是FlowPipeline它自己也是一个FlowNode这是SkeletonFlow最有意思的设计一个编排单元可以被当作一个节点嵌入更大的编排单元。FlowPipeline内部维护一个节点列表执行时按顺序依次调用每个节点的execute。export class FlowPipeline extends FlowNode { private nodes: FlowNode[] []; add(node: FlowNode): FlowPipeline { this.nodes.push(node); return this; } async execute(ctx: FlowContext): Promisevoid { for (const node of this.nodes) { await node.execute(ctx); } } }这样一个FlowPipeline套另一个FlowPipeline就实现了流程的嵌套编排从根上看整个业务链路依然是一棵对象树。2.2 流程是怎么串起来的流程串起来的关键就是FlowContext。每个节点执行完把结果写进context下一个节点从context里取。这就像流水线上的工位每个工位从传送带上拿零件加工完放回传送带传送带就是context。这里有个设计细节很关键节点之间不直接引用。NodeA执行完它只需要context.set(user, user)NodeB需要用户信息只需要context.get(user)。这样两个节点完全解耦我们可以随意调整节点的顺序而不需要改节点代码。比如先做风控再做实名认证或者反过来只需要在组装pipeline时改变添加顺序。但是解耦也会带来一个代价上下文里到底有哪些key没有编译期保证。为了让框架更安全SkeletonFlow给FlowNode增加了一个可选的声明能力节点可以声明自己需要哪些key、产出哪些key框架在流程启动前做一次校验如果上游没有产出下游需要的key直接报错。这块实现起来就是给FlowNode增加一个metadata属性组装pipeline时统一解析依赖关系。2.3 流程定义与执行分离的好处SkeletonFlow把“流程长什么样”和“流程怎么跑”完全拆开。开发者只需要组装FlowPipeline定义哪些节点按什么顺序执行至于节点如何并发、如何重试、如何记录日志全部由框架在执行引擎里统一处理。这个分离带来的最直接好处是可配置化。我们内部把流程定义序列化成一份JSON配置运营人员可以通过后台编辑配置调整流程顺序不用发版。比如某个活动需要临时加一个节点只要配置里加一行运行时框架动态加载节点类并加入到pipeline里。这才是编排框架该有的样子业务逻辑是零件编排是装配图装配图可以随时改零件本身不需要动。另一个好处是可观测性。因为所有节点都继承自FlowNode框架可以在execute执行前后插入统一的埋点逻辑记录每个节点耗时、写入context的数据量、异常信息。这在函数式写法里很难做到——函数之间没有这个天然的“拦截点”。在实际生产中这个能力帮我们定位过好几次性能瓶颈哪一步慢了一目了然。3. 与函数式编程的异同全面对比3.1 二者都在解决什么问题如果把面向对象和函数式编程放在流程编排这个场景下你会发现它们的目标其实是一致的把一个大业务拆成小单元再通过组合把小单元拼成完整的流程。函数式用函数、管道、组合子SkeletonFlow用节点类、pipeline、context。本质上都是在做“分而治之、组合复用”。拿最经典的数组map/reduce来说它就是函数式组合的缩影。把集合处理拆成单个元素的纯函数再用reduce汇聚结果。SkeletonFlow也一样把业务链拆成单个节点的execute再通过pipeline按顺序遍历。二者都强调“小单元可复用大流程可组合”。3.2 相似点一组合性是第一公民不管是什么范式流程编排最核心的能力就是组合。SkeletonFlow的FlowPipeline本身就是FlowNode这意味着任意一个流程片段都可以嵌入另一个流程形成层级结构化。函数式编程里也有类似思想函数的返回值可以作为另一个函数的入参通过compose或pipe自由组合。在这里我举一个函数式管道的例子大家感受一下const pipe (...fns: Array(x: any) Promiseany) (input: any) fns.reduce((acc, fn) acc.then(fn), Promise.resolve(input));这个pipe就是函数式的FlowPipeline。它接收一组函数依次把上一个函数的结果传给下一个函数。对比SkeletonFlow的FlowPipeline二者在“顺序执行、依次传递”这个层面几乎一模一样。3.3 相似点二都强调副作用隔离好的代码都有一个共同追求副作用越少越好。函数式编程用纯函数强制隔离副作用——函数只依赖入参只返回结果不修改外部变量。SkeletonFlow虽然允许节点通过context写入数据但框架层面做了一个约定节点不应该修改context之外的任何外部状态。我们在框架里通过上下文隔离做到了这一点。每个节点只能拿到context拿不到其他节点实例也无法直接调用兄弟节点的方法。这就在结构上保证了节点之间的副作用不会相互污染。如果一个节点要写数据库那是它自己的事但写入数据库这个动作的调用入口必须封装在节点内部不会散落到业务代码里。3.4 核心差异一状态如何流动——可变上下文 vs 显式传参这是两者最大的分水岭。SkeletonFlow用FlowContext一个可变的共享状态容器。节点往context里写下一个节点从context里读状态是“隐式”地在节点间流动的。函数式编程完全相反状态通过函数参数显式传递函数返回新状态不修改传入的参数。各自的优缺点很清晰。可变上下文写起来快尤其数据项多的时候不用费劲设计每个函数的参数表但它依赖“时序”——读数据的节点必须在写数据的节点之后执行一旦顺序错了拿到undefined排查要靠日志。显式传参的优势是函数之间的依赖一目了然参数表就是接口但流程一复杂数据项一多每个函数的参数列表会变得很长而且组合起来很啰嗦。我个人的经验是流程步骤少于5个函数式显式传参很清爽步骤超过10个或者节点之间数据交集很多SkeletonFlow的上下文模型更省心。3.5 核心差异二扩展单元——节点类 vs 高阶函数SkeletonFlow的扩展单元是“类”。这个类不只是execute方法它还可以有属性、构造函数、生命周期钩子甚至可以继承公共基类。比如我们框架里有个RedisNode封装了所有redis操作业务节点继承它就能直接使用redis客户端这个“共享能力通过继承和组合获得”的模型函数式实现起来很别扭。函数式的扩展单元是“函数”以及“返回函数的高阶函数”。高阶函数很灵活可以包裹任意逻辑生成新函数。但灵活的另一面是不稳定函数没有天然的“类型结构”你很难约束一个“节点”必须具备哪些字段。类就不一样接口和抽象类定义好了想加节点照着实现就行编译器帮你把关。3.6 核心差异三错误处理与可观测性函数式风格处理错误一般用Either/Result这类封装把错误当作值来传递配合管道一层层传播。这个做法很优雅但问题在于每个函数都得处理Result写起来累而且一旦套了多层pipe错误从哪里来要好好分析。SkeletonFlow采用面向对象的异常机制。节点抛异常框架捕获后做统一处理如果是可重试异常按策略重试如果是业务异常直接终止流程并标记失败原因如果是可降级异常则跳过当前节点继续执行。配合FlowNode的钩子方法重试、熔断的逻辑都写在基类里子类无感知。更重要的是可观测性。面向对象在“流程”这个层面有结构我们可以给每个节点打点开始时间、结束时间、输入数据快照、输出数据快照、异常信息。函数式的管道很难做这种粒度的拦截因为函数之间没有统一的边界约束。SkeletonFlow在这一点上的优势对排查生产问题是决定性的。3.7 一张表看清异同对比维度SkeletonFlow面向对象函数式编程组合方式FlowPipeline嵌套节点pipe/compose组合函数状态流动共享FlowContext可变显式参数传递不可变扩展单元节点类继承组合高阶函数接口约束抽象类泛型强约束函数签名约定错误处理异常框架统一捕获Either/Result值传递可观测性节点钩子统一埋点需手工包裹或依赖库学习门槛低懂类懂继承即可偏高需理解纯函数、组合子适合场景长流程、多状态、高复用短管道、无状态、纯计算4. 实操用SkeletonFlow搭建一个订单处理流程4.1 场景定义空谈概念不如跑一个实例。我用一个典型的订单处理流程来演示包含订单校验、用户风控、库存扣减、支付回调通知四个节点。假设我们收到了一个创建订单的请求需要依次执行这四个步骤任何一个失败就终止整个流程。这个场景非常典型有数据传递订单信息、用户信息、扣减结果有外部IO数据库、redis、第三方API有顺序依赖。用SkeletonFlow来写每一步都是一个独立的节点类流程只用一条pipeline组装。4.2 定义节点类先定义四个节点类每个类继承FlowNode重写execute方法。注意每个节点的execute都是同一个签名接收FlowContext处理完往context里写数据不返回值。export class ValidateOrderNode extends FlowNode { async execute(ctx: FlowContext): Promisevoid { const order ctx.getOrder(order); if (!order || order.amount 0) { throw new BizError(INVALID_ORDER, 订单参数不合法); } ctx.set(orderValidated, true); } } export class RiskCheckNode extends FlowNode { async execute(ctx: FlowContext): Promisevoid { const order ctx.getOrder(order); const user ctx.getUser(user); const riskLevel await this.riskService.check(order, user); ctx.set(riskLevel, riskLevel); if (riskLevel high) { throw new BizError(RISK_REJECTED, 风控未通过); } } } export class DeductStockNode extends FlowNode { async execute(ctx: FlowContext): Promisevoid { const order ctx.getOrder(order); const stock ctx.getStock(stock); await this.stockService.deduct(stock.id, order.quantity); ctx.set(deducted, true); } } export class NotifyNode extends FlowNode { async execute(ctx: FlowContext): Promisevoid { const order ctx.getOrder(order); await this.notificationService.sendCreateOrderMessage(order); ctx.set(notified, true); } }这里有几个细节值得说。首先每个节点都通过context.get获取自己需要的数据没有出现节点之间的直接引用其次业务异常用BizError包装框架会识别这个异常类型并做对应处理最后每个节点写到context里的key都固定且有意义后续加节点只需要读同样的key完全不影响已有节点。4.3 组装流程与执行定义好节点组装就是几行代码的事。FlowPipeline天然支持链式add而且因为pipeline本身也是FlowNode整个订单流程还可以作为一个子节点嵌入更大的业务流程。const orderFlow new FlowPipeline() .add(new ValidateOrderNode()) .add(new RiskCheckNode()) .add(new DeductStockNode()) .add(new NotifyNode()); const ctx new FlowContext(); ctx.set(order, order); ctx.set(user, user); ctx.set(stock, stock); await orderFlow.execute(ctx); const riskLevel ctx.getstring(riskLevel); console.log(订单流程执行完成风控等级, riskLevel);执行完毕后所有节点写入context的数据都可以被外部读取。这里要特别注意一个设计原则不要在节点execute里直接console.log业务中间结果而是统一在编排层、或者在节点外部读取context做日志。这样节点保持纯粹日志策略可以随时替换。4.4 如果换成函数式写法差异在哪为了让大家更直观感受差异我把同一个流程用函数式管道写一遍type Ctx { order: Order; user: User; stock: Stock; riskLevel?: string; deducted?: boolean; notified?: boolean; }; const validateOrder (ctx: Ctx): Ctx { if (!ctx.order || ctx.order.amount 0) throw new BizError(...); return { ...ctx, orderValidated: true }; }; const riskCheck (ctx: Ctx): Ctx { ... }; const deductStock (ctx: Ctx): Ctx { ... }; const notify (ctx: Ctx): Ctx { ... }; const orderFlow pipe(validateOrder, riskCheck, deductStock, notify); const result await orderFlow(ctx);表面上结构很像仔细看区别就出来了。函数式写法为了保持不可变每个函数都返回一个新对象。对于只有4个步骤的流程这还能接受一旦步骤增多每次都是展开、赋值、返回新对象性能开销和代码噪音会成倍增加。而SkeletonFlow的context是原地修改步骤再多也就一遍拷贝都没有。再说错误处理。函数式版本里如果某个函数抛异常pipe会直接中断最外层看不到任何上下文信息。SkeletonFlow版本则可以在FlowPipeline层统一捕获异常并把当前执行到哪个节点、context里有哪些key全部打出来这个对排查线上问题太有用了。5. 踩坑与排查经验实录5.1 最容易踩的四个坑第一个坑是context的key命名冲突。因为context是全局共享的不同节点如果用了同一个key且含义不一致就会数据覆盖。比如A节点set(status, pending)B节点也set(status, approved)最后读到什么完全依赖执行顺序。我们的解决办法是引入key前缀约定节点所属模块名加下划线作为前缀同时核心数据的key在框架层定义为常量。第二个坑节点执行顺序和依赖关系靠人肉保证。SkeletonFlow本身不会阻止你调用A节点之前就读取A节点写入的数据运行时只会得到undefined。我们后来在FlowPipeline组装阶段增加了依赖声明校验但老项目里已经写好的流程没有补元数据所以现在一直有运行时兜底检查发现undefined就日志报警。第三个坑节点内隐式共享状态。虽然上下文是共享的但节点自身的私有字段可别拿去存业务数据。我们早期有个节点把订单号存在this变量里单线程跑没问题一旦并发多路请求复用同一个节点实例数据就串了。记住FlowNode实例默认是单例的所有业务数据必须走context。第四个坑继承层级过深导致复用变成灾难。FlowNode可以继承很多同事图省事从已有的业务节点继承再override最后搞出五六层的继承链改一个底层方法影响一片。后来我们定规矩复用优先用组合也就是把公共逻辑做成独立的服务或中间件节点而不是继承业务节点。5.2 排查思路与工具建议流程编排的排查重点在于“流程走到哪一步了”。SkeletonFlow天生适合做全链路日志每个节点执行前后框架都会记录一条结构化日志包含流程ID、节点名、耗时、context大小。排查问题时我一般先看整个流程日志用流程ID过滤找到耗时最长的节点或者抛异常的节点再点进去看那个节点的输入输出快照。这里要强调一个建议context里的值不要原样打日志尤其涉及用户隐私或者大对象。我们实现了一个可选的序列化装饰器只有显式标记成可透出的key才会被记录其余统一打mask。这个细节看起来不起眼在线上环境能省掉很多合规问题。5.3 什么场景用函数式什么场景用面向对象这是被问得最多的一个问题。功能上函数式不是不能写流程编排但代码会更“薄”它适合数据流固定、步骤少、以计算为主的管道。比如把请求对象转换成内部模型再转换成外部DTO这用pipe写爽得很每个函数都是纯函数测试覆盖简单直接。面向对象则适合业务链路复杂、节点状态多、生命周期管理需求强的场景。SkeletonFlow的节点类可以持有依赖、可以复用、可以继承公共逻辑节点之间有context隐式传值写长流程的时候代码不会爆炸。而且当你的流程需要配置化、需要运行时动态增删节点时面向对象几乎是唯一自然的选择。我现在的个人习惯是一个漏斗里如果只是做数据变换用函数式涉及外部IO、重试、降级、状态流转上SkeletonFlow。二者不是敌人可以在一个项目里共存毕竟SkeletonFlow的节点内部完全可以使用纯函数做数据计算。范式是工具业务才是目的。做了半年多SkeletonFlow的迭代和落地我觉得最有价值的不是框架本身那几百行代码而是它逼着我们重新思考了“流程”的本质。流程不是一系列函数的调用而是有结构、有状态、有边界的业务单元的组合。面向对象给了这些单元一个稳定的骨架让它们可以生长、复用、被观测。函数式则提醒我们单元内部要尽可能纯粹、无副作用。两种思想在同一套框架里达成了一种务实平衡这可能比纠结“谁更高级”有意思得多。
返回列表