Java Spring Boot 参数校验及自定义校验

发布时间 2023-12-06 16:14:13作者: 进击的davis

在项目开发中,时常会碰到前端传递过来的请求参数需要校验,毕竟永远不要相信没有经过自己校验的数据,如果是零星几个参数,直接 if...else if ...else... 即可,但数据量大了,同时为了尽可能地增加复用,这里就可以用到参数校验了,如果你觉得框架提供的校验方法不够用,或者你的校验比较个性化,那就自定义校验

环境:

  • Spring Boot:3.1.6
  • JDK:17

声明:

接下来的内容主要基于 [1] 做一定改动,如果想看原文,请点击原文,链接在下面参考中。

1.主要注解

先看常用注解有哪些:

image.png

可以看到注解主要集中于以下几个方面:

  • 针对时间的,以前,现在,未来
  • 针对数值型的,最小,最大,正负情况
  • 针对字符串的,长度大小,是否空
  • 针对布尔值的,true or false

以下是主要注解:

  • @NotNull:值不能为null;

  • @NotEmpty:字符串、集合或数组的值不能为空,即长度大于0;

  • @NotBlank:字符串的值不能为空白,即不能只包含空格;

  • @Size:字符串、集合或数组的大小是否在指定范围内;

  • @Min:数值的最小值;

  • @Max:数值的最大值;

  • @DecimalMin:数值的最小值,可以包含小数;

  • @DecimalMax:数值的最大值,可以包含小数;

  • @Digits:数值是否符合指定的整数和小数位数;

  • @Pattern:字符串是否匹配指定的正则表达式;

  • @Email:字符串是否为有效的电子邮件地址;

  • @AssertTrue:布尔值是否为true;

  • @AssertFalse:布尔值是否为false;

  • @Future:日期是否为将来的日期;

  • @Past:日期是否为过去的日期;

2.注解使用

创建项目&&添加依赖

首先肯定还是先创建一个 Spring Boot web 项目,因为我们会用到参数校验,这里需要在 pom.xml 添加三方包依赖:

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

我们这里假设一种用户场景,后端根据前端提交过来的用户参数做校验,校验通过后,存入数据库中(项目演示为主,忽略数据库的使用),如果校验失败,将失败信息返回给前端。

校验的用户类

package com.example.springbootparamvalidatedemo.param;

import com.example.springbootparamvalidatedemo.util.Phone;
import jakarta.validation.constraints.*;
import lombok.Data;

@Data
public class User {
    @NotBlank(message = "用户名不能为空")
    private String name;

    @NotBlank(message = "密码不能为空")
    @Size(min = 6, max = 30, message = "密码长度在6到30之间")
    private String password;

    @Min(value = 18, message = "必须成年")
    @Max(value = 120, message = "不得超过年龄极限")
    private int age;

    @Pattern(regexp = "^(18[0-9])\d{8}$", message = "格式不正确")
    private String phone;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public String getPhone() {
        return phone;
    }

    public void setPhone(String phone) {
        this.phone = phone;
    }
}

在用户类中,我们仅通过几个基本字段,根据字段属性,添加一定的校验注解。

统一的响应体

通常在项目开发中,我们都会用到统一的响应数据格式,所以这里将响应格式封装后,作为工具类供调用。

package com.example.springbootparamvalidatedemo.util;

import lombok.Data;

import java.io.Serializable;

@Data
public class Resp<T> implements Serializable {
    private int code;
    private boolean success;
    private T data;
    private String msg;

    private Resp(int code, T data, String msg) {
        this.code = code;
        this.data = data;
        this.msg = msg;
        this.success = code == 200;
    }

    public static <T> Resp<T> ok(T data) {
        return new Resp<>(200, data, null);
    }

    public static <T> Resp<T> error(String msg) {
        return new Resp<>(500, null, msg);
    }
}

上面的响应体中,主要简单分为 成功和失败 的两种情况,考虑到通用,我们引入 泛型。

controller类中应用

这里就是简单根据前端传入的用户参数做校验,然后返回响应。

package com.example.springbootparamvalidatedemo.controller;

import com.example.springbootparamvalidatedemo.param.User;
import com.example.springbootparamvalidatedemo.util.Resp;

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/user")
public class UserController {

    @PostMapping("/save")
    public Resp save(@Valid @RequestBody User user) { // @Valid 表示校验参数
        return Resp.ok(user);
    }
}

为了让项目更加完善,引入全局异常处理,对最后的响应做拦截输出。

全局异常处理

package com.example.springbootparamvalidatedemo.exception;

import com.example.springbootparamvalidatedemo.util.Resp;

import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler
    public Resp handleError(BindException e) {
        BindingResult bindingResult = e.getBindingResult();

        return Resp.error(bindingResult.getFieldError().getDefaultMessage());
    }
}

校验演示

我们启动项目,然后在 postman 中测试看看。

这里假定传入了 age 不合规定的值:
image.png

传入全部合规的值:

image.png

从上面结果来看,参数校验是可以用的。

3.自定义注解

自定义实现

尽管框架提供一些校验规则,难免遇到一些现有规则不能覆盖的情况,这里我们就一些特定情况做个自定义的参数校验。

我们可以先观察下现有校验规则是怎样的:

Min

@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(Min.List.class)
@Documented
@Constraint(
    validatedBy = {}
)
public @interface Min {
    String message() default "{jakarta.validation.constraints.Min.message}";

    Class<?>[] groups() default {};

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

    long value();

    @Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface List {
        Min[] value();
    }
}

NotBlank

@Documented
@Constraint(
    validatedBy = {}
)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(NotBlank.List.class)
public @interface NotBlank {
    String message() default "{jakarta.validation.constraints.NotBlank.message}";

    Class<?>[] groups() default {};

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

    @Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface List {
        NotBlank[] value();
    }
}

理论上说,我们写个类似的注解接口,是不是就可以用呢?

实际上,在做自定义的参数校验的时候,除了自定义的注解接口,另外我们还需要再实现一个接口 ConstraintValidator

我们在 User 类中加个参数,比如加个用户地区,限定 Asia 或 Ameraica,只有这两个地区的人才可以注册。

下面开始我们的自定义编码。

Address

package com.example.springbootparamvalidatedemo.util;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.*;

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {AddressValidator.class})
@Target({ElementType.METHOD, ElementType.FIELD,
        ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR,
        ElementType.PARAMETER, ElementType.TYPE_USE})
public @interface Address {
    String message() default "不在合法地区范围内";

    Class<?>[] groups() default {};

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

实现类

package com.example.springbootparamvalidatedemo.util;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

import java.util.Arrays;

public class AddressValidator implements ConstraintValidator<Address, String> {
    private static  String[] addresses = {"Asia", "America"};

    @Override
    public boolean isValid(String address, ConstraintValidatorContext context) {
        if (Arrays.asList(addresses).contains(address)) {
            return true;
        }

        return false;
    }
}

在 User类 中加入 address 字段:

public String getAddress() {
    return address;
}

public void setAddress(String address) {
    this.address = address;
}

@NotBlank(message = "地址非空")
@Address
private String address;

测试:
address 不在范围内的

image.png

address 在范围内

image.png

可以看到这里是生效了的。

自定义要点

注解接口:

  • @Constraint(validatedBy = {PhoneValidator.class}):用于指定验证器类;

  • @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE}):指定@Phone注解可以作用在方法、字段、构造函数、参数以及类型上

实现类[2]:

想让自定义验证注解生效,需要实现 ConstraintValidator 接口。接口的第一个参数是 自定义注解类型,第二个参数是 被注解字段的类型。这里因为订单ID 是 String 类型,我们第二个参数定义为 String 就可以了,需要提到的一点是 ConstraintValidator 接口的实现类无需添加 @Component 它在启动的时候就已经被加载到容器中了。

参考: