【Java】参数校验与统一异常处理

Java参数校验与统一异常处理

【前言】参数校验是接口开发不可或缺的环节,校验参数在以前基本上依靠大量的if/else控制语句来实现,后来可以使用反射+自定义注解的形式进行校验,但是复用性不是很好。其中,《阿里开发手册》关于参数校验的规约的描述:
在这里插入图片描述

如何优雅地校验接口参数的合法性?spring开发了validated框架用于注解校验,可以节省很多冗余的校验实现逻辑细节,为开发和代码维护提升效能。

PART1.基本概念


  • JSR-303
    JSR是Java Specification Requests的缩写(Java 规范提案)。任何人都可以提交JSR,以向Java平台增添新的API和服务。JSR已成为Java界的一个重要标准。
    JSR-303 是JAVA EE 6 中的一项子规范,叫做Bean Validation,是Java定义的一套基于注解的数据校验规范。

  • Hibernate Validator

    Hibernate Validator 是 Bean Validation 的参考实现 . Hibernate Validator 提供了 JSR 303 规范中所有内置 constraint 的实现,除此之外还有一些附加的 constraint。

    其pom引用为:

<!--jsr 303-->
<dependency>
    <groupId>javax.validation</groupId>
    <artifactId>validation-api</artifactId>
    <version>2.0.1.Final</version>
</dependency>
<dependency>
    <groupId>jakarta.validation</groupId>
    <artifactId>jakarta.validation-api</artifactId>
    <version>2.0.1</version>
</dependency>
<!-- hibernate validator-->
<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.1.5.Final</version>
</dependency>

它们的关系是,Hibernate Validator 是 Bean Validation 的实现,除了JSR规范中的,还加入了它自己的一些constraint实现,所以点开pom发现Hibernate Validator依赖了validation-api。
因为18年Java EE改名Jakarta EE,所以jakarta.validation是javax.validation改名而来。
对于spring boot应用,可以直接引用它提供的starter

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

spring boot有它的版本号配置,继承了spring boot的pom,所以不需要自己指定版本号了。
这个starter它内部也依赖了Hibernate Validator。Hibernate Validator内部依赖了jakarta.validation-api。

PART2.Validator参数校验


实现方式和使用方式:一般使用较多的是两个注解:@Validated@Valid

@valid和@validate的区别

区别 @Valid @Validated
提供者 JSR-303规范 Spring
是否支持分组 不支持 支持
标注位置 METHOD, FIELD, CONSTRUCTOR, PARAMETER, TYPE_USE TYPE, METHOD, PARAMETER
嵌套校验 支持 不支持

ElementType.TYPE:能修饰类、接口或枚举类型
ElementType.FIELD:能修饰成员变量
ElementType.METHOD:能修饰方法
ElementType.PARAMETER:能修饰参数
ElementType.CONSTRUCTOR:能修饰构造器
ElementType.LOCAL_VARIABLE:能修饰局部变量
ElementType.ANNOTATION_TYPE:能修饰注解
ElementType.PACKAGE:能修饰包

TYPE_USE则可以用于标注任意类型(不包括class)

具体校验功能常用注解如下:

Constraint 说明 支持的数据类型
@AssertFalse 被注释的元素必须为 false Boolean
@AssertTrue 被注释的元素必须为 true Boolean
@DecimalMax 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 BigDecimal, BigInteger, CharSequence, byte, short, int, long
@DecimalMin 被注释的元素必须是一个数字,其值必须大于等于指定的最小值 BigDecimal, BigInteger, CharSequence, byte, short, int, long
@Max 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 BigDecimal, BigInteger, byte, short, int, long
@Min 被注释的元素必须是一个数字,其值必须大于等于指定的最小值 BigDecimal, BigInteger, byte, short, int, long
@Digits(integer=,fraction=) 检查注释的值是否为最多为整数位(integer)和小数位(fraction)的数字 BigDecimal, BigInteger, CharSequence, byte, short, int, long
@Email 被注释的元素必须是电子邮箱地址,可选参数 regexp和flag允许指定必须匹配的附加正则表达式(包括正则表达式标志) CharSequence
@Future 被注释的元素必须是一个将来的日期 Date,Calendar,Instant,LocalDate等
@FutureOrPresent 被注释的元素必须是一个将来的日期或现在的日期 Date,Calendar,Instant,LocalDate等
@Past 被注释的元素必须是一个过去的日期 Date,Calendar,Instant,LocalDate等
@PastOrPresent 被注释的元素必须是一个过去的日期或现在的日期 Date,Calendar,Instant,LocalDate等
@NotBlank 被注释的元素不为null,并且去除两边空白字符后长度大于0 CharSequence
@NotEmpty 被注释的元素不为null,并且集合不为空 CharSequence, Collection, Map, arrays
@NotNull 被注释的元素不为null Any type
@Null 被注释的元素为null Any type
@Pattern(regex=, flags=) 被注释的元素必须与正则表达式 regex 匹配 CharSequence
@Size(min=, max=) 被注释的元素大小必须介于最小和最大(闭区间)之间 CharSequence, Collection, Map,arrays

接下来对参数校验的实践进行一个简单的demo:

  1. 定义一个参数实体类,对需要校验的参数加上对应的注解
@Data
public class UserInfo {
    
    

    @NotBlank(message = "姓名不能为空")
    public String name;

    @Min(value = 10, message = "年龄不得少于10岁")
    public int age;
    
}

​ 2.定义一个Controller类,对入参加上@Validated注解进行参数校验

@RestController
@Slf4j
public class TestController {
    
    

    @PostMapping("getInfo")
    public String test(@RequestBody @Validated UserInfo userInfo) {
    
    
        log.info("入参:【{}】", userInfo);
        return "success";
    }
}

请求结果如下:
在这里插入图片描述

PART3.分组校验


有时候我们多个接口会复用一个参数对象,里面的参数在A接口中是必填的,在B接口中又是非必填的。

诸如此类的场景就需要使用分组校验的方法,分组校验的灵活搭配,足以应付大部分的场景。

Validator中有一个groups的概念,通过该参数可以帮助我们指定不同的校验分组。

Validated有自己默认的组 Default.class,当我们需要在默认校验的基础上,增加新的分组校验时,建议继承Default,因为默认的groups就是groups = {Default.class}

在这里插入图片描述

Step1.设置分组接口

我们要建的组,就是不同业务使用字段分成的组,举例的业务是一个用户对象,用户有不同的角色,不同的接口会用到这个用户对象的不同字段。比如学生(Student),老师(Teacher):

public interface Student {
    
    
}
public interface Teacher {
    
    
}

Step2.在需要分组校验的参数上加上groups

@Data
public class UserInfo {
    
    

    @NotBlank(message = "姓名不能为空")
    public String name;

    @Min(value = 10, message = "年龄不得少于10岁", groups = {
    
    Default.class})
    public int age;

    @NotBlank(message = "手机号不能为空", groups = {
    
    Teacher.class})
    public String phone;

    @NotEmpty(message = "授课科目不能为空", groups = {
    
    Teacher.class})
    @Size(min = 2, message = "必须至少两个科目", groups = {
    
    Teacher.class})
    private List<String> subjects;

}

Step3.在@Validated上加入对应的分组

@RestController
@Slf4j
public class TestController {
    
    

    @PostMapping("getInfo")
    public String test(@RequestBody @Validated({
    
    Teacher.class}) UserInfo userInfo) {
    
    
        log.info("入参:【{}】", userInfo);
        return "success";
    }
}

ps.分组继承校验:

自定义的分组可以使用继承方式进行校验,比如我们将很多个分组封装到一个特定的分组里面,方便我们自由组合,多个自定义分组下面请看如下的案例:

public interface GroupsOpration extends GroupUpdate{
    
    
}
public interface GroupUpdate extends Default {
    
    
}
public interface GroupDel extends Default {
    
    
}
public interface GroupAdd extends Default {
    
    
}

PART4.对象校验


有时候我们接口的入参里包含一个集合对象元素,这个对象的各个属性也是要分别进行校验的,针对这个场景,我们可以结合@Valid注解,来进行嵌套校验,示例如下:

Step1.重新定义一个入参UsersVo,里面包含由@Valid注解标注的userInfoList这个集合属性,其中每一个元素UserInfo对象的校验约束参考前文的定义:

@Data
public class UsersVo {
    
    

    @NotBlank(message = "ID不能为空")
    public String id;

    @Valid
    @NotEmpty
    public List<UserInfo> userInfoList;

}

Step2.Controller层接口参数加@Validated注解,以实现嵌套校验的目的

 @PostMapping("getInfos")
 public String test(@RequestBody @Validated UsersVo usersVo) {
    
    
    log.info("入参:【{}】", usersVo);
    return "success";
}

PART5.自定义Validator


如果默认的注解规则无法满足业务需求,这时候validator提供了自定义注解的形式帮助开发者可以进行自定的规则校验。

Step1、定义自定义注解

首先第一步是确定自己需要自定义的注解,比如我这里定义了一个检查姓名是否以Zake开头的注解

@Target({
    
    ElementType.FIELD}) // 可以注入的类型,字段和参数类型
@Retention(RUNTIME) // 运行时生效
@Constraint(validatedBy = {
    
    CheckNameValidator.class}) // 指定用于验证元素的验证器
public @interface CheckName {
    
    

    String message() default "姓名不正确";   //提示的信息

    Class<?>[] groups() default {
    
     };  //分组验证,例如只在新增时进行校验等

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

}

三个属性messagegroupspayload都是必须定义的,否则进行校验的时候,会抛出如下的错误

Step2、定义真实注解处理类

需要实现接口ConstraintValidator,泛型的第一个参数为注解类,第二个参数为具体校验对象的类型

public class CheckNameValidator implements ConstraintValidator<CheckName, String> {
    
    

    // 初始化注解的校验内容
    @Override
    public void initialize(CheckName constraintAnnotation) {
    
    
        ConstraintValidator.super.initialize(constraintAnnotation);
    }

    // 具体的校验逻辑
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
    
    
        return value.startsWith("zake");
    }
}

Step3、参数变量上加上注解

@Data
public class UserInfo {
    
    

    @NotBlank(message = "姓名不能为空")
    @CheckName
    public String name;

    @Min(value = 10, message = "年龄不得少于10岁", groups = {
    
    Default.class})
    public int age;

    @NotBlank(message = "手机号不能为空", groups = {
    
    Teacher.class})
    public String phone;

    @NotEmpty(message = "授课科目不能为空", groups = {
    
    Teacher.class})
    @Size(min = 2, message = "必须至少两个科目", groups = {
    
    Teacher.class})
    private List<String> subjects;

}

PART6.异常处理


上述实现了参数校验的目标,但是异常信息的提示是不友好的,关于异常处理还可以进一步进行优化。

如何处理validate异常信息?

  • BindingResult信息处理
  • 控制器进行特定异常处理
  • 统一异常处理
1、BindingResult信息处理

validate提供BindResult对象封装异常信息,需要将该对象 紧跟@Validated注解参数位置之后,注意一定要紧跟,否则是无法注入的,加入后,该对象在校验失败之后,BindResult对象里面封装的基本异常信息既可以由开发者自由处理了。

  @PostMapping("getInfo")
    public String test(@RequestBody @Validated({
    
    Teacher.class}) UserInfo userInfo, BindingResult result) {
    
    
        log.info("入参:【{}】", userInfo);
        if (result.hasErrors()) {
    
    
            FieldError fieldError = result.getFieldError();
            String field = fieldError.getField();
            String msg = fieldError.getDefaultMessage();

            return field + ":" + msg;   // 异常信息的处理返回
        }
        return "success";
    }
2、控制器进行特定异常处理

一般这种方式使用的比较少,在有全局异常处理的情况下,很少在Controller层进行异常处理,某些特殊情况可以用到。

该方式和全局异常处理器类似,只不过定义方法修改到了对应的Controller控制器层。

@RestController
@Slf4j
public class TestController {
    
    

    @PostMapping("getInfo")
    public String test(@RequestBody @Validated({
    
    Teacher.class}) UserInfo userInfo) {
    
    
        log.info("入参:【{}】", userInfo);
        return "success";
    }

    /**
     在控制器层处理异常信息,仅仅适用于当前控制器
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Object processException(MethodArgumentNotValidException e){
    
    
        log.error(e.getMessage());
        return e.getAllErrors().get(0).getDefaultMessage();
    }

}
3、统一异常处理(推荐)

全局统一异常处理是最常用的处理手段,该方法将异常信息组装自定义的结果,也可以使用用来做日志记录和处理。处理方式如下:

Step1、新建全局统一异常处理类,在类名标注:@ControllerAdvice或者@RestControllerAdvice,分别对应Controller层注解@Controller@RestController

@ControllerAdvice -> @Controller
@RestControllerAdvice -> @RestController

Step2、在全局统一异常处理类中对应的方法内部,使用@ExceptionHandler进行方法标注,@ExceptionHandler注解中可以添加参数,参数是某个异常类的class,代表这个方法专门处理该类异常。当异常发生时,Spring会选择最接近抛出异常的处理方法。

代码示例如下:

定义错误码枚举

@Getter
public enum ResultCodeEnum {
    
    

    SUCCESS(200, "成功"),
    FAILED(1001,"失败"),
    ERROR(500, "系统内部错误");


    private int code;
    private String message;

    ResultCodeEnum(int code, String message) {
    
    
        this.code = code;
        this.message = message;
    }
}

定义统一的响应格式:

@Data
public class ResultInfo<T> {
    
    

    public int code;

    public String message;

    public T data;

    public ResultInfo(T data){
    
    
        this.code = ResultCodeEnum.SUCCESS.getCode();
        this.message = ResultCodeEnum.SUCCESS.getMessage();
        this.data = data;
    }

    public ResultInfo(int code, String msg) {
    
    
        this.code = code;
        this.message = msg;
    }
}

全局异常处理器增加对异常信息的拦截,并修改异常时返回的数据格式。

@RestControllerAdvice
public class GlobalExceptionHandlerAdvice {
    
    

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResultInfo<String> methodArgumentNotValidException(MethodArgumentNotValidException e) {
    
    
        // 从异常对象中拿到ObjectError对象
        ObjectError objectError = e.getBindingResult().getAllErrors().get(0);
        // 然后提取错误提示信息进行返回
        return new ResultInfo<>(ResultCodeEnum.FAILED.getCode(), objectError.getDefaultMessage());
    }

    @ExceptionHandler(Exception.class)
    public ResultInfo<String> ExceptionHandler(Exception e) {
    
    
        return new ResultInfo<>(ResultCodeEnum.ERROR.getCode(), ResultCodeEnum.ERROR.getMessage());
    }

}

猜你喜欢

转载自blog.csdn.net/zhzh980/article/details/129229479
今日推荐