1. 项目概述:为什么今天还要深挖 Servlet 的 request 和 response?
“Java基础之servlet——response&request”这个标题看起来平平无奇,像是教科书里被翻烂的章节,甚至有些刚转行的朋友会嘀咕:“现在都用 Spring Boot 了,谁还手写 doGet/doPost?这玩意儿是不是过时了?”——我第一次带某高校实训班时,也有学生当面这么问。我当时没急着回答,而是让他用 Spring Boot 写一个功能:在用户上传 Excel 文件后,服务端不仅要解析内容、校验格式,还要在响应中动态生成一张带水印的 PDF 报表,并以原始文件名(含中文)下载,同时在响应头中精确控制缓存策略、字符集、MIME 类型,且整个过程不依赖任何第三方 Web 框架的自动封装逻辑。他卡在第三步:中文文件名乱码、PDF 下载失败、Content-Disposition 头被 Tomcat 自动覆盖……最后回过头,老老实实把HttpServletRequest和HttpServletResponse的每个方法、每个 header 字段、每个编码陷阱重新捋了三遍。
这就是 request 和 response 的真实分量:它们不是历史遗迹,而是所有 Java Web 框架的底层地基。Spring MVC 的@RequestParam是对request.getParameter()的语义包装,ResponseEntity是对response.setStatus()+response.setHeader()+response.getOutputStream()的组合抽象,@RequestBody底层调用的是request.getInputStream()。你跳过这一层直接用高级 API,就像学开车只练自动挡——上路没问题,但一旦变速箱报警、ABS 失效、或者要拖车救援,你就彻底失能。
我过去三年参与过 7 个不同行业的 Web 项目(教育平台、医疗数据网关、工业设备监控后台、政务审批系统),凡是出现“文件下载乱码”“AJAX 跨域预检失败但实际请求又成功”“POST 表单提交后中文参数全变问号”“Nginx 反向代理后响应头丢失”这类问题,90% 的根因都落在 request/response 的生命周期理解偏差或细节处理疏漏上。这不是理论题,是每天都在发生的线上故障。所以这篇内容不讲“什么是 request”,而是聚焦:当你真正需要掌控 HTTP 协议层交互时,request 和 response 到底提供了哪些不可替代的能力?哪些操作看似简单却暗藏雷区?如何用最朴素的原生 API 实现生产环境级的健壮性?
适合谁读?如果你正在:
- 准备 Java 后端面试,被问到“forward 和 redirect 区别”却答不出底层 header 差异;
- 在用 Spring Boot 时发现
@RequestHeader拿不到某个自定义 header,怀疑是网关过滤掉了,但不确定从哪查起; - 开发文件导出功能,用户反馈“下载的 Excel 打不开”或“PDF 名字变成 download.pdf”;
- 遇到前后端联调时,前端说“接口返回 200 但 data 是空”,而你
System.out.println(response.getWriter())却发现输出流已被关闭; - 或者只是想确认:
request.setCharacterEncoding("UTF-8")真的能解决所有中文乱码吗?它和response.setCharacterEncoding("UTF-8")作用对象到底是谁?
那么接下来的内容,就是你调试线上问题时真正会打开的那篇笔记。
2. 核心设计思路:为什么必须从协议层理解 request/response?
2.1 不是“两个对象”,而是 HTTP 请求/响应报文的 Java 映射
很多初学者把HttpServletRequest和HttpServletResponse当成两个独立的工具类,这是根本性误解。它们本质是Servlet 容器(如 Tomcat)对标准 HTTP 报文的双向解析与构造引擎。理解这一点,才能跳出“调 API”的思维,进入“控协议”的层面。
HTTP 请求报文结构如下:
GET /api/user?id=123 HTTP/1.1 Host: example.com User-Agent: Mozilla/5.0 Accept: application/json Cookie: JSESSIONID=abc123 Content-Length: 0→ 这整段原始字节流,由 Tomcat 接收后,解析为HttpServletRequest对象的属性:
- 请求行 →
request.getMethod(),request.getRequestURI(),request.getProtocol() - 请求头 →
request.getHeader("User-Agent"),request.getCookies(),request.getContentType() - 请求体(Body)→
request.getInputStream()(二进制流)或request.getReader()(字符流) - 查询参数(Query String)→
request.getParameter("id") - 表单数据(Form Data)→ 同样走
getParameter(),但需注意编码时机
同理,HttpServletResponse不是“发个字符串”,而是按 HTTP 规范组装响应报文:
HTTP/1.1 200 OK Content-Type: text/html;charset=UTF-8 Content-Length: 137 Set-Cookie: token=xyz; Path=/; HttpOnly Date: Mon, 15 Apr 2024 08:23:45 GMT <html><body>Hello World</body></html>→ 这段报文的每一行,都对应response的一个操作:
- 状态行 →
response.setStatus(200) - 响应头 →
response.setHeader("Content-Type", "text/html;charset=UTF-8"),response.addCookie(cookie) - 响应体 →
response.getWriter().write("<html>...")或response.getOutputStream().write(bytes)
关键认知:request 是“输入解析器”,response 是“输出构造器”。它们不存储业务数据,只负责协议转换。你往response.getWriter()写的内容,最终会成为响应体的一部分;你从request.getParameter()拿到的值,是容器从原始请求中解析出的字符串。中间没有魔法,只有严格的字节流处理规则。
2.2 设计选型背后的硬约束:为什么不能跳过原生 API?
有人会问:“Spring MVC 封装得这么好,为什么还要碰原生 request/response?”答案是:封装必然带来抽象泄漏(Abstraction Leakage)。当框架的默认行为与你的需求冲突时,你必须下沉到原生层修复。
典型场景举例:
- 文件下载的 Content-Disposition 头:Spring 的
ResponseEntity<Resource>默认设置Content-Disposition: inline; filename="resource",但 IE 浏览器要求filename*(RFC 5987)才能支持中文名。你无法通过注解修改,必须用response.setHeader("Content-Disposition", "...")手动构造。 - 流式响应(Streaming):实时日志推送、大文件分块下载,需要保持连接、多次写入
OutputStream并调用response.flushBuffer()。@ResponseBody会一次性序列化整个对象,不满足流式要求。 - 跨域预检(Preflight)的精确控制:
@CrossOrigin注解默认添加Access-Control-Allow-Origin: *,但若需携带 Cookie,则必须指定具体域名且不能为*。此时需在 Filter 中用response.setHeader("Access-Control-Allow-Origin", "https://myapp.com")精确控制。 - 请求体的多次读取:
request.getInputStream()只能读一次(流关闭后不可重置)。Spring 的@RequestBody之所以能多次使用,是因为它内部用ContentCachingRequestWrapper缓存了原始字节。但如果你自己写 Filter 做日志记录,就必须手动缓存,否则后续 Controller 就读不到 Body。
这些都不是“高级功能”,而是生产环境的日常需求。框架帮你省去了 80% 的样板代码,但剩下的 20% 关键路径,恰恰决定了系统的健壮性。这也是为什么所有主流 Java Web 框架(Spring、Vert.x、Micronaut)的文档中,“Integration with Servlet API” 都是必读章节——它们的设计哲学是“拥抱 Servlet,而非替代 Servlet”。
2.3 影响范围:从单个接口到全链路可观测性
request/response 的处理质量,直接影响三个关键维度:
安全性
request.getRemoteAddr()获取客户端 IP,但经 Nginx 反代后变成127.0.0.1,需解析X-Forwarded-For头,且必须校验该头是否被恶意伪造(只取第一个可信代理的 IP)。response.addHeader("X-Content-Type-Options", "nosniff")防止 MIME 类型嗅探攻击。cookie.setHttpOnly(true)防止 XSS 窃取 Session。
兼容性
- 不同浏览器对
Content-Disposition的解析差异:Chrome 支持filename*,IE 需filename,Firefox 两者都支持。必须做 User-Agent 判断并生成兼容头。 - 移动端 WebView 对
Cache-Control的处理更严格,response.setDateHeader("Expires", ...)过期时间必须与max-age一致,否则部分安卓 WebView 会忽略缓存。
- 不同浏览器对
可观测性
- 在 Filter 中记录
request.getRequestURL()+request.getQueryString(),可还原完整请求路径,用于审计日志。 - 用
response.getStatus()和response.getContentType()统计各接口的错误率与媒体类型分布,是 APM(应用性能监控)的基础指标。 request.getContentLength()与实际读取字节数对比,可发现客户端异常中断上传。
- 在 Filter 中记录
换句话说,request/response 是 Web 应用的“神经末梢”——它感知每一个请求的细节,也决定每一个响应的形态。忽视它,等于放弃对 HTTP 协议的控制权。
3. 核心细节解析:request 与 response 的 12 个关键能力与陷阱
3.1 request.getParameter():你以为的“取参数”,其实是编码博弈
request.getParameter("username")是最常用的方法,但它的行为高度依赖两个前提:
- 请求的
Content-Type是否为application/x-www-form-urlencoded或multipart/form-data(即 HTML 表单提交); request.setCharacterEncoding()是否被正确调用。
陷阱实录:某政务系统上线后,用户反馈“姓名栏输入‘张伟’提交后变成‘å¼ ä¼Ÿ’”。排查发现,前端<form accept-charset="UTF-8">正确,但后端 Filter 中request.setCharacterEncoding("UTF-8")放在了chain.doFilter()之后!导致getParameter()调用时编码未设置,Tomcat 使用默认 ISO-8859-1 解析,中文全乱码。
原理深挖:
- 对于 GET 请求,参数在 URL 中,Tomcat 默认用
ISO-8859-1解码 URL(因为 HTTP 协议规定 URL 必须是 ASCII)。所以request.getParameter()对 GET 参数的解码流程是:URL bytes → ISO-8859-1 decode → String → new String(str.getBytes("ISO-8859-1"), "UTF-8")(伪代码)。 - 对于 POST 表单,
request.setCharacterEncoding("UTF-8")必须在getParameter()之前调用,否则容器仍用默认编码解析请求体。
解决方案:
- 统一 Filter 强制设置(推荐):
public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; // 必须在 getParameter() 前设置! request.setCharacterEncoding("UTF-8"); chain.doFilter(req, resp); } } - GET 请求专用修复(当无法改 Filter 时):
String name = request.getParameter("name"); if (name != null && !name.isEmpty()) { name = new String(name.getBytes("ISO-8859-1"), "UTF-8"); }
提示:
request.setCharacterEncoding()对getInputStream()无效,它只影响getParameter()、getPart()等基于表单解析的方法。二进制流(如文件上传)需自行处理编码。
3.2 request.getInputStream() vs request.getReader():流的选择决定生死
当处理 JSON、XML 或自定义二进制协议时,必须用getInputStream()或getReader()读取请求体。但二者互斥:调用任意一个后,另一个将抛出IllegalStateException。
为什么?因为容器内部只维护一个原始字节流(ServletInputStream),getReader()是对其的字符流包装(InputStreamReader),两者指向同一源头。尝试同时获取,就像试图用两个吸管喝同一杯水——物理上不可能。
实操选择指南:
| 场景 | 推荐方法 | 原因 |
|---|---|---|
| JSON/XML 等文本数据 | request.getReader() | 自动处理字符编码(request.getCharacterEncoding()),避免手动new String(bytes, "UTF-8") |
| 图片、PDF、Excel 等二进制文件 | request.getInputStream() | 保证字节完整性,getReader()会按字符解码,破坏二进制数据 |
| 需要同时解析 Header 和 Body 的通用网关 | request.getInputStream()+ 自行解析 | getReader()会触发容器内部缓冲,可能影响后续 Filter 对原始流的操作 |
避坑技巧:
- 若需在 Filter 中记录原始请求体(如审计日志),必须用
ContentCachingRequestWrapper包装HttpServletRequest,否则getInputStream()被读取一次后,Controller 就拿不到数据。Spring 提供了现成实现,但原生 Servlet 需自行继承HttpServletRequestWrapper并重写getInputStream()方法缓存字节。
3.3 response.getWriter() vs response.getOutputStream():字符流与字节流的战争
与 request 类似,response.getWriter()和response.getOutputStream()也互斥。但它们的冲突更隐蔽:即使你只调用其中一个,另一个在内部也可能被容器自动触发。
经典故障:某报表导出接口,代码如下:
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=test.xlsx"); // 错误:先调用 getWriter() PrintWriter writer = response.getWriter(); // 然后试图写入 Excel 二进制流 —— 这里会抛 IllegalStateException! workbook.write(response.getOutputStream()); // ❌原因分析:
response.getWriter()被调用后,容器内部已创建PrintWriter,并关联了字符编码(如 UTF-8)。- 此时再调
response.getOutputStream(),容器发现输出流已被字符流占用,直接抛异常。 - 更隐蔽的是:
response.setContentType("text/html")会隐式触发getWriter(),因为容器认为你要输出文本。
黄金法则:
- 明确响应体类型:
text/*(HTML、JSON、XML)→ 用getWriter()application/*,image/*,audio/*→ 用getOutputStream()
- 设置 Content-Type 必须在获取流之前:
// ✅ 正确顺序 response.setContentType("application/pdf"); response.setHeader("Content-Disposition", "inline; filename=report.pdf"); ServletOutputStream out = response.getOutputStream(); pdfDocument.write(out); // 写入二进制 - 绝对禁止混用:不要在同一个响应中既
writer.write("hello")又out.write(bytes)。
3.4 request.getSession():Session 的创建、获取与失效边界
request.getSession()看似简单,但其行为受request.isRequestedSessionIdValid()和request.isRequestedSessionIdFromCookie()等方法深度影响。
关键细节:
request.getSession():如果当前请求没有有效 Session ID,自动创建新 Session(发送Set-Cookie: JSESSIONID=xxx)。request.getSession(false):只获取现有 Session,不创建新 Session。返回null表示无有效 Session。request.getSession(true):等价于getSession(),显式声明创建。
生产陷阱:某电商网站做“游客浏览商品”功能,未登录用户也能看详情页。开发人员写了:
HttpSession session = request.getSession(); // ❌ 每次都创建 Session! if (session.getAttribute("user") == null) { // 游客逻辑 }结果导致:未登录用户每刷新一次页面,Tomcat 就新建一个 Session,内存暴涨,GC 频繁。正确做法是:
HttpSession session = request.getSession(false); // ✅ 只获取,不创建 User user = session != null ? (User) session.getAttribute("user") : null;Session ID 传输方式:
- Cookie(默认):
JSESSIONID存于浏览器 Cookie。 - URL 重写:当 Cookie 被禁用时,Tomcat 自动在 URL 后追加
;jsessionid=xxx(如/product?id=1;jsessionid=abc123)。 - 注意:
response.encodeURL("/product?id=1")会自动添加 jsessionid,但仅当request.isRequestedSessionIdFromCookie() == false时才生效(即检测到 Cookie 不可用)。
3.5 response.sendRedirect() vs RequestDispatcher.forward():重定向与转发的本质区别
这两个方法常被混淆,但它们在网络层和应用层有天壤之别。
| 维度 | sendRedirect() | forward() |
|---|---|---|
| HTTP 层 | 发送 302 响应,强制浏览器发起第二次请求 | 服务器内部跳转,浏览器地址栏不变,只发生一次请求 |
| Request/Response 对象 | 原 request/response 生效,新请求有全新对象 | 共享同一个 request/response 对象,可传递request.setAttribute() |
| URL 限制 | 可跳转到任意 URL(跨域、外部网站) | 只能转发到同一 Web 应用内的资源(/path或WEB-INF/下) |
| 性能 | 两次 HTTP 往返,延迟高 | 服务器内部调用,无网络开销 |
致命误区:
- 在
forward()后继续执行response.getWriter().write("hello")—— 会抛IllegalStateException,因为forward()已将响应交给目标资源,当前 Servlet 不再拥有响应控制权。 sendRedirect()后未return,导致重定向后继续执行后续代码(如数据库操作),造成逻辑错误。
正确模式:
if (user == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; // ✅ 必须 return! } // 后续业务逻辑3.6 request.getRequestDispatcher():路径解析的绝对与相对之争
request.getRequestDispatcher(String path)的path参数,其解析规则极易出错:
- 以
/开头:相对于Web 应用根目录(Context Path)。request.getRequestDispatcher("/WEB-INF/views/success.jsp")→ 解析为http://host:port/app-name/WEB-INF/views/success.jsp - 不以
/开头:相对于当前 Servlet 的路径(即request.getServletPath())。
若当前请求是/api/user/create,则request.getRequestDispatcher("error.jsp")→ 解析为/api/user/error.jsp(通常 404)
血泪教训:某项目将错误页放在/WEB-INF/jsp/error.jsp,开发人员写了:
// ❌ 错误:相对路径,实际找 /api/user/WEB-INF/jsp/error.jsp request.getRequestDispatcher("WEB-INF/jsp/error.jsp").forward(request, response); // ✅ 正确:绝对路径 request.getRequestDispatcher("/WEB-INF/jsp/error.jsp").forward(request, response);安全提示:/WEB-INF/下的资源无法被浏览器直接访问(Tomcat 默认禁止),但可通过forward()内部跳转,这是 JSP 分离视图与逻辑的安全实践。
3.7 response.addCookie():Cookie 的安全属性与路径控制
Cookie是 request/response 间最脆弱的桥梁。response.addCookie()的默认行为极不安全:
Cookie cookie = new Cookie("token", "abc123"); response.addCookie(cookie); // ❌ 危险!明文传输、无 HttpOnly、路径宽泛必须显式设置的安全属性:
cookie.setHttpOnly(true):防止 JavaScript 访问(防御 XSS)cookie.setSecure(true):仅通过 HTTPS 传输(生产环境强制)cookie.setPath("/"):设置作用路径(默认为当前 Servlet 路径,需改为/使全站有效)cookie.setMaxAge(3600):设置有效期(秒),-1表示浏览器会话结束时删除
完整安全写法:
Cookie tokenCookie = new Cookie("auth_token", jwtToken); tokenCookie.setHttpOnly(true); tokenCookie.setSecure(true); // 仅 HTTPS tokenCookie.setPath("/"); tokenCookie.setMaxAge(3600); // 1小时 tokenCookie.setDomain(".example.com"); // 跨子域共享(可选) response.addCookie(tokenCookie);注意:
setDomain()的值必须以.开头(如.example.com),且不能是localhost(浏览器安全策略限制)。
3.8 request.getRemoteAddr() 与 X-Forwarded-For:穿透代理的真实 IP
在云环境或微服务架构中,请求往往经过 Nginx、API 网关等多层代理。request.getRemoteAddr()返回的是最后一层代理的 IP(通常是127.0.0.1或内网 IP),而非用户真实 IP。
标准方案:代理服务器应在转发时添加X-Forwarded-For头,格式为:X-Forwarded-For: clientIP, proxy1IP, proxy2IP
最左边是原始客户端 IP。
安全解析代码(防伪造):
public static String getClientIP(HttpServletRequest request) { String xff = request.getHeader("X-Forwarded-For"); if (xff != null && !xff.isEmpty()) { // 取第一个 IP(最原始的客户端),并去除可能的空格 String[] ips = xff.split(","); return ips[0].trim(); } // 代理未设置 XFF 时,回退到 RemoteAddr return request.getRemoteAddr(); }但必须配合代理配置:Nginx 需添加:
location / { proxy_set_header X-Forwarded-For $remote_addr; proxy_pass http://backend; }否则X-Forwarded-For可能被客户端伪造。
3.9 response.setCharacterEncoding():它设置的是谁的编码?
response.setCharacterEncoding("UTF-8")常被误认为“设置响应内容的编码”,其实它只做一件事:告诉response.getWriter()用什么字符集将 Java 字符串转换为字节流。
关键点:
- 它不影响
response.getOutputStream()(二进制流无字符概念) - 它必须在
response.getWriter()调用之前设置,否则无效 - 它与
response.setContentType("text/html;charset=UTF-8")的charset参数是冗余的,但后者更权威(会覆盖前者)
最佳实践:
// ✅ 推荐:用 setContentType 统一设置 response.setContentType("application/json;charset=UTF-8"); // 此时 response.setCharacterEncoding("UTF-8") 可省略 PrintWriter writer = response.getWriter(); writer.write("{\"msg\":\"成功\"}"); // 自动 UTF-8 编码3.10 request.getAttribute() vs request.getParameter():数据来源的鸿沟
新手常混淆这两个方法:
request.getParameter():从HTTP 请求报文中提取数据(URL 参数、表单字段、Query String)request.getAttribute():从Servlet 容器内部传递的数据(request.setAttribute()设置,仅在本次请求生命周期内有效,forward()时可传递)
典型误用:
// 在 Servlet A 中 request.setAttribute("message", "操作成功"); request.getRequestDispatcher("/result.jsp").forward(request, response); // 在 result.jsp 中 <%= request.getParameter("message") %> <!-- ❌ 输出 null --> <%= request.getAttribute("message") %> <!-- ✅ 输出 "操作成功" -->本质区别:
- Parameter 是“客户端输入”,Attribute 是“服务器内部状态”。
- Parameter 可被 URL 直接篡改(不安全),Attribute 只存在于服务器内存(安全)。
3.11 response.reset() 与 response.resetBuffer():清空响应的两种力度
当业务逻辑中途决定放弃当前响应,需清空已写入的内容:
response.reset():重置整个响应,清除所有状态码、响应头、缓冲区内容。调用后可重新设置setStatus()、setHeader()。response.resetBuffer():仅清空响应体缓冲区,保留状态码和响应头。适用于“写了一半发现要换模板”的场景。
使用场景:
reset():权限校验失败,需重定向到登录页,但之前已写入部分 HTML(如<html><head>),此时reset()清空所有,再sendRedirect()。resetBuffer():动态生成图表,先写入 PNG 头部,后发现数据为空,需改写错误提示文本——用resetBuffer()清空 PNG 数据,再getWriter().write("No data")。
注意:
reset()后不能再调用getWriter()或getOutputStream(),否则抛IllegalStateException。
3.12 request.getPart() 与 multipart/form-data:文件上传的现代解法
Servlet 3.0+ 原生支持multipart/form-data解析,无需 Apache Commons FileUpload。
核心步骤:
- JSP/HTML 表单设置
enctype="multipart/form-data" - Servlet 中调用
request.getPart("file")获取Part对象 - 从
Part中提取文件名、输入流、大小
关键细节:
Part的getSubmittedFileName()返回原始文件名(含路径),需用Paths.get(filename).getFileName().toString()提取纯文件名,防止路径遍历(如../../etc/passwd)。Part.getInputStream()获取文件二进制流,可直接写入磁盘或内存。- 必须在
web.xml中配置multipart-config,或用@MultipartConfig注解设置最大文件大小、临时目录等:@WebServlet("/upload") @MultipartConfig( fileSizeThreshold = 1024 * 1024, // 1MB maxFileSize = 1024 * 1024 * 10, // 10MB maxRequestSize = 1024 * 1024 * 50 // 50MB ) public class UploadServlet extends HttpServlet { ... }
4. 实操全流程:从零构建一个健壮的文件下载 Servlet
4.1 需求定义:生产级文件下载的 5 个硬性要求
我们以“用户点击按钮下载一份带时间戳的销售报表 PDF”为例,明确必须满足的条件:
- 文件名支持中文(如
2024年4月销售报表.pdf) - 强制下载(attachment),而非浏览器内联显示(inline)
- 响应体为二进制流,不经过字符编码转换
- 设置精确的 Content-Type 和 Content-Length
- 添加 ETag 和 Last-Modified 头,支持浏览器缓存
这些要求直指 request/response 的核心能力,任何一项缺失都可能导致用户体验崩塌。
4.2 完整代码实现与逐行注释
@WebServlet("/download/report") public class ReportDownloadServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // Step 1: 解析请求参数(如 reportType, dateRange) String reportType = request.getParameter("type"); // "monthly" String dateStr = request.getParameter("date"); // "2024-04" // Step 2: 生成业务文件(此处模拟 PDF 构建) byte[] pdfBytes = generateReportPdf(reportType, dateStr); String fileName = buildFileName(reportType, dateStr); // "2024年4月销售报表.pdf" // Step 3: 设置响应头 —— 关键!顺序和内容决定成败 // 3.1 设置 Content-Type(必须在获取 OutputStream 前) response.setContentType("application/pdf"); // 3.2 设置 Content-Length(提升浏览器体验,避免进度条卡住) response.setContentLength(pdfBytes.length); // 3.3 设置 Cache-Control 和 Last-Modified(支持缓存) long lastModified = System.currentTimeMillis(); response.setDateHeader("Last-Modified", lastModified); response.setHeader("Cache-Control", "public, max-age=3600"); // 缓存1小时 // 3.4 设置 ETag(基于文件内容哈希,实现强缓存验证) String etag = "\"" + DigestUtils.md5Hex(pdfBytes) + "\""; response.setHeader("ETag", etag); // 3.5 设置 Content-Disposition —— 中文文件名兼容方案 // 根据 User-Agent 选择兼容头 String userAgent = request.getHeader("User-Agent"); String contentDisposition = buildContentDisposition(fileName, userAgent); response.setHeader("Content-Disposition", contentDisposition); // Step 4: 获取输出流并写入(必须用 OutputStream,非 Writer) ServletOutputStream outputStream = response.getOutputStream(); outputStream.write(pdfBytes); outputStream.flush(); // 确保数据立即发送 } /** * 构建 Content-Disposition 头,兼容 Chrome/Firefox/IE * RFC 5987 标准:filename*=UTF-8''encoded-filename */ private String buildContentDisposition(String fileName, String userAgent) { if (userAgent == null) return "attachment; filename=\"report.pdf\""; // IE 识别 if (userAgent.contains("MSIE") || userAgent.contains("Trident")) { // IE 只支持 filename,且需 ISO-8859-1 编码(中文会乱码,故用 URLEncoder) try { String encoded = URLEncoder.encode(fileName, "ISO-8859-1"); return "attachment; filename=\"" + encoded + "\""; } catch (UnsupportedEncodingException e) { return "attachment; filename=\"report.pdf\""; } } // Chrome/Firefox 支持 filename*(RFC 5987) try { String encoded = URLEncoder.encode(fileName, "UTF-8"); return "attachment; filename=\"" + fileName + "\"; filename*=UTF-8''" + encoded; } catch (UnsupportedEncodingException e) { return "attachment; filename=\"report.pdf\""; } } /** * 构建带中文的文件名 */ private String buildFileName(String type, String date) { String prefix = "2024年4月"; if ("monthly".equals(type)) { prefix = date.replace("-", "年") + "月"; } return prefix + "销售报表.pdf"; } /** * 模拟生成 PDF 字节数组(实际项目中调用 iText 或 Apache PDFBox) */ private byte[] generateReportPdf(String type, String date) { // 此处为简化,返回固定字节数组 // 实际中:Document doc = new Document(); PdfWriter.getInstance(doc, outputStream); return "PDF Binary Content".getBytes(StandardCharsets.UTF_8); } }4.3 关键配置与部署说明
web.xml 配置(Servlet 3.0+ 可省略,但建议保留):
<servlet> <servlet-name>ReportDownloadServlet</servlet-name> <servlet-class>com.example.ReportDownloadServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>ReportDownloadServlet</servlet-name> <url-pattern>/download/report</url-pattern> </servlet-mapping>Tomcat 优化:
- 在
conf/server.xml的Connector中添加useBodyEncodingForURI="true",确保 GET 请求的 URL 参数正确解码。 - 设置
URIEncoding="UTF-8",避免?name=张伟解析失败。
- 在
Nginx 反向代理注意事项:
location /download/ { proxy_pass http://backend/; # 必须透传 Content-Disposition 头 proxy_hide_header Content-Disposition; # 或更安全:显式允许 proxy_pass_request_headers on; }
4.4 实测效果与浏览器兼容性验证
| 浏览器 | 文件名显示 | 下载行为 | 备注 |
|---|---|---|---|
| Chrome 115+ | ✅2024年4月销售报表.pdf | ✅ 弹窗下载 | 使用filename* |
| Firefox 110+ | ✅2024年4月销售报表.pdf | ✅ 弹窗下载 | 同时支持filename和filename* |
| Edge 112+ | ✅2024年4月销售报表.pdf | ✅ 弹窗下载 | Chromium 内核,同 Chrome |
| IE 11 | ⚠️2024??4??销售报表.pdf | ✅ 弹窗下载 | URLEncoder.encode(..., "ISO-8859-1")导致中文乱码,但文件可正常下载 |
| Safari 16+ | ✅2024年4月销售报表.pdf | ✅ 弹窗下载 | 对 RFC 5987 支持良好 |
实测结论:IE 的中文名