JavaEE从入门到起飞(九) ~Activiti 工作流

工作流

当一道流程逻辑需要用到多个表单的提交和多个角色的审核共同完成的时候,就可以使用工作流。

工作流一般使用的是第三方技术,也就是说别人帮你创建数据库表和service层、mapper层,你只需要注入工具接口即可使用。

原理:一切操作都是sql语句,第三方做好,你只要按照它们的规范,即可完成工作流。

Activiti

Activiti是一个开源的轻量级工作流引擎,2010年基于jBPM4实现首次开源。官网地址:https://www.activiti.org

Activiti可以将业务系统中复杂的业务流程抽取出来,使用专门的建模语言BPMN进行定义

BPMN是一种用于图形化表示和描述业务流程的标准化标记语言,目前主流的版本是2.0

事件event

事件是业务流程模型中的重要元素之一,事件可以发生在流程的任何阶段,并且可以影响流程的执行。分为以下几类:

  • 开始事件(Start Event):表示流程的起点,通常用于触发流程的启动

  • 结束事件(End Event):表示流程的结束点,通常用于触发流程的结束

活动activiti

任务(Task)是最基本的活动类型,表示一个简单的、可执行的工作单元。任务通常由人工执行,并且需要指定执行者

用户任务是由人工执行的,需要指定执行的用户或角色,并提供相应的输入

手动任务是由系统自动执行的,不需要指定执行的用户或角色

流向flow

流是连接两个流程节点的连线。常见的流向包含以下几种

请假工作流案例

在学校中,如果有事需要请假,一般需要向得到老师批准才可以完成请假。

  • 学生:请假的学生需要先填写请假单,填写的字段有:请假人、请假天数、开始请假时间、请假事由。
  • 老师:审批员工的请假单,如果不同意,则需要说明不同意的理由。
    1. 同意,进入下个任务
    2. 不同意,直接结束该流程实例
    3. 驳回,觉得信息填写不完善,需要改善

实现步骤

1、搭建环境:使用SpringBoot集成Activiti,把初始化环境做出来

2、绘制流程:按照BPMN的规范,使用流程定义工具,用流程符号把整个流程描述出来

3、部署流程:把画好的流程定义文件,加载到数据库中,生成表的数据

4、操作流程:使用java代码来操作数据库表中的内容

提示:数据库的依赖和配置也需要写,但我这里就不写了

添加依赖

xml 复制代码
        <!--安全框架 spring security -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <!--springboot与activiti7整合的starter-->
        <dependency>
            <groupId>org.activiti</groupId>
            <artifactId>activiti-spring-boot-starter</artifactId>
            <version>7.10.0</version>
        </dependency>
    <!--如果activiti依赖下载不了,可以配置如下地址进行下载-->
    <repositories>
        <repository>
            <id>activiti-releases</id>
            <url>https://artifacts.alfresco.com/nexus/content/repositories/activiti-releases</url>
        </repository>
    </repositories>

添加配置文件

yaml 复制代码
  activiti:
    # 记录所有历史数据
    history-level: full
    # 是否需要使用历史表,默认false不使用,而配置true是使用历史表
    db-history-used: true
    # 关闭流程自动部署,需要手动部署流程
    check-process-definitions: false
    # 如果部署过程遇到任何问题,服务不会失败
    deployment-mode: never-fail

注意:需要在自己的MySQL中创建一个新的数据库:activiti-db

我们也可以直接查询数据库,数据库中创建了25张表,目前说明Springboot已成功集成了activiti7

表结构

Activiti 的表都以ACT_ 开头。 第二部分是表示表的用途的两个字母标识。 用途也和服务的 API 对应。

  • ACT_GE :GE 表示 general, 通用数据
  • ACT_RE :RE表示 repository,这个前缀的表包含了流程定义信息
  • ACT_RU:RU表示 runtime,这些运行时的表,包含流程实例,任务,变量,异步任务等运行中的数据
  • ACT_HI:HI表示 history, 这些表包含历史数据,比如历史流程实例, 变量,任务等等
表分类 表名 解释
一般数据
[ACT_GE_BYTEARRAY] 通用的流程定义和流程资源
[ACT_GE_PROPERTY] 系统相关属性
流程历史记录
[ACT_HI_ACTINST] 历史的活动实例
[ACT_HI_ATTACHMENT] 历史的流程附件
[ACT_HI_COMMENT] 历史的说明性信息
[ACT_HI_DETAIL] 历史的流程运行中的细节信息
[ACT_HI_IDENTITYLINK] 历史的流程运行过程中用户关系
[ACT_HI_PROCINST] 历史的流程实例
[ACT_HI_TASKINST] 历史的任务实例
[ACT_HI_VARINST] 历史的流程运行中的变量信息
流程定义表
[ACT_RE_DEPLOYMENT] 部署单元信息
[ACT_RE_MODEL] 模型信息
[ACT_RE_PROCDEF] 已部署的流程定义
运行实例表
[ACT_RU_EVENT_SUBSCR] 运行时事件
[ACT_RU_EXECUTION] 运行时流程执行实例
[ACT_RU_IDENTITYLINK] 运行时用户关系信息,存储任务节点与参与者的相关信息
[ACT_RU_JOB] 运行时作业
[ACT_RU_TASK] 运行时任务
[ACT_RU_VARIABLE] 运行时变量表

常见api(重点)

在activiti7框架内部,已经对25张表的数据操作,已经封装了对应的service(相当于自己开发的25个mapper和25个service

  • RepositoryService:用于部署流程定义,可以添加、删除、查询和管理流程定义,相当与类,所有人请假都需要按照这个模板执行。

    1. act_re_deployment:流程部署,记录每次工作流的部署信息,包括部署名称和时间等
    2. act_ge_bytearray:流程资源表,系统会将流程定义的两个文件保存到这张表中
    3. act_re_procdef:流程定义表,记录每个流程定义的信息,包括流程定义的名称、版本号、部署ID等
  • RuntimeService:用于启动、查询和管理正在运行的流程实例,相当于对象,启动之后每个人请假都要按照这个模板走。

    1. act_ru_execution表(运行时流程实例表):插入一条新的流程实例的执行信息,包括流程实例ID、流程定义ID等
    2. act_ru_task(运行时任务表):插入一条新的任务记录,表示流程实例的启动任务
    3. act_ru_identitylink (运行时身份关联表):插入一条新的身份关联记录,表示流程实例的启动任务与相关用户的关系
  • TaskService:用于查询和管理当前用户可以操作的任务,以及完成任务,相当于栈中的方法

    • 流程启动之后,不同的人登录的时候,需要查询是否要自己要执行的任务,如果有可以对流程进行操作,一般是在一个专门的事务待办页面
    • act_ru_execution表中发现,新增了【经理审批】待执行的节点,而完成的【填写请假单】节点被删除了
    • act_ru_task表中,待办任务也变成了李四【经理审批】, 一个任务结束后,该任务的记录就会在ru_task表中删除,会在运行时任务表添加下一条需要执行的任务。
    • act_ru_variable表中会存储代码中传入的表单数据
  • HistoryService:用于查询历史数据,例如已完成的流程实例、已删除的流程实例、用户任务等,历史统计,主要是用于查询,比如说统计某个人这个月请了多少假。相当于日志

    1. act_hi_procinst:历史的流程实例
    2. act_hi_actinst:历史的活动实例
    3. **act_hi_taskinst:历史的任务实例 ** 任务执行完毕后,endTime结束时间会有值。
    4. act_hi_identitylink:历史流程用户关系
    5. act_hi_varinst:历史流程运行中的变量信息

开始定义一个类(流程模板),根据类创建对象(流程实例)按照栈的顺序(流程模板定义的),执行类中的每个方法(任务),在每次操作后都会记录日志(历史数据)。

因为我们现在使用的是springboot集成了activiti,这些api也被spring容器进行了管理,需要用到以上api的时候,直接注入即可

绘制流程

我们打开bpmn-js,可以直接在页面中画图,步骤如下:

① 定义流程编号(ID)和名称

② 新增一个用户任务,并指定代理人为:张三

③ 新增一个用户任务,并指定代理人为:李四,同时需要结束这个流程,最后需要有一个结束事件

④ 流程图画好之后,在页面的左下角有一个导出,就可以直接导出为bpmn文件(xml文件)

⑤ 把生成后的bpmn文件改名拷贝到idea中备用,存储位置:resource/bpmn/qingjia.bpmn

​ 因为保存的文件都是xml文件,我们为了方便查看这些流程,也可以截个图一起放入bpmn目录下

案例分析

在指派用户任务的执行人时,使用的都是直接指派给固定账号,这样流程设计审批的灵活性就很差

因此,Activiti提供了各种不同的分配方式,这章我们就来详细研究下其它任务分配方式,主要有:表达式分配、监听器分配

表达式分配

值表达式就是使用UEL表达式(一种占位符)来替换具体的分配人,在使用的时候只需要对表达式中的变量进行赋值即可

${user.assignee} 指定对象的属性值为代理人

${student} 指定变量值为代理人

绘制流程

重新绘制前面的流程,但是在代理人的位置不再直接写死为张三、李四,而是使用 a s s i n g e e 0 、 {assingee0}、 assingee0、{assingee1}来代替

代码实现

java 复制代码
/**
 * @author windStop
 * @version 1.0
 * @description 测试:含有表达式的代理人
 * @date 2024年08月21日17:37:23
 */
@SuppressWarnings("ALL")
@SpringBootTest
@Slf4j
public class ActivitiTestPlus {

    @Autowired
    private RepositoryService repositoryService;
    @Autowired
    private RuntimeService runtimeService;
    @Autowired
    private TaskService taskService;

    @Test
    public void test(){
        //1. 创建模版,部署信息,流程定义
        Deployment deploy = repositoryService.createDeployment()
                .addClasspathResource("bpmn/qingjia2.bpmn")
                .addClasspathResource("bpmn/qingjia.png")
                .name("qingjia")
                .deploy();
        log.info("部署信息:{}", deploy);
        //2. 创建流程实例
        Map<String,Object> claim = new HashMap<>();
        claim.put("student","张三");
        claim.put("teacher","吴彦祖");
        ProcessInstance processInstance = runtimeService.
                startProcessInstanceByKey("qingjia",claim);
        log.info("流程实例信息:{}",processInstance);
        //3. 查询自己的待办任务
        List<Task> list = taskService.createTaskQuery()
                .processDefinitionKey("qingjia").taskAssignee("张三").list();
        log.info("自己待办任务集合:{}",list);
        //4. 任务一:执行请假
        for (Task task : list) {
            log.info("正在执行的任务id为:{}", task.getId());
            log.info("正在执行的任务名称为:{}", task.getName());
            //请假原因,任务一 需要填写的额外内容,一般是前端表单传来的数据(用户自己输入)
            Map<String,Object> leaveContent = new HashMap<>();
            leaveContent.put("username","蔡申友");//请假人
            leaveContent.put("reason","出差工作..");//请假原因
            leaveContent.put("startDate","2024年8月21日18:10:53");//请假开始时间
            leaveContent.put("days",20);//请假天数
            //完成任务
            taskService.complete(task.getId(),leaveContent);
            log.info("任务完成....");
        }
    }
	//老师同意
    @Test
    public void test2(){
        //5. 任务二:老师审批
        //5.1 查询自己的任务待办集
        List<Task> list = taskService.createTaskQuery().processDefinitionKey("qingjia")
                .taskAssignee("吴彦祖").list();
        log.info("老师的任务集为:{}",list);
        //5.2 老师执行任务
        for (Task task : list) {
            log.info("正在执行的任务id为:{}", task.getId());
            log.info("正在执行的任务名称为:{}", task.getName());
            //组装业务数据,前端传递的
            Map<String,Object> claim = new HashMap<>();
            claim.put("remark","同意请假,但要按时返校");
            taskService.complete(task.getId(),claim);
            log.info("任务二执行完毕...");
        }
    }
    //老师不同意
    @Test
    public void test3(){
        //1. 查询老师(当前用户),要处理的任务集,在专门的页面展示
         List<Task> tasks = taskService.createTaskQuery()
                .processDefinitionKey("qingjia").taskAssignee("吴彦祖").list();
        //2. 执行任务 -> 拒绝任务
        for (Task task : tasks) {
            log.info("正在执行的任务id为:{}", task.getId());
            log.info("正在执行的任务名称为:{}", task.getName());
            //组装业务数据,前端传递的
            Map<String,Object> claim = new HashMap<>();
            claim.put("approvalStatus", "不同意");
            claim.put("approvalNode", "时间太久,不同意");
            //记录流程变量
            runtimeService.setVariables(task.getProcessInstanceId(),claim);
            //添加流程变量,删除流程实例,表示任务被拒绝
            runtimeService.deleteProcessInstance(task.getProcessInstanceId(), "时间太久,不同意");
            log.info("任务二, 拒绝执行完毕...");
        }
    }
}

注意:

​ 老师不同意则流程会终止执行。,如果老师审批不同意,那么主任就不用审批了,整个流程就应该直接结束。结束调用的方法和同意不同,并且变量需要存到流程变量中。

因此不同意,则应该是终止流程而不是完成节点,在删除流程时,同时也把审批不同意及理由,作为流程变量存储到流程变量中。

候选人

在前面的流程定义中在任务结点的都是设置了一个负责人,但是在企业中,每个节点上都可能有多个负责人。

下面我们就需要使用候选人或者候选人组做为身份标识替换掉前面的单个参与者来完成任务。需要将候选人提权成执行人才能执行任务,该任务只能让一个人处理。

一个审批节点可能有多个人同时具有审批的权限,这时我们就可以通过候选人来处理。

注意:候选人默认可以在身份关联表中(ru_identtitylink)查看

绘制流程

这次绘制流程时,对于经理审批,不再设置代理人,而是设置候选人,多个候选人是,分隔

流程启动后任务,在act_ru_task表中的审批人是空的,但是act_ru_identitylink表保存了候选人信息.

拾取任务(提权)

提升候选人的权限为执行人。

java 复制代码
  	// 测试候选人
    @Test
    public void test(){
        //1. 创建流程模版
        Deployment deploy = repositoryService.createDeployment()
                .addClasspathResource("bpmn/qingjia3.bpmn")
                .addClasspathResource("bpmn/qingjia.png")
                .name("qingjia").deploy();
        log.info("模版创建成功:{}",deploy);
        //2. 启动流程(创建流程实例)
        //2.1 填充执行人变量
        Map<String,Object> variables = new HashMap<>();
        variables.put("a1","蔡申友");//填充发起人
        variables.put("c1","王老师");//候选老师1
        variables.put("c2","陈老师");//候选老师2
        variables.put("c3","张老师");//候选老师3
        //2.2 启动流程
        ProcessInstance processInstance = runtimeService.startProcessInstanceByKey("qingjia",variables);
        log.info("启动流程成功:{}",processInstance);
        //3. 发起人,查询自己的任务列表
        List<Task> list = taskService.createTaskQuery().list();
        //4. 发起流程 执行任务一
        for (Task task : list) {
            log.info("正在执行的任务id为:{}", task.getId());
            log.info("正在执行的任务名称为:{}", task.getName());
            //请假原因,任务一 需要填写的额外内容,一般是前端表单传来的数据(用户自己输入)
            Map<String,Object> leaveContent = new HashMap<>();
            leaveContent.put("username","蔡申友");//请假人
            leaveContent.put("reason","出差工作..");//请假原因
            leaveContent.put("startDate","2024年8月21日18:10:53");//请假开始时间
            leaveContent.put("days",20);//请假天数
            //完成任务
            taskService.complete(task.getId(),leaveContent);
            log.info("任务完成....");
        }
    }
    // 候选人之一,王老师执行任务
    @Test
    public void test2(){
        //模拟登录,防止UsernameNotFoundException错误
        securityUtil.logInAs("王老师");
        //查询候选人或者执行人 和指定人相同的任务
        List<Task> list = taskService.createTaskQuery().taskCandidateOrAssigned("王老师").list();
        //全部任务拾取: 将指定用户从候选人提升为审批人
        for (Task task : list) {
            taskService.claim(task.getId(),"王老师");
            //执行任务
            Map<String,Object> map = new HashMap<>();
            map.put("leavenContend","同意请假");
            taskService.complete(task.getId(),map);
        }
    }

归还任务(消权)/ 交接任务(换人)

归还任务:将执行人列设置为null。

交接任务:将执行人的值设置为别人的名称 / id。

java 复制代码
    //归还:拾取的用户不审批了。就放弃审批人的操作
    //交接:拾取任务后如果不想操作那么可以归还任务,也可以将任务交接给其他用户
    @Test
    public void test3() {
        //模拟登录,防止UsernameNotFoundException错误
        new SecurityUtil().logInAs("王老师");

        List<Task> list = taskService.createTaskQuery()
                .taskCandidateOrAssigned("王老师") // 根据 审批人或者候选人 来查询待办任务
                .list();
        for (Task task : list) {
            // 归还操作的本质其实就是设置审批人为空
            // taskService.unclaim(task.getId());

            //任务交接
            taskService.setAssignee(task.getId(), "陈老师");
        }
    }

候选人组(防止新增该权限的人)

按照组名查任务,该组名一般都是权限 按照组查找任务,查找到了然后进行提升权限。

拾取任务

拾取任务的目的是将候选人提升为审批人

java 复制代码
    //查询经理部门的任务
    @Test
    public void test4() {
        //模拟登录,防止UsernameNotFoundException错误
        //new SecurityUtil().logInAs("孙经理");

        //根据候选人查询任务
        List<Task> list = taskService.createTaskQuery()
                .taskCandidateGroup("manageGroup") // 查询经理部门的任务
                .list();

        //任务拾取: 将指定用户从候选人提升为审批人
        for (Task task : list) {
            taskService.claim(task.getId(), "孙经理");
        }
    }

其他操作都和上述相似

查询历史任务

历史任务的查询需要使用HistoryService完成,主要就是根据各种条件从前面讲过的一堆历史表中查询数据

java 复制代码
    @Autowired
    private HistoryService historyService;

    @Test
    public void test9(){
        HistoricTaskInstanceQuery instanceQuery = historyService.createHistoricTaskInstanceQuery()
                .includeProcessVariables()//包含流程变量(配合下面使用)
                .orderByHistoricTaskInstanceEndTime().desc()//按历史任务实例结束时间排序
                .finished()//只查询已完成的任务
                .taskAssignee("张三");//根据执行人查询

        //自定义流程变量  条件查询
        //instanceQuery.processVariableValueGreaterThan("days", 1);

        //查询历史流程
        List<HistoricTaskInstance> list = instanceQuery.list();
        for (HistoricTaskInstance history : list) {
            System.out.println("Id: " + history.getId());
            System.out.println("ProcessInstanceId: " + history.getProcessInstanceId());
            System.out.println("StartTime: " + history.getStartTime());
            System.out.println("Name: " + history.getName());
            Map<String, Object> processVariables = history.getProcessVariables();
            System.out.println(processVariables.get("days").toString());
            System.out.println(processVariables.get("reason").toString());
            System.out.println("=======================================");
        }
    }

查询条件API说明

方法名称
processInstanceBusinessKey(String processInstanceBusinessKey) 根据流程实例业务Key查询
taskId(String taskId) 根据任务ID查询
taskAssignee(String taskAssignee) | taskAssigneeLike(String taskAssignee) 根据执行人查询
finished() 已完成的(申请过、同意过)
unfinished() 未完成任务
orderByHistoricTaskInstanceEndTime().desc() 按照执行时间排序
taskName(String var1) | taskNameLike(String var1) 根据节点任务名称查询
list() 返回分页数据
includeProcessVariables() 包含流程变量(配合下面使用)
processVariableValueEquals(String variableName, Object variableValue) 两个值相等
processVariableValueNotEquals(String variableName, Object variableValue) 两个值不相等
processVariableValueGreaterThan(String name, Object value) 大于
processVariableValueLessThan(String name, Object value) 小于

流程网关

网关用于控制流程的执行流向,它的作用是在流程执行时进行决策,决定流程的下一个执行步骤。Activiti7中,有以下几种类型的网关:

  1. 排他网关:用于在流程中进行条件判断,根据不同的条件选择不同的分支路径,只有满足条件的分支会被执行,其他分支会被忽略,排除其他路径,只走一条。一条同意即可

  2. 并行网关:用于将流程分成多个并行的分支,这些分支可以同时执行,当所有分支都执行完毕后,流程会继续向下执行,全部路径都走,都同意才可,有一个拒绝直接结束流程实例。

  3. 包容网关:用于根据多个条件的组合情况选择分支路径,可以选择满足任意一个条件的分支执行,或者选择满足所有条件的分支执行,带条件的只走一条,没条件的必走

排他网关

排他网关用于对流程中分支进行决策,当执行到达这个网关时,会按照所有出口顺序流定义的顺序对它们进行计算,选择第一个条件为true的顺序流继续流程。

注意:这种设计有一点小bug,当有多个条件都符合会选取到第一个为true的路径进行走。

并行网关

并行网关用于将流程分成多个并行的分支,这些分支可以同时执行,当所有分支都执行完毕后,流程会继续向下执行

  • fork分支:并行后的所有外出顺序流,为每个顺序流都创建一个并发分支
  • join汇聚: 所有到达并行网关,在此等待的进入分支,直到所有进入顺序流的分支都到达以后, 流程就会通过网关

包容网关

包含网关用于根据多个条件的组合情况选择分支路径,可以选择满足任意一个条件的分支执行(有条件必须执行,无条件的必须执行)

业务id对接

目前我们已经基本完成了activiti的学习,我们发现目前的工作流其实是脱离我们的实际业务存在的

如果想将activiti与实际业务联系起来,需要用到它提供的一个字段:buinessId,这个字段用来记录业务表的主键

我们可以在启动流程的时候设置

java 复制代码
ProcessInstance startProcessInstanceByKey(String processDefinitionKey, String businessKey, Map<String, Object> variables);

你可以直接通过 businessKey 查询到与特定请假申请相关的流程实例。

java 复制代码
// 查询请假申请的流程实例
List<HistoricProcessInstance> processInstances = historyService.createHistoricProcessInstanceQuery()
    .processInstanceBusinessKey(leaveApplication.getId().toString())
    .list();

for (HistoricProcessInstance instance : processInstances) {
    System.out.println("Process Instance ID: " + instance.getId());
    System.out.println("Start Time: " + instance.getStartTime());
    System.out.println("End Time: " + instance.getEndTime());
    System.out.println("Status: " + (instance.getEndTime() == null ? "Running" : "Completed"));
}

通过 businessKey,你可以查询到与特定请假申请相关的所有流程实例,这有助于审计和监控流程实例的状态。

java 复制代码
// 查询请假申请的流程实例
HistoricProcessInstance processInstance = historyService.createHistoricProcessInstanceQuery()
    .processInstanceBusinessKey(leaveApplication.getId().toString())
    .singleResult();

if (processInstance != null && processInstance.getEndTime() != null) {
    // 流程实例已完成
    LeaveStatus status = processInstance.getEndTime().isBefore(LocalDate.now()) ? LeaveStatus.APPROVED : LeaveStatus.REJECTED;
    leaveApplication.setStatus(status);
    leaveApplicationRepository.save(leaveApplication);
}

通过 businessKey,你可以轻松地找到与特定请假申请相关的流程实例,并根据流程实例的状态更新请假申请的状态

注意:与执行人查询的区别

查询目的不同:

使用执行人查询:主要关注当前分配给特定执行人的任务,适用于查询当前执行人需要处理的任务

使用 businessKey 查询:关注与特定业务实体相关的所有流程实例,适用于审计和监控流程实例的状态。

查询范围不同:

使用执行人查询:仅限于当前分配给执行人的任务,不涉及流程实例的全局状态。

使用 businessKey 查询:可以查询到与特定业务实体相关的所有流程实例,包括已完成的流程实例。

查询深度不同:

使用执行人查询:通常只涉及到当前的任务,可能需要额外步骤来获取流程实例的信息。

使用 businessKey 查询:可以直接获取到流程实例的完整信息,包括历史记录

相关推荐
Crazy程序猿202018 分钟前
jar包解压和重新打包
java·jar
老马啸西风19 分钟前
java 老矣,尚能饭否?
java
捂月23 分钟前
从零开始:使用 Spring Boot 开发图书管理系统
java·spring boot·后端
张彦峰ZYF23 分钟前
接口性能优化宝典:解决性能瓶颈的策略与实践
java·redis·分布式·后端·算法·性能优化·架构
浅念同学26 分钟前
JavaEE-线程安全专题
java·安全·java-ee
苹果酱056729 分钟前
4-SpringCloud整合服务间的调用即负载均衡
java·开发语言·spring boot·mysql·中间件
明月56634 分钟前
JDK下载
java·jdk8下载 ;
码蜂窝编程官方35 分钟前
【含开题报告+文档+PPT+源码】基于SSM的电影数据挖掘与分析可视化系统设计与实现
java·vue.js·人工智能·后端·spring·数据挖掘·maven
G丶AEOM36 分钟前
JVM标量替换
java·jvm
以卿a38 分钟前
C++ 日期计算器的实现(运算符重载)
java·开发语言·c++