一個優秀的 Controller 層邏輯
從現狀看問題
改造 Controller 層邏輯
統一返回結構
統一包裝處理
參數校驗
自定義異常與統一攔截異常
總結
一個優秀的 Controller 層邏輯
說到 Controller,相信大家都不陌生,它可以很方便地對外提供數據接口。它的定位,我認為是「不可或缺的配角」。
說它不可或缺是因為無論是傳統的三層架構還是現在的 COLA 架構,Controller 層依舊有一席之地,說明他的必要性。
說它是配角是因為 Controller 層的代碼一般是不負責具體的邏輯業務邏輯實現,但是它負責接收和響應請求。
從現狀看問題
Controller 主要的工作有以下幾項:
接收請求并解析參數
調用 Service 執行具體的業務代碼(可能包含參數校驗)
捕獲業務邏輯異常做出反饋
業務邏輯執行成功做出響應
//DTO @Data publicclassTestDTO{ privateIntegernum; privateStringtype; } //Service @Service publicclassTestService{ publicDoubleservice(TestDTOtestDTO)throwsException{ if(testDTO.getNum()<=?0)?{ ????????????throw?new?Exception("輸入的數字需要大于0"); ????????} ????????if?(testDTO.getType().equals("square"))?{ ????????????return?Math.pow(testDTO.getNum(),?2); ????????} ????????if?(testDTO.getType().equals("factorial"))?{ ????????????double?result?=?1; ????????????int?num?=?testDTO.getNum(); ????????????while?(num?>1){ result=result*num; num-=1; } returnresult; } thrownewException("未識別的算法"); } } //Controller @RestController publicclassTestController{ privateTestServicetestService; @PostMapping("/test") publicDoubletest(@RequestBodyTestDTOtestDTO){ try{ Doubleresult=this.testService.service(testDTO); returnresult; }catch(Exceptione){ thrownewRuntimeException(e); } } @Autowired publicDTOidsetTestService(TestServicetestService){ this.testService=testService; } }
如果真的按照上面所列的工作項來開發 Controller 代碼會有幾個問題:
參數校驗過多地耦合了業務代碼,違背單一職責原則
可能在多個業務中都拋出同一個異常,導致代碼重復
各種異常反饋和成功響應格式不統一,接口對接不友好
改造 Controller 層邏輯
統一返回結構
統一返回值類型無論項目前后端是否分離都是非常必要的,方便對接接口的開發人員更加清晰地知道這個接口的調用是否成功(不能僅僅簡單地看返回值是否為 null 就判斷成功與否,因為有些接口的設計就是如此)。
使用一個狀態碼、狀態信息就能清楚地了解接口調用情況:
//定義返回數據結構 publicinterfaceIResult{ IntegergetCode(); StringgetMessage(); } //常用結果的枚舉 publicenumResultEnumimplementsIResult{ SUCCESS(2001,"接口調用成功"), VALIDATE_FAILED(2002,"參數校驗失敗"), COMMON_FAILED(2003,"接口調用失敗"), FORBIDDEN(2004,"沒有權限訪問資源"); privateIntegercode; privateStringmessage; //省略get、set方法和構造方法 } //統一返回數據結構 @Data @NoArgsConstructor @AllArgsConstructor publicclassResult{ privateIntegercode; privateStringmessage; privateTdata; publicstaticResultsuccess(Tdata){ returnnewResult<>(ResultEnum.SUCCESS.getCode(),ResultEnum.SUCCESS.getMessage(),data); } publicstaticResultsuccess(Stringmessage,Tdata){ returnnewResult<>(ResultEnum.SUCCESS.getCode(),message,data); } publicstaticResultfailed(){ returnnewResult<>(ResultEnum.COMMON_FAILED.getCode(),ResultEnum.COMMON_FAILED.getMessage(),null); } publicstaticResultfailed(Stringmessage){ returnnewResult<>(ResultEnum.COMMON_FAILED.getCode(),message,null); } publicstaticResultfailed(IResulterrorResult){ returnnewResult<>(errorResult.getCode(),errorResult.getMessage(),null); } publicstaticResultinstance(Integercode,Stringmessage,Tdata){ Resultresult=newResult<>(); result.setCode(code); result.setMessage(message); result.setData(data); returnresult; } }
統一返回結構后,在 Controller 中就可以使用了,但是每一個 Controller 都寫這么一段最終封裝的邏輯,這些都是很重復的工作,所以還要繼續想辦法進一步處理統一返回結構。
統一包裝處理
Spring 中提供了一個類 ResponseBodyAdvice ,能幫助我們實現上述需求:
publicinterfaceResponseBodyAdvice{ booleansupports(MethodParameterreturnType,Class>converterType); @Nullable TbeforeBodyWrite(@NullableTbody,MethodParameterreturnType,MediaTypeselectedContentType,Class>selectedConverterType,ServerHttpRequestrequest,ServerHttpResponseresponse); }
ResponseBodyAdvice 是對 Controller 返回的內容在 HttpMessageConverter 進行類型轉換之前攔截,進行相應的處理操作后,再將結果返回給客戶端。
那這樣就可以把統一包裝的工作放到這個類里面:
supports: 判斷是否要交給 beforeBodyWrite 方法執行,ture:需要;false:不需要
beforeBodyWrite: 對 response 進行具體的處理
//如果引入了swagger或knife4j的文檔生成組件,這里需要僅掃描自己項目的包,否則文檔無法正常生成 @RestControllerAdvice(basePackages="com.example.demo") publicclassResponseAdviceimplementsResponseBodyAdvice{ @Override publicbooleansupports(MethodParameterreturnType,Class>converterType){ //如果不需要進行封裝的,可以添加一些校驗手段,比如添加標記排除的注解 returntrue; } @Override publicObjectbeforeBodyWrite(Objectbody,MethodParameterreturnType,MediaTypeselectedContentType,Class>selectedConverterType,ServerHttpRequestrequest,ServerHttpResponseresponse){ //提供一定的靈活度,如果body已經被包裝了,就不進行包裝 if(bodyinstanceofResult){ returnbody; } returnResult.success(body); } }
經過這樣改造,既能實現對 Controller 返回的數據進行統一包裝,又不需要對原有代碼進行大量的改動。
參數校驗
Java API 的規范 JSR303 定義了校驗的標準 validation-api ,其中一個比較出名的實現是 hibernate validation。
spring validation 是對其的二次封裝,常用于 SpringMVC 的參數自動校驗,參數校驗的代碼就不需要再與業務邏輯代碼進行耦合了。
①@PathVariable 和 @RequestParam 參數校驗
Get 請求的參數接收一般依賴這兩個注解,但是處于 url 有長度限制和代碼的可維護性,超過 5 個參數盡量用實體來傳參。
對 @PathVariable 和 @RequestParam 參數進行校驗需要在入參聲明約束的注解。
如果校驗失敗,會拋出 MethodArgumentNotValidException 異常。
@RestController(value="prettyTestController") @RequestMapping("/pretty") publicclassTestController{ privateTestServicetestService; @GetMapping("/{num}") publicIntegerdetail(@PathVariable("num")@Min(1)@Max(20)Integernum){ returnnum*num; } @GetMapping("/getByEmail") publicTestDTOgetByAccount(@RequestParam@NotBlank@EmailStringemail){ TestDTOtestDTO=newTestDTO(); testDTO.setEmail(email); returntestDTO; } @Autowired publicvoidsetTestService(TestServiceprettyTestService){ this.testService=prettyTestService; } }
校驗原理
在 SpringMVC 中,有一個類是 RequestResponseBodyMethodProcessor,這個類有兩個作用(實際上可以從名字上得到一點啟發)
用于解析 @RequestBody 標注的參數
處理 @ResponseBody 標注方法的返回值
解析 @RequestBoyd 標注參數的方法是 resolveArgument。
publicclassRequestResponseBodyMethodProcessorextendsAbstractMessageConverterMethodProcessor{ /** *ThrowsMethodArgumentNotValidExceptionifvalidationfails. *@throwsHttpMessageNotReadableExceptionif{@linkRequestBody#required()} *is{@codetrue}andthereisnobodycontentorifthereisnosuitable *convertertoreadthecontentwith. */ @Override publicObjectresolveArgument(MethodParameterparameter,@NullableModelAndViewContainermavContainer, NativeWebRequestwebRequest,@NullableWebDataBinderFactorybinderFactory)throwsException{ parameter=parameter.nestedIfOptional(); //把請求數據封裝成標注的DTO對象 Objectarg=readWithMessageConverters(webRequest,parameter,parameter.getNestedGenericParameterType()); Stringname=Conventions.getVariableNameForParameter(parameter); if(binderFactory!=null){ WebDataBinderbinder=binderFactory.createBinder(webRequest,arg,name); if(arg!=null){ //執行數據校驗 validateIfApplicable(binder,parameter); //如果校驗不通過,就拋出MethodArgumentNotValidException異常 //如果我們不自己捕獲,那么最終會由DefaultHandlerExceptionResolver捕獲處理 if(binder.getBindingResult().hasErrors()&&isBindExceptionRequired(binder,parameter)){ thrownewMethodArgumentNotValidException(parameter,binder.getBindingResult()); } } if(mavContainer!=null){ mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX+name,binder.getBindingResult()); } } returnadaptArgumentIfNecessary(arg,parameter); } } publicabstractclassAbstractMessageConverterMethodArgumentResolverimplementsHandlerMethodArgumentResolver{ /** *Validatethebindingtargetifapplicable. *
Thedefaultimplementationchecksfor{@code@javax.validation.Valid}, *Spring's{@linkorg.springframework.validation.annotation.Validated}, *andcustomannotationswhosenamestartswith"Valid". *@parambindertheDataBindertobeused *@paramparameterthemethodparameterdescriptor *@since4.1.5 *@see#isBindExceptionRequired */ protectedvoidvalidateIfApplicable(WebDataBinderbinder,MethodParameterparameter){ //獲取參數上的所有注解 Annotation[]annotations=parameter.getParameterAnnotations(); for(Annotationann:annotations){ //如果注解中包含了@Valid、@Validated或者是名字以Valid開頭的注解就進行參數校驗 Object[]validationHints=ValidationAnnotationUtils.determineValidationHints(ann); if(validationHints!=null){ //實際校驗邏輯,最終會調用HibernateValidator執行真正的校驗 //所以SpringValidation是對HibernateValidation的二次封裝 binder.validate(validationHints); break; } } } }
②@RequestBody 參數校驗
Post、Put 請求的參數推薦使用 @RequestBody 請求體參數。
對 @RequestBody 參數進行校驗需要在 DTO 對象中加入校驗條件后,再搭配 @Validated 即可完成自動校驗。
如果校驗失敗,會拋出 ConstraintViolationException 異常。
//DTO @Data publicclassTestDTO{ @NotBlank privateStringuserName; @NotBlank @Length(min=6,max=20) privateStringpassword; @NotNull @Email privateStringemail; } //Controller @RestController(value="prettyTestController") @RequestMapping("/pretty") publicclassTestController{ privateTestServicetestService; @PostMapping("/test-validation") publicvoidtestValidation(@RequestBody@ValidatedTestDTOtestDTO){ this.testService.save(testDTO); } @Autowired publicvoidsetTestService(TestServicetestService){ this.testService=testService; } }
校驗原理
聲明約束的方式,注解加到了參數上面,可以比較容易猜測到是使用了 AOP 對方法進行增強。
而實際上 Spring 也是通過 MethodValidationPostProcessor 動態注冊 AOP 切面,然后使用 MethodValidationInterceptor 對切點方法進行織入增強。
publicclassMethodValidationPostProcessorextendsAbstractBeanFactoryAwareAdvisingPostProcessorimplementsInitializingBean{ //指定了創建切面的Bean的注解 privateClassvalidatedAnnotationType=Validated.class; @Override publicvoidafterPropertiesSet(){ //為所有@Validated標注的Bean創建切面 Pointcutpointcut=newAnnotationMatchingPointcut(this.validatedAnnotationType,true); //創建Advisor進行增強 this.advisor=newDefaultPointcutAdvisor(pointcut,createMethodValidationAdvice(this.validator)); } //創建Advice,本質就是一個方法攔截器 protectedAdvicecreateMethodValidationAdvice(@NullableValidatorvalidator){ return(validator!=null?newMethodValidationInterceptor(validator):newMethodValidationInterceptor()); } } publicclassMethodValidationInterceptorimplementsMethodInterceptor{ @Override publicObjectinvoke(MethodInvocationinvocation)throwsThrowable{ //無需增強的方法,直接跳過 if(isFactoryBeanMetadataMethod(invocation.getMethod())){ returninvocation.proceed(); } Class[]groups=determineValidationGroups(invocation); ExecutableValidatorexecVal=this.validator.forExecutables(); MethodmethodToValidate=invocation.getMethod(); Set>result; try{ //方法入參校驗,最終還是委托給HibernateValidator來校驗 //所以SpringValidation是對HibernateValidation的二次封裝 result=execVal.validateParameters( invocation.getThis(),methodToValidate,invocation.getArguments(),groups); } catch(IllegalArgumentExceptionex){ ... } //校驗不通過拋出ConstraintViolationException異常 if(!result.isEmpty()){ thrownewConstraintViolationException(result); } //Controller方法調用 ObjectreturnValue=invocation.proceed(); //下面是對返回值做校驗,流程和上面大概一樣 result=execVal.validateReturnValue(invocation.getThis(),methodToValidate,returnValue,groups); if(!result.isEmpty()){ thrownewConstraintViolationException(result); } returnreturnValue; } }
③自定義校驗規則
有些時候 JSR303 標準中提供的校驗規則不滿足復雜的業務需求,也可以自定義校驗規則。
自定義校驗規則需要做兩件事情:
自定義注解類,定義錯誤信息和一些其他需要的內容
注解校驗器,定義判定規則
//自定義注解類 @Target({ElementType.METHOD,ElementType.FIELD,ElementType.ANNOTATION_TYPE,ElementType.CONSTRUCTOR,ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Documented @Constraint(validatedBy=MobileValidator.class) public@interfaceMobile{ /** *是否允許為空 */ booleanrequired()defaulttrue; /** *校驗不通過返回的提示信息 */ Stringmessage()default"不是一個手機號碼格式"; /** *Constraint要求的屬性,用于分組校驗和擴展,留空就好 */ Class[]groups()default{}; Class[]payload()default{}; } //注解校驗器 publicclassMobileValidatorimplementsConstraintValidator{ privatebooleanrequired=false; privatefinalPatternpattern=Pattern.compile("^1[34578][0-9]{9}$");//驗證手機號 /** *在驗證開始前調用注解里的方法,從而獲取到一些注解里的參數 * *@paramconstraintAnnotationannotationinstanceforagivenconstraintdeclaration */ @Override publicvoidinitialize(MobileconstraintAnnotation){ this.required=constraintAnnotation.required(); } /** *判斷參數是否合法 * *@paramvalueobjecttovalidate *@paramcontextcontextinwhichtheconstraintisevaluated */ @Override publicbooleanisValid(CharSequencevalue,ConstraintValidatorContextcontext){ if(this.required){ //驗證 returnisMobile(value); } if(StringUtils.hasText(value)){ //驗證 returnisMobile(value); } returntrue; } privatebooleanisMobile(finalCharSequencestr){ Matcherm=pattern.matcher(str); returnm.matches(); } } ,>
自動校驗參數真的是一項非常必要、非常有意義的工作。JSR303 提供了豐富的參數校驗規則,再加上復雜業務的自定義校驗規則,完全把參數校驗和業務邏輯解耦開,代碼更加簡潔,符合單一職責原則。
自定義異常與統一攔截異常
原來的代碼中可以看到有幾個問題:
拋出的異常不夠具體,只是簡單地把錯誤信息放到了 Exception 中
拋出異常后,Controller 不能具體地根據異常做出反饋
雖然做了參數自動校驗,但是異常返回結構和正常返回結構不一致
自定義異常是為了后面統一攔截異常時,對業務中的異常有更加細顆粒度的區分,攔截時針對不同的異常作出不同的響應。
而統一攔截異常的目的一個是為了可以與前面定義下來的統一包裝返回結構能對應上,另一個是我們希望無論系統發生什么異常,Http 的狀態碼都要是 200 ,盡可能由業務來區分系統的異常。
//自定義異常 publicclassForbiddenExceptionextendsRuntimeException{ publicForbiddenException(Stringmessage){ super(message); } } //自定義異常 publicclassBusinessExceptionextendsRuntimeException{ publicBusinessException(Stringmessage){ super(message); } } //統一攔截異常 @RestControllerAdvice(basePackages="com.example.demo") publicclassExceptionAdvice{ /** *捕獲{@codeBusinessException}異常 */ @ExceptionHandler({BusinessException.class}) publicResulthandleBusinessException(BusinessExceptionex){ returnResult.failed(ex.getMessage()); } /** *捕獲{@codeForbiddenException}異常 */ @ExceptionHandler({ForbiddenException.class}) publicResulthandleForbiddenException(ForbiddenExceptionex){ returnResult.failed(ResultEnum.FORBIDDEN); } /** *{@code@RequestBody}參數校驗不通過時拋出的異常處理 */ @ExceptionHandler({MethodArgumentNotValidException.class}) publicResulthandleMethodArgumentNotValidException(MethodArgumentNotValidExceptionex){ BindingResultbindingResult=ex.getBindingResult(); StringBuildersb=newStringBuilder("校驗失敗:"); for(FieldErrorfieldError:bindingResult.getFieldErrors()){ sb.append(fieldError.getField()).append(":").append(fieldError.getDefaultMessage()).append(","); } Stringmsg=sb.toString(); if(StringUtils.hasText(msg)){ returnResult.failed(ResultEnum.VALIDATE_FAILED.getCode(),msg); } returnResult.failed(ResultEnum.VALIDATE_FAILED); } /** *{@code@PathVariable}和{@code@RequestParam}參數校驗不通過時拋出的異常處理 */ @ExceptionHandler({ConstraintViolationException.class}) publicResulthandleConstraintViolationException(ConstraintViolationExceptionex){ if(StringUtils.hasText(ex.getMessage())){ returnResult.failed(ResultEnum.VALIDATE_FAILED.getCode(),ex.getMessage()); } returnResult.failed(ResultEnum.VALIDATE_FAILED); } /** *頂級異常捕獲并統一處理,當其他異常無法處理時候選擇使用 */ @ExceptionHandler({Exception.class}) publicResulthandle(Exceptionex){ returnResult.failed(ex.getMessage()); } }
總結
做好了這一切改動后,可以發現 Controller 的代碼變得非常簡潔,可以很清楚地知道每一個參數、每一個 DTO 的校驗規則,可以很明確地看到每一個 Controller 方法返回的是什么數據,也可以方便每一個異常應該如何進行反饋。這一套操作下來后,我們能更加專注于業務邏輯的開發,代碼簡介、功能完善,何樂而不為呢
,>-
Controller
+關注
關注
0文章
398瀏覽量
57229 -
代碼
+關注
關注
30文章
4857瀏覽量
69526 -
spring
+關注
關注
0文章
340瀏覽量
14574
原文標題:Controller層代碼就該這么寫,簡潔又優雅!
文章出處:【微信號:芋道源碼,微信公眾號:芋道源碼】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
如何做到每天寫代碼?
請問這個代碼怎么寫?
用shell腳本寫curl命令調用自己寫的代碼接口
不寫代碼也能玩轉人工智能 Uber宣布開源Ludwig
我們該如何用powerPCB設定4層板的層
怎么看懂別人寫的單片機項目代碼?

評論