ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JSP登录模块中的jsp:forward跳转:原理、实践与避坑

JSP登录模块中的jsp:forward跳转:原理、实践与避坑 这几天帮人调一个学生管理系统的登录模块页面之间清一色jsp:forwardlogin.jsp 提交到 doLogin.jsp校验完再 forward 到 welcome.jsp。说实话现在正经企业项目里很少见到这种写法了但我不打算直接上来就批它因为在课程设计、毕业设计这类场景里用jsp:forward做登录跳转反而能把 HTTP 请求流转、request 作用域、页面间数据传递这些基础概念讲得特别清楚。这篇就把这条链路完整拆开从登录页到校验页再到欢迎页每行代码为什么这么写、跳转背后发生了什么、哪些地方容易踩坑全说透。提示本文基于 Tomcat JSP/Servlet 的传统 Web 工程写法适合正在做课程设计、毕业设计或者想补 JSP 基础的同学参考。项目整体很简单但每步我都会把原理讲清楚方便你直接照抄也方便你应付老师的追问。1. 先看明白 jsp:forward 在登录里到底是怎么跳的1.1 一次表单提交背后发生了什么你把用户名密码填进输入框点登录按钮浏览器做的事情非常单纯根据form标签里的 action 属性把表单字段打包成一个 HTTP POST 请求发出去。剩下的所有事服务器说了算。在传统 JSP 工程里这个请求落到了 doLogin.jsp 头上。注意doLogin.jsp 不是一个页面它本质上是一个被 Tomcat 编译成 Servlet 的 Java 类它接收请求、读取参数、做逻辑判断然后决定让谁来回应用户。这个过程里可能出现三种结果一是直接把响应输出回浏览器二是用response.sendRedirect()告诉浏览器你再去访问另一个地址三就是本文的主角jsp:forward服务器内部把这次请求转交给另一个资源处理。很多同学学到这里搞混转发和重定向根源在于没有意识到浏览器从头到尾只发了一个请求而服务器内部可能换了两个人的工位来处理。1.2 forward 的本质是一次服务器内部交接jsp:forward动作标签在底层等价于调用RequestDispatcher.forward()。它的机制是这样的当服务器执行到jsp:forward时会拿到当前这个 request 对象和 response 对象然后交给目标资源可以是另一个 JSP、Servlet也可以是 HTML目标资源处理完再由它把响应返回给浏览器。你可以把它想成在办事大厅的窗口咨询问题窗口A的员工看了一眼你的材料发现自己办不了直接把你的材料和工单递给旁边的员工B由B接着办然后把办好的结果给你。你本人没有重新排队也没有换窗口。整个过程里浏览器地址栏不会发生任何变化用户看到的 URL 始终是提交表单时那个地址。这个特性对登录场景非常关键不管 forward 到成功页还是失败页地址栏都停留在原来的 URL 上用户不会一眼看出你内部的页面结构。但同时它也有代价——刷新页面时浏览器会重新提交上一份表单这就是后文要说的重复提交坑。1.3 为什么课程设计都爱用它做登录跳转原因其实很实在。第一语法简单没有 Servlet 配置的负担在一个 JSP 文件里写判断逻辑、直接套jsp:forward标签几分钟就能跑通。第二jsp:forward天然支持携带参数通过子标签jsp:param可以把错误信息、提示信息直接传给目标页面这不就是登录失败回显错误提示的标准需求吗第三JSP 教材普遍把forward 动作标签列为重点章节做登录界面是对这个知识点最自然的检验。所以这篇我不是劝你扔掉它而是让你先把它用对、用明白等你理解了数据流转再用它作为跳板去理解 Servlet 和重定向就会顺很多。2. 最小可运行工程目录、web.xml 与登录页原型2.1 工程目录与 Tomcat 部署先搭一个最小的 Web 工程。不管你是用 IDEA 还是 Eclipse最终扔进 Tomcat 的 webapps 目录下结构长这样login-demo/ ├── login.jsp ├── doLogin.jsp ├── welcome.jsp └── WEB-INF/ ├── web.xml └── lib/如果你的项目是通过 IDEA 的 Artifact 以 exploded war 方式部署那么 IDEA 会在输出目录帮你生成这个结构你要关心的是 src/main/webapp 下的这堆文件。Tomcat 启动后访问http://localhost:8080/login-demo/login.jsp就能出登录页。web.xml 在 Servlet 3.0 之后不写也能跑纯 JSP 应用但建议还是留一个空的指定一下应用名和编码声明。Tomcat 8/9 对应 javax 命名空间Tomcat 10 要换成 jakarta 命名空间学生项目绝大多数是前者?xml version1.0 encodingUTF-8? web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 display-nameLoginDemo/display-name welcome-file-list welcome-filelogin.jsp/welcome-file /welcome-file-list /web-appwelcome-file-list配了之后直接访问http://localhost:8080/login-demo/就会自动落到登录页省得每次手打 login.jsp。2.2 login.jsp只做一件事的输入页登录页不要写任何业务逻辑它的职责就是渲染表单以及在登录失败被 forward 回来时显示错误信息。先看最基础的版本% page contentTypetext/html;charsetUTF-8 languagejava % html head title系统登录/title /head body form actiondoLogin.jsp methodpost table tr td用户名/td tdinput typetext nameusername placeholder请输入用户名//td /tr tr td密码/td tdinput typepassword namepassword placeholder请输入密码//td /tr tr td colspan2 input typesubmit value登录/ input typereset value重置/ /td /tr /table /form /body /html这里面有两个容易被忽略的细节。第一method必须写成post而不是get。如果用 get用户名和密码会拼到 URL 里既暴露隐私又显得很不专业。第二name属性决定了服务器端用request.getParameter(username)取值时拿到的键名name 写错了后面对不上排查起来非常折磨人。2.3 form 的 action 应该写给谁表单提交的目标就是你校验逻辑所在的资源。这里有两种主流做法方法一教学版本文主推action 直接写给一个 JSP比如doLogin.jsp。好处是配置少、看着直观适合课程设计。方法二工程版action 写给一个 Servlet 的映射地址比如actionloginServlet再在 web.xml 或WebServlet注解里做映射由 Servlet 完成校验和 forward。如果你以后要写 Servlet 版本强烈建议把校验逻辑放在 Servlet 里而不是 JSP 里。不过这篇既然以jsp:forward为核心就不引 Servlet 了避免概念交叉太多。还有个细节action 里我写的是相对路径doLogin.jsp意思是当前请求 URL 的目录下找这个文件。如果你把登录页放在admin/子目录下那 action 就要写成admin/doLogin.jsp或者更稳妥地写成带上下文根的绝对路径。很多转发后页面样式错乱的问题就是路径写法埋下的雷第 6 章会专门讲。3. 校验页 doLogin.jsp条件 forward 参数携带3.1 取参数前先处理编码doLogin.jsp 是整个登录模块的大脑。它的核心工作分四步取参数、做校验、按结果转发、在转发时携带提示信息。先看完整代码% page contentTypetext/html;charsetUTF-8 languagejava % % // 1. 处理 POST 提交的中文编码 request.setCharacterEncoding(UTF-8); // 2. 取出表单参数 String username request.getParameter(username); String password request.getParameter(password); // 3. 模拟校验实际项目中改成查数据库 if (admin.equals(username) 123456.equals(password)) { // 登录成功把用户写入 session再转发到成功页 session.setAttribute(user, username); % jsp:forward pagewelcome.jsp jsp:param namemsg valuelogin success / /jsp:forward % } else { % jsp:forward pagelogin.jsp jsp:param nameerror value用户名或密码错误请重新输入 / /jsp:forward % } %第一步千万别省。POST 请求的参数在getParameter()之前需要先设置解码字符集否则用户名里的中文会变成乱码。这里request.setCharacterEncoding(UTF-8)必须在所有getParameter()调用之前执行写晚了就无效了。如果你在 login.jsp 的 contentType 里声明了 UTF-8表单页面本身的字符集是没问题但服务器解析 POST 实体的字符集是独立设置的容易被忽略。3.2 两个分支的两条 forward注意代码结构校验成功和失败各有一条独立的jsp:forward它们都在%%脚本片段组成的 if/else 分支内部。这里必须理解 JSP 的执行方式——JSP 页面在执行时会先输出所有静态 HTML 和表达式再按从上到下的顺序执行脚本片段和动作标签。jsp:forward pagewelcome.jsp这一行生成到 Java 代码里等价于先创建RequestDispatcher然后调用forward()并且在调用之后还有一个return。也就是说一旦执行到 forward当前 JSP 后续的内容不会继续输出所以 if/else 分支里两个 forward 是不会互相干扰的。jsp:param子标签的作用是往请求参数列表里追加数据。注意一个坑如果目标页面本来就有同名参数jsp:param会覆盖原值如果没有同名参数就直接新增。这些参数最终通过request.getParameter()来取转发过程中 request 对象是同一个所以无论你在原始表单里的username、password还是通过jsp:param加进去的msg、error目标页面都能取到。3.3 用 session 补上真正的登录状态上面的代码里我做了一个很多人写课程设计时会漏掉的动作session.setAttribute(user, username)。为什么登录成功要写 session因为 forward 的本质是一次请求内部的交接请求一结束request 里的参数和属性就没了。如果只靠jsp:param把用户名带到 welcome.jsp那用户刷新一下、或者直接访问 welcome.jsp这个信息就丢了更糟糕的是——任何人都能绕过登录页直接打开欢迎页。session 是保存在服务器端、关联到当前浏览器会话的一块存储区域只要浏览器不关、session 不过期它就一直有效。把登录成功的用户写进 session才是真正意义上的建立登录态。后文第 6 章讲受保护页面校验时你会发现这个动作几乎决定了整个登录模块是否安全。如果你的项目要求记住用户名密码七天免登录之类那是 Cookie 的活不要往 session 里堆这块先不展开。4. 目标页面怎么接数据welcome.jsp 接收与错误回显4.1 参数、属性、session 三种取法welcome.jsp 是登录成功后的落点。它需要展示当前登录的人是谁数据来源其实有三种request.getParameter(msg)接收jsp:param带过来的参数request.getAttribute(user)接收通过request.setAttribute()设置属性session.getAttribute(user)接收会话级别的登录状态很多人会混参数和属性。简单区分参数来自 URL 或表单存在于 request 里属性是你在代码里往请求对象上挂的值也存在于 request 里。前者用getParameter()后者用getAttribute()API 不一样别换着用。welcome.jsp 的完整写法% page contentTypetext/html;charsetUTF-8 languagejava % html head title欢迎页/title /head body % String user (String) session.getAttribute(user); String msg request.getParameter(msg); if (user null) { user 访客; } % h2% msg ! null ? msg : 欢迎 %% user %/h2 p这里是登录后的个人信息展示区域可以放用户名、注册时间、最近登录时间等字段。/p pa hreflogout.jsp退出登录/a/p /body /html这种写法是传统 Scriptlet 式。现在更推荐用 EL 表达式${sessionScope.user}取 session 里的值代码更干净但如果你还在交课程设计两种都建议掌握至少老师问起来你能说清 EL 本质上是帮你调用了pageContext.findAttribute()按 page、request、session、application 四个作用域依次查找。4.2 失败回跳登录页时错误信息的展示再看失败分支。doLogin.jsp 在用户名或密码错误时 forward 回 login.jsp并带上error参数。这时 login.jsp 需要有能力感知到这个参数并展示出来。改造后的 login.jsp% String error request.getParameter(error); % ... % if (error ! null !.equals(error)) { % div stylecolor: red; margin-bottom: 10px;% error %/div % } %这里有个体验层面的知识点因为 forward 不改地址栏用户看到 URL 还是doLogin.jsp或者最初提交表单的地址但内容已经切回了登录页并且多了红色错误提示。对用户来说这是可以接受的交互。但如果你不希望用户看到 doLogin.jsp 这串地址可以用response.sendRedirect(login.jsp)来重定向代价是没法用 request 带参数错误信息要么拼到 URL 后面要么放 session。这也是下一章要对比的核心差异。4.3 顺带把个人信息展示页的数据流理清搜jsp个人信息展示页面的同学其实就是在问上面这段逻辑的变体。个人信息页和 welcome.jsp 的区别只是数据量更大用户名、性别、邮箱、头像路径、权限角色等等。它们都是同一个套路——登录时把用户对象整体放进 sessionUser u userDao.findByUsername(username); session.setAttribute(loginUser, u);然后在展示页直接取对象用 EL 嵌套取属性p用户名${sessionScope.loginUser.username}/p p邮箱${sessionScope.loginUser.email}/p这比一个字段一个字段地 get 要干净得多。前提是你的User类有对应的 getter 方法EL 底层就是靠 getter 取值的。5. forward 不是万能跳转和 sendRedirect 的对比与选型5.1 两者到底差在哪一张表讲透很多同学写了两年 JSP 都没真正搞懂jsp:forward和response.sendRedirect()的区别。直接看表对比维度jsp:forward服务器转发response.sendRedirect客户端重定向执行位置服务器内部完成浏览器收到响应后重新发请求地址栏不改变变为目标地址请求次数1 次2 次request 数据参数和属性都保留全部丢失目标范围本站点内部资源含 WEB-INF任意 URL包括外部站点刷新行为原请求被重放可能重复提交变成新的 GET 请求相对安全能否访问 WEB-INF能不能这张表背下来基本够用。核心记忆点forward 是内部交接redirect 是告知新地址、你自己再跑一趟。5.2 登录成功后为什么我建议你用 redirect这句话可能和很多教材的例题相反但这是我实际写登录模块的真实体会登录成功那个分支最稳的处理不是 forward而是 sendRedirect。原因有三个防止重复提交。forward 之后地址栏还是原来的 URL用户一按 F5浏览器会重新 POST 一次 doLogin.jsp表单数据再校验一遍如果校验通过又 forward 回去。如果这个过程中涉及登录日志、积分奖励这类有副作用的操作就等于重复执行了。地址栏会误导用户。用户明明已经登录成功看到了欢迎页地址栏却还停在一串看起来像提交中的地址这不符合直觉。重定向到 welcome.jsp 后地址栏变成明文地址用户还可以直接收藏这个页面。刷新行为可预期。redirect 后浏览器发起的是 GET 请求刷新只是重新拉取页面没有表单重放的副作用。所以我的落地方案其实是混合式的登录失败用 forward 回登录页带错误提示登录成功则设置 session 后 redirect 到 welcome.jsp。这样既保留了 forward 在错误回显上的便利又规避了成功分支的重复提交问题。这篇文章前面为了保证jsp:forward的演示完整性留了两个 forward 的写法真正交项目时建议按这个思路改。5.3 必须用 forward 的三个场景既然 redirect 这么好为什么 forward 还活着因为有些场景只有 forward 能做同一请求内需要传递 request 属性和参数。比如 A 页面经过一系列计算把中间结果request.setAttribute()后交给 B 页面展示中途不能断断了数据就没了。访问 WEB-INF 下的资源。WEB-INF 目录对浏览器是不可见的用户直接敲 URL 访问会 404。你只能通过服务器内部 forward 过去。想隐藏某个页面不想让它被直接打开时把页面挪进 WEB-INF 再用 forward 接是最朴素的保护手段。权限校验失败时回到登录页。受保护页面顶部写一段 session 判断没登录就拿jsp:forward回登录页这是课程设计里非常经典的安全骨架。注意这里登录失败时 GET 到哪里其实无所谓因为本来就是前一个请求的上下文里做的判断。6. 实际项目里最容易踩的五个坑和排查思路6.1 坑一转发后 CSS、图片全部失效症状很典型登录页样式正常forward 到 welcome.jsp 后页面 HTML 结构出来了但 CSS 全丢、图片裂开。原因是 forward 不改变浏览器地址栏的 URL而 HTML 里的相对路径是相对于当前浏览器地址解析的。比如你从http://localhost:8080/login-demo/login.jsp提交forward 到welcome/welcome.jsp后浏览器地址还是.../login.jsp页面里写link hrefcss/style.css就会被解析成.../login-demo/css/style.css如果登录页和欢迎页不在同一目录层级路径就错位了。解决办法是统一用带上下文根的绝对路径在页面 head 里加一行 base 标签base href%request.getContextPath()%/或者手动拼接link relstylesheet href%request.getContextPath()%/css/style.css如果项目用了 EL可以简写成${pageContext.request.contextPath}/css/style.css。核心思路只要涉及页面跳转尤其是 forward 这种地址栏不变的跳转资源路径一律要用绝对路径别偷懒写相对路径。6.2 坑二WEB-INF 页面进不去反而保护了后台我开始也说过forward 可以到 WEB-INF浏览器直接访问则不行。这个特性在登录场景里是把双刃剑。好的用法把 welcome.jsp 这类需要登录才能看的页面放 WEB-INF 下登录成功后 forward 进去。这样用户即使在地址栏输入http://localhost:8080/项目名/WEB-INF/welcome.jsp也看不到页面挡住了绕过登录直接进后台的低级漏洞。坏的用法有些同学把 login.jsp 也放进 WEB-INF结果部署后发现无论如何都访问不到登录页因为浏览器没办法直接把 WEB-INF 下的 JSP 当 URL 访问。记住入口页面必须在 WEB-INF 外面受保护页面才放里面。6.3 坑三F5 一下表单又提交了一遍这是 forward 在登录成功分支最尴尬的地方。地址栏没变F5 或 CtrlR 会重复发送上一次的 POST 请求doLogin.jsp 又重新执行一遍重新判断、重新 forward。对纯展示型登录来说危害不大但如果登录时有写日志、发短信、扣积分这类副作用重复提交就是事故。我见过一个真实的课程设计登录页有个计数器统计登录次数结果用户每次刷新都 2闹出笑话。解决方案就是第 5 章说过的 PRG 模式Post-Redirect-Get。POST 提交校验成功后不要 forward而是response.sendRedirect(welcome.jsp)浏览器先收到 302再 GET 一次 welcome.jsp刷新页面时重复的是 GET 请求而不是 POST 登录请求。这是所有登录模块都建议遵守的实践。6.4 坑四受保护页面没有会话校验顺着 session 的思路再进一步。welcome.jsp 如果放在根目录且没有任何判断用户在未登录状态下直接访问.../welcome.jsp也能打开登录模块形同虚设。课程设计里最常见的补救是在每个受保护页面的最顶部写% if (session.getAttribute(user) null) { % jsp:forward pagelogin.jsp / % return; } %这样未登录用户访问 welcome.jsp会被直接转发回登录页。注意这里我加了一个return;防止 JSP 容器在处理完 forward 后又往下渲染了 welcome.jsp 的正文内容。虽然在标准实现中 forward 调用后 Java 方法会返回但加上return能让编译后的代码更明确地结束当前处理避免某些容器差异造成的内容泄露。不过要提醒一句每个页面都贴这段会非常啰嗦且容易漏正规做法是写一个 Filter 统一拦截配置好 URL 规则后一个类搞定。课程设计阶段先用页面顶部判断成本最低也够演示了。6.5 坑五forward 之后还有代码看编译后的 class 就懂了排查 forward 相关问题最有效的办法是直接看 JSP 编译后的 Java 源码。有很多人问jsp编译class文件保存在哪里——在 Tomcat 的 work 目录下默认路径是TOMCAT_HOME/work/Catalina/localhost/项目名/org/apache/jsp/里面能找到doLogin_jsp.java和对应的.class。打开_jspService方法找到jsp:forward对应的代码你会发现它是这样的结构if (true) { _jspx_page_context.forward(welcome.jsp ? _jspx_qfe); return; }return的存在说明 forward 之后当前页面确实不会再继续输出了。同时你也能看到jsp:param拼接成了查询字符串附加在目标地址后面这就是为什么目标页面能用request.getParameter(msg)取到值。理解了这段编译结果你就能解释很多看似诡异的现象比如 forward 前页面已经输出了一点 HTML到目标页面时这些输出不见了——因为 JSP 容器在 forward 之前清空了响应缓冲区。注意如果 forward 之前已经往响应里写入了大量内容导致缓冲区被写满并提交到浏览器response 已 committed再执行 forward 就会抛出IllegalStateException。所以页面里别在jsp:forward之前乱输出尤其别用out.flush()。另外再补充一个编码相关的坑。doLogin.jsp 里设置request.setCharacterEncoding(UTF-8)只对 POST 请求体有效。如果用 GET 方式提交表单参数编码取决于 Tomcat 的 URIEncoding 配置setCharacterEncoding就不管用了。所以表单老老实实用 POST能躲开一大半乱码问题。还有一个被问得很多的退出登录问题。logout 页面别用 forward 回登录页因为地址栏不变会让用户很困惑。正确做法是% session.invalidate(); // 销毁整个会话 response.sendRedirect(login.jsp); %session.invalidate()会把当前会话里所有属性清掉比逐个removeAttribute干净。如果你已经写了页面离开确认脚本window.onbeforeunload重定向前会先触发浏览器的离开提示这是预期行为点击离开后才会跳到登录页不用觉得奇怪。我在实际带项目时发现所有关于登录跳转的疑难杂症只要抓住三条主线就能快速定位请求是哪个forward 还是 redirect、数据在哪request 还是 session、地址栏是什么变了还是没变。把这几个问题问清楚问题基本先解决一半。剩下的一半就是用第 6 章那个 work 目录下的编译产物去验证你的猜测。JSP 这东西看着老但把这种猜不出来的就去看底层的排查思路练熟了后面学 Spring MVC、过滤器链那些更复杂的请求流转都会轻松得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进