跟我学springboot第二讲-加载Environment源码解析&最佳实践

跟我学springboot第二讲-加载Environment源码解析&最佳实践

SpringBoot中的配置文件是如何加载的?系统级变量SpringBoot是否获得的?如何做到按环境不同加载不同的配置文件?是不是对此感兴趣,那点进来一起学习吧!

在上一节SpringBoot应用启动流程分析中,我们了解了一个SpringBoot应用启动所有的环节,那么本章我们将重点展开Environment的最佳实践和实现原理。

什么是Environment

Environment表示当前应用程序运行环境的接口,为应用环境两个关键方面建模:profiles和properties,通过PropertyResolver接口来实现属性访问相关的方法。Environment是Spring对应用所处环境的抽象,一般情况下包含配置文件,系统变量等等

SpringBoot启动流程中,构建环境只需要一行代码ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments),这行代码背后的秘密就是本文的主要内容。

SpringBoot Environment最佳实践

在讲Environment初始化源码前掌握SpringBoot中Environment相关最佳实践,是学习的最佳方式。主要包括了配置文件路径变更,占位符,多环境配置,加载顺序,自动映射对象。

最佳实践中的内容,有些摘抄自Spring Boot 2.x基础教程:配置文件详解,该文章讲解的很详细,没有必要重复发明轮子了。

参数传递形式

对于一个Java应用,参数有多种传递形式。可以是虚拟机参数(VM options)应用参数(Program arguments)操作系统变量(Environment variables)

虚拟机参数

  • Standard Options(-D but not only)

    标准参数,适用于所有的Java虚拟机,如果你需要传入自定义的参数,建议加上-D,当然你完全可以不加-D,但是可能会和JVM原生的 option冲突。比如你的参数叫-server=myApp,这样就和原生的-server参数冲突,在启动的时候会报错。

    1
    2
    3
    Unrecognized option: -server=111
    Error: Could not create the Java Virtual Machine.
    Error: A fatal exception has occurred. Program will exit.

    当你使用-Dserver=111时,应用则顺利启动,它会被SpringBoot的systemProperties采集到”server”->”111”。

  • Non-Standard Options(Prefixed with -X)

    -X不是通用的JVM参数,只适用于Java HotSpot虚拟机,例如:-Xmssize,-Xmxsize

  • Advanced Runtime Options (prefixed with -XX)

    Java HotSpot虚拟机运行时参数

  • Advanced JIT Compiler Options (prefixed with -XX)

    Java HotSpot JIT 参数

  • Advanced Serviceability Options (prefixed with -XX)

    收集系统信息和调试的参数

  • Advanced Garbage Collection Options (prefixed with -XX)

    Java HotSpot垃圾回收期参数

应用参数

应用参数public static void main(String[] args){}args,在SpringBoot中,是通过--前缀进行解析的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class SimpleCommandLineArgsParser {

/**
* Parse the given {@code String} array based on the rules described {@linkplain
* SimpleCommandLineArgsParser above}, returning a fully-populated
* {@link CommandLineArgs} object.
* @param args command line arguments, typically from a {@code main()} method
*/
public CommandLineArgs parse(String... args) {
CommandLineArgs commandLineArgs = new CommandLineArgs();
for (String arg : args) {
if (arg.startsWith("--")) {
String optionText = arg.substring(2, arg.length());
String optionName;
String optionValue = null;
if (optionText.contains("=")) {
optionName = optionText.substring(0, optionText.indexOf('='));
optionValue = optionText.substring(optionText.indexOf('=')+1, optionText.length());
}
else {
optionName = optionText;
}
if (optionName.isEmpty() || (optionValue != null && optionValue.isEmpty())) {
throw new IllegalArgumentException("Invalid argument syntax: " + arg);
}
commandLineArgs.addOptionArg(optionName, optionValue);
}
else {
commandLineArgs.addNonOptionArg(arg);
}
}
return commandLineArgs;
}
}

操作系统变量

SpringBoot也会采集系统变量。Linux中通过export命令设定系统变量。

配置文件路径变更

SpringBoot默认会自动检查classpath:/,classpath:/config/,file:/,file:/config/,这四个路径下的application.yml,application.yaml,application.properties,application.xml。

如果想更改配置文件路径或者文件名则需要增加系统环境变量。例如 java -jar xxx.jar --spring.config.location=classpath:/default.properties,classpath:/override.properties,告诉SpringBoot加载指定的default.propertiesoverride.properties也可以指定目录,但是必须以\结尾,例如java -jar xxx.jar --spring.config.location=classpath:/otherconfig/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* The "config location" property name.
*/
public static final String CONFIG_LOCATION_PROPERTY = "spring.config.location";

/**
* The "config additional location" property name.
*/
public static final String CONFIG_ADDITIONAL_LOCATION_PROPERTY = "spring.config.additional-location";


private void load(Profile profile, DocumentFilterFactory filterFactory, DocumentConsumer consumer) {
getSearchLocations().forEach((location) -> {
//看这里,一定要用/结尾
boolean isFolder = location.endsWith("/");
Set<String> names = isFolder ? getSearchNames() : NO_SEARCH_NAMES;
names.forEach((name) -> load(location, name, profile, filterFactory, consumer));
});
}
//获取配置文件location的关键代码
private Set<String> getSearchLocations() {
if (this.environment.containsProperty(CONFIG_LOCATION_PROPERTY)) {
return getSearchLocations(CONFIG_LOCATION_PROPERTY);
}
Set<String> locations = getSearchLocations(CONFIG_ADDITIONAL_LOCATION_PROPERTY);
locations.addAll(
asResolvedSet(ConfigFileApplicationListener.this.searchLocations, DEFAULT_SEARCH_LOCATIONS));
return locations;
}

也可以追加自定义的配置文件和路径,通过指定spring.config.additional-location参数,例如java -jar xxx.jar --spring.config.additional-location=classpath:/default.properties,会读取默认配置的基础上再追加配置文件。也可以追加目录java -jar xxx.jar --spring.config.additional-location=classpath:/otherconfig/

自定义参数

我们除了可以在Spring Boot的配置文件中设置各个Starter模块中预定义的配置属性,也可以在配置文件中定义一些我们需要的自定义属性。比如在application.properties中添加:

1
2
book.name=SpringCloudInAction
book.author=crowley

然后,在应用中我们可以通过@Value注解来加载这些自定义的参数,比如:

1
2
3
4
5
6
7
8
9
10
@Component
public class Book {

@Value("${book.name}")
private String name;
@Value("${book.author}")
private String author;

// 省略getter和setter
}

@Value注解加载属性值的时候可以支持两种表达式来进行配置:

  • 一种是我们上面介绍的PlaceHolder方式,格式为 ${…},大括号内为PlaceHolder
  • 另外还可以使用SpEL表达式(Spring Expression Language), 格式为 #{…},大括号内为SpEL表达式

参数引用

application配置文件中的各个参数之间,也可以通过使用placeHolder的方式进行引用。

1
2
3
book.name=SpringCloud
book.author=ZhaiYongchao
book.desc=${book.author} is writing《${book.name}》

在Spring应用程序的environment中读取属性的时候,每个属性的唯一名称符合如下规则:

  • 通过.分离各个元素
  • 最后一个.将前缀与属性名称分开
  • 必须是字母(a-z)和数字(0-9)
  • 必须是小写字母
  • 用连字符-来分隔单词
  • 唯一允许的其他字符是[],用于List的索引
  • 不能以数字开头

所以,如果我们要读取配置文件中spring.jpa.database-platform的配置,可以这样写:this.environment.containsProperty("spring.jpa.database-platform")

而下面的方式是无法获取到spring.jpa.database-platform配置内容的:this.environment.containsProperty("spring.jpa.databasePlatform")

注意:使用@Value获取配置内容的时候也需要这样的特点

使用随机数

在一些特殊情况下,有些参数我们希望它每次加载的时候不是一个固定的值,比如:密钥、服务端口等。在Spring Boot的属性配置文件中,我们可以通过使用${random}配置来产生随机的int值、long值或者string字符串,这样我们就可以容易的通过配置来属性的随机生成,而不是在程序中通过编码来实现这些逻辑。

${random}的配置方式主要有一下几种,读者可作为参考使用。

1
2
3
4
5
6
7
8
9
10
# 随机字符串
com.didispace.blog.value=${random.value}
# 随机int
com.didispace.blog.number=${random.int}
# 随机long
com.didispace.blog.bignumber=${random.long}
# 10以内的随机数
com.didispace.blog.test1=${random.int(10)}
# 10-20的随机数
com.didispace.blog.test2=${random.int[10,20]}

该配置方式可以用于设置应用端口等场景,避免在本地调试时出现端口冲突的麻烦

命令行参数

在命令行方式启动Spring Boot应用时,连续的两个减号–就是对application.properties中的属性值进行赋值的标识。所以,java -jar xxx.jar --server.port=8888命令,等价于我们在application.properties中添加属性server.port=8888

通过命令行来修改属性值是Spring Boot非常重要的一个特性,通过此特性,理论上已经使得我们应用的属性在启动前是可变的,所以其中端口号也好、数据库连接也好,都是可以在应用启动时发生改变,而不同于以往的Spring应用通过Maven的Profile在编译器进行不同环境的构建。其最大的区别就是,Spring Boot的这种方式,可以让应用程序的打包内容,贯穿开发、测试以及线上部署,而Maven不同Profile的方案每个环境所构建的包,其内容本质上是不同的。但是,如果每个参数都需要通过命令行来指定,这显然也不是一个好的方案,所以下面我们看看如果在Spring Boot中实现多环境的配置。

多环境配置

我们在开发任何应用的时候,通常同一套程序会被应用和安装到几个不同的环境,比如:开发、测试、生产等。其中每个环境的数据库地址、服务器端口等等配置都会不同,如果在为不同环境打包时都要频繁修改配置文件的话,那必将是个非常繁琐且容易发生错误的事。

对于多环境的配置,各种项目构建工具或是框架的基本思路是一致的,通过配置多份不同环境的配置文件,再通过打包命令指定需要打包的内容之后进行区分打包,Spring Boot也不例外,或者说更加简单。

在Spring Boot中多环境配置文件名需要满足application-{profile}.properties的格 式,其中{profile}对应你的环境标识,比如:

  • application-dev.properties:开发环境
  • application-test.properties:测试环境
  • application-prod.properties:生产环境

至于哪个具体的配置文件会被加载,需要在application.properties文件中通过spring.profiles.active属性来设置,其值对应配置文件中的{profile}值。如:spring.profiles.active=test就会加载application-test.properties配置文件内容。

下面,以不同环境配置不同的服务端口为例,进行样例实验。

针对各环境新建不同的配置文件application-dev.propertiesapplication-test.propertiesapplication-prod.properties在这三个文件均都设置不同的server.port属性,如:dev环境设置为1111,test环境设置为2222,prod环境设置为3333

application.properties中设置spring.profiles.active=dev,就是说默认以dev环境设置

测试不同配置的加载

执行java -jar xxx.jar,可以观察到服务端口被设置为1111,也就是默认的开发环境(dev)
执行java -jar xxx.jar –spring.profiles.active=test,可以观察到服务端口被设置为2222,也就是测试环境的配置(test)
执行java -jar xxx.jar –spring.profiles.active=prod,可以观察到服务端口被设置为3333,也就是生产环境的配置(prod)
按照上面的实验,可以如下总结多环境的配置思路:

application.properties中配置通用内容,并设置spring.profiles.active=dev,以开发环境为默认配置
application-{profile}.properties中配置各个环境不同的内容。也可以采用-Dspring.profiles.active=dev设置激活的Profile。通过命令行方式去激活不同环境的配置

加载顺序

在上面的例子中,我们将Spring Boot应用需要的配置内容都放在了项目工程中,虽然我们已经能够通过spring.profiles.active或是通过Maven来实现多环境的支持。但是,当我们的团队逐渐壮大,分工越来越细致之后,往往我们不需要让开发人员知道测试或是生成环境的细节,而是希望由每个环境各自的负责人(QA或是运维)来集中维护这些信息。那么如果还是以这样的方式存储配置内容,对于不同环境配置的修改就不得不去获取工程内容来修改这些配置内容,当应用非常多的时候就变得非常不方便。同时,配置内容都对开发人员可见,本身这也是一种安全隐患。对此,现在出现了很多将配置内容外部化的框架和工具,比如Spring Cloud Config,Apollo,Nacos等等。

Spring Boot为了能够更合理的重写各属性的值,使用了下面这种较为特别的属性加载顺序:

  • 1、命令行中传入的参数。
  • 2、SPRING_APPLICATION_JSON中的属性。SPRING_APPLICATION_JSON是以JSON格式配置在系统环境变量中的内容。
  • 3、java:comp/env中的JNDI属性。
  • 4、Java的系统属性,可以通过System.getProperties()获得的内容。
  • 5、操作系统的环境变量
  • 6、通过random.*配置的随机属性
  • 7、位于当前应用jar包之外,针对不同{profile}环境的配置文件内容,例如:application-{profile}.properties或是YAML定义的配置文件
  • 8、位于当前应用jar包之内,针对不同{profile}环境的配置文件内容,例如:application-{profile}.properties或是YAML定义的配置文件
  • 9、位于当前应用jar包之外的application.properties和YAML配置内容
  • 10、位于当前应用jar包之内的application.properties和YAML配置内容
  • 11、在@Configuration注解修改的类中,通过@PropertySource注解定义的属性
  • 12、应用默认属性,使用SpringApplication.setDefaultProperties定义的内容

优先级按上面的顺序有高到低,数字越小优先级越高

可以看到,其中第7项和第9项都是从应用jar包之外读取配置文件,所以,实现外部化配置的原理就是从此切入,为其指定外部配置文件的加载位置来取代jar包之内的配置内容。通过这样的实现,我们的工程在配置中就变的非常干净,我们只需要在本地放置开发需要的配置即可,而其他环境的配置就可以不用关心,由其对应环境的负责人去维护即可。

2.x新特性

在Spring Boot 2.0中推出了Relaxed Binding 2.0,对原有的属性绑定功能做了非常多的改进以帮助我们更容易的在Spring应用中加载和读取配置信息。下面本文就来说说Spring Boot 2.0中对配置的改进。

配置文件绑定

简单类型

在Spring Boot 2.0中对配置属性加载的时候会除了像1.x版本时候那样移除特殊字符外,还会将配置均以全小写的方式进行匹配和加载。所以,下面的4种配置方式都是等价的:

  • properties格式:

    1
    2
    3
    4
    spring.jpa.databaseplatform=mysql
    spring.jpa.database-platform=mysql
    spring.jpa.databasePlatform=mysql
    spring.JPA.database_platform=mysql
  • yaml格式

    1
    2
    3
    4
    5
    6
    spring:
    jpa:
    databaseplatform: mysql
    database-platform: mysql
    databasePlatform: mysql
    database_platform: mysql

Tips:推荐使用全小写配合-分隔符的方式来配置,比如:spring.jpa.database-platform=mysql

List类型

在properties文件中使用[]来定位列表类型,比如:

1
2
spring.my-example.url[0]=http://example.com
spring.my-example.url[1]=http://spring.io

也支持使用逗号分割的配置方式,上面与下面的配置是等价的:

1
spring.my-example.url=http://example.com,http://spring.io

而在yaml文件中使用可以使用如下配置:

1
2
3
4
5
spring:
my-example:
url:
- http://example.com
- http://spring.io

也支持逗号分割的方式:

1
2
3
spring:
my-example:
url: http://example.com, http://spring.io

注意:在Spring Boot 2.0中对于List类型的配置必须是连续的,不然会抛出UnboundConfigurationPropertiesException异常,所以如下配置是不允许的:

1
2
foo[0]=a
foo[2]=b

在Spring Boot 1.x中上述配置是可以的,foo[1]由于没有配置,它的值会是null

Map类型

Map类型在properties和yaml中的标准配置方式如下:

  • Properties格式:

    1
    2
    spring.my-example.foo=bar
    spring.my-example.hello=world
  • Yaml格式:

    1
    2
    3
    4
    spring:
    my-example:
    foo: bar
    hello: world

注意:如果Map类型的key包含非字母数字-的字符,需要用[]括起来,比如:

1
2
3
spring:
my-example:
'[foo.baz]': bar

全新绑定API

在Spring Boot 2.0中增加了新的绑定API来帮助我们更容易的获取配置信息。下面举个例子来帮助大家更容易的理解:

  • 例1:简单类型

    假设在propertes配置中有这样一个配置:com.didispace.foo=bar

    1
    2
    3
    4
    5
    @Data
    @ConfigurationProperties(prefix = "com.didispace")
    public class FooProperties {
    private String foo;
    }

    接下来,通过最新的Binder就可以这样来拿配置信息了:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @SpringBootApplication
    public class Application {
    public static void main(String[] args) {
    ApplicationContext context = SpringApplication.run(Application.class, args);
    Binder binder = Binder.get(context.getEnvironment());
    // 绑定简单配置
    FooProperties foo = binder.bind("com.didispace", Bindable.of(FooProperties.class)).get();
    System.out.println(foo.getFoo());
    }
    }
  • 例2:List类型

    如果配置内容是List类型呢?比如:

    1
    2
    3
    4
    5
    6
    7
    com.didispace.post[0]=Why Spring Boot
    com.didispace.post[1]=Why Spring Cloud

    com.didispace.posts[0].title=Why Spring Boot
    com.didispace.posts[0].content=It is perfect!
    com.didispace.posts[1].title=Why Spring Cloud
    com.didispace.posts[1].content=It is perfect too!

    要获取这些配置依然很简单,可以这样实现:

    1
    2
    3
    4
    5
    6
    7
    8
    ApplicationContext context = SpringApplication.run(Application.class, args);
    Binder binder = Binder.get(context.getEnvironment());
    // 绑定List配置
    List<String> post = binder.bind("com.didispace.post", Bindable.listOf(String.class)).get();
    System.out.println(post);

    List<PostInfo> posts = binder.bind("com.didispace.posts", Bindable.listOf(PostInfo.class)).get();
    System.out.println(posts);

SpringBoot Environment加载源码解析

通过最佳实践,我们对SpringBoot的Environment有了一个概要的了解,接下来我们要从源码角度,分析一下SpringBoot是如何实现这么人性化的配置功能的。

Spring应用启动的时候,会依赖系统的环境和应用的配置,所以Spring定义了Environment包装系统的环境和应用的配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
private ConfigurableEnvironment getOrCreateEnvironment() {
if (this.environment != null) {
return this.environment;
}
switch (this.webApplicationType) {
case SERVLET:
return new StandardServletEnvironment();
case REACTIVE:
return new StandardReactiveWebEnvironment();
default:
return new StandardEnvironment();
}
}

从上面的代码可以知道,SpringBoot会根据webApplicationType的不同,选择创建StandardServletEnvironmentStandardReactiveWebEnvironmentStandardEnvironment这三个对象中的某一个,它们的共同超类是AbstractEnvironment,下图中还整理了Environment依赖的其他核心类。AbstractEnvironment中最重要的成员变量是MutablePropertyResolver和PropertySourcePropertyResover,prepareEnvironment方法执行的过程,也是设置这两个成员变量的过程

AbstractEnvironment类图

接下来,我们通过了解SpringBoot中Environment的启动流程来更加清晰的认识Environment。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private ConfigurableEnvironment prepareEnvironment(SpringApplicationRunListeners listeners,
ApplicationArguments applicationArguments) {
//第一步,创建Environment,根据webApplicationType来判定具体创建哪个Environment实例
ConfigurableEnvironment environment = getOrCreateEnvironment();
//第二步,配置Environment,主要分为三步:1设置转换器(Conversion),2设置propertySources,3设置Profile
configureEnvironment(environment, applicationArguments.getSourceArgs());
//第三步,装载configurationProperties,最终Environment持有的PropertySource如Enviroment加载debug图所示
ConfigurationPropertySources.attach(environment);
//第四步,很关键。发送ApplicationEnvironmentPreparedEvent事件,最终该事件会被ConfigFileApplicationListener消费。具体消费之后如何绑定配置文件,下一小节会重点讲述。
listeners.environmentPrepared(environment);
//第五步,绑定环境变量
bindToSpringApplication(environment);
//如果是自定义Environment,则重新创建Environment,应用场景很少,不重点研究。
if (!this.isCustomEnvironment) {
environment = new EnvironmentConverter(getClassLoader()).convertEnvironmentIfNecessary(environment,
deduceEnvironmentClass());
}
ConfigurationPropertySources.attach(environment);
return environment;
}

相信看过prepareEnvironment方法之后,还不能在脑海中形成一个具体的Environment的数据结构,Environment结构示意图可以很形象的描述各个类之间的关系。

Environment结构示意图

加载之后的Environment对象内容如下。

Environment Debug

ConfigFileApplicationListener

ConfigFileApplicationListener实现了EnvironmentPostProcessor函数式接口,负责监听ApplicationEnvironmentPreparedEvent事件来加载application.properties或者application.yml。

1
2
3
4
5
6
7
8
9
10
11
public class ConfigFileApplicationListener {
@Override
public void onApplicationEvent(ApplicationEvent event) {
if (event instanceof ApplicationEnvironmentPreparedEvent) {
onApplicationEnvironmentPreparedEvent((ApplicationEnvironmentPreparedEvent) event);
}
if (event instanceof ApplicationPreparedEvent) {
onApplicationPreparedEvent(event);
}
}
}

Loader

LoaderConfigFileApplicationListener的内部类,它通过监听ApplicationEnvironmentPreparedEvent事件,加载候选的property sources和配置激活的profile,相关的类图如下所示。

SpringBoot Loader类图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
private class Loader {
private final ConfigurableEnvironment environment;

private final PropertySourcesPlaceholdersResolver placeholdersResolver;

private final ResourceLoader resourceLoader;

private final List<PropertySourceLoader> propertySourceLoaders;
//所有的profile
private Deque<Profile> profiles;
//处理过的Profile
private List<Profile> processedProfiles;
//是否激活了profile,如果激活就不再设置active profile
private boolean activatedProfiles;
//已经加载的属性,MutablePropertySources底层是CopyOnWriteArrayList
private Map<Profile, MutablePropertySources> loaded;
//缓存的属性
private Map<DocumentsCacheKey, List<Document>> loadDocumentsCache = new HashMap<>();

Loader(ConfigurableEnvironment environment, ResourceLoader resourceLoader) {
this.environment = environment;
//把environment中的MutablePropertySources交给PropertySourcesPlaceholdersResolver,由PropertyPlaceholderHelper完成变量的替换
this.placeholdersResolver = new PropertySourcesPlaceholdersResolver(this.environment);
this.resourceLoader = (resourceLoader != null) ? resourceLoader : new DefaultResourceLoader();
//获取org.springframework.boot.env.PropertiesPropertySourceLoaderh和org.springframework.boot.env.YamlPropertySourceLoader
this.propertySourceLoaders = SpringFactoriesLoader.loadFactories(PropertySourceLoader.class,
getClass().getClassLoader());
}

public void load() {
this.profiles = new LinkedList<>();
this.processedProfiles = new LinkedList<>();
this.activatedProfiles = false;
this.loaded = new LinkedHashMap<>();
//设置profile
initializeProfiles();
//轮询profile
while (!this.profiles.isEmpty()) {
Profile profile = this.profiles.poll();
if (profile != null && !profile.isDefaultProfile()) {
addProfileToEnvironment(profile.getName());
}
//加载资源,参数分别是profile,DocumentFilter函数,DocumentConsumer函数
load(profile, this::getPositiveProfileFilter, addToLoaded(MutablePropertySources::addLast, false));
//处理完一个profile就把该profile添加进processed容器中
this.processedProfiles.add(profile);
}
resetEnvironmentProfiles(this.processedProfiles);
load(null, this::getNegativeProfileFilter, addToLoaded(MutablePropertySources::addFirst, true));
addLoadedPropertySources();
}
}

参考文档

1、基于SpringBoot的Environment源码理解实现分散配置

2、Spring Boot 2.x基础教程:配置文件详解

评论