• SpringBoot2.x集成分布式任务调度平台XXL-JOB



    title: "SpringBoot2.x集成分布式任务调度平台XXL-JOB"
    categories: SpringBoot2.x XXL-JOB
    tags: SpringBoot2.x XXL-JOB
    author: LIUREN

    SpringBoot2.x集成分布式任务调度平台XXL-JOB

    XXL-JOB是一个分布式任务调度平台,其核心设计目标是开发迅速、学习简单、轻量级、易扩展。现已开放源代码并接入多家公司线上产品线,开箱即用。

    协议:CC BY-SA 4.0 https://creativecommons.org/licenses/by-sa/4.0/

    版权声明:本文为原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。

    1.打开官网地址可以先大致浏览一下

    1.6 环境

    • Maven3+
    • Jdk1.8+
    • Mysql5.7+

    官方文档地址:https://www.xuxueli.com/xxl-job/

    2.下载源码:

    源码仓库地址

    源码仓库地址 Release Download
    https://github.com/xuxueli/xxl-job Download
    http://gitee.com/xuxueli0323/xxl-job Download

    下载zip或者tar.gz格式的源码信息。

    A:然后把xxl-job下的xxl-job-admin 导入到Eclipse中进行打包。【因为xxl-job分为服务端和客户端两个。xxl-job-admin为服务端和管理界面】

    B.把xxl-jobdocdb下的tables_xxl_job.sql导入到mysql数据库

    tables_xxl_job.sql内容如下:

    #
    # XXL-JOB v2.3.0
    # Copyright (c) 2015-present, xuxueli.
    
    CREATE database if NOT EXISTS `xxl_job` default character set utf8mb4 collate utf8mb4_unicode_ci;
    use `xxl_job`;
    
    SET NAMES utf8mb4;
    
    CREATE TABLE `xxl_job_info` (
      `id` int(11) NOT NULL AUTO_INCREMENT,
      `job_group` int(11) NOT NULL COMMENT '执行器主键ID',
      `job_desc` varchar(255) NOT NULL,
      `add_time` datetime DEFAULT NULL,
      `update_time` datetime DEFAULT NULL,
      `author` varchar(64) DEFAULT NULL COMMENT '作者',
      `alarm_email` varchar(255) DEFAULT NULL COMMENT '报警邮件',
      `schedule_type` varchar(50) NOT NULL DEFAULT 'NONE' COMMENT '调度类型',
      `schedule_conf` varchar(128) DEFAULT NULL COMMENT '调度配置,值含义取决于调度类型',
      `misfire_strategy` varchar(50) NOT NULL DEFAULT 'DO_NOTHING' COMMENT '调度过期策略',
      `executor_route_strategy` varchar(50) DEFAULT NULL COMMENT '执行器路由策略',
      `executor_handler` varchar(255) DEFAULT NULL COMMENT '执行器任务handler',
      `executor_param` varchar(512) DEFAULT NULL COMMENT '执行器任务参数',
      `executor_block_strategy` varchar(50) DEFAULT NULL COMMENT '阻塞处理策略',
      `executor_timeout` int(11) NOT NULL DEFAULT '0' COMMENT '任务执行超时时间,单位秒',
      `executor_fail_retry_count` int(11) NOT NULL DEFAULT '0' COMMENT '失败重试次数',
      `glue_type` varchar(50) NOT NULL COMMENT 'GLUE类型',
      `glue_source` mediumtext COMMENT 'GLUE源代码',
      `glue_remark` varchar(128) DEFAULT NULL COMMENT 'GLUE备注',
      `glue_updatetime` datetime DEFAULT NULL COMMENT 'GLUE更新时间',
      `child_jobid` varchar(255) DEFAULT NULL COMMENT '子任务ID,多个逗号分隔',
      `trigger_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '调度状态:0-停止,1-运行',
      `trigger_last_time` bigint(13) NOT NULL DEFAULT '0' COMMENT '上次调度时间',
      `trigger_next_time` bigint(13) NOT NULL DEFAULT '0' COMMENT '下次调度时间',
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
    
    CREATE TABLE `xxl_job_log` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT,
      `job_group` int(11) NOT NULL COMMENT '执行器主键ID',
      `job_id` int(11) NOT NULL COMMENT '任务,主键ID',
      `executor_address` varchar(255) DEFAULT NULL COMMENT '执行器地址,本次执行的地址',
      `executor_handler` varchar(255) DEFAULT NULL COMMENT '执行器任务handler',
      `executor_param` varchar(512) DEFAULT NULL COMMENT '执行器任务参数',
      `executor_sharding_param` varchar(20) DEFAULT NULL COMMENT '执行器任务分片参数,格式如 1/2',
      `executor_fail_retry_count` int(11) NOT NULL DEFAULT '0' COMMENT '失败重试次数',
      `trigger_time` datetime DEFAULT NULL COMMENT '调度-时间',
      `trigger_code` int(11) NOT NULL COMMENT '调度-结果',
      `trigger_msg` text COMMENT '调度-日志',
      `handle_time` datetime DEFAULT NULL COMMENT '执行-时间',
      `handle_code` int(11) NOT NULL COMMENT '执行-状态',
      `handle_msg` text COMMENT '执行-日志',
      `alarm_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '告警状态:0-默认、1-无需告警、2-告警成功、3-告警失败',
      PRIMARY KEY (`id`),
      KEY `I_trigger_time` (`trigger_time`),
      KEY `I_handle_code` (`handle_code`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
    
    CREATE TABLE `xxl_job_log_report` (
      `id` int(11) NOT NULL AUTO_INCREMENT,
      `trigger_day` datetime DEFAULT NULL COMMENT '调度-时间',
      `running_count` int(11) NOT NULL DEFAULT '0' COMMENT '运行中-日志数量',
      `suc_count` int(11) NOT NULL DEFAULT '0' COMMENT '执行成功-日志数量',
      `fail_count` int(11) NOT NULL DEFAULT '0' COMMENT '执行失败-日志数量',
      `update_time` datetime DEFAULT NULL,
      PRIMARY KEY (`id`),
      UNIQUE KEY `i_trigger_day` (`trigger_day`) USING BTREE
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
    
    CREATE TABLE `xxl_job_logglue` (
      `id` int(11) NOT NULL AUTO_INCREMENT,
      `job_id` int(11) NOT NULL COMMENT '任务,主键ID',
      `glue_type` varchar(50) DEFAULT NULL COMMENT 'GLUE类型',
      `glue_source` mediumtext COMMENT 'GLUE源代码',
      `glue_remark` varchar(128) NOT NULL COMMENT 'GLUE备注',
      `add_time` datetime DEFAULT NULL,
      `update_time` datetime DEFAULT NULL,
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
    
    CREATE TABLE `xxl_job_registry` (
      `id` int(11) NOT NULL AUTO_INCREMENT,
      `registry_group` varchar(50) NOT NULL,
      `registry_key` varchar(255) NOT NULL,
      `registry_value` varchar(255) NOT NULL,
      `update_time` datetime DEFAULT NULL,
      PRIMARY KEY (`id`),
      KEY `i_g_k_v` (`registry_group`,`registry_key`,`registry_value`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
    
    CREATE TABLE `xxl_job_group` (
      `id` int(11) NOT NULL AUTO_INCREMENT,
      `app_name` varchar(64) NOT NULL COMMENT '执行器AppName',
      `title` varchar(12) NOT NULL COMMENT '执行器名称',
      `address_type` tinyint(4) NOT NULL DEFAULT '0' COMMENT '执行器地址类型:0=自动注册、1=手动录入',
      `address_list` text COMMENT '执行器地址列表,多地址逗号分隔',
      `update_time` datetime DEFAULT NULL,
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
    
    CREATE TABLE `xxl_job_user` (
      `id` int(11) NOT NULL AUTO_INCREMENT,
      `username` varchar(50) NOT NULL COMMENT '账号',
      `password` varchar(50) NOT NULL COMMENT '密码',
      `role` tinyint(4) NOT NULL COMMENT '角色:0-普通用户、1-管理员',
      `permission` varchar(255) DEFAULT NULL COMMENT '权限:执行器ID列表,多个逗号分割',
      PRIMARY KEY (`id`),
      UNIQUE KEY `i_username` (`username`) USING BTREE
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
    
    CREATE TABLE `xxl_job_lock` (
      `lock_name` varchar(50) NOT NULL COMMENT '锁名称',
      PRIMARY KEY (`lock_name`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
    
    INSERT INTO `xxl_job_group`(`id`, `app_name`, `title`, `address_type`, `address_list`, `update_time`) VALUES (1, 'xxl-job-executor-sample', '示例执行器', 0, NULL, '2018-11-03 22:21:31' );
    INSERT INTO `xxl_job_info`(`id`, `job_group`, `job_desc`, `add_time`, `update_time`, `author`, `alarm_email`, `schedule_type`, `schedule_conf`, `misfire_strategy`, `executor_route_strategy`, `executor_handler`, `executor_param`, `executor_block_strategy`, `executor_timeout`, `executor_fail_retry_count`, `glue_type`, `glue_source`, `glue_remark`, `glue_updatetime`, `child_jobid`) VALUES (1, 1, '测试任务1', '2018-11-03 22:21:31', '2018-11-03 22:21:31', 'XXL', '', 'CRON', '0 0 0 * * ? *', 'DO_NOTHING', 'FIRST', 'demoJobHandler', '', 'SERIAL_EXECUTION', 0, 0, 'BEAN', '', 'GLUE代码初始化', '2018-11-03 22:21:31', '');
    INSERT INTO `xxl_job_user`(`id`, `username`, `password`, `role`, `permission`) VALUES (1, 'admin', 'e10adc3949ba59abbe56e057f20f883e', 1, NULL);
    INSERT INTO `xxl_job_lock` ( `lock_name`) VALUES ( 'schedule_lock');
    
    commit;
    

    C.修改xxl-job-admin的配置文件【主要修改数据库相关信息】

    ### web
    server.port=8080
    server.servlet.context-path=/xxl-job-admin
    
    ### actuator
    management.server.servlet.context-path=/actuator
    management.health.mail.enabled=false
    
    ### resources
    spring.mvc.servlet.load-on-startup=0
    spring.mvc.static-path-pattern=/static/**
    spring.resources.static-locations=classpath:/static/
    
    ### freemarker
    spring.freemarker.templateLoaderPath=classpath:/templates/
    spring.freemarker.suffix=.ftl
    spring.freemarker.charset=UTF-8
    spring.freemarker.request-context-attribute=request
    spring.freemarker.settings.number_format=0.##########
    
    ### mybatis
    mybatis.mapper-locations=classpath:/mybatis-mapper/*Mapper.xml
    #mybatis.type-aliases-package=com.xxl.job.admin.core.model
    
    ### xxl-job, datasource
    spring.datasource.url=jdbc:mysql://127.0.0.1:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
    spring.datasource.username=root
    spring.datasource.password=root
    spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
    
    ### datasource-pool
    spring.datasource.type=com.zaxxer.hikari.HikariDataSource
    spring.datasource.hikari.minimum-idle=10
    spring.datasource.hikari.maximum-pool-size=30
    spring.datasource.hikari.auto-commit=true
    spring.datasource.hikari.idle-timeout=30000
    spring.datasource.hikari.pool-name=HikariCP
    spring.datasource.hikari.max-lifetime=900000
    spring.datasource.hikari.connection-timeout=10000
    spring.datasource.hikari.connection-test-query=SELECT 1
    spring.datasource.hikari.validation-timeout=1000
    
    ### xxl-job, email
    spring.mail.host=smtp.qq.com
    spring.mail.port=25
    spring.mail.username=xxxx@qq.com
    spring.mail.from=xxxx@qq.com
    spring.mail.password=xxx
    spring.mail.properties.mail.smtp.auth=true
    spring.mail.properties.mail.smtp.starttls.enable=true
    spring.mail.properties.mail.smtp.starttls.required=true
    spring.mail.properties.mail.smtp.socketFactory.class=javax.net.ssl.SSLSocketFactory
    
    ### xxl-job, access token
    xxl.job.accessToken=
    
    ### xxl-job, i18n (default is zh_CN, and you can choose "zh_CN", "zh_TC" and "en")
    xxl.job.i18n=zh_CN
    
    ## xxl-job, triggerpool max size
    xxl.job.triggerpool.fast.max=200
    xxl.job.triggerpool.slow.max=100
    
    ### xxl-job, log retention days
    xxl.job.logretentiondays=30
    

    D.打包成xxl-job-admin-2.3.0.jar文件,然后启动信息,启动结果如图

    E.开始集成Springboot【重新初始化一套Springboot代码】

    springboot中的pom.xml主要是集成如下两个包

    第一个

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <version>2.3.7.RELEASE</version>
    </dependency>
    

    第二个

    <!-- http://repo1.maven.org/maven2/com/xuxueli/xxl-job-core/ -->
    <dependency>
        <groupId>com.xuxueli</groupId>
        <artifactId>xxl-job-core</artifactId>
        <version>2.3.0</version>
    </dependency>
    

    F.开始编写对应代码

    Springboot启动类

    package cn.codepeople;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    
    @SpringBootApplication
    public class CodepeopleApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(CodepeopleApplication.class, args);
        }
    
    }
    

    配置文件

    # 应用名称
    spring.application.name=codepeople
    
    # 应用服务 WEB 访问端口,不要和服务器端冲突
    server.port=8082
    
    ### 调度中心部署跟地址 [选填]:如调度中心集群部署存在多个地址则用逗号分隔。执行器将会使用该地址进行"执行器心跳注册"和"任务结果回调";为空则关闭自动注册;
    xxl.job.admin.addresses=http://localhost:8080/xxl-job-admin
    ### 执行器通讯TOKEN [选填]:非空时启用;
    xxl.job.accessToken=
    ### 执行器AppName [选填]:执行器心跳注册分组依据;为空则关闭自动注册
    xxl.job.executor.appname=xxl-job-executor-sample
    ### 执行器注册 [选填]:优先使用该配置作为注册地址,为空时使用内嵌服务 ”IP:PORT“ 作为注册地址。从而更灵活的支持容器类型执行器动态IP和动态映射端口问题。
    xxl.job.executor.address=
    ### 执行器IP [选填]:默认为空表示自动获取IP,多网卡时可手动设置指定IP,该IP不会绑定Host仅作为通讯实用;地址信息用于 "执行器注册" 和 "调度中心请求并触发任务";
    xxl.job.executor.ip=
    ### 执行器端口号 [选填]:小于等于0则自动获取;默认端口为9999,单机部署多个执行器时,注意要配置不同执行器端口;
    xxl.job.executor.port=9999
    ### 执行器运行日志文件存储磁盘路径 [选填] :需要对该路径拥有读写权限;为空则使用默认路径;
    xxl.job.executor.logpath=/data/applogs/xxl-job/jobhandler
    ### 执行器日志文件保存天数 [选填] : 过期日志自动清理, 限制值大于等于3时生效; 否则, 如-1, 关闭自动清理功能;
    xxl.job.executor.logretentiondays=30
    

    初始化配置信息使Springboot通过bean对配置信息进行管理

    package cn.codepeople.config;
    
    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;
    
    import com.xxl.job.core.executor.impl.XxlJobSpringExecutor;
    
    @Configuration
    public class XxJobConfig {
    
    	private final static Logger logger = LoggerFactory.getLogger(XxJobConfig.class);
    	
    	
    	@Value("${xxl.job.admin.addresses}")
    	private String adminAddresses;
    	
    	@Value("${xxl.job.executor.appname}")
    	private String appname;
    	
    	@Value("${xxl.job.executor.ip}")
        private String ip;
    
        @Value("${xxl.job.executor.port}")
        private int port;
    
        @Value("${xxl.job.accessToken}")
        private String accessToken;
        
    	@Value("${xxl.job.executor.logpath}")
    	private String logPath;
    	
    	@Value("${xxl.job.executor.logretentiondays}")
    	private int logRetentionDays;
    	
    	@Bean
    	public XxlJobSpringExecutor xxlJobSpringExecutor(){
    		logger.info(">>>>>>>>>>>>>>>>  xxl-job config init.");
    		XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
    		
    		xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
    		xxlJobSpringExecutor.setAppname(appname);
    		xxlJobSpringExecutor.setIp(ip);
    		xxlJobSpringExecutor.setPort(port);
    		xxlJobSpringExecutor.setAccessToken(accessToken);
    		xxlJobSpringExecutor.setLogPath(logPath);
    		xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);
    		
    		return xxlJobSpringExecutor;
    	}
    }
    

    编写个人定时任务

    package cn.codepeople.xxljob;
    
    import java.util.concurrent.TimeUnit;
    
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.stereotype.Component;
    
    import com.xxl.job.core.biz.model.ReturnT;
    import com.xxl.job.core.context.XxlJobHelper;
    import com.xxl.job.core.handler.IJobHandler;
    import com.xxl.job.core.handler.annotation.XxlJob;
    
    import groovy.util.logging.Slf4j;
    
    
    @Component
    @Slf4j
    public class MyDemoJobHandler extends IJobHandler{
    
    	private static final Logger logger = LoggerFactory.getLogger(MyDemoJobHandler.class);
    	
    	@Override
    	public void execute() throws Exception {
    		
    		logger.info("xxl-job, heoooo world.");
    		
    	}
    
    	@XxlJob("demoJobHandler")
    	public ReturnT<String> demoJobHandler() throws Exception {
    	    XxlJobHelper.log("XXL-JOB, Hello World.");
    	    for (int i = 0; i < 5; i++) {
    			logger.info("this is >>>>>>>>>>> " + i);
    			TimeUnit.SECONDS.sleep(2);
    		}
    	    return ReturnT.SUCCESS;
    	}
    
    }
    

    然后启动Springboot项目

    F.使用浏览器访问http://localhost:8080/xxl-job-admin/地址然后登录admin/123456登录后如下界面

    点击左侧菜单"任务管理"然后点击 右侧 “新增”

    根据图里进行信息配置

    新增任务完毕后,需要手动启动任务

    然后等待一会,观察Eclipse控制台上打印的信息如下:

    项目顺利成功进行

    G.如果项目有问题Eclipse控制台没有打印相关的日志信息可以通过如下方法查找问题

    主要的坑是在

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

    早期版本要加上(initMethod = "start", destroyMethod = "destroy") 如下。只要加了这一段就会一直启动报错;报错内容是端口被占用

    @Bean(initMethod = "start", destroyMethod = "destroy")
    	public XxlJobSpringExecutor xxlJobSpringExecutor(){
    		logger.info(">>>>>>>>>>>>>>>>  xxl-job config init.");
    		XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
    		
    		xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
    		xxlJobSpringExecutor.setAppname(appname);
    		xxlJobSpringExecutor.setIp(ip);
    		xxlJobSpringExecutor.setPort(port);
    		xxlJobSpringExecutor.setAccessToken(accessToken);
    		xxlJobSpringExecutor.setLogPath(logPath);
    		xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);
    		
    		return xxlJobSpringExecutor;
    	}
    

    Exception in thread "Thread-6" java.net.BindException: Address already in use: bind
    	at sun.nio.ch.Net.bind0(Native Method)
    	at sun.nio.ch.Net.bind(Net.java:433)
    	at sun.nio.ch.Net.bind(Net.java:425)
    	at sun.nio.ch.ServerSocketChannelImpl.bind(ServerSocketChannelImpl.java:223)
    	at io.netty.channel.socket.nio.NioServerSocketChannel.doBind(NioServerSocketChannel.java:134)
    	at io.netty.channel.AbstractChannel$AbstractUnsafe.bind(AbstractChannel.java:550)
    	at io.netty.channel.DefaultChannelPipeline$HeadContext.bind(DefaultChannelPipeline.java:1334)
    	at io.netty.channel.AbstractChannelHandlerContext.invokeBind(AbstractChannelHandlerContext.java:506)
    	at io.netty.channel.AbstractChannelHandlerContext.bind(AbstractChannelHandlerContext.java:491)
    	at io.netty.channel.DefaultChannelPipeline.bind(DefaultChannelPipeline.java:973)
    	at io.netty.channel.AbstractChannel.bind(AbstractChannel.java:248)
    	at io.netty.bootstrap.AbstractBootstrap$2.run(AbstractBootstrap.java:356)
    	at io.netty.util.concurrent.AbstractEventExecutor.safeExecute(AbstractEventExecutor.java:164)
    	at io.netty.util.concurrent.SingleThreadEventExecutor.runAllTasks(SingleThreadEventExecutor.java:472)
    	at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:500)
    	at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:989)
    	at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
    	at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
    	at java.lang.Thread.run(Thread.java:745)
    

    注意:

    最新版本目前也没有这个@JobHandler(value="demoJobHandler")这个注解了。换成直接在方法上的注解了@XxlJob("demoJobHandler")

    以上是springboot集成分布式任务调度平台XXL-JOB的最新实践


    =====================================================================

    微信公众号:

    不定期会发布一些实用的Java开发文章
  • 相关阅读:
    安装Oracle时出现环境变量Path的值大于1023的解决办法
    JavaScript通过元素id和name直接获取元素的方法
    [转载]T-SQL(Oracle)语句查询执行顺序
    [转载]T-SQL(MSSQL)语句查询执行顺序
    HTML5权威指南 17.其他API
    HTML5权威指南 16.拖放API与通知API
    HTML5权威指南 15.获取地理位置信息
    HTML5权威指南 14.使用Web Workers处理线程
    HTML5权威指南 13.扩展的XMLHttpRequest API
    HTML5权威指南 12.WebRTC通信
  • 原文地址:https://www.cnblogs.com/lr393993507/p/14507141.html
Copyright © 2020-2023  润新知