一次Spring Bean初始化顺序问题排查记录

时间:2022-07-10 17:20:18

最近在使用Springboot的时候需要通过静态的方法获取到Spring容器托管的bean对象,参照一些博文里写的,新建了个类,并实现ApplicationContextAware接口。代码大致如下:

@Component
public class SpringUtils implements ApplicationContextAware { private static ApplicationContext applicationContext; @Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
if(SpringUtils.applicationContext == null) {
SpringUtils.applicationContext = applicationContext;
}
}
public static <T> T getBean(String name,Class<T> clazz){
return getApplicationContext().getBean(name, clazz);
}
}

然后另外一个bean需要依赖这个静态获取bean的方法,代码大致如下:

@Component
public class TestBean{ private Object dependencyBean = SpringUtils.getBean(OtherBean.class); }

(注: 忽略代码逻辑是否合理~~ 这些代码是为演示所用简化的逻辑,肯定有同学会说:既然都是bean了为什么不注入,而是要用静态的获取呢?这个暂时不考虑,暂认为就必须要这样搞)

这两个类的层次结构和包名大致如下: 

utils

> notice

  > TestBean

> SpringUtils


就是TestBean在SpringUtils的下一级,TestBean所在包名为notice(这这个名字很重要! 直接影响到这两个bean的加载顺序,具体原理往下看)

代码就这么多,从以上代码来静态分析看,确实有些漏洞,因为没有考虑到Spring bean的加载顺序,可能导致的SpringUtils报空指针异常(在TestBean先于SpringUtils初始化的场景下),不管怎么样先执行一下看下效果,效果如下:

macOS操作系统下代码正常

windows平台下代码空指针异常

为什么这还跟平台有关了呢?难道Spring bean的初始化顺序还跟平台有关?事实证明这个猜想是正确的。下面从Spring源代码里来找原因。

这里需要重点关注的类是 ConfigurationClassPostProcessor,这个类是干什么的?它从哪里来?如何实现bean的加载的?

在Spring里可以指定甚至自定义多个BeanFactoryPostProcessor来实现在实例化bean之前做一些bean容器的更新操作,比如修改某些bean的定义、增加一些bean、删除一些bean等,而ConfigurationClassPostProcessor就是Spring为了支持基于注解bean的功能而实现的BeanFactoryPostProcessor。

web环境的Springboot默认使用的应用上下文(ApplicationContext,BeanFactoryPostProcessor就是注册到这里才会起作用的)是AnnotationConfigEmbeddedWebApplicationContext,在AnnotationConfigEmbeddedWebApplicationContext的构造方法里初始化this.reader的时候,在reader的构造方法里把ConfigurationClassPostProcessor添加到ApplicationContext里了:

public class AnnotationConfigEmbeddedWebApplicationContext
extends EmbeddedWebApplicationContext { private final AnnotatedBeanDefinitionReader reader; private final ClassPathBeanDefinitionScanner scanner; private Class<?>[] annotatedClasses; private String[] basePackages; /**
* Create a new {@link AnnotationConfigEmbeddedWebApplicationContext} that needs to be
* populated through {@link #register} calls and then manually {@linkplain #refresh
* refreshed}.
*/
public AnnotationConfigEmbeddedWebApplicationContext() {
this.reader = new AnnotatedBeanDefinitionReader(this); // 重点关注这句
this.scanner = new ClassPathBeanDefinitionScanner(this);
}
public class AnnotatedBeanDefinitionReader {

    private final BeanDefinitionRegistry registry;

    private BeanNameGenerator beanNameGenerator = new AnnotationBeanNameGenerator();

    private ScopeMetadataResolver scopeMetadataResolver = new AnnotationScopeMetadataResolver();

    private ConditionEvaluator conditionEvaluator;

    /**
* Create a new {@code AnnotatedBeanDefinitionReader} for the given registry and using
* the given {@link Environment}.
* @param registry the {@code BeanFactory} to load bean definitions into,
* in the form of a {@code BeanDefinitionRegistry}
* @param environment the {@code Environment} to use when evaluating bean definition
* profiles.
* @since 3.1
*/
public AnnotatedBeanDefinitionReader(BeanDefinitionRegistry registry, Environment environment) {
Assert.notNull(registry, "BeanDefinitionRegistry must not be null");
Assert.notNull(environment, "Environment must not be null");
this.registry = registry;
this.conditionEvaluator = new ConditionEvaluator(registry, environment, null);
AnnotationConfigUtils.registerAnnotationConfigProcessors(this.registry); // 这里把ConfigurationClassPostProcessor注册到上下文里了
}

上面介绍了Spring如何注册这个ConfigurationClassPostProcessor,下面看下这个类如何实现bean定义加载的。

public void processConfigBeanDefinitions(BeanDefinitionRegistry registry) {
// ....
// Parse each @Configuration class
ConfigurationClassParser parser = new ConfigurationClassParser(
this.metadataReaderFactory, this.problemReporter, this.environment,
this.resourceLoader, this.componentScanBeanNameGenerator, registry); Set<BeanDefinitionHolder> candidates = new LinkedHashSet<BeanDefinitionHolder>(configCandidates);
Set<ConfigurationClass> alreadyParsed = new HashSet<ConfigurationClass>(configCandidates.size());
do {
parser.parse(candidates);
parser.validate(); Set<ConfigurationClass> configClasses = new LinkedHashSet<ConfigurationClass>(parser.getConfigurationClasses());

主要的代码是 parser.parse(candidates); 这句,当执行到这里的时候candidates已经包含Springboot的配置类,这个parse会根据配置类里定义的basePackge递归扫描这个目录下面的class文件(如果没有定义basePackage字段则把配置类所在的包作为basePackage),debug跟踪代码到最底层可以看到使用的是PathMatchingResourcePatternResolver的doRetrieveMatchingFiles方法:


protected void doRetrieveMatchingFiles(String fullPattern, File dir, Set<File> result) throws IOException {
if (logger.isDebugEnabled()) {
logger.debug("Searching directory [" + dir.getAbsolutePath() +
"] for files matching pattern [" + fullPattern + "]");
}
File[] dirContents = dir.listFiles();
if (dirContents == null) {
if (logger.isWarnEnabled()) {
logger.warn("Could not retrieve contents of directory [" + dir.getAbsolutePath() + "]");
}
return;
}
Arrays.sort(dirContents);
for (File content : dirContents) {
String currPath = StringUtils.replace(content.getAbsolutePath(), File.separator, "/");
if (content.isDirectory() && getPathMatcher().matchStart(fullPattern, currPath + "/")) {
if (!content.canRead()) {
if (logger.isDebugEnabled()) {
logger.debug("Skipping subdirectory [" + dir.getAbsolutePath() +
"] because the application is not allowed to read the directory");
}
}
else {
doRetrieveMatchingFiles(fullPattern, content, result);
}
}
if (getPathMatcher().match(fullPattern, currPath)) {
result.add(content);
}
}
}
 

在这里可以看到有句 Arrays.sort(dirContents); 这个代码,就是在遍历一个文件夹下的资源时(包括文件夹和class文件),会先把资源排序一下,这个排序决定了bean的加载顺序!

那再看下File(上面代码中的dirContents是File列表)是如何排序的:

public class File
implements Serializable, Comparable<File>
{ /**
* The FileSystem object representing the platform's local file system.
*/
private static final FileSystem fs = DefaultFileSystem.getFileSystem(); /* -- Basic infrastructure -- */ /**
* Compares two abstract pathnames lexicographically. The ordering
* defined by this method depends upon the underlying system. On UNIX
* systems, alphabetic case is significant in comparing pathnames; on Microsoft Windows
* systems it is not.
*
* @param pathname The abstract pathname to be compared to this abstract
* pathname
*
* @return Zero if the argument is equal to this abstract pathname, a
* value less than zero if this abstract pathname is
* lexicographically less than the argument, or a value greater
* than zero if this abstract pathname is lexicographically
* greater than the argument
*
* @since 1.2
*/
public int compareTo(File pathname) {
return fs.compare(this, pathname);
}

使用的是FileSystem的排序方法,再看看DefaultFileSystem.getFileSystem();拿到是是什么:

mac下:

/**
*
* @since 1.8
*/
class DefaultFileSystem { /**
* Return the FileSystem object for Unix-based platform.
*/
public static FileSystem getFileSystem() {
return new UnixFileSystem();
}
}
// UnixFileSystem

class UnixFileSystem extends FileSystem {

/* -- Basic infrastructure -- */

  public int compare(File f1, File f2) {
    return f1.getPath().compareTo(f2.getPath());
  }

}

 

windows下:

/**
*
* @since 1.8
*/
class DefaultFileSystem { /**
* Return the FileSystem object for Windows platform.
*/
public static FileSystem getFileSystem() {
return new WinNTFileSystem();
}
}
//WinNTFileSystem

@Override
public int compare(File f1, File f2) {
  return f1.getPath().compareToIgnoreCase(f2.getPath());
}


由于windows下和mac下使用的FileSystem不同,jdk windows版FileSystem实现的compare方法在比较文件是忽略了文件名的大小写,而mac版没有忽略大小写,所以导致前面提出的同样的代码在windows下报错,在mac下就是正常的问题。

但是为什么会有这样的差别呢?不是太明白为什么

那还剩最后一个问题,这个问题如何解决呢?我这有两个方法,一是修改类名让被依赖的类排在前面,这种方法不是太优雅,而且如果以后jdk更新了排序方法可能还会出bug,第二种是在使用类上加DependOn注解,主动说明在初始化使用类时首先加载被依赖的类,这样就没有问题了,但是我感觉在开发的时候尽量避免这种依赖问题,这让容器和业务代码参杂,以后维护是个噩梦