Spring 5 启动性能优化之 @Indexed

背景

Spring 经过近20年的发展,目前版本已经迭代到了5.x,每个版本 Spring 都有不同的改进,版本 5.x 中,Spring 把重心放到了性能优化上。我们知道,Spring 注解驱动编程中,Spring 启动时需要对类路径下的包进行扫描,以便发现所需管理的 bean。如果在应用启动前能够确定 Spring bean,不再进行扫描,那么性能就会大大提高,Spring 5 对此进行了实现。

@Indexed 与 spring-context-indexer

spring-framework 在版本 5.x 中新增了一个模块 spring-context-indexer,作用就是在编译时扫描 @Indexed 注解,确定 bean,生成索引文件。先看如下 Spring 官网 候选组件索引生成 一节的介绍。

尽管类路径扫描已经非常快了,仍可以在编译时创建静态的候选列表来提高大型应用的启动性能。在这种模式下,组件扫描的所有目标的模块都必须使用这种机制。

现有的 @ComponentScan 或 <context:component-scan 指令必须保留,以便让上下文扫描包中的候选对象。当 ApplicationContext 扫描到这样的索引,将自动使用它,而不是扫描类路径。

要生成索引,需要向包含组件的每个模块中添加附件的依赖,这些组件是组件扫描的目标。以下示例展示了如何在 maven 中使用。

<dependencies>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-context-indexer</artifactId>
        <version>5.2.6.RELEASE</version>
        <optional>true</optional>
    </dependency>
</dependencies>

对于 Gradle 4.5 或更早的版本,依赖关系应该在 compileOnly 配置中声明。如下例所示。

dependencies {
    
    
    compileOnly "org.springframework:spring-context-indexer:5.2.6.RELEASE"
}

对于 Gradle 4.6 或以后的版本,依赖应该被定义在 annotationProcessor 配置中。如下例所示。

dependencies {
    
    
    annotationProcessor "org.springframework:spring-context-indexer:{spring-version}"
}

编译处理过程将生成 META-INF/spring.components 文件到 jar 包中。

在 IDE 中使用这种模式时,必须将 spring-context-indexer 注册为注解处理器以确保当候选组件被更新的时候索引是最新的。

当 META-INF/spring.components 在类路径下时索引自动启用。如果索引在一些库(或用例)有效,但不能为整个应用构建索引,可以通过设置 spring.index.ignore 为 true 来回退到常规的类路径处理,参数可以是系统属性或在根路径的 spring.properties 文件中。

@Indexed 如何提高 Spring 启动性能

根据上面官网的介绍我们知道 spring-context-indexer 可以生成 META-INF/spring.components 文件到类路径,那么文件中到底保存了哪些信息?文件中的的所有内容都会被用到吗?Spring 又是如何读取文件的?

Indexed 注解的定义

Indexed 源码如下。

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Indexed {
    
    
}

可以看到,@Indexed 注解只能标注在类型上。@Indexed 标注的注解如果标注或元标注在类上,则这个类将成为候选的对象。

META-INF/spring.components 如何生成?

spring-context-indexer 定义了一个注解处理器(参见前面的文章 Java 注解处理器及其应用 ),因此只需要跟踪注解处理的逻辑即可了解其内部的实现,确定写入的文件内容。事实上 spring-context-indexer 模块中的类也确实没有几个,其项目结构如下图所示。
在这里插入图片描述CandidateComponentsIndexer 就是注解处理器,其核心源码如下所示。

public class CandidateComponentsIndexer implements Processor {
    
    

	...省略部分代码
	
	@Override
	public synchronized void init(ProcessingEnvironment env) {
    
    
		this.stereotypesProviders = getStereotypesProviders(env);
		this.typeHelper = new TypeHelper(env);
		this.metadataStore = new MetadataStore(env);
		this.metadataCollector = new MetadataCollector(env, this.metadataStore.readMetadata());
	}
	
	// 处理注解
	@Override
	public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
    
    
		// 标记元素已被处理
		this.metadataCollector.processing(roundEnv);
		// 处理元素
		roundEnv.getRootElements().forEach(this::processElement);
		if (roundEnv.processingOver()) {
    
    
			// 最后一轮处理写文件
			writeMetaData();
		}
		return false;
	}

	...省略部分代码
	
	private void processElement(Element element) {
    
    
		addMetadataFor(element);
		staticTypesIn(element.getEnclosedElements()).forEach(this::processElement);
	}

	private void addMetadataFor(Element element) {
    
    
		Set<String> stereotypes = new LinkedHashSet<>();
		// 分别收集不同的元数据
		this.stereotypesProviders.forEach(p -> stereotypes.addAll(p.getStereotypes(element)));
		if (!stereotypes.isEmpty()) {
    
    
			// 元数据汇总,ItemMetadata 包含元素的类型和模式信息
			this.metadataCollector.add(new ItemMetadata(this.typeHelper.getType(element), stereotypes));
		}
	}

	...省略部分代码
}

CandidateComponentsIndexer 一轮一轮的处理元素,到最后一轮时将获取到的元数据写入到文件中。StereotypesProvider 是元数据的收集器,用来收集元素中的模式信息,模式信息可能是注解等类型,也可能是其他的信息。收集到的每一项元数据保存到 ItemMetadata,包含类型和类型对应的模式列表。最终类型作为 key,模式列表以英文逗号分隔作为 value,以 properties 的形式保存到文件。StereotypesProvider 定义如下。

interface StereotypesProvider {
    
    

	/**
	 * 返回给定元素的模式信息
	 */
	Set<String> getStereotypes(Element element);

}

StereotypesProvider 有三个实现,具体如下。

  • IndexedStereotypesProvider: 用来处理 @Indexed 注解,事实上也只有这个类才处理 @Indexed 注解。其获取到的模式信息如下。
    • @Indexed 注解直接标注的类型完全限定名,包括父类和接口。
    • 标注或元标注在类上的并且被 @Indexed 元标注的注解类型的完全限定名。
  • StandardStereotypesProvider:收集的模式信息为类上直接标注或通过 @Inherited 可以获取到的 Java 中包名以 javax 开头的注解的完全限定名。
  • PackageInfoStereotypesProvider:注解如果标注在 package-info.java 文件中的包名上,则收集的模式信息为 package-info 。

示例如下:

@Indexed
@Service
public interface Inter1 {
    
    

}

@Named
public interface Inter2 {
    
    

}

@Indexed
public class Parent {
    
    

}

@Component
@Indexed
public class Child extends Parent implements Inter1,Inter2 {
    
    

}

// package-info.java
@PackageAnnotation
package com.zzhkp;

编译后获取到的文件内容如下。

com.zzhkp=package-info
com.zzhkp.Child=org.springframework.stereotype.Component,com.zzhkp.Child,com.zzhkp.Parent,com.zzhkp.Inter1
com.zzhkp.Inter1=org.springframework.stereotype.Component,com.zzhkp.Inter1
com.zzhkp.Inter2=javax.inject.Named
com.zzhkp.Parent=com.zzhkp.Parent

可见 StereotypesProvider 已经正确收集了所需的信息,这些信息保存在文件,将作为索引,在 Spring 应用上下文启动时进行读取。

META-INF/spring.components 如何读取?

以 AnnotationConfigApplicationContext 应用上下文为例,其扫描 bean 的过程需要保证在刷新之前,其扫描的核心源码如下。

public class AnnotationConfigApplicationContext extends GenericApplicationContext implements AnnotationConfigRegistry {
    
    

	private final AnnotatedBeanDefinitionReader reader;
	
	//类路径下的 BeanDefinition 扫描器
	private final ClassPathBeanDefinitionScanner scanner;

	public AnnotationConfigApplicationContext() {
    
    
		this.reader = new AnnotatedBeanDefinitionReader(this);
		this.scanner = new ClassPathBeanDefinitionScanner(this);
	}

	@Override
	public void scan(String... basePackages) {
    
    
		Assert.notEmpty(basePackages, "At least one base package must be specified");
		this.scanner.scan(basePackages);
	}
}

可以看到 AnnotationConfigApplicationContext 将扫描的功能委托给 ClassPathBeanDefinitionScanner 进行实现,ClassPathBeanDefinitionScanner 对象在应用上下文实例化时创建,其核心构造方法如下。

	public ClassPathBeanDefinitionScanner(BeanDefinitionRegistry registry, boolean useDefaultFilters,
			Environment environment, @Nullable ResourceLoader resourceLoader) {
    
    

		Assert.notNull(registry, "BeanDefinitionRegistry must not be null");
		this.registry = registry;

		if (useDefaultFilters) {
    
    
			// 注册默认的过滤器
			registerDefaultFilters();
		}
		setEnvironment(environment);
		// 加载 META-INF/spring.components 文件
		setResourceLoader(resourceLoader);
	}

registerDefaultFilters() 方法用来注册默认的过滤器,满足过滤器要求的类型才会被作为 bean,其源码如下。

	protected void registerDefaultFilters() {
    
    
		this.includeFilters.add(new AnnotationTypeFilter(Component.class));
		ClassLoader cl = ClassPathScanningCandidateComponentProvider.class.getClassLoader();
		try {
    
    
			this.includeFilters.add(new AnnotationTypeFilter(
					((Class<? extends Annotation>) ClassUtils.forName("javax.annotation.ManagedBean", cl)), false));
			logger.trace("JSR-250 'javax.annotation.ManagedBean' found and supported for component scanning");
		} catch (ClassNotFoundException ex) {
    
    
			// JSR-250 1.1 API (as included in Java EE 6) not available - simply skip.
		}
		try {
    
    
			this.includeFilters.add(new AnnotationTypeFilter(
					((Class<? extends Annotation>) ClassUtils.forName("javax.inject.Named", cl)), false));
			logger.trace("JSR-330 'javax.inject.Named' annotation found and supported for component scanning");
		} catch (ClassNotFoundException ex) {
    
    
			// JSR-330 API not available - simply skip.
		}
	}

默认的过滤器是 AnnotationTypeFilter,用来匹配类上存在的注解或元注解。这里支持的注解是被 @Indexed 标注的 @Component 注解以及 javax 包下的 @ManagedBean、@Named 注解。由此可见,并不是所有 spring.components 文件内的类型都会被作为 bean 处理。

spring.components 文件的读取在 ClassPathBeanDefinitionScanner 的父类ClassPathScanningCandidateComponentProvider 的 setResourceLoader 方法中,源码如下。

	@Nullable
	private CandidateComponentsIndex componentsIndex;
	
	@Override
	public void setResourceLoader(@Nullable ResourceLoader resourceLoader) {
    
    
		this.resourcePatternResolver = ResourcePatternUtils.getResourcePatternResolver(resourceLoader);
		this.metadataReaderFactory = new CachingMetadataReaderFactory(resourceLoader);
		this.componentsIndex = CandidateComponentsIndexLoader.loadIndex(this.resourcePatternResolver.getClassLoader());
	}

可以看到读取文件最终是由CandidateComponentsIndexLoader.loadIndex完成,并将读取结果保存到 CandidateComponentsIndex 中。读取的逻辑也较为简单,判断类路径 spring.properties 文件内或系统参数 spring.index.ignore 是否设置为true,如果是则跳过读取,否则使用 ClassLoader 获取类路径下所有的 META-INF/spring.components 文件(参见前面的文章 Java 中如何获取 classpath 下资源文件? ),并加载为 Properties,然后保存到 CandidateComponentsIndex 。

那么读取到的文件内容都会被使用吗?事实并非如此。ClassPathBeanDefinitionScanner 获取候选组件的工作交给父类来完成,源码如下。

	public Set<BeanDefinition> findCandidateComponents(String basePackage) {
    
    
		if (this.componentsIndex != null && indexSupportsIncludeFilters()) {
    
    
			// 从索引中添加候选组件
			return addCandidateComponentsFromIndex(this.componentsIndex, basePackage);
		} else {
    
    
			// 从类路径下扫描候选组件
			return scanCandidateComponents(basePackage);
		}
	}

这个方法是查找候选组件的核心方法,这里加了判断,如果存在 CandidateComponentsIndex 并且 存在支持 @Indexed 的过滤器,则会从 spring.components 读取 bean,否则还是需要从类路径下进行组件扫描。从索引中添加候选组件的源码如下。

	private Set<BeanDefinition> addCandidateComponentsFromIndex(CandidateComponentsIndex index, String basePackage) {
    
    
		Set<BeanDefinition> candidates = new LinkedHashSet<>();
		try {
    
    
			Set<String> types = new HashSet<>();
			for (TypeFilter filter : this.includeFilters) {
    
    
				// 获取过滤器支持的模式
				String stereotype = extractStereotype(filter);
				if (stereotype == null) {
    
    
					throw new IllegalArgumentException("Failed to extract stereotype from " + filter);
				}
				// 根据包名和模式查找对应的类型
				types.addAll(index.getCandidateTypes(basePackage, stereotype));
			}
			boolean traceEnabled = logger.isTraceEnabled();
			boolean debugEnabled = logger.isDebugEnabled();
			for (String type : types) {
    
    
				// 读取类型信息
				MetadataReader metadataReader = getMetadataReaderFactory().getMetadataReader(type);
				// 判断类型是否为候选组件
				if (isCandidateComponent(metadataReader)) {
    
    
					ScannedGenericBeanDefinition sbd = new ScannedGenericBeanDefinition(metadataReader);
					sbd.setSource(metadataReader.getResource());
					if (isCandidateComponent(sbd)) {
    
    
						if (debugEnabled) {
    
    
							logger.debug("Using candidate component class from index: " + type);
						}
						candidates.add(sbd);
					} else {
    
    
						if (debugEnabled) {
    
    
							logger.debug("Ignored because not a concrete top-level class: " + type);
						}
					}
				} else {
    
    
					if (traceEnabled) {
    
    
						logger.trace("Ignored because matching an exclude filter: " + type);
					}
				}
			}
		} catch (IOException ex) {
    
    
			throw new BeanDefinitionStoreException("I/O failure during classpath scanning", ex);
		}
		return candidates;
	}

includeFilters 是指满足条件的过滤器,满足条件才会被添加。上述 registerDefaultFilters() 方法注册了支持注解 @Component、@ManagedBean、@Named 的注解类型过滤器。 这里先获取过滤器支持的模式,然后根据所需的包名和模式从前面保存模式信息的 CandidateComponentsIndex 对象中获取模式对应的类型。然后判断类型是否为候选的组件,如果是则创建对应的 BeanDefinition 并添加到返回的列表中。至此 META-INF/spring.components 文件内满足条件的 bean 被转换为 BeanDefinition 。

@Indexed 使用注意事项

通过上面的描述可以知道,我们自定义的注解如果标注了 @Component 也会被作为 bean ,因为 @Component 上面标注了 @Indexed 注解。

@Indexed 并非可以任意使用。在没有其他模块依赖或者所依赖的模块都生成了 spring.components 文件时不会存在问题,然而如果依赖的模块只有部分模块存在 spring.components 文件,则其他模块的 bean 也不会被扫描,为避免这种问题,需要在类路径下 spring.properties 文件中或系统属性中的 spring.index.ignore 参数设置为 true,这样就会跳过 spring.components 文件的扫描,而转为重新扫描类路径下的 bean 。

总结

本篇首先通过介绍 @Indexed 实现启动性能优化的原理,然后从源码层面对索引文件的生成和读取进行了剖析,最后还介绍了 @Indexed 的注意事项。其中使用到的技术包括类路径下资源获取、注解处理器等,事实上 Spring 内部的实现都是依赖 Java 最基础的技术,只有提高最核心的能力,阅读 Spring 源码才能够得心应手。最后欢迎大家指导,共同进步。

猜你喜欢

转载自blog.csdn.net/zzuhkp/article/details/108257764