Spring常见问题解决 - @Value注解注入的值出错了?

  • 一. @Value注解注入的值与预想不一致
  • 1.1 案例
  • 1.2 原理分析
  • 1.2.1 寻找@Value
  • 1.2.2 解析Value
  • 1.3 解决方案
  • 1.4 # 和 $ 的区别
  • 二. 总结
  • 2.1 总结


一. @Value注解注入的值与预想不一致

首先来说下@Value这个注解,它的功能也就是给某个对象赋值。不过,我相信大部分人用它都是用来给String类型赋值的。例如:

@Value("Hello")
private String name;

因为这个写法太常见了,以至于很多人可能不知道(包括我),@Value还能用来做装配对象成员,即和@Autowired一样的作用。例如:

自定义一个Bean

@Configuration
public class MyBean {
    @Bean
    public User getUser() {
        User user = new User();
        user.setName("LJJ");
        return user;
    }
}

然后通过@Value装配:

@Value("#{getUser}")
private User user;
@Value("Hello")
private String name;
@Value("#{getUser.name}")
private String username;

@GetMapping("/hello")
@ResponseBody
public String hello() {
    return user.getName();
}

结果如下:

java更改对象属性注解的值 修改注解的value值_spring

那么接下来就来看下本文针对的案例:@Value注解注入的值并非我们预期的值。

1.1 案例

1.首先我们在application.properties文件中添加两个自定义的属性:

username=admin
password=pass

2.Controller代码如下:

@Value("${username}")
private String username;
@Value("${password}")
private String password;

@GetMapping("/hello")
@ResponseBody
public String hello() {
    return username + ": " + password;
}

运行结果如下:

java更改对象属性注解的值 修改注解的value值_spring_02


输出的username并不是配置文件中的admin而是我的计算机名。

1.2 原理分析

关于@Value注解相关的装配,可以从DefaultListableBeanFactory.doResolveDependency()类中开始看起。这个方法主要是类型匹配的一个通用方法。我在Spring源码系列:Bean的加载中也贴出了下面这行代码,只不过我写了个注释就一笔带过。

// 寻找注解Value
Object value = getAutowireCandidateResolver().getSuggestedValue(descriptor);
if (value != null) {
	// 如果是String类型的,就赋值即可
	if (value instanceof String) {
		// 解析值
		String strVal = resolveEmbeddedValue((String) value);
		BeanDefinition bd = (beanName != null && containsBean(beanName) ?
				getMergedBeanDefinition(beanName) : null);
		value = evaluateBeanDefinitionString(strVal, bd);
	}
	// 否则可能是Object对象,需要做对应的类型转换
	TypeConverter converter = (typeConverter != null ? typeConverter : getTypeConverter());
	try {
		// 进行装配,即将value转化为所需要的对象然后赋值。因为解析的结果可能是个对象也可能是个字符串
		return converter.convertIfNecessary(value, type, descriptor.getTypeDescriptor());
	}
	catch (UnsupportedOperationException ex) {
		// ...
	}
}

1.2.1 寻找@Value

这里我们先看下debug下的结果:

java更改对象属性注解的值 修改注解的value值_字符串_03

紧接着我们重点看下getSuggestedValue()的实现:

public Object getSuggestedValue(DependencyDescriptor descriptor) {
	// 根据描述符来寻找对应的注解
	Object value = findValue(descriptor.getAnnotations());
	// ...
	return value;
}

可见,传入的参数descriptor:field user。意思就是MyController类下的一个字段属性。同时还能得到这个字段上有着对应的注解@Value

java更改对象属性注解的值 修改注解的value值_spring_04


随后通过findValue将注解中对应的值取出来:

protected Object findValue(Annotation[] annotationsToSearch) {
	// 如果某个字段上的注解个数>0
	if (annotationsToSearch.length > 0) {   
		// 查看这个注解是否为Value。这里的this.valueAnnotationType =  Value.class;
		AnnotationAttributes attr = AnnotatedElementUtils.getMergedAnnotationAttributes(
				AnnotatedElementUtils.forAnnotations(annotationsToSearch), this.valueAnnotationType);
		// 得到注解里面的值
		if (attr != null) {
			return extractValue(attr);
		}
	}
	return null;
}

1.2.2 解析Value

从本文的案例出发,我们知道我的value就是一个很简单的字符串:${username}。那么我们需要从解析角度来看:

String strVal = resolveEmbeddedValue((String) value);
↓↓↓↓↓↓↓↓↓
public abstract class AbstractBeanFactory extends FactoryBeanRegistrySupport implements ConfigurableBeanFactory {
	public String resolveEmbeddedValue(@Nullable String value) {
		// ..
		// 解析
		result = resolver.resolveStringValue(result);
		return result;
	}
}
↓↓↓↓↓↓↓↓↓
private class PlaceholderResolvingStringValueResolver implements StringValueResolver {
	public String resolveStringValue(String strVal) throws BeansException {
		// 解析
		String resolved = this.helper.replacePlaceholders(strVal, this.resolver);
		if (trimValues) {
			resolved = resolved.trim();
		}
		return (resolved.equals(nullValue) ? null : resolved);
	}
}
↓↓↓↓↓↓↓↓↓
public String replacePlaceholders(String value, PlaceholderResolver placeholderResolver) {
	// 解析
	return parseStringValue(value, placeholderResolver, null);
}
↓↓↓↓↓↓↓↓↓
protected String parseStringValue(String value, PlaceholderResolver placeholderResolver, @Nullable Set<String> visitedPlaceholders) {
	// this.placeholderPrefix = "${" ,如果以他为开头
	int startIndex = value.indexOf(this.placeholderPrefix);
	if (startIndex == -1) {
		return value;
	}

	StringBuilder result = new StringBuilder(value);
	while (startIndex != -1) {
		// 找到占位符的结束位置。
		int endIndex = findPlaceholderEndIndex(result, startIndex);
		if (endIndex != -1) {
			// 获取括号里的位置 ,以${username}为例,这里获取到的就是username
			String placeholder = result.substring(startIndex + this.placeholderPrefix.length(), endIndex);
			String originalPlaceholder = placeholder;
			if (visitedPlaceholders == null) {
				visitedPlaceholders = new HashSet<>(4);
			}
			if (!visitedPlaceholders.add(originalPlaceholder)) {
				throw new IllegalArgumentException(
						"Circular placeholder reference '" + originalPlaceholder + "' in property definitions");
			}
			// 递归调用本方法,因为属性键中可能仍然有占位符
			placeholder = parseStringValue(placeholder, placeholderResolver, visitedPlaceholders);
			// 获取属性键placeholder对应的属性值,这里将会得到结果jj.lin
			String propVal = placeholderResolver.resolvePlaceholder(placeholder);
			// 如果对应的属性值为空,做的对应处理
			if (propVal == null && this.valueSeparator != null) {
				// ...
			}
			// 做值的替换
			if (propVal != null) {
				// Recursive invocation, parsing placeholders contained in the
				// previously resolved placeholder value.
				propVal = parseStringValue(propVal, placeholderResolver, visitedPlaceholders);
				result.replace(startIndex, endIndex + this.placeholderSuffix.length(), propVal);
				// ...
			}
			else if (this.ignoreUnresolvablePlaceholders) {
				// Proceed with unprocessed value.
				startIndex = result.indexOf(this.placeholderPrefix, endIndex + this.placeholderSuffix.length());
			}
			// ...
		}
		else {
			startIndex = -1;
		}
	}
	return result.toString();
}

可以看到,上述代码的本质,就是占位符的一个替换操作。解析${xxx}格式的字符串。将中间的内容提取出来得到对应的值,然后进行替换操作。

我们可以看到,值的寻找是通过以下函数来执行的:

String propVal = placeholderResolver.resolvePlaceholder(placeholder);

resolvePlaceholder是一个接口,它的底层实现最终都是通过PropertySourcesPropertyResolver.getProperty()来实现:

protected <T> T getProperty(String key, Class<T> targetValueType, boolean resolveNestedPlaceholders) {
	if (this.propertySources != null) {
		for (PropertySource<?> propertySource : this.propertySources) {
			if (logger.isTraceEnabled()) {
				logger.trace("Searching for key '" + key + "' in PropertySource '" +
						propertySource.getName() + "'");
			}
			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);
			}
		}
	}
	if (logger.isTraceEnabled()) {
		logger.trace("Could not find key '" + key + "' in any property source");
	}
	return null;
}

首先,会循环遍历不同的属性资源配置类,例如本案例debug后,就有两个:

java更改对象属性注解的值 修改注解的value值_spring boot_05


我们看下第一个环境变量相关的数据源:

java更改对象属性注解的值 修改注解的value值_java_06


这里存在一个相同名称的USERNAME(忽略大小写),因此能够找到对应的变量值。同时根据代码逻辑,如果找到的value != null,就会直接return。而我们application.properties中的值在哪呢?

java更改对象属性注解的值 修改注解的value值_java更改对象属性注解的值_07

1.3 解决方案

最简单的方法,但也是最需要引起注意和有一定规范的方法:自定义属性名遵循一定的规范,不要和系统变量重名了。 (user.name也是不行的)

xxx.user.name=xxx
xxx.user.password-xxx

1.4 # 和 $ 的区别

在上文案例中,有这么两种Value的写法:

@Value("#{getUser}")
@Value("${username}")

先说结论:

  • $:去找外部配置的参数,将值赋过来。
  • #SpEL表达式,去寻找对应变量的内容。

首先说下第一种 $,上文分析的代码面向的就是这种类型:

  1. 如果不是以 ${ 为开头,就会直接返回。例如常规的@Valie("hello") ,就是单纯的将括号里面的值赋值给对象罢了。
  2. ${ 为开头的表达式,则会去所有的配置属性里面去寻找对应的键值对。找到了则进行字符串的替换操作。

接下来说下如果是#为开头的字符串该怎么办:

Object value = getAutowireCandidateResolver().getSuggestedValue(descriptor);
if (value != null) {
	if (value instanceof String) {
		// 这里面会判断是否以 # 开头,如果不是,就直接返回字符串
		String strVal = resolveEmbeddedValue((String) value);
		BeanDefinition bd = (beanName != null && containsBean(beanName) ?
				getMergedBeanDefinition(beanName) : null);
		// 主要的逻辑则在这里体现
		value = evaluateBeanDefinitionString(strVal, bd);
	}
	TypeConverter converter = (typeConverter != null ? typeConverter : getTypeConverter());
	try {
		return converter.convertIfNecessary(value, type, descriptor.getTypeDescriptor());
	}

如果我们解析出来的字符串是一个SpEL表达式,那么就会通过以下这个函数进行二次解析:

value = evaluateBeanDefinitionString(strVal, bd);

原理如下:

public abstract class AbstractBeanFactory extends FactoryBeanRegistrySupport implements ConfigurableBeanFactory {
	@Nullable
	protected Object evaluateBeanDefinitionString(@Nullable String value, @Nullable BeanDefinition beanDefinition) {
		// ...省略
		return this.beanExpressionResolver.evaluate(value, new BeanExpressionContext(this, scope));
	}
}

这里的this.beanExpressionResolver,实际上是一个接口BeanExpressionResolver,而这个接口的实现只有一个!就是StandardBeanExpressionResolver,其evaluate的具体实现如下:

public Object evaluate(@Nullable String value, BeanExpressionContext evalContext) throws BeansException {
	if (!StringUtils.hasLength(value)) {
		return value;
	}
	try {
		Expression expr = this.expressionCache.get(value);
		if (expr == null) {
			// ... 不考虑为null的情况
		}
		StandardEvaluationContext sec = this.evaluationCache.get(evalContext);
		if (sec == null) {
			// ... 不考虑为null的情况
		}
		// 终极实现
		return expr.getValue(sec);
	}
	catch (Throwable ex) {
		throw new BeanExpressionException("Expression parsing failed", ex);
	}
}

最后的 expr.getValue(sec);这段代码,使用的是org.springframework.expression包下的SpEL表达式解析器相关逻辑。我们只要知道,Spring本身就支持SpEL表达式的解析。可能用于Bean的自动装配过程。

二. 总结

2.1 总结

总结下就是:

  1. 对于${}这样的占位符,值的注入本质上就是字符串的替换操作。
  2. 对于 #{} 这样的占位符。值的注入本质上就是SpEL表达式的计算。
  3. 对于${}的原理。先看这个字段上是否有@Value注解,如果有拿到它的占位符。
  4. 如果这个占位符是字符串,就会进行对应的解析。去掉${},拿中间的部分,例如本案例中的username
  5. 根据username去整个系统的数据源中寻找有相同名称的键值对 。返回第一个找到的值。
  6. 由于系统环境变量数据源OriginAwareSystemEnvironmentPropertySource {name='systemEnvironment'}遍历顺序优先于OriginTrackedMapPropertySource {name='applicationConfig: [classpath:/application.properties]'}这类外部数据源配置。
  7. 而系统变量数据源恰好有username这个属性,因此提前返回。导致最终注入的值和我们预想的不一致。
  8. 因此实际开发过程中,我们需要根据业务、场景、层级关系等因素,命名具有一定规范的变量名,即可大大降低这类自定义命名和系统变量命名重复的概率。