Java 基础体系 · 第 90/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Spring 参数校验:Bean Validation、分组、嵌套、错误模型和国际化
参数校验解决的问题不是“把几个注解放到 DTO 上”,而是把外部输入转换成一个可验证的对象,并在约束不满足时生成可定位、可国际化、可稳定传输的错误结果。
在 Spring 应用中,一次典型的请求校验涉及以下组件:
- Bean Validation 规范:定义
@NotNull、@Size、@Email、分组、级联校验和消息插值等规则。 - Bean Validation Provider:真正执行规则的实现,Spring Boot 项目中通常是 Hibernate Validator。
- Spring 参数解析器:把 HTTP 请求体、路径变量、查询参数转换为 Java 参数,并决定何时触发校验。
- Spring 错误模型:把校验失败表示为
FieldError、ObjectError、MethodArgumentNotValidException等对象。 - 消息解析和国际化组件:根据错误码、参数和当前 Locale 生成面向用户的文本。
这些组件的职责不同。Bean Validation 负责判断约束是否成立,Spring 负责触发校验并承接错误,消息系统负责把错误转换为某种语言的文本。
一、Bean Validation 到底校验什么
1. 约束、验证器和验证结果
Bean Validation 中的约束是一个声明式规则。例如:
@NotBlank
private String username;
它表达的不是“调用某个 Spring 方法检查字符串”,而是一个约束:
不同约束对 null 的处理方式可能不同。例如:
@NotNull检查引用不为null;@NotBlank通常同时要求字符串非null、去除空白后非空;@Size检查长度或集合大小,但通常不负责阻止null;@Email通常也不把null当作格式错误。
因此,下列声明的语义不同:
@NotNull
@Size(min = 8, max = 64)
private String password;
它表示:
而只有:
@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-web 和 spring-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"
}
]
}
}'
处理过程是:
- Jackson 把 JSON 转换为
RegisterRequest; - Spring MVC 看到参数上的
@Valid; - Spring 调用 Bean Validation;
- 顶层字段校验失败;
profile不为null,因此继续级联校验;addresses被标记为@Valid,因此继续校验第一个Address;- 产生多个字段错误;
- 控制器方法体不会执行;
- 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
) {
}
设:
- 是所有约束;
- 是本次校验选择的分组;
- 是约束所属的组;
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,但不会执行password的OnCreate约束。
控制器:
@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 时:
- 先执行
Default; - 只有
Default没有失败,才执行OnCreate; Default失败时,不执行OnCreate。
如果定义:
@GroupSequence({
BasicChecks.class,
BusinessChecks.class
})
public interface RegistrationSequence {
}
设:
- 是基础组失败集合;
- 是业务组失败集合。
最终结果不是简单地同时执行两个集合,而是:
组序列适合表达“先检查基础输入,再检查依赖基础输入的业务规则”。但它会改变错误集合:基础组失败时,后续组的错误不会出现。若接口希望一次返回所有错误,就不应使用会短路的组序列,或者应接受这种交互语义。
五、嵌套对象、容器元素和组转换
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. BindingResult、FieldError 和 ObjectError
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": "城市不能为空"
}
]
}
type、title、status、detail 可以使用 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
关键路径有三个:
- JSON 解析失败:对象还没有成功建立,不能按字段约束处理;
- 对象校验失败:对象已经建立,但约束不成立;
- 校验通过:才进入控制器业务逻辑。
如果控制器方法中又手动调用一次 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");
}
这段代码表达:
对于 @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. 分组约束没有执行
检查三个位置:
- 约束属于哪个组;
- 入口选择了哪个组;
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. 控制错误数量和响应大小
一个请求可能包含数千个列表元素。若每个元素都有多个约束,错误数量会快速增长:
其中:
- 是集合元素数量;
- 是每个元素可能失败的约束数量;
- 是潜在错误数量。
应在请求体大小、集合长度和错误响应大小上设置边界。限制集合长度不仅是用户体验问题,也是资源消耗和拒绝服务风险问题。
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 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java MongoDB 工程:文档建模、驱动、事务、索引和变更流
- 下一篇:Spring 异常处理:Problem Detail、全局处理、错误码和日志边界
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论