在web项目中,有不少场景需要统一处理一些和实际业务基本不相关的逻辑,比如rest接口的监控、出入参日志、操作记录、统一异常处理(避免将错误堆栈等信息直接打到web端)。
如果你觉得日志打印rest接口出入参非常简单,直接getParameter()就好了,那么多思考3s继续看吧
打印Request中的内容
Servlet处理请求的时候,会将header、url上的参数,已经解析放到了Request对象中了,所以获取header和url上带的参数就非常容易的,直接通过api就可以拿到了。
但是对于body中的内容,会发现Request中是没有任何直接api可以获取body里的内容的,但是可以发现Request中有个InputStream,body中的内容就是通过这个InputStream来获取到的。
所以,知道了这个就简单了,直接将InputStream中的字节全部读出来,构造成一个Stream打印出日志就好了,完美。
但是如果真的这么做了,会发现有一个问题:Controller中的注释@RequestBody的入参会没有值,这不就gg了么
其本质原因就是不带缓存的InputStream是单向的,简单粗暴理解就是:只能读一次,不能重复读的。你在Filter中已经将InputStream读到了末尾,那么后续spring mvc在处理@RequestBody的时候,拿到的InputStream是空的,当然Controller也就没法处理了。
所以解决方式也是很简单的,就是重写InputStream,提供缓存能力,让springmvc在后续的处理中还能获取到内容就好了。
可以翻看下jdk中带Buffer的InputStream,会发现,虽然支持重复度,是需要自己管理那个读游标的,springmvc处理@RequestBody的时候,并没有这么做,所以直接用jdk中待Buffer的InputStream,也就不可行了。
所以就还剩一个办法:将Requst中InputStream的内容先读出来,缓存下来,然后再重写写入到流中,这样,打印日志的时候就可以从缓存中读取内容,而给到spring mvc后续处理逻辑中的InputStream内容也还是原来的内容了。
public class RequestBodyCachableRequestWrapper extends HttpServletRequestWrapper {
private static final Logger LOGGER = LoggerFactory.getLogger(RequestBodyCachableRequestWrapper.class);
private String bodyContent;
public RequestBodyCachableRequestWrapper(HttpServletRequest request) {
super(request);
initReqestBody(request);
}
@Override
public ServletInputStream getInputStream() throws IOException {
final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bodyContent.getBytes());
return new ServletInputStream() {
@Override
public int read() throws IOException {
return byteArrayInputStream.read();
}
};
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(this.getInputStream()));
}
public String getBodyString() {
return this.body;
}
private void initReqestBody(HttpServletRequest request) {
StringBuilder stringBuilder = new StringBuilder();
BufferedReader bufferedReader = null;
InputStream inputStream = null;
try {
try {
inputStream = request.getInputStream();
if (inputStream != null) {
bufferedReader = new BufferedReader(new InputStreamReader(inputStream, Charset.defaultCharset()));
String line;
while ((line = bufferedReader.readLine()) != null) {
stringBuilder.append(line);
}
} else {
stringBuilder.append("");
}
} finally {
if (inputStream != null) {
inputStream.close();
}
if (bufferedReader != null) {
bufferedReader.close();
}
}
bodyContent = stringBuilder.toString();
} catch (Exception e) {
LogUtils.warn(LOGGER, "拦截获得http接口参数异常" + e);
}
}
}
打印response中的内容
因为是输出流,jdk中我们没有任何办法从OutputStream中读取去流程中的任何内容,所以从jdk中是找不到办法的,但是但是想想,OutputStream中输出数据的方式,就只有write()方法,那么是不是说只要重载write()方法,将写入的字节给旁路缓存下来,是不是就直接可以从缓存中获取到内容呢?
所以,基本的思路也就有了,我们只是需要自定义一个OutPutStream,然后重写write()方法,将写进来的内容给缓存下来,然后在拦截器中,就能够获取到缓存到自定义OutputStream中的内容了
缓存的方式其实可以直接用个字节数字来缓存,也可以使用一个并行的流,这样,我们就可以不动Response中默认的OutputStream,所以方法就是自定义Response,但是在Response中获取OutputStream的时候,返回的OutPutStream重载一下write()方法,同时写入到这个并行的分支流上,然后我们从这个分支流中获取对应的数据,当然这个分支流就必须要有缓存数据的能力,jdk中的ByteArrayOutputStream其实就是将流中的数据直接写入到自己的缓存字节数组中,那么就可以直接用它来做这个并行的分支流。
public class ResponseBodyCachableResponseWrapper extends HttpServletResponseWrapper {
private static final Logger LOGGER = LoggerFactory.getLogger(ResponseBodyCachableResponseWrapper.class);
//旁路输出流,response在将内容通过outputsteam的同时,将内容也写入到这个旁路outputStream,然后打印日志的时候可以从这个旁路outputStream中获取内容
// 这个旁路输出流的生命期和response中的outputStream同步
private final ByteArrayOutputStream branchByteArrayOutputStream = new ByteArrayOutputStream();
public ResponseBodyCachableResponseWrapper(HttpServletResponse response) {
super(response);
}
@Override
public ServletResponse getResponse() {
return this;
}
@Override
public ServletOutputStream getOutputStream() throws IOException {
// 相当于重载response中保留的outputstream,处理将内容写给前端,同时将内容写给旁路的outputstream,然后旁路outputStream使用带有缓存的outputsream,
// 打印日志的时候从旁路outputstream中获取值,
return new ServletOutputStream() {
// 注意这里入参int的含义,这个入参含义有点绕的
@Override
public void write(int bytes) throws IOException {
ResponseBodyCachableResponseWrapper.super.getOutputStream().write(bytes);
ResponseBodyCachableResponseWrapper.this.branchByteArrayOutputStream.write(bytes);
}
@Override
public void write(byte[] bytes, int off, int len) throws IOException {
ResponseBodyCachableResponseWrapper.super.getOutputStream().write(bytes, off, len);
try {
ResponseBodyCachableResponseWrapper.this.branchByteArrayOutputStream.write(bytes, off, len);
} catch (Exception e) {
LOGGER.error("write(byte[],off,len)写入分支outputStream失败");
}
}
@Override
public void write(byte[] bytes) throws IOException {
ResponseBodyCachableResponseWrapper.super.getOutputStream().write(bytes);
try {
ResponseBodyCachableResponseWrapper.this.branchByteArrayOutputStream.write(bytes);
} catch (Exception e) {
LOGGER.error("write(byte[])写入分支outputStream失败");
}
}
@Override
public void flush() throws IOException {
ResponseBodyCachableResponseWrapper.super.getOutputStream().flush();
try {
ResponseBodyCachableResponseWrapper.this.branchByteArrayOutputStream.flush();
} catch (Exception e) {
LOGGER.error("close分支outputStream失败");
}
}
@Override
public void close() throws IOException {
ResponseBodyCachableResponseWrapper.super.getOutputStream().close();
try {
ResponseBodyCachableResponseWrapper.this.branchByteArrayOutputStream.close();
} catch (Exception e) {
LOGGER.error("flush分支outputStream失败");
}
}
};
}
public byte[] getByteArray() {
try {
return this.branchByteArrayOutputStream.toByteArray();
} catch (Exception e) {
return new byte[0];
}
}
}
关于OutputStream.write(int byte)的理解:
猜测一下再从文件中读出来内容是啥:
97
a
答案是a。
原因就是a的ascii码是97。write(int byte)其实就是项输出流中写入了4个字节,即将这个int型数据转换成字节后,写入。而在读取的时候,将这个字节当成ascii来解释的,所以答案就是a。
如果我们将这4个字节按照int类型来解释,那就是97。这么解释后可以理解为啥入参的名称叫bytes了吧
不想管那么多的,就想拿来就用的,问题也好办,我找到了一个大兄弟封装好了放到了github上,可以直接干下来就用,它的处理方式是一样的。
https://github.com/isrsal/spring-mvc-logger/blob/master/README.md
有了这两个,那么Filter就好写了,脑补一下就好了。
只是需要特别注意一下,多了这些操作是有成本的,另外就是那种非文本的请求,比如文件/图片/视频/音频等这些的上传下载,是需要排除的,不要用这个封装。
统一异常处理
在spring项目中,不要意思说到统一,就去自定义各种拦截器,然后写一堆aspectj表达式去拉结类。在使用spring mvc的web项目中,除了spring framework,不要忘了还有servlet和spring mvc的扩展点可用,以及广为流传的注解可以帮助来完成很多和业务不相关的统一逻辑处理的。
@RestControllerAdvice+@ExceptionHandler(Exception.class)注解可实现web的统一异常处理
实现HandlerExceptionResolver接口
但要注意@RestControllerAdvice不单单是统一异常处理的,还可以完成其他事情的
@RestControllerAdvice是一个组合注解,由@ControllerAdvice、@ResponseBody组成,而@ControllerAdvice继承了@Component,因此@RestControllerAdvice本质上是个Component,用于定义@ExceptionHandler,@InitBinder和@ModelAttribute方法,适用于所有使用@RequestMapping方法。从而将对于控制器的全局配置放在同一个位置
@ExceptionHandler:用于指定异常处理方法,用于全局处理控制器里的异常。
@InitBinder:用来设置WebDataBinder,用于自动绑定前台请求参数到Model中。
@ModelAttribute:本来作用是绑定键值对到Model中,当与@ControllerAdvice配合使用时,可以让全局的@RequestMapping都能获得在此处设置的键值对
具体使用其实都是非常简单的,随便百度都有示例,只是注意的是统一异常处理本质还是在拦截异常,如果在统一异常处理之前,就将异常给吞掉了,那毫无疑问,就走不到这里的统一异常处理了。