Web开发轮子(一)——使用spring validation注解和自定义校验注解
前言
web开发过程中,难免会遇到参数校验的场景,这种需求往往校验的行为类似,只是具体的规则不同。比如A功能校验文本的长度是否大于10,B功能要校验文本的长度是否大于5。虽然这些校验都可以通过代码来实现,但如果有工具类来帮助我们应对这类场景的话,就可以让我们把更多精力放在核心业务逻辑上面。实际上,针对这个场景spring已经提供了相关的注解来帮助我们优雅地完成参数校验。
本篇文章也将这对这部分的知识点进行总结,方便自己后面回顾。同时也希望对各位读者有所帮助
文章最后有注解总结,对背景和使用过程没有兴趣的话可以直接看最后的部分即可。
一、聊聊背景
spring validate
并不是凭空冒出来的工具类,实际上这个工具类和JSR303/JSR-349
标准的出现有着密切的关系。
(一)JSR303/JSR-349标准
JSR-303规范
(Bean Validation规范)提供了对 Java EE 和 Java SE 中的 Java Bean 进行验证的方式。该规范主要使用注解的方式来实现对 Java Bean 的验证功能 ,JSR-349
是其的升级版本,添加了一些新特性。他们规定一些校验规范即校验注解,如@Null,@NotNull,@Pattern,他们位于javax.validation.constraints包下
(二)Hibernate Validation实现
规范虽然定下来了,但是具体的实现还是得有人来做,hibernate validation
是对这个规范的实践(这里的hibernate不是指orm框架的hibernate),他提供了相应的实现,并增加了一些其他校验注解,如@Email,@Length,@Range等等,他们位于org.hibernate.validator.constraints包下。
(三)Spring Validation实现
为了让开发者可以更加便捷地使用validation注解,Spring对hibernate validation进行了二次封装,显示校验validated bean时,你可以使用spring validation或者hibernate validation,而spring validation另一个特性,便是其在springmvc模块中添加了自动校验,并将校验信息封装进了特定的类中。
springboot在2.3之前默认集成了spring validation
工具,但在2.3版本之后就没有再默认集成这个工具类了,想要使用的话需要我们单独引入,具体操作可以看文章后面介绍。
也就是说,spring validation
是JSR303/JSR-349
标准的具体实现,同时也是基于hibernate validation
所封装和拓展的工具类。
二、开始使用
(一)项目中引入validation
依赖
我们在第一章中提到,考虑到开发的便捷性,springboot已经帮我们做了validation
依赖的继承,所以理论上来说,当我们项目中引入spring-boot-starter-web
时,就可以自动引入validation
依赖了。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
但是,在springboot2.3之后,springboot移除了对validation
的默认依赖引入,所以对于springboot2.3之后的项目,我们需要单独引入对应的依赖。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
(二)在项目中进行validation
注解的使用
步骤一:定义Request实体类
public class ValidateRequest {x
@NotNull(message="产品名称不能为空")
private String productName;
public String getProductName() {
return productName;
}
public void setProductName(String productName) {
this.productName = productName;
}
...
}
@NotNull
一般是加在字段上面,校验失败后有默认的错误信息,但我们一般会传入message
自定义的错误信息。
步骤二:定义Controller代码
@RestController
public class ValidationController {
@RequestMapping(value = "/validate",method = RequestMethod.POST)
public ResponseResult validate(@RequestBody @Validated ValidateRequest request, BindingResult bindingResult){
String errorMsg = this.handleBindResult(bindingResult);
if(StringUtils.hasText(errorMsg)){
return ResponseResult.FAIL(errorMsg);
}
return ResponseResult.OK(null);
}
private String handleBindResult(BindingResult bindingResult) {
if(bindingResult.hasErrors()){
for (ObjectError error : bindingResult.getAllErrors()) {
System.out.println("errorMessage = " + error.getDefaultMessage());
return error.getDefaultMessage();
}
}
return null;
}
}
除了常规的MVC配置外,我们需要在我们的入参前面加上@Validated
注解,表示当前这个接口需要启用实体类中的参数校验。同时,需要有BindingResult
作为入参去承接校验的结果(如果不加的话,当字段校验失败后接口就会直接返回400状态码出去,后台只能看到失败日志,但是不能对校验的结果进行个性化的处理,例如统一错误格式返回给前端)。
步骤三:启动项目进行测试
当我们的接口入参没有传productName
的时候,就会把错误信息返回给前端,不需要再手写校验代码。
当我们的接口入参有传productName
的时候,就会正常返回结果
注意:实际项目中,我们一般更多的做法是定义全局的异常处理类,在处理BindingResult
的过程中根据情况抛出异常,再来进行统一的异常结果返回。
(三)各种注解使用用例
@NotNull
@NotNull(message="产品名称不能为空")
private String productName;
@Null
@Null(message="别名必须为空")
private String aliasName;
@AssertTrue
@AssertTrue(message="inDisCount值必须为true")
private boolean inDisCount;
@AssertFalse
@AssertFalse(message="stopSell值必须为true")
private boolean stopSell;
@Min和@Max
@Min(value = 100,message = "价格不得低于100")
@Max(value = 500,message = "价格不得高于500")
private Integer price;
@Size
需要注意,@Size
注解并不能用在数值上面,而是用在String类型的数据上面,用于限制长度
@Size(min = 3,max = 10,message = "用户名长度需要在3-10之间")
private String userName;
@DecimalMin和@DecimalMax
有相似功能的注解还有@Range
,但是@Range
注解适用于整数,而这两个注解可以用于小数级别的限制
@DecimalMin(value = "100.5",message = "价格不得低于100.5")
@DecimalMax(value = "200.5",message = "价格不得高于100.5")
private BigDecimal price;
@Digits
这个注解主要用来限制数字的格式
@Digits(integer = 3,fraction = 3,message = "价格格式错误,整数位和小数位不得超过3位")
private BigDecimal price;
@Past
如果是LocalDateTime这类时间格式的话,一般需要搭配JsonFormat
注解使用
@Past(message = "销售时间必须为过去的时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime sellTime;
@Future
如果是LocalDateTime这类时间格式的话,一般需要搭配JsonFormat
注解使用
@Future(message = "过期时间必须为未来的时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime expirationTime;
@Pattern
十分实用的一个规则校验注解,我们可以在上面通过正则来定义对应的规则
@Pattern(regexp = "^QiQv[1-9]{2}$",message = "格式错误")
private String userName;
@Length
@Length(min = 3,max = 5,message = "长度需在3-5之间")
private String userName;
@Email(regexp = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$",message = "邮件格式错误")
private String email;
注意,@Pattern
注解中还允许我们传递flag
属性,用来解决负责场景下的格式校验,有兴趣的可以查阅资料看一下这些模式的用法。
public static enum Flag {
UNIX_LINES(1),
CASE_INSENSITIVE(2),
COMMENTS(4),
MULTILINE(8),
DOTALL(32),
UNICODE_CASE(64),
CANON_EQ(128);
}
@NotBlank
@NotBlank(message = "邮箱不能为空")
private String email;
@Range
@Range(min = 1,max = 300,message = "年龄需在1-300之间")
private Integer age;
@Email
默认的校验规则是.*
,一般来说,我们需要根据实际的要求来定义邮箱的格式
(四)使用分组校验功能
使用validation
注解时我们可能会遇到这样一种场景,同一个Dto可能会在新增和查询接口中使用到,但是这两个接口需要校验的字段规则不同,单独再创建一个相同的dto显然是有点不优雅的,validation
已经为我们提供了分组的概念入口,我们可以根据场景需要在同一个dto中划分不同的规则定义。
步骤一:定义分组
一般来说,接口的操作类型可以大致分为增删改查四种类型,所以我们在项目中可以根据需要定义分组。比如我们暂时把操作类型分为新增和更新这两种类型(分组)。
public interface Group {
interface Add{
}
interface Update{
}
interface Delete{
}
}
步骤二:在validation
注解上面使用分组,注明这个校验应用在哪一种场景
比如下面的代码,新增和删除的操作场景我们用一套校验规则,更新的时候用另外一套校验规则
@Range(min = 1,max = 300,message = "年龄需在1-300之间",groups = {Group.Add.class,Group.Delete.class})
@Range(min = 1,max = 20,message = "年龄需在1-20之间",groups = Group.Update.class)
private Integer age;
步骤三:在接口中标识接口所适用的分组
在上个步骤中,我们定义了不同分组的校验规则,下一步的话我们需要在接口上面定义该接口所使用的分组,比如是更新接口,那么就使用更新分组的校验。
@RequestMapping(value = "/validate",method = RequestMethod.POST)
public ResponseResult validate(@RequestBody @Validated({Group.Update.class}) ValidateRequest request,
BindingResult bindingResult){
String errorMsg = this.handleBindResult(bindingResult);
if(StringUtils.hasText(errorMsg)){
return ResponseResult.FAIL(errorMsg);
}
return ResponseResult.OK(null);
}
private String handleBindResult(BindingResult bindingResult) {
...
}
注意:@Validated
注解里面可以传多个值,只需要放入{}
中即可
(五)创建自定义注解
项目中校验场景难免会有spring validation
所涵盖不了的场景,比如校验某个字段的值是否含有空格。针对这种情况,如果出现的频率比较少的话,建议直接在代码里面校验就行。如果需要校验的场景比较多的话,就可以考虑做成一个注解,方便后面使用。
下面我们就以校验某个字段的值是否含有空格这个功能为例,来创建一个自定义注解
步骤一:创建自定义注解
正常定义一个注解,重点在于有message
属性和groups
属性。我们可以在message
属性中定义默认的错误信息。
同时,需要在自定义注解上加入@Constraint
注解,标识由哪个类来做具体的校验逻辑。如下面的代码所示,我们定义了将由BlankValidation
类来做是否存在空格的校验。
PS:如果希望注解支持使用多次的话,可以加上@Repeatable
注解,记得要在自定义注解中定义一个内部注解
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = {BlankValidation.class})
@Repeatable(NotContainWhiteBlank.List.class) // 让注解可以在同个地方使用多次,比如同个字段上面使用多次来实现分组校验
public @interface NotContainWhiteBlank {
String message() default "字段中间不允许有空格";
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
@interface List {
NotContainWhiteBlank[] value();
}
}
步骤二:创建校验类
校验类需要实现ConstraintValidator
接口,同时需要传入校验类对应的注解名和支持的校验类型。注解名就是我们在步骤一定义的自定义注解,因为我们这个案例是用来校验文本中是否有空格,所以校验的参数就为String
。(也可以理解为校验类型就是定义我们校验的是什么数据类型的数据)
自定义校验类的核心在于实现isValid
,为true
表示通过校验,为false
表示不通过校验。ConstraintValidatorContext
这个参数上下文包含了认证中所有的信息,我们可以利用这个上下文实现获取默认错误提示信息,禁用错误提示信息,改写错误提示信息等操作。
public class BlankValidation implements ConstraintValidator<NotContainWhiteBlank,String> {
@Override
public void initialize(NotContainWhiteBlank constraintAnnotation) {
}
@Override
public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) {
return !StringUtils.containsWhitespace(value);
}
}
步骤三:在Controller
接口中验证我们的自定义接口
public class ValidateRequest {
@NotContainWhiteBlank
private String productName;
...忽略get、set方法
}
@RestController
public class ValidationController {
@RequestMapping(value = "/validate",method = RequestMethod.POST)
public ResponseResult validate(@RequestBody @Validated ValidateRequest request, BindingResult bindingResult){
String errorMsg = this.handleBindResult(bindingResult);
if(StringUtils.hasText(errorMsg)){
return ResponseResult.FAIL(errorMsg);
}
return ResponseResult.OK(null);
}
private String handleBindResult(BindingResult bindingResult) {
if(bindingResult.hasErrors()){
for (ObjectError error : bindingResult.getAllErrors()) {
System.out.println("errorMessage = " + error.getDefaultMessage());
return error.getDefaultMessage();
}
}
return null;
}
}
我们在postman中进行测试,可以看到自定义注解确实已经生效了。
总结
注解名称 | 功能 |
---|---|
@Null | 被注释的元素必须为null |
@NotNull | 被注释的元素必须不为null |
@AssertTrue | 被注释的元素必须为true |
@AssertFalse | 被注释的元素必须为false |
@Min(value) | 被注释的元素必须是一个数字,其值必须大于等于指定的最小值 |
@Max(value) | 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 |
@DecimalMin(value) | 被注释的元素必须是一个数字,其值必须大于等于指定的最小值 |
@DecimalMax(value) | 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 |
@Size(max, min) | 被注释的元素必须为null |
@Digits (integer, fraction) | 被注释的元素必须是一个数字,其值必须在可接受的范围内 |
@Past | 被注释的元素必须是一个过去的日期 |
@Future | 被注释的元素必须是一个将来的日期 |
@Pattern(value) | 被注释的元素必须符合指定的正则表达式 |
被注释的元素必须是电子邮箱地址 | |
@Length | 被注释的字符串的大小必须在指定的范围内 |
@NotEmpty | 被注释的字符串的必须非空 |
@Range | 被注释的元素必须在合适的范围内 |
参考文章:
java:validate注解做校验 https://blog.csdn.net/en_joker/article/details/110440190
Java中JSR303的基本使用详情 https://www.jb51.net/article/263439.htm