这个问题困扰过很多JavaWeb开发者。想象一下,你正在开发一个REST API,需要在日志记录、权限校验和业务处理等多个环节都读取请求体。当你第一次调用request.getInputStream()后,第二次再调用就会抛出"getInputStream() has already been called"异常。这就像一瓶矿泉水,一旦喝完就空了,想再喝只能重新买一瓶。
底层原理其实很简单:Servlet规范中的请求体是以流(Stream)的形式传输的。流的特点是单向、一次性消费。就像磁带一样,播放完就得倒带才能重新听。在Servlet的实现中,一旦流被读取,指针就移动到了末尾,无法自动重置。
我遇到过这样一个实际案例:一个电商系统的订单创建接口,需要在日志中记录完整请求体,同时业务逻辑也要解析JSON数据。最初直接在Controller和日志拦截器中分别读取请求体,结果总是报错。后来通过调试发现,日志拦截器已经读取了流,导致Controller获取到的流已经是空的了。
解决这个问题的关键在于Filter和HttpServletRequestWrapper这对黄金搭档。Filter是Servlet规范中的拦截器,可以在请求到达Servlet之前进行预处理。而HttpServletRequestWrapper是请求对象的包装类,允许我们修改请求的行为。
这个方案的巧妙之处在于:Filter拦截请求后,立即读取并缓存请求体数据,然后用自定义的Wrapper包装原始请求。后续所有操作都使用这个包装后的请求对象,它会在每次调用getInputStream()时都返回一个包含缓存数据的新流。
java复制public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
private byte[] cachedBody;
public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
super(request);
this.cachedBody = StreamUtils.copyToByteArray(request.getInputStream());
}
@Override
public ServletInputStream getInputStream() {
return new CachedBodyServletInputStream(this.cachedBody);
}
@Override
public BufferedReader getReader() {
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.cachedBody);
return new BufferedReader(new InputStreamReader(byteArrayInputStream));
}
}
让我们一步步实现这个解决方案。首先创建自定义的ServletInputStream:
java复制public class CachedBodyServletInputStream extends ServletInputStream {
private ByteArrayInputStream inputStream;
public CachedBodyServletInputStream(byte[] cachedBody) {
this.inputStream = new ByteArrayInputStream(cachedBody);
}
@Override
public boolean isFinished() {
return inputStream.available() == 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener listener) {
throw new UnsupportedOperationException();
}
@Override
public int read() throws IOException {
return inputStream.read();
}
}
然后实现Filter来包装请求:
java复制public class CachingRequestBodyFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
CachedBodyHttpServletRequest wrappedRequest = new CachedBodyHttpServletRequest(httpServletRequest);
chain.doFilter(wrappedRequest, response);
}
}
最后在Spring Boot中注册这个Filter:
java复制@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<CachingRequestBodyFilter> loggingFilter(){
FilterRegistrationBean<CachingRequestBodyFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new CachingRequestBodyFilter());
registrationBean.addUrlPatterns("/*");
return registrationBean;
}
}
虽然这个方案解决了问题,但需要考虑性能影响。大文件上传的场景下,缓存整个请求体会消耗大量内存。我有次在处理视频上传时,就遇到了内存溢出的问题。
优化建议:
修改后的Filter实现:
java复制public class CachingRequestBodyFilter implements Filter {
private static final int MAX_BODY_SIZE = 1024 * 1024; // 1MB
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
// 排除文件上传请求
if (httpRequest.getContentType() != null
&& httpRequest.getContentType().startsWith("multipart/form-data")) {
chain.doFilter(request, response);
return;
}
// 检查请求体大小
int contentLength = httpRequest.getContentLength();
if (contentLength > MAX_BODY_SIZE) {
throw new ServletException("Request body too large");
}
CachedBodyHttpServletRequest wrappedRequest = new CachedBodyHttpServletRequest(httpRequest);
chain.doFilter(wrappedRequest, response);
}
}
在实际使用中,可能会遇到一些坑。比如当同时使用Spring的@RequestBody注解和手动读取请求体时,可能会出现奇怪的行为。这是因为Spring MVC在处理@RequestBody时已经读取了流。
另一个常见问题是字符编码。如果请求的Content-Type没有明确指定字符集,不同环节可能会使用不同的默认编码。建议在Filter中统一处理:
java复制public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
private String charset = "UTF-8";
public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
super(request);
String contentType = request.getContentType();
if (contentType != null) {
// 解析Content-Type中的charset
String[] parts = contentType.split(";");
for (String part : parts) {
if (part.trim().startsWith("charset=")) {
charset = part.trim().substring(8);
}
}
}
this.cachedBody = StreamUtils.copyToByteArray(request.getInputStream());
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(
new ByteArrayInputStream(this.cachedBody), charset));
}
}
这个方案不仅能解决多次读取的问题,还能实现一些有趣的功能。比如:
这里展示一个修改请求体的例子:
java复制public class ModifiableBodyHttpServletRequest extends CachedBodyHttpServletRequest {
private byte[] modifiedBody;
public ModifiableBodyHttpServletRequest(HttpServletRequest request) throws IOException {
super(request);
this.modifiedBody = getCachedBody();
}
public void modifyBody(String newBody) {
this.modifiedBody = newBody.getBytes(getCharacterEncoding());
}
@Override
public ServletInputStream getInputStream() {
return new CachedBodyServletInputStream(this.modifiedBody);
}
}
在Filter中使用:
java复制public class ModifyRequestBodyFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
ModifiableBodyHttpServletRequest wrappedRequest =
new ModifiableBodyHttpServletRequest((HttpServletRequest) request);
// 获取原始请求体
String body = new String(wrappedRequest.getCachedBody(),
wrappedRequest.getCharacterEncoding());
// 修改请求体
String modifiedBody = body.replace("oldValue", "newValue");
wrappedRequest.modifyBody(modifiedBody);
chain.doFilter(wrappedRequest, response);
}
}
实现完这个方案后,必须进行充分测试。我建议至少覆盖以下场景:
一个简单的测试用例:
java复制@SpringBootTest
@AutoConfigureMockMvc
class MultiReadRequestTest {
@Autowired
private MockMvc mockMvc;
@Test
void testMultipleReads() throws Exception {
String json = "{\"name\":\"test\",\"value\":123}";
mockMvc.perform(post("/api/test")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isOk())
.andExpect(jsonPath("$.log").value(containsString(json)))
.andExpect(jsonPath("$.data.name").value("test"));
}
}
这个方案可以很好地与Spring生态系统中的其他组件配合使用。比如与Spring Security整合时,可以在认证过滤器中读取请求体:
java复制public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 可以安全地多次读取请求体
String requestBody = request.getReader().lines()
.collect(Collectors.joining(System.lineSeparator()));
// 解析JWT token等操作
// ...
chain.doFilter(request, response);
}
}
与日志框架整合时,可以记录完整的请求信息:
java复制public class RequestLoggingFilter implements Filter {
private static final Logger logger = LoggerFactory.getLogger(RequestLoggingFilter.class);
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
// 读取请求体不会影响后续处理
String requestBody = httpRequest.getReader().lines()
.collect(Collectors.joining());
logger.info("Request {} {} - Body: {}",
httpRequest.getMethod(),
httpRequest.getRequestURI(),
requestBody);
chain.doFilter(request, response);
}
}