拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Servlet响应对象HttpServletResponse:状态码、响应头与编码全解析

Servlet响应对象HttpServletResponse:状态码、响应头与编码全解析 后端开发每天打交道最多的两个对象除了 HttpServletRequest就是 HttpServletResponse。不过说实话刚做 Java Web 那两年我对 Request 对象的关注度远高于 Response——参数从哪来、Header 里带了什么 token、Body 怎么解析这些用起来很直观而 Response 对我来说似乎只是个“返回字符串”的出口。直到后来排查了几次线上怪问题才意识到这张“答卷”从头到尾都是自己用代码一笔一笔填出来的填错一格客户端拿到的就是另一份答案。这篇文章就以 HttpServletResponse 为中心把 HTTP 响应里最核心的状态码、响应头、响应体输出讲透。我会从 Servlet 运行时的工作方式说起再聊到具体 API 的选择、常见场景的配置最后把我会遇到的典型坑和排查思路一并整理出来。适合刚接触 Servlet 的新手也适合写了不少接口但没系统抠过响应细节的老手。1. HttpServletResponse 在 Servlet 体系里的真实位置1.1 一次请求的生命周期里响应对象做了什么在 Java Web 应用里你们写的 Servlet 并不是直接面向 TCP 连接的。真正把网络字节流变成对象的是 Servlet 容器常见如 Tomcat、Jetty、Undertow。容器收到一个 HTTP 请求后会解析请求行、请求头、请求体包装出一个HttpServletRequest对象同时它会准备一个HttpServletResponse对象这个对象不是一个“空的盒子”而是容器预留好的一块数据区域专门用来承接你想返回给客户端的信息。当容器调用你的doGet或doPost方法时你用到的方法比如setStatus、setHeader、getWriter、sendRedirect本质上都是在往这块数据区域里写入信息。方法返回之后容器再把这块数据区域里已经填好的状态行、响应头、响应体按照 HTTP 协议编码最终通过 Socket 写到客户端。所以可以这么理解HttpServletResponse是“应用代码”和“HTTP 网络报文”之间的翻译器。你每次调用它其实是在告诉容器“请把这句话放到状态行”“请把这个字段放到响应头”“请把这些字节放到报文主体”。容器什么时间把这些信息真正发送出去取决于响应是否已经提交。这个环节很容易被忽略但也特别重要。因为HttpServletResponse不是一个你可以随便创建的普通对象它是容器在请求生命周期内临时创建并注入到 Servlet 里的。你既不能把它保存到静态变量里留给下一个请求用也不应该尝试 new 一个出来自己玩——它背后关联着真实的网络连接状态。1.2 响应对象的“可修改窗口期”和生命周期HTTP 响应从生成到发送大致分为两个阶段未提交阶段和已提交阶段。未提交阶段容器只是把状态码、响应头、响应体暂存在内存缓冲里。这个时候你想改什么都可以改状态码、加响应头、重设字符编码都没问题。一旦响应提交committed容器的意思就是“我已经开始往网络连接上写数据了”从这一刻起再想改状态码、加响应头通常都是无效的甚至直接抛异常。提交发生得比你想象中更早。很多时候你只是在getWriter()后写了几 KB 数据并没有主动调用 flush数据也没超缓冲大小响应却可能已经提交了。因为容器会自己在合适的时机提交比如缓冲区满了或者 Servlet 方法执行到了末尾。另外调用flushBuffer()、sendRedirect()、sendError()这些方法也会立即触发提交。对开发者来说最重要的记忆点是所有影响 HTTP 报文“头部信息”的操作都必须赶在第一次写响应体之前完成。尤其是 Content-Type、字符编码、Content-Length、Cookie 这类响应头一旦提交就晚了。从生命周期上看Servlet 容器为了性能会复用很多东西但每一个请求的 Request 和 Response 对象基本是每个请求新建的请求处理完毕后对象会被回收或放回池中。你在一个请求里持有的 Response 引用绝不能在异步线程里无限期保存更不能跨请求使用。2. 状态码这么设接口语义才准确2.1 setStatus 与 sendError看似都能返回 4xx性质完全不同HttpServletResponse里设置状态码有两个常见入口setStatus(int)和sendError(int)。很多初学者以为它们只是“写法不同”其实背后的行为差异很大。setStatus只是简单地把状态行里的状态码改成你传入的值。它不会打断你后续继续输出响应体也不会触发容器对错误页的处理。比如你调了resp.setStatus(404)接下来还可以正常调用getWriter().write(not found)最终客户端收到的就是状态码 404 加一段普通文本。sendError则更像一个“流程控制”操作。调用它之后Servlet 方法通常就不应该再继续写响应体了。容器会重新走一遍错误处理流程如果 web.xml 里配置了对应错误码的错误页就返回那个错误页如果没有配置容器会生成一个默认的错误页面。所以sendError不适合用来返回 JSON 格式的业务错误除非你额外做了统一异常处理或错误页适配。在实际接口开发中我一般这样区分如果是业务异常比如参数校验失败、资源不存在尽量自己设置状态码并配合 JSON 响应体返回维护起来更可控如果是真正意义上的容器级错误、需要触发错误页兜底的场景才用sendError。另外一个容易踩的坑是重复设置状态码。比如前面 filter 里已经执行了sendError(401)后面 Servlet 又更新为 200最终客户端拿到的状态码以第一个“能生效”的操作为准。一旦响应提交后面再改都是徒劳。所以状态码的设置最好集中在同一层别到处撒。2.2 常用状态码踩坑场景状态码不能只看到 200 和 500。下面是接口开发中高频出现的状态码我按实际使用场景整理了一份速查表状态码英文名典型使用场景注意点200OK正常返回最常见但别把所有异常都包装成 200201Created创建资源成功REST 接口里新增数据后返回204No Content删除成功、无需返回体记得别往响应体里写内容304Not Modified配合缓存头做协商缓存必须同时处理好 ETag / Last-Modified400Bad Request参数缺失、格式错误通常返回 JSON 说明具体错误401Unauthorized未登录、登录态失效别和 403 搞混403Forbidden已登录但无权限访问权限不足时使用404Not Found资源不存在路由未匹配、接口路径写错405Method Not Allowed请求方法不支持比如 GET 接口收到了 POST429Too Many Requests触发限流记得带 Retry-After 头500Internal Server Error未捕获的运行时异常需要记录堆栈日志502Bad Gateway上游节点异常网关拿到不可用上游响应503Service Unavailable服务过载、停机维护可以配 Retry-After 表示恢复时间这里特别叮嘱一句502 这类状态码经常出现在负载均衡或网关后面。如果你在浏览器里直接看到 “502 Bad Gateway”说明请求的链路里某个环节没有拿到上游正常的 HTTP 响应。排查时不要只看业务代码日志还要确认下游服务是否存活、超时配置是否合理、连接池是否被打满。状态码代表的是同一时刻整条链路的健康情况而不只是 Servlet 代码里那一行setStatus。3. 响应头里藏着的编码、缓存与跨域规则3.1 Content-Type 和字符编码中文乱码的根子在这做过 Web 开发的十有八九都遇到过中文乱码。乱码的原因九成以上和 Content-Type 里的字符编码设置有关。HttpServletResponse里常用的三个方法setContentType(String)、setCharacterEncoding(String)、setLocale(Locale)。setContentType实际上可以带上字符集比如resp.setContentType(text/html;charsetUTF-8)。这时候容器会同时把 Content-Type 响应头和内部字符编码都设置好。如果你先用setContentType(text/html)再单独调用setCharacterEncoding(UTF-8)也是可以的但要记住一个时间点必须在第一次调用getWriter()之前完成。因为getWriter()创建 Writer 时会基于当前 Response 的字符编码去构造后续的编码转换器。一旦 Writer 已经创建再改字符编码就来不及了之前写进缓冲区的字符已经按旧编码被转换成字节。排查乱码的顺序我建议从这几个方向来第一看响应头里实际返回的 Content-Type 是什么。浏览器 F12 打开网络面板直接点进响应头查看。第二看代码里是否在getWriter()之后才设置编码如果是把它挪到前面。第三看容器或全局过滤器是否覆盖了编码设置。比如有些统一编码过滤器会强制设置 UTF-8但如果你在过滤器之后手动改成 GBK就可能造成前后不一致。另一个隐蔽问题是 JSON 接口。很多框架已经帮你把application/json;charsetUTF-8设置好了但如果是在手写 Servlet 或者老项目中很容易漏掉 charset。只写application/json不写编码部分老容器可能按 ISO-8859-1 处理返回中文就乱。稳妥做法是完整写出application/json;charsetUTF-8。3.2 Content-Length、缓存和 CORS 的响应头写法响应头家族里除了 Content-Type还有几个高频成员值得花时间理解。Content-Length表示响应体的字节长度。容器通常可以自动计算并设置前提是响应体在提交之前已经完整写入缓冲。如果你手动设置了错误的 Content-Length客户端可能只读到一部分内容或者一直等待剩余字节直到连接超时。所以一般情况下让容器自动生成比手动指定更安全。只有在你明确知道响应体总长度、并且希望减少一次“分块传输”时才考虑手动设置比如大文件下载。缓存相关的响应头也很常见。Cache-Control用来控制浏览器缓存策略ETag和Last-Modified配合完成协商缓存。在HttpServletResponse里这些都是一行setHeader的事。比如返回静态资源时常常这样设置resp.setHeader(Cache-Control, public, max-age3600); resp.setDateHeader(Last-Modified, lastModifiedTime); resp.setHeader(ETag, \abc123\);跨域场景集中在 CORS 响应头。浏览器发跨域请求时后端返回的Access-Control-Allow-Origin决定这个请求能不能被读取。只需要返回一个允许的 Origin比如resp.setHeader(Access-Control-Allow-Origin, https://example.com); resp.setHeader(Access-Control-Allow-Methods, GET,POST,OPTIONS); resp.setHeader(Access-Control-Allow-Headers, Content-Type,Authorization);这里有个值得留意的细节Access-Control-Allow-Origin如果直接返回*浏览器和非凭证请求还能正常配合但一旦请求里带了 Cookie浏览器会拒绝响应。带凭证的跨域请求必须返回明确的 Origin而且不能是*。这是联调时经常踩的坑。3.3 addHeader 与 setHeader 怎么选HttpServletResponse提供了两种设置响应头的姿势setHeader和addHeader。setHeader的逻辑是“覆盖”。如果之前已经设置过同名头再调用一次会用新值替换旧值。addHeader的逻辑是“追加”每调用一次就往响应头里新增一个同名条目。什么时候必须用addHeader典型场景是Set-Cookie。一个响应里可以同时下发多个 Cookie自然就有多个Set-Cookie头。如果你用setHeader反复设置前面的 Cookie 会被后面的覆盖掉浏览器只能收到最后一个。虽然业务开发中更多用response.addCookie(cookie)但如果你正在手写底层 HTTP 包装就会遇到addHeader(Set-Cookie, ...)这种写法。不过直接操作Set-Cookie头要特别小心因为 Cookie 值如果来自用户输入不做转义就可能引发响应头注入。最好优先使用Cookie对象和addCookie方法让容器去处理必要的转义规则。自己拼响应头字符串时凡是带不可信输入的都要过滤掉回车换行符。4. 响应体怎么输出Writer、OutputStream 与缓冲4.1 文本和二进制getWriter 与 getOutputStream 的选择HttpServletResponse提供了两个写出响应体的入口getWriter()和getOutputStream()。getWriter()返回PrintWriter适合输出字符串文本。它内部会按照 Response 当前设置的字符编码把字符转换成字节。好处是方便写中文、写 HTML、写 JSON 都顺手。坏处是如果你手动控制字节编码不仔细很容易出乱码。getOutputStream()返回ServletOutputStream适合输出二进制数据图片、PDF、Excel、压缩文件等。它直接操作字节流不关心字符编码拿到什么字节就写什么字节。这两个方法不能在同一响应中同时调用。规范里明确说了如果你调用了getWriter()之后再去调用getOutputStream()会抛出IllegalStateException。反过来也一样。原因是它们底层其实对应同一个响应输出通道容器不允许你一会儿按字符写、一会儿按字节写因为这会导致编码状态错乱。选择方法时判断依据其实很简单你的响应内容是文本还是二进制如果是文本优先getWriter()如果是文件、图片、原始字节就用getOutputStream()。不要因为想输出一行字符串就强行开字节流再去getBytes()那样还要自己处理编码容易出错。还有一个小技巧写大量文本时可以调用PrintWriter的print或write方法分批输出不要一次性拼一个超大字符串再写那样会占用很多内存。分批写时注意编码一致性Writer 内部已经按固定编码转换好了。4.2 缓冲、提交时刻与 keep-alive 连接复用响应体输出看起来简单但它和 HTTP 连接复用、性能表现息息相关。Servlet 容器默认会给响应分配一个输出缓冲区。getBufferSize()可以看到当前缓冲大小setBufferSize(int)可以在写入任何内容之前调整大小。缓冲的意义在于让响应头和响应体可以在内存中先组装等到合适时机再一次发送减少网络分段、提升传输效率。当缓冲区满了或者你主动调用了flushBuffer()容器就会把当前已写入的数据发送出去并标记响应为“已提交”。之后之前强调过的约束就开始生效响应头不能改、状态码不能改、缓冲区也不能重置了。如果你开发的是大文件下载之类场景可以把缓冲区作为一个读写中转站。边读文件边写响应比如byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } out.flush();这里要特别注意flush()的时机。频繁 flush 会让小包数量暴增网络开销变大一直不 flush 可能导致响应迟迟不发出客户端长时间看着“请求中”。正确思路是大批量数据写完后统一 flush 一次或者在关键节点主动 flush配合缓冲区大小控制节奏。关于 keep-alive 连接复用也和响应体密切关联。HTTP/1.1 默认支持连接复用客户端希望在处理完一个响应后继续在同一个 TCP 连接上发下一个请求。但连接能否被安全复用取决于响应是否有明确的结束边界。如果响应体有正确的Content-Length连接边界就清晰如果没设置 Content-Length容器可能改用Transfer-Encoding: chunked用 0 长度的结束块来标记响应完成。无论哪种方式关键是响应必须“完整收尾”。如果响应体写了一半代码抛异常、连接没正常结束客户端就会觉得“连接处于联机状态但一直没有真正的响应内容”。这种情况下排查方向往往不是协议本身而是业务代码里流没关、循环没退出、或锁没释放。保持对finally块中释放资源的敏感度比背协议更能解决问题。5. 那几个出现频率很高的实用场景5.1 sendRedirect 与 RequestDispatcher.forward 的差别sendRedirect是HttpServletResponse上一个很有辨识度的方法。调用它之后容器会返回一个 302 状态码并在响应头Location里写上目标地址浏览器收到后会自动发起一次新的 GET 请求。写起来很简单resp.sendRedirect(/login); resp.sendRedirect(https://example.com/path);这个方法背后有三点要知道。第一它是一个“客户端跳转”浏览器地址栏会变化整个请求会重新发起。第二它会立即提交响应所以不能再回头写响应体。第三相对路径的解析规则比较绕建议尽量写清楚完整路径避免踩到上下文路径拼接的坑。和它经常对比的是RequestDispatcher.forward。forward是服务端内部跳转浏览器完全无感知地址栏不会变同一个请求内的参数、属性都还在。它使用的是RequestDispatcher而不是Response的方法。选哪个关键是看你想要什么效果。如果是登录成功后进入主页用sendRedirect更符合语义还能刷新浏览器地址如果是服务端内部路由到另一个 Servlet 渲染页面用forward更高效少一次网络往返。使用sendRedirect时还有一个安全习惯如果重定向目标地址里有用户输入一定要做校验避免被拼出钓鱼链接或意料之外的外部地址。对内部跳转最好只允许相对路径或可枚举的白名单地址。5.2 文件下载的参数组合与流处理细节让浏览器弹出“下载”而不是直接打开文件靠的是Content-Disposition响应头。这个头写成attachment时浏览器就会把响应内容当作附件保存。一个典型的文件下载响应长这样resp.setContentType(application/octet-stream); resp.setHeader(Content-Disposition, attachment; filename\report.pdf\);配合Content-Type和Content-Length一起使用会更完整。设置Content-Disposition必须在响应提交之前完成。如果你先写了几 MB 数据再设置多半会收到IllegalStateException。对中文文件名直接放进filename里很容易乱码。规范做法是同时提供filename*并使用 RFC 5987 的编码格式resp.setHeader(Content-Disposition, attachment; filename\report.pdf\; filename*UTF-8 URLEncoder.encode(中文报表.pdf, UTF-8));流处理方面不要用Files.readAllBytes把整个文件读进内存再写大文件会撑爆内存。用流式读写比如 8 KB 或 64 KB 的缓冲区循环写入。另外文件下载场景最常见的错误还有忘了设置Content-Length。设置了它浏览器能准确显示下载进度不设置有些情况会走 chunked 编码下载过程也可能异常。如果能提前知道文件长度就主动设置。5.3 Cookie 操作addCookie 和属性设置在HttpServletResponse上设置 Cookie最正规的方法是构造Cookie对象再调用addCookie。Cookie cookie new Cookie(sessionId, abc123); cookie.setPath(/); cookie.setMaxAge(3600); cookie.setHttpOnly(true); resp.addCookie(cookie);容器会把 Cookie 序列化成Set-Cookie响应头。setPath决定 Cookie 在哪些路径下生效setMaxAge控制过期时间负数表示会话级 Cookie浏览器关闭即失效setHttpOnly是安全设置加了之后脚本就拿不到 Cookie 值了。有几个容易被忽视的点。第一addCookie和任何写响应体操作一样必须在响应提交前调用否则头已经发出去了就补不了。第二一个响应可以 add 多个 Cookie每次调用都会追加一个新的Set-Cookie头不需要手动拼字符串。第三如果后面又有同名的 Cookie 被 add 进去浏览器按路径规则处理时可能会发生覆盖所以别在一条链路里反复设置同名 Cookie容易出现预期之外的结果。Session 的工作原理也和 Cookie 紧密相连。容器默认通过JSESSIONID这个 Cookie 来维持会话但如果你把整个应用都设置成禁 CookieSession 就只能在 URL 里传递了那是另一个复杂的坑真遇到了再单独排查。6. 实际排查中最常见的三坨问题6.1 IllegalStateException响应提交后不能改头运行期最典型的异常之一就是java.lang.IllegalStateException: Cannot set header after response has been committed或者类似 “response already committed” 的提示。翻译过来就是响应已经提交了你想设置的响应头来不及了。这个异常在以下场景特别容易出现。一是在一个很长的 Controller 方法里先写了大量数据然后在末尾设置响应头。比如resp.getWriter().write(...很多内容...); resp.setHeader(X-Custom, late);第二行大概率会炸。因为写大内容时缓冲区已满响应被提前提交。解决办法是把响应头设置挪到所有写操作前面或者把数据先暂存在内存里最后再一次性写出。二是filter和servlet之间各写各的头。filter 里执行了 flush然后 servlet 又尝试加头也会抛异常。这种情况要在设计阶段就明确“谁负责设置哪些头”避免重复。三是异步 Servlet 场景。响应对象被拿到异步线程里结果主线程提前返回容器以为响应结束并提交异步线程再来操作就失败了。异步场景必须统一走AsyncContext的逻辑不在容器认为的请求结束点之后操作。遇到这个异常先别急着改代码。打开日志看异常发生的位置找到最后一次 flush 和写大数据的点再把需要设置的响应头前移到它们之前。6.2 中文乱码从响应编码到容器层面的层层排查中文乱码这个问题我把它单独拎出来是因为它太容易复现而且原因不止一层。最常见的场景是手写 Servletresp.setContentType(text/plain); resp.getWriter().write(你好世界);返回给浏览器后变成一堆问号。原因是setContentType(text/plain)没有指定 charset而容器默认字符编码可能不是 UTF-8。改造方法就是在获取 Writer 之前设置完整 Content-Typeresp.setContentType(text/plain;charsetUTF-8);另一种情况是用了getOutputStream输出字符串。很多同学图省事直接resp.getOutputStream().write(str.getBytes())。这里getBytes()依赖平台默认字符集一旦服务器操作系统字符集不是 UTF-8就会乱。正确做法是显式指定resp.getOutputStream().write(str.getBytes(StandardCharsets.UTF_8));但底层字节现在和响应头声明的 charset 是否一致还得靠你自己保证。所以输出文本时更稳妥是走getWriter()而不是自己转字节。还有一种情况是过滤器层面统一设置了字符编码但你的代码又用setCharacterEncoding覆盖成别的值两处设置不一致造成乱码。遇到乱码不要一上来就改 pom 依赖或重新部署先在浏览器响应头里确认 charset 到底是什么。确定服务端声明的编码再确定服务端实际写入的编码再确认客户端解析的编码。三层对齐乱码自然消失。6.3 页面一直转圈、连接不释放到底是谁的锅有时候你打开一个接口浏览器状态一直转圈最后提示“页面处于联机状态但未对连接尝试做出响应”。这种问题表面上像网络或网关故障但从后端排查往往另有答案。首先要分清是“响应慢”还是“响应没出来”。响应慢通常能看到日志里有请求进入但耗时很长可能是数据库慢查询、远程调用超时、锁等待。响应没出来更可能是请求根本没进到业务代码或者进入后线程被卡住。排查步骤建议从四件事做起。看服务是否还活着检查进程状态、端口监听、健康检查接口。看线程堆栈如果有请求长时间不返回执行jstack抓线程栈找 RUNNABLE 但卡在锁、连接、IO 上的线程。看数据库连接池状态连接池被打满时新请求会在获取连接处排队表现就是转圈无响应。看日志有没有异常堆积如果其他地方疯狂刷错误日志也可能影响整体性能。一个我亲自踩过的例子是某次接口偶尔卡住 60 秒左右才返回浏览器偶尔就报“未响应”。排查时发现代码里用了同步块而另一个任务持有锁迟迟不释放。线程堆栈里清楚看到请求在wait等待锁。这类问题从 HTTP 协议层面找不到答案必须回到并发和资源的视角。另一个常见病根是连接池、线程池被耗尽。Tomcat 默认最大线程数有限如果前面的请求都因为慢 SQL 占着线程不释放后面的请求自然在队列里排队客户端感知就是“连上了但没人回答”。遇到这种情况优先解决慢查询而不是无限调大线程数。7. 用一个包装器扩展响应对象7.1 HttpServletResponseWrapper 解决的问题HttpServletResponseWrapper是 Servlet 规范提供的一个装饰器类。它的存在价值在于你不需要把容器创建的 Response 对象拿来直接用而是可以在外面包一层统一拦截或修改行为。为什么要这么做因为真实项目里经常需要做“全局改造”。比如给每个响应统一增加一个响应头按说可以在 filter 里完成任务。但有些场景 filter 不够用比如你想在响应体写出后统计字节数、想做响应体压缩、想拦截所有输出的内容做关键词过滤、想临时缓存输出结果。HttpServletResponseWrapper就是为这种“包一层”的用法设计的。你可以继承它重写getOutputStream()、getWriter()、setHeader()、setStatus()等方法在原有逻辑前后插入自己的逻辑。这个模式要注意一点包装器只是把 Response 包装了一下它内部最终还是持有真实 Response。容器在 filter 链的最后会把包装器对象传给 Servlet但真实网络输出用的还是最底层那个原始对象。所以你在包装器里替换了输出流之后必须保证所有写操作都走你的包装流否则数据会漏到下面去。7.2 一个统计响应字节数的极简包装器我写一个非常简单的统计响应字节数的包装器方便理解这个模式是怎么落地的。public class ByteCountingResponseWrapper extends HttpServletResponseWrapper { private final CountingOutputStream counter new CountingOutputStream(); public ByteCountingResponseWrapper(HttpServletResponse response) { super(response); } Override public ServletOutputStream getOutputStream() throws IOException { return counter; } Override public PrintWriter getWriter() throws IOException { // 简化处理字符输出也先转成字节统计真实场景会更复杂 return new PrintWriter(new OutputStreamWriter(counter, StandardCharsets.UTF_8)); } public int getByteCount() { return counter.getCount(); } static class CountingOutputStream extends ServletOutputStream { private int count; Override public void write(int b) { count; } Override public boolean isReady() { return true; } Override public void setWriteListener(WriteListener writeListener) { } public int getCount() { return count; } } }这个包装器在实际运行中取代了原始 Response 的默认输出流所有写入都会经过CountingOutputStream于是我们就能拿到一个响应体的大致字节数。真实项目中你可以在 filter 里包一层等整个请求结束后打印这个统计值。不过说句实话包装器带来的自由度也意味着责任。你在替换getWriter和getOutputStream时要保证行为兼容比如异步 Servlet 场景下的isReady、setWriteListener这些方法都不能偷懒。如果只是简单业务场景能不用自定义包装器就别用毕竟容器默认实现已经过多年优化贸然替换很可能引入性能问题。真正需要时就从最小的重写开始验证行为没问题再扩展。我在实际项目里最常做的扩展反而是在 filter 中统一给所有响应加安全相关响应头、统计耗时这些用普通HttpServletResponse加几个setHeader就能完成。包装器则适合那些“想对响应体动手”的特殊需求。不要因为 API 灵活就滥用保持响应链路的简洁线上问题才会更少。
返回列表