四步幫你把Controller的程式碼變得簡潔

👉 這是一個或許對你有用的社群
🐱 一對一交流/面試小冊/簡歷最佳化/求職解惑,歡迎加入芋道快速開發平臺知識星球。下面是星球提供的部分資料:
👉這是一個或許對你有用的開源專案
國產 Star 破 10w+ 的開源專案,前端包括管理後臺 + 微信小程式,後端支援單體和微服務架構。
功能涵蓋 RBAC 許可權、SaaS 多租戶、資料許可權、商城、支付、工作流、大屏報表、微信公眾號、ERPCRMAI 大模型等等功能:
  • Boot 多模組架構:https://gitee.com/zhijiantianya/ruoyi-vue-pro
  • Cloud 微服務架構:https://gitee.com/zhijiantianya/yudao-cloud
  • 影片教程:https://doc.iocoder.cn
【國內首批】支援 JDK 17/21 + SpringBoot 3.3、JDK 8/11 + Spring Boot 2.7 雙版本 

一個優秀的 Controller 層邏輯

說到 Controller,相信大家都不陌生,它可以很方便地對外提供資料介面。它的定位,我認為是「不可或缺的配角」。
說它不可或缺是因為無論是傳統的三層架構還是現在的 COLA 架構,Controller 層依舊有一席之地,說明他的必要性。
說它是配角是因為 Controller 層的程式碼一般是不負責具體的邏輯業務邏輯實現,但是它負責接收和響應請求。
基於 Spring Boot + MyBatis Plus + Vue & Element 實現的後臺管理系統 + 使用者小程式,支援 RBAC 動態許可權、多租戶、資料許可權、工作流、三方登入、支付、簡訊、商城等功能
  • 專案地址:https://github.com/YunaiV/ruoyi-vue-pro
  • 影片教程:https://doc.iocoder.cn/video/

從現狀看問題

Controller 主要的工作有以下幾項:
  • 接收請求並解析引數
  • 呼叫 Service 執行具體的業務程式碼(可能包含引數校驗)
  • 捕獲業務邏輯異常做出反饋
  • 業務邏輯執行成功做出響應
//DTO
@Data
publicclassTestDTO

{

private

 Integer num;

private

 String type;

}

//Service
@Service
publicclassTestService

{

public Double service(TestDTO testDTO)throws Exception 

{

if

 (testDTO.getNum() <= 

0

) {

thrownew

 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

;

            }

return

 result;

        }

thrownew

 Exception(

"未識別的演算法"

);

    }

}

//Controller
@RestController
publicclassTestController

{

private

 TestService testService;

@PostMapping

(

"/test"

)

public Double test(@RequestBody TestDTO testDTO)

{

try

 {

            Double result = 

this

.testService.service(testDTO);

return

 result;

        } 

catch

 (Exception e) {

thrownew

 RuntimeException(e);

        }

    }

@Autowired
public DTOid setTestService(TestService testService)

{

this

.testService = testService;

    }

}

如果真的按照上面所列的工作項來開發 Controller 程式碼會有幾個問題:
  • 引數校驗過多地耦合了業務程式碼,違背單一職責原則
  • 可能在多個業務中都丟擲同一個異常,導致程式碼重複
  • 各種異常反饋和成功響應格式不統一,介面對接不友好
基於 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 實現的後臺管理系統 + 使用者小程式,支援 RBAC 動態許可權、多租戶、資料許可權、工作流、三方登入、支付、簡訊、商城等功能
  • 專案地址:https://github.com/YunaiV/yudao-cloud
  • 影片教程:https://doc.iocoder.cn/video/

改造 Controller 層邏輯

統一返回結構

統一返回值型別無論專案前後端是否分離都是非常必要的,方便對接介面的開發人員更加清晰地知道這個介面的呼叫是否成功(不能僅僅簡單地看返回值是否為 null 就判斷成功與否,因為有些介面的設計就是如此)。
使用一個狀態碼、狀態資訊就能清楚地瞭解介面呼叫情況:
//定義返回資料結構
publicinterfaceIResult

{

Integer getCode()

;

String getMessage()

;

}

//常用結果的列舉
publicenum

 ResultEnum implements IResult {

    SUCCESS(

2001

"介面呼叫成功"

),

    VALIDATE_FAILED(

2002

"引數校驗失敗"

),

    COMMON_FAILED(

2003

"介面呼叫失敗"

),

    FORBIDDEN(

2004

"沒有許可權訪問資源"

);

private

 Integer code;

private

 String message;

//省略get、set方法和構造方法

}

//統一返回資料結構
@Data
@NoArgsConstructor
@AllArgsConstructor
publicclassResult<T

{

private

 Integer code;

private

 String message;

private

 T data;

publicstatic

 <T> 

Result<T> success(T data)

{

returnnew

 Result<>(ResultEnum.SUCCESS.getCode(), ResultEnum.SUCCESS.getMessage(), data);

    }

publicstatic

 <T> 

Result<T> success(String message, T data)

{

returnnew

 Result<>(ResultEnum.SUCCESS.getCode(), message, data);

    }

publicstatic

 Result<?> failed() {

returnnew

 Result<>(ResultEnum.COMMON_FAILED.getCode(), ResultEnum.COMMON_FAILED.getMessage(), 

null

);

    }

publicstatic

 Result<?> failed(String message) {

returnnew

 Result<>(ResultEnum.COMMON_FAILED.getCode(), message, 

null

);

    }

publicstatic

 Result<?> failed(IResult errorResult) {

returnnew

 Result<>(errorResult.getCode(), errorResult.getMessage(), 

null

);

    }

publicstatic

 <T> 

Result<T> instance(Integer code, String message, T data)

{

        Result<T> result = 

new

 Result<>();

        result.setCode(code);

        result.setMessage(message);

        result.setData(data);

return

 result;

    }

}

統一返回結構後,在 Controller 中就可以使用了,但是每一個 Controller 都寫這麼一段最終封裝的邏輯,這些都是很重複的工作,所以還要繼續想辦法進一步處理統一返回結構。

統一包裝處理

Spring 中提供了一個類 ResponseBodyAdvice ,能幫助我們實現上述需求:
publicinterfaceResponseBodyAdvice<T

{

booleansupports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType)

;

@Nullable
beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response)

;

}

ResponseBodyAdvice 是對 Controller 返回的內容在 HttpMessageConverter 進行型別轉換之前攔截,進行相應的處理操作後,再將結果返回給客戶端。
那這樣就可以把統一包裝的工作放到這個類裡面:
  • supports: 判斷是否要交給 beforeBodyWrite 方法執行,ture:需要;false:不需要
  • beforeBodyWrite: 對 response 進行具體的處理
// 如果引入了swagger或knife4j的文件生成元件,這裡需要僅掃描自己專案的包,否則文件無法正常生成
@RestControllerAdvice

(basePackages = 

"com.example.demo"

)

publicclassResponseAdviceimplementsResponseBodyAdvice<Object

{

@Override
publicbooleansupports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType)

{

// 如果不需要進行封裝的,可以新增一些校驗手段,比如新增標記排除的註解
returntrue

;

    }

@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response)

{

// 提供一定的靈活度,如果body已經被包裝了,就不進行包裝
if

 (body 

instanceof

 Result) {

return

 body;

        }

return

 Result.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

{

private

 TestService testService;

@GetMapping

(

"/{num}"

)

public Integer detail(@PathVariable("num") @Min(1) @Max(20) Integer num) 

{

return

 num * num;

    }

@GetMapping

(

"/getByEmail"

)

public TestDTO getByAccount(@RequestParam @NotBlank @Email String email)

{

        TestDTO testDTO = 

new

 TestDTO();

        testDTO.setEmail(email);

return

 testDTO;

    }

@Autowired
publicvoidsetTestService(TestService prettyTestService)

{

this

.testService = prettyTestService;

    }

}

校驗原理
在 SpringMVC 中,有一個類是 RequestResponseBodyMethodProcessor,這個類有兩個作用(實際上可以從名字上得到一點啟發)
  • 用於解析 @RequestBody 標註的引數
  • 處理 @ResponseBody 標註方法的返回值
解析 @RequestBoyd 標註引數的方法是 resolveArgument。
publicclassRequestResponseBodyMethodProcessorextendsAbstractMessageConverterMethodProcessor

{

/**

     * Throws MethodArgumentNotValidException if validation fails.

     * 

@throws

 HttpMessageNotReadableException if {

@link

 RequestBody#required()}

     * is {

@code

 true} and there is no body content or if there is no suitable

     * converter to read the content with.

     */


@Override
public Object resolveArgument

(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,

        NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory)

throws Exception 

{
      parameter = parameter.nestedIfOptional();

//把請求資料封裝成標註的DTO物件

      Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());

      String name = Conventions.getVariableNameForParameter(parameter);

if

 (binderFactory != 

null

) {

        WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);

if

 (arg != 

null

) {

//執行資料校驗

          validateIfApplicable(binder, parameter);

//如果校驗不透過,就丟擲MethodArgumentNotValidException異常
//如果我們不自己捕獲,那麼最終會由DefaultHandlerExceptionResolver捕獲處理
if

 (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {

thrownew

 MethodArgumentNotValidException(parameter, binder.getBindingResult());

          }

        }

if

 (mavContainer != 

null

) {

          mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());

        }

      }

return

 adaptArgumentIfNecessary(arg, parameter);

    }

}

publicabstractclassAbstractMessageConverterMethodArgumentResolverimplementsHandlerMethodArgumentResolver

{

/**

    * Validate the binding target if applicable.

    * <p>The default implementation checks for {

@code@javax

.validation.Valid},

    * Spring's {

@link

 org.springframework.validation.annotation.Validated},

    * and custom annotations whose name starts with "Valid".

    * 

@param

 binder the DataBinder to be used

    * 

@param

 parameter the method parameter descriptor

    * 

@since

 4.1.5

    * 

@see

 #isBindExceptionRequired

    */


protectedvoidvalidateIfApplicable(WebDataBinder binder, MethodParameter parameter)

{

//獲取引數上的所有註解

      Annotation[] annotations = parameter.getParameterAnnotations();

for

 (Annotation ann : annotations) {

//如果註解中包含了@Valid、@Validated或者是名字以Valid開頭的註解就進行引數校驗

         Object[] validationHints = ValidationAnnotationUtils.determineValidationHints(ann);

if

 (validationHints != 

null

) {

//實際校驗邏輯,最終會呼叫Hibernate Validator執行真正的校驗
//所以Spring Validation是對Hibernate Validation的二次封裝

            binder.validate(validationHints);

break

;

         }

      }

   }

}

②@RequestBody 引數校驗
Post、Put 請求的引數推薦使用 @RequestBody 請求體引數。
對 @RequestBody 引數進行校驗需要在 DTO 物件中加入校驗條件後,再搭配 @Validated 即可完成自動校驗。
如果校驗失敗,會丟擲 ConstraintViolationException 異常。
//DTO
@Data
publicclassTestDTO

{

@NotBlank
private

 String userName;

@NotBlank
@Length

(min = 

6

, max = 

20

)

private

 String password;

@NotNull
@Email
private

 String email;

}

//Controller
@RestController

(value = 

"prettyTestController"

)

@RequestMapping

(

"/pretty"

)

publicclassTestController

{

private

 TestService testService;

@PostMapping

(

"/test-validation"

)

publicvoidtestValidation(@RequestBody @Validated TestDTO testDTO)

{

this

.testService.save(testDTO);

    }

@Autowired
publicvoidsetTestService(TestService testService)

{

this

.testService = testService;

    }

}

校驗原理
宣告約束的方式,註解加到了引數上面,可以比較容易猜測到是使用了 AOP 對方法進行增強。
而實際上 Spring 也是透過 MethodValidationPostProcessor 動態註冊 AOP 切面,然後使用 MethodValidationInterceptor 對切點方法進行織入增強。
publicclassMethodValidationPostProcessorextendsAbstractBeanFactoryAwareAdvisingPostProcessorimplementsInitializingBean

{

//指定了建立切面的Bean的註解
private

 Class<? extends Annotation> validatedAnnotationType = Validated

.class

;

@Override
publicvoidafterPropertiesSet()

{

//為所有@Validated標註的Bean建立切面

        Pointcut pointcut = 

new

 AnnotationMatchingPointcut(

this

.validatedAnnotationType, 

true

);

//建立Advisor進行增強
this

.advisor = 

new

 DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(

this

.validator));

    }

//建立Advice,本質就是一個方法攔截器
protected Advice createMethodValidationAdvice(@Nullable Validator validator)

{

return

 (validator != 

null

 ? 

new

 MethodValidationInterceptor(validator) : 

new

 MethodValidationInterceptor());

    }

}

publicclassMethodValidationInterceptorimplementsMethodInterceptor

{

@Override
public Object invoke(MethodInvocation invocation)throws Throwable 

{

//無需增強的方法,直接跳過
if

 (isFactoryBeanMetadataMethod(invocation.getMethod())) {

return

 invocation.proceed();

        }
        Class<?>[] groups = determineValidationGroups(invocation);

        ExecutableValidator execVal = 

this

.validator.forExecutables();

        Method methodToValidate = invocation.getMethod();

        Set<ConstraintViolation<Object>> result;

try

 {

//方法入參校驗,最終還是委託給Hibernate Validator來校驗
//所以Spring Validation是對Hibernate Validation的二次封裝

            result = execVal.validateParameters(

                invocation.getThis(), methodToValidate, invocation.getArguments(), groups);

        }

catch

 (IllegalArgumentException ex) {

            ...

        }

//校驗不透過丟擲ConstraintViolationException異常
if

 (!result.isEmpty()) {

thrownew

 ConstraintViolationException(result);

        }

//Controller方法呼叫

        Object returnValue = invocation.proceed();

//下面是對返回值做校驗,流程和上面大概一樣

        result = execVal.validateReturnValue(invocation.getThis(), methodToValidate, returnValue, groups);

if

 (!result.isEmpty()) {

thrownew

 ConstraintViolationException(result);

        }

return

 returnValue;

    }

}

③自定義校驗規則
有些時候 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

;

/**

     * 校驗不透過返回的提示資訊

     */


String message()default "不是一個手機號碼格式"

;

/**

     * Constraint要求的屬性,用於分組校驗和擴充套件,留空就好

     */

    Class<?>[] groups() 

default

 {};

    Class<? extends Payload>[] payload() 

default

 {};

}

//註解校驗器
publicclassMobileValidatorimplementsConstraintValidator<MobileCharSequence

{

privateboolean

 required = 

false

;

privatefinal

 Pattern pattern = Pattern.compile(

"^1[34578][0-9]{9}$"

); 

// 驗證手機號

/**

     * 在驗證開始前呼叫註解裡的方法,從而獲取到一些註解裡的引數

     *

     * 

@param

 constraintAnnotation annotation instance for a given constraint declaration

     */


@Override
publicvoidinitialize(Mobile constraintAnnotation)

{

this

.required = constraintAnnotation.required();

    }

/**

     * 判斷引數是否合法

     *

     * 

@param

 value   object to validate

     * 

@param

 context context in which the constraint is evaluated

     */


@Override
publicbooleanisValid(CharSequence value, ConstraintValidatorContext context)

{

if

 (

this

.required) {

// 驗證
return

 isMobile(value);

        }

if

 (StringUtils.hasText(value)) {

// 驗證
return

 isMobile(value);

        }

returntrue

;

    }

privatebooleanisMobile(final CharSequence str)

{

        Matcher m = pattern.matcher(str);

return

 m.matches();

    }

}

自動校驗引數真的是一項非常必要、非常有意義的工作。JSR303 提供了豐富的引數校驗規則,再加上覆雜業務的自定義校驗規則,完全把引數校驗和業務邏輯解耦開,程式碼更加簡潔,符合單一職責原則。

自定義異常與統一攔截異常

原來的程式碼中可以看到有幾個問題:
  • 丟擲的異常不夠具體,只是簡單地把錯誤資訊放到了 Exception 中
  • 丟擲異常後,Controller 不能具體地根據異常做出反饋
  • 雖然做了引數自動校驗,但是異常返回結構和正常返回結構不一致
自定義異常是為了後面統一攔截異常時,對業務中的異常有更加細顆粒度的區分,攔截時針對不同的異常作出不同的響應。
而統一攔截異常的目的一個是為了可以與前面定義下來的統一包裝返回結構能對應上,另一個是我們希望無論系統發生什麼異常,Http 的狀態碼都要是 200 ,儘可能由業務來區分系統的異常。
//自定義異常
publicclassForbiddenExceptionextendsRuntimeException

{

publicForbiddenException(String message)

{

super

(message);

    }

}

//自定義異常
publicclassBusinessExceptionextendsRuntimeException

{

publicBusinessException(String message)

{

super

(message);

    }

}

//統一攔截異常
@RestControllerAdvice

(basePackages = 

"com.example.demo"

)

publicclassExceptionAdvice

{

/**

     * 捕獲 {

@code

 BusinessException} 異常

     */


@ExceptionHandler

({BusinessException

.

class

})

publicResult

<?> 

handleBusinessException

(

BusinessExceptionex

{

return

 Result.failed(ex.getMessage());

    }

/**

     * 捕獲 {

@code

 ForbiddenException} 異常

     */


@ExceptionHandler

({ForbiddenException

.

class

})

publicResult

<?> 

handleForbiddenException

(

ForbiddenExceptionex

{

return

 Result.failed(ResultEnum.FORBIDDEN);

    }

/**

     * {

@code@RequestBody

} 引數校驗不透過時丟擲的異常處理

     */


@ExceptionHandler

({MethodArgumentNotValidException

.

class

})

publicResult

<?> 

handleMethodArgumentNotValidException

(

MethodArgumentNotValidExceptionex

{

        BindingResult bindingResult = ex.getBindingResult();

        StringBuilder sb = 

new

 StringBuilder(

"校驗失敗:"

);

for

 (FieldError fieldError : bindingResult.getFieldErrors()) {

            sb.append(fieldError.getField()).append(

":"

).append(fieldError.getDefaultMessage()).append(

", "

);

        }

        String msg = sb.toString();

if

 (StringUtils.hasText(msg)) {

return

 Result.failed(ResultEnum.VALIDATE_FAILED.getCode(), msg);

        }

return

 Result.failed(ResultEnum.VALIDATE_FAILED);

    }

/**

     * {

@code@PathVariable

} 和 {

@code@RequestParam

} 引數校驗不透過時丟擲的異常處理

     */


@ExceptionHandler

({ConstraintViolationException

.

class

})

publicResult

<?> 

handleConstraintViolationException

(

ConstraintViolationExceptionex

{

if

 (StringUtils.hasText(ex.getMessage())) {

return

 Result.failed(ResultEnum.VALIDATE_FAILED.getCode(), ex.getMessage());

        }

return

 Result.failed(ResultEnum.VALIDATE_FAILED);

    }

/**

     * 頂級異常捕獲並統一處理,當其他異常無法處理時候選擇使用

     */


@ExceptionHandler

({Exception

.

class

})

publicResult

<?> 

handle

(

Exceptionex

{

return

 Result.failed(ex.getMessage());

    }
}

總結

做好了這一切改動後,可以發現 Controller 的程式碼變得非常簡潔,可以很清楚地知道每一個引數、每一個 DTO 的校驗規則,可以很明確地看到每一個 Controller 方法返回的是什麼資料,也可以方便每一個異常應該如何進行反饋。
這一套操作下來後,我們能更加專注於業務邏輯的開發,程式碼簡介、功能完善,何樂而不為呢?

歡迎加入我的知識星球,全面提升技術能力。
👉 加入方式,長按”或“掃描”下方二維碼噢
星球的內容包括:專案實戰、面試招聘、原始碼解析、學習路線。
文章有幫助的話,在看,轉發吧。
謝謝支援喲 (*^__^*)

相關文章