Spring Boot + validator 实现全注解式的数据校验,真优雅!

01、故事背景

关于参数合法性验证的重要性就不多说了,即使前端对参数做了基本验证,后端依然也需要进行验证,以防不合规的数据直接进入服务器,如果不对其进行拦截,严重的甚至会造成系统直接崩溃

本文结合自己在项目中的实际使用经验,主要以实用为主,对数据合法性验证做一次总结,不了解的朋友可以学习一下,同时可以立马实践到项目上去。

下面我们通过几个示例来演示如何判断参数是否合法,废话不多说,直接撸起来!

02、断言验证

对于参数的合法性验证,最初的做法比较简单,自定义一个异常类。

public class CommonException extends RuntimeException {
    private Integer code;
    public Integer getCode() {
        return code;
    }
    public CommonException(String message) {
        super(message);
        this.code = 500;
    }
    public CommonException(Integer code, String message) {
        super(message);
        this.code = code;
    }
}

当检查到某个参数不合法的时候,直接抛异常!

@RestController
public class HelloController {
    @RequestMapping("/upload")
    public void upload(MultipartFile file) {
        if (file == null) {
            throw new CommonException("请选择上传文件!");
        }
        //.....
    }
}

最后写一个统一异常拦截器,对抛异常的逻辑进行兜底处理。

这种做法比较简单直观,如果当前参数既要判断是否为空,又要判断长度是否超过最大限制的时候,代码就会显得很臃肿,而且复用性很差

于是,程序界的大佬想到了一个更加优雅又能节省代码的方式,创建一个断言类工具类,专门用来判断参数的是否合法,如果不合法就抛异常,示例如下:

/**
 * 断言工具类
 */
public abstract class LocalAssert {

    public static void isTrue(boolean expression, String message) throws CommonException {
        if (!expression) {
            throw new CommonException(message);
        }
    }
    public static void isStringEmpty(String param, String message) throws CommonException{
        if(StringUtils.isEmpty(param)) {
            throw new CommonException(message);
        }
    }
    public static void isObjectEmpty(Object object, String message) throws CommonException {
        if (object == null) {
            throw new CommonException(message);
        }
    }
    public static void isCollectionEmpty(Collection coll, String message) throws CommonException {
        if (coll == null || (coll.size() == 0)) {
            throw new CommonException(message);
        }
    }
}

当我们需要对参数进行验证的时候,直接通过这个类就可以完成,示例如下:

@RestController
public class HelloController {
    @RequestMapping("/save")
    public void save(String name, String email) {
        LocalAssert.isStringEmpty(name, "用户名不能为空!");
        LocalAssert.isStringEmpty(email, "邮箱不能为空!");

        //.....
    }
}

相比上面的实现方式,这种处理逻辑,代码明显要简洁的多!

类似这样的工具类还很多,比如spring也提供了一个名为Assert的断言工具类,在开发的时候,可以直接使用!

03、注解验证

下面我们要介绍的是另一种更简洁的参数验证逻辑,使用注解来对数据进行合法性验证,不仅代码会变得很简洁,阅读起来也十分令人赏心悦目!

Spring Boot 工程为例,下面我们一起来看看具体的实践方式。

3.1、添加依赖包

首先在pom.xml中引入spring-boot-starter-web依赖包即可,它会自动将注解验证相关的依赖包打入工程!

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

3.2、编写注解校验请求对象

接着创建一个实体User,用于封装用户注册时的请求参数,并在参数属性上添加对应的注解验证规则

import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
public class User {
    @NotBlank(message = "用户名不能为空!")
    private String userName;
    @Email(message = "邮箱格式不正确")
    @NotBlank(message = "邮箱不能为空!")
    private String email;
    @NotBlank(message = "密码不能为空!")
    @Size(min = 8, max = 16,message = "请输入长度在8~16位的密码")
    private String userPwd;
    @NotBlank(message = "确认密码不能为空!")
    private String confirmPwd;
    // set、get方法等...
}

3.3、编写请求接口

web层创建一个register()注册接口方法,同时在请求参数上添加@Valid注解,示例如下:

import javax.validation.Valid;
@RestController
public class UserController {
    @RequestMapping("/register")
    public ResultMsg register(@RequestBody @Valid User user){
        if(!user.getUserPwd().equals(user.getConfirmPwd())){
            throw new CommonException(4001, "确认密码与密码不相同,请确认!");
        }
        //业务处理...
        return ResultMsg.success();
    }
}

3.4、编写全局异常处理器

最后自定义一个异常全局处理器,用于处理异常逻辑,如下:

@ControllerAdvice
public class GlobalExceptionHandler {
    private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    /**
     * 拦截Controller层的异常
     * @param e
     * @return
     */
    @ExceptionHandler(value = {Exception.class})
    @ResponseBody
    public Object exceptionHandler(HttpServletRequest request, Exception e){
        LOGGER.error("【统一异常拦截】请求地址:{}, 错误信息:{}", request.getRequestURI(), e.getMessage());
        // 注解验证抛出的异常
        if(e instanceof MethodArgumentNotValidException){
            // 获取错误信息
            String error = ((MethodArgumentNotValidException) e).getBindingResult().getFieldError().getDefaultMessage();
            return ResultMsg.fail(500, error);
        }
        // 自定义抛出的异常
        if(e instanceof CommonException){
            return ResultMsg.fail(((CommonException) e).getCode(), e.getMessage());
        }
        return ResultMsg.fail(999, e.getMessage());
    }
}

统一响应对象ResultMsg,如下:

public class ResultMsg<T> {
    /**状态码**/
    private int code;
    /**结果描述**/
    private String message;
    /**结果集**/
    private T data;
    /**时间戳**/
    private long timestamp;
    // set、get方法等...
}

3.5、服务测试

启动项目,使用postman来验证一下代码的正确性,看看效果如何?

  • 测试字段是否为空

  • 测试邮箱是否合法

  • 测试密码长度是否符合要求

  • 测试密码与确认密码是否相同

可以看到,验证结果与预期一致!

04、实现原理

看完以上的注解验证参数的案例之后,是不是觉得很神奇。当要验证某个参数是否合法的时候,只需要在属性上配置一下校验注解即可,应用非常简单。

事实上,熟悉 SpringMVC 源码的同学可能知道,Spring Boot 内置了一个hibernate-validator校验组件,通过它来完成对请求时入参上的注解验证。

如果想要在 Java 项目中单独使用这个校验工具,我们可以这样做。

4.1、添加相关依赖包

首先,引入以下几个依赖包!

<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>6.0.9.Final</version>
</dependency>
<dependency>
     <groupId>javax.el</groupId>
     <artifactId>javax.el-api</artifactId>
     <version>3.0.0</version>
 </dependency>
 <dependency>
    <groupId>org.glassfish.web</groupId>
    <artifactId>javax.el</artifactId>
    <version>2.2.6</version>
 </dependency>

4.2、编写注解校验工具

然后,编写一个注解校验工具,用于将注解验证结果中的错误信息提取出来。

/**
 * 注解校验工具类
 */
public class ValidatorUtils {
    /**
     * 获取对象中所有注解校验证异常信息
     * @param object
     * @return
     */
    public static String validated(Object object){
        List<String> errorMessageList = new ArrayList<>();
        // 1.获取注解校验工厂
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        // 2.获取注解校验对象
        Validator validator = factory.getValidator();
        // 3.对参数上的注解进行验证
        Set<ConstraintViolation<Object>> violations = validator.validate(object);
        // 4.获取校验结果
        for (ConstraintViolation<Object> constraintViolation : violations) {
            errorMessageList.add(constraintViolation.getMessage());
        }
        return errorMessageList.toString();
    }
}

当然你也可以对ValidatorUtils工具类进行改造,当有异常信息的时候,直接抛异常,类似于 Spring Boot 中的处理方式!

4.3、测试工具类

最后,测试一下代码的正确性。

继续以上文的对象为例,编写一个带有注解校验的对象,示例代码如下:

public class User {
    @NotBlank(message = "用户名不能为空!")
    private String userName;
    @Email(message = "邮箱格式不正确")
    @NotBlank(message = "邮箱不能为空!")
    private String email;
    @NotBlank(message = "密码不能为空!")
    @Size(min = 8, max = 16,message = "请输入长度在8~16位的密码")
    private String userPwd;
    // set、get方法等...
}

编写一个测试类,验证一下工具类逻辑。

public class TestMain {
    public static void main(String[] args) {
        User user = new User();
        user.setUserName(null);
        user.setEmail("2");
        user.setUserPwd("3");
        // 通过注解验证参数合法性
        System.out.println(ValidatorUtils.validated(user));
    }
}

启动服务,执行结果如下:

[邮箱格式不正确, 请输入长度在8~16位的密码, 用户名不能为空!]

实际上,SpringMVC 对请求中的入参验证,主要通过方法参数解析器来完成。当收到请求时,会遍历当前方法参数对象上的注解,如果注解是@Validated或者注解的名字以Valid开头,此时会对参数对象执行注解校验逻辑。

最后的校验处理逻辑与上文介绍的类似,底层实现都引用自hibernate-validator校验组件。稍有不同的是,当检查到有非法信息时,SpringMVC 直接抛异常。

4.4、自定义注解验证

默认的情况下,依赖包已经给我们提供了非常多的校验注解,如下!

  • JSR 提供的校验注解!

  • Hibernate Validator 提供的校验注解

但是某些情况,例如性别这个参数,可能需要我们自己去手动验证。

其实,我们也可以自定义一个注解来完成参数的校验,也便于进一步了解注解验证的原理。

自定义注解验证,实现方式如下!

首先,创建一个Sex注解。

@Target({FIELD})
@Retention(RUNTIME)
@Constraint(validatedBy = SexValidator.class)
@Documented
public @interface Sex {
    String message() default "性别值不在可选范围内";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

然后,创建一个SexValidator类,实现自ConstraintValidator接口

public class SexValidator implements ConstraintValidator<Sex, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        Set<String> sexSet = new HashSet<String>();
        sexSet.add("男");
        sexSet.add("女");
        return sexSet.contains(value);
    }
}

最后,在User实体类上加入一个性别参数,使用自定义注解进行校验!

public class User {
    @NotBlank(message = "用户名不能为空!")
    private String userName;
    @Email(message = "邮箱格式不正确")
    @NotBlank(message = "邮箱不能为空!")
    private String email;
    @NotBlank(message = "密码不能为空!")
    @Size(min = 8, max = 16,message = "请输入长度在8~16位的密码")
    private String userPwd;
    /**
     * 自定义注解校验
     */
    @Sex(message = "性别输入有误!")
    private String sex;
    // set、get方法等...
}

将上文的测试类进行重新改造。

public static void main(String[] args) {
    User user = new User();
    user.setUserName("张三");
    user.setEmail("123@123.com");
    user.setUserPwd("12345678");
    user.setSex("未知");
    // 通过注解验证参数合法性
    System.out.println(ValidatorUtils.validated(user));
}

启动服务,运行结果如下:

[性别输入有误!]

结果与预期一致!

05、总结

参数验证,在开发中使用非常频繁,如何优雅的进行验证,让代码变得更加可读,是业界大佬一直在追求的目标,希望本篇文章能帮助大家。

示例代码地址:

https://gitee.com/pzblogs/spring-boot-example-demo

06、参考

1、SpringMVC源码

2、https://juejin.im/post/5dc8bc745188254e7a155ba0#heading-14

3、https://www.cnblogs.com/hujunzheng/p/12570921.html

4