1. 内容整体设计与思路拆解
1.1 为什么请求响应是JavaWeb的命脉
做JavaWeb开发,不管你是用SSH还是SSM,后面再时髦一点用Spring Boot,本质上都是在处理一件事:接收客户端发来的请求,处理完业务,再把结果返回给客户端。这一来一回,就是请求与响应的全部。我在带新人或者自己复盘的时候,发现很多初学者框架用得很溜,注解随手就写@RequestMapping,但问到“请求参数到底是怎么绑定的”“重定向和转发到底有什么本质区别”,往往答不上来。这其实就是地基没打牢。
Day05这篇笔记,我打算彻底把请求响应这条链路撕开揉碎。不是纯讲API,也不是上来就贴代码,而是从一个完整的请求生命周期出发,从客户端发起到服务端接收,再到业务处理完毕写回响应,把每一个环节里Servlet容器帮忙做了什么、哪些得我们自己动手,全部理清楚。
1.2 一条请求的完整旅程:从URL到响应体
我习惯用一条实际发生的请求来开场。假设浏览器地址栏输入http://localhost:8080/student/list?page=1&size=10,回车之后发生了什么?
- 浏览器根据域名解析出IP,和服务器建立TCP连接(HTTP/1.1默认长连接,所以不一定每次都有三次握手,但概念上要理解)。
- 浏览器把请求行、请求头、请求体(如果是GET,请求体为空)打包成一个符合HTTP协议格式的报文,发给服务器。
- 服务器上的Web容器(比如Tomcat)接住这个TCP报文,解析出
HttpServletRequest对象,同时准备一个空的HttpServletResponse对象。 - 容器根据URL映射规则找对应的Servlet(或者DispatcherServlet之类的入口),调用
service()方法,进而分发到doGet()或者doPost()。 - 我们的业务代码从
request里拿参数、拿Header、拿Session,处理完之后往response里写状态码、写Header、写响应体。 - 容器把
response里的内容按照HTTP协议格式拼成响应报文,发回给客户端。 - 浏览器收到响应,根据Content-Type决定是解析HTML、渲染图片、还是触发下载。
这一条链路下来,你会发现Servlet容器帮我们处理了最底层的Socket通信和协议解析,我们写代码时操作的是封装好的request和response。理解了这个大框架,再去看具体API就有一种“原来如此”的通透感。
1.3 学习路线规划:先全局再细节
我的笔记从来不是一上来就罗列方法,而是先画全局,再钻细节。这篇的安排是这样:
- 先把HTTP协议里请求方和响应方各自该干什么讲清楚,因为代码里的方法名都是从协议来的,协议懂了,方法不用背。
- 然后分别进入
HttpServletRequest和HttpServletResponse两大块,把高频方法分成“获取行”“获取头”“获取体”三个维度去记忆。 - 再单独把转发和重定向拿出来做对比,因为这是面试最爱问、也是新手最容易搞混的地方。
- 最后用一顿“包含请求响应全流程的示例”把知识串起来,再加上我实际开发里踩过的坑,整理成问题速查表。
这五个部分走下来,不敢说你立刻成为请求响应专家,但至少你在写代码的时候,脑子里的路径是清晰的,不再是“这里好像要写个getParameter,但为什么是这样,不知道”。
2. 核心概念与API详解:HttpServletRequest实战拆解
2.1 获取请求行:三兄弟的用途区别
请求行是HTTP报文的第一行,长这样:
GET /student/list?page=1&size=10 HTTP/1.1它由三部分组成:请求方式、请求URI、协议版本。在HttpServletRequest里有对应的方法去拿这三样东西:
// 获取请求方式,会得到 "GET" 或 "POST" String method = request.getMethod(); // 获取请求URI,会得到 "/student/list",注意是带ContextPath的 String requestURI = request.getRequestURI(); // 获取协议版本,会得到 "HTTP/1.1" String protocol = request.getProtocol();很多新手会疑惑getRequestURI()和getRequestURL()的区别,前者返回的是不带域名和端口的路径部分,比如/student/list;后者返回的是完整URL,比如http://localhost:8080/student/list。这俩在写权限拦截、做URL重写的时候有微妙差别,我后面在问题排查里会细说。
还有一个高频方法getQueryString(),拿的是?后面的原始字符串。不过实际开发中我们很少直接用这个原始串,而是用getParameter()去取解析后的单个值。
2.2 获取请求头:从Header中挖掘隐藏信息
请求头是HTTP报文里“键值对”格式的元信息区域,用来告诉服务器客户端的偏好和能力。比如:
Host: localhost:8080 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Accept: text/html,application/xhtml+xml Accept-Language: zh-CN,zh;q=0.9 Cookie: JSESSIONID=AB12CD34EF56在Servlet里获取请求头有两种姿势:
// 方式一:按名称获取单个头信息 String userAgent = request.getHeader("User-Agent"); // 方式二:获取所有请求头的枚举集合 Enumeration<String> headerNames = request.getHeaderNames(); while (headerNames.hasMoreElements()) { String headerName = headerNames.nextElement(); String headerValue = request.getHeader(headerName); System.out.println(headerName + ": " + headerValue); }实际开发中,我常用User-Agent做浏览器类型判断,用Referer做简单的防盗链(虽然不够安全但简单有效),用Accept判断客户端期望的响应格式。在微服务里,还会把用户身份信息放进自定义Header(比如X-User-Id)在服务间传递。刚入门时建议把上面遍历代码跑一遍,你会对客户端到底发了什么有个直观感知。
2.3 获取请求体:POST参数与IO流读取
GET请求没有请求体,参数在URL上;POST请求参数放在请求体里,格式可以是application/x-www-form-urlencoded(表单默认)、multipart/form-data(文件上传)、application/json(AJAX提交)等。
对于普通表单提交,getParameter()能直接拿到值,因为容器帮你把请求体解析好了。但这里有个关键前提:容器只对application/x-www-form-urlencoded这种格式做自动解析,如果你用AJAX提交JSON格式,那getParameter()直接拿不到,必须自己读流:
// 读取JSON格式请求体内容 BufferedReader reader = request.getReader(); StringBuilder jsonBuilder = new StringBuilder(); String line; while ((line = reader.readLine()) != null) { jsonBuilder.append(line); } String json = jsonBuilder.toString();这个“只能读一次流”的坑后面会细讲,这里先记住:读流这种操作很敏感,读完之后再想拿就没了。很多框架(比如Spring MVC)帮你封装好了JSON解析,但底层原理就是在干这件事。
2.4 参数接收:getParameter的底层逻辑与中文乱码根源
getParameter()大概是Servlet里使用频率最高的方法,没有之一。每次写接口,第一步就是拿参数。但很多人不知道,这个方法本质上是容器对请求体(或查询字符串)解析后,存入一个内部Map里的便捷封装。
// 接收单个参数 String username = request.getParameter("username"); // 接收同名多值参数(比如多选框提交的多个爱好) String[] hobbies = request.getParameterValues("hobby"); // 获取所有参数名 Enumeration<String> parameterNames = request.getParameterNames();中文乱码问题几乎是每个初学JavaWeb的人必经的噩梦。根源在于HTTP协议默认的编码是ISO-8859-1(Latin-1),不支持中文,而现代浏览器和服务器约定用UTF-8。GET请求乱码发生在Tomcat 8之前,因为URI编码默认是ISO-8859-1,Tomcat 8之后默认改成UTF-8了;POST请求乱码则是因为请求体解析用的字符集默认不是UTF-8,所以必须手动指定:
// POST请求必须在读取任何参数之前设置编码 // 注意:必须放在第一次getParameter之前,否则无效 request.setCharacterEncoding("UTF-8");一个记忆方法是踩坑总结出来的:谁先动手解析数据,谁就得先告诉它用什么编码。容器解析请求体的时候如果发现你已经设置了字符集,就用你设置的;如果没设置,就用平台默认的。所以配置的黄金位置就是Servlet里的第一行代码,或者一个过滤器里。
2.5 域对象存储:在请求范围内传递数据
除了拿客户端的数据,request还能当作一个“临时储物柜”,在一次请求范围内传递数据。转发之前的参数传递就是靠这个实现的:
// 存数据 request.setAttribute("student", studentObj); // 取数据,注意是Object类型,需要强转 Student student = (Student) request.getAttribute("student");这个机制特别适合MVC架构中,Controller把查询结果放进request域,然后在JSP页面里用EL表达式${student.name}取出来渲染。生命周期很清晰:请求开始->存数据->转发->页面渲染->请求结束->数据销毁。记住这句话:request域适合一次请求内的数据传递,绝不能跨请求使用。
3. 响应核心机制:HttpServletResponse完全指南
3.1 响应行与状态码:别再用数字硬记了
HTTP响应报文也有三部分:状态行、响应头、响应体。状态行长这样:
HTTP/1.1 200 OK里面的200就是状态码,OK是原因短语。在HttpServletResponse里,设置状态码的方法有两种:
// 方式一:直接设置数字 response.setStatus(404); // 方式二:使用内置常量(推荐,可读性好) response.setStatus(HttpServletResponse.SC_NOT_FOUND);实际开发中常见的状态码就那几个:200成功、302重定向、304未修改(走缓存)、400请求参数错误、401未认证、403无权限、404找不到资源、500服务器内部错误。很多新手不管三七二十一,业务失败就返回200然后在响应体里写个“失败”,这种做法在前后端分离的项目里其实不推荐。正确的做法是:业务成功与否,HTTP状态码该是什么就是什么,4xx、5xx要大胆用,这样前端才能统一走错误拦截逻辑。
3.2 响应头设置:Content-Type是重中之重
响应头里最重要的就是Content-Type,它告诉客户端响应体的媒体类型和字符集。没设置这个头,浏览器拿到响应体完全不知道该用HTML解析还是用文本解析,就会乱给你看。
JavaWeb里设置Content-Type的方法很直白:
// 告诉浏览器返回的是HTML,且用UTF-8编码显示 response.setContentType("text/html; charset=UTF-8"); // 返回JSON格式数据 response.setContentType("application/json; charset=UTF-8"); // 返回普通文本 response.setContentType("text/plain; charset=UTF-8");这里有一个很多人忽略的点:setContentType里的charset=UTF-8不仅影响响应头的值,还会影响getWriter()往输出流写字符串时使用的编码。也就是说,你设了UTF-8,response.getWriter().write("中文")写出去的就是UTF-8编码的字节流。这就是为什么很多中文响应乱码,只要把setContentType里的charset加上,问题就迎刃而解的原因。
其他常用响应头还有:
Content-Length:响应体长度,某些场景下手动设置可以提高性能。Location:配合302状态码实现重定向的跳转地址。Refresh:定时刷新或跳转,老门户网站用得比较多。Cache-Control:控制浏览器缓存策略,前后端分离的项目经常设成no-cache。
3.3 响应体输出:Writer与OutputStream的殊途同归
写响应体就两个出入口:getWriter()返回PrintWriter,适合输出字符文本;getOutputStream()返回ServletOutputStream,适合输出二进制数据(图片、文件下载、PDF等)。
// 字符流输出HTML response.setContentType("text/html; charset=UTF-8"); PrintWriter writer = response.getWriter(); writer.write("<html><body>"); writer.write("<h1>学生列表</h1>"); writer.write("</body></html>"); // 字节流输出文件下载 response.setContentType("application/octet-stream"); response.setHeader("Content-Disposition", "attachment; filename=report.xlsx"); ServletOutputStream out = response.getOutputStream(); out.write(fileBytes); out.flush();注意一个硬性规定:写数据时只能选一个出口,不能同时用Writer和OutputStream,否则会抛IllegalStateException。我自己刚开始学的时候犯过这个错,往响应里又写文本又写二进制,结果直接爆异常,排查了很久。
还有一个细节:用完输出流要记得flush()和close()。不flush的话,数据可能还留在缓冲区里,特别是大数据量输出时,必须边写边flush。
3.4 重定向:让浏览器帮你去另一个地方
重定向的实现方式我最早记的方法很蠢,是死记sendRedirect这个单词。后来理解了HTTP状态码,就再也没忘过:
// 方式一:最常用的重定向方法 response.sendRedirect("/student/login"); // 方式二:等价的手写方式(了解原理,不推荐直接用) response.setStatus(302); response.setHeader("Location", "/student/login");sendRedirect的本质就是在响应里写上302状态码和Location头。响应回到浏览器后,浏览器看到302,会读Location头,然后自动发一个全新的GET请求去访问那个新地址。所以,重定向之后你在请求域里存的值全部丢失,因为这其实是“第二次请求”了。
重定向有个坑:如果Location写的是/student/login这种以斜杠开头的地址,它是相对于服务器根路径的,即http://localhost:8080/student/login。但你的项目如果部署在http://localhost:8080/myapp下,这个地址就会跳错,正确处理要先拼上上下文路径:
// 拼上上下文路径,保证不管项目部署在哪都正确 response.sendRedirect(request.getContextPath() + "/student/login");4. 转发与重定向:新手最容易混淆的两兄弟
4.1 转发的完整使用姿势
转发在代码里不是response干的事,而是通过RequestDispatcher来做的。标准三段式:
// 1. 往request域里塞数据 request.setAttribute("studentList", list); // 2. 获取转发器 RequestDispatcher dispatcher = request.getRequestDispatcher("/student/list.jsp"); // 3. 执行转发 dispatcher.forward(request, response);转发是服务端内部行为,浏览器全程不知道发生了什么。客户端只发了一次请求,服务端内部把请求交给了另一个资源(另一个Servlet、JSP、静态页面)去处理,然后把这个资源的响应当作自己的响应发回去。所以转发前后,request还是同一个对象,request域里存的数据也还在,浏览器地址栏不会发生变化。
4.2 核心差异对照表
这个表我希望初学者能抄到笔记本上,或者存到备忘录里。它解答了90%的“转发重定向区别”问题:
| 对比维度 | 转发(forward) | 重定向(sendRedirect) |
|---|---|---|
| 发生位置 | 服务器内部 | 服务器指示浏览器,浏览器二次请求 |
| 请求次数 | 1次 | 2次 |
| request对象 | 同一个,域数据可共享 | 不同,域数据丢失 |
| 地址栏变化 | 不变 | 变成新地址 |
| 速度 | 快 | 慢(多一次网络往返) |
| 使用场景 | 服务端跳转JSP页面 | 登录后跳转、表单提交后跳转(防重复提交) |
| 是否能跨项目 | 不能 | 能,可跳转到外部网站 |
4.3 开发中如何选型
我个人在实际开发里选型就两条原则:
原则一:能用转发绝不重定向,除非必须刷新浏览器地址。查询列表走增删改查,查询结果展示用转发,地址栏保持/list不变,用户刷新不会重复提交表单。但如果是表单提交之后(比如新增、修改、删除),处理完必须重定向,否则用户按F5刷新会再次提交同一份表单,造成重复数据。这就是著名的Post/Redirect/Get模式。
原则二:重定向的目标可以是外部URL,而转发不行。如果你要跳到另一个系统的页面,或者跳到第三方支付平台,就只能用重定向。转发只能在当前Web应用内部完成跳转,这是硬限制。
看起来好像转发更优越,实际用起来却不是这样。我在做一个校园二手交易平台模拟项目时,用户登录成功后如果直接转发到首页,那么用户按F5刷新页面,浏览器会再次发出登录请求的POST信息(用户在页面里看到的还是登录那个URL),这既不安全也不是我们想要的结果。后来改成重定向到首页,用户地址栏变成/首页URL,再刷新也无压力,问题彻底解决。这类经验不做一次完整项目很难体会到。
4.4 一个必须掌握的细节:转发与include的区别
有人会问,RequestDispatcher还有include()方法,和forward()长得很像。区别在于:forward是“整个请求都交给下一个资源,当前资源不再输出任何内容”;include是“当前资源先输出一部分,再把另一个资源的输出包含进来,最终一起回给浏览器”。实际工作中include用得少,但了解它在页面布局拆分上有奇效:比如一个公共的页头和页脚,可以在多个页面里include进来自动拼装。
5. 请求响应全流程示例:一个学生管理系统的登录与列表
5.1 项目背景与功能设计
光说理论空洞,我拿一个非常经典的“学生管理系统”里登录和列表两个功能,演示请求响应全流程怎么串起来。项目背景是某高校内部的一个选课系统简化版,学生通过自己的学号和密码登录,登录成功后看到课程列表。
技术选型很老派但适合教学:Servlet + JSP + JDBC,不用任何框架。之所以这么选,是因为框架封装了太多细节,初学者很难看到请求到底是怎么被Servlet容器处理、如何在Servlet和JSP之间流转的。
5.2 前端表单:GET与POST的正确使用
登录页面是个最简单的HTML表单,重点是method属性:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>学生登录</title> </head> <body> <h2>学生登录</h2> <form action="/studentSystem/login" method="post"> <p>学号:<input type="text" name="username"></p> <p>密码:<input type="password" name="password"></p> <p><button type="submit">登录</button></p> </form> </body> </html>这里用POST是有讲究的。登录信息属于敏感信息,如果用GET,学号和密码会出现在URL上(浏览器历史、服务器日志都会留下痕迹),风险很大。虽然HTTP本身不加密,但至少减少暴露面。当然真正的安全还得上HTTPS,那不在本笔记范围。
5.3 登录Servlet:从请求中取参并校验
接下来写处理登录的Servlet。核心逻辑分为三步:拿参数、校验、决定跳转方式。
@Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 第一步:处理POST请求中文乱码,必须在读取参数之前设置 request.setCharacterEncoding("UTF-8"); // 第二步:从请求体里取参数 String username = request.getParameter("username"); String password = request.getParameter("password"); // 第三步:模拟数据库校验(真实项目会用JDBC去查表) boolean loginSuccess = "2023001".equals(username) && "123456".equals(password); if (loginSuccess) { // 登录成功:把用户信息放进会话域 request.getSession().setAttribute("loginUser", username); // 使用重定向,防止刷新页面时重复提交登录请求 response.sendRedirect(request.getContextPath() + "/student/list"); } else { // 登录失败:转发回登录页,并携带错误提示 request.setAttribute("errorMsg", "学号或密码错误,请重试!"); request.getRequestDispatcher("/login.jsp").forward(request, response); } }这段代码演示了请求处理的两大分支:失败时用转发,因为我们需要把errorMsg放进request域带到JSP页面,并且地址栏保持/login;成功时用重定向,因为跳转到新URL后用户刷新不会再次提交POST表单。一个简单的登录功能,把请求参数获取、中文乱码处理、域对象存值、转发重定向选择全给串起来了。
5.4 列表Servlet:请求行、请求头、参数的综合读取
列表功能相对复杂一点,涉及分页参数的读取、请求头的判断、数据查询和响应输出。这里用一个简化版演示:
@Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 第一步:读取分页参数,给默认值防止空指针 int page = 1; int size = 10; String pageStr = request.getParameter("page"); String sizeStr = request.getParameter("size"); if (pageStr != null && !pageStr.isEmpty()) { page = Integer.parseInt(pageStr); } if (sizeStr != null && !sizeStr.isEmpty()) { size = Integer.parseInt(sizeStr); } // 第二步:从请求头读取User-Agent,记录请求来源设备信息 String userAgent = request.getHeader("User-Agent"); System.out.println("请求来源设备信息:" + userAgent); // 第三步:模拟查询数据库获取学生列表(真实场景是JDBC/MyBatis查询) // 这里直接用固定数据表示 List<String> studentList = new ArrayList<>(); studentList.add("张三"); studentList.add("李四"); studentList.add("王五"); // 第四步:把数据放到request域,转发给JSP渲染 request.setAttribute("studentList", studentList); request.setAttribute("page", page); request.setAttribute("size", size); request.getRequestDispatcher("/student/list.jsp").forward(request, response); }这个Servlet把所有请求相关的操作都涵盖了:请求行相关的隐含信息(通过URI映射)、请求头读取、参数接收、域存储、转发。JSP页面那边通过EL表达式把studentList循环渲染成表格,代码就不展示了,重点是理解数据在request域内的流动。
5.5 响应完整的代码示例:JSON输出与文件下载
前面的例子都是配合JSP做页面渲染,但前后端分离的今天,直接输出JSON才是日常。下面这段代码展示如何用response写一个规范的JSON响应:
@Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 设置响应类型为JSON response.setContentType("application/json; charset=UTF-8"); // 2. 构造一个简单的JSON字符串(真实项目会用Jackson或FastJSON) Map<String, Object> result = new LinkedHashMap<>(); result.put("code", 200); result.put("message", "查询成功"); result.put("data", "张三"); StringBuilder json = new StringBuilder(); json.append("{"); json.append("\"code\":").append(result.get("code")).append(","); json.append("\"message\":\"").append(result.get("message")).append("\","); json.append("\"data\":\"").append(result.get("data")).append("\""); json.append("}"); // 3. 通过字符流输出 PrintWriter writer = response.getWriter(); writer.write(json.toString()); writer.flush(); }文件下载是响应的另一个典型应用。核心就是Content-Disposition响应头,告诉浏览器“这是个附件,请下载而不是打开”:
// 文件下载的响应头设置 response.setContentType("application/octet-stream"); response.setHeader("Content-Disposition", "attachment; filename=student_info.xlsx"); // 真实项目里这里会从服务器读取文件字节流,这里省略 byte[] bytes = new byte[]{}; ServletOutputStream out = response.getOutputStream(); out.write(bytes); out.flush(); out.close();6. 常见问题排查与避坑指南:那些年我们一起踩过的坑
6.1 乱码问题的终极排查清单
乱码问题我用一个自制的排查清单来解决,以后遇到乱码照这个顺序查,基本不慌:
| 症状 | 排查方向 | 解决办法 |
|---|---|---|
| POST请求中文变问号 | 服务器读取请求体时用的编码不对 | 在读取参数前调用request.setCharacterEncoding("UTF-8") |
| GET请求中文乱码(Tomcat 7及以下) | URI解码默认字符集不是UTF-8 | 修改Tomcat的conf/server.xml,在Connector上加URIEncoding="UTF-8" |
| 响应中文变乱码 | 响应Content-Type缺少charset | 设置response.setContentType("text/html; charset=UTF-8") |
| 数据库存进去乱码 | JDBC连接字符集不对 | 连接URL上加characterEncoding=UTF-8 |
| JSP页面乱码 | JSP文件的page指令编码和文件编码不一致 | 统一设置<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> |
6.2 流只能读一次:那令人崩溃的IllegalStateException
第一次遇到“重复读取请求体”问题的时候,我排查了整整一个下午。场景是:过滤器里想读一次请求体做日志记录,然后业务Servlet又要读一次请求体拿参数,结果Servlet那边读出来全是空。
原因很简单:request.getInputStream()或者getReader()返回的流,一旦读完就结束了,再次调用读不到任何内容。而Servlet容器不会帮你重新创建一个流。解决思路有两个:
方案一:用包装类重写getInputStream(),把读到的内容缓存一份,后续再次读取时从缓存里返回。这是过滤器里常用的ContentCachingRequestWrapper的思路,也是Spring框架的默认做法。
方案二:只在业务代码里读一次流,把读出来的字符串缓存到自定义对象里,后续需要数据时从缓存里取。
新手阶段知道“只能读一次”这个限制就可以了,等做过滤器拦截器的时候再深入实现方案。
6.3 响应提交之后的修改:丢掉的异常信息
还有一个常见的低级错误:在响应已经提交(response.isCommitted()返回true)之后,再去调用sendRedirect()或者setHeader()。这会导致异常,因为状态行和响应头已经发出去了,无法再修改。
// 错误示范:数据已经输出,再重定向就晚了 PrintWriter writer = response.getWriter(); writer.write("<html><body>"); writer.flush(); // 下面的代码会抛异常,因为响应已经提交了 response.sendRedirect("/other/page");正确做法是:在响应流输出开始前就决定好是重定向、转发还是直接输出内容,不要在中间切换。业务逻辑有分支就提前选好路径,不要一边写数据一边做跳转。
6.4 getParameter读取JSON的秘密
我在前后端分离的项目里踩过一个印象特别深的坑:前端用Axios发了一个JSON格式的POST请求,后端Servlet里用getParameter()想拿用户名,怎么拿都是null。
原因前面已经提过:容器只对application/x-www-form-urlencoded类型的请求体做解析,JSON不在自动解析范围内。所以要手动读流再解析JSON。这里补全一个完整示例:
// 读取JSON请求体的正确姿势 StringBuilder sb = new StringBuilder(); try (BufferedReader reader = request.getReader()) { String line; while ((line = reader.readLine()) != null) { sb.append(line); } } // 拿到JSON字符串之后,用Jackson或Gson解析成Java对象 // 这里示例用Gson Gson gson = new Gson(); Student student = gson.fromJson(sb.toString(), Student.class);6.5 重定向丢失数据:通过URL携带参数的替代方案
重定向有一个讨厌的特性:request域数据全部丢失。那如果重定向后的页面确实需要一些数据怎么办?两个办法:
办法一:把数据拼到URL的查询字符串里,但要注意只适合短小非敏感的数据:
response.sendRedirect(request.getContextPath() + "/student/detail?id=" + studentId);办法二:放到Session域里,用完记得移除,否则Session里垃圾数据越堆越多:
request.getSession().setAttribute("tempStudent", student); response.sendRedirect(request.getContextPath() + "/student/detail"); // 注意:detail页取完数据后一定要移除 // session.removeAttribute("tempStudent");这两种方案我都有实际用到,具体选哪个取决于数据量和敏感性。
6.6 路径问题:什么时候加斜杠,什么时候不加
路径问题是新手绕不过的坎。我总结了一套自己的经验:
| 场景 | 写法 | 说明 |
|---|---|---|
| 转发路径 | /student/list.jsp | 相对于Web应用根路径(不包含项目名) |
| 重定向路径 | request.getContextPath() + "/student/list" | 必须以/开头,并拼上上下文路径 |
| HTML表单action | /studentSystem/login | 相对于服务器根路径,要包含项目名 |
| 静态资源(CSS/JS) | ${pageContext.request.contextPath}/css/style.css | JSP页面必须拼项目名,否则后台路由匹配时资源404 |
| web.xml中URL映射 | /student/list | 必须以/开头,相对于应用根路径 |
这里面最容易出问题的是HTML里的绝对路径。如果你在JSP页面写<link href="/css/style.css">,而项目部署在http://localhost:8080/studentSystem下,浏览器访问的CSS地址就是http://localhost:8080/css/style.css,少了项目名studentSystem,导致404。一个通用解法是JSP页面顶部加:
<% String basePath = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + request.getContextPath() + "/"; %> <head> <base href="<%=basePath%>"> </head>这个base标签会让页面里所有相对路径都基于它解析,彻底根治路径混乱问题。
6.7 不要忽略的调试神器:请求转储
最后分享一个排查请求响应问题的调试技巧。当你遇到“前端传的参数后端点拿不到”这类问题时,别急着改代码,先把原始请求报文打出来看看。
// 打印请求行 System.out.println("Method: " + request.getMethod()); System.out.println("URI: " + request.getRequestURI()); System.out.println("QueryString: " + request.getQueryString()); // 打印所有请求头 Enumeration<String> headers = request.getHeaderNames(); while (headers.hasMoreElements()) { String name = headers.nextElement(); System.out.println("Header [" + name + "]: " + request.getHeader(name)); } // 打印所有参数 Enumeration<String> params = request.getParameterNames(); while (params.hasMoreElements()) { String name = params.nextElement(); System.out.println("Param [" + name + "]: " + Arrays.toString(request.getParameterValues(name))); }这段代码放在业务逻辑的最前面,一跑起来,客户端到底发了什么、容器解析到了什么,一目了然。很多诡异的“参数丢失”问题,其实都是请求本身就没发对,比如前端参数名拼错了、FormData格式没设置对等,打印一下请求报文立刻真相大白。
7. 写在最后的经验沉淀
做个JavaWeb项目就像开一家餐厅,请求是顾客递过来的菜单,响应是厨房端出来的菜。Servlet容器是那个前厅服务员,负责接单、传菜;而你要做的,是搞清楚菜单上写了什么(请求解析)、菜品该怎么出(响应输出)、以及哪些菜适合堂食哪些菜适合打包(转发与重定向的抉择)。这套请求响应的基本功,不管以后用不用Servlet原生API,理解都不会过时,因为所有框架都是在这些基础之上做了一层又一层封装。
我个人带过的同学里,凡是花时间把这一篇的知识彻底搞透的,后面学Spring MVC、理解拦截器过滤器、处理跨域、做登录鉴权,都轻松很多。反而是那些直接上手Spring Boot却不懂底层请求流的,遇到一个奇特的问题就容易卡死,因为没有形成完整的知识链路。
最后再分享一个小建议:找一天时间,关掉IDE的框架依赖,纯手工写一个Servlet或者按这个思路整理一份自己的请求响应总结,坚持把它跑通,然后试着用代码里打印的方式观察一遍真实请求的报文内容。这个过程不会太久,但对请求响应这条主线的理解,会比看十遍教程都管用。