SpringBoot动态定时任务

682 阅读8分钟

前言

使用 @Scheduled 注解来实现定时任务的调度虽然说很方便,但是这并不符合大多数的场景,我们希望可以动态的修改定时任务的执行与关闭。

相关链接:springBoot 实现静态定时任务

准备工作

(1)pom 文件

项目所需的配置的依赖如下:

 <dependencies>
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-web</artifactId>
         </dependency>
 ​
         <dependency>
             <groupId>com.baomidou</groupId>
             <artifactId>mybatis-plus-boot-starter</artifactId>
         </dependency>
 ​
         <dependency>
             <groupId>mysql</groupId>
             <artifactId>mysql-connector-java</artifactId>
         </dependency>
 ​
         <dependency>
             <groupId>org.projectlombok</groupId>
             <artifactId>lombok</artifactId>
         </dependency>
     </dependencies>

(2)yaml 配置文件

yaml 文件中主要对数据源进行了配置

 spring:
   # 数据源相关
   datasource:
     driver-class-name: com.mysql.cj.jdbc.Driver
     url: jdbc:mysql://localhost:3306/schedule?useUnicode=true&characterEncoding=utf-8&serverTimezone=UTC&useSSL=false
     username: root
     password: root

(3)数据库

既然要实现定时任务的动态开启与关闭,必然要使用数据库,主要涉及如下三张表:

t_job 用于保存定时任务

image-20221011214517723.png

这张表中的数据,一般不会修改,表中的每条记录代表代码中执行定时任务的一个 bean,这种硬编码是无可避免的,如果说要实现向表中添加一个任务,就在代码中动态添加一个执行任务的 bean ,实现起来非常困难,至少我现在毫无头绪。

t_corn 用于保存 corn 表达式

image-20221011214546982.png

这张表,主要存一些 cron 表达式,通常也不会修改,如果要修改的话,也是能很容易实现修改 bean 所执行的任务的。

t_cron_job 用于保存工作表 t_job 与 cron 表达式表 t_cron 之间的关系

image-20221011214610839.png 此表将执行任务的 bean 与 cron 表达式关联了起来,其 status字段用于控制定时任务的关闭与开启。

构建项目

(1)生成项目结构

项目的持久层使用的是 MyBatis-Plus 框架,整体结构使用 MybatisX 插件自动生成,没什么可说的,这里先看一下 CronJobService 及其实现类。

 public interface CornJobService extends IService<CronJob> {
 ​
     List<CronJob> list(String status);
 ​
 }

CronJobService 的 list 方法用于查询所有工作的定时任务。

 @Service
 public class CornJobServiceImpl extends ServiceImpl<CronJobMapper, CronJob> implements CornJobService {
     @Override
     public List<CronJob> list(String status) {
         LambdaQueryWrapper<CronJob> queryWrapper = new LambdaQueryWrapper<>();
         queryWrapper.eq(CronJob::getStatus, status);
         return list(queryWrapper);
     }
 }
 ​

再来看一下 Cron 实体类

 @Data
 @TableName("t_cron")
 public class Cron extends BaseEntity{
 ​
     // 主键
     @TableId(type = IdType.AUTO)
     private Long id;
     // 任务表达式
     private String cron;
     // 表达式描述
     private String description;
 ​
     // 提供转换为 CronTrigger 的工具方法
     public CronTrigger toCronTrigger() {
         return new CronTrigger(this.cron);
     }
 }

这里提供了一个 toCronTrigger 方法,用于将 cron 表达式转换为 CronTrigger 对象,下文会详细说明。

(2)配置执行任务的 bean

 @Configuration
 @Slf4j
 public class JobConfig {
 ​
     @Bean(name = "clean")
     public Worker cleanJob() {
         return () -> log.info("清理数据任务: {}", LocalDateTime.now());
     }
 ​
     @Bean(name = "count")
     public Worker countJob() {
         return () -> log.info("统计报表: {}", LocalDateTime.now());
     }
 ​
     @Bean(name = "updateViewCount")
     public Worker updateViewCountJob() {
         return () -> log.info("更新浏览量: {}", LocalDateTime.now());
     }
 ​
     @Bean(name = "sendMail")
     public Worker sendMailJob() {
         return () -> log.info("发送邮件: {}", LocalDateTime.now());
     }
 }

上面配置的 bean 类型都是 Worker ,为自定义函数式接口,代码如下:

 @FunctionalInterface
 public interface Worker extends Runnable {
 ​
     void doWork();
 ​
     default void run() {
         doWork();
     }
 }

至于为什么这样配置,下面会说到。

铺垫知识

(1)ThreadPoolTaskScheduler

执行定时任务

我们使用 ThreadPoolTaskScheduler 来调度定时任务,其执行定时任务的五个个核心方法如下:

  • schedule(Runnable task, Date stateTime):在指定时间执行一次定时任务
  • schedule(Runnable task, Trigger trigger):动态创建指定表达式 cron 的定时任务
  • scheduleAtFixedRate(Runnable task, long period):指定间隔时间执行一次任务,间隔时间为前一次执行开始到下次任务开始时间
  • scheduleWithFixedDelay(Runnable task, Date startTime, long delay):指定间隔时间执行一次任务,指定任务的第一次执行时间
  • scheduleWithFixedDelay(Runnable task, long delay):指定间隔时间执行一次任务

这里,也就解释了执行任务的 bean 为何要实现 Runnable。此外,本例子使用的执行任务的核心方法是:schedule(Runnable task, Trigger trigger),第一个参数不用多说了,是处理定时任务的 bean,第二个参数,表示一个触发器,用于确定与其关联的任务的下一个执行时间。

取消定时任务

每开启一个定时任务,即执行 threadPoolTaskScheduler.schedule() 方法后,都会产生一个 ScheduledFuture 对象,这个就是当前的任务调度器,停止定时任务的时候需要使用这个调度器,调用的方法为 scheduledFuture.cancel(true)

(2)@PostConstruct 注解

@PostConstruct 注解是 Java 自带的注解,属于 jsr250 规范,在方法上加该注解会在项目启动的时候执行该方法,也可以理解为在 spring 容器初始化的时候执行该方法,需要注意的是 @PostConstruct 注解标注的方法为返回值为 void 的非静态方法。

(3)依赖搜索

Springoot 的依赖搜索,就是 Spring 依赖注入的机制,对所需求的 Bean 进行查找。

下面通过例子,来说明依赖搜索的具体实现。

  1. 定义一个接口
 public interface Animal {
     void say();
 }

很简单的一个接口,仅仅一个抽象方法 say()。

下面用 @Component 注解,Animal 接口的两个实现类

 @Component
 public class Dog implements Animal{
     @Override
     public void say() {
         System.out.println("汪~~~~");
     }
 }
 ​
 @Component
 public class Cat implements Animal {
     @Override
     public void say() {
         System.out.println("喵~~~");
     }
 }

下面,就在 controller 中来演示 Spring 的依赖搜索

 @RestController
 public class TestController {
 ​
     @Autowired
     List<Animal> animalList;
 ​
     @Autowired
     Map<String,Animal> animalMap;
 ​
     @GetMapping("/test")
     public void test(){
         animalList.forEach(Animal::say);
     }
 ​
     @GetMapping("/index")
     public void index(){
         animalMap.entrySet().forEach(System.out::println);
         animalMap.keySet().forEach(System.out::println);
     }
 }

在 Controller 中,我并未明确注入 Animal 接口的实现类,看一下,访问 Controller 中的两个接口,控制台打印如下

 http://localhost:8080/test
 喵~~~
 汪~~~~
     
 http://localhost:8080/index
 cat=com.project.domain.Cat@11a8042c
 dog=com.project.domain.Dog@6a4ccef7
 cat
 dog

通过 @Autowired 注解,注入的接口,若是 List 则会搜索 Spring 容器中所有实现该接口的 Bean,若是 Map 则 key 为 Bean 的名字。

(4)Map的compute 方法

Java 中的 Map 类的 compute 方法,定义为 :

default V compute(K key, BiFunction<? super K, ? super V, ? extends V> remappingFunction)

用于计算指定 key 的值,如果 Map 中不存在指定的 key ,则计算时值为null,,并将计算的结果作为 key 的值

  1. 如果 Map 存在此 key 且值不为 null ,且 remappingFunction 函数的结果是 null,则删除此key,否则不做改变
  2. 如果 remappingFunction 函数抛出了异常,则 compute 把异常抛出,Map 不做改变

实现

(1)配置定时任务调度器

@Configuration
@AllArgsConstructor
public class TaskConfig {

    /**
     * 存储有效使用中的 Worker有些工作可能没配置使用
     * key 是数据库表 t_job 的 id
     */
    @Bean(name = "workerMap")
    public Map<Long, Worker> workerMap() {
        return new ConcurrentHashMap<>();
    }
    /**
     * 存储 t_corn 表的记录
     * key 是 t_cron id, value 是将 corn 表达式转为了后续操作需要的 CronTrigger 对象
     * 参看 {@link Cron#toCronTrigger()}
     */
    @Bean(name = "triggerMap")
    public Map<Long, Trigger> triggerMap() {
        return new ConcurrentHashMap<>();
    }

    /**
     * 1. 每开启一个定时任务,会产生一个 ScheduledFuture 对象.
     * 2. 我们要关闭定时任务,就要调用 scheduledFuture.cancel(true) 方法.
     * 3. 所以我们需要在每次开启定时任务的同时维护其引用对象 ScheduledFuture,也就是此 map 的作用.
     * 4. 在 spring 容器中维护一个 ScheduledFuture 的注册表,用于我们来操作其开启关闭.
     */
    @Bean(name = "scheduledFutureMap")
    public Map<String, ScheduledFuture<?>> scheduledFutureMap() {
        return new ConcurrentHashMap<>();
    }

    /**
     * 用表驱动法简化 if 判断,也就是提前配置好映射关系
     * 这个 Map 是用于简化判断的,如: if(status==1) do... if(status==0) do...
     * 存储开启或关闭定时任务的两个操作,用消费型函数式接口来封装
     */
    @Bean(name = "operationMap")
    public Map<String, Consumer<CronJob>> operationMap() {
        return new ConcurrentHashMap<>();
    }

    /**
     * 一个用于开启定时任务的线程池;我们关注的核心方法是:
     * threadPoolTaskScheduler.schedule(工作内容, 触发器)
     * 此方法用于开启一个定时任务.
     */
    @Bean(name = "threadPoolTaskScheduler")
    public ThreadPoolTaskScheduler threadPoolTaskScheduler() {
        ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler();
        threadPoolTaskScheduler.setPoolSize(Runtime.getRuntime().availableProcessors());
        threadPoolTaskScheduler.setThreadNamePrefix("WorKerThread:");
        threadPoolTaskScheduler.setWaitForTasksToCompleteOnShutdown(true);
        threadPoolTaskScheduler.setAwaitTerminationSeconds(30);
        return threadPoolTaskScheduler;
    }

}

(2)初始化配置

@AllArgsConstructor
@SpringBootConfiguration
public class TaskInitializer {

    private final CornJobService cornJobService;
    private final CornService cornService;
    private final JobService jobService;

    // 依赖搜索:拿到 spring 容器中的所有实现类,即 JobConfig 配置类中配置的 bean
    private final Map<String, Worker> allWorkerMap;
    // 拿到所有配置工作的 bean
    private final Map<Long, Worker> workerMap;
    // 触发器注册表
    private final Map<Long, Trigger> triggerMap;
    // 定时任务注册表
    private final Map<String, ScheduledFuture<?>> scheduledFutureMap;
    // 操作注册表
    private final Map<String, Consumer<CronJob>> operationMap;
    // 线程池
    private final ThreadPoolTaskScheduler threadPoolTaskScheduler;

    /**
     * jsr250规范中的注解,初始化方法的一种, 同 bean initMethod,InitializingBean 接口一样
     * 用于在 bean 属性赋值后的初始化逻辑,只是三者在调用时机上略有不同而已
     * 此方法用于以上 map 数据的初始化
     */
    @PostConstruct
    public void initMap() {
        // 初始化工作注册表
        initWorkerMap();
        // 初始化触发器注册表
        initTriggerMap();
        // 初始化ScheduledFuture注册表
        initScheduledFutureMap();
        // 初始化操作注册表
        initOperationMap();
    }

    private void initWorkerMap() {
        // 查询数据库配置的所有 job 记录
        var jobList = jobService.list();
        var effectiveWorkerMap = jobList.stream()
                .collect(Collectors.toMap(Job::getId, job -> allWorkerMap.get(job.getBeanName())));
        workerMap.putAll(effectiveWorkerMap);
    }
    private void initTriggerMap() {
        // 查询数据库配置的所有 corn 记录
        var cornList = cornService.list();
        var cronTriggerMap = cornList.stream()
                .collect(Collectors.toMap(Cron::getId, Cron::toCronTrigger));
        triggerMap.putAll(cronTriggerMap);
    }
    private void initScheduledFutureMap() {
        // 查询数据库配置的corn-job关系表
        var cornJobList = cornJobService.list("1");
        cornJobList.forEach(cornJob -> {
            ScheduledFuture<?> schedule = threadPoolTaskScheduler
                    .schedule((workerMap.get(cornJob.getJobId())), triggerMap.get(cornJob.getCronId()));
            scheduledFutureMap.put(cornJob.getStoreKey(), schedule);
        });
    }
    private void initOperationMap() {
        // 打开操作
        Consumer<CronJob> open = cornJob -> {
            var key = cornJob.getStoreKey();
            // 将任务添加至注册表中进行维护,这里应该先判断存在再开启
            scheduledFutureMap.compute(key, (k, v) -> {
                Optional<ScheduledFuture<?>> ov = Optional.ofNullable(v);
                ov.ifPresent(v0 -> v0.cancel(true));
                // 动态开启一个定时任务
                // 这里应该直接返回最新打开的 schedule 定时任务对象,而不是原先的
                return threadPoolTaskScheduler
                    .schedule(workerMap.get(cornJob.getJobId()), triggerMap.get(cornJob.getCronId()));
            });
        };

        // 关闭操作
        Consumer<CronJob> close = cornJob -> {
            var key = cornJob.getStoreKey();
            // 取消此定时任务
            var scheduledFuture = scheduledFutureMap.get(key);
            Optional.ofNullable(scheduledFuture).ifPresent(os -> os.cancel(true));
            // 从注册表中移除
            scheduledFutureMap.remove(key);
        };
        operationMap.put("1", open);
        operationMap.put("0", close);
    }

}

下面来说一下各个 map 的作用及其之间的关系。

  • allWorkerMap:通过依赖搜索,拿到 JobConfig 中配置的所有 Job,key 为 Bean 的名称
  • workerMap:此 map 依赖于 allWorkerMap 将数据库中的 Job 记录和 allWorkerMap 中的 Worker 联系了起来,key 为 Job 记录的 id
  • triggerMap:里面保存了数据库中配置的所有 cron 表达式对应的 Trigger 对象,key 为 Cron 记录的 id
  • scheduledFutureMap:在此 map 初始化时,会查询 status 字段为 1 的记录,也就是可以工作的 job 与 cron 之间的关系,有了这个关系,就可以通过 threadPoolTaskScheduler 来调度定时任务。
  • operationMap:用于操作 scheduledFutureMap

下面是测试代码

@RequiredArgsConstructor
@RestController
@RequestMapping("/task")
public class TaskController {

    private final CornJobService cornJobService;
    private final Map<String, Consumer<CronJob>> operationMap;

    // 向scheduled_corn_job中插入记录,并刷新定时任务
    @PostMapping
    public String add(@RequestBody CronJob cornJob) {
        // 操作关系表
        cornJobService.save(cornJob);
        // 刷新内存中定时任务
        refresh(cornJob.getId());
        return "添加成功,定时任务已开启";
    }

    // 开启一个定时任务或者关闭一个定时任务
    @PutMapping
    public String update(@RequestBody CronJob cornJob) {
        // 操作关系表
        cornJobService.updateById(cornJob);
        // 刷新内存中定时任务
        refresh(cornJob.getId());
        return "操作成功";
    }

    // 刷新所有,我们为了测试方便,直接改表记录,调用此方法就行
    @GetMapping("/refreshAll")
    public void refreshAll() {
        List<CronJob> cornJobList = cornJobService.list();
        cornJobList.forEach(this::refresh);
    }

    // 通过id去查询数据库完成单个定时任务的刷新,调用下面重载方法,传入查询的实时结果
    private void refresh(Long id) {
        refresh(cornJobService.getById(id));
    }

    // 重载方法:根据传入的查询的实时结果,拿到定时任务的状态(开启/关闭),去 operationMap 中取对应操作来执行
    private void refresh(CronJob cornJob) {
        operationMap.get(cornJob.getStatus()).accept(cornJob);
    }
}

重点看一下,refresh 方法,在 operationMap 中存入的开启和关闭定时任务的方法key 为 "1" 和 "0",而定时任务的开启与关闭是通过 t_corn_job 表中的 status 字段控制的。

operationMap.put("1", open);
operationMap.put("0", close);

(3)测试

向表中插入的数据如下:

 INSERT INTO `t_corn` VALUES (1, '0/1 * * * * ?', '每秒执行一次', NULL, NULL, NULL, NULL);
 INSERT INTO `t_corn` VALUES (2, '0/10 * * * * ?', '每10秒执行一次', NULL, NULL, NULL, NULL);
 INSERT INTO `t_corn` VALUES (3, '0/30 * * * * ?', '每30秒执行一次', NULL, NULL, NULL, NULL);
 INSERT INTO `t_corn` VALUES (4, '0 0/1 * * * ?', '每分组执行一次', NULL, NULL, NULL, NULL);
 ​
 INSERT INTO `t_job` VALUES (1, 'clean', '清理数据', NULL, NULL, NULL, NULL);
 INSERT INTO `t_job` VALUES (2, 'count', '统计报表', NULL, NULL, NULL, NULL);
 INSERT INTO `t_job` VALUES (3, 'updateViewCount', '更新浏览量', NULL, NULL, NULL, NULL);
 INSERT INTO `t_job` VALUES (4, 'sendMail', '发送邮件', NULL, NULL, NULL, NULL);
 ​
 INSERT INTO `t_corn_job` VALUES (1, 'task1', 2, 1, '1', NULL, NULL, NULL, NULL);
 INSERT INTO `t_corn_job` VALUES (2, 'task2', 1, 2, '0', NULL, NULL, NULL, NULL);

这里只配置开启了一个 定时任务,当项目启动后,可直接修改数据库中的数据然后调用刷新接口来观察结果,这里就不贴图了。