生也有涯,知也无涯。这篇文章主要讲述#yyds干货盘点#?并发技术系列「Web请求读取系列」如何构建一个可重复读取的Request的流机制相关的知识,希望能为你提供帮助。
前提背景
项目中需要记录用户的请求参数便于后面查找问题,对于这种需求一般可以通过Spring中的拦截器或者是使Servlet中的过滤器来实现。这里我选择使用过滤器来实现,就是添加一个过滤器,然后在过滤器中获取到Request对象,将Reques中的信息记录到日志中。
问题介绍
有时候我们的请求是post,但我们又要对参数签名,这个时候我们需要获取到body的信息,但是当我们使用HttpServletRequest的getReader()和getInputStream()获取参数后,后面不管是框架还是自己想再次获取body已经没办法获取。当然也有一些其他的场景,可能需要多次获取的情况。
可能抛出类似以下的异常
java.lang.IllegalStateException: getReader() has already been called for this request
定义过滤器解决使用过滤器很快我实现了统一记录请求参数的的功能,整个代码实现如下:
@Slf4j
@Component
public class CheckDataFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
Map<
String, String[]>
parameterMap = request.getParameterMap();
log.info("请求参数:{}", JSON.toJSONString(parameterMap));
filterChain.doFilter(request,response);
}
}
上面的实现方式对于GET请求没有问题,可以很好的记录前端提交过来的参数。对于POST请求就没那么简单了。根据POST请求中Content-Type类型我们常用的有下面几种:
- application/x-www-form-urlencoded:这种方式是最常见的方式,浏览器原生的form表单就是这种方式提交。
- application/json:这种方式也算是一种常见的方式,当我们在提交一个复杂的对象时往往采用这种方式。
- multipart/form-data:这种方式通常在使用表单上传文件时会用。
@Slf4j
@Component
public class CheckDataFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
Map<
String, String[]>
parameterMap = request.getParameterMap();
log.info("请求参数:{}",JSON.toJSONString(parameterMap));
ByteArrayOutputStream out = new ByteArrayOutputStream();
IOUtils.copy(request.getInputStream(),out);
log.info("请求体:{}", out.toString(request.getCharacterEncoding()));
filterChain.doFilter(request,response);
}
}
上面的代码中我通过获取Request中的流来获取到请求提交到服务器中的JSON数据,最后在日志中能打印出客户端提交过来的JSON数据。但是最后接口的返回并没有成功,而且在Controller中也无法获取到请求参数,最后程序给出的错误提示关键信息为:Required request body is missing。
之所以会出现异常是因为Request中的流只能读取一次,我们在过滤器中读取之后如果后面有再次读取流的操作就会导致服务异常,简单的说就是Request中获取的流不支持重复读取。
扩展HttpServletRequest HttpServletRequestWrapper通过上面的分析我们知道了问题所在,对于Request中流无法重复读取的问题,我们要想办法让其支持重复读取。
难道我们要自己去实现一个Request,且我们的Request中的流还支持重复读取,想想就知道这样做很麻烦了。
【#yyds干货盘点#?并发技术系列「Web请求读取系列」如何构建一个可重复读取的Request的流机制】幸运的是Servlet中提供了一个HttpServletRequestWrapper类,这个类从名字就能看出它是一个Wrapper类,就是我们可以通过它将原先获取流的方法包装一下,让它支持重复读取即可。
创建一个自定义类继承HttpServletRequestWrapper实现一个CustomHttpServletRequest并且写一个构造函数来缓存body数据,先将RequestBody保存为一个byte数组,然后通过Servlet自带的HttpServletRequestWrapper类覆盖getReader()和getInputStream()方法,使流从保存的byte数组读取。
public class CustomHttpServletRequest extends HttpServletRequestWrapper {
private byte[] cachedBody;
public CustomHttpServletRequest(HttpServletRequest request) throws IOException {
super(request);
InputStream is = request.getInputStream();
this.cachedBody = StreamUtils.copyToByteArray(is);
}
}
重写getReader()
@Override
public BufferedReader getReader() throws IOException {
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.cachedBody);
return new BufferedReader(new InputStreamReader(byteArrayInputStream));
}
重写getInputStream()
@Override
public ServletInputStream getInputStream() throws IOException {
return new CachedBodyServletInputStream(this.cachedBody);
}
然后再Filter中将ServletRequest替换为ServletRequestWrapper。代码如下:
实现ServletInputStream创建一个继承了ServletInputStream的类
public class CachedBodyServletInputStream extends ServletInputStream {
private InputStream cachedBodyInputStream;
public CachedBodyServletInputStream(byte[] cachedBody) {
this.cachedBodyInputStream = new ByteArrayInputStream(cachedBody);
}@Override
public boolean isFinished() {
try {
return cachedBodyInputStream.available() == 0;
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return false;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener readListener) {
throw new UnsupportedOperationException();
}
@Override
public int read() throws IOException {
return cachedBodyInputStream.read();
}
}
创建一个Filter加入到容器中既然要加入到容器中,可以创建一个Filter,然后加入配置
我们可以简单的继承OncePerRequestFilter然后实现下面方法即可。
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
CustomHttpServletRequest customHttpServletRequest =
new CustomHttpServletRequest(httpServletRequest);
filterChain.doFilter(customHttpServletRequest, httpServletResponse);
}
然后,添加该Filter加入即可,在上面的过滤器中先调用了getParameterMap方法获取参数,然后再获取流,如果我先getInputStream然后再调用getParameterMap会导致参数解析失败。
例如,将过滤器中代码调整顺序为如下:
@Slf4j
@Component
public class CheckDataFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//使用包装Request替换原始的Request
request = new CustomHttpServletRequest(request);
//读取流中的内容
ByteArrayOutputStream out = new ByteArrayOutputStream();
IOUtils.copy(request.getInputStream(),out);
log.info("请求体:{}", out.toString(request.getCharacterEncoding()));
Map<
String, String[]>
parameterMap = request.getParameterMap();
log.info("请求参数:{}",JSON.toJSONString(parameterMap));
filterChain.doFilter(request,response);
}
}
调整了getInputStream和getParameterMap这两个方法的调用时机,最后却会产生两种结果,这让我一度以为这个是个BUG。最后我从源码中知道了为啥会有这种结果,如果我们先调用getInputStream,这将会getParameterMap时不会去解析参数,以下代码是SpringBoot中嵌入的tomcat实现。
org.apache.catalina.connector.Request:
protected void parseParameters() {
parametersParsed = true;
Parameters parameters = coyoteRequest.getParameters();
boolean success = false;
try {
// Set this every time in case limit has been changed via JMX
parameters.setLimit(getConnector().getMaxParameterCount());
// getCharacterEncoding() may have been overridden to search for
// hidden form field containing request encoding
Charset charset = getCharset();
boolean useBodyEncodingForURI = connector.getUseBodyEncodingForURI();
parameters.setCharset(charset);
if (useBodyEncodingForURI) {
parameters.setQueryStringCharset(charset);
}
// Note: If !useBodyEncodingForURI, the query string encoding is
//that set towards the start of CoyoyeAdapter.service()
parameters.handleQueryParameters();
if (usingInputStream || usingReader) {
success = true;
return;
}
String contentType = getContentType();
if (contentType == null) {
contentType = "";
}
int semicolon = contentType.indexOf(;
);
if (semicolon >
= 0) {
contentType = contentType.substring(0, semicolon).trim();
} else {
contentType = contentType.trim();
}
if ("multipart/form-data".equals(contentType)) {
parseParts(false);
success = true;
return;
}
if( !getConnector().isParseBodyMethod(getMethod()) ) {
success = true;
return;
}
if (!("application/x-www-form-urlencoded".equals(contentType))) {
success = true;
return;
}
int len = getContentLength();
if (len >
0) {
int maxPostSize = connector.getMaxPostSize();
if ((maxPostSize >
= 0) &
&
(len >
maxPostSize)) {
Context context = getContext();
if (context != null &
&
context.getLogger().isDebugEnabled()) {
context.getLogger().debug(
sm.getString("coyoteRequest.postTooLarge"));
}
checkSwallowInput();
parameters.setParseFailedReason(FailReason.POST_TOO_LARGE);
return;
}
byte[] formData = https://www.songbingjia.com/android/null;
if (len <
CACHED_POST_LEN) {
if (postData == null) {
postData = new byte[CACHED_POST_LEN];
}
formData = postData;
} else {
formData = new byte[len];
}
try {
if (readPostBody(formData, len) != len) {
parameters.setParseFailedReason(FailReason.REQUEST_BODY_INCOMPLETE);
return;
}
} catch (IOException e) {
// Client disconnect
Context context = getContext();
if (context != null &
&
context.getLogger().isDebugEnabled()) {
context.getLogger().debug(
sm.getString("coyoteRequest.parseParameters"), e);
}
parameters.setParseFailedReason(FailReason.CLIENT_DISCONNECT);
return;
}
parameters.processParameters(formData, 0, len);
} else if ("chunked".equalsIgnoreCase(
coyoteRequest.getHeader("transfer-encoding"))) {
byte[] formData = https://www.songbingjia.com/android/null;
try {
formData = readChunkedPostBody();
} catch (IllegalStateException ise) {
// chunkedPostTooLarge error
parameters.setParseFailedReason(FailReason.POST_TOO_LARGE);
Context context = getContext();
if (context != null &
&
context.getLogger().isDebugEnabled()) {
context.getLogger().debug(
sm.getString("coyoteRequest.parseParameters"),
ise);
}
return;
} catch (IOException e) {
// Client disconnect
parameters.setParseFailedReason(FailReason.CLIENT_DISCONNECT);
Context context = getContext();
if (context != null &
&
context.getLogger().isDebugEnabled()) {
context.getLogger().debug(
sm.getString("coyoteRequest.parseParameters"), e);
}
return;
}
if (formData != null) {
parameters.processParameters(formData, 0, formData.length);
}
}
success = true;
} finally {
if (!success) {
parameters.setParseFailedReason(FailReason.UNKNOWN);
}
}
}
上面代码从方法名字可以看出就是用来解析参数的,其中有一处关键的信息如下:
if (usingInputStream || usingReader) {
success = true;
return;
}
这个判断的意思是如果usingInputStream或者usingReader为true,将导致解析中断直接认为已经解析成功了。这个是两个属性默认都为false,而将它们设置为true的地方只有两处,分别为getInputStream和getReader,源码如下:
getInputStream()
public ServletInputStream getInputStream() throws IOException {
if (usingReader) {
throw new IllegalStateException(sm.getString("coyoteRequest.getInputStream.ise"));
}
//设置usingInputStream 为true
usingInputStream = true;
if (inputStream == null) {
inputStream = new CoyoteInputStream(inputBuffer);
}
return inputStream;
}
getReader()
public BufferedReader getReader() throws IOException {
if (usingInputStream) {
throw new IllegalStateException(sm.getString("coyoteRequest.getReader.ise"));
}
if (coyoteRequest.getCharacterEncoding() == null) {
// Nothing currently set explicitly.
// Check the content
Context context = getContext();
if (context != null) {
String enc = context.getRequestCharacterEncoding();
if (enc != null) {
// Explicitly set the context default so it is visible to
// InputBuffer when creating the Reader.
setCharacterEncoding(enc);
}
}
}
//设置usingReader为true
usingReader = true;
inputBuffer.checkConverter();
if (reader == null) {
reader = new CoyoteReader(inputBuffer);
}
return reader;
}
为何在tomcat要如此实现呢?tomcat如此实现可能是有它的道理,作为Servlet容器那必须按照Servlet规范来实现,通过查询相关文档还真就找到了Servlet规范中的内容,下面是Servlet3.1规范中关于参数解析的部分内容:
文章图片
总结为了获取请求中的参数我们要解决的核心问题就是让流可以重复读取即可,同时注意先读取流会导致getParameterMap时参数无法解析这两点关键点即可。
参考资料
- https://www.cnblogs.com/alter888/p/8919865.html
- https://www.iteye.com/blog/zhangbo-peipei-163-com-2022460
推荐阅读
- #yyds干货盘点# Spring嵌套事务是怎么回滚的()
- LINUX:FTP服务
- #yyds干货盘点#Java ASM系列((097)生成Control Flow Graph)
- Linux的进程和计划任务管理
- #yyds干货盘点#Redis之Stream
- LINUX文件系统及日志分析
- 删除目录下三天以前的所有的目录下的文件
- #yyds干货盘点#?Java深层系列「技术盲区」让我们一起完全吃透针对于时间和日期相关的API指南
- 学习使用调用shell函数