Spring Boot - 切面对Quartz定时任务Job失效的问题解决

背景

最近的项目涉及到的定时任务需要统计Job的执行时间,但是由于项目中的定时任务很多,不可能每一个Job的执行方法上开始都写一个开始时间,写一个结束时间,进行相减,太费事了,而且以后万一加了定时任务呢?维护起来还是相当麻烦的,所以我的第一反应就是AOP----面向切面编程。

错误操作

我自己定义了一个切面类,切点在job的执行方法上。执行的操作也很简单,就是计算了一个开始时间结束时间的时间差,代码如下:

@Slf4j
@Aspect
@Component
public class JobAspect {

    /**
     * 异步事件切入点
     */
    @Pointcut("execution(* com.xxx.xxx.job.*.*(..))")
    public void jobPointcut() {}

    @Around("jobPointcut()")
    public void jobTime(ProceedingJoinPoint jp) {
        String method = jp.getSignature().toShortString();
        long startTime = System.currentTimeMillis();
        log.info("{} 开始执行了 >>> {}", method, startTime);
        try {
            jp.proceed();
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
        log.info("{} 执行结束 >>> 共执行了{} ms", method, System.currentTimeMillis() - startTime);
    }


    @AfterThrowing(pointcut = "jobPointcut()", throwing = "e")
    public void jobTimeError(JoinPoint jp, Throwable e) {
        String method = jp.getSignature().toShortString();
        log.error("[定时任务]:{} 执行失败",method, e);
    }

}

问题排查

开开心心的写完了,心想这么简单的切面,肯定没问题的!自信的启动了项目!结果可想而言,果然还是太年轻了!定时任务啪啪啪的在控制台跑着,切面里的代码一行都没有执行!我有仔仔细细,里里外外的看了切点写的对不对,反复检查没有问题啊,后来我以为切面的配置有问题,依赖包啊,项目配置等等,都没有问题!所以我又三下五除二在job之外的业务上写了个简单的切面,一执行,嘿!切面的代码执行了!所以我断定切面的失效和quartz的job是有关系的。

寻找方案

切面失效的几种情况

1.AOP是在Bean创建后面的BeanPostProcessor的后置方法实现。所以我们的切面目标类必须是Spring容器管理的。换句话说就是目标类可以使用@Resource或者@Autowired注入的。

2.切面类还需要交给Spring容器管理,所以说切面除了要加上@Aspect,还要加上@Component

3.切点的方法权限是public或者protected修饰,而且@Pointcut注解路径要正确

4.切面类和切面类要被扫描到,但是我这个是springboot项目。@SpringBootApplication包含@ComponentScan,而启动类都是在包的最外层,所以说肯定是可以扫描到的。

问题分析

根据上边切面失效的分析来看,除了2,3,4,只有1符合失效的情况,也就是说quartz的job是没有被Spring容器管理,Job实例是quartz进行管理的,所以AOP并不能对job进行操作。经过上边的问题分析,问题就变成了Job实例应该怎么样才可以让Spring容器管理呢?

灾厄解答这个问题之前呢,我们先来聊一下quartz。quartz里边有几个比较重要的概念:Scheduler,Job,Trigger.

Scheduler是对job实例的管理,可以进行添加,删除,暂停,恢复等等操作。也就是说Scheduler是问题的关键。

在此多说一句:大家既然看到这里,说明大家的项目已经完成了spring对quartz集成。只是切面没有生效,那我就默认大家完成了对spring和quartz的相关配置。

在我们进行quartz的配置的时候,我们已经在Spring容器中注入了一个类SchedulerFactoryBean,它是一个工厂类,用来创建上边提到的Scheduler,同时它也是由quartz进入spring容易的核心入口和桥梁。

Job实例应该怎么样才可以让Spring容器管理呢?那就要借助JobFactory,quartz提供了一个接口JobFactory,而spring框架集成quartz也实现了JobFactory接口,其中两个比较重要的是AdaptableJobFactory和SpringBeanJobFactory,那么和刚刚我们所提到的SchedulerFactoryBean有什么关系呢?查看SchedulerFactoryBean的源码:

try {
			Scheduler scheduler = createScheduler(schedulerFactory, this.schedulerName);
			populateSchedulerContext(scheduler);

			if (!this.jobFactorySet && !(scheduler instanceof RemoteScheduler)) {
				// Use AdaptableJobFactory as default for a local Scheduler, unless when
				// explicitly given a null value through the "jobFactory" bean property.
				this.jobFactory = new AdaptableJobFactory();
			}
			if (this.jobFactory != null) {
				if (this.applicationContext != null && this.jobFactory instanceof ApplicationContextAware) {
					((ApplicationContextAware) this.jobFactory).setApplicationContext(this.applicationContext);
				}
				if (this.jobFactory instanceof SchedulerContextAware) {
					((SchedulerContextAware) this.jobFactory).setSchedulerContext(scheduler.getContext());
				}
				scheduler.setJobFactory(this.jobFactory);
			}
			return scheduler;
		}

它使用的JobFactory是AdaptableJobFactory,既然我们注入的SchedulerFactoryBean已经默认使用了AdaptableJobFactory不起作用,那我们就先看一下SpringBeanJobFactory的源码:

public class SpringBeanJobFactory extends AdaptableJobFactory
		implements ApplicationContextAware, SchedulerContextAware {
    
    @Override
	protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception {
		Object job = (this.applicationContext != null ?
				this.applicationContext.getAutowireCapableBeanFactory().createBean(
						bundle.getJobDetail().getJobClass(), AutowireCapableBeanFactory.AUTOWIRE_CONSTRUCTOR, false) :
				super.createJobInstance(bundle));

		if (isEligibleForPropertyPopulation(job)) {
			BeanWrapper bw = PropertyAccessorFactory.forBeanPropertyAccess(job);
			MutablePropertyValues pvs = new MutablePropertyValues();
			if (this.schedulerContext != null) {
				pvs.addPropertyValues(this.schedulerContext);
			}
			pvs.addPropertyValues(bundle.getJobDetail().getJobDataMap());
			pvs.addPropertyValues(bundle.getTrigger().getJobDataMap());
			if (this.ignoredUnknownProperties != null) {
				for (String propName : this.ignoredUnknownProperties) {
					if (pvs.contains(propName) && !bw.isWritableProperty(propName)) {
						pvs.removePropertyValue(propName);
					}
				}
				bw.setPropertyValues(pvs);
			}
			else {
				bw.setPropertyValues(pvs, true);
			}
		}

		return job;
	}
}

从源码中可以看出SpringBeanJobFactory使用getAutowireCapableBeanFactory()将job放入了 spring的容器之中,所以我们就可以用它解决我们遇到的问题。

问题解决

通过从源码中可以看出SpringBeanJobFactory使用getAutowireCapableBeanFactory()将job放入了 spring的容器之中,而且也继承了AdaptableJobFactory。所以我们可以使用SpringBeanJobFactory把job实例放进spring容器中,再把SpringBeanJobFactory和SchedulerFactoryBean进行关联,这个问题就完美解决了。我们需要在quartz的配置类中注入SchedulerFactoryBean并关联SpringBeanJobFactory,代码如下(其他的配置代码就不多写了):

@Bean
    public SchedulerFactoryBean schedulerFactoryBean() throws IOException {
        SchedulerFactoryBean schedulerFactoryBean = new SchedulerFactoryBean();
        schedulerFactoryBean.setQuartzProperties(properties());
        // 将JobFactory和schedulerFactoryBean相关联,将job放入到spring容器中,使得切面生效
        schedulerFactoryBean.setJobFactory(new SpringBeanJobFactory());
        return schedulerFactoryBean;

    }

这样问题就解决了。启动项目切面已经生效。

小插曲-程序计时之StopWatch

我们在开发的时候为了更好的观看性能都会对程序的运行时间计时,之前我们常用的是得到两个System.currentTimeMillis()进行相减,现在有了更加简单优雅的方式!那就是StopWatch!

StopWatch是spring框架提供的, 当然在hutool工具和 apache的工具包中也有,它们的原理和使用方式都是差不多的。

我们先看一下使用方式吧:

StopWatch watch = new StopWatch("test");// test为任务的id
watch.start("StopWatch-test");// 声明任务名称并开始
watch.stop();// 任务结束
watch.getLastTaskTimeMillis();// 获取任务的执行时间

就像我们上边刚刚的例子,我们可以这样写:

@Pointcut("execution(* com.citicbank.somt.job.*.*(..))")
    public void jobPointcut() {}

    @Around("jobPointcut()")
    public void jobTime(ProceedingJoinPoint jp) throws Throwable {
        String typeName = jp.getSignature().getDeclaringTypeName();

        String method = jp.getSignature().toShortString();
        StopWatch watch = new StopWatch(getClassName(typeName));
        watch.start(method);
        jp.proceed();
        watch.stop();
        String info = watch.prettyPrint(); // 以更美观的形式打印
        log.info(info);
    }


控制台显示结果:
StopWatch 'HelloJob': running time = 351900 ns
---------------------------------------------
ns         %     Task name
---------------------------------------------
000351900  100%  HelloJob.execute(..)