Spring MVC 参数绑定与异常处理:Converter、Validator、ControllerAdvice(094)

Spring与Spring Boot约 18 分钟阅读9,844 字符
知识库文档 95 / 251

引言:从"字符串"到"业务对象",中间发生了什么

上一篇讲清楚了请求怎么被路由到方法、方法怎么被调用。但方法被调用之前还有一步经常被忽略:HTTP 请求里的一切都是字符串(查询参数、路径变量、表单字段)或字节流(请求体),而 Controller 方法的参数往往是 Long、LocalDate、自定义的 UserDTO。这中间的转换、校验、失败后的报错,是三件独立但又紧密关联的事:类型转换(Converter/ConversionService)、数据校验(Validator)、异常统一处理(ControllerAdvice/ExceptionHandler)。这三者任何一个环节配置不当,都会表现为"接口莫名其妙报错""校验规则不生效""异常返回的格式不统一"这类问题。这篇文章把这条链路从头到尾捋一遍。

一、参数绑定的核心引擎:DataBinder 与 ConversionService

文章配图
文章配图

WebDataBinder:一次绑定过程的容器

当参数不是简单类型(比如是一个自定义对象,需要把多个请求参数字段绑定到对象的多个属性上)时,Spring MVC 会创建一个 WebDataBinder 来完成这次绑定。WebDataBinder 内部委托 BeanWrapper 完成属性的读写,同时持有一个 ConversionService(或者更早期的 PropertyEditor 体系)负责把字符串转换成目标属性的类型。

java
// 简化表示:DataBinder 绑定的核心步骤
MutablePropertyValues pvs = new MutablePropertyValues(request.getParameterMap());
BeanWrapper bw = new BeanWrapperImpl(target);
bw.setConversionService(conversionService);
bw.setPropertyValues(pvs); // 绑定过程中逐个属性做类型转换

如果某个属性转换失败(比如把 "abc" 转成 Integer),这次绑定不会立刻抛异常中断整个请求,而是把这个字段的错误记录进 BindingResult,绑定过程继续处理剩下的属性——这也是为什么"表单里 10 个字段,1 个格式错了,其余 9 个字段的绑定结果依然可用"的原因:绑定阶段的失败是收集式的,不是立即抛出式的。

ConversionService:现代 Spring 类型转换的统一入口

早期 Spring MVC 主要依赖 JDK 自带的 PropertyEditor(比如 java.beans.PropertyEditorSupport),但 PropertyEditor 是有状态、非线程安全的,每次绑定都要创建新实例,用起来比较别扭。现代 Spring 统一用 ConversionService(核心实现是 GenericConversionService,Spring MVC 里具体是 DefaultFormattingConversionService)来做类型转换,底层由三类可插拔组件构成:

组件 职责 典型场景
Converter<S, T> 一对一类型转换,S 转 T String 转自定义枚举
ConverterFactory<S, R> 一对一族转换,S 转任意 R 的子类型 String 转任意 Enum 子类
GenericConverter 支持多对多、携带泛型/注解信息的复杂转换 集合、数组之间的转换
Formatter<T> 既能转换又能格式化(双向:解析和打印) 日期、货币等有本地化格式的类型

注册自定义 Converter 的标准方式是实现 WebMvcConfigurer.addFormatters():

java
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new StringToOrderStatusConverter());
    }
}

一个容易被忽视的细节:Converter 的匹配是按声明的最具体类型优先,如果同时注册了 Converter<String, OrderStatus> 和一个更通用的转换路径(比如通过 Enum 的 ConverterFactory),Spring 会优先选择匹配度更高(更具体)的那一个。这也是为什么自定义了枚举转换器却发现"不生效",往往是因为默认的枚举转换器(基于 Enum.valueOf)本身已经能处理,自定义的转换器反而被判定为不如默认路径匹配,实际并没有生效——排查这类问题的关键,是搞清楚当前生效的到底是哪一个转换器,而不是想当然认为自己注册的那个一定会被使用。

文章配图
文章配图

绑定失败的落点:从 TypeMismatchException 到 BindException/MethodArgumentNotValidException

  • 如果绑定发生在简单类型参数上(比如 @RequestParam Integer page),转换失败会直接产生 MethodArgumentTypeMismatchException,这个异常在参数解析阶段就抛出,方法体完全不会执行。
  • 如果绑定发生在复杂对象参数上(比如 @ModelAttribute UserForm form 或 @RequestBody 配合 @Valid),转换失败会被收集进这个对象关联的 BindingResult,不会立刻抛异常——除非方法签名里没有紧跟着一个**BindingResult**/**Errors**参数,这种情况下 Spring 才会在参数解析阶段主动抛出 BindException(或校验场景下的 MethodArgumentNotValidException),因为没有地方接收这个错误信息,继续执行方法体是不安全的。

这是理解"为什么加了 BindingResult 参数校验就不生效了"这类问题的关键:只要方法签名里显式声明了**BindingResult**,Spring 就认为你打算自己处理校验错误,不会再帮你抛异常中断请求——这是设计上刻意的"控制权转移",不是 bug。

二、常见参数来源与背后的 ArgumentResolver

上一篇提到 HandlerMethodArgumentResolver 是一组"遍历找第一个支持的解析器"的策略集合,这里具体展开几种最常用的注解各自对应哪个解析器、绑定路径有什么差异:

注解 解析器 数据来源 绑定方式
@RequestParam RequestParamMethodArgumentResolver 查询参数/表单字段 单值直接转换,不经过完整 DataBinder
@PathVariable PathVariableMethodArgumentResolver URI 模板变量 单值直接转换
@RequestHeader RequestHeaderMethodArgumentResolver 请求头 单值直接转换
@RequestBody RequestResponseBodyMethodProcessor 请求体 委托 HttpMessageConverter 反序列化,不走 ConversionService
@ModelAttribute(或省略注解的复杂对象) ServletModelAttributeMethodProcessor 查询参数+表单字段的组合 完整走 WebDataBinder 逐属性绑定

这张表最重要的一行是 @RequestBody:请求体的反序列化完全是 HttpMessageConverter(比如 MappingJackson2HttpMessageConverter)的职责,走的是 JSON 库自己的类型转换规则,跟 ConversionService/Converter 体系是两套完全独立的转换机制——这也是为什么"我注册了一个 Converter<String, LocalDate>,@RequestParam 生效了,但 @RequestBody 里的同名字段还是转换失败",因为一个走的是 Spring 的 ConversionService,另一个走的是 Jackson 自己的 ObjectMapper(要配置 Jackson 的 JavaTimeModule 或自定义 JsonDeserializer 才能生效)。

文章配图
文章配图

三、Validator 体系:JSR-380 Bean Validation 的集成方式

文章配图
文章配图

@Valid 与 @Validated 的核心差异:分组校验

@Valid 是 JSR-380(Bean Validation 2.0)标准注解,@Validated 是 Spring 自己的注解,两者在最基本的用法上效果相同(触发校验),但 @Validated 支持分组校验(groups 属性),可以让同一个 DTO 在不同场景下应用不同的校验规则子集:

java
public interface Create {}
public interface Update {}

public class UserForm {
    @Null(groups = Create.class)
    @NotNull(groups = Update.class)
    private Long id;

    @NotBlank
    private String name;
}

@PostMapping
public void create(@Validated(Create.class) UserForm form) { ... }

@PutMapping
public void update(@Validated(Update.class) UserForm form) { ... }

@Valid 做不到这一点,它永远校验所有没有指定分组(属于默认分组)的约束。工程上一个常见误区是"分组校验规则写好了却发现完全没生效"——原因通常是 Controller 上用的是 @Valid 而不是 @Validated,@Valid 根本不识别分组参数。

两种触发路径:MethodArgumentNotValidException vs ConstraintViolationException

这是一个经常被面试问到、但容易讲错的知识点:同样是校验失败,异常类型可能完全不同,取决于校验发生在哪一层。

  • Controller 方法参数上的**@Valid**/**@Validated**:由 ServletModelAttributeMethodProcessor(或 @RequestBody 场景下的 RequestResponseBodyMethodProcessor)在参数解析阶段主动调用校验,失败后包装成 MethodArgumentNotValidException(如果紧跟 BindingResult 则不抛异常,同上一节的逻辑)。这个异常本质上是 Spring MVC 参数解析流程的一部分,走的是 HandlerExceptionResolver 链条。
  • Service 方法参数/返回值上的**@Validated**(配合类上的**@Validated**):这是通过 AOP 代理实现的,MethodValidationPostProcessor 会给标注了 @Validated 的类生成一个校验切面(MethodValidationInterceptor),拦截方法调用,用标准的 Bean Validation Validator.validate() 校验参数,失败后抛出的是 JSR-380 标准的 ConstraintViolationException,跟 Spring MVC 完全无关。

也就是说,如果你的应用同时在 Controller 参数上和 Service 方法上都做了校验,出问题时抓到的异常类型不一样,全局异常处理器必须同时处理这两种异常,只处理其中一种会导致另一种校验失败场景返回一个没有被正确格式化的、暴露内部堆栈信息的默认错误响应。

文章配图
文章配图

自定义 Validator:脱离注解的编程式校验扩展点

如果约束逻辑复杂到用注解表达不清楚(比如"如果字段 A 是某个值,字段 B 必须非空"这种跨字段联动校验),可以实现 Spring 自己的 org.springframework.validation.Validator 接口,通过 @InitBinder 注册进 WebDataBinder:

java
@InitBinder
public void initBinder(WebDataBinder binder) {
    binder.addValidators(new UserFormValidator());
}

public class UserFormValidator implements Validator {
    public boolean supports(Class<?> clazz) { return UserForm.class.isAssignableFrom(clazz); }
    public void validate(Object target, Errors errors) {
        UserForm form = (UserForm) target;
        if ("VIP".equals(form.getType()) && form.getVipLevel() == null) {
            errors.rejectValue("vipLevel", "required.forVip", "VIP 用户必须填写等级");
        }
    }
}

这条路径跟 JSR-380 注解校验是并行叠加的关系:@InitBinder 注册的 Validator 会在 WebDataBinder.validate() 时一并执行,错误同样汇总进 BindingResult,不需要额外的异常处理分支。

文章配图
文章配图

四、异常处理机制全景:谁来接住这些异常

优先级顺序:从局部到全局

一次异常处理,实际的匹配顺序是:

  1. Controller 内部自己的**@ExceptionHandler**:只处理本 Controller 类抛出的异常,优先级最高。
  2. **@ControllerAdvice**(或**@RestControllerAdvice**)里的**@ExceptionHandler**:全局兜底,Controller 内部没有匹配的处理器时才会走到这里。
  3. 框架默认的**HandlerExceptionResolver**(ResponseStatusExceptionResolver、DefaultHandlerExceptionResolver):处理 @ResponseStatus 标注的异常和一些内置的 Spring MVC 异常(如 HttpRequestMethodNotSupportedException),如果前两层都没接住,走到这里。
  4. 容器/Servlet 层的默认错误页面:连 HandlerExceptionResolver 都没处理成功(比如抛出的是一个 Error 而不是 Exception,或者处理过程中又抛出了新异常),最终交给 Servlet 容器的默认错误处理机制。

这四层的底层实现其实是同一套机制:Spring MVC 把所有 HandlerExceptionResolver 实现(包括 @ExceptionHandler 的底层实现 ExceptionHandlerExceptionResolver)都注册进一个 HandlerExceptionResolverComposite,按 Order 顺序依次询问,第一个返回非空 ModelAndView 的生效。Controller 内部 @ExceptionHandler 优先,是因为 ExceptionHandlerExceptionResolver 内部查找处理方法时,会先在异常抛出方所属的 Controller 类里找,找不到再去全局 @ControllerAdvice 里找——这个查找顺序是写在 ExceptionHandlerExceptionResolver 内部逻辑里的,不是靠 @Order 控制的。

文章配图
文章配图

异常类型匹配算法:最近继承距离优先

一个 @ExceptionHandler 方法可以同时声明处理多个异常类型,一个类里也可以有多个 @ExceptionHandler 方法分别处理不同异常。当抛出的异常有多层继承关系(比如 BusinessException extends RuntimeException,同时存在处理 BusinessException 和处理 RuntimeException 的两个方法)时,Spring 会计算抛出异常的实际类型到每个 @ExceptionHandler 声明类型之间的继承距离,选择距离最近(最具体)的那一个。

java
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<?> handleRuntime(RuntimeException e) { ... }

@ExceptionHandler(BusinessException.class)
public ResponseEntity<?> handleBusiness(BusinessException e) { ... } // 抛出 BusinessException 时命中这个

这也解释了一类常见 bug:新增了一个更具体的自定义异常类,期望被更具体的处理方法接住,结果发现走到了更宽泛的处理方法——多半是新异常类没有继承自预期的那个基类,导致继承距离计算出了跟设计预期不一样的结果。

@ControllerAdvice 的作用范围限定

默认情况下,@ControllerAdvice 是全局生效的,对所有 Controller 都适用。但可以通过 basePackages、assignableTypes、annotations 属性收窄作用范围:

java
@ControllerAdvice(basePackages = "com.example.admin")
public class AdminExceptionHandler { ... }

@ControllerAdvice(assignableTypes = {OrderController.class, PaymentController.class})
public class OrderPaymentExceptionHandler { ... }

多个 @ControllerAdvice 同时存在、作用范围有重叠时,同样受 @Order/Ordered 控制优先级,逻辑跟上一篇讲的 HandlerExceptionResolver 排序完全一致——Spring MVC 里几乎所有"多个候选者选一个"的场景,都是同一套"排序 + 第一个命中"的设计思路,理解了一处,其余的地方基本可以直接迁移过去。

文章配图
文章配图

ResponseEntityExceptionHandler:处理 Spring MVC 内置异常的现成基类

如果只是想在默认异常处理的基础上定制返回格式,不需要从零实现,ResponseEntityExceptionHandler 已经预置了对 MethodArgumentNotValidException、HttpMessageNotReadableException、HttpRequestMethodNotSupportedException 等一批 Spring MVC 常见异常的处理骨架,只需要继承它并重写具体的 handleXxx 方法,返回自己期望的响应体格式即可,避免重新声明一遍所有异常类型的 @ExceptionHandler。

五、统一响应结构设计:把校验错误转成规范的错误码

文章配图
文章配图

从 BindingResult/Errors 提取字段级错误信息

MethodArgumentNotValidException 内部持有一个 BindingResult,可以提取出所有 FieldError,转换成前端友好的结构:

java
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
    List<FieldErrorItem> errors = ex.getBindingResult().getFieldErrors().stream()
        .map(fe -> new FieldErrorItem(fe.getField(), fe.getDefaultMessage()))
        .collect(Collectors.toList());
    return ResponseEntity.badRequest().body(new ErrorResponse("VALIDATION_ERROR", "参数校验失败", errors));
}

文章配图
文章配图

业务异常体系设计:区分"预期内"和"预期外"

工程上通常把异常分成两大类分别处理:

  • 业务异常(比如 BusinessException、ResourceNotFoundException):预期内的、业务规则导致的失败,返回结构化的错误码+消息,HTTP 状态码通常是 4xx,不需要打印堆栈(打印了也没有排查价值,纯粹是预期内的业务分支)。
  • 系统异常(未捕获的 RuntimeException、Error):预期外的、代码缺陷或依赖故障导致的失败,返回统一的"系统繁忙"类提示(不暴露内部细节),HTTP 状态码是 5xx,必须记录完整堆栈用于排查。

这个划分直接决定了全局异常处理器里通常至少要有两个兜底级别:一个专门处理自定义的业务异常基类,一个用 @ExceptionHandler(Exception.class) 兜住一切没被前面处理器接住的异常,避免任何异常最终暴露出框架默认的、包含堆栈信息的错误页面。

与请求流程的对照:异常在哪个环节被谁捕获

结合上一篇的 doDispatch() 主干逻辑:Controller 方法内部抛出的异常,会在 ha.handle() 调用处被 try-catch 捕获,进入 processHandlerException(),依次询问 HandlerExceptionResolverComposite 里的每个解析器(ExceptionHandlerExceptionResolver → ResponseStatusExceptionResolver → DefaultHandlerExceptionResolver),第一个返回非空结果的生效,之后仍然会走 applyPostHandle()、processDispatchResult()、triggerAfterCompletion() 这条正常收尾路径——异常处理不是"跳出"正常流程,而是正常流程里专门为异常场景准备的一个分支,收尾动作(拦截器 afterCompletion、资源清理)依然会执行。

六、小结:从字符串到统一错误响应的完整链路

code
请求参数(字符串/字节流)
    → ArgumentResolver 按参数类型路由
        → 简单类型:ConversionService(Converter/Formatter)直接转换,失败抛 MethodArgumentTypeMismatchException
        → 复杂对象:WebDataBinder 逐属性绑定 + 校验,失败收集进 BindingResult
        → 请求体:HttpMessageConverter 反序列化(独立于 ConversionService)
    → 校验阶段
        → Controller 参数 @Valid/@Validated:失败抛 MethodArgumentNotValidException(或写入紧跟的 BindingResult)
        → Service 方法 @Validated(AOP 代理):失败抛 ConstraintViolationException
    → 异常处理
        → Controller 内部 @ExceptionHandler(优先)
        → 全局 @ControllerAdvice 的 @ExceptionHandler(按继承距离匹配最具体类型)
        → 框架默认 HandlerExceptionResolver
    → 统一错误响应结构(错误码 + 消息 + 字段级详情)

这条链路的每一步都对应一类具体的"配置了却不生效"问题:转换器不生效多半是匹配优先级问题;分组校验不生效多半是 @Valid 和 @Validated 用混了;异常处理器不生效多半是继承距离匹配到了别的方法,或者异常类型来自 AOP 校验而不是参数解析校验。把这条链路在脑子里过一遍,比对着报错信息瞎猜配置项要高效得多。

(以上正文阅读时长约 40-50 分钟,以下问答部分不计入正文阅读时长)

文末答疑:面试官会怎么问

问 1:你说绑定失败是"收集式"的,那什么情况下它会变成"立即抛出式"?这个设计是为了解决什么问题?

答:区分点在方法签名里是否紧跟一个 BindingResult/Errors 参数。如果没有,Spring 认为没有人会去读取绑定错误信息,继续往下执行方法体是不安全的(业务代码会拿着一个字段值为 null 或默认值的对象去做逻辑判断,产生更隐蔽的错误),所以主动抛出 BindException/MethodArgumentNotValidException 中断请求。如果显式声明了 BindingResult,说明开发者打算自己读取并处理这些错误,Spring 就把控制权交还给开发者,不再代为抛异常。这本质上是一种"是否有人接管错误处理"的运行时判断,而不是根据字段本身的严重程度决定的。

问 2:Service 层用**@Validated**校验参数,为什么抓**MethodArgumentNotValidException**抓不到?你的全局异常处理器要怎么改才能覆盖这种场景?

答:因为 Service 层的 @Validated 走的不是 Spring MVC 的参数解析流程,而是 MethodValidationPostProcessor 生成的 AOP 代理拦截方法调用后做的标准 JSR-380 校验,失败抛出的是 ConstraintViolationException,这个异常跟 MethodArgumentNotValidException 没有继承关系,全局异常处理器必须额外增加一个 @ExceptionHandler(ConstraintViolationException.class) 才能覆盖到,否则这类校验失败会直接冒泡成未处理异常,暴露默认的 500 错误页面。

问 3:如果我同时定义了处理**Exception.class**和处理某个具体业务异常的两个**@ExceptionHandler**方法,Spring 怎么决定用哪个?如果这两个方法在不同的类里(一个在 Controller 内部,一个在全局**ControllerAdvice**里)呢?

答:如果两个方法在同一个类里,Spring 按继承距离选择最具体的匹配,业务异常的方法会被选中而不是 Exception.class 兜底方法。如果分别在 Controller 内部和全局 ControllerAdvice 里,优先级规则会先起作用:只要 Controller 内部存在能处理当前异常的方法(不管是精确匹配还是通过 Exception.class 兜底),就不会再去查找全局 ControllerAdvice,即便全局里有一个继承距离更近的处理方法。这也是为什么"Controller 内部随手写了一个 catch-all 的 @ExceptionHandler(Exception.class)"会让全局精心设计的分类处理器全部失效的原因——局部优先级本身不比较继承距离,只要局部有得用就不去看全局。

问 4:**@RequestBody**的字段类型转换失败和**@RequestParam**的字段类型转换失败,从异常类型到修复方式分别有什么不同?

答:@RequestParam(或其他走 ConversionService 的简单类型参数)转换失败抛出的是 MethodArgumentTypeMismatchException,修复方式是注册或修正对应的 Converter/Formatter。@RequestBody 的转换失败发生在 HttpMessageConverter 反序列化阶段(比如 Jackson 解析 JSON 时类型不匹配),抛出的是 HttpMessageNotReadableException,跟 ConversionService 完全无关,修复方式是配置对应的 Jackson 模块(比如日期用 JavaTimeModule)或自定义 JsonDeserializer,注册 Spring 的 Converter 对这种场景完全不起作用——这也是实际排查中最容易踩的坑:把两套转换机制当成了一套来配置。