@Value("#{...}") 写成了 @Value("${...}"),启动时报 Could not resolve placeholder;配置里 max-size: 10MB 绑定到一个 long 字段,直接报类型不匹配;集合类型没写泛型,运行期抛 ClassCastException。这些都是同一件事的不同侧面:字符串到对象的转换由谁负责、以什么规则挑选转换器。
Spring 里这条链路有三层:最老的 PropertyEditor、中间的 Converter、最外面的 ConversionService。SpEL 是另一条支线,求值结果再交回同一套转换链路。以下按 Boot 3.3.x / Framework 6.1.x 核对。
1. SpEL 的用途边界
| 使用点 | 典型写法 | 求值时机 |
|---|---|---|
@Value |
@Value("#{systemProperties['user.home']}") |
Bean 初始化 |
| 缓存 key | @Cacheable(key = "#id + '-' + #type") |
每次调用 |
| 条件装配 | @ConditionalOnExpression |
启动期,仅一次 |
| 事件过滤 | @EventListener(condition = "#event.level == 'ERROR'") |
事件发布 |
| 安全表达式 | @PreAuthorize("hasRole('ADMIN')") |
调用前 |
它不是模板引擎:没有循环、没有递归包含,写复杂逻辑会把配置变成没人敢改的代码。判断标准很朴素——表达式超过一两个运算符,就该搬到 Java 里。
2. 语法要点
| 写法 | 含义 |
|---|---|
#user.name、#map['k']、#list[0] |
属性访问、Map 取值、索引 |
#svc.load(#id) |
方法调用 |
T(java.lang.Math).max(#a,#b) |
类型引用,可调静态方法 |
#a > 10 and #b < 20 ? 'y' : 'n' |
比较、逻辑与三元 |
#user?.address?.city |
安全导航,任一段 null 则整体返回 null |
#list.?[#this > 5] |
选择:筛出满足条件的元素 |
#list.![#this * 2] |
投影:对每个元素求值,返回新集合 |
'{1,2,3}'、"{'k':1}" |
内联列表与内联 Map |
. 与 .? 的差别只在 null 处理:#a.b 遇 null 抛异常,#a?.b 返回 null。字段链长的表达式统一用安全导航,比到处写三元判断可读。
3. @Value 的三种用法
@Component
public class ClientConfig {
@Value("10") // ① 字面量:直接给常量
private int retry;
@Value("${clarity.timeout:3s}") // ② 占位符:取环境变量/配置,冒号后是默认值
private Duration timeout;
@Value("#{2 * 1024}") // ③ 表达式:SpEL 求值
private int bufferSize;
@Value("#{${clarity.retry-count:2} + 1}") // ④ 嵌套:占位符结果作为 SpEL 输入
private int totalRetry;
}
第 ④ 种的坑在于类型:#{${x}} 要求配置值本身是合法 SpEL 片段。写 clarity.retry-count=2 时表达式变成 #{2 + 1};配置若被改成 2; T(java.lang.Runtime).getRuntime(),这串字符就会进入表达式解析——配置来源不可控时它就是一个执行入口。
| 写法 | 解析层 | 报错信息 |
|---|---|---|
${key} |
属性占位符 | Could not resolve placeholder |
#{expr} |
SpEL | SpelEvaluationException |
#{${key}} |
先占位符、后 SpEL | 看哪层先炸 |
用错符号的典型表现:把简单值写成 #{},得到 SpelEvaluationException: EL1008E: Property or field 'xxx' cannot be found。
4. 表达式注入
用户可控的字符串只要进入 SpEL 求值,就等同于任意代码执行。最小的验证写法:
ExpressionParser parser = new SpelExpressionParser();
// 危险:直接对不可信输入求值
Object r = parser.parseExpression(untrustedInput)
.getValue(new StandardEvaluationContext());
输入形如 T(java.lang.Runtime).getRuntime().exec("id") 就会被求值。StandardEvaluationContext 可用 setVariable/registerFunction 收紧能力,但白名单永远比黑名单可靠:
// 只允许方法调用 + 属性访问,禁止类型引用、构造器与 new
SimpleEvaluationContext ctx = SimpleEvaluationContext
.forReadOnlyDataBinding()
.withInstanceMethods()
.build();
parser.parseExpression("#svc.find(#id)").getValue(ctx, template, String.class);
SimpleEvaluationContext 默认没有 T()、构造器、赋值等能力,是表达式来自外部时的默认选择。仅用于方法参数绑定的场景,用 withInstanceMethods() 就够,别为省事回退到 StandardEvaluationContext。
5. 类型转换的三层
| 层 | 接口 | 现状 |
|---|---|---|
| 属性编辑器 | PropertyEditor |
早期 JDK 机制,现在只为兼容保留 |
| 转换器 | Converter<S,T>、ConverterFactory、GenericConverter |
主流,无状态、线程安全 |
| 转换服务 | ConversionService / FormattingConversionService |
统一入口,Binder、MVC、@Value 都走它 |
选择逻辑由 GenericConversionService 实现:先按"源类型 → 目标类型"精确查表;没有精确匹配时沿类层次与接口向上找最接近的转换器;再没有就交给 ConverterFactory 与 GenericConverter;最后检查 parent 转换服务。找不到时抛 ConverterNotFoundException,消息里会写清是哪两个类型之间缺转换器。
DefaultConversionService 自带常见转换(String→Number、String→Enum、集合与数组互转等);FormattingConversionService 在其上叠加 @NumberFormat、@DateTimeFormat 的解析。
6. 注册自定义转换器
public class MoneyConverter implements Converter<String, Money> {
@Override
public Money convert(String src) {
return Money.of(new BigDecimal(src.trim()));
}
}
@Configuration
public class WebConvertConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new MoneyConverter());
}
}
| 注册位置 | 影响范围 | 优先级 |
|---|---|---|
WebMvcConfigurer#addFormatters |
MVC 参数绑定、@RequestParam |
覆盖默认同名转换 |
自定义 ConversionService Bean |
全局,包括 Binder 与 @Value |
Boot 在缺少时才装配默认的 |
@ConfigurationPropertiesBinding 标注的 Converter Bean |
只影响属性绑定 | 与上一条并存 |
registry.addConverterFactory(...) |
一族类型(如所有 Enum) |
精确匹配仍然优先 |
顺序上的经验:精确类型的 Converter 一定赢过 ConverterFactory,自己注册的会覆盖 DefaultConversionService 的同类型条目。想确认当前用的是哪一个,把断点打在 GenericConversionService#getConverter。
7. @ConfigurationProperties 的绑定机制
绑定走 Binder,它把 Environment 里的属性名先规范成 ConfigurationPropertyName,再做宽松匹配:
| 配置里的写法 | 绑定到 Java 字段 |
|---|---|
clarity.cache.max-size |
maxSize |
clarity.cache.maxSize |
maxSize |
CLARITY_CACHE_MAXSIZE |
maxSize(环境变量场景) |
clarity.cache[0].name |
List<Item> items 的第一个元素 |
@ConfigurationProperties(prefix = "clarity.cache")
public class CacheProperties {
private Duration ttl; // 5s / PT5S / 5000ms
private DataSize maxSize; // 10MB / 1MB / 1048576
private List<String> hosts = new ArrayList<>();
private Map<String, Duration> perHost = new HashMap<>();
}
Duration 与 DataSize 是绑定器重点照顾的对象:不带后缀时 Duration 按毫秒、DataSize 按字节;带后缀走 ChronoUnit 或二进制/十进制单位,@DurationUnit 可改无后缀时的默认单位。
嵌套对象不需要额外注解,只要字段有 getter 且类型可实例化;List 必须有可写入口(setter 或初始化好的可变集合);构造函数绑定要求类只有一个带参构造器,字段可声明为 final。
8. 格式化与转换的区别
| 维度 | 转换(Conversion) | 格式化(Formatting) |
|---|---|---|
| 目的 | 类型之间可逆映射 | 与用户可见文本互转,带区域设置 |
| 接口 | Converter<S,T> |
Formatter<T>、Printer、Parser |
| 注解 | 无 | @NumberFormat、@DateTimeFormat |
| 区域敏感 | 否 | 是,跟随 Locale |
| 典型场景 | String → Money |
LocalDate ↔ "2026-11-09" |
public class ReportQuery {
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
private LocalDate from;
@NumberFormat(pattern = "#,##0.00")
private BigDecimal amount;
}
这两个注解由 FormattingConversionService 读取,只在 MVC 参数绑定与 Binder 场景生效;写进 @Value 不起作用,那条路径不具备注解感知。
9. 常见坑
| 现象 | 根因 | 处理 |
|---|---|---|
Could not resolve placeholder |
把 ${} 写成了 #{},或占位符名写错 |
简单值用 ${},表达式才用 #{} |
集合转换报 ClassCastException |
目标类型缺泛型信息 | 声明为 List<Money> |
| 属性绑定后全是 null | 缺 setter、类未被扫描或没加 @EnableConfigurationProperties |
补 setter,@ConfigurationPropertiesScan 或显式注册 |
自定义 Converter 不生效 |
注册位置不对,或被精确匹配覆盖 | 用 addFormatters 或 @ConfigurationPropertiesBinding |
@Value 注入 List<String> 失败 |
默认只按单个值注入,逗号列表需显式转换 | 用 @ConfigurationProperties 承载集合 |
@Value("${a,b,c}") 能工作是因为字符串到集合的转换器把逗号当分隔符,但空值与含逗号的字符串会让它翻车。集合类配置一律交给 @ConfigurationProperties。
10. 边界
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 一段逻辑要跑在配置里 | 写成 @Bean 或工具类 |
SpEL 没有调试手段 |
| 表达式来自外部输入 | SimpleEvaluationContext + 白名单 |
收窄能力比过滤字符可靠 |
| 一批相关配置 | @ConfigurationProperties |
支持校验与元数据 |
| 跨类型映射族 | ConverterFactory |
单类型 Converter 要写很多个 |
| 区域敏感的输出 | Formatter |
Converter 不感知 Locale |
选型只问两句:这段字符串最终变成对象还是变成表达式?它来自代码还是来自外部?答案决定走 ConversionService 还是 SpEL,也决定要不要收紧求值上下文。
11. 自测
@Value("#{...}")与@Value("${...}")分别由谁解析,报错信息怎么区分?#{${key}}这种嵌套写法在什么条件下会把配置值当代码执行?PropertyEditor、Converter、ConversionService三者的分工是什么?- 自定义
Converter有哪几种注册方式,各自影响范围? SimpleEvaluationContext相比StandardEvaluationContext少了哪些能力?Duration从配置绑定时,带与不带单位后缀分别怎么解释?