背景
在SpringMVC项目中,通常会使用@ExceptionHandler
方法,对Controller做统一的全局异常处理。当发生异常时,希望对异常的上下文做日志记录,特别是HTTP请求信息,如请求的URL、请求参数。对于POST请求,主要信息在请求体里,因此还希望能记录请求体内容。
问题
HTTP请求信息,一般通过HttpServletRequest
对象(request)获取。然而,在按常规思路,通过request获取请求体内容时,却会发生异常。
有问题的全局异常处理写法如下所示。
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleException(HttpServletRequest request,
Exception e) {
log.error("Global exception: {} [uri: {}] [query: {}] [body: {}]", e.getMessage(), request.getRequestURI(),
request.getQueryString(), getRequestBody(request), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Error");
}
@SneakyThrows
private static String getRequestBody(HttpServletRequest request) {
// 发生异常: java.lang.IllegalStateException: getInputStream() has already been called for this request
BufferedReader reader = request.getReader();
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
return sb.toString();
}
}
可见,请求体内容是作为一个输入流(inputStream
)来处理的,只能被读取一次。在Controller层已经读取过请求体了,那么在全局异常处理就无法再次读取。
解决办法
Spring提供了一个ContentCachingRequestWrapper
,用于缓存从输入流读取出来的内容。我们可以在过滤器(Filter)中,将HttpServletRequest
包装成ContentCachingRequestWrapper
,这样后续就可以重复获取到请求体内容了。
- 定义Filter:
@Component
public class RequestWrapperFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
filterChain.doFilter(new ContentCachingRequestWrapper(request), response);
}
}
- 全局异常处理中,将获取请求体内容的方法改成:
private static String getRequestBody(HttpServletRequest request) {
ContentCachingRequestWrapper requestWrapper =
WebUtils.getNativeRequest(request, ContentCachingRequestWrapper.class);
if (requestWrapper == null) {
return "";
}
return new String(requestWrapper.getContentAsByteArray(), StandardCharsets.UTF_8);
}
注意:使用上述方法获取请求体内容,要求请求体已经被读取过一次,否则获取到的为空。例如,在Controller的方法中,如果不传@RequestBody
参数(如下所示), 就不会读取请求体,上述方法失效。
@RestController
@RequestMapping("/test")
public class TestController {
@PostMapping("/add")
public String add() {
// ...
}
}
原理分析
阅读ContentCachingRequestWrapper
源码可知,它就是通过在内部维护一个cachedContent
,来实现请求体内容的重复读取的。
public class ContentCachingRequestWrapper extends HttpServletRequestWrapper {
// ...
private final ByteArrayOutputStream cachedContent;
@Nullable
private final Integer contentCacheLimit;
@Nullable
private ServletInputStream inputStream;
// ...
@Override
public ServletInputStream getInputStream() throws IOException {
if (this.inputStream == null) {
this.inputStream = new ContentCachingInputStream(getRequest().getInputStream());
}
return this.inputStream;
}
// ...
public byte[] getContentAsByteArray() {
return this.cachedContent.toByteArray();
}
// ...
private class ContentCachingInputStream extends ServletInputStream {
private final ServletInputStream is;
private boolean overflow = false;
// ...
@Override
public int read(byte[] b) throws IOException {
int count = this.is.read(b);
writeToCache(b, 0, count);
return count;
}
private void writeToCache(final byte[] b, final int off, int count) {
if (!this.overflow && count > 0) {
if (contentCacheLimit != null &&
count + cachedContent.size() > contentCacheLimit) {
this.overflow = true;
cachedContent.write(b, off, contentCacheLimit - cachedContent.size());
handleContentOverflow(contentCacheLimit);
return;
}
cachedContent.write(b, off, count);
}
}
// ...
}
}
外部在调用ContentCachingRequestWrapper
的getInputStream()
方法时,实际上获取到的是其内部类ContentCachingInputStream
的对象。该内部类继承自ServletInputStream
,当它的read()
方法被调用时,请求体内容会被写入缓存cachedContent
。于是,通过ContentCachingRequestWrapper
的getContentAsByteArray()
方法,就可以获取到cachedContent
的内容。
总结
SpringMVC项目中,将HttpServletRequest
包装成ContentCachingRequestWrapper
,可以实现对请求体内容的重复获取。这点在全局异常处理中很实用。