独立完成系统开发十一:工作流Activiti
在某些实际的业务场景中工作流还是比较常用的,通过工作流可以让复杂的业务流程变得更好维护,并且使用工作流还能让我们获取到流程中很多有用的信息。不过工作流这东西并不简单,因为有很多我们实际需要使用的功能他并没有提供,所以只能我们自己去实现,而且相关资料还不是很多,弄工作流的时候也花了我很长的时间。不过他基础的文档还是写的挺好的而且还提供了很多的示例,值得去好好的看看。
这篇文章主要包括activiti中核心功能的介绍、activiti的使用及改造还有一些特殊功能的实现。至于详细的基础的部分可以去官方文档中看哈,我当时也啃了很久不过最后也看完了
工作流activiti介绍
什么工作流
工作流(Workflow),就是通过计算机对业务流程自动化执行管理。它主要解决的是“使在多个参与者之间按照某种预定义的规则自动进行传递文档、信息或任务的过程,从而实现某个预期的业务目标,或者促使此目标的实现”。
简单的理解就是工作流他可以让我们的业务流程根据预定的规则在各个参与者之间自动流转传递信息,最终实现业务目标。
而activiti工作流引擎则是工作流的一个实现,当然还有其他的工作流引擎例如:jbpm、camunda、flowable 等等,目前在java中用的最多的工作流实现为activiti,而且相对于其他的我对activiti会更熟悉,并且activiti的资料在网上也比较多。所以在MyAdmin中我就选择了activiti。
注意:这里的activiti的版本是:5.22.0 。其中5.x版本和6.x版本还是有区别。
相关资料:
- activiti官网文档:https://www.activiti.org/5.x/userguide/
- 中文文档:http://www.mossle.com/docs/activiti/index.html
流程引擎和服务
在activiti中一系列service服务是最常用也是最重要的,因为所有的操作都是通过这些服务去进行的
通过上面可以看出在activiti中都是通过activiti.cfg.xml配置文件创建出ProcessEngineConfiguration,然后再通过ProcessEngineConfiguration创建ProcessEngine,之后通过ProcessEngine来获取各种Service,在通过这些Service来调用对应的api进行一些列的操作。当然api最后操作的是数据库中的表。
下面就介绍一下每个service的作用以及涉及到的对象
- RepositoryService
- 用于对流程进行部署,以及操作流程定义的信息和一些资源信息(例如bpmn.xml以及图片)
- 涉及对象
- 流程部署文件对象Deployment
- 流程定义文件对象ProcessDefinition
- 流程定义对应的模型对象BpmnModel
- RuntimeService
- 用于启动流程,以及操作流程实例的信息和流程变量
- 涉及对象
- 流程实例ProcessInstance
- 执行对象Execution
- 流程实例和执行对象的区别,流程实例表示这个流程的实例,而执行对象可以理解为流程实例中的流程分支因为一个流程中可能会出现多个流程分支
- 涉及对象
- 用于启动流程,以及操作流程实例的信息和流程变量
- TaskService
- 用于对任务节点(主要是用户任务(userTask))进行一系列操作,例如用户任务的curd,任务的完成、设置用户任务的权限信息(拥有者、候选人、办理人)、对用户任务添加以及查询附件(Attachment)、评论(Comment)以及事件记录(Event),设置变量等等
- 涉及对象
- Task任务对象
- IdentityService
- 用于管理用户,用户组,以及用户和组的关系
- 涉及对象
- 用户 user
- 组 group
- 用户的组的关系 membership
- formService
- 用于解析流程定义中的表单配置,还可以通过提交表单的方式驱动用户节点流转
- form表单可以分为3种分别是:动态表单、外置表单和普通表单。
- 涉及对象
- StartFormData ,start节点表单的数据
- TaskFormData, task节点的表单数据
- historyService
- 用于管理流程的历史数据
- 涉及对象
- HistoricProcessInstance 历史流程实例实体类
- HistoricVariableInstance 历史流程变量实体类
- HistoricActivitiInstance 历史节点信息(所有执行过的节点的信息)
- HistoricTaskInstance 用户任务的历史信息
- HistoricDetail 历史节点变量详情实体类
- ManagementService
- 他提供了job任务的管理操作,数据库相关的操作(例如,获取数据库中表的元数据,自定义sql的执行等等)以及执行流程引擎命令。因为activiti是通过命令模式运行的我们通过service操作之后他会将操作转换为命令然后通过命令去进行对应的操作,而通过ManagementService我们可以执行这些命令
而这些service可以调用那些方法可以参考官网的api文档。api有很多不过我们只需要明确一点那就是所有的api基本上操作的都是数据库的中对应的表,所以我们只需要知道当前操作的是那张表,然后再找到对应的service,在到service的api中看有那些对应的方法,在调用对应的方法进行操作就可以了。
具体的使用示例可以参考官网中示例,简单的理解就是通过对应的service调用对应的api操作对应的表从而实现对应的目的
activiti中的表
在activiti 5.x版本中总共有25张表。他的表设计时遵从一定规范的,其中所有的表都以ACT_开头。 第二部分是表示表的用途的两个字母标识。 用途也和服务的API对应,第三部分才是表的名称。
- ACT_RE_*: 'RE’表示repository。用于存储流程相关信息, 这个前缀的表包含了流程定义、流程模型和流程部署等信息。对应的service为RepositoryService
- ACT_RU_*: 'RU’表示runtime。用于存储流程运行时信息,这个前缀的表包含了流程实例,任务,变量等运行中的数据。 Activiti只在流程实例执行过程中保存这些数据, 在流程结束时就会删除这些记录。 这样运行时表可以一直很小速度很快。对应的service为RuntimeService和TaskService
- ACT_ID_*: 'ID’表示identity。用于存储流程中用户相关信息 ,这个前缀的表包含了用户,组的信息。对应的service为IdentityService
- ACT_HI_*: 'HI’表示history。用于存储历史流程相关信息,这个前缀的表包含了历史流程实例, 历史变量,历史任务节点等等。对应的service为HistoryService
- ACT_GE_*: 'GE’表示general。用于存储通用数据,例如当前activiti的版本信息、部署的资源如bpmn文件、图片、资源元数据等等。对应的service为RepositoryService
具体的表结构及注释可以参考:https://lucaslz.gitbooks.io/activiti-5-22/content/
表区别
在activiti中有些表看起来可能很相似,不过他们的区别还是很大的,千万不能混淆
- ACT_HI_ACTINST和ACT_HI_TASKINST
- 其中ACT_HI_ACTINST是历史节点表,记录了流程流转过的所有节点,而ACT_HI_TASKINST是历史流程任务表。他们的区别是ACT_HI_ACTINST会记录流程中的所有节点(包括开始节点、网关等),而ACT_HI_TASKINST只会记录userTask节点。
- ACT_HI_DETAIL和ACT_HI_VARINST
- 其中ACT_HI_DETAIL是历史变量详情表,记录流程所有活动节点中产生的变量,包括控制流程流转的变量,业务表单中填写的流程需要用到的变量等。而ACT_HI_VARINST是流程历史变量表,它用于记录历史流程中的变量。他们的区别是:ACT_HI_DETAIL他对应着流程中的每一个活动节点(其实就是ACT_HI_ACTINST中的记录),活动中涉及的变量都会记录在这张表里面通过
ACT_INST_ID_
关联,如果一个变量在不同的活动中有不同的值,这里值也会被记录下来并且对应着活动。而ACT_HI_VARINST他只会记录流程中涉及到的所有最新变量,如果一个变量在活动中更改那么他只会记录最新的值。
- 其中ACT_HI_DETAIL是历史变量详情表,记录流程所有活动节点中产生的变量,包括控制流程流转的变量,业务表单中填写的流程需要用到的变量等。而ACT_HI_VARINST是流程历史变量表,它用于记录历史流程中的变量。他们的区别是:ACT_HI_DETAIL他对应着流程中的每一个活动节点(其实就是ACT_HI_ACTINST中的记录),活动中涉及的变量都会记录在这张表里面通过
BPMN2.0
bpmn全称为business process model and notation
他是一套业务流程模型与符号建模标准,通过精确的执行语义来描述元素的操作,以xml为载体,以符号可视化业务。我们的所画的流程图就是通过bpmn中的元素组成的
BPMN2.0中的元素可以分为流对象(Flow Object)
、连接对象(Connecting Object)
、数据(Data)
、泳道(Swimlanes)
、描述对象(Artifacts)
bpmn中的所有元素及释义,当然在activiti中并不是所有元素都会用到
如果看不清可以到bpmn官网中查看以及官网介绍。而activiti官网文档中也对这些元素的使用进行了很详细的介绍
这里简单说一下activiti中的常用元素,更详细的介绍和使用可以参考activiti官网介绍
- 各种事件:事件如果根据位置可以分为开始事件、中间事件、边界事件和结束事件。
- 开始事件用于表示流程的开始,通过不同的方式来触发流程的开始就对应着各个不同的开始事件,例如通过消息事件来触发就是消息开始事件,通过定时事件触发就是定时开始事件,所以开始事件都属于捕获事件
- 中间事件指的是在流程中可以作为流程节点展示的事件,中间事件可以是一个抛出事件也可以是一个捕获事件
- 边界事件是指附属于某个流程节点的事件,他会在依附的任务的生命周期内等待一个抛出事件,所以所有的边界事件都属于捕获事件
- 结束事件用于表示流程的结束,通过不同的方式来触发流程的结束就对应着各个不同的结束事件,例如通过错误事件来触发就是错误结束事件。
- 当然根据事件类型分类可以分为:常规事件、定时事件、错误事件、信号事件、消息事件
- 常规事件就是正常的启动和结束事件,定时事件可以定时触发的事件,错误事件用于表示一种错误可以进行捕获或抛出,信号事件用于表示一个信号默认是作用于全局的,消息事件用于表示一个消息只作用于指定的接收者。
- 顺序流:顺序流是流程中两个元素之间的连接器。可以分为条件顺序流和默认顺序流
- 网关:网关用于控制顺序流的执行逻辑,网关有很多种,不同的网关用于表示不同的执行逻辑,其中常用的网关包括:排他网关、并行网关、包容网关和事件网关
- 任务:任务用于执行具体的逻辑。bpmn中任务分为很多中,例如用户任务、脚本任务、服务任务等等。其中用户任务表示需要人工参与的任务,脚本任务用于执行对应的脚本,服务任务用于在任务中执行java程序。
底层实现
可能有些同学会很好奇当我们通过Service调用对应的方法后,activiti中具体的操作是如何实现的。我们能不能干预activiti的内部操作呢,因为框架往往不能满足我们所有的需求,所以当我们有特殊需求的时候就可能需要干预他的执行。其实我也有这种想法,所以我就去看了一下activiti的内部实现,当然我并没有完整的去看只是用到哪里看到哪里。下面是我的查看部分源码后的总结,建议看的时候自己debugger跟着源码走
- 首先当我们通过各种Service调用对应的方法进行操作,他底层是通过命令模式执行的,他会将业务逻辑封装为一个个的
Command
接口实现类。这样新增一个功能时只需要新增一个Command实现即可。 - 例如当我们调用
repositoryService.deploy
时,他会调用commandExecutor
的execute
方法然后传入对应的Command接口实现类,流程部署时Command实现为DeployCmd
。其中CommandExecutor封装了一系列的CommandInterceptor
在内部形成一个命令拦截链,从而实现在命令执行前后进行拦截。-
在activiti中默认提供了一些拦截器,其中包括
LogInterceptor
用于记录日志、CommandContextInterceptor
用于生成命令执行的上下文(CommandContext)、CommandInvoker
他是CommandInterceptor链的最后一个对象,负责调用具体的Command。 -
-
当然我们也可以自定义一些命令拦截器。因为命令拦截链是在
ProcessEngineConfigurationImpl
的initCommandInterceptors
中初始化的,在初始化时他会将ProcessEngineConfigurationImpl中customPreCommandInterceptors
和customPostCommandInterceptors
属性中的拦截器设置到拦截器链中,所以自定义命令拦截器我们,只需要将我们的自定义拦截器添加到customPreCommandInterceptors或customPostCommandInterceptors属性中就可以了。例如在跟spring整合后activiti的事务处理就是通过SpringTransactionInterceptor
实现的 -
protected void initCommandInterceptors() { if (commandInterceptors==null) { commandInterceptors = new ArrayList<CommandInterceptor>(); if (customPreCommandInterceptors!=null) { commandInterceptors.addAll(customPreCommandInterceptors); } commandInterceptors.addAll(getDefaultCommandInterceptors()); if (customPostCommandInterceptors!=null) { commandInterceptors.addAll(customPostCommandInterceptors); } commandInterceptors.add(commandInvoker); } }
-
- 其中在
CommandInvoker
中调用具体的Command实现需要传入一个CommandContext
命令执行的上下文,这个命令执行上下文在CommandContextInterceptor
拦截器中创建。CommandContext中包含各种SessionFactory
,SessionFactory负责生成Session,通过Session来对数据库进行操作,当然Session只是一个接口他的实现类有很多都是一系列的XXXEntityManager
。但最终都是通过mybatis
来对数据库进行操作的。不过需要注意的是如果执行的是更新,删除,插入等操作,那么实际上会将这些操作缓存在内部。只有在执行flush
方法时才会真正的提交到数据库去执行。所以这类数据操作实际上最终都是要等到CommandContext
执行close
方法时,才会真正提到到数据库。 - 在执行流程的部署时除了将bpmn资源存储到数据库还会通过
Deployer
进行部署,默认Deployer的实现为BpmnDeployer
。在BpmnDeployer中会通过BpmnParse
将bpmn资源转为BpmnModel
(由BpmnXMLConverter
完成转换),其中BpmnModel是一个用于描述xml节点的类。之后在通过BpmnParseHandlers
将BpmnModel转为ProcessDefinitionEntity
。ProcessDefinitionEntity中包含运行时PVM相关对象。- 需要注意的是流程部署时Activiti持久化的是bpmn流程文件,所以每次系统重新启动都要根据持久化的数据生成ProcessDefinitionEntity对象,这样原本在运行的流程才可以在运行时使用PVM相关对象。
- PVM又称为流程虚拟机,Activiti的流程运行于PVM模型之上。在PVM将流程元素分为:流程节点
PvmActivity
和连线PvmTransition
。其中PvmActivity
的实现为ActivityImpl
他里面维护了流程图中当前节点所拥有的出入线、节点的信息以及节点的行为,节点行为动作通过ActivityBehavior
表示,不同的ActivityBehavior
实现类对应了流程中不同功能的节点。而PvmTransition
的实现为TransitionImpl
他里面维护了当前连线所连接的两个节点(ActivityImpl)。如果我们需要操作PVM可以先获取流程定义ProcessDefinitionEntity
,然后通过流程定义获取ActivityImpl
,之后在通过ActivityImpl获取相关联的TransitionImpl
和ActivityBehavior
,之后就可以对PVM节点进行操作了例如我们还可以修改ActivityImpl所对应的流出线的指向节点。- ActivityImpl、TransitionImpl和ActivityBehavior只是描述了流程的节点、节点连线和节点行为。如果想要流程流转起来还需要
AtomicOperation
来驱动。AtomicOperation的实现类有很多,并且AtomicOperation间还会形成链式调用,但最后都会调用ActivityBehavior
中的execute
来执行节点的动作。当然不同的ActivityBehavior的操作不同,例如Java Service Task
节点的Behavior就是执行指定的java类然后离开当前节点。而Uset Task
节点也类似,当办理任务后执行Uset Task对应的Behavior的逻辑并离开节点 - 不过需要注意的是在activiti6.x开始PVM就被移除了,所以pvm包下的类都无法在使用,在activiti6.x之后所有的流程定义有关的信息都可以通过
BpmnModel
来获得,具体参考官网说明。当然在MyAdmin中使用的是activiti5.22所以pvm可以正常使用。
- ActivityImpl、TransitionImpl和ActivityBehavior只是描述了流程的节点、节点连线和节点行为。如果想要流程流转起来还需要
- 所以在activiti中的执行过程简单概括就是,当通过Service调用方法后会经过一系列的拦截链,之后在调用具体的
Command
实现。Command中会通过mybatis对数据库进行操作。如果需要驱动流程流转则通过对应的AtomicOperation
对流程进行驱动,在AtomicOperation中最终会获取当前节点的ActivityImpl
在获取对应ActivityBehavior
,并调用Behavior的行为执行节点的行为动作,之后流向下一个节点。当然这只是简单概括实际会复杂很多,具体可以看相关源码实现。
activiti的使用及改造
springboot中整合activiti
由于在MyAdmin中使用的是sprinboot,所以就需要将activiti和springboot进行整合。因为在springboot中存在跟activiti整合的start,所以我们直接导入对应的start依赖就可以了
<dependency>
<groupId>org.activiti</groupId>
<artifactId>activiti-spring-boot-starter-basic</artifactId>
<version>5.22.0</version>
</dependency>
不过直接导入依赖然后启动项目之后你会发现项目启动报错:
org.springframework.beans.factory.BeanCreationException: Error creating bean with name ‘org.activiti.spring.boot.SecurityAutoConfiguration‘: Initialization of bean failed; nested exception is java.lang.ArrayStoreException: sun.reflect.annotation.TypeNotPresentExceptionProxy
这是因为activiti-spring-boot-starter-basic
中引入了spring-boot-starter-security
导致的,而我们项目里面又没有使用Spring sucerity。因为我们不需要所以不加载就行,不加载Spring sucerity我们可以在@SpringBootApplication注解中排除他。当然因为我们不需要Spring sucerity我们还可以在maven中直接将Spring sucerity的依赖排除
@SpringBootApplication(
exclude = {org.activiti.spring.boot.SecurityAutoConfiguration.class}
)
还有需要注意的是因为activiti-spring-boot-starter-basic
中还引入的mybatis,如果你使用了mybatis-plus那么会出现mybatis的版本冲突从而导致mybatis-plus有问题。所以如果使用了mybatis-plus则需要将activiti-spring-boot-starter-basic中的mybatis依赖给排除
<dependency>
<groupId>org.activiti</groupId>
<artifactId>activiti-spring-boot-starter-basic</artifactId>
<exclusions>
<exclusion>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
</exclusion>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</exclusion>
</exclusions>
</dependency>
activiti整合sprinboot之后,springboot就会在spring容器中为我们提供activiti中的所有service,使用的时候我们直接通过依赖注入就可以使用了,并且很多配置都会给我配置好,如果需要改那么直接在配置文件中修改就行或者自己编写配置类对配置进行修改
用户模块改造
在activit中是自带了对于用户和组的管理模块Identify模块,并且还自带了用户和组需要使用的用户表、角色表(组表)、用户和组之间的关系表等等也就是act_id_xxx
之类的表。不过我们的系统中通常都会有一套用于维护用户权限管理的模块或平台。所以我们如果使用activiti那么我们就需要将我们系统中的用户、角色以及用户和角色的对应关系和activiti中的同步。
而将activiti中的用户模块数据跟我们系统中用户模块数据同步的方法可以分为三种:
- 在我们对系统中的用户和角色进行操作的时候同时通过
IdentifyService
对acitvit中的用户和组进行操作以此来实现同步。这种方式有点傻所以一般不会使用,更多的是使用下面两种方式 - 将activiti中的用户组织管理表替换为对应的视图,而对应视图数据由我们的系统用户角色表获取。注意:创建的视图要保证数据类型一致,例如用户的
ACT_ID_MEMBERSHIP
表的两个字段都是字符型,一般系统中都是用NUMBER作为用户、角色的主键类型,所以创建视图的时候要把数字类型转换为字符型。而且替换为视图之后由于视图的结果可以是多张表关联后得到的结果所以是很灵活的 - 自定义实现Activiti用户组织管理接口(重写
SessionFactory
中的openSession
方法,在方法中返回我们自定义的UserEntityManager
),在我们自定义的实现中操作我们的系统用户及角色数据,这样就完全摒弃了activiti中的用户组织管理表。
这三种方法中最常用的是第二种方法通过视图的方式来整合activiti的用户模块并且这种方法也是最简单的,因为使用第三种方法还挺麻烦的还得自己去实现activiti的用户组织管理接口要重写的方法还是很多的。不过使用第二种方法存在一个问题那就是不能通过activiti对系统中的用户进行增删改操作因为他是基于视图的,不过在一个系统中用户角色的增删改应该交给对应的用户角色模块去实现而不应该交给activiti去操作,这个应该是不被允许的。所以这个问题可以忽略哈,使用的时候注意一下就行
下面是在mysql中创建视图的sql:
DROP TABLE IF EXISTS ACT_ID_USER;-- 用户信息表
DROP TABLE IF EXISTS ACT_ID_GROUP;-- 用户组信息表
DROP TABLE IF EXISTS ACT_ID_MEMBERSHIP;-- 用户与用户组关系信息表
DROP TABLE IF EXISTS ACT_ID_INFO;-- 用户扩展信息表
CREATE OR REPLACE VIEW ACT_ID_USER AS
(SELECT
su.user_code AS ID_,
NULL AS REV_,
su.user_name AS FIRST_,
NULL AS LAST_,
su.email AS EMAIL_,
su.password AS PWD_,
su.avatar AS PICTURE_ID_
FROM
sys_user su) ;
CREATE OR REPLACE VIEW ACT_ID_GROUP AS
(SELECT
sr.role_code AS ID_,
NULL AS REV_,
sr.role_name AS NAME_,
'assignment' AS TYPE_
FROM
sys_role sr) ;
CREATE OR REPLACE VIEW ACT_ID_MEMBERSHIP AS
(SELECT
u.user_code AS USER_ID_,
r.role_code AS GROUP_ID_
FROM
sys_user_role ur
LEFT JOIN sys_user u
ON ur.user_id = u.user_id
LEFT JOIN sys_role r
ON ur.role_id = r.role_id) ;
CREATE OR REPLACE VIEW ACT_ID_INFO AS
(SELECT
NULL AS ID_,
NULL AS REV_,
NULL AS USER_ID_,
NULL AS TYPE_,
NULL AS KEY_,
NULL AS VALUE_,
NULL AS PASSWORD_,
NULL AS PARENT_ID_
FROM
DUAL) ;
如果我们不想让activiti自动表的时候创建用户组织表可以在配置文件application.properties
中配置将spring.activiti.dbIdentityUsed
配置为false,那么activiti创建表的时候就不会创建用户组织表了
动态表单
在MyAdmin中工作流的使用方式是:
每个业务流程的使用我会新增一个对应的业务管理页面,在这个页面中可以显示业务数据以及部分流程数据,可以根据业务数据进行条件查询(这里业务数据为主数据而流程数据为附属数据),当时我是想把所有的业务流程的启动放在一公共的页面的,但是如果把所有的流程启动放在一个公共的页面那么在这个公共页面中就无法显示业务数据以及无法使用业务条件查询,并且数据多了就会很乱。因为如果要在公共页面中显示不同业务的业务数据我们需要将流程数据去关联不同的业务表这样才能获取到对应的业务数据而业务是不固定的会随时新增所以要在公共页面中显示不同的业务数据是无法实现的。为了体验更好所以我就把不同业务流程的启动单独新增了一个页面进行管理,具体可以参考MyAdmin中的流程示例,针对不同的业务我都新增了一个页面
然后任务的办理、查询已办理的业务、运行中流程和已结束流程则放在一个公共的菜单页面中,因为这些功能只需要关心流程数据就可以了,而这个公共页面中点击办理或详情需要根据不同的业务数据显示出不同的表单,所以这些表单需要动态渲染。
在activiti中表单的使用分为三种:动态表单、外置表单和普通表单
-
动态表单
- 动态表单就是在流程定义文件中定义表单的属性,在流程运行的时候我们可以获取这些属性,然后再将这些属性传给前端,前端在将这些属性渲染成form表单。
-
外置表单
- 这种方式常用于基于工作流平台开发的方式,代码写的很少,开发人员只要把表单内容写好保存到**.form**文件中即可,然后配置每个节点需要的表单名称(form key),实际运行时通过引擎提供的API读取Task对应的form内容输出到前端。
-
普通表单
- 这是最灵活的一种方式,我们可以事先将表单页面写好,然后当流程到达节点之后再根据节点中的表单标识加载之前写好的表单
上面三种方法中最常用的是动态表单和普通表单。在MyAdmin中我最开始是打算使用动态表单的方式来实现的,在流程定义文件的任务节点中定义好表单的属性然后再前端根据这些属性将表单渲染出来,不过最后我发现了一个问题,这种方式只能适用于一些比较简单的表单,如果表单中包含非表单元素例如富文本或图片之类的那么这种情况是不适用的。所以我最后选择了通过普通表单的方式来实现。而至于在使用动态表单这种方式的情况下如何通过流程定义中的表单属性在前端动态渲染生成表单组件我倒是找到了一个比较好用的插件form-create,有需要的童鞋可以去看看。
前端动态加载表单组件
在activiti中使用普通表单的方式我们所需要解决的问题是如何在vue中根据表单组件的标识来加载并显示不同的表单组件。
其实这个功能在vue中本来就提供了的。在vue中切换组件我们可以在页面中定义一个<component/>
组件在通过组件中的:is
指向我们需要显示的组件就可以了,而通常:is
属性在使用的时候都是先将对应的组件导入然后注册,最后在通过:is
属性指向对应组件的名称。通过这种方式需要事先将组件导入然后注册,很明显通过这种方式并不符合我们的要求,因为你不可能将所有的form表单组件都事先导入并且注册好,并且如果通过这种方式以后新增了form组件我们还得去改代码把新增的组件给加上,那这样就太麻烦了。
不过经过查找相关资料我发现了一种好用的方法,那就是我们可以通过require来导入组件,例如require('./components/LeaveForm.vue').default
然后将:is
直接指向这个导入的组件,这样就可以在运行的时候动态加载对应的组件了。而流程中的任务节点和表单组件的对应关系我们可以通过流程任务节点定义中的formkey来进行关联
<template>
<component ref="dialog" :is="dialogComponent"></component>
</template>
<script>
export default {
data () {
return {
comp: 'formkey',
dialogComponent: undefined
}
},
created () {
this.dialogComponent = require(`@/form/${this.comp}.vue`).default
this.$nextTick(() => {
// 调用子组件的方法
this.$refs.dialog.method()
})
}
}
</script>
这样在vue中就可以根据任务节点中定义的formkey来动态显示不同表单组件了。
特殊功能
工作流在实际的使用过程中我发现有很多的需求activiti并没有给出解决方法,所以这些需要就需要我们自己去实现,下面我会介绍一下在activiti中一些特殊功能的实现方案,当然如果有更好的方法可以在评论中说一下哈
办理任务的时候指定下一任务办理人
这个功能在审批流中我觉得还是很有必要的。而这个功能涉及到的功能点有
- 获取下一用户任务节点的办理人、候选人或候选组中的人员,然后在前端显示
- 在当前任务办理后,将指定的人员设置为任务的办理人或候选人
其实就两个功能点看着可能很简单但是其中却涉及到很多的问题,下面都会说到
获取下一用户任务节点的办理人、候选人
获取下一用户节点办理人我们得先找到下一用户节点。不过获取到下一节点有两个问题需要解决
- 流程中下一节点或下下一节点…不是用户任务节点我们怎么处理,他可能为排他网关、并行网关或其他的元素节点
- 当下一节点为排他网关的时候我们怎么知道走那条路线去找用户节点呢
对于第一个问题其实我们只需要考虑排他网关的情况就可以了,如果下一节点不是用户任务节点而是排他网关那么我们就获取排他网关的下一节点看是否是用户任务节点依次下去。而如果下一节点既不是用户节点也不是排他网关例如并行网关,并行网关后面跟着两个用户节点我们并不能同时设置两个用户节点的办理人,这个时候可以考虑使用其他的方式设置用户任务节点的办理人例如监听器,并且除了用户任务节点,其他的节点也根本不需要设置办理用户。
第二个问题我的解决方案是自己定义一个规范,在流程启动时设置流程默认走向的控制变量(变量加后缀_$temp
,例如某个排他网关的走向的控制变量为result那么在流程启动的时候我就设置一个流程变量result_$temp
,他的值为流程的默认走向),这样在排他网关中判断走那条路线的时候直接通过流程启动时设置的控制变量进行判断就可以了。
具体实现
获取下一用户节点的办理人、候选人的逻辑是:首先根据当前任务id去获取下一个节点的信息,如果下一个节点为排他网关则继续获取排他网关的下一节点依次下去直到找到用户节点或其他节点,如果下一节点既不是用户节点也不是排他网关则获取不到下一任务的办理人,当然如果流程图中没有使用网关并且用户节点后面直接跟着两条流转线,那么就根据条件判断是走那条,从而获取到下一节点。
而判断连线上的条件是否成立的逻辑是:首先获取流程中的所有变量,然后判断这些变量中是否有_$temp
结尾的临时结果变量,_$temp
结尾的临时结果变量需要流程启动的时候设置进去这里用于判断流程的走向,除了这种先设置流程结果变量的方法,我们还可以直接对表达式进行解析就是直接对表达式字符串进行匹配因为我们的流程结果变量的$reslut_
开头的(后面会介绍为什么流程结果变量为$reslut_
开头)所以我们可以通过一定的规则对字符串进行解析从而获取到结果变量的名称,不过这种感觉有点复杂因为表达式里不仅仅只有一个条件而且还会出现多种情况例如还会出现与或非的情况,而且这种解析方式特别复杂因为要判断的情况太多了,如果某些情况没有考虑到就可能会出现问题,并且这种方式结果变量的值的类型只能是boolean类型因为我们无法获取到变量对应的值所以就只能规定一种类型。因此就没有使用直接解析的方法。如果有_$temp
结尾的临时结果变量那么我们就将_$temp
替换为空,这样就得到了结果变量以及他的值,然后设置到执行表达式的上下文中。如果不是_$temp
结尾的则判断是否以$result
开头的如果有则将这个剔除(不剔除在有驳回的情况下会影响表达式的判断结果),当然如果变量不是上面两种情况直接将变量设置到执行表达式的上下文中,最后执行表达式从而得到结果。
最后获取到下一用户节点就可以获取用户节点中设置的办理人、候选人或候选组了,其中当设置了办理人那么设置的候选人和候选者将失效。
因为要在运行时的当前节点获取流程图中的其他节点的信息,这个时候就需要获取流程图中定义的数据。由于activiti在是运行于PVM模型之上的,所以我们可以通过PVM相关对象来操作流程定义以及获取流程定义的数据
具体代码:
/**
* 功能描述: 根据任务id获取当前任务的下一任务办理人
* @param taskId 任务id
* @return com.myadminmain.workflow.common.entity.NextTaskUser
* @author cdfan
*/
public NextTaskUser nextTaskUser(String taskId) {
NextTaskUser nextTaskUser = new NextTaskUser();
// 获取下一个用户任务信息
TaskDefinition nextTaskInfo = this.getNextTaskInfo(taskId);
ArrayList<String> userCodes = new ArrayList<>();
String pattern = "^[#$]\\{.*}$";
if (ObjectUtils.isNotEmpty(nextTaskInfo)) {
// 如果下一步找不到用户节点
if (ActConstant.OTHERS_NODE.equals(nextTaskInfo.getKey())) {
return nextTaskUser;
}
// 如果指定了办理人且指定的不是变量
if (ObjectUtils.isNotEmpty(nextTaskInfo.getAssigneeExpression())
&& !Pattern.matches(pattern, nextTaskInfo.getAssigneeExpression().getExpressionText())) {
// 判断设置的用户在当前系统中是否存在
QueryWrapper<User> queryWrapper =
new QueryWrapper<User>().eq("user_code", nextTaskInfo.getAssigneeExpression().getExpressionText());
List<User> users = userService.list(queryWrapper);
if (users.size() > 0) {
userCodes.add(nextTaskInfo.getAssigneeExpression().getExpressionText());
nextTaskUser.setHasNextUser(true);
nextTaskUser.setUserCodes(userCodes);
return nextTaskUser;
}
}
// 如果指定了候选人且指定的不是变量
Set<Expression> candidateUserIds = nextTaskInfo.getCandidateUserIdExpressions();
if (ObjectUtils.isNotEmpty(candidateUserIds)) {
ArrayList<String> codes = new ArrayList<>();
for (Expression candidateUserId : candidateUserIds) {
if (!Pattern.matches(pattern, candidateUserId.getExpressionText())) {
codes.add(candidateUserId.getExpressionText());
}
}
QueryWrapper<User> queryWrapper = new QueryWrapper<User>().in("user_code", codes);
List<User> users = userService.list(queryWrapper);
for (User user : users) {
userCodes.add(user.getUserCode());
}
}
// 如果指定了候选组且指定的不是变量
Set<Expression> candidateGroupIds = nextTaskInfo.getCandidateGroupIdExpressions();
if (ObjectUtils.isNotEmpty(candidateGroupIds)) {
ArrayList<String> groups = new ArrayList<>();
for (Expression candidateGroupId : candidateGroupIds) {
if (!Pattern.matches(pattern, candidateGroupId.getExpressionText())) {
groups.add(candidateGroupId.getExpressionText());
}
}
if (groups.size() > 0) {
// 通过设置的候选组获取组里面的所有用户
List<User> users = userService.queryUserByRolecodes(groups);
for (User user : users) {
userCodes.add(user.getUserCode());
}
}
}
nextTaskUser.setHasNextUser(true);
nextTaskUser.setUserCodes(userCodes);
return nextTaskUser;
}
return nextTaskUser;
}
/**
* 获取下一个用户任务信息
*
* @param taskId 任务id
* @return 下一个用户任务用户组信息
* @author cdfan
*/
private TaskDefinition getNextTaskInfo(String taskId) {
ProcessDefinitionEntity processDefinitionEntity = null;
String id = null;
TaskDefinition taskDef = null;
Task task = taskService.createTaskQuery().taskId(taskId).singleResult();
if(ObjectUtils.isEmpty(task)){
throw new MyAdminException("未获取到当前的任务");
}
// 获取流程发布Id信息
String definitionId = runtimeService.createProcessInstanceQuery().processInstanceId(task.getProcessInstanceId())
.singleResult().getProcessDefinitionId();
processDefinitionEntity = (ProcessDefinitionEntity)((RepositoryServiceImpl)repositoryService)
.getDeployedProcessDefinition(definitionId);
ExecutionEntity execution = (ExecutionEntity)runtimeService.createExecutionQuery().executionId(task.getExecutionId()).singleResult();
// 当前流程节点Id信息
String activitiId = execution.getActivityId();
// 获取流程所有节点信息
List<ActivityImpl> activitiList = processDefinitionEntity.getActivities();
// 遍历所有节点信息
for (ActivityImpl activityImpl : activitiList) {
id = activityImpl.getId();
if (activitiId.equals(id)) {
// 获取下一个节点信息
taskDef = this.nextTaskDefinition(activityImpl, execution.getId());
break;
}
}
return taskDef;
}
/**
* 下一个任务节点信息, 如果下一个节点为用户任务则直接返回, 如果下一个节点为排他网关, 根据流程变量中的信息判断流程的走向,从而获取下一任务
*
* @param activityImpl 流程节点信息
* @param executionId 执行对象id
* @author cdfan
*/
private TaskDefinition nextTaskDefinition(ActivityImpl activityImpl, String executionId) {
// 获取节点所有流向线路信息
List<PvmTransition> outTransitions = activityImpl.getOutgoingTransitions();
if (outTransitions.size() > 1) {
// 遍历路线判断走那条路线
PvmTransition goTr = null;
PvmActivity ac = null;
for (PvmTransition tr : outTransitions) {
Object conditionText = tr.getProperty("conditionText");
// 判断el表达式是否成立
if (ObjectUtils.isNotEmpty(conditionText)
&& isCondition(StringUtils.trim(conditionText.toString()), executionId)) {
goTr = tr;
break;
}
}
// 获取目标线路的终点节点
if (ObjectUtils.isNotEmpty(goTr)) {
ac = goTr.getDestination();
}
if (ObjectUtils.isNotEmpty(ac)) {
return getTaskDefinition(executionId, ac);
} else {
TaskDefinition taskDefinition = new TaskDefinition(null);
taskDefinition.setKey(ActConstant.OTHERS_NODE);
return taskDefinition;
}
} else {
// 获取目标节点
PvmActivity ac = outTransitions.get(0).getDestination();
// 判断节点类型
return getTaskDefinition(executionId, ac);
}
}
/**
* 功能描述: 判断节点类型,如果是网关继续迭代,用户任务直接返回,否则直接返回空任务
*
* @param executionId 执行实例id
* @param ac PvmActivity
* @return org.activiti.engine.impl.task.TaskDefinition
* @author cdfan
*/
private TaskDefinition getTaskDefinition(String executionId, PvmActivity ac) {
if ("userTask".equals(ac.getProperty("type"))) {
return ((UserTaskActivityBehavior)((ActivityImpl)ac).getActivityBehavior()).getTaskDefinition();
} else if ("exclusiveGateway".equals(ac.getProperty("type"))) {
// 如果是网关继续获取下一节点
return nextTaskDefinition((ActivityImpl)ac, executionId);
} else {
// 如果既不是排他网关也不是用户任务,则直接返回,因为如果包含其他类型节点没必要设置任务办理人
TaskDefinition taskDefinition = new TaskDefinition(null);
taskDefinition.setKey(ActConstant.OTHERS_NODE);
return taskDefinition;
}
}
/**
* 根据key和value判断el表达式是否通过信息
*
* @param el el表达式信息
* @param executionId 执行对象id
* @author cdfan
*/
private boolean isCondition(String el, String executionId) {
Map<String, Object> variables = runtimeService.getVariables(executionId);
ExpressionFactory factory = new ExpressionFactoryImpl();
SimpleContext context = new SimpleContext();
String pattern_prefix = "^" + ActConstant.RESULT_PREFIX.replace("$", "\\$") + ".*";
String pattern_suffix = ".*" + ActConstant.TEMP_RESULT_SUFFIX.replace("$", "\\$") + "$";
for (String key : variables.keySet()) {
String tempKey = null;
// 判断是否以_$temp结尾,如果是则为临时结果变量,将临时结果变量的值替换为结果变量
if (Pattern.matches(pattern_suffix, key)) {
tempKey = key.replace(ActConstant.TEMP_RESULT_SUFFIX, "");
} else {
// 判断是否以$result开头,如果是则为结果变量,结果变量需要去除,因为在驳回的情况下结果变量的值为false
// 这样就会导致无法获取到正常情况下流程流转的下一节点,从而无法获取到下一任务办理人
if(!Pattern.matches(pattern_prefix, key)){
tempKey = key;
}
}
if(ObjectUtils.isNotEmpty(tempKey)){
context.setVariable(tempKey, factory.createValueExpression(variables.get(key), variables.get(key).getClass()));
}
}
ValueExpression e = factory.createValueExpression(context, el, boolean.class);
return (Boolean)e.getValue(context);
}
任务办理后通过指定的人员设置任务的办理人或候选人
这里由于要根据前端传过来的人员来在当前任务完成后设置下一任务的办理人。但是activiti中办理任务后是不会将下一个任务返回的。所以在办理任务后我们需要在去查询下一个任务。而activiti中精确的获取任务都是通过办理人或候选人来查询。经过研究发现,如果通过流程实例id获取任务可能会得到多个任务(并行的情况下可能会有多个),不过如果通过执行实例id来查询那么就只能查询到一个最新的任务(因为如果有多个并行分支会创建多个执行实例)。当然在执行到并行分支的地方通过主流程执行id查询会查不到(只有子执行id可以获取到任务),但是在并行分支节点我们根本无法设置下一步任务办理人,因为两个或多个任务我们没法设置呀,而且上面也说了在遇到并行网关我们无法获取到下一用户任务节点,所以在并行任务中每个分支的第一个用户节点设置办理人可以直接写死或通过监听器来设置。当然如果是并行任务分支中除了第一个用户任务节点的其他节点是可以指定下一任务办理人的。因此在当前任务办理后设置下一任务的办理人或候选人我们只需要通过执行id去获取运行中的任务,然后再给这个任务设置办理人或候选人就可以了。
还有在系统中不管是通过直接指定办理人还是指定候选人、候选组。我们都会将设置的人员显示到前端,前端在根据实际情况进行选择,所以我们的办理人以及候选人要以前端选择的为准,所以在设置下一任务办理用户的时候我们需要将任务里面设置的用户或组清理掉,然后再通过前端选择的人员设置办理或候选人。
public void handleTask(HandleTaskData handleTaskData, Map<String, Object> variable) {
// 完成任务
taskService.complete(handleTaskData.getTaskId(), variable);
// 设置下一任务办理人, 如果是并行流程设置无效,请使用监听器指定
Task task = taskService.createTaskQuery().taskId(handleTaskData.getTaskId()).singleResult();
Task newTask = taskService.createTaskQuery().executionId(task.getExecutionId()).singleResult();
if (ObjectUtils.isNotEmpty(handleTaskData.getUser()) && ObjectUtils.isNotEmpty(newTask)) {
clearTaskUser(newTask.getId());
// 重新设置关联用户
if (handleTaskData.getUser().size() == 1) {
// 如果只指定了一个人,则直接指定办理人
taskService.setAssignee(newTask.getId(), handleTaskData.getUser().get(0));
} else {
// 如果指定了多个人,那么就指定候选人
for (String user : handleTaskData.getUser()) {
taskService.addCandidateUser(newTask.getId(), user);
}
}
}
}
/**
* 功能描述: 清理任务用户
*
* @param taskId 任务id
* @author cdfan
*/
private void clearTaskUser(String taskId) {
List<IdentityLink> identityLinksForTask = taskService.getIdentityLinksForTask(taskId);
for (IdentityLink identityLink : identityLinksForTask) {
// 关联了用户(可能是候选人或办理人)
if (identityLink.getUserId() != null) {
taskService.deleteUserIdentityLink(taskId, identityLink.getUserId(), identityLink.getType());
}
// 关联了组
if (identityLink.getGroupId() != null) {
taskService.deleteGroupIdentityLink(taskId, identityLink.getGroupId(), identityLink.getType());
}
}
}
任务办理历史记录获取
在查询历史任务的时候有两种一种是历史活动(HistoricActivityInstance)它包括了所有节点,对应表act_hi_actinst
,一种是历史任务(HistoricTaskInstance)他只包括活动中的用户任务节点。但是在通常我们在获取历史任务的时候需要显示这个任务的审核结果是通过还是驳回,而任务中是没有标识任务是否审核通过的标识的
对于这个问题,目前的方案是获取任务对应的审核结果变量,通过变量来获取审核结果状态。而在act_hi_detail表中记录了每个活动节点设置的变量,所以我们可以通过查询历史活动(HistoricActivityInstance),在通过活动获取到活动中设置的审核结果变量就可以了,因为在一个节点中可能会设置多个变量而且审核结果变量的名称不一定都是一致的,怎么区分那个变量是审核结果变量呢,目前的方法是在设置审核结果变量的时候添加上一个前缀($result
),根据这个前缀来区分变量是否为审核结果变量,所以流程中用于判断流程走向的结果变量必须要添加上一个前缀($result
),或者直接使用$result
来表示流程走向的结果变量 。使用这种方式之后那么在流程图中设置控制流程走向的变量就要求必须包含$result
前缀,当然如果有更好的方式欢迎在评论区中提出来
还有这里需要注意的是,我们通过历史活动获取这个历史活动中所有的变量是通过流程实例id找到这个流程中流转过的所有活动节点(对应act_hi_actinst
表),然后再通过活动节点找到活动节点中设置的变量(对应act_hi_detail
表通过ACT_INST_ID_
字段关联)。不过在某些情况下会出现act_hi_detail
表的ACT_INST_ID_
字段没有值,例如当我们在完成任务的时候设置流程变量taskService.complete(taskId, variable);
,那么如果在流程中出现并行分支的时候并行分支接着的第一个用户节点设置的变量在act_hi_detail
表中的ACT_INST_ID_
字段就没有值。如果act_hi_detail
表中的ACT_INST_ID_
字段就没有值那么我们就无法通过流转过的活动节点获取到节点中设置的变量,这样也就找不到结果变量了。经过查看taskService.complete(taskId, variable);
的源码发现,如果要想设置的变量在act_hi_detail
表中的ACT_INST_ID_
字段有值那么就得通过execution来设置而不是通过task来设置,通过下面的源码可以看到如果设置变量的时候是通过task.setVariables(variables)
来设置的那么在act_hi_detail
表中的ACT_INST_ID_
字段就没有值,但如果是通过task.setExecutionVariables(variables);
来设置的那么ACT_INST_ID_
字段就有值。所以在设置流程变量的时候我们可以直接通过runtimeService.setVariables(executionId,variable);
来设置,这样就可以避免act_hi_detail
表中的ACT_INST_ID_
字段没有值的情况了
protected Void execute(CommandContext commandContext, TaskEntity task) {
if (variables!=null) {
if (localScope) {
task.setVariablesLocal(variables);
} else if (task.getExecutionId() != null) {
task.setExecutionVariables(variables);
} else {
task.setVariables(variables);
}
}
task.complete(variables, localScope);
return null;
}
获取历史任务的代码
public List<HistoryTask> historyTask(String procInstId){
// 获取历史活动
List<HistoricActivityInstance> taskActivitys = historyService.createHistoricActivityInstanceQuery().processInstanceId(procInstId).activityType("userTask").orderByHistoricActivityInstanceStartTime().asc().list();
// 历史实例
HistoricProcessInstance historicProcessInstance =
historyService.createHistoricProcessInstanceQuery().processInstanceId(procInstId).singleResult();
// 获取活动中的人员名称
List<String> userCodes = new ArrayList<>();
Map<String, String> userMap = new HashMap<String, String>();
for (HistoricActivityInstance taskActivity : taskActivitys) {
userCodes.add(taskActivity.getAssignee());
}
if (ObjectUtils.isNotEmpty(userCodes)) {
List<User> users = userService.list(new QueryWrapper<User>().in("user_code", userCodes));
for (User user : users) {
userMap.put(user.getUserCode(), user.getUserName());
}
}
// 获取流程中设置的所有变量
List<HistoricDetail> details = historyService.createHistoricDetailQuery().processInstanceId(procInstId).orderByTime().asc().list();
HistoricDetailVariableInstanceUpdateEntity detailEntity;
Map<String, String> resultMap = new HashMap<String, String>();
String pattern = "^" + ActConstant.RESULT_PREFIX.replace("$", "\\$") + ".*";
for (HistoricDetail detail : details) {
detailEntity = (HistoricDetailVariableInstanceUpdateEntity) detail;
// 如果当前变量为结果变量
if (Pattern.matches(pattern, detailEntity.getName())
&& ObjectUtils.isNotEmpty(detailEntity.getLongValue())) {
if (detailEntity.getLongValue() == 1) {
resultMap.put(detailEntity.getActivityInstanceId(), "通过");
} else {
resultMap.put(detailEntity.getActivityInstanceId(), "驳回");
}
}
}
// 获取所有设置的意见说明
List<Comment> comments = taskService.getProcessInstanceComments(procInstId);
Map<String, String> commentMap = new HashMap<String, String>();
for (Comment comment : comments) {
commentMap.put(comment.getTaskId(),((CommentEntity)comment).getMessage());
// 转成utf-8后还是会乱码,可能是因为utf-8有BOM和无BOM的原因,这里直接使用message就行了他存储的时候就是字符串,而不是字节
// try {
// commentMap.put(comment.getTaskId(), new String(comment.getFullMessage().getBytes(),"utf-8"));
// } catch (UnsupportedEncodingException e) {
// commentMap.put(comment.getTaskId(), "");
// e.printStackTrace();
// }
}
// 获取历史任务
List<HistoricTaskInstance> historicTaskInstances = historyService.createHistoricTaskInstanceQuery().processInstanceId(procInstId).list();
Map<String, HistoricTaskInstance> historicTaskMap = new HashMap<String, HistoricTaskInstance>();
for (HistoricTaskInstance historicTaskInstance : historicTaskInstances) {
historicTaskMap.put(historicTaskInstance.getId(),historicTaskInstance);
}
List<HistoryTask> historyTasks = new ArrayList<>();
HistoricActivityInstance taskActivity;
for (int i = 0; i < taskActivitys.size(); i++) {
taskActivity = taskActivitys.get(i);
HistoryTask historyTask = new HistoryTask();
historyTask.setTaskId(taskActivity.getTaskId());
historyTask.setTaskName(taskActivity.getActivityName());
historyTask.setFormKey(historicTaskMap.get(taskActivity.getTaskId()).getFormKey());
historyTask.setBusinessKey(historicProcessInstance.getBusinessKey());
historyTask.setUserName(userMap.get(taskActivity.getAssignee()));
if (ObjectUtils.isEmpty(taskActivity.getEndTime())) {
// 当前任务未结束
historyTask.setResult("处理中");
} else {
historyTask.setResult(resultMap.get(taskActivity.getId()));
historyTask.setEndTime(LocalDateTimeUtil.convertDateToLDT(taskActivity.getEndTime()));
historyTask.setElapsedTime(DateUtil.secondToTime(taskActivity.getDurationInMillis() / 1000));
}
historyTask.setComment(commentMap.get(taskActivity.getTaskId()));
historyTask.setStartTime(LocalDateTimeUtil.convertDateToLDT(taskActivity.getStartTime()));
historyTasks.add(historyTask);
}
return historyTasks;
}
获取执行轨迹高亮的流程图
获取流程图,我们需要做的是获取到所有需要高亮的节点以及所有需要高亮的线
其中获取到高亮的节点以及当前活动节点很简单,我们可以通过流程实例id获取流程的历史活动(HistoricActivityInstance)这就是需要高亮的节点。难点是获取流转过的线,这个稍微有点复杂。
获取流转过的线,想到过一种很简单的方案,不过这种方案在有回退的时候会出现问题,先说一下这方案:
其实我获取线的时候主要判断的是在某个节点有多条流转线的时候我们具体走的是那一条,而我们走过的每一个节点在历史活动中都会记录,没有走过的节点就不会记录,那么我们是不是可以直接遍历历史活动然后拿到其中的一个活动节点,根据这个活动节点拿到对应的流程活动定义,通过流程活动定义获取到他的流转线从而找到所有的目的地节点,然后再判断这些目的地节点是否在当前活动节点之后的节点中出现过,如果出现过那么就证明那个目的地节点流转过,所以指向目的地的线为流转线。这样就可以获取所有的流转线了。但是这个方案我试了一下发现了一个问题,哪就是当存在回退的时候,如果我们曾经回退过那么再次申请的时候所有的回退线都会被当做流转线。因为当我们流程回退过那么回退线的目标节点就处于历史节点的最后,所以前面节点去匹配的时候回退线的目标节点肯定会匹配到。所以回退线就被当做了流转线。当然没有回退这个逻辑是不会有问题的
而我最后使用获取流转线的逻辑是:首先根据流程实例id获取流程的历史活动(HistoricActivityInstance)集合,然后再获取流程定义对象(ProcessDefinitionEntity ),遍历历史活动集合,在流程定义对象中找到与历史活动匹配的活动节点定义。然后判断节点类型,如果是并行网关那么他所有的流出线都是流转线,如果是包容网关,由于包容网关中不符合条件的节点肯定不会执行到(不管你怎么回退都不会回退到不符合条件的节点中反正就是不会经过这个节点)所以可以使用上面说过的第一种方案,直接根据流出线获取到目的地节点然后跟当前节点之后的所有历史节点匹配,如果匹配到了则这个目的地节点就流转过所以流出线就为流转线。如果不是上面的两种类型那么我们需要根据当前节点获取到他的下一节点。
在获取下一节点的时候由于我们获取所有历史活动节点的时候是根据节点的创建时间升序获取的,按理说当前节点的下一节点就是流转节点,不过在有一种情况会可能会出现问题那就是在并行流程中,由于两个节点是并行执行的就会出现多个执行对象(多条路线)所以节点的创建顺序可能会出现混乱(不过节点的创建时间顺序是不会乱的,后创建的节点一定在后面),而我的解决方案是,遍历当前节点后面的所有节点,首先判断当前节点和后面节点的执行id是否一致,如果一致则表示他们的执行对象相同那么为同一条执行流程,那么下一节点一定为当前节点的流转节点,如果不一致则说明这两个节点不在同一条执行流程中(并行流程会出现这种情况,他会出现多条执行流程)那么就继续判断后面的节点。最后找到流转节点后在找到对应的流转线。(这里有个问题要注意一下,最开始我以为并行流程的出口节点的执行对象和并行流程中的执行对象不一致,而是和主流程的执行对象一致(如果不一致那么根据上面的方案就会出现在并行流程中连接出口的线获取不到),不过后面我去看数据库表发现在并行流程中不同的执行流程会创建一个跟当前执行流程所对应的并行流程出口节点记录,所以每条并行流程都可以找到与之对应的出口节点记录,所以并行流程出口的线获取是没有问题的)
最后在拿着获取到的流转线集合以及历史活动节点和当前节点去生成流程图。
最后这里有一个问题,那就是我们要求获取到的流程实例活动的顺序是跟流程的执行顺序一致的。目前我们可以通过活动的创建时间和完成时间来进行排序但是我发现如果一个活动没有人参与并且执行的太快的时候那么就会出现两个节点的创建时间是一样的,例如本来一个Service Task在User task前面。但由于Service Task执行太快导致和Service Task在User task的创建时间一样(在秒级别一样)。并且如果两个节点的创建时间是一样的而且有一个完成一个没完成(Service Task完成,User task未完成),那么排序后没完成的任务节点还排在前面(因为他的完成时间为null,mysql中null比其他值都要小),这样Service Task和User task查询出来的顺序就有问题。目前使用的解决方案是,因为我们的id生成规则是自定义的,而每创建一条记录都需要调一次id的生成,所以我们只需要在生成id的时候阻塞1毫秒那么他的生成时间就会延迟1毫秒。这样不同记录之间就会有时间差了。
具体代码实现:
public String getHighlightTrackImage(String procInstId) {
HistoricProcessInstance processInstance =
historyService.createHistoricProcessInstanceQuery().processInstanceId(procInstId).singleResult();
BpmnModel bpmnModel = repositoryService.getBpmnModel(processInstance.getProcessDefinitionId());
// 获取流程定义对象
ProcessDefinitionEntity definitionEntity =
(ProcessDefinitionEntity)repositoryService.getProcessDefinition(processInstance.getProcessDefinitionId());
// 获取流程活动集合
List<HistoricActivityInstance> activityList = historyService.createHistoricActivityInstanceQuery()
.processInstanceId(procInstId).orderByHistoricActivityInstanceStartTime().asc().list();
// 高亮线路id集合
List<String> highLightedFlows = getHighLightedFlows(definitionEntity, activityList);
// 高亮节点id集合
List<String> highLightedActivitis = activityList.stream().map(HistoricActivityInstance::getActivityId).collect(Collectors.toList());
ProcessDiagramGenerator processDiagramGenerator = processEngineConfiguration.getProcessDiagramGenerator();
//InputStream imageStream = processDiagramGenerator.generateDiagram(bpmnModel, "png", highLightedActivitis, highLightedFlows);
// 必须在生成图片时指定字体,因为默认字体为Arial不支持中文
InputStream imageStream = processDiagramGenerator.generateDiagram(bpmnModel, "png", highLightedActivitis,highLightedFlows,
"宋体", "宋体", "宋体",null, 1.0);
byte[] bytes = IOUtil.readInputStream(imageStream);
return new BASE64Encoder().encode(bytes);
}
/**
* 功能描述: 获取需要高亮的线
*
* @param processDefinitionEntity 流程定义实体对象
* @param historicActivityInstances 历史活动节点集合
* @return java.util.List<java.lang.String>
* @author cdfan
*/
private List<String> getHighLightedFlows(ProcessDefinitionEntity processDefinitionEntity,
List<HistoricActivityInstance> historicActivityInstances) {
// 用以保存高亮的线flowId
List<String> highFlows = new ArrayList<>();
for (int i = 0; i < historicActivityInstances.size() - 1; i++) {
// 获取当前历史节点
HistoricActivityInstance currentActivityInstance = historicActivityInstances.get(i);
// 下一历史节点
HistoricActivityInstance nextActivityInstance = null;
// 得到节点定义的详细信息
ActivityImpl activityImpl = processDefinitionEntity.findActivity(currentActivityInstance.getActivityId());
List<PvmTransition> pvmTransitions = activityImpl.getOutgoingTransitions();
// 判断节点类型
String type = (String)activityImpl.getProperty("type");
if ("parallelGateway".equals(type)) {
// 并行网关
for (PvmTransition pvmTransition : pvmTransitions) {
highFlows.add(pvmTransition.getId());
}
} else if ("inclusiveGateway".equals(type)) {
// 包容网关
for (PvmTransition pvmTransition : pvmTransitions) {
// 获取流出线的目的地
ActivityImpl pvmActivityImpl = (ActivityImpl)pvmTransition.getDestination();
// 当前节点记录之后的流程节点
List<HistoricActivityInstance> activityInstanceList = historicActivityInstances.subList(
historicActivityInstances.indexOf(currentActivityInstance), historicActivityInstances.size());
for (HistoricActivityInstance activityInstance : activityInstanceList) {
if (pvmActivityImpl.getId().equals(activityInstance.getActivityId())) {
highFlows.add(pvmTransition.getId());
break;
}
}
}
} else {
// 先获取到下一历史活动节点
for (int j = i + 1; j < historicActivityInstances.size(); j++) {
if (currentActivityInstance.getExecutionId()
.equals(historicActivityInstances.get(j).getExecutionId())) {
nextActivityInstance = historicActivityInstances.get(j);
break;
}
}
if (ObjectUtils.isNotEmpty(nextActivityInstance)) {
// 遍历流出线
for (PvmTransition pvmTransition : pvmTransitions) {
// 获取流出线的目的地
ActivityImpl pvmActivityImpl = (ActivityImpl)pvmTransition.getDestination();
// 判断是否为下一节点
if (pvmActivityImpl.getId().equals(nextActivityInstance.getActivityId())) {
highFlows.add(pvmTransition.getId());
break;
}
}
}
}
}
return highFlows;
}
上面代码中我用的是activiti默认提供和流程图生成器。默认情况下流程图生成器的实现类为DefaultProcessDiagramGenerator
,对应的画布实现为DefaultProcessDiagramCanvas
。通过查看画布源码可以看到他默认的字体都为Arial
,而这种字体是不支持中文的所以会乱码,所以我们生成图片的时候必须传递字体名称。直接画出来的效果为:
可以看到虽然可以正常的标注出流转过的节点以及流转线,但是却无法修改标注的颜色,以及通过不同颜色标注当前运行节点。这些是写死在DefaultProcessDiagramCanvas
中的。所以如果我们想要修改标注的颜色我们得自定义ProcessDiagramCanvas以及对应的ProcessDiagramGenerator。至于如何自定义ProcessDiagramCanvas和对应的ProcessDiagramGenerator,这个其实只需要参考DefaultProcessDiagramGenerator以及DefaultProcessDiagramCanvas就可以了,在原有实现的基础上修改高亮节点和流转线的颜色,以及额外添加一个颜色来标注当前运行节点。然后再将自定义的ProcessDiagramGenerator设置到ProcessEngineConfiguration中,具体代码有点多就不贴了。大家可以根据自己的需求去改造。
还有就是除了Arial
字体不支持中文会导致流程图乱码外,如果操作系统中不存在指定的字体库也是会导致流程图中文乱码的。例如在某些centos中就没有宋体的字体库,并且jdk8的版本也缺少中文字体。此时就会出现在windows中正常显示在linux中图片就会乱码。处理方法是将windows中的字体(默认位置为C:\Windows\Fonts
)复制到jdk的字体库中,位置为$JAVA_HOME/jre/lib/fonts/fallback/
(这个fallback目录如果不存在则先创建这个目录)
至此独立完成系统开发的专栏到这里就结束了,而MyAdmin项目源码我也把他放到了github以及gitee上,并且还提供了演示地址,不过代码目前是部分开源并没有完全开源,如有需要可以持续关注,有问题欢迎评论~