Spring-Schedule的@Scheduled註解繼承問題-

問題java

在咱們的項目中,有這樣兩個類(示意):服務器

@Component
public class FatherScheduler {
    @Scheduled(cron = "0/10 * * * * ?")
    public void execute() {
        System.out.println(new Date() + " 執行任務的類是:" + this.getClass());
    }
}

@Component
public class SonSchedulerImpl extends FatherScheduler {
    @Override
    @Scheduled(cron = "1/10 * * * * ?")
    public void execute() {
        super.execute();
    }
}

這是一對父子類。FatherScheduler定義了一個定時任務,並利用Spring-Scheduler註解聲明瞭每10秒調度執行一次。SonSchedulerImpl繼承了FatherScheduler,重寫了該定時任務的註解。
框架

線上系統中,咱們已有一個邏輯比較完備的定時任務父類;子類只須要修改父類的一個注入實例、修改cron表達式便可。因此出現了這樣的類結構。ide

咱們但願父類定義的定時任務在啓動後的第0/10/20/30……秒啓動執行;子類定時任務則在第1/11/21/31……秒啓動執行。從代碼上看彷佛沒有問題,可是實際執行結果是這樣的:工具

Tue Jul 30 10:54:40 CST 2019 執行任務的類是:class net.loyintean.blog.scheduer.FatherSchedulerpost

Tue Jul 30 10:54:40 CST 2019 執行任務的類是:class net.loyintean.blog.scheduer.SonSchedulerImpl性能

Tue Jul 30 10:54:41 CST 2019 執行任務的類是:class net.loyintean.blog.scheduer.SonSchedulerImpl字體

Tue Jul 30 10:54:50 CST 2019 執行任務的類是:class net.loyintean.blog.scheduer.FatherSchedulerthis

Tue Jul 30 10:54:50 CST 2019 執行任務的類是:class net.loyintean.blog.scheduer.SonSchedulerImplspa

Tue Jul 30 10:54:51 CST 2019 執行任務的類是:class net.loyintean.blog.scheduer.SonSchedulerImpl

也就是說,父類定時任務確實是按照咱們的指望在調度執行。可是子類定時任務……在咱們的預期以外,它多作了一次調度,並且調度規律與父類相同(紅色字體部分)。雖然咱們對定時任務都作了冪等處理,即便多跑了幾回也只是浪費點服務器性能而已,可是代碼應該作且只作咱們要作的事,不該該作多餘的事——不然說不定哪天「天網」系統就要誕生了哈哈。

緣由

從代碼運行表現來看,彷佛是Spring-Scheduler在爲子類註冊定時任務時,除了解析子類重寫方法上的@Scheduled註解以外,還把父類方法上的註解也解析了一遍。可是究竟是不是這樣,仍是要去找註冊定時任務的相關代碼。

找到@Scheduled註解,能夠看到它在的javadoc中已經指明瞭這個註解是在哪兒處理的了:

Processing of {@code @Scheduled} annotations is performed by registering a {@link ScheduledAnnotationBeanPostProcessor}. This can be done manually or, more conveniently, through the {@code <task:annotation-driven/>} element or @{@link EnableScheduling} annotation.

(這也算一個啓示:好好寫Javadoc。一份好的Javadoc能爲使用代碼、維護代碼的人提供很大的便利。)

ScheduledAnnotationBeanPostProcessor的定義和核心處理代碼是這樣的:

public class ScheduledAnnotationBeanPostProcessor implements BeanPostProcessor, Ordered,
      EmbeddedValueResolverAware, BeanFactoryAware, ApplicationContextAware,
      SmartInitializingSingleton, ApplicationListener<ContextRefreshedEvent>, DisposableBean {
    @Override
    public Object postProcessAfterInitialization(final Object bean, String beanName) {
       Class<?> targetClass = AopUtils.getTargetClass(bean);
       if (!this.nonAnnotatedClasses.contains(targetClass)) {
          final Set<Method> annotatedMethods = new LinkedHashSet<Method>(1);
          ReflectionUtils.doWithMethods(targetClass, new MethodCallback() {
             @Override
             public void doWith(Method method) throws IllegalArgumentException, IllegalAccessException {
                for (Scheduled scheduled :
                      AnnotationUtils.getRepeatableAnnotation(method, Schedules.class, Scheduled.class)) {
                   processScheduled(scheduled, method, bean);
                   annotatedMethods.add(method);
                }
             }
          });
          if (annotatedMethods.isEmpty()) {
             this.nonAnnotatedClasses.add(targetClass);
             if (logger.isDebugEnabled()) {
                logger.debug("No @Scheduled annotations found on bean class: " + bean.getClass());
             }
          }
          else {
             // Non-empty set of methods
             if (logger.isDebugEnabled()) {
                logger.debug(annotatedMethods.size() + " @Scheduled methods processed on bean '" + beanName +
                      "': " + annotatedMethods);
             }
          }
       }
       return bean;
    }               
}

簡單看下來,其中的核心邏輯在ReflectionUtils.doWithMethods方法中。這個方法內部是這樣的:

public static void doWithMethods(Class<?> clazz, ReflectionUtils.MethodCallback mc, ReflectionUtils.MethodFilter mf) {
    Method[] methods = getDeclaredMethods(clazz);
    Method[] var4 = methods;
    int var5 = methods.length;

    int var6;
    for(var6 = 0; var6 < var5; ++var6) {
        Method method = var4[var6];
        if (mf == null || mf.matches(method)) {
            try {
                mc.doWith(method);
            } catch (IllegalAccessException var9) {
                throw new IllegalStateException("Not allowed to access method '" + method.getName() + "': " + var9);
            }
        }
    }

    if (clazz.getSuperclass() != null) {
        doWithMethods(clazz.getSuperclass(), mc, mf);
    } else if (clazz.isInterface()) {
        Class[] var10 = clazz.getInterfaces();
        var5 = var10.length;

        for(var6 = 0; var6 < var5; ++var6) {
            Class<?> superIfc = var10[var6];
            doWithMethods(superIfc, mc, mf);
        }
    }

}

哎~果不其然地,咱們在這個類裏面發現了遞歸調用父類的代碼:

if (clazz.getSuperclass() != null) {
        doWithMethods(clazz.getSuperclass(), mc, mf);
    }

結合mc.doWith()方法的定義:

new MethodCallback() {
    @Override
    public void doWith(Method method) throws IllegalArgumentException, IllegalAccessException {
        for (Scheduled scheduled :
          AnnotationUtils.getRepeatableAnnotation(method, Schedules.class, Scheduled.class)) {
               processScheduled(scheduled, method, bean);
               annotatedMethods.add(method);
        }
    }
}

問題緣由就很明顯了:

ScheduledAnnotationBeanPostProcessor在處理SonSchedulerImpl實例的時候,首先找出了子類重寫過的execute()方法及其Scheduled註解,併爲其註冊了一個定時任務。隨後,又按一樣的邏輯,找到的它的父類FatherScheduler上定義的execute()方法及其Scheduled註解,又按父類的配置註冊了一個定時任務。這樣,同一個bean實例上就註冊了兩個定時任務,從而致使同一個定時任務被調度了兩次。

解決方案

解決方案有兩個。

首先就是……父類方法上不要註解@Scheduled。爲了能儘可能複用代碼、又能不在父類上註解@Scheduled,咱們最後把代碼改爲了這樣:

public class FatherScheduler {
    
    public void execute() {
        System.out.println(new Date() + " 執行任務的類是:" + this.getClass());
    }
}

@Component
public class SonSchedulerImpl extends FatherScheduler {
    @Override
    @Scheduled(cron = "1/10 * * * * ?")
    public void execute() {
        super.execute();
    }
}

@Component
public class DaughterSchedulerImpl {
    @Scheduled(cron = "0/10 * * * * ?")
    public void execute() {
        super.execute();
    }
}

也就是父類只定義業務邏輯,不作@Scheduled註解。兩個子類分別註解。

另外一種方式更完全一些:升級Spring版本。這個問題目前已知是在4.1.6.RELEASE版本中出現的;在最新版的5.1.8RELEASE中已經被修復了。這個版本中調用ReflectionUtils.doWithMethods時,傳入的是這樣的一個回調方法:

ReflectionUtils.doWithMethods(currentHandlerType, method -> {
      Method specificMethod = ClassUtils.getMostSpecificMethod(method, targetClass);
      T result = metadataLookup.inspect(specificMethod);
      if (result != null) {
         Method bridgedMethod = BridgeMethodResolver.findBridgedMethod(specificMethod);
         if (bridgedMethod == specificMethod || metadataLookup.inspect(bridgedMethod) == null) {
            methodMap.put(specificMethod, result);
         }
      }
   }, ReflectionUtils.USER_DECLARED_METHODS);
}

注意其中的這一行代碼:

Method specificMethod = ClassUtils.getMostSpecificMethod(method, targetClass);

這行代碼的做用在於,從targetClass上找出它重寫過的method方法。在出現問題的流程中,targetClass始終指向子類SonSchedulerImpl;而method則會隨着ReflectionUtils.doWithMethods的遞歸調用而從SonSchedulerImpl#execute()方法變成了FatherScheduler#execute()方法。可是,通過ClassUtils.getMostSpecificMethod()方法的處理後,咱們最終獲得的specificMethod還是子類重寫的SonSchedulerImpl#execute()方法,而非父類上原生的FatherScheduler#execute()方法。這樣一來,後續處理中也就只會按照子類方法上的@Scheduled註解來註冊定時任務了。

這是第二個啓示:框架工具應及時升級,以免踩中別人已經填上的坑。


qrcode?scene=10000004&size=102&__biz=MzUzNzk0NjI1NQ==&mid=2247484184&idx=1&sn=3aad31ec47ca01e2e80b9cd50a7f2ac0&send_time=

相關文章
相關標籤/搜索