学会Spring Boot的参数校验又可以少些十几行代码,真好用!

前言

在开发Web应用时,处理用户输入是不可避免的一环。然而,用户输入往往充满不确定性,可能是格式不正确、类型不匹配,甚至包含恶意内容。为了确保应用的稳定性和安全性,对输入参数进行有效校验显得尤为重要。Spring Boot,作为当前最流行的Java开发框架之一,通过其丰富的特性和集成的库,为我们提供了一套高效、灵活的参数校验机制。本文将深入探讨Spring Boot中的参数校验技术,包括基于JSR-303/JSR-349(Bean Validation)的注解校验、自定义校验器以及如何在不同场景下应用这些校验技术,从而帮助你构建更加健壮、易于维护的Spring Boot应用。
一、为什么需要参数校验
在Web应用中,用户输入是数据流动的起点。然而,用户输入的数据往往难以预测和控制,可能包含各种不符合预期的情况。如果不对这些输入进行校验,就可能导致应用出现各种异常,如类型转换错误、数据格式错误、业务逻辑错误等。这些错误不仅会影响用户体验,还可能对应用的安全性和稳定性构成威胁。因此,在数据进入应用的核心处理流程之前,进行严格的参数校验是非常必要的。
二、常用的校验注解
Spring Boot支持的校验注解非常丰富,包括但不限于:

  • @NotNull:确保字段或参数的值不为null。
  • @NotEmpty:确保字符串、集合或数组不为null且不为空(对于字符串而言,长度大于0;对于集合或数组而言,元素个数大于0)。
  • @NotBlank:仅适用于字符串,确保字段或参数的值不为null且去除首尾空白字符后的长度大于0。
  • @Size(min=value, max=value):限制字符串、集合或数组的长度或元素个数。
  • @Email:确保字段或参数的值是一个有效的电子邮件地址。
  • @Pattern(regex=value):使用正则表达式校验字段或参数的值。

接下来,我们将详细讲解如何在Spring Boot项目中应用这些校验注解,以及如何处理校验失败的情况。同时,我们还将探讨如何自定义校验注解和校验器,以满足更加复杂的校验需求。

实体类参数校验

SpringBoot 使用校验注解不需要新引入任何依赖,是默认支持的。

首先,看在项目中是如何使用校验注解的。先来定义一个用户实体类:

import lombok.Builder;
import lombok.Data;
import javax.validation.constraints.*;
import java.util.List;

@Data
@Builder
public class UserEntity {

    @NotBlank(message = "用户名不能为空")
    private String username;

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

    @NotBlank(message = "邮箱不能为空")
    @Pattern(regexp = "^\\w+([-+.']\\w+)*@\\w+([-.]\\w+)*(\\.\\w+)+$", message = "邮箱格式不正确")
    private String email;

    @NotEmpty(message = "至少包含一位好友")
    private List<UserEntity> friends;

}

编写一个注册接口用来测试:

import com.boot3.demo.commons.ResponseApi;
import com.boot3.demo.entity.UserEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;


/**
 * 用户信息处理器
 * ResponseApi 是我自定义的响应对象,为了方便测试也可以直接返回 UserEntity 对象
 * @CreateTime: 2024-07-01  14:50
 */
@RestController
@RequestMapping("users")
public class UserController {

    @PostMapping("register")
    public ResponseApi<UserEntity> register(@Valid @RequestBody UserEntity user) {
    
        return ResponseApi.success(user);
    }
}

准备就绪以后就可以请求 /users/register 接口验证一下对于 UserEntity 使用的注解是否生效。

{
    "username": "",
    "password": "12312321",
    "email": "123@qq.com",
    "friends": [
        {
            "username": "",
            "password": "1234567",
            "email": "123@qq.com",
            "friends": [
                {}
            ]
        }
    ]
}
  • 测试1:
    image.png
  • 测试2:
    image.png
  • 测试3:
    image.png
  • 测试4:
    image.png

这里你应该注意到了,接口的响应数据对于用户而言是非常的友好的,意思清晰明了,这个是因为我在项目中定义了全局异常处理器统一处理由参数校验所抛出的异常并进一步对响应结果进行封装的结果。

import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.StrUtil;
import com.boot3.demo.commons.ResponseApi;
import lombok.SneakyThrows;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.servlet.http.HttpServletRequest;
import javax.validation.ValidationException;
import java.util.Optional;

/**
 * 全局异常处理
 *
 * @CreateTime: 2024-06-20  13:40
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    public final Logger logger = LoggerFactory.getLogger(this.getClass());

    /**
     * 拦截实体类参数验证异常
     */
    @SneakyThrows
    @ExceptionHandler(value = {MethodArgumentNotValidException.class})
    public ResponseApi validExceptionHandler(HttpServletRequest request, MethodArgumentNotValidException ex) {
        BindingResult bindingResult = ex.getBindingResult();
        FieldError firstFieldError = CollectionUtil.getFirst(bindingResult.getFieldErrors());
        String exceptionStr = Optional.ofNullable(firstFieldError)
                .map(FieldError::getDefaultMessage)
                .orElse(StrUtil.EMPTY);
        logger.error("[{}] {} [ex] {}", request.getMethod(), getUrl(request), exceptionStr);
        return ResponseApi.error(exceptionStr);
    }

    /**
     * 拦截未捕获异常
     */
    @ExceptionHandler(Throwable.class)
    public ResponseApi defaultErrorHandler(HttpServletRequest request, Throwable throwable) {
        logger.error("[{}] {} ", request.getMethod(), getUrl(request), throwable);
        return ResponseApi.error();
    }

    private String getUrl(HttpServletRequest request) {
        if (StringUtils.isEmpty(request.getQueryString())) {
            return request.getRequestURL().toString();
        }
        return request.getRequestURL().toString() + "?" + request.getQueryString();
    }
}

至此,对于实体类的参数校验已经完成,接下来再讲一讲如果不是实体类传参,而是单个或多个String、Integer 等类型的传参应该怎么处理。

普通参数校验

假如我现在再定义一个根据用户名称获取用户信息的接口,如下:

/**
 * 用户信息处理器
 * @CreateTime: 2024-07-01  14:50
 */
@RestController
@RequestMapping("users")
@Validated // 普通参数校验需要和这个注解一起使用,不然不会生效。
public class UserController {

    @PostMapping("register")
    public ResponseApi<UserEntity> register(@Valid @RequestBody UserEntity user) {

        return ResponseApi.success(user);
    }

    @GetMapping("getUserByName")
    public ResponseApi<UserEntity> getUserByName(@RequestParam("username") @NotBlank String username) {

        return ResponseApi.success(test(username));
    }

    /**
     * 为了测试,这里就随便写一个模拟查询数据库操作
     * @CreateTime: 2024-07-01  14:50
     */
    public UserEntity test(String username) {

        return UserEntity
                .builder()
                .username("李白")
                .password("admin")
                .email("admin@qq.com")
                .build();
    }

}

同样的,准备就绪以后就可以请求 /users/getUserByName 接口验证一下对于 username 使用的注解是否生效。

  • 测试1:
    这一次,你可能会发现接口的响应结果和上面不一样,并不是很友好,打开控制台一看,嗯?抛了一个叫 ConstraintViolationException 的异常,是正常拦截成功了,但是好像并没有被全局异常控制器中的 validExceptionHandler() 方法处理,这是因为validExceptionHandler() 方法指定拦截 项目中抛出的 MethodArgumentNotValidException 异常,所以它不在拦截范围内。
    image.png
  • 测试2:
    既然知道它抛出的是什么异常,那好办,在异常处理器中再添加一个方法拦截这个异常就好了,如下:
/**
 * 拦截普通参数验证异常
 */
@ExceptionHandler(value = {ConstraintViolationException.class})
public ResponseApi validExceptionHandler(HttpServletRequest request, ConstraintViolationException  ex) {
    logger.error("[{}] {} [ex] {}", request.getMethod(), getUrl(request), ex.getMessage());
    return ResponseApi.error(ex.getMessage());
}

再次请求一下:
image.png
因为没有对 @NotBlank 注解指定话术,所以展示的为默认话术,可以通过 @NotBlank(message = "用户名不能为空") 自定义拦截话术。
至此,普通参数的校验也完成了。

自定义校验规则

书接上回,假如现在有个需求要求用户的名称是唯一的,不能重复。如果按照以前的习惯写这个需求可能是这样的:

@PostMapping("register")
public ResponseApi<UserEntity> register(@Valid @RequestBody UserEntity user) {
    // 前面校验都通过了,获取出用户的名称
    String username = user.getUsername();
    // 根据用户名查询数据库
    UserEntity userInfo = getUserByName(username);
    // 判断如果 userInfo 不为空就代表用户名重复了
    if (userInfo != null) {
        return ResponseApi.error("用户名已存在");
    }
    return ResponseApi.success(user);
}

至此,这个需求就完成了,可是我不想这样写,我就想通过加一个注解就能解决用户名重复的问题,那怎么办呢?
好办,按照下面步骤来,先定一个注解:

想要了解以下元注解的含义请移步附录1

/**
 * 自定义唯一值参数校验注解
 */
@Documented
@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })
@Retention(RUNTIME)
@Constraint(validatedBy = UniqueValidator.class) // 指定实现校验逻辑的类
public @interface UniqueValue {

    String message() default "用户名不能重复";

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

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

创建一个校验器:

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

/**
 * 自定义唯一参数校验器
 */
public class UniqueValidator implements ConstraintValidator<UniqueValue, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // value 就是入参,比如在用户名上面加了注解,那这个值就是用户名,这里根据用户名查询一下数据库
        return getUserByName(value);
    }

    /**
     * 模拟查询数据库,如果是张三就代表重复了
     */
    private boolean getUserByName(String userName) {
        if ("张三".equals(userName)) {
            return false;
        }
        return true;
    }
}

接下来在 UserEntity 对象的用户名上加上 @UniqueValidator 注解:

@Data
@Builder
public class UserEntity {

    @NotBlank(message = "用户名不能为空")
    @UniqueValue(message = "用户名已存在")
    private String username;

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

    @NotBlank(message = "邮箱不能为空")
    @Pattern(regexp = "^\\w+([-+.']\\w+)*@\\w+([-.]\\w+)*(\\.\\w+)+$", message = "邮箱格式不正确")
    private String email;

    @NotEmpty(message = "至少包含一位好友")
    private List<UserEntity> friends;

}

然后请求再次请求 /users/register 接口:
image.png
至此,自定义校验规则也完成了,整个流程都非常简单,赶快打开电脑练习一下吧!

附录1

在这个自定义注解@UniqueValue的定义中,使用了几个Java注解(也称为元注解)来定义其特性和行为。这些元注解分别是@Documented、@Target、@Retention和@Constraint。下面我将依次详细解释这些元注解的含义:

  1. @Documented:
    • @Documented注解表明该自定义注解(@UniqueValue)在通过javadoc等工具生成文档时,应该被包含进去。这意味着当你查看使用@UniqueValue注解的类的文档时,你可以看到这个注解的信息。默认情况下,自定义注解不会在javadoc中显示。
  2. @Target:
    • @Target注解用于指定被注解的注解(@UniqueValue)可以应用的Java元素类型。在这个例子中,@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER })表明@UniqueValue可以应用于方法、字段、注解类型、构造器以及方法参数上。这为@UniqueValue的使用提供了灵活性,可以根据需要将其应用于不同类型的元素上。
  3. @Retention:
    • @Retention注解指定了被注解的注解(@UniqueValue)的保留策略。保留策略决定了注解在什么级别上可用:源代码(SOURCE)、类文件(CLASS)或运行时(RUNTIME)。在这个例子中,@Retention(RUNTIME)表明@UniqueValue注解在运行时是可以通过反射被读取的。这对于在运行时进行注解处理(如参数校验)是必需的。
  4. @Constraint(validatedBy = UniqueValidator.class):
    • @Constraint是Bean Validation API(JSR 303/349)中定义的,用于标识一个自定义的校验注解。它不是Java标准库中的一部分,而是Bean Validation规范的一部分。
    • validatedBy = UniqueValidator.class指定了实现该注解校验逻辑的类。在这个例子中,UniqueValidator类将包含校验@UniqueValue注解所标记的元素是否满足“唯一性”逻辑的代码。这意味着当Spring Boot或任何支持Bean Validation的框架遇到@UniqueValue注解时,它会调用UniqueValidator类来执行实际的校验逻辑。

总结来说,这些元注解定义了@UniqueValue注解的基本属性和行为,包括它是否应该被文档化、它可以应用于哪些Java元素、它在何处被保留以及它的校验逻辑由哪个类实现。这些定义对于创建功能丰富且易于使用的自定义校验注解至关重要。

  • 5
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值