• 基于Quartz实现动态定时任务


    生命无罪,健康万岁,我是laity。

    我曾七次鄙视自己的灵魂:

    第一次,当它本可进取时,却故作谦卑;

    第二次,当它在空虚时,用爱欲来填充;

    第三次,在困难和容易之间,它选择了容易;

    第四次,它犯了错,却借由别人也会犯错来宽慰自己;

    第五次,它自由软弱,却把它认为是生命的坚韧;

    第六次,当它鄙夷一张丑恶的嘴脸时,却不知那正是自己面具中的一副;

    第七次,它侧身于生活的污泥中,虽不甘心,却又畏首畏尾。

    本文带各位学习下Quartz的基本使用及业务中的整合,包括基本概念以及如何动态地对定时任务进行CRUD,并且如何实现定时任务的持久化以及任务恢复;其中分享下本人在使用时遇到的问题,和解决方案。

    Quartz的基本使用

    Quartz 是一个开源的作业调度框架,支持分布式定时任务,Quartz定时任务据我了解可分为Trigger(触发器)Job(任务)和Scheduler(调度器),定时任务的逻辑大体为:创建触发器和任务,并将其加入到调度器中。

    Quartz 的核心类有以下三部分:

    • 任务 Job : 需要实现的任务类,实现 execute() 方法,执行后完成任务;
    • 触发器 Trigger : 包括 SimpleTriggerCronTrigger;
    • 调度器 Scheduler : 任务调度器,负责基于 Trigger触发器,来执行 Job任务.

    在这里插入图片描述

    Trigger 有五种触发器:

    • SimpleTrigger 触发器:需要在特定的日期/时间启动,且以指定的间隔时间(单位毫秒)重复执行 n 次任务,如 :在 9:00 开始,每隔1小时,每隔几分钟,每隔几秒钟执行一次 。没办法指定每隔一个月执行一次(每月的时间间隔不是固定值)。
    • CalendarIntervalTrigger 触发器:指定从某一个时间开始,以一定的时间间隔(单位有秒,分钟,小时,天,月,年,星期)执行的任务。
    • DailyTimeIntervalTrigger 触发器:指定每天的某个时间段内,以一定的时间间隔执行任务。并且支持指定星期。如:指定每天 9:00 至 18:00 ,每隔 70 秒执行一次,并且只要周一至周五执行。
    • CronTrigger 触发器:基于日历的任务调度器,即指定星期、日期的某时间执行任务。
    • NthIncludedDayTrigger 触发器:不同时间间隔的第 n 天执行任务。比如,在每个月的第 15 日处理财务发票记帐,同样设定双休日或者假期。

    使用场景

    • 发布消息、问卷等信息时,发布者可以指定星期、月份的具体时间进行定时发布(cron 触发器)
    • 设置当天或指定日期的时间范围内,指定时间间隔执行任务。
    • 其他定时功能可根据不同的任务触发器进行实现。

    依赖的引入

            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-quartz</artifactId>
            </dependency>
    
    • 1
    • 2
    • 3
    • 4

    简单的测试

    将job封装给jobDetail,由调度器scheudler根据触发器trggier条件触发相应的jobDetail,每次触发都会让jobDetail重新创建job对象,并且jobDetail会将数据传给job

    有两种方式:

    • 1.jobDetail会根据自己usingJobData中的参数主动调用job对应的set方法,设置给job使用。

    • 2.*job可以从重写方法传过来的参数jobExecutionContext中获取jobDetail,*然后从jobDetail中获取到jobDataMap。

    /**
     * @author: Laity
     * @Project: JavaLaity
     * @Description: 测试定时任务并获取自定义参数
     */
    
    public class MyJob implements Job {
        @Override
        public void execute(JobExecutionContext content) throws JobExecutionException {
            long count = (long) content.getJobDetail().getJobDataMap().get("count");
            System.out.println("当前执行,第" + count + "次");
            content.getJobDetail().getJobDataMap().put("count", ++count);
            System.out.println("任务执行.....");
        }
    
        public static void main(String[] args) throws Exception {
            // 1.创建调度器 Scheduler
            SchedulerFactory factory = new StdSchedulerFactory();
            Scheduler scheduler = factory.getScheduler();
    
            // 2.创建JobDetail实例,并与MyJob类绑定(Job执行内容)
            JobDetail job = JobBuilder.newJob(MyJob.class)
                    .withIdentity("job1", "group1")
                    .usingJobData("count", 1L)
                    .build();
    
            // 3.构建Trigger实例,每隔3s执行一次
            Trigger trigger = TriggerBuilder.newTrigger()
                    .withIdentity("trigger1", "group1")
                    .startNow()
                    .withSchedule(simpleSchedule()
                            .withIntervalInSeconds(3)
                            .repeatForever())
                    .build();
    
            // 4.执行,开启调度器
            scheduler.scheduleJob(job, trigger);
            System.out.println(System.currentTimeMillis());
            scheduler.start();
    
            //主线程睡眠1分钟,然后关闭调度器
            TimeUnit.MINUTES.sleep(1);
            scheduler.shutdown();
            System.out.println(System.currentTimeMillis());
        }
    }
    
    • 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

    在这里插入图片描述

    Quartz高级使用

    当遇到更新版本等情况时,肯定要将程序给停了,但是程序停止后那些还未开始或者没执行完的定时任务就没了。所以我们需要将任务持久化到数据库中,然后在程序启动时将这些任务进行恢复。

    数据库表设计

    • 官方提供了一份数据库表设计,有兴趣的小伙伴可以去下载
    DROP TABLE IF EXISTS `quartz_entity`;
    CREATE TABLE `quartz_entity` (
      `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
      `job_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '任务名',
      `group_name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '任务分组',
      `start_time` timestamp DEFAULT NULL COMMENT '任务开始时间',
      `end_time` timestamp DEFAULT NULL COMMENT '任务结束时间',
      `job_class` varchar(255) DEFAULT NULL COMMENT '定时任务所在的类',
      `cron` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT 'cron表达式',
      `job_data_map_json` varchar(255) DEFAULT NULL COMMENT 'json格式的jobDataMap',
      `status` varchar(1) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '0' COMMENT '任务状态。0-进行中;1-已完成;2-取消', 
      PRIMARY KEY (`id`) USING BTREE
    ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='定时任务信息';
    
    SET FOREIGN_KEY_CHECKS = 1;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    application-local.yml配置

    spring:
      info:
        build:
          encoding: UTF-8
      datasource:
        dynamic:
          druid:
            initial-size: 10
            # 初始化大小,最小,最大
            min-idle: 20
            maxActive: 500
            # 配置获取连接等待超时的时间
            maxWait: 60000
            # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
            timeBetweenEvictionRunsMillis: 60000
            # 配置一个连接在池中最小生存的时间,单位是毫秒
            minEvictableIdleTimeMillis: 300000
            testWhileIdle: true
            testOnBorrow: true
            validation-query: SELECT 1
            testOnReturn: false
            # 打开PSCache,并且指定每个连接上PSCache的大小
            poolPreparedStatements: true
            maxPoolPreparedStatementPerConnectionSize: 20
            filters: stat,wall
            filter:
              wall:
                config:
                  multi-statement-allow: true
                  none-base-statement-allow: true
                enabled: true
            # 配置DruidStatFilter
            web-stat-filter:
              enabled: true
              url-pattern: "/*"
              exclusions: "*.js,*.gif,*.jpg,*.bmp,*.png,*.css,*.ico,/druid/*"
            # 配置DruidStatViewServlet
            stat-view-servlet:
              enabled: true
              url-pattern: "/druid/*"
              allow:
              deny:
              reset-enable: false
              login-username: admin
              login-password: 111111
            query-timeout: 36000
          primary: slave
          strict: false
          datasource:
            master:
              url: jdbc:mysql://127.0.0.1:3306/jxgl?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true
              username: root
              password: wang9264
              driver-class-name: com.mysql.jdbc.Driver
            slave:
              url: jdbc:mysql://127.0.0.1:3306/java?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true
              username: root
              password: wang9264
              driver-class-name: com.mysql.jdbc.Driver
    
    • 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

    实体类的创建

    /**
     * @author: Laity
     * @Project: JavaLaity
     */
    
    @Data
    public class QuartzEntity {
    
        @TableId(value = "id",type = IdType.AUTO)
        private Long id;
    
        private String jobName;
    
        private String groupName;
    
        private Date startTime;
    
        private Date endTime;
    
        private String jobClass;
    
        private String cron;
    
        private String jobDataMapJson;
    
        private String status;
    
    }
    
    • 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

    service层

    /**
     * @author: Laity
     * @Project: JavaLaity
     */
    
    public interface QuartzService {
        void save(QuartzEntity entity);
    
        boolean modifyJob(QuartzEntity entity);
    
        boolean modifyTaskStatus(String jobName,String status);
    
        List<QuartzEntity> notStartOrNotEndJobs();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    serviceImpl层

    /**
     * @author: Laity
     * @Project: JavaLaity
     */
    @Service("quartzService")
    public class QuartzServiceImpl implements QuartzService {
        @Resource
        private QuartzDao quartzMapper;
    
        @Override
        public void save(QuartzEntity entity) {
            quartzMapper.insert(entity);
        }
    
        @Override
        public boolean modifyJob(QuartzEntity entity) {
            LambdaQueryWrapper<QuartzEntity> wrapper = new LambdaQueryWrapper<>();
            wrapper.eq(QuartzEntity::getJobName, entity.getJobName());
            QuartzEntity one = quartzMapper.selectOne(wrapper);
            if (one != null) {
                entity.setId(one.getId());
                return quartzMapper.updateById(entity) > 0;
            }
            return false;
        }
    
        @Override
        public boolean modifyTaskStatus(String jobName, String status) {
            LambdaQueryWrapper<QuartzEntity> wrapper = new LambdaQueryWrapper<>();
            wrapper.eq(QuartzEntity::getJobName, jobName);
            QuartzEntity one = quartzMapper.selectOne(wrapper);
            if (one != null) {
                one.setStatus(status);
                return quartzMapper.updateById(one) > 0;
            }
            return false;
        }
    
        @Override
        public List<QuartzEntity> notStartOrNotEndJobs() {
            return quartzMapper.notStartOrNotEndJobs();
        }
    }
    
    • 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

    Dao层

    /**
     * @author: Laity
     * @Project: JavaLaity
     */
    @Mapper
    public interface QuartzDao extends BaseMapper<QuartzEntity> {
        @Select("SELECT " +
                " *  " +
                "FROM " +
                " quartz_entity  " +
                "WHERE " +
                " ( end_time IS NULL  " +                                  // 没有结束时间的
                "  OR ( start_time < NOW() AND end_time > NOW())  " +      // 已经开始但未结束的
                "  OR start_time > NOW()  " +                              // 还未开始的
                " )  " +
                " AND `status` = '0'")
        List<QuartzEntity> notStartOrNotEndJobs();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    封装组件

    QuartzUtil.java

    封装了 定时任务的创建、定时任务的修改、定时任务的结束、定时任务的查询、定时任务的恢复(重启服务的时候使用)

    /**
     * @author: Laity
     * @Project: JavaLaity
     */
    @Component
    public class QuartzUtil {
    
        private static final SchedulerFactory SCHEDULER_FACTORY = new StdSchedulerFactory();
    
        @Autowired
        private QuartzService quartzService;
    
        /**
         * 添加一个定时任务
         *
         * @param name      任务名。每个任务唯一,不能重复。方便起见,触发器名也设为这个
         * @param group     任务分组。方便起见,触发器分组也设为这个
         * @param jobClass  任务的类类型  eg:MyJob.class
         * @param startTime 任务开始时间。传null就是立即开始
         * @param endTime   任务结束时间。如果是一次性任务或永久执行的任务就传null
         * @param cron      时间设置表达式。传null就是一次性任务
         */
        public boolean addJob(String name, String group, Class<? extends Job> jobClass,
                              Date startTime, Date endTime, String cron, JobDataMap jobDataMap) {
            try {
                // 第一步: 定义一个JobDetail
                JobDetail jobDetail = JobBuilder.newJob(jobClass).
                        withIdentity(name, group).setJobData(jobDataMap).build();
                // 第二步: 设置触发器
                TriggerBuilder<Trigger> triggerBuilder = newTrigger();
                triggerBuilder.withIdentity(name, group);
                triggerBuilder.startAt(toStartDate(startTime));
                triggerBuilder.endAt(toEndDate(endTime)); //设为null则表示不会停止
                if (StrUtil.isNotEmpty(cron)) {
                    triggerBuilder.withSchedule(CronScheduleBuilder.cronSchedule(cron));
                }
                Trigger trigger = triggerBuilder.build();
                //第三步:调度器设置
                Scheduler scheduler = SCHEDULER_FACTORY.getScheduler();
                scheduler.scheduleJob(jobDetail, trigger);
                if (!scheduler.isShutdown()) {
                    scheduler.start();
                }
            } catch (Exception e) {
                e.printStackTrace();
                return false;
            }
            //存储到数据库中
            QuartzEntity entity = new QuartzEntity();
            entity.setJobName(name);
            entity.setGroupName(group);
            entity.setStartTime(startTime != null ? startTime : new Date());
            entity.setEndTime(endTime);
            entity.setJobClass(jobClass.getName());
            entity.setCron(cron);
            entity.setJobDataMapJson(JSONUtil.toJsonStr(jobDataMap));
            entity.setStatus("0");
            quartzService.save(entity);
            return true;
        }
    
        /**
         * 修改一个任务的开始时间、结束时间、cron。不改的就传null
         *
         * @param name         任务名。每个任务唯一,不能重复。方便起见,触发器名也设为这个
         * @param group        任务分组。方便起见,触发器分组也设为这个
         * @param newStartTime 新的开始时间
         * @param newEndTime   新的结束时间
         * @param cron         新的时间表达式
         */
        public boolean modifyJobTime(String name, String group, Date newStartTime,
                                     Date newEndTime, String cron) {
            try {
                Scheduler scheduler = SCHEDULER_FACTORY.getScheduler();
                TriggerKey triggerKey = TriggerKey.triggerKey(name, group);
                Trigger oldTrigger = scheduler.getTrigger(triggerKey);
                if (oldTrigger == null) {
                    return false;
                }
                TriggerBuilder<Trigger> triggerBuilder = newTrigger();
                triggerBuilder.withIdentity(name, group);
                if (newStartTime != null) {
                    triggerBuilder.startAt(toStartDate(newStartTime));   // 任务开始时间设定
                } else if (oldTrigger.getStartTime() != null) {
                    triggerBuilder.startAt(oldTrigger.getStartTime()); //没有传入新的开始时间就不变
                }
                if (newEndTime != null) {
                    triggerBuilder.endAt(toEndDate(newEndTime));   // 任务结束时间设定
                } else if (oldTrigger.getEndTime() != null) {
                    triggerBuilder.endAt(oldTrigger.getEndTime()); //没有传入新的结束时间就不变
                }
                if (StrUtil.isNotEmpty(cron)) {
                    triggerBuilder.withSchedule(CronScheduleBuilder.cronSchedule(cron));
                } else if (oldTrigger instanceof CronTrigger) {
                    String oldCron = ((CronTrigger) oldTrigger).getCronExpression();
                    triggerBuilder.withSchedule(CronScheduleBuilder.cronSchedule(oldCron));
                }
                Trigger newTrigger = triggerBuilder.build();
                scheduler.rescheduleJob(triggerKey, newTrigger);    // 修改触发时间
            } catch (Exception e) {
                e.printStackTrace();
                return false;
            }
            // 修改数据库中的记录
            QuartzEntity entity = new QuartzEntity();
            entity.setJobName(name);
            entity.setGroupName(group);
            if (newStartTime != null) {
                entity.setStartTime(newStartTime);
            }
            if (newEndTime != null) {
                entity.setEndTime(newEndTime);
            }
            if (StrUtil.isNotEmpty(cron)) {
                entity.setCron(cron);
            }
            return quartzService.modifyJob(entity);
        }
    
        /**
         * 结束任务
         * @param jobName 任务名称
         * @param groupName 分组名称
         * @return boolean
         */
        public boolean cancelJob(String jobName, String groupName) {
            try {
                Scheduler scheduler = SCHEDULER_FACTORY.getScheduler();
                TriggerKey triggerKey = TriggerKey.triggerKey(jobName, groupName);
                scheduler.pauseTrigger(triggerKey); // 停止触发器
                scheduler.unscheduleJob(triggerKey);    // 移除触发器
                scheduler.deleteJob(JobKey.jobKey(jobName, groupName)); // 删除任务
            } catch (Exception e) {
                e.printStackTrace();
                return false;
            }
            //将数据库中的任务状态设为 取消
            return quartzService.modifyTaskStatus(jobName, "2");
        }
    
        /**
         * 获取所有job任务信息
         * @return list
         * @throws SchedulerException error
         */
        public List<QuartzEntity> getAllJobs() throws SchedulerException {
            Scheduler scheduler = SCHEDULER_FACTORY.getScheduler();
    
            List<QuartzEntity> quartzJobs = new ArrayList<>();
            try {
                List<String> triggerGroupNames = scheduler.getTriggerGroupNames();
                for (String groupName : triggerGroupNames) {
                    GroupMatcher<TriggerKey> groupMatcher = GroupMatcher.groupEquals(groupName);
                    Set<TriggerKey> triggerKeySet = scheduler.getTriggerKeys(groupMatcher);
                    for (TriggerKey triggerKey : triggerKeySet) {
                        Trigger trigger = scheduler.getTrigger(triggerKey);
                        JobKey jobKey = trigger.getJobKey();
                        JobDetail jobDetail = scheduler.getJobDetail(jobKey);
                        //组装数据
                        QuartzEntity entity = new QuartzEntity();
                        entity.setJobName(jobDetail.getKey().getName());
                        entity.setGroupName(jobDetail.getKey().getGroup());
                        entity.setStartTime(trigger.getStartTime());
                        entity.setEndTime(trigger.getStartTime());
                        entity.setJobClass(jobDetail.getJobClass().getName());
                        if (trigger instanceof CronTrigger) {
                            entity.setCron(((CronTrigger) trigger).getCronExpression());
                        }
                        entity.setJobDataMapJson(JSONUtil.toJsonStr(jobDetail.getJobDataMap()));
                        quartzJobs.add(entity);
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
            return quartzJobs;
        }
    
        public void recoveryAllJob() {
            List<QuartzEntity> tasks = quartzService.notStartOrNotEndJobs();
            if (tasks != null && tasks.size() > 0) {
                for (QuartzEntity task : tasks) {
                    try {
                        JobDataMap jobDataMap = JSONUtil.toBean(task.getJobDataMapJson(), JobDataMap.class);
                        JobDetail jobDetail = JobBuilder.newJob((Class<? extends Job>) Class.forName(task.getJobClass()))
                                .withIdentity(task.getJobName(), task.getGroupName())
                                .setJobData(jobDataMap).build();
                        TriggerBuilder<Trigger> triggerBuilder = TriggerBuilder.newTrigger();
                        triggerBuilder.withIdentity(task.getJobName(), task.getGroupName());
                        triggerBuilder.startAt(toStartDate(task.getStartTime()));
                        triggerBuilder.endAt(toEndDate(task.getEndTime()));
                        if (StrUtil.isNotEmpty(task.getCron())) {
                            triggerBuilder.withSchedule(CronScheduleBuilder.cronSchedule(task.getCron()));
                        }
                        Trigger trigger = triggerBuilder.build();
                        Scheduler scheduler = SCHEDULER_FACTORY.getScheduler();
                        scheduler.scheduleJob(jobDetail, trigger);
                        if (!scheduler.isShutdown()) {
                            scheduler.start();
                        }
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    
        private static Date toEndDate(Date endDateTime) {
            // endDateTime为null时转换会报空指针异常,所以需要进行null判断。
            // 结束时间可以为null,所以endDateTime为null,直接返回null即可
            return endDateTime != null ?
                    DateUtil.date(endDateTime) : null;
        }
    
        private static Date toStartDate(Date startDateTime) {
            // startDateTime为空时返回当前时间,表示立即开始
            return startDateTime != null ?
                    DateUtil.date(startDateTime) : new Date();
        }
    }
    
    • 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
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166
    • 167
    • 168
    • 169
    • 170
    • 171
    • 172
    • 173
    • 174
    • 175
    • 176
    • 177
    • 178
    • 179
    • 180
    • 181
    • 182
    • 183
    • 184
    • 185
    • 186
    • 187
    • 188
    • 189
    • 190
    • 191
    • 192
    • 193
    • 194
    • 195
    • 196
    • 197
    • 198
    • 199
    • 200
    • 201
    • 202
    • 203
    • 204
    • 205
    • 206
    • 207
    • 208
    • 209
    • 210
    • 211
    • 212
    • 213
    • 214
    • 215
    • 216
    • 217
    • 218
    • 219
    • 220

    SpringContextJobUtil.java

    用于获取Bean

    /**
     * @author: Laity
     * @Project: JavaLaity
     */
    
    @Component
    public class SpringContextJobUtil implements ApplicationContextAware {
    
        private static ApplicationContext context;
    
        @Override
        @SuppressWarnings("static-access")
        public void setApplicationContext(ApplicationContext context)
                throws BeansException {
            this.context = context;
        }
    
        public static Object getBean(String beanName) {
            return context.getBean(beanName);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    CronUtil.java

    你不可能让用户来输入cron表达式,所以根据用户的选择来解析成cron表达式

    package com.ys.control_core.util.job;
    
    import org.apache.commons.lang3.StringUtils;
    
    import java.util.ArrayList;
    import java.util.Arrays;
    import java.util.List;
    
    /**
     * @author: Laity
     * @Project: JavaLaity
     * @Description: 用于生成Cron表达式
     */
    
    public class CronUtil {
    
        /**
         * 每天
         */
        private static final int DAY_JOB_TYPE = 1;
        /**
         * 每周
         */
        private static final int WEEK_JOB_TYPE = 2;
        /**
         * 每月
         */
        private static final int MONTH_JOB_TYPE = 3;
    
        /**
         * 构建Cron表达式
         *
         * @param jobType        作业类型: 1/每天; 2/每周; 3/每月
         * @param minute         指定分钟
         * @param hour           指定小时
         * @param lastDayOfMonth 指定一个月的最后一天:0/不指定;1/指定
         * @param weekDays       指定一周哪几天:1/星期天; 2/...3/..   ; 7/星期六
         * @param monthDays      指定一个月的哪几天
         * @return String
         */
        public static String createCronExpression(Integer jobType, Integer minute, Integer hour, Integer lastDayOfMonth, List<Integer> weekDays, List<Integer> monthDays) {
            StringBuilder cronExp = new StringBuilder();
            // 秒
            cronExp.append("0 ");
            // 指定分钟,为空则默认0分
            cronExp.append(minute == null ? "0" : minute).append(" ");
            // 指定小时,为空则默认0时
            cronExp.append(hour == null ? "0" : hour).append(" ");
            // 每天
            if (jobType == DAY_JOB_TYPE) {
                // 日
                cronExp.append("* ");
                // 月
                cronExp.append("* ");
                // 周
                cronExp.append("?");
            } else if (lastDayOfMonth != null && lastDayOfMonth == 1) {
                // 日
                cronExp.append("L ");
                // 月
                cronExp.append("* ");
                // 周
                cronExp.append("?");
            }
            // 按每周
            else if (weekDays != null && jobType == WEEK_JOB_TYPE) {
                // 日
                cronExp.append("? ");
                // 月
                cronExp.append("* ");
                // 一个周的哪几天
                cronExp.append(StringUtils.join(weekDays, ","));
            }
            // 按每月
            else if (monthDays != null && jobType == MONTH_JOB_TYPE) {
                // 日
                cronExp.append(StringUtils.join(monthDays, ",")).append(" ");
                // 月
                cronExp.append("* ");
                // 周
                cronExp.append("?");
            } else {
                cronExp.append("* ").append("* ").append("?");
            }
            return cronExp.toString();
        }
    
        public static void main(String[] args) {
            String cronExpression = createCronExpression(1, 26, null, null, null, null);
            createCronExpression(2, 26, 9, 0, null, null);
            // 0/2 * * * * ?
            System.out.println(cronExpression);
        }
        /*
        {
        "jobType":2,
        "times":[
            {
                "minute":"30",
                "hour":"8"
            },
            {
                "minute":"00",
                "hour":"20"
            }
        ],
        "weekDays":[1,2]
        }
         */
    }
    
    • 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
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110

    ValidCron.java

    用于检验Cron表达式的正确性

    /**
     * @author: Laity
     * @Project: JavaLaity
     */
    public class ValidCronUtil {
    
        public static boolean isValidCronExpression(String exp) {
            if (exp == null || exp.length() ==0) return false;
            boolean validExpression = CronExpression.isValidExpression(exp);
            if (validExpression) System.out.println("cron expression is valid.");
            return validExpression;
        }
    
        public static void main(String[] args) {
            String cron = "0 26 9 ? * 1,2,3,4,5";
            boolean validCronExpression = isValidCronExpression(cron);
            System.out.println(validCronExpression);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    Controller层

    /**
     * @author: Laity
     * @Project: JavaLaity
     */
    @RestController
    @RequestMapping("/quartz/web")
    @Api(tags = "定时任务相关接口API")
    public class QuartzWebController {
        @Autowired
        private QuartzUtil quartzUtil;
    
        @Autowired
        private QuartzWebService quartzWebService;
    
        @PostMapping("/add-job")
        @ApiOperation(value = "添加任务", notes = "添加任务", httpMethod = "POST")
        public Rs AddQuartz(@Valid @RequestBody CreateJobParam entity) {
            JobDataMap jobDataMap = getJobDataMap(entity);
            String exp = CronUtil.createCronExpression(2, (Integer) jobDataMap.get("minute"), (Integer) jobDataMap.get("hour"), null, (List<Integer>) jobDataMap.get("weekDays"), null);
            boolean res = ValidCronUtil.isValidCronExpression(exp);
            if (!res) GlobalException.cast("参数有误!");
            entity.setCron(exp);
            boolean result = quartzUtil.addJob(entity.getJobname(), QuartzGroupEnum.T1.getValue(), MyJob.class,
                    entity.getStarttime(), entity.getEndtime(), entity.getCron(), jobDataMap, entity.getRoleid());
            return result ? Rs.success("添加成功") : Rs.error("添加失败");
        }
    
    
        @PostMapping("/modify-job")
        @ApiOperation(value = "修改任务", notes = "修改任务", httpMethod = "POST")
        public Rs modifyQuartz(@Valid @RequestBody UpdateJobParam entity) {
            JobDataMap jobDataMap = new JobDataMap();
            // false || false || true
            if (entity.getMinute() != null || entity.getHour() != null || entity.getWeekDays() != null) {
                String exp = CronUtil.createCronExpression(2, entity.getMinute(), entity.getHour(), null, entity.getWeekDays(), null);
                boolean res = ValidCronUtil.isValidCronExpression(exp);
                if (!res) GlobalException.cast("参数有误!");
                entity.setCron(exp);
                jobDataMap.put("minute", entity.getMinute());
                jobDataMap.put("hour", entity.getHour());
                jobDataMap.put("weekDays", entity.getWeekDays());
            }
            if (entity.getRoleid() != null) {
                jobDataMap.put("roleId", entity.getRoleid());
            }
            if (entity.getSendMessage() != null) {
                jobDataMap.put("megContent", entity.getSendMessage());
            }
            if (entity.getDayType() != null) {
                jobDataMap.put("dayType", entity.getDayType() == null ? null : 1);
            }
            boolean result = quartzUtil.modifyJobTime(entity.getJobname(), QuartzGroupEnum.T1.getValue(),
                    entity.getStarttime(), entity.getEndtime(), entity.getCron(), entity.getId(), jobDataMap, entity.getRoleid());
            return result ? Rs.success("修改成功") : Rs.success("修改失败");
        }
    
        @PostMapping("/cancel-job")
        @ApiOperation(value = "停止任务", notes = "停止任务", httpMethod = "POST")
        public Rs cancelTimeQuartz(@RequestBody QuartzEntity entity) {
            boolean result = quartzUtil.cancelJob(entity.getJobname(), QuartzGroupEnum.T1.getValue());
            return result ? Rs.success("操作成功") : Rs.success("操作失败");
        }
    
        @GetMapping("/get-all-jobs")
        @ApiOperation(value = "查询正在执行的任务", notes = "查询正在执行的任务", httpMethod = "GET")
        public Rs getAllJobs() throws SchedulerException {
            return Rs.success(quartzUtil.getAllJobs());
        }
    
        @GetMapping("/query-all-job")
        @ApiOperation(value = "查询所有创建的任务", notes = "查询所有创建的任务", httpMethod = "GET")
        public Rs getAllJob() {
            return Rs.success(quartzWebService.queryJobAll());
        }
    
        private JobDataMap getJobDataMap(CreateJobParam entity) {
            JobDataMap jobDataMap = new JobDataMap();
            jobDataMap.put("megContent", entity.getSendMessage());
            jobDataMap.put("roleId", entity.getRoleid());
            jobDataMap.put("dayType", entity.getDayType() == null ? null : 1);
            jobDataMap.put("minute", entity.getMinute());
            jobDataMap.put("hour", entity.getHour());
            jobDataMap.put("weekDays", entity.getWeekDays());
            return jobDataMap;
        }
    }
    
    • 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
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86

    Application启动类配置

    /**
     * @author Laity
     */
    @MapperScan("com.laity.control_core.dao")
    @ComponentScan({"com.laity.*"})
    @EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
    @SpringBootApplication() // exclude = {SecurityAutoConfiguration.class, SecurityFilterAutoConfiguration.class}
    public class ControlApplication implements ApplicationRunner {
    
        @Resource
        private QuartzUtil quartzUtil;
    
        public static void main(String[] args) {
            SpringApplication.run(ControlApplication.class, args);
        }
    
        @Override
        public void run(ApplicationArguments args) throws Exception {
            quartzUtil.recoveryAllJob();
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    MyJob定时业务

    /**
     * @author: Laity
     * @Project: JavaLaity
     */
    @Component("MysqlJob")
    public class MysqlJob implements Job {
        protected final Logger logger = LoggerFactory.getLogger(this.getClass());
    
        @Override
        public void execute(JobExecutionContext context) throws JobExecutionException {
            JobKey key = context.getJobDetail().getKey();
            JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
            System.out.println(key.getName());
            String megContent = (String) context.getTrigger().getJobDataMap().get("megContent");
            Integer roleId = (Integer) context.getTrigger().getJobDataMap().get("roleId");
            Integer dayType = (Integer) context.getTrigger().getJobDataMap().get("dayType");
            // 需要使用ServiceBean => ARR arr = (ARR) SpringContextJobUtil.getBean("arrWebService");
            ……
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    SchedulerListener监听器

    /**
     * @author: Laity
     * @Project: JavaLaity
     * @Description: 全局监听器 - 接收所有的Trigger/Job的事件通知
     */
    public class MyJobListener implements SchedulerListener {
    
        @Override
        public void jobScheduled(Trigger trigger) {
            // 用于部署JobDetail时调用
            String jobName = trigger.getJobKey().getName();
            System.out.println("用于部署JobDetail时调用==>" + jobName);
        }
    
        @Override
        public void jobUnscheduled(TriggerKey triggerKey) {
            // 用于卸载JobDetail时调用
            System.out.println(triggerKey + "完成卸载");
        }
    
        @Override
        public void triggerFinalized(Trigger trigger) {
            // 当endTime到了就会执行
            System.out.println("触发器被移除:" + trigger.getJobKey().getName());
            QuartzWebService quartzService = (QuartzWebService) SpringContextJobUtil.getBean("quartzService");
            quartzService.modifyTaskStatus(trigger.getJobKey().getName(), "2");
        }
    
        @Override
        public void triggerPaused(TriggerKey triggerKey) {
            System.out.println(triggerKey + "正在被暂停");
        }
    
        @Override
        public void triggersPaused(String s) {
            // s = triggerGroup
            System.out.println("触发器组:" + s + ",正在被暂停");
        }
    
        @Override
        public void triggerResumed(TriggerKey triggerKey) {
            System.out.println(triggerKey + "正在从暂停中恢复");
        }
    
        @Override
        public void triggersResumed(String s) {
            System.out.println("触发器组:" + s + ",正在从暂停中恢复");
        }
    
        @Override
        public void jobAdded(JobDetail jobDetail) {
            System.out.println(jobDetail.getKey() + "=>已添加工作任务");
        }
    
        @Override
        public void jobDeleted(JobKey jobKey) {
            System.out.println(jobKey + "=> 已删除该工作任务");
        }
    
        @Override
        public void jobPaused(JobKey jobKey) {
            System.out.println(jobKey + "=> 工作任务正在被暂停");
        }
    
        @Override
        public void jobsPaused(String s) {
            System.out.println("工作任务组:" + s + ",正在被暂停");
        }
    
        @Override
        public void jobResumed(JobKey jobKey) {
            System.out.println(jobKey + "jobKey正在从暂停中恢复");
        }
    
        @Override
        public void jobsResumed(String s) {
            System.out.println("工作任务组:" + s + ",正在从暂停中恢复");
        }
    
        @Override
        public void schedulerError(String s, SchedulerException e) {
    
        }
    
        @Override
        public void schedulerInStandbyMode() {
    
        }
    
        @Override
        public void schedulerStarted() {
    
        }
    
        @Override
        public void schedulerStarting() {
            System.out.println("=============================开启监听===========================");
        }
    
        @Override
        public void schedulerShutdown() {
    
        }
    
        @Override
        public void schedulerShuttingdown() {
    
        }
    
        @Override
        public void schedulingDataCleared() {
    
        }
    }
    
    • 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
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114

    监听器使用

    •             scheduler.scheduleJob(jobDetail, trigger);
                  scheduler.getListenerManager().addSchedulerListener(new MyJobListener()); // 使用监听器
      
      • 1
      • 2

    封装接收前端Param

    /**
     * @author: Laity
     * @Project: JavaLaity
     */
    @Data
    @ApiModel(value = "创建定时任务")
    @Accessors(chain = true)
    public class CreateJobParam {
    
        @ApiModelProperty(value = "任务名称")
        @NotBlank(message = "任务名称不能为空")
        private String jobname;
    
        @ApiModelProperty(value = "开始时间")
        private Date starttime;
    
        @ApiModelProperty(value = "结束时间")
        private Date endtime;
    
        // @Ignore
        @ApiModelProperty(value = "cron表达式")
        private String cron;
    
        @ApiModelProperty(value = "角色id")
        @NotNull(message = "角色ID不能为空")
        private Integer roleid;
    
        @ApiModelProperty(value = "消息内容")
        @NotBlank(message = "消息内容不能为空")
        private String sendMessage;
    
        @ApiModelProperty(value = "因为有的消息是发给昨日的某人,所以设立此标识符,正常的不用传值,非正常:1")
        private Integer dayType;
    
        @ApiModelProperty(value = "指定分钟 0-60")
        @Max(60)
        @Min(0)
        private Integer minute;
        @Max(24)
        @Min(0)
        @ApiModelProperty(value = "指定小时 0-24")
        private Integer hour;
        @ApiModelProperty(value = "星期列表: 1/星期天、2/星期一、3/...、7/星期六")
        private List<Integer> weekDays;
    }
    
    • 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

    测试

    在这里插入图片描述
    在这里插入图片描述

    说明

    可根据自己的需求自行配置其余配置:多线程、Reids缓存、MySQL、Quartz其余配置等

    解决问题

    修改Quartz中的JobDetailMap数据

    因为我在JobDetailMap中放入了一些数据,但是修改之后数据不发生变化

    解决思路:

    最早写法是:

    			// addjob中存jobDetail
                JobDetail jobDetail = JobBuilder.newJob(jobClass).
                        withIdentity(name, group).setJobData(jobDataMap).build();
                TriggerBuilder<Trigger> triggerBuilder = newTrigger();
    
    • 1
    • 2
    • 3
    • 4

    更改后写法:

    // 在构建Trigger实例时使用.usingJobData()方法实现
                TriggerBuilder<Trigger> triggerBuilder = newTrigger();
                triggerBuilder.withIdentity(name, group);
                triggerBuilder.startAt(toStartDate(startTime));
                triggerBuilder.endAt(toEndDate(endTime));
                triggerBuilder.usingJobData(jobDataMap);  // usingJobData传入jobDataMap
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    其中出现的问题:停止服务,查询配置不一致

    存数据库最初写法:

    // getAllJobs
    entity.setJobDataMapJson(JSONUtil.toJsonStr(jobDetail.getJobDataMap()));
    
    • 1
    • 2

    现在写法:

    // oldTrigger需要自己构建
    entity.setJobdatamapjson(JSONUtil.toJsonStr(oldTrigger.getJobDataMap()));
    
    • 1
    • 2

    那么任务job的数据呢?

    最早的写法:获取jobDataMap数据

    String megContent = (String) context.getJobDetail().getJobDataMap().get("megContent");
    Integer roleId = (Integer) context.getJobDetail().getJobDataMap().get("roleId");
    Integer dayType = (Integer) context.getJobDetail().getJobDataMap().get("dayType");
    
    • 1
    • 2
    • 3

    期间也断点调试使用过其它数据获取方式

    String megContent1 = (String) jobDataMap.get("megContent");
    Integer roleId1 = (Integer) jobDataMap.get("roleId");
    Integer dayType1 = (Integer) jobDataMap.get("dayType");
    
    • 1
    • 2
    • 3

    最终实现写法:数据不论是临时修改还是怎么都可以实时更新

    String megContent = (String) context.getTrigger().getJobDataMap().get("megContent");
    Integer roleId = (Integer) context.getTrigger().getJobDataMap().get("roleId");
    Integer dayType = (Integer) context.getTrigger().getJobDataMap().get("dayType");
    
    • 1
    • 2
    • 3

    昨日之深渊,今日之浅谈;我是Laity,正在前行的Laity。

  • 相关阅读:
    Pytorch 入门
    Spring核心与设计思想
    MySQL的安装
    排序进阶----快速排序
    C++ 函数对象(Function Object)是什么?C++重载小括号()是什么作用?
    公共管理学选择题(最终版)
    Synchronized与锁升级
    AIR32F103(四) 27倍频216MHz,CoreMark跑分测试
    【必知必会的MySQL知识】⑤DCL语言
    行业内比较优秀的UI设计师都使用哪个设计工具
  • 原文地址:https://blog.csdn.net/duyun0/article/details/134325199