-->

xxl-job实现java分布式定时任务

1 前言

1.1 业务场景

业务数据同步(线上数据同步到线下,新平台老平台数据的同步),消息通知,业务数据的补偿。

1.2 什么是定时任务

定时任务是指基于给定的时间点,给定的时间间隔或者给定执行次数自动的执行程序。
任务调度是系统的重要组成部分。
任务调度直接影响着系统的实时性。
任务调度涉及到多线程并发、运行时间规则定制及解析、线程池的维护等诸多方面的工作。

1.2.1 常见的任务框架

常见的分布式任务调度框架有:xxl-job【美团】、Elastic-job【当当】、saturn【唯品会】、lts【阿里】、TBSchedule、cronsun、Quartz等。

1.2.2 一般定时任务的不足

  • 不支持集群
  • 不支持任务重试,即任务出错误无解决办法
  • 不支持动态调用规则
  • 无报警机制
  • 不支持生命周期的统一管理
  • 任务数据难以统计

2 XXL-JOB定时任务

2.1 前言

image

package com.fanxl.xxljob.task;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class TaskDemo {
    /**
     * 从数据库中查询数据,插入到另外一个数据
     * 程序分两部分:调度模块和任务程序块的代码耦合在一起,
     * 1、如果想改变调度规则,就必须先停止掉服务,才能改变cron表达式里的调用规则
     * 2、将taskdemo部署多台时,多个线程调用同样的方法,这样的话程序不支持集群部署,可用性无法保障
     *
     * 两部分:
     * 1、Scheduled:本质上是由springboot内置的线程池,1个长度的线程池按照定义的时间规则调用程序
     * spring.task.scheduling.pool.size=1
     * 2、开发者自己定义的业务程序
     */
    @Scheduled(cron = "0/5 * * * * *")
    public void syncData(){
        //查询出的数据
        int size = 1000;
        for (int i = 0; i < size; i++) {
            System.out.println("向数据库中插入数据"+i);
        }

    }
}
package com.fanxl.xxljob;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
public class XxljobApplication {

    public static void main(String[] args) {
        SpringApplication.run(XxljobApplication.class, args);
    }
}

2.2 设计思想

    xxl-job是一个轻量级的分布式任务调度框架,其核心设计目标是开发迅速、学习简单、轻量级、易扩展。

image

  1. 将调度行为抽象形成“调度中心”平台,平台本身并不承担业务逻辑,只负责发起调度请求。
  2. 将任务抽象成分散的JobHandler,交由执行器统一管理,执行器负责接收调度请求并执行对应的JobHandler中的业务。
  3. “调度”和“任务”互相解耦,提高系统整体的稳定性和扩展性。
  4. 将程序分为调度中心【A】和执行器【B】两部分。执行器指当前应用程序,应用程序中包含N个具体的任务。相当于【A】调用【B】里面的一个方法。
  5. 调用机制:1) HTTP请求 2) RPC远程调用
    无论那种调用方式,首先需要知道执行器的地址ip,端口。所以在执行器中起了一个注册线程【后台线程】。注册线程会向调度中心发送请求【注册服务】。调度中心记下后,方便后期调用执行器。
    数据中心:用于持久化的存储,避免下次启动后数据丢失。
  6. 调度中心内置调度器,用来调用执行器,执行器自研RPC进行远程调用,执行器会注册到调度中心,调度中心基于地址和端口进行RPC的远程调用。双向。
  7. 调度中心调用执行器中的某个具体任务之后,执行器接收到服务之后,进行调度,执行某个具体的方法【JobHandler】,而后将执行结果传给调度中心。
  8. 调用结果执行完后执行回调服务,回调服务将结果记录,形成调度日志。
    调度日志:

2.3 核心组件

  1. 调度模块(调度中心)
  • 负责管理调度信息,按照调度配置发出调度请求,本身不承担业务代码。
  • 调度系统与任务解耦,提高了系统可用性与稳定性,同时调度系统性能不在受限于任务模块。
  • 支持可视化,简单且动态的管理调度信息,包括任务新建,更新,删除,GLUE开发和任务报警等,所有上述操作都会实时生效,同时支持监控调度结果及执行日志,支持执行器Failover【故障转移】。
  1. 执行模块(执行器)
  • 负责接受调度请求并执行任务逻辑。
  • 任务模块专注于任务的执行等操作,开发和维护更加简单和高效。
  • 接受“调度中心”的执行请求,终止请求和日志请求等。

2.4 特点

官网:https://www.xuxueli.com/xxl-job/
1、简单:支持通过Web页面对任务进行CRUD操作,操作简单,一分钟上手。
2、动态:支持动态修改任务状态、启动/停止任务,以及终止运行中任务,即时生效。
3、调度中心HA(中心式):调度采用中心式设计,“调度中心”自研调度组件并支持集群部署,可保证调度中心HA;【HA:高可用】由于调度的中心式设计,所以调度中心在部署时,需要所有的调度中心访问同一个DB(数据库)。
4、执行器HA(分布式):任务分布式执行,任务”执行器”支持集群部署,可保证任务执行HA。【不调用不执行
5、注册中心: 执行器会周期性自动注册任务, 调度中心将会自动发现注册的任务并触发执行。同时,也支持手动录入执行器地址。
6、自定义任务参数:支持在线配置调度任务入参,即时生效;【当任务失败时,可以手动设置参数使任务再次执行】
7、任务依赖:支持配置子任务依赖,当父任务执行结束且执行成功后将会主动触发一次子任务的执行, 多个子任务用逗号分隔。
8、弹性扩容缩容:一旦有新执行器机器上线或者下线,下次调度时将会重新分配任务。
9、路由策略:执行器集群部署时提供丰富的路由策略,包括:第一个、最后一个、轮询【第一次调用执行器1,第二次调用执行器2】、随机、一致性HASH【A任务永远执行1,或者永远执行2】、最不经常使用、最近最久未使用、故障转移【当第一次执行,执行执行器1,当第一次执行失败时,可以转而执行执行器2】,忙碌转移【当访问执行器1时,可能比较忙碌,此时可以去访问执行器2】等。
10、故障转移:任务路由策略选择”故障转移”情况下,如果执行器集群中某一台机器故障,将会自动Failover切换到一台正常的执行器发送调度请求。
11、阻塞处理策略:调度过于密集执行器来不及处理时的处理策略。
策略包括:
1)单机串行(默认):即等待,等待上一个执行完再执行;
2)丢弃后续调度:即一个任务的后面还有任务时,丢掉后面的任务;
3)覆盖之前调度:如果现在有一个任务A在执行,后面又一个任务A在执行,此时可以停掉前面的A任务,直接执行第二次的新的A任务。
12、事件触发:除了“Cron方式”和“任务依赖方式”触发任务执行之外,支持基于事件的触发任务方式。调度中心提供触发任务单次执行的API服务,可根据业务事件灵活触发。【手动触发和父子任务】
13、任务进度监控:支持实时监控任务进度;
14、Rolling实时日志:支持在线查看调度结果,并且支持以Rolling方式实时查看执行器输出的完整的执行日志;
......

3 XXL-JOB实战

3.1 XXL-JOB入门使用

3.1.1 下载源码

  1. 码云地址:https://gitee.com/xuxueli0323/xxl-job

3.1.2 初始化数据库

  1. 用IDEA将maven项目导入到本地
  2. 初始化数据库,运行doc/db/tables_xxl_job.sql中的sql生成数据库和表
    image

右键数据库点击运行SQL文件,选择Sql脚本对应文件

image

3.1.3 数据库介绍

- xxl_job_lock:任务调度锁表;
- xxl_job_group:执行器信息表,维护任务执行器信息;
- xxl_job_info:调度扩展信息表: 用于保存XXL-JOB调度任务的扩展信息,如任务分组、任务名、机器地址、执行器、执行入参和报警邮件等等;
- xxl_job_log:调度日志表: 用于保存XXL-JOB任务调度的历史信息,如调度结果、执行结果、调度入参、调度机器和执行器等等;
- xxl_job_log_report:调度日志报表:用户存储XXL-JOB任务调度日志的报表,调度中心报表功能页面会用到;
- xxl_job_logglue:任务GLUE日志:用于保存GLUE更新历史,用于支持GLUE的版本回溯功能;
- xxl_job_registry:执行器注册表,维护在线的执行器和调度中心机器地址信息;
- xxl_job_user:系统用户表;权限设计比较弱,若设置权限管理需要二次开发。

3.1.4 编译部署调度中心

3.1.4.1 项目介绍

  • doc:相关介绍文档,存放有XXL-JOB架构图和官方文档
  • xxl-job-admin:调度中心模块
  • xxl-job-core:执行器的核心模块,使用时必须引入xxl-job-core,公共Jar依赖
  • xxl-job-executor:集成代码,执行器
    image

3.1.4.2 配置管理数据源

修改application.properties文件的配置
文件路径:/xxl-job/xxl-job-admin/src/main/resources/application.properties
image

3.1.4.3 访问管理界面

启动服务后访问网址:http://localhost:8080/xxl-job-admin/

3.1.5 部署架构图

image
用一个调度中心部署多个节点,多个调度中心必须使用同一个Mysql数据库,多个节点前面使用负载均衡器,所有的任务执行器也可以部署多个节点,任务执行器配置调度中心的地址的时候,配置负载均衡器的地址。

3.1.6 创建执行器

3.1.6.1 引入核心依赖

<dependency>
	<groupId>com.xuxueli</groupId>
	<artifactId>xxl-job-core</artifactId>
	<version>2.3.1</version>
</dependency>

3.1.6.2 编辑配置文件

将官方代码示例文件application.properties和logback.xml中的内容拷贝到执行器项目中,根据需要修改application.properties文件中的配置

# web port 当前应用端口
server.port=8081
# no web
#spring.main.web-environment=false

# log config 日志名称
logging.config=classpath:logback.xml

### 注册地址
# xxl-job admin address list, such as "http://address" or "http://address01,http://address02"
xxl.job.admin.addresses=http://127.0.0.1:8080/xxl-job-admin

### xxl-job, access token
xxl.job.accessToken=default_token

### xxl-job executor appname  执行器名称,同一应用多个节点使用同样的名称
xxl.job.executor.appname=xxl-job-executor-sample
### 执行器的ip和address可以动态的被发现
### xxl-job executor registry-address: default use address to registry , otherwise use ip:port if address is null
xxl.job.executor.address=
### xxl-job executor server-info
xxl.job.executor.ip=

### 执行器内部端口 rpc 内部调用端口
xxl.job.executor.port=8181
### 日志存储位置
### xxl-job executor log-path
xxl.job.executor.logpath=/data/applogs/xxl-job/jobhandler
### xxl-job executor log-retention-days
xxl.job.executor.logretentiondays=30

3.1.6.3 配置类

新建core.config目录,并新建类XxlJobConfig,将官方文档中的代码拷入。
image

package com.fanxl.xxljobdemo.config.XxlJobConfig;

import com.xxl.job.core.executor.impl.XxlJobSpringExecutor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @description:
 * @author: fanxl
 * @date: 2023/4/26 11:07
 * @param:
 * @return:
 **/
@Configuration
public class XxlJobConfig {
    private Logger logger = LoggerFactory.getLogger(XxlJobConfig.class);

    @Value("${xxl.job.admin.addresses}")
    private String adminAddresses;

    @Value("${xxl.job.accessToken}")
    private String accessToken;

    @Value("${xxl.job.executor.appname}")
    private String appname;

    @Value("${xxl.job.executor.address}")
    private String address;

    @Value("${xxl.job.executor.ip}")
    private String ip;

    @Value("${xxl.job.executor.port}")
    private int port;

    @Value("${xxl.job.executor.logpath}")
    private String logPath;

    @Value("${xxl.job.executor.logretentiondays}")
    private int logRetentionDays;


    @Bean
    public XxlJobSpringExecutor xxlJobExecutor() {
        logger.info(">>>>>>>>>>> xxl-job config init.");
        XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
        xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
        xxlJobSpringExecutor.setAppname(appname);
        xxlJobSpringExecutor.setAddress(address);
        xxlJobSpringExecutor.setIp(ip);
        xxlJobSpringExecutor.setPort(port);
        xxlJobSpringExecutor.setAccessToken(accessToken);
        xxlJobSpringExecutor.setLogPath(logPath);
        xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);

        return xxlJobSpringExecutor;
    }

    /**
     * 针对多网卡、容器内部署等情况,可借助 "spring-cloud-commons" 提供的 "InetUtils" 组件灵活定制注册IP;
     *
     *      1、引入依赖:
     *          <dependency>
     *             <groupId>org.springframework.cloud</groupId>
     *             <artifactId>spring-cloud-commons</artifactId>
     *             <version>${version}</version>
     *         </dependency>
     *
     *      2、配置文件,或者容器启动变量
     *          spring.cloud.inetutils.preferred-networks: 'xxx.xxx.xxx.'
     *
     *      3、获取IP
     *          String ip_ = inetUtils.findFirstNonLoopbackHostInfo().getIpAddress();
     */
}

3.1.6.4 编写xxl-job任务

在src/main/java/com/fanxl/下新建一个包job,并在包下新建配置类:xxljobdemo.job

package com.fanxl.xxljobdemo.job;
import com.xxl.job.core.biz.model.ReturnT;
import com.xxl.job.core.handler.annotation.XxlJob;
import org.springframework.stereotype.Component;
@Component //定义Spring管理Bean(也就是将标注@Component注解的类交由spring管理)
public class DemoXxlJob {
    /**
     * @description:第一个xxl-job任务
     * @author: fanxl
     * @date: 2023/4/26 11:16
     * @param: []
     * @return: com.xxl.job.core.biz.model.ReturnT
     **/
    //value:任务名称
    @XxlJob(value = "demoTask")
    public ReturnT demoTask(){
        System.out.println("Hello xxl job");
        //调度结果
        return ReturnT.SUCCESS;
    }
}

3.1.7 配置定时任务

3.1.7.1 配置执行器

image
注册方式:后台线程自动注册/手动录入。
当注册方式为自动注册时,会自动生成OnLine机器地址。【需要先在配置文件中配置执行器的名称xxl.job.executor.appname=xxl-job-executor-sample】
注册节点的端口号为配置文件中设置的执行器内部调用的端口号。
image
image

3.1.7.2 配置具体定时任务

方法上加注解@XxlJob,value为任务的名称。
JobHandler的值为DemoXxlJob中指定的任务名称,即value的值。
image
image

3.1.7.3 手动触发执行

image

3.1.7.4 结果示例

image

3.2 路由策略

假设有三台执行器:A1,A2,A3

  • 第一个:默认调用A1
  • 最后一个:默认调用A3
  • 轮询:第一次调用时调用A1,第二次调用时调用A2,第三次调用时调用A3
  • 随机:随机调用A1,A2,A3

步骤一:创建xxl-job任务

package com.fanxl.xxljobdemo.job;

import com.xxl.job.core.biz.model.ReturnT;
import com.xxl.job.core.handler.annotation.XxlJob;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Component;

/**
*@Author: fanxl
*@CreateTime: 2023-04-26  11:14
*/
@Component //定义Spring管理Bean(也就是将标注@Component注解的类交由spring管理)
@Log4j2
public class DemoXxlJob {

    /**
     * @description:第一个xxl-job任务
     * @author: fanxl
     * @date: 2023/4/26 11:16
     * @param: []
     * @return: com.xxl.job.core.biz.model.ReturnT
     **/
    //value:任务名称
    @XxlJob(value = "demoTask")
    public ReturnT demoTask(){
        System.out.println("Hello xxl job");
        //调度结果
        return ReturnT.SUCCESS;
    }
    /**
     * @description:演示:调度策略:第一个执行器
     * @author: fanxl
     * @date: 2023/4/26 13:35
     * @param: []
     * @return: com.xxl.job.core.biz.model.ReturnT
     **/
    @XxlJob("fisrtTask")
    public ReturnT firstTask(){
        log.info("========演示调度策略:第一个执行器");
        return ReturnT.SUCCESS;
    }
    /**
     * @description:演示:调度策略:最后一个执行器
     * @author: fanxl
     * @date: 2023/4/26 13:37
     * @param: []
     * @return: com.xxl.job.core.biz.model.ReturnT
     **/
    @XxlJob("lastTask")
    public ReturnT lastTask(){
        log.info("========演示调度策略:最后一个执行器");
        return ReturnT.SUCCESS;
    }
    /**
     * @description:演示:调度策略:轮询
     * @author: fanxl
     * @date: 2023/4/26 13:38
     * @param: []
     * @return: com.xxl.job.core.biz.model.ReturnT
     **/
    @XxlJob("pollTask")
    public ReturnT pollTask(){
        log.info("========演示调度策略:轮询执行器");
        return ReturnT.SUCCESS;
    }
    /**
     * @description:演示:调度策略:随机
     * @author: fanxl
     * @date: 2023/4/26 13:39
     * @param: []
     * @return: com.xxl.job.core.biz.model.ReturnT
     **/
    @XxlJob("randomTask")
    public ReturnT randomTask(){
        log.info("========演示调度策略:随机执行器");
        return ReturnT.SUCCESS;
    }
    /**
     * @description:演示:调度策略:hash策略
     * @author: fanxl
     * @date: 2023/4/26 13:39
     * @param: []
     * @return: com.xxl.job.core.biz.model.ReturnT
     **/
    @XxlJob("hashTask")
    public ReturnT hashTask(){
        log.info("========演示调度策略:哈希执行器");
        return ReturnT.SUCCESS;
    }
}

步骤二:编辑启动项配置和端口

  1. 复制原启动项,更改名称;
  2. 修改配置文件中的应用端口和内部调用端口
    image
    image
  3. 启动服务
    image

步骤三:创建任务
1、在步骤二启动服务后,可以在执行器管理处看见已经生成三个注册节点。
image
2、在任务管理处创建对应的任务
image
步骤四:手动执行任务测试
结果:
1、当路由策略为第一个时,只有8081的服务被调用了。
image
2、当路由策略为最后一个时,只有8083的服务被调用了。
image
3、当路由策略为轮询时,以每两秒的时间按顺序调用,第一次调用8082,第二次调用8083,第三次调用8081
image
4、当路由策略为随机时,每个执行器均有被调用的机会。
5、当路由策略为一致哈希时,后续调用结果默认和第一次的调用一致。
image

3.3 父子任务

父子任务,是指父任务执行后会自动调用子任务,完成一种类似于链式的调用,在使用时只需要配置子任务的ID即可。
1、在DemoXxlJob中添加任务

    @XxlJob("parentTask")
    public ReturnT parentTask(){
        log.info("=====父任务执行了=====");
        return ReturnT.SUCCESS;
    }

    @XxlJob("sonTask")
    public ReturnT sonTask(){
        log.info("=====子任务执行了=====");
        return ReturnT.SUCCESS;
    }

2、在任务管理处添加父任务和子任务,并在父任务的高级配置处添加子任务的ID。
image
image
image
3、结果:点击执行父任务时,子任务也执行了。
image

3.4 动态任务参数

xxl-job支持动态的接收参数进行任务调度,调度器可以传指定参数给具体的任务,具体的任务接收调度参数后,进行相应的业务逻辑处理。
步骤一:编写测试代码

    @XxlJob("paramTask")
    public ReturnT paramTask(){
        log.info("=====演示动态参数任务=====");
        // 获取当前天
        String date = DateUtil.today();
        // 获取调度器传递的参数
        String param = XxlJobHelper.getJobParam();
        if (StrUtil.isNotBlank(param)) {
            date = param;
        }
        log.info("处理的业务时间为:{}",date);
        return ReturnT.SUCCESS;
    }

步骤二:新增任务
image
步骤三:测试执行
image
如果想更改参数,可以点击操作→执行一次,在任务参数框中设置要修改的参数
image

image
还可以再创建任务时固定任务参数,这样的话每次执行都是同样的时间
image
image

3.5 分片任务

分片任务是指会对所有的执行器广播这个任务,所有的执行器都会接收到请求调用,每个执行器可以根据总分片数及当前执行器的索引进行相关业务处理。
【类似于多台机器合作,把大任务拆分成小任务交由多台机器协作完成,可以提高程序的执行效率。】
步骤一:编写核心代码

 /**
     * @description:假设10万条数据需要处理,3台执行器处理,每台执行器处理部分数据,合并在一起就是10万条数据
     * A1  1-33333
     * A2 33334-66666
     * A3 66667 - 100000
     * 总分片数,
     *   当前分片索引,
     *      总数据量,
     *      每个分片大概数据量:总数据量/总分片数
     *      每个分片起始值:
     *              开始值:当前分片的索引 * 大概数据量 + 1
     *              结束值:(当前分片的索引+1) * 大概数据量
     * @author: fanxl
     * @date: 2023/4/26 15:01
     * @param: []
     * @return: com.xxl.job.core.biz.model.ReturnT
     **/
    @XxlJob("shardTask")
    public ReturnT shardTask(){
        log.info("=====分片任务=====");
        // 获取分片信息
        //总分片数量
        int shardTotal = XxlJobHelper.getShardTotal();
        //当前分片的索引
        int shardIndex = XxlJobHelper.getShardIndex();
        // 总数据量
        int  total = 10 * 10000;
        // 分片平均数据量
        int size =  total / shardTotal;
        // 每个分片起始值
        int startIndex = shardIndex * size +1;
        int endIndex = (shardIndex + 1) * size;
        //处理最后一个分片,最后一个分片要处理到最后一条数据
        if (shardIndex == (shardTotal-1)){
            endIndex = total;
        }
        log.info("总分片数为:{},当前分片索引为:{},处理的数据的范围为:{}~{}",shardTotal,shardIndex,startIndex,endIndex);
        return ReturnT.SUCCESS;
    }

步骤二:启动三个服务
步骤三:新增任务:路由策略选择分片广播
image
步骤四:执行任务,查看结果
image
image
image
扩展:Xxl支持伸缩,如果此时停掉一个服务,在执行任务,结果如下
image
image

3.6 日志回调

日志回调是指执行器在执行任务时可以将执行日志传递给调度者,即使任务没有执行完成,调度中心也可以看到回调的调度日志内容,便于开发者能够更细化的分析任务的执行情况。
步骤一:编写核心代码

 /**
     * @description:日志回调任务
     * @author: fanxl
     * @date: 2023/4/26 15:32
     * @param: []
     * @return: com.xxl.job.core.biz.model.ReturnT
     **/
    @XxlJob("logCallbackTask")
    public ReturnT logCallbackTask() throws InterruptedException {
        log.info("=====演示日志回调任务=====");
        XxlJobHelper.log("当前程序执行到了:{}行",156);
        Thread.sleep(3000);
        XxlJobHelper.log("当前程序执行到了:{}行",158);
        Thread.sleep(3000);
        XxlJobHelper.log("当前程序完成");
        return ReturnT.SUCCESS;
    }

步骤二:创建日志回调任务
image
步骤三:手动执行任务,可在调度日志处查看调度结果
image
image

3.7 任务生命周期管理

xxl-job支持在调用任务时,第一次调用时先执行指定的方法,然后在执行具体的任务,当执行器停止时会执行指定的方法,这就是xxl-job任务的生命周期。
步骤一:编写代码

/**
     * @description:生命周期任务,value为任务名称,init第一次调用的时候会去调用方法,destory:执行器停止的时候
     * @author: fanxl
     * @date: 2023/4/26 15:46
     * @param: []
     * @return: com.xxl.job.core.biz.model.ReturnT
     **/
    @XxlJob(value = "lifeCycleTask",init = "init",destroy = "destory")
    public ReturnT lifeCycleTask() throws InterruptedException {
        log.info("=====演示任务的生命周期=====");
        return ReturnT.SUCCESS;
    }
    public void init(){
        log.info("第一次调用当前任务时,才会执行,预处理");
    }
    public void destory(){
        log.info("当执行器停止时才会执行,善后处理");
    }

步骤二:启动服务
步骤三:创建任务
image
步骤四:
执行一次任务,可以看到init()方法被调用
image

当再次执行一次任务时,不会再调用init方法。
image

清空控制台,启动生命周期任务
image
image

停止服务时,调用destory方法。
image

4 视频链接

https://www.bilibili.com/video/BV12N4y1G7b4/?spm_id_from=333.999.0.0&vd_source=9140dcc493e34a9f4e95ca2f8f71bbd3

posted @ 2023-09-11 13:36  角刀牛Java  阅读(2565)  评论(0编辑  收藏  举报