同样是 400,异常类型为何不同

{bad、{"name":"","quantity":0} 和 ?n=oops 都是错误输入,但没有在同一阶段失败。第一个连 JSON 对象都不能形成,第二个可以形成对象却不符合约束,第三个不能转换成参数声明的整数。用一个笼统的“参数错误”遮住阶段差异,客户端也许仍能识别失败,服务端却难以决定应该修正文、字段值还是转换规则。

控制器方法直接在参数上声明 @Min(1) 时,又可能得到 HandlerMethodValidationException,而不是处理 @Valid @RequestBody 时熟悉的 MethodArgumentNotValidException。只捕获后一种异常的统一处理器,会遗漏同一接口体系中另一条合法的校验路径。

本篇基线为 Spring Framework 6.2.11,固定源码提交 4c134254642d88e058aa004bdaf44168e1be7bb2,JDK 21、Tomcat 10.1.46、Hibernate Validator 8.0.2.Final、Jackson 2.18.4。实验通过真实 HTTP 观察状态码、JSON 错误结构和异常类型;完整 Java 入口为下载工程 MvcLab 28。

输入形成、校验与业务调用的失败位置

对象存在之前不能校验对象约束

/valid 接受 @Valid @RequestBody Order。首先由消息转换器读取正文。损坏的 JSON 触发 HttpMessageNotReadableException,程序返回 400 与 {"code":"HttpMessageNotReadableException"}。因为对象尚未形成,Order.name 的 NotBlank 和 quantity 的 Min 不是这次失败的原因。

同样,RequestParam 的类型转换发生在控制器调用前。/number?n=bad 返回 MethodArgumentTypeMismatchException 对应的错误码。给控制器方法体增加 try/catch 无法捕获这个错误,因为该方法根本没有开始运行。可观察的位置应是参数解析和 HandlerExceptionResolver,而不是业务 Service。

RequestResponseBodyMethodProcessor 的固定源码明确把读取消息放在创建 binder 和 validateIfApplicable 之前。这个顺序给出了排障方法:先检查转换器是否成功产生目标对象,再检查 binder 是否应用校验,最后检查方法级约束。把所有输入失败都归因于“校验器没生效”,会在错误的层次修改配置。RequestBody 解析与校验顺序

单个对象的 Bean Validation

合法 JSON {"name":"","quantity":0} 可以反序列化成 Order,但 name 违反 NotBlank,quantity 违反 Min(1)。本例参数标注 Valid,类路径存在验证器实现,MVC 将 LocalValidatorFactoryBean 接入绑定器。没有紧邻的 BindingResult 接收这些错误,最终抛出 MethodArgumentNotValidException。

这里有三个不同条件:约束注解写在 DTO 上,校验入口标注在方法参数上,运行时存在能够执行约束的 provider。只引入注解 API 只能让源码编译,不自动提供约束执行能力。本模块显式依赖 Hibernate Validator,启动日志记录其版本,避免把其他依赖偶然带入的实现当作前提。

随后发送 {"name":"desk","quantity":1} 得到 200,证明该配置下合法对象确实进入了控制器,而不是所有请求都被错误处理器短路。正例与反例必须配对;一个总返回 400 的假校验器也能通过仅包含坏请求的测试。

若采用 BindingResult,由控制器自行检查错误并决定响应,异常路径会改变。错误存在与异常抛出并不等价。第 27 篇的表单反例已经展示:BindingResult 中有转换错误,控制器仍能返回 200。声明该参数意味着应用接管相应错误的处理责任,不是让错误自动消失。

MVC 方法校验与代理校验

/minimum 声明 @RequestParam @Min(1) int n。这里约束直接属于方法参数,Spring MVC 的内建方法校验会检查已经解析好的实参数组。?n=0 成功转换为整数,但随后失败,HTTP 为 400,异常类型为 HandlerMethodValidationException。HandlerMethodValidator

实验控制器没有类级 Validated,也没有给它安装 MethodValidationPostProcessor 代理。因此这条结果证明的是 MVC 内建路径,不能写成“AOP 拦截器总会抛出这个异常”。类级 Validated 配合代理方法校验是另一种配置,会改变校验入口及异常类型;升级时应检查两者是否混用。MVC 校验文档

Valid 本身表示级联校验,不是 NotNull、Min 这样的约束。直接约束、嵌套对象约束与 BindingResult 的组合决定由哪条路径报告错误。HandlerMethodValidator 还会尝试把部分对象错误关联到已有 BindingResult;无法由对应结果参数承接的错误才继续作为方法校验异常传播。把一张“注解到异常”的静态对照表当成所有签名组合的规则,会遗漏这些分支。

本例只验证输入参数约束。返回值约束失败意味着服务器生成了不符合自身契约的结果,不应机械转换成客户端 400。若在统一处理器中捕获 HandlerMethodValidationException,需要区分输入与返回值验证。本实验的 handler 仅覆盖实际声明的输入约束,没有声明返回值约束;通用生产处理器应保留异常提供的状态语义或显式区分结果来源。

异常解析器怎样决定响应

DispatcherServlet 捕获处理器执行阶段的异常后,按配置的 HandlerExceptionResolver 尝试解析。ExceptionHandlerExceptionResolver 负责选择控制器或 ControllerAdvice 中的 ExceptionHandler。找到合适方法后,这个异常处理方法本身也会经历参数解析与返回值处理,最终可以写出 ResponseEntity 或模型视图。

“统一错误结构”应统一字段契约,不应把所有 HTTP 状态都压成 200 或 400。实验采用最小的 code JSON 字段,输入错误返回 400,订单冲突返回 409。/business 直接抛出 Conflict,异常处理方法返回 {"code":"ORDER_CONFLICT"};客户端实际收到 409,而不是从正文再猜请求是否失败。

实验为便于定位,部分 code 直接使用 Java 异常简单类名。生产接口通常应映射成稳定的协议代码,以免框架升级改变公开契约;堆栈、Java 类型与未经筛选的拒绝值可留在内部诊断中。把全部异常消息原样回传,还可能泄露路径、SQL、配置细节或敏感输入。

错误处理方法也可能失败。例如 JSON 序列化失败后,再返回一个同样无法序列化的错误对象,并不能提供可靠兜底。第 29 篇还会出现响应已经提交的情况,此时再构造 ResponseEntity 也无法收回已经发出的状态和字节。异常解析不是对 HTTP 写入历史的撤销操作。

ControllerAdvice 的覆盖范围

/filter-error 的 Filter 在调用 DispatcherServlet 前执行 sendError。它不会先进入某个控制器,再被该控制器的 ControllerAdvice 捕获。Tomcat 按已配置错误页发起 ERROR 分派,由另一个错误页控制器输出 {"dispatch":"ERROR"},实际状态仍为 500。

这条反例说明,上游 Filter 错误和控制器错误需要分别设计处理边界。跨域、安全过滤链、容器请求解析以及代理层可能各自产生响应。统一响应设计必须知道每一层拥有的失败类型;只写一个捕获 Exception 的 Advice,不足以覆盖所有外部可见错误。

本章实际错误矩阵如下。

场景 HTTP code 或观察
损坏 JSON 400 HttpMessageNotReadableException
查询参数无法转 int 400 MethodArgumentTypeMismatchException
DTO 字段约束失败 400 MethodArgumentNotValidException
方法参数 Min 失败 400 HandlerMethodValidationException
合法 DTO 200 控制器返回对象
订单冲突 409 ORDER_CONFLICT
Filter sendError 500 容器 ERROR 分派

这些观察足以证明当前端点与配置的阶段差别,不包含所有异常处理器优先级、嵌套 cause 匹配或国际化规则。对已有应用迁移时,应把其真实错误处理器加入实验,重新验证,而不是只复制这里的默认推导。

复现与练习

下载工程包含 DTO、依赖、控制器、Advice 和真实 HTTP 客户端。执行:

1
2
./mvnw -f mvc-lab/pom.xml -q compile exec:java \
-Dexec.mainClass=blog.spring.mvc.MvcLab -Dexec.args=28

本章 11 条断言与四类资源终态记录位于 evidence/28/local-20261002/。日志保存每个请求的状态、媒体类型与正文;异常类型仅来自实际响应,不根据接口注解推测填入。

推导题:把 /minimum?n=0 改为 /minimum?n=bad,是否仍由 Min 产生 HandlerMethodValidationException?不会。字符串不能先转换成 int,方法约束还没有合法实参可检验,应在转换阶段失败。

改动练习:为 /valid 增加紧邻的 BindingResult,显式在有错误时返回 422 与稳定业务错误码,成功时保留 200。重跑合法、损坏 JSON、合法 JSON 但约束失败三种请求。预期损坏 JSON 仍失败在消息读取阶段,不能因为添加 BindingResult 就变成一个包含字段错误的 Order。

完整工程下载见 深入 Spring(00):从手动组装到可验证的容器实验。六章共用独立的 mvc-lab 模块,单章参数决定执行场景;正文中的改动练习未计入已通过断言。

参考资料