背景
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 源码才能够得心应手。最后欢迎大家指导,共同进步。