Springboot 接入 Activiti7

   最近新项目要用工作流,查了几天资料,主要集中在 Activiti7、Flowable、Camunda 三个,同是 jbpm 框架发展而来,各有优劣。最终选择了 Activiti7,原因无他,仅是手头参与的其他项目用这个,方便尽快上手,下个项目应该会试试另外两个。

   本次项目采用 RuoYi-Vue-v3.8 开发,使用的 Springboot 版本为 v2.5.8。记录一下接入使用过程及踩坑信息,便于自己以后查看,如果对其他人有一些帮助也算意外之喜吧。以下内容主要针对于本次项目,可能一些说明不准确或解决方式不完善的地方,能力有限,只能留有遗憾了。


一、项目引入 Activiti 依赖

  1. 在 pom 文件中添加 Activiti 相关依赖:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    <dependency>
    <groupId>org.activiti</groupId>
    <artifactId>activiti-spring-boot-starter</artifactId>
    <version>7.1.0.M4</version>
    <exclusions>
    <exclusion>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis</artifactId>
    </exclusion>
    </exclusions>
    </dependency>
    <dependency>
    <groupId>org.activiti.dependencies</groupId>
    <artifactId>activiti-dependencies</artifactId>
    <version>7.1.0.M4</version>
    <type>pom</type>
    </dependency>
    <!-- Activiti 生成流程图 -->
    <dependency>
    <groupId>org.activiti</groupId>
    <artifactId>activiti-image-generator</artifactId>
    <version>7.1.0.M4</version>
    </dependency>
  2. 项目使用 Mysql 数据库,已引入驱动,因此这里不再添加。Activiti 默认使用 H2 数据库,如项目未使用数据库,应引入数据库驱动。

二、 添加 Activiti 相关配置

  1. 修改 application.yml 配置文件,添加内容如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    spring:
    ...

    # 工作流
    activiti:
    deployment-mode: never-fail # 关闭 SpringAutoDeployment
    check-process-definitions: false #自动部署验证设置:true- 开启(默认)、false- 关闭
    database-schema-update: true #true 表示对数据库中所有表进行更新操作。如果表不存在,则自动创建。
    history-level: full #full 表示全部记录历史,方便绘制流程图
    db-history-used: true #true 表示使用历史表
    main:
    allow-bean-definition-overriding: true #不同配置文件中存在 id 或者 name 相同的 bean 定义,后面加载的 bean 定义会覆盖前面的 bean 定义

三、 启动项目,生成数据表

  1. 踩坑 1:不明白别人的项目为什么要加 “allow-bean-definition-overriding“ 属性,所以没加。结果很快就明白为什么要加了,项目启动出现如下错误(错误信息还给出了解决提醒,我真搞笑):

    1
    2
    3
    4
    5
    Description:
    The bean 'methodSecurityInterceptor', defined in class path resource [org/activiti/spring/boot/MethodSecurityConfig.class], could not be registered. A bean with that name has already been defined in class path resource [org/springframework/security/config/annotation/method/configuration/GlobalMethodSecurityConfiguration.class] and overriding is disabled.

    Action:
    Consider renaming one of the beans or enabling overriding by setting spring.main.allow-bean-definition-overriding=true
  2. 踩坑 2:在上述步骤之后,重新运行项目,然后又出现了如下错误:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    16:46:33.758 [restartedMain] INFO  o.a.e.i.c.ProcessEngineConfigurationImpl - [configuratorsAfterInit,1571] - Executing configure() of class org.activiti.spring.process.conf.ProcessExtensionsConfiguratorAutoConfiguration$$EnhancerBySpringCGLIB$$3d85fc52 (priority:10000)
    16:46:33.893 [restartedMain] ERROR o.a.e.i.i.CommandContext - [logException,149] - Error while closing command context
    org.apache.ibatis.exceptions.PersistenceException:
    ### Error querying database. Cause: java.sql.SQLSyntaxErrorException: Table 'zhhq.act_ge_property' doesn't exist
    ### The error may exist in org/activiti/db/mapping/entity/Property.xml
    ### The error may involve org.activiti.engine.impl.persistence.entity.PropertyEntityImpl.selectProperty-Inline
    ### The error occurred while setting parameters
    ### SQL: select * from ACT_GE_PROPERTY where NAME_ = ?
    ### Cause: java.sql.SQLSyntaxErrorException: Table 'zhhq.act_ge_property' doesn't exist
    • 解决方式:修改 mysql 连接字符串,添加 &nullCatalogMeansCurrent=true
    1
    2
    3
    4
    // 原来的 url
    url: jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
    // 修改后 url,添加了 &nullCatalogMeansCurrent=true
    url: jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&nullCatalogMeansCurrent=true
    • 网友给出的说明:因为 mysql 使用 schema 标识库名而不是 catalog,因此 mysql 会扫描所有的库来找表,如果其他库中有相同名称的表,activiti 就以为找到了,本质上这个表在当前数据库中并不存在。设置 nullCatalogMeansCurrent=true,表示 mysql 默认当前数据库操作,在 mysql-connector-java 5.xxx 该参数默认为 true,在 6.xxx 以上默认为 false,因此需要设置 nullCatalogMeansCurrent=true。
  3. 经过以上修改步骤,再次运行项目。好吧,这次我成功了,项目顺利启动,且数据库增加了 25 个表。

  4. 留个坑,其实这版 Activiti 自动生成的表字段不全,下面有补充说明。

四、 Activiti7 整合 Spring Security

  1. Activiti7 没有身份管理的表,其能力依赖和 Spring Security 整合,新 Api 包括 TaskRuntime 和 ProcessRuntime 都会强制使用 Security 验证用户权限。
  2. 查看 ProcessRuntime 类的源码可发现,需要“ACTIVITI_USER”角色权限。因此,处理思路是,在登录验证时,给登录用户增加对应权限。
  3. 在 RuoYi 里,登录验证的实现如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    public String login(String username, String password, String code, String uuid)
    {
    boolean captchaOnOff = configService.selectCaptchaOnOff();
    // 验证码开关
    if (captchaOnOff)
    {
    validateCaptcha(username, code, uuid);
    }
    // 用户验证
    Authentication authentication = null;
    try
    {
    // 该方法会去调用 UserDetailsServiceImpl.loadUserByUsername
    authentication = authenticationManager
    .authenticate(new UsernamePasswordAuthenticationToken(username, password));
    }
    catch (Exception e)
    {
    if (e instanceof BadCredentialsException)
    {
    AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
    throw new UserPasswordNotMatchException();
    }
    else
    {
    AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
    throw new ServiceException(e.getMessage());
    }
    }
    AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
    LoginUser loginUser = (LoginUser) authentication.getPrincipal();
    recordLoginInfo(loginUser.getUserId());
    // 生成 token
    return tokenService.createToken(loginUser);
    }
  4. 根据上述代码中,需要在 UserDetailsServiceImpl.loadUserByUsername 中加入对应逻辑:
    • 原代码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    @Service
    public class UserDetailsServiceImpl implements UserDetailsService
    {
    private static final Logger log = LoggerFactory.getLogger(UserDetailsServiceImpl.class);

    @Autowired
    private ISysUserService userService;

    @Autowired
    private SysPermissionService permissionService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
    {
    SysUser user = userService.selectUserByUserName(username);
    if (StringUtils.isNull(user))
    {
    log.info(" 登录用户:{} 不存在.", username);
    throw new ServiceException(" 登录用户:" + username + " 不存在 ");
    }
    else if (UserStatus.DELETED.getCode().equals(user.getDelFlag()))
    {
    log.info(" 登录用户:{} 已被删除.", username);
    throw new ServiceException(" 对不起,您的账号:" + username + " 已被删除 ");
    }
    else if (UserStatus.DISABLE.getCode().equals(user.getStatus()))
    {
    log.info(" 登录用户:{} 已被停用.", username);
    throw new ServiceException(" 对不起,您的账号:" + username + " 已停用 ");
    }

    return createLoginUser(user);
    }

    public UserDetails createLoginUser(SysUser user)
    {
    return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
    }
    }
    • 修改后的代码:
    1
    2
    3
    4
    5
    6
    7
    8
    // 有变更的方法,在原来的用户信息类中加入了需要的权限信息
    public UserDetails createLoginUser(SysUser user) {
    Set<String> postCode = sysPostService.selectPostCodeByUserId(user.getUserId());
    postCode = postCode.parallelStream().map(s -> "GROUP_" + s).collect(Collectors.toSet());
    postCode.add("ROLE_ACTIVITI_USER");
    List<SimpleGrantedAuthority> collect = postCode.stream().map(s -> new SimpleGrantedAuthority(s)).collect(Collectors.toList());
    return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user), collect);
    }
  5. 修改相应的用户信息类 LoginUser
    • 原代码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    public class LoginUser implements UserDetails
    {
    private static final long serialVersionUID = 1L;

    /**
    * 用户 ID
    */
    private Long userId;

    /**
    * 部门 ID
    */
    private Long deptId;

    /**
    * 用户唯一标识
    */
    private String token;

    /**
    * 登录时间
    */
    private Long loginTime;

    /**
    * 过期时间
    */
    private Long expireTime;

    /**
    * 登录 IP 地址
    */
    private String ipaddr;

    /**
    * 登录地点
    */
    private String loginLocation;

    /**
    * 浏览器类型
    */
    private String browser;

    /**
    * 操作系统
    */
    private String os;

    /**
    * 权限列表
    */
    private Set<String> permissions;

    /**
    * 用户信息
    */
    private SysUser user;

    // 省去 set/get 方法

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities()
    {
    return null;
    }
    }
    • 修改后的代码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public class LoginUser implements UserDetails
    {
    // 去掉不变的代码

    // +++++
    private List<SimpleGrantedAuthority> authorities;

    // +++++
    public void setAuthorities(List<SimpleGrantedAuthority> authorities) {
    this.authorities = authorities;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities()
    {
    return authorities; // 变更
    }
    }
    • 主要是实体类中,增加了权限属性,且修改了对应的 get 方法。
  6. 现在,Activiti 就可以识别到登录用户,且有了新 Api 对应的权限。
  7. 总结一下,对于 Ruoyi 框架原来的代码实现而言,修改了两处,LoginUser 类和 UserDetailsServiceImpl 类,当然,依赖方法增加了相应的查询岗位(postCode)的方法。
  8. 整合工作就是这样,实在是不清不楚,马马虎虎。鉴于现在的时间和精力,只能紧着项目的实际问题来,希望以后有机会系统地学习一下 Activiti 吧。

五、 工作流简单测试

  1. 准备工作流(bpmn)文件,因为之后项目要接入 web 流程设计器,所以没在 IDE 上安装设计器插件,在旧项目里随便找了个 bpmn 文件测试用;
  2. 编写单元测试,先进行工作流部署测试
  • 编写单元测试代码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    @SpringBootTest()
    public class ActivitiTest {
    @Autowired
    private RepositoryService repositoryService;

    @Test
    public void deployTest() {
    Deployment deployment = repositoryService.createDeployment()
    .addClasspathResource("leave.bpmn")
    .name(" 测试流程 ")
    .deploy();
    System.out.println(" 部署 ID:" + deployment.getId());
    }
    }
  • 运行后,出现缺失表字段错误:
    1
    2
    ### SQL: insert into ACT_RE_DEPLOYMENT(ID_, NAME_, CATEGORY_, KEY_, TENANT_ID_, DEPLOY_TIME_, ENGINE_VERSION_, VERSION_, PROJECT_RELEASE_VERSION_)     values(?, ?, ?, ?, ?, ?, ?, ?, ?)
    ### Cause: java.sql.SQLSyntaxErrorException: Unknown column 'VERSION_' in 'field list'
  • 好吧,Activiti7.1.0.M4 这版通过自动创建的表结构是少字段的,这 BUG 之前有网友提过,自己忘记这茬了,现在补坑吧:
    1
    2
    3
    -- 修复 Activiti7 的 M4 版本缺失字段 Bug
    alter table ACT_RE_DEPLOYMENT add column PROJECT_RELEASE_VERSION_ varchar(255) DEFAULT NULL;
    alter table ACT_RE_DEPLOYMENT add column VERSION_ varchar(255) DEFAULT NULL;
  • 再次运行,测试顺利通过,打印部署成功日志:
    1
    15:59:08.035 [main] INFO  o.a.e.i.b.d.BpmnDeployer - [dispatchProcessDefinitionEntityInitializedEvent,234] - Process deployed: {id: leave:1:f67ecd7e-a047-11ec-a9cd-d45d64273150, key: leave, name: 请假流程 - 普通表单 }
  1. 进行创建流程实例测试
  • 编写测试代码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Test
    public void startProcessTest() {
    ProcessInstance processInstance = processRuntime.start(ProcessPayloadBuilder
    .start()
    .withProcessDefinitionKey("test")
    .withName(" 请假测试 ")
    .build());
    System.out.println(" 实例 ID:" + processInstance.getId());
    }
  • 运行,如果出现以下错误:
    1
    org.springframework.security.authentication.AuthenticationCredentialsNotFoundException: An Authentication object was not found in the SecurityContext
  • 是因为没有整合 SpringSecurity ,上面整合内容里描述了新提供 Apl,比如 ProcessRuntime 必须有身份验证,看源码可知:
    1
    2
    @PreAuthorize("hasRole('ACTIVITI_USER')")
    public class ProcessRuntimeImpl implements ProcessRuntime {}
  • 两种解决思路:一种是整合 SpringSecurity;一种是试一下旧版 Api。那么试下旧版 Api 吧,重新修改代码:
    1
    2
    3
    4
    5
    6
    7
    @Test
    public void startProcessTest() {
    Map<String, Object> paramMap = new HashMap<>();
    paramMap.put("deptLeader", "test01");
    ProcessInstance pi = runtimeService.startProcessInstanceByKey("leave", " 测试请假 ", paramMap);
    System.out.println(" 实例 ID:" + pi.getId());
    }
  • 再次运行,顺利通过测试。
  1. 单元测试就算完了,证明 Activiti 顺利接入到了项目中,等前端流程设计器接入了,才能真正方便地用起来吧。

六、 数据表命名规则说明

  1. 简单记录一下 Activiti 表的命名规则,留个印象吧,想知道具体信息还是得对应到每个表;
  2. Activiti 的表都以 ”ACT_“ 开头。第二部分是表示表的用途的两个字母标识。用途也和服务的 API 对应。
  • act_hi_*:’hi’表示 history,此前缀的表包含历史数据,如历史 (结束) 流程实例,变量,任务等等。
  • act_ge_*:’ge’表示 general,此前缀的表为通用数据,用于不同场景中。
  • act_evt_*:’evt’表示 event,此前缀的表为事件日志。
  • act_procdef_*:’procdef’表示 processdefine,此前缀的表为记录流程定义信息。
  • act_re_*:’re’表示 repository,此前缀的表包含了流程定义和流程静态资源(图片,规则等等)。
  • act_ru_*:’ru’表示 runtime,此前缀的表是记录运行时的数据,包含流程实例,任务,变量,异步任务等运行中的数据。Activiti 只在流程实例执行过程中保存这些数据,在流程结束时就会删除这些记录。

后记

   很基础的一些东西,算是项目使用工作流的第一步吧。接下来,就是在前端项目中接入流程设计器了,然后是实际使用中一些处理和技巧,一步步来。