28.1 概述

B/S 系统中多数会在客户端(前端)正式提交 HTTP 请求之前校验用户输入的数据,这也是出于简单及用户体验性方面的考虑。但是对于所有的系统来说,服务端校验都是是不可缺少的。

在后端进行二次校验的理由有很多,总结起来其实就是一句话:一切前端安全措施都是纸老虎(详见前端教程第20章)。

28.2 传统的原始作法

如果不做专门的学习,可能每个软件工程师都会按照类似如下的方式实现后端校验

  1. public String add(UserEntry user) {
  2. if(user.getAge() == null){
  3. return "年龄不能为空";
  4. }
  5. if(user.getAge() > 120){
  6. return "年龄不能超过120";
  7. }
  8. if(user.getName().isEmpty()){
  9. return "用户名不能为空";
  10. }
  11. // 省略一堆参数校验...
  12. return "OK";
  13. }

业务代码还没开始,仅参数校验就写了一堆判断。这样写虽然没什么错,但是给人的感觉就是:不优雅、太原始、不专业,代码可读性也很差,一看就是在这方面没有经验。

28.3 使用注解做参数校验

正规Spring Boot项目是不应该出现这样代码的——它提供了整合的参数校验解决方案 Spring Boot Validation。

28.3.1 依赖项目

在Spring Bootv 2.3之前的版本只需要引入 web 依赖即包含了validation校验包,在此之后的版本把validation校验包独立了出来,需要单独引入依赖:

  1. <dependency>
  2. <groupId>org.springframework.boot</groupId>
  3. <artifactId>spring-boot-starter-validation</artifactId>
  4. </dependency>

28.3.2 可用的校验注解

Spring Boot Validation 提供了很多用于校验的注解,清单如下:

注解 校验功能 注解 校验功能
@AssertFalse 必须是false @Negative 负数(不包括0)
@AssertTrue 必须是true @NegativeOrZero 负数或0
@DecimalMax 小于等于给定的值 @NotBlank 不为null并有至少一个非空白字符
@DecimalMin 大于等于给定的值 @NotEmpty 不为null并且不为空
@Digits 最大整数位数和最大小数位数 @NotNull 不为null
@Email 校验是否符合Email格式 @Null null
©Future 必须是将来的时间 @Past 必须是过去的时间
@FutureOrPresent 当前或将来时间 @PastOrPresent 必须是过去的时间,包含现在
@Max 最大值 @Pattern 必须满足正则表达式
@Min 最小值 @PositiveOrZero 正数或0
@Size 校验容器的元素个数

28.3.3 单个参数校验

使用这些检验注解的方法非常简单,只需要给Controller类加上 @Validated 注解,然后在需校验的参数前加上对应校验规则注解即可:

package com.longser.union.cloud.controller;

import com.longser.union.cloud.data.model.UserEntry;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.Valid;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;

@RestController
@Validated
@RequestMapping("/test")
public class ValidatedController {

    @GetMapping("/name-mail")
    public String normal(@NotBlank String name, @Email @NotBlank String email) {
        return "Hello " + name + ". Your email is " + email;
    }

    @RequestMapping("/object")
    public UserEntry oejectMethod(@Valid UserEntry user) {
        return user;
    }

    @RequestMapping("/objectjson")
    public UserEntry jsonMethod(@Valid @RequestBody UserEntry user) {
        return user;
    }
}

直接拿访问 http://localhost:8088/test/name-mail (不提供任何参数),会得到如下的结果

{
    "success": false,
    "errorCode": 400,
    "errorMessage": "normal.email: 不能为空, normal.name: 不能为空",
    "data": null
}

然后进一步查看后台输出的信息:
[Exception] ConstraintViolationException :normal.email: 不能为空, normal.name: 不能为空
可见此时抛出的异常为 ConstraintViolationException。

接下来给出名字和格式错误的邮件地址,可以得到如下的结果:

{
    "success": false,
    "errorCode": 400,
    "errorMessage": "normal.email: 不是一个合法的电子邮件地址",
    "data": null
}

此时抛出的异常仍旧为 ConstraintViolationException。

28.3.4 对象参数校验

如前文代码所示,如果参数是对象,那么首先需要在该对象参数前面加上 @Valid 注解

    @GetMapping("/object")
    public UserEntry oejectMethod(@Valid UserEntry user) {
        return user;
    }

然后在该对象的类定义中给没给需要校验的属性上面加上对应规则的注解:

@Data
public class UserEntry {

        private Long id;
        @NotBlank
        private String userName;
        private String nickName;
        @Email
        @NotBlank
        private String mobile;

直接拿访问 http://localhost:8088/test/object(不提供任何参数),会得到如下的结果

{
    "success": false,
    "errorCode": 400,
    "errorMessage": "Field error in object 'userEntry' on field 'userName': rejected value [null]; codes [NotBlank.userEntry.userName,NotBlank.userName,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [userEntry.userName,userName]; arguments []; default message [userName]]; default message [不能为空]\nField error in object 'userEntry' on field 'mobile': rejected value [null]; codes [NotBlank.userEntry.mobile,NotBlank.mobile,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [userEntry.mobile,mobile]; arguments []; default message [mobile]]; default message [不能为空]",
    "data": null
}

查看控制台信息可以了解到此时抛出的异常为BindException。

如果前端传递过来的是用JSON表示的对象,那么参数需要增加@RequestBody注解

    @GetMapping("/objectjson")
    public UserEntry jsonMethod(@Valid @RequestBody UserEntry user) {
        return user;
    }

用Postman测试传递空的JSON数据
image.png
此时在控制台输出的信息限时异常类型为MethodArgumentNotValidException。

28.5 优化校验异常的信息格式

显然ConstraintViolationException、BindException、MethodArgumentNotValidException默认给出的出错信息都过于冗长复杂,非常不直观,为此我们需要优化它们的信息格式。

具体作法为在GlobalExceptionHandler中显示地定义这三个异常的处理器,并且自定义反馈信息:

    private String toFormalString(List<String> list) {
        return removeBrackets(list.toString());
    }

    private String removeBrackets(String message) {
        message = message.substring(message.indexOf('[') + 1);
        message = message.substring(0, message.indexOf(']'));

        return message;
    }

    /**
     * 处理单个参数校验失败抛出的异常
     */
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<RestfulResult<String>> handleParamsException(ConstraintViolationException ex) {
        List<String> errorList = new ArrayList<>();

        Set<ConstraintViolation<?>> violations = ex.getConstraintViolations();
        for (ConstraintViolation<?> violation : violations) {
            StringBuilder message = new StringBuilder();
            Path path = violation.getPropertyPath();
            String[] pathArr = path.toString().split("\\.");

            String msg;
            if(pathArr.length >1) {
                msg = message.append(pathArr[1]).append(violation.getMessage()).toString();
            } else {
                msg = message.append(path.toString()).append(violation.getMessage()).toString();
            }

            errorList.add(msg);
        }

        return exceptionResult(ex, toFormalString(errorList));
    }

    /**
     * 处理对象参数校验失败抛出的异常
     */
    @ExceptionHandler(BindException.class)
    public ResponseEntity<RestfulResult<String>> handleBindParamsException(BindException ex) {
        List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors();
        List<String> collect = fieldErrors.stream()
                .map(o -> o.getField() + o.getDefaultMessage())
                .collect(Collectors.toList());

        return exceptionResult(ex, toFormalString(collect));
    }

    /**
     * 处理 json 请求体调用接口对象参数校验失败抛出的异常
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<RestfulResult<String>> handdleJsonParamsException(MethodArgumentNotValidException ex) {
        BindingResult bindingResult = ex.getBindingResult();
        List<String> errorList = new ArrayList<>();

        for (FieldError fieldError : bindingResult.getFieldErrors()) {
            String msg = String.format("%s%s", fieldError.getField(), fieldError.getDefaultMessage());
            errorList.add(msg);
        }

        return exceptionResult(ex, toFormalString(errorList));
    }

重新启动后再次重复上面的测试,可以得到下面的结果

{
    "success": false,
    "errorCode": 400,
    "errorMessage": "email不是一个合法的电子邮件地址",
    "data": null
}
{
    "success": false,
    "errorCode": 400,
    "errorMessage": "userName不能为空, mobile不能为空",
    "data": null
}
{
    "success": false,
    "errorCode": 400,
    "errorMessage": "mobile不能为空, userName不能为空",
    "data": null
}

版权说明:本文由北京朗思云网科技股份有限公司原创,向互联网开放全部内容但保留所有权力。