这几天帮人调一个学生管理系统的登录模块,页面之间清一色<jsp:forward>:login.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 version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0"> <display-name>LoginDemo</display-name> <welcome-file-list> <welcome-file>login.jsp</welcome-file> </welcome-file-list> </web-app>welcome-file-list配了之后,直接访问http://localhost:8080/login-demo/就会自动落到登录页,省得每次手打 login.jsp。
2.2 login.jsp:只做一件事的输入页
登录页不要写任何业务逻辑,它的职责就是:渲染表单,以及在登录失败被 forward 回来时显示错误信息。先看最基础的版本:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>系统登录</title> </head> <body> <form action="doLogin.jsp" method="post"> <table> <tr> <td>用户名:</td> <td><input type="text" name="username" placeholder="请输入用户名"/></td> </tr> <tr> <td>密码:</td> <td><input type="password" name="password" placeholder="请输入密码"/></td> </tr> <tr> <td colspan="2"> <input type="submit" value="登录"/> <input type="reset" 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 的映射地址,比如
action="loginServlet",再在 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 contentType="text/html;charset=UTF-8" language="java" %> <% // 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 page="welcome.jsp"> <jsp:param name="msg" value="login success" /> </jsp:forward> <% } else { %> <jsp:forward page="login.jsp"> <jsp:param name="error" 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 page="welcome.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 contentType="text/html;charset=UTF-8" language="java" %> <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> <p><a href="logout.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 style="color: red; margin-bottom: 10px;"><%= error %></div> <% } %>这里有个体验层面的知识点:因为 forward 不改地址栏,用户看到 URL 还是doLogin.jsp(或者最初提交表单的地址),但内容已经切回了登录页,并且多了红色错误提示。对用户来说,这是可以接受的交互。但如果你不希望用户看到 doLogin.jsp 这串地址,可以用response.sendRedirect("login.jsp")来重定向,代价是没法用 request 带参数,错误信息要么拼到 URL 后面,要么放 session。这也是下一章要对比的核心差异。
4.3 顺带把个人信息展示页的数据流理清
搜"jsp个人信息展示页面"的同学其实就是在问上面这段逻辑的变体。个人信息页和 welcome.jsp 的区别只是数据量更大:用户名、性别、邮箱、头像路径、权限角色等等。它们都是同一个套路——登录时把用户对象整体放进 session:
User 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 href="css/style.css">就会被解析成.../login-demo/css/style.css,如果登录页和欢迎页不在同一目录层级,路径就错位了。
解决办法是统一用带上下文根的绝对路径,在页面 head 里加一行 base 标签:
<base href="<%=request.getContextPath()%>/">或者手动拼接:
<link rel="stylesheet" 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 或 Ctrl+R 会重复发送上一次的 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 page="login.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、过滤器链那些更复杂的请求流转,都会轻松得多。