自從《設(shè)計(jì)之道-controller層的設(shè)計(jì)》去年發(fā)布之后实檀,收獲了許多讀者朋友和同僚的歡迎和喜愛,也收到了不少的意見和建議新翎,首先十分感謝大家的支持署鸡。同時(shí)我也一直在思考如何進(jìn)一步的優(yōu)化這部分代碼,在這里把最近的一些優(yōu)化點(diǎn)總結(jié)一下绊诲。主要針對兩部分進(jìn)行了優(yōu)化送粱,統(tǒng)一返回對象的封裝和統(tǒng)一的請求/響應(yīng)日志打印。
首先回顧下在上一篇當(dāng)中講到的controller層主要的職責(zé):
1.參數(shù)校驗(yàn)
2.調(diào)用service層接口實(shí)現(xiàn)業(yè)務(wù)邏輯
3.轉(zhuǎn)換業(yè)務(wù)/數(shù)據(jù)對象
4.組裝返回對象
5.異常處理
當(dāng)時(shí)遺漏了一點(diǎn)現(xiàn)在補(bǔ)上:
6.請求日志打印
接下來進(jìn)入正題:
1. 統(tǒng)一返回對象的封裝
這一點(diǎn)其實(shí)在上一篇中已經(jīng)講過掂之,就是第4點(diǎn):組裝返回對象抗俄。只不過當(dāng)時(shí)使用的是在BaseController中封裝返回方法,在業(yè)務(wù)controller中調(diào)用responseOK/responseFail方法世舰。文章發(fā)出去不久后动雹,天草二十六_就建議我可以使用SpringMVC的ResponseBodyAdvice
接口來實(shí)現(xiàn)統(tǒng)一的返回對象封裝從而進(jìn)一步優(yōu)化代碼(感謝天草)。他山之石可以攻玉跟压,這里就先來講一下該接口給我們的代碼帶來的變化。
首先看下ResponseBodyAdvice
這個(gè)接口:
/**
* Allows customizing the response after the execution of an {@code @ResponseBody}
* or a {@code ResponseEntity} controller method but before the body is written
* with an {@code HttpMessageConverter}.
*
* <p>Implementations may be registered directly with
* {@code RequestMappingHandlerAdapter} and {@code ExceptionHandlerExceptionResolver}
* or more likely annotated with {@code @ControllerAdvice} in which case they
* will be auto-detected by both.
*/
public interface ResponseBodyAdvice<T> {
/**
* Whether this component supports the given controller method return type
*/
boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);
/**
* Invoked after an HttpMessageConverter is selected and just before
* its write method is invoked.
*/
T beforeBodyWrite(T body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response);
}
上面的源碼由于篇幅問題裆馒,刪掉了一些注釋姊氓,感興趣的同學(xué)可以自己查看代碼。通過查看注釋可以發(fā)現(xiàn)喷好,這個(gè)接口可以讓我在controller方法(需有@ResponseBody
或@ResponseEntity
注解)的返回對象被寫到HTTP response body之前做一些事情翔横。這里的兩個(gè)方法都很重要,首先supports
決定了哪些controller會被該接口攔截梗搅,其次beforeBodyWrite
決定了攔截之后我們的操作禾唁。另外效览,注意到上面注釋中建議了該接口的使用方法,其中有提到可以在實(shí)現(xiàn)該接口的類上使用@ControllerAdvice
注解荡短,從而同時(shí)實(shí)現(xiàn)異常捕獲和返回對象的封裝丐枉。
弄明白了ResponseBodyAdvice
的作用,我們便可摒棄之前的BaseController掘托,讓controller直接返回DTO瘦锹,通過實(shí)現(xiàn)beforeBodyWrite
方法來做統(tǒng)一的返回對象封裝。既然BaseController不需要了闪盔,那其中的封裝方法responseOK/responseFail又該何去何從呢弯院?我這邊的做法是參照了《Effective Java》中的建議,用靜態(tài)方法代替構(gòu)造函數(shù)泪掀,改寫了統(tǒng)一返回包裝類HttpResult:
public class HttpResult<T> implements Serializable {
private static final long serialVersionUID = -1L;
private boolean success;
private T data;
private String code;
private String message;
private HttpResult(boolean success, T data, String code, String message) {
this.success = success;
this.data = data;
this.code = code;
this.message = message;
}
private HttpResult(boolean success, T data, ResultCode resultCode) {
this.success = success;
this.data = data;
this.code = resultCode.getCode();
this.message = resultCode.getMessage();
}
/**
* 成功返回
*/
public static <T> HttpResult<T> ok(T data) {
return new HttpResult<>(Boolean.TRUE, data, ResultCode.SUCCESS);
}
/**
* 異常返回-指定錯(cuò)誤碼
*/
public static HttpResult fail(ResultCode resultCode) {
return new HttpResult<>(Boolean.FALSE, null, resultCode);
}
/**
* 異常返回-非指定異常
*/
public static HttpResult fail(String code, String message) {
return new HttpResult<>(Boolean.FALSE, null, code, message);
}
//getter and setter
}
并將先前的統(tǒng)一異常處理類ExceptionAdvice改名為ResponseAdvice听绳,并實(shí)現(xiàn)ResponseBodyAdvice
接口。
其中要注意兩點(diǎn):
-
@ExceptionHandler
要加上@ResponseBody
注解异赫,否則會默認(rèn)返回mav椅挣。 - 返回結(jié)果被
@ExceptionHandler
處理后仍然會進(jìn)入到beforeBodyWrite
處理,所以為了需要增加判斷邏輯以防重復(fù)包裝返回結(jié)果塔拳。
/**
* @Author: Sawyer
* @Description: 統(tǒng)一異常處理及返回對象封裝
* @Date: Created in 上午11:17 17/8/11
*/
@Slf4j
@ControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice {
@Autowired
HttpServletRequest httpServletRequest;
@Override
public boolean supports(MethodParameter methodParameter, Class aClass) {
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType,
MediaType selectedContentType,
Class selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response) {
//返回對象封裝
if (body instanceof HttpResult) {
// 被exceptionHandler處理過了鼠证,直接返回
return body;
} else {
return HttpResult.ok(body);
}
}
/**
* 異常日志記錄
*/
private void logErrorRequest(Exception e) {
log.error("報(bào)錯(cuò)API URL:{}", httpServletRequest.getRequestURL().toString());
log.error("異常:{}", e.getMessage());
}
/**
* 參數(shù)未通過@Valid驗(yàn)證異常,
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseBody
private HttpResult methodArgumentNotValid(MethodArgumentNotValidException exception) {
logErrorRequest(exception);
return HttpResult.fail(ResultCode.INVALID_PARAM);
}
/**
* 參數(shù)格式有誤
*/
@ExceptionHandler({MethodArgumentTypeMismatchException.class, HttpMessageNotReadableException.class})
@ResponseBody
private HttpResult typeMismatch(Exception exception) {
logErrorRequest(exception);
return HttpResult.fail(ResultCode.MISTYPE_PARAM);
}
/**
* 缺少參數(shù)
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
@ResponseBody
private HttpResult missingServletRequestParameter(MissingServletRequestParameterException exception) {
logErrorRequest(exception);
return HttpResult.fail(ResultCode.MISSING_PARAM);
}
/**
* 不支持的請求類型
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
@ResponseBody
private HttpResult httpRequestMethodNotSupported(HttpRequestMethodNotSupportedException exception) {
logErrorRequest(exception);
return HttpResult.fail(ResultCode.UNSUPPORTED_METHOD);
}
/**
* 業(yè)務(wù)層異常
*/
@ExceptionHandler(ServiceEx.class)
@ResponseBody
private HttpResult serviceExceptionHandler(ServiceEx exception) {
logErrorRequest(exception);
return HttpResult.fail(ResultCode.S_SYS_UNKNOWN.getCode(), exception.getMessage());
}
/**
* 其他異常
*/
@ExceptionHandler({HttpClientErrorException.class, IOException.class, Exception.class})
@ResponseBody
private HttpResult commonExceptionHandler(Exception exception) {
logErrorRequest(exception);
return HttpResult.fail(ResultCode.S_SYS_UNKNOWN);
}
}
這樣改造后蝙斜,我們的UserController就不再需要繼承BaseController及返回HttpResult對象了:
@RestController
@RequestMapping("/v1/user")
public class UserController {
@Autowired
UserService userService;
@PutMapping("/{id}")
public UserDTO updateUser(@PathVariable("id") Integer id, @Valid @RequestBody UserDTO userDTO) throws Exception {
return UserDTO.convert(userService.updateUser(id, userDTO));
}
}
這樣名惩,我們就完成了統(tǒng)一返回對象封裝的優(yōu)化。有的同學(xué)要問了孕荠,你這兒明明沒有指定@ResponseBody
娩鹉,為啥也能被攔截呢?這里暴露了很多同學(xué)寫代碼的一個(gè)問題:無意識地寫一些多余的代碼稚伍。仔細(xì)看@RestController
的源碼弯予,其實(shí)其中已經(jīng)包含了@ResponseBody
注解了:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Controller
@ResponseBody
public @interface RestController {
String value() default "";
}
2. 統(tǒng)一的請求/響應(yīng)日志打印
對于開發(fā)過web應(yīng)用的同學(xué)來說,日志的重要性相信是不言而喻的个曙。特別是排查問題的時(shí)候锈嫩,如果如果少打了請求/響應(yīng)日志,那查問題的難度簡直一下子就上升了幾個(gè)等級垦搬。那我們該如何做才能最簡便呢呼寸?其實(shí)看了前一部分,你一定能想到可以在實(shí)現(xiàn)beforeBodyWrite
接口的同時(shí)做日志打印猴贰,畢竟參數(shù)里都有ServerHttpRequest
和ServerHttpResponse
了嘛对雪。但這有一個(gè)問題,這個(gè)方法是在responseBody被寫入之前執(zhí)行的米绕,但如果controller本身就已經(jīng)報(bào)錯(cuò)了瑟捣,這個(gè)方法是不會被執(zhí)行的馋艺,這個(gè)時(shí)候日志也就不會被打印了。
其實(shí)對于ResponseBodyAdvice
來說迈套,還有一個(gè)對應(yīng)的RequestBodyAdvice
(這里就不展開了捐祠,感興趣的同學(xué)可以自行研究),似乎可以在beforeBodyRead
中打印請求日志桑李,在beforeBodyWrite
中打印正常返回日志踱蛀,在@ExceptionHandler
中打印異常返回日志。這個(gè)方案的確可行芙扎,但會有一個(gè)問題星岗,這里賣個(gè)關(guān)子暫且不表填大,先來看我所采用的方法:單獨(dú)創(chuàng)建一個(gè)切面來做統(tǒng)一的日志打咏渫荨:
/**
* @Author: Sawyer
* @Description: 請求日志切面
* @Date: Created in 3:07 PM 2019/8/15
*/
@Slf4j
@Aspect
@Component
public class RequestLogAspect {
@Autowired
HttpServletRequest request;
@Around("execution(* com.sawyer.api.controller..*.*(..))")
public Object around(final ProceedingJoinPoint joinPoint) throws Throwable {
log.info("請求url:{}", request.getRequestURL().toString());
ObjectMapper mapper = new ObjectMapper();
log.info("請求參數(shù):{}", mapper.writeValueAsString(joinPoint.getArgs()));
Object result = joinPoint.proceed();
log.info("請求返回:{}", mapper.writeValueAsString(result));
return result;
}
}
這里稍微講一下AOP表達(dá)式"execution(* com.sawyer.api.controller..*.*(..))"
的含義:
-
execution()
表示是最常用的切點(diǎn)函數(shù),表示切面作用于方法執(zhí)行時(shí)允华; - 第一個(gè)
*
表示不限制返回類型圈浇; - controller后面的
..
表示要攔截的包路徑包含controller目錄及其所有子目錄; - 第二個(gè)
*
表示不限類名靴寂; - 第三個(gè)
*
表示不限方法名磷蜀;
-(..)
表示不限參數(shù); -
@Around
表示該切面的類型是包圍類型百炬;
故總體的含義為:在com.sawyer.api.controller包下所有的類的所有方法的執(zhí)行前后進(jìn)行攔截褐隆。
通過定義這樣一個(gè)切面,我們就可以在controller的方法被調(diào)用前打印請求日志剖踊,被調(diào)用后打印響應(yīng)日志庶弃。當(dāng)然,在拋出異常的情況德澈,日志還是打印在@ExceptionHandler
里的歇攻。這個(gè)做法和之前的方法相比,有什么特別的好處嗎梆造?這里就要講到剛剛賣的關(guān)子缴守。
真正在生產(chǎn)中,我們往往會遇到一個(gè)問題镇辉,就是有些接口的日志我們并不想打印出來屡穗。特別是一些批量查詢接口的響應(yīng)結(jié)果,一打就一堆忽肛,如果調(diào)用頻繁村砂,就可能會造成大量空間的浪費(fèi),也不方便日志的排查麻裁。那我們就需要針對不同的類箍镜,甚至方法進(jìn)行區(qū)別對待源祈。對于不同類,自定義切面和@ControllerAdvice
都可以解決色迂,對于AOP來說可以在表達(dá)式里使用'||'或者'or'來指定多個(gè)連接點(diǎn)香缺,而@ControllerAdvice
則可以用basePackages
數(shù)組來指定多個(gè)類。但是如果同一個(gè)類中不同的方法有不同的日志需求歇僧,那@ControllerAdvice
就愛莫能助了图张。不過,我們真的需要在切點(diǎn)表達(dá)式中維護(hù)那么復(fù)雜的又無聊的關(guān)系嗎诈悍?有更好的做法嗎祸轮?當(dāng)然有。
這里我的做法創(chuàng)建了一個(gè)自定義注解@LessLog
用來指定是否要打日志侥钳、打什么日志适袜。然后通過切面中的joinPoint及java反射機(jī)制來獲取到方法上的注解,從而影響日志的行為舷夺,直接看代碼:
首先是忽略的日志內(nèi)容苦酱,主要有url日志、請求日志给猾、響應(yīng)日志疫萤、全部忽略和全部不忽略這5種:
/**
* @Author: Sawyer
* @Description: 忽略的日志類型
* @Date: Created in 3:56 PM 2019/8/14
*/
public enum LogType {
/**
* 請求url
*/
URL,
/**
* 請求
*/
REQUEST,
/**
* 返回
*/
RESPONSE,
/**
* 全部
*/
ALL,
/**
* 無
*/
NONE
}
然后是注解@LessLog
本身,這里制定了一個(gè)type參數(shù)敢伸,用來指定忽略的日志內(nèi)容扯饶,默認(rèn)是全部不忽略:
/**
* @Author: Sawyer
* @Description: 忽略日志的注解
* @Date: Created in 2:40 PM 2019/8/14
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LessLog {
/**
* 默認(rèn)不忽略日志
*
* @return
*/
LogType type() default LogType.NONE;
}
最后是改寫我們的RequestLogAspect
,利用反射機(jī)制獲取到controller方法上的LessLog
注解實(shí)例池颈,并根據(jù)其type參數(shù)決定具體忽略的日志內(nèi)容:
@Slf4j
@Aspect
@Component
public class RequestLogAspect {
@Autowired
HttpServletRequest request;
@Around("execution(* com.yst.nfsq.vem.api.controller..*.*(..))")
public Object around(final ProceedingJoinPoint joinPoint) throws Throwable {
boolean urlLogRequired = Boolean.TRUE;
boolean requestLogRequired = Boolean.TRUE;
boolean responseLogRequired = Boolean.TRUE;
Class<?> clazz = joinPoint.getTarget().getClass();
String methodName = joinPoint.getSignature().getName();
Class<?>[] args = ((MethodSignature) joinPoint.getSignature()).getParameterTypes();
Method method = clazz.getMethod(methodName, args);
if (method.isAnnotationPresent(LessLog.class)) {
//減少日志的注解
LessLog lessLog = method.getAnnotation(LessLog.class);
LogType logType = lessLog.type();
switch (logType) {
case URL:
urlLogRequired = Boolean.FALSE;
break;
case REQUEST:
requestLogRequired = Boolean.FALSE;
break;
case RESPONSE:
responseLogRequired = Boolean.FALSE;
break;
case ALL:
urlLogRequired = Boolean.FALSE;
requestLogRequired = Boolean.FALSE;
responseLogRequired = Boolean.FALSE;
break;
default:
}
}
//url日志
if (urlLogRequired) {
log.info("請求url:{}", request.getRequestURL().toString());
}
ObjectMapper mapper = new ObjectMapper();
//請求日志
if (requestLogRequired) {
log.info("請求參數(shù):{}", mapper.writeValueAsString(joinPoint.getArgs()));
}
Object result = joinPoint.proceed();
//響應(yīng)日志
if (responseLogRequired) {
log.info("請求返回:{}", mapper.writeValueAsString(result));
}
return result;
}
}
這樣尾序,我們就可以在具體的發(fā)放上使用@LessLog
注解來控制日志打印的內(nèi)容了,比如下面的方法就不會打印響應(yīng)日志:
@RestController
@RequestMapping("/v1/user")
public class UserController {
@Autowired
UserService userService;
//不打印響應(yīng)日志
@LessLog(type = LogType.RESPONSE)
@PutMapping("/{id}")
public UserDTO updateUser(@PathVariable("id") Integer id, @Valid @RequestBody UserDTO userDTO) throws Exception {
return UserDTO.convert(userService.updateUser(id, userDTO));
}
}
寫到這里饶辙,結(jié)合上一篇蹲诀,我們已經(jīng)完成了controller層的所有任務(wù),再來回顧一下:
1.參數(shù)校驗(yàn)
2.調(diào)用service層接口實(shí)現(xiàn)業(yè)務(wù)邏輯
3.轉(zhuǎn)換業(yè)務(wù)/數(shù)據(jù)對象
4.組裝返回對象
5.異常處理
6.請求日志打印
這篇文章中用到的技術(shù)包括AOP弃揽、反射脯爪、注解等其實(shí)大家都耳熟能詳,但很多時(shí)候都只是只知其然而不知其所以然矿微。我還是鼓勵大家在寫代碼時(shí)多加思考痕慢,創(chuàng)造機(jī)會使用這些技術(shù),而非一味地照搬安全的老代碼涌矢,從而喪失了使自己技術(shù)精進(jìn)和代碼更優(yōu)雅的機(jī)會掖举。