Spring-boot如何实现参数校验
目录
一、为什么需要参数校验
在平时的接口开发中,为了预防非法参数传入对业务造成影响,经常需要对接口入参进行校验拦截,例如:登陆时需要判断用户名密码是否为空,新增用户的时候需要判断用户手机号、邮箱是否符合规范。早期将参数校验写入了代码中,长期下来校验逻辑很繁琐,代码可读性也比较差。所以,引进了Validator参数校验框架。
Validator参数校验框架就是为了减少开发人员日常代码量,对于参数校验提供方便,提升开发效率。
Validator校验框架遵循了JSR-303验证规范(参数校验规范),
JSR是 Java Specification Requests的缩写。
二、Validator框架发展史
1、Hibernate-Validator参数校验
(1)早先 POM文件 依赖引入
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-validator</artifactId>
</dependency>
(2)某个版本之后迁移依赖位置
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
</dependency>
2、Springboot-Validator参数校验
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Springboot-Validate --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
注:从 springboot-2.3开始,校验包被独立成了一个 starter组件,并且内部也引用了 上述(2)的依赖,
所以需要引入validation和web,而 springboot-2.3之前的版本只需要引入 web 依赖就可以了。
三、使用校验注解定义对象
1、定义入参对象
@Data public class MenuInfoEditionInput { /** * 主键ID */ @NotEmpty(message = "主键ID不可为空!") private String id; /** * 版本号 */ @NotEmpty(message = "版本号不可为空!") private String editionId; /** * 版本名称 */ @NotEmpty(message = "版本名称不可为空!") private String editionName; /** * 排序号 */ @NotNull(message = "版本排序号不可为空!") private Integer sortNo; /** * 学科ID */ @NotNull(message = "学科ID不可为空!") private Integer subId; }
每个注解后需要根据业务需求设计返回描述,即message值。
2、常用的注解
注解 | 描述 |
@AssertFalse | 可以为null,如果不为null的话必须为false |
@AssertTrue | 可以为null,如果不为null的话必须为true |
@DecimalMax | 设置不能超过最大值 |
@DecimalMin | 设置不能超过最小值 |
@Digits | 设置必须是数字且数字整数的位数和小数的位数必须在指定范围内 |
@Future | 日期必须在当前日期的未来 |
@Past | 日期必须在当前日期的过去 |
@Max | 最大不得超过此最大值 |
@Min | 最大不得小于此最小值 |
@NotNull | 不能为null,可以是空 |
@Null | 必须为null |
@NotBlank | 字符串不能为null,字符串trim()后也不能等于“” |
@NotEmpty | 不能为null,集合、数组、map等size()不能为0;字符串trim()后可以等于“” |
@Pattern | 必须满足指定的正则表达式 |
@Size | 集合、数组、map等的size()值必须在指定范围内 |
必须是email格式 | |
@Length | 长度必须在指定范围内 |
@Range | 值必须在指定范围内 |
@URL | 必须是一个URL |
注:此表格只是简单的对注解功能的说明,并没有对每一个注解的属性进行说明;可详见源码。
3、接口调用触发校验机制
(1) Controller层校验设计
@RestController @CrossOrigin @RequestMapping(value = "/menuEdition") public class MenuInfoEditionController { private static Logger logger = LoggerFactory.getLogger(MenuInfoEditionController.class); @Autowired private IMenuInfoEditionService menuInfoEditionService; /** * 目录版本列表查询 */ @PostMapping(value = "/selectEditionList") public ApiResult<List<MenuInfoEditionOutput>> selectListEditionBySubId(@Validated @RequestBody MenuInfoEditionInput menuInfoEditionInput) { logger.info("开始查询目录版本列表……"); List<MenuInfoEditionOutput> menuInfoEditionOutputList = menuInfoEditionService.selectListEditionBySubId(menuInfoEditionInput); logger.info("目录列表查询结束……"); return ApiResult.OK(menuInfoEditionOutputList); } }
这里定义的方法上使用了 @RequestBody 注解,用于接收前端传送的JSON数据。注意:使用Validator校验必须加上 @Validated 注解才能生效,否则无用。
(2)执行结果
{
"code": 5000,
"message": "HV000030: No validator could be found for constraint 'javax.validation.constraints.NotEmpty' validating type 'java.lang.Integer'. Check configuration for 'subId'",
"path": null,
"traceId": "bf1b061badf1441395be5ead0cf4026d",
"data": null
}
4、参数异常触发全局异常拦截器
根据上述拦截提示发现,尽管系统中已经引入了全局异常拦截器,可是对于 Validator校验的返回内容不太友好,显得有些臃肿,不便于阅读,故,将拦截内容进行改进,优化一下。
@Slf4j @ControllerAdvice public class ValidException { @ExceptionHandler({MethodArgumentNotValidException.class, BindException.class}) @ResponseStatus(HttpStatus.OK) @ResponseBody public ApiResult handleMethodArgumentNotValidException(Exception exception) { StringBuilder errorInfo = new StringBuilder(); BindingResult bindingResult=null; if(exception instanceof MethodArgumentNotValidException){ bindingResult= ((MethodArgumentNotValidException)exception).getBindingResult(); } if(exception instanceof BindException){ bindingResult= ((BindException)exception).getBindingResult(); } for(int i = 0; i < bindingResult.getFieldErrors().size(); i++){ if(i > 0){ errorInfo.append(","); } FieldError fieldError = bindingResult.getFieldErrors().get(i); errorInfo.append(fieldError.getField()).append(" :").append(fieldError.getDefaultMessage()); } log.error(errorInfo.toString()); //这里返回自己的Result的结果类。 return ApiResult.validateFailed(errorInfo.toString()); } @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.BAD_REQUEST) @ResponseBody public ApiResult handleDefaultException(Exception exception) { log.error(exception.toString()); //这里返回自己的Result的结果类。 return ApiResult.validateFailed("服务器错误",exception); } }
测试结果:
{
"code": 5000,
"message": "subId :学科ID不可为空!",
"path": null,
"traceId": "81e696c472654c67a771dd59ab39809b",
"data": null
}
5、自定义校验注解
(1)创建自定义注解
@Documented @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE}) @Constraint(validatedBy = {MobileValidator.class}) @Retention(RetentionPolicy.RUNTIME) @Repeatable(Mobile.List.class) public @interface Mobile { /** * 错误提示信息,可以写死,也可以填写国际化的key */ String message() default "手机号码不正确"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; String regexp() default "^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\\d{8}$"; @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE}) @Retention(RetentionPolicy.RUNTIME) @Documented @interface List { Mobile[] value(); } }
(2)自定义校验逻辑
public class MobileValidator implements ConstraintValidator<Mobile, String> { /** * 手机验证规则 */ private Pattern pattern; @Override public void initialize(Mobile mobile) { pattern = Pattern.compile(mobile.regexp()); } @Override public boolean isValid(String value, ConstraintValidatorContext context) { if (value == null) { return true; } return pattern.matcher(value).matches(); } }
(3)增加校验注解
//手机号
@Mobile
private String userPhone;
(4)测试结果
{
"code": 5000,
"message": "userPhone :手机号码不正确",
"path": null,
"traceId": "79da96c472654c67a771dd59ab3qv518",
"data": null
}
四、分组校验
1、场景
入参对象中,一些属性在新增时需要必填,但是在修改时不需要必填,如上边的 MenuInfoEditionInput对象中,editionId 和 editionName 在新增时需要必填,但是在修改时不需要。之前遇到过同事的处理方式是,创建两个接收对象,分别对象新增、修改两个业务类别,这样可以实现最终的效果,但是业务分类多的情况下,这样会造成类膨胀,对于代码阅读性不友好。
其实,在 Validator校验框架中考虑到了这个实际应用场景并给予了解决方案,已经引入了业务分组的概念,简称分组校验。
2、自定义分组接口
import javax.validation.groups.Default; public interface ResourceValidateGroup extends Default { // 资源管理工具--目录信息校验分组 interface ManageMenuInfo extends Default { // 查询目录 interface Select {} // 新增目录 interface Insert {} // 修改目录 interface Update {} // 删除目录 interface Delete {} // 新增或修改目录 interface InsertOrUpdate {} } // 资源管理工具--目录版本校验分组 interface ManageMenuInfoEdition extends Default { // 查询目录 interface Select {} // 新增目录 interface Insert {} // 修改目录 interface Update {} // 删除目录 interface Delete {} // 新增或修改目录 interface InsertOrUpdate {} } }
自定义一个 ResourceValidateGroup 接口,用于组合各个业务场景的校验分组情况,继承了Default类,具体用于什么场景,待定。
3、入参对象中引用分组类别
@Data public class MenuInfoEditionInput { /** * 主键ID */ @NotEmpty(message = "主键ID不可为空!", groups = {ResourceValidateGroup.ManageMenuInfoEdition.Delete.class}) private String id; /** * 版本号 */ @NotEmpty(message = "版本号不可为空!", groups = {ResourceValidateGroup.ManageMenuInfoEdition.InsertOrUpdate.class}) private String editionId; /** * 版本名称 */ @NotEmpty(message = "版本名称不可为空!", groups = {ResourceValidateGroup.ManageMenuInfoEdition.InsertOrUpdate.class}) private String editionName; /** * 排序号 */ @NotNull(message = "版本排序号不可为空!", groups = {ResourceValidateGroup.ManageMenuInfoEdition.InsertOrUpdate.class}) private Integer sortNo; /** * 学科ID */ @NotNull(message = "学科ID不可为空!", groups = {ResourceValidateGroup.ManageMenuInfoEdition.InsertOrUpdate.class, ResourceValidateGroup.ManageMenuInfoEdition.Delete.class, ResourceValidateGroup.ManageMenuInfoEdition.Select.class}) private Integer subId; }
4、接口调用引用分组校验
@RestController @CrossOrigin @RequestMapping(value = "/menuEdition") public class MenuInfoEditionController { private static Logger logger = LoggerFactory.getLogger(MenuInfoEditionController.class); @Autowired private IMenuInfoEditionService menuInfoEditionService; /** * 目录版本列表查询 */ @PostMapping(value = "/selectEditionList") public ApiResult<List<MenuInfoEditionOutput>> selectListEditionBySubId( @Validated({ResourceValidateGroup.ManageMenuInfoEdition.Select.class, Default.class}) @RequestBody MenuInfoEditionInput menuInfoEditionInput) { logger.info("开始查询目录版本列表……"); List<MenuInfoEditionOutput> menuInfoEditionOutputList = menuInfoEditionService.selectListEditionBySubId(menuInfoEditionInput); logger.info("目录列表查询结束……"); return ApiResult.OK(menuInfoEditionOutputList); } /** * 目录版本新增或修改 */ @PostMapping(value = "/insertOrUpdateEditionList") public ApiResult<List<MenuInfoEditionOutput>> insertOrUpdateListMenuInfoEdition( @Validated({ResourceValidateGroup.ManageMenuInfoEdition.InsertOrUpdate.class, Default.class}) @RequestBody List<MenuInfoEditionInput> menuInfoEditionInput) { logger.info("开始新增目录版本列表……"); List<MenuInfoEditionOutput> menuInfoEditionOutputList = menuInfoEditionService.insertOrUpdateListMenuInfoEdition(menuInfoEditionInput); logger.info("目录列表新增结束……"); return ApiResult.OK(menuInfoEditionOutputList); } /** * 目录版本删除 */ @PostMapping(value = "/deleteEditionList") public ApiResult<List<MenuInfoEditionOutput>> deleteMenuInfoEditionList( @Validated({ResourceValidateGroup.ManageMenuInfoEdition.Delete.class, Default.class}) @RequestBody List<MenuInfoEditionInput> menuInfoEditionInput) { logger.info("开始删除目录版本列表……"); List<MenuInfoEditionOutput> menuInfoEditionOutputList = menuInfoEditionService.deleteMenuInfoEditionList(menuInfoEditionInput); logger.info("目录列表删除结束……"); return ApiResult.OK(menuInfoEditionOutputList); } }
这里需要注意,如果校验分组继承了 Default类,Controller层接口引用校验分组时,需要加上 Default分类才能生效,具体原因还在研究中……
五、小结
非空校验是校验的第一步, 除了非空校验,我们还需要做到以下几点:
- 普通参数 - 需要限定字段的长度。如果会将数据存入数据库,长度以数据库为准,反之根据业务确定。
- 类型参数 - 最好使用正则对可能出现的类型做到严格校验。比如type的值是【0|1|2】这样的。
- 列表(list)参数 - 不仅需要对list内的参数是否合格进行校验,还需要对list的size进行限制。比如说 100。
- 日期,邮件,金额,URL这类参数都需要使用对于的正则进行校验。
- 参数真实性 - 这个主要针对于 各种Id 比如说 userId、merchantId,对于这样的参数,都需要进行真实性校验
参数校验越严格越好,严格的校验规则不仅能减少接口出错的概率,同时还能避免出现脏数据,从而来保证系统的安全性和稳定性。
最后,本人开发经验不是很多,好多东西都在学习记录中,文章写的不好还请见谅,如有建议可以在评论区讨论,感谢大家!