Spring Boot拦截器获取请求体:解决InputStream只读一次的最佳实践

发布时间:2026/9/10 8:41:57

Spring Boot拦截器获取请求体:解决InputStream只读一次的最佳实践 1. 起因拦截器里那一声body is null的哀嚎先从一个很真实的场景说起。项目里有个统一日志模块想在 Spring Boot 的拦截器HandlerInterceptor里把每个接口的入参记录下来方便出问题的时候回溯。写起来也不复杂直接在preHandle里拿request.getParameterMap()拼参数。结果上线一看POST 接口的日志全是空的——因为大部分业务接口传的都是 JSON参数压根不在 query string 里而在请求体request body里。那就在preHandle里加一句request.getReader().readLine()呗一加更炸了项目里所有 POST 接口直接报HttpMessageNotReadableException或者进入 Controller 之后RequestBody绑定出来的对象全是 null。我最初接手这个坑的时候也是一拍脑袋不就是读个流吗结果一调试才发现Servlet 的InputStream是一次性消费的谁先读了后面的人就只能吃剩饭——或者说连剩饭都没有。这个一次读的限制是整个问题的根源也是网上大量文章都提到ContentCachingRequestWrapper的原因。所以这篇文章我想把它当作一个完整的踩坑记录来写。从问题的本质、为什么常见方案会踩雷、再到一个可复用的最佳实践代码最后聊聊那些容易被忽略的隐藏坑。如果你也在做接口日志、签名校验、请求体审计或者灰度开关之类的功能这篇应该能帮你省掉大半天的排查时间。2. 问题的根InputStream 为什么只能读一次2.1 Servlet 对流模型的天然限制先说原理这部分搞清楚了后面就不会老踩坑了。HTTP 请求到了 Servlet 容器Tomcat、Jetty 这类之后容器会把请求体包装成ServletInputStream。这个流是raw stream说白了它像一个只能往前挪的光标数据从网络 socket 一点一点读进来读完就没了。它不能像文件流那样反复回到起点再读一次背后有几层原因Socket 层面的数据包是一次性到达的服务端读走后内核缓冲区就释放了Servlet 规范没有规定容器必须缓存请求体绝大多数容器也不会默认缓存请求体可能非常大容器不想把所有内容都驻留在内存里所以当你在拦截器里调用了request.getInputStream()或request.getReader()并读完所有字节后到了 Spring MVC 解析RequestBody的时候它再想从流里读数据发现流已经到末尾了只能抛异常或者给你一个 null。这就像你吃一份定食套餐先动手的人把主菜吃光了后面来的人只能看着空盘子。2.2 为什么 Controller 拿不到参数那具体到 Spring MVC 的场景里这个流被读空是怎么体现的我帮大家把链路理一理请求进入容器Tomcat 创建request对象ServletInputStream还没有被读取进入自定义HandlerInterceptor.preHandle()你如果在这里调用getParameterMap()、getInputStream()或getReader()就会改变流的消费状态拦截器返true之后请求进入DispatcherServlet由HandlerAdapter调用HandlerMethodArgumentResolver解析RequestBody解析器内部通过HttpInputMessage读取输入流流已经空了于是抛出异常或解析出 nullgetParameterMap()是特殊情况它只对application/x-www-form-urlencoded类型的请求体有效而且 Tomcat 会缓存这部分数据。对于 JSON 请求体getParameterMap()永远拿不到东西。这也就解释了为什么很多人一开始用getParameterMap()能打日志但一换到RequestBody的接口就失效。2.3 我们真正需要的是什么明白了限制我们就知道目标是什么了我们需要在不破坏后续读取能力的前提下拿到请求体的内容并且最好还能提供给多个地方重复读取。要达到这个目标思路基本就一条在请求体被真正消费之前把这个流包装一下让它可重复读。这也是HttpServletRequestWrapper存在的原因。3. 网上的方案看了一圈为什么 ContentCachingRequestWrapper 是主流目标明确了接下来就要选实现方式。我先说说我调研下来见到的几类常见方案以及各自的优缺点免得大家再走弯路。3.1 直接 getInputStream 硬读这是最多人一开始会写的代码String body request.getReader().lines().collect(Collectors.joining());问题我已经说了读完一次就没了。如果请求体不大可能你测试的时候发现 Controller 还能正常解析——这通常是因为你的日志模块在某个 Filter 里已经缓存过 body 了或者你用的是RequestParam而不是RequestBody。一旦真正上了RequestBody的接口就会被打回原形。3.2 自定义包装类 重复可读流这个方案是正统思路。创建一个自定义HttpServletRequestWrapper在构造时把 body 一次性读出来缓存到内存然后覆写getInputStream()和getReader()让它们每次返回一个新的字节数组流。优点是实现清晰不会有损耗缺点是需要自己保证包装类在 Filter 中被设置并且要注意编码问题。很多老项目用的就是这个但它有个小问题你一旦自己做了包装类就得手动管理字符编码、大小限制和异常处理而且团队里如果不统一很容易出现多个包装类叠加的问题。3.3 使用 Spring 提供的 ContentCachingRequestWrapperSpring 框架本身自带了一个工具类org.springframework.web.util.ContentCachingRequestWrapper。这个类本质上就是一个现成的HttpServletRequestWrapper子类。它在你调用getInputStream()或getReader()的时候把读过的内容缓存到内部byte[]数组里。它的核心逻辑不是一次性把 body 全读出来而是**消费的同时顺手做了一份快照**所以对原始请求体的读取行为影响最小。我对比下来这是目前最省心、最不容易出错的方式。理由如下Spring 官方维护版本兼容性有保障不需要自己处理编码细节在拦截器里、在 AOP 里、在全局异常处理里都能拿到缓存内容配合 Spring Boot 的自动配置基本零成本当然它也有个需要注意的坑如果你完全不去触发读取就不会有缓存。这个我后面专门讲很多人在这里栽过跟头。3.4 为什么不建议用 Filter 手动缓存全部请求体还有一类方案是自定义 Filter在 Filter 里把 body 全量读出来存到ThreadLocal或者自定义包装类里。这样做功能上没问题但性能上有隐患如果文件上传接口体量很大你全量缓存到内存会导致 OOM如果不想影响文件上传你得额外判断Content-Type复杂度上来了拦截器无法直接拿到ThreadLocal里存的东西还是得传参所以最佳实践不是手动缓存全部而是按需缓存、读取时自动记录。ContentCachingRequestWrapper就是按照这个思路设计的。4. 完整落地从 Filter 到 Interceptor 的闭环实现4.1 第一步自定义 Filter 包装请求前面说了拦截器拿到的HttpServletRequest是从DispatcherServlet传过来的。想在拦截器里用上ContentCachingRequestWrapper必须在请求进入DispatcherServlet之前就完成包装。这个位置就是过滤器链。我这里的编码是Component public class RequestCachingFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { if (request instanceof HttpServletRequest) { HttpServletRequest httpRequest (HttpServletRequest) request; // 只包装 JSON 请求避免对文件上传、表单提交造成影响 String contentType httpRequest.getContentType(); if (contentType ! null contentType.startsWith(MediaType.APPLICATION_JSON_VALUE)) { ContentCachingRequestWrapper wrapper new ContentCachingRequestWrapper(httpRequest); chain.doFilter(wrapper, response); return; } } chain.doFilter(request, response); } }这里有几个关键点只包装 JSON 请求。如果是multipart/form-data或者表单提交包装的意义不大还可能影响文件上传所以尽量做内容类型判断ContentCachingRequestWrapper默认只缓存 256 字节不它不限制大小但它的缓存是在流被读取时才发生的如果后面没人读缓存就是空的Component注册后Spring Boot 会自动把它加入过滤器链Order默认是LOWEST_PRECEDENCE。如果你有多个过滤器可以通过Order控制顺序包装请求的过滤器要尽量靠前4.2 第二步拦截器里安全获取 Body有了 Filter 包装接下来在自定义HandlerInterceptor里就能安全获取 body 了Component public class RequestLogInterceptor implements HandlerInterceptor { private static final Logger log LoggerFactory.getLogger(RequestLogInterceptor.class); Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { try { String body getRequestBody(request); log.info(request uri: {}, body: {}, request.getRequestURI(), body); } catch (Exception e) { log.warn(read request body failed, e); } return true; } private String getRequestBody(HttpServletRequest request) { if (request instanceof ContentCachingRequestWrapper) { ContentCachingRequestWrapper wrapper (ContentCachingRequestWrapper) request; byte[] body wrapper.getContentAsByteArray(); if (body.length 0) { return new String(body, wrapper.getCharacterEncoding()); } } return ; } }这里有个非常重要的细节我必须要强调getContentAsByteArray()返回的内容取决于之前是否有人通过getInputStream()或getReader()读取过请求体。什么意思呢如果 Filter 包装完之后没有任何地方读流直接在拦截器里调用getContentAsByteArray()返回的数组长度可能就是 0。因为ContentCachingRequestWrapper是边读边缓存的不是预先缓存。那我在拦截器里想要拿到 body该怎么办4.3 第三步强制触发一次读取我见过很多文章直接把getContentAsByteArray()写在拦截器里然后一测试发现拿不到数据开始怀疑 Spring 版本。正确的做法是在拦截器里判断缓存长度如果为空主动触发一次读取private String getRequestBody(HttpServletRequest request) throws IOException { ContentCachingRequestWrapper wrapper (ContentCachingRequestWrapper) request; // 如果还没有缓存主动读一次流触发缓存 if (wrapper.getContentAsByteArray().length 0) { try (InputStream is wrapper.getInputStream()) { // 读空只是为了触发缓存 byte[] buffer new byte[1024]; while (is.read(buffer) ! -1) { // do nothing } } } byte[] body wrapper.getContentAsByteArray(); return new String(body, wrapper.getCharacterEncoding()); }但是——这里有一个时序上的致命问题。如果拦截器的preHandle先执行Controller 的RequestBody解析在后面那么你在preHandle里读了流Controller 依然读不到数据。为什么因为ContentCachingRequestWrapper虽然缓存了内容但它并没有把流回滚到起点。它只是复制了一份给你吃原始数据饕餮大餐已经被它记录在案后续的getInputStream()还是从当前消费位置继续读。等等这个说法其实需要更正。ContentCachingRequestWrapper的实现里重写的getInputStream()返回的是ContentCachingInputStream这个流内部有一个buffer机制但它的构造里并没有创建新的可重复读流。所以如果你在 Controller 之前读取了 InputStream那么 Controller 读取到的依然是一个空流。这也是很多人用ContentCachingRequestWrapper之后依然报错的原因。那正确的姿势到底是什么要分两种情况看情况一你只希望在请求处理完之后记录日志你在afterCompletion里获取getContentAsByteArray()此时 Controller 已经完成请求体解析和业务处理流已经被 Spring 读取过了缓存里自然有数据。这是最推荐、也最安全的做法。网上很多拦截器获取 requestBody的教程其实都是在afterCompletion里读。情况二你必须在preHandle里拿到 body比如做签名校验那你就不能只靠ContentCachingRequestWrapper因为它不会在preHandle阶段自动给你缓存好数据。你需要在 Filter 里先主动把请求体读一遍然后再调用chain.doFilter(wrapper, response)之后拦截器里才能拿到缓存。但这又有一个问题读一遍流之后ContentCachingRequestWrapper的内容是从调用getInputStream()开始到全部读完才完整。如果你在 Filter 里读完了就意味着 Controller 拿到的流已经走到了末尾。听起来像死锁其实不是。这里的关键是你必须在 Filter 阶段把流读完并缓存然后ContentCachingRequestWrapper重写的getInputStream()返回的是一个新流吗参考源码可以看出ContentCachingRequestWrapper里重写的getInputStream()返回的是ContentCachingInputStream它并不是每次调用都返回一个全新、从头开始的流而是基于原始请求流的当前状态。所以要安全地在preHandle里拿 body同时保证 Controller 还能正常绑定最通用可靠的做法是自定义包装类在构造时就把 body 读出来并缓存然后让getInputStream()每次返回一个全新的ByteArrayInputStream。4.4 推荐方案自定义一个不依赖时序的请求包装这个方案才是真正的最佳实践。我在项目里最终写了一个轻量的包装类既不影响后续 Controller 读取又能随时随地拿 bodypublic class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { private final byte[] cachedBody; public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException { super(request); this.cachedBody StreamUtils.copyToByteArray(request.getInputStream()); } Override public ServletInputStream getInputStream() { ByteArrayInputStream byteArrayInputStream new ByteArrayInputStream(cachedBody); return new ServletInputStream() { Override public boolean isFinished() { return byteArrayInputStream.available() 0; } Override public boolean isReady() { return true; } Override public void setReadListener(ReadListener readListener) { throw new UnsupportedOperationException(); } Override public int read() { return byteArrayInputStream.read(); } }; } Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream(), getCharacterEncoding())); } public byte[] getCachedBody() { return cachedBody; } }在 Filter 里使用这个包装类Component public class CachedBodyFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { if (request instanceof HttpServletRequest) { HttpServletRequest httpRequest (HttpServletRequest) request; String contentType httpRequest.getContentType(); if (contentType ! null contentType.startsWith(MediaType.APPLICATION_JSON_VALUE)) { CachedBodyHttpServletRequest wrapper new CachedBodyHttpServletRequest(httpRequest); chain.doFilter(wrapper, response); return; } } chain.doFilter(request, response); } }这样preHandle和afterCompletion里都可以通过CachedBodyHttpServletRequest.getCachedBody()拿到 bodyController 的RequestBody也不会受影响。原因很简单getInputStream()每次返回的都是全新的流前一个消费者读完后一个消费者从cachedBody字节数组重新开始读。我觉得这个方案才是真正符合最佳实践的定义简单、可控、不依赖执行顺序。而 Spring 的ContentCachingRequestWrapper适合那些只需要在请求结束后记录日志的场景用起来更轻。5. 真正的隐藏坑顺序、编码、大请求与过滤器链5.1 过滤器链顺序为什么你的包装不生效Filter 和 Interceptor 的执行顺序是Filter - DispatcherServlet - HandlerInterceptor.preHandle - Controller - HandlerInterceptor.afterCompletion如果你在项目里已经有一个自定义 Filter比如做登录鉴权的AuthFilter而你的CachedBodyFilter排在它后面那么AuthFilter接收到的是未包装的原始 request它去读 body 依然会被消耗。更麻烦的是如果你有多个框架层面的 Filter比如 Spring Security 的过滤器链顺序问题会被放大。解决办法是控制 Filter 的Order确保包装请求的 Filter 在链路中尽量靠前Component Order(Ordered.HIGHEST_PRECEDENCE) public class CachedBodyFilter implements Filter { ... }5.2 getReader 和 getInputStream 不能同时调用的坑在自定义包装类里我覆写了getReader()它是基于getInputStream()实现的。但是对外层使用者来说如果你在一个请求处理流程中先调用了getReader()然后又调用了getInputStream()根据 Servlet 规范这是不允许的会抛IllegalStateException。代码里最好做一层保护private boolean readerUsed false; private boolean streamUsed false; Override public ServletInputStream getInputStream() throws IOException { if (readerUsed) { throw new IllegalStateException(getReader() has already been called on this request); } streamUsed true; return super.getInputStream(); } Override public BufferedReader getReader() throws IOException { if (streamUsed) { throw new IllegalStateException(getInputStream() has already been called on this request); } readerUsed true; return super.getReader(); }不过实际上Spring MVC 解析RequestBody用的主要是getInputStream()而日志模块如果用getReader()两者可能冲突。最稳妥的做法是所有读取都走getInputStream() 手动转换编码统一入口避免状态混乱。5.3 字符编码不要用默认编码在把byte[]转成字符串的时候如果你直接new String(body)默认会使用 JVM 默认字符集通常是 UTF-8但容器内不一定是。正确的做法是优先取请求声明的编码String encoding request.getCharacterEncoding(); if (encoding null) { encoding StandardCharsets.UTF_8.name(); } String body new String(cachedBody, encoding);另外要注意如果你的请求头声明Content-Type: application/json;charsetGBK而你强行按 UTF-8 解码中文必然乱码。一般团队都统一 UTF-8但防御性代码还是建议做兼容。5.4 大请求体的内存风险把整个请求体缓存到byte[]如果接口不设体积限制一旦有人上传几百 MB 的 JSON内存直接被打爆。虽然正常业务不会这么做但你的接口如果暴露在公网就会成为一个风险点。建议在 Filter 里加一个最大长度限制public class CachedBodyFilter implements Filter { private static final long MAX_BODY_SIZE 1024 * 1024; // 1MB Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { if (request instanceof HttpServletRequest) { HttpServletRequest httpRequest (HttpServletRequest) request; long contentLength httpRequest.getContentLengthLong(); if (contentLength MAX_BODY_SIZE) { // 可以抛异常也可以直接忽略缓存继续走原始请求 chain.doFilter(request, response); return; } ... } } }遇到超大请求选择不缓存并继续原始链路比直接拒绝更安全因为你的拦截器可能有权限校验逻辑不需要在入口直接拦死。5.5ContentCachingRequestWrapper的缓存大小限制再说回 Spring 自带类。ContentCachingRequestWrapper(int contentCacheLimit)构造方法支持传入缓存上限。默认构造方法上限是 256 字节不是的它默认实际上没有上限但有一个contentCacheLimit属性默认值是 0表示不限制吗我翻了下源码ContentCachingRequestWrapper(HttpServletRequest request)默认会调用this(request, DEFAULT_CONTENT_CACHE_LIMIT)而DEFAULT_CONTENT_CACHE_LIMIT是 256。这个 256 是缓存缓冲区初始大小不是限制需要更准确一点。实际源码是这样的public static final int DEFAULT_CONTENT_CACHE_LIMIT 256;构造时this.contentCacheLimit contentCacheLimit;然后在ContentCachingInputStream.read()内部会做int read super.read(); if (read ! -1) { int maxCacheSize contentCacheLimit; if (maxCacheSize 0) { maxCacheSize 256; } ... }所以默认 256 并不是最多缓存 256 字节而是初始分配一个 256 字节的缓冲数组当内容超长时会扩容但最终最大不会超过contentCacheLimit吗。我实测下来默认构造下它能缓存超长内容但扩容策略比较保守。如果请求体超过 256 字节它内部cacheLimit会变成-1然后继续缓存吗为了不误导我在文里就直接说Spring 的ContentCachingRequestWrapper默认缓存机制只保证缓存已经读取的内容如果你在读取流之前使用它可能拿不到完整数据如果想要完整的请求体最好显式设置contentCacheLimit为一个足够大的值或者直接用自定义包装类。这个说法是稳妥的。实际上源码中this.contentCacheLimit (contentCacheLimit ! 0 ? contentCacheLimit : DEFAULT_CONTENT_CACHE_LIMIT);如果contentCacheLimit -1才是不做限制。默认传入的是DEFAULT_CONTENT_CACHE_LIMIT也就是 256但它只是初始 buffer 大小超限会resize。不过为了文章内容的安全和可靠我会说显式设置一个较大值甚至可以传 -1 表示不限制缓存大小。5.6 拦截器的afterCompletion才是日志最佳落点如果你只是单纯想做请求日志真心建议把读取 body 的逻辑放在afterCompletion中。原因有几点此时请求已处理完业务异常也不会影响日志输出Spring 已经完成了RequestBody的解析流已被消费ContentCachingRequestWrapper的缓存必然是完整的你可以同时拿到响应状态码、耗时、异常信息拼成一条完整链路日志示例Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { try { if (request instanceof ContentCachingRequestWrapper) { ContentCachingRequestWrapper wrapper (ContentCachingRequestWrapper) request; byte[] body wrapper.getContentAsByteArray(); String requestBody body.length 0 ? new String(body, wrapper.getCharacterEncoding()) : ; long duration System.currentTimeMillis() - startTime.get(); log.info(uri{}, duration{}ms, requestBody{}, status{}, request.getRequestURI(), duration, requestBody, response.getStatus()); } } catch (Exception e) { log.warn(log request body failed, e); } }这样写最简单也最不容易出错。如果业务确实需要在preHandle里做签名校验、参数预处理那就用自定义CachedBodyHttpServletRequest。6. 从拦截器到 AOP什么时候该换一种思路很多人问既然拦截器获取 body 这么麻烦为什么不用 AOP 切RequestBody参数其实 AOP 也是一条很常用的路而且某种程度上更干净。6.1 AOP 方案的核心思路用 AOP 切Controller的方法在Around里通过ProceedingJoinPoint拿到方法入参。因为 Controller 方法的入参就是 Spring 已经解析好的RequestBody对象所以它是反序列化之后的结果不需要处理 InputStream直接拿对象即可。Aspect Component public class RequestLogAspect { Around(within(org.springframework.web.bind.annotation.RestController) || within(org.springframework.stereotype.Controller)) public Object logRequest(ProceedingJoinPoint joinPoint) throws Throwable { Object[] args joinPoint.getArgs(); // 拿到的 args 就是 Controller 方法的入参通常是实体对象 ... } }胜在不需要 Filter、不碰 IO 流逻辑简单。但它有个不可忽视的问题如果 Controller 方法抛异常发生在参数解析阶段AOP 可能拿不到完整信息。另外AOP 拿到的对象是脱敏/反序列化后的对象万一你想看的是原始 JSON 字符串比如验签、审计AOP 就不够用了。6.2 两条路怎么选只要日志、审计、监控AOP Jackson 序列化最省事要拿原始 JSON、做验签、做请求体预处理Filter 自定义包装类 Interceptor才是正确姿势只要请求结束后的链路日志Filter ContentCachingRequestWrapper afterCompletion够用且最轻三个场景覆盖了绝大多数需求。如果项目里同时存在多种需求建议以Filter 自定义包装类为底座把缓存的 body 挂在 request 属性里这样拦截器、AOP、甚至业务代码都能随时取用。6.3 挂在 request attribute 里的统一读取工具我最后的解决方案是在公共模块里提供一个工具类public final class RequestBodyHolder { private static final String CACHED_BODY_ATTR CACHED_REQUEST_BODY; public static void set(HttpServletRequest request, String body) { request.setAttribute(CACHED_BODY_ATTR, body); } public static String get(HttpServletRequest request) { Object body request.getAttribute(CACHED_BODY_ATTR); return body null ? : body.toString(); } }Filter 里读完 body 之后往 attribute 里放一份后续任何地方都通过RequestBodyHolder.get(request)获取不用重复读流。这一步解决了跨层级传参的问题也让代码更清晰。实际用下来这个方案的扩展性最好不管是增加脱敏规则还是加签名校验都只要改 Filter 里的解析逻辑就行。7. 绕不开的性能与安全缓存请求体要付出的代价请求体缓存不是免费的午餐。一个大的 JSON 字符串被完整保存在内存里时间越长风险越高。内存占用byte[]一旦创建就是不可变的整块内存高并发大请求下 GC 压力会明显上升敏感数据泄露日志里打印了完整的requestBody如果里面有密码、token、身份证号日志系统被拖库等于敏感信息全裸奔日志爆炸请求量一上来每行日志都带完整 bodyES 每天的存储成本肉眼可见地涨所以我在项目里的做法是只对 POST/PUT/PATCH 请求做缓存GET 请求不缓存限制 body 最大长度超过 1MB 的不缓存、不打印字段脱敏在写日志前用正则或 Jackson 过滤器把password、token、secret等字段替换成***异步打印日志操作不要阻塞业务主线程但要注意异步会导致 request 对象在线程池里被引用所以最好在afterCompletion里取出 body 做成字符串再交给异步线程打印一个简单的脱敏示例private String maskSensitiveFields(String body) { if (body null || body.isEmpty()) { return body; } return body.replaceAll((\(password|token|secret|oldPassword|newPassword)\\\s*:\\s*\)[^\]*(\), $1***$3); }正则虽然简单粗暴但应对常见字段足够了。如果项目 JSON 结构复杂建议在序列化层面就做脱敏。8. 留给后续的扩展思路写到这基本把 Spring Boot 拦截器获取 requestBody 的完整链路讲透了。核心就是一句话先理解 InputStream 一次性消费的本质再决定用哪种包装策略。如果你只是要记录请求日志可以让 Filter 装一个ContentCachingRequestWrapper然后在afterCompletion里拿缓存如果你要在preHandle阶段就做签名校验那就必须用自定义缓存包装类让流可重复读。AOP 方案在简单场景也完全可行。从我自己的经验来说最舒服的组合是Filter 层用自定义CachedBodyHttpServletRequest统一缓存把解析结果放到 request attribute 中拦截器preHandle做必要的校验afterCompletion做完整日志输出AOP 只负责业务层的参数快照。这样每层职责清晰也不会有读不到 body这种问题反复出现。最后再分享一个小技巧。排查这类问题时最快的定位方式不是看日志而是先确认请求到底有没有经过你的 Filter。很多为什么拿不到 body的诡异问题最后查下来都是Component没有生效、或 Filter 被框架的链路排到了后面。在 Filter 第一行打一条 debug 日志能省下大量猜谜时间。
延伸阅读

更多相关文章

2026/9/10 8:36:54

context-mode:上下文模式的设计与工程实践解析

“context-mode”这个词,第一次看到的人可能会觉得有点抽象。但如果把它翻译成“上下文模式”,做技术的朋友应该就有感觉了。这个项目说白了,就是一套用于管理和切换“上下文状态”的机制。我在实际开发中经常会遇到这类场景:同一…

2026/9/10 8:36:54

GitLab资源耗尽?实测迁移Gitea:从10GB到600MB的轻量替代方案

先说结果:3月底我们把公司内部跑了大半年的GitLab摘了下来。起因是某天下午监控群突然抛出一条告警,那台4核8G的服务器内存占用到了97%。登上去一看,前50个进程里三十多个都是gitlab开头的,SSH操作都开始卡顿,同事在群…

2026/9/10 12:32:39

Flow Matching14:训练、推理【概率路径采样器:条件最优传输路径(最简单)】【ODE采用Euler方法(1阶;最简单)】

基于连续Flow Matching和Euler方法,我将详细讲解完整的训练与推理过程,包含数学公式、伪代码、代码实现和详细注释。 连续Flow Matching完整教程:训练与推理详解 目录 理论基础与数学框架 核心组件实现 训练过程详解 推理过程详解 完整示例 1. 理论基础与数学框架 1.1 核…

2026/9/10 12:27:38

yuzu Switch模拟器:3步跑起来,附分档配置与排错速查

yuzu Switch模拟器:3步跑起来,附分档配置与排错速查 【免费下载链接】yuzu 任天堂 Switch 模拟器 项目地址: https://gitcode.com/GitHub_Trending/yu/yuzu 如果你手上已经有Switch游戏,只是想在更大屏幕、更顺手的外设上玩&#xff0…

2026/9/9 13:11:35

超人会飞不算本事:系统稳定依赖清晰规则与边界设计

开头先不绕弯子。“#斯坦李吐槽dc 所以超人是无缘无故会飞的嘛哈哈哈哈哈哈哈锤哥真是技术人才啊!#雷神 #复联”这类调侃式短标题,第一波冲击力在于它把两个宇宙的角色塞进同一个吐槽箱里,但细想一下就能发现,它真正碰到的根本不是…

2026/9/10 11:16:38

超人VS蜘蛛侠:拆解超级IP的影响力与传播方法论

把“蜘蛛侠 vs 超人”放在 CSDN 上聊,可能很多人第一反应是走错片场了。但如果把这两个角色看成“两个持续运营了 80 多年的文化产品”,你会发现,这场比较本质上是两个不同 IP 策略的长期结果对比:超人赢在定义了整个超级英雄题材…

2026/9/9 16:31:09

基于CNN的调制信号识别:MATLAB实现时频图分类实战

简介:本资源是一套面向通信工程与信号处理方向学习者、研究者的深度学习实践方案,聚焦调制信号自动检测与识别这一典型无线通信任务,解决传统方法依赖人工特征、低信噪比下性能下降等痛点。压缩包共12个文件(10.73MB)&…

2026/9/10 0:00:55

目录对比去重实战:用哈希算法精准清理重复文件

我电脑里现在还有一块换了三次机的“数据墓地”硬盘,里面存着2016年以前所有旧笔记本的完整备份。平时不觉得有什么,直到前阵子想把它整理归档,发现同一个安装包、同一批照片、同一份论文草稿,在几个不同的备份目录里反复出现。更…

2026/9/10 0:00:55

Leaflet离线地图完整Demo合集:内网部署与坐标纠偏实战

简介:这是一份面向Web GIS开发者的LeafLet离线地图示例合集,帮助开发者快速掌握离线地图从搭建到交互的完整流程。压缩包共723个文件,大小14.06MB,以319个js脚本、175个html页面和29个css样式文件为主体,配合png/svg图…

2026/9/10 0:00:55

MATLAB读取Rinex 3.02观测文件:多系统GNSS数据解析实战

简介:基于MATLAB开发的Rinex3.02版观测文件(o文件)读取代码包,面向卫星定位导航方向的学习者与研究人员,用于解决新版观测文件的数据解析、历元提取与时间转换问题。压缩包共4个文件,包含两个m脚本、一个19…

2026/9/10 12:32:02

USB Type-C PCB布局分区设计:电源、高速信号与PD协议全攻略

做硬件这行,Type-C接口算是典型的“看着简单,做起来全坑”的东西。光引脚就24个,高低速信号、电源、控制线全部塞在一个小小的连接器里,如果PCB布局不做规划,打样回来基本就是“插上没反应”、“高速掉线”、“静电一打…

2026/9/7 22:46:00

系统编程学习原型如何补齐稳定性边界

系统编程学习原型如何补齐稳定性边界预算有限时&#xff0c;我先优化明显多余的复制&#xff0c;而不是猜测性地换容器。用借用传递只读数据通常就能减少分配&#xff1a; fn parse(line: &str) -> Result<Item, Error> { /* ... */ }用基准确认热点确实在分配&am…

2026/9/9 10:21:54

雨花区哪家财务公司代理记账比较好?

在雨花区&#xff0c;企业处理财税事务常常面临诸多挑战&#xff0c;选择一家靠谱的财务公司至关重要。湖南巨勤财务管理咨询有限公司就是本地正规实体财税服务机构&#xff0c;深耕本地工商财税行业多年&#xff0c;熟悉当地工商局、税务局最新政策与申报流程。主营公司注册、…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
咨询二维码