性能文章>这些不知道,别说你熟悉 Spring>

这些不知道,别说你熟悉 Spring原创

2年前
503336

大家好,这篇文章跟大家来聊下 Spring 中提供的常用扩展点、Spring SPI 机制、以及 SpringBoot 自动装配原理,重点介绍下 Spring 基于这些扩展点怎么跟配置中心(Apollo、Nacos、Zookeeper、Consul)等做集成。

写在前面

我们大多数 Java 程序员的日常工作基本都是在做业务开发,俗称 crudboy。

作为 crudboy 的你有没有这些烦恼呢?

  1. 随着业务的迭代,新功能的加入,代码变得越来越臃肿,可维护性越来越低,慢慢变成了屎山

  2. 遇到一些框架层的问题不知道怎么解决

  3. 面试被问到使用的框架、中间件原理、源码层东西,不知道怎么回答

  4. 写了 5 年代码了,感觉自己的技术没有理想的长进

如果你有上述这些烦恼,我想看优秀框架的源码会是一个很好的提升方式。通过看源码,我们能学到业界大佬们优秀的设计理念、编码风格、设计模式的使用、高效数据结构算法的使用、魔鬼细节的巧妙应用等等。这些东西都是助力我们成为一个优秀工程师不可或缺的。

如果你打算要看源码了,优先推荐 Spring、Netty、Mybatis、JUC 包。

Spring 扩展

我们知道 Spring 提供了很多的扩展点,第三方框架整合 Spring 其实大多也都是基于这些扩展点来做的。所以熟练的掌握 Spring 扩展能让我们在阅读源码的时候能快速的找到入口,然后断点调试,一步步深入框架内核。

这些扩展包括但不限于以下接口:

BeanFactoryPostProcessor:在 Bean 实例化之前对 BeanDefinition 进行修改

BeanPostProcessor:在 Bean 初始化前后对 Bean 进行一些修改包装增强,比如返回代理对象

Aware:一个标记接口,实现该接口及子接口的类会收到 Spring 的通知回调,赋予某种 Spring 框架的能力,比如 ApplicationContextAware、EnvironmentAware 等

ApplicationContextInitializer:在上下文准备阶段,容器刷新之前做一些初始化工作,比如我们常用的配置中心 client 基本都是继承该初始化器,在容器刷新前将配置从远程拉到本地,然后封装成 PropertySource 放到 Environment 中供使用

ApplicationListener:Spring 事件机制,监听特定的应用事件(ApplicationEvent),观察者模式的一种实现

FactoryBean:用来自定义 Bean 的创建逻辑(Mybatis、Feign 等等)

ImportBeanDefinitionRegistrar:定义@EnableXXX 注解,在注解上 Import 了一个 ImportBeanDefinitionRegistrar,实现注册 BeanDefinition 到容器中

InitializingBean:在 Bean 初始化时会调用执行一些初始化逻辑

ApplicationRunner/CommandLineRunner:容器启动后回调,执行一些初始化工作

上述列出了几个比较常用的接口,但是 Spring 扩展远不于此,还有很多扩展接口大家可以自己去了解。

Spring SPI 机制

在讲接下来内容之前,我们先说下 Spring 中的 SPI 机制。Spring 中的 SPI 主要是利用 META-INF/spring.factories 文件来实现的,文件内容由多个 k = list(v) 的格式组成,比如:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.dtp.starter.adapter.dubbo.autoconfigure.ApacheDubboTpAutoConfiguration,\
  com.dtp.starter.adapter.dubbo.autoconfigure.AlibabaDubboTpAutoConfiguration

org.springframework.boot.env.EnvironmentPostProcessor=\
  com.dtp.starter.zookeeper.autoconfigure.ZkConfigEnvironmentProcessor

这些 spring.factories 文件可能是位于多个 jar 包中,Spring 容器启动时会通过 ClassLoader.getResources() 获取这些 spring.factories 文件的全路径。然后遍历路径以字节流的形式读取所有的 k = list(v) 封装到到一个 Map 中,key 为接口全限定类名,value 为所有实现类的全限定类名列表。

上述说的这些加载操作都封装在 SpringFactoriesLoader 类里。该类很简单,提供三个加载方法、一个实例化方法,还有一个 cache 属性,首次加载到的数据会保存在 cache 里,供后续使用。

SpringBoot 核心要点

上面讲的 SPI 其实就是我们 SpringBoot 自动装配的核心。

何为自动装配?

自动装配对应的就是手动装配,在没 SpringBoot 之前,我们使用 Spring 就是用的手动装配模式。在使用某项第三方功能时,我们需要引入该功能依赖的所有包,并测试保证这些引入包版本兼容。然后在 XML 文件里进行大量标签配置,非常繁琐。后来 Spring4 里引入了 JavaConfig 功能,利用 @Configuration + @Bean 来代替 XML 配置,虽然对开发来说是友好了许多,但是这些模板式配置代码还是很繁琐,会浪费大量时间做配置。Java 重可能也就是这个时候给人留的一种印象。

在该背景下出现了 SpringBoot,SpringBoot 可以说是稳住了 Java 的地位。SpringBoot 提供了自动装配功能,自动装配简单来说就是将某种功能(如 web 相关、redis 相关、logging 相关等)打包在一起,统一管理依赖包版本,并且约定好相关功能 Bean 的装配规则,使用者只需引入一个依赖,通过少量注解或简单配置就可以使用第三方组件提供的功能了。

在 SpringBoot 中这类功能组件有一个好听的名字叫做 starter。比如 spring-boot-starter-web、spring-boot-starter-data-redis、spring-boot-starter-logging 等。starter 里会通过 @Configuration + @Bean + @ConditionalOnXXX 等注解定义要注入 Spring 中的 Bean,然后在 spring.factories 文件中配置为 org.springframework.boot.autoconfigure.EnableAutoConfiguration 的实现,就可以完成自动装配了。

具体装配流程怎么样的呢?

其实也很简单,基本都是 Spring 中的知识,没啥新颖的。主要依托于@EnableAutoConfiguration 注解,该注解上会 Import 一个 AutoConfigurationImportSelector,看下继承关系,该类继承于 DeferredImportSelector。

主要方法为 getAutoConfigurationEntry()

	protected AutoConfigurationEntry getAutoConfigurationEntry(AnnotationMetadata annotationMetadata) {
      // 1
      if (!isEnabled(annotationMetadata)) {
        return EMPTY_ENTRY;
      }
      AnnotationAttributes attributes = getAttributes(annotationMetadata);
      // 2
      List<String> configurations = getCandidateConfigurations(annotationMetadata, attributes);
      configurations = removeDuplicates(configurations);
      // 3
      Set<String> exclusions = getExclusions(annotationMetadata, attributes);
      checkExcludedClasses(configurations, exclusions);
      configurations.removeAll(exclusions);
      // 4
      configurations = getConfigurationClassFilter().filter(configurations);
      fireAutoConfigurationImportEvents(configurations, exclusions);
      return new AutoConfigurationEntry(configurations, exclusions);
	}

方法解读

  1. 通过 spring.boot.enableautoconfiguration 配置项判断是否启用自动装配,默认为 true

  2. 使用上述说的 SpringFactoriesLoader.loadFactoryNames() 加载所有 org.springframework.boot.autoconfigure.EnableAutoConfiguration 的实现类的全限定类名,借助 HashSet 进行去重

  3. 获取 @EnableAutoConfiguration 注解上配置的要 exclude 的类,然后排除这些特定类

  4. 通过 @ConditionalOnXXX 进行过滤,满足条件的类才会留下,封装到 AutoConfigurationEntry 里返回

那 getAutoConfigurationEntry() 方法在哪儿调用呢?

public void refresh() throws BeansException, IllegalStateException {
				// Allows post-processing of the bean factory in context subclasses.
				postProcessBeanFactory(beanFactory);

				StartupStep beanPostProcess = this.applicationStartup.start("spring.context.beans.post-process");
				// Invoke factory processors registered as beans in the context.
				invokeBeanFactoryPostProcessors(beanFactory);

				// Register bean processors that intercept bean creation.
				registerBeanPostProcessors(beanFactory);
				beanPostProcess.end();

				// Initialize message source for this context.
				initMessageSource();

				// Initialize event multicaster for this context.
				initApplicationEventMulticaster();

				// Initialize other special beans in specific context subclasses.
				onRefresh();

				// Check for listener beans and register them.
				registerListeners();

				// Instantiate all remaining (non-lazy-init) singletons.
				finishBeanFactoryInitialization(beanFactory);

				// Last step: publish corresponding event.
				finishRefresh();
	}

以上是 Spring 容器刷新时的几个关键步骤,在步骤二 invokeBeanFactoryPostProcessors() 中会调用所有已经注册的 BeanFactoryPostProcessor 进行处理。此处调用也是有顺序的,优先会调用所有 BeanDefinitionRegistryPostProcessor#postProcessBeanDefinitionRegistry(),BeanDefinitionRegistryPostProcessor 是一个特殊的 BeanFactoryPostProcessor,然后再调用所有 BeanFactoryPostProcessor#postProcessBeanFactory()。

ConfigurationClassPostProcessor 是 BeanDefinitionRegistryPostProcessor 的一个实现类,该类主要用来处理 @Configuration 注解标注的类。我们用 @Configuration 标注的类会被 ConfigurationClassParser 解析包装成 ConfigurationClass 对象,然后再调用 ConfigurationClassBeanDefinitionReader#loadBeanDefinitionsForConfigurationClass() 进行 BeanDefination 的注册。

其中 ConfigurationClassParser 解析时会递归处理源配置类上的注解(@PropertySource、@ComponentScan、@Import、@ImportResource)、 @Bean 标注的方法、接口上的 default 方法,进行 ConfigurationClass 类的补全填充,同时如果该配置类有父类,同样会递归进行处理。具体代码请看 ConfigurationClassParser#doProcessConfigurationClass() 方法

protected final SourceClass doProcessConfigurationClass(
			ConfigurationClass configClass, SourceClass sourceClass, Predicate<String> filter)
			throws IOException {
      
		// Process any @PropertySource annotations

		// Process any @ComponentScan annotations

		// Process any @Import annotations
		processImports(configClass, sourceClass, getImports(sourceClass), filter, true);

		// Process any @ImportResource annotations

		// Process individual @Bean methods
		Set<MethodMetadata> beanMethods = retrieveBeanMethodMetadata(sourceClass);
		for (MethodMetadata methodMetadata : beanMethods) {
			 configClass.addBeanMethod(new BeanMethod(methodMetadata, configClass));
		}

		// Process default methods on interfaces
		processInterfaces(configClass, sourceClass);

		// Process superclass, if any
		if (sourceClass.getMetadata().hasSuperClass()) {
			 String superclass = sourceClass.getMetadata().getSuperClassName();
			 if (superclass != null && !superclass.startsWith("java") &&
					!this.knownSuperclasses.containsKey(superclass)) {
				  this.knownSuperclasses.put(superclass, configClass);
			  	// Superclass found, return its annotation metadata and recurse
				  return sourceClass.getSuperClass();
			}
		}

		// No superclass -> processing is complete
		return null;
	}

1)parser.parse(candidates) 解析得到完整的 ConfigurationClass 对象,主要填充下图框中的四部分。

2)this.reader.loadBeanDefinitions(configClasses) 根据框中的四部分进行 BeanDefination 的注册。

在上述 processImports() 过程中会将 DeferredImportSelector 的实现类放在 deferredImportSelectorHandler 中以便延迟到所有的解析工作完成后进行处理。deferredImportSelectorHandler 中就存放了 AutoConfigurationImportSelector 类的实例。process() 方法里经过几步走会调用到 AutoConfigurationImportSelector#getAutoConfigurationEntry() 方法上获取到自动装配需要的类,然后进行与上述同样的 ConfigurationClass 解析封装工作。

代码层次太深,调用太复杂,建议自己断点调试源码跟一遍印象会更深刻。

ApplicationContextInitializer 调用时机

我们就以 SpringBoot 项目为例来看,在 SpringApplication 的构造函数中会进行 ApplicationContextInitializer 的初始化。

上图中的 getSpringFactoriesInstances 方法内部其实就是调用 SpringFactoriesLoader.loadFactoryNames 获取所有 ApplicationContextInitializer 接口的实现类,然后反射创建对象,并对这些对象进行排序(实现了 Ordered 接口或者加了 @Order 注解)。

	private <T> Collection<T> getSpringFactoriesInstances(Class<T> type, Class<?>[] parameterTypes, Object... args) {
      ClassLoader classLoader = getClassLoader();
      // Use names and ensure unique to protect against duplicates
      Set<String> names = new LinkedHashSet<>(SpringFactoriesLoader.loadFactoryNames(type, classLoader));
      List<T> instances = createSpringFactoriesInstances(type, parameterTypes, classLoader, args, names);
      AnnotationAwareOrderComparator.sort(instances);
      return instances;
	}

至此,项目中所有 ApplicationContextInitializer 的实现已经加载并且创建好了。在 prepareContext 阶段会进行所有已注册的 ApplicationContextInitializer#initialize() 方法的调用。在此之前prepareEnvironment 阶段已经准备好了环境信息,此处接入配置中心就可以拉到远程配置信息然后填充到 Spring 环境中供应用使用。

SpringBoot 集成 Apollo

ApolloApplicationContextInitializer 实现 ApplicationContextInitializer 接口,并且在 spring.factories 文件中配置如下

org.springframework.context.ApplicationContextInitializer=\
com.ctrip.framework.apollo.spring.boot.ApolloApplicationContextInitializer

initialize() 方法中会根据 apollo.bootstrap.namespaces 配置的 namespaces 进行配置的拉去,拉去到的配置会封装成 ConfigPropertySource 添加到 Spring 环境 ConfigurableEnvironment 中。具体的拉去流程就不展开讲了,感兴趣的可以自己去阅读源码了解。

SpringCloud 集成 Nacos、Zk、Consul

在 SpringCloud 场景下,SpringCloud 规范中提供了 PropertySourceBootstrapConfiguration 继承 ApplicationContextInitializer,另外还提供了个 PropertySourceLocator,二者配合完成配置中心的接入。

initialize 方法根据注入的 PropertySourceLocator 进行配置的定位获取,获取到的配置封装成 PropertySource 对象,然后添加到 Spring 环境 Environment 中。

Nacos、Zookeeper、Consul 都有提供相应 PropertySourceLocator 的实现

我们来分析下 Nacos 提供的 NacosPropertySourceLocator,locate 方法只提取了主要流程代码,可以看到 Nacos 启动会加载以下三种配置文件,也就是我们在 bootstrap.yml 文件里配置的扩展配置 extension-configs、共享配置 shared-configs 以及应用自己的配置,加载到配置文件后会封装成 NacosPropertySource 放到 Spring 的 Environment 中。

public PropertySource<?> locate(Environment env) {
		 loadSharedConfiguration(composite);
		 loadExtConfiguration(composite);
		 loadApplicationConfiguration(composite, dataIdPrefix, nacosConfigProperties, env);
		 return composite;
	}

loadApplicationConfiguration 加载应用配置时,同时会加载以下三种配置,分别是

  1. 不带扩展名后缀,application

  2. 带扩展名后缀,application.yml

  3. 带环境,带扩展名后缀,application-prod.yml

并且从上到下,优先级依次增高

private void loadApplicationConfiguration(
			CompositePropertySource compositePropertySource, String dataIdPrefix,
			NacosConfigProperties properties, Environment environment) {
		String fileExtension = properties.getFileExtension();
		String nacosGroup = properties.getGroup();
		// load directly once by default
		loadNacosDataIfPresent(compositePropertySource, dataIdPrefix, nacosGroup,
				fileExtension, true);
		// load with suffix, which have a higher priority than the default
		loadNacosDataIfPresent(compositePropertySource,
				dataIdPrefix + DOT + fileExtension, nacosGroup, fileExtension, true);
		// Loaded with profile, which have a higher priority than the suffix
		for (String profile : environment.getActiveProfiles()) {
			String dataId = dataIdPrefix + SEP1 + profile + DOT + fileExtension;
			loadNacosDataIfPresent(compositePropertySource, dataId, nacosGroup,
					fileExtension, true);
		}
	}

加载过程中,通过 namespace, dataId, group 唯一定位一个配置文件

  1. 首先获取本地缓存的配置,如果有直接返回

  2. 如果步骤1从本地没找到相应配置文件,开始从远处拉去,Nacos 2.0 以上版本使用 Grpc 协议进行远程通信,1.0 及以下使用 Http 协议进行远程通信

  3. 对拉去到的字符串进行解析,封装成 NacosPropertySource 返回

具体细节就不展开讲了,可以自己看源码了解

Zookeeper、Consul 的接入也是非常简单,可以自己分析一遍。如果我们有自研的配置中心,需要在 SpringCloud 环境下使用,可以根据 SpringCloud 提供的这些扩展参考以上几种实现快速的写个 starter 进行接入。

总结

本篇文章主要讲了下 Spring SPI 机制、SpringBoot 自动装配原理,以及扩展点 ApplicationContextInitializer 在集成配置中心时的应用。篇幅有限,一些具体代码细节就没展开讲了,以后会出些文章针对某一个点进行详细讲解。

个人开源项目

DynamicTp 是一个基于配置中心实现的轻量级动态线程池管理工具,主要功能可以总结为动态调参、通知报警、运行监控、三方包线程池管理等几大类。

目前累计 2k star,代码优雅,使用了大量设计模式,如果你觉得看这些大型框架源码费劲,那么可以尝试从 DynamicTp 源码入手,欢迎大家了解试用

官网https://dynamictp.cn

gitee地址https://gitee.com/dromara/dynamic-tp

github地址https://github.com/dromara/dynamic-tp

点赞收藏
yanhom

动态线程池DynamicTp作者,Nacos、Dubbo、RocketMq等开源项目贡献者,专注后端领域,跟你一起变强!

请先登录,查看3条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

JDBC PreparedStatement 字段值为null导致TBase带宽飙升的案例分析

JDBC PreparedStatement 字段值为null导致TBase带宽飙升的案例分析

随机一门技术分享之Netty

随机一门技术分享之Netty

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

6
3