简介:这份PDF资料聚焦Java Web开发中一个高频却易踩坑的技术点:如何通过HttpServletRequest读取POST请求body中的原始内容。面向已掌握Servlet基础、需要处理JSON报文或非表单提交数据的Java后端开发者,帮助解决body无参数名、无法用getParameter()获取的难题。资源包共1个PDF文件,约38KB,内容以代码示例与注意事项讲解为主,篇幅精炼、便于快速查阅。目前已有34465人学习下载,说明该问题在实际开发中颇具普遍性。资料给出了基于getInputStream()配合BufferedReader与IOUtils.read()读取body的完整写法,并重点提示两个易错点:一是必须在调用getParameter()之前读取body,否则流已失效只能拿到空字符串;二是需注意body与URL参数的读取顺序,避免QueryString解析异常。读者可据此直接套用到Servlet业务代码中,将客户端提交的JSON数据取出并解析为Java对象,用于后续业务处理。
1. 从一次线上事故说起:POST body 为什么读一次就没了
有个场景很多 Java 后端都遇到过:接口里用HttpServletRequest读了一次 POST body,日志里打印出来了,结果后面@RequestBody反序列化直接报Required request body is missing。更玄学的是,本地 Postman 发请求一切正常,一上生产就翻车。问题不在 Spring,也不在 Postman,而在于HttpServletRequest的 body 本质上是一个只能顺序读一次的输入流。
这篇讲的就是「java 通过 HttpServletRequest 获取 post 请求中的 body 内容的方法」这件事本身:body 到底藏在哪、怎么读、读完为什么不能再用、怎么读才能既拿到内容又不破坏后续流程。它适合正在写过滤器、拦截器、签名验签、审计日志、接口自动化测试框架的 Java 工程师,也适合被@RequestBody和getInputStream冲突折磨过的人。核心结论先放这:body 只能读一次,想重复读就必须缓存,所有方法都是围绕这句话展开的。
2. 先搞清楚 body 在哪:InputStream、Reader 与编码的三角关系
2.1 getInputStream 和 getReader 为什么不能同时用
Servlet 规范里,HttpServletRequest提供两个读取 body 的入口:getInputStream()返回ServletInputStream(字节流),getReader()返回BufferedReader(字符流)。规范明确要求二者互斥,调用了其中一个,另一个再调用就会抛IllegalStateException。这不是实现偷懒,而是因为底层是同一个 socket 输入流,包一层 Reader 之后字节已经被消费掉了。
所以选哪个取决于你要什么。要拿原始字节做签名、验签、算 MD5,用getInputStream;要拿字符串做 JSON 解析、日志打印,用getReader更省事。但只要你打算「读完还要给后面用」,两个都不能直接用,必须先缓存。
// 错误示范:直接读,读完 body 就没了 protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { BufferedReader reader = req.getReader(); StringBuilder sb = new StringBuilder(); String line; while ((line = reader.readLine()) != null) { sb.append(line); } System.out.println("body = " + sb); // 这里能打印 // 后面 Spring 再想读 body,流已到末尾,拿到空字符串 }这段代码逻辑上没错,问题在于readLine()把流读到了 EOF。参数说明:readLine()按行读,遇到\n或\r\n结束,返回 null 表示流结束。它会把换行符吃掉,所以拼接出来的字符串和原始 body 在换行上可能有差异,做签名时这点很致命。
2.2 编码这件事,别等乱码了才想起来
getReader()用的字符集来自请求头Content-Type里的charset,没写就默认 ISO-8859-1。中文场景下如果客户端发的是 UTF-8 但没声明 charset,读出来就是乱码。稳妥做法是读字节流后自己指定编码:
// 用字节流读,自己控制编码,避免依赖请求头 ServletInputStream in = req.getInputStream(); ByteArrayOutputStream bos = new ByteArrayOutputStream(); byte[] buf = new byte[1024]; int len; while ((len = in.read(buf)) != -1) { bos.write(buf, 0, len); } String body = bos.toString("UTF-8"); // 显式指定 UTF-8参数说明:缓冲区buf大小 1024 是常见折中,body 大可以调到 8192;bos.toString("UTF-8")显式指定编码,不依赖请求头。注意ByteArrayOutputStream会把整个 body 放内存,body 特别大(比如上传文件)时不要用这套,那是multipart的领域,得走getPart。
3. 可重复读的核心:用 HttpServletRequestWrapper 缓存 body
3.1 为什么必须包一层 Wrapper
直接读会消费流,那能不能读完再「塞回去」?Servlet 的HttpServletRequest没有提供 setInputStream 之类的接口,你没法把流写回原对象。标准解法是装饰器模式:写一个HttpServletRequestWrapper子类,在构造时把 body 读出来存成字节数组,然后重写getInputStream()和getReader(),让它们每次都从缓存数组里新建流返回。这样无论后面读多少次,拿到的都是同一份内容。
这是所有「body 重复读」方案的公共底座,过滤器、拦截器、AOP 都建立在它之上。
public class BodyCachingRequestWrapper extends HttpServletRequestWrapper { private final byte[] body; // 缓存 body 字节 public BodyCachingRequestWrapper(HttpServletRequest request) throws IOException { super(request); // 构造时一次性读完并缓存 this.body = readBytes(request.getInputStream()); } private static byte[] readBytes(ServletInputStream in) throws IOException { ByteArrayOutputStream bos = new ByteArrayOutputStream(); byte[] buf = new byte[8192]; int len; while ((len = in.read(buf)) != -1) { bos.write(buf, 0, len); } return bos.toByteArray(); } @Override public ServletInputStream getInputStream() { // 每次返回一个基于缓存数组的新流 ByteArrayInputStream bais = new ByteArrayInputStream(body); return new ServletInputStream() { @Override public int read() { return bais.read(); } @Override public boolean isFinished() { return bais.available() == 0; } @Override public boolean isReady() { return true; } @Override public void setReadListener(ReadListener listener) { // 同步读取场景不需要异步回调 } }; } @Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } public byte[] getBody() { return body; } }逻辑说明:构造方法里把原始流读干,存进body数组。getInputStream()每次调用都基于body新建一个ByteArrayInputStream,所以可以无限次读。getReader()复用getInputStream(),保证两个入口看到的是同一份数据。参数说明:缓冲区 8192 适合大多数 JSON 请求;isFinished()用available() == 0判断,isReady()恒为 true 因为是内存流。
3.2 在过滤器里替换请求对象
Wrapper 写好了,得在请求进入业务代码之前把原始 request 换掉。标准位置是Filter,因为它在 DispatcherServlet 之前执行。
@Component @Order(Ordered.HIGHEST_PRECEDENCE) // 尽量靠前,保证后续都能拿到缓存 public class BodyCacheFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; // 只对需要 body 的请求包装,GET 等无 body 请求可跳过 if ("POST".equalsIgnoreCase(req.getMethod()) || "PUT".equalsIgnoreCase(req.getMethod()) || "PATCH".equalsIgnoreCase(req.getMethod())) { BodyCachingRequestWrapper wrapper = new BodyCachingRequestWrapper(req); chain.doFilter(wrapper, response); // 传包装后的对象 } else { chain.doFilter(request, response); } } }逻辑说明:@Order(HIGHEST_PRECEDENCE)保证这个过滤器最先执行,后面所有过滤器、拦截器、Controller 拿到的都是包装后的 request。参数说明:只对 POST/PUT/PATCH 包装,避免无谓的内存开销;chain.doFilter(wrapper, response)是关键,传的是 wrapper 不是原始 req,否则包装白做。
提示:如果你的项目里已经有其他过滤器也读了 body,要确认它们的执行顺序,包装过滤器必须在最前面,否则别人先读干了,你缓存到的是空。
4. 三种落地姿势:过滤器、拦截器、AOP 怎么选
4.1 过滤器方案:最通用,但要注意 multipart
过滤器方案就是上面那套,优点是位置最靠前、覆盖面最广,无论后面是 Spring MVC 还是裸 Servlet 都能用。坑在于multipart/form-data请求:文件上传时 body 是二进制大块,全读进内存会 OOM。判断方式是看Content-Type是否以multipart/开头,是就跳过包装。
String contentType = req.getContentType(); if (contentType != null && contentType.startsWith("multipart/")) { chain.doFilter(request, response); // 文件上传不缓存,交给 getPart 处理 return; }参数说明:getContentType()返回完整 Content-Type,可能带 boundary,用startsWith判断前缀即可。这一步不做,上传大文件时内存直接爆。
4.2 拦截器方案:能拿到 Handler,但拿不到 body 的时机更晚
HandlerInterceptor的preHandle里也能拿到 request,但它执行时 DispatcherServlet 已经做了一部分处理。如果你只是想记录日志,拦截器够用;但如果你想在参数绑定之前改 body,拦截器就晚了。而且拦截器拿到的 request 如果没被包装过,读一次照样消费掉。所以拦截器方案通常也要配合 Wrapper,只是替换时机在preHandle里手动做,不如过滤器干净。
4.3 AOP 方案:适合只读日志,不适合改 body
用@Around切 Controller 方法,从方法参数里拿@RequestBody对象,或者从RequestContextHolder拿 request。优点是能直接拿到反序列化后的对象,做审计日志很方便;缺点是它发生在参数绑定之后,此时 body 已经被 Spring 读过了,你再去getInputStream只会拿到空。所以 AOP 适合「读对象」,不适合「读原始 body」。
| 方案 | 执行时机 | 能否读原始 body | 适合场景 |
|---|---|---|---|
| Filter + Wrapper | DispatcherServlet 之前 | 能,且可重复读 | 签名验签、全局日志、改 body |
| Interceptor | Handler 映射之后 | 需自行包装 | 权限校验、简单日志 |
| AOP | 方法调用时 | 不能(已被消费) | 基于对象的审计 |
选型结论:只要涉及「原始 body 且要重复读」,一律用 Filter + Wrapper,别在拦截器和 AOP 上绕。
5. 避坑与排查:body 读取的 5 个血泪现场
5.1 现象:@RequestBody 报 Required request body is missing
原因:某个过滤器或拦截器先调用了getInputStream/getReader把流读干了,Spring 再读时流已到末尾。解决:确认所有读 body 的地方都基于 Wrapper,且 Wrapper 过滤器@Order最靠前。排查手段是在 Wrapper 构造里打一行日志,看它缓存到的字节数是不是 0。
5.2 现象:中文 body 读出来是乱码
原因:用了getReader()但请求头没带 charset,默认按 ISO-8859-1 解码。解决:改用字节流读,new String(bytes, StandardCharsets.UTF_8)显式指定编码,别依赖请求头。
5.3 现象:上传文件时服务 OOM
原因:Wrapper 把 multipart 的二进制 body 全读进内存。解决:在过滤器里判断Content-Type前缀,multipart/直接放行不包装,文件走getPart。
5.4 现象:签名验签偶尔失败,重发又好了
原因:readLine()拼接会吃掉换行符,或者多次读取时流位置不一致。解决:统一用字节数组缓存,签名基于原始字节算,不要基于readLine拼出来的字符串。
5.5 现象:包装后 body 能读了,但 getParameter 拿不到表单参数
原因:application/x-www-form-urlencoded的 body 被 Servlet 容器解析成 parameter 后,原始流已经被消费,Wrapper 缓存到的是空。解决:这类请求不要走 body 缓存,直接用getParameter;或者接受「表单参数和原始 body 二选一」。
6. 进阶技巧:把 body 缓存做成可开关、可限流的基础设施
前面讲的 Wrapper 是能跑,但直接上生产还有两个问题:一是所有 POST 都缓存,内存压力大;二是 body 可能很大,得有个上限。我一般会把它做成可配置的组件,用配置项控制开关和最大缓存字节数。
@Component @ConfigurationProperties(prefix = "body.cache") public class BodyCacheProperties { private boolean enabled = true; // 总开关 private int maxBytes = 1024 * 1024; // 单请求最大缓存 1MB // getter/setter 省略 }然后在 Wrapper 里加长度校验:
private static byte[] readBytes(ServletInputStream in, int maxBytes) throws IOException { ByteArrayOutputStream bos = new ByteArrayOutputStream(); byte[] buf = new byte[8192]; int len; int total = 0; while ((len = in.read(buf)) != -1) { total += len; if (total > maxBytes) { throw new IOException("request body exceeds limit: " + maxBytes); } bos.write(buf, 0, len); } return bos.toByteArray(); }参数说明:maxBytes默认 1MB,超过就抛异常,避免恶意大 body 打爆内存。这个值要按业务调,接口传大 JSON 的可以放宽到 5MB,但别不设上限。
验证方法很简单:写个测试接口,用 Postman 发一个带中文的 JSON,在过滤器里打印缓存字节数,在 Controller 里用@RequestBody接收,两边都能拿到内容就说明通了。再发一个超过maxBytes的 body,确认抛的是你定义的异常而不是 OOM。
一个具体技巧:如果你只想在特定接口读 body(比如只有支付回调需要验签),别全局包装,用OncePerRequestFilter加 URL 匹配,只对白名单路径包装,其余放行。这样既省内存,又不会影响其他接口。
我自己踩过最深的一次坑,是早期图省事在拦截器里读 body 打日志,结果上线后所有 POST 接口集体报 body missing,回滚才恢复。从那以后我定了个习惯:任何要读 body 的代码,先问一句「读完后面还要不要用」,要用就一律走 Wrapper,绝不裸读。这个习惯帮我省了至少三次线上事故。希望帮到你。
本文还有配套的精品资源,点击获取