场景
对于单机模式,spring Scheduled能够很方便的解决我们定时任务的问题。但是在集群(微服务)模式下,就不能使用单机的模式了,因为某些任务,是不可重复执行的。例如每月一次的月报统计,又例如定时向日志分析系统推送今天的本地日志,等等。此时我们要引入集群的工作模式quartz,但是以往的业务,是动的代码越少越好(那啥啥的名言:小改小BUG,大改大BUG,不改无BUG😜)
功能目标
我们希望按照以下的标准完成我们的迁移
- 能够尽量不要调整业务代码,最好只需要Ctrl+CV即可完成调整
- 使用Scheduled类似的配置,但能够适配集群的工作模式
分析问题
根据以上目标,下一步会遇到一些问题:
- 你需要几台服务器来运行你的定时任务
- 如何实现quartz的自动配置,是否需要增加新的配置项
- Scheduled的配置能不能适配quartz,如何以最小的修改适配quartz工作模式
- 新增一个定时任务,应该是怎样一个更新模式
- quartz job默认不支持注入,如何适配spring的注入模式
解决问题
部署模式
对于集群环境,当然是集群的方式来部署了,我们至少需要两台定时服务器来执行定时任务。为什么是两台,一是作为高可用某台定时服务器瘫痪了,另外一台能够接手。因为quartz的cluster模式下,任务的获取会通过锁来解决。qutz_locks表提供行级锁,在并发时,通过加锁运行避免单个任务同时被执行以及重复执行。二是通常不需要部署多台,因为对应的数据量还未达到需要多台并发执行的程度,在未进行日志统一汇总的时候只有两台定时服务器也便于查看服务器运行日志。
配置模式
再次先温习一下Quartz的概念
1.Job:Job是任务执行的流程,是一个类
2.JobDetail:JobDetail是Job是实例,是一个对象,包含了该实例的执行计划和所需要的数据
3.Trigger:Trigger是定时器,决定任务何时执行
4.Scheduler:调度器,调度器接受一组JobDetail+Trigger即可安排一个任务,其中一个JobDetail可以关联多个Trigger
然后对比Spring Scheduled,发现Spring Scheduled有以下一些不同的特征
- 执行的任务也是一个类,一个spring托管的Bean,也具备主执行方法
- 没有执行环境数据,简单的调用无参方法即可
- 定时器是通过@Scheduled Annotaion来配置,配置后spring通过线程池执行定时器方法
- 调度即spring的线程池,spring默认是单线程执行,也可以配置为多线程。在多线程情况下,执行周期超时会重复执行。
- 一个任务关联一个定时规则,这点quartz比spring Scheduled要灵活,但是我们现在是迁移代码。不增加业务模式,因此我们也可以认为quartz下每个Job一一对应一个Trigger
因此我们采用以下方案来解决:
配置任务仍旧基于Bean扫描,将所有Job接口的实现类加载到Spring,并且利用spring的ApplicationContext工具基于接口的获取方式,将所有的Job批量获取出来进行配置,Group默认为DEFAULT,Trigger和Job的命名都以Bean name来进行命名。
for(Entry<String, Job> entry: SpringContextHelper.getBeansOfType(Job.class).entrySet()) {
try {
Scheduled scheduled = entry.getValue().getClass().getAnnotation(Scheduled.class);
if(scheduled != null) { //自动任务配置管理
if(StringUtils.isNotBlank(scheduled.cron())) { //cron表达式
addJob(entry.getValue().getClass().getName(), scheduled.cron(), createMode);
}else if(scheduled.fixedRate() > -1){ //定时周期
addJob(entry.getValue().getClass().getName(), scheduled.fixedRate(), createMode);
}
}
} catch (SecurityException e) {
e.printStackTrace();
}
}
以上是代码片段,核心是getBeansOfType(Job.class)来获取所有spring托管bean里有哪些quart任务(决定了Spring Scheduled的任务必须更改一下继承或接口实现)。以及读取Scheduled Annotation的配置,Scheduled Annotation需要由方法挪到类上,并变更为新包,因为我们写了一个同样属性的类命名一致,只是包位置不一样而已。
我们将Bean name,Scheduled的定时配置,以及CreateMode(这个是自己业务的一个定义,表示系统为初次初始化,需要立即启动所有的定时器)作为quart的配置进行处理,接下来看以下的代码片段、
private void addJob(String jobClass, String cronExpression, long fixedRate, boolean autoStart) { //添加任务,用于初始化或运维系统增量更新
Scheduler scheduler = schedulerFactoryBean.getScheduler();
try {
Class<? extends Job> aClass = (Class <? extends Job>)Class.forName(jobClass);
Trigger trigger = scheduler.getTrigger(TriggerKey.triggerKey(aClass.getSimpleName()));
if(trigger == null) { //任务不存在才会增加
JobDetail jobDetail = JobBuilder.newJob(aClass).withIdentity(aClass.getSimpleName()).build();
if(StringUtils.isBlank(cronExpression)) {
trigger = TriggerBuilder.newTrigger()
.withIdentity(aClass.getSimpleName())
.withSchedule(SimpleScheduleBuilder.repeatSecondlyForever((int)fixedRate / 1000))
.startAt(UtilDateTime.addSeconds(new Date(), 1))
.build();
}else {
trigger = TriggerBuilder.newTrigger()
.withIdentity(aClass.getSimpleName())
.withSchedule(CronScheduleBuilder.cronSchedule(cronExpression))
.startAt(UtilDateTime.addSeconds(new Date(), 1))
.build();
}
scheduler.scheduleJob(jobDetail, trigger);
if(!autoStart) {
scheduler.pauseTrigger(trigger.getKey());
}
}
} catch (SchedulerException | ClassNotFoundException e) {
throw new RuntimeException(e);
}
}
以上代码实现了一些quart任务的创建规则:
- 执行类即job对应的类,而Job和Trigger的名称则直接使用Job类的Simplename;
- 创建时先检查是否有相同的任务存在,如果存在,则不添加任务;
- 在非autostart的情况下,如果发现有新的任务,则添加为PAUSED状态。为什么要是PAUSED状态呢?因为此场景可能是业务更新,在高可用的前提下,先更新A服务器,后续再更新B服务器。那么问题来了,更新A服务器后,如果定时任务立即开启,则有可能会因为B服务器没有对应的Job类而导致执行失败。为了避免此情况,我们添加一个PAUSED的任务,在我们同时更新B服务器后,我们手动的通过管理控制台调用Trigger状态改变功能来启动定时任务。
这样我们问题2、3、4都解决了,剩下还剩一个问题要解决
支持Spring注入
支持spring注入网络上都有对应的代码。在此再啰嗦几句自己的理解:
关键在于JobFactory的创建方法,在每个Job根据class newInstance之后,使用spring的工具自动根据该类的autowired annotation从spring容器里找合适的bean注入。也就是
/**
* job工厂,可以使用Runnalbe自动注入
*/
public class QuartzJobFactory extends AdaptableJobFactory {
//AutowireCapableBeanFactory 可以将一个对象添加到SpringIOC容器中,并且完成该对象注入
@Autowired
private AutowireCapableBeanFactory autowireCapableBeanFactory;
/**
*
* 将实例化的任务对象手动添加到Spring容器中,完成对象的注入,否则程序会报空指针异常
* @param bundle
* @return
* @throws Exception
*/
@Override
protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception {
Object jobInstance=super.createJobInstance(bundle);
this.autowireCapableBeanFactory.autowireBean(jobInstance);
return jobInstance;
}
}
下一步我们要拿它来替换原来的JOB工厂
@Configuration
public class QuartzConfiguration {
@Bean
public QuartzJobFactory jobFactory() {
QuartzJobFactory factory = new QuartzJobFactory();
return factory;
}
}
完工!看看我们做了这些功夫后。迁移是怎样一个模式
系统初始化标志
这个问题不应该在业务迁移时提出来的,本来在Scheduled模式下,就需要解决的,因为我们需要在系统完全准备好(例如索引构建,所有spring初始化等)才能去正常执行,否则可能因为定时任务过早执行而各种资源为准备好。为了让代码更完整,也贴出来凑点字数😆
public abstract class BaseQuartzJob implements Job, Runnable{
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
if(SysInitBeanHelper.inited) {
this.run();
}else {
// throw new JobExecutionException("Data not ready", false);
//NO OP
}
}
}
迁移示例
这样业务代码迁移只需要两步就可以了
1)更改继承类
2)将@Scheduled配置Annotaion放到类上,并变更import
原代码
package org.ccframe.subsys.core.job;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import com.google.common.collect.Iterables;
import org.ccframe.subsys.core.service.UserSearchService;
@Component
public class MyTestJob implements Runnable{
@Autowired
private UserSearchService userSearchService;
@Override
@Scheduled(cron="0/3 * * * * ?")
public void run() {
// TODO Auto-generated method stub
System.out.println("当前用户数=>" + Iterables.size(userSearchService.listAll()));
}
}
迁移后
package org.ccframe.subsys.core.job;
import org.quartz.DisallowConcurrentExecution;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import com.google.common.collect.Iterables;
import org.ccframe.commons.quartz.BaseQuartzJob;
import org.ccframe.commons.quartz.Scheduled;
import org.ccframe.subsys.core.service.UserSearchService;
@Component
@Scheduled(cron="0/3 * * * * ?")
@DisallowConcurrentExecution //执行时间超过周期不另启线程执行
public class MyTestJob extends BaseQuartzJob{
@Autowired
private UserSearchService userSearchService;
@Override
public void run() {
// TODO Auto-generated method stub
System.out.println("当前用户数=>" + Iterables.size(userSearchService.listAll()));
}
}
完整的QuartzService
package org.ccframe.subsys.core.service;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Map.Entry;
import org.apache.commons.lang.StringUtils;
import org.quartz.CronScheduleBuilder;
import org.quartz.Job;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.JobKey;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.SimpleScheduleBuilder;
import org.quartz.Trigger;
import org.quartz.Trigger.TriggerState;
import org.quartz.TriggerBuilder;
import org.quartz.TriggerKey;
import org.quartz.impl.matchers.GroupMatcher;
import org.quartz.impl.triggers.CronTriggerImpl;
import org.quartz.impl.triggers.SimpleTriggerImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;
import org.springframework.stereotype.Service;
import org.ccframe.commons.helper.SpringContextHelper;
import org.ccframe.commons.quartz.JobMonitorListener;
import org.ccframe.commons.quartz.Scheduled;
import org.ccframe.commons.util.UtilDateTime;
import org.ccframe.subsys.core.dto.QuartzRowDto;
@Service
public class QuartzService {
@Autowired
private SchedulerFactoryBean schedulerFactoryBean;
public void addJob(String jobClass, long fixedRate, boolean autoStart) {
addJob(jobClass, null, fixedRate, autoStart);
}
public void addJob(String jobClass, String cronExpression, boolean autoStart) {
addJob(jobClass, cronExpression, -1, autoStart);
}
public void pauseJob(String triggerKey) {
try {
schedulerFactoryBean.getScheduler().pauseTrigger(TriggerKey.triggerKey(triggerKey, Scheduler.DEFAULT_GROUP));
} catch (SchedulerException e) {
throw new RuntimeException(e);
}
}
public void resumeJob(String triggerKey) {
try {
schedulerFactoryBean.getScheduler().resumeTrigger(TriggerKey.triggerKey(triggerKey, Scheduler.DEFAULT_GROUP));
} catch (SchedulerException e) {
throw new RuntimeException(e);
}
}
private void addJob(String jobClass, String cronExpression, long fixedRate, boolean autoStart) { //添加任务,用于初始化或运维系统增量更新
Scheduler scheduler = schedulerFactoryBean.getScheduler();
try {
Class<? extends Job> aClass = (Class <? extends Job>)Class.forName(jobClass);
Trigger trigger = scheduler.getTrigger(TriggerKey.triggerKey(aClass.getSimpleName()));
if(trigger == null) { //任务不存在才会增加
JobDetail jobDetail = JobBuilder.newJob(aClass).withIdentity(aClass.getSimpleName()).build();
if(StringUtils.isBlank(cronExpression)) {
trigger = TriggerBuilder.newTrigger()
.withIdentity(aClass.getSimpleName())
.withSchedule(SimpleScheduleBuilder.repeatSecondlyForever((int)fixedRate / 1000))
.startAt(UtilDateTime.addSeconds(new Date(), 1))
.build();
}else {
trigger = TriggerBuilder.newTrigger()
.withIdentity(aClass.getSimpleName())
.withSchedule(CronScheduleBuilder.cronSchedule(cronExpression))
.startAt(UtilDateTime.addSeconds(new Date(), 1))
.build();
}
scheduler.scheduleJob(jobDetail, trigger);
if(!autoStart) {
scheduler.pauseTrigger(trigger.getKey());
}
}
} catch (SchedulerException | ClassNotFoundException e) {
throw new RuntimeException(e);
}
}
public List<QuartzRowDto> list() {
Scheduler scheduler = schedulerFactoryBean.getScheduler();
List<QuartzRowDto> resultList = new ArrayList<>();
try {
for(String groupName: scheduler.getTriggerGroupNames()) {
for(TriggerKey triggerKey: scheduler.getTriggerKeys(GroupMatcher.groupEquals(groupName))) {
Trigger trigger = scheduler.getTrigger(triggerKey);
JobKey jobKey = trigger.getJobKey();
Class<? extends Job> jobClass = scheduler.getJobDetail(jobKey).getJobClass();
TriggerState triggerState = scheduler.getTriggerState(triggerKey);
if(trigger instanceof CronTriggerImpl) { //基于Corn的
resultList.add(new QuartzRowDto(triggerKey.toString(), jobKey.toString(), jobClass.getName(), triggerState.toString(), ((CronTriggerImpl)trigger).getCronExpression()));
}else if(trigger instanceof SimpleTriggerImpl) { //简单的,周期性的
resultList.add(new QuartzRowDto(triggerKey.toString(), jobKey.toString(), jobClass.getName(), triggerState.toString(), ((SimpleTriggerImpl)trigger).getRepeatInterval()));
}else {
//no used
}
}
}
return resultList;
} catch (SchedulerException e) {
throw new RuntimeException(e);
}
}
public void initJob(boolean createMode) {
try {
schedulerFactoryBean.getScheduler().getListenerManager().addJobListener(new JobMonitorListener()); //TODO 对接任务执行日志系统
} catch (SchedulerException e) {
throw new RuntimeException(e);
}
for(Entry<String, Job> entry: SpringContextHelper.getBeansOfType(Job.class).entrySet()) {
try {
Scheduled scheduled = entry.getValue().getClass().getAnnotation(Scheduled.class);
if(scheduled != null) { //自动任务配置管理
if(StringUtils.isNotBlank(scheduled.cron())) { //cron表达式
addJob(entry.getValue().getClass().getName(), scheduled.cron(), createMode);
}else if(scheduled.fixedRate() > -1){ //定时周期
addJob(entry.getValue().getClass().getName(), scheduled.fixedRate(), createMode);
}
}
} catch (SecurityException e) {
e.printStackTrace();
}
}
}
}
------------------------------------ 完 ------------------------------------
怎么完了呢?说好的无缝迁移呢?怎么还要改代码。好吧,先承认有小点标题党嫌疑😎
已经做到最小化配置了,相信下一步难不倒聪明的你。你要解决的是:
- 通过反射,去读JobBean方法的annotation配置,而不是类的annotation配置
- 根据目录或你原系统的接口去获取Bean,并执行对应的方法。或者是使用统一的job类,将要反射调用的Bean放到JobDataMap作为参数让统一job类去调用
这些要根据你原系统的实际情况来做处理,例如原系统的Scheduler对应的方法名都千奇百怪,那得灵活来处理反射规则了。一招打遍天下太理想。