Spring 事件、资源、类型转换与校验
ApplicationContext 除了管理 Bean,还提供进程内事件、资源定位和消息解析。类型转换、数据绑定、校验与表达式引擎则可独立使用,也能接入容器与 Web 框架。它们解决不同问题,不是一套必须依次执行的工作流。
| 组件 | 输入和结果 |
|---|---|
| ApplicationEventPublisher | 发布对象,通知匹配监听器 |
| Resource / ResourceLoader | 位置描述,取得流或其他资源访问方式 |
| ConversionService / Formatter | 原始值与目标类型,执行转换或本地化格式化 |
| DataBinder / Validator | 属性输入与目标对象,产生绑定/约束错误 |
| MessageSource | 消息 code、参数和 Locale,取得显示文本 |
| SpEL | 表达式、数据与求值上下文,计算结果 |
应用事件如何到达监听器
默认同步分派
默认 SimpleApplicationEventMulticaster 在发布线程执行匹配监听器。发布方法要等这些监听返回才返回,监听异常也可能回到发布者。接口允许不同执行实现,配置线程池或异步监听后,行为会改变。Context 事件
事件可继承 ApplicationEvent,也可发布普通对象;后者由框架包装。监听器可以实现 ApplicationListener,或在容器 Bean 方法上使用 EventListener。事件类型决定匹配,Order 决定同步监听次序。返回非 void 的注解监听方法还可以把返回值作为后续事件发布,需避免同类型递归。
完整同步事件程序
下载源码,在 Linux 的独立目录解压。宿主账号已能使用 Docker,镜像为 Maven 3.9.12-eclipse-temurin-25;项目固定 Spring 7.0.9,目标 Java 17,不访问外部 URL 或业务数据库。
mkdir spring-infrastructure-lab
unzip spring-framework-events-resources-conversion-lab.zip -d spring-infrastructure-lab
cd spring-infrastructure-lab
mkdir -p .m2
IMAGE=maven:3.9.12-eclipse-temurin-25
docker pull "$IMAGE"完整 src/main/java/example/FirstEvent.java:
package example;
import java.util.*;
import org.springframework.context.annotation.*;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
public class FirstEvent {
record Saved(int id) {}
static final List<String> trace = new ArrayList<>();
public static class Listener {
@EventListener @Order(1) public void audit(Saved event) { trace.add("audit:" + event.id()); }
@EventListener @Order(2) public void metric(Saved event) { trace.add("metric:" + event.id()); }
}
public static void main(String[] args) {
try(var context = new AnnotationConfigApplicationContext()) {
context.registerBean(Listener.class);
context.refresh();
trace.add("publish");
context.publishEvent(new Saved(7));
trace.add("return");
if(!trace.equals(List.of("publish", "audit:7", "metric:7", "return")))
throw new AssertionError(trace);
System.out.println(trace);
}
}
}Saved 是不可变事实对象;两个监听按顺序附加结果,最后发布者才记录 return。列表用于单线程实验,真实监听不要共享修改一个可变事件对象。
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>example</groupId><artifactId>infrastructure-lab</artifactId><version>1.0.0</version>
<properties><maven.compiler.release>17</maven.compiler.release><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>
<dependencies>
<dependency><groupId>org.springframework</groupId><artifactId>spring-context</artifactId><version>7.0.9</version></dependency>
</dependencies>
<build><plugins>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.14.1</version><configuration><parameters>true</parameters></configuration></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>3.9.0</version></plugin>
</plugins></build>
</project>docker run --rm --user "$(id -u):$(id -g)" -e MAVEN_CONFIG=/tmp/maven \
-v "$PWD:/work" -v "$PWD/.m2:/cache" -w /work "$IMAGE" \
sh -ec 'mvn -B -ntp -Duser.home=/tmp -Dmaven.repo.local=/cache \
clean package dependency:build-classpath -Dmdep.outputFile=target/classpath.txt
java -cp "target/infrastructure-lab-1.0.0.jar:$(cat target/classpath.txt)" example.FirstEvent'[publish, audit:7, metric:7, return]运行时使用打包后的 JAR,后续资源实验才能检查 JAR 内资源与文件路径的差异。构建容器身份由 --user 指定,.m2 和 target 对该 UID 可写;企业网络使用批准的 Maven settings 或可信私服,离线环境提前准备全部依赖后加 -o。Maven settings
监听失败与异步执行
同步:publish → listener → return / throw → 发布者
异步:publish → 提交任务 → 发布者返回
└── executor → listener → 独立错误处理扩展程序先用抛异常的同步监听,再配置有 ErrorHandler 的 multicaster:
docker run --rm --user "$(id -u):$(id -g)" --network none \
-v "$PWD:/work:ro" -v "$PWD/.m2:/cache:ro" -w /work "$IMAGE" \
sh -ec 'CP="target/infrastructure-lab-1.0.0.jar:$(cat target/classpath.txt)"
java -cp "$CP" example.InfrastructureLab event-failure
java -cp "$CP" example.InfrastructureLab async'输出 publisherObservedListenerFailure=true 和 asyncListenerErrors=1。异步模式使用 CountDownLatch 有界等待实际失败处理,不把“任务已提交”当作监听已完成。执行器在 finally 中 shutdown 并等待终止。
给 multicaster 设置 executor 后,支持异步执行的监听器才会被调度。某些监听器声明必须同步,例如事务相关同步监听;任务被拒绝时的具体回退也要看实现。使用 EventListener 加 Async 是另一套异步方法拦截路径,异常处理不能只依赖 multicaster 的同步 ErrorHandler。Multicaster API
事务事件可以选择 BEFORE_COMMIT、AFTER_COMMIT、AFTER_ROLLBACK 或 AFTER_COMPLETION。没有事务时默认不执行,fallbackExecution 可改变它;AFTER_COMMIT 失败无法撤销已提交数据。这些都是进程内通知,没有持久消息队列的确认、重放和崩溃恢复能力。事务事件
父子 Context 中事件可以向父容器传播,监听器应识别事件来源和所在容器,避免不必要的重复处理。关键业务步骤如果必须完成才算成功,应使用明确应用服务编排或可靠状态记录,不能只依赖“某监听器应该注册了”。
从 Resource 取得字节与显示文本
路径、句柄和输入流
Resource 描述如何访问内容;getInputStream() 取得流,调用方关闭。getFile() 仅在资源能对应本地文件时成立。类路径资源在 IDE 中可能是文件,打包进 JAR 后就没有同名操作系统文件。Resource
classpath:sample.txt → 一个类路径资源
classpath*:config/*.xml → ResourcePatternResolver 跨位置搜索
file:/opt/app/config.json → 外部文件资源
无协议路径 → 含义取决于 ResourceLoader / Context 类型classpath* 是模式解析器扩展,不是任意 ResourceLoader 都支持的标准 URL 协议。大范围扫描应在启动或受控刷新时执行,避免每个请求重复遍历 JAR。
扩展源码按字节上限读取资源:
Resource resource = new ClassPathResource("sample.txt");
try (InputStream input = resource.getInputStream()) {
byte[] bytes = input.readNBytes(1025);
if (bytes.length > 1024) throw new IllegalStateException("resource too large");
String text = new String(bytes, StandardCharsets.UTF_8);
}上限 1 KiB 只是此示例文件的限制,实际值应按允许配置大小设置。exists() 之后资源仍可能被删除或读取失败,打开动作仍需处理异常;URL 资源的探测还可能进行网络 I/O。
docker run --rm --user "$(id -u):$(id -g)" --network none \
-v "$PWD:/work:ro" -v "$PWD/.m2:/cache:ro" -w /work "$IMAGE" \
sh -ec 'java -cp "target/infrastructure-lab-1.0.0.jar:$(cat target/classpath.txt)" example.InfrastructureLab resource'resourceText=spring-resource
jarGetFileRejected=true
oneShotRejected=true
message=Hello Spring第一行通过 JAR 输入流读取成功,第二行确认相同 Resource 的 getFile 失败。InputStreamResource 封装一次性流,重复获取会被拒绝;需要重复读取且内容足够小时,可在限制大小后使用 ByteArrayResource。不要对一次性流随意调用可能通过读取计算 contentLength 的方法。InputStreamResource
用户输入不得直接成为任意 Resource 位置。只允许既定文件名或资源标识更易控制;确需下载外部内容时,应由受控 HTTP 客户端限制协议、目标、重定向、解析地址、响应大小和超时。Resource 抽象自身不提供完整 SSRF 防护。
MessageSource 与 Locale
实验资源包包含 messages.properties 的 greeting=Hello {0},ResourceBundleMessageSource 用 code、参数和 Locale 解析出 Hello Spring。把消息 code 与业务错误分开,接口仍返回稳定错误码,显示文本按 Locale 变化。
Context 会查找名为 messageSource 的 Bean;可使用 ResourceBundleMessageSource 或按需求选择可重载实现。显式设置编码,测试缺失 code 时的默认文本或异常,不让“没有翻译”变成隐藏业务故障。消息源
转换、绑定与校验各做一件事
转换先解决类型表示
Converter<S,T> 转换明确类型,ConverterFactory 处理相关目标类型族,GenericConverter 可以查看包含泛型和注解的 TypeDescriptor。ConversionService 选择适用转换器;Formatter 增加 Locale 下的 parse/print,适合用户可见文本。类型转换、字段格式化
转换 "12" 到 Integer 处理的是文本表示;数量是否允许为 0 需要约束校验。将字符串转换为 User 时顺便查数据库会隐藏 I/O 和不存在错误,通常先转为 UserId,再由应用服务加载数据。
字符串 "abc" → Integer 转换失败 → typeMismatch
字符串 "0" → Integer(0) → 范围校验失败 → positivePropertyEditor 是可变状态的旧式接口,通常按绑定过程使用;不能把同一实例当全局并发转换器。注册宽泛 String→Object 转换器会影响多个入口,优先选择窄类型对并保留失败测试。
DataBinder 只写允许字段
DataBinder 把属性值应用到目标,对转换失败记录 BindingResult。客户端不应绑定实体全部可写属性,尤其 admin、价格、状态等服务端控制字段。独立 DTO 加 setAllowedFields 可以限制可写面。DataBinder API
实验 Form 包含 quantity 和 admin,只允许 quantity;Spring Validator 检查 quantity 至少为 1:
binder.setAllowedFields("quantity");
binder.bind(new MutablePropertyValues(Map.of("quantity", "0", "admin", "true")));
binder.validate();
BindingResult result = binder.getBindingResult();完整转换器、Form 与 Validator 声明在源码包的 InfrastructureLab 中。运行:
docker run --rm --user "$(id -u):$(id -g)" --network none \
-v "$PWD:/work:ro" -v "$PWD/.m2:/cache:ro" -w /work "$IMAGE" \
sh -ec 'java -cp "target/infrastructure-lab-1.0.0.jar:$(cat target/classpath.txt)" example.InfrastructureLab binding'quantityError=positive suppressedAdmin=true
formatError=typeMismatchadmin 保持 false,且出现在 suppressed fields 中。绑定器抑制了字段并不自动生成客户端错误响应;应用需决定是否拒绝出现这些字段的请求。未知字段、嵌套属性和集合自动增长也应有明确限制。
Spring Validator 通过 supports 声明可处理类型,validate 向 Errors 写入字段或对象级错误。Bean Validation 则使用 Jakarta 约束和 provider,LocalValidatorFactoryBean 可桥接两套 API。只有添加 API 注解而没有实现 provider 或触发 validate,不会自动执行校验;MVC 参数注解、方法校验代理和手工校验各有独立触发条件。Bean Validation
格式转换失败后,需要该字段的范围或跨字段校验应跳过,避免再次抛类型异常。校验错误 code 用于稳定分类;日志和响应不回显密码、令牌等拒绝值。数据库唯一约束、版本检查仍要在写入时守住并发不变量,不能只靠提交前的查询校验。
SpEL 求值与基础设施排障
为表达式配置可见能力
SpEL 可以读取属性、遍历集合、调用方法或引用类型,具体能力由 EvaluationContext 决定。开发者控制表达式、用户只提供数据,与让用户提交表达式本身,是两种安全要求。
SimpleEvaluationContext.forReadOnlyDataBinding() 只开放受限属性读取,不支持标准类型引用、构造器和 Bean 引用。这个选择适合表达式只需读取允许 DTO 属性的场景;它仍不是任意不可信表达式的通用安全沙箱,需要限制可达对象、长度、复杂度和资源消耗。求值上下文
docker run --rm --user "$(id -u):$(id -g)" --network none \
-v "$PWD:/work:ro" -v "$PWD/.m2:/cache:ro" -w /work "$IMAGE" \
sh -ec 'java -cp "target/infrastructure-lab-1.0.0.jar:$(cat target/classpath.txt)" example.InfrastructureLab spel'输出 value=6 typeReferenceRejected=true,quantity=3 的受限属性运算成功,类型访问表达式被拒绝。需要额外方法调用时,逐项开放特定能力,不改用字符串黑名单过滤 T(。
解析结果可以缓存,但每请求 EvaluationContext 不应共享可变变量。表达式缓存必须有容量限制;记录模板标识、解析/求值阶段和目标类型即可,不打印含秘密的完整表达式。
从错误回到具体服务
| 现象 | 首查内容 | 修复与验证 |
|---|---|---|
| 发布调用异常 | 同步监听器 cause、顺序 | 修监听或明确错误策略,重复 event-failure 对照 |
| 发布已返回却无处理结果 | executor 拒绝/失败处理与任务完成 | 有界队列和错误统计,等待真实完成而非提交成功 |
| IDE 正常、JAR 读失败 | getFile 与资源 URL 协议 | 改流读取,使用最终 JAR 重跑 resource |
| 同一资源第二次读失败 | InputStreamResource 生命周期 | 只读一次或显式缓冲受限内容 |
| 文本绑定失败 | typeMismatch、源/目标类型 | 修转换与客户端格式,再执行约束校验 |
| 未授权字段被提交 | allowed fields / DTO / suppressed fields | 拒绝非法输入并确认领域对象未变 |
| 表达式能力过宽 | EvaluationContext 与对象图 | 缩小根对象和能力,保留拒绝类型访问反例 |
修复时保持同一数据与上下文,只改变相关配置;不要同时换转换器、校验规则和表达式上下文,否则难以解释结果变化。实验结束关闭 Context 和执行器,资源流由 try 关闭;实验没有持久数据库或外部服务需要恢复。
权威资料与规范地址
可按接口、注解和实现类查阅详细契约。
