背景
項目中需要記錄用戶的請求參數便于后面查找問題,對于這種需求一般可以通過Spring中的攔截器或者是使Servlet中的過濾器來實現。這里我選擇使用過濾器來實現,就是添加一個過濾器,然后在過濾器中獲取到Request對象,將Reques中的信息記錄到日志中。
實現過程
使用過濾器很快我實現了統一記錄請求參數的的功能,整個代碼實現如下:
@Slf4j
@Component
public class LogFilter 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:這種方式通常在使用表單上傳文件時會用。
上面三種常見的POST方式我實現的過濾器有一種是無法記錄到的,當Content-Type為application/json時,通過調用Request對象中getParameter*相關方法是無法獲取到請求參數的。
application/json解決方案及問題
想要該形式的請求參數能被打印,我們可以通過讀取Request中流的方式來獲取請求JSON請求參數,現在修改代碼如下:
@Slf4j
@Component
public class LogFilter 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中獲取的流不支持重復讀取。
HttpServletRequestWrapper
通過上面的分析我們知道了問題所在,對于Request中流無法重復讀取的問題,我們要想辦法讓其支持重復讀取。難道我們要自己去實現一個Request,且我們的Request中的流還支持重復讀取,想想就知道這樣做很麻煩了。幸運的是Servlet中提供了一個HttpServletRequestWrapper類,這個類從名字就能看出它是一個Wrapper類,就是我們可以通過它將原先獲取流的方法包裝一下,讓它支持重復讀取即可。下面是我自己實現的一個可重復讀取流的HttpServletRequestWrapper實現。
public class RepeatReadRequest extends HttpServletRequestWrapper {
private ByteArrayOutputStream out;
public RepeatReadRequest(HttpServletRequest request) {
super(request);
}
@Override
public ServletInputStream getInputStream() throws IOException {
if (out == null){
out = new ByteArrayOutputStream();
IOUtils.copy(super.getInputStream(),out);
}
return new CustomerServletInputStream(out.toByteArray());
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(getInputStream()));
}
private static class CustomerServletInputStream extends ServletInputStream{
private final ByteArrayInputStream in;
public CustomerServletInputStream(byte[] data) {
this.in = new ByteArrayInputStream(data);
}
@Override
public boolean isFinished() {
throw new UnsupportedOperationException();
}
@Override
public boolean isReady() {
throw new UnsupportedOperationException();
}
@Override
public void setReadListener(ReadListener listener) {
throw new UnsupportedOperationException();
}
@Override
public int read() throws IOException {
return in.read();
}
}
}
上面的代碼很簡單,就是在獲取流的同時將流里面的內容緩存起來,如果再次獲取流時返回一個新創建的流給你。所以在過濾器中我們只要將原始的Request對象包裝一下,然后再FilterChain中使用我們包裝的Request即可,修改代碼如下:
@Slf4j
@Component
public class LogFilter 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));
//使用包裝Request替換原始的Request
request = new RepeatReadRequest(request);
//讀取流中的內容
ByteArrayOutputStream out = new ByteArrayOutputStream();
IOUtils.copy(request.getInputStream(),out);
log.info("請求體:{}", out.toString(request.getCharacterEncoding()));
filterChain.doFilter(request,response);
}
}
流讀取導致參數解析失敗
我在上面的過濾器中先調用了getParameterMap方法獲取參數,然后再獲取流。這樣是沒毛病的。但是在我寫這個之前遇見一個特別奇怪的問題,就是如果我先getInputStream然后再調用getParameterMap會導致參數解析失敗。例如我將過濾器中代碼調整順序為如下:
@Slf4j
@Component
public class LogFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//使用包裝Request替換原始的Request
request = new RepeatReadRequest(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);
}
}
現在發送請求如下:
curl -d 'userName=tom&password=123' -H 'Content-type:application/x-www-form-urlencoded' -X POST http://127.0.0.1:8080/post/form
最后日志打印結果如下:
請求體:userName=tom&password=123
請求參數:{}
而之前的日志的打印結果如下:
請求參數:{"userName":["tom"],"password":["123"]}
請求體:
我只是調整了getInputStream和getParameterMap這兩個方法的調用時機,最后卻會產生兩種結果,這讓我一度以為這個是個BUG。最后我從源碼中知道了為啥會有這種結果,如果我們先調用getInputStream,這將會getParameterMap時不會去解析參數,以下代碼是SpringBoot中嵌入的tomcat實現:
org.apache.catalina.connector.Request.class
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 = 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 = 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時參數無法解析這兩點關鍵點即可。
示例代碼:servlet-log