Spring MVC統一異常處理及原理分析(二)

前言

上篇,我們已經闡述在Spring MVC中如何優雅地處理異常,並通過源碼分析了其原理及工作過程。
但是一定會有同學產生疑問:原來的異常處理方式,可以直接在catch塊中打印請求入參,當異常發生時,能夠清晰知道是什麼入參導致的異常,方便問題的排查。使用統一異常處理的方案,只打印了異常堆棧,丟失了相關上下文信息,怎麼辦?

try {
	// do biz
} catch (Exception e) {
	log.error("xxx method exception, param1:{}, param2:{}, param3:{}", param1, param2, param3 ,e);
}

先說說這種打印日誌方式的弊端

  1. Controller層充斥着大量的try\catch塊[上篇文章亦有提及],catch的邏輯中打印了請求參數相關信息,某天在接口新增參數,很有可能漏掉在catch的日誌中將之打印出來,拿排查問題的場景來說----在測試環境很容易發生新增參數,對應增加業務邏輯,但測試結果與預期不一致,但又因爲漏打日誌,無法確認問題所在,需要重新添加日誌排查問題:修改,提交代碼,發起Merge-Request,Accept,發佈,整一套流程下來,耗費多少時間?另一方面,這種重複且無意義的勞動,相信大家都不願意去做

  2. 每個人打印日誌的習慣都不一樣,甚至同一個人不同成長階段都不一樣。有的人喜歡用=來分割參數與值,有的人喜歡用:;有的人喜歡添加簡略信息如方法名,以便於日誌關鍵字搜索,有的人喜歡添加詳細信息,便於仔細記錄。那麼,不一致就會帶來許多的問題,比如無法做數據格式化及統一收集展示,無法基於日誌對系統運行過程中的錯誤和潛在風險進行監控和報警,另一方面,帶來的問題額外的是學習成本。舉個例子,張三負責的系統出現線上問題,但張三休假了,李四幫忙排查,他就需要先看異常信息是什麼,但大部分的日誌打印因爲不規範的原因,對問題的排查沒有實質性幫助,還需要跑到代碼裏看對應的日誌上下文含義是什麼,才能理解日誌的含義,以幫助排查問題,這無異於提高排查問題的門檻,即所謂的學習成本

一個系統全無日誌不利於問題排查,全打日誌又如同信息垃圾,反而把重點信息給掩埋。因此,如何規範化打印日誌,是一門學問,是我們做爲合作程序員、工程師的職業素養,也是對外界戲稱我們爲碼農的吶喊與抗議。

在這裏,我們並不討論具體該怎樣規範化打印日誌,而是藉着日誌打印問題,提出一些思考,並嘗試藉着現有的一些開源框架,去解決一部分規範化日誌打印的問題。

正文

在上篇中,我們提出統一異常處理的方式,僅只打印了異常堆棧,丟失了相關上下文信息,想要解決此問題,一個很自然的想法是,能不能在統一異常處理中,同樣打印請求參數呢?先試着在方法中添加
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.png

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,還初始化了argumentResolversreturnValueHandlers

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);
	}
}

默認的參數解析器有三類

  1. 基於註解
  • SessionAttribute
    • 參數被@SessionAttribute註解
  • RequestAttribute
    • 參數被@RequestAttribute註解
  1. 基於類型
  • ServletRequest
    • 參數類型是WebRequest或其子類
    • 參數類型是ServletRequest或其子類
    • 參數類型是MultipartRequest或其子類
    • 參數類型是HttpSession或其子類
    • 參數類型是Principal或其子類
    • 參數類型是InputStream或其子類
    • 參數類型是Reader或其子類
    • 參數類型是HttpMethod
    • 參數類型是Locale
    • 參數類型是TimeZone
    • 參數類型是ZoneId(Since JDK1.8)
  • ServletResponse
    • 參數類型是ServletResponse或其子類
    • 參數類型是OutputStream或其子類
    • 參數類型是Writer或其子類
  • RedirectAttributes
    • 參數類型是RedirectAttributes或其子類
  • Model
    • 參數類型是Model或其子類
  1. 自定義
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()));
}

簡單總結一下,異常處理方法中的參數支持兩大類型

  1. providedArgs: exception, handlerMethod
  2. argumentResolvers: 基於註解、基於類型、自定義

總結

本篇開篇討論了統一日誌規範的重要性,接着拋出一個問題:引入統一異常處理方案後,如何打印請求上下文?答案是在異常處理方法中引入Request,通過Request拿到請求信息。但隨之而來的問題是,普通的HttpServletRequest無法二次讀取請求體信息,因此又引入了ContentCachingRequestWrapper,並介紹了其工作原理,通過配置Filter的方式使其生效,此時再配合GlobalExceptionHandler就可以打印請求上下文信息。最後,拓展開來,介紹了異常處理方法中支持哪些類型的參數,爲以後實現更靈活的功能提供可能性。


導讀:Spring MVC統一異常處理及原理分析

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章