在开发过程中有时候会遇到需要定期执行某项任务(如定期向某个外部服务请求回执)。
在Springboot已经提供了简单的实现(@EnableScheduling
和@Scheduled
),但是使用这种方式实现难以做到可控的任务暂停恢复,因此借助Quartz
来实现支持更加复杂行为的定时任务。
你可以直接引入Quartz
,但如果你希望在Springboot的项目中引用Quartz
,则可以引用如下依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
<version>${spring-boot-quartz-version}</version>
</dependency>
本文中实现一个简单的Springboot应用,这个应用每分钟向控制台输出一段包含当前系统时间的字符串(充当真实项目中需要定时执行的业务逻辑)。在此之上,可以通过web接口控制该任务的暂停和恢复。
创建项目所使用的Spring boot:
- Spring boot 2.4.9
一、创建空的Springboot项目(略)
在这一步中,可以选择集成接下来要使用的spring-boot-starter-web
、spring-boot-starter-quartz
。
二、创建Job
在Quartz
中,创建一个Job时,需要定时执行的业务逻辑被包含在实现了Job
的JobBean中,与此Job
相关的一些信息则配置在一个JobDetail
的Bean中。用户需要继承QuartzJobBean
,并实现其中的executeInternal
方法。Scheduler
每次触发任务时,都会在一个新的线程实例化这个Bean并调用QuartzJobBean
的execute
方法。executeInternal
则在execute
中被调用。
public class TimelyInvokedTask extends QuartzJobBean {
@Override
protected void executeInternal(JobExecutionContext jobExecutionContext) throws JobExecutionException {
System.out.println("TestQuartz----Timely:" + DateUtil.now());
}
}
JobDetail
是一个配置Bean,需要被Spring管理起来。新建一个用于配置Quartz
的Bean QuartzConfiguration
,在其中编写一个方法,利用JobBuilder
构造一个JobDetail
。
@Configuration
public class QuartzConfiguration {
public static final String TIMELY_JOB_KEY = "timelyTask";
@Bean("timelyJob")
public JobDetail getTimelyJob(){
return JobBuilder.newJob(TimelyInvokedTask.class)
.withIdentity(TIMELY_JOB_KEY)
.storeDurably().build();
}
}
withIdentity(String)
配置这个Job
的身份,将来需要对这个Job
进行管理时,就可以通过这个名字找到它;storeDurably(boolean = true)
表示当没有Trigger
与之绑定时,该任务是否还被保存在上下文中,这个值默认为false
,这个方法将其设置为true
。
三、Schedule
顾名思义,Schedule
表示一个任务被执行的计划。根据不同的需求,Quartz
也提供不同种类的Schedule
,对于一些间隔时间较短的任务,以下的两种便可满足绝大多数的需求。
3.1 SimpleSchedule
这类计划支持每隔固定的系统时间触发有限或无限次数的任务。Schedule
同样有相应的Builder来构造它,使用SimpleScheduleBuilder
可以构造出SimpleSchedule
。
在其中已经预置了数个模式,通过方法名称就可以辨识,例如repeatMinutelyForever
即表示“每分钟重复触发,没有次数上限”,当给定参数时,可以指定多少分钟触发一次;repeatHourlyForTotalCount
表示“每小时触发,最多触发count
次”,同样也可以设置间隔。除了这些方法外,使用with...
开头的方法可以单独指定其中的某一个参数,以及misfire
时采取的行为(这个将在后文解释)。
我们默认这些任务将会立即开始,现使用如下的方法构造一个简单计划:
ScheduleBuilder<SimpleTrigger> scheduleBuilder = SimpleScheduleBuilder.repeatMinutelyForever();
先预览一下这样配置的任务将会以何种方式进行:
可以看到它确实在以分钟为单位执行,由于计划器(Scheduler
)初始化时正好在40s的时刻上,因此之后的所有任务也都在每分钟的第40s时触发了。然而在真实业务逻辑中,往往需要在“整点”或每日0点等特别的时刻触发任务,我们当然不可能确保应用启动且Scheduler
初始化完成时正好处于整点,你当然可以通过获得系统当前时间计算出下一次触发的时间并配置在扳机(Trigger
)中,但这样过于复杂,还有更好的实现方法。
3.2 CronSchedule
CronSchedule
是一类通过cron表达式配置任务触发时机的计划。cron表达式非常灵活,甚至可以表达类似“在每个工作日的上午8点到下午5点的每个半点触发”这样非常复杂的逻辑。关于cron表达式,可以参考下面的文章:
使用cron表达式配置CronSchedule
通过CronScheduleBuilder
完成。
ScheduleBuilder<CronTrigger> scheduleBuilder = CronScheduleBuilder.cronSchedule("0 * * * * ? *");
上述的cron表达式的含义是,对于任意的(*)年、月、日、小时、分钟,且不论(?)今天是周几,每逢秒为0(也就是在每分钟的00秒),就触发一次计划任务。通过这样配置出来的效果如下:
四、Trigger
在说Trigger
之前,先小结一下目前使用到的Quartz
的组件。Job
是需要定时触发的任务,它包含的是业务逻辑;Schedule
是计划,它表示任务应当以何种周期或规律执行。但是在配置计划时,并没有绑定任务,可以看到一个Schedule
其实可以应用于多个Job
;而一个Job
应该也可以按照不同的Schedule
混合触发,真正将这两者关系起来的就是Trigger
。
Trigger
也由一个Builder构造:
@Configuration
public class QuartzConfiguration {
public static final String TIMELY_JOB_KEY = "timelyTask";
public static final String TIMELY_TRIGGER_KEY = "timelyTrigger";
//other code
@Bean
public Trigger getTimelyTrigger(){
ScheduleBuilder<CronTrigger> scheduleBuilder = CronScheduleBuilder.cronSchedule("0 * * * * ? *");
return TriggerBuilder.newTrigger().forJob(TIMELY_JOB_KEY)
.withSchedule(scheduleBuilder)
.withIdentity(TIMELY_TRIGGER_KEY)
.startNow().build();
}
}
在构造Trigger
时,通过配置在Job上的identity绑定一个任务,然后将ScheduleBuilder
的对象绑定上。Trigger也有一个identity,在本例中并没有作用,只需要注意其区别于Job的identity即可。最后startNow()
和startAt(Date)
表示这个Trigger最近一次生效的时间,当使用startAt(Date)
时,在规定的时间之前不会触发定时任务。
以上Job
、Schedule
和Trigger
就是Quartz
最基本的3个组成部分,只要完成这3者的配置,定时任务就已经可以执行了。效果如3.2中的图所示。
五、暂停/恢复/触发定时任务
5.1 Scheduler
Scheduler
是Quartz
中的“管理员”,能够调整和管理Job
、Schedule
和Trigger
的状态和行为。当我们需要暂停、恢复或触发定时任务时,管理的对象主要是Job
。
Scheduler
是通过对象工厂创建的,运行在Spring boot中的Quartz
在应用启动时就实例化了一个SchedulerFactoryBean
,可以通过这个Bean来获得一个Scheduler
。一般来说也无需多个Scheduler
,因此也可以将其作为一个Bean交给Spring管理。
@Configuration
public class QuartzConfiguration {
//other code
@Bean("testScheduler")
public Scheduler getScheduler(SchedulerFactoryBean schedulerFactoryBean){
return schedulerFactoryBean.getScheduler();
}
}
编写一个Service,在其中注入Scheduler
。Scheduler
通过JobKey
提供对任务的暂停(pause)、恢复(resume)和触发(trigger)方法,在Service中分别包装这个3个方法,然后在Controller中注入并调用即可。
@Service("quartzTestService")
@Setter(onMethod_={@Autowired})
class QuartzTestServiceImpl implements QuartzTestService {
private Scheduler testScheduler;
@Override
public void pauseTimelyTask() {
JobKey key = new JobKey(QuartzConfiguration.TIMELY_JOB_KEY);
try {
testScheduler.pauseJob(key);
} catch (SchedulerException e) {
e.printStackTrace();
}
}
@Override
public void resumeTimelyTask() {
JobKey key = new JobKey(QuartzConfiguration.TIMELY_JOB_KEY);
try {
testScheduler.resumeJob(key);
} catch (SchedulerException e) {
e.printStackTrace();
}
}
@Override
public void forceRunTask() {
JobKey key = new JobKey(QuartzConfiguration.TIMELY_JOB_KEY);
try {
testScheduler.triggerJob(key);
} catch (SchedulerException e) {
e.printStackTrace();
}
}
}
下面是我们分别通过web请求一次调用pause、resume和trigger方法的运行效果:
- 暂停:2021-08-10 15:58:00
- 恢复:2021-08-10 15:59:59
- 触发:2021-08-10 16:00:43
5.2 Misfire处理
在5.1的图中,可以发现在暂停的任务恢复的瞬间,任务触发了一次,随后才按照正常的节奏继续执行。这就是Quartz
的Misfire的处理机制之一。每当到了计划配置的时刻,任务触发,这个行为在Quartz
中用“fire”(开火)表示,因此Trigger
既可以理解为“触发器”,又可以理解为“扳机”。但是如果由于各种原因(例如暂停),而导致本应触发的任务没有触发,这就称为“misfire”。
当本应执行的任务没有按时执行,在业务逻辑上来说往往需要进行补救(recovery),而针对CronSchedule
,Quartz
也提供了3种策略来处理misfires。
5.2.1 Misfire_Handling_Instruction_Do_Nothing
当发现有Misfires时不作任何补救,直到下一次触发任务时正常执行。采用这种方法也就是忽略了没有按期执行的任务。
@Bean("timelyJobTrigger")
public Trigger getTimelyTrigger(){
ScheduleBuilder<CronTrigger> scheduleBuilder = CronScheduleBuilder.cronSchedule("0/10 * * * * ? *")
.withMisfireHandlingInstructionDoNothing();
return TriggerBuilder.newTrigger().forJob(TIMELY_JOB_KEY)
.withSchedule(scheduleBuilder)
.withIdentity(TIMELY_TRIGGER_KEY)
.startNow().build();
}
为了便于测试,将任务的运行间隔改为10s。
5.2.2 Misfire_Handling_Instruction_Fire_And_Proceed
当发现有Misfire时,立即补发一次,然后按照预定的节奏正常执行以后的任务。这也是默认的策略。
@Bean
public Trigger getTimelyTrigger(){
ScheduleBuilder<CronTrigger> scheduleBuilder = CronScheduleBuilder.cronSchedule("0/10 * * * * ? *")
.withMisfireHandlingInstructionFireAndProceed();
return TriggerBuilder.newTrigger().forJob(TIMELY_JOB_KEY)
.withSchedule(scheduleBuilder)
.withIdentity(TIMELY_TRIGGER_KEY)
.startNow().build();
}
测试截图略(见5.1图)。
5.2.3 Misfire_Handling_Instruction_Ignore_Misfires
从名称上看,似乎与DoNothing很像,但此处忽略的其实是Misfire Handling Instruction,也就是说Trigger将立即将手头堆积的任务(Misfires)立即发射出去,应发尽发。在说明文档中举了一个例子,如果你的任务每15秒触发一次,而任务暂停了5分钟的话,再次启动时,这个任务将会连续触发20次。
@Bean("contextJobTrigger")
public Trigger getContextTestTrigger(){
ScheduleBuilder<CronTrigger> scheduleBuilder = CronScheduleBuilder.cronSchedule("1 * * * * ? *")
.withMisfireHandlingInstructionDoNothing();
return TriggerBuilder.newTrigger().forJob(CONTEXT_JOB_KEY)
.withSchedule(scheduleBuilder)
.withIdentity(CONTEXT_TRIGGER_KEY)
.usingJobData(TRIGGER_TEST_KEY, "context aware!")
.startNow().build();
}