2260 字
约 7 分钟
22
S07-X13 · SpEL 与类型转换机制

@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. 自测

  1. @Value("#{...}") 与 @Value("${...}") 分别由谁解析,报错信息怎么区分?
  2. #{${key}} 这种嵌套写法在什么条件下会把配置值当代码执行?
  3. PropertyEditor、Converter、ConversionService 三者的分工是什么?
  4. 自定义 Converter 有哪几种注册方式,各自影响范围?
  5. SimpleEvaluationContext 相比 StandardEvaluationContext 少了哪些能力?
  6. Duration 从配置绑定时,带与不带单位后缀分别怎么解释?
S07-X13 · SpEL 与类型转换机制
https://www.clxhxhhr.top/posts/5954/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。