Решить неповторяемое чтение потоковых данных HttpServletRequest

Spring Boot
Решить неповторяемое чтение потоковых данных HttpServletRequest

предисловие

В некоторых компаниях может потребоваться многократное чтение параметров в 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, PUTmultipart/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, PUTapplication/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()Вышеупомянутое исключение будет выбрано. Конкретные проблемы можно разбить на множество ситуаций:

  1. Метод запросаmultipart/form-data, вызываемый вручную в перехватчикеrequest.getInputStream()
// 上文讲了在 doDispatch() 时就会进行处理,因此这里会取不到值
log.info("input stream content: {}", new String(StreamUtils.copyToByteArray(request.getInputStream())));
  1. Метод запроса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"));
  1. Метод запроса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();

подсказки:

  1. Здесь нам нужно сделать различие в соответствии с contentType, встретитьmultipart/form-dataПри использовании данных обертка не нужна, а параметры будут напрямую инкапсулированы в Map через MultipartResolver.Конечно, об этом также можно гибко судить в перехватчике.
  2. обертка В конкретном случае мы можем использоватьgetContentAsByteArray()чтобы получить данные и пройтиIOUtilsПреобразовать в строку. попробуй не использоватьrequest.getInputStream(). Потому что, несмотря на то, что он был обернут, InputStream по-прежнему может быть прочитан только один раз, а преобразование параметров HttpMessageConverter требует вызова этого метода до того, как параметры войдут в метод Controller, поэтому его достаточно сохранить.

Суммировать

Когда я столкнулся с этой проблемой, я также обратился ко многим блогам, некоторые использовали ContentCachingRequestWrapper, а некоторые реализовали Wrapper самостоятельно. Но решение самостоятельной реализации Wrapper в основном состоит в том, чтобы читать потоковые данные непосредственно в данные byte[] в конструкторе Wrapper, так что при встречеmultipart/form-dataПроблема возникает с этим типом данных, поскольку оболочка выполняется до вызова MultipartResolver, и данные не считываются при повторном вызове.

Итак, блогер самостоятельно изучил исходный код Spring и реализовал эту схему, которая в принципе может обрабатывать различные общие типы данных.

использованная литература

код приложения

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()));
}