HttpServletResponseWrapper实践:在Filter中统一修改响应内容的完整指南
1. 从修改响应内容的痛点说起做 Java Web 开发的朋友几乎都遇到过这样的需求想在接口返回给前端之前偷偷改一下响应内容——比如给 JSON 加个统一包装、把敏感字段打码、往页面里注入一段统计脚本、或者记录完整的响应日志。但你很快会发现一件事HttpServletResponse的getWriter()和getOutputStream()返回的流是直接连到客户端的数据一旦写出去就收不回来了改都没法改。我刚入行那会儿就踩过这个坑。当时有个需求是要给所有接口的响应自动包一层{ code: 0, data: ... }第一个版本我是在 Controller 里手动包装的结果每个接口都要改后来有个接口漏了线上排查了半天才发现是返回格式不一致。从那以后我就开始认真研究HttpServletResponseWrapper用它在 Filter 层面统一处理。这篇博文我把实际项目里用HttpServletResponseWrapper的各种场景、踩坑记录和完整代码示例整理出来。如果你是做 Servlet/Spring Boot 开发的或者正在为“如何修改响应内容”“如何缓存响应日志”发愁这篇文章可以直接帮你省掉两三天自己摸索的时间。2. 核心思路为什么需要 Wrapper 而不是直接改 Response很多新手遇到修改响应内容的需求第一反应是直接在 Filter 里调用response.getWriter().write(修改后内容)。这种做法会直接让你怀疑人生你写了但前端收到的还是原样或者直接报IllegalStateException: getWriter() has already been called。原因很简单HttpServletResponse就像一个单向管道数据从管道里流出去了就没了你没法把流出来的水再拽回来重新加工。而HttpServletResponseWrapper的核心作用就是让你在这个管道中间“插入”一个缓冲容器——所有要写出到客户端的数据先流进你自定义的缓冲区里而不是直接流向远端。等 Filter 过滤链路走完你在缓冲区里拿到的就是完整的响应内容这时候你想怎么处理都行替换、拼接、压缩、加密处理完再真正写入原始 response 输出流。这里的关键点是HttpServletResponseWrapper本身并不改变 response 的任何行为它只是提供了一个“代理”的壳子。你真正要做的是重写getWriter()和getOutputStream()这两个方法让它们返回你自定义的、带有缓冲功能的流对象。原始的response对象并不会把数据直接暴露给你只有当你自己持有并操作这个缓冲流时才能拿到完整数据。这样设计的好处是Filter 链路不变Controller 代码不用动所有响应逻辑照常运行只是最终出口被我们“拦截”了一下。这种模式在框架层面很常见比如 Spring 的ContentCachingResponseWrapper就是基于这个思想实现的。2.1 包装器模式的底层逻辑要理解 Wrapper 为什么能做到“凭空截获”你要先明白 Java Web 中 Filter 的责任链机制。请求进来时Filter 依次执行chain.doFilter()响应返回时也是一层一层往回走。当你在某个 Filter 中调用chain.doFilter()之前把HttpServletResponse替换成了自己的包装类那么后续所有 Servlet 和 Filter 拿到的都是你的包装类它们往 response 里写数据实际就会写到你自定义的缓冲区里。当chain.doFilter()返回时缓冲区里已经有了完整的响应内容。这时候你再从缓冲区读取数据做任意修改然后通过this.httpServletResponse.getWriter()或getOutputStream()把修改后的数据真正写给客户端。这个思路就像在快递流水线上加了一个暂存仓库快递员先把所有包裹放到仓库货主清点、打包、改单后再统一发出去。如果没有这个仓库包裹肉眼可见地运走就再也追不回来了。3. 工具选型自己写 Wrapper 还是用现成的在动手写代码之前先明确一个现实问题很多时候我们不需要自己从零实现合理利用现成封装能少踩很多坑。但理解底层原理依然是必要的因为现成方案不一定满足所有场景。3.1 Spring 框架自带的 ContentCachingResponseWrapper如果你用的是 Spring Boot尤其是 Web 或 WebFlux 场景框架其实已经提供了一个现成的包装器org.springframework.web.util.ContentCachingResponseWrapper它的用法非常简洁Component public class CacheResponseFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { ContentCachingResponseWrapper wrapper new ContentCachingResponseWrapper((HttpServletResponse) response); chain.doFilter(request, wrapper); byte[] body wrapper.getContentAsByteArray(); // 对 body 做修改 byte[] newBody modify(body); wrapper.getResponse().getOutputStream().write(newBody); wrapper.getResponse().flushBuffer(); } }这个类的核心优势在于自动缓存getOutputStream()和getWriter()两种模式的数据。提供了getContentAsByteArray()直接获取缓存的字节内容。调用copyBodyToResponse()可以原样把缓存内容写回客户端。但要注意ContentCachingResponseWrapper默认会缓存所有响应内容到内存如果响应体非常大比如大文件下载容易导致内存溢出。它更适合 JSON 接口这类中小心响应体。还有一点Spring 的这个类在写回时需要你手动调用copyBodyToResponse()或者自己从 wrapper 拿原始 response 去写。如果你只调用了chain.doFilter(), 而不调用任何写回方法前端会收不到任何响应。3.2 为什么仍然需要自己写 Wrapper虽然 Spring 提供了现成方案但有几种情况下你仍然需要自己实现你要对响应内容做压缩、加密、字符集转换等特殊操作现成缓存类不能满足。你需要同时缓存响应头Header并在修改后重新设置。你用的不是 Spring比如纯 Servlet 项目只能靠标准 Servlet API。你想更精准地控制缓冲大小的上限防止大响应打爆内存。下面我就围绕“手写一个 Wrapper”来展开完整流程。这套方法在纯 Servlet 项目里同样适用理解了它再看 Spring 的封装一眼就透了。4. 完整实现手写一个响应缓存包装器下面进入正题。我会一步步说明如何从零写一个服务于响应修改的包装器并在 Filter 中使用它。这里我以常用的 JSON 响应统一包装为例同时兼顾通用性。4.1 定义自定义缓冲输出流首先我们需要一个能“承载数据但不立即发出去”的流。最简单的实现是继承ServletOutputStream底层用一个ByteArrayOutputStream作为真正的容器public class TeeServletOutputStream extends ServletOutputStream { private final ByteArrayOutputStream buffer; private final ServletOutputStream originalStream; public TeeServletOutputStream(ServletOutputStream originalStream, int bufferSize) { this.originalStream originalStream; this.buffer new ByteArrayOutputStream(bufferSize); } Override public void write(int b) throws IOException { buffer.write(b); } Override public void write(byte[] b, int off, int len) throws IOException { buffer.write(b, off, len); } Override public boolean isReady() { return originalStream.isReady(); } Override public void setWriteListener(WriteListener listener) { originalStream.setWriteListener(listener); } public byte[] toByteArray() { return buffer.toByteArray(); } }这里我把原始输出流存了下来但没有直接用originalStream.write()而是把数据都写进buffer。好处是在 Filter 的后续链路中Servlet 写入的每一个字节都被缓存住了。isReady()和setWriteListener()必须代理给原始流否则在异步 Servlet 场景下会出问题。4.2 处理 getWriter() 的编码问题getWriter()返回的是一个字符流底层要包一层PrintWriter。这个过程中有一个非常容易被忽略的细节必须使用 response 设置好的字符编码来构建PrintWriter否则中文会乱码。public class CharResponseWrapper extends HttpServletResponseWrapper { private final ByteArrayOutputStream buffer new ByteArrayOutputStream(); private ServletOutputStream outputStream; private PrintWriter writer; private HttpServletResponse originalResponse; public CharResponseWrapper(HttpServletResponse response) { super(response); this.originalResponse response; } Override public ServletOutputStream getOutputStream() throws IOException { if (writer ! null) { throw new IllegalStateException(getWriter() has already been called on this response.); } if (outputStream null) { outputStream new TeeServletOutputStream(originalResponse.getOutputStream(), 1024); } return outputStream; } Override public PrintWriter getWriter() throws IOException { if (outputStream ! null) { throw new IllegalStateException(getOutputStream() has already been called on this response.); } if (writer null) { String encoding getCharacterEncoding(); if (encoding null) { encoding UTF-8; } writer new PrintWriter(new OutputStreamWriter(buffer, encoding)); } return writer; } public byte[] getBufferContent() { if (writer ! null) { writer.flush(); } return buffer.toByteArray(); } }注意这里的两个关键点getOutputStream()和getWriter()只能调用一次如果混用会抛异常。这个限制并不是我刻意加的它是 Servlet 规范本身的要求在一个响应周期里要么用字节流要么用字符流不能混用。拿到PrintWriter写完数据后必须flush()否则数据只停留在PrintWriter的内部缓冲区里getBufferContent()拿不到完整数据。我在getBufferContent()里主动调用了writer.flush()就是为了防止用户忘记 flush 导致缓存内容不全。4.3 设计一个支持修改的通用包装器上面的CharResponseWrapper只能缓存不能直接修改。实际项目中我们通常需要拿到缓存内容后替换成新的内容并写回。我把这部分整合到一个完整的 Filter 示例里。4.4 在 Filter 中实现响应内容的统一修改现在来到应用层用一个 Filter 将包装器接入请求链路。以“给所有 JSON 响应加统一包装”为例WebFilter(urlPatterns /*) public class JsonWrapperFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; HttpServletResponse httpResponse (HttpServletResponse) response; CharResponseWrapper wrapper new CharResponseWrapper(httpResponse); chain.doFilter(request, wrapper); // 获取原始响应内容 byte[] originalBytes wrapper.getBufferContent(); // 判断是否为 JSON 响应 String contentType wrapper.getContentType(); if (contentType ! null contentType.contains(application/json)) { String originalBody new String(originalBytes, wrapper.getCharacterEncoding()); // 这里做具体修改比如统一包装 String newBody {\code\:0,\data\: originalBody ,\msg\:\success\}; // 重新设置响应大小因为内容长度变了 response.setContentLength(newBody.getBytes(wrapper.getCharacterEncoding()).length); // 真正写回 response.getWriter().write(newBody); } else { // 非 JSON 响应原样写回 response.getOutputStream().write(originalBytes); } } }这里有一个非常关键的坑如果你在 Filter 里已经调用过wrapper.getWriter()后续直接调用原始的response.getWriter()一样会冲突吗其实不会因为response是原始 response不是wrapper它内部还没有打开过 writer。但如果你不小心调用了wrapper.getWriter()去写那写进去的内容还是进缓冲区并不会直接发出去。写回的时候我选择用原始response来写这是标准做法。因为wrapper里的 writer / stream 指向缓冲区不能再自己写给客户端否则又回到缓冲区里死循环了。另一个需要特别注意的点是setContentLength。原始响应头里很可能带Content-Length这个值是基于原始内容算出来的。修改内容后字节数变了如果不更新 header前端会按旧的 Content-Length 读取数据导致内容截断或额外解析出错。所以务必在写回前重新设置Content-Length。4.5 完整调用链路与执行时序为了让你看得更清楚我把过滤链路的执行时序整理一下客户端发送请求到 Servlet 容器。Filter 拦截请求创建CharResponseWrapper把它作为响应对象传给chain.doFilter()。Servlet 或 Spring MVC 处理业务逻辑调用response.getWriter()写入 JSON 数据。数据实际上被写入包装器内部的ByteArrayOutputStream。chain.doFilter()返回控制权回到 Filter。Filter 从缓存中获取完整响应数据进行修改。Filter 修改响应头如 Content-Length、Content-Type。Filter 将修改后的数据通过原始 response 写给客户端。浏览器/前端收到的是修改后的内容。这个链路中第 3 步到第 4 步是“缓存”发生的关键。你可以在任意 Filter 中一层一层地再包装每一层都有机会修改数据。这正是 Servlet 规范中包装器模式最强大的地方它具有可组合性。5. 核心细节拆解那些一不留神就翻车的地方5.1 字符编码是万恶之源我在给不少同事 review 代码时发现每个人写 Wrapper 时都会遇到同一个问题乱码。乱码的根源在于字符与字节的转换用了错误的编码表。PrintWriter写入字符串时会按创建时指定的编码把字符转换成字节。如果你创建PrintWriter时没指定编码系统会使用平台默认编码Windows 上是 GBKLinux 上通常是 UTF-8部署环境一变就炸。我建议在包装器中强制默认编码为 UTF-8并且每次转换字节数组时都显式传入编码参数String charset response.getCharacterEncoding(); if (charset null || charset.isEmpty()) { charset UTF-8; }同时业务侧也最好在请求入口统一设置编码。Spring Boot 默认已经设置了charsetutf-8但如果你是在原生 Servlet 环境里务必在 Filter 最前面加上request.setCharacterEncoding(UTF-8); response.setCharacterEncoding(UTF-8);5.2 Content-Length 不更新导致响应被截断在 HTTP/1.1 中Content-Length是判断响应体结束的主要依据。如果响应体实际长度和 header 不一致客户端可能出现内容被截断实际返回多了客户端只读取 Content-Length 声明的大小。内容后沾着垃圾数据实际返回少了客户端会一直等待最终超时。所以在修改完响应体后一定要重新计算并设置长度。如果是 HTTP/2header 会略有不同但Content-Length仍然有语义保持更新总没错。有个技巧如果修改后的内容和原内容长度相差不大你可以先写到一个临时字节数组里再一次性setContentLength并写回避免分多次写导致 header 与 body 不匹配。5.3 getWriter 与 getOutputStream 二选一的限制Servlet 规范强制规定在一次请求的响应周期中getWriter()和getOutputStream()不能同时被调用否则抛出IllegalStateException。这个限制在包装器场景下依然存在。你要做的就是保持代理方法和原始 response 行为一致Override public ServletOutputStream getOutputStream() throws IOException { if (writer ! null) { throw new IllegalStateException(getWriter() has already been called.); } ... } Override public PrintWriter getWriter() throws IOException { if (outputStream ! null) { throw new IllegalStateException(getOutputStream() has already been called.); } ... }实际项目里Spring MVC 的MappingJackson2HttpMessageConverter通常使用getOutputStream()写 JSON而Thymeleaf模板渲染一般使用getWriter()。所以写 Wrapper 时两个方法都要实现完整避免某些底层框架调用时不支持。5.4 大响应导致内存占用飙升ByteArrayOutputStream是把全部数据放在内存里的。如果接口返回超大文件比如几十 MB 的下载缓存所有字节很可能直接把 JVM 内存打满严重影响服务稳定性。我的建议是对可能产生大响应的接口做排除不经过修改 Filter或者设置一个上限值。例如通过warpper.getBufferContent()拿数据之前先判断Content-Length是否超过 10MB如果超过不缓存直接放行。如果必须修改大响应考虑使用磁盘临时文件作为缓冲区或者使用流式处理比如替换某段字符串后用分段输出流写出。但这会显著增加复杂度非必要不轻易尝试。另外Filter 里缓存响应后原始 response 的缓冲功能就失效了。原本setBufferSize()可以优化写入性能现在所有内容都进了内存响应缓冲区的意义不大了。5.5 异步 Servlet 下包装器的特殊注意点如果你在项目里用了异步 Servletrequest.startAsync()包装器的行为会变得非常微妙异步线程里调用response.getWriter()时如果拿到的还是包装器写入行为依然会被拦截。异步完成后响应真正提交的时机不受 Filter 控制你必须在AsyncListener中或者complete()之前完成数据写回。isReady()和setWriteListener()如果不同时正确代理会破坏异步 IO 机制。我这边的经验是如果接口走异步 Servlet尽量不要用这种“整体缓存再修改”的方式而是改造业务代码直接输出正确结果。或者只对同步 Filter 链路启用包装。因为你很难保证异步线程中最终回调的顺序不会出错。6. 典型应用场景实战从响应日志到统一包装下面用几个高频场景来演示HttpServletResponseWrapper在实际项目中的用法。这些都是我在真实项目里做过的事代码可以直接套用。6.1 场景一记录完整的响应日志排查线上问题时最头疼的一件事就是“看不到接口到底返回了啥”。只记录请求参数不记录响应问题往往只能靠猜。用 Wrapper 把响应先缓存再记录日志非常直观WebFilter(urlPatterns /*) public class ResponseLoggingFilter implements Filter { private static final Logger log LoggerFactory.getLogger(ResponseLoggingFilter.class); Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; CharResponseWrapper wrapper new CharResponseWrapper((HttpServletResponse) res); long start System.currentTimeMillis(); chain.doFilter(req, wrapper); long cost System.currentTimeMillis() - start; byte[] content wrapper.getBufferContent(); String responseBody new String(content, wrapper.getCharacterEncoding()); // 注意日志里不要打印敏感字段如果要打印需要做脱敏处理 log.info(request uri: {}, cost: {}ms, response status: {}, body: {}, request.getRequestURI(), cost, wrapper.getStatus(), responseBody); res.getOutputStream().write(content); } }这里有个细节记录日志后要把内容原样写回给客户端否则前端啥也收不到。还有一种做法是把日志放在 Filter 链的最后一步前面的 Filter 已经写回过给客户端了那就不需要再写一次但一般业务系统中临时打印响应日志的 Filter 最好只放一个。在实际生产环境里我还会对响应体长度做一个截断比如超过 2000 字符的日志只打印前 2000 字符避免日志文件巨快膨胀。6.2 场景二敏感字段脱敏接口返回用户信息时手机号、身份证号等字段往往需要做部分脱敏。在 Controller 里逐字段处理又麻烦又容易漏一个通用 Filter 就能解决。原理是拿到响应 JSON 字符串用正则或 JSON 解析把特定字段替换掉String body new String(content, charset); // 这里根据具体需求解析 JSON比如使用 Jackson 或 Gson JsonNode root objectMapper.readTree(body); if (root.has(phone)) { String phone root.get(phone).asText(); root.put(phone, phone.substring(0, 3) **** phone.substring(7)); } String newBody objectMapper.writeValueAsString(root); response.setContentLength(newBody.getBytes(charset).length); response.getWriter().write(newBody);这里提醒一句千万不要用字符串替换的方式去匹配手机号文本很容易误伤数据本身包含的相同数字。一定要用 JSON 解析再修改。6.3 场景三HTML 页面注入公共脚本如果你的项目是一个传统多页应用服务端渲染想在所有页面上统一注入一段统计脚本逐个改模版不现实。在 Filter 中用 Wrapper 处理 HTML 响应就非常合适String body new String(content, charset); if (contentType ! null contentType.contains(text/html)) { String script script src/common/stats.js/script; if (body.contains(/body)) { body body.replace(/body, script /body); } response.setContentLength(body.getBytes(charset).length); response.getWriter().write(body); } else { response.getOutputStream().write(content); }这个操作的代价是需要完整页面渲染完成后才能操作占内存较高。页面很大时可以考虑用 5.4 节提到的方式做保护。6.4 场景四压缩响应减少流量消耗如果你的接口响应体很大且网络传输是瓶颈可以在 Filter 里对响应内容做 Gzip 压缩。注意这时候又要修改两个头部Content-Encoding: gzip和Content-Length压缩后的字节数。byte[] compress compress(content); response.setHeader(Content-Encoding, gzip); response.setContentLength(compress.length); response.getOutputStream().write(compress);要留意的是如果上游服务器如 Nginx已经开启了 Gzip你再压缩一次就会导致双重压缩徒增 CPU 开销。所以务必确认链路中是否已有 Gzip 处理。7. 常见问题与坑位实录这一节我把和 Wrapper 相关的典型问题整理成速查表你在自己动手时遇到类似问题可以直接对号入座。问题现象可能原因解决方案前端收到空响应Filter 中没有把缓存内容写回原始 response在chain.doFilter()之后调用response.getOutputStream()或getWriter()写出缓存数据响应乱码创建 PrintWriter 时未指定编码使用response.getCharacterEncoding()并默认 UTF-8 显式传给 OutputStreamWritergetWriter() has already been called某个组件调用了包装器的getWriter()后续又调用了getOutputStream()遵循 Servlet 规范一个响应周期内只调用一种流Content-Length 不匹配导致数据截断修改响应体后未更新 Content-Length修改后重新setContentLength(newBytes.length)大文件下载内存溢出包装器把整个响应体缓存进内存对大于阈值的响应直接跳过包装不过滤HttpServletResponse无法强转为自定义 Wrapper 类型下游代码拿到的是原始 response 而非包装器在需要转换的地方先判断实例类型并确保链中确实传入了包装器异步请求下响应丢失异步线程完成后 Filter 才写回时机不对在异步回调中处理内容写回或对异步接口跳过包装修改后的 JSON 无法被正常解析手工拼字符串导致 JSON 语法错误用 Jackson / Gson 解析之后再序列化不要用 replace 处理 JSON响应头丢失或错误只修改了 body 忘了设置响应头或某些 header 被原始响应提前提交在 Filter 中显式设置响应头避免在 Controller 中提前 flush7.1 为什么有时候 Controller 已经 flush 了Filter 里还是拿不到完整数据Servlet 规范里响应数据一旦flush会立即提交到客户端。此时如果 Filter 还没执行到chain.doFilter()后半段数据就已经在路上了你再想从包装器缓存里拿可能只能拿到 flush 之前的数据。所以关键操作是在你的包装器内部要重写flushBuffer()方法让它变成“只刷新到缓存”而不是真正刷给客户端Override public void flushBuffer() throws IOException { // 不调用原始 response 的 flush避免提前提交响应 if (writer ! null) { writer.flush(); } if (outputStream ! null) { outputStream.flush(); } }这样一来即使业务代码主动调用了response.flushBuffer()数据也只是刷到我们的ByteArrayOutputStream里不会真的发送给客户端。等到 Filter 最后我们再统一写回。同理sendError和sendRedirect这两个方法也会直接影响响应状态。如果你在 Wrapper 里希望修改响应内容理论上sendError之后就不能再写 body 了所以要根据业务决定是否代理这两个方法。7.2 多个 Filter 嵌套包装时注意顺序一个请求可能经过多个 Filter每个 Filter 都可能对响应做包装。假设 FilterA 包装了 responseFilterB 又包装了 FilterA 生成的包装器那么 FilterB 拿到的响应对象依然是代理链上的一个环节。此时FilterB 修改后的内容会写回给“下一层”的 response也就是 FilterA 的包装器而不是直接写给客户端。FilterA 在chain.doFilter()之后拿到的内容是经过 FilterB 修改的还是之前 FilterB 缓存后的内容这个要看 FilterB 实现细节。如果 FilterB 在最后写回了原始 response那么 FilterA 的包装器里能拿到修改后的完整内容。这种嵌套模式灵活但容易让人糊涂。我的建议是能合并到一个 Filter 的就不要拆成多个避免“你改了我的修改”这种混乱状态。7.3 用完了记得释放资源虽然ByteArrayOutputStream不需要显式关闭但包装器里的PrintWriter如果调用了close()会导致流不可继续写入。Servlet 容器在请求结束时会自己处理这些资源所以你不需要在 Filter 里调用 response 的close()或flushBuffer()否则会干扰后续容器写回过程。我见过最离谱的问题Filter 里wrapper.getWriter().close()之后再去原始 response 写入数据结果什么也写不出去因为 PrintWriter 的 close 又把 Stream 关了。记住一句话在 Filter 里只做数据的读取和最终写回不要关闭任何由容器管理的流对象。7.4 包装器内部不要持有太多引用如果你把HttpSession、ServletContext等大对象放进了包装器实现类会导致整个 Filter 链路上所有请求都背着这些大对象跑拖累性能。包装器应该保持轻量——只持有原始 response 和缓存流即可。8. 进阶与 Spring MVC 的结合以及拦截器方式的对比有的读者可能想问Spring MVC 已经有ControllerAdvice能处理响应体了还需要 Filter 吗这里做一个客观对比ControllerAdvice能在 Controller 返回后、响应写出前拿到数据例如ResponseBodyAdvice适合做统一的返回包装和加密。但它只对 Spring MVC 处理的接口生效如果项目里还有 Servlet、静态资源、第三方 Filter它们不会被ControllerAdvice拦截。Filter 是 Java Web 层面最底层的机制它能看到所有经过 Servlet 容器的请求/响应包括静态资源和自定义 Servlet。缺点是需要自己处理响应缓存和写回代码相对繁琐。如果项目里全是 Spring Boot 接口我推荐优先使用ControllerAdviceResponseBodyAdvice它更符合框架分层语义。但如果你有类似于“所有路径都要记录日志”的需求Filter Wrapper 无疑是最通用的。还有一点Spring 的ContentCachingResponseWrapper本质也是基于包装器模式它比我自己写的这个CharResponseWrapper多了一个便利方法copyBodyToResponse()可以直接把缓存内容写回。如果你用 Spring 框架直接拿它用也行。我在项目里封装过一个基于 Spring 的 Filter用的就是ContentCachingResponseWrapper完整结构类似Component public class ApiResponseModifyFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { ContentCachingResponseWrapper wrapper new ContentCachingResponseWrapper(response); try { filterChain.doFilter(request, wrapper); } finally { byte[] original wrapper.getContentAsByteArray(); byte[] modified process(original); if (modified ! null) { response.resetBuffer(); response.setContentLength(modified.length); response.getOutputStream().write(modified); } else { wrapper.copyBodyToResponse(); } } } }OncePerFilter保证了 Filter 只会执行一次避免在某些容器中重复执行的问题。9. 性能考量与经验总结使用包装器缓存完整响应本质上是在用内存换功能。在大多数业务接口上响应体往往只有几十 KB这点内存开销完全可接受但如果你把写日志、脱敏、统一包装等功能全都堆在同一个 Filter 里就需要特别关注以下几点评估接口响应体大小分布设置缓存阈值。对超过阈值的请求放行不修改或直接响应 413看具体业务。使用池化技术重用ByteArrayOutputStream的底层数组减少频繁扩容带来的复制开销。在业务高峰期观察 JVM 内存和 GC 日志确认没有因为响应缓存引入压力。如果确实要处理超大响应建议换成流式修改的思路。例如Gzip 压缩可以用GZIPOutputStream接到原始输出流上这样数据一边从 Servlet 端产生一边直接被压缩发送不需要整体缓冲。但这种方案只能处理“从无到有”的压缩无法对已有内容做查找替换或修改。10. 结束前的一点实操心得最后分享一些我在实际项目中总结出来的小经验。Filter 包装器的代码并不长但越是这种“短小精悍”的代码越容易在细节上出问题。我自己每次写完一个 Wrapper Filter都会做这么几件事情第一写一个单元测试。由于 Filter 需要 Servlet 容器环境测试相对繁琐但起码要把包装器本身的读写缓存逻辑用本地ByteArrayOutputStream测一遍。第二用真正的 HTTP 请求验证响应内容和 Content-Length 是否和前端预期一致。我会用curl -i观察响应头确认Content-Length、Content-Type、Content-Encoding等关键 header 没有异常。第三专门测试中文、Emoji 和换行符的场景。因为编码问题大多出现在这些特殊字符上。第四测试一下异常场景比如 Controller 抛出异常后Filter 拿到的响应内容是怎样的。如果包装器实现不健壮异常页面的内容也可能被错误修改掉。如果是在团队协作项目里使用 Wrapper我还会建议在代码注释里把“为什么重写 flushBuffer”“为什么不能关闭流”这些关键点写清楚防止后来维护的同事一不小心又踩坑。HttpServletResponseWrapper 本身只是一个很薄的包装层但用好了就能让你在请求-响应链路中拥有“上帝视角”。不管是稳定地记录日志、优雅地脱敏、还是统一包装接口响应它都能给你一个干净的通用解法。希望这篇文章能帮你在自己的项目里少走些弯路。

相关新闻

Ubuntu Server 18.04系统镜像备份:Systemback原理与实战指南

Ubuntu Server 18.04系统镜像备份:Systemback原理与实战指南

1. 项目概述:为什么在Ubuntu Server 18.04上坚持用Systemback做系统镜像,而不是直接用dd或Timeshift?Systemback这个工具,在2019年前后曾是Ubuntu桌面用户圈里一个近乎“神级”的存在——它能把整个运行中的系统打包成一个可启动的…

2026/10/17 12:26:50 阅读更多 →
Vue 集成 Dify 智能问答:流式对话与会话管理实战

Vue 集成 Dify 智能问答:流式对话与会话管理实战

简介:这份资源面向Vue前端开发者与客服系统搭建者,聚焦将Dify智能问答集成进Vue客服组件的完整落地实践,适合具备一定Vue基础、希望快速实现AI数字人客服的中级开发者。压缩包共10个文件,以6个js脚本、3个vue组件和1份md说明为主&…

2026/10/17 12:26:50 阅读更多 →
Hive导入导出CSV全攻略:从建表到踩坑实战

Hive导入导出CSV全攻略:从建表到踩坑实战

1. 项目背景与核心诉求解析做数据开发的同学,十有八九都跟CSV打过交道。CSV这玩意儿说简单也简单,就是个逗号分隔的纯文本文件,说麻烦也麻烦——编码问题、引号转义、换行符不一致,每一样都能折腾人半天。我自己在实际项目中接过不…

2026/10/17 12:25:50 阅读更多 →

最新新闻

强化学习策略优化:从单调提升理论到TRPO/PPO工程实践

强化学习策略优化:从单调提升理论到TRPO/PPO工程实践

强化学习学到这个阶段,大部分公式推导和代码实现都能跑通了,但有一类问题始终绕不过去:理论上讲,策略优化的目标应该让性能越改越好,实际训练里却经常出现"越改越差"。这个问题的系统解答,就是 M…

2026/10/17 13:30:35 阅读更多 →
BiliTools B站下载工具完整教程:视频、番剧、弹幕、无损音乐一次存进本地

BiliTools B站下载工具完整教程:视频、番剧、弹幕、无损音乐一次存进本地

BiliTools B站下载工具完整教程:视频、番剧、弹幕、无损音乐一次存进本地 【免费下载链接】BiliTools 本项目已停止维护。 项目地址: https://gitcode.com/GitHub_Trending/bilit/BiliTools 你想把一部番剧留在自己硬盘里,客户端却只给"缓存…

2026/10/17 13:30:35 阅读更多 →
Vue3+TypeScript实现MD5加密解密工具:算法原理与工程实践

Vue3+TypeScript实现MD5加密解密工具:算法原理与工程实践

做开发这些年,MD5 大概是碰得最频繁的“加密”工具之一。测接口签名、校验文件完整性、给测试数据造个定长指纹,随手就是一个 md5()。但真正要把这东西做成一个能上线的工具,尤其是用 Vue 3 和 TypeScript 从零写一个带“加密/解密”能力的在…

2026/10/17 13:30:35 阅读更多 →
DouK-Downloader 完整指南:5 分钟跑通抖音 / TikTok 视频下载与归档

DouK-Downloader 完整指南:5 分钟跑通抖音 / TikTok 视频下载与归档

DouK-Downloader 完整指南:5 分钟跑通抖音 / TikTok 视频下载与归档 【免费下载链接】TikTokDownloader 抖音 / TikTok 平台作品下载/数据采集工具 项目地址: https://gitcode.com/GitHub_Trending/ti/TikTokDownloader 想把这个账号的所有作品一次归档&…

2026/10/17 13:30:35 阅读更多 →
数字员工工作流实战:从IT工单到财务报销的可交付落地指南

数字员工工作流实战:从IT工单到财务报销的可交付落地指南

1. 这不是又一个“AI Agent教程”,而是一套可跑通、能交付、真落地的数字员工工作流 最近在几个技术社区和企业内训现场,反复被问到一个问题:“Hermes Agent到底能不能用?不是概念演示,是真能替人干活的那种。”我试过…

2026/10/17 13:30:35 阅读更多 →
2025人工智能指数报告解读:从趋势数据到工程决策

2025人工智能指数报告解读:从趋势数据到工程决策

简介:这份2025年人工智能指数报告由斯坦福大学人类中心人工智能研究所发布,是第八版AI指数报告,旨在为政策制定者、研究人员、企业高管、记者及公众提供关于全球AI发展态势的权威数据参考。压缩包内为1个PDF文件,大小29.46MB&…

2026/10/17 13:29:34 阅读更多 →

日新闻

DeepSeek选型与部署:V3/R1、Ollama、API全攻略

DeepSeek选型与部署:V3/R1、Ollama、API全攻略

简介:《DeepSeek使用方法.pdf》是一份面向DeepSeek入门及进阶用户的完整使用指南,重点围绕V3与R1两款模型的能力差异与适用场景展开,帮助读者根据任务类型与成本选择合适的模型。资源共1个PDF文件,压缩包大小约311KB,篇…

2026/10/17 0:00:25 阅读更多 →
200+Deepseek润色指令拆解:从底层原理到场景落地的完整指南

200+Deepseek润色指令拆解:从底层原理到场景落地的完整指南

简介:这是一份聚焦DeepSeek提示词优化的《200Deepseek润色指令》PDF文档,共收录两百余条可直接套用的润色指令,涵盖语法修正、风格统一、逻辑梳理、表达精炼等维度,并针对商务写作、学术论文、广告文案等场景给出差异化建议。文档…

2026/10/17 0:00:25 阅读更多 →
OpenCV轮廓法物体计数系统:轻量、可控、可落地的工业级实现

OpenCV轮廓法物体计数系统:轻量、可控、可落地的工业级实现

简介:本资源是一份基于OpenCV的本科课程设计完整报告,面向计算机视觉初学者、图像处理学习者及高校通信/信息类专业学生,解决物体智能计数与检测结果结构化记录的实际问题。项目覆盖图像预处理、轮廓检测、边界框绘制、轨迹栏交互式参数调节、…

2026/10/17 0:00:25 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/16 21:35:51 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/17 1:44:59 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/16 15:04:24 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 21:54:38 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 3:49:43 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/16 14:23:36 阅读更多 →