SpringBoot中處理校驗邏輯的兩種方式,真的很機智!

  • 2020 年 3 月 13 日
  • 筆記

平時在開發介面的時候,常常會需要對參數進行校驗,這裡提供兩種處理校驗邏輯的方式。一種是使用Hibernate Validator來處理,另一種是使用全局異常來處理,下面我們講下這兩種方式的用法。

Hibernate Validator

Hibernate Validator是SpringBoot內置的校驗框架,只要集成了SpringBoot就自動集成了它,我們可以通過在對象上面使用它提供的註解來完成參數校驗。

常用註解

我們先來了解下常用的註解,對Hibernate Validator所提供的校驗功能有個印象。

  • @Null:被注釋的屬性必須為null;
  • @NotNull:被注釋的屬性不能為null;
  • @AssertTrue:被注釋的屬性必須為true;
  • @AssertFalse:被注釋的屬性必須為false;
  • @Min:被注釋的屬性必須大於等於其value值;
  • @Max:被注釋的屬性必須小於等於其value值;
  • @Size:被注釋的屬性必須在其min和max值之間;
  • @Pattern:被注釋的屬性必須符合其regexp所定義的正則表達式;
  • @NotBlank:被注釋的字元串不能為空字元串;
  • @NotEmpty:被注釋的屬性不能為空;
  • @Email:被注釋的屬性必須符合郵箱格式。

使用方式

接下來我們以添加品牌介面的參數校驗為例來講解下Hibernate Validator的使用方法,其中涉及到一些AOP的知識,不了解的朋友可以參考下《SpringBoot應用中使用AOP記錄介面訪問日誌》。

  • 首先我們需要在添加品牌介面的參數PmsBrandParam中添加校驗註解,用於確定屬性的校驗規則及校驗失敗後需要返回的資訊;
/**   * 品牌傳遞參數   * Created by macro on 2018/4/26.   */  public class PmsBrandParam {      @ApiModelProperty(value = "品牌名稱",required = true)      @NotEmpty(message = "名稱不能為空")      private String name;      @ApiModelProperty(value = "品牌首字母")      private String firstLetter;      @ApiModelProperty(value = "排序欄位")      @Min(value = 0, message = "排序最小為0")      private Integer sort;      @ApiModelProperty(value = "是否為廠家製造商")      @FlagValidator(value = {"0","1"}, message = "廠家狀態不正確")      private Integer factoryStatus;      @ApiModelProperty(value = "是否進行顯示")      @FlagValidator(value = {"0","1"}, message = "顯示狀態不正確")      private Integer showStatus;      @ApiModelProperty(value = "品牌logo",required = true)      @NotEmpty(message = "品牌logo不能為空")      private String logo;      @ApiModelProperty(value = "品牌大圖")      private String bigPic;      @ApiModelProperty(value = "品牌故事")      private String brandStory;       //省略若干Getter和Setter方法...  }
  • 然後在添加品牌的介面中添加@Validated註解,並注入一個BindingResult參數;
/**   * 品牌功能Controller   * Created by macro on 2018/4/26.   */  @Controller  @Api(tags = "PmsBrandController", description = "商品品牌管理")  @RequestMapping("/brand")  public class PmsBrandController {      @Autowired      private PmsBrandService brandService;        @ApiOperation(value = "添加品牌")      @RequestMapping(value = "/create", method = RequestMethod.POST)      @ResponseBody      public CommonResult create(@Validated @RequestBody PmsBrandParam pmsBrand, BindingResult result) {          CommonResult commonResult;          int count = brandService.createBrand(pmsBrand);          if (count == 1) {              commonResult = CommonResult.success(count);          } else {              commonResult = CommonResult.failed();          }          return commonResult;      }  }
  • 然後在整個Controller層創建一個切面,在其環繞通知中獲取到注入的BindingResult對象,通過hasErrors方法判斷校驗是否通過,如果有錯誤資訊直接返回錯誤資訊,驗證通過則放行;
/**   * HibernateValidator錯誤結果處理切面   * Created by macro on 2018/4/26.   */  @Aspect  @Component  @Order(2)  public class BindingResultAspect {      @Pointcut("execution(public * com.macro.mall.controller.*.*(..))")      public void BindingResult() {      }        @Around("BindingResult()")      public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {          Object[] args = joinPoint.getArgs();          for (Object arg : args) {              if (arg instanceof BindingResult) {                  BindingResult result = (BindingResult) arg;                  if (result.hasErrors()) {                      FieldError fieldError = result.getFieldError();                      if(fieldError!=null){                          return CommonResult.validateFailed(fieldError.getDefaultMessage());                      }else{                          return CommonResult.validateFailed();                      }                  }              }          }          return joinPoint.proceed();      }  }
  • 此時我們訪問添加品牌的介面,不傳入name欄位,就會返回名稱不能為空的錯誤資訊;

自定義註解

有時候框架提供的校驗註解並不能滿足我們的需要,此時我們就需要自定義校驗註解。比如還是上面的添加品牌,此時有個參數showStatus,我們希望它只能是0或者1,不能是其他數字,此時可以使用自定義註解來實現該功能。

  • 首先自定義一個校驗註解類FlagValidator,然後添加@Constraint註解,使用它的validatedBy屬性指定校驗邏輯的具體實現類;
/**   * 用戶驗證狀態是否在指定範圍內的註解   * Created by macro on 2018/4/26.   */  @Documented  @Retention(RetentionPolicy.RUNTIME)  @Target({ElementType.FIELD,ElementType.PARAMETER})  @Constraint(validatedBy = FlagValidatorClass.class)  public @interface FlagValidator {      String[] value() default {};        String message() default "flag is not found";        Class<?>[] groups() default {};        Class<? extends Payload>[] payload() default {};  }
  • 然後創建FlagValidatorClass作為校驗邏輯的具體實現類,實現ConstraintValidator介面,這裡需要指定兩個泛型參數,第一個需要指定為你自定義的校驗註解類,第二個指定為你要校驗屬性的類型,isValid方法中就是具體的校驗邏輯。
/**   * 狀態標記校驗器   * Created by macro on 2018/4/26.   */  public class FlagValidatorClass implements ConstraintValidator<FlagValidator,Integer> {      private String[] values;      @Override      public void initialize(FlagValidator flagValidator) {          this.values = flagValidator.value();      }        @Override      public boolean isValid(Integer value, ConstraintValidatorContext constraintValidatorContext) {          boolean isValid = false;          if(value==null){              //當狀態為空時使用默認值              return true;          }          for(int i=0;i<values.length;i++){              if(values[i].equals(String.valueOf(value))){                  isValid = true;                  break;              }          }          return isValid;      }  }
  • 接下來我們就可以在傳參對象中使用該註解了;
/**   * 品牌傳遞參數   * Created by macro on 2018/4/26.   */  public class PmsBrandParam {        @ApiModelProperty(value = "是否進行顯示")      @FlagValidator(value = {"0","1"}, message = "顯示狀態不正確")      private Integer showStatus;       //省略若干Getter和Setter方法...  }
  • 最後我們測試下該註解,調用介面是傳入showStatus=3,會返回顯示狀態不正確的錯誤資訊。

優缺點

這種方式的優點是可以使用註解來實現參數校驗,不需要一些重複的校驗邏輯,但是也有一些缺點,比如需要在Controller的方法中額外注入一個BindingResult對象,只支援一些簡單的校驗,涉及到要查詢資料庫的校驗就無法滿足了。

全局異常處理

使用全局異常處理來處理校驗邏輯的思路很簡單,首先我們需要通過@ControllerAdvice註解定義一個全局異常的處理類,然後自定義一個校驗異常,當我們在Controller中校驗失敗時,直接拋出該異常,這樣就可以達到校驗失敗返回錯誤資訊的目的了。

使用到的註解

@ControllerAdvice:類似於@Component註解,可以指定一個組件,這個組件主要用於增強@Controller註解修飾的類的功能,比如說進行全局異常處理。

@ExceptionHandler:用來修飾全局異常處理的方法,可以指定異常的類型。

使用方式

  • 首先我們需要自定義一個異常類ApiException,當我們校驗失敗時拋出該異常:
/**   * 自定義API異常   * Created by macro on 2020/2/27.   */  public class ApiException extends RuntimeException {      private IErrorCode errorCode;        public ApiException(IErrorCode errorCode) {          super(errorCode.getMessage());          this.errorCode = errorCode;      }        public ApiException(String message) {          super(message);      }        public ApiException(Throwable cause) {          super(cause);      }        public ApiException(String message, Throwable cause) {          super(message, cause);      }        public IErrorCode getErrorCode() {          return errorCode;      }  }
  • 然後創建一個斷言處理類Asserts,用於拋出各種ApiException;
/**   * 斷言處理類,用於拋出各種API異常   * Created by macro on 2020/2/27.   */  public class Asserts {      public static void fail(String message) {          throw new ApiException(message);      }        public static void fail(IErrorCode errorCode) {          throw new ApiException(errorCode);      }  }
  • 然後再創建我們的全局異常處理類GlobalExceptionHandler,用於處理全局異常,並返回封裝好的CommonResult對象;
/**   * 全局異常處理   * Created by macro on 2020/2/27.   */  @ControllerAdvice  public class GlobalExceptionHandler {        @ResponseBody      @ExceptionHandler(value = ApiException.class)      public CommonResult handle(ApiException e) {          if (e.getErrorCode() != null) {              return CommonResult.failed(e.getErrorCode());          }          return CommonResult.failed(e.getMessage());      }  }
  • 這裡拿用戶領取優惠券的程式碼為例,我們先對比下改進前後的程式碼,首先看Controller層程式碼。改進後只要Service中的方法執行成功就表示領取優惠券成功,因為領取不成功的話會直接拋出ApiException從而返回錯誤資訊;
/**   * 用戶優惠券管理Controller   * Created by macro on 2018/8/29.   */  @Controller  @Api(tags = "UmsMemberCouponController", description = "用戶優惠券管理")  @RequestMapping("/member/coupon")  public class UmsMemberCouponController {      @Autowired      private UmsMemberCouponService memberCouponService;        //改進前      @ApiOperation("領取指定優惠券")      @RequestMapping(value = "/add/{couponId}", method = RequestMethod.POST)      @ResponseBody      public CommonResult add(@PathVariable Long couponId) {          return memberCouponService.add(couponId);      }        //改進後      @ApiOperation("領取指定優惠券")      @RequestMapping(value = "/add/{couponId}", method = RequestMethod.POST)      @ResponseBody      public CommonResult add(@PathVariable Long couponId) {          memberCouponService.add(couponId);          return CommonResult.success(null,"領取成功");      }  }
  • 再看下Service介面中的程式碼,區別在於返回結果,改進後返回的是void。其實CommonResult的作用本來就是為了把Service中獲取到的數據封裝成統一返回結果,改進前的做法違背了這個原則,改進後的做法解決了這個問題;
/**   * 用戶優惠券管理Service   * Created by macro on 2018/8/29.   */  public interface UmsMemberCouponService {      /**       * 會員添加優惠券(改進前)       */      @Transactional      CommonResult add(Long couponId);        /**       * 會員添加優惠券(改進後)       */      @Transactional      void add(Long couponId);  }
  • 再看下Service實現類中的程式碼,可以看到原先校驗邏輯中返回CommonResult的邏輯都改成了調用Asserts的fail方法來實現;
/**   * 會員優惠券管理Service實現類   * Created by macro on 2018/8/29.   */  @Service  public class UmsMemberCouponServiceImpl implements UmsMemberCouponService {      @Autowired      private UmsMemberService memberService;      @Autowired      private SmsCouponMapper couponMapper;      @Autowired      private SmsCouponHistoryMapper couponHistoryMapper;      @Autowired      private SmsCouponHistoryDao couponHistoryDao;        //改進前      @Override      public CommonResult add(Long couponId) {          UmsMember currentMember = memberService.getCurrentMember();          //獲取優惠券資訊,判斷數量          SmsCoupon coupon = couponMapper.selectByPrimaryKey(couponId);          if(coupon==null){              return CommonResult.failed("優惠券不存在");          }          if(coupon.getCount()<=0){              return CommonResult.failed("優惠券已經領完了");          }          Date now = new Date();          if(now.before(coupon.getEnableTime())){              return CommonResult.failed("優惠券還沒到領取時間");          }          //判斷用戶領取的優惠券數量是否超過限制          SmsCouponHistoryExample couponHistoryExample = new SmsCouponHistoryExample();          couponHistoryExample.createCriteria().andCouponIdEqualTo(couponId).andMemberIdEqualTo(currentMember.getId());          long count = couponHistoryMapper.countByExample(couponHistoryExample);          if(count>=coupon.getPerLimit()){              return CommonResult.failed("您已經領取過該優惠券");          }          //省略領取優惠券邏輯...          return CommonResult.success(null,"領取成功");      }        //改進後       @Override       public void add(Long couponId) {           UmsMember currentMember = memberService.getCurrentMember();           //獲取優惠券資訊,判斷數量           SmsCoupon coupon = couponMapper.selectByPrimaryKey(couponId);           if(coupon==null){               Asserts.fail("優惠券不存在");           }           if(coupon.getCount()<=0){               Asserts.fail("優惠券已經領完了");           }           Date now = new Date();           if(now.before(coupon.getEnableTime())){               Asserts.fail("優惠券還沒到領取時間");           }           //判斷用戶領取的優惠券數量是否超過限制           SmsCouponHistoryExample couponHistoryExample = new SmsCouponHistoryExample();           couponHistoryExample.createCriteria().andCouponIdEqualTo(couponId).andMemberIdEqualTo(currentMember.getId());           long count = couponHistoryMapper.countByExample(couponHistoryExample);           if(count>=coupon.getPerLimit()){               Asserts.fail("您已經領取過該優惠券");           }           //省略領取優惠券邏輯...       }  }
  • 這裡我們輸入一個沒有的優惠券ID來測試下該功能,會返回優惠券不存在的錯誤資訊。

優缺點

使用全局異常來處理校驗邏輯的優點是比較靈活,可以處理複雜的校驗邏輯。缺點是我們需要重複編寫校驗程式碼,不像使用Hibernate Validator那樣只要使用註解就可以了。不過我們可以在上面的Asserts類中添加一些工具方法來增強它的功能,比如判斷是否為空和判斷長度等都可以自己實現。

總結

我們可以兩種方法一起結合使用,比如簡單的參數校驗使用Hibernate Validator來實現,而一些涉及到資料庫操作的複雜校驗使用全局異常處理的方式來實現。

項目源碼地址

https://github.com/macrozheng/mall

Tags: