分离变化和不变是软件设计的一个原则,将不变的部分形成模版,将变化的部分抽出为配置文件;不同的环境使用不同的配置文件,方便维护且不需要重新编译代码;Spring框架引入占位符为其提供了一个解决方案。
本文作为Spring系列文章的第六篇,内容包含占位符的使用和背后原理;其中,原理部分会伴随着Spring源码进行。
本文讨论的占位符指${}
, 常见于SpringBoot的application.properties
(或application.yml
)配置文件、或自定义*.properties
配置文件中,也常见于@Value等注解、Feign相关接口上;在Spring项目中,常见于Spring的配置文件,可以用在bean的定义上。占位符中的变量在程序启动过程中进行解析,developer需要引入配置文件使得解析过程正常执行。
本章节以Spring系列-4 国际化中的国际化Bean的配置过程为例进行介绍。
在resources资源路径下准备一个配置文件,文件内容如下:
# default.properties
basename=i18n/messages
defaultEncoding=UTF-8
在Bean定义的配置文件中,通过
<context:property-placeholder location="classpath:default.properties"/>
等价于以下方式:
<bean class="org.springframework.context.support.PropertySourcesPlaceholderConfigurer">
<property name="location" value="classpath:default.properties"/>
bean>
location属性:
location属性为PropertySourcesPlaceholderConfigurer对象指定了配置文件路径;当需要指定多个配置文件时,应使用逗号分隔开。另外,也可以使用通配符形式,如下所示:
<context:property-placeholder location="classpath*:My*.properties"/>
默认情况下,如果指定了文件而没找到时——抛出异常;使用通配符时,即使匹配结果为空也不会抛出异常。
注意:当在一个
标签中指定了多个配置文件时,处理占位符时会按照配置顺序依次向配置文件中进行匹配,第一次完成匹配时即返回;否则一直向下匹配,直到抛出异常。
因此,配置顺序决定了配置文件的优先级,靠前的优先级较高。
ignore-unresolvable和ignore-resource-not-found属性:
除location外,有两个属性需要关注:ignore-unresolvable和ignore-resource-not-found.
ignore-unresolvable表示解析失败时是否忽略(不抛出异常-返回原字符串);默认值false表示解析失败时抛出异常。ignore-resource-not-found表示获取不到配置文件时是否忽略(不抛出异常);默认值false表示获取文件失败时抛出异常。二者经常组合出现,因为存在逻辑上的优先级顺序:当ignore-unresolvable配置为true时,无论文件是否存在-解析是否成功,都不会抛出异常,即ignore-resource-not-found处于逻辑失效状态;当ignore-resource-not-found配置为false时,ignore-resource-not-found才会生效。
配置多个PropertySourcesPlaceholderConfigurer对象
当配置多个
需要注意:这与一个
标签中配置多个配置文件不同;每个 标签对应一个独立的Bean对象。
以下通过简单案例介绍,已知道原因的读者,可跳过过该案例:
Bean定义的配置文件如下:
<context:property-placeholder location="classpath:default.properties"/>
<context:property-placeholder location="classpath:local.properties"/>
<bean id="testPhc" class="com.seong.context.TestPlaceHolderConfigurer">
<property name="name" value="${name}"/>
<property name="age" value="${age}"/>
</bean>
Spring会按照配置顺序,先后向IOC容器注入 default.properties 对应的Bean对象(使用default解析器表示)和 local.properties 对应的Bean对象(使用local解析器表示);在解析 testPhc 的BeanDefinition时,会按照IOC顺序依次调用两个PropertySourcesPlaceholderConfigurer对象去处理 ${name}
和 ${age}
.
这两个Bean对象是完全独立的且解析过程在时间上先后进行,互补干扰;整个解析过程如下:
default解析器解析时,如果解析正常,即default.properties文件中配置了name
和age
变量,则将testPhc的BeanDefinition对象中的占位符替换为配置的value. 然后使用local解析器再次解析,发现没有占位符号,直接退出解析过程,表现为整个解析过程正常。
default解析器解析失败时,即default.properties文件中未配置name
或age
变量,会直接抛出异常,不再进入其他解析器。
因此,整个解析过程中只有default解析器生效,其他Bean对象都被逻辑失效了(等价于仅配置了default解析器)。
可以将最后一个PropertySourcesPlaceholderConfigurer对象前的所有PropertySourcesPlaceholderConfigurer对象的属性设置为true,来解决上述问题,如下所示:
<context:property-placeholder location="default.properties" ignore-unresolvable="true"/>
<context:property-placeholder location="location.properties"/>
当然,最后一个PropertySourcesPlaceholderConfigurer对象的ignore-unresolvable属性也可以设置为true;但作为最后一个解析器,需要保持当解析失败时抛出异常的功能。
提醒:尽量让异常尽早抛出,能在编译期的不要延迟到启动时,能在启动时抛出的不要延迟到运行过程中。
配置国际化bean对象:
<bean id="messageSource" class="org.springframework.context.support.ResourceBundleMessageSource">
<property name="basename" value="i18n/messages"/>
<property name="defaultEncoding" value="UTF-8"/>
bean>
使用配置文件实现等效配置:
<bean id="messageSource" class="org.springframework.context.support.ResourceBundleMessageSource">
<property name="basename" value="${basename}"/>
<property name="defaultEncoding" value="${defaultEncoding}"/>
bean>
实际上,在Bean定义的配置文件中, 所有值对象(包括bean的id、class等属性)都可以使用占位符形式,甚至也包括引入配置文件的标签:
<context:property-placeholder location="default.properties"/>
<context:property-placeholder location="${location}"/>
另外,需要注意如果占位符解析失败,会抛出异常;比如上面的location必须要求在default.properties进行了配置。
SpringBoot项目中的配置数据可以来自application.properties(或application.yml)或手动引入的自定义配置文件。
通过@PropertySource
注解可手动引入配置文件:
@Configuration
@PropertySource(value = {"classpath:default.properties", "classpath:location.properties"})
public class PropertiesConfiguration {
}
或者在application.yml文件中进行配置:
# application.yml
placeholder:
serverName: PlaceHolderServer
url: http://127.0.0.1:8080/phs
SpringBoot中占位符常见 @Value注解和 @ConfigurationProperties等注入场景, 其中 @ConfigurationProperties注解是SpringBoot引入的.
使用@Value注解
// PlaceHolderBean.java文件
@Data
@Component
public class PlaceHolderBean {
@Value("${placeholder.serverName}")
private String serverName;
@Value("${placeholder.url}")
private String url;
}
// 测试用例
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class PlaceHolderTest {
@Autowired
private PlaceHolderBean placeHolderBean;
@Test
public void testValueAnnotation() {
LOGGER.info("placeHolderBean is {}.", placeHolderBean);
}
}
通过 @Value注解,可以将配置文件中的placeholder.serverName
和placeholder.url
变量分别赋值给PlaceHolderBean对象的serverName和url属性,测试用例执行结果如下:
使用@ConfigurationProperties注解
// PlaceHolderBean.java文件
@Data
@Component
@ConfigurationProperties(prefix = "placeholder")
public class PlaceHolderProperties {
private String serverName;
private String url;
}
// 测试用例
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class PlaceHolderTest {
@Autowired
private PlaceHolderProperties placeHolderProperties;
@Test
public void testPlaceHolder() {
LOGGER.info("placeHolderProperties is {}.", placeHolderProperties);
}
}
Spring通过@ConfigurationProperties
注解将配置的placeholder.serverName
和placeholder.url
属性值分别赋值给PlaceHolderProperties对象的serverName属性和url属性;得到如下结果:
对比@Value注解和@ConfigurationProperties注解
(1) 首先需要注意:@Value来自Spring, 而@ConfigurationProperties来自SpringBoot.
(2) 占位符要求变量名以及大小写完全匹配,如@Value及前面涉及的Spring项目中使用的占位符;但@ConfigurationProperties忽略大小写且会忽略中划线,如下所示:
# application.yml
placeholder:
serverName: PlaceHolderServer
url: http://127.0.0.1:8080/phs
#等价于:
placeholder:
SerVer-NA-m-e: PlaceHolderServer
URl: http://127.0.0.1:8080/phs
(3) 此外,@Value不需要强行关联变量名与属性名(通过配置注解的value属性关联),而@ConfigurationProperties需要进行变量名与属性名称的关联;
(4) @Value除了可以使用占位符之外,还可以直接对属性注入字符串;
最后,建议大家面向Java编程,而不面向Spring编程:一些特殊场景除外,提倡使用@ConfigurationProperties替代@Value。同理,提倡使用构造函数注入而非@Autowired注解注入方式。
前文提到占位符变量的数据来源有配置文件,除此之外还包括系统属性、应用属性、环境变量.
准备测试用例:
@Data
@Component
public class EnvProperties {
@Value("${HOME}")
private String home;
@Value("${USER}")
private String user;
}
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class PlaceHolderTest {
@Autowired
private EnvProperties envProperties;
@Test
public void testEnvProperties() {
LOGGER.info("envProperties is {}.", envProperties);
}
}
得到如下结果:
结果显示EnvProperties的属性值与机器对应的环境变量值保持一致。
Spring给占位符提供了默认配置,待解析字符串的第一个冒号为分隔符,分隔符后的值为默认值。
<bean id="placeHolderServer" class="org.seong.context.DefaultPlaceHolderServer">
<property name="name" value="${servername:placeholder:001}"/>
<property name="url" value="${url}"/>
bean>
此时,若未配置servername
变量,则placeHolderServer这个bean的name属性被设置为默认值placeholder:001
;若未配置url变量,则抛出异常。
Spring解析表达式时会递归调用,先解析最内层的变量——得到一个中间值(配置文件中变量配置的值),再解析外围;这使得${}可以嵌套使用。这个过程中,解析器还会对得到的中间值进行递归解析操作,这使得配置文件中的变量也可以引用其他变量。
${}嵌套使用
<bean id="placeHolderServer" class="org.seong.context.DefaultPlaceHolderServer">
<property name="name" value="${servername:placeholder:001}"/>
<property name="url" value="${PHS_HOST:127.0.0.1:${PHS_PORT:${SERVER_PORT:8080}}}"/>
bean>
程序解析时由外向内,但developer在阅读和编写时应按照由外到内的顺序进行。
如配置文件:placeHolderServer对象的url属性对应占位符字符串为${PHS_HOST:127.0.0.1:${PHS_PORT:${SERVER_PORT:8080}}}
;不妨假设变量的结果为驼峰形式,即:
# 配置变量
PHS_HOST=phsHost
PHS_PORT=phsPort
SERVER_PORT=serverPort
根据是否配置了PHS_HOST、PHS_PORT、SERVER_PORT变量可以得到4种不同的结果:
配置文件嵌套使用
为了维护方便,配置文件中的变量也可以抽取出公共部分,如下所示:
#application.yml
SERVER_IP: 127.0.0.1
SERVER_PORT: 8080
SERVER_NAME: phc
URL: http://${SERVER_IP}:${SERVER_PORT}/${SERVER_NAME}
API_URL: ${URL}/api
RPC_URL: ${URL}/rpc
在介绍原理前,需要先熟悉PropertySource相关的几个类:Properties、PropertySource(PropertiesPropertySource、ResourcePropertySource、MutablePropertySources)、PropertyResolver(PropertySourcesPropertyResolver).
public class Properties extends Hashtable<Object,Object> {
}
Properties继承了Hashtable,因此可以看作一个特殊的Map类型(功能加强的Map), 因此Properties也基于键值对的存储结构提供了很多接口,这里只介绍与本文有关的部分。
// 向内存中添加键值对
public synchronized Object setProperty(String key, String value) {//...}
// 根据key从内存中读取数据
public String getProperty(String key) {//...}
// load方法会从InputStream流对象中读取数据,写入到Properties中:
public synchronized void load(InputStream inStream) throws IOException {//...}
For example:
在resources资源路径下准备文件:
// default.properties文件
name=root
passwd=root
测试代码如下:
@Slf4j
public class PropertiesTest {
@Test
public void testProperties() throws IOException {
Properties properties = new Properties();
properties.setProperty("key-1", "value-1");
properties.load(this.getClass().getClassLoader().getResourceAsStream("default.properties"));
properties.setProperty("key-2", "value-2");
LOGGER.info("properties is {}.", properties);
}
}
得到如下结果:
因此,Properties可以被用来从配置文件中加载资源入内存。
抽象类PropertySource被定义为资源对象,内部存在两个属性:name和泛型的source对象。通过equals
和hashCode
方法可知name属性被作为判断PropertySource对象是否相等的依据。
public abstract class PropertySource<T> {
// @Getter
protected final String name;
// @Getter
protected final T source;
public boolean containsProperty(String name) {
return this.getProperty(name) != null;
}
@Nullable
public abstract Object getProperty(String propertyName);
public boolean equals(@Nullable Object other) {
return this == other || other instanceof PropertySource && ObjectUtils.nullSafeEquals(this.getName(), ((PropertySource)other).getName());
}
public int hashCode() {
return ObjectUtils.nullSafeHashCode(this.getName());
}
}
定义类一个抽象的getProperty(String propertyName)
方法给自类实现,接口的功能是根据propertyName从source属性中取值;需要注意propertyName与PropertySource中的name属性不是同一概念(name仅作为PropertySource对象对外的身份)。PropertySource有两个比较重要的实现类:PropertiesPropertySource和ResourcePropertySource。
PropertySource的实现类PropertiesPropertySource:
public class PropertiesPropertySource extends MapPropertySource {
public PropertiesPropertySource(String name, Properties source) {
super(name, source);
}
protected PropertiesPropertySource(String name, Map<String, Object> source) {
super(name, source);
}
//...
}
PropertiesPropertySource的source属性为Properties类型;注意Properties是Hashtable的子类,自然也是Map类型的子类。
其父类MapPropertySource实现了PropertySource定义的Object getProperty(String name)
接口:
//MapPropertySource类
public Object getProperty(String key) {
return ((Map)this.source).get(key);
}
根据key从Map类型的source对象中取值。
PropertySource的实现类ResourcePropertySource:
ResourcePropertySource作为PropertiesPropertySource的子类,在PropertiesPropertySource基础上新增了读取资源文件的能力。
public class ResourcePropertySource extends PropertiesPropertySource {
public ResourcePropertySource(Resource resource) throws IOException {
super(getNameForResource(resource), PropertiesLoaderUtils.loadProperties(new EncodedResource(resource)));
this.resourceName = null;
}
//...
}
其中PropertiesLoaderUtils.loadProperties(new EncodedResource(resource)))
会根据传入的Resource对象指定的文件资源去加载、读取并生成Properties对象。
PropertySource的容器类MutablePropertySources:
MutablePropertySources作为PropertySource的容器,在内部维持了一个PropertySource类型的列表,基于此对外提供了存储、管理、查询PropertySource对象能力的API:
public class MutablePropertySources implements PropertySources {
private final List<PropertySource<?>> propertySourceList;
//...
}
PropertyResolver接口介绍
public interface PropertyResolver {
boolean containsProperty(String key);
String getProperty(String key);
String getProperty(String key, String defaultValue);
<T> T getProperty(String key, Class<T> targetType);
<T> T getProperty(String key, Class<T> targetType, T defaultValue);
String getRequiredProperty(String key) throws IllegalStateException;
<T> T getRequiredProperty(String key, Class<T> targetType) throws IllegalStateException;
String resolvePlaceholders(String text);
String resolveRequiredPlaceholders(String text) throws IllegalArgumentException;
}
PropertyResolver接口定义了根据key获取value以及处理占位符字符串的能力。
PropertyResolver的实现类PropertySourcesPropertyResolver:
public class PropertySourcesPropertyResolver extends AbstractPropertyResolver {
private final PropertySources propertySources;
public PropertySourcesPropertyResolver(PropertySources propertySources) {
this.propertySources = propertySources;
}
//...
}
实例化PropertySourcesPropertyResolver对象时,需要传入一个PropertySources作为入参。
String getProperty(String key)
及其重载方法取值的实现原理:遍历propertySources对象内部的PropertySource对象,依次从中取值,直到取值成功或者遍历至最后一个PropertySource对象。
String resolvePlaceholders(String text)
的入参为待解析的字符串(包含占位符),返回的字符串为解析后的结果。解析过程中需要通过String getProperty(String key)
及其重载方法从PropertySource对象列表中取值。
解析的核心方法在于:
protected String parseStringValue(String value, PropertyPlaceholderHelper.PlaceholderResolver placeholderResolver, @Nullable Set<String> visitedPlaceholders) {
int startIndex = value.indexOf(this.placeholderPrefix);
if (startIndex == -1) {
return value;
}
StringBuilder result = new StringBuilder(value);
while (startIndex != -1) {
int endIndex = this.findPlaceholderEndIndex(result, startIndex);
if (endIndex != -1) {
String placeholder = result.substring(startIndex + this.placeholderPrefix.length(), endIndex);
String originalPlaceholder = placeholder;
if (visitedPlaceholders == null) {
visitedPlaceholders = new HashSet(4);
}
if (!((Set) visitedPlaceholders).add(placeholder)) {
throw new IllegalArgumentException("Circular placeholder reference '" + placeholder + "' in property definitions");
}
// ⚠️递归调用:先处理最内部的占位符
placeholder = this.parseStringValue(placeholder, placeholderResolver, (Set) visitedPlaceholders);
// 调用placeholderResolver对象处理占位符
// [placeholderResolver对象内部封装了配置文件的属性信息和解析过程]
String propVal = placeholderResolver.resolvePlaceholder(placeholder);
if (propVal == null && this.valueSeparator != null) {
int separatorIndex = placeholder.indexOf(this.valueSeparator);
if (separatorIndex != -1) {
// 取第一个冒号后的字符串为默人值
String actualPlaceholder = placeholder.substring(0, separatorIndex);
String defaultValue = placeholder.substring(separatorIndex + this.valueSeparator.length());
propVal = placeholderResolver.resolvePlaceholder(actualPlaceholder);
if (propVal == null) {
// 解析失败时,使用默认值填充
propVal = defaultValue;
}
}
}
if (propVal != null) {
// ⚠️递归调用:解析得到的变量值(因为配置文件中变量的值也可能包含占位符)
propVal = this.parseStringValue(propVal, placeholderResolver, (Set) visitedPlaceholders);
result.replace(startIndex, endIndex + this.placeholderSuffix.length(), propVal);
startIndex = result.indexOf(this.placeholderPrefix, startIndex + propVal.length());
} else {
if (!this.ignoreUnresolvablePlaceholders) {
throw new IllegalArgumentException("Could not resolve placeholder '" + placeholder + "' in value \"" + value + "\"");
}
startIndex = result.indexOf(this.placeholderPrefix, endIndex + this.placeholderSuffix.length());
}
((Set) visitedPlaceholders).remove(originalPlaceholder);
} else {
startIndex = -1;
}
}
return result.toString();
}
代码的整体逻辑比较简单,通过递归操作先解析最内侧的占位符,得到一个中间值propVal(来源于配置文件或者环境变量等);propVal可能也包含占位符,因此也需要对其进行解析。递归返回后,会按照由外到内的顺序依次进行。
Spring框架处理占位符问题时选择的目标对象是BeanDefinition,因此无论以何种方式引入的Bean,处理过程均可统一。具体的实现方案是引入一个BeanFactoryPostProcessor类型的PropertySourcesPlaceholderConfigurer类,并将解析逻辑封装在其内部;在Spring容器启动过程中,通过invokeBeanFactoryPostProcessors(beanFactory);
进行触发。
PropertySourcesPlaceholderConfigurer中的postProcessBeanFactory方法:
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
if (this.propertySources == null) {
this.propertySources = new MutablePropertySources();
if (this.environment != null) {
this.propertySources.addLast(
new PropertySource<Environment>("environmentProperties", this.environment) {
@Override
@Nullable
public String getProperty(String key) {
return this.source.getProperty(key);
}
}
);
}
PropertySource<?> localPropertySource =
new PropertiesPropertySource("localProperties", mergeProperties());
if (this.localOverride) {
this.propertySources.addFirst(localPropertySource);
} else {
this.propertySources.addLast(localPropertySource);
}
}
processProperties(beanFactory, new PropertySourcesPropertyResolver(this.propertySources));
this.appliedPropertySources = this.propertySources;
}
主线逻辑较为简单:
(1) 构建一个MutablePropertySources资源对象容器;
(2) 向其中加入名称为environmentProperties的PropertySource对象,包含了系统属性、应用属性、环境变量等信息;其中application.yml中配置的属性也包含在其中;
(3) 向其中加入名称为localProperties的PropertySource对象,包含了引入的配置文件中的信息;
(4) 将MutablePropertySources作为构造参数创建一个PropertySourcesPropertyResolver解析器对象;
(5) 调用processProperties(beanFactory, new PropertySourcesPropertyResolver(this.propertySources));
处理占位符。
processProperties方法共两个入参:beanFactory和PropertySourcesPropertyResolver解析器对象;beanFactory容器能够获取所有的BeanDefinition信息,PropertySourcesPropertyResolver解析器对象内部包含了所有的配置信息以及基于此封装的解析能力。
跟进processProperties方法:
protected void processProperties(ConfigurableListableBeanFactory beanFactoryToProcess,
final ConfigurablePropertyResolver propertyResolver) throws BeansException {
// 根据配置信息设置解析器,使得valueResolver与配置保持一致
doProcessProperties(beanFactoryToProcess, valueResolver);
}
跟进doProcessProperties方法:
// 简化后,仅突出主线逻辑
protected void doProcessProperties(ConfigurableListableBeanFactory beanFactoryToProcess, StringValueResolver valueResolver) {
BeanDefinitionVisitor visitor = new BeanDefinitionVisitor(valueResolver);
String[] beanNames = beanFactoryToProcess.getBeanDefinitionNames();
for (String curName : beanNames) {
BeanDefinition bd = beanFactoryToProcess.getBeanDefinition(curName);
visitor.visitBeanDefinition(bd);
}
}
将解析器包装为BeanDefinitionVisitor对象,遍历IOC容器中的所有BeanDefinition,调用visitor.visitBeanDefinition(bd);
解析占位符。
跟进visitor.visitBeanDefinition(bd)方法:
public void visitBeanDefinition(BeanDefinition beanDefinition) {
visitParentName(beanDefinition);
visitBeanClassName(beanDefinition);
visitFactoryBeanName(beanDefinition);
visitFactoryMethodName(beanDefinition);
visitScope(beanDefinition);
if (beanDefinition.hasPropertyValues()) {
visitPropertyValues(beanDefinition.getPropertyValues());
}
if (beanDefinition.hasConstructorArgumentValues()) {
ConstructorArgumentValues cas = beanDefinition.getConstructorArgumentValues();
visitIndexedArgumentValues(cas.getIndexedArgumentValues());
visitGenericArgumentValues(cas.getGenericArgumentValues());
}
}
从名称可以看出来,依次解析BeanDefinition的parentName、class、FactoryBeanName、FactoryMethodName、scope等Bean元信息;之后解析属性值以及构造函数值信息。这些visitXxxx(beanDefinition);
方法内部的实现通过解析器实现占位符的解析。
最后想说一下,解析占位符的过程中涉及很多类,这些类的内部设计和相互引用编织得很巧妙,在写框架代码时具备很高的参考意义,建议详细体会。