ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

deer-flow实战:用JSON DSL构建低代码可视化流程编排引擎

2026/9/11 2:33:01 拓冰建站 浏览量
deer-flow实战:用JSON DSL构建低代码可视化流程编排引擎 第一次跑通 deer-flow我在本地起了个 MySQL把一段几百行的 JSON 扔进它的引擎三秒后系统里弹出一张待审批工单再点一下审批通过后续的所有自动节点按顺序执行整个过程我一行业务代码都没写。这种感觉确实有点颠覆——要知道在这之前我把同样逻辑写进过一个微服务光状态机和条件分支就嵌套了三层每次改需求都要翻半天代码。deer-flow 是一个开源的低代码流程编排引擎核心是把业务流程定义成一张流程图通过可视化设计器拖拽节点、配置连线最终落到一份 JSON DSL 上交给引擎去执行。它自带前端设计器、后端执行引擎、管理端 API也能嵌入现有系统作为独立的流程服务。这篇文章我准备从实际项目落地视角把它的核心模型、部署接入、DSL 编写、前端设计器、稳定性问题和性能调优完整梳理一遍适合打算在业务系统里引入流程编排能力的后端工程师也适合正在选型低代码工作流引擎的团队。1. 从散装的流程逻辑到可视化编排deer-flow 想解决什么问题1.1 业务流程散落在代码里的典型症状我在很多中后台项目里见过同一种病业务流程没有独立建模而是散落在定时任务、状态机、消息队列和一堆 if-else 里。举个例子订单超时自动退款流程往往是这样实现的一个定时任务每 30 秒扫一次订单表找到已支付但超时未发货的记录然后调用退款接口退款状态写死在订单表的 status 字段Service 层用 switch 枚举所有状态迁移想加一个超过 500 元需要人工审核的规则得改表结构、加接口、再调整定时任务的判断逻辑业务方想看一笔退款流程走到哪一步只能靠日志或者让 DBA 临时写 SQL 查数据。这种实现方式的本质问题不在于代码写得差而在于流程逻辑没有独立的表达载体。当流程节点从三五个增长到几十个、涉及多角色审批、包含并行分支和超时处理时代码的可读性、可维护性、可观测性会同时恶化。状态字段越加越多枚举越写越乱任何一次需求变更都像在拆雷。1.2 deer-flow 的定位轻量级但完整的编排引擎deer-flow 走的是轻量级低代码流程编排这条路它的设计做了三个关键取舍让它和传统的 BPM 工作流引擎明显区分开来。第一流程定义用 JSON DSL而不是 BPMN 2.0 XML。业务系统里绝大部分人看到 BPMN 的bpmn2:sequenceFlow标签就已经放弃了JSON 天然适合 Web 存储、传输、渲染前端图形化编辑器可以直接把 JSON 映射成流程图后端解析也省去了一大堆 XML 解析代码。第二节点类型面向真实业务场景而不是面向流程建模规范。内置的 HTTP 请求节点、审批节点、定时节点、条件判断节点、脚本节点几乎覆盖了互联网业务里最常见的流程要素。尤其是 HTTP 节点让流程可以很自然地调用外部微服务接口而不需要像传统工作流那样写一堆 Java 委托类。第三运行状态全部落库流程可观测。每个流程实例当前停留在哪个节点、状态是什么、变量怎么变化的、节点日志是什么都能在管理端查得到。这个特性对排障和审计非常重要——流程跑到一半挂了你得知道它挂在哪一步、为什么挂。2. 核心模型拆解图、节点、连线和运行时状态要真正用好 deer-flow必须先理解它的核心数据模型它本质上就是把现实中的业务流转抽象成一张有向图而引擎就是这张图上的状态机和调度器。2.1 静态模型流程定义Flow Definition在 deer-flow 里一份流程定义由三部分组成节点列表、连线列表、流程变量定义。节点Node是流程的基本执行单元常见类型大概有这些节点类型作用说明start流程入口只能有一个触发流程时从这里开始执行end流程出口流程执行到这里即结束http调用外部 HTTP 接口支持 GET/POST/PUT 等常用方法approval人工审批节点生成一条审批任务等待用户审批通过或拒绝timer / delay延迟执行常用于超时处理、定时提醒等场景condition条件判断根据表达式结果走向不同分支script执行一段脚本Groovy/Python做转换或计算subflow调用另一个流程定义实现流程复用连线Edge表达节点之间的流转关系除了普通的上一步到下一步还可以挂条件表达式。比如订单金额大于 1000 走总监审批否则走部门主管审批这个分支逻辑就落在连线的条件里而不是写在代码里。流程变量是节点之间传递数据的唯一通道。流程启动时传入初始参数每个节点执行完可以把输出写入变量后续节点从变量里读。我用过之后觉得它很像水管连接不同节点的不是 Java 对象引用而是全局的变量上下文。2.2 运行时流程实例Flow Instance当流程被触发后引擎会根据流程定义创建一个流程实例。这是运行时概念包含当前执行到哪个节点、各个节点的执行状态、流程变量的当前快照、完整的执行日志。流程实例的状态大概包括RUNNING执行中、WAITING等待中通常是在等人工审批、SUCCESS已完成、FAILED执行失败、TERMINATED被终止。每个节点执行时也会生成一条节点任务记录包含节点 ID、开始时间、结束时间、输出参数和执行结果。审批节点比较特殊它会额外生成一条flow_approval_task审批任务记录。审批人通过管理端 API 或前端页面点击通过引擎才会继续推进流程点击拒绝流程会按拒绝分支走下去或者直接终止。2.3 和 Flowable、Activiti 的本质区别我之前也研究过 Flowable 和 Activiti它们是非常成熟的 BPM 引擎功能强大但带来的复杂度也高。那次我光是研究怎么在 Spring Boot 里正确配置 ProcessEngine、部署 BPMN 文件、处理历史数据清理就花了一个周末。它们的设计前提是流程专家参与建模流程文件由专门建模工具生成开发者更多是围绕它做集成开发。deer-flow 的定位更像是面向开发者的流程基础设施。它不需要专门的建模工具不强制 BPMN 规范不要求团队里有人懂泳道消息边界事件这些概念。你只需要按业务直觉画一张流程图把节点和连线用 JSON 描述出来就能把流程跑起来。这也决定了它更适合互联网业务节奏——快速建模、快速改版、出了问题能直接看日志定位。3. 部署接入本地把第一份流程跑起来说再多概念不如实际跑通一个流程。我带大家从零搭一遍环境是 macOS 上开的本地服务整体耗时大概半小时。3.1 环境准备开始之前电脑上需要准备这些基础环境JDK 17 及以上Maven 3.8 及以上MySQL 8.0本地要有能连上的实例Redis 可选但建议装上引擎做分布式锁和缓存会用到我个人建议直接用 Docker 起 MySQL 和 Redis比本地安装干净得多版本也统一。3.2 初始化数据库表结构deer-flow 的运行状态全在数据库里所以第一步是把表结构建出来。项目源码里一般会有doc/sql或db目录里面是初始化脚本。核心表大致如下表名作用flow_definition流程定义表存 JSON 格式的定义内容flow_instance流程实例表记录每一条实际运行的流程flow_task节点任务表记录每个节点的执行状态flow_variable流程变量表存储流程上下文变量flow_approval_task审批任务表存人工审批节点产生的待办导入完成后用SHOW TABLES;确认所有表都建出来了。这一步如果表少了后面启动流程会直接报表不存在排查起来还挺耽误时间的。3.3 引入依赖并配置应用在项目的pom.xml里引入引擎核心依赖然后配置application.yml。数据源指向刚才初始化的库并配置引擎的线程池、重试策略、回调地址等参数。spring: datasource: url: jdbc:mysql://localhost:3306/deer_flow?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: your_password redis: host: localhost port: 6379 flow: engine: thread-pool: core-size: 8 max-size: 16 queue-capacity: 1024 retry: max-attempts: 3 initial-interval-ms: 1000这里要特别提醒一点线程池参数不只是性能配置它直接决定了流程执行的并发行为。核心线程数太小大量流程实例会排队队列容量太大流量突发时内存会被打满。如果拿不准先按默认值跑观察稳定后再调。3.4 创建第一份流程定义并触发执行接下来创建一份最简单的流程定义三个节点start-http-end。含义是流程触发后调用一次外部 HTTP 接口然后结束。{ key: hello-world, name: 第一个示例流程, nodes: [ { nodeId: start1, type: start, name: 开始 }, { nodeId: http1, type: http, name: 调用示例接口, config: { url: https://httpbin.org/post, method: POST, body: {} } }, { nodeId: end1, type: end, name: 结束 } ], edges: [ { from: start1, to: http1 }, { from: http1, to: end1 } ] }通过引擎提供的管理接口把流程定义部署进去然后发起一次流程触发请求。返回的响应里能看到流程实例 ID再用这个 ID 去查流程状态和执行日志确认http1节点是否成功执行。我自己的体验是当你在数据库里看到flow_instance表多了一行SUCCESS状态的记录这个引擎的全链路就算跑通了。在此之后加节点、加分支就是改 JSON 的事核心思路完全一致。4. 用 JSON DSL 编排一条真实业务链路光跑通一个 hello-world 不算什么下面我用一个售后退款场景来演示更有实际参考价值的编排思路。这个场景同时用到了条件分支、人工审批、HTTP 调用和变量传递基本涵盖了日常开发的大部分需求。4.1 场景定义假设业务规则是用户在订单详情页发起退款申请金额小于等于 500 元且无异常记录系统自动退款退款金额大于 500 元进入人工审核审核通过才退款无论哪种方式退款完成后发一条站内信通知用户流程结束。4.2 DSL 结构拆解我把这个场景映射成节点和连线后JSON DSL 大概是下面这种结构{ key: refund-flow, name: 售后退款流程, nodes: [ { nodeId: start1, type: start, name: 退款申请发起 }, { nodeId: cond1, type: condition, name: 判断金额是否超限, config: { expression: ${order.amount} 500 } }, { nodeId: approval1, type: approval, name: 人工审核, config: { assigneeType: role, assigneeValue: financial_manager, onReject: { to: end1 } } }, { nodeId: http1, type: http, name: 调用退款服务, config: { url: http://payment-service/api/refund, method: POST, body: { orderId: ${order.orderId}, amount: ${order.amount}, reason: ${order.reason} } } }, { nodeId: http2, type: http, name: 发送站内信, config: { url: http://notify-service/api/message, method: POST, body: { userId: ${order.userId}, content: 您的退款申请已处理完成 } } }, { nodeId: end1, type: end, name: 结束 } ], edges: [ { from: start1, to: cond1 }, { from: cond1, to: approval1, condition: ${order.amount} 500 }, { from: cond1, to: http1, condition: ${order.amount} 500 }, { from: approval1, to: http1 }, { from: http1, to: http2 }, { from: http2, to: end1 } ] }这里有个很关键的细节condition节点本身不执行业务它只负责分流。cond1根据表达式结果决定走向approval1还是直接走http1。表达式的值来自流程变量order这是发起流程时传入的参数对象。4.3 节点间的变量传递机制变量传递是流程编排的灵魂。在 deer-flow 里流程变量本质上是一个上下文对象节点之间通过这个对象传递数据。发起流程时可以传入一个初始变量集合curl -X POST http://localhost:8080/flow/instance/start \ -H Content-Type: application/json \ -d { flowKey: refund-flow, variables: { order: { orderId: ORDER20250101001, amount: 888.00, reason: 商品质量问题, userId: 10086 } } }后续节点在配置里通过${order.amount}这样的表达式引用变量。HTTP 节点的请求体可以直接做插值条件分支的表达式也基于同样的语法。这样做的好处是节点自身和节点之间的依赖被解耦了每个节点只需要从变量上下文里读东西不需要知道数据来自哪个上游节点。我踩过的一个坑是变量命名冲突。两个不同分支给同一个变量赋值后执行的分支会把前面的值覆盖掉。解决方法是约定好的命名规范比如所有变量都带业务前缀order.amount、refund.result、approval.opinion避免团队协作时互相覆盖。4.4 人工审批节点的回调和推进approval节点是流程里唯一不能自动往下走的节点。当流程执行到approval1时引擎会创建一条审批任务并处于WAITING状态。这时候需要通过管理端 API 查询待审批任务再由审批人调用审批接口传入结果curl -X POST http://localhost:8080/flow/approval/approve \ -H Content-Type: application/json \ -d { taskId: TASK20250101001, action: APPROVE, comment: 审核通过同意退款 }审批通过后引擎会自动把流程从approval1推进到下一步。如果配置了onReject.to拒绝时会走向终止节点如果不配置默认拒绝就是终止流程。这里要特别强调审批接口必须做幂等。因为前端可能重试提交或者网络波动导致同一结果被投递两次。如果引擎收到两次APPROVE流程实例可能会被推进两次产生重复退款。标准做法是在审批任务上加一个状态标记只有待审批的任务才能被推进已经处理过的任务重复回调直接忽略。5. 前端设计器拖拽背后的数据契约deer-flow 的另一个核心竞争力是自带可视化设计器业务人员和技术人员可以像画流程图一样编辑流程。很多人以为这只是个画图工具但我深入研究后发现它本质上是一个 JSON 数据结构的可视化编辑器。5.1 设计器与引擎的关系JSON 就是契约设计器拖拽出的流程图最终保存到后端的就是一份 JSON DSL。引擎执行时从数据库读取这份 JSON然后逐条解析执行。这意味着设计器不是引擎功能的子集而是引擎能力的另一种表达形式。你在设计器里能配置什么JSON 里就有对应的字段反过来手动改 JSON 也能实现设计器不支持的高级配置。在集成时前端设计器通过 REST API 和后端引擎分离。引擎负责流程编排和执行设计器只是一个纯前端工程可以通过 iframe 或路由集成进任何现有系统。它和引擎的关系就相当于代码编辑器和编译器的关系——编辑器负责人类可读的编排引擎负责机器可执行的调度。5.2 一个拖拽操作背后的数据结构变化拿前面售后流程来举例。当你在设计器里拖出一个http节点到画布上实际发生的是设计器在当前流程定义的nodes数组里 push 了一个新对象{ nodeId: http_random_001, type: http, name: 未命名节点, config: { url: , method: GET, body: {} } }当你从一个节点的输出锚点拖线到另一个节点的输入锚点时实际上是在edges数组里新增了一条记录{ from: cond1, to: http_random_001, condition: }这个理解方式非常有用。当你在设计器里找不到某个配置项或者想批量修改流程定义时直接改 JSON 再导入往往比在界面上点半天更快。设计器生成的 JSON 和手写的 JSON 在数据结构上完全等价可以互相转换。5.3 自定义节点前后端如何通过 type 字段串联内置节点类型满足不了所有业务场景时就需要开发自定义节点。deer-flow 的扩展机制做得很直接核心就是type字段这个契约。假设业务里需要一个发送短信节点整体开发分三步服务端实现一个自定义节点处理器注册到引擎的节点类型解析器里让引擎看到type: sms时调用你自己写的处理逻辑前端设计器注册一个sms类型的可视化组件当拖拽一个sms节点到画布时右侧配置面板会渲染出手机号、短信模板等输入项配置保存后设计器生成的 JSON 里该节点的type字段就是sms配置面板里的输入项会映射到config对象里。这个自定义节点的开发体验和很多低代码平台的插件机制类似但 deer-flow 把前后端契约简化到了一个type字符串。理解了这个机制后面做不断扩展就是时间问题。6. 排错与稳定性部署过程中最值得警惕的四个问题任何一个流程引擎光把常用的 happy path 跑通是不够的稳定性才是最考验工程能力的地方。我在实际部署和维护 deer-flow 期间踩过几个印象很深的坑。6.1 回调通知必须幂等审批节点是异步推进的整个流程能否顺利走完完全依赖于回调通知是否被正确处理。这里最典型的坑就是重复回调。我在测试阶段就遇到过审批页面按钮被用户双击导致同样的审批结果被前端提交了两次网络不稳定时前端检测到请求超时自动重试也会产生重复提交。如果引擎没有做幂等保护同一个流程实例会被推进两次后续节点可能被重复执行——最严重的时候我见过同一笔订单被退款两次下游支付渠道直接报了交易号重复异常。排查这类问题最好的入口是数据库审批任务表。检查任务的状态字段如果它已经APPROVED了后续到达的相同任务 ID 请求就应该直接返回成功不再执行推进逻辑。6.2 HTTP 节点超时与重试的连锁反应deer-flow 的 HTTP 节点支持配置超时时间和失败重试。这个功能初衷是好的但配置不当会放大故障。比如支付服务响应本来就慢你给退款节点配置了 3 次重试结果就是同一个退款请求被发送了 3 次。如果支付服务端没做幂等用户会被扣三笔钱。我的经验是重试只能启用在下游接口具备幂等能力的节点上。如果下游接口本身不幂等宁可失败后走人工处理分支也不要自动重试。另外超时时间不能设置得太短尤其是在大促场景下下游服务响应变慢超时阈值要留出足够余量。6.3 并发节点共享流程变量的数据竞争并行分支是流程引擎很重要的能力。比如退款流程中发通知和留存档两个节点完全可以并行执行。但并行带来的变量写入冲突问题我在实际使用中踩得很痛。两个并行分支同时对流程变量里的auditLog字段追加内容由于没有做并发保护最终变量里可能只保留了一个分支写入的数据另一条被覆盖。轻则日志缺失重则影响流程结果。解决思路也很直接遵循变量只读优先、写入隔离原则。每个节点优先读上游变量需要写数据时写入自己节点的局部输出而不是直接修改共享的流程变量。如果确实需要汇总并行分支的结果可以在并行分支汇合后再处理避免交叉写。6.4 终止和失败状态的边界处理流程引擎执行过程中总会有失败场景。我见过最典型的问题某个节点抛异常后流程实例处于FAILED状态但从系统设计上这个节点是允许失败后走其他分支的。由于没有在节点上配置异常分支整个流程被永久卡在失败状态。在配置节点时要明确考虑异常分支调用支付接口失败的节点应该能走失败通知分支而不是直接终止整个流程。把异常当成一种正常的分支条件而不是意外情况是流程编排设计中很重要的一种思维方式。7. 部署形态与调优单机跑通之后如何走向生产环境本地搭一个演示环境很容易但真正在生产环境稳定运行还需要考虑部署形态、线程池调优、服务发现和灰度发布这些事。7.1 从单机到集群要解决什么问题deer-flow 引擎本身是无状态的所有状态都落在数据库里所以从单机扩展成多实例集群是完全可行的。但无状态应用变成集群后会冒出几个只有分布式环境下才会有的问题。第一是定时触发节点的重复执行。某个流程定义里配置了每 30 分钟检查一次库存如果集群里有 3 个实例这个定时任务会在每个实例上都触发导致同一个流程被启动 3 次。解决办法是引入分布式锁保证同一时刻只会有一个实例执行定时触发逻辑。第二是异步回调的负载均衡。外部系统调用审批回调接口时请求可能被负载均衡打到任意一个实例。如果一个实例处理回调时修改了流程状态但另一个实例同时也在处理同一流程的另外一个 SQL 请求就可能产生状态更新冲突。这需要在更新流程实例状态时加上乐观锁保护比如WHERE status WAITING这样的条件更新保证只有一个请求能真正修改状态。7.2 线程池参数怎么调引擎线程池的参数直接影响流量峰值时的表现。我长期观察后发现几个规律流程节点基本都是 IO 密集型的HTTP 调用、数据库读写核心线程数可以配置得大一些参考值为 CPU 核数的 4 到 8 倍队列容量要跟业务量匹配。队列太小流量突增时任务直接被拒绝队列太大高峰期内存会被大量排队任务占据。我的建议是宁可拒绝在入口层排队也不要让引擎内部堆积大量任务这样通过日志可以快速感知到压力。对拒绝策略我推荐用于记录并丢弃策略加上告警而不是直接抛异常让上游响应 500这样至少能保证外部系统不会因为流程引擎抖动而全部报错。7.3 节点 URL 的管理方式默认情况下HTTP 节点的url是直接写在配置里的。但在微服务架构里服务地址可能经常变动写死 URL 会导致配置四处发散。更合理的做法是把 URL 中的服务名和注册中心打通。HTTP 节点配置serviceName和path引擎在执行时通过注册中心解析出真实地址。如果没有注册中心也可以配合配置中心把 URL 模板配置在 Nacos 或 Apollo 里通过环境变量注入。总而言之要尽量避免把具体服务地址硬编码在流程定义里。关于版本管理我建议把流程定义纳入 Git 管理每次修改走 code review 流程。deer-flow 的流程定义本质上是代码格式是 JSON它应该享受和代码一样的版本管理待遇。我团队里现在任何流程定义的变更都必须提 PR这样出了问题可以随时回溯。最后分享一个我在选型和落地过程中最深的体会不要一上来就建一个大而全的流程中心。我犯过的最大错误就是想把公司所有业务流全部迁到流程引擎上结果不仅周期拉得很长还因为部分流程的逻辑过于复杂反而比原来代码实现更难维护。如果团队此前没有流程编排的经验建议先挑两三个最有价值、边界清晰的流程比如审批流、退款流、工单流建模跑通让团队在过程中积累对 DSL 编写和稳定性保障的认知然后再逐步扩大边界。流程引擎是解放生产力的工具不是制造复杂度的玩具这个度要自己把握好。