Camunda Service Task 五种实现方式解析:原理、场景与选型 你有没有注意过在 Camunda Modeler 里给 Service Task 设置属性时那个 Implementation 下拉框里总是躺着五种选项External、Java class、Expression、Delegate expression、Connector。刚接触工作流的同事十有八九会在这个地方发懵——都是服务任务为什么搞出五种做法更麻烦的是选错一次后面整个系统的架构都会跟着遭殃。这篇博文我就把这五种实现方式从头到尾掰扯清楚每种方式的执行原理、配置写法、适用场景以及我在实际项目里踩过的坑。不管你是刚入门 Camunda 的普通开发还是正在做技术选型的高级工程师这篇文章都能帮你少走一两个月的弯路。1. 从调用模型说起为什么 Service Task 会有五种实现方式1.1 一个服务任务在引擎里到底经历了什么先弄清楚 Service Task 的本质。BPMN 里 Service Task 代表一个不需要人来干预的自动化工作节点流程实例执行到这个节点就要触发一段业务逻辑。至于这段逻辑怎么触发、由谁来触发就是五种实现方式的分水岭。Camunda 引擎在核心层面做得很巧妙它本身只负责流程状态的推进不关心业务代码怎么组织。到了服务任务节点引擎相当于抛出一个“活”然后通过不同机制把这个“活”交给不同的执行者。Java Class 是引擎直接反射一个类来干活Expression 是引擎解析一段表达式来干活Delegate Expression 是引擎先解析表达式、再把解析结果作为干活的对象External 是引擎把任务写进外部任务表由外部 Worker 来拉取干活Connector 则是引擎按预先定义好的模板把一次调用映射成外部系统接口访问。理解了这层你再看 Modeler 里的下拉框就不会觉得五个选项很突兀了——它们只是实现“触发业务逻辑”这个动作的五种契约。1.2 五种方式背后的两大阵营虽然表面上有五种但按执行模型其实可以归类成两个阵营同步进程内阵营Java Class、Expression、Delegate Expression。这三种方式的业务逻辑都运行在流程引擎所在的 JVM 里在当前线程内同步执行执行完引擎才会继续推进流程。它们的区别仅仅是“调用方式”不同本质上都是进程内委托。异步解耦阵营External Task 和 Connector。这两种方式中流程引擎执行到 Service Task 后不会等待业务逻辑完成至少不会在本地线程里同步执行而是把“需要执行啥”记录下来由外部系统来消费。这个是架构层面的差异不是 api 层面的差异。为什么要分这两个阵营因为同步方式的好处是简单、一致性好、事务边界清楚坏处是流程引擎和业务逻辑强耦合引擎一升级业务代码就得跟着重新部署而且长耗时任务会一直占住引擎线程。异步方式刚好相反你可以在独立的微服务里维护业务逻辑甚至可以让不同的团队各自负责自己的 Worker互不干扰。代价是你要额外处理分布式事务、幂等、监控等问题。选哪一种本质上是在回答三个问题这个任务的耗时是多长业务逻辑能不能跟引擎进程放一起谁负责运维这段逻辑的代码2. External Task 的落地姿势轮询机制、锁定时间与重试策略2.1 External Task 的完整执行链路先讲 External Task因为它最能打破对 Service Task 的传统认知。这种模式下服务任务本身不写任何业务代码而是在流程定义的 XML 里声明一个 TopicserviceTask idtask_approve name风控审核 camunda:typeexternal camunda:topicrisk-control /引擎执行到这个节点时会往ACT_RU_EXTERNAL_TASK表里插入一条记录然后流程实例在这里停住。外部系统里要有一个 Worker 程序通过 Camunda REST API 去拉取这个 Topic 下的任务。完整链路是这样的Worker 启动后调用POST /engine-rest/external-task/fetchAndLock向引擎发起长轮询请求引擎返回一批未处理的外部任务同时给这些任务加上一把“锁”Worker 在自己的线程池里执行业务逻辑业务逻辑完成后Worker 调用complete接口把结果变量传回给引擎引擎收到 complete 后流程实例继续往下走这里最关键的概念是锁Lock。多个 Worker 可以同时订阅同一个 Topic引擎通过锁机制保证一个外部任务在同一时间只会被一个 Worker 处理。锁有超时时间Worker 处理太久锁会过期任务就会重新被其他 Worker 拉走。2.2 客户端配置里的关键参数在 Java 项目里External Task 客户端最普遍的写法是这样ExternalTaskClient client ExternalTaskClient.create() .baseUrl(http://localhost:8080/engine-rest) .workerId(risk-worker) .maxTasks(10) .asyncResponseTimeout(10000) .build(); client.subscribe(risk-control) .lockDuration(30000) .handler((externalTask, externalTaskService, client) - { // 1. 拿到流程变量 String orderId (String) externalTask.getVariable(orderId); // 2. 执行业务逻辑 boolean approved riskService.check(orderId); // 3. 完成后调用 complete把结果写回引擎 externalTaskService.complete(externalTask, Variables.putValue(riskApproved, approved)); }) .open();几个参数值得仔细说明lockDuration锁定时间默认一般是 20 到 30 秒。含义是“这个任务我认领了别让别人动”。如果业务逻辑经常超过锁定时长任务会被重复拉取、重复执行这时候要么调大 lockDuration要么在业务代码里做幂等。反过来如果锁设得太长Worker 中途宕机其他 Worker 就得等锁过期才能接手故障恢复会被拖慢。maxTasks单次拉取数量每次 fetchAndLock 最多拿多少个任务。这个值直接影响吞吐和 Worker 压力。拉取太多任务全锁在手里其他 Worker 抢不到拉取太少频繁请求引擎导致网络开销变大。一般建议结合单任务处理耗时会话比如单任务 5 秒想让 Worker 每秒消化一个那 maxTasks 设 5 到 10 比较合理。asyncResponseTimeout长轮询时间Worker 如果没有拉到任务REST 连接不会立刻断开而是挂住等有任务进来。这个参数就是挂住的最长时间。设置得太短Worker 会疯狂空轮询白白消耗引擎和网络资源常规推荐 5000 到 10000 毫秒。2.3 我在实际项目里踩过的 External 坑External Task 的坑主要集中在分布式场景。第一个坑是重复执行问题。complete 调用发出去了但网络抖动导致引擎没收到响应Worker 端认为失败引擎端锁又过期了任务被重新分发给另一个 Worker业务逻辑就被执行了两次。我在支付系统里遇到过业务方对账因为这个问题投诉过我们。解决思路只能是External Task 的处理器必须设计成可重入的或者通过业务单据状态做幂等。这不是框架 bug而是分布式异步模型天然需要面对的问题。第二个坑是失败处理别乱用 handleFailure。External Task 的handleFailure可以设置重试次数和重试时间间隔。但如果你把重试次数设成很大、间隔设得很短系统里就会有大量外部任务在那反复失败反复重试把引擎的ACT_RU_EXTERNAL_TASK表和日志搞得一团糟。合理的做法是对可预期的瞬时错误比如下游暂时不可用设置有限次数的重试2 到 3 次对不可恢复的业务错误直接调用handleBpmnError走到补偿分支或者记一条错误表人工介入。第三个坑是租户隔离。如果引擎开启了多租户模式外部任务默认带着租户 ID。Worker 在订阅时需要指定租户否则拉不到任务。很多团队第一次遇到“流程明明启动了Worker 就是没反应”八成就是这个原因。外部任务适合长耗时、跨进程、微服务化的场景但记住一旦选择它你就得额外负责监控、幂等和任务积压告警这些全是成本。3. Java Class 与 Delegate Expression把“类”和“Bean”交给流程引擎的两种玩法3.1 Java Class 的硬编码路线Java Class 是历史最悠久的实现方式。在流程定义里直接写全限定类名引擎执行到任务时自动实例化这个类并调用它serviceTask idtask_deduct name扣减库存 camunda:classcom.example.process.InventoryDeductDelegate /对应的 Java 类需要继承JavaDelegate接口public class InventoryDeductDelegate implements JavaDelegate { Override public void execute(DelegateExecution execution) throws Exception { String sku (String) execution.getVariable(sku); Integer quantity (Integer) execution.getVariable(quantity); boolean result inventoryService.deduct(sku, quantity); execution.setVariable(deductResult, result); } }这里有个重要的实现细节Camunda 每次执行到该节点时都会通过无参构造器创建这个委托类的新实例而不是复用单例。也就是说你不能指望在类的成员变量里缓存请求级别的状态因为不同流程实例的线程可能会用同一个 Class 定义创建出多个实例但如果你把状态放到静态字段或者依赖单例 Bean 里就会有并发隐患。我在早期项目里就见过有人这样把状态放在实例字段里结果高并发下数据串名了排查了很久。3.2 Delegate ExpressionSpring Bean 委托的正确打开方式既然每次实例化这么麻烦那在 Spring Boot 项目里大家几乎都不直接用camunda:class而是委托给 Spring 容器管理的 Bean。这个方式就是 Delegate ExpressionserviceTask idtask_deduct name扣减库存 camunda:delegateExpression${inventoryDeductDelegate} /Java 侧定义一个普通的 Spring 组件Component(inventoryDeductDelegate) public class InventoryDeductDelegate implements JavaDelegate { // 该 Bean 由 Spring 管理天然支持依赖注入 Autowired private InventoryService inventoryService; Override public void execute(DelegateExecution execution) throws Exception { // 业务逻辑与上面类似 } }这个写法的优势非常明显委托对像本身是 Spring Bean可以注入任何需要的服务Bean 的加载和生命周期由 Spring 管理不会出现“每次执行都重新创建”的奇怪行为Spring 默认是单例也方便写单元测试。所以你会看到在 Spring Boot 项目中Delegate Expression 是五种方式里绝对的主流。Java Class 反而基本剩下两种用途一是没有 Spring 环境的纯 SE 部署二是历史遗留系统不想改流程定义。3.3 两个容易混淆的细节初始化时机与接口选择关于 Delegate Expression 有两点容易被新手忽略。其一表达式${inventoryDeductDelegate}的解析结果是 Bean 实例而这个 Bean必须实现JavaDelegate接口。如果你写了一个普通 Service 类放进去引擎启动时会报类型转换错误。这个跟 Expression 方式不一样后面我会对比。其二委托 Bean 默认是 Spring 单例所以它必须是无状态的。靠成员变量保存某个流程实例的上下文在并发下一定会出问题。正确的做法是所有状态都通过DelegateExecution的变量传递委托对象只放无状态逻辑。Java Class 和 Delegate Expression 在异常处理上有共性如果execute方法抛出异常引擎会把整个流程实例事务回滚前提是没有使用异步延续。回滚意味着之前在这个任务上设置的流程变量也会丢失。如果你的业务代码里有外部副作用比如调了第三方接口成功但后续失败了那这个外部调用是不会跟着事务回滚的——这也是使用 JavaDelegate 时一定要记住的坑我建议把有副作用的操作放在流程的外部环节或者依赖下游的幂等能力。4. Expression 实现方式一段表达式能调多远边界在哪里4.1 表达式能做什么有些服务任务的逻辑简单到不值得单独写一个 Delegate 类。比如设置一个变量、计算一个值、调用一个现成方法。这种场景可以用 Expression 实现!-- 直接调用 execution 的内置方法 -- serviceTask idtask_setvar name设置来源标记 camunda:expression${execution.setVariable(source, manual)} / !-- 调用 Spring Bean 的方法并传入 execution -- serviceTask idtask_calc name计算金额 camunda:expression${priceCalculator.calculate(execution)} /在这个模式下引擎会把表达式交给 JUEL/Spring EL 引擎解析在流程上下文里执行。表达式里可以访问很多隐式对象execution代表当前的流程实例执行对象task代表当前任务对象在用户任务相关场景下有用businessLogic可以获取流程变量相关工具。也可以直接调用 Spring 容器里的 Bean 方法。不少人对 Expression 的认知是“这是个玩具”其实它的能力边界比想象中宽。只要方法签名允许你可以传 execution、task、流程变量进去也可以把返回值写到变量里通过 resultVariable 配置。在流程编排时用表达式做数据加工、调用策略类服务是很常见的做法。4.2 方法解析、返回值与事务边界写表达式的时候有两条容易翻车的事项。第一关于返回值。如果表达式返回一个JavaDelegate对象引擎会把这个对象当作委托来执行如果返回的是普通对象引擎会忽略这个返回值除非配置了camunda:resultVariable去接收它。很多初学者以为表达式里调用了方法返回值就能自动放进流程变量里到自己跑通的时候发现没有变量生成然后一脸茫然。实际上要保存结果需要这样写serviceTask idtask_calc name计算金额 camunda:expression${priceCalculator.getAmount(execution)} camunda:resultVariableamountResult /第二关于事务。表达式调用的是进程内方法它的执行发生在流程引擎的外层事务里。如果方法内部用了 Spring 的Transactional那它的事务是加入外层事务还是开启新事务要看传播级别。方法内部抛出的异常也会传导到流程引擎导致流程事务整个回滚。所以表达式里的方法最好只做“快速、无副作用、幂等”的操作如果有网络请求或写库操作还是交给 Delegate 或者 External 更稳妥。4.3 Expression 和 Delegate Expression 的区别到底在哪里这两者在配置界面上长得像都是${...}开头但语义完全不同。用一个表格对比能看得很清楚维度ExpressionDelegate Expression表达式含义调用某个方法或表达式逻辑解析出一个委托对象解析结果要求无返回 JavaDelegate 对象时才执行委托必须是 JavaDelegate 或 ActivityBehavior 实例典型写法${service.calculate(execution)}${serviceDelegate}适用场景快速方法调用、设置变量、表达式运算把完整业务处理委托给独立对象一句话总结Delegate Expression 是一个名词指向一个 BeanExpression 是一段动词直接执行一个动作。如果你把${inventoryService.deduct(...)}写进了 Delegate Expression引擎会尝试把它解析成一个 Bean 对象然后莫名奇妙报类型错误反过来如果用表达式方式去解析一个只有默认 execute 方法的 Delegate Bean引擎也会因为拿不到可调用方法而报 ELException。经验上只要业务逻辑超过三四行就不要用 Expression 硬塞到流程定义里。流程文件首先是给人看的其次才是给引擎跑的。我在代码评审里不止一次让大家把一大段 EL 方法链拆成独立的 Delegate Bean表达式的可维护性和可调试性真的不如真实代码。5. Connector 的定位与实操从 HTTP 连接器到新版本连接器体系5.1 Connector 到底是什么Connector 在五种实现方式里最容易引起误解因为不同版本、不同产品形态下的 Connector 长得完全不一样。在 Camunda 7 里Service Task 的 Implementation 选择 Connector核心思路是把一次外部系统调用抽象成“连接器插件 元素模板”。你不需要写委托类只需要在流程定义里声明连接器 ID 和输入输出映射serviceTask idhttpTask name调用订单接口 camunda:typeconnector camunda:connector camunda:connectorIdhttp-connector/camunda:connectorId camunda:inputOutput camunda:inputParameter nameurlhttps://api.example.com/orders/camunda:inputParameter camunda:inputParameter namemethodPOST/camunda:inputParameter camunda:inputParameter namecontentTypeapplication/json/camunda:inputParameter camunda:inputParameter namepayload${orderPayload}/camunda:inputParameter camunda:outputParameter nameresponse${apiResponse}/camunda:outputParameter /camunda:inputOutput /camunda:connector /serviceTask要真正跑起来引擎端需要装对应的连接器插件。连接器插件负责解析camunda:connectorId把输入参数转成一次真实的 HTTP 请求再把响应写回输出参数。这里的关键点是这个“http-connector”并不是 Camunda 7 社区版自带的内置能力需要依赖扩展插件或者企业组件才能拿到开箱即用的 HTTP 连接器。Connector 的优点是配置驱动、可视化程度高。如果你恰好熟悉 Camunda Modeler 的元素模板Element Template功能可以给常用接口做成模板业务人员或普通开发在 Modeler 里选择一个模板填几个参数就能搞定一个服务任务完全不用碰 Java 代码。5.2 选择 Connector 前的现实考量实际项目里我对 Connector 的态度比较审慎。它是“低代码”路线边界很清晰适合调用次数多、参数结构简单、响应处理不复杂的外部系统接口。比如发短信、调用风控网关、推送消息到队列这些都很合适。但如果调用逻辑复杂比如需要根据响应报文做多分支处理、需要多个接口串联、需要复杂的错误重试策略那 Connector 的表达式映射会非常臃肿调试也麻烦。这种情况我宁可写一个 Delegate Bean 或者外部 Task Worker逻辑清晰得多。另外要留意的是版本差异。到了 Camunda 8Connector 的体系发生了很大变化它被提到了头等公民的位置官方提供了一批开箱即用的连接器HTTP、Kafka、AWS 等模型里画一个 Service Task绑定一个 Connector云上自动就会部署对应的连接器。从架构上看Camunda 8 的 Service Task 本身就是基于 Job 的异步执行模型Connector 本质上是预打包好的 Job Worker这和 Camunda 7 里“引擎内部解析连接器”的机制是完全两套。如果你正在做 7 到 8 的迁移这个认知要第一时间纠正过来。6. 五种方式的选型决策面试题之外的真相6.1 一张对比表看穿差异做一个综合对比方便你直接截图保存实现方式执行模型业务代码位置典型场景运维依赖事务一致性Java Class同步进程内引擎进程内无 Spring 环境、历史代码需随引擎部署同引擎事务Expression同步进程内引擎进程内简单方法调用、设置变量Bean 需在引擎上下文可用同引擎事务Delegate Expression同步进程内引擎进程内Spring 项目主流业务委托Spring 容器管理同引擎事务External Task异步跨进程独立 Worker 服务微服务、长耗时、跨团队需独立部署 Worker无强一致需幂等Connector异步或同步视连接器而定连接器插件或外部系统外部接口标准化调用需安装插件/连接器视连接器实现6.2 我总结的选型原则第一原则先看同步还是异步。任务耗时长、需要跨进程、需要独立扩容、需要隔离故障选 External任务短、对一致性要求高、希望流程引擎一个事务搞定选同步方式。这个分叉是你做决策的第一层也是最关键的一层。第二原则Spring 项目无脑优先 Delegate Expression。它能依赖注入、能单测、能被 Spring 生态管理几乎没有理由在 Spring 项目里用 Java Class。Java Class 只在非 Spring 场景出现。第三原则外部系统对接优先考虑 Connector 模板化但复杂逻辑要果断降级为 Delegate 或 External。判断标准很简单这个任务的处理逻辑能不能在流程定义里用几个输入输出映射描述清楚能用 Connector不能别硬用。第四原则表达式是“轻量快捷方式”不是主战兵力。只用于简单赋值、简单方法调用。任何超过三行的逻辑、任何有异常处理的逻辑、任何需要写单元测试确认的逻辑都不应该塞在 EL 表达式里。6.3 混合使用一个真实流程的组合方案最后给一个具体例子。我在一个电商订单流程里是这样组合五种方式的第一步“校验订单参数”用Expression${orderValidator.validate(execution)}因为它只是调一个纯校验方法返回 boolean没有外部依赖。第二步“扣减库存”用Delegate Expression委托给inventoryDeductDelegate因为这里要从 Spring 容器注入库存服务业务逻辑较多。第三步“风控审核”用External Task订阅 topicrisk-control。因为风控团队独立维护服务跟流程引擎不在一个部署单元还要异步处理。第四步“发送通知短信”用ConnectorHTTP 连接器因为短信网关就是一个标准化 HTTP 接口用模板配置即可。第五步“更新订单状态”返回到Java Class不这里我用的是 Delegate Expression 调一个orderStatusUpdateDelegate。Java Class 在这套流程里其实一个都没用到这并不奇怪也不丢人——现代开发里Java Class 就是那个“存在但基本不出场”的选项。组合使用的好处是各取所长短任务同步处理、跨团队任务异步解耦、外部接口模板化。同时你也可以看到实现方式不是写静态题而是工程取舍。同样的一个“发短信”如果团队没有人维护 Worker你就应该用 Delegate Expression 里封装的短信客户端如果短信通道性能压力大想独立扩容那就迁到 External Task 或者 Connector。我在实际工作中最大的体会是Camunda 的所有机制都是在帮你“把控制权放在该放的地方”。Java Class 给你字节码级别的控制Delegate Expression 给你容器级控制External Task 给你系统级的边界控制Connector 给你配置级的接口控制。理解这五种的差异本质上是理解“这段业务逻辑由谁负责、在哪里执行、怎么被监控”这三个问题。所以下次再看到 Service Task 属性面板里的下拉框别把它当成一道选择题。它是一份架构契约。选任何一种实现方式都意味着你要接受那套配套的运维责任。把这个想清楚了你才算是真正把 Service Task 用明白了。