在后端开发中,参数校验是保障接口安全性和数据合法性的核心环节,硬编码的if-else校验逻辑不仅繁琐冗余,还会让代码可读性大打折扣。Jakarta Validation(原Java Validation)为我们提供了一套轻量、优雅的注解式参数校验方案,通过标准化的注解即可实现各类参数校验规则,大幅简化开发流程。
本文将从基础注解使用、实战场景落地、统一异常处理到自定义校验注解,全方位讲解Jakarta Validation的使用技巧,结合实际业务代码示例,让你快速上手并灵活运用到项目中。
一、核心校验注解速查
Jakarta Validation提供了一系列开箱即用的校验注解,覆盖空值、长度、格式、数值、日期等绝大多数日常校验场景,核心注解及功能如下表所示,可直接作为开发速查手册:
| 注解 | 适用类型 | 核心功能 |
|---|---|---|
| @NotNull | 所有类型 | 字段值不能为null |
| @NotBlank | 字符串 | 不能为null,且去除首尾空格后长度大于0 |
| @NotEmpty | 字符串/集合/数组 | 不能为null,且长度/元素个数大于0 |
| @Size(min, max) | 字符串/集合/数组 | 长度/元素个数在[min, max]范围内 |
| @Pattern(regexp) | 字符串 | 必须匹配指定的正则表达式 |
| 字符串 | 必须符合合法的邮箱格式(支持自定义正则) | |
| @Min(value) | 数值类型 | 数值必须大于等于value |
| @Max(value) | 数值类型 | 数值必须小于等于value |
| @Positive | 数值类型 | 必须为正数(大于0) |
| @Negative | 数值类型 | 必须为负数(小于0) |
| @PositiveOrZero | 数值类型 | 必须为正数或0 |
| @NegativeOrZero | 数值类型 | 必须为负数或0 |
| @Future | 日期/时间类型 | 必须是未来的时间 |
| @FutureOrPresent | 日期/时间类型 | 必须是未来或当前时间 |
| @Past | 日期/时间类型 | 必须是过去的时间 |
| @PastOrPresent | 日期/时间类型 | 必须是过去或当前时间 |
注解使用小技巧
- @NotBlank/@NotEmpty/@NotNull 区分:字符串优先用
@NotBlank(过滤空白字符),集合/数组用@NotEmpty,非字符串非集合类型用@NotNull; - 注解组合使用:实际业务中可组合多个注解,如用户账号需同时满足「非空、正则匹配、长度限制」;
- 默认提示语自定义:所有注解都支持
message属性,用于自定义校验失败的提示信息,贴合业务场景。
二、实战场景落地:三种核心使用方式
Jakarta Validation的注解可根据参数传递方式灵活使用,核心分为「简单参数直接注解」「实体对象属性注解」两种核心场景,后者是项目中最常用的方式。
场景1:GET请求简单参数,直接注解参数
对于GET请求的URL拼接参数(如/user/get?name=test&id=1),可直接在接口方法的参数前添加校验注解,适用于参数数量少的简单场景。
/**
* 根据用户名和ID查询用户
* @param name 用户名(不能为空)
* @param id 用户ID(必须为正数)
* @return 用户信息
*/
@GetMapping("/get")
public CommonResult<UserVO> getUser(
@NotBlank(message = "用户名不能为空") String name,
@Positive(message = "用户ID必须为正数") Long id
) {
UserVO user = userService.getByNameAndId(name, id);
return CommonResult.success(user);
}场景2:POST请求实体参数,注解+@Valid 触发校验
对于POST/PUT请求,参数通常封装为实体对象(如新增/编辑用户的入参),只需在实体的属性上添加校验注解,并在接口方法的实体参数前添加@Valid(或@Validated)注解,即可触发整体校验逻辑。
这是项目中最常用的方式,适合复杂参数的校验场景。
步骤1:实体类添加校验注解
@Data
@Schema(description = "新增用户请求参数")
public class UserSaveReqVO {
@Schema(description = "用户编号(编辑时传,新增时不传)", example = "1024")
private Long id;
@Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "yudao")
@NotBlank(message = "用户账号不能为空")
@Pattern(regexp = "^[a-zA-Z0-9]+$", message = "用户账号仅支持数字、字母组成")
@Size(min = 4, max = 30, message = "用户账号长度为4-30个字符")
private String username;
@Schema(description = "用户密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456a")
@NotBlank(message = "用户密码不能为空")
@Size(min = 6, max = 20, message = "用户密码长度为6-20个字符")
private String password;
@Schema(description = "用户年龄", example = "25")
@Min(value = 18, message = "用户年龄不能小于18岁")
@Max(value = 60, message = "用户年龄不能大于60岁")
private Integer age;
@Schema(description = "邮箱", example = "test@example.com")
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不合法")
private String email;
}步骤2:接口方法添加@Valid 触发校验
@PostMapping("/create")
@Operation(summary = "新增用户")
public CommonResult<Long> createUser(@Valid @RequestBody UserSaveReqVO reqVO) {
Long userId = userService.createUser(reqVO);
return CommonResult.success(userId);
}关键区别:@Valid vs @Validated
- @Valid:属于JSR-380标准注解,支持嵌套实体校验(如实体中包含另一个实体属性);
- @Validated:属于Spring扩展注解,支持分组校验(如新增和编辑用户时,同一实体的校验规则不同),可替代
@Valid使用。
嵌套实体校验示例:如果UserSaveReqVO中包含AddressVO实体属性,只需在AddressVO属性上添加@Valid+自身属性注解,即可触发嵌套校验。
@Data
public class UserSaveReqVO {
// 其他属性...
@Schema(description = "用户地址")
@Valid // 触发嵌套校验
@NotNull(message = "用户地址不能为空")
private AddressVO address;
}
@Data
public class AddressVO {
@NotBlank(message = "省不能为空")
private String province;
@NotBlank(message = "市不能为空")
private String city;
}三、全局异常拦截:统一处理校验失败结果
当参数校验失败时,Jakarta Validation会自动抛出异常,不同场景抛出的异常类型不同:
- 简单参数校验失败:抛出
ConstraintViolationException; - 实体对象校验失败:抛出
MethodArgumentNotValidException。
为了让前端能接收到统一格式的错误返回,我们需要在项目中添加全局异常处理器,拦截这些校验异常,封装成统一的返回结果。
全局异常处理器实现
结合Spring Boot的@RestControllerAdvice和@ExceptionHandler实现全局异常拦截,统一返回格式(如包含错误码、错误信息的CommonResult)。
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* 处理实体对象参数校验失败异常(@Valid + 实体注解)
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public CommonResult<?> handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
log.warn("参数校验失败:{}", ex.getMessage());
// 获取校验失败的第一条错误信息
String errorMsg = getFirstValidErrorMessage(ex.getBindingResult());
return CommonResult.error(HttpStatus.BAD_REQUEST.value(), "请求参数不正确:" + errorMsg);
}
/**
* 处理简单参数校验失败异常(直接注解参数)
*/
@ExceptionHandler(ConstraintViolationException.class)
public CommonResult<?> handleConstraintViolation(ConstraintViolationException ex) {
log.warn("参数校验失败:{}", ex.getMessage());
// 获取校验失败的第一条错误信息
String errorMsg = ex.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.findFirst()
.orElse("参数校验失败");
return CommonResult.error(HttpStatus.BAD_REQUEST.value(), "请求参数不正确:" + errorMsg);
}
/**
* 提取BindingResult中的第一条错误信息
*/
private String getFirstValidErrorMessage(BindingResult bindingResult) {
// 优先获取字段级别的错误
if (bindingResult.hasFieldErrors()) {
return bindingResult.getFieldErrors().get(0).getDefaultMessage();
}
// 无字段错误则获取全局错误
if (bindingResult.hasGlobalErrors()) {
return bindingResult.getGlobalErrors().get(0).getDefaultMessage();
}
return "参数校验失败";
}
}异常处理小技巧
- 返回第一条错误信息:避免返回所有错误信息导致前端展示混乱,优先返回第一条校验失败的信息;
- 统一错误码:参数校验失败统一使用
400(BAD_REQUEST)错误码,符合HTTP协议规范; - 日志记录:记录异常日志便于问题排查,但无需打印完整堆栈(非运行时异常,属于业务异常)。
四、高级扩展:自定义校验注解
Jakarta Validation提供的默认注解无法覆盖所有业务场景(如「参数必须为指定枚举值」「手机号格式校验」「身份证号校验」),此时可通过自定义校验注解实现个性化的校验规则,步骤固定且可复用。
实战示例:实现「参数必须为指定枚举值」的自定义注解
以最常见的「参数必须是枚举中的某个值」为例,实现自定义注解@InEnum,支持校验参数是否为指定枚举的有效值。
步骤1:定义自定义注解
通过@Constraint指定校验器实现类,注解的属性可传递自定义参数(如枚举类),同时指定注解的适用目标(字段、参数等)。
@Target({
ElementType.METHOD,
ElementType.FIELD,
ElementType.PARAMETER,
ElementType.ANNOTATION_TYPE
})
@Retention(RetentionPolicy.RUNTIME)
@Documented
// 指定校验器实现类:InEnumValidator
@Constraint(validatedBy = {InEnumValidator.class})
public @interface InEnum {
/**
* 校验失败的提示语
*/
String message() default "必须为指定枚举值:{value}";
/**
* 分组校验(可省略,默认空)
*/
Class<?>[] groups() default {};
/**
* 负载(可省略,默认空)
*/
Class<? extends Payload>[] payload() default {};
/**
* 目标枚举类(必须实现ArrayValuable接口,提供枚举值数组)
*/
Class<? extends ArrayValuable<?>> value();
}
/**
* 枚举值获取接口,所有需要被@InEnum校验的枚举需实现此接口
* @param <T> 枚举值类型
*/
public interface ArrayValuable<T> {
/**
* 获取枚举的所有值数组
*/
T[] array();
}步骤2:实现注解校验器
实现ConstraintValidator<A, T>接口,其中A为自定义注解,T为被校验的参数类型,重写initialize(初始化注解参数)和isValid(核心校验逻辑)方法。
@Slf4j
public class InEnumValidator implements ConstraintValidator<InEnum, Object> {
/**
* 枚举的有效值集合
*/
private List<?> validValues;
/**
* 初始化:获取注解中指定的枚举类,提取其有效值
*/
@Override
public void initialize(InEnum annotation) {
Class<? extends ArrayValuable<?>> enumClass = annotation.value();
// 获取枚举的所有实例
ArrayValuable<?>[] enumConstants = enumClass.getEnumConstants();
if (ArrayUtil.isEmpty(enumConstants)) {
this.validValues = Collections.emptyList();
return;
}
// 提取枚举的有效值数组,转为List方便判断
this.validValues = Arrays.asList(enumConstants[0].array());
}
/**
* 核心校验逻辑
* @param value 被校验的参数值
* @param context 校验上下文
* @return true=校验通过,false=校验失败
*/
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
// 1. 参数为null时,默认校验通过(如需非空,可配合@NotNull注解)
if (value == null) {
return true;
}
// 2. 参数值在枚举有效值集合中,校验通过
if (validValues.contains(value)) {
return true;
}
// 3. 校验失败,自定义提示语(替换{value}为实际枚举有效值)
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
context.getDefaultConstraintMessageTemplate()
.replace("{value}", validValues.toString())
).addConstraintViolation();
return false;
}
}步骤3:枚举实现接口并使用自定义注解
让目标枚举实现ArrayValuable接口,提供有效值数组,然后在实体/参数上添加@InEnum注解即可。
/**
* 性别枚举
*/
public enum GenderEnum implements ArrayValuable<Integer> {
MALE(1, "男"),
FEMALE(2, "女");
private final Integer code;
private final String name;
GenderEnum(Integer code, String name) {
this.code = code;
this.name = name;
}
@Override
public Integer[] array() {
// 返回枚举的有效值数组
return new Integer[]{MALE.getCode(), FEMALE.getCode()};
}
public Integer getCode() {
return code;
}
}
// 在实体中使用@InEnum注解
@Data
public class UserSaveReqVO {
// 其他属性...
@Schema(description = "性别(1=男,2=女)", example = "1")
@NotNull(message = "性别不能为空")
@InEnum(value = GenderEnum.class, message = "性别必须为{value}")
private Integer gender;
}自定义注解开发通用规范
- 注解属性规范:必须包含
message/groups/payload三个基础属性(符合Jakarta Validation标准); - 空值处理:校验器中默认对
null放行,如需非空可配合@NotNull注解,解耦「非空校验」和「业务规则校验」; - 提示语自定义:通过
context.disableDefaultConstraintViolation()禁用默认提示语,实现动态替换(如替换枚举有效值); - 可复用性:自定义注解应设计为通用型(如手机号、身份证号校验注解),可在项目中全局复用。
五、实用进阶技巧
1. 分组校验:同一实体不同场景不同校验规则
实际开发中,新增和编辑用户时,同一实体的校验规则可能不同(如新增时id无需传,编辑时id必须传),可通过分组校验实现,基于@Validated的分组属性。
步骤1:定义分组标识接口
/**
* 校验分组 - 新增
*/
public interface AddGroup {
}
/**
* 校验分组 - 编辑
*/
public interface EditGroup {
}步骤2:实体注解指定分组
@Data
public class UserSaveReqVO {
@Schema(description = "用户编号", example = "1024")
@NotNull(message = "用户ID不能为空", groups = EditGroup.class) // 仅编辑时校验id非空
private Long id;
@NotBlank(message = "用户账号不能为空", groups = {AddGroup.class, EditGroup.class}) // 新增+编辑都校验
private String username;
}步骤3:接口指定分组触发校验
// 新增用户:使用AddGroup分组
@PostMapping("/create")
public CommonResult<Long> createUser(@Validated(AddGroup.class) @RequestBody UserSaveReqVO reqVO) {
return CommonResult.success(userService.createUser(reqVO));
}
// 编辑用户:使用EditGroup分组
@PutMapping("/edit")
public CommonResult<Boolean> editUser(@Validated(EditGroup.class) @RequestBody UserSaveReqVO reqVO) {
return CommonResult.success(userService.editUser(reqVO));
}六、总结
Jakarta Validation通过注解式编程让参数校验从繁琐的硬编码中解放出来,实现了「校验规则和业务逻辑的解耦」,让代码更简洁、优雅、易维护。
合理使用Jakarta Validation,不仅能提升开发效率,还能让接口的参数校验更规范、更健壮,为项目的稳定性提供保障。
评论 (0)