前言

  • 最近有一个功能需求,大致简述如下:

需要为Spring操作MongoDB的save方法添加一个切面,来对指定PO对象的save操作进行一个日志追溯功能(记录前后功能的变化)。

  • 当这个需求下来的时候,我内心OS为:
以我对spring的熟悉程度,这个任务简直是为我量身定做的。

于是我主动请战,揽下了这个需求,并进行了开发。

一、设计思路

  • 主要核心的设计思路这里就不阐述了。但有这么一个功能,因为它需要记录po对象保存前的信息,因此需要在save方法之前从DB中获取旧对象,但这多多少少会影响到一丢丢主业务流程。为了防止此功能上线后出现大异常并减少回退版本的成本,我建议添加一个开关,由开关来控制功能是否生效,同时就算开关生效了,若项目中未依赖MongoDB相关的jar包,此功能也无需生效。
  • 本着这样的一个原则,我最先想到的就是使用spring的@Import扩展点(类似于SpringBoot的各种Enable系列的注解),初此之外,还需要用到SpringBoot的@Conditional系列的注解。我首先想到的就是,利用自定义开关注解(叫@EnablePOFlow)来导入一个spring的配置类,同时这个配置类中使用@Bean方法来初始化一个Bean A,同时,MongoDB save方法的切面的创建条件为Bean A存在。本着这样的想法,我快速构建。

二、构建项目

  • ps: 公司的包名前缀基本上是不变的。注意这个点,是我踩坑的根本原因

2.1 开关:EnablePOFlow

  • 源码如下:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(POFlowImportSelector.class)
public @interface EnablePOFlow {
}
  • 主要核心为:导入一个实现了ImportSelecto*接口的类

2.2 POFlowImportSelector

  • 源码如下:
public class POFlowImportSelector implements ImportSelector {

    @Override
    public String[] selectImports(AnnotationMetadata importingClassMetadata) {
        if (importingClassMetadata.hasAnnotation(EnablePOFlow.class.getName())) {
            return new String[] {
                    POFlowConfigured.class.getName()
            };
        }

        return new String[0];
    }
}

2.3 POFlowConfigured

  • 源码如下
public class POFlowConfigured {

    @Bean
    @ConditionalOnClass(name = "org.springframework.data.mongodb.repository.MongoRepository")
    public MongoDBAction mongoDBAction() {
        System.out.println("================mongoDBAction==============");
        return new MongoDBAction();
    }
}

2.4 MongoTestAspectBean

  • 源码如下
@Component
@ConditionalOnBean({POFlowConfigured.class, MongoDBAction.class})
public class MongoTestAspectBean {

    public MongoTestAspectBean() {
        System.out.println("-----------MongoTestAspectBean----------------");
    }

}

2.5 期望逻辑

  • 在SpringBoot启动类中添加@EnablePOFlow注解,将POFlowConfigured配置类导入spring容器中,spring容器在处理POFlowConfigured配置类中,发现它内部有被@Bean注解标识的方法,当构建MongoDBAction bean时,发现它有条件:需要在classpath下能加载到org.springframework.data.mongodb.repository.MongoRepository类才能被创建成bean。除此之外,由于MongoTestAspectBean是被@Component注解标识的,而且团队中项目的包名前缀的一样的,因此项目在启动过程中肯定能扫描扫描到它,但是它的创建依赖于spring环境中存在类型为POFlowConfigured和MongoDBAction bean。因此,完美实现了上文中的开关的功能。

2.6 期望逻辑事与愿违

  • 期望逻辑来看,功能是完全没任何问题的。所谓的开关也存在,就算开关打开了,但项目中没有依赖操作mongodb的jar包的话,切面逻辑也不会生效。但实际情况是:我的开关加上了,项目中也依赖了操作mongodb的jar包。但是,切面就是不生效。于是,我陷入沉思。

2.7 排查问题

  • 上面说了,切面生效的情况都满足了,但是切面却没有构建成功。因此,让我不得不怀疑@ConditionalOnBean注解的作用。根据@ConditionalOnBean注解内部的注释,我很快将此注解的处理类定位到了此处:org.springframework.boot.autoconfigure.condition.OnBeanCondition#getMatchOutcome 。通过方法栈的调用链的追踪,我最终把入口定位到了此处org.springframework.context.annotation.ConfigurationClassBeanDefinitionReader#loadBeanDefinitions,我们来看下此方法的源码:
/**
 * Read {@code configurationModel}, registering bean definitions
 * with the registry based on its contents.
 */
public void loadBeanDefinitions(Set<ConfigurationClass> configurationModel) {
	TrackedConditionEvaluator trackedConditionEvaluator = new TrackedConditionEvaluator();
	for (ConfigurationClass configClass : configurationModel) {
		loadBeanDefinitionsForConfigurationClass(configClass, trackedConditionEvaluator);
	}
}

根据源码中的注释可知:它主要是解析ConfigurationClass类,并把它们转化成BeanDefinition,同时,在解析成BeanDefinition时,会进行校验,看是否满足条件。比如,在上述创建MongoTestAspectBean时,需要保证在spring容器中存在POFlowConfigured和MongoDBAction bean(这里说明下,其实这里还没有bean被创建,只是确认下看是否有POFlowConfigured和MongoDBAction的beanDefinition)。其实,这个事与愿违的情况就是加载MongoTestAspectBean时POFlowConfigured和MongoDBAction并没有被解析到,所以spring 容器认为它不满足条件,所以把他给剔除了。我们可以看一下@ConditionalOnBean注解中的源码注释:

/*
 * // 忽略其他注释
 * The condition can only match the bean definitions that have been processed by the
 * application context so far and, as such, it is strongly recommended to use this
 * condition on auto-configuration classes only. If a candidate bean may be created by
 * another auto-configuration, make sure that the one using this condition runs after.
 *
 * @author Phillip Webb
 */
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Conditional(OnBeanCondition.class)
public @interface ConditionalOnBean {

鄙人英语比较蹩脚,因此借助一些翻译软件来翻译,否则怕误人子弟:

该条件只能匹配到目前为止应用程序上下文已处理的bean定义,
因此,强烈建议仅在自动配置类上使用此条件。
如果一个候选bean可能是由另一个自动配置创建的,请确保使用此条件的那个运行

由以上翻译可知,我们必须确保依赖的bean先被执行。顺着这个思路,咱们可以确定:

MongoTestAspectBean创建时,类型为POFlowConfigured和MongoDBAction的bean 并还没被加载到spring中去。

2.8 出现此问题的原因

  • 出现此问题的原因在于org.springframework.context.annotation.ConfigurationClassBeanDefinitionReader#loadBeanDefinitions类的定义,它是对传入的配置类集合遍历,并挨个解析他们,将他们转换成BeanDefinition对象。大家可以看此方法的调用情况,目前只有一处引用到了它:
org.springframework.context.annotation.ConfigurationClassPostProcessor#processConfigBeanDefinitions方法内部的此行代码:this.reader.loadBeanDefinitions(configClasses);
  • 在向上追踪调用链后,你会发现是此处org.springframework.context.annotation.ConfigurationClassPostProcessor#postProcessBeanDefinitionRegistry调用的,此处的调用可以参考我之前的文章:spring 5.0.x源码学习系列六: 后置处理器ConfigurationClassPostProcessor之BeanDefinitionRegistryPostProcessor身份。如果你阅读过之前的文章,那你就会明白,此处传入的所有配置类是处理@ComponentScan注解解析出来的类,其中包括:@Component注解的类、@Import注解导入的普通类、@Configuration的类。因为首先扫描的是@Component注解,其他存储这些配置类的数据结构为LinkedHashSet,因此它是有序的。所以@Component注解扫描的类肯定先被解析到,其他相关的注解后被解析到。 有了这样的结论后,我们可以知道上述问题的所在了,因为MongoTestAspectBean是使用@Component注解标识的,而它依赖的bean是通过@Bean方法创建的,所以在解析MongoTestAspectBean的bean时,由@Bean方式创建的POFlowConfigured和MongoDBAction的bean并还没有被解析成beanDefinition,因此不满足创建MongoTestAspectBean的条件,所以这个切面就没有被生成,这也是为什么@ConditionalOnBean建议我们和@Configuration注解一起使用,因为此注解内部被@Component注解修饰了,可以优先被扫描到

2.9 解决方案

  • 找到了问题的根源后,解决方案就变得简单了。现在我们可以断定:MongoTestAspectBean的解析先于POFlowConfigured和MongoDBAction。不满足@ConditionalOnBean的条件。于是,我将MongoTestAspectBean的创建移到了POFlowConfigured中,以@Bean的方式来创建,不使用@Component注解的方式来创建,这个问题就完美解决了。

三、总结

  • 通过本次的一个记录总结,也算是了解了@ConditionalOnBean注解有顺序加载的坑吧。其中也了解到了@Component注解和@Bean注解的差异,@Component注解是基于@ComponentScan的,当处理完@ComponentScan注解时,@Component注解就被扫描完了,同时,这些扫描出来的配置类优先被解析。
  • I am a slow walker, but I never walk backwards.