一個優秀的Controller層邏輯
說到 Controller,相信大家都不陌生,它可以很友善地對外提供資料接口。它的定位,我認為是「不可或缺的配角」,說它不可或缺是因為無論是傳統的三層架構還是現在的COLA架構,Controller 層依舊有一席之地,說明他的必要性;說它是配角是因為 Controller 層的代碼一般是不負責具體的邏輯業務邏輯實作,但是它負責接收和響應請求
從現狀看問題
Controller 主要的工作有以下幾項
- 接收請求并解析參數
- 調用 Service 執行具體的業務代碼(可能包含參數校驗)
- 捕獲業務邏輯異常做出回報
- 業務邏輯執行成功做出響應
//DTO
@Data
public class TestDTO {
private Integer num;
private String type;
}
//Service
@Service
public class TestService {
public Double service(TestDTO testDTO) throws Exception {
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;
}
return result;
}
throw new Exception("未識别的算法");
}
}
//Controller
@RestController
public class TestController {
private TestService testService;
@PostMapping("/test")
public Double test(@RequestBody TestDTO testDTO) {
try {
Double result = this.testService.service(testDTO);
return result;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Autowired
public DTOid setTestService(TestService testService) {
this.testService = testService;
}
}
如果真的按照上面所列的工作項來開發 Controller 代碼會有幾個問題
1、 參數校驗過多地耦合了業務代碼,違背單一職責原則;
2、 可能在多個業務中都抛出同一個異常,導緻代碼重複;
3、 各種異常回報和成功響應格式不統一,接口對接不友好;
改造 Controller 層邏輯
統一傳回結構
統一傳回值類型無論項目前後端是否分離都是非常必要的,友善對接接口的開發人員更加清晰地知道這個接口的調用是否成功(不能僅僅簡單地看傳回值是否為 null 就判斷成功與否,因為有些接口的設計就是如此),使用一個狀态碼、狀态資訊就能清楚地了解接口調用情況
//定義傳回資料結構
public interface IResult {
Integer getCode();
String getMessage();
}
//常用結果的枚舉
public enum ResultEnum implements IResult {
SUCCESS(2001, "接口調用成功"),
VALIDATE_FAILED(2002, "參數校驗失敗"),
COMMON_FAILED(2003, "接口調用失敗"),
FORBIDDEN(2004, "沒有權限通路資源");
private Integer code;
private String message;
//省略get、set方法和構造方法
}
//統一傳回資料結構
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {
private Integer code;
private String message;
private T data;
public static <T> Result<T> success(T data) {
return new Result<>(ResultEnum.SUCCESS.getCode(), ResultEnum.SUCCESS.getMessage(), data);
}
public static <T> Result<T> success(String message, T data) {
return new Result<>(ResultEnum.SUCCESS.getCode(), message, data);
}
public static Result<?> failed() {
return new Result<>(ResultEnum.COMMON_FAILED.getCode(), ResultEnum.COMMON_FAILED.getMessage(), null);
}
public static Result<?> failed(String message) {
return new Result<>(ResultEnum.COMMON_FAILED.getCode(), message, null);
}
public static Result<?> failed(IResult errorResult) {
return new Result<>(errorResult.getCode(), errorResult.getMessage(), null);
}
public static <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 ,能幫助我們實作上述需求
ResponseBodyAdvice 是對 Controller 傳回的内容在 HttpMessageConverter 進行類型轉換之前攔截,進行相應的處理操作後,再将結果傳回給用戶端。那這樣就可以把統一包裝的工作放到這個類裡面。
public interface ResponseBodyAdvice<T> {
boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);
@Nullable
T beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response);
}
- supports:判斷是否要交給 beforeBodyWrite 方法執行,ture:需要;false:不需要
- beforeBodyWrite:對 response 進行具體的處理
// 如果引入了swagger或knife4j的文檔生成元件,這裡需要僅掃描自己項目的包,否則文檔無法正常生成
@RestControllerAdvice(basePackages = "com.example.demo")
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
// 如果不需要進行封裝的,可以添加一些校驗手段,比如添加标記排除的注解
return true;
}
@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 傳回的資料進行統一包裝,又不需要對原有代碼進行大量的改動
處理 cannot be cast to java.lang.String 問題
如果直接使用 ResponseBodyAdvice,對于一般的類型都沒有問題,當處理字元串類型時,會抛出 xxx.包裝類 cannot be cast to java.lang.String 的類型轉換的異常
在ResponseBodyAdvice 實作類中 debug 發現,隻有 String 類型的 selectedConverterType 參數值是 org.springframework.http.converter.StringHttpMessageConverter,而其他資料類型的值是 org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
- String 類型
- 其他類型 (如 Integer 類型)
現在問題已經較為清晰了,因為我們需要傳回一個 Result 對象
是以使用 MappingJackson2HttpMessageConverter 是可以正常轉換的
而使用StringHttpMessageConverter 字元串轉換器會導緻類型轉換失敗
現在處理這個問題有兩種方式
1、 在beforeBodyWrite方法處進行判斷,如果傳回值是String類型就對Result對象手動進行轉換成JSON字元串,另外友善前端使用,最好在@RequestMapping中指定ContentType;
@RestControllerAdvice(basePackages = "com.example.demo")
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
...
@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;
}
// 如果傳回值是String類型,那就手動把Result對象轉換成JSON字元串
if (body instanceof String) {
try {
return this.objectMapper.writeValueAsString(Result.success(body));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
return Result.success(body);
}
...
}
@GetMapping(value = "/returnString", produces = "application/json; charset=UTF-8")
public String returnString() {
return "success";
}
2、 修改HttpMessageConverter執行個體集合中MappingJackson2HttpMessageConverter的順序因為發生上述問題的根源所在是集合中StringHttpMessageConverter的順序先于MappingJackson2HttpMessageConverter的,調整順序後即可從根源上解決這個問題;
- 網上有不少做法是直接在集合中第一位添加 MappingJackson2HttpMessageConverter
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(0, new MappingJackson2HttpMessageConverter());
}
}
- 誠然,這種方式可以解決問題,但其實問題的根源不是集合中缺少這一個轉換器,而是轉換器的順序導緻的,是以最合理的做法應該是調整 MappingJackson2HttpMessageConverter 在集合中的順序
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
/**
* 交換MappingJackson2HttpMessageConverter與第一位元素
* 讓傳回值類型為String的接口能正常傳回包裝結果
*
* @param converters initially an empty list of converters
*/
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
for (int i = 0; i < converters.size(); i++) {
if (converters.get(i) instanceof MappingJackson2HttpMessageConverter) {
MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter = (MappingJackson2HttpMessageConverter) converters.get(i);
converters.set(i, converters.get(0));
converters.set(0, mappingJackson2HttpMessageConverter);
break;
}
}
}
}
參數校驗
Java API 的規範 JSR303 定義了校驗的标準 validation-api ,其中一個比較出名的實作是 hibernate validation ,spring validation 是對其的二次封裝,常用于 SpringMVC 的參數自動校驗,參數校驗的代碼就不需要再與業務邏輯代碼進行耦合了
@PathVariable 和 @RequestParam 參數校驗
Get請求的參數接收一般依賴這兩個注解,但是處于 url 有長度限制和代碼的可維護性,超過 5 個參數盡量用實體來傳參
對@PathVariable 和 @RequestParam 參數進行校驗需要在入參聲明限制的注解
如果校驗失敗,會抛出 MethodArgumentNotValidException 異常
@RestController(value = "prettyTestController")
@RequestMapping("/pretty")
@Validated
public class TestController {
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
public void setTestService(TestService prettyTestService) {
this.testService = prettyTestService;
}
}
校驗原理
在SpringMVC 中,有一個類是 RequestResponseBodyMethodProcessor ,這個類有兩個作用(實際上可以從名字上得到一點啟發)
1、 用于解析@RequestBody标注的參數;
2、 處理@ResponseBody标注方法的傳回值;
解析@RequestBoyd 标注參數的方法是 resolveArgument
public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor {
/**
* 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)) {
throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
}
}
if (mavContainer != null) {
mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
}
}
return adaptArgumentIfNecessary(arg, parameter);
}
}
public abstract class AbstractMessageConverterMethodArgumentResolver implements HandlerMethodArgumentResolver {
/**
* 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
* @seeisBindExceptionRequired
*/
protected void validateIfApplicable(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
public class TestDTO {
@NotBlank
private String userName;
@NotBlank
@Length(min = 6, max = 20)
private String password;
@NotNull
@Email
private String email;
}
//Controller
@RestController(value = "prettyTestController")
@RequestMapping("/pretty")
public class TestController {
private TestService testService;
@PostMapping("/test-validation")
public void testValidation(@RequestBody @Validated TestDTO testDTO) {
this.testService.save(testDTO);
}
@Autowired
public void setTestService(TestService testService) {
this.testService = testService;
}
}
校驗原理
聲明限制的方式,注解加到了參數上面,可以比較容易猜測到是使用了 AOP 對方法進行增強
而實際上 Spring 也是通過 MethodValidationPostProcessor 動态注冊 AOP 切面,然後使用 MethodValidationInterceptor 對切點方法進行織入增強
public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor implements InitializingBean {
//指定了建立切面的Bean的注解
private Class<? extends Annotation> validatedAnnotationType = Validated.class;
@Override
public void afterPropertiesSet() {
//為所有@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());
}
}
public class MethodValidationInterceptor implements MethodInterceptor {
@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()) {
throw new ConstraintViolationException(result);
}
//Controller方法調用
Object returnValue = invocation.proceed();
//下面是對傳回值做校驗,流程和上面大概一樣
result = execVal.validateReturnValue(invocation.getThis(), methodToValidate, returnValue, groups);
if (!result.isEmpty()) {
throw new ConstraintViolationException(result);
}
return returnValue;
}
}
自定義校驗規則
有些時候 JSR303 标準中提供的校驗規則不滿足複雜的業務需求,也可以自定義校驗規則
自定義校驗規則需要做兩件事情
1、 自定義注解類,定義錯誤資訊和一些其他需要的内容;
2、 注解校驗器,定義判定規則;
//自定義注解類
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = MobileValidator.class)
public @interface Mobile {
/**
* 是否允許為空
*/
boolean required() default true;
/**
* 校驗不通過傳回的提示資訊
*/
String message() default "不是一個手機号碼格式";
/**
* Constraint要求的屬性,用于分組校驗和擴充,留白就好
*/
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
//注解校驗器
public class MobileValidator implements ConstraintValidator<Mobile, CharSequence> {
private boolean required = false;
private final Pattern pattern = Pattern.compile("^1[34578][0-9]{9}#34;); // 驗證手機号
/**
* 在驗證開始前調用注解裡的方法,進而擷取到一些注解裡的參數
*
* @param constraintAnnotation annotation instance for a given constraint declaration
*/
@Override
public void initialize(Mobile constraintAnnotation) {
this.required = constraintAnnotation.required();
}
/**
* 判斷參數是否合法
*
* @param value object to validate
* @param context context in which the constraint is evaluated
*/
@Override
public boolean isValid(CharSequence value, ConstraintValidatorContext context) {
if (this.required) {
// 驗證
return isMobile(value);
}
if (StringUtils.hasText(value)) {
// 驗證
return isMobile(value);
}
return true;
}
private boolean isMobile(final CharSequence str) {
Matcher m = pattern.matcher(str);
return m.matches();
}
}
自動校驗參數真的是一項非常必要、非常有意義的工作。 JSR303 提供了豐富的參數校驗規則,再加上複雜業務的自定義校驗規則,完全把參數校驗和業務邏輯解耦開,代碼更加簡潔,符合單一職責原則。
自定義異常與統一攔截異常
原來的代碼中可以看到有幾個問題
1、 抛出的異常不夠具體,隻是簡單地把錯誤資訊放到了Exception中;
2、 抛出異常後,Controller不能具體地根據異常做出回報;
3、 雖然做了參數自動校驗,但是異常傳回結構和正常傳回結構不一緻;
自定義異常是為了後面統一攔截異常時,對業務中的異常有更加細顆粒度的區分,攔截時針對不同的異常作出不同的響應
而統一攔截異常的目的一個是為了可以與前面定義下來的統一包裝傳回結構能對應上,另一個是我們希望無論系統發生什麼異常,Http 的狀态碼都要是 200 ,盡可能由業務來區分系統的異常
//自定義異常
public class ForbiddenException extends RuntimeException {
public ForbiddenException(String message) {
super(message);
}
}
//自定義異常
public class BusinessException extends RuntimeException {
public BusinessException(String message) {
super(message);
}
}
//統一攔截異常
@RestControllerAdvice(basePackages = "com.example.demo")
public class ExceptionAdvice {
/**
* 捕獲 {@code BusinessException} 異常
*/
@ExceptionHandler({BusinessException.class})
public Result<?> handleBusinessException(BusinessException ex) {
return Result.failed(ex.getMessage());
}
/**
* 捕獲 {@code ForbiddenException} 異常
*/
@ExceptionHandler({ForbiddenException.class})
public Result<?> handleForbiddenException(ForbiddenException ex) {
return Result.failed(ResultEnum.FORBIDDEN);
}
/**
* {@code @RequestBody} 參數校驗不通過時抛出的異常處理
*/
@ExceptionHandler({MethodArgumentNotValidException.class})
public Result<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
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})
public Result<?> handleConstraintViolationException(ConstraintViolationException ex) {
if (StringUtils.hasText(ex.getMessage())) {
return Result.failed(ResultEnum.VALIDATE_FAILED.getCode(), ex.getMessage());
}
return Result.failed(ResultEnum.VALIDATE_FAILED);
}
/**
* 頂級異常捕獲并統一處理,當其他異常無法處理時候選擇使用
*/
@ExceptionHandler({Exception.class})
public Result<?> handle(Exception ex) {
return Result.failed(ex.getMessage());
}
}
總結
做好了這一切改動後,可以發現 Controller 的代碼變得非常簡潔,可以很清楚地知道每一個參數、每一個 DTO 的校驗規則,可以很明确地看到每一個 Controller 方法傳回的是什麼資料,也可以友善每一個異常應該如何進行回報
這一套操作下來後,我們能更加專注于業務邏輯的開發,代碼簡潔、功能完善,何樂而不為呢?