Java 基础体系 · 第 90/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Spring 参数校验:Bean Validation、分组、嵌套、错误模型和国际化

参数校验解决的问题不是“把几个注解放到 DTO 上”,而是把外部输入转换成一个可验证的对象,并在约束不满足时生成可定位、可国际化、可稳定传输的错误结果。

在 Spring 应用中,一次典型的请求校验涉及以下组件:

  1. Bean Validation 规范:定义 @NotNull@Size@Email、分组、级联校验和消息插值等规则。
  2. Bean Validation Provider:真正执行规则的实现,Spring Boot 项目中通常是 Hibernate Validator。
  3. Spring 参数解析器:把 HTTP 请求体、路径变量、查询参数转换为 Java 参数,并决定何时触发校验。
  4. Spring 错误模型:把校验失败表示为 FieldErrorObjectErrorMethodArgumentNotValidException 等对象。
  5. 消息解析和国际化组件:根据错误码、参数和当前 Locale 生成面向用户的文本。

这些组件的职责不同。Bean Validation 负责判断约束是否成立,Spring 负责触发校验并承接错误,消息系统负责把错误转换为某种语言的文本。


一、Bean Validation 到底校验什么

1. 约束、验证器和验证结果

Bean Validation 中的约束是一个声明式规则。例如:

@NotBlank
private String username;

它表达的不是“调用某个 Spring 方法检查字符串”,而是一个约束:

usernamenulltrim(username)""\text{username} \neq null \land \text{trim(username)} \neq ""

不同约束对 null 的处理方式可能不同。例如:

  • @NotNull 检查引用不为 null
  • @NotBlank 通常同时要求字符串非 null、去除空白后非空;
  • @Size 检查长度或集合大小,但通常不负责阻止 null
  • @Email 通常也不把 null 当作格式错误。

因此,下列声明的语义不同:

@NotNull
@Size(min = 8, max = 64)
private String password;

它表示:

passwordnull8password64password \neq null \land 8 \leq |password| \leq 64

而只有:

@Size(min = 8, max = 64)
private String password;

时,password == null 通常不会因为 @Size 失败。如果业务要求字段必须存在,就需要显式增加 @NotNull@NotBlank

验证过程可以抽象为:

Java 对象
   │
   ├─ 读取约束元数据
   ├─ 按指定分组选择约束
   ├─ 执行字段约束
   ├─ 执行嵌套对象的级联约束
   └─ 生成 ConstraintViolation 集合

每个 ConstraintViolation 至少包含:

  • 违反的消息模板;
  • 实际消息;
  • 属性路径;
  • 无效值;
  • 约束注解;
  • 根对象和叶子对象。

例如:

对象:RegisterRequest
路径:profile.addresses[0].postalCode
模板:{postal.invalid}
无效值:abc

Spring MVC 会在这个结果之上构造自己的错误对象。


2. 规范与实现的边界

jakarta.validation 是规范 API,常见的具体实现是 Hibernate Validator。Spring Boot 通常通过:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

引入 Spring 的校验集成和 Hibernate Validator。

代码应导入 Jakarta 包,而不是旧的 javax.validation 包:

import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

Spring Framework 6 和 Spring Boot 3 迁移到 Jakarta 命名空间。使用 Java 25 LTS 时,仍需根据所选 Spring Boot 版本的支持范围选择对应版本;Java 版本本身不会改变 Bean Validation 的约束语义。


二、一个可运行的 Spring MVC 校验示例

下面的示例使用 Spring Boot、Spring MVC 和 Java 记录类。假设项目已经通过 spring-boot-starter-webspring-boot-starter-validation 引入依赖。

1. 请求对象

package com.example.demo.user;

import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

import java.util.List;

public record RegisterRequest(
        @NotBlank(message = "{user.username.required}")
        @Size(min = 3, max = 20, message = "{user.username.size}")
        String username,

        @NotBlank(message = "{user.email.required}")
        @Email(message = "{user.email.invalid}")
        String email,

        @NotBlank(message = "{user.password.required}")
        @Size(min = 12, message = "{user.password.size}")
        String password,

        @NotNull(message = "{user.profile.required}")
        @Valid
        Profile profile
) {
}

public record Profile(
        @NotBlank(message = "{profile.displayName.required}")
        String displayName,

        @Valid
        List<@NotNull(message = "{address.required}") @Valid Address> addresses
) {
}

public record Address(
        @NotBlank(message = "{address.city.required}")
        String city,

        @NotBlank(message = "{address.postalCode.required}")
        String postalCode
) {
}

这里有三层关系:

RegisterRequest
└── profile                 @NotNull + @Valid
    └── addresses           @Valid
        └── Address         元素上的约束

@Valid 表示级联校验:校验当前对象后,继续校验它引用的对象或容器元素。

@Valid 本身不要求对象存在。例如:

@Valid
Profile profile

profile == null 时,通常不会进入 Profile 的内部校验。若字段必须存在,应写成:

@NotNull
@Valid
Profile profile

对于集合,必须同时考虑集合本身、集合元素和元素内部字段:

@NotNull
@Size(min = 1)
@Valid
List<@NotNull @Valid Address> addresses

它们分别表示:

  • @NotNull:集合不能为 null
  • @Size(min = 1):集合至少有一个元素;
  • @Valid:进入每个 Address
  • 元素上的 @NotNull:集合元素不能为 null

如果只写:

List<Address> addresses

即使 Address 内部有约束,通常也不会自动递归校验。缺少 @Valid 是嵌套校验“不生效”的常见原因。


2. 控制器

package com.example.demo.user;

import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/users")
public class UserController {

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserResponse register(@Valid @RequestBody RegisterRequest request) {
        return new UserResponse(request.username(), request.email());
    }

    public record UserResponse(String username, String email) {
    }
}

请求:

curl -i -X POST http://localhost:8080/users \
  -H 'Content-Type: application/json' \
  -d '{
    "username": "ab",
    "email": "wrong",
    "password": "short",
    "profile": {
      "displayName": "",
      "addresses": [
        {
          "city": "",
          "postalCode": "abc"
        }
      ]
    }
  }'

处理过程是:

  1. Jackson 把 JSON 转换为 RegisterRequest
  2. Spring MVC 看到参数上的 @Valid
  3. Spring 调用 Bean Validation;
  4. 顶层字段校验失败;
  5. profile 不为 null,因此继续级联校验;
  6. addresses 被标记为 @Valid,因此继续校验第一个 Address
  7. 产生多个字段错误;
  8. 控制器方法体不会执行;
  9. Spring 抛出 MethodArgumentNotValidException

@RequestBody 只负责请求体反序列化,@Valid@Validated 才负责触发 Bean Validation。请求体不是合法 JSON 时,失败点发生在反序列化阶段,通常不会产生 Bean Validation 错误。


三、校验触发点和 @Valid@Validated 的区别

1. @Valid:级联校验和默认分组

jakarta.validation.Valid 是 Bean Validation 标准注解,主要作用是:

  • 对请求体参数触发默认分组校验;
  • 对字段引用的对象执行级联校验;
  • 对集合、数组、Map 的元素执行级联校验。

例如:

public UserResponse register(
        @Valid @RequestBody RegisterRequest request) {
    // ...
}

@Valid 本身不能指定分组。

2. @Validated:Spring 的分组入口

org.springframework.validation.annotation.Validated 是 Spring 注解,支持指定校验分组:

public UserResponse register(
        @Validated(OnCreate.class)
        @RequestBody UserRequest request) {
    // ...
}

也可以放在控制器类上:

@RestController
@Validated
public class UserController {
}

需要区分两个场景:

  • 请求体对象校验:由 MVC 参数解析过程触发;
  • 方法参数约束校验:例如 @Min 放在 @PathVariable@RequestParam 上,属于方法级校验。

在 Spring Framework 6.1 及之后,Spring MVC 对控制器方法级校验提供了内建支持。较早版本常见的做法是在类上使用 @Validated,借助 Spring AOP 的 MethodValidationInterceptor 实现方法校验。升级 Spring 版本时,应以对应版本的 Spring Framework 文档和实际异常类型为准。


四、分组:同一个对象在不同操作中使用不同规则

1. 分组的形式化定义

Bean Validation 中,每个约束都可以属于一个或多个组。组本质上是接口类型:

public interface OnCreate {
}

public interface OnUpdate {
}

约束可以声明所属分组:

public record UserRequest(
        @NotBlank(groups = OnCreate.class)
        String username,

        @NotBlank(groups = {OnCreate.class, OnUpdate.class})
        String email,

        @Size(min = 12, groups = OnCreate.class)
        String password
) {
}

设:

  • CC 是所有约束;
  • GG 是本次校验选择的分组;
  • c.groupc.group 是约束所属的组;
  • valid(c, x) 表示约束 cc 在对象 xx 上成立。

本次校验失败集合为:

F(x,G)={cCGc.group¬valid(c,x)}F(x, G)=\{c \in C \mid G \cap c.group \neq \varnothing \land \neg valid(c,x)\}

因此:

  • 校验 OnCreate 时,只执行属于 OnCreate 的约束;
  • 校验 OnUpdate 时,只执行属于 OnUpdate 的约束;
  • 不属于所选组的约束不会产生错误。

2. 默认分组

没有显式写 groups 的约束属于 Default 组:

@NotBlank
String email;

等价于:

@NotBlank(groups = Default.class)
String email;

@Valid 默认触发 Default 组。若在校验入口指定了其他组,例如:

@Validated(OnCreate.class)

不能简单假定所有默认约束仍然会执行。是否把 Default 包含到自定义组,需要通过组继承或组序列明确表达。

一种常见写法是让业务组继承 Default

public interface OnCreate extends jakarta.validation.groups.Default {
}

此时校验 OnCreate 时,也会包含 Default 组中的约束。

完整示例:

public interface OnCreate extends jakarta.validation.groups.Default {
}

public interface OnUpdate extends jakarta.validation.groups.Default {
}

public record UserRequest(
        @NotBlank(message = "{user.username.required}")
        @Size(min = 3, max = 20,
              groups = OnCreate.class,
              message = "{user.username.size}")
        String username,

        @NotBlank(message = "{user.email.required}")
        @Email(message = "{user.email.invalid}")
        String email,

        @NotBlank(groups = OnCreate.class,
                  message = "{user.password.required}")
        @Size(min = 12, groups = OnCreate.class,
              message = "{user.password.size}")
        String password
) {
}

这里:

  • email 属于 Default
  • OnCreate 继承 Default,因此创建时会执行 email 和密码规则;
  • password 只在创建时必须提供;
  • OnUpdate 也继承 Default,更新时会执行 email,但不会执行 passwordOnCreate 约束。

控制器:

@PostMapping
public UserResponse create(
        @Validated(OnCreate.class)
        @RequestBody UserRequest request) {
    return new UserResponse(request.username(), request.email());
}

@PutMapping("/{id}")
public UserResponse update(
        @PathVariable long id,
        @Validated(OnUpdate.class)
        @RequestBody UserRequest request) {
    return new UserResponse(request.username(), request.email());
}

3. 组序列和短路行为

组序列使用 @GroupSequence 定义校验顺序:

public interface CreateSequence {
}

更常见的定义方式是:

import jakarta.validation.GroupSequence;

@GroupSequence({
        jakarta.validation.groups.Default.class,
        OnCreate.class
})
public interface CreateSequence {
}

校验 CreateSequence 时:

  1. 先执行 Default
  2. 只有 Default 没有失败,才执行 OnCreate
  3. Default 失败时,不执行 OnCreate

如果定义:

@GroupSequence({
        BasicChecks.class,
        BusinessChecks.class
})
public interface RegistrationSequence {
}

设:

  • FBF_B 是基础组失败集合;
  • FBizF_{Biz} 是业务组失败集合。

最终结果不是简单地同时执行两个集合,而是:

F={FB,FBFBiz,FB=F = \begin{cases} F_B, & F_B \neq \varnothing \\ F_{Biz}, & F_B = \varnothing \end{cases}

组序列适合表达“先检查基础输入,再检查依赖基础输入的业务规则”。但它会改变错误集合:基础组失败时,后续组的错误不会出现。若接口希望一次返回所有错误,就不应使用会短路的组序列,或者应接受这种交互语义。


五、嵌套对象、容器元素和组转换

1. 嵌套对象需要级联入口

下面的类型具有不同语义:

public record OrderRequest(
        Address address
) {
}

不会自动校验 Address

public record OrderRequest(
        @Valid Address address
) {
}

会在 address != null 时校验其内部字段。

public record OrderRequest(
        @NotNull
        @Valid
        Address address
) {
}

同时要求地址存在,并校验地址内部字段。

对于集合:

public record OrderRequest(
        @Valid
        List<@Valid LineItem> items
) {
}

这里有两个 @Valid

  • 字段上的 @Valid:级联到列表元素;
  • 类型参数上的 @Valid:声明元素本身也要级联校验。

在常见的 Hibernate Validator 版本中,容器元素约束支持 List<@NotNull LineItem> 这类写法。它属于 Bean Validation 2.0 之后的容器元素约束能力,项目应使用与 Spring Boot 版本匹配的 Jakarta Validation Provider。

2. 嵌套对象的分组不会自动等同于父对象分组

假设:

public interface OnCreate {
}

public record Parent(
        @Valid Child child
) {
}

public record Child(
        @NotBlank(groups = OnCreate.class)
        String code
) {
}

父对象以 OnCreate 校验时,级联子对象通常也使用当前分组 OnCreate。如果希望对子对象使用不同分组,可以使用 @ConvertGroup

import jakarta.validation.Valid;
import jakarta.validation.groups.ConvertGroup;
import jakarta.validation.groups.Default;

public record Parent(
        @Valid
        @ConvertGroup(from = OnCreate.class, to = Default.class)
        Child child
) {
}

它表达:

父对象以 OnCreate 校验
        │
        └── child 改用 Default 校验

组转换只对级联校验路径生效,不能把它理解成“把对象上所有约束的分组全局重命名”。

一个完整示例:

public interface OnCreate {
}

public record CreateOrderRequest(
        @Valid
        @ConvertGroup(from = OnCreate.class, to = OrderChecks.class)
        Customer customer
) {
}

public interface OrderChecks {
}

public record Customer(
        @NotBlank(groups = OrderChecks.class)
        String customerId
) {
}

调用:

@Validated(OnCreate.class)
@RequestBody CreateOrderRequest request

校验路径为:

CreateOrderRequest / OnCreate
        ↓ 组转换
Customer / OrderChecks

如果忘记 @ConvertGroup,子对象不会因为父对象使用 OnCreate 就自动改用 OrderChecks


六、跨字段校验:字段约束无法表达的规则

字段约束适合表达单字段条件,例如“不能为空”“长度至少为 12”。但以下规则涉及多个字段:

confirmPassword 必须等于 password
startTime 必须早于 endTime
paymentType 为 CARD 时,cardNumber 必须存在

这类规则通常需要类级约束。

1. 定义约束注解

package com.example.demo.validation;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.*;

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordMatchValidator.class)
@Documented
public @interface PasswordMatch {
    String message() default "{password.mismatch}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

2. 定义验证器

package com.example.demo.validation;

import com.example.demo.user.PasswordRequest;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class PasswordMatchValidator
        implements ConstraintValidator<PasswordMatch, PasswordRequest> {

    @Override
    public boolean isValid(
            PasswordRequest value,
            ConstraintValidatorContext context) {

        if (value == null) {
            return true;
        }

        if (value.password() == null ||
            value.confirmPassword() == null) {
            return true;
        }

        boolean valid = value.password().equals(value.confirmPassword());

        if (!valid) {
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate(
                            "{password.mismatch}")
                    .addPropertyNode("confirmPassword")
                    .addConstraintViolation();
        }

        return valid;
    }
}

3. 使用类级约束

package com.example.demo.user;

import com.example.demo.validation.PasswordMatch;
import jakarta.validation.constraints.NotBlank;

@PasswordMatch
public record PasswordRequest(
        @NotBlank(message = "{password.required}")
        String password,

        @NotBlank(message = "{password.confirm.required}")
        String confirmPassword
) {
}

这里有一个重要边界:类级约束默认可能产生对象级 ObjectError,而不是字段级 FieldError。验证器通过:

.addPropertyNode("confirmPassword")

把错误定位到具体字段,前端就可以按照字段错误处理。

跨字段验证器常见的返回策略是:当参与比较的基础字段本身为空时返回 true,让 @NotBlank 负责报告“字段缺失”;当两个字段都有值时,类级验证器再报告“不一致”。这样可以避免一次输入产生重复或相互冲突的错误。


七、Spring 的错误模型

1. BindingResultFieldErrorObjectError

Spring 的 BindingResult 保存数据绑定和校验结果:

public interface BindingResult extends Errors {
    Map<String, Object> getModel();
}

其中:

  • FieldError:某个字段的错误;
  • ObjectError:整个对象的错误;
  • getField():字段路径,例如 profile.addresses[0].postalCode
  • getRejectedValue():被拒绝的值;
  • getCodes():用于消息解析的错误码候选;
  • getArguments():消息格式化参数;
  • getDefaultMessage():默认消息。

嵌套路径通常可能长这样:

profile.addresses[0].city
items[2].quantity
attributes[color]

不要只把 FieldError.getDefaultMessage() 当作稳定错误标识。默认消息是面向文本的,错误码才适合作为客户端或日志中的机器可读信息。

2. MethodArgumentNotValidException

对于:

@PostMapping
public void create(@Valid @RequestBody RegisterRequest request) {
}

如果 Bean Validation 失败,Spring MVC 通常抛出:

MethodArgumentNotValidException

它包含:

ex.getBindingResult()

可以得到字段错误和对象错误。

3. HandlerMethodValidationException

控制器方法参数本身也可以有约束:

@GetMapping("/{id}")
public UserResponse find(
        @PathVariable
        @jakarta.validation.constraints.Positive
        long id,

        @RequestParam
        @jakarta.validation.constraints.Size(max = 20)
        String keyword) {
    // ...
}

这不是“校验一个请求体对象”,而是“校验方法参数”。在 Spring Framework 6.1 及之后,Spring MVC 对这类场景使用:

HandlerMethodValidationException

它的错误通常与方法参数、参数索引和参数上的约束相关,而不是天然对应一个 DTO 字段。

因此,异常处理器不能只处理 MethodArgumentNotValidException。生产接口至少要明确区分:

请求体绑定/校验失败       → MethodArgumentNotValidException
方法参数校验失败          → HandlerMethodValidationException
JSON 格式非法              → HttpMessageNotReadableException
类型转换失败              → MethodArgumentTypeMismatchException

这些错误都可能对应 HTTP 400,但诊断信息和修复方式不同。


八、设计稳定的错误响应

一个适合接口的错误模型应同时包含:

  • HTTP 状态;
  • 稳定的错误类型;
  • 字段路径;
  • 机器可读错误码;
  • 面向当前 Locale 的消息;
  • 必要的参数信息。

例如:

{
  "type": "https://example.com/problems/validation",
  "title": "请求参数校验失败",
  "status": 400,
  "detail": "请求包含一个或多个无效字段",
  "errors": [
    {
      "field": "email",
      "code": "Email",
      "message": "邮箱格式不正确"
    },
    {
      "field": "profile.addresses[0].city",
      "code": "NotBlank",
      "message": "城市不能为空"
    }
  ]
}

typetitlestatusdetail 可以使用 Spring 的 ProblemDetail 表达。

1. 处理请求体校验错误

package com.example.demo.web;

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ProblemDetail handleBodyValidation(
            MethodArgumentNotValidException ex) {

        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST,
                "请求包含一个或多个无效字段");

        List<Map<String, Object>> errors =
                ex.getBindingResult()
                        .getAllErrors()
                        .stream()
                        .map(this::toError)
                        .toList();

        problem.setTitle("请求参数校验失败");
        problem.setProperty("errors", errors);
        return problem;
    }

    private Map<String, Object> toError(ObjectError error) {
        Map<String, Object> item = new LinkedHashMap<>();

        if (error instanceof FieldError fieldError) {
            item.put("field", fieldError.getField());
            item.put("rejectedValue",
                    safeRejectedValue(fieldError.getRejectedValue()));
        } else {
            item.put("field", null);
        }

        String code = firstCode(error);
        item.put("code", code);
        item.put("message", error.getDefaultMessage());
        return item;
    }

    private String firstCode(ObjectError error) {
        String[] codes = error.getCodes();
        return codes != null && codes.length > 0
                ? codes[0]
                : "ValidationError";
    }

    private Object safeRejectedValue(Object value) {
        if (value == null) {
            return null;
        }

        return value instanceof String string && string.length() > 128
                ? string.substring(0, 128)
                : value;
    }
}

这段代码可以直接处理前面的 RegisterRequest 示例。输入中:

"profile": {
  "addresses": [
    {
      "city": "",
      "postalCode": "abc"
    }
  ]
}

产生的字段路径可能是:

profile.displayName
profile.addresses[0].city

具体错误顺序不应作为 API 契约依赖。不同 Provider、约束顺序和 Spring 版本可能影响错误排列。若客户端需要稳定处理,应按 field + code 而不是数组位置判断。

2. 不要无条件返回被拒绝值

以下值通常不应返回:

  • 密码;
  • 访问令牌;
  • 身份证号;
  • 银行卡号;
  • 大型上传内容;
  • 可能包含敏感信息的完整对象。

如果确实需要返回 rejectedValue,应按字段、类型和长度做过滤。错误响应面向客户端,不应直接把整个 BindingResult 序列化出去,因为其中可能包含内部对象、堆栈相关信息或敏感数据。

3. 方法参数校验需要单独适配

对于 HandlerMethodValidationException,错误对象可能没有 DTO 字段名,而是包含:

参数名:id
参数索引:0
约束:Positive
消息:必须是正数

Spring 6.1 之后可以根据该异常提供的验证结果访问器,把参数级错误统一转换为自己的 errors 数组。实现时应保留参数名和参数索引;如果参数是容器元素,还应保留索引或键。

不要假定所有校验错误都可以映射成一个简单的 field 字符串:

{
  "parameter": "id",
  "index": 0,
  "code": "Positive",
  "message": "必须是正数"
}

这比伪造一个不存在的 DTO 字段更准确。


九、消息模板、错误码和国际化

1. 消息模板不是最终消息

约束可以写:

@NotBlank(message = "{user.username.required}")
String username;

花括号表示消息键,而不是直接输出这段文本。验证器会尝试解析:

user.username.required

如果找不到消息,可能退回注解的默认消息或模板本身。

不要把面向用户的中文硬编码在 Java 注解中:

@NotBlank(message = "用户名不能为空")

这会让语言切换、统一措辞和产品文案调整变得困难。

2. 配置消息文件

在:

src/main/resources/

下创建:

ValidationMessages.properties
ValidationMessages_zh_CN.properties
ValidationMessages_en.properties

ValidationMessages.properties

user.username.required=用户名不能为空
user.username.size=用户名长度必须在 {min} 到 {max} 个字符之间
user.email.required=邮箱不能为空
user.email.invalid=邮箱格式不正确
user.password.required=密码不能为空
user.password.size=密码长度不能少于 {min} 个字符
user.profile.required=个人资料不能为空
profile.displayName.required=显示名称不能为空
address.required=地址不能为 null
address.city.required=城市不能为空
address.postalCode.required=邮政编码不能为空

ValidationMessages_en.properties

user.username.required=Username is required
user.username.size=Username length must be between {min} and {max}
user.email.required=Email is required
user.email.invalid=Email format is invalid
user.password.required=Password is required
user.password.size=Password must contain at least {min} characters
user.profile.required=Profile is required
profile.displayName.required=Display name is required
address.required=Address must not be null
address.city.required=City is required
address.postalCode.required=Postal code is required

{min}{max} 是约束属性参数。@Size(min = 3, max = 20) 会为消息插值提供对应值。

Spring Boot 可以配置消息基础名:

spring.messages.basename=messages,ValidationMessages

如果项目同时使用 Spring MVC 错误消息解析和 Bean Validation 消息解析,建议明确消息文件的职责和命名,避免同一个键在多个资源文件中产生覆盖歧义。

3. Locale 从哪里来

HTTP 请求通常通过:

Accept-Language: en

表达客户端偏好。Spring MVC 的 Locale 解析器根据请求确定当前 Locale,消息源再按该 Locale 查找资源。

请求:

curl -i -X POST http://localhost:8080/users \
  -H 'Content-Type: application/json' \
  -H 'Accept-Language: en' \
  -d '{
    "username": "ab",
    "email": "wrong",
    "password": "short",
    "profile": null
  }'

与:

curl -i -X POST http://localhost:8080/users \
  -H 'Content-Type: application/json' \
  -H 'Accept-Language: zh-CN' \
  -d '{
    "username": "ab",
    "email": "wrong",
    "password": "short",
    "profile": null
  }'

应当得到相同的机器错误码,但消息文本不同。

这里需要区分两个层面:

约束失败事实       不因语言改变
错误码和字段路径   不应因语言改变
message 文本        随 Locale 改变

如果客户端需要根据规则执行逻辑,应使用 code;如果客户端直接展示文本,可以使用服务端生成的 message。不建议让前端根据中文消息文本判断错误类型。

4. 自定义消息参数

可以在约束中声明业务参数:

@Size(
    min = 3,
    max = 20,
    message = "{user.username.size}"
)
String username;

资源文件中的:

user.username.size=用户名长度必须在 {min} 到 {max} 个字符之间

由验证器进行插值。

如果要把动态业务数据放入消息,不应把未经限制的用户输入直接拼接进消息模板。消息参数既影响日志,也可能影响响应大小和转义行为。复杂业务文案可以在异常处理层基于稳定错误码重新映射,而不是让约束验证器承担完整的业务文案生成责任。


十、不同校验失败的生命周期

一次请求可能在不同阶段失败:

sequenceDiagram
    participant C as 客户端
    participant MVC as Spring MVC
    participant J as Jackson
    participant V as Bean Validation
    participant H as ControllerAdvice
    participant S as 消息源

    C->>MVC: HTTP 请求
    MVC->>J: 反序列化 JSON
    alt JSON 非法
        J-->>MVC: HttpMessageNotReadableException
        MVC->>H: 转换为 400 错误
    else JSON 合法
        MVC->>V: 按 @Valid/@Validated 校验
        alt 约束失败
            V-->>MVC: ConstraintViolation/BindingResult
            MVC->>H: MethodArgumentNotValidException
            H->>S: 按 Locale 解析消息
            S-->>H: 本地化文本
            H-->>C: ProblemDetail
        else 校验通过
            MVC->>MVC: 调用控制器方法
            MVC-->>C: 正常响应
        end
    end

关键路径有三个:

  1. JSON 解析失败:对象还没有成功建立,不能按字段约束处理;
  2. 对象校验失败:对象已经建立,但约束不成立;
  3. 校验通过:才进入控制器业务逻辑。

如果控制器方法中又手动调用一次 Validator,可能造成重复校验和重复错误。通常应让边界层负责结构和基础约束,让业务层只处理确实需要访问数据库或领域状态的规则。


十一、请求参数、路径变量和方法级校验

请求体不是唯一的参数来源。

@GetMapping("/{id}")
public UserResponse find(
        @PathVariable
        @jakarta.validation.constraints.Positive(message = "{user.id.positive}")
        long id,

        @RequestParam(defaultValue = "")
        @jakarta.validation.constraints.Size(
                max = 50,
                message = "{user.keyword.size}")
        String keyword) {
    return new UserResponse("demo", "demo@example.com");
}

这段代码表达:

id>0keyword50id > 0 \land |keyword| \leq 50

对于 @RequestParam 的缺失问题,Spring 可能在 Bean Validation 之前就因为必需参数缺失而失败。比如:

@RequestParam String keyword

当参数完全不存在时,通常是请求参数缺失异常,而不是 @Size 失败。若参数可选,应使用:

@RequestParam(required = false)
String keyword

然后再根据业务需要使用 @Size@Nullable 或默认值。

路径变量也有类似边界:

@PathVariable long id

当请求中的 id 不是数字时,首先发生类型转换失败;@Positive 只能处理已经成功转换为 long 的值。


十二、常见失败表现和诊断方法

1. 约束完全没有执行

优先检查:

是否引入 spring-boot-starter-validation
是否使用 jakarta.validation.* 而不是 javax.validation.*
是否在请求体参数上写了 @Valid 或 @Validated
是否真的进入了 Spring MVC 管理的控制器

以下代码不会因为 DTO 上有注解就自动触发校验:

@PostMapping
public void create(@RequestBody RegisterRequest request) {
}

应改为:

@PostMapping
public void create(@Valid @RequestBody RegisterRequest request) {
}

2. 嵌套字段没有错误

检查父字段是否标记了 @Valid

@NotNull
@Valid
Profile profile

对于列表,检查是否需要:

@Valid
List<@Valid Address> addresses

还要确认集合元素不是 null@Valid 不会自动把 null 元素变成错误,元素存在性需要 @NotNull

3. 分组约束没有执行

检查三个位置:

  1. 约束属于哪个组;
  2. 入口选择了哪个组;
  3. Default 是否通过继承或组序列纳入。

例如:

@NotBlank(groups = OnCreate.class)
String password;

在:

@Valid

下不会执行,因为 @Valid 默认使用 Default,而该约束属于 OnCreate

入口应明确选择:

@Validated(OnCreate.class)

或者调整组设计,使 OnCreate 继承 Default

4. 只有一个对象错误,没有字段错误

跨字段约束通常产生 ObjectError。如果前端需要定位具体字段,应在验证器中使用:

context.buildConstraintViolationWithTemplate("{password.mismatch}")
       .addPropertyNode("confirmPassword")
       .addConstraintViolation();

否则接口只能报告:

{
  "field": null,
  "code": "PasswordMatch",
  "message": "两次密码不一致"
}

这在语义上并不错误,但前端无法将其绑定到单个输入框。

5. 国际化始终显示默认语言

检查:

Accept-Language 是否真正到达服务端
LocaleResolver 是否被自定义配置覆盖
资源文件命名是否正确
文件是否位于 src/main/resources
消息键是否与 message 中的键完全一致

例如:

message = "{user.email.invalid}"

不能在资源文件中误写成:

user.email.invald=邮箱格式不正确

找不到键时,最终可能出现默认消息或键本身,不能只看“响应有 message”就认为国际化生效。


十三、边界:Bean Validation 不等于业务校验

Bean Validation 适合处理输入对象的局部、不依赖外部状态的约束:

不能为空
长度范围
数字范围
格式
集合元素结构
对象间的简单关系

以下规则通常属于业务服务或领域层:

用户名在数据库中必须唯一
优惠券必须属于当前用户
订单状态必须允许当前操作
账户余额必须足够
两个请求字段的组合必须匹配数据库配置

原因是这些规则依赖:

  • 数据库;
  • 当前用户;
  • 外部服务;
  • 当前聚合状态;
  • 并发事务。

例如“用户名唯一”不能只用:

@UniqueUsername
String username;

然后在验证器中查询数据库并把结果当作最终事实。即使验证时用户名不存在,另一个并发请求也可能在随后插入同名数据。正确的最终保障仍应包括数据库唯一约束,并在事务层把冲突转换成稳定错误。

Bean Validation 可以提供提前反馈,但不能取代持久化层的并发安全保证。


十四、生产中的取舍

1. 不要把 DTO 直接当作领域模型

请求 DTO 的约束取决于接口场景,领域对象的约束取决于业务不变量。创建和更新往往需要不同分组,甚至不同类型:

CreateUserRequest
UpdateUserRequest
User

把所有场景压进一个 DTO,容易形成大量分组、条件约束和隐式规则,最终难以理解错误来源。

2. 控制错误数量和响应大小

一个请求可能包含数千个列表元素。若每个元素都有多个约束,错误数量会快速增长:

En×mE \approx n \times m

其中:

  • nn 是集合元素数量;
  • mm 是每个元素可能失败的约束数量;
  • EE 是潜在错误数量。

应在请求体大小、集合长度和错误响应大小上设置边界。限制集合长度不仅是用户体验问题,也是资源消耗和拒绝服务风险问题。

3. 记录稳定信息,不记录敏感值

日志中保留以下信息通常足够:

请求路径
HTTP 方法
字段路径
约束错误码
Locale
traceId

不要无条件记录完整请求体或 rejectedValue。校验失败是高频输入错误,异常日志应避免把正常的客户端错误堆成大量堆栈日志。

4. 为 API 固定错误契约

Spring 的异常类和内部错误码适合服务端实现,但不应直接成为跨服务 API 契约。对外响应应由自己的错误模型承接:

内部:
MethodArgumentNotValidException
BindingResult
FieldError

外部:
ValidationProblem
field
code
message

这样升级 Spring、切换 Web MVC 或统一 WebFlux 错误处理时,客户端不必感知框架内部类型。


十五、最终组合示例

一个较完整的请求模型可以写成:

public interface OnCreate extends jakarta.validation.groups.Default {
}

public record CreateUserRequest(
        @NotBlank(
                groups = OnCreate.class,
                message = "{user.username.required}")
        @Size(
                min = 3,
                max = 20,
                groups = OnCreate.class,
                message = "{user.username.size}")
        String username,

        @NotBlank(
                groups = OnCreate.class,
                message = "{user.email.required}")
        @Email(
                groups = OnCreate.class,
                message = "{user.email.invalid}")
        String email,

        @NotBlank(
                groups = OnCreate.class,
                message = "{user.password.required}")
        @Size(
                min = 12,
                groups = OnCreate.class,
                message = "{user.password.size}")
        String password,

        @NotNull(
                groups = OnCreate.class,
                message = "{user.profile.required}")
        @Valid
        Profile profile
) {
}

控制器:

@PostMapping
public UserResponse create(
        @Validated(OnCreate.class)
        @RequestBody CreateUserRequest request) {
    return new UserResponse(request.username(), request.email());
}

这段代码的校验语义可以逐步展开为:

选择 OnCreate
    ↓
因为 OnCreate 继承 Default,执行 Default 约束
    ↓
执行 username、email、password、profile 的顶层约束
    ↓
profile 不为 null 且有 @Valid
    ↓
进入 Profile 的嵌套约束
    ↓
集合有 @Valid 时继续进入每个元素
    ↓
生成字段错误和对象错误
    ↓
ControllerAdvice 转换为对外错误模型
    ↓
根据请求 Locale 解析 message

真正稳定的参数校验设计,依赖的是这条完整链路,而不是某个单独注解:

约束声明
→ 分组选择
→ 级联路径
→ Provider 执行
→ Spring 异常承接
→ 错误码建模
→ Locale 消息解析
→ 安全的 API 响应

只要明确每一步的职责,Bean Validation、分组、嵌套对象、错误模型和国际化就不再是互相独立的配置技巧,而是一套可追踪的请求边界机制。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。