Flowable 表单

3,110 阅读9分钟

[TOC]

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

本专栏第五篇已发布,尚未看过的小伙伴请移步这里:

  1. Flowable 开篇,流程引擎扫盲
  2. 通过 Flowable-UI 来体验一把 Flowable 流程引擎
  3. 搞懂 Flowable 中的流程定义和流程实例
  4. 梳理流程引擎 Flowable 四大常见任务
  5. Flowable 中的网关、流程变量以及历史流程

今天这篇文章,松哥和大家梳理一下 Flowable 中的网关、流程变量以及历史流程的玩法。

1. 动态表单

有小伙伴在星球上催了好几次了,今天松哥就来和大家聊一聊流程中的表单。

1.1. 表单分类

整体上来说,我们可以将表单分为三种不同的类型:

  1. 动态表单:这种表单定义方式我们可以配置表单中每一个字段的可读性、可写性、是否必填等信息,不过不能定义完整的表单页面。
  2. 外置表单:外置表单我们只需要定义一下表单的 key,至于这个 key 对应的表单是什么样子,则由开发者自己去维护。
  3. 内置表单:这是内置的表单定义以及渲染引擎,松哥在之前的一个不用写代码的案例,来看看Flowable到底给我们提供了哪些功能?一文中所使用的表单,就是这种。

另外小伙伴们需要注意,Flowable 中有很多不同类型的节点,但是只有开始节点和任务节点是支持表单定义的,其他节点均不支持表单定义。

1.2. 动态表单

今天我们就先来看看动态表单的玩法。

假设我有如下一个请假流程:

在第一个任务节点中,需要填写请假的基本信息,那么我们选中该节点,然后点击动态表单属性,如下图:

然后就可以开启动态表单属性的配置了:

我这里一共配置了四个属性,这些属性的含义应该都好理解,我就不一一赘述了。

接下来我们来下载这个流程图。

流程的 XML 文件下载下来之后,我们可以在看到在 UserTask 节点中多了 flowable:formProperty 标签,现在,如果我想将 UserTask 节点中的动态表单属性拷贝到启动节点中,直接拷贝即可,如下:

<process id="FormDemo01" name="FormDemo01" isExecutable="true">
  <documentation>FormDemo01</documentation>
  <startEvent id="startEvent1" flowable:formFieldValidation="true">
    <extensionElements>
      <flowable:formProperty id="startTime" name="请假开始时间" type="date" datePattern="yyyy-MM-dd HH:mm" required="true"></flowable:formProperty>
      <flowable:formProperty id="endTime" name="请假结束时间" type="date" datePattern="yyyy-MM-dd HH:mm" required="true"></flowable:formProperty>
      <flowable:formProperty id="reason" name="请假理由" type="string" required="true"></flowable:formProperty>
      <flowable:formProperty id="days" name="请假天数" type="long" required="true"></flowable:formProperty>
    </extensionElements>
    </startEvent>
  <userTask id="sid-F4DE03F1-D09F-4527-9267-0E5C276D08B8" name="提交请假申请" flowable:formFieldValidation="true">
    <extensionElements>
      <flowable:formProperty id="startTime" name="请假开始时间" type="date" datePattern="yyyy-MM-dd HH:mm" required="true"></flowable:formProperty>
      <flowable:formProperty id="endTime" name="请假结束时间" type="date" datePattern="yyyy-MM-dd HH:mm" required="true"></flowable:formProperty>
      <flowable:formProperty id="reason" name="请假理由" type="string" required="true"></flowable:formProperty>
      <flowable:formProperty id="days" name="请假天数" type="long" required="true"></flowable:formProperty>
    </extensionElements>
  </userTask>
  <sequenceFlow id="sid-2A8D19F2-927C-4FCE-AF31-534425B1CA18" sourceRef="startEvent1" targetRef="sid-F4DE03F1-D09F-4527-9267-0E5C276D08B8"></sequenceFlow>
  <userTask id="sid-9136F312-F00B-467E-A61B-F2932BA9068A" name="请假审批" flowable:formFieldValidation="true"></userTask>
  <sequenceFlow id="sid-877A95AB-B8A4-47FE-BC9F-0998FEAEC52C" sourceRef="sid-F4DE03F1-D09F-4527-9267-0E5C276D08B8" targetRef="sid-9136F312-F00B-467E-A61B-F2932BA9068A"></sequenceFlow>
  <endEvent id="sid-E26593D4-C67B-4784-98EE-772B9659F805"></endEvent>
  <sequenceFlow id="sid-1A5C4E8C-6705-4148-A0E3-E7769631BFD9" sourceRef="sid-9136F312-F00B-467E-A61B-F2932BA9068A" targetRef="sid-E26593D4-C67B-4784-98EE-772B9659F805"></sequenceFlow>
</process>

可以看到,在 startEvent 和第一个 userTask 中都有 flowable:formProperty 标签。

接下来,按照我们之前所讲的,我们来部署一下这个流程。部署完成之后,我们可以通过如下方式来查询流程中的动态表单信息:

@Autowired
FormService formService;

@Test
void test01() {
    ProcessDefinition pd = repositoryService.createProcessDefinitionQuery().processDefinitionKey("FormDemo01").latestVersion().singleResult();
    StartFormData startFormData = formService.getStartFormData(pd.getId());
    System.out.println("startFormData.getDeploymentId() = " + startFormData.getDeploymentId());
    System.out.println("startFormData.getFormKey() = " + startFormData.getFormKey());
    List<FormProperty> formProperties = startFormData.getFormProperties();
    for (FormProperty fp : formProperties) {
        String value = fp.getValue();
        String id = fp.getId();
        boolean readable = fp.isReadable();
        boolean writable = fp.isWritable();
        boolean required = fp.isRequired();
        String name = fp.getName();
        FormType type = fp.getType();
        String key = "";
        if (type instanceof EnumFormType) {
            key = "values";
        } else if (type instanceof DateFormType) {
            key = "datePattern";
        }
        Object information = type.getInformation(key);
        logger.info("value:{},id:{},readable:{},writeable:{},required:{},name:{},info:{}", value, id, readable, writable, required, name, information);
    }
}

小伙伴们可以看到,这个查询是通过流程定义查询的,所以这里查询到的信息,其实也是和流程实例无关的。只是单纯的查看一下启动节点上有哪些动态表单需要输入,以及这些动态表单的类型。最终输出日志如下:

1.3. 启动带表单的实例

动态表单,其实跟普通的变量有点像,启动的时候我们可以通过表单服务类来启动,代码如下:

@Test
void test02() {
    ProcessDefinition pd = repositoryService.createProcessDefinitionQuery().processDefinitionKey("FormDemo01").latestVersion().singleResult();
    Map<String, String> vars = new HashMap<>();
    vars.put("startTime", "2022-10-10 10:10");
    vars.put("endTime", "2022-10-12 10:10");
    vars.put("reason", "玩两天");
    vars.put("days", "3");
    ProcessInstance pi = formService.submitStartFormData(pd.getId(), vars);
}

小伙伴们看到,我们这里通过 formService.submitStartFormData 方法来启动流程实例,启动的时候,传入了 vars 变量。

流程实例启动成功之后,我们在 ACT_RU_VARIABLE 表中就可以看到这些动态表单的信息。

从这里可以看到我们刚刚存入的数据。

1.4. 查询任务上的表单

现在我们的流程走到了 提交请假申请 这一步了,我们在绘制流程图的时候,提交请假申请 这个 UserTask 中也是有动态表单的,前面启动流程时传递的动态表单信息,现在已经传到 提交请假申请 这一步了,我们可以通过如下方式来进行查询:

@Test
void test03() {
    Task task = taskService.createTaskQuery().singleResult();
    TaskFormData taskFormData = formService.getTaskFormData(task.getId());
    List<FormProperty> formProperties = taskFormData.getFormProperties();
    for (FormProperty fp : formProperties) {
        String value = fp.getValue();
        String id = fp.getId();
        boolean readable = fp.isReadable();
        boolean writable = fp.isWritable();
        boolean required = fp.isRequired();
        String name = fp.getName();
        FormType type = fp.getType();
        String key = "";
        if (type instanceof EnumFormType) {
            key = "values";
        } else if (type instanceof DateFormType) {
            key = "datePattern";
        }
        Object information = type.getInformation(key);
        logger.info("value:{},id:{},readable:{},writeable:{},required:{},name:{},info:{}", value, id, readable, writable, required, name, information);
    }
}

小伙伴们看到,调用 formService.getTaskFormData 方法传入 TaskId 即可进行查询。这个时候查询出来的内容就有值了:

可能有的小伙伴会说,这跟用变量有啥区别呀,用变量不也是这样吗?

变量是散的,而表单是整的。

在上面的代码中,一个方法就可以提取出来所有的表单信息了,然后就遍历就行了。

另外还需要注意,如果 提交请假申请 中的动态表单和启动节点的动态表单不一致的话,提交请假申请 节点中有哪些动态表单,就能拿到哪些数据,其他的数据就不能通过表单拿到。

以上面的案例来说,startEvent 中有 startTime、endTime、reason 以及 days 四个动态表单属性,如果 提交请假申请 中只有 reason 和 days 两个动态表单属性的话,那么就只能获取这两个动态表单属性,其他的动态表单属性则可以通过变量去获取。

1.5. 保存与完成

对于 UserTask 上的表单,我们首先可以通过如下方式来提交表单数据:

@Test
void test04() {
    Task task = taskService.createTaskQuery().singleResult();
    Map<String, String> vars = new HashMap<>();
    vars.put("startTime", "2022-10-11 11:11");
    vars.put("endTime", "2022-10-19 11:11");
    formService.saveFormData(task.getId(), vars);
}

这个方法只是保存动态表单变量,并不会完成当前 Task。

如果想在提交表单变量的同时顺便完成当前 UserTask,方式如下:

@Test
void test04() {
    Task task = taskService.createTaskQuery().singleResult();
    Map<String, String> vars = new HashMap<>();
    vars.put("startTime", "2022-10-11 11:11");
    vars.put("endTime", "2022-10-19 11:11");
    formService.submitTaskFormData(task.getId(), vars);
}

该方法在提交表单变量的同时,还会顺便 complete 当前 UserTask。

好啦,这就是关于动态表单松哥和大家介绍的内容啦~

动态表单用法简单,很多小伙伴想不明白为什么要用表单,用变量不行吗?技术上来说,变量当然可以,但是变量是一个一个的,是零散的,而表单是整的,整存整取的。

just this。

2. HTML 表单

2.1. 外置表单

首先,所谓的外置表单,其实说白了,类似我们平时在 HTML 中写的 form 表单。

现在的 flowable 中,我们既可以利用 JSON 的形式来定义 form 表单,也可以直接就使用 HTML 来定义,都是 OK 的。本文为了直观,松哥这里采用 HTML 来定义表单。

现在假设我有如下一个请假流程:

在开始节点中,我们需要一个表单来输入用户提交的请假信息,在组长审批和经理审批这两个节点中我们希望能够看到用户提交的请假信息,那么我们准备两个表单文件,第一个是提交请假信息的表单文件 askleave.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form action="">
    <table>
        <tr>
            <td>请假天数:</td>
            <td><input type="text" name="days"></td>
        </tr>
        <tr>
            <td>请假理由:</td>
            <td><input type="text" name="reason"></td>
        </tr>
        <tr>
            <td>起始时间:</td>
            <td><input type="date" name="startTime"></td>
        </tr>
        <tr>
            <td>结束时间:</td>
            <td><input type="date" name="endTime"></td>
        </tr>
        <tr>
            <td><input type="submit" value="提交"></td>
        </tr>
    </table>
</form>
</body>
</html>

小伙伴们看到,这其实就是一个普通的 HTML 页面,这里为了省事,我就没写 form 的 action 了。

还有一个是查看用户提交的请假信息的表单 leader_approval.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form action="">
    <table>
        <tr>
            <td>请假天数:</td>
            <td><input type="text" name="days" value="${days}"></td>
        </tr>
        <tr>
            <td>请假理由:</td>
            <td><input type="text" name="reason" value="${reason}"></td>
        </tr>
        <tr>
            <td>起始时间:</td>
            <td><input type="date" name="startTime" value="${startTime}"></td>
        </tr>
        <tr>
            <td>结束时间:</td>
            <td><input type="date" name="endTime" value="${endTime}"></td>
        </tr>
        <tr>
            <td><input type="submit" value="提交"></td>
        </tr>
    </table>
</form>
</body>
</html>

和前面的 askleave.html 文件相比,leader_approval.html 文件中,各个表单属性只是多了 value 属性而已,value 给了一个预填的变量,其他都是一样的。

两个表单文件定义完成之后,接下来我们为我们的流程来配置这两个表单文件,如下图,为开始节点设置表单 key 为 askforleave.html,为组长审批和经理审批节点设置表单 key 为 leader_approval.html

另:在 Spring Boot 项目中,外置表单默认放在 resources/forms 目录下,也就是说,凡是放在这个目录下的表单文件,会被自动部署(要求文件后缀为 .form)。

好啦,这样我们的流程图就准备完成了。

2.2. 流程部署

小伙伴们需要注意,外置表单的部署需要和流程图一起部署,只有一起部署,他们才会有相同的 DEPLOYMENT_ID,否则两者的 DEPLOYMENT_ID 不同,在后续的查找中就找不到对应的表单。

因此,我们来修改一下流程部署的接口:

@RestController
public class ProcessDeployController {

    @Autowired
    RepositoryService repositoryService;

    @PostMapping("/deploy")
    public RespBean deploy(MultipartFile[] files) throws IOException {
        System.out.println(new Date());
        DeploymentBuilder deploymentBuilder = repositoryService.createDeployment()
                .category("javaboy的工作流分类")
                .name("javaboy的工作流名称")
                .key("javaboy的工作流key666");
        for (int i = 0; i < files.length; i++) {
            MultipartFile file = files[i];
            deploymentBuilder.addInputStream(file.getOriginalFilename(), file.getInputStream());
        }
        Deployment deployment = deploymentBuilder
                .deploy();
        return RespBean.ok("部署成功", deployment.getId());
    }
}

小伙伴们看到,这里我将上传文件改为了数组,也就是流程图、form 表单等统统都以文件的形式上传,然后在部署的时候,统一都调用 addInputStream 方法进行添加。

我们来看下使用 POSTMAN 部署的方式:

部署成功之后,我们来看下 ACT_GE_BYTEARRAY 表中的记录,如下:

小伙伴们看到,四条记录具有相同的 DEPLOYMENT_ID,这一点尤为重要。

2.3. 流程开启与执行

在流程开启之前,我们首先可以通过如下方式查询启动节点上的表单内容:

@Test
void test05() {
    ProcessDefinition pd = repositoryService.createProcessDefinitionQuery().latestVersion().processDefinitionKey("FormDemo02").singleResult();
    String startFormKey = formService.getStartFormKey(pd.getId());
    String renderedStartForm = (String) formService.getRenderedStartForm(pd.getId());
    System.out.println("startFormKey = " + startFormKey);
    System.out.println("renderedStartForm = " + renderedStartForm);
}

控制台输出的内容如下:

可以看到,表单的内容就被输出来了。

如果我们这里是一个 Web 工程,那么可以通过 Ajax 来请求到这个表单数据,并动态渲染到前端,然后在前端输入对应的值,点击提交按钮,就可以在服务端开启一个流程了。

服务端开启流程方式如下:

@Test
void test02() {
    ProcessDefinition pd = repositoryService.createProcessDefinitionQuery().processDefinitionKey("FormDemo02").latestVersion().singleResult();
    Map<String, String> vars = new HashMap<>();
    vars.put("startTime", "2022-10-10 10:10");
    vars.put("endTime", "2022-10-12 10:10");
    vars.put("reason", "玩两天");
    vars.put("days", "3");
    ProcessInstance pi = formService.submitStartFormData(pd.getId(), vars);
}

调用 submitStartFormData 方法来开启一个流程,我这里参数直接硬编码了。

流程开启之后,接下来组长 zhangsan 要来审批这个流程,审批之前他需要先查看一下用户提交的表单信息,查看方式如下:

@Test
void test06() {
    Task task = taskService.createTaskQuery().taskAssignee("zhangsan").singleResult();
    String renderedTaskForm = (String) formService.getRenderedTaskForm(task.getId());
    System.out.println("renderedTaskForm = " + renderedTaskForm);
}

小伙伴们注意,这个 getRenderedTaskForm 方法只有外置表单才有,动态表单调用这个方法是没有东西的,因为动态表单单纯的就只是变量的传递,不涉及到渲染问题,我们来看下这里打印出来的结果:

小伙伴们看到,和前面的表单相比,这里的表单都渲染出来了对应的值。如果这是一个 Web 项目,那么我们就可以使用 Ajax 请求这个渲染后的表单,并展示在前端页面。当然实际审批中,这里可以有更多的字段,组长填完之后,进入到下一个环节。

zhangsan 进行流程审批的代码如下:

@Test
void test08() {
    Task task = taskService.createTaskQuery().taskAssignee("zhangsan").singleResult();
    Map<String, String> vars = new HashMap<>();
    vars.put("startTime", "2022-10-30 10:10");
    vars.put("endTime", "2022-12-30 10:10");
    vars.put("reason", "玩十天");
    vars.put("days", "10");
    formService.submitTaskFormData(task.getId(),vars);
}

可以使用 formService#submitTaskFormData 方法进行审批,也可以使用 taskService.complete 方法进行审批。

剩下的玩法就和普通流程一样了。

好啦,这就是和大家介绍的外置表单。

3. JSON 表单

3.1. 默认规则

使用 JSON 来定义 Flowable 表单,我们刚好可以利用 Spring Boot 中的默认机制,即将表单文件置于 classpath:forms 目录下,那么在系统启动的时候,表单文件就会被自动部署。并且默认情况下,表单文件的后缀是 .form

不过对于默认的表单文件位置和表单文件后缀,我们也可以通过在 application.properties 配置文件中添加如下内容进行修改:

# 默认的表单文件后缀
flowable.form.resource-suffixes=**.form
# 默认的表单文件位置
flowable.form.resource-location=classpath*:/forms/

3.2. 创建表单

还是以我们的请假请求为例,我来创建一个表单文件,文件名为 application_form.form,如下:

{
    "key": "application_form.form",
    "name": "经理审批表单",
    "fields": [
        {
            "id": "days",
            "name": "请假天数",
            "type": "string",
            "required": true,
            "placeholder": "empty"
        },
        {
            "id": "reason",
            "name": "请假原因",
            "type": "string",
            "required": true,
            "placeholder": "empty"
        },
        {
            "id": "startTime",
            "name": "开始时间",
            "type": "date",
            "required": true,
            "placeholder": "empty"
        },
        {
            "id": "endTime",
            "name": "结束时间",
            "type": "date",
            "required": true,
            "placeholder": "empty"
        }
    ]
}

这个 key 就是表单的唯一标识符,当有多个表单的时候,这个该值不可以重复,name 是表单是名称,fields 则定义了具体的字段,这里一共有四个。

在每一个 filed 的定义中,id 表示字段名,name 则是字段的中文名称,type 表示字段的类型,require 则表示这个字段是否是必填字段,placeholder 不用多说,跟我们日常使用的 input 标签中的 placeholder 的含义一致。

OK,这样,我们的表单现在就创建好了。

由于 .form 文件,在 IDEA 中,默认会被当成 Swing 里边的 form 去处理,所以需要小伙伴提前先用其他的编辑器写好 .form 文件,然后再拷贝到 IDEA 中即可。

3.3. 创建流程

接下来我们来创建一个流程图,流程中中引用这个表单。流程图如下:

在流程图的三个 UserTask 中,分别通过如下方式去配置表单的标识:

关于流程图的其他细节我这里就不多说了,前面和大家介绍了很多了。

最后我们下载这个流程图,将之放在 Spring Boot 项目的 classpath:/processes/ 目录下,这样当项目启动的时候,这个流程图会被自动部署。

3.4. 测试

接下来,我们启动 Spring Boot 项目,启动之后,流程和表单都会被自动部署好,我们执行如下代码启动一个流程实例:

@Test
void contextLoads() {
    runtimeService.startProcessInstanceByKey("askforleave");
}

流程启动成功之后,进入到 提交请假申请 环节,该环节有一个表单需要填写,我们可以先通过如下代码来查看需要填写的表单内容:

@Test
void test01() {
    Task task = taskService.createTaskQuery().singleResult();
    FormInfo formInfo = taskService.getTaskFormModel(task.getId());
    SimpleFormModel formModel = (SimpleFormModel) formInfo.getFormModel();
    System.out.println("formInfo.getId() = " + formInfo.getId());
    System.out.println("formInfo.getName() = " + formInfo.getName());
    System.out.println("formInfo.getKey() = " + formInfo.getKey());
    List<FormField> fields = formModel.getFields();
    for (FormField field : fields) {
        System.out.println("field.getId() = " + field.getId());
        System.out.println("field.getName() = " + field.getName());
        System.out.println("field.getValue() = " + field.getValue());
        System.out.println("field.getType() = " + field.getType());
        System.out.println("===============");
    }
}

最终打印出来的内容如下:

formInfo.getId() = a5b1306a-5ab0-11ed-b35b-acde48001122
formInfo.getName() = 经理审批表单
formInfo.getKey() = application_form.form
field.getId() = days
field.getName() = 请假天数
field.getValue() = null
field.getType() = text
===============
field.getId() = reason
field.getName() = 请假原因
field.getValue() = null
field.getType() = text
===============
field.getId() = startTime
field.getName() = 开始时间
field.getValue() = null
field.getType() = date
===============
field.getId() = endTime
field.getName() = 结束时间
field.getValue() = null
field.getType() = date

小伙伴们看到,打印出来的 value 都是 null,这是因为我们还没有填写表单。

接下来我们先来完成 提交请假申请 这一任务:

@Test
void test02() {
    Task task = taskService.createTaskQuery().singleResult();
    Map<String, Object> vars = new HashMap<>();
    vars.put("days", 10);
    vars.put("reason", "玩一下");
    vars.put("startTime", "2022-10-10");
    vars.put("endTime", "2022-11-10");
    taskService.complete(task.getId(),vars);
}

完成之后,此时任务进入到 组长审批 这一环节,现在我们再去执行 test01 方法,此时查询的就是 组长审批 这个任务的表单信息,最终打印出来日志如下:

formInfo.getId() = a5b1306a-5ab0-11ed-b35b-acde48001122
formInfo.getName() = 经理审批表单
formInfo.getKey() = application_form.form
field.getId() = days
field.getName() = 请假天数
field.getValue() = 10
field.getType() = text
===============
field.getId() = reason
field.getName() = 请假原因
field.getValue() = 玩一下
field.getType() = text
===============
field.getId() = startTime
field.getName() = 开始时间
field.getValue() = 2022-10-10
field.getType() = date
===============
field.getId() = endTime
field.getName() = 结束时间
field.getValue() = 2022-11-10
field.getType() = date

可以看到,此时都有对应的 value 了。

后续的流程就不需要我多说了吧,小伙伴们可以自行尝试下~