Spring Boot 核心接口之 Envirnoment

发布时间 2023-12-24 17:10:35作者: l_v_y_forever

Spring Boot 核心接口之 Envirnoment

转载自:https://zhuanlan.zhihu.com/p/357050965?utm_id=0

Srping Boot 中我们使用 EnvironmentAware 注入 Environment 对象后,可以在 Environment 中获得系统参数,命令行采参数,文件配置等信息。

Environment 是如何存储,管理这些值的呢?变量发生冲突怎么办呢?我们可以扩展 Environment 的行为吗?本文结合 Spring Boot 启动时 Environment 的初始化过程,了解 Environment 的配置方式、优先级、配置源与扩展方式。

Spring 中的 Environment 指什么?

Spring 中的 Environment 是什么呢?了解 Environment 之前,不得不提到 Property 和 Profile。

我们在写项目的时候,经常会抽取一些配置项,在 Java 中通常叫做属性,也就是 Property,本质是一组键值对配置信息。使用配置项的好处在于修改起来很容易,只需修改下配置文件或命令行参数,然后重启一下就可以了。

开发过程中,大多数项目都有多套配置对应多个环境,一般来说有开发环境、测试环境和生产环境。这里的“环境”就叫做 Profile。程序可以读取到 Profile 的值,根据 Profile 的不同展示不同的特性。其实从本质上讲,“环境”也是一个“配置”,只是这个配置太重要了,也比较特殊,所以作为一个单独的概念来处理。

Environment = Property + Profile

Spring Boot 中,默认使用的 Environment 的实现类是 StandardServletEnvironment,我们可以通过它的类图了解 Spring Boot 中的 Environment 是如何管理的。

PropertyResolver 接口负责 Property 的获取(通过 key 获得 value),Environment 继承了这个接口,加入获得 Profile 的内容。ConfigurablePropertyResolver 继承了 PropertyResolver,为了解决 Property 的获取过程中涉及到的数据类型的转换和${..}表达式的解析问题。ConfigurableEnvironment 在此基础上,加入了 Profile 的设置功能。ConfigurableWebEnvironment 扩展了 web 功能,将 servlet 上下文作为配置源。

AbstractEnvironment,StandardEnvironment,StandardServletEnvironment 都是 Spring 对上述功能的实现。

Spring Boot 中的配置来自哪里?

Spring Boot 中的配置来自不同的地方,最常见的来自于 application.properties、application.yaml、环境变量和命令行参数。我们可以在 Spring Boot 的官方文档看到各种各样的配置方式。

官方一共给出了 14 中配置方式,并且给出了配置的优先级。数字越大优先级越高。

  1. 通过硬编码的方式(SpringApplication.setDefaultProperties)进行配置。
  2. 在 Spring Boot 的配置类上使用 @PropertySource 注解指定配置文件。
  3. 使用配置文件 (比如 application.properties 文件)。
  4. 通过 random.* 配置的随机属性。
  5. 操作系统中的环境变量。
  6. Java 的系统属性,可通过 System.getProperties() 获得相关内容。
  7. java:comp/env 中 JNDI 属性。
  8. ServletContext 初始化参数(web 环境)
  9. ServletConfig 初始化参数(web 环境)
  10. SPRING_APPLICATION_JSON 属性,该属性以 JSON 形式存储在系统环境变量中。
  11. 命令行参数,类似于 java -jar -Denv=DEV 之类。
  12. @SpringBootTest 注解,仅在测试中使用。
  13. @TestPropertySource 注解,仅在测试中使用。
  14. 激活 devtools 时,位于 $HOME/.config/spring-boot 下的配置。

如此之多的配置方式,且配置项的来源是多样化的,如何对用户暴露这些配置呢?一种方式就是将所有来源都暴露给用户,用户可以从任意配置源中获得配置。还有一种方式就是由 Spring 管理这些配置源,内部排好优先级,对外暴露统一的 get 方法,用户不需要知道其中的细节。Spring 显然使用了后者。

Spring 使用 PropertySource 来表示一个配置源,PropertySource 有很多子类,比如 SystemEnvironmentPropertySource,PropertiesPropertySource 等等。Spring 将这些 PropertySource 维护在一个列表中,当用户想要获得一个配置的时候,Spring 会遍历这些配置源,依次判断是否有匹配的配置。配置源在列表中的数据其实就代表了优先级。

如下是 Spring 从 propertySources 中获得 Property 的方式。

protected <T> T getProperty(String key, Class<T> targetValueType, boolean resolveNestedPlaceholders) {
  if (this.propertySources != null) {
    for (PropertySource<?> propertySource : this.propertySources) {

      Object value = propertySource.getProperty(key);
      if (value != null) {
        if (resolveNestedPlaceholders && value instanceof String) {
          value = resolveNestedPlaceholders((String) value);
        }
        logKeyFound(key, propertySource, value);
        return convertValueIfNecessary(value, targetValueType);
      }
    }
  }
  return null;
}

综上,Spring Boot 中的 Environment 中维护了若干个 PropertySource,也就是配置源,所有的配置到来自于这些配置源。

Spring Boot 中的 PropertySource

Spring Boot 中 prepareEnvironment 方法事情确定过程中首先调用的一个方法,该其实就是 Spring 收集各种 PropertySource 的方法。方法执行完毕后,Spring 中有若干 个 PropertySource(这个数目会根据依赖的不同,配置的不同而发生变化,并不是一个定值)。我们的配置都是这些 PropertySource 来的。如下是 prepareEnvironment 的代码。

private ConfigurableEnvironment prepareEnvironment(SpringApplicationRunListeners listeners,
    DefaultBootstrapContext bootstrapContext, ApplicationArguments applicationArguments) {
  // 创建 StandardServletEnvironment,这是 WEB 环境默认的 Environment
  // 创建该对象的过程中,会同时初始化 4 个 PropertySource,名称是:
  // 1. servletConfigInitParams
  // 2. servletContextInitParams
  // 3. systemProperties
  // 4. systemEnvironment
  ConfigurableEnvironment environment = getOrCreateEnvironment();

  // 1. 配置默认的配置源,DefaultPropertiesPropertySource
  // 2. 解析命令行参数,作为一个 PropertySource: commandLineArgs
  configureEnvironment(environment, applicationArguments.getSourceArgs());

  ConfigurationPropertySources.attach(environment);

  // 发出 ApplicationEnvironmentPreparedEvent 事件,监听器监听到事件后,会注入一些配置源,名称是
  // 1. random
  // 2. systemEnvironment,加强上面的 systemEnvironment
  // 3. spring.application.json
  // 4. devtools
  // 5. application.properties 等文件
  listeners.environmentPrepared(bootstrapContext, environment);

  // 用户设置的 defaultProperties 优先级最低
  DefaultPropertiesPropertySource.moveToEnd(environment);
  configureAdditionalProfiles(environment);

  // 解析 spring.xxx.xxx 配置
  bindToSpringApplication(environment);
  if (!this.isCustomEnvironment) {
    environment = new EnvironmentConverter(getClassLoader()).convertEnvironmentIfNecessary(environment,
        deduceEnvironmentClass());
  }
  ConfigurationPropertySources.attach(environment);
  return environment;
}

首先,我们看下在调用 getOrCreateEnvironment 方法创建 StandardServletEnvironment 对象过程中,初始化的几个 PropertySource。

  1. servletConfigInitParams,初始类型是 StubPropertySource,默认没有存任何值,由于我们使用的是 Web 环境,AbstractRefreshableWebApplicationContext 在后续的初始化变量过程中,会触发 initPropertySources 的方法,进行该 PropertySource 的赋值,如果存在值的话,会将类型替换为 ServletContextPropertySource。我们配置的 Servlet 初始化参数就存放在这里。
  2. servletContextInitParams,同 servletConfigInitParams。不同之处是这个数据源存放 Servlet 上下文参数。如下是 AbstractRefreshableWebApplicationContext 初始化这两个配置源的代码。
  3. systemProperties, 类型是 PropertiesPropertySource,该 source 中存储了 Java 的系统属性,内部变量是系统调用 System.getProperties() 方法获得的,有 Java 运行时环境版本,Java 运行时环境供应商,Java 安装目录等信息。
  4. systemEnvironment,类型是 SystemEnvironmentPropertySource,该 source 中存储了系统环境变量,内部变量是系统调用 System.getenv() 方法获得的,有 path 环境变量,计算机名,用户名等信息。

environment 对象创建完毕后,在 configureEnvironment 方法中会添加一个默认的配置源,这个配置源是我们通过硬编码的方式写入的,一般不会使用这种方式。

public static void main(String[] args) {
  SpringApplication springApplication = new SpringApplication(XiangApplication.class);
  Properties properties = new Properties();
  properties.setProperty("name", "Tom");
  springApplication.setDefaultProperties(properties);
  springApplication.setAdditionalProfiles();
  springApplication.run(args);
}

除此之外,还会添加命令行参数作为一个新的数据源,名称是 commandLineArgs,类型是 SimpleCommandLinePropertySource,以 key value 的形式存放 Spring Boot 启动命令行参数。

接下来我们看下在 Spring 发出 ApplicationEnvironmentPreparedEvent 事件后,有哪些 Spring 的监听器在这个阶段添加了那些数据源。Spring 启动监听器的注册请看Spring Boot 是如何监听启动事件的一文。

Spring Boot 启动时候注册的有个监听器名称是 EnvironmentPostProcessorApplicationListener,它监听的事件中就有 ApplicationEnvironmentPreparedEvent,在这个事件回调中,它会使用 SPI 的方式获得在 spring.factories 文件中定义为 EnvironmentPostProcessor 的类。然后执行这些类的 postProcessEnvironment 方法。

其中第一个 EnvironmentPostProcessor 实现类是 RandomValuePropertySourceEnvironmentPostProcessor,它会执行这样一段代码。

@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
  RandomValuePropertySource.addToEnvironment(environment, this.logger);
}

这就是向配置源中新增加了一个类型是 RandomValuePropertySource 的类,名称叫做 random,这个配置源的作用是在我们的配置文件中注入随机值,我举个例子。我们如果想要配置一些随机数,那我们可以使用如下的方式进行配置,RandomValuePropertySource 就是用于解析以 random 开头的配置的。

program.max.a=${random.int}
program.max.b=${random.long}

第二个 postProcessEnvironment 是 SystemEnvironmentPropertySourceEnvironmentPostProcessor。这个类加强了在初始化时加入的 systemEnvironment 配置源。配置源的名称没变,还是 systemEnvironment,但是类型变为了 OriginAwareSystemEnvironmentPropertySource。

第三个 postProcessEnvironment 是 SpringApplicationJsonEnvironmentPostProcessor。这个配置源本质上并没有为 Spring 增加新的配置的来源,而是增加了一种解析配置的方式,它会从当前的配置中找出名称为 spring.application.json 或是 SPRING_APPLICATION_JSON 的配置,然后将其按照 json 的方式解析,将解析得到的配置存起来。我们一般在命令行中设置。

java -jar xxx.jar --spring.application.json={"name":"Tom"}

或者

java -Dspring.application.json={"name":"Tom"} -jar xxx.jar

如果是在 IDEA 中设置的话,需要注意下转义,写成 --spring.application.json={\"name\":\"Tom\"}

此时会增加一个名称为 spring.application.json 的配置源,类型是 JsonPropertySource。其中存储了我们在 jSON 串中定义的 key value 对。注意此时并没有加载 application.properties 文件,所以在 application.properties 中配置 spring.application.json 是无效的。

后续还有一些 postProcessEnvironment,会根据当前代码环境添加一些配置源,比如 spring cloud 环境下会添加 vcap 配置源,默认还会添加一个 devtools 数据源。

可以看出,除了初始化时候增加了必要的配置源外,大部分配置是在 postProcessEnvironment 中设置的。Spring Boot 为该接口的实现提供了 SPI 的加载机制,如果要扩展配置源的话,使用这种方式是最推荐的方式。

application.properties

上一步我们故意遗漏了一个 postProcessEnvironment,那就是 ConfigDataEnvironmentPostProcessor,这是用来处理配置文件的,简单来说,就是用来读取 application.{profile}.properties 或是 application.{profile}.yml 文件的。由于这个环境变量后置处理器比较复杂,单独拿出来看下。这也是最后一个 PropertySource。

首先关注下我们到底会从哪些路径下获得配置文件,在 ConfigDataEnvironment 类中规定了这个路径。如下所示。

static {
  List<ConfigDataLocation> locations = new ArrayList<>();
  locations.add(ConfigDataLocation.of("optional:classpath:/"));
  locations.add(ConfigDataLocation.of("optional:classpath:/config/"));
  locations.add(ConfigDataLocation.of("optional:file:./"));
  locations.add(ConfigDataLocation.of("optional:file:./config/*/"));
  locations.add(ConfigDataLocation.of("optional:file:./config/"));
  DEFAULT_SEARCH_LOCATIONS = locations.toArray(new ConfigDataLocation[0]);
};

默认情况下,Spring 会从这 5 处加载配置文件,file 指的是项目的根目录,classpath 指的是 resource 目录。这两个目录下可以直接放置配置文件,也可以新建一个 config 文件夹,将配置文件放入。

有了位置,还需要能在这些地址下获得配置文件,Spring Boot 在构建 ConfigDataEnvironment 过程中,还指定了从路径下获得位置文件的类。如下是获得路径解析器的方式,还是熟悉的 SPI 模式。

SpringFactoriesLoader.loadFactoryNames(ConfigDataLocationResolver.class, null)

这两个类就是 Spring Boot 指定的从指定路径下加载配置文件的实现类。我们重点看第二个类,StandardConfigDataLocationResolver。

# ConfigData Location Resolvers
org.springframework.boot.context.config.ConfigDataLocationResolver=\
org.springframework.boot.context.config.ConfigTreeConfigDataLocationResolver,\
org.springframework.boot.context.config.StandardConfigDataLocationResolver

StandardConfigDataLocationResolver 负责从指定路径下加载文件,但是获得文件并不是我们的目标,因为该类并不能正确的解析其中的值,所以在 StandardConfigDataLocationResolver 初始化的时候,使用 SPI 的方式获得解析配置的类。

SpringFactoriesLoader.loadFactories(PropertySourceLoader.class, getClass().getClassLoader());

饶了这么大一圈子,我们终于找到了用于解析配置文件的类。分别是 PropertiesPropertySourceLoader 和 YamlPropertySourceLoader,看名字就知道了,是用来解析 Properties 文件和 Yaml 文件的。

# PropertySource Loaders
org.springframework.boot.env.PropertySourceLoader=\
org.springframework.boot.env.PropertiesPropertySourceLoader,\
org.springframework.boot.env.YamlPropertySourceLoader

到现在为止,基本万事具备了,我们有路径,有用于读取文件的类,有用于解析文件的类。我们挑取其中的一些问题进行分析。

第一个问题是,Spring Boot 会从多个文件下读取配置文件,那这些文件夹的扫描顺序是怎样的?

在不考虑 spring.config.location 等配置的影响下时,访问文件夹的顺序是 optional:file:./config/ -> optional:file:./config/*/ -> optional:file:./ -> optional:classpath:/config/ -> optional:classpath:/。为什么要了解加载顺序呢?是因为 Spring Boot 中是支持多配置文件的,我们完全可以将多个配置文件放在不同的目录下,此时加载顺序就非常重要了,因为一个配置在加入配置集合的时候,会首先判断是否已经存在同名配置,如果存在的话,就不加入了,所以遇到同名问题的时候需要根据加载顺序判断优先级。顺序越靠前,优先级越高。

接下来我们考虑下几个特殊的配置,首先是 spring.config.location,这个配置只能用在命令行里,写在配置文件里是无效的,主要用来运维已经打包好了的程序,想要指定配置文件路径的情况。

java -jar demo.jar --spring.config.location=C:/application.properties

这个配置会导致系统默认的规则不生效,也就是除了它指定的配置文件外,其余任何文件夹下的配置文件都会失效。

还有 spring.config.additional-location,这个配置的方式和 spring.config.location 是一样的,区别在于,该配置会和系统默认配置做合并,且优先级最高。spring.config.import 用法也一致,优先级高于 spring.config.additional-location。

第二个问题是,Spring Boot 是如何读取某个文件夹下的配置文件的?

StandardConfigDataLocationResolver 中规定了配置文件的默认名称是 application,给对象中还配置了两个解析器,每个解析器中配置了能够解析的问价类型,也就是后缀名,根据名称和后缀名能获得一个完整的文件路径。这样就能读取到这个文件并且加载其中的配置了。

private static final String[] DEFAULT_CONFIG_NAMES = { "application" };

我们看下 PropertiesPropertySourceLoader 类支持的文件类型,后缀是 properties 和 xml 的都支持。而 YamlPropertySourceLoader 支持的文件类型是 yml 和 yaml。

@Override
public String[] getFileExtensions() {
  return new String[] { "properties", "xml" };
}

所以,在一个文件夹下,其实可以有名称相同,后缀不同的配置文件,加载的顺序是 properties > xml > yml > yaml。其实这个顺序并没有什么深意,仅仅是由于代码前后执行导致的,真实情况下也不会有同名但不同后缀的配置文件出现。

第三个问题是,spring.profiles.active 会对配置文件产生那些影响?

spring.profiles.active 是 Spring Boot 多环境配置的关键,我们通过配置这个值加载不同的配置文件。

以上我们讲的所有流程都没有涉及到 profile,实际上,配置文件加载是个分阶段的过程,在加载好默认的配置文件后,也就是完成第二个问题中提到的过程后,接下来就是判断 profile 的过程。涉及到的配置有两个。spring.profiles.active 和 spring.profiles.include。这两个配置都是用来激活环境配置用的。

以 spring.profiles.active 为例,我们通过在 application.properyies 设置 spring.profiles.active 来具体激活一个或者多个配置文件,如果没有没有指定任何 profile 的配置文件的话,spring boot 默认会启动application-default.properties。

如下是根据不同的 profile 确定文件名的过程。

StandardConfigDataReference(ConfigDataLocation configDataLocation, String directory, String root, String profile,
            String extension, PropertySourceLoader propertySourceLoader) {
  ...
  String profileSuffix = (StringUtils.hasText(profile)) ? "-" + profile : "";
  this.resourceLocation = root + profileSuffix + ((extension != null) ? "." + extension : "");
  ...
}

此时,application-{profile}.properties 中的配置会覆盖之前同目录下的其余配置。注意了,必须是同目录,不能跨目录进行覆盖。

总结

  1. Environment = Property + Profile。前置指配置项,后者指环境。
  2. Spring Boot 中 Environment 中的配置源于多个配置源,有的来自系统环境变量,有的来自命令行参数,有的来自用户配置。Environment 屏蔽了各个配置源间不同的配置获取方式,对外提供统一的调用接口。
  3. Spring 中配置的优先级,本质上是不同配置源的优先级问题,Environment 中的优先级比较繁琐,但是本质是按照后置流程优先级高的约定设置的。比如用户文件配置高于硬编码,命令行参数高于用户配置等等。
  4. Spring 除了系统内置的配置源外,还通过发布事件的方式为用户提供了自定义配置源的方法。具体是实现 EnvironmentPostProcessor 接口,然后在 spring.factories 进行设置,这种 SPI 的加载方式也是最为推荐的方式。Spring Boot 内部很多配置源都是用这种方式实现的。
  5. Spring Boot 支持使用配置文件的方式进行配置。文件的类型是 properties(.properties .xml) 或者 yaml(.yml .yaml)。文件在项目中放的位置和优先级有关。
  6. Spring Boot 通过设置(一般在打包的时候通过命令行设置) spring.profiles.active 激活多环境的能力, 如果存在同名配置的话,application-{profile}.properties 会覆盖当前同级目录下的 application.properties 的配置。