前言
上篇,我們已經闡述在Spring MVC中如何優雅地處理異常,並通過源碼分析了其原理及工作過程。
但是一定會有同學產生疑問:原來的異常處理方式,可以直接在catch
塊中打印請求入參,當異常發生時,能夠清晰知道是什麼入參導致的異常,方便問題的排查。使用統一異常處理的方案,只打印了異常堆棧,丟失了相關上下文信息,怎麼辦?
try {
// do biz
} catch (Exception e) {
log.error("xxx method exception, param1:{}, param2:{}, param3:{}", param1, param2, param3 ,e);
}
先說說這種打印日誌方式的弊端
-
Controller層充斥着大量的
try\catch
塊[上篇文章亦有提及],catch
的邏輯中打印了請求參數相關信息,某天在接口新增參數,很有可能漏掉在catch
的日誌中將之打印出來,拿排查問題的場景來說----在測試環境很容易發生新增參數,對應增加業務邏輯,但測試結果與預期不一致,但又因爲漏打日誌,無法確認問題所在,需要重新添加日誌排查問題:修改,提交代碼,發起Merge-Request,Accept,發佈,整一套流程下來,耗費多少時間?另一方面,這種重複且無意義的勞動,相信大家都不願意去做 -
每個人打印日誌的習慣都不一樣,甚至同一個人不同成長階段都不一樣。有的人喜歡用
=
來分割參數與值,有的人喜歡用:
;有的人喜歡添加簡略信息如方法名,以便於日誌關鍵字搜索,有的人喜歡添加詳細信息,便於仔細記錄。那麼,不一致就會帶來許多的問題,比如無法做數據格式化及統一收集展示,無法基於日誌對系統運行過程中的錯誤和潛在風險進行監控和報警,另一方面,帶來的問題額外的是學習成本。舉個例子,張三負責的系統出現線上問題,但張三休假了,李四幫忙排查,他就需要先看異常信息是什麼,但大部分的日誌打印因爲不規範的原因,對問題的排查沒有實質性幫助,還需要跑到代碼裏看對應的日誌上下文含義是什麼,才能理解日誌的含義,以幫助排查問題,這無異於提高排查問題的門檻,即所謂的學習成本
一個系統全無日誌不利於問題排查,全打日誌又如同信息垃圾,反而把重點信息給掩埋。因此,如何規範化打印日誌,是一門學問,是我們做爲合作程序員、工程師的職業素養,也是對外界戲稱我們爲碼農
的吶喊與抗議。
在這裏,我們並不討論具體該怎樣規範化打印日誌,而是藉着日誌打印問題,提出一些思考,並嘗試藉着現有的一些開源框架,去解決一部分規範化日誌打印的問題。
正文
在上篇中,我們提出統一異常處理的方式,僅只打印了異常堆棧,丟失了相關上下文信息,想要解決此問題,一個很自然的想法是,能不能在統一異常處理中,同樣打印請求參數呢?先試着在方法中添加
HttpServletRequest
參數,該思路的出發點是,只要能拿到request,就能拿到請求參數
@ExceptionHandler
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) // Http Status Code 500
public ResponseDTO handleException(Exception e, HttpServletRequest request) {
// 兜底邏輯,通常用於處理未預期的異常,比如不知道哪兒冒出來的空指針異常
log.error("請求發生異常,請求參數:{}", JsonUtils.toJsonString(request.getParameterMap()), e);
return ResponseDTO.failedResponse().withErrorMessage("服務器開小差了");
}
通過實驗發現,可以拿到請求參數,這樣就把請求參數打印出來了。但是這裏又引入了另外一個問題,ServletRequest#getParameterMap
只能拿到請query string
或者posted form data
,對於post body
,正常情況下,我們需要通過ServletRequest#getInputStream
或者ServletRequest#getReader
才能拿到請求體。
String requestBody = null;
try {
requestBody = IOUtils.toString(request.getInputStream(), Charset.defaultCharset());
} catch (IOException ex) {
log.info("IO 發生異常", ex);
}
log.error("請求發生異常,請求體:{}", requestBody, e);
但是,在這裏,獲取請求體的方式存在一些問題,原因在於: 大部分的InputStream僅允許讀取一次,而對於ServletInputStream,如果進行二次讀取,會直接拋出java.io.IOException: Stream closed
異常。
if (closed) {
throw new IOException(sm.getString("inputBuffer.streamClosed"));
}
在Controller的方法中使用@RequestBody
註解參數,Spring MVC就會調用request.getInputStream()
讀取請求體,並將請求體轉化成我們需要的請求參數,因此在這裏已經將流讀取完畢。
@PostMapping("/test")
public void test(@RequestBody XXXDTO xxxDTO) {
// do biz
}
如果我們在後置的異常處理流程中,嘗試再一次讀取請求體,程序就會拋出java.io.IOException: Stream closed
異常,因此,通過這種簡單的方式拿不到請求體,我們需要嘗試用另一種方式來獲取請求體。
ContentCachingRequestWrapper
從ContentCachingRequestWrapper
的繼承體系圖可以看出,它是一個HttpServletRequest
,且是一個Wrapper
,這是一個很典型的裝飾器模式,從類名中,可以猜測它能夠緩存請求體內容。其實現機制是代理getInputStream
方法,且內部持有的一個ByteArrayOutputStream
,每當從InputStream中讀取內容,同時會將讀取到的內容緩存到ByteArrayOutputStream
中,實現數據重複利用。getContentAsByteArray
方法即可返回緩存的內容
public ServletInputStream getInputStream() throws IOException {
if (this.inputStream == null) {
this.inputStream = new ContentCachingInputStream(getRequest().getInputStream());
}
return this.inputStream;
}
private class ContentCachingInputStream extends ServletInputStream {
private final ServletInputStream is;
private boolean overflow = false;
public ContentCachingInputStream(ServletInputStream is) {
this.is = is;
}
@Override
public int read() throws IOException {
int ch = this.is.read();
if (ch != -1 && !this.overflow) {
if (contentCacheLimit != null && cachedContent.size() == contentCacheLimit) {
this.overflow = true;
handleContentOverflow(contentCacheLimit);
}
else {
// 將讀取到的內容寫到ByteArrayOutputStream
cachedContent.write(ch);
}
}
return ch;
}
}
public byte[] getContentAsByteArray() {
return this.cachedContent.toByteArray();
}
我們需要一個擴展點,在Spring MVC處理請求之前,能夠對Request進行增強,因此,很自然想到增加一個Filter。這樣,Spring MVC在後續讀取請求體時,增強的Request(ContentCachingRequestWrapper)就將請求體緩存了起來,爲後續統一異常處理打印請求上下文提供可能性
public class HttpRequestWrapperFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (isAsyncDispatch(request)) {
filterChain.doFilter(request, response);
} else {
filterChain.doFilter(wrapRequest(request), response);
}
}
private ContentCachingRequestWrapper wrapRequest(HttpServletRequest request) {
if (request instanceof ContentCachingRequestWrapper) {
return (ContentCachingRequestWrapper) request;
} else {
return new ContentCachingRequestWrapper(request);
}
}
}
接着,只需要改造GlobalExceptionHandler,在請求出現異常後,將請求上下文打印出來
public class GlobalExceptionHandler {
private static final String LINE_SEPARATOR = System.getProperty("line.separator");
private static final List<MediaType> VISIBLE_TYPES = Arrays.asList(
MediaType.valueOf("text/*"),
MediaType.APPLICATION_FORM_URLENCODED,
MediaType.APPLICATION_JSON,
MediaType.valueOf("application/*+json"),
MediaType.MULTIPART_FORM_DATA
);
@ExceptionHandler
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) // Http Status Code 500
public ResponseDTO handleException(Exception e, HttpServletRequest request) {
String requestLog = StringUtils.EMPTY;
if (request instanceof ContentCachingRequestWrapper) {
requestLog = logRequest((ContentCachingRequestWrapper) request);
}
log.error("請求發生異常, {}", requestLog, e);
return ResponseDTO.failedResponse().withErrorMessage("服務器開小差了");
}
private String logRequest(ContentCachingRequestWrapper request) {
StringBuilder sb = new StringBuilder(1024)
.append("request:").append(request.getMethod()).append(" ")
.append(request.getRequestURI()).append(LINE_SEPARATOR);
byte[] content = request.getContentAsByteArray();
if (content.length > 0 && (!MediaType.APPLICATION_FORM_URLENCODED.includes(MediaType.valueOf(request.getContentType())))) {
logContent(content, request.getContentType(), request.getCharacterEncoding(), "requestBody:", sb);
} else {
String paramString = StringUtils.EMPTY;
Map<String, String[]> parameterMap = request.getParameterMap();
if (MapUtils.isNotEmpty(parameterMap)) {
List<String> pairs = Lists.newArrayList();
parameterMap.forEach((name, values) -> {
for (String value : values) {
pairs.add(name + "=" + StringUtils.trimToEmpty(value));
}
});
paramString = Joiner.on("&").join(pairs);
}
if (StringUtils.equals(request.getContentType(), MediaType.APPLICATION_FORM_URLENCODED_VALUE)) {
try {
paramString = URLDecoder.decode(paramString, request.getCharacterEncoding());
} catch (UnsupportedEncodingException e) {
}
}
sb.append("requestParams:").append(paramString).append(LINE_SEPARATOR);
}
return StringUtils.trimToEmpty(sb.toString());
}
private void logContent(byte[] content, String contentType, String contentEncoding, String prefix, StringBuilder sb) {
sb.append(prefix);
MediaType mediaType = MediaType.valueOf(contentType);
boolean visible = VISIBLE_TYPES.stream().anyMatch(visibleType -> visibleType.includes(mediaType));
if (visible) {
try {
String contentString = new String(content, contentEncoding);
sb.append(contentString).append(LINE_SEPARATOR);
} catch (UnsupportedEncodingException e) {
sb.append("[" + content.length + " bytes content]").append(LINE_SEPARATOR);
}
} else {
sb.append("[" + content.length + " bytes content]").append(LINE_SEPARATOR);
}
}
}
拓展學習
回到最初的想法:只要拿到request,就能將請求參數打印出來,我們實驗之後,確實是能拿到request。但是,爲什麼在參數裏寫個request,Spring MVC就能把request給注入進來?在參數裏寫response,又能拿到嗎?更一般地,這兒到底能寫什麼參數,能夠讓Spring MVC幫我們注入呢?
@ExceptionHandler
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ResponseDTO handleException(Exception e, HttpServletRequest request) {
//
}
上篇文章提到,調用異常處理方法的入口是
protected ModelAndView doResolveHandlerMethodException(HttpServletRequest request,
HttpServletResponse response, HandlerMethod handlerMethod, Exception exception) {
// ...
exceptionHandlerMethod.invokeAndHandle(webRequest, mavContainer, exception, handlerMethod);
// ...
}
我們看到,exception
, handlerMethod
被當作參數傳入了providedArgs
數組中
public void invokeAndHandle(ServletWebRequest webRequest, ModelAndViewContainer mavContainer,
Object... providedArgs) throws Exception {
Object returnValue = invokeForRequest(webRequest, mavContainer, providedArgs);
// ...
public Object invokeForRequest(NativeWebRequest request, ModelAndViewContainer mavContainer,
Object... providedArgs) throws Exception {
Object[] args = getMethodArgumentValues(request, mavContainer, providedArgs);
// ...
private Object[] getMethodArgumentValues(NativeWebRequest request, ModelAndViewContainer mavContainer,
Object... providedArgs) throws Exception {
MethodParameter[] parameters = getMethodParameters();
Object[] args = new Object[parameters.length];
for (int i = 0; i < parameters.length; i++) {
MethodParameter parameter = parameters[i];
parameter.initParameterNameDiscovery(this.parameterNameDiscoverer);
// 重點看這一行
args[i] = resolveProvidedArgument(parameter, providedArgs);
if (args[i] != null) {
continue;
}
if (this.argumentResolvers.supportsParameter(parameter)) {
try {
// 重點看這一行
args[i] = this.argumentResolvers.resolveArgument(
parameter, mavContainer, request, this.dataBinderFactory);
continue;
}
catch (Exception ex) {
if (logger.isDebugEnabled()) {
logger.debug(getArgumentResolutionErrorMessage("Failed to resolve", i), ex);
}
throw ex;
}
}
if (args[i] == null) {
throw new IllegalStateException("Could not resolve method parameter at index " +
parameter.getParameterIndex() + " in " + parameter.getMethod().toGenericString() +
": " + getArgumentResolutionErrorMessage("No suitable resolver for", i));
}
}
return args;
}
先來看args[i] = resolveProvidedArgument(parameter, providedArgs);
private Object resolveProvidedArgument(MethodParameter parameter, Object... providedArgs) {
if (providedArgs == null) {
return null;
}
for (Object providedArg : providedArgs) {
if (parameter.getParameterType().isInstance(providedArg)) {
return providedArg;
}
}
return null;
}
只要方法參數類型是providedArg(exception
, handlerMethod
)的實例,就返回解析成功,這就解釋了,爲什麼能在方法參數裏寫Exception類型的參數,且會被注入相應的異常實例。此外,通過此處知道,我們還可以拿到handlerMethod
的實例,即拋出異常的Controller Method,如此,我們就可以拿到相應的方法或其上的註解,基於此我們可以擴展很多玩法。
再接着看args[i] = this.argumentResolvers.resolveArgument( parameter, mavContainer, request, this.dataBinderFactory);
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
HandlerMethodArgumentResolver resolver = getArgumentResolver(parameter);
if (resolver == null) {
throw new IllegalArgumentException("Unknown parameter type [" + parameter.getParameterType().getName() + "]");
}
return resolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
}
resolver(HandlerMethodArgumentResolver)有很多實現類,此處按下不表,先直接進到ServletRequestMethodArgumentResolver#resolveArgument
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
Class<?> paramType = parameter.getParameterType();
if (WebRequest.class.isAssignableFrom(paramType)) {
if (!paramType.isInstance(webRequest)) {
throw new IllegalStateException(
"Current request is not of type [" + paramType.getName() + "]: " + webRequest);
}
return webRequest;
}
HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
// 只要參數類型是ServletRequest、MultipartRequest或者它們的子類,就獲取nativeRequest並返回
if (ServletRequest.class.isAssignableFrom(paramType) || MultipartRequest.class.isAssignableFrom(paramType)) {
Object nativeRequest = webRequest.getNativeRequest(paramType);
if (nativeRequest == null) {
throw new IllegalStateException(
"Current request is not of type [" + paramType.getName() + "]: " + request);
}
return nativeRequest;
}
// ...
通過這兒的代碼,我們可以看出Spring MVC是如何尋找並注入request的。
接下來,分析更一般化地,異常處理方法支持什麼類型的參數
ExceptionHandlerExceptionResolver
在Bean初始化的時候,回調afterPropertiesSet
方法,除了初始化上篇中提到的exceptionHandlerAdviceCache
,還初始化了argumentResolvers
和returnValueHandlers
public void afterPropertiesSet() {
// Do this first, it may add ResponseBodyAdvice beans
initExceptionHandlerAdviceCache();
if (this.argumentResolvers == null) {
// 這兒獲取了默認參數解析器
List<HandlerMethodArgumentResolver> resolvers = getDefaultArgumentResolvers();
this.argumentResolvers = new HandlerMethodArgumentResolverComposite().addResolvers(resolvers);
}
if (this.returnValueHandlers == null) {
List<HandlerMethodReturnValueHandler> handlers = getDefaultReturnValueHandlers();
this.returnValueHandlers = new HandlerMethodReturnValueHandlerComposite().addHandlers(handlers);
}
}
默認的參數解析器有三類
- 基於註解
- SessionAttribute
- 參數被
@SessionAttribute
註解
- 參數被
- RequestAttribute
- 參數被
@RequestAttribute
註解
- 參數被
- 基於類型
- ServletRequest
- 參數類型是
WebRequest
或其子類 - 參數類型是
ServletRequest
或其子類 - 參數類型是
MultipartRequest
或其子類 - 參數類型是
HttpSession
或其子類 - 參數類型是
Principal
或其子類 - 參數類型是
InputStream
或其子類 - 參數類型是
Reader
或其子類 - 參數類型是
HttpMethod
- 參數類型是
Locale
- 參數類型是
TimeZone
- 參數類型是
ZoneId
(Since JDK1.8)
- 參數類型是
- ServletResponse
- 參數類型是
ServletResponse
或其子類 - 參數類型是
OutputStream
或其子類 - 參數類型是
Writer
或其子類
- 參數類型是
- RedirectAttributes
- 參數類型是
RedirectAttributes
或其子類
- 參數類型是
- Model
- 參數類型是
Model
或其子類
- 參數類型是
- 自定義
protected List<HandlerMethodArgumentResolver> getDefaultArgumentResolvers() {
List<HandlerMethodArgumentResolver> resolvers = new ArrayList<HandlerMethodArgumentResolver>();
// Annotation-based argument resolution
resolvers.add(new SessionAttributeMethodArgumentResolver());
resolvers.add(new RequestAttributeMethodArgumentResolver());
// Type-based argument resolution
resolvers.add(new ServletRequestMethodArgumentResolver());
resolvers.add(new ServletResponseMethodArgumentResolver());
resolvers.add(new RedirectAttributesMethodArgumentResolver());
resolvers.add(new ModelMethodProcessor());
// Custom arguments
if (getCustomArgumentResolvers() != null) {
resolvers.addAll(getCustomArgumentResolvers());
}
return resolvers;
}
public boolean supportsParameter(MethodParameter parameter) {
Class<?> paramType = parameter.getParameterType();
return (WebRequest.class.isAssignableFrom(paramType) ||
ServletRequest.class.isAssignableFrom(paramType) ||
MultipartRequest.class.isAssignableFrom(paramType) ||
HttpSession.class.isAssignableFrom(paramType) ||
Principal.class.isAssignableFrom(paramType) ||
InputStream.class.isAssignableFrom(paramType) ||
Reader.class.isAssignableFrom(paramType) ||
HttpMethod.class == paramType ||
Locale.class == paramType ||
TimeZone.class == paramType ||
"java.time.ZoneId".equals(paramType.getName()));
}
簡單總結一下,異常處理方法中的參數支持兩大類型
- providedArgs: exception, handlerMethod
- argumentResolvers: 基於註解、基於類型、自定義
總結
本篇開篇討論了統一日誌規範的重要性,接着拋出一個問題:引入統一異常處理方案後,如何打印請求上下文?答案是在異常處理方法中引入Request,通過Request拿到請求信息。但隨之而來的問題是,普通的HttpServletRequest
無法二次讀取請求體信息,因此又引入了ContentCachingRequestWrapper
,並介紹了其工作原理,通過配置Filter的方式使其生效,此時再配合GlobalExceptionHandler
就可以打印請求上下文信息。最後,拓展開來,介紹了異常處理方法中支持哪些類型的參數,爲以後實現更靈活的功能提供可能性。