实战:基于 Camunda 8 的复杂审批流程实战指南

💡 这不是一篇普通的技术文章,而是一份经过实战检验的完整解决方案!

你将获得

  • ✅ 可直接运行的企业级代码(含详细注释)
  • ✅ 完整的 BPMN 流程设计文件
  • ✅ 生产环境配置最佳实践
  • ✅ 踩坑记录与解决方案

📦 案例源码:https://github.com/zhouByte-hub/camunda-bpmn | 如果这份实战经验对你有帮助,请给个 ⭐ Star,让更多开发者看到!


一、引言

1.1 工作流引擎在企业应用中的重要性

在现代企业信息化建设中,业务流程的自动化管理已成为提升运营效率的关键。传统的硬编码方式处理复杂业务流程存在诸多痛点:流程变更成本高、业务逻辑与技术实现耦合严重、缺乏可视化监控手段。工作流引擎应运而生,它通过标准化的流程建模语言(BPMN)和强大的流程引擎,实现了业务流程与技术实现的解耦,让业务人员能够直接参与流程设计,技术团队专注于系统实现。

工作流引擎的核心价值体现在:

  • 流程可视化:通过 BPMN 图形化建模,业务流程一目了然
  • 灵活变更:流程调整无需修改代码,重新部署即可生效
  • 状态追踪:实时监控流程执行状态,支持审计与合规要求
  • 异常处理:内置重试、超时、补偿等机制,提升系统健壮性

1.2 Camunda 8 架构概述与核心优势

Camunda 8 是 Camunda 公司推出的新一代云原生工作流引擎,相比传统的 Camunda 7,它在架构上进行了彻底的重构,具备以下核心优势:

云原生架构

  • 基于 Zeebe 分布式工作流引擎,支持水平扩展
  • 采用事件溯源架构,确保数据一致性
  • 无状态设计,适合容器化部署

高性能设计

  • 单个 Zeebe Broker 可处理数万流程实例
  • 基于 Actor 模型的并发处理机制
  • 低延迟的任务分发与执行

开发者友好

  • 提供丰富的 Java Client API
  • 支持 Spring Boot 无缝集成
  • Job Worker 模式简化任务处理逻辑

运维便捷

  • 提供 Operate(流程监控)、Tasklist(任务管理)、Optimize(流程优化)等工具
  • 支持 Prometheus/Grafana 监控集成
  • 完善的日志与诊断机制

1.3 本文案例背景:服务器采购审批流程

流程图展示:

复制代码
开始 → 项目组长审批 → 部门经理审批 → [金额判断]
                                        ↓
                                   金额 >= 2000?
                                   ↙        ↘
                              是            否
                              ↓              ↓
                          CEO 审批      财务审批
                              ↓              ↓
                              → 财务审批 ←
                                  ↓
                            [审批结果判断]
                                  ↓
                            通过 → 拨款购买 → 发送邮件 → 结束
                            不通过 → 发送邮件 → 结束

本文将以一个典型的企业级审批流程------"服务器采购审批流程"为例,深入讲解 Camunda 8 的实际应用。该流程包含以下业务场景:

业务流程

  1. 项目组长提交采购申请
  2. 项目组长审批(初审)
  3. 部门经理审批(复审)
  4. 根据金额大小路由:
    • 金额 ≥ 2000 元:需要CEO 审批
    • 金额 < 2000 元:财务直接审批
  5. 财务审批通过后执行拨款购买
  6. 发送邮件通知申请人

技术挑战

  • 多级审批的条件路由
  • 审批超时的自动处理
  • 审计日志的自动记录
  • 流程撤销与消息订阅

1.4 技术选型与项目架构总览

本项目采用以下技术栈:

技术组件 版本 用途
Java 21 开发语言
Spring Boot 3.x 应用框架
Camunda 8 8.8.0 工作流引擎

项目架构

复制代码
camunda_api/
├── src/main/java/org/zhoubyte/camunda_api/
│   ├── CamundaApiApplication.java       # 应用启动类
│   ├── process/                         # 流程控制层
│   │   ├── ProcessController.java       # 流程实例管理
│   │   └── ProcessConstant.java         # 流程常量定义
│   ├── jobs/                            # Job Worker 实现
│   │   ├── PurchaseWorkerJob.java       # 拨款购买任务
│   │   ├── AuditLogsWorkerJob.java      # 审计日志监听器
│   │   ├── OverdueNoticeWorkerJob.java  # 逾期通知任务
│   │   └── SendMessageWorkerJob.java    # 发送邮件任务
│   ├── opt/                             # 运维管理
│   │   ├── DeployController.java        # 流程部署管理
│   │   └── Topology.java                # 集群拓扑查询
│   └── util/                            # 工具类
│       └── TimerCycleUtil.java          # 定时器周期工具
└── src/main/resources/
    ├── bpmn/                            # BPMN 流程定义
    │   ├── purchase_server_process.bpmn # 采购审批流程
    │   ├── apply_form.form              # 申请表单
    │   └── confirm_audit_form.form      # 审批表单
    └── application.yaml                 # 应用配置

二、核心功能介绍

2.1 流程部署与管理

2.1.1 BPMN 流程定义部署

Camunda 8 的流程部署采用资源化方式,通过 CamundaClient 的 newDeployResourceCommand() 方法实现。在 DeployController.java 中,我们实现了流程的部署功能:

java 复制代码
/**
 * 部署 BPMN 流程定义及相关资源
 * 
 * @return 部署事件信息,包含流程定义Key、版本等
 */
@GetMapping(value = "/bpmn_resource")
public Optional<DeploymentEvent> deployBpmnResource() {
    // 创建部署命令,同时部署流程文件和表单文件
    DeploymentEvent join = camundaClient.newDeployResourceCommand()
            .addResourceFromClasspath("bpmn/purchase_server_process.bpmn")  // 流程定义文件
            .addResourceFromClasspath("bpmn/apply_form.form")              // 申请表单
            .addResourceFromClasspath("bpmn/confirm_audit_form.form")      // 审批表单
            .send()   // 异步发送请求
            .join();  // 阻塞等待结果
    return Optional.of(join);
}

技术要点

  • 支持同时部署 BPMN 流程文件和表单文件
  • 资源从 classpath 加载,便于打包部署
  • 返回 DeploymentEvent 包含部署的流程定义信息
2.1.2 表单资源管理

Camunda 8 支持将表单与用户任务绑定,实现表单的独立管理和复用。本项目中定义了两个表单:

  • apply_form.form:采购申请表单
  • confirm_audit_form.form:审批确认表单

在 BPMN 中通过 formId 引用表单:

xml 复制代码
<userTask id="project_leader_approval" name="项目组长审批">
  <bpmn:extensionElements>
    <zeebe:userTask />
    <zeebe:formDefinition formId="confim_audit_form" />
  </bpmn:extensionElements>
</userTask>
2.1.3 流程定义查询与版本控制

通过 newProcessDefinitionSearchRequest() 可以查询已部署的流程定义:

java 复制代码
/**
 * 查询所有已部署的流程定义
 * 
 * @return 流程定义列表
 */
@GetMapping(value = "/process_definition")
public List<ProcessDefinition> bpmnProcessDefinition(){
    // 创建搜索请求并获取所有流程定义
    return camundaClient.newProcessDefinitionSearchRequest()
            .send()   // 异步发送请求
            .join()   // 阻塞等待结果
            .items(); // 获取流程定义列表
}

Camunda 8 支持流程版本管理,每次部署同名流程会自动递增版本号,运行中的流程实例继续使用旧版本,新启动的实例使用新版本。


2.2 流程实例生命周期管理

2.2.1 流程实例启动与变量注入

ProcessController.java 中实现了流程实例的启动:

java 复制代码
/**
 * 启动流程实例并注入初始变量
 * 
 * @param money   采购金额(必填),用于条件路由判断
 * @param overdue 逾期天数(可选),用于设置定时器触发时间
 * @return 流程实例信息
 */
@GetMapping(value = "/start")
public Map<String, Object> startProcess(@RequestParam(value = "money") Integer money, 
                                        @RequestParam(value = "overdue", required = false) Integer overdue) {
    // 初始化流程变量
    Map<String, Object> variables = new HashMap<>();
    variables.put(ProcessConstant.MONEY, money);                              // 采购金额
    variables.put(ProcessConstant.PROCESS_INSTANCE_REVOKE_KEY, UUID.randomUUID()); // 流程撤销关联键
    variables.put(ProcessConstant.RESPONSIBILITY, "项目组长");                  // 当前责任人
    
    // 设置逾期定时器周期
    if(overdue == null || overdue <= 0) {
        // 未指定逾期时间或逾期时间无效,立即触发(测试场景)
        variables.put(ProcessConstant.OVERDUE_TIMER_CYCLE, TimerCycleUtil.immediately());
    } else {
        // 设置指定的逾期天数
        variables.put(ProcessConstant.OVERDUE_TIMER_CYCLE, TimerCycleUtil.ofDays(overdue));
    }
    
    // 创建并启动流程实例
    ProcessInstanceEvent processInstanceEvent = camundaClient.newCreateInstanceCommand()
            .bpmnProcessId(ProcessConstant.PROCESS_INSTANCE_ID)  // 流程定义ID
            .latestVersion()                                     // 使用最新版本
            .variables(variables)                                // 注入流程变量
            .send()   // 异步发送请求
            .join();  // 阻塞等待结果
    
    // 返回流程实例信息
    Map<String, Object> result = new HashMap<>();
    result.put("processInstance", processInstanceEvent);
    return result;
}

关键变量说明

  • money:采购金额,用于条件路由
  • process_instance_revoke_key:流程撤销的关联键
  • responsibility:当前责任人
  • overdue_timer_cycle:逾期定时器周期
2.2.2 用户任务完成与审批流转

用户任务需要人工介入完成,通过 API 调用完成任务并传递审批结果:

java 复制代码
/**
 * 完成用户任务并传递审批结果
 * 
 * @param taskKey   用户任务Key
 * @param variables 任务变量(包含审批结果等)
 * @return 审批结果信息
 */
@PostMapping(value = "/userTask/{task_key}:complete")
public Map<String, Object> completeUserTask(@PathVariable("task_key") Long taskKey, 
                                            @RequestBody Map<String, Object> variables) {
    // 完成用户任务并提交变量
    camundaClient.newCompleteUserTaskCommand(taskKey)
            .variables(variables)  // 携带审批结果等变量
            .send()   // 异步发送请求
            .join();  // 阻塞等待结果
    
    // 查询任务详情(任务已完成,但可获取任务名称等信息)
    UserTask userTask = camundaClient.newUserTaskGetRequest(taskKey).send().join();
    
    // 获取审批结果,默认为false(不通过)
    Object auditResult = variables.getOrDefault("audit_result", false);
    
    // 构建返回结果
    Map<String, Object> result = new HashMap<>();
    result.put(userTask.getName(), auditResult);  // 任务名称 -> 审批结果
    result.put("audit_result", auditResult);      // 统一的审批结果字段
    return result;
}

审批流转逻辑

  • 审批结果通过 audit_result 变量传递
  • BPMN 中的条件表达式根据 audit_result 决定流转方向
  • 审批不通过会回退到项目组长重新审批
2.2.3 流程实例取消与终止

当需要提前结束流程时,可以通过取消命令终止流程实例:

java 复制代码
/**
 * 取消流程实例
 * 
 * @param processInstanceId 流程实例ID
 * @return 操作结果
 */
@GetMapping(value = "/end")
public String endProcess(@RequestParam("process_instance_id") Long processInstanceId) {
    // 发送取消流程实例命令
    camundaClient.newCancelInstanceCommand(processInstanceId)
            .send()   // 异步发送请求
            .join();  // 阻塞等待结果
    return "success";
}

2.3 Job Worker 任务处理机制

2.3.1 Service Task 自动化处理

Camunda 8 通过 Job Worker 模式处理 Service Task。在 PurchaseWorkerJob.java 中实现了拨款购买任务:

java 复制代码
/**
 * 拨款购买 Job Worker
 * 处理 BPMN 中的 Service Task(拨款购买)
 */
@Component
@Slf4j
public class PurchaseWorkerJob {
    
    /**
     * 执行拨款购买任务
     * 
     * @param client        Job客户端,用于完成或失败Job
     * @param activatedJob  激活的Job,包含流程变量等信息
     */
    @JobWorker(type = "to_do_purchase_service", autoComplete = false)
    public void purchaseServerWorkerJob(final JobClient client, final ActivatedJob activatedJob) {
        log.info("[拨款购买] elementId={}, processInstanceKey={} 开始执行",
                activatedJob.getElementId(), activatedJob.getProcessInstanceKey());
        try {
            // TODO: 实际拨款购买业务逻辑
            // 可以通过 activatedJob.getVariable("money") 获取采购金额等变量
            
            // 业务处理完成,手动完成Job
            client.newCompleteCommand(activatedJob.getKey())
                    .send()   // 异步发送请求
                    .join();  // 阻塞等待结果
            log.info("[拨款购买] elementId={} 执行完成", activatedJob.getElementId());
        } catch (Exception e) {
            log.error("[拨款购买] elementId={} 执行失败: {}", activatedJob.getElementId(), e.getMessage(), e);
            // 失败时上报错误,减少重试次数
            client.newFailCommand(activatedJob.getKey())
                    .retries(activatedJob.getRetries() - 1)  // 重试次数减1
                    .errorMessage(e.getMessage())              // 错误信息
                    .send()   // 异步发送请求
                    .join();  // 阻塞等待结果
        }
    }
}

技术要点

  • @JobWorker 注解声明 Job Worker,type 必须与 BPMN 中的 taskDefinition type 一致
  • autoComplete = false 表示手动完成模式,需要显式调用 newCompleteCommand
  • 异常处理通过 newFailCommand 上报错误,触发重试机制
2.3.2 Task Listener 事件监听

Task Listener 是 Camunda 8 的高级特性,用于监听用户任务的生命周期事件。在 AuditLogsWorkerJob.java 中实现了审计日志记录:

java 复制代码
/**
 * 审计日志 Task Listener
 * 监听用户任务的 creating 事件,记录审计日志并设置责任人
 */
@Component
@Slf4j
public class AuditLogsWorkerJob {
    
    private final CamundaClient camundaClient;
    
    /**
     * 记录审计日志并设置责任人
     * 注意:Task Listener 必须手动完成Job,否则用户任务会卡在CREATING状态
     * 
     * @param jobClient    Job客户端
     * @param activatedJob 激活的Job
     */
    @JobWorker(type = "record_audit_logs", autoComplete = false)
    public void recordAuditLogs(JobClient jobClient, ActivatedJob activatedJob) {
        // 根据任务ID获取责任人
        String responsibility = this.getResponsibility(activatedJob.getElementId());
        try {
            if (!StringUtils.isEmpty(responsibility)) {
                // 设置责任人变量
                Map<String, Object> variables = new HashMap<>();
                variables.put(ProcessConstant.RESPONSIBILITY, responsibility);
                
                // Task Listener不支持在完成时携带变量,需要单独设置
                // 使用异步方式设置变量,确保变量设置完成后再完成Job
                camundaClient.newSetVariablesCommand(activatedJob.getProcessInstanceKey())
                        .variables(variables)
                        .send()
                        .thenAccept(response -> jobClient.newCompleteCommand(activatedJob.getKey()).send().join());
            } else {
                // 必须完成Job,用户任务才能从CREATING转为CREATED状态
                jobClient.newCompleteCommand(activatedJob.getKey())
                        .send()
                        .join();
            }
            log.info("[审计日志] responsibility = {}, elementId={}, userTaskKey={}, processInstanceKey={} 任务正在执行",
                    responsibility,
                    activatedJob.getElementId(),
                    activatedJob.getUserTask().getUserTaskKey(),
                    activatedJob.getProcessInstanceKey());
        } catch (Exception e) {
            log.error("[审计日志] elementId={} 执行失败: {}", activatedJob.getElementId(), e.getMessage(), e);
            // 失败时上报错误
            jobClient.newFailCommand(activatedJob.getKey())
                    .retries(activatedJob.getRetries() - 1)
                    .errorMessage(e.getMessage())
                    .send()
                    .join();
        }
    }
    
    /**
     * 根据任务ID获取责任人
     */
    private String getResponsibility(String elementId) {
        return switch (elementId) {
            case ProcessConstant.PROJECT_LEADER_APPROVAL -> "项目组长";
            case ProcessConstant.DEPARTMENT_MANAGER_APPROVAL -> "部门经理";
            case ProcessConstant.FINANCIAL_APPROVAL -> "财务";
            case ProcessConstant.CEO_APPROVAL -> "CEO";
            default -> "未知";
        };
    }
}

关键技术点

  • Task Listener 监听 creating 事件,在用户任务创建时触发
  • 必须手动完成 Job,否则用户任务会卡在 CREATING 状态
  • Task Listener 不支持在完成时携带变量,需要通过 newSetVariablesCommand 单独设置
2.3.3 逾期通知与消息发布

OverdueNoticeWorkerJob.java 中实现了逾期通知和消息发布:

java 复制代码
/**
 * 逾期通知 Job Worker
 * 处理用户任务超时事件,发送逾期通知并触发流程撤销
 */
@Component
@Slf4j
public class OverdueNoticeWorkerJob {
    
    private final CamundaClient camundaClient;
    
    /**
     * 发送逾期通知
     * 当用户任务超时时触发,发布消息以触发事件子流程
     * 
     * @param activatedJob 激活的Job
     */
    @JobWorker(type = "send-overdue-notice")
    public void sendOverdueNotice(ActivatedJob activatedJob) {
        // 获取流程撤销关联键
        String revokeKey = (String) activatedJob.getVariable(ProcessConstant.PROCESS_INSTANCE_REVOKE_KEY);
        
        if(!StringUtils.isEmpty(revokeKey)) {
            // 发布消息,触发事件子流程中的消息开始事件
            camundaClient.newPublishMessageCommand()
                    .messageName(ProcessConstant.TASK_REVOKE)  // 消息名称
                    .correlationKey(revokeKey)                  // 关联键,用于匹配流程实例
                    .send()   // 异步发送请求
                    .join();  // 阻塞等待结果
        }
        
        // 记录日志
        Object responsibility = activatedJob.getVariable(ProcessConstant.RESPONSIBILITY);
        log.info("Send overdue-notice job start, responsibility={}", responsibility);
    }
}

消息发布机制

  • 通过 newPublishMessageCommand 发布消息
  • messageName 对应 BPMN 中的消息定义
  • correlationKey 用于关联流程实例

2.4 集群拓扑与运维监控

2.4.1 Zeebe 集群拓扑查询

Topology.java 中实现了集群拓扑查询:

java 复制代码
/**
 * 集群拓扑查询控制器
 * 用于查询 Zeebe 集群的拓扑信息
 */
@RestController
@RequestMapping(value = "/topology")
public class Topology {
    
    private final CamundaClient camundaClient;
    
    /**
     * 查询集群拓扑信息
     * 返回Broker节点列表、分区分布、Gateway版本等信息
     * 
     * @return 集群拓扑信息
     */
    @GetMapping(value = "/all")
    public io.camunda.client.api.response.Topology showTopology() {
        // 发送拓扑查询请求
        return camundaClient.newTopologyRequest()
                .send()   // 异步发送请求
                .join();  // 阻塞等待结果
    }
}

拓扑信息包含

  • Broker 节点列表
  • 分区分布情况
  • Gateway 版本信息
  • 集群健康状态

三、技术实现细节

3.1 Spring Boot 集成 Camunda 8

3.1.1 Maven 依赖配置与版本管理

pom.xml 中配置了核心依赖:

xml 复制代码
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <dependency>
        <groupId>io.camunda</groupId>
        <artifactId>camunda-spring-boot-starter</artifactId>
    </dependency>
    
    <dependency>
        <groupId>com.github.ulisesbocchio</groupId>
        <artifactId>jasypt-spring-boot-starter</artifactId>
    </dependency>
    
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

重要配置

xml 复制代码
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <parameters>true</parameters>
    </configuration>
</plugin>

<parameters>true</parameters> 配置保留方法参数名,Camunda Client 依赖此项将流程变量自动绑定到方法参数。

3.1.2 Camunda Client 配置详解(自托管模式)

application.yaml 中配置了 Camunda Client:

yaml 复制代码
camunda:
  client:
    mode: self-managed
    grpc-address: ENC(3Oz5CdOP1Mxu4Y7u/sn92ZpRMXeUNUJgJoDTXuv0riYpiZcfm5ngZ714RNjkWA1EfS4LxdL/lWuGuLhGIQo5UQ==)
    rest-address: ENC(WkzegAwa1WfH++IAkRDSGo0OBc6DfiDIDPpnNP58e1TlGoZeaicQP7wORpiDg7O15Znt/VEPceVpwevWX+10Ow==)
    prefer-rest-over-grpc: false
    request-timeout: 60s
    auth:
      method: basic
      username: ENC(Xf/tTg4Ji8hl6e21AYzCH8nTUnene5z2Z/Umxq7DKXX54WGYlgiVQeIkh/6+tEql)
      password: ENC(k15ZkwmrfZeoh+qNf/j1TdvW0MURzPwI44U69rF+5/dChupoXNA0N7mD58tB3Xia)
    enabled: true
    worker:
      defaults:
        timeout: 30s
        max-jobs-active: 32

配置说明

  • mode: self-managed:自托管模式,区别于 Camunda SaaS 云服务
  • grpc-address:Zeebe 网关的 gRPC 地址,用于流程部署、实例创建等操作
  • rest-address:Zeebe 网关的 REST 地址,用于 HTTP REST API 交互
  • prefer-rest-over-grpc: false:优先使用 gRPC,Task Listener 依赖 REST API
  • request-timeout: 60s:HTTP 请求超时,需大于服务端长轮询等待时间
  • auth.method: basic:使用 HTTP Basic 认证
  • worker.defaults.timeout: 30s:Job 锁定超时时间
  • worker.defaults.max-jobs-active: 32:单个 Worker 最大并发处理 Job 数
3.1.3 认证机制:Basic Auth 配置实践

本项目使用 Jasypt 对敏感配置进行加密,配置值以 ENC() 包裹:

yaml 复制代码
auth:
  method: basic
  username: ENC(Xf/tTg4Ji8hl6e21AYzCH8nTUnene5z2Z/Umxq7DKXX54WGYlgiVQeIkh/6+tEql)
  password: ENC(k15ZkwmrfZeoh+qNf/j1TdvW0MURzPwI44U69rF+5/dChupoXNA0N7mD58tB3Xia)

认证方式对比

认证方式 适用场景 配置复杂度
none 开发环境,无安全要求
basic 自托管环境,简单认证
identity 生产环境,集成 Keycloak

3.2 BPMN 2.0 流程建模深度解析

3.2.1 用户任务与表单绑定

在 BPMN 中定义用户任务并绑定表单:

xml 复制代码
<userTask id="project_leader_approval" name="项目组长审批">
  <bpmn:extensionElements>
    <zeebe:userTask />
    <zeebe:formDefinition formId="confim_audit_form" />
    <zeebe:taskListeners>
      <zeebe:taskListener eventType="creating" retries="3" type="record_audit_logs" />
    </zeebe:taskListeners>
    <zeebe:taskHeaders>
      <zeebe:header key="responsibility" value="项目组长" />
    </zeebe:taskHeaders>
  </bpmn:extensionElements>
</userTask>

关键元素

  • <zeebe:userTask />:声明为用户任务
  • <zeebe:formDefinition formId="confim_audit_form" />:绑定表单
  • <zeebe:taskListener>:配置任务监听器
  • <zeebe:taskHeaders>:任务元数据
3.2.2 服务任务与 Job Worker 映射

服务任务通过 taskDefinition 映射到 Job Worker:

xml 复制代码
<serviceTask id="purchase_of_funds" name="拨款购买">
  <bpmn:extensionElements>
    <zeebe:taskDefinition type="to_do_purchase_service" retries="1" />
  </bpmn:extensionElements>
</serviceTask>

映射关系

  • BPMN 中的 type="to_do_purchase_service"
  • Java 代码中的 @JobWorker(type = "to_do_purchase_service")
  • 两者必须完全一致
3.2.3 排他网关与条件表达式

排他网关用于条件路由:

xml 复制代码
<exclusiveGateway id="Gateway_1mm01bz">
  <bpmn:incoming>Flow_05v6331</bpmn:incoming>
  <bpmn:outgoing>Flow_152vbpg</bpmn:outgoing>
  <bpmn:outgoing>Flow_0y79hu3</bpmn:outgoing>
</exclusiveGateway>

<sequenceFlow id="Flow_152vbpg" name="组长审批通过" sourceRef="Gateway_1mm01bz" targetRef="department_manager_approval">
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=audit_result=true</bpmn:conditionExpression>
</sequenceFlow>

<sequenceFlow id="Flow_0y79hu3" name="组长审批不通过" sourceRef="Gateway_1mm01bz" targetRef="Event_10jc7ry">
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=audit_result=false</bpmn:conditionExpression>
</sequenceFlow>

条件表达式语法

  • 使用 = 开头表示表达式
  • 支持 =!=><>=<= 等运算符
  • 可引用流程变量,如 =money>=2000
3.2.4 边界事件与定时器配置

边界事件用于处理超时场景:

xml 复制代码
<boundaryEvent id="Event_133pqmq" name="超时处置" cancelActivity="false" attachedToRef="project_leader_approval">
  <bpmn:outgoing>Flow_1f0k2on</bpmn:outgoing>
  <bpmn:timerEventDefinition>
    <bpmn:timeCycle xsi:type="bpmn:tFormalExpression">=overdue_timer_cycle</bpmn:timeCycle>
  </bpmn:timerEventDefinition>
</boundaryEvent>

定时器类型

  • timeDate:指定时间触发
  • timeDuration:指定持续时间后触发
  • timeCycle:周期性触发(本项目使用)

cancelActivity 属性

  • true:中断当前活动(取消用户任务)
  • false:不中断当前活动(并行执行逾期处理)
3.2.5 事件子流程与消息订阅

事件子流程用于处理消息事件:

xml 复制代码
<subProcess id="Activity_1eux7iq" name="接受逾期事件并发送邮件提醒" triggeredByEvent="true">
  <serviceTask id="Activity_06bae2p" name="发送逾期邮件">
    <bpmn:extensionElements>
      <zeebe:taskDefinition type="send_overdue_message" retries="3" />
    </bpmn:extensionElements>
  </serviceTask>
  <startEvent id="revoke" name="revoke" isInterrupting="false">
    <bpmn:messageEventDefinition messageRef="Message_2tobcm6" />
  </startEvent>
</subProcess>

<message id="Message_2tobcm6" name="task_revoke">
  <bpmn:extensionElements>
    <zeebe:subscription correlationKey="=process_instance_revoke_key" />
  </bpmn:extensionElements>
</message>

消息订阅机制

  • triggeredByEvent="true":声明为事件子流程
  • isInterrupting="false":非中断事件,不影响主流程
  • correlationKey:关联键,用于匹配消息与流程实例

3.3 Job Worker 开发最佳实践

3.3.1 @JobWorker 注解与类型匹配

@JobWorker 注解用于声明 Job Worker:

java 复制代码
@JobWorker(type = "to_do_purchase_service", autoComplete = false)
public void purchaseServerWorkerJob(final JobClient client, final ActivatedJob activatedJob) {
    // Job 处理逻辑
}

注解参数

  • type:Job 类型,必须与 BPMN 中的 taskDefinition type 一致
  • autoComplete:是否自动完成,默认 true
  • timeout:Job 锁定超时时间
  • maxJobsActive:最大并发 Job 数
3.3.2 手动完成模式

手动完成模式适用于以下场景:

  • 需要在完成前执行异步操作
  • 需要根据业务逻辑决定是否完成
  • Task Listener 必须使用手动完成
java 复制代码
@JobWorker(type = "record_audit_logs", autoComplete = false)
public void recordAuditLogs(JobClient jobClient, ActivatedJob activatedJob) {
    // 异步设置变量
    camundaClient.newSetVariablesCommand(activatedJob.getProcessInstanceKey())
            .variables(variables)
            .send()
            .thenAccept(response -> jobClient.newCompleteCommand(activatedJob.getKey()).send().join());
}
3.3.3 错误处理与重试机制

Job 执行失败时,通过 newFailCommand 上报错误:

java 复制代码
try {
    // 业务逻辑
    client.newCompleteCommand(activatedJob.getKey()).send().join();
} catch (Exception e) {
    client.newFailCommand(activatedJob.getKey())
            .retries(activatedJob.getRetries() - 1)
            .errorMessage(e.getMessage())
            .send()
            .join();
}

重试机制

  • retries 在 BPMN 中定义,每次失败减 1
  • retries = 0 时,Job 进入 Incident 状态
  • Incident 需要人工干预或通过 API 解决
3.3.4 Task Listener 特殊处理(creating 事件)

Task Listener 的 creating 事件在用户任务创建时触发,有以下特殊要求:

问题:Task Listener 不支持在完成时携带变量

解决方案 :使用 newSetVariablesCommand 单独设置变量

java 复制代码
camundaClient.newSetVariablesCommand(activatedJob.getProcessInstanceKey())
        .variables(variables)
        .send()
        .thenAccept(response -> jobClient.newCompleteCommand(activatedJob.getKey()).send().join());

异步处理原因

  • send() 返回 CompletableFuture
  • thenAccept() 在设置变量完成后异步执行 Job 完成
  • 避免阻塞 Job Worker 线程

3.4 流程变量与数据流转

3.4.1 变量作用域与生命周期

Camunda 8 的流程变量有以下作用域:

作用域 生命周期 使用场景
流程实例变量 流程实例启动到结束 全局共享数据
本地变量 任务执行期间 任务临时数据

变量操作 API

  • newSetVariablesCommand:设置流程实例变量
  • newCompleteCommand.variables():完成任务时携带变量
  • activatedJob.getVariablesAsMap():获取所有变量
  • activatedJob.getVariable(key):获取单个变量
3.4.2 条件表达式中的变量引用

在 BPMN 条件表达式中引用变量:

xml 复制代码
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=money>=2000</bpmn:conditionExpression>

表达式示例

  • =audit_result=true:布尔值判断
  • =money>=2000:数值比较
  • =responsibility="项目组长":字符串比较
3.4.3 动态定时器周期生成

TimerCycleUtil.java 中实现了定时器周期工具:

java 复制代码
public class TimerCycleUtil {
    
    public static String ofDays(int days) {
        if (days <= 0) {
            throw new IllegalArgumentException("days 必须大于 0,当前值:" + days);
        }
        return "R/P" + days + "D";
    }
    
    public static String ofDays(int days, int repeat) {
        if (days <= 0) {
            throw new IllegalArgumentException("days 必须大于 0,当前值:" + days);
        }
        if (repeat <= 0) {
            throw new IllegalArgumentException("repeat 必须大于 0,当前值:" + repeat);
        }
        return "R" + repeat + "/P" + days + "D";
    }
    
    public static String never() {
        return "R0/P1D";
    }
    
    public static String immediately() {
        return "R1/PT1S";
    }
}

ISO 8601 时间周期格式

  • R/P1D:每天触发一次,无限重复
  • R3/P1D:每天触发一次,共触发 3 次
  • R/PT1H:每小时触发一次,无限重复
  • R0/P1D:永不触发
  • R1/PT1S:1 秒后触发一次
    Camunda 8 使用 ISO 8601 重复时间间隔格式:

格式规范

复制代码
R[重复次数]/P[年]Y[月]M[天]DT[时]H[分]M[秒]S

示例

格式 说明
R/P1D 每天触发一次,无限重复
R3/P1D 每天触发一次,共触发 3 次
R/PT1H 每小时触发一次,无限重复
R5/PT30M 每 30 分钟触发一次,共触发 5 次
R/P1DT2H 每 1 天 2 小时触发一次,无限重复

3.5 消息中间件事件机制

3.5.1 消息订阅与关联键

消息订阅通过 correlationKey 关联流程实例:

xml 复制代码
<message id="Message_2tobcm6" name="task_revoke">
  <bpmn:extensionElements>
    <zeebe:subscription correlationKey="=process_instance_revoke_key" />
  </bpmn:extensionElements>
</message>

关联机制

  • 流程启动时设置 process_instance_revoke_key 变量
  • 消息发布时使用相同的 correlationKey
  • Camunda 自动匹配消息与流程实例
3.5.2 消息发布与流程撤销实现

OverdueNoticeWorkerJob.java 中实现了消息发布:

java 复制代码
@JobWorker(type = "send-overdue-notice")
public void sendOverdueNotice(ActivatedJob activatedJob) {
    String revokeKey = (String) activatedJob.getVariable(ProcessConstant.PROCESS_INSTANCE_REVOKE_KEY);
    if(!StringUtils.isEmpty(revokeKey)) {
        camundaClient.newPublishMessageCommand()
                .messageName(ProcessConstant.TASK_REVOKE)
                .correlationKey(revokeKey)
                .send()
                .join();
    }
}

消息发布 API

  • messageName:消息名称,对应 BPMN 中的消息定义
  • correlationKey:关联键,用于匹配流程实例
  • variables:消息携带的变量(可选)

四、关键技术难点与解决方案

4.1 Task Listener Job 完成时机问题

4.1.1 问题现象:UserTask 卡在 CREATING 状态

在实现审计日志功能时,发现用户任务一直处于 CREATING 状态,无法进入 CREATED 状态,导致审批人无法操作任务。

4.1.2 根因分析:Job 未完成导致状态转换阻塞

问题根源

  • Task Listener 在用户任务创建时触发
  • Task Listener 本质上是一个 Job,需要 Job Worker 处理
  • 如果 Job 未完成,用户任务的状态转换会被阻塞
  • 用户任务从 CREATING 到 CREATED 需要等待 Task Listener Job 完成

错误代码示例

java 复制代码
@JobWorker(type = "record_audit_logs", autoComplete = true)
public void recordAuditLogs(ActivatedJob activatedJob) {
    // autoComplete = true 会自动完成 Job
    // 但如果在此处执行异步操作,可能导致时序问题
}
4.1.3 解决方案:异步变量设置与 Job 完成

正确实现

java 复制代码
@JobWorker(type = "record_audit_logs", autoComplete = false)
public void recordAuditLogs(JobClient jobClient, ActivatedJob activatedJob) {
    String responsibility = this.getResponsibility(activatedJob.getElementId());
    try {
        if (!StringUtils.isEmpty(responsibility)) {
            Map<String, Object> variables = new HashMap<>();
            variables.put(ProcessConstant.RESPONSIBILITY, responsibility);
            
            // 异步设置变量,完成后异步完成 Job
            camundaClient.newSetVariablesCommand(activatedJob.getProcessInstanceKey())
                    .variables(variables)
                    .send()
                    .thenAccept(response -> jobClient.newCompleteCommand(activatedJob.getKey()).send().join());
        } else {
            // 必须完成此 Job,UserTask 才能从 CREATING 转为 CREATED 状态
            jobClient.newCompleteCommand(activatedJob.getKey())
                    .send()
                    .join();
        }
    } catch (Exception e) {
        jobClient.newFailCommand(activatedJob.getKey())
                .retries(activatedJob.getRetries() - 1)
                .errorMessage(e.getMessage())
                .send()
                .join();
    }
}

关键点

  1. 使用 autoComplete = false 手动控制 Job 完成时机
  2. 使用 thenAccept() 确保变量设置完成后再完成 Job
  3. 异常情况必须调用 newFailCommandnewCompleteCommand,避免 Job 卡住

4.2 流程变量动态设置与并发问题

4.2.1 Task Listener 不支持完成时携带变量

问题 :Task Listener 的 newCompleteCommand 不支持 variables() 方法

错误尝试

java 复制代码
jobClient.newCompleteCommand(activatedJob.getKey())
        .variables(variables)  // Task Listener 不支持
        .send()
        .join();

原因 :Camunda 8 的 Task Listener 设计上不允许在完成时修改流程变量。

4.2.2 使用 newSetVariablesCommand 独立设置变量

解决方案

java 复制代码
camundaClient.newSetVariablesCommand(activatedJob.getProcessInstanceKey())
        .variables(variables)
        .send()
        .thenAccept(response -> jobClient.newCompleteCommand(activatedJob.getKey()).send().join());

技术细节

  • newSetVariablesCommand 独立于 Job 完成
  • 使用 thenAccept() 确保变量设置完成后再完成 Job
  • 避免并发问题:变量设置和 Job 完成是两个独立操作

4.3 定时器周期动态配置

4.3.1 TimerCycleUtil 工具类设计

为了简化定时器周期的配置,设计了 TimerCycleUtil 工具类:

java 复制代码
public class TimerCycleUtil {
    
    public static String ofDays(int days) {
        if (days <= 0) {
            throw new IllegalArgumentException("days 必须大于 0,当前值:" + days);
        }
        return "R/P" + days + "D";
    }
    
    public static String ofDays(int days, int repeat) {
        if (days <= 0) {
            throw new IllegalArgumentException("days 必须大于 0,当前值:" + days);
        }
        if (repeat <= 0) {
            throw new IllegalArgumentException("repeat 必须大于 0,当前值:" + repeat);
        }
        return "R" + repeat + "/P" + days + "D";
    }
    
    public static String never() {
        return "R0/P1D";
    }
    
    public static String immediately() {
        return "R1/PT1S";
    }
}
4.3.2 立即触发与永不触发场景

立即触发

  • 使用场景:测试、演示
  • 实现:TimerCycleUtil.immediately()R1/PT1S(1 秒后触发一次)

永不触发

  • 使用场景:禁用逾期处理
  • 实现:TimerCycleUtil.never()R0/P1D(重复次数为 0)

使用示例

java 复制代码
if(overdue == null || overdue <= 0) {
    variables.put(ProcessConstant.OVERDUE_TIMER_CYCLE, TimerCycleUtil.immediately());
} else {
    variables.put(ProcessConstant.OVERDUE_TIMER_CYCLE, TimerCycleUtil.ofDays(overdue));
}

五、常见问题解答(FAQ)

5.1 如何避免流程重复部署?

问题:每次启动应用都会部署流程,导致流程版本不断递增。

解决方案:部署前检查流程定义是否已存在:

java 复制代码
@GetMapping(value = "/bpmn_resource")
public Optional<DeploymentEvent> deployBpmnResource() {
    boolean alreadyDeployed = !camundaClient.newProcessDefinitionSearchRequest()
            .filter(f -> f.processDefinitionId("purchase_server_process"))
            .send()
            .join()
            .items().isEmpty();
    
    if (alreadyDeployed) {
        log.info("流程定义已存在,跳过部署");
        return Optional.empty();
    }
    
    DeploymentEvent join = camundaClient.newDeployResourceCommand()
            .addResourceFromClasspath("bpmn/purchase_server_process.bpmn")
            .addResourceFromClasspath("bpmn/apply_form.form")
            .addResourceFromClasspath("bpmn/confirm_audit_form.form")
            .send()
            .join();
    return Optional.of(join);
}

5.2 Job Worker 的 autoComplete 模式何时使用?

autoComplete = true(自动完成)

  • 适用场景:简单的同步处理逻辑
  • 优点:代码简洁,无需手动完成
  • 缺点:无法控制完成时机,无法执行异步操作

autoComplete = false(手动完成)

  • 适用场景:
    • 需要执行异步操作
    • 需要根据业务逻辑决定是否完成
    • Task Listener(必须手动完成)
  • 优点:完全控制 Job 生命周期
  • 缺点:代码复杂,必须显式调用完成命令

5.3 如何处理 Job 执行失败?

重试机制

java 复制代码
try {
    // 业务逻辑
    client.newCompleteCommand(activatedJob.getKey()).send().join();
} catch (Exception e) {
    if (activatedJob.getRetries() > 1) {
        // 还有重试次数
        client.newFailCommand(activatedJob.getKey())
                .retries(activatedJob.getRetries() - 1)
                .errorMessage(e.getMessage())
                .send()
                .join();
    } else {
        // 重试次数耗尽,进入 Incident
        log.error("Job 执行失败,进入 Incident 状态: {}", e.getMessage());
        client.newFailCommand(activatedJob.getKey())
                .retries(0)
                .errorMessage(e.getMessage())
                .send()
                .join();
    }
}

Incident 处理

  • 通过 Camunda Operate 查看 Incident
  • 解决问题后,通过 API 或 Operate 重试 Job

5.4 流程变量如何在不同任务间传递?

流程实例变量

  • 在流程启动时设置
  • 所有任务都可以访问
  • 通过 activatedJob.getVariable(key) 获取

任务完成时设置变量

java 复制代码
camundaClient.newCompleteUserTaskCommand(taskKey)
        .variables(variables)
        .send()
        .join();

独立设置变量

java 复制代码
camundaClient.newSetVariablesCommand(processInstanceKey)
        .variables(variables)
        .send()
        .join();

5.5 如何实现流程的动态撤销?

消息订阅方式

  1. 在 BPMN 中定义消息事件子流程:
xml 复制代码
<subProcess id="Activity_1eux7iq" triggeredByEvent="true">
  <startEvent id="revoke" isInterrupting="false">
    <bpmn:messageEventDefinition messageRef="Message_2tobcm6" />
  </startEvent>
  <!-- 撤销逻辑 -->
</subProcess>

<message id="Message_2tobcm6" name="task_revoke">
  <zeebe:subscription correlationKey="=process_instance_revoke_key" />
</message>
  1. 流程启动时设置关联键:
java 复制代码
variables.put("process_instance_revoke_key", UUID.randomUUID().toString());
  1. 发布消息触发撤销:
java 复制代码
camundaClient.newPublishMessageCommand()
        .messageName("task_revoke")
        .correlationKey(revokeKey)
        .send()
        .join();

六、性能优化与最佳实践

6.1 Job Worker 并发配置优化

配置参数

yaml 复制代码
camunda:
  client:
    worker:
      defaults:
        timeout: 30s
        max-jobs-active: 32

优化建议

  • max-jobs-active:根据业务处理时间和服务器资源调整
    • IO 密集型任务:可设置较高值(64-128)
    • CPU 密集型任务:设置较低值(8-16)
  • timeout:根据任务处理时间设置
    • 短任务:10-30 秒
    • 长任务:60-300 秒

6.2 长轮询超时时间设置

配置参数

yaml 复制代码
camunda:
  client:
    request-timeout: 60s

优化建议

  • 必须显著大于服务端长轮询等待时间(默认约 10 秒)
  • 建议设置为 60 秒,保留足够余量
  • 过短会导致请求超时,过长会浪费资源

6.3 流程变量设计原则

最佳实践

  1. 最小化变量数量:只保留必要的变量
  2. 避免大对象:不要在变量中存储大对象(如文件内容)
  3. 使用简单类型:优先使用 String、Integer、Boolean
  4. 变量命名规范:使用有意义的变量名,避免歧义

反例

java 复制代码
// 不要这样做
variables.put("data", largeJsonObject);  // 大对象
variables.put("temp", temporaryValue);    // 临时变量
variables.put("x", value);                // 无意义命名

正例

java 复制代码
// 应该这样做
variables.put("purchase_amount", 3000);
variables.put("audit_result", true);
variables.put("approver_comment", "同意采购申请");

6.4 错误处理与重试策略

重试策略设计

错误类型 重试次数 重试间隔 处理方式
网络超时 3 指数退避 自动重试
业务异常 1 进入 Incident
系统异常 5 固定间隔 自动重试

指数退避实现

java 复制代码
@JobWorker(type = "my_service", autoComplete = false)
public void myServiceJob(JobClient client, ActivatedJob job) {
    try {
        // 业务逻辑
        client.newCompleteCommand(job.getKey()).send().join();
    } catch (RetryableException e) {
        // 计算退避时间
        long backoffTime = (long) Math.pow(2, job.getRetries()) * 1000;
        
        client.newFailCommand(job.getKey())
                .retries(job.getRetries() - 1)
                .errorMessage(e.getMessage())
                .retryBackoff(Duration.ofMillis(backoffTime))
                .send()
                .join();
    }
}
相关推荐
烧饼Fighting3 小时前
java+vue推rtsp流实现视频播放(由javacv+ffmpg转为vlcj)
java·开发语言·音视频
XiYang-DING3 小时前
【Java SE】泛型(Generics)
java·windows·python
紫丁香3 小时前
03-Flask请求上下文响应与错误处理机制深度解析
后端·python·flask
zb200641203 小时前
Spring Boot spring-boot-maven-plugin 参数配置详解
spring boot·后端·maven
云霄IT3 小时前
安卓apk逆向之crc32检测打补丁包crc32_patcher.py
java·前端·python
小捏哩3 小时前
死锁检测组件的设计
linux·网络·数据结构·c++·后端
小句3 小时前
Java Web 技术演进:Servlet → Spring → Spring Boot
java·前端·spring
Elastic 中国社区官方博客3 小时前
Elasticsearch BBQ:一场教科书式的向量搜索 “弯道超车”
大数据·数据库·人工智能·elasticsearch·搜索引擎·ai·全文检索
麦聪聊数据3 小时前
基于 SQL2API 架构快速发布 RESTful 接口
数据库·后端·sql·低代码·restful