红茶的个人站点

  • 首页
  • 专栏
  • 开发工具
  • 其它
  • 隐私政策
Awalon
Talk is cheap,show me the code.
  1. 首页
  2. 未分类
  3. 正文

Activiti 学习笔记 2:流程定义

2025年5月16日 8点热度 0人点赞 0条评论

流程定义

获取

获取指定流程定义:

final String PROCESS_DEFINITION_KEY = "test"; // 流程定义的 key
RepositoryService repositoryService = processEngine.getRepositoryService();
List<ProcessDefinition> definitionList = repositoryService.createProcessDefinitionQuery()
    .processDefinitionKey(PROCESS_DEFINITION_KEY) // 只查询指定 key 的流程定义
    .orderByProcessDefinitionVersion().desc() // 结果按照版本降序排列
    .list();
for (ProcessDefinition processDefinition : definitionList) {
    // 打印单条流程定义信息
    System.out.printf("ID:%s,名称:%s,版本:%d%n",
                      processDefinition.getId(),
                      processDefinition.getName(),
                      processDefinition.getVersion());
}

删除

删除流程定义:

final String DEPLOY_ID = "1";
RepositoryService repositoryService = processEngine.getRepositoryService();
repositoryService.deleteDeployment(DEPLOY_ID);

这里的DEPLOY_ID指代要删除的部署 id,deleteDeployment会将指定的部署“回滚”,即删除掉相关流程定义(act_re_procdef 表)、部署信息(act_re_deployment 表)以及用于部署的二进制文件(act_ge_bytearray 表)。

流程已经产生的历史信息(act_his前缀的表)不会被删除。

要注意的是,当要删除的流程仍然存在进行中的流程实例,该操作无法正常执行,而是抛出一个异常:

org.apache.ibatis.exceptions.PersistenceException: 
### Error updating database.  Cause: java.sql.SQLIntegrityConstraintViolationException: Cannot delete or update a parent row: a foreign key constraint fails (`activiti`.`act_ru_execution`, CONSTRAINT `ACT_FK_EXE_PROCDEF` FOREIGN KEY (`PROC_DEF_ID_`) REFERENCES `act_re_procdef` (`ID_`))
### The error may exist in org/activiti/db/mapping/entity/ProcessDefinition.xml
### The error may involve org.activiti.engine.impl.persistence.entity.ProcessDefinitionEntityImpl.deleteProcessDefinitionsByDeploymentId-Inline
### The error occurred while setting parameters

错误信息说的很清楚,act_ru_execution表和act_re_procdef表有外键关联,当act_ru_execution中存在数据行对act_re_procdef中外键引用时,act_re_procdef对应的数据行就不能被删除。

要解决这个问题,可以通过 API 进行级联删除(在删除部署的同时删除相关仍然在执行的进程实例):

repositoryService.deleteDeployment(DEPLOY_ID,true); // 级联删除

获取资源文件

部署流程时使用的资源文件(BPMN 和 PNG)是保存在act_ge_bytearray表,以二进制的方式保存:

CREATE TABLE `act_ge_bytearray` (
  `ID_` varchar(64) COLLATE utf8_bin NOT NULL,
  `REV_` int DEFAULT NULL,
  `NAME_` varchar(255) COLLATE utf8_bin DEFAULT NULL,
  `DEPLOYMENT_ID_` varchar(64) COLLATE utf8_bin DEFAULT NULL,
  `BYTES_` longblob,
  `GENERATED_` tinyint DEFAULT NULL,
  PRIMARY KEY (`ID_`),
  KEY `ACT_FK_BYTEARR_DEPL` (`DEPLOYMENT_ID_`),
  CONSTRAINT `ACT_FK_BYTEARR_DEPL` FOREIGN KEY (`DEPLOYMENT_ID_`) REFERENCES `act_re_deployment` (`ID_`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8_bin

用于二进制文件的字段类型是longblob。

final String PROCESS_DEFINITION_ID = "travel_apply:4:25004"; // 流程定义 id
// 获取流程定义
RepositoryService repositoryService = processEngine.getRepositoryService();
ProcessDefinition processDefinition = repositoryService.getProcessDefinition(PROCESS_DEFINITION_ID);
// 获取资源名称
String bpmnName = processDefinition.getResourceName(); // bpmn资源名称
String pngName = processDefinition.getDiagramResourceName(); // PNG资源名称
// 获取资源的输入流
String deploymentId = processDefinition.getDeploymentId(); // 流程定义所属的部署 id
InputStream bpmnInputStream = repositoryService.getResourceAsStream(
    deploymentId, //  部署id
    bpmnName); // 资源名
InputStream pngInputStream = repositoryService.getResourceAsStream(
    deploymentId, //  部署id
    pngName); // 资源名
// 保存到 classpath 目录
String classpathDir = this.getClass().getProtectionDomain()
    .getCodeSource()
    .getLocation()
    .getPath(); // 当前运行的 classpath 目录
saveInputStream(bpmnInputStream, classpathDir + "travel.bpmn20.xml");
saveInputStream(pngInputStream, classpathDir + "travel.png");

saveInputStream是一个自定义的用于处理输入流的方法:

/**
     * 将输入流保存到指定位置的文件
     *
     * @param inputStream 输入流
     * @param savePath    保存的文件位置
     * @throws IOException
     */
private void saveInputStream(InputStream inputStream, String savePath) throws IOException {
    try (inputStream) {
        OutputStream outputStream = new FileOutputStream(savePath);
        inputStream.transferTo(outputStream);
        outputStream.close();
    }
}

活动

通常我们需要获取某个流程实例的历史活动记录,比如查看某个出差申请的审批记录。

可以用以下方式获取:

final String PROCESS_INSTANCE_ID = "27501"; // 流程实例id
HistoryService historyService = processEngine.getHistoryService();
List<HistoricActivityInstance> activityInstances = historyService.createHistoricActivityInstanceQuery()
    .processInstanceId(PROCESS_INSTANCE_ID)
    .orderByHistoricActivityInstanceStartTime().asc() // 按活动开始时间升序
    .list();
for (HistoricActivityInstance activityInstance : activityInstances) {
    System.out.println("活动实例ID:" + activityInstance.getId());
    System.out.println("活动ID:" + activityInstance.getActivityId());
    System.out.println("活动名称:" + activityInstance.getActivityName());
    System.out.println("活动类型:" + activityInstance.getActivityType());
    System.out.println("活动负责人:" + activityInstance.getAssignee());
    System.out.println("活动开始时间:" + activityInstance.getStartTime());
    System.out.println("活动结束时间:" + activityInstance.getEndTime());
    System.out.println("========================================");
}

需要注意的是,活动实例 ID(Activity instance ID)和活动 ID(Activity ID)是不同的概念。前者指的是 ProcessEngine 启动一个进程实例后,在系统中创建的所属活动实例的 ID,对应 act_ru_execution 表的 ID 字段。后者是用 BPMN2 定义活动(或事件)时,为活动(或事件)指定的一个 ID(通常由 BPMN 设计工具自动生成)。

关联业务

将 Activiti 与业务进行整合时,创建流程实例时往往需要为流程实例附加一些业务信息,比如对于一个 OA 系统,提交一个出差申请,必然要将出差申请单的内容附加到出差申请进程实例上,作为后续任务委托人进行审批的依据。

对此,我们只需要将相关信息保存在相关业务表中,将用于关联的业务标识(Business ID)关联到 Activiti 的流程实例上。

Activiti 用于存储业务标识的是act_ru_execution表的BUSINESS_KEY_字段:

  `BUSINESS_KEY_` varchar(255) COLLATE utf8_bin DEFAULT NULL,

利用 Activiti 的 API,可以在启动进程实例时附加 Business ID:

final String PROCESS_DEF_KEY = "travel_apply";
// 启动进程实例
RuntimeService runtimeService = processEngine.getRuntimeService();
String businessId = "1001";
ProcessInstance instance = runtimeService.startProcessInstanceByKey(PROCESS_DEF_KEY, businessId);
System.out.println(instance.getBusinessKey());
TaskService taskService = processEngine.getTaskService();
// 获取进程实例的当前需要处理的任务
Task task = taskService.createTaskQuery()
    .processInstanceId(instance.getId())
    .orderByTaskCreateTime().desc() // 按照任务创建事件降序
    .singleResult();
// 完成任务
if (task != null) {
    taskService.complete(task.getId());
}

该信息不仅会记录在代表进程实例的act_ru_execution表中:

image-20250514153655912

代表任务实例的act_ru_task表中也有:

image-20250514153628825

因此可以通过获取应用实例对象 ProcessInstance 或者任务对象 Task 获取到该信息。

进程实例的挂起与恢复

进程实例可以挂起(suspend),此时进程实例将无法继续执行。

挂起单个进程实例

挂起一个进程实例:

// 获取一个没有被挂起的出差申请实例
RuntimeService runtimeService = processEngine.getRuntimeService();
List<ProcessInstance> processInstances = runtimeService.createProcessInstanceQuery()
    .active()
    .processDefinitionKey(TRAVEL_APPLY_DEFINITION_KEY)
    .list();
if (processInstances == null || processInstances.isEmpty()) {
    System.out.println("没有找到未挂起的进程实例");
    return;
}
ProcessInstance instance = processInstances.get(0);
System.out.println("进程实例ID:" + instance.getId());
// 挂起进程实例
runtimeService.suspendProcessInstanceById(instance.getId());
// 检查实例状态
instance = runtimeService.createProcessInstanceQuery()
    .processInstanceId(instance.getId())
    .singleResult();
if (instance.isSuspended()) {
    System.out.println("进程实例已挂起");
} else {
    System.out.println("进程实例未挂起");
}

查看代表进程实例的表act_ru_execution可以发现SUSPENSION_STATE_字段的值是2,表示进程实例已经被挂起:

image-20250514155609400

表示任务实例的act_ru_task表中同样有SUSPENSION_STATE_字段,表示进程实例下的任务同样被挂起:

image-20250514155812836

如果尝试执行一个已经被挂起的进程实例的任务:

// 获取一个已经被挂起的进程实例
RuntimeService runtimeService = processEngine.getRuntimeService();
List<ProcessInstance> processInstances = runtimeService.createProcessInstanceQuery()
.suspended()
.processDefinitionKey(TRAVEL_APPLY_DEFINITION_KEY)
.list();
if (processInstances == null || processInstances.isEmpty()) {
System.out.println("没有已挂起的进程实例");
return;
}
ProcessInstance instance = processInstances.get(0);
// 执行进程实例的当前任务
completeTask(instance.getId(), processEngine.getTaskService());

会产生异常:

org.activiti.engine.ActivitiException: Cannot complete a suspended task

异常信息很明确,不能完成已经挂起的任务。

激活单个进程实例

恢复(激活)进程实例:

RuntimeService runtimeService = processEngine.getRuntimeService();
runtimeService.activateProcessInstanceById(instance.getId());

挂起进程定义

除了挂起单个进程实例,还可以挂起进程定义(Process Definition):

// 挂起进程定义
processEngine.getRepositoryService().suspendProcessDefinitionByKey(TRAVEL_APPLY_DEFINITION_KEY);
// 查询已挂起的进程定义下的进程实例状态
RuntimeService runtimeService = processEngine.getRuntimeService();
List<ProcessInstance> list = runtimeService.createProcessInstanceQuery()
    .processDefinitionKey(TRAVEL_APPLY_DEFINITION_KEY)
    .list();
for (ProcessInstance instance : list) {
    if (!instance.isSuspended()) {
        System.out.println("进程实例" + instance.getId() + "没有被挂起!");
        return;
    }
}
System.out.printf("进程定义(%s)下的所有进程实例都已被挂起%n", TRAVEL_APPLY_DEFINITION_KEY);

此时再尝试启动该进程的实例,会产生异常:

org.activiti.engine.ActivitiException: Cannot start process instance. Process definition 出差申请 (id = travel_apply:4:25004) is suspended

需要注意的是,上面这种 API 调用挂起进程定义,只能确保不能产生新的进程实例,但已经运行的相关进程实例不受影响(观察控制台输出能观察到),如果要将已经运行的进程实例同时挂起,可以:

RepositoryService repositoryService = processEngine.getRepositoryService();
repositoryService.suspendProcessDefinitionByKey(
    TRAVEL_APPLY_DEFINITION_KEY,
    true, // 同时挂起进程实例
    null); // 立即挂起

suspendProcessDefinitionByKey的最后一个参数可以指定一个时间,将由定时任务在指定时间挂起进程定义,null表示立即执行。

激活进程定义

恢复被挂起的进程定义:

RepositoryService repositoryService = processEngine.getRepositoryService();
repositoryService.activateProcessDefinitionByKey(TRAVEL_APPLY_DEFINITION_KEY);
// 查询进程定义挂起状态
List<ProcessDefinition> list = repositoryService.createProcessDefinitionQuery()
    .processDefinitionKey(TRAVEL_APPLY_DEFINITION_KEY)
    .list();
for (ProcessDefinition processDefinition : list) {
    if (processDefinition.isSuspended()){
        System.out.printf("进程定义(%s)仍然处于挂起状态!%n", processDefinition.getId());
        return;
    }
}
System.out.printf("进程定义(%s)都已恢复%n", TRAVEL_APPLY_DEFINITION_KEY);

同样的,如果要恢复进程定义的同时恢复相关进程实例:

RepositoryService repositoryService = processEngine.getRepositoryService();
repositoryService.activateProcessDefinitionByKey(
    TRAVEL_APPLY_DEFINITION_KEY,
    true, // 同时恢复进程实例
    null // 立即执行
);

UEL

当前示例中,流程定义的各个用户任务的负责人都是在 BPMN 文件中以硬编码的方式指定的:

image-20250514185100865

在实际项目中,显然不能用这种方式确定每个任务环节的负责人。

Activiti 工作流引擎使用统一表达式语言(UEL, Unified Expression Language)来实现流程中的动态行为。UEL表达式在 Activiti 中广泛用于各种场景,如服务任务、网关条件、监听器等。

Activiti支持两种UEL表达式:

值表达式(Value Expression)

用于获取或设置值,格式为 ${expression}:

<serviceTask id="javaService" 
             activiti:expression="${myBean.doBusinessLogic(execution)}" />

方法表达式(Method Expression)

用于调用方法,格式为 #{expression}

<serviceTask id="javaService" 
             activiti:delegateExpression="#{myJavaDelegate}" />

用 UEL 设置委托人

下面演示如何用 UEL 动态地指定任务的委托人。

修改 BMPN 文件使用 UEL 变量的方式设置任务的委托人:

<userTask id="..." name="创建出差申请" activiti:assignee="${self}"/>
<userTask id="..." name="经理审批" activiti:assignee="${manager}"/>
<userTask id="..." name="高级经理审批" activiti:assignee="${highManager}"/>
<userTask id="..." name="财务审批" activiti:assignee="${finance}"/>

启动流程实例:

Map<String, Object> variables = new HashMap<>();
variables.put("self", "Jack");
variables.put("manager", "Tom");
variables.put("highManager", "Brus");
variables.put("finance", "Jerry");
runtimeService.startProcessInstanceByKey(
    TRAVEL_APPLY_DEFINITION_KEY,
    businessKey,
    variables
);

这里在启动流程实例时,以参数的方式传入变量信息(variables),因此在 BPMN 文件中可以使用 UEL 表达式使用这些变量作为任务的委托人。

绑定到流程实例的变量信息保存在act_ru_variable表:

image-20250515101211803

同样的,对于已结束的进程实例,可以在历史表act_hi_varinst中找到绑定的变量信息:

image-20250515101352287

监听器

虽然可以用 UEL 动态指定委托人,但这样做有一些局限性,比如在流程实例启动时就指定所有审批环节的审批人,会导致即使某个人转岗,已经不再担任原来的职务,也必须要完成审批,这样显然是不合理的。

Activiti 提供任务监听器,可以利用它监听任务执行的某个阶段,以完成一些特殊工作。

在 BPMN 中添加监听器:

<userTask id="sid-4d3d4ca0-0345-44d3-995b-41461603b491" name="经理审批" activiti:assignee="manager">
    <extensionElements>
        <activiti:taskListener event="create" class="cn.icexmoon.demo.listener.ReAssigneeListener"/>
    </extensionElements>
</userTask>
<sequenceFlow id="sid-fbb07feb-8d88-44a2-a2a4-0c4451f086de" sourceRef="sid-51ffcbf1-b76f-4ef6-aa7a-2eb499426d4e" targetRef="sid-4d3d4ca0-0345-44d3-995b-41461603b491"/>
<userTask id="sid-113caa7f-ce80-46a3-8ac3-7d3e90e09b3b" name="高级经理审批" activiti:assignee="highManager">
    <extensionElements>
        <activiti:taskListener event="create" class="cn.icexmoon.demo.listener.ReAssigneeListener"/>
    </extensionElements>
</userTask>
<userTask id="sid-114f0d95-1444-4c5a-b796-db94953f865f" name="财务审批" activiti:assignee="finance">
    <extensionElements>
        <activiti:taskListener event="create" class="cn.icexmoon.demo.listener.ReAssigneeListener"/>
    </extensionElements>
</userTask>

这里只需要为按照职级确定审批人的三个任务添加监听器,第一个创建节点不需要添加监听器,依然使用 UEL 读取变量来指定委托人。

监听器(activiti:taskListener)可以监听三种事件(event):

  • create,任务创建后触发

  • assignment,任务分配给委托人时触发

  • complete,任务完成时触发

  • delete,任务删除时触发

定义监听器类:

// ...
public class ReAssigneeListener implements TaskListener {
    private ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine();

    @Override
    public void notify(DelegateTask delegateTask) {
        if ("create".equals(delegateTask.getEventName())) {
            // 只在任务实例创建后,指定委托人之前生效
            // 根据用户任务名称的不同进行不同处理
            String assignee = null;
            String userId = getApplyUserId(delegateTask.getExecutionId());
            switch (delegateTask.getName()) {
                case "经理审批":
                    assignee = getAssigneeUserId(userId, "manager");
                    break;
                case "高级经理审批":
                    assignee = getAssigneeUserId(userId, "highManager");
                    break;
                case "财务审批":
                    assignee = getAssigneeUserId(userId, "finance");
                    break;
                default:
                    // 不做任何处理
            }
            if (assignee != null) {
                TaskService taskService = processEngine.getTaskService();
                taskService.claim(delegateTask.getId(), assignee);
            }
        }
    }

    private String getAssigneeUserId(String applyUserId, String assigneeRole) {
        // TODO 根据申请人用户 id 和当前审批环节职级获取审批人 id
        // 这里使用假数据
        switch (assigneeRole) {
            case "manager":
                return "Jack";
            case "highManager":
                return "Lili";
            case "finance":
                return "James";
        }
        return null;
    }

    private String getApplyUserId(String executionId) {
        // 从运行时获取绑定的 self 变量作为流程发起人返回
        RuntimeService runtimeService = processEngine.getRuntimeService();
        String self = (String) runtimeService.getVariable(executionId, "self");
        if (self == null) {
            throw new RuntimeException(String.format("执行id(%s)缺少变量self", executionId));
        }
        return self;
    }
}
  • 监听器类必须实现TaskListener接口。

  • notify方法中修改委托人要使用taskService.claim,而不是delegateTask.setAssignee,原因是后者有 BUG,调用后者仅会修改 act_ru_task 中的委托人信息,历史记录act_hi_taskinst 中的委托人信息并不会修改。

条件分支

根据不同的申请单内容决定不同的审批流也是 OA 系统中最常见的一种情况,在 Activiti 中,可以使用流程实例变量作为判断依据,结合 UEL 表达式设置流程走向。

新建一个待条件分支的请假申请 BPMN 文件,其中条件流转如下:

<sequenceFlow id="sid-baf48ca0-0997-41d1-872c-2ccfad0b2541" sourceRef="sid-28bb9c01-7626-40e3-ac71-4f37f5f071a0" targetRef="sid-2c0506f2-42ea-4bf3-ae19-8b6d8f8e130b" name="出差时间小于等于3天">
    <conditionExpression>${form.days&lt;=3}</conditionExpression>
</sequenceFlow>
<sequenceFlow id="sid-a427cf8a-ddcf-4ca7-9ce0-364105daf978" sourceRef="sid-28bb9c01-7626-40e3-ac71-4f37f5f071a0" targetRef="sid-f8ab8b84-6148-4b69-bc11-020f488edffd" name="出差时间大于3天">
    <conditionExpression>${form.days&gt;3}</conditionExpression>
</sequenceFlow>
  • 要注意的是,在 XML 中使用>和<等条件判断符需要使用转义符,因为属于 XML 的保留符号。幸运的是,这部分工作 Idea 的 BPMN 设计插件可以帮我们完成。

  • 完整的 BPMN 示例文件见这里。

因为审批表单通常具有一些固定信息,比如申请人的 id 等,以及一些特有信息,比如出差申请的表单应当有出差时长,差旅费等。因此将这些结构化信息使用自定义类对象作为一个整体保存在流程变量中是一个更好的实践。

自定义表单类:

@Data
public abstract class BaseForm implements Serializable {
    private String creator; //表单发起人

    public BaseForm(String creator) {
        this.creator = creator;
    }
}
@Data
public class TravelForm extends BaseForm {
    // 出差时长(单位:天)
    private int days;

    public TravelForm(String creator, int days) {
        super(creator);
        this.days = days;
    }
}

注意,这里的基类实现Serializable是有意为之,因为自定义类型的进程变量将以二进制的方式保存在 Activiti 的act_ru_variable表中,所以必须要实现 Java 的序列化接口,否则会报错。

在启动流程实例时附加表单信息作为流程变量:

RuntimeService runtimeService = processEngine.getRuntimeService();
Map<String, Object> data = new HashMap<>();
data.put("form", new TravelForm("ZhangSan", 5));
runtimeService.startProcessInstanceByKey(
    PROCESS_DEFINITION_KEY,
    data);

监听器也需要改为从表单信息获取流程发起人,具体见这里。

测试就能看到,超过3天的出差会经过高级经理审批,小于3天的不会经过高级经理审批。

进程变量

修改进程变量

除了在启动进程实例时指定进程变量,还可以在任务完成时设置,比如有一个特殊需求——在经理审批时可以修改出差申请的天数:

Map<String, Object> variables = new HashMap<>(); // 进程变量
if ("经理审批".equals(lastTask.getName())) {
    // 经理审批时强行修改出差天数为3天以下
    TravelForm oldForm = processEngine.getRuntimeService().getVariable(lastTask.getExecutionId(), "form", TravelForm.class);
    if (oldForm.getDays() > 3) {
        BaseForm newForm = new TravelForm(oldForm.getCreator(), 2);
        variables.put("form", newForm);
    }
}
// 完成任务
TaskService taskService = processEngine.getTaskService();
if (!variables.isEmpty()){
    taskService.complete(lastTask.getId(), variables);
}
else{
    taskService.complete(lastTask.getId());
}

在上面的示例中,在完成任务(taskService.complete)时设置了新的进程变量。

如果你维护过 OA 系统,就知道人事的要求会有多么奇葩。

除了在完成任务时修改进程变量,还可以直接调用 RuntimeService 修改:

if ("经理审批".equals(lastTask.getName())) {
    // 经理审批时强行修改出差天数为3天以下
    RuntimeService runtimeService = processEngine.getRuntimeService();
    TravelForm oldForm = runtimeService.getVariable(lastTask.getExecutionId(), "form", TravelForm.class);
    if (oldForm.getDays() > 3) {
        BaseForm newForm = new TravelForm(oldForm.getCreator(), 2);
        // 使用 RuntimeService 直接修改进程变量
        runtimeService.setVariable(lastTask.getExecutionId(), "form", newForm);
    }
}
// 完成任务
TaskService taskService = processEngine.getTaskService();
taskService.complete(lastTask.getId());

在这个示例中更为简洁。

局部变量

前面说的进程变量的作用域都是整个进程实例,因此也可以称作进程实例的全局(Global)变量。相应的,还有局部(Local)变量,局部变量仅在关联的任务范围内有效。

比如,可以将每个审批环节的审批意见作为局部变量添加:

TaskService taskService = processEngine.getTaskService();
String comment = lastTask.getAssignee() + "的审批意见"; //审批意见
taskService.setVariableLocal(lastTask.getId(), "comment", comment);
// 完成任务
taskService.complete(lastTask.getId());

在act_hi_varinst表中就能看到任务绑定的局部变量:

image-20250515155726115

没有绑定(TASK_ID为null)的就是全局变量。

打印审批意见:

// 获取历史任务
List<HistoricTaskInstance> taskInstances = historyService.createHistoricTaskInstanceQuery()
    .processInstanceId(processInstanceId)
    .orderByHistoricTaskInstanceEndTime().asc()
    .list();
for (HistoricTaskInstance taskInstance : taskInstances) {
    String assignee = taskInstance.getAssignee();
    Date endTime = taskInstance.getEndTime();
    // 获取任务的审批意见
    HistoricVariableInstance variableInstance = historyService.createHistoricVariableInstanceQuery()
        .taskId(taskInstance.getId())
        .variableName("comment")
        .singleResult();
    String comment = (String) variableInstance.getValue();
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    String timeStr = sdf.format(endTime);
    log.info(String.format("审批人:%s,审批时间:%s,审批意见:%s%n", assignee, timeStr, comment));
}

候选人

当前示例是一个理想状况,根据申请人只分配一个经理进行审批,在实际情况下,一个部门可能有多个经理这种情况,他们都应当具有审批权限,这时候就需要使用候选人(Candidate Users)。

创建一个简单的出差申请流程定义:

<userTask id="sid-e94b4489-5c01-4189-9ad0-f0e27b64968f" name="创建出差申请" activiti:assignee="${form.creator}"/>
<userTask id="sid-12e24d72-8729-4d5d-a3b3-ed5972651b45" name="经理审批" activiti:candidateUsers="Brus,Jack"/>

这里的candidateUsers指定有两个候选人,任意一个都有权审批(完成用户任务)。

可以通过以下方式获取一个任务的候选人:

/**
     * 返回指定任务实例的候选人列表
     *
     * @param taskId 任务实例id
     * @return 候选人列表
     */
public List<String> listCandidates(String taskId) {
    TaskService taskService = processEngine.getTaskService();
    List<IdentityLink> identityLinksForTask = taskService.getIdentityLinksForTask(taskId);
    List<String> candidates = new ArrayList<>();
    for (IdentityLink identityLink : identityLinksForTask) {
        if ("candidate".equals(identityLink.getType())) {
            if (identityLink.getUserId() != null) {
                candidates.add(identityLink.getUserId());
            }
        }
    }
    return candidates;
}

实际上任务的候选人信息保存在act_ru_identitylink表:

image-20250515194101716

type字段是candidate说明是候选人,具体的候选人 ID 保存在user_id字段。

通过以下方式可以获取指定用户作为候选人或者委托人的任务:

/**
     * 列出指定用户可以完成的任务(包括个人任务和作为候选人的任务)
     *
     * @param processDefinitionKey 流程定义key
     * @return 任务列表
     */
public List<Task> listCompletableTask(String userId, String processDefinitionKey) {
    // 获取个人任务
    TaskService taskService = processEngine.getTaskService();
    return taskService.createTaskQuery()
        .taskCandidateOrAssigned(userId)
        .processDefinitionKey(processDefinitionKey)
        .list();
}

如果在完成任务时检查用户是否有权限(是任务的委托人或者候选人),就需要:

/**
     * 完成任务(会检查指定用户是否有权限完成该任务)
     *
     * @param userId 指定用户id
     * @param taskId 任务id
     */
public void completeTaskWithCheck(String userId, String taskId) {
    if (userId == null) {
        throw new RuntimeException("必须指定一个用户id");
    }
    // 检查指定用户是否是任务的委托人
    TaskService taskService = processEngine.getTaskService();
    Task task = taskService.createTaskQuery()
        .taskId(taskId)
        .singleResult();
    if (task == null) {
        throw new RuntimeException(String.format("任务(%s)不存在!", taskId));
    }
    if (!userId.equals(task.getAssignee())) {
        // 指定用户不是任务的委托人
        // 检查指定用户是否是任务的候选人
        List<String> candidates = listCandidates(taskId);
        if (candidates == null || candidates.isEmpty() || !candidates.contains(userId)) {
            // 指定用户不是任务的候选人
            throw new RuntimeException(String.format("用户(%s)不是任务(%s)的委托人或候选人,不能完成任务", userId, taskId));
        }
    }
    // 如果指定用户不是任务的委托人,先获取任务
    if (!userId.equals(task.getAssignee())) {
        taskService.claim(taskId, userId);
    }
    // 完成任务
    taskService.complete(taskId);
}

如果执行人不是任务的委托人,在完成任务(TaskService.complete)之前要先获取任务(TaskService.claim),该操作会将运行时与历史记录中任务的委托人修改为执行人。

当然,在 BMPN 文件中直接指定候选人 ID 不具有实用性,与前边类似,同样可以用监听器来“自动匹配”候选人:

public class ReCandidateListener implements TaskListener {

    @Override
    public void notify(DelegateTask delegateTask) {
        Set<IdentityLink> candidates = delegateTask.getCandidates();
        // 任务已经有委托人,不进行处理
        if (delegateTask.getAssignee() != null) {
            return;
        }
        // 如果指定委托人,不进行处理
        if (candidates != null && !candidates.isEmpty()) {
            return;
        }
        // 根据任务名称匹配候选人
        Set<String> targetCandidates = getCandidates(getProcessCreator(delegateTask.getProcessInstanceId()), delegateTask.getName());
        if (targetCandidates == null || targetCandidates.isEmpty()) {
            // 没有匹配到候选人,说明流程描述文件有误
            // TODO 记录错误
            return;
        }
        for (String targetCandidate : targetCandidates) {
            // 添加候选人
            delegateTask.addCandidateUser(targetCandidate);
        }
    }

    private String getProcessCreator(String processInstanceId) {
        // TODO 实现根据进程实例id获取申请人id
        return "ZhangSan";
    }

    private Set<String> getCandidates(String processCreator, String taskName) {
        // TODO 实现根据任务名称和流程申请人确定审批候选人
        return Set.of("Jack", "Brus");
    }
}

修改 BPMN 文件:

<userTask id="sid-12e24d72-8729-4d5d-a3b3-ed5972651b45" name="经理审批" activiti:candidateUsers="">
    <extensionElements>
        <activiti:taskListener event="create" class="cn.icexmoon.demo.listener.ReCandidateListener"/>
    </extensionElements>
</userTask>

网关

使用网关(Gateway)可以更精细地控制流程流向。

排它网关

流程经过排它网关(Exclusive Gateway)后,只能选择一个符合条件的流向。

创建一个使用排它网关的流程:

image-20250516102125347

完整的 BPMN2 文件见这里。

整个流程和之前用于示例的出差申请流程是类似的,只不过使用了排它网关连接带判断条件的顺序流(Sequence Flow)。

对该流程测试会发现,其效果与之前没有使用排它网关时一致。

测试用例见这里。

他们的区别在于如果找不到符合条件的顺序流时的情况。假设我们的 BPMN 文件编写出错:

<conditionExpression>${form.days&gt;3}</conditionExpression>
<!-- ... -->
<conditionExpression>${form.days&lt;3}</conditionExpression>

这里两个条件分支分别是form.days>3和form.days<3,如果form.days==3就不符合任意的条件分支。

部署错误的 BMPN 并测试:

Map<String, Object> variables = new HashMap<>();
variables.put("form", new TravelForm("ZhangSan", 3));
String processInstanceId = activitiUtils.startAndNext(PROCESS_DEFINITION_KEY, variables);
log.info("进程实例id:"+processInstanceId);
// 经理审批
activitiUtils.nextActivity(processInstanceId);
// 触发排它网关,此处应该报错

控制台输出:

org.activiti.engine.ActivitiException: No outgoing sequence flow of the exclusive gateway 'sid-1ec6602e-30f5-4c22-807f-51130c44a205' could be selected for continuing the process

错误信息标明 id 为sid-1ec6602e-30f5-4c22-807f-51130c44a205的排它网关后,没有符合条件的顺序流,因此抛出了一个ActivitiException类型的异常。

查看数据库的act_ru_task表可以发现,该流程实例依然停留在排它网关之前的用户任务(经理审批)。换言之,使用排它网关可以帮助排除顺序流条件设置不正确的问题,因此应当尽量使用排它网关,而不是直接使用带条件的顺序流。

  • 如果不使用排它网关,遇到此类问题进程实例将直接结束。

  • 如果经过排它网关后有多个顺序流的条件都满足,流程将经过 ID 最小的顺序流。

并行网关

并行网关(Parallel Gateway),顾名思义,通过并行网关的顺序流将并行执行(都执行)。

下面是一个使用并行网关的流程示例:

image-20250516125108312

执行测试用例就能发现,流程在完成创建项目立项申请后,生成了两个任务实例,分别对应项目经理审批和技术经理审批环节,只有当两个审批环节都完成,才会进入总经理审批环节。

顺带一提,如果并行网关后的顺序流附加了判断条件,这些条件将不起作用。

包含网关

包含网关(Inclusive Gateway)可以看做是带条件的并行网关。

使用包含网关的示例流程:

image-20250516152403518

这里使用了包含网关,预算小于1万的项目,申请后只会经过技术经理审批(没有条件)就会到总经理审批。而如果预算超过1万,但没有到5万,就需要项目经理和技术经理审批才能到总经理审批。如果项目预算超过5万,就需要项目经理、技术经理以及财务共同审批完成后才能到总经理审批。

我为这三种情况编写了测试用例,见这里。

本文的完整示例代码见这里。

The End.

参考资料

  • 黑马程序员java教程工作流引擎Activiti7基础到进阶

本作品采用 知识共享署名 4.0 国际许可协议 进行许可
标签: activiti bpmn 网关
最后更新:2025年5月16日

魔芋红茶

加一点PHP,加一点Go,加一点Python......

点赞
< 上一篇

文章评论

取消回复

*

code

COPYRIGHT © 2021 icexmoon.cn. ALL RIGHTS RESERVED.
本网站由提供CDN加速/云存储服务

Theme Kratos Made By Seaton Jiang

宁ICP备2021001508号

宁公网安备64040202000141号