Jakarta Validation 优雅实现参数校验:从基础使用到自定义扩展

Jakarta Validation 优雅实现参数校验:从基础使用到自定义扩展

Luca Ju
2026-02-03 / 0 评论 / 1 阅读 / 正在检测是否收录...

在后端开发中,参数校验是保障接口安全性和数据合法性的核心环节,硬编码的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)字符串必须匹配指定的正则表达式
@Email字符串必须符合合法的邮箱格式(支持自定义正则)
@Min(value)数值类型数值必须大于等于value
@Max(value)数值类型数值必须小于等于value
@Positive数值类型必须为正数(大于0)
@Negative数值类型必须为负数(小于0)
@PositiveOrZero数值类型必须为正数或0
@NegativeOrZero数值类型必须为负数或0
@Future日期/时间类型必须是未来的时间
@FutureOrPresent日期/时间类型必须是未来或当前时间
@Past日期/时间类型必须是过去的时间
@PastOrPresent日期/时间类型必须是过去或当前时间

注解使用小技巧

  1. @NotBlank/@NotEmpty/@NotNull 区分:字符串优先用@NotBlank(过滤空白字符),集合/数组用@NotEmpty,非字符串非集合类型用@NotNull
  2. 注解组合使用:实际业务中可组合多个注解,如用户账号需同时满足「非空、正则匹配、长度限制」;
  3. 默认提示语自定义:所有注解都支持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

  1. @Valid:属于JSR-380标准注解,支持嵌套实体校验(如实体中包含另一个实体属性);
  2. @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 "参数校验失败";
    }
}

异常处理小技巧

  1. 返回第一条错误信息:避免返回所有错误信息导致前端展示混乱,优先返回第一条校验失败的信息;
  2. 统一错误码:参数校验失败统一使用400(BAD_REQUEST)错误码,符合HTTP协议规范;
  3. 日志记录:记录异常日志便于问题排查,但无需打印完整堆栈(非运行时异常,属于业务异常)。

四、高级扩展:自定义校验注解

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;
}

自定义注解开发通用规范

  1. 注解属性规范:必须包含message/groups/payload三个基础属性(符合Jakarta Validation标准);
  2. 空值处理:校验器中默认对null放行,如需非空可配合@NotNull注解,解耦「非空校验」和「业务规则校验」;
  3. 提示语自定义:通过context.disableDefaultConstraintViolation()禁用默认提示语,实现动态替换(如替换枚举有效值);
  4. 可复用性:自定义注解应设计为通用型(如手机号、身份证号校验注解),可在项目中全局复用。

五、实用进阶技巧

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

评论 (0)

取消