前言
使用 @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 用于保存定时任务
这张表中的数据,一般不会修改,表中的每条记录代表代码中执行定时任务的一个 bean,这种硬编码是无可避免的,如果说要实现向表中添加一个任务,就在代码中动态添加一个执行任务的 bean ,实现起来非常困难,至少我现在毫无头绪。
t_corn 用于保存 corn 表达式
这张表,主要存一些 cron 表达式,通常也不会修改,如果要修改的话,也是能很容易实现修改 bean 所执行的任务的。
t_cron_job 用于保存工作表 t_job 与 cron 表达式表 t_cron 之间的关系
此表将执行任务的 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 进行查找。
下面通过例子,来说明依赖搜索的具体实现。
- 定义一个接口
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 的值
- 如果 Map 存在此 key 且值不为 null ,且 remappingFunction 函数的结果是 null,则删除此key,否则不做改变
- 如果 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 记录的 idtriggerMap:里面保存了数据库中配置的所有 cron 表达式对应的Trigger对象,key 为 Cron 记录的 idscheduledFutureMap:在此 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);
这里只配置开启了一个 定时任务,当项目启动后,可直接修改数据库中的数据然后调用刷新接口来观察结果,这里就不贴图了。