предисловие
В некоторых компаниях может потребоваться многократное чтение параметров в HTTP-запросе, например, проверка подписи перед API. В настоящее время мы можем реализовать эту логику в перехватчике или фильтре, но попробовав, мы обнаружим, что если перехватчик проходитgetInputStream()
После того, как параметры будут прочитаны, их нельзя повторно прочитать в контроллере, и будут выброшены следующие исключения:
HttpMessageNotReadableException: Required request body is missing
IllegalStateException: getInputStream() can't be called after getReader()
В это время нам нужно кэшировать запрошенные данные. Эта статья начнётся с принципа инкапсуляции данных ServletRequest и подробно расскажет, как решить эту проблему. Если вы не хотите видеть принцип, вы можете прочитать его напрямуюлучшее решение.
Принцип инкапсуляции данных ServletRequest
Обычно, когда мы принимаем параметры HTTP-запросов, мы в основном оборачиваем их через SpringMVC.
- При отправке параметра данных формы вы можете напрямую использовать класс сущности или заполнить параметр непосредственно в методе контроллера.Вручную вы можете передать
request.getParameter()
чтобы получить. - При публикации json он будет добавлен в класс сущности.
@RequestBody
параметры или вызов напрямуюrequest.getInputStream()
Получить потоковые данные.
Мы можем обнаружить, что методы, вызываемые при получении данных в разных форматах данных, различаются, но чтение исходного кода может обнаружить, что все основные источники данных одинаковы, но SpringMVC помог нам в этом. Поговорим о принципе инкапсуляции данных ServletRequest.
На самом деле параметры, которые мы передаем через HTTP, будут храниться в InputStream объекта Request.Этот объект Request является окончательной реализацией ServletRequest, предоставляемой tomcat. Затем для разных форматов данных данные в InputStream будут инкапсулироваться в разное время.
Spring MVC инкапсулирует различные типы данных
-
Данные запроса GET обычно представляют собой строку запроса, которая находится непосредственно за URL-адресом и не требует специальной обработки.
-
Отправлено, например, POST, PUT
multipart/form-data
форматированные данные
// 源码中适当去除无关代码
// 对于这类数据,SpringMVC 在 DispatchServlet 的 doDispatch() 方法中就会进行处理。具体处理流程如下:
// org.springframework.web.servlet.DispatcherServlet.java
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
HttpServletRequest processedRequest = request;
HandlerExecutionChain mappedHandler = null;
boolean multipartRequestParsed = false;
WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
processedRequest = checkMultipart(request);
multipartRequestParsed = (processedRequest != request);
// Determine handler for the current request.
// other code...
}
// 1. 调用 checkMultipart(request),当前请求的数据类型是否为 multipart/form-data
protected HttpServletRequest checkMultipart(HttpServletRequest request) throws MultipartException {
if (this.multipartResolver != null && this.multipartResolver.isMultipart(request)) {
return this.multipartResolver.resolveMultipart(request);
}
return request;
}
//2. 如果是,调用 multipartResolver 的 resolveMultipart(request),返回一个 StandardMultipartHttpServletRequest 对象。
// org.springframework.web.multipart.support.StandardMultipartHttpServletRequest.java
public StandardMultipartHttpServletRequest(HttpServletRequest request) throws MultipartException {
this(request, false);
}
public StandardMultipartHttpServletRequest(HttpServletRequest request, boolean lazyParsing) throws MultipartException {
super(request);
if (!lazyParsing) {
parseRequest(request);
}
}
// 3. 在构造 StandardMultipartHttpServletRequest 对象时,会调用 parseRequest(request),将 InputStream 中是数据流进行进一步的封装。
// 不贴源码了,主要是对 form-data 数据的封装,包含字段和文件。
- Отправлено, например, POST, PUT
application/x-www-form-urlencoded
форматированные данные
// 非 form-data 的数据,会存储在 HttpServletRequest 的 InputStream 中。
// 在第一次调用 getParameterNames() 或 getParameter() 时,
// 会调用 parseParameters() 方法对参数进行封装,从 InputStream 中读取数据,并封装到 Map 中。
//org.apache.catalina.connector.Request.java
public String getParameter(String name) {
if (!this.parametersParsed) {
this.parseParameters();
}
return this.coyoteRequest.getParameters().getParameter(name);
}
- Отправлено, например, POST, PUT
application/json
форматированные данные
// 数据会直接会存储在 HttpServletRequest 的 InputStream 中,通过 request.getInputStream() 或 getReader() 获取。
Проблемы со чтением параметров
Теперь у нас есть общее представление о том, как SpringMVC инкапсулирует параметры HTTP-запроса. Согласно предыдущему описанию, если мы хотим повторно прочитать параметры в перехватчике и в Контроллере, произойдет следующее исключение:
HttpMessageNotReadableException: Required request body is missing
IllegalStateException: getInputStream() can't be called after getReader()
Это связано со спецификой данных потока InputStream. При чтении данных InputStream в Java байтовые данные считываются один за другим посредством движения указателя. После прочтения один раз указатель не будет сбрасываться. будут проблемы при чтении во второй раз. Как упоминалось ранее, параметры HTTP-запроса также инкапсулируются в InputStream в объекте Request, поэтому при втором вызовеgetInputStream()
Вышеупомянутое исключение будет выбрано. Конкретные проблемы можно разбить на множество ситуаций:
- Метод запроса
multipart/form-data
, вызываемый вручную в перехватчикеrequest.getInputStream()
// 上文讲了在 doDispatch() 时就会进行处理,因此这里会取不到值
log.info("input stream content: {}", new String(StreamUtils.copyToByteArray(request.getInputStream())));
- Метод запроса
application/x-www-form-urlencoded
, вызываемый вручную в перехватчикеrequest.getInputStream()
// 第 1 次可以取到值
log.info("input stream content: {}", new String(StreamUtils.copyToByteArray(request.getInputStream())));
// 第一次执行 getParameter() 会调用 parseParameters(),parseParameters 进一步调用 getInputStream()
// 这里就取不到值了
log.info("form-data param: {}", request.getParameter("a"));
log.info("form-data param: {}", request.getParameter("b"));
- Метод запроса
application/json
, вызываемый вручную в перехватчикеrequest.getInputStream()
// 第 1 次可以取到值
log.info("input stream content: {}", new String(StreamUtils.copyToByteArray(request.getInputStream())));
// 之后再任何地方再调用 getInputStream() 都取不到值,会抛出异常
Чтобы иметь возможность получать параметры для HTTP-запросов несколько раз, нам необходимо кэшировать данные в потоке InputStream.
лучшее решение
Судя по информации, на самом деле сам springframework имеет соответствующую оболочку для решения этой проблемы.org.springframework.web.util
Есть один под сумкойContentCachingRequestWrapper
тип. Роль этого класса заключается в кэшировании InputStream дляByteArrayOutputStream
, повторяющееся чтение данных потока достигается вызовом ``getContentAsByteArray()`.
/** * {@link javax.servlet.http.HttpServletRequest} wrapper that caches all content read from * the {@linkplain #getInputStream() input stream} and {@linkplain #getReader() reader}, * and allows this content to be retrieved via a {@link #getContentAsByteArray() byte array}. * @see ContentCachingResponseWrapper */
При использовании вам нужно только добавить фильтр, который будетHttpServletRequest
упаковано вContentCachingResponseWrapper
Вернулся к перехватчику и диспетчеру на нем.
@Slf4j
@WebFilter(urlPatterns = "/*")
public class CachingContentFilter implements Filter {
private static final String FORM_CONTENT_TYPE = "multipart/form-data";
@Override
public void init(FilterConfig filterConfig) {
}
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
String contentType = request.getContentType();
if (request instanceof HttpServletRequest) {
HttpServletRequest requestWrapper = new ContentCachingRequestWrapper((HttpServletRequest) request);
// #1
if (contentType != null && contentType.contains(FORM_CONTENT_TYPE)) {
chain.doFilter(request, response);
} else {
chain.doFilter(requestWrapper, response);
}
return;
}
chain.doFilter(request, response);
}
@Override
public void destroy() {
}
}
// 添加扫描 filter 注解
@ServletComponentScan
@SpringBootApplication
public class SeedApplication {
public static void main(String[] args) {
SpringApplication.run(SeedApplication.class, args);
}
}
В перехватчике получаем параметры запроса:
// 流数据获取,比如 json
// #2
String jsonBody = IOUtils.toString(wrapper.getContentAsByteArray(), "utf-8");
// form-data 和 urlencoded 数据
String paramA = request.getParameter("paramA");
Map<String,String[]> params = request.getParameterMap();
подсказки:
- Здесь нам нужно сделать различие в соответствии с contentType, встретить
multipart/form-data
При использовании данных обертка не нужна, а параметры будут напрямую инкапсулированы в Map через MultipartResolver.Конечно, об этом также можно гибко судить в перехватчике. - обертка В конкретном случае мы можем использовать
getContentAsByteArray()
чтобы получить данные и пройтиIOUtils
Преобразовать в строку. попробуй не использоватьrequest.getInputStream()
. Потому что, несмотря на то, что он был обернут, InputStream по-прежнему может быть прочитан только один раз, а преобразование параметров HttpMessageConverter требует вызова этого метода до того, как параметры войдут в метод Controller, поэтому его достаточно сохранить.
Суммировать
Когда я столкнулся с этой проблемой, я также обратился ко многим блогам, некоторые использовали ContentCachingRequestWrapper, а некоторые реализовали Wrapper самостоятельно. Но решение самостоятельной реализации Wrapper в основном состоит в том, чтобы читать потоковые данные непосредственно в данные byte[] в конструкторе Wrapper, так что при встречеmultipart/form-data
Проблема возникает с этим типом данных, поскольку оболочка выполняется до вызова MultipartResolver, и данные не считываются при повторном вызове.
Итак, блогер самостоятельно изучил исходный код Spring и реализовал эту схему, которая в принципе может обрабатывать различные общие типы данных.
использованная литература
- Как повторно использовать inputStream?
- Read stream twice
- Решите проблему, что request.getInputStream() может быть прочитан только один раз
- Http Servlet request lose params from POST body after read it once
- Вторая серия Spring Boot: картинка для понимания процесса обработки запроса
код приложения
package com.example.seed.common.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.util.ContentCachingRequestWrapper;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* @author Fururur
* @date 2020/5/6-14:26
*/
@Slf4j
@WebFilter(urlPatterns = "/*")
public class CachingContentFilter implements Filter {
private static final String FORM_CONTENT_TYPE = "multipart/form-data";
@Override
public void init(FilterConfig filterConfig) {
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
String contentType = request.getContentType();
if (request instanceof HttpServletRequest) {
HttpServletRequest requestWrapper = new ContentCachingRequestWrapper((HttpServletRequest) request);
if (contentType != null && contentType.contains(FORM_CONTENT_TYPE)) {
chain.doFilter(request, response);
} else {
chain.doFilter(requestWrapper, response);
}
return;
}
chain.doFilter(request, response);
}
@Override
public void destroy() {
}
}
package com.example.seed;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.ServletComponentScan;
@ServletComponentScan
@SpringBootApplication
public class SeedApplication {
public static void main(String[] args) {
SpringApplication.run(SeedApplication.class, args);
}
}
@RequestMapping("/query")
public void query(HttpServletRequest request) {
ContentCachingRequestWrapper wrapper = new ContentCachingRequestWrapper(request);
log.info("{}", new String(wrapper.getContentAsByteArray()));
}