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

前言

上篇,我們已經(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);
}

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

  1. 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ù)且無意義的勞動,相信大家都不愿意去做

  2. 每個人打印日志的習慣都不一樣,甚至同一個人不同成長階段都不一樣。有的人喜歡用=來分割參數(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.png

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

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ù)解析器有三類

  1. 基于注解
  • SessionAttribute
    • 參數(shù)被@SessionAttribute注解
  • RequestAttribute
    • 參數(shù)被@RequestAttribute注解
  1. 基于類型
  • 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)
  • ServletResponse
    • 參數(shù)類型是ServletResponse或其子類
    • 參數(shù)類型是OutputStream或其子類
    • 參數(shù)類型是Writer或其子類
  • RedirectAttributes
    • 參數(shù)類型是RedirectAttributes或其子類
  • Model
    • 參數(shù)類型是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()));
}

簡單總結(jié)一下,異常處理方法中的參數(shù)支持兩大類型

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

總結(jié)

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


導(dǎo)讀:Spring MVC統(tǒng)一異常處理及原理分析

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容