【Spring Boot】SpringBoot参数验证以及实现原理

时间:2021-03-02 01:25:53

前言

参数验证很重要,是平时开发环节中不可少的一部分,但是我想很多后端同事会偷懒,干脆不做,这样很可能给系统的稳定性和安全性带来严重的危害。
那么在Spring Boot应用中如何做好参数校验工作呢,本文提供了小技巧以及验证实现原理,你知道几个呢?

SpringBoot参数验证技巧(12个技巧)

一、使用验证注解

Spring Boot 提供了内置的验证注解,可以帮助简单、快速地对输入字段进行验证,例如检查 null 或空字段、强制执行长度限制、使用正则表达式验证模式以及验证电子邮件地址。

  • 一些最常用的验证注释包括:

    • @NotNull:指定字段不能为空。
    • @NotEmpty:指定列表字段不能为空。
    • @NotBlank:指定字符串字段不得为空或仅包含空格。
    • @Min 和 @Max:指定数字字段的最小值和最大值。
    • @Pattern:指定字符串字段必须匹配的正则表达式模式。
    • @Email:指定字符串字段必须是有效的电子邮件地址。
  • 具体用法参考下面例子:

public class User {
    @NotNull
    private Long id;

    @NotBlank
    @Size(min = 2, max = 50)
    private String firstName;

    @NotBlank
    @Size(min = 2, max = 50)
    private String lastName;

    @Email
    private String email;

    @NotNull
    @Min(18)
    @Max(99)
    private Integer age;

    @NotEmpty
    private List<String> hobbies;

    @Pattern(regexp = "[A-Z]{2}\d{4}")
    private String employeeId;
 }

二、使用自定义验证注解

虽然 Spring Boot 的内置验证注释很有用,但它们可能无法涵盖所有情况。如果有特殊参数验证的场景,可以使用 Spring 的 JSR 303 验证框架创建自定义验证注释。自定义注解可以让你的的验证逻辑更具可重用性和可维护性。

假设我们有一个应用程序,用户可以在其中创建帖子。每个帖子都应该有一个标题和一个正文,并且标题在所有帖子中应该是唯一的。虽然 Spring Boot 提供了用于检查字段是否为空的内置验证注释,但它没有提供用于检查唯一性的内置验证注释。在这种情况下,我们可以创建一个自定义验证注解来处理这种情况。

  • 首先,我们创建自定义约束注解UniqueTitle :
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueTitleValidator.class)
public @interface UniqueTitle {
    String message() default "Title must be unique";

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

    Class<? extends Payload>[] payload() default {};
}
  • 接下来,我们创建一个PostRepository接口,目的是从数据库中检索帖子:
public interface PostRepository extends JpaRepository<Post, Long> {
    Post findByTitle(String title);
}
  • 然后我们需要定义验证器类 UniqueTitleValidator,如下所示:
@Component
public class UniqueTitleValidator implements ConstraintValidator<UniqueTitle, String> {

    @Autowired
    private PostRepository postRepository;

    @Override
    public boolean isValid(String title, ConstraintValidatorContext context) {
        if (title == null) {
            return true;
        }
        return Objects.isNull(postRepository.findByTitle(title));
    }
}

UniqueTitleValidator实现了ConstraintValidator接口,它有两个泛型类型:
第一个是自定义注解UniqueTitle
第二个是正在验证的字段类型(在本例中为String). 我们还自动装配了PostRepository 类以从数据库中检索帖子。
isValid()方法通过查询 PostRepository 来检查 title 是否为 null 或者它是否是唯一的。如果 title 为 null 或唯一,则验证成功,并返回 true。

  • 最后定义了自定义验证注释和验证器类后,我们现在可以使用它来验证 Spring Boot 应用程序中的帖子标题:
public class Post {
    @UniqueTitle
    private String title;

    @NotNull
    private String body;
}

我们已将 @UniqueTitle 注释应用于 Post 类中的 title 变量。验证此字段时,这将触发 UniqueTitleValidator 类中定义的验证逻辑。

三、在服务器端验证

除了前端或者客户端做了验证意外,服务器端验证输入是至关重要的。它可以确保在处理或存储任何恶意或格式错误的数据之前将其捕获,这对于应用程序的安全性和稳定性至关重要。
假设我们有一个允许用户创建新帐户的 REST 端点。端点需要一个包含用户用户名和密码的 JSON 请求体。为确保输入有效,我们可以创建一个 DTO(数据传输对象)类并将验证注释应用于其字段:

public class UserDTO {

    @NotBlank
    private String username;

    @NotBlank
    private String password;
}

我们使用@NotBlank注解来确保username和password字段不为空或 null。
接下来,我们可以创建一个控制器方法来处理 HTTP POST 请求并在创建新用户之前验证输入:

@RestController
@RequestMapping("/users")
@Validated
public class UserController {
    @Autowired
    private UserService userService;

    @PostMapping
    public ResponseEntity<String> createUser(@Valid @RequestBody UserDTO userDto) {
        userService.createUser(userDto);
        return ResponseEntity.status(HttpStatus.CREATED).body("User created successfully");
    }
}

我们使用 Spring 的@Validated注解来启用方法级验证,我们还将 @Valid 注释应用于 userDto 参数以触发验证过程。

四、提供有意义的错误信息

当验证失败时,必须提供清晰简洁的错误消息来描述出了什么问题以及如何修复它。

这是一个示例,如果我们有一个允许用户创建新用户的 RESTful API。我们要确保姓名和电子邮件地址字段不为空,年龄在 18 到 99 岁之间,除了这些字段,如果用户尝试使用重复的“用户名”创建帐户,我们还会提供明确的错误消息或“电子邮件”。
为此,我们可以定义一个带有必要验证注释的模型类 User,如下所示:

public class User {

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

    @NotBlank(message = "Email不能为空")
    @Email(message = "无效的Emaild地址")
    private String email;

    @NotNull(message = "年龄不能为空")
    @Min(value = 18, message = "年龄必须大于18")
    @Max(value = 99, message = "年龄必须小于99")
    private Integer age;
}

我们使用 message属性为每个验证注释提供了自定义错误消息。

接下来,在我们的 Spring 控制器中,我们可以处理表单提交并使用 @Valid 注释验证用户输入:

@RestController
@RequestMapping("/users")
public class UserController {
    @Autowired
    private UserService userService;

    @PostMapping
    public ResponseEntity<String> createUser(@Valid @RequestBody User user, BindingResult result) {
        if (result.hasErrors()) {
            List<String> errorMessages = result.getAllErrors().stream()
                    .map(DefaultMessageSourceResolvable::getDefaultMessage)
                    .collect(Collectors.toList());
            return ResponseEntity.badRequest().body(errorMessages.toString());
        }

        // save the user to the database using UserService
        userService.saveUser(user);

        return ResponseEntity.status(HttpStatus.CREATED).body("User created successfully");
    }
}

我们使用 @Valid 注释来触发 User 对象的验证,并使用 BindingResult 对象来捕获任何验证错误。

五、将 i18n 用于错误消息

如果你的应用程序支持多种语言,则必须使用国际化 (i18n) 以用户首选语言显示错误消息。
以下是在 Spring Boot 应用程序中使用 i18n 处理错误消息的示例

  • 首先,在资源目录下创建一个包含默认错误消息的 messages.properties 文件
# messages.properties
user.name.required=Name is required.
user.email.invalid=Invalid email format.
user.age.invalid=Age must be a number between 18 and 99.
  • 接下来,为每种支持的语言创建一个 messages_xx.properties 文件,例如,中文的 messages_zh_CN.properties。
user.name.required=名称不能为空.
user.email.invalid=无效的email格式.
user.age.invalid=年龄必须在1899岁之间.
  • 然后,更新您的验证注释以使用本地化的错误消息
public class User {
    @NotNull(message = "{user.id.required}")
    private Long id;

    @NotBlank(message = "{user.name.required}")
    private String name;

    @Email(message = "{user.email.invalid}")
    private String email;

    @NotNull(message = "{user.age.required}")
    @Min(value = 18, message = "{user.age.invalid}")
    @Max(value = 99, message = "{user.age.invalid}")
    private Integer age;
}
  • 最后,在Spring 配置文件中配置 MessageSource bean 以加载 i18n 消息文件
@Configuration
public class AppConfig {
    @Bean
    public MessageSource messageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        messageSource.setBasename("messages");
        messageSource.setDefaultEncoding("UTF-8");
        return messageSource;
    }

    @Bean
    public LocalValidatorFactoryBean validator() {
        LocalValidatorFactoryBean validatorFactoryBean = new LocalValidatorFactoryBean();
        validatorFactoryBean.setValidationMessageSource(messageSource());
        return validatorFactoryBean;
    }
}

现在,当发生验证错误时,错误消息将根据随请求发送的“Accept-Language”标头以用户的首选语言显示。

六、使用分组验证

验证组是 Spring Boot 验证框架的一个强大功能,允许您根据其他输入值或应用程序状态应用条件验证规则。

现在有一个包含三个字段的User类的情况下:firstName、lastName和email。我们要确保如果 email 字段为空,则 firstName 或 lastName 字段必须非空。否则,所有三个字段都应该正常验证。

为此,我们将定义两个验证组:EmailNotEmpty 和 Default。EmailNotEmpty 组将包含当 email 字段不为空时的验证规则,而 Default 组将包含所有三个字段的正常验证规则。

创建带有验证组的 User 类

public class User {
    @NotBlank(groups = Default.class)
    private String firstName;

    @NotBlank(groups = Default.class)
    private String lastName;

    @Email(groups = EmailNotEmpty.class)
    private String email;

    // getters and setters omitted for brevity
    public interface EmailNotEmpty {}
    public interface Default {}
}

请注意,我们在User类中定义了两个接口,EmailNotEmpty和 Default。这些将作为我们的验证组。

接下来,我们更新Controller使用这些验证组

@RestController
@RequestMapping("/users")
@Validated
public class UserController {
    public ResponseEntity<String> createUser(
            @Validated({org.example.model.ex6.User.EmailNotEmpty.class}) @RequestBody User userWithEmail,
            @Validated({User.Default.class}) @RequestBody User userWithoutEmail)
    {
        // Create the user and return a success response
       
    }
}

我们已将@Validated注释添加到我们的控制器,表明我们想要使用验证组。我们还更新了 createUser 方法,将两个 User 对象作为输入,一个在 email 字段不为空时使用,另一个在它为空时使用。
@Validated 注释用于指定将哪个验证组应用于每个 User 对象。对于 userWithEmail 参数,我们指定了 EmailNotEmpty 组,而对于 userWithoutEmail 参数,我们指定了 Default 组。

进行这些更改后,现在将根据“电子邮件”字段是否为空对“用户”类进行不同的验证。如果为空,则 firstName 或 lastName 字段必须非空。否则,所有三个字段都将正常验证。

七、对复杂逻辑使用跨域验证

如果需要验证跨多个字段的复杂输入规则,可以使用跨字段验证来保持验证逻辑的组织性和可维护性。跨字段验证可确保所有输入值均有效且彼此一致,从而防止出现意外行为。

假设我们有一个表单,用户可以在其中输入任务的开始日期和结束日期,并且我们希望确保结束日期不早于开始日期。我们可以使用跨域验证来实现这一点。

  • 首先,我们定义一个自定义验证注解EndDateAfterStartDate:
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = EndDateAfterStartDateValidator.class)
public @interface EndDateAfterStartDate {
    String message() default "End date must be after start date";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}
  • 然后,我们创建验证器EndDateAfterStartDateValidator:
public class EndDateAfterStartDateValidator implements ConstraintValidator<EndDateAfterStartDate, TaskForm> {
    @Override
    public boolean isValid(TaskForm taskForm, ConstraintValidatorContext context) {
        if (taskForm.getStartDate() == null || taskForm.getEndDate() == null) {
            return true;
        }

        return taskForm.getEndDate().isAfter(taskForm.getStartDate());
    }
}
  • 最后,我们将EndDateAfterStartDate注释应用于我们的表单对象TaskForm:
@EndDateAfterStartDate
public class TaskForm {
    @NotNull
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate startDate;

    @NotNull
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate endDate;
}

现在,当用户提交表单时,验证框架将自动检查结束日期是否晚于开始日期,如果不是,则提供有意义的错误消息。

八、对验证错误使用异常处理

可以使用异常处理ExceptionHandler来统一捕获和处理验证错误。
以下是如何在 Spring Boot 中使用异常处理来处理验证错误的示例:

@RestControllerAdvice
public class RestExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
                                                                  HttpHeaders headers, HttpStatus status,
                                                                  WebRequest request) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("timestamp", LocalDateTime.now());
        body.put("status", status.value());

        // Get all errors
        List<String> errors = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(x -> x.getDefaultMessage())
                .collect(Collectors.toList());

        body.put("errors", errors);

        return new ResponseEntity<>(body, headers, status);
    }
}
  • 在这里,我们创建了一个用 @RestControllerAdvice 注解的 RestExceptionHandler 类来处理我们的 REST API 抛出的异常。

  • 然后我们创建一个用 @ExceptionHandler 注解的方法来处理在验证失败时抛出的 MethodArgumentNotValidException。
    在处理程序方法中,我们创建了一个 Map 对象来保存错误响应的详细信息,包括时间戳、HTTP 状态代码和错误消息列表。我们使用 MethodArgumentNotValidException 对象的 getBindingResult() 方法获取所有验证错误并将它们添加到错误消息列表中。

  • 最后,我们返回一个包含错误响应详细信息的ResponseEntity对象,包括作为响应主体的错误消息列表、HTTP 标头和 HTTP 状态代码。
    有了这个异常处理代码,我们的 REST API 抛出的任何验证错误都将被捕获并以结构化和有意义的格式返回给用户,从而更容易理解和解决问题。

九、测试你的验证逻辑

需要为你的验证逻辑编写单元测试,以帮助确保它正常工作。

@DataJpaTest
public class UserValidationTest {

    @Autowired
    private TestEntityManager entityManager;

    @Autowired
    private Validator validator;

    @Test
    public void testValidation() {
        User user = new User();
        user.setFirstName("John");
        user.setLastName("Doe");
        user.setEmail("invalid email");

        Set<ConstraintViolation<User>> violations = validator.validate(user);

        assertEquals(1, violations.size());
        assertEquals("must be a well-formed email address", violations.iterator().next().getMessage());
    }
}

我们使用 JUnit 5 编写一个测试来验证具有无效电子邮件地址的“用户”对象。然后我们使用 Validator 接口来验证 User 对象并检查是否返回了预期的验证错误。

十、 PathVariable校验

@GetMapping("/path/{group:[a-zA-Z0-9_]+}/{userid}")
@ResponseBody
public String path(@PathVariable("group") String group, @PathVariable("userid") Integer userid) {
    return group + ":" + userid;
}

用法是:路径变量:正则表达式。当请求URI不满足正则表达式时,客户端将收到404错误码。不方便的地方是,不能通过捕获异常的方式,向前端返回统一的、自定义格式的响应参数。

十一、方法参数校验

@GetMapping("/validate1")
@ResponseBody
public String validate1(
        @Size(min = 1,max = 10,message = "姓名长度必须为1到10")@RequestParam("name") String name,
        @Min(value = 10,message = "年龄最小为10")@Max(value = 100,message = "年龄最大为100") @RequestParam("age") Integer age) {
    return "validate1";
}

十二、异常拦截

通过设置全局异常处理,统一向前端返回校验信息

import com.scj.springbootdemo.WebResult;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.CollectionUtils;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;

import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import java.util.List;
import java.util.Set;

/**
 * 全局异常处理器
 */
@ControllerAdvice
public class GlobalExceptionHandler {

    private Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    /**
     * 用来处理bean validation异常
     * @param ex
     * @return
     */
    @ExceptionHandler(ConstraintViolationException.class)
    @ResponseBody
    public  WebResult resolveConstraintViolationException(ConstraintViolationException ex){
        WebResult errorWebResult = new WebResult(WebResult.FAILED);
        Set<ConstraintViolation<?>> constraintViolations = ex.getConstraintViolations();
        if(!CollectionUtils.isEmpty(constraintViolations)){
            StringBuilder msgBuilder = new StringBuilder();
            for(ConstraintViolation constraintViolation :constraintViolations){
                msgBuilder.append(constraintViolation.getMessage()).append(",");
            }
            String errorMessage = msgBuilder.toString();
            if(errorMessage.length()>1){
                errorMessage = errorMessage.substring(0,errorMessage.length()-1);
            }
            errorWebResult.setInfo(errorMessage);
            return errorWebResult;
        }
        errorWebResult.setInfo(ex.getMessage());
        return errorWebResult;
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseBody
    public WebResult resolveMethodArgumentNotValidException(MethodArgumentNotValidException ex){
        WebResult errorWebResult = new WebResult(WebResult.FAILED);
        List<ObjectError>  objectErrors = ex.getBindingResult().getAllErrors();
        if(!CollectionUtils.isEmpty(objectErrors)) {
            StringBuilder msgBuilder = new StringBuilder();
            for (ObjectError objectError : objectErrors) {
                msgBuilder.append(objectError.getDefaultMessage()).append(",");
            }
            String errorMessage = msgBuilder.toString();
            if (errorMessage.length() > 1) {
                errorMessage = errorMessage.substring(0, errorMessage.length() - 1);
            }
            errorWebResult.setInfo(errorMessage);
            return errorWebResult;
        }
        errorWebResult.setInfo(ex.getMessage());
        return errorWebResult;
    }
}


Spring method validation的实现原理

spring method validation实现原理研究

  • 一言以蔽之:
    spring validation方法级别校验的实现是通过MethodValidationPostProcessor类动态注册AOP切面,使用MethodValidationInterceptor对切点方法织入增强。

注意:必须要掌握:AOP的基本概念、拦截器的功能和原理、spring aware接口的功能和大致原理。 如果对以上知识的不太清楚的话,建议首先研究下这些。

具体实现流程如下所示:

  • 如下图所示,在MethodValidationPostProcessor的afterPropertiesSet方法中,动态创建了一个AnnotationMatchingPointcut类型切点,同时调用createMethodValidationAdvice方法创建出对应的增强。

注意:afterPropertiesSet方法是InitializingBean接口中定义的方法,用于在spring容器初始化的时候,进行对Bean的一些初始化操作,调用的时机是在bean的属性都set完毕。

【Spring Boot】SpringBoot参数验证以及实现原理

  • AnnotationMatchingPointcut,见文知意,即注解匹配类型的切点,MethodValidationPostProcessor会创建一个这样的切点。如下图,其中classAnnotationType接收到的值Validated.class,表明该切点匹配所有标注了Validated注解的类,同时checkInherited参数是true,表明即使子类不标注Validated注解,但是其父类或者接口标注之后,那么子类也会命中切点。
    【Spring Boot】SpringBoot参数验证以及实现原理

  • 创建完切点之后,会创建增强,创建过程如下所示:
    【Spring Boot】SpringBoot参数验证以及实现原理

  • 其中MethodValidationInterceptor是aop联盟MethodInterceptor接口的实现,MethodInterceptor接口中声明了invoke方法,在该方法实现中,可以织入对应的增强。

@FunctionalInterface
public interface MethodInterceptor extends Interceptor {

	/**
	 * Implement this method to perform extra treatments before and
	 * after the invocation. Polite implementations would certainly
	 * like to invoke {@link Joinpoint#proceed()}.
	 * @param invocation the method invocation joinpoint
	 * @return the result of the call to {@link Joinpoint#proceed()};
	 * might be intercepted by the interceptor
	 * @throws Throwable if the interceptors or the target object
	 * throws an exception
	 */
	Object invoke(MethodInvocation invocation) throws Throwable;

}
  • 其中MethodInterceptor织入增强的代码如下所示:
    【Spring Boot】SpringBoot参数验证以及实现原理

  • 在这里调用Bean validation的检验接口

  1. 进行方法参数的校验
execVal.validateParameters(invocation.getThis(), 
    			methodToValidate,  invocation.getArguments(), groups);
  1. 进行方法返回值的校验
execVal.validateReturnValue(invocation.getThis(), 
						methodToValidate, returnValue, groups);
  1. 如果存在违反约束的情况,将会抛出ConstraintViolationException异常,我们可以使用ExceptionHandler显示捕获该异常,然后返回前端对应的message。
if (!result.isEmpty()) {
	throw new ConstraintViolationException(result);
}
  • 捕获异常代码可以参考如下:
@ControllerAdvice
public class GlobalExceptionHandler {

    @ResponseBody
    @ExceptionHandler
    public String handleException(Throwable e) {
        //方法级别校验异常,注意笔者这里使用快速失败机制,如果存在一条违反的约定,那么就不会继续后续的校验,所以这里返回的ConstraintViolations只有一条
        //读者可以根据自己的需求,按需返回对应的错误信息
        if (e instanceof ConstraintViolationException) {
            return ((ConstraintViolationException) e).getConstraintViolations().iterator().next().getMessage();
        }

        //对象级别校验,绑定异常
        if (e instanceof BindException) {
            return e.getMessage();
        }

        return e.getMessage();
    }
}

相关标准

JSR 303 是Bean验证的规范 ,Hibernate Validator 是该规范的参考实现,它除了实现规范要求的注解外,还额外实现了一些注解。
validation-api-1.1.0.jar 包括如下约束注解:

约束注解 说明
@AssertFalse 被注释的元素必须为 false
@AssertTrue 被注释的元素必须为 true
@DecimalMax(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@DecimalMin(value) 被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@Digits (integer, fraction) 被注释的元素必须是一个数字,其值必须在可接受的范围内
@Null 被注释的元素必须为null
@NotNull 被注释的元素必须不为null
@Min(value) 被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@Max(value) 被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@Size(max, min) 被注释的元素的大小必须在指定的范围内
@Past 被注释的元素必须是一个过去的日期
@Future 被注释的元素必须是一个将来的日期
@Pattern(value) 被注释的元素必须符合指定的正则表达式

hibernate-validator-5.3.6.jar 包括如下约束注解:

约束注解 说明
@Email 被注释的元素必须是电子邮箱地址
@Length 被注释的字符串的大小必须在指定的范围内
@NotBlank 被注释的字符串的必须非空
@NotEmpty 被注释的字符串、集合、Map、数组必须非空
@Range 被注释的元素必须在合适的范围内
@SafeHtml 被注释的元素必须是安全Html
@URL 被注释的元素必须是有效URL

总结

客户端验证可以通过向用户提供即时反馈并减少对服务器的请求数量来改善用户体验。但是,不应依赖它作为验证输入的唯一方法。客户端验证很容易被绕过或操纵,因此必须在服务器端验证输入,以确保安全性和数据完整性。

有效的验证对于任何 Web 应用程序的稳定性和安全性都是必不可少的。Spring Boot 提供了一套工具和库来简化验证逻辑并使其更易于维护。通过遵循本文中讨论的最佳实践,您可以确保您的验证组件有效并提供出色的用户体验。