Spring系列-6 占位符使用和原理

背景

分离变化和不变是软件设计的一个原则,将不变的部分形成模版,将变化的部分抽出为配置文件;不同的环境使用不同的配置文件,方便维护且不需要重新编译代码;Spring框架引入占位符为其提供了一个解决方案。
本文作为Spring系列文章的第六篇,内容包含占位符的使用和背后原理;其中,原理部分会伴随着Spring源码进行。

1.占位符

本文讨论的占位符指${}, 常见于SpringBoot的application.properties(或application.yml)配置文件、或自定义*.properties配置文件中,也常见于@Value等注解、Feign相关接口上;在Spring项目中,常见于Spring的配置文件,可以用在bean的定义上。占位符中的变量在程序启动过程中进行解析,developer需要引入配置文件使得解析过程正常执行。

2.使用方式

2.1 Spring项目:

本章节以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对象
当配置多个 标签,即配置多个PropertySourcesPlaceholderConfigurer实例时,需要注意配置好ignore-unresolvable和ignore-resource-not-found属性,否则会出现意料之外的结果。

需要注意:这与一个标签中配置多个配置文件不同;每个标签对应一个独立的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文件中配置了nameage变量,则将testPhc的BeanDefinition对象中的占位符替换为配置的value. 然后使用local解析器再次解析,发现没有占位符号,直接退出解析过程,表现为整个解析过程正常。
default解析器解析失败时,即default.properties文件中未配置nameage变量,会直接抛出异常,不再进入其他解析器。
因此,整个解析过程中只有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进行了配置。

2.2 SpringBoot项目

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.serverNameplaceholder.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.serverNameplaceholder.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注解注入方式。

2.3 注意点

2.3.1 数据来源

前文提到占位符变量的数据来源有配置文件,除此之外还包括系统属性、应用属性、环境变量.

查看机器环境变量:
Spring系列-6 占位符使用和原理_第1张图片

准备测试用例:

@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的属性值与机器对应的环境变量值保持一致。

2.3.2 默认值

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变量,则抛出异常。

2.3.3 嵌套使用

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

3.原理

3.1 前置知识

在介绍原理前,需要先熟悉PropertySource相关的几个类:PropertiesPropertySource(PropertiesPropertySource、ResourcePropertySource、MutablePropertySources)、PropertyResolver(PropertySourcesPropertyResolver).

3.1.1 Properties类型介绍

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可以被用来从配置文件中加载资源入内存。

3.1.2 PropertySource类型介绍

抽象类PropertySource被定义为资源对象,内部存在两个属性:name和泛型的source对象。通过equalshashCode方法可知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;
    //...
}

3.1.3 PropertyResolver类型介绍

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可能也包含占位符,因此也需要对其进行解析。递归返回后,会按照由外到内的顺序依次进行。

3.2 原理

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);方法内部的实现通过解析器实现占位符的解析。

最后想说一下,解析占位符的过程中涉及很多类,这些类的内部设计和相互引用编织得很巧妙,在写框架代码时具备很高的参考意义,建议详细体会。

你可能感兴趣的:(Spring系列,spring,java,后端)