当@RequestBody遇上Request:数据去哪儿了?

当@RequestBody遇上Request:数据去哪儿了?

一、问题引入

宝子们,今天来和大家聊聊我在日常开发中遇到的一个超让人迷惑的问题。不知道你们有没有遇到过,在使用@RequestBody注解之后,request里的数据就好像 "人间蒸发" 了一样,啥都没有了😱。这可把我折磨惨了,调试了好久,查阅了各种资料,才终于搞明白其中的缘由。今天就来给大家分享一下这个问题,希望能帮助到同样被这个问题困扰的小伙伴们🧑‍💻。接下来,就让我们一起深入探究一下@RequestBody之后,request里为什么 "什么都没有了"。

二、@RequestBody 与 Request 简介

(一)@RequestBody 是什么

在 Spring MVC 和 Spring Boot 开发中,@RequestBody注解可是个 "狠角色"🤩。它主要用于读取 HTTP 请求体(Body)中的数据,并将其通过HttpMessageConverter(通常是 Jackson)反序列化为 Java 对象。简单来说,就是当客户端发送请求时,@RequestBody可以帮我们把请求体中的数据(比如 JSON 格式的数据)转化为 Java 对象,方便我们在后端进行处理。

举个例子,在一个前后端分离的项目中,前端以 JSON 格式发送用户注册信息:

json 复制代码
{
    "username": "张三",
    "password": "123456",
    "email": "zhangsan@example.com"
}

后端的 Controller 层可以这样接收:

java 复制代码
@PostMapping("/register")
public String register(@RequestBody User user) {
    // 这里的user就是反序列化后的Java对象
    userService.register(user);
    return "注册成功";
}

在这个例子中,@RequestBody就像是一个 "翻译官",把前端传来的 JSON 数据准确无误地 "翻译" 成我们后端可以操作的 Java 对象。它最常用于处理Content-Type: application/json的请求,是实现现代前后端分离架构(RESTful API )中最核心的注解之一。

(二)Request 的基本概念

在 Java Web 开发中,HttpServletRequest是一个非常重要的接口😎。它代表客户端向服务器发送的 HTTP 请求,Servlet 在收到请求后会创建一个HttpServletRequest实例,并把它作为参数传递给 Servlet 的service()doGet()doPost()等方法。简单来说,HttpServletRequest就像是一个 "快递包裹",里面装着客户端发送给服务器的所有信息,包括请求行(方法、路径、协议)、请求头(Headers)、请求参数(query string + form data)、请求体(JSON、文件、二进制)、Cookie、Session 以及客户端信息(IP、User - Agent 等)。

我们可以通过它的各种方法来获取这些信息,比如:

  • getParameter(String name):获取请求参数,比如String username = request.getParameter("username");

  • getHeader(String name):获取请求头信息,例如String userAgent = request.getHeader("User - Agent");

  • getMethod():获取请求的 HTTP 方法,像String method = request.getMethod();(返回值可能是GETPOST等)

  • getInputStream():获取请求的输入流,用于处理 POST 请求等,当请求体是输入流形式时就可以用这个方法。

HttpServletRequest在整个 Web 请求处理过程中扮演着至关重要的角色,是我们与客户端请求数据交互的重要桥梁。

三、数据丢失现象呈现

(一)实际案例展示

下面给大家分享一个我在实际项目中遇到的案例。在一个电商项目的订单创建接口中,前端会发送包含订单信息(如商品列表、用户信息、收货地址等)的 JSON 数据到后端。后端的 Controller 代码大致如下:

java 复制代码
@RestController
@RequestMapping("/order")
public class OrderController {

    @Autowired
    private OrderService orderService;

    @PostMapping("/create")
    public String createOrder(@RequestBody Order order, HttpServletRequest request) {
        // 这里尝试从request中获取一些额外的参数,比如用户的IP地址
        String userIp = request.getRemoteAddr();
        System.out.println("用户IP: " + userIp);

        // 调用服务层创建订单
        orderService.createOrder(order);
        return "订单创建成功";
    }
}

当我满怀信心地进行测试时,却发现一个严重的问题😱!在createOrder方法中,通过request.getRemoteAddr()获取用户 IP 地址时,得到的竟然是null。这就很奇怪了,明明前端已经发送了请求,@RequestBody也能正常接收订单数据,为什么request里的其他数据好像 "消失" 了呢🤔?我进一步查看日志,发现没有任何报错信息,只是数据获取不到,这让排查问题变得更加困难。

(二)引发的问题思考

这种数据丢失的现象对开发的影响可不小😖。首先,它会导致业务逻辑无法正常进行。就像上面的订单创建接口,获取不到用户 IP 地址,可能会影响后续的一些业务操作,比如根据用户 IP 地址进行地域统计分析,或者记录用户操作日志时缺少关键信息。其次,数据处理也可能出现错误。如果在后续的代码中依赖request里的数据进行计算或判断,而这些数据丢失了,就会导致计算结果错误或判断逻辑失误,从而影响整个系统的稳定性和可靠性。这不仅会增加开发人员的调试成本,还可能给用户带来不好的体验,甚至造成业务损失。所以,我们必须要深入探究这种现象背后的原因,找到解决办法。

四、深度剖析数据丢失原因

(一)HTTP 协议的流式传输特性

HTTP 协议是基于请求 - 响应模型的应用层协议,其中请求数据是通过流式传输的🧐。这意味着请求体中的数据就像一条源源不断的 "数据流",从客户端流向服务器。当服务器端读取这个数据流时,就像从水管中取水一样,一旦读取,数据就会从 "水管" 中流过,无法再次回到水管中供下次读取。

在 HTTP 协议中,输入流数据一旦被读取,就会被消耗掉。这是因为 HTTP 协议设计的初衷是为了高效地传输数据,避免数据在内存中不必要的重复存储。当@RequestBody注解读取请求体数据时,它实际上是从请求的输入流中读取数据,并将其反序列化为 Java 对象。在这个过程中,输入流中的数据被读取并处理,之后如果再尝试从request中读取数据,就会发现数据已经被 "读光" 了,自然就 "什么都没有了"😅。就好比你一次性把杯子里的水喝完了,再去看杯子,肯定是空的啦。这种流式传输特性在大多数情况下能够满足我们的需求,但在一些特殊场景下,就可能会引发数据丢失的问题。

(二)Servlet 中 Request 的工作机制

在 Java Servlet 中,request.getInputStream()方法用于获取请求的输入流,以便读取请求体中的数据。然而,这个方法有一些特殊的调用逻辑和限制🤔。当我们调用request.getInputStream()时,Servlet 容器会创建一个ServletInputStream对象,它继承自InputStream。这个ServletInputStream对象负责从底层的网络连接中读取数据,并提供给我们的应用程序。

但问题来了,ServletInputStream并没有实现reset()方法,这意味着一旦输入流被读取到末尾,就无法将读取位置重置到开头,从而无法再次读取数据😖。当@RequestBody注解调用request.getInputStream()读取数据时,输入流的指针会移动到数据末尾,后续再调用request.getInputStream()或者其他依赖输入流的方法时,就会因为指针已经在末尾而无法读取到任何数据。

而且,Servlet 中对于请求数据并没有内置的缓存机制。不像我们日常用的缓存工具,它不会自动帮我们把读取过的数据存起来以备后用。所以,一旦数据被读取,就没有其他地方可以找回这些数据了,除非我们自己手动缓存。这就像是你看完一本书后,没有把它放在书架上(缓存起来),下次再想看的时候,就找不到它了。这就是为什么在使用@RequestBody之后,request里的数据会丢失的重要原因之一。

五、解决数据丢失问题的方法

(一)使用 HttpServletRequestWrapper

既然我们知道了数据丢失的原因是因为request的输入流不可重复读取,那么有没有办法解决这个问题呢🧐?答案是肯定的!我们可以使用HttpServletRequestWrapper类来解决这个问题。HttpServletRequestWrapperHttpServletRequest的一个包装类,它提供了一种简单的方式来扩展或修改HttpServletRequest的行为。

我们可以通过继承HttpServletRequestWrapper类,并重写它的getInputStream()getReader()方法,来实现对输入流数据的缓存和多次读取。具体来说,我们在自定义的HttpServletRequestWrapper类中,在构造函数中读取一次输入流的数据,并将其缓存起来(比如保存在一个字节数组中)。然后在重写的getInputStream()getReader()方法中,返回从缓存中读取数据的输入流和读取器,这样就可以实现对输入流数据的多次读取了,就像给输入流数据找了一个 "小仓库",把数据存起来,什么时候想用都能拿出来😎。

(二)代码示例与详细步骤

下面给大家上代码,看看具体是怎么实现的👇。

  1. 自定义 HttpServletRequestWrapper 类
java 复制代码
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;

public class BodyReaderHttpServletRequestWrapper extends HttpServletRequestWrapper {
    // 用于存储请求体数据的字节数组
    private final byte[] body;

    public BodyReaderHttpServletRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        // 读取请求体数据并存储到body数组中
        body = getBodyString(request).getBytes();
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        final ByteArrayInputStream bais = new ByteArrayInputStream(body);
        return new ServletInputStream() {
            @Override
            public int read() throws IOException {
                return bais.read();
            }

            @Override
            public boolean isFinished() {
                return false;
            }

            @Override
            public boolean isReady() {
                return false;
            }

            @Override
            public void setReadListener(ReadListener readListener) {
                // 不实现
            }
        };
    }

    @Override
    public BufferedReader getReader() throws IOException {
        return new BufferedReader(new InputStreamReader(getInputStream()));
    }

    // 获取请求体数据的工具方法
    private String getBodyString(HttpServletRequest request) throws IOException {
        StringBuilder sb = new StringBuilder();
        BufferedReader reader = request.getReader();
        String line;
        while ((line = reader.readLine()) != null) {
            sb.append(line);
        }
        return sb.toString();
    }
}

在这个类中,我们首先定义了一个body字节数组来存储请求体数据。在构造函数中,通过调用getBodyString方法读取请求体数据,并将其转换为字节数组存储在body中。然后重写getInputStream方法,返回一个从body数组中读取数据的ByteArrayInputStream,这样就实现了对输入流数据的缓存读取。重写getReader方法,通过调用重写后的getInputStream方法来获取读取器,确保读取的数据是从缓存中获取的。

  1. 获取输入流内容的工具类(上面已经包含在自定义类中,这里单独提取出来讲解)
java 复制代码
import javax.servlet.ServletRequest;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

public class HttpHelper {
    public static String getBodyString(ServletRequest request) throws IOException {
        StringBuilder sb = new StringBuilder();
        BufferedReader reader = request.getReader();
        String line;
        while ((line = reader.readLine()) != null) {
            sb.append(line);
        }
        return sb.toString();
    }
}

这个工具类中的getBodyString方法用于从ServletRequest中读取请求体数据。它通过创建一个BufferedReader,逐行读取请求体数据,并将其拼接成一个字符串返回。这个方法在自定义的HttpServletRequestWrapper类的构造函数中被调用,用于获取并存储请求体数据。

  1. 配置过滤器
java 复制代码
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class RequestBodyCacheFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 使用自定义的HttpServletRequestWrapper包装原始request
        HttpServletRequest wrappedRequest = new BodyReaderHttpServletRequestWrapper(request);
        // 将包装后的request传递给过滤器链
        filterChain.doFilter(wrappedRequest, response);
    }
}

这里我们创建了一个过滤器RequestBodyCacheFilter,它继承自OncePerRequestFilter,保证每个请求只会被该过滤器执行一次。在doFilterInternal方法中,我们使用自定义的BodyReaderHttpServletRequestWrapper类包装原始的HttpServletRequest,然后将包装后的request传递给过滤器链。这样,在后续的处理中,所有对request的读取操作都会从我们缓存的数据中获取,从而避免了数据丢失的问题。

通过以上三步,我们就成功地解决了@RequestBody之后request数据丢失的问题。小伙伴们可以根据自己的项目需求,将这些代码集成到项目中,让你的项目更加健壮和稳定💪。

六、总结与拓展

(一)回顾核心内容

今天我们深入探讨了在使用@RequestBody之后,request里数据丢失的问题。我们先了解了@RequestBody的作用是将 HTTP 请求体中的数据反序列化为 Java 对象,而HttpServletRequest则代表了客户端发送的 HTTP 请求,包含了各种请求信息。接着通过实际案例展示了数据丢失的现象,以及这种现象对开发造成的业务逻辑和数据处理方面的影响。

在剖析原因时,我们发现 HTTP 协议的流式传输特性以及 Servlet 中Request的工作机制是导致数据丢失的关键因素。HTTP 请求体数据通过流式传输,一旦被读取就无法再次读取,而 Servlet 中request的输入流没有内置缓存机制且不可重复读取,这就使得@RequestBody注解读取数据后,request里的数据好像 "消失" 了。

为了解决这个问题,我们使用了HttpServletRequestWrapper类,通过继承并重写其getInputStream()getReader()方法,实现了对输入流数据的缓存和多次读取。并通过自定义HttpServletRequestWrapper类、获取输入流内容的工具类以及配置过滤器这三个步骤,成功解决了数据丢失的问题。小伙伴们一定要记住这些关键知识点,在实际开发中遇到类似问题时,能够快速定位并解决。

(二)拓展思考

在实际开发中,遇到类似问题时,我们可以从多个角度进行思考和排查。首先,要深入理解相关技术的原理和工作机制,比如 HTTP 协议、Servlet 规范等,这有助于我们从根本上分析问题产生的原因。其次,要善于利用日志和调试工具,通过打印日志信息和使用调试工具,我们可以更清楚地了解程序的执行过程和数据的变化情况,从而快速定位问题所在。另外,多参考官方文档和优秀的开源项目也是一个很好的方法,这些资源中往往包含了许多解决常见问题的最佳实践和经验总结。

希望今天的分享能对大家有所帮助,如果你在开发中也遇到了有趣的问题或者有更好的解决方案,欢迎在评论区留言分享,让我们一起学习,共同进步💖!

相关推荐
yige453 小时前
SpringBoot 集成 Activiti 7 工作流引擎
java·spring boot·后端
G探险者3 小时前
SQL 性能优化实战:一次压测 404 的根因追查与解决
后端
人间打气筒(Ada)3 小时前
如何使用 Go 更好地开发并发程序?
开发语言·后端·golang
honor_zhang3 小时前
Spring Boot集成Websocket服务以及连接时需要注意的问题
spring boot·后端·websocket
陈随易3 小时前
深度拆解技术架构的三大鸿沟:企业级Claw vs OpenClaw的工程差异
前端·后端·程序员
qq_256247053 小时前
穿透网络壁垒:在 Docker 中配置 OpenClaw 实现带状态的网页自动化
后端
奕成则成3 小时前
Redis List 全面总结(含底层+命令+实战+调优)
后端
Cache技术分享4 小时前
361. Java IO API - 了解文件存储设备
前端·后端
神奇小汤圆4 小时前
Spring Kafka @KafkaListener源码剖析
后端