1. 为什么在注解满天飞的年代还要直接用 HttpServletRequest
先说说我自己的经历。前几年接手过一个老项目,用的是早期版本的 Spring MVC,Controller 里到处是HttpServletRequest request作为方法入参,配合request.getParameter("xxx")手动取值。当时觉得这写法又土又啰嗦,后来在新项目里用惯了@RequestParam、@PathVariable、@RequestBody,就更不想碰它了。直到有一次对接一个第三方回调接口,对方把参数放在了一个自定义的 HTTP 头里面,同时还要求记录完整的原始请求体用于签名校验,我才发现那些注解在这时候帮不上什么忙,最后还是得老老实实把HttpServletRequest拿出来。
这个接口的场景很典型:URL 上带着查询参数,请求体是一段 JSON,但真正用于验签的数据是 Header 里的一个加密串加原始 body 的拼接结果。用@RequestBody接收 JSON 没问题,但拿不到原始字符串,因为框架已经把流里的数据反序列化成对象了,再想读一次输入流就会报错。这时候直接操作HttpServletRequest才是唯一靠谱的路径。也是从那次之后,我开始系统地把HttpServletRequest的常见用法整理了一遍,发现它在实际项目中能做的事情远比想象中多。
这篇文章就把我整理的这些用法分享出来。内容围绕 Java Web 开发中最常用的几个场景:从请求行和 Header 里取参数、从请求体里读 JSON 或表单、处理文件上传、拿会话和客户端信息,以及我在真实项目中踩过的坑。无论你是刚接触 Servlet 的新手,还是天天写接口但很少直接碰HttpServletRequest的老手,这都应该对你有用。
2. 请求行和 Header 里的数据:从 URL 参数到自定义头部取值
HttpServletRequest最基础的职责,就是把客户端发来的 HTTP 请求包了一层,让你能方便地取出请求行和 Header 上的信息。我先说请求行,也就是 URL 那一行。一个典型的请求长这样:
GET /api/order/1001?source=app&debug=true HTTP/1.1 Host: api.example.com User-Agent: Mozilla/5.0 X-Request-Id: 7f3a2b91-c4d8-4e11-9a3f-001请求行里包含方法、路径和查询字符串三部分。HttpServletRequest分别对应getMethod()、getRequestURI()、getQueryString()。但实际开发中,最常用的是通过getParameter()系列方法获取查询参数的值。
2.1 getParameter 和 getParameterValues 的区别
getParameter("source")返回的是 String 类型,拿到 URL 上的source=app就是app。但有一个场景很容易被忽略:当同一个参数名出现多次时,比如?tag=java&tag=http,getParameter("tag")只返回第一个值java,要拿到全部值得用getParameterValues("tag"),它返回String[]。
这个差异在处理复选框、多选下拉框时非常关键。我见过不少初学者在做批量删除接口时,前端传了id=1&id=2&id=3,后端却只拿到了1,排错排了半天,最后才发现是用了getParameter而不是getParameterValues。如果你不确定参数可能传几个值,直接写String[] ids = request.getParameterValues("id")是最稳妥的。
2.2 参数不存在与空字符串的区分
getParameter还有一个容易让人困惑的点:如果参数不存在,它返回null;如果参数存在但没赋值,比如?name=,它返回空字符串""。这两个状态在接口逻辑里往往代表不同的含义。举个例子,一个搜索接口?keyword=表示用户明确搜了空字符串,可能应该返回空结果或提示输入关键词;而?page=2里根本没有keyword这个参数,可能就应该走默认逻辑。如果只写String keyword = request.getParameter("keyword")然后拼 SQL,这两种情况都会被if (keyword == null || keyword.isEmpty())拦截下来,但实际的业务意图可能完全不同。
我的习惯是先用getParameterMap()看一眼参数是否真的存在。getParameterMap()返回Map<String, String[]>,可以一次性拿到全部参数。判断某个参数是否存在,最准确的方式不是getParameter("x") != null,而是request.getParameterMap().containsKey("x")。因为参数存在但值缺失时,getParameter返回空字符串也是非 null 的,用 containsKey 才能区分"没传"和"传了空值"。
2.3 Header 参数的读取
Header 的读取方式也很直接。getHeader("X-Request-Id")返回单个字符串值,getHeaders("X-Forwarded-For")返回Enumeration<String>,适合同一个头名出现多次的情况,比如代理服务器会叠加X-Forwarded-For链。
说说我实际项目里最常用的几个 Header 场景:
- 自定义请求 ID:前后端联调时,前端把一次请求的唯一 ID 放在
X-Request-Id里,后端从 Header 取出后写入日志和响应头,排查问题的时候直接按 ID 串起整个调用链路,省了老大的劲。 - 鉴权信息:Token 一般放在
Authorization头里,格式通常是Bearer eyJhbGci...,取出后需要去掉前缀再解析,参考代码:
String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); // 校验 token }- 客户端类型识别:有个需求要根据请求来源返回不同格式的数据,比如 App 端返回精简字段,Web 端返回完整字段。这时通过
User-Agent或自定义的X-Client-Type头来判断,比在 URL 上挂?client=app优雅得多。因为 Header 不会出现在浏览器历史、代理日志的 URL 部分,相对更隐蔽,也便于客户端统一维护。
Header 的一个坑是:某些头会被代理覆盖或剥离。比如X-Forwarded-For,如果你们前面有三层 Nginx,第一层没配置透传,后端拿到的就可能是内网 IP。这类问题最好在联调环境就确认清楚,别等上线后才发现 IP 全是 127.0.0.1。
3. 请求体里的秘密:表单参数、JSON 原始串和处理顺序
请求体是 POST、PUT 接口的主要数据来源。很多人以为HttpServletRequest只能读表单参数,其实不是。请求体本质上就是一个输入流,无论里面是 JSON、XML 还是纯文本,你都可以通过getInputStream()按字节读出来,关键在于请求的Content-Type决定了容器怎么解析它。
3.1 application/x-www-form-urlencoded 表单
当浏览器或客户端提交 HTML 表单,或者用axios的默认方式发 POST 时,Content-Type通常是application/x-www-form-urlencoded,body 的内容是name=zhangsan&age=25这种键值对拼接。Servlet 容器在收到这种请求后,会帮你把 body 解析好,所以你依然可以用request.getParameter("name")直接取值。
这里有一个很容易踩的坑:一旦以表单方式提交,getParameter 能读到 body 里的参数,但如果你改用getInputStream()去读,会发现流已经读过了(容器解析表单时会消耗掉 body 内容),或者读到的是空或已关闭的流。所以对表单请求,老老实实用getParameter就行,别想着再读原始 body。
3.2 application/json 请求体:getInputStream 的正确姿势
对 JSON 请求,getParameter是永远拿不到 body 里的数据的,因为容器不解析 JSON 为参数表。必须从输入流中读取。标准操作是:
BufferedReader reader = request.getReader(); StringBuilder sb = new StringBuilder(); char[] buf = new char[1024]; int len; while ((len = reader.read(buf)) != -1) { sb.append(buf, 0, len); } String jsonBody = sb.toString();拿到jsonBody后再用 Jackson 或 Gson 反序列化成对象。这段代码在绝大多数场景下都能正常工作,但有一个重要的细节:请求体只能读一次。输入流一旦读完,再用request.getReader()读取,会拿到空串,因为流已经到达末尾且容器不会重新创建它。这在使用 Spring MVC 的@RequestBody时没问题,因为框架帮你读了;但如果你自己读了一遍,又在后面的拦截器里想用它做参数校验,就会遇到读不到的问题。
我建议封装一个工具方法,把读取 body 的逻辑放在最前面统一处理,后续需要的模块都从缓存里拿,别重复读流。下面是我项目里用的方案:
public class RequestBodyCache { public static String getBody(HttpServletRequest request) throws IOException { // 先从 request attribute 里查缓存 Object cached = request.getAttribute("CACHED_BODY"); if (cached != null) { return cached.toString(); } String body = readBody(request); request.setAttribute("CACHED_BODY", body); return body; } private static String readBody(HttpServletRequest request) throws IOException { BufferedReader reader = request.getReader(); StringBuilder sb = new StringBuilder(); char[] buf = new char[1024]; int len; while ((len = reader.read(buf)) != -1) { sb.append(buf, 0, len); } return sb.toString(); } }把这个工具放在过滤器或拦截器入口调用,后面再有人要读 body,直接从 attribute 里拿。这个方法我用了很久,稳定性很高。
3.3 读取顺序:先读流再做其他判断
还有一个顺序问题值得强调。如果你打算在过滤器里对请求体做统一的日志记录或签名校验,一定要让读取流的操作发生在 Controller 处理之前,并且读完后把数据缓存。因为 Controller 一旦通过@RequestBody把流消费掉了,过滤器里再想读就晚了。反过来说,如果过滤器先读了流,但没有缓存,Controller 里的@RequestBody就会因为流已经耗尽而解析失败,报java.io.IOException: Stream closed或拿到 null。
正确做法是:要么用ContentCachingRequestWrapper(Spring 提供的包装类)包装一下 request,让流的读取可以被重复进行;要么就用上面说的 attribute 缓存方案。下面是一个用包装类的过滤器示例:
@Component public class CachingRequestBodyFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request); chain.doFilter(wrappedRequest, response); // 获取已读内容 byte[] body = wrappedRequest.getContentAsByteArray(); String content = new String(body, request.getCharacterEncoding()); // 日志记录 log.info("request body: {}", content); } }注意ContentCachingRequestWrapper也有一个限制:它缓存的是getInputStream读取过的内容,如果后续代码根本没有读取流,那缓存可能为空。所以拿到包装类之后,最好从getContentAsByteArray()读取时确认是否还需要主动触发一次流读取。我在实际使用中,倾向把wrappedRequest.getInputStream().readAllBytes()先调用一遍,让缓存区有机会填充。
4. 文件上传:getPart 和多文件处理的完整链路
文件上传是HttpServletRequest里比较特别的一块。它依赖multipart/form-data这种 Content-Type,请求的 body 结构不再是单纯键值对,而是由一段段 boundary 分隔开的分段数据,每一段可以是一个普通表单字段,也可以是一个文件。
在 Servlet 3.0 以前,处理文件上传要引入第三方库(比如 commons-fileupload)手动解析multipart请求,比较麻烦。Servlet 3.0 起,标准 API 提供了request.getPart("file")和request.getParts(),配合容器的配置就能直接拿到文件部分。
4.1 单文件上传的取值流程
假设前端用的是<input type="file" name="file">,后端代码大致是这样:
Part filePart = request.getPart("file"); String fileName = filePart.getSubmittedFileName(); long size = filePart.getSize(); InputStream in = filePart.getInputStream(); // 保存或处理文件getSubmittedFileName()返回客户端提交的原始文件名。这里有个细节:不同浏览器、不同前端库对文件名处理方式不同,有的会带上路径前缀(比如 IE 老版本会传C:\fakepath\test.txt),有的则只传文件名。我习惯在拿到的 fileName 里做一层清理,截取出真正的文件名部分:
String rawFileName = filePart.getSubmittedFileName(); String cleanName = rawFileName != null ? rawFileName.substring(rawFileName.lastIndexOf("\\") + 1) : "unnamed";4.2 普通表单字段和文件混在一起时
multipart/form-data请求里,文件上传往往还伴随着其他业务字段,比如上传图片时附带一个description描述字段。取出这些普通字段的方式有两种:一是表单字段在 multipart 结构里也是以 Part 形式存在,可以遍历getParts()判断getContentType() == null(普通字段没有 Content-Type)来区分;二是直接在容器解析后,用request.getParameter("description")来拿。
这里建议优先使用request.getParameter()拿普通字段,因为容器已经帮你解析好了,代码简洁。但有一个小坑:如果你已经通过getParts()遍历过一次请求,部分容器实现会消耗掉输入流,再调getParameter可能拿不到值。稳妥的顺序是先取普通字段参数,再取文件 Part;或者反过来,在拿到所有需要的值之后再做进一步处理,避免交叉读取。
4.3 多文件上传的迭代
多文件上传就是前端把多个文件都用同一个字段名files提交,后端遍历:
Collection<Part> parts = request.getParts(); for (Part part : parts) { if (part.getContentType() != null) { // 这是一个文件 String name = part.getName(); String fileName = part.getSubmittedFileName(); long size = part.getSize(); // 处理 } }part.getName()返回的是表单字段名,part.getSubmittedFileName()返回的是文件名。这两者经常被混淆,我之前就差点把name当文件名存进数据库,存出来的全是files,排查半天才意识到。
4.4 上传大小限制的坑
文件上传遇到最多的错误是MaxUploadSizeExceededException或请求直接被容器拒绝。如果你用的是 Spring Boot,默认单文件大小限制是 1MB,总请求大小限制是 10MB,可通过spring.servlet.multipart.max-file-size和spring.servlet.multipart.max-request-size调整。但如果你是传统 Servlet 项目,在标准 Tomcat 下,限制由maxSwallowSize和连接器的maxPostSize控制,超过限制时请求可能会被直接丢弃或抛出异常,而不像 Spring Boot 那样能捕获到异常并返回友好提示。
我遇到过的情况是:上传一个 12MB 的压缩包,接口返回 500,Tomcat 日志里有一句"the request or response is too large"。当时没经验,以为是程序 bug,查了半天才发现是 Tomcat 的maxPostSize默认只有 2MB,超出后根本没走到 Controller 就断了。改成 -1(表示不限制)或适当调大即可解决。
5. 请求的"周边信息":路径、IP、会话与 Cookie
除了参数和文件,HttpServletRequest还承载了大量和请求相关的上下文信息。这些信息在日志追踪、安全校验、业务逻辑里都很有用,但很多人没有系统整理过,我在这里列一下实际项目中最常用的几个。
5.1 请求路径成分的区分
初学者最容易搞混的是这几个方法:
| 方法 | 示例 | 说明 |
|---|---|---|
getRequestURI() | /app/api/order/1001 | 不包括协议和域名,包含上下文路径 |
getRequestURL() | http://api.example.com/app/api/order/1001 | 完整 URL,包含协议、域名、端口和路径 |
getContextPath() | /app | 应用上下文路径,常用来拼静态资源路径 |
getServletPath() | /api/order/1001 | 去掉上下文路径后剩余的路径 |
getPathInfo() | null(取决于映射方式) | 在 Servlet 匹配中额外附加的路径信息 |
实际业务中,解析路径时我一般这样用:想看"当前访问的是哪个接口",用getRequestURI()去掉getContextPath()的部分;想生成绝对链接(比如回调地址)时,用getRequestURL()拼上参数;想判断请求属于哪个模块时,可以用getServletPath()更干净。
5.2 获取客户端 IP 的完整姿势
取客户端 IP 看起来简单,request.getRemoteAddr()一行就搞定了。但在真实网络环境中,客户端和你的服务之间往往隔了 Nginx、F5、SLB 等代理,getRemoteAddr()拿到的可能是最后一跳代理的 IP,不是真实客户端 IP。这种情况下要从X-Forwarded-For或X-Real-IP头里取。
X-Forwarded-For的格式是:client, proxy1, proxy2,从左到右依次为客户端 IP 和各级代理 IP。比较稳妥的取法是取第一个逗号前的部分,但要注意:这个头是可伪造的。如果应用直接暴露在公网,攻击者可以随便伪造这个头。所以要取 IP 做安全校验(比如 IP 白名单)时,不能完全信任X-Forwarded-For,得结合代理配置来过滤可信来源。
我项目里的取 IP 工具方法大致长这样:
public static String getClientIp(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("X-Real-IP"); } if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } // 多级代理时取第一个 IP if (ip != null && ip.contains(",")) { ip = ip.split(",")[0].trim(); } return ip; }这个逻辑不算最优,但在大多数中小项目里够用了。更严谨的做法是配置一个代理信任列表,只从可信代理的转发头中取值。
5.3 会话、Cookie 和请求方法
getSession()是另一个高频用法。需要特别注意的是:getSession()会创建新会话(如果当前没有),而getSession(false)仅在已有会话时返回,不会新建。在需要鉴权的接口里,我通常用getSession(false),避免恶意请求被强制创建大量无效会话,浪费服务端内存。
Cookie 的读取相对简单:
Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { if ("SESSION_ID".equals(cookie.getName())) { // 处理 } } }getMethod()用于区分 GET/POST/PUT/DELETE,在做统一拦截校验时很常用。比如某些接口只允许 POST,可以直接在过滤器里判断。getProtocol()可以拿到 HTTP 版本,偶尔排查性能问题时会用到,比如发现线上请求全是HTTP/1.0,那可能是代理或客户端配置有问题,而不是服务端慢。
6. 我踩过的那些深坑:读流顺序、编码、空指针
这一节我想集中说几个真实工作中的教训。它们都不是什么难懂的大道理,但每个都让我至少排查了几个小时。
6.1 getInputStream 和 getParameter 不能混用的真正原因
Servlet 规范里,如果请求的 Content-Type 是application/x-www-form-urlencoded,并且请求方法是 POST,那么容器会解析 body 生成参数表,getParameter可从其中取值。但与此同时,getInputStream的流可能已经被消耗掉。这意味着:
- 对表单请求,先调
getParameter后调getInputStream读 body,有可能读到空流。 - 对 JSON 请求,先调
getInputStream读 body 后调getParameter,getParameter返回的值不受影响,因为浏览器/容器不会从 JSON 里生成参数表,它内部一定是一个空参数表,流读取不影响空表。
所以,接口里到底用哪种参数读取方式,要在代码设计阶段就确定下来。同一个接口既想用@RequestBody拿 JSON 体,又想用@RequestParam拿 URL 参数,这是允许的,因为 URL 参数和 body 是两套数据源。但同一个 body 数据源,要么走表单解析,要么走流读取,不能在业务代码里同时操作。
6.2 字符编码问题
getParameter在解析 URL 参数和表单 body 时,默认使用容器的编码设置。Tomcat 8 及以上默认 URI 编码是 UTF-8,但表单 body 的默认编码取决于request.setCharacterEncoding()的调用时机和容器配置。
最容易出问题的场景是:客户端用application/json; charset=utf-8发送中文,服务端读取流时用默认ISO-8859-1做new String(bytes),结果中文全变成乱码。解决办法很直接,读流之前先明确编码:
String encoding = request.getCharacterEncoding(); if (encoding == null) { encoding = "UTF-8"; } byte[] bodyBytes = request.getInputStream().readAllBytes(); String body = new String(bodyBytes, encoding);还有一种情况是 Header 里的中文乱码。HTTP Header 本身不支持直接的中文,通常需要 URL 编码后传输,服务端做 URLDecoder 解码。这个细节容易被人忽略,我试过一次直接在 Header 里传中文值,部分容器或代理会直接报错或者被截断。
6.3 参数类型转换和空值判断
getParameter返回的永远是字符串,在转成数字、布尔值等类型时要注意:参数传了非法值(比如age=abc),Integer.parseInt会直接抛NumberFormatException;参数传了空字符串"",Integer.parseInt("")也会抛异常。我的处理办法是封装一个安全转换的方法:
public static Integer getIntParameter(HttpServletRequest request, String name) { String value = request.getParameter(name); if (value == null || value.isEmpty()) { return null; } try { return Integer.parseInt(value); } catch (NumberFormatException e) { return null; } }接口对外尽可能宽容处理。参数格式不对时,返回一个明确的错误提示比直接抛 500 好太多,尤其是对外提供的 API,调用方五花八门,什么参数都可能给你传过来。
6.4 重复读取 body 的后果与缓存方案
再强调一次,请求体只能读一次这个事实。我在做接口签名校验时栽过一个大跟头。当时设计了一个拦截器,要在业务 Controller 执行前读取请求体,和签名串做比对。第一版代码直接在拦截器里调了request.getReader(),读完后签名校验通过,但进入 Controller 后@RequestBody怎么都拿不到对象,一直报 400。排查了整整一个下午,从 Controller 写错了查到参数类型不匹配,最后才意识到是拦截器把流读光了。
这个问题的本质是:Servlet 的ServletRequest.getInputStream()返回的流是一次性的,底层就是 socket 的输入流,读完了就没了。容器虽然能多次调用getInputStream(),但每次都返回同一个流对象,流位置已经在末尾。
解决方案也不复杂,就是我前面提到的两种:用ContentCachingRequestWrapper,或者自己把 body 缓存到 attribute 里。如果项目用了 Spring,第一种更省事;如果不用 Spring,第二种更直观。核心思路都是让 body 内容在整个请求生命周期内可以被多次读取。
7. 写在最后:几个可以直接落地的经验
做 Web 开发这几年,HttpServletRequest给我的感觉是:它像一个万能的"脏盒子",你可以从里面拿出所有和 HTTP 请求有关的东西,但里面的每一样东西都有使用前提,不了解这些前提,就容易踩坑。
如果让我给读者一个落地建议清单,大概是这几条:
- 能分清"表单参数""URL 参数""JSON body"三个数据来源,不要混用读取方式。
- 凡是需要请求体内容的场景,在架构上提前设计缓存方案,别等用到时再四处救火。
- 文件上传时注意文件大小限制的配置层级,Spring Boot 的配置只管 Spring 自己的上传解析,传统容器的限制你得单独确认。
- 取客户端 IP 不要盲目信
X-Forwarded-For,要在了解自己网络链路的前提下决定信任策略。 - 所有从请求里拿到的字符串,默认按"不可信"处理,该做类型转换就做安全转换,该判空就判空。
最后分享一个小技巧。排查请求相关问题时,最快的定位方式是在过滤器里把请求的getMethod()、getRequestURI()、getContentType()、所有 Header 名,以及 body 内容打印一条日志。这样一个请求的完整面貌就出来了,参数解析不到、中文乱码、流被提前读取这类问题,往往一眼就能看出来。我现在接手任何新项目,第一件事就是先把这条请求日志加上,它能帮你在不熟悉代码的时候快速建立全局认知。