Skip to content

SpringMVC 参数绑定、@Valid 校验与文件上传

提出问题

接口参数怎么进 Controller 的,很多人用了三年 Spring 也说不清。@RequestParam@RequestBody 虽然眼熟,但要问它们各自走了哪个解析器、@Valid 校验失败后异常被谁捕获、文件上传的 MultipartFile 上限在哪配——大部分人只能答个大概。

生产上常见的坑:@RequestParam 中文变乱码,前端传的 JSON 里日期格式解析失败,Long 转 JSON 给前端丢精度,上传文件超过 1MB 直接 500 不提示。这些问题不是 Spring 的 bug,是对参数绑定机制的理解不够。

理解参数绑定的核心是 HandlerMethodArgumentResolver 接口——SpringMVC 用一系列解析器把 HTTP 请求内容转换成方法参数。每个参数类型和注解组合对应一个解析器,走完解析器列表找到匹配的就执行,找不到就报 400。

分析问题

参数绑定与 HandlerMethodArgumentResolver

先看 @RequestParam@RequestBody 的区别。@RequestParamRequestParamMethodArgumentResolver,从 HttpServletRequest.getParameterMap() 里取键值对,适合 GET 请求的 query string 或 POST 表单的 application/x-www-form-urlencoded 数据。@RequestBodyRequestResponseBodyMethodProcessor,它依赖 HttpMessageConverter 把请求体(InputStream)反序列化成 Java 对象。

java
@RestController
public class UserController {

    // 走 RequestParamMethodArgumentResolver
    @GetMapping("/user")
    public User getUser(@RequestParam Long id,
                        @RequestParam(defaultValue = "zh") String lang) {
        return userService.findById(id);
    }

    // 走 RequestResponseBodyMethodProcessor + HttpMessageConverter
    @PostMapping("/user")
    public User createUser(@RequestBody @Valid UserCreateRequest req) {
        return userService.create(req);
    }
}

常见坑:@RequestParam 遇到中文时,Tomcat 的默认解码是 ISO-8859-1,只要 URL 里带了中文就会乱码。解法是在 server.tomcat.uri-encoding=UTF-8(Spring Boot 默认已配,但如果你手动配了 Connector 就容易被覆盖)。或者前端用 encodeURIComponent 编码后再传。

@RequestBody 的反序列化依赖 Jackson 的 ObjectMapper。三个高频坑:

  1. 日期格式:前端传 "2026-09-01 10:00:00",Jackson 默认不认识不带 T 的格式,报 InvalidFormatException。需要在 application.ymlspring.jackson.date-format=yyyy-MM-dd HH:mm:ss 或直接在字段上加 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")

  2. Long 精度丢失:Java 的 Long 有 19 位,JS 的 Number 安全精度只有 53 位(约 16 位十进制)。前端拿到 1743158400000 这种 13 位时间戳还好,但 ID 如果超过 16 位,前端会丢失精度。配 @JsonSerialize(using = ToStringSerializer.class) 把 Long 转成字符串发出去。

  3. 泛型擦除@RequestBody List<Long> ids 没问题,但如果是 @RequestBody Result<List<Long>> data,Jackson 在反序列化时不知道 List<> 里的泛型是 Long,会反成 List<LinkedHashMap>。需要 TypeReference 或自定义 HttpMessageConverter

Bean Validation:@Valid vs @Validated

@Valid 是 JSR-380 标准注解,@Validated 是 Spring 的增强版。核心区别在分组校验

java
@Data
public class UserCreateRequest {

    @NotBlank(message = "用户名不能为空")
    private String username;

    @Email
    private String email;

    @NotNull(groups = AdminCreate.class)
    private String role;

    @Valid  // 嵌套校验
    private Address address;

    public interface AdminCreate {}
}

@RestController
public class UserController {

    @PostMapping("/user")
    public User create(@RequestBody @Validated UserCreateRequest req) {
        return userService.create(req);
    }

    @PostMapping("/admin/user")
    public User createAdmin(@RequestBody @Validated(UserCreateRequest.AdminCreate.class)
                            UserCreateRequest req) {
        return userService.createAsAdmin(req);
    }
}

@Valid 不支持分组,@Validated 支持。但 @Validated 不能用在字段上做嵌套校验的触发——嵌套校验仍然是 @Valid 的职责。所以常见写法是 @Validated 在方法参数上激活分组,实体字段上用 @Valid 触发嵌套校验。

校验失败后,框架会在进入 Controller 方法体之前抛出异常:@RequestBody 校验失败走 MethodArgumentNotValidException(Spring 封装),@RequestParam 校验失败走 ConstraintViolationException。全局异常处理用 @ControllerAdvice 捕获这两类:

java
@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidation(
            MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors()
            .forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
        return ResponseEntity.badRequest().body(errors);
    }

    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<Map<String, String>> handleValidation(
            ConstraintViolationException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getConstraintViolations()
            .forEach(v -> errors.put(v.getPropertyPath().toString(), v.getMessage()));
        return ResponseEntity.badRequest().body(errors);
    }
}

文件上传

文件上传走 MultipartResolver,Spring Boot 自动配置了 StandardServletMultipartResolver。参数用 MultipartFile 声明:

java
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) {
    if (file.isEmpty()) {
        return "请选择文件";
    }
    // 存到本地或 OSS
    String originalFilename = file.getOriginalFilename();
    String contentType = file.getContentType();
    long size = file.getSize();
    // 务必对文件名做防注入处理,避免 ../ 路径穿越
    String safeName = UUID.randomUUID() + "_" + 
        originalFilename.replaceAll("[^a-zA-Z0-9.\\-_]", "_");
    file.transferTo(new File("/data/upload/" + safeName));
    return "上传成功";
}

配置项在 application.yml

yaml
spring:
  servlet:
    multipart:
      max-file-size: 10MB       # 单文件上限
      max-request-size: 50MB    # 一次请求总大小
      location: /tmp/upload     # 临时目录,默认 /tmp/servlet-upload-xxx

四个坑:

  1. 上传超时:大文件配合 nginx 时,nginx 默认 proxy_read_timeout 60s,上传慢会被 nginx 切断。需要调大超时时间。

  2. 临时目录被清理:默认 /tmp/servlet-upload-xxx 的临时目录在 Linux 上可能被 systemd 的 tmpfiles.d 清理掉。显式指定 location 避免生产环境出这种偶发 500。

  3. 文件名注入originalFilename 可能带 ../ 路径穿越,需要做安全过滤——只保留字母数字和点、下划线、中划线,用 UUID 重命名更安全。

  4. 内存溢出MultipartFile 默认只在内存 < 256KB 时存内存,超过会写磁盘临时文件,但极端情况下并发上传大文件还是可能撑爆内存,配合连接池限流。

总结

参数绑定这条链从请求进来到业务方法拿到参数,涉及三层:HandlerMethodArgumentResolver 选型决定参数来源 → HttpMessageConverter 处理序列化 → Bean Validation 做校验拦截。三层里任何一层没配好,前端就收到 400 或 500。

面试一个常见追问链条:讲一下 @RequestBody@RequestParam 区别 → 怎么配校验 → 校验失败返回什么异常 → 怎么自定义错误消息 → 文件上传超时怎么排查。按这个链过一遍,基本把这整块说透了。

参考:Spring Framework 源码 org.springframework.web.method.support.HandlerMethodArgumentResolver / org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor / Spring Boot 官方文档 Core Features 章节 Validation 和 File Upload 部分。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1