类型转换、绑定校验与 SpEL
"字符串怎么变成 Money""表单字段绑错了为什么没报错""用户拼的表达式执行了什么"分别对应转换、绑定和表达式求值。本篇固定 Framework v6.1.12:容器注入候选见配置与候选解析,Environment 取值见Environment 篇;MVC参数校验分组见Web主题,Boot的record/JavaBean ConfigurationProperties绑定、Jakarta校验与失败阶段见Boot配置,本篇只讲Framework容器内机制。
转换发生在取值的地方
容器里所有"字符串 → 目标类型"的路口都经过 ConversionService:Environment.getProperty(key, targetType)(Environment 篇摘录的 convertValueIfNecessary)、@Value 占位符注入、DataBinder 绑定、XML 属性填充。DefaultConversionService 预置了数字/集合/Resource 等转换器,自定义只需注册 Converter<String, Money> 之类。实测:convert("42.50USD", Money.class) 经自定义转换器得到 42.50/USD;convert("abc", Integer.class) 抛 ConversionFailedException,异常携带源值(getValue()=="abc")与目标类型(消息含 java.lang.Integer)——失败信息是排障入口,不是可忽略噪音。
容器集成有一道显式前提:@Value("${u16.money}") Money 要生效,必须把 ConversionService 设置到 BeanFactory(beanFactory.setConversionService(service))。实测不设置时启动失败:Failed to convert value ... no matching editors or conversion strategy found——容器默认只有属性编辑器兜底,不知道你的 Money。"转换不生效"先查三件事:源里有没有值、ConversionService 是否已挂到工厂、转换器支不支持目标类型。
绑定是 JavaBean 属性,校验是独立一步
DataBinder 把一组 PropertyValues 写进目标对象。原码:DataBinder.bind 与 doBind,v6.1.12,1081–1113 行(含 6.1 新增的 declarative 判定)。固定来源。
public void bind(PropertyValues pvs) {
if (shouldNotBindPropertyValues()) {
return;
}
MutablePropertyValues mpvs = (pvs instanceof MutablePropertyValues mutablePropertyValues ?
mutablePropertyValues : new MutablePropertyValues(pvs));
doBind(mpvs);
}
/**
* Whether to not bind parameters to properties. Returns "true" if
* {@link #isDeclarativeBinding()} is on, and
* {@link #setAllowedFields(String...) allowedFields} are not configured.
* @since 6.1
*/
protected boolean shouldNotBindPropertyValues() {
return (isDeclarativeBinding() && ObjectUtils.isEmpty(this.allowedFields));
}
/**
* Actual implementation of the binding process, working with the
* passed-in MutablePropertyValues instance.
* @param mpvs the property values to bind,
* as MutablePropertyValues instance
* @see #checkAllowedFields
* @see #checkRequiredFields
* @see #applyPropertyValues
*/
protected void doBind(MutablePropertyValues mpvs) {
checkAllowedFields(mpvs);
checkRequiredFields(mpvs);
applyPropertyValues(mpvs);
}摘录给出三个边界。其一,绑定按 JavaBean 属性走:BeanWrapper 通过 setter 写值,只有公开字段没有 setter 的对象绑定后保持原值且零报错(默认 ignoreUnknownFields=true 静默跳过未知属性)——实测无 setter 的表单对象 bind 后 name/age 不变、getAllErrors() 为空,这类静默跳过比报错难排查得多。其二,类型不匹配进 BindingResult:实测 age="abc" 产生 typeMismatch 字段错误并记录 rejectedValue "abc",目标对象的 age 保持未绑定值——绑定失败不是异常,是要显式检查的错误集合。其三,6.1 的 declarativeBinding 边界:开启后若未配置 allowedFields,bind() 直接跳过(实测 name/age 均未绑定),配置 setAllowedFields("name","age") 后才绑定——从旧版本升级到 6.x 后"bind 突然什么都不绑"先查这个开关。
校验是 bind 之后的独立步骤:Validator 实现 supports/validate,实测空名字与负年龄产生 name.required、age.negative 两个错误码,合法对象零错误;BeanPropertyBindingResult.getFieldValue 经 BeanWrapper 读值,目标类要有 getter。jakarta Bean Validation(@NotNull 等注解校验)由 Hibernate Validator 提供,属 MVC/Boot 票据边界;本篇的 Spring Validator 是同一分层思想的最小编程式形态。
SpEL:解析一次,求值多次
SpelExpressionParser 实例可复用且线程安全;parseRaw 产出 SpelExpression,每次 getValue 都重新求值——实测根对象计数器在两次 getValue 后为 2(求值不是缓存结果),模板 Hello #{#who} 每次按当前变量求值。原码:SpelExpression.getEvaluationContext,v6.1.12,104–109 行。固定来源。
public EvaluationContext getEvaluationContext() {
if (this.evaluationContext == null) {
this.evaluationContext = new StandardEvaluationContext();
}
return this.evaluationContext;
}这七行是安全边界:不指定评估上下文时,默认构造的是全能力 StandardEvaluationContext——类型引用、构造器调用、方法调用全部放行。实测:T(java.lang.System).getProperty('user.dir') 在默认上下文真实执行并返回当前目录;T(java.lang.Runtime) 直接解析出 Runtime 类对象——解析即授予类型访问,连求值阶段都不需要。
不可信表达式不是配置
SpEL 能力等价于"一小段 Java",把用户输入喂给 parseRaw + StandardEvaluationContext 等于把注入面开到类路径。对照实验:同一表达式 T(java.lang.System).getProperty('user.dir') 在 SimpleEvaluationContext.forReadOnlyDataBinding() 下抛 SpelEvaluationException——受限上下文不提供类型引用与构造器能力。取舍规则:表达式作者可信(自己写的 @Value("#{2 * 21}")、@Cacheable(key="#id"))→ 默认上下文可用;表达式来源含请求参数、消息体、数据库字符串 → 要么用 SimpleEvaluationContext 的最小能力组合,要么不经过 SpEL(改为代码内白名单映射)。@Value 里 ${key} 是占位符(Environment 取值)、#{...} 才是 SpEL——两者混排时先占位符替换再求值,排查时按来源分段。本实验只覆盖类型引用与构造器能力的正反对照;SpEL DoS 全谱系与历史 CVE 不冒充本机复现。
实验:失败信息与边界对照
从仓库根执行 JAVA_HOME=$(/usr/libexec/java_home -v17) bash labs/spring-senior/run.sh binding,单模式命令见实验说明。四个模式独立 JVM:conversion(自定义转换/失败信息/容器集成)、binding(绑定/类型不匹配/6.1 开关)、validator(错误码)、spel(求值时点/模板/Standard vs Simple)。
| 公开输入 | 实测判据 |
|---|---|
convert("42.50USD") / convert("abc", Integer.class) | 自定义转换成功;失败带源值+目标类型(PASS conversion ... failure=ConversionFailedException valueInjected=7.25EUR) |
无 setter 对象 bind / age="abc" / declarativeBinding=true | 静默零错误零绑定 / typeMismatch+rejectedValue / 直接跳过,补 allowedFields 后恢复 |
| 空名+负年龄 / 合法对象 | 两个错误码 / 零错误 |
| 两次 getValue / T() / SimpleEvaluationContext | 计数 2(逐次求值);默认上下文真实执行;受限上下文抛 SpelEvaluationException |
断言异常类型、错误码与计数,不拿"能跑"当边界证明。未测:SpEL 编译模式(SpelCompilerMode)、格式化(Formatter/注解驱动的 Locale 格式化,按参考文档推导)、@Value 中占位符与 SpEL 的完整解析顺序矩阵、jakarta 校验器集成。实验唯一源码内嵌如下。
package study.spring;
import java.util.Map;
import org.springframework.beans.MutablePropertyValues;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.ConversionFailedException;
import org.springframework.core.convert.converter.Converter;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.expression.Expression;
import org.springframework.expression.common.TemplateParserContext;
import org.springframework.expression.spel.SpelEvaluationException;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.SimpleEvaluationContext;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.DataBinder;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
/**
* U16 conversion, binding/validation and SpEL boundaries. One JVM per mode.
*/
public class BindingLab {
record Money(double amount, String currency) {}
static class MoneyConverter implements Converter<String, Money> {
@Override
public Money convert(String source) {
String trimmed = source.trim();
if (trimmed.length() < 4 || !Character.isLetter(trimmed.charAt(trimmed.length() - 3)))
throw new IllegalArgumentException("expected amount+currency, got: " + source);
return new Money(Double.parseDouble(trimmed.substring(0, trimmed.length() - 3)),
trimmed.substring(trimmed.length() - 3));
}
}
static class MoneyHolder {
@Value("${u16.money}") Money money;
}
@Configuration static class MoneyConfig {
@Bean MoneyHolder holder() { return new MoneyHolder(); }
}
static class FormObject {
String name;
int age;
// DataBinder binds JavaBean properties through setters, not fields.
public void setName(String name) { this.name = name; }
public void setAge(int age) { this.age = age; }
}
static class Person {
String name;
int age;
// BindingResult.getFieldValue reads through the BeanWrapper, so getters are required.
public String getName() { return name; }
public int getAge() { return age; }
}
static class PersonValidator implements Validator {
@Override
public boolean supports(Class<?> type) { return Person.class.isAssignableFrom(type); }
@Override
public void validate(Object target, Errors errors) {
Person person = (Person) target;
if (person.name == null || person.name.isBlank()) errors.rejectValue("name", "name.required");
if (person.age < 0) errors.rejectValue("age", "age.negative");
}
}
static class Counter {
int calls;
public int next() { return ++calls; }
}
public static void main(String[] args) {
String mode = args.length == 0 ? "conversion" : args[0];
switch (mode == null || mode.isEmpty() ? "conversion" : mode) {
case "conversion" -> conversion();
case "binding" -> binding();
case "validator" -> validator();
case "spel" -> spel();
default -> throw new IllegalArgumentException(mode);
}
}
static void conversion() {
var service = new DefaultConversionService();
service.addConverter(new MoneyConverter());
Money money = service.convert("42.50USD", Money.class);
if (money.amount() != 42.50 || !"USD".equals(money.currency()))
throw new AssertionError("custom converter must parse amount+currency: " + money);
if (service.convert("3", Integer.class) != 3) throw new AssertionError("built-in conversion");
try {
service.convert("abc", Integer.class);
throw new AssertionError("expected conversion failure");
} catch (ConversionFailedException e) {
if (!String.valueOf(e.getValue()).equals("abc")
|| !e.getTargetType().getType().equals(Integer.class))
throw new AssertionError("failure must carry source value and target type: " + e.getMessage());
if (!e.getMessage().contains("java.lang.Integer")) throw new AssertionError("target type in message: " + e.getMessage());
}
// Container integration: the factory ConversionService converts @Value placeholders.
System.setProperty("u16.money", "7.25EUR");
try {
var c = new AnnotationConfigApplicationContext();
c.getBeanFactory().setConversionService(service);
c.register(MoneyConfig.class);
try {
c.refresh();
MoneyHolder holder = c.getBean(MoneyHolder.class);
if (holder.money.amount() != 7.25 || !"EUR".equals(holder.money.currency()))
throw new AssertionError("@Value conversion through container service: " + holder.money);
} finally {
c.close();
}
} finally {
System.clearProperty("u16.money");
}
System.out.println("PASS conversion custom=42.50USD builtIn=true failure=ConversionFailedException valueInjected=7.25EUR");
}
static void binding() {
var form = new FormObject();
var binder = new DataBinder(form, "form");
binder.bind(new MutablePropertyValues(Map.of("name", "Ann", "age", "42")));
if (binder.getBindingResult().hasErrors() || !"Ann".equals(form.name) || form.age != 42)
throw new AssertionError("plain values must bind: " + binder.getBindingResult());
// Type mismatch: recorded as a field error, the target keeps the unbound value.
var mismatch = new FormObject();
var mismatchBinder = new DataBinder(mismatch, "form");
mismatchBinder.bind(new MutablePropertyValues(Map.of("name", "Bo", "age", "abc")));
var result = mismatchBinder.getBindingResult();
if (!result.hasFieldErrors("age") || !result.getFieldError("age").getCode().contains("typeMismatch"))
throw new AssertionError("type mismatch must be a field error: " + result);
if (!"abc".equals(result.getFieldError("age").getRejectedValue())) throw new AssertionError("rejected value recorded");
if (mismatch.age != 0) throw new AssertionError("unconvertible value must not corrupt the target");
// 6.1 boundary: declarative binding without allowedFields silently binds nothing.
var strict = new FormObject();
var strictBinder = new DataBinder(strict, "form");
strictBinder.setDeclarativeBinding(true);
strictBinder.bind(new MutablePropertyValues(Map.of("name", "Cyd", "age", "9")));
if (strict.name != null || strict.age != 0)
throw new AssertionError("declarative binding without allowedFields must skip binding");
strictBinder.setAllowedFields("name", "age");
strictBinder.bind(new MutablePropertyValues(Map.of("name", "Cyd", "age", "9")));
if (!"Cyd".equals(strict.name) || strict.age != 9)
throw new AssertionError("allowedFields unlock declarative binding");
System.out.println("PASS binding plainBound=true typeMismatch=fieldError declarativeBinding=skipsWithoutAllowedFields");
}
static void validator() {
var person = new Person();
person.name = " ";
person.age = -1;
var result = new BeanPropertyBindingResult(person, "person");
new PersonValidator().validate(person, result);
if (result.getErrorCount() != 2) throw new AssertionError("two violations expected: " + result.getAllErrors());
if (!"name.required".equals(result.getFieldError("name").getCode())
|| !"age.negative".equals(result.getFieldError("age").getCode()))
throw new AssertionError("codes must be the declared ones: " + result.getAllErrors());
person.name = "Ann";
person.age = 3;
var clean = new BeanPropertyBindingResult(person, "person");
new PersonValidator().validate(person, clean);
if (clean.hasErrors()) throw new AssertionError("valid person must pass");
System.out.println("PASS validator rejected=[name.required,age.negative] validPasses=true");
}
static void spel() {
var parser = new SpelExpressionParser();
Expression arithmetic = parser.parseRaw("1 + 2 * 3");
// Parsing happens once; every getValue re-evaluates against the current state.
if (!Integer.valueOf(7).equals(arithmetic.getValue())) throw new AssertionError("arithmetic");
var counter = new Counter();
Expression call = parser.parseRaw("next()");
var shared = new StandardEvaluationContext(counter);
if ((Integer) call.getValue(shared) != 1 || (Integer) call.getValue(shared) != 2 || counter.calls != 2)
throw new AssertionError("parse once, evaluate per getValue call");
Expression template = parser.parseExpression("Hello #{#who}", new TemplateParserContext());
var variables = new StandardEvaluationContext();
variables.setVariable("who", "Ann");
if (!"Hello Ann".equals(template.getValue(variables))) throw new AssertionError("template");
// StandardEvaluationContext is the full-power default: type references really execute.
Expression typeRef = parser.parseRaw("T(java.lang.System).getProperty('user.dir')");
if (!System.getProperty("user.dir").equals(typeRef.getValue(new StandardEvaluationContext())))
throw new AssertionError("T() must evaluate on the standard context");
// The same untrusted expression is rejected by the restricted context.
try {
typeRef.getValue(SimpleEvaluationContext.forReadOnlyDataBinding().build());
throw new AssertionError("SimpleEvaluationContext must reject type references");
} catch (SpelEvaluationException expected) {
// Rejection is the boundary: parsing succeeds, evaluation is restricted.
}
// A user-supplied string names a type: parsing alone already grants class access.
Expression userType = parser.parseRaw("T(java.lang.Runtime)");
if (!java.lang.Runtime.class.equals(userType.getValue(new StandardEvaluationContext())))
throw new AssertionError("type reference resolves the class object");
System.out.println("PASS spel parseOnceEvaluatePerCall=true template=true standardExecutesT()=true simpleRejects=true");
}
}架构师推演:输入边界与迁移
给定配置驱动的规则引擎:方案一把规则写进数据库并由服务端拼接进 SpEL 求值,运营可即时改规则,但表达式作者实际是数据库写入者——任何能改库的入口都升级为任意类引用能力,事故半径是实例级;方案二把规则收敛为枚举操作 + 参数,表达式能力为零,表达力下降但输入空间可枚举、可测试。判据是"表达式的作者是否可信、出错后想在哪恢复":作者=开发者且经代码评审,SpEL 可用;作者含运营/请求,就必须能力受限的上下文或干脆不用表达式求值。恢复责任:表达式执行失败要落在可观测的错误通道并回退默认行为,不能让半个求值结果污染业务对象。
版本迁移检查(5.x → 6.1):declarativeBinding 新默认行为、绑定静默跳过、SpEL 沙箱能力随 SimpleEvaluationContext 版本演进——三项都要有对照实验而不是读变更日志。验证组合:每个不可信表达式入口一个"应拒绝"负例、每个可信入口一个正常例,升级后在两个上下文下复跑对照。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。