☰
HttpServletResponse 响应机制与重定向排错实践
2026/10/27 15:00:19 网站建设 项目流程

接口联调那天,前端甩过来一句话:请求发出去了,Network 面板里明明白白看到一个 302,然后浏览器自己跳走了,可回调里拿到的数据是空的。后台这边更奇怪,代码里明明写了request.setAttribute("msg","..."),跳转过去的页面读出来却是 null。这个问题的答案,一半藏在HttpServletResponse对象里,一半藏在“重定向”这三个字的语义里。Servlet 编程里,绝大部分人入门时只记住了resp.getWriter().write("hello"),等到真正写跳转、写下载、写接口返回、写跨域,才发现这个对象远比想象中要细。这篇就把HttpServletResponse从响应行的状态码、响应头的 set 与 add、响应体的两条流,到sendRedirect背后的 302 往返,再到实际排错里几个几乎人人都会撞上的异常,完整地捋一遍。刚接触 Servlet 的同学可以照着抄代码跑一遍,已经写过一段时间业务的人,重点看编码顺序、提交时机和路径拼接这几段,那里才是真正出问题的地方。

1. 先把 response 这个"回信包"拆开看:谁在写,写什么,写到哪

Servlet 的service方法签名叫service(ServletRequest req, ServletResponse resp),容器每次收到请求,都会造一对新的请求响应对象塞进来。请求对象是"读",响应对象是"写"。听起来简单,但一旦你把HttpServletResponse当成一个普通的输出流包装器,后面遇到的所有坑基本都能追溯到理解偏差上。它本质上是容器对一条 HTTP 响应报文的完整封装,你调的每一个方法,最后都会被容器翻译成报文里的某个字节段。

1.1 容器递给你的只是接口,真正干活的是实现类

我们代码里拿到的类型是HttpServletResponse,这是个接口,位于jakarta.servlet.http(老项目里是javax.servlet.http)包下,它继承自ServletResponse。真正 new 出来的对象是容器自己写的实现类,而且往往不是你直接可见的那个——容器通常在外面套一层门面(Facade),把内部对象暴露出去的方法做裁剪和状态同步。

这个设计带来的第一个现实约束是:不要试图把它强转成容器内部实现类去调私有方法。很多人写((ResponseFacade) resp).getResponse()这类代码,短时间能跑,容器一升级、或者换一个容器部署,直接ClassCastException或者行为不一致。门面的存在是为了让容器能够精确跟踪"这条响应有没有被提交""缓冲区还剩多少""cookie 是不是已经写进去了"这些状态,绕过门面就等于绕过了状态跟踪。

第二个约束更隐蔽:同一个 response 对象在一次请求内是有状态的。它记录着缓冲区里已经堆了多少字节、状态码改过几次、头是不是已经锁死。所以你会看到isCommitted()这个方法,它是这个对象最重要的一个状态位,后面第五章的报错几乎都和它有关。

1.2 一条 HTTP 响应由四块拼成,方法一一对应

一条响应报文拆开看就那么几段,我把它们和 API 的对应关系摆出来,你对这个对象的整体感会立刻清晰:

报文组成部分具体内容对应的 Servlet API
状态行协议版本、状态码、原因短语setStatus(int)、sendError(int)
响应头Content-Type、Location、Set-Cookie等setHeader、addHeader、setContentType、addCookie
空行分隔符容器自动写
响应体HTML、JSON、二进制字节getWriter()、getOutputStream()

这个表格值得记一下,因为几乎所有"为什么我这个设置没生效"的问题,都能对应到"这一步是在报文哪一段、什么时候必须完成"。响应头和响应体有一个硬性顺序:头必须在体的第一个字节之前全部写完。HTTP 协议本身不允许你在 body 中间再插一个头进去。Servlet 容器为了保证这一点,用了缓冲区的机制——你把内容写进缓冲区的时候,头还是可以改的;一旦缓冲区被刷到底层 socket,头就彻底锁死了,再调setHeader会被静默忽略,再调sendRedirect会直接抛异常。

1.3 缓冲区:那个决定"还能不能改"的开关

缓冲区默认大小通常是 8KB(不同容器略有差异,可以通过setBufferSize调整,但必须在写入任何内容之前调)。它的行为逻辑是这样的:你调用getWriter().write("...")写内容,内容先进缓冲区;缓冲区没满,什么都不发生;缓冲区满了或者你显式调用了flushBuffer(),容器就把状态行、响应头、缓冲区内容一起打包发出去,同时把isCommitted()置为true。

这里有两个方法容易混淆,值得单独说清楚:

  • reset():清空缓冲区内容,清空已设置的响应头,状态码重置回 200,前提是未提交。
  • resetBuffer():只清空缓冲区内容,保留已设置的响应头和状态码,前提同样是未提交。

区别就在"头"上。假设你在过滤器里设置了一堆响应头,然后在 Servlet 里发现逻辑走错了想重来,用reset()会把过滤器设的头一起清掉,用resetBuffer()则保留。这两个方法一旦在isCommitted()为 true 的情况下调用,都会抛IllegalStateException。

我个人的习惯是:任何可能抛异常、可能走分支的响应逻辑,都在写第一段内容之前把状态码和头全部定下来。因为只要动了 Writer,后面所有决策的空间都在迅速收窄。很多人写代码喜欢"先输出一段 HTML 头部,然后判断逻辑再决定跳不跳转",这种写法必然出事——HTML 头部一写,缓冲区一旦被刷,sendRedirect就废了。

2. 响应头:set 与 add 的分水岭,以及几类高频头的写法

响应头是HttpServletResponse里最能体现"你到底懂不懂 HTTP"的部分。绝大多数业务代码只需要setContentType,但只要涉及缓存、跨域、文件下载、Cookie,就必须搞清楚setHeader和addHeader的语义差别。这里搞错,症状往往非常诡异:多个 Cookie 只生效一个、跨域请求偶尔成功偶尔失败、明明设了不缓存浏览器还是拿缓存。

2.1 setHeader 与 addHeader 的语义差别,以及哪些头只能有一个值

两者签名一样,行为完全不同:

  • setHeader(name, value):覆盖。如果同名头已经存在,旧值被替换掉。
  • addHeader(name, value):追加。同名头会有多个值,最终在报文里出现多行。

什么时候必须用addHeader?最典型的就是Set-Cookie。一个响应里要下发三个 Cookie,就必须addHeader三次,用setHeader三次只会保留最后一个。同理Vary、Warning这类允许多值的头也是。

什么时候必须用setHeader?那些 HTTP 规范规定"只能有一个值"的头:Content-Type、Content-Length、Location、Content-Disposition。这些头如果用addHeader追加成两行,浏览器的行为是不确定的——有的取第一个,有的取最后一个,有的直接忽略整个响应。这种 bug 特别难查,因为本地测试往往只有一个值,跑到线上因为某个过滤器也设了一次,才变成两个。

判断准则很简单:这个头的语义是"唯一事实"还是"多个条目"。唯一事实用 set,多个条目用 add。

顺带提一下containsHeader(String name),它能在你动手之前先探一下这个头是不是已经被上游(比如某个过滤器)设过了。写公共组件的时候挺有用,可以避免覆盖别人已经设好的值。

2.2 缓存控制:Cache-Control 与 Expires 的配合

老系统里经常能看到这种写法:

response.setHeader("Cache-Control", "no-cache"); response.setHeader("Pragma", "no-cache"); response.setDateHeader("Expires", 0);

三行一起写是有历史原因的。Pragma和Expires是 HTTP/1.0 时代的产物,Cache-Control是 HTTP/1.1 的。现代浏览器只看Cache-Control,但某些老旧的中间层(某些代理、某些内嵌浏览器内核)还会读前两个。三行一起写属于"兼容成本极低"的做法,可以保留。

但要注意no-cache和no-store不是一回事:

  • no-cache:可以缓存,但每次使用前必须向服务器验证。不是说"不缓存"。
  • no-store:完全不允许缓存,连存都不许存。
  • must-revalidate:过期后必须验证,不能拿过期的凑合用。

对于包含敏感信息的接口响应,用no-store更彻底。对于静态资源,反而应该设一个长的max-age,配合文件名加哈希做版本控制,这是性能优化的常规操作。

还有一个细节:setDateHeader接受的是毫秒时间戳,容器会自动格式化成 HTTP 日期格式(RFC 1123)。不要自己用SimpleDateFormat拼字符串,时区和格式很容易搞错,一旦格式不对,浏览器直接忽略这个头。

2.3 跨域与预检请求:response 里到底要回什么

前后端分离之后,跨域几乎是绕不开的。虽然大多数项目会用统一的网关或者框架层的注解来处理,但知道底层 response 要回哪几个头,排查问题时能省很多时间。

浏览器发跨域请求时,如果属于"非简单请求"(比如带了自定义头、用了 PUT/DELETE、Content-Type 是 application/json),会先发一个 OPTIONS 预检请求。这个预检的响应里必须包含:

response.setHeader("Access-Control-Allow-Origin", origin); response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With"); response.setHeader("Access-Control-Max-Age", "3600");

如果请求要带 Cookie,还必须加上Access-Control-Allow-Credentials: true,而且此时Access-Control-Allow-Origin不能是*,必须是具体的源。这是规范硬性要求,很多人卡在这里,报错信息也不够直白,只提示"credentials 模式下不允许通配符"。

还有一个容易忽略的点:预检请求本身如果返回了 302 重定向,浏览器会直接判定预检失败。所以处理 CORS 的过滤器要放在很靠前的位置,并且在遇到 OPTIONS 请求时直接返回 200,不要让它继续走后面的业务链路。

3. 响应体:Writer 与 OutputStream 的二选一,以及乱码的根子

写响应体只有两个入口:getWriter()拿字符流,getOutputStream()拿字节流。看起来选择很自由,实际上约束很硬。这一章把互斥规则、编码顺序、文件下载三件事讲透,中文乱码这个问题基本就能根治了。

3.1 两条流为什么互斥

先记住结论:同一次请求内,getWriter()和getOutputStream()只能用一个。先调了getWriter()再调getOutputStream(),或者反过来,都会抛IllegalStateException。

原因不难理解。字符流最终要变成字节写出去,这个转换过程需要一个字符编码器,而编码器是挂在底层的字节流上的。容器如果用字符流包装了输出,就说明它已经接管了"字符→字节"这一步;这时候你再直接拿字节流往里写,两边的数据会交错,缓冲区也无法确定该刷新谁。所以容器干脆禁止这种混用。

选哪个?看你要输出什么:

  • 输出 HTML、JSON、纯文本 →getWriter(),省去自己处理编码。
  • 输出文件、图片、Excel、压缩包 →getOutputStream(),字节原样透传。
  • 输出到一半可能会切换 → 在两个分支里合并成一个方法,只在一个入口里拿流。

还有一点经常被忽略:即使你只用了getOutputStream(),setContentType里的 charset 依然是有效的。因为浏览器渲染 HTML 时需要知道用什么编码解析,这个信息来自Content-Type头,跟你用哪条流写没有关系。

3.2 乱码不是一处问题:字符集、内容类型、流三者的先后顺序

中文乱码是 Servlet 入门最高频的投诉。它其实不是"一个问题",而是好几个独立的点,任何一个错了都会出乱码:

环节常见错误正确做法
响应字符集没设,容器用默认显式设置 UTF-8
内容类型只设了text/html,没带 charset设成text/html;charset=UTF-8
设置时机在getWriter()之后才设必须在拿流之前
编码器用new String(bytes, "GBK")硬转统一走容器编码

最关键的是时机。编码器是在调用getWriter()的那一刻根据当前字符集创建出来的,之后你再调setCharacterEncoding就不起作用了——注意,是"不起作用",不是报错。这种静默失败最容易让人抓狂,代码看起来完全正常,就是出乱码。

下面两行的效果是有差别的:

// 只设置字符集,不影响 Content-Type 的其他部分 response.setCharacterEncoding("UTF-8"); // 设置内容类型,如果带了 charset 参数,会同时把字符集也设上 response.setContentType("text/html;charset=UTF-8");

规范里明确写了:setContentType的字符串如果包含charset参数,等效于同时调用了setCharacterEncoding。所以最省事的写法就是直接用第二种,一步到位。但要注意方向性:后调用的会覆盖先调用的。如果你先setContentType("text/html;charset=UTF-8")再setCharacterEncoding("GBK"),最终是 GBK;反过来就是 UTF-8。别在两处地方设不同的值,那是在给自己埋雷。

还有一个新项目里容易踩的点:容器版本的默认字符集。老版本的容器默认是 ISO-8859-1,新版本(Servlet 4.0 之后)默认已经改成 UTF-8 了。这导致一个很典型的场景——本地跑得好好的,部署到另一台服务器上就乱码。所以永远不要依赖默认值,显式写出来,三行代码的事。

3.3 文件下载:Content-Disposition 与二进制流的正确姿势

文件下载是HttpServletResponse用得最"重"的场景之一,也是最容易出细节问题的地方。一个能用的骨架大概是这样:

response.setContentType("application/octet-stream"); response.setHeader("Content-Disposition", "attachment; filename=\"" + URLEncoder.encode(fileName, "UTF-8") + "\""); response.setContentLengthLong(file.length()); try (InputStream in = new FileInputStream(file); OutputStream out = response.getOutputStream()) { byte[] buf = new byte[8192]; int len; while ((len = in.read(buf)) != -1) { out.write(buf, 0, len); } out.flush(); }

这里面有几个点值得单独拎出来说。

Content-Disposition里的中文文件名是经典问题。按 RFC 5987,正确写法是filename*=UTF-8''加上百分号编码:

String encoded = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20"); response.setHeader("Content-Disposition", "attachment; filename=\"" + encoded + "\"; filename*=UTF-8''" + encoded);

同时给filename和filename*两个参数,是为了兼容那些不认 RFC 5987 的老客户端。注意URLEncoder会把空格编成+,而 HTTP 头里的空格应该编成%20,所以要替换一下,不然后缀名带空格的文件名会出问题。

setContentLengthLong也建议设上。设了之后浏览器能显示下载进度条,用户知道还要等多久;不设的话只能显示"未知大小",体验差一截。而且对于大文件,容器能更早决定是否用分块传输。

缓冲区大小 8192 是个经验值,跟磁盘块大小和网络 MTU 大致对得上。太小会导致频繁系统调用,太大会占用过多堆内存,8K 到 64K 之间都算合理。

最后一个坑:下载接口不要在前面写任何内容。有些人在下载方法里先getWriter().write("开始下载")打个日志,然后再切到getOutputStream(),直接抛异常。日志就老老实实写日志文件,别往响应里塞。

4. sendRedirect:一行代码背后其实是两次请求

response.sendRedirect("xxx")这一行,看起来像"跳转",实际上它什么都没跳。它做的事情只有一件:在响应里写上状态码 302 和一个 Location 头。真正发起第二次请求的是浏览器。理解这一点,后面所有的行为差异就都顺理成章了。

4.1 302 与 Location:浏览器替你发了第二次请求

一次重定向的完整链路是这样的:

  1. 浏览器请求/app/old-path。
  2. 服务器执行sendRedirect("/app/new-path"),返回状态码 302,响应头Location: /app/new-path,响应体通常是空的或者一小段提示文字。
  3. 浏览器收到 302,自动发起一个新的请求GET /app/new-path。
  4. 服务器处理新请求,返回真正的页面。

关键在于第 3 步:这是浏览器的行为,不是服务器的。所以从服务器的角度看,/app/old-path这个请求到此就结束了,它产生的所有request域数据、所有线程局部变量,全部随着这次请求销毁。

这也解释了为什么"重定向之后request.setAttribute读不到"。不是容器有问题,是这根本就是两次独立的请求,两个不同的HttpServletRequest对象,里面当然什么都没有。

那什么能跨过去?session可以,因为 session 靠 Cookie 里的JSESSIONID关联,同一个浏览器第二次请求会带上同一个 Cookie。所以"重定向传数据"的常规做法是塞 session 或者塞进 URL 参数,而不是塞 request。

sendRedirect内部还会做一件事:把响应缓冲区里已有的内容清掉,然后提交。这也是为什么它在"已经提交"之后调用会抛异常——缓冲区都发出去了,没法再改状态码和头了。

4.2 Location 里写什么路径才不会 404

这是重定向翻车率最高的地方。先看几种写法:

// 写法一:以 / 开头 response.sendRedirect("/login.html"); // 写法二:显式带上 contextPath response.sendRedirect(request.getContextPath() + "/login.html"); // 写法三:相对路径 response.sendRedirect("login.html"); // 写法四:外部完整地址 response.sendRedirect("https://example.com/login");

写法一的行为在不同容器、不同版本上是有差异的。Servlet 规范的说法是:如果 location 以/开头,容器应当把它解释为相对于 Servlet 容器根,并自动补上上下文路径。但实际项目里,因为中间可能隔着反向代理、路径重写,这个"自动补"经常补不准,导致跳到http://host/login.html而不是http://host/app/login.html,直接 404。

我的建议很直接:永远用写法二,显式拼request.getContextPath()。多敲几个字符,省掉一堆线上排查。而且这个写法在任何容器、任何代理配置下行为都是一致的。

写法三的相对路径,是相对于当前请求的 URI 目录来解析的。比如当前请求是/app/user/list,那么sendRedirect("login.html")会跳到/app/user/login.html。这个结果往往不是你要的,所以相对路径在重定向里基本不用。

写法四用于跳转到外部站点,注意只能跳 http/https,其他协议(比如javascript:)某些容器会做过滤,这也算是容器帮你挡了一层安全问题。

还有一个容易被忘掉的方法:encodeRedirectURL(String url)。它的作用是在浏览器禁用 Cookie 时,把JSESSIONID拼到 URL 上,保证会话不丢。现在浏览器基本都开 Cookie,这个方法用得少了,但如果你的系统要兼容极端场景,重定向前包一层是稳妥的:

response.sendRedirect(response.encodeRedirectURL(target));

4.3 重定向和转发的取舍:不是谁更好,是场景不同

初学者最爱问"重定向和转发哪个好"。这俩根本不是同一个层面的东西,不存在优劣,只有适用场景。我把核心差异列成表:

对比项重定向 sendRedirect转发 forward
请求次数2 次1 次
谁发起的第二次浏览器服务器内部
地址栏会变化不变
request 域数据丢失保留
能否跳外部站点能不能
目标资源范围任意 URL只能是当前应用内部
状态码302200(最终)
性能多一次网络往返更快

判断准则用一句话概括:"这个跳转用户需不需要知道、需不需要收藏"。

用户登录成功后跳到首页,地址栏应该变成首页地址,用户刷新不会重复提交登录表单——用重定向。表单提交成功后跳结果页,也是重定向,这就是常说的 PRG 模式(Post-Redirect-Get),能有效避免用户按 F5 重复提交。

Servlet 处理完请求,要把渲染工作交给一个 JSP 或者模板引擎,地址栏不需要变,request 里还有一堆数据要传过去——用转发。

还有一个现实区别:转发只能在自己应用内部转,跨应用、跨域名一律无解,只能重定向。微服务拆分之后,"登录统一跳到一个独立的认证服务"这种需求,本质上只能靠重定向。

4.4 重定向带参数:中文、长度、以及 POST 变 GET 的现实

重定向想传参数,只能拼在 URL 上:

String msg = URLEncoder.encode("用户名或密码错误", "UTF-8"); response.sendRedirect(request.getContextPath() + "/login?error=" + msg);

三个必须注意的点:

第一,中文必须编码。不编码的话,中文和特殊字符(&、=、#、空格)会破坏 URL 结构,或者被浏览器按某种默认编码处理,接收端解出来就是乱码。注意URLEncoder.encode是按表单编码规则来的,空格会变成+,接收端用request.getParameter取值时容器会自动解码,一般能对上;但如果是自己手动解析 query string,就得记得把+还原成空格。

第二,接收端要保证解码用同一个字符集。老版本容器里,query string 的解码用的是容器连接器上的 URI 编码配置,不是request.setCharacterEncoding。也就是说,你设了request.setCharacterEncoding("UTF-8"),对 POST body 有效,对 URL 上的参数可能无效,取决于容器配置。这个坑非常经典,症状是"POST 表单里的中文正常,URL 参数里的中文乱码"。解决办法要么改容器配置,要么干脆别在 URL 上放中文,用一个短码(比如错误类型枚举)代替,服务端再翻译成文案。

第三,不要试图通过重定向传敏感数据。URL 会进浏览器历史、会进服务器访问日志、会进 Referer 头传给第三方。密码、token 这类东西放 URL 上是明确的安全隐患,这类数据应该走 session 或者一次性的服务端存储。

还有一个绕不开的现实:POST 请求遇到 302,浏览器会用 GET 重新请求。这是历史遗留行为,虽然规范上 302 没说必须改成 GET,但所有主流浏览器的实现都是这么干的。如果你的接口是 POST 提交,返回 302 之后,目标地址收到的会是 GET,参数全丢了。

如果确实需要"重定向但保留方法和 body",得用 307(临时)或 308(永久)。Servlet 6.0 之后sendRedirect有重载可以直接指定状态码:

response.sendRedirect(target, 307);

注意 307 要求客户端把原请求的 body 原样重发,浏览器对表单提交支持还好,对 fetch/XHR 就得自己处理了。所以这个方案在纯后端跳转里用得通,在前端接口场景里要谨慎。

5. 排错实录:那几个几乎每个人都会撞上的响应异常

理论讲完,来点真实的。下面这四个场景,我在不同项目里见过太多次,每一个的症状都很有代表性。我把排查链路完整写出来,方便你遇到时按图索骥。

5.1 Cannot call sendRedirect() after the response has been committed

这个异常信息非常直白,但很多人第一反应是"我明明没写任何输出啊"。

排查的第一步:确认在sendRedirect之前,响应缓冲区有没有被刷出去。isCommitted()是最直接的验证手段,加一行日志:

System.out.println("committed=" + response.isCommitted()); response.sendRedirect(...);

如果打印出来是 true,说明在你调sendRedirect之前,已经有东西写进了响应并且刷了出去。常见的几个来源:

  • JSP 文件顶部的空行。JSP 里<% ... %>标签前面的换行、空格,都会被当作模板文本输出。如果你的 JSP 里第一行就是重定向代码,前面又有一个空行,那这个空行已经把响应提交了。
  • 过滤器或拦截器里写了日志到 response。这个比较少见,但确实有人把调试信息直接getWriter().write()到响应里。
  • 缓冲区太小,内容写超了。默认 8KB,一般业务不会超,但如果前面输出了大量 HTML,确实可能触发自动刷新。
  • 上游组件调用了flushBuffer()。某些框架的拦截器会在特定条件下刷缓冲区。

修复思路分两层。治标:在sendRedirect之前调response.reset()(前提是未提交),把之前的内容和头全清掉。治本:调整代码结构,把所有跳转判断提到写任何内容之前。一个 Servlet 的正确骨架应该是:

protected void doPost(HttpServletRequest req, HttpServletResponse resp) { // 1. 先做所有业务判断 if (needRedirect) { resp.sendRedirect(req.getContextPath() + "/target"); return; } // 2. 判断完了再设置头和写内容 resp.setContentType("text/html;charset=UTF-8"); resp.getWriter().write("..."); }

注意那个return。漏掉 return 是最常见的原因之一——跳转都发出去了,代码继续往下跑,又往 response 里写了一段内容,最后不仅报错,还可能拼出一堆乱七八糟的东西。

5.2 跳转成功但页面空白、乱码、或者 404

"跳转成功"是用户的描述,实际现象千差万别,得拆开看。

页面空白:常见于重定向之后返回了 200,但响应体是空的。这种情况先看 Network 面板,如果看到 302 之后紧跟一个 200,说明重定向本身是通的,问题出在目标页面。目标页面返回空,可能是转发链路上某个环节吞了输出,或者模板渲染抛异常被捕获后没往外抛。还有一种可能是重定向的目标地址和预期不符——比如跳到了一个接口而不是页面,接口返回 JSON,浏览器直接显示或下载。

跳转后乱码:分两种。一种是目标页面本身乱码,那问题在目标页的编码设置,跟重定向没关系。另一种是 URL 参数里的中文乱码,回到 4.4 那三条去看。

404:八成是路径问题。排查顺序是:先在浏览器地址栏看实际跳到的完整 URL,跟预期对比;再检查contextPath是不是漏了;最后确认目标资源是不是真的部署在那个路径下。如果前端有反向代理(Nginx 之类),还要确认代理有没有做路径重写,/app/xxx被转发到后端之后是不是还是/app/xxx。这个环节最容易出"本地好好的,线上 404"。

5.3 重定向死循环与登录拦截的经典冲突

症状:浏览器一直转圈,Network 面板里请求一条接一条地重复,最后报ERR_TOO_MANY_REDIRECTS。

几乎可以确定是拦截器里的登录校验写错了。典型错误代码:

if (session.getAttribute("user") == null) { response.sendRedirect(request.getContextPath() + "/login.html"); }

这段代码拦截了所有请求,包括/login.html本身。用户没登录 → 访问首页 → 被拦 → 重定向到登录页 → 登录页也被拦(因为没登录)→ 又重定向到登录页 → 死循环。

修复方法是在拦截逻辑里加白名单:

String uri = request.getRequestURI(); String ctx = request.getContextPath(); String path = uri.substring(ctx.length()); List<String> whiteList = List.of("/login.html", "/login", "/css/", "/js/", "/favicon.ico"); boolean pass = whiteList.stream().anyMatch(path::startsWith); if (!pass && session.getAttribute("user") == null) { response.sendRedirect(ctx + "/login.html"); return; }

两个细节很容易漏:一是静态资源必须放行,否则登录页的 CSS、JS 也会被重定向到 HTML,浏览器加载出来是一堆乱七八糟的错误;二是return必须写,不写的话拦截器继续往下走,或者过滤器链继续执行,后面的逻辑可能又写了一次响应。

还有一种更隐蔽的循环:重定向目标是个需要认证的接口,而这个接口返回 401 之后又被某个全局异常处理器重定向到登录页。这种跨层的循环最难查,建议在重定向的地方统一加一条日志,把来源和目标打出来,一眼就能看出环在哪。

5.4 Ajax 里的 302:前端为什么拿不到跳转后的数据

回到开头那个场景。前端用 fetch 或者 XHR 发请求,后端返回了 302,浏览器会自动跟进这个重定向,把第二次请求的结果作为最终响应返回给前端。前端拿到的永远是重定向之后的最终结果,看不到中间的 302。

这就导致几种让人困惑的现象:

  • 后端重定向到一个登录页,前端以为拿到了接口数据,实际拿到了一段 HTML,JSON.parse直接报错。
  • 重定向的目标是跨域的,浏览器在跟进时可能因为 CORS 策略失败,前端只看到一个模糊的网络错误。
  • 重定向之后的请求方法变成了 GET,POST 的参数全丢,后端拿不到参数返回业务错误。

正确的做法是:接口请求不要用 302 表达"未登录"。约定一个业务状态码放在响应体里返回,前端拿到之后自己决定跳哪:

resp.setStatus(200); resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"code\":401,\"msg\":\"未登录\",\"redirect\":\"/login.html\"}");

浏览器页面级请求用 302 是合适的,接口级请求用业务状态码是合适的。这两条路分开走,前端能省掉一大半的困惑。

如果你的项目里既有页面请求又有接口请求,判断依据一般是请求头里的X-Requested-With或者Accept字段,前者是 XHR 时代的老约定,后者更标准。拿到之后走不同分支就行。

6. 把这套东西沉淀下来:几个让代码少出事的习惯

前面讲的都是"遇到问题怎么解",这一章换个角度,讲"怎么让它不出问题"。这些东西说穿了都很朴素,但确实是我在多个项目里反复验证过、能实际降低故障率的做法。

6.1 一个够用的响应工具类

每次写setContentType+getWriter().write太啰嗦,而且容易漏编码。封一个薄薄的工具类能省很多事,关键是不用每个地方都记得设 UTF-8:

public final class ResponseUtil { private ResponseUtil() {} public static void writeJson(HttpServletResponse resp, String json) throws IOException { resp.setContentType("application/json;charset=UTF-8"); resp.setCharacterEncoding("UTF-8"); resp.getWriter().write(json); } public static void writeText(HttpServletResponse resp, String text) throws IOException { resp.setContentType("text/plain;charset=UTF-8"); resp.getWriter().write(text); } public static void redirect(HttpServletRequest req, HttpServletResponse resp, String path) throws IOException { resp.sendRedirect(req.getContextPath() + path); } }

注意redirect这个方法把contextPath的拼接收进去了。这个是我认为最有价值的一个封装——只要所有人都走这个方法,就再也不会有人漏写 contextPath。

工具类的边界要守住:只做响应格式的标准化,不做业务判断。见过不少项目把这个类越写越肥,最后变成了一个什么都管的上帝类,反而更难维护。

6.2 编码统一交给过滤器,别在业务里到处写

setCharacterEncoding在业务代码里出现超过三次,就说明该用过滤器了:

@WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); resp.setCharacterEncoding("UTF-8"); chain.doFilter(req, resp); } }

两个注意点。第一,request.setCharacterEncoding对 GET 请求的 query string 是无效的,只对 POST body 有效,这一点前面说过,别指望它解决 URL 参数乱码。第二,这个过滤器要注册在最前面,@WebFilter的@Order或者 web.xml 里的<filter-mapping>顺序决定了它的执行次序,如果它在某个已经读取了参数的过滤器后面,就完全失效了——因为参数一旦被解析,编码就固定了。

如果项目里业务代码已经写了满地的setCharacterEncoding,可以在过滤器里先设,业务里逐步删。两者不冲突,过滤器是兜底。

6.3 状态码怎么选:301、302、303、307 的实用边界

大部分人只用过 302,其实这一族状态码各有分工:

状态码语义客户端行为典型场景
301永久移动可能缓存,后续直接访问新地址域名迁移、URL 永久改版
302临时移动不缓存,每次都回源登录跳转、PRG
303见其他强制用 GET 访问新地址POST 之后跳结果页
307临时移动保留原方法和 body需要重发 POST 的临时跳转
308永久移动保留原方法和 body永久性的、保留方法的迁移

最需要注意的坑是 301 会被浏览器永久缓存。有些浏览器把 301 缓存得非常彻底,即使你后来把服务器配置改了,用户端还是会直接跳到旧地址,清缓存都不一定管用。所以做临时跳转的时候,千万别顺手写 301,尤其是调试阶段,一旦写错,测试同学会被坑很久。

sendRedirect默认给的是 302,这个默认值在 90% 的情况下是对的。真正需要区分 303 和 307 的场景不多,主要是对 HTTP 语义要求比较严格的服务。日常写业务,记住"默认 302、临时跳转别用 301"就够了。

再补一个小习惯:重定向之前先确认isCommitted()。虽然正常写法不会触发,但如果你的代码调用了第三方组件、或者走了一段不太可控的模板渲染,加个判断能省掉一次线上报错:

if (!resp.isCommitted()) { resp.sendRedirect(req.getContextPath() + "/result"); }

这几行防御性代码本身不优雅,但它能保证出错时是错误的业务结果,而不是一个 500 异常堆栈。在线上环境里,这两者的差别还是挺大的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询