【SpringBoot】Java优雅地实现接口数据校验


在工作中写过Java程序的朋友都知道,目前使用Java开发服务最主流的方式就是通过SpringMVC定义一个Controller层接口,并将接口请求或返回参数分别定义在一个Java实体类中,这样Spring MVC在接收到Http请求(POST/GET)后,就会自动将请求报文自动映射成一个Java对象。这样的代码通常是这样写的:

@RestController
public class CheckController {
    @PostMapping("/insert")
    public Result validatedDemo1(@Validated @RequestBody User1Dto user1Dto){
        return ResultUtil.success(user1Dto);
    }

这样的代码相信大家并不陌生,但在后续的逻辑实现过程中却会遇到这样的问题:“在接收请求参数后如何实现报文对象数据值的合法性校验?”。一些同学也可能认为这并不是什么问题,因为具体某个参数字段是否为空、值的取值是否在约定范围、格式是否合法等等,在业务代码中校验就好了。例如可以在Service实现类中对报文格式进行各种if-else的数据校验。

从功能上说冗余的if-else代码没啥毛病,但从代码的优雅性来说冗长的if-else代码会显得非常臃肿。接下来的内容将给大家介绍一种处理此类问题的实用方法。具体将从以下几个方面进行介绍:

  • 使用@Validated注解实现Controller接口层数据直接绑定校验;
  • 扩展约束性注解实现数据取值范围的校验;
  • 更加灵活的对象数据合法性校验工具类封装;
  • 数据合法性校验结果异常统一返回处理;

1. Controller接口层数据绑定校验

实际上在Java开发中目前普通使用的Bean数据校验工具是"hibernate-validator",它是一个hibernete独立的jar包,所以使用这个jar包并不需要一定要集成Hibernete框架。该jar包主要实现并扩展了javax.validation(是一个基于JSR-303标准开发出来的Bean校验规范)接口。

由于Spring Boot在内部默认集成了"hibernate-validator",所以使用Spring Boot构建的Java工程可以直接使用相关注解来实现Bean的数据校验。例如我们最常编写的Controller层接口参数对象,可以在定义Bean类时直接编写这样的代码

@Data
public class CreateOrderDTO {
 
    @NotNull(message = "订单号不能为空")
    private String orderId;
    @NotNull(message = "订单金额不能为空")
    @Min(value = 1, message = "订单金额不能小于0")
    private Integer amount;
    @Pattern(regexp = "^1[3|4|5|7|8][0-9]{9}$", message = "用户手机号不合法")
    private String mobileNo;
    private String orderType;
    private String status;
}

如上所示代码,我们可以使用@NotNull注解来约束该字段必须不能为空,也可以使用@Min注解来约束字段的最小取值,或者还可以通过@Pattern注解来使用正则表达式来约束字段的格式(如手机号格式)等等。

以上这些注解都是“hibernate-validator”依赖包默认提供的,更多常用的注解还有很多,例如:
在这里插入图片描述
利用这些约束注解,我们就可以很轻松的搞定接口数据校验,而不需要在业务逻辑中编写大量的if-else来进行数据合法性校验。而定义好Bean参数对象并使用相关注解实现参数值约束后,在Controller层接口定义中只需要使用@Validated注解就可以实现在接收参数后自动进行数据绑定校验了,具体代码如下:

    @PostMapping("/insert2")
    public Result validatedDemo2(@Validated @RequestBody Team1Dto team1Dto){
        return ResultUtil.success(team1Dto);
    }

如上所示,在Controller层中通过Spring提供的@Validated注解可以自动实现数据Bean的绑定校验,如果数据异常则会统一抛出校验异常!

1.1 约束性注解扩展

在“hibernate-validator”依赖jar包中,虽然提供了很多很方便的约束注解,但是也有不满足某些实际需要的情况,例如我们想针对参数中的某个值约定其值的枚举范围,如orderType订单类型只允许传“pay”、“refund”两种值,那么现有的约束注解可能就没有特别适用的了。此外,如果对这样的枚举值,我们还想在约束定义中直接匹配代码中的枚举定义,以更好地统一接口参数与业务逻辑的枚举定义。那么这种情况下,我们还可以自己扩展定义相应地约束注解逻辑。

接下来我们定义新的约束注解@EnumValue,来实现上面我们所说的效果,具体代码如下:

@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
@Retention(RUNTIME)
@Documented
@Constraint(validatedBy = {EnumValueValidator.class})
public @interface EnumValue {
 
    //默认错误消息
    String message() default "必须为指定值";
 
    //支持string数组验证
    String[] strValues() default {};
 
    //支持int数组验证
    int[] intValues() default {};
 
    //支持枚举列表验证
    Class<?>[] enumValues() default {};
 
    //分组
    Class<?>[] groups() default {};
 
    //负载
    Class<? extends Payload>[] payload() default {};
 
    //指定多个时使用
    @Target({FIELD, METHOD, PARAMETER, ANNOTATION_TYPE})
    @Retention(RUNTIME)
    @Documented
    @interface List {
        EnumValue[] value();
    }
 
    /**
     * 校验类逻辑定义
     */
    class EnumValueValidator implements ConstraintValidator<EnumValue, Object> {
 
        //字符串类型数组
        private String[] strValues;
        //int类型数组
        private int[] intValues;
        //枚举类
        private Class<?>[] enumValues;
 
        /**
         * 初始化方法
         *
         * @param constraintAnnotation
         */
        @Override
        public void initialize(EnumValue constraintAnnotation) {
            strValues = constraintAnnotation.strValues();
            intValues = constraintAnnotation.intValues();
            enumValues = constraintAnnotation.enumValues();
        }
 
        /**
         * 校验方法
         *
         * @param value
         * @param context
         * @return
         */
        @SneakyThrows
        @Override
        public boolean isValid(Object value, ConstraintValidatorContext context) {
            //针对字符串数组的校验匹配
            if (strValues != null && strValues.length > 0) {
                if (value instanceof String) {
                    for (String s : strValues) {//判断值类型是否为Integer类型
                        if (s.equals(value)) {
                            return true;
                        }
                    }
                }
            }
            //针对整型数组的校验匹配
            if (intValues != null && intValues.length > 0) {
                if (value instanceof Integer) {//判断值类型是否为Integer类型
                    for (Integer s : intValues) {
                        if (s == value) {
                            return true;
                        }
                    }
                }
            }
            //针对枚举类型的校验匹配
            if (enumValues != null && enumValues.length > 0) {
                for (Class<?> cl : enumValues) {
                    if (cl.isEnum()) {
                        //枚举类验证
                        Object[] objs = cl.getEnumConstants();
                        //这里需要注意,定义枚举时,枚举值名称统一用value表示
                        Method method = cl.getMethod("getValue");
                        for (Object obj : objs) {
                            Object code = method.invoke(obj, null);
                            if (value.equals(code.toString())) {
                                return true;
                            }
                        }
                    }
                }
            }
            return false;
        }
    }
}

如上所示的@EnumValue约束注解,是一个非常实用的扩展,通过该注解我们可以实现对参数取值范围(不是大小范围)的约束,它支持对int、string以及enum三种数据类型的约束,具体使用方式如下:

/**
 * 定制化注解,支持参数值与指定类型数组列表值进行匹配
 *  (缺点是需要将枚举值写死在字段定义的注解中)
 */
@EnumValue(strValues = {"pay", "refund"}, message = "订单类型错误")
private String orderType;
/**
 * 定制化注解,实现参数值与枚举列表的自动匹配校验(能更好地与实际业务开发匹配)
 */
@EnumValue(enumValues = Status.class, message = "状态值不在指定范围")
private String status;

如上所示代码,该扩展注解既可以使用strValues或intValues属性来编程列举取值范围,也可以直接通过enumValues来绑定枚举定义。但是需要注意,处于通用考虑,具体枚举定义的属性的名称要统一匹配为value、desc,例如Status枚举定义如下:

public enum Status {
    PROCESSING(1, "处理中"),
    SUCCESS(2, "订单已完成");
    Integer value;
    String desc;
 
    Status(Integer value, String desc) {
        this.value = value;
        this.desc = desc;
    }
 
    public Integer getValue() {
        return value;
    }
 
    public String getDesc() {
        return desc;
    }
}

通过注解扩展,就能实现更多方便的约束性注解!

1.2 更加灵活的数据校验工具类封装

除了上面直接在Controller层使用@Validated进行绑定数据校验外,在有些情况,例如你的参数对象中的某个字段是一个复合对象,或者业务层的某个方法所定义的入参对象也需要进行数据合法性校验,那么这种情况下如何实现像Controller层一样的校验效果呢?

需要说明在这种情况下@Validated已经无法直接使用了,因为@Validated注解发挥作用主要是Spring MVC在接收参数的过程中实现了自动数据绑定校验,而在普通的业务方法或者复合参数对象中是没有办法直接绑定校验的。这种情况下,我们可以通过定义ValidateUtils工具类来实现一样的校验效果,具体代码如下:

public class ValidatorUtils {
 
    private static Validator validator =
                       Validation.buildDefaultValidatorFactory().getValidator();
 
    /**
     * bean整体校验,有不合规范,抛出第1个违规异常
     */
    public static void validate(Object obj, Class<?>... groups) {
        Set<ConstraintViolation<Object>> resultSet =
                                          validator.validate(obj, groups);
        if (resultSet.size() > 0) {
            //如果存在错误结果,则将其解析并进行拼凑后异常抛出
            List<String> errorMessageList = resultSet.stream().
                        map(o -> o.getMessage()).collect(Collectors.toList());
            StringBuilder errorMessage = new StringBuilder();
            errorMessageList.stream().
              forEach(o -> errorMessage.append(o + ";"));
                   throw new IllegalArgumentException(errorMessage.toString());
        }
    }
}

如上所示,我们定义了一个基于"javax.validation"接口的工具类实现,这样就可以在非@Validated直接绑定校验的场景中通过校验工具类来实现对Bean对象约束注解的校验处理,具体使用代码如下:

public boolean orderCheck(OrderCheckBO orderCheckBO) {
    //对参数对象进行数据校验
    ValidatorUtils.validate(orderCheckBO);
    return true;
}

而方法入参对象则还是可以继续使用前面我们介绍的约束性注解进行约定,例如上述方法的入参对象定义如下:

@Data
@Builder
public class OrderCheckBO {
 
    @NotNull(message = "订单号不能为空")
    private String orderId;
    @Min(value = 1, message = "订单金额不能小于0")
    private Integer orderAmount;
    @NotNull(message = "创建人不能为空")
    private String operator;
    @NotNull(message = "操作时间不能为空")
    private String operatorTime;
}

这样在编程体验上就可以整体上保持一致!

2. 数据合法性校验结果异常统一处理

通过前面我们所讲的各种约束注解,我们实现了对Controller层接口以及业务方法参数对象的统一数据校验。而为了保持校验异常处理的统一处理和错误报文统一输出,我们还可以定义通用的异常处理机制,来保证各类数据校验错误都能以统一错误格式反馈给调用方。具体代码如下:

@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {
    /**
     * 统一处理参数校验错误异常(非Spring接口数据绑定验证)
     *
     * @param response
     * @param e
     * @return
     */
    @ExceptionHandler(BindException.class)
    @ResponseBody
    public ResponseResult<?> processValidException(HttpServletResponse response, BindException e) {
        response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
        //获取校验错误结果信息,并将信息组装
        List<String> errorStringList = e.getBindingResult().getAllErrors()
                .stream().map(ObjectError::getDefaultMessage).collect(Collectors.toList());
        String errorMessage = String.join("; ", errorStringList);
        response.setContentType("application/json;charset=UTF-8");
        log.error(e.toString() + "_" + e.getMessage(), e);
        return ResponseResult.systemException(GlobalCodeEnum.GL_FAIL_9998.getCode(),
                errorMessage);
    }
 
    /**
     * 统一处理参数校验错误异常
     *
     * @param response
     * @param e
     * @return
     */
    @ExceptionHandler(IllegalArgumentException.class)
    @ResponseBody
    public ResponseResult<?> processValidException(HttpServletResponse response, IllegalArgumentException e) {
        response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
        String errorMessage = String.join("; ", e.getMessage());
        response.setContentType("application/json;charset=UTF-8");
        log.error(e.toString() + "_" + e.getMessage(), e);
        return ResponseResult.systemException(GlobalCodeEnum.GL_FAIL_9998.getCode(),
                errorMessage);
    }
 
    ...
}

如上所示,我们定义了针对前面两种数据校验方式的统一异常处理机制,这样数据校验的错误信息就能通过统一的报文格式反馈给调用端,从而实现接口数据报文的统一返回!

其中通用的接口参数对象ResponseResult的代码定义如下:

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonPropertyOrder({"code", "message", "data"})
public class ResponseResult<T> implements Serializable {
 
    private static final long serialVersionUID = 1L;
 
    /**
     * 返回的对象
     */
    @JsonInclude(JsonInclude.Include.NON_NULL)
    private T data;
    /**
     * 返回的编码
     */
    private Integer code;
    /**
     * 返回的信息
     */
    private String message;
 
    /**
     * @param data 返回的数据
     * @param <T>  返回的数据类型
     * @return 响应结果
     */
    public static <T> ResponseResult<T> OK(T data) {
        return packageObject(data, GlobalCodeEnum.GL_SUCC_0);
    }
 
    /**
     * 自定义系统异常信息
     *
     * @param code
     * @param message 自定义消息
     * @param <T>
     * @return
     */
    public static <T> ResponseResult<T> systemException(Integer code, String message) {
        return packageObject(null, code, message);
    }
}

当然,这样的统一报文格式也不仅仅只处理异常返回,正常的数据报文格式也可以通过该对象来进行统一封装!

3. @Validated和@Valid区别

参数校验

  • Spring Validation验证框架对参数的验证机制提供了@Validated(Spring’s JSR-303规范,是标准JSR-303的一个变种)
  • javax提供了@Valid(标准JSR-303规范),配合BindingResult可以直接提供参数验证结果。

主要区别

  • 分组
    • @Validated:提供了一个分组功能,可以在入参验证时,根据不同的分组采用不同的验证机制,
    • @Valid:作为标准JSR-303规范,还没有吸收分组的功能。
  • 注解位置
    • @Validated:可以用在类型、方法和方法参数上。但是不能用在成员属性(字段)上
    • @Valid:可以用在方法、构造函数、方法参数和成员属性(字段)上
    • 两者是否能用于成员属性(字段)上直接影响能否提供嵌套验证的功能
  • 嵌套验证
    • 在比较两者嵌套验证时,先说明下什么叫做嵌套验证。比如我们现在有个实体叫做Item:

嵌套验证举例

public class Item {

    @NotNull(message = "id不能为空")
    @Min(value = 1, message = "id必须为正整数")
    private Long id;

    @Valid // 嵌套验证必须用@Valid
    @NotNull(message = "props不能为空")
    @Size(min = 1, message = "props至少要有一个自定义属性")
    private List<Prop> props;
}

然后我们在ItemController的addItem函数上再使用@Validated或者@Valid,就能对Item的入参进行嵌套验证。此时Item里面的props如果含有Prop的相应字段为空的情况,Spring Validation框架就会检测出来,bindingResult就会记录相应的错误。

注意: Spring validation验证框架对入参实体进行嵌套验证必须在相应属性(字段)加上@Valid而不是@Validated

4. 总结一下@Validated和@Valid在嵌套验证功能上的区别

  • @Validated:用在方法入参上无法单独提供嵌套验证功能。不能用在成员属性(字段)上,也无法提示框架进行嵌套验证。能配合嵌套验证注解@Valid进行嵌套验证。

  • @Valid:用在方法入参上无法单独提供嵌套验证功能。能够用在成员属性(字段)上,提示验证框架进行嵌套验证。能配合嵌套验证注解@Valid进行嵌套验证。

相关文章

  1. @Validated或者@Valid
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值