阅读:54
我们知道SpringBoot能使用@Scheduled注解来进行定时任务的控制,该注解需要配合Cron表达式以及在启动类上添加@EnableScheduling注解才能使用。
不过我们现在的假定情景并不是程序员设定的定时任务,而是用户可以在我们的网页上定制定时任务,前端将该任务的信息发送到后端后,后端可以将此任务存入数据库并在规定的时间内执行。例如用户可以设定定时任务的执行时间段,执行时刻等,并可以随时新增、删除和改变定时任务。
接下来我们来使用SpringBoot实现这个假定情景
我们需要创建实体类Cron代表定时任务,这里假设Cron有如下属性:执行时刻、任务标题、任务开始的日期、任务截止日期,以及存入数据库所需要的几个基本属性:id(作为主键)、创建时间、更新时间、状态status
我们用一个BaseEntity来保存基本属性,Cron将继承BaseEntity,使用MyBatisPlus作为ORM框架,Cron的代码如下:
@Data
@EqualsAndHashCode(callSuper = true)
public class Cron extends BaseEntity {
private static final long serialVersionUID = 1L;
@NotNull(message = "执行时刻不能为空")
private LocalTime executeTime;
@NotBlank(message = "标题不能为空")
private String title;
@NotNull(message = "截止日期不能为空")
private LocalDate deadTime;
@NotNull(message = "开始日期不能为空")
private LocalDate startTime;
}
这里需要注意的是lombok的@Data注解相当于@Getter @Setter @RequiredArgsConstructor @ToString @EqualsAndHashCode这5个注解的合集。
其中,@EqualsAndHashCode注解会生成equals(Object other) 和 hashCode()方法。我们重写了equals(Object other) 和 hashCode()方法,就是为了在两个对象的属性相同时equals能返回true,认为它们两个相同。但@EqualsAndHashCode默认仅使用该类中定义的属性且不会调用父类的equals(Object other) 和 hashCode()方法。这是什么意思呢?仅使用该类中的属性,也就是如果该类的两个对象属性相同,即使这两个对象对应父类的属性不同,equals也会认为它们两个对象相同,从而返回true。默认的实现中不使用父类的属性,将会导致问题,比如,有多个类有相同的部分属性,恰好id(数据库主键)在父类中,那么就会存在部分对象在比较时,它们并不相等,却因为lombok自动生成的equals(Object other) 和 hashCode()方法判定为相等,从而导致出错。所以我们在使用@Data时同时需要加上@EqualsAndHashCode(callSuper=true)注解来解决这一问题,加上(callSuper=true),其生成的equals(Object other) 和 hashCode()方法将调用父类的方法,也就是会考虑父类的属性。
加上@EqualsAndHashCode(callSuper=true)就符合我们的要求了,这样即使两个Cron对象,它们的属性相同,但它们在父类中对应的主键不同,equals将认为它们是不同的对象,返回false。
对于前端传参,我们需要进行非空验证,我们在实体类中还加入了@NotNull和@NotBlank注解,并且使用message配置提示语句。这两个注解都来自于javax.validation.constraints包,该包内还有另一个常用注解@NotEmpty,@NotEmpty 用在集合上面,一般用来校验List类型(不能注释枚举类型),而且长度必须大于0。@NotBlank 用在String上面,一般用来校验String类型不能为空,而且调用trim()后,长度必须大于0。@NotNull 可用在所有类型上,校验是否为非null。这些注解都需要配合@Validated注解使用,从而检验Controller的入参是否符合规范,例如:
public Result save(@Validated @RequestBody Cron cron)
Cron的父类BaseEntity的代码如下:
@Data
public class BaseEntity implements Serializable {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
private LocalDateTime created;
private LocalDateTime updated;
private Integer status;
}
由于实体类需要在网络中传输,所以BaseEntity需要实现Serializable接口,这里使用MyBatisPlus的@TableId注解进行属性与数据库主键的映射。
我们需定义接口CronService来实现用户定制定时任务需求。用户能创建、删除、修改定时任务,创建任务即判断当前日期是否为既定的执行日期,若是则启动定时任务。删除任务即判断该任务是否已被启动,若是,则将其停止。修改任务即先停止该任务,再重新启动该任务。我们让CronService继承MyBatisPlus的IService接口,对应的数据库操作直接在Controller层中调用相应方法即可,我们就不需要再在CronService中定义了。于是,我们需要在CronService中定义startCron(Cron cron)
、stopCron(Cron cron)
、changeCron(Cron cron)
三个方法,分别对应用户的创建、删除、修改定时任务操作。
public interface CronService extends IService<Cron> {
void startCron(Cron cron);
void stopCron(Cron cron);
void changeCron(Cron cron);
}
我们创建CronService的实现类CronServiceImpl来实现上述3个方法。
对于每个定时任务,我们肯定是让线程池提供一个线程去执行它,springboot提供了ThreadPoolTaskScheduler
,可以很方便地对重复执行的任务进行调度管理;相比于通过java自带的周期性任务线程池ScheduleThreadPoolExecutor
,ThreadPoolTaskScheduler
支持根据cron表达式创建周期性任务,这正是我们所需要的。其实ThreadPoolTaskScheduler
底层也是通过线程池ScheduleThreadPoolExecutor
实现的,不过多加了一些支持Cron表达式的代码。ThreadPoolTaskScheduler
的核心成员变量是ScheduledExecutorService scheduledExecutor
,一个 ExecutorService
可以安排任务在给定的延迟后运行,或者定期执行。ScheduledFuture
表示可以取消的延迟结果动作。 通常,ScheduledFuture
是使用 ScheduledExecutorService
执行任务的返回结果。
因此,我们使用ThreadPoolTaskScheduler
来启动线程,执行定时任务。但这还不够,我们有很多定时任务,我们必须保存它们的信息,以便查找,因为我们还有停止任务和更新任务操作。于是我们可以创建一个HashMap来保存定时任务的信息,key肯定是cron的id,value为ScheduledExecutorService
执行任务的返回结果ScheduledFuture
。我们可以调用ScheduledFuture
的cancel
方法来终止任务的执行。
接下来我们来考虑CronService接口3个方法的具体实现。对于startCron方法,我们需要避免它重复启动已经启动的任务,因此我们要先判断该任务是否已经在HashMap中,若不在,我们再去判断当前日期是否在执行日期范围内,若在,我们通过Cron的执行时刻属性构造cron表达式,创建实现了Runnable接口的内部类来实现任务要做的事,调用ThreadPoolTaskScheduler
的schedule
方法启动该任务,并将该任务存入HashMap中。
对于stopCron方法,我们通过Cron的id从HashMap中查找其对应的ScheduledFuture
,若不为空,则调用其cancel(true)
方法停止任务,并将其从HashMap中删除。cancel方法的参数传入true会中断线程停止任务,而传入false则会让线程正常执行至完成。
changeCron方法的实现很简单,先调用stopCron,再调用startCron即可
CronServiceImpl的完整代码如下:
@Service
public class CronServiceImpl extends ServiceImpl<CronMapper, Cron> implements CronService {
private Logger log = LoggerFactory.getLogger(getClass());
@Autowired
private ThreadPoolTaskScheduler threadPoolTaskScheduler;
private Map<Long, ScheduledFuture<?>> futureMap = new HashMap<>();
@Bean
public ThreadPoolTaskScheduler threadPoolTaskScheduler() {
return new ThreadPoolTaskScheduler();
}
@Override
public void startCron(Cron cron) {
if (futureMap.containsKey(cron.getId())) {
log.warn("已经存在重复任务,任务id:{},任务标题:{},任务提醒时刻:{},任务开始时间:{},任务截止时间:{}",
cron.getId(), cron.getTitle(), cron.getExecuteTime(), cron.getStartTime(), cron.getDeadTime());
return;
}
if (LocalDate.now().isEqual(cron.getStartTime()) || LocalDate.now().isEqual(cron.getDeadTime()) ||
(LocalDate.now().isAfter(cron.getStartTime()) && LocalDate.now().isBefore(cron.getDeadTime()))) {
LocalTime executeTime = cron.getExecuteTime();
String cronExp = StringUtils.join(Integer.valueOf(executeTime.getSecond()).toString(), " ", Integer.valueOf(executeTime.getMinute()).toString()
, " ", Integer.valueOf(executeTime.getHour()).toString(), " * * ?");
ScheduledFuture<?> future = threadPoolTaskScheduler.schedule(new MyRunnable(cron), new CronTrigger(cronExp));
futureMap.put(cron.getId(), future);
log.info("启动定时任务成功,任务id:{},任务标题:{},任务提醒时刻:{},任务开始时间:{},任务截止时间:{}",
cron.getId(), cron.getTitle(), cron.getExecuteTime(), cron.getStartTime(), cron.getDeadTime());
}
}
@Override
public void stopCron(Cron cron) {
ScheduledFuture<?> future = futureMap.get(cron.getId());
if (future != null) {
future.cancel(true);
futureMap.remove(cron.getId());
log.info("关闭定时任务成功,任务id:{},任务标题:{},任务提醒时刻:{},任务开始时间:{},任务截止时间:{}",
cron.getId(), cron.getTitle(), cron.getExecuteTime(), cron.getStartTime(), cron.getDeadTime());
}
}
@Override
public void changeCron(Cron cron) {
stopCron(cron);// 先停止,在开启.
startCron(cron);
}
private class MyRunnable implements Runnable {
private Cron cron;
public MyRunnable(Cron cron) {
this.cron = cron;
}
@Override
public void run() {
// 定义任务要做的事,完成任务逻辑
}
}
}
其实我们这样做还没有完成需求,因为在startCron中,只有当前时间在执行时间段内,才会创建线程去执行定时任务,这样是肯定不行的。我们还需要创建一个定时任务管理器,让它每天定时去启动数据库中尚未启动的定时任务,并删除已经过期的定时任务,防止数据积压。
这时我们就需要用@Scheduled注解了,我们定义CronManageTask中的cronManage()方法,加上@Scheduled注解,让它每天定时去启动数据库中尚未启动的定时任务,并停止并删除已经过期的定时任务。
使用@Scheduled注解需要注意几个点,一是CronManageTask需使用@Component注解,且此类中不能包含其他带任何注解的方法;二是cronManage()方法不能有参数、不能有返回值;三是需添加@EnableScheduling注解到启动类上面。
违反上述任一点,@Scheduled注解就不会生效
CronManageTask的代码如下:
@Component
public class CronManageTask {
private Logger log = LoggerFactory.getLogger(getClass());
@Autowired
private CronService cronService;
@Scheduled(cron = "0 0 3 * * ?")
public void cronManage() {
List<Cron> list = cronService.list();
list.forEach(c -> {
if (LocalDate.now().isAfter(c.getDeadTime())) {
cronService.stopCron(c);
cronService.removeById(c.getId());
log.info("删除过期定时任务成功,任务id:{},任务标题:{},任务提醒时刻:{},任务开始时间:{},任务截止时间:{}",
c.getId(), c.getTitle(), c.getExecuteTime(), c.getStartTime(), c.getDeadTime());
} else {
log.info("尝试启动尚未start的定时任务,任务id:{},任务标题:{},任务提醒时刻:{},任务开始时间:{},任务截止时间:{}",
c.getId(), c.getTitle(), c.getExecuteTime(), c.getStartTime(), c.getDeadTime());
cronService.startCron(c);
}
});
}
}
cron表达式"0 0 3 * * ?"
表示每天凌晨3点执行。需要注意的是,@Scheduled注解的cron表达式一般都要定义在配置文件里,方便修改,使用cron = "${xiaolinbao.cron}"
,并在application.yml中配置xiaolinbao.cron=0 0 3 * * ?
即可。上面的代码偷懒了。
至此,使用SpringBoot实现动态定时任务的需求就完成了