前言
上篇,我們已經(jīng)闡述在Spring MVC中如何優(yōu)雅地處理異常,并通過源碼分析了其原理及工作過程。
但是一定會有同學產(chǎn)生疑問:原來的異常處理方式,可以直接在catch塊中打印請求入?yún)?,當異常發(fā)生時,能夠清晰知道是什么入?yún)?dǎo)致的異常,方便問題的排查。使用統(tǒng)一異常處理的方案,只打印了異常堆棧,丟失了相關(guān)上下文信息,怎么辦?
try {
// do biz
} catch (Exception e) {
log.error("xxx method exception, param1:{}, param2:{}, param3:{}", param1, param2, param3 ,e);
}
先說說這種打印日志方式的弊端
Controller層充斥著大量的
try\catch塊[上篇文章亦有提及],catch的邏輯中打印了請求參數(shù)相關(guān)信息,某天在接口新增參數(shù),很有可能漏掉在catch的日志中將之打印出來,拿排查問題的場景來說----在測試環(huán)境很容易發(fā)生新增參數(shù),對應(yīng)增加業(yè)務(wù)邏輯,但測試結(jié)果與預(yù)期不一致,但又因為漏打日志,無法確認問題所在,需要重新添加日志排查問題:修改,提交代碼,發(fā)起Merge-Request,Accept,發(fā)布,整一套流程下來,耗費多少時間?另一方面,這種重復(fù)且無意義的勞動,相信大家都不愿意去做每個人打印日志的習慣都不一樣,甚至同一個人不同成長階段都不一樣。有的人喜歡用
=來分割參數(shù)與值,有的人喜歡用:;有的人喜歡添加簡略信息如方法名,以便于日志關(guān)鍵字搜索,有的人喜歡添加詳細信息,便于仔細記錄。那么,不一致就會帶來許多的問題,比如無法做數(shù)據(jù)格式化及統(tǒng)一收集展示,無法基于日志對系統(tǒng)運行過程中的錯誤和潛在風險進行監(jiān)控和報警,另一方面,帶來的問題額外的是學習成本。舉個例子,張三負責的系統(tǒng)出現(xiàn)線上問題,但張三休假了,李四幫忙排查,他就需要先看異常信息是什么,但大部分的日志打印因為不規(guī)范的原因,對問題的排查沒有實質(zhì)性幫助,還需要跑到代碼里看對應(yīng)的日志上下文含義是什么,才能理解日志的含義,以幫助排查問題,這無異于提高排查問題的門檻,即所謂的學習成本
一個系統(tǒng)全無日志不利于問題排查,全打日志又如同信息垃圾,反而把重點信息給掩埋。因此,如何規(guī)范化打印日志,是一門學問,是我們做為合作程序員、工程師的職業(yè)素養(yǎng),也是對外界戲稱我們?yōu)?code>碼農(nóng)的吶喊與抗議。
在這里,我們并不討論具體該怎樣規(guī)范化打印日志,而是借著日志打印問題,提出一些思考,并嘗試借著現(xiàn)有的一些開源框架,去解決一部分規(guī)范化日志打印的問題。
正文
在上篇中,我們提出統(tǒng)一異常處理的方式,僅只打印了異常堆棧,丟失了相關(guān)上下文信息,想要解決此問題,一個很自然的想法是,能不能在統(tǒng)一異常處理中,同樣打印請求參數(shù)呢?先試著在方法中添加
HttpServletRequest參數(shù),該思路的出發(fā)點是,只要能拿到request,就能拿到請求參數(shù)
@ExceptionHandler
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) // Http Status Code 500
public ResponseDTO handleException(Exception e, HttpServletRequest request) {
// 兜底邏輯,通常用于處理未預(yù)期的異常,比如不知道哪兒冒出來的空指針異常
log.error("請求發(fā)生異常,請求參數(shù):{}", JsonUtils.toJsonString(request.getParameterMap()), e);
return ResponseDTO.failedResponse().withErrorMessage("服務(wù)器開小差了");
}
通過實驗發(fā)現(xiàn),可以拿到請求參數(shù),這樣就把請求參數(shù)打印出來了。但是這里又引入了另外一個問題,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 發(fā)生異常", ex);
}
log.error("請求發(fā)生異常,請求體:{}", requestBody, e);
但是,在這里,獲取請求體的方式存在一些問題,原因在于: 大部分的InputStream僅允許讀取一次,而對于ServletInputStream,如果進行二次讀取,會直接拋出java.io.IOException: Stream closed異常。
if (closed) {
throw new IOException(sm.getString("inputBuffer.streamClosed"));
}
在Controller的方法中使用@RequestBody注解參數(shù),Spring MVC就會調(diào)用request.getInputStream()讀取請求體,并將請求體轉(zhuǎn)化成我們需要的請求參數(shù),因此在這里已經(jīng)將流讀取完畢。
@PostMapping("/test")
public void test(@RequestBody XXXDTO xxxDTO) {
// do biz
}
如果我們在后置的異常處理流程中,嘗試再一次讀取請求體,程序就會拋出java.io.IOException: Stream closed異常,因此,通過這種簡單的方式拿不到請求體,我們需要嘗試用另一種方式來獲取請求體。
ContentCachingRequestWrapper

從ContentCachingRequestWrapper的繼承體系圖可以看出,它是一個HttpServletRequest,且是一個Wrapper,這是一個很典型的裝飾器模式,從類名中,可以猜測它能夠緩存請求體內(nèi)容。其實現(xiàn)機制是代理getInputStream方法,且內(nèi)部持有的一個ByteArrayOutputStream,每當從InputStream中讀取內(nèi)容,同時會將讀取到的內(nèi)容緩存到ByteArrayOutputStream中,實現(xiàn)數(shù)據(jù)重復(fù)利用。getContentAsByteArray方法即可返回緩存的內(nèi)容
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 {
// 將讀取到的內(nèi)容寫到ByteArrayOutputStream
cachedContent.write(ch);
}
}
return ch;
}
}
public byte[] getContentAsByteArray() {
return this.cachedContent.toByteArray();
}
我們需要一個擴展點,在Spring MVC處理請求之前,能夠?qū)equest進行增強,因此,很自然想到增加一個Filter。這樣,Spring MVC在后續(xù)讀取請求體時,增強的Request(ContentCachingRequestWrapper)就將請求體緩存了起來,為后續(xù)統(tǒng)一異常處理打印請求上下文提供可能性
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,在請求出現(xiàn)異常后,將請求上下文打印出來
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("請求發(fā)生異常, {}", requestLog, e);
return ResponseDTO.failedResponse().withErrorMessage("服務(wù)器開小差了");
}
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,就能將請求參數(shù)打印出來,我們實驗之后,確實是能拿到request。但是,為什么在參數(shù)里寫個request,Spring MVC就能把request給注入進來?在參數(shù)里寫response,又能拿到嗎?更一般地,這兒到底能寫什么參數(shù),能夠讓Spring MVC幫我們注入呢?
@ExceptionHandler
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ResponseDTO handleException(Exception e, HttpServletRequest request) {
//
}
上篇文章提到,調(diào)用異常處理方法的入口是
protected ModelAndView doResolveHandlerMethodException(HttpServletRequest request,
HttpServletResponse response, HandlerMethod handlerMethod, Exception exception) {
// ...
exceptionHandlerMethod.invokeAndHandle(webRequest, mavContainer, exception, handlerMethod);
// ...
}
我們看到,exception, handlerMethod被當作參數(shù)傳入了providedArgs數(shù)組中
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;
}
只要方法參數(shù)類型是providedArg(exception, handlerMethod)的實例,就返回解析成功,這就解釋了,為什么能在方法參數(shù)里寫Exception類型的參數(shù),且會被注入相應(yīng)的異常實例。此外,通過此處知道,我們還可以拿到handlerMethod的實例,即拋出異常的Controller Method,如此,我們就可以拿到相應(yīng)的方法或其上的注解,基于此我們可以擴展很多玩法。
再接著看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)有很多實現(xiàn)類,此處按下不表,先直接進到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);
// 只要參數(shù)類型是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的。
接下來,分析更一般化地,異常處理方法支持什么類型的參數(shù)
ExceptionHandlerExceptionResolver在Bean初始化的時候,回調(diào)afterPropertiesSet方法,除了初始化上篇中提到的exceptionHandlerAdviceCache,還初始化了argumentResolvers和returnValueHandlers
public void afterPropertiesSet() {
// Do this first, it may add ResponseBodyAdvice beans
initExceptionHandlerAdviceCache();
if (this.argumentResolvers == null) {
// 這兒獲取了默認參數(shù)解析器
List<HandlerMethodArgumentResolver> resolvers = getDefaultArgumentResolvers();
this.argumentResolvers = new HandlerMethodArgumentResolverComposite().addResolvers(resolvers);
}
if (this.returnValueHandlers == null) {
List<HandlerMethodReturnValueHandler> handlers = getDefaultReturnValueHandlers();
this.returnValueHandlers = new HandlerMethodReturnValueHandlerComposite().addHandlers(handlers);
}
}
默認的參數(shù)解析器有三類
- 基于注解
- SessionAttribute
- 參數(shù)被
@SessionAttribute注解
- 參數(shù)被
- RequestAttribute
- 參數(shù)被
@RequestAttribute注解
- 參數(shù)被
- 基于類型
- ServletRequest
- 參數(shù)類型是
WebRequest或其子類 - 參數(shù)類型是
ServletRequest或其子類 - 參數(shù)類型是
MultipartRequest或其子類 - 參數(shù)類型是
HttpSession或其子類 - 參數(shù)類型是
Principal或其子類 - 參數(shù)類型是
InputStream或其子類 - 參數(shù)類型是
Reader或其子類 - 參數(shù)類型是
HttpMethod - 參數(shù)類型是
Locale - 參數(shù)類型是
TimeZone - 參數(shù)類型是
ZoneId(Since JDK1.8)
- 參數(shù)類型是
- ServletResponse
- 參數(shù)類型是
ServletResponse或其子類 - 參數(shù)類型是
OutputStream或其子類 - 參數(shù)類型是
Writer或其子類
- 參數(shù)類型是
- RedirectAttributes
- 參數(shù)類型是
RedirectAttributes或其子類
- 參數(shù)類型是
- Model
- 參數(shù)類型是
Model或其子類
- 參數(shù)類型是
- 自定義
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()));
}
簡單總結(jié)一下,異常處理方法中的參數(shù)支持兩大類型
- providedArgs: exception, handlerMethod
- argumentResolvers: 基于注解、基于類型、自定義
總結(jié)
本篇開篇討論了統(tǒng)一日志規(guī)范的重要性,接著拋出一個問題:引入統(tǒng)一異常處理方案后,如何打印請求上下文?答案是在異常處理方法中引入Request,通過Request拿到請求信息。但隨之而來的問題是,普通的HttpServletRequest無法二次讀取請求體信息,因此又引入了ContentCachingRequestWrapper,并介紹了其工作原理,通過配置Filter的方式使其生效,此時再配合GlobalExceptionHandler就可以打印請求上下文信息。最后,拓展開來,介紹了異常處理方法中支持哪些類型的參數(shù),為以后實現(xiàn)更靈活的功能提供可能性。
導(dǎo)讀:Spring MVC統(tǒng)一異常處理及原理分析