SpringBoot实现用户定制的定时任务(动态定时任务)

     阅读:54

SpringBoot实现用户定制的定时任务(动态定时任务)

情景

  我们知道SpringBoot能使用@Scheduled注解来进行定时任务的控制,该注解需要配合Cron表达式以及在启动类上添加@EnableScheduling注解才能使用。
  不过我们现在的假定情景并不是程序员设定的定时任务,而是用户可以在我们的网页上定制定时任务,前端将该任务的信息发送到后端后,后端可以将此任务存入数据库并在规定的时间内执行。例如用户可以设定定时任务的执行时间段,执行时刻等,并可以随时新增、删除和改变定时任务。
  接下来我们来使用SpringBoot实现这个假定情景

实现

实体类Cron

  我们需要创建实体类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注解进行属性与数据库主键的映射。

Service层:接口CronService以及其实现类CronServiceImpl

  我们需定义接口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自带的周期性任务线程池ScheduleThreadPoolExecutorThreadPoolTaskScheduler支持根据cron表达式创建周期性任务,这正是我们所需要的。其实ThreadPoolTaskScheduler底层也是通过线程池ScheduleThreadPoolExecutor实现的,不过多加了一些支持Cron表达式的代码。ThreadPoolTaskScheduler的核心成员变量是ScheduledExecutorService scheduledExecutor,一个 ExecutorService 可以安排任务在给定的延迟后运行,或者定期执行。ScheduledFuture表示可以取消的延迟结果动作。 通常,ScheduledFuture是使用 ScheduledExecutorService 执行任务的返回结果。

  因此,我们使用ThreadPoolTaskScheduler来启动线程,执行定时任务。但这还不够,我们有很多定时任务,我们必须保存它们的信息,以便查找,因为我们还有停止任务和更新任务操作。于是我们可以创建一个HashMap来保存定时任务的信息,key肯定是cron的id,value为ScheduledExecutorService 执行任务的返回结果ScheduledFuture。我们可以调用ScheduledFuturecancel方法来终止任务的执行。

  接下来我们来考虑CronService接口3个方法的具体实现。对于startCron方法,我们需要避免它重复启动已经启动的任务,因此我们要先判断该任务是否已经在HashMap中,若不在,我们再去判断当前日期是否在执行日期范围内,若在,我们通过Cron的执行时刻属性构造cron表达式,创建实现了Runnable接口的内部类来实现任务要做的事,调用ThreadPoolTaskSchedulerschedule方法启动该任务,并将该任务存入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中,只有当前时间在执行时间段内,才会创建线程去执行定时任务,这样是肯定不行的。我们还需要创建一个定时任务管理器,让它每天定时去启动数据库中尚未启动的定时任务,并删除已经过期的定时任务,防止数据积压。

定时任务管理器CronManageTask

  这时我们就需要用@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实现动态定时任务的需求就完成了