前言

​ 在日常的开发中,后端经常会对前端传来的参数进行校验,这会导致大量的重复代码出现在后台代码中,会让我们的代码显得很臃肿

​ 这种对数据验证很常见,在JSR(Java Specification Requests)中就有一项JSR-303专门用来处理这种情况,它是针对在Java EE and Java SE中对Java Bean validation的提案,目标是为Java应用程序开发人员提供类级约束声明和验证工具,以及约束元数据 repository和 query API

​ JSR-303只提供了接口声明,并没有具体实现,而Hibernate Validator实现了这套规范,并增加了一些属于自己的特性

Springboot2.3之前引入web依赖默认会引入Hibernate Validator,但是在之后就不会引入了,详细信息可以查看这个ISSUE #19550,现在引入需要添加pom依赖

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

一、校验注解

1.1 jsr-303 标准注解

jsr标准注解,位于javax.validation.constraints下

注解名作用支持的类型
@AssertFalse必须为falseBoolean boolean null
@AssertTrue必须为trueBoolean boolean null
@DecimalMax所标记的属性必须小于等于给定的最大值BigDecimal BigInteger CharSequence byte short int long 还有它们的包装类 null
@DecimalMin所标记的属性必须大于等于给定的最大值BigDecimal BigInteger CharSequence byte short int long 还有它们的包装类 null
@Digits必须是数字,在给定的范围内BigDecimal BigInteger CharSequence byte short int long 还有它们的包装类 null
@Email必须满足邮箱格式CharSequence, null
@Future必须是未来的时间java.util.Date java.util.Calendar
java.time.Instant java.time.LocalDate java.time.LocalDateTime java.time.LocalTime java.time.MonthDay java.time.OffsetDateTime java.time.OffsetTime java.time.Year java.time.YearMonth java.time.ZonedDateTime
java.time.chrono.HijrahDate java.time.chrono.JapaneseDate java.time.chrono.MinguoDate java.time.chrono.ThaiBuddhistDate
null
@FutureOrPresent必须是现在或未来的时间java.util.Date java.util.Calendar
java.time.Instant java.time.LocalDate java.time.LocalDateTime java.time.LocalTime java.time.MonthDay java.time.OffsetDateTime java.time.OffsetTime java.time.Year java.time.YearMonth java.time.ZonedDateTime
java.time.chrono.HijrahDate java.time.chrono.JapaneseDate java.time.chrono.MinguoDate java.time.chrono.ThaiBuddhistDate
null
@Max必须小于等于给定的最大值BigDecimal,BigInteger,byte,short,int,long,还有它们的包装类,null(和DecimalMax相比,没有字符串)
@Min必须大于等于给定的最大值BigDecimal,BigInteger,byte,short,int,long,还有它们的包装类,null(和DecimalMax相比,没有字符串)
@Negative必须是负数BigDecimal BigInteger byte short int long float double 还有它们的包装类 null
@NegativeOrZero必须是负数或0BigDecimal BigInteger byte short int long float double 还有它们的包装类 null
@NotBlank必须不是null并且包含至少一个非空白字符CharSequence
@NotEmpty必须不为null并且不为空CharSequence Collection Map Array
@NotNull必须不为空any type
@Null必须为空any type
@Past必须是过去的时间java.util.Date java.util.Calendar
java.time.Instant java.time.LocalDate java.time.LocalDateTime java.time.LocalTime java.time.MonthDay java.time.OffsetDateTime java.time.OffsetTime java.time.Year java.time.YearMonth java.time.ZonedDateTime
java.time.chrono.HijrahDate java.time.chrono.JapaneseDate java.time.chrono.MinguoDate java.time.chrono.ThaiBuddhistDate
null
@PastPresent必须是过去或现在的时间java.util.Date java.util.Calendar
java.time.Instant java.time.LocalDate java.time.LocalDateTime java.time.LocalTime java.time.MonthDay java.time.OffsetDateTime java.time.OffsetTime java.time.Year java.time.YearMonth java.time.ZonedDateTime
java.time.chrono.HijrahDate java.time.chrono.JapaneseDate java.time.chrono.MinguoDate java.time.chrono.ThaiBuddhistDate
null
@Pattern必须匹配正则表达式CharSequence null
@Positive必须是正数BigDecimal BigInteger byte short int long float double 还有它们的包装类 null
@PositiveOrZero必须是正数或0BigDecimal BigInteger byte short int long float double 还有它们的包装类 null
@Sizesize必须在给定范围内CharSequence Collection Map Array
@Valid递归校验对象的属性any type

注:允许为null的属性为null时就不会校验,除了NotNull

1.2 Hibernate注解

位于org.hibernate.validator.constraints下

注解名作用支持的类型
@Length检测字符串长度String null
@Range检测数字范围必须是数字或能转为数字的字符串 null
@ScriptAssert类级别,支持JSR-223脚本表达式验证
@UniqueElements检测每个元素都是唯一的Collection
@URL检测字符串是否URLString

1.3 @Valid和@Validated区别

@Valid@Validated
提供者JSR-303规范Spring
所在包javax.validationorg.springframework.validation.annotation
是否支持分组
标记位置METHOD, FIELD, CONSTRUCTOR, PARAMETER, TYPE_USETYPE,METHOD,PARAMETER
是否支持嵌套校验

注:使用@Validated时,不会校验所标记对象的方法

二、组校验使用

下面以一个User类为例子,演示group validate使用

2.1 pom依赖

	<properties>
        <java.version>1.8</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <spring-boot.version>2.3.7.RELEASE</spring-boot.version>
    </properties>

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

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

2.2 实体类

@Data
public class User {

    @NotNull(groups = {UserGroup.Query.class, UserGroup.Update.class, UserGroup.Delete.class}, message = "id不能为空")
    @Null(groups = {UserGroup.Insert.class, UserGroup.Login.class}, message = "不能传入id")
    private Integer id;

    @NotBlank(groups = {UserGroup.Insert.class, UserGroup.Login.class}, message = "账号不能为空")
    @Size(min = 6, max = 16, groups = {UserGroup.Insert.class, UserGroup.Login.class, UserGroup.Update.class}, message = "账号必须在6-16位")
    private String username;

    @NotBlank(groups = {UserGroup.Insert.class, UserGroup.Login.class}, message = "密码不能为空")
    @Size(min = 6, max = 16, groups = {UserGroup.Insert.class, UserGroup.Login.class, UserGroup.Update.class}, message = "密码必须在6-16位")
    private String password;

    @Range(min = 0, max = 150, groups = {UserGroup.Insert.class, UserGroup.Update.class}, message = "年龄不能超过0-150")
    @NotNull(groups = UserGroup.Insert.class, message = "年龄不能为空")
    private Integer age;

    @Email(groups = {UserGroup.Insert.class, UserGroup.Update.class}, message = "邮箱格式不对")
    @NotNull(groups = {UserGroup.Insert.class}, message = "邮箱不能为空")
    private String email;

    // 手机号正则表达式,见 https://github.com/VincentSit/ChinaMobilePhoneNumberRegex
    @Pattern(regexp = "^(?:\\+?86)?1(?:3\\d{3}|5[^4\\D]\\d{2}|8\\d{3}|7(?:[0-35-9]\\d{2}|4(?:0\\d|1[0-2]|9\\d))|9[0-35-9]\\d{2}|6[2567]\\d{2}|4(?:(?:10|4[01])\\d{3}|[68]\\d{4}|[579]\\d{2}))\\d{6}$",
            groups = {UserGroup.Insert.class,UserGroup.Update.class}, message = "手机号格式错误")
    @NotNull(groups = {UserGroup.Insert.class}, message = "手机号不能为空")
    private String mobile;

}

2.3 全局异常处理类

@RestControllerAdvice
public class GloadExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<String> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        BindingResult bindingResult = e.getBindingResult();
        List<FieldError> fieldErrors = bindingResult.getFieldErrors();
        List<ObjectError> objectErrors = bindingResult.getGlobalErrors();

        String returnResult = Stream.concat(objectErrors.stream(), fieldErrors.stream()).map(ObjectError::getDefaultMessage)
                .collect(Collectors.joining(System.lineSeparator()));

        return ResponseEntity.badRequest().body(returnResult);

    }
}

2.4 配置类

@Configuration
public class MyConfiguration {

    @Value("${spring.jackson.date-format:yyyy-MM-dd HH:mm:ss}")
    private String pattern;

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
        return builder -> {
            DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern);

            //返回时间数据序列化
            builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(formatter));
            //接收时间数据反序列化
            builder.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(formatter));
        };
    }
    
    @Bean
    public Validator validator(){
        ValidatorFactory validatorFactory = Validation.byProvider( HibernateValidator.class )
                .configure()
                .addProperty( "hibernate.validator.fail_fast", "true" )
                .buildValidatorFactory();
        return validatorFactory.getValidator();
    }

}

2.5 组定义

public interface CommonGroup  {

    interface Query{

    }

    interface Insert {

    }

    interface Update {

    }

    interface Delete {

    }
}
public interface UserGroup extends CommonGroup{
    interface Login{};
}

2.6 Controller

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


    @PostMapping("/login")
    ResponseEntity login(@RequestBody @Validated(UserGroup.Login.class) User user) {
        return ResponseEntity.ok(user);
    }

    @PostMapping("/register")
    ResponseEntity register(@RequestBody @Validated(UserGroup.Insert.class) User user) {
        return ResponseEntity.ok(user);
    }

    @PostMapping("/update")
    ResponseEntity update(@RequestBody @Validated(UserGroup.Update.class) User user) {

        return ResponseEntity.ok(user);
    }

    @PostMapping("/query")
    ResponseEntity query(@RequestBody @Validated(UserGroup.Query.class) User user) {
        return ResponseEntity.ok(user);
    }

    @PostMapping("/delete")
    ResponseEntity delete(@RequestBody @Validated(UserGroup.Delete.class) User user) {
        return ResponseEntity.ok(user);
    }

}

2.7 测试类

@SpringBootTest
class DemovalidApplicationTests {

    @Autowired
    private  WebApplicationContext webApplicationContext;

    private MockMvc mockMvc;


    @BeforeEach
    public void setUp() {
        mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
    }


    @Test
    void testUserRegisterValid() throws Exception {
        String url = "/user/register";
        String requestJsonMissingUsername = "{\n" +
                "    \"password\": \"avebwew32f\",\n" +
                "    \"age\": 13,\n" +
                "    \"email\": \"163@163.com\",\n" +
                "    \"mobile\": \"13638888888\"\n" +
                "}";

        assertThat(this.getFromUrl(url, requestJsonMissingUsername)).contains("账号不能为空");


        String requestJsonMissingPassword = "{\n" +
                "    \"username\": \"avebwew32f\",\n" +
                "    \"age\": 13,\n" +
                "    \"email\": \"163@163.com\",\n" +
                "    \"mobile\": \"13638888888\"\n" +
                "}";

        assertThat(this.getFromUrl(url, requestJsonMissingPassword)).contains("密码不能为空");

        String requestJsonShortUsername = "{\n" +
                "    \"username\": \"ave\",\n" +
                "    \"password\": \"163@163.com\",\n" +
                "    \"age\": 13,\n" +
                "    \"email\": \"163@163.com\",\n" +
                "    \"mobile\": \"13638888888\"\n" +
                "}";

        assertThat(this.getFromUrl(url, requestJsonShortUsername)).contains("账号必须在6-16位");
    }

    private String getFromUrl(String url, String requestJson) throws Exception {
        MvcResult result = mockMvc.perform(MockMvcRequestBuilders.post(url).contentType(MediaType.APPLICATION_JSON)
                .content(requestJson)
                .accept(MediaType.APPLICATION_JSON)
                .characterEncoding("UTF-8"))
                .andReturn();

        return result.getResponse().getContentAsString(Charset.defaultCharset());
    }

}

三、拓展使用

3.1 业务场景1:传入两个时间,startTime必须在endTime前面

有以下三个方案可供选择,各有优缺点

  1. 使用@AssertTrue

    @Data
    public class Time {
        @NotNull(message = "开始时间不能为空")
        private LocalDateTime startTime;
    
        @NotNull(message = "结束时间不能为空")
        private LocalDateTime endTime;
    
        @AssertTrue(message = "结束时间必须在开始时间后面")
        public boolean isValid() {
            return endTime.compareTo(startTime) >= 0;
        }
    }
    
    @PostMapping("/time")
    ResponseEntity time(@RequestBody @Valid Time time) {
         return ResponseEntity.ok(time);
    }
    

    使用这种方法就是简单,只需要加个方法就可以,但是它只能使用@Valid做校验,而使用组校验时则必须用到@Validated,所以当用到组校验时就不能用这种方法了

  2. 使用@ScriptAssert

    @Data
    @ScriptAssert(lang = "javascript", alias = "_", script = "_.isValid()", message = "结束时间必须在开始时间后面")
    public class Time {
        @NotNull(message = "开始时间不能为空")
        private LocalDateTime startTime;
    
        @NotNull(message = "结束时间不能为空")
        private LocalDateTime endTime;
    
        public boolean isValid() {
            return endTime.compareTo(startTime) >= 0;
        }
    }
    
    @PostMapping("/time")
    ResponseEntity time(@RequestBody @Validated Time time) {
         return ResponseEntity.ok(time);
    }
    

    @ScriptAssert在官方文档中给出的例子就是两个时间比对,但由于官方例子中时间类型是Date,而我用的是LocalDateTime,在js脚本中没有找到具体如何使用,后来发现可以调用内部的方法

  3. 自定义注解

    @Target({ ElementType.TYPE})
    @Retention(RetentionPolicy.RUNTIME)
    @Constraint(validatedBy = TimeValidator.class)
    public @interface TimeValid {
        String message() default "";
        Class<?>[] groups() default { };
        Class<? extends Payload>[] payload() default { };
    }
    
    public class TimeValidator implements ConstraintValidator<TimeValid, Time> {
    
        @Override
        public void initialize(TimeValid constraintAnnotation) {
    		// 可以在这获取注解中的值
        }
    
        @Override
        public boolean isValid(Time value, ConstraintValidatorContext context) {
            return value.getEndTime().isAfter(value.getStartTime());
        }
    }
    
    @Data
    @TimeValid(message = "开始时间必须在结束时间后面")
    public class Time {
        @NotNull(message = "开始时间不能为空")
        private LocalDateTime startTime;
    
        @NotNull(message = "结束时间不能为空")
        private LocalDateTime endTime;
    }
    
    @PostMapping("/time")
    ResponseEntity time(@RequestBody @Valid Time time) {
         return ResponseEntity.ok(time);
    }
    

3.2 业务场景2:注册账号不能和已存在用户相同

这个可以使用自定义注解,只需要在isValid方法里访问数据库就能比对用户账号是否一致了

具体代码实现略

3.3 业务场景3:根据用户级别,校验角色个数

@Data
@GroupSequenceProvider(VipUserSequenceProvider.class)
public class VipUser {

    @Max(1)
    @Min(0)
    @NotNull
    private Integer level;

    @NotNull(groups = {VipUserGroup.level1.class, VipUserGroup.level2.class})
    @Size(min = 0, max = 2, groups = VipUserGroup.level1.class, message = "角色个数必须在0和2之间")
    @Size(min = 0, max = 5, groups = VipUserGroup.level2.class, message = "角色个数必须在0和5之间")
    private List<String> roles;
}
public interface VipUserGroup {
    interface level1{}
    interface level2{}
}
public class VipUserSequenceProvider implements DefaultGroupSequenceProvider<VipUser> {

    @Override
    public List<Class<?>> getValidationGroups(VipUser object) {
        List<Class<?>> list = new ArrayList<>();
        list.add(VipUser.class);
        Optional.ofNullable(object).map(VipUser::getLevel)
                .ifPresent(level -> list.add(level == 0 ? VipUserGroup.level1.class : VipUserGroup.level2.class) );
        return list;

    }
}
@PostMapping("/vip")
ResponseEntity vip(@RequestBody @Validated VipUser user) {
	return ResponseEntity.ok(user);
}

参考

  1. Spring实现Controller中方法参数校验
  2. 分组序列@GroupSequenceProvider、@GroupSequence控制数据校验顺序,解决多字段联合逻辑校验问题
  3. https://stackoverflow.com/questions/16317207/spring-validation-between-two-date-fields
Logo

CSDN联合极客时间,共同打造面向开发者的精品内容学习社区,助力成长!

更多推荐