阿粉寫了八千多字,只為講透參數(shù)合法性驗(yàn)證
最近很多讀者給阿粉留言,說怎么好久沒看到我的文章了,這里說一下。
由于公眾號(hào)不再按時(shí)間線排序,所以你會(huì)發(fā)現(xiàn)有時(shí)候能看到幾天前的文章,這不是出BUG,是公眾號(hào)的一次改變。
至于排序的具體標(biāo)準(zhǔn)是啥,阿粉也不太清楚,大概和你打開某個(gè)公眾號(hào)的頻率有關(guān)。
所以如果你想第一時(shí)間收到阿粉的文章,可以點(diǎn)擊Java極客技術(shù)的的頭像,再點(diǎn)右上角三個(gè)點(diǎn),進(jìn)去設(shè)置一下【星標(biāo)】。
一、介紹
關(guān)于參數(shù)合法性驗(yàn)證的重要性就不多說了,即使前端對(duì)參數(shù)做了基本驗(yàn)證以外,后端依然還需要進(jìn)行驗(yàn)證,以防不合規(guī)的數(shù)據(jù)直接進(jìn)入后端,嚴(yán)重的甚至?xí)斐上到y(tǒng)直接崩潰!
本文結(jié)合自己在項(xiàng)目中的實(shí)際使用經(jīng)驗(yàn),主要以實(shí)用為主,對(duì)數(shù)據(jù)合法性驗(yàn)證做一次總結(jié),不了解的朋友可以學(xué)習(xí)一下,同時(shí)可以立馬實(shí)踐到項(xiàng)目上去。
下面我們通過幾個(gè)示例來演示如何判斷參數(shù)是否合法,不多說直接開擼!
二、斷言驗(yàn)證
對(duì)于參數(shù)的合法性驗(yàn)證,最初的做法比較簡(jiǎn)單,自定義一個(gè)異常類。
- public class CommonException extends RuntimeException {
- /**錯(cuò)誤碼*/
- private Integer code;
- /**錯(cuò)誤信息*/
- private String msg;
- //...set/get
- public CommonException(String msg) {
- super(msg);
- this.msg = msg;
- }
- public CommonException(String msg, Throwable cause) {
- super(msg, cause);
- this.msg = msg;
- }
- }
當(dāng)判斷某個(gè)參數(shù)不合法的時(shí)候,直接拋異常!
- @RestController
- public class HelloController {
- @RequestMapping("/upload")
- public void upload(MultipartFile file) {
- if (file == null) {
- throw new CommonException("請(qǐng)選擇上傳文件!");
- }
- //.....
- }
- }
然后寫一個(gè)統(tǒng)一異常攔截器,對(duì)拋異常的程序進(jìn)行處理。
這種做法比較直觀,如果當(dāng)前參數(shù)既要判斷是否為空,又要判斷長(zhǎng)度是否超過最大長(zhǎng)度的時(shí)候,代碼就顯得有點(diǎn)多了!
于是,程序界的大佬想到了一個(gè)更加優(yōu)雅又能節(jié)省代碼的方式,創(chuàng)建一個(gè)斷言類工具類,專門用來判斷參數(shù)的是否合法,如果不合法,就拋異常!
- /**
- * 斷言工具類
- */
- public abstract class LocalAssert {
- public static void isTrue(boolean expression, String message) throws CommonException {
- if (!expression) {
- throw new CommonException(message);
- }
- }
- public static void isStringEmpty(String param, String message) throws CommonException{
- if(StringUtils.isEmpty(param)) {
- throw new CommonException(message);
- }
- }
- public static void isObjectEmpty(Object object, String message) throws CommonException {
- if (object == null) {
- throw new CommonException(message);
- }
- }
- public static void isCollectionEmpty(Collection coll, String message) throws CommonException {
- if (coll == null || (coll.size() == 0)) {
- throw new CommonException(message);
- }
- }
- }
當(dāng)我們需要對(duì)參數(shù)進(jìn)行驗(yàn)證的時(shí)候,直接通過這個(gè)類就可以完成基本操作,方式如下:
- @RestController
- public class HelloController {
- @RequestMapping("/save")
- public void save(String name, String email) {
- LocalAssert.isStringEmpty(name, "用戶名不能為空!");
- LocalAssert.isStringEmpty(email, "郵箱不能為空!");
- //.....
- }
- }
相比上個(gè)步驟,當(dāng)要判斷的參數(shù)比較多時(shí),代碼明顯簡(jiǎn)潔多了!
類似這樣的工具類,spring也提供了一個(gè)名為Assert的斷言工具類,在開發(fā)的時(shí)候,可以直接使用!
三、注解驗(yàn)證
使用注解對(duì)數(shù)據(jù)進(jìn)行合法性驗(yàn)證,可以說是 java 界一項(xiàng)非常偉大的創(chuàng)新,使用這種方式不僅使的代碼變得很簡(jiǎn)潔,而且閱讀起來非常令人賞心悅目!
3.1、依賴包引入
下面我們一起來看看具體的實(shí)踐方式,以Spring Boot工程為例,如果需要使用注解校驗(yàn),直接引入spring-boot-starter-web依賴包即可,會(huì)自動(dòng)將注解驗(yàn)證相關(guān)的依賴包打入工程!
- <!-- spring boot web -->
- <dependency>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-starter-web</artifactId>
- </dependency>
下面在創(chuàng)建實(shí)體類的時(shí)候,還會(huì)用到lombok插件,因此還需要引入lombok依賴包!
- <!-- lombok -->
- <dependency>
- <groupId>org.projectlombok</groupId>
- <artifactId>lombok</artifactId>
- <version>1.18.4</version>
- <scope>provided</scope>
- </dependency>
如果是普通的Java工程,引入以下幾個(gè)依賴包即可!
- <dependency>
- <groupId>org.hibernate.validator</groupId>
- <artifactId>hibernate-validator</artifactId>
- <version>6.0.9.Final</version>
- </dependency>
- <dependency>
- <groupId>javax.el</groupId>
- <artifactId>javax.el-api</artifactId>
- <version>3.0.0</version>
- </dependency>
- <dependency>
- <groupId>org.glassfish.web</groupId>
- <artifactId>javax.el</artifactId>
- <version>2.2.6</version>
- </dependency>
3.2、注解校驗(yàn)請(qǐng)求對(duì)象
緊接著我們來創(chuàng)建一個(gè)實(shí)體User,用于模擬用戶注冊(cè)時(shí)的請(qǐng)求實(shí)體對(duì)象!
- @Data
- @EqualsAndHashCode(callSuper = false)
- @Accessors(chain = true)
- public class User {
- @NotBlank(message = "用戶名不能為空!")
- private String userName;
- @Email(message = "郵箱格式不正確")
- @NotBlank(message = "郵箱不能為空!")
- private String email;
- @NotBlank(message = "密碼不能為空!")
- @Size(min = 8, max = 16,message = "請(qǐng)輸入長(zhǎng)度在8~16位的密碼")
- private String userPwd;
- @NotBlank(message = "確認(rèn)密碼不能為空!")
- private String confirmPwd;
- }
在web層創(chuàng)建一個(gè)register()注冊(cè)接口方法,同時(shí)在請(qǐng)求參數(shù)上添加@Valid,如下:
- @RestController
- public class UserController {
- @RequestMapping("/register")
- public boolean register(@RequestBody @Valid User user){
- if(!user.getUserPwd().equals(user.getConfirmPwd())){
- throw new CommonException("確認(rèn)密碼與密碼不相同,請(qǐng)確認(rèn)!");
- }
- //業(yè)務(wù)處理...
- return true;
- }
- }
最后自定義一個(gè)異常全局處理器,用于處理異常消息,如下:
- @Slf4j
- @Configuration
- public class GlobalWebMvcConfig implements WebMvcConfigurer {
- /**
- * 統(tǒng)一異常處理
- * @param resolvers
- */
- @Override
- public void configureHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
- resolvers.add(new HandlerExceptionResolver() {
- @Override
- public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object o, Exception e) {
- log.error("【統(tǒng)一異常攔截】請(qǐng)求出現(xiàn)異常,內(nèi)容如下:",e);
- ModelAndView mv = new ModelAndView(new MappingJackson2JsonView());
- String uri = request.getRequestURI();
- if(e instanceof CommonException){
- //CommonExecption為自定義異常類拋出的異常
- printWrite(((CommonException) e).getMsg(),((CommonException) e).getData(), uri, mv);
- } else if(e instanceof MethodArgumentNotValidException){
- //MethodArgumentNotValidException為注解校驗(yàn)異常類
- //獲取注解校驗(yàn)異常信息
- String error = ((MethodArgumentNotValidException) e).getBindingResult().getFieldError().getDefaultMessage();
- printWrite(error,null, uri, mv);
- } else {
- printWrite(e.getMessage(),null, uri, mv);
- }
- return mv;
- }
- });
- }
- /**
- * 異常封裝相應(yīng)結(jié)果
- * @param object
- */
- private void printWrite(String msg, Object object, String uri, ModelAndView mv){
- ResResult resResult = new ResResult(uri, object);
- if(msg != null){resResult.setMsg(msg);}
- if(log.isDebugEnabled()){
- log.debug("【response】異常輸出結(jié)果:" + JSONObject.toJSONString(resResult, SerializerFeature.WriteMapNullValue));
- }
- Map resultMap = BeanToMapUtil.beanToMap(resResult);
- mv.addAllObjects(resultMap);
- }
- }
下面我們啟動(dòng)項(xiàng)目,使用postman來測(cè)試一把,看看效果如何?
- 測(cè)試字段是否為空
- 測(cè)試郵箱是否合法
- 測(cè)試密碼長(zhǎng)度是否符合要求
- 測(cè)試密碼與確認(rèn)密碼是否相同
3.3、注解校驗(yàn)請(qǐng)求參數(shù)
上面我們介紹了請(qǐng)求對(duì)象的驗(yàn)證方式,那如果直接在方法上對(duì)請(qǐng)求參數(shù)進(jìn)行驗(yàn)證是否同樣有效呢?
為了眼見為實(shí),下面我們就來模擬在方法上對(duì)請(qǐng)求參數(shù)進(jìn)行驗(yàn)證,看看結(jié)果如何。
新建一個(gè)查詢接口query,如下
- @RestController
- public class UserController {
- @PostMapping("/query")
- public boolean query(@RequestParam("userId") @Valid @NotBlank(message = "用戶ID不能為空") String userId ){
- return true;
- }
- }
使用postman請(qǐng)求試一試,默認(rèn)給userId參數(shù)為null,結(jié)果如下:
很清晰的看到,query()方法中的參數(shù)注解驗(yàn)證無(wú)效!
當(dāng)我們?cè)赨serController類上加上@Validated注解!
- @RestController
- @Validated
- public class UserController {
- @PostMapping("/query")
- public boolean query(@RequestParam("userId") @Valid @NotBlank(message = "用戶ID不能為空") String userId ){
- return true;
- }
- }
使用postman請(qǐng)求再試一試,結(jié)果如下!
很清晰的看到,注解進(jìn)行了驗(yàn)證,同時(shí)還拋出異常ConstraintViolationException!
@Validated參數(shù)作用于類上時(shí),表示告訴Spring可以對(duì)方法中請(qǐng)求參數(shù)進(jìn)行校驗(yàn)!
所有在實(shí)際開發(fā)的時(shí)候,我們可以使用@Validated和@Valid注解的組合來對(duì)方法中的請(qǐng)求參數(shù)和請(qǐng)求對(duì)象進(jìn)行校驗(yàn)!
同時(shí),@Validated和@Valid注解不僅僅只是驗(yàn)證控制器級(jí)別,可以驗(yàn)證任何Spring組件,例如Service層方法入?yún)⒌尿?yàn)證!
- @Service
- @Validated
- public class UserService {
- public void saveUser(@Valid User user){
- //dao插入
- }
- }
3.4、自定義注解驗(yàn)證
默認(rèn)的情況下,依賴包已經(jīng)給我們提供了非常多的校驗(yàn)注解,如下!
- JSR提供的校驗(yàn)注解!
- Hibernate Validator提供的校驗(yàn)注解
但是某些情況,例如性別這個(gè)參數(shù)可能需要我們自己去驗(yàn)證,同時(shí)我們也可以自定義一個(gè)注解來完成參數(shù)的校驗(yàn),實(shí)現(xiàn)方式如下!
- 新創(chuàng)建一個(gè)Sex注解,其中SexValidator類指的是具體的參數(shù)驗(yàn)證類
- @Target({FIELD})
- @Retention(RUNTIME)
- @Constraint(validatedBy = SexValidator.class)
- @Documented
- public @interface Sex {
- String message() default "性別值不在可選范圍內(nèi)";
- Class<?>[] groups() default {};
- Class<? extends Payload>[] payload() default {};
- }
- SexValidator類,實(shí)現(xiàn)自ConstraintValidator接口
- public class SexValidator implements ConstraintValidator<Sex, String> {
- @Override
- public boolean isValid(String value, ConstraintValidatorContext context) {
- Set<String> sexSet = new HashSet<String>();
- sexSet.add("男");
- sexSet.add("女");
- return sexSet.contains(value);
- }
- }
最后在User實(shí)體類上加入一個(gè)性別參數(shù),使用自定義注解進(jìn)行校驗(yàn)!
- @Data
- @EqualsAndHashCode(callSuper = false)
- @Accessors(chain = true)
- public class User {
- @NotBlank(message = "用戶名不能為空!")
- private String userName;
- @Email(message = "郵箱格式不正確")
- @NotBlank(message = "郵箱不能為空!")
- private String email;
- @NotBlank(message = "密碼不能為空!")
- @Size(min = 8, max = 16,message = "請(qǐng)輸入長(zhǎng)度在8~16位的密碼")
- private String userPwd;
- @NotBlank(message = "確認(rèn)密碼不能為空!")
- private String confirmPwd;
- /**
- * 自定義注解校驗(yàn)
- */
- @Sex(message = "性別輸入有誤!")
- private String sex;
- }
使用postman來請(qǐng)求試一試,結(jié)果如下!
- 不傳sex參數(shù)
很清晰的看到,已經(jīng)生效!
3.5、手動(dòng)進(jìn)行注解校驗(yàn)
某些時(shí)候呢,假如有100個(gè)類需要用到校驗(yàn)注解,此時(shí)我們可能在每個(gè)類會(huì)加上注解@Validated或者@Valid,再增加100個(gè)這樣的類,就會(huì)造成很多大量的重復(fù)工作。
而此時(shí),我們的訴求是想對(duì)有校驗(yàn)注解的實(shí)體類進(jìn)行全局參數(shù)驗(yàn)證!
解決辦法就會(huì)用到Validator提供的手動(dòng)注解校驗(yàn)證工具類,實(shí)現(xiàn)方法如下!
- 新建一個(gè)注解驗(yàn)證工具類
- /**
- * 注解校驗(yàn)工具類
- */
- public class ValidatorUtils {
- /**
- * 獲取對(duì)象中所有注解校驗(yàn)證異常信息
- * @param object
- * @return
- */
- public static String validated(Object object){
- List<String> errorMessageList = new ArrayList<>();
- //獲取注解校驗(yàn)工廠
- ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
- Validator validator = factory.getValidator();
- Set<ConstraintViolation<Object>> violations = validator.validate(object);
- for (ConstraintViolation<Object> constraintViolation : violations) {
- errorMessageList.add(constraintViolation.getMessage());
- }
- return errorMessageList.toString();
- }
- }
使用ValidatorUtils工具類,對(duì)參數(shù)進(jìn)行驗(yàn)證
- @Test
- public void testUser(){
- User user = new User();
- System.out.println(ValidatorUtils.validated(user));
- }
執(zhí)行之后,結(jié)果如下!
- [郵箱不能為空!, 用戶名不能為空!, 密碼不能為空!, 確認(rèn)密碼不能為空!, 性別輸入有誤!]
當(dāng)然你還可以對(duì)ValidatorUtils類進(jìn)行改造,當(dāng)有異常信息的時(shí)候,直接拋異常!
同時(shí),你還可以通過@Autowired直接注入的方式來獲取Validator對(duì)象!
- @Autowired
- Validator validator
3.6、spring 注解校驗(yàn)原理
如果你對(duì)springmvc的方法參數(shù)解析器(HandlerMethodArgumentResolver)了解的話,就可能會(huì)想到參數(shù)校驗(yàn)這塊肯定是在對(duì)應(yīng)的方法參數(shù)解析器里執(zhí)行的。
直接定位到resolveArgument這個(gè)方法,先通過WebDataBinder進(jìn)行入?yún)傩越壎?,然后再進(jìn)行校驗(yàn)!
validateIfApplicable方法邏輯,會(huì)遍歷當(dāng)前參數(shù)methodParam所有的注解,如果注解是@Validated或者注解的名字以Valid開頭,則使用WebDataBinder對(duì)象執(zhí)行校驗(yàn)邏輯。
方法參數(shù)解析器只針對(duì)接口請(qǐng)求時(shí)入?yún)⑦M(jìn)行驗(yàn)證,如果想對(duì)任何組件中方法進(jìn)行注解校驗(yàn),似乎還缺了點(diǎn)什么!
而當(dāng)需要對(duì)一個(gè)類中的方法參數(shù)使用注解校驗(yàn)時(shí),在類上加上@Validated就是為了告訴Spring去校驗(yàn)方法參數(shù)!
底層核心是通過切面代理類并配合MethodValidationPostProcessor這個(gè)后置處理器進(jìn)行處理!
四、總結(jié)
參數(shù)驗(yàn)證,在開發(fā)中使用非常頻繁,如何優(yōu)雅的進(jìn)行驗(yàn)證,讓代碼變得更加可讀,是業(yè)界大佬一直在追求的目標(biāo)!
本文主要是對(duì)自己在項(xiàng)目中的實(shí)際使用到參數(shù)驗(yàn)證方式加一整理,希望能幫助到各位網(wǎng)友!
五、參考1、SpringMVC源碼
2、JavaGuide - 如何在 Spring/Spring Boot 中做參數(shù)校驗(yàn)?[1]
3、胡峻崢 - SpringMvc @Validated注解執(zhí)行原理[2]
參考資料
[1]JavaGuide - 如何在 Spring/Spring Boot 中做參數(shù)校驗(yàn)?: https://juejin.im/post/5dc8bc745188254e7a155ba0#heading-14[2]胡峻崢 - SpringMvc @Validated注解執(zhí)行原理: https://www.cnblogs.com/hujunzheng/p/12570921.html