工艺演进中的开发沉思-182 JSP :MVC 模式
当年刚用 JSP 开发时,总觉得 “能跑就行”—— 把表单处理、数据库查询、页面渲染全堆在一个 JSP 里,一个user.jsp写上千行代码也不觉得离谱。直到维护一个 Model1 架构的 OA 系统,改一个 “请假审批流程” 要在 3000 行代码里找逻辑,改完还牵出三个 bug,才明白:没有架构的代码,就是待爆的 “定时炸弹”。而 MVC 模式,就是当年拯救我们于混乱的 “架构圣经”。

一、MVC
第一次接触 MVC 时,老领导拿着白板画了三个框:Model、View、Controller,说 “以后写代码,就按这三个框分,别再混在一起了”。当时似懂非懂,后来写多了才发现,MVC 的核心不是 “三个组件”,而是 “职责分离”—— 让该干杂活的干杂活,该做展示的做展示,该控流程的控流程,互不干涉。
1. 三个组件
MVC 的三个部分,就像餐馆里的 “后厨、前厅、服务员”,分工明确:
- Model(模型):数据的 “管家”
Model 不关心页面怎么展示,也不关心请求怎么处理,只负责 “管数据”—— 要么是承载数据的 JavaBean(比如User、Order),要么是处理业务逻辑的 Service(比如UserService里的登录验证、订单计算)。当年写User类时,只放username、password这些属性和 getter/setter,至于用户怎么登录、登录后怎么显示,全不管 —— 这就是 Model 的 “本分”。
- View(视图):页面的 “化妆师”
View 只负责 “展示数据”,也就是 JSP 页面。它不能有复杂业务逻辑,最多用 JSTL/EL 从 Model 里取数据渲染页面。当年重构 OA 系统时,把原来 JSP 里的JDBC代码、if-else判断全删掉,只留${user.name}、<c:forEach>这样的渲染逻辑,结果页面代码量减少了 60%,美工改样式时再也不用问我 “这段 Java 代码能不能动”。
- Controller(控制器):请求的 “交通警察”
Controller 是 “中间人”,负责接收浏览器请求、调用 Model 处理业务、再把结果传给 View。它就是 Servlet—— 当年写LoginServlet时,只做三件事:从request里拿用户名密码、调用UserService.login()验证、把结果存到request里转发给 JSP。至于密码怎么加密、页面怎么显示,全交给 Model 和 View——Controller 只控流程,不碰细节。
这三者的协作流程特别清晰:
浏览器发请求 → Controller(Servlet)接请求 → 调用Model处理业务 → Controller把数据传给View → View渲染页面返回浏览器
就像餐馆里 “顾客点餐 → 服务员传菜单 → 后厨做菜 → 服务员端菜 → 顾客用餐”,每个环节只做自己的事,效率高还不容易出错。
2. Servlet+JSP 的 MVC 实现
在 JSP 时代,MVC 没有现成框架(后来才有 Struts),全靠 “Servlet+JSP+JavaBean” 手动实现,这套组合当年是企业级应用的 “标配”,每个 Java 程序员都得练熟。
具体分工是:
- Controller:Servlet(如LoginServlet、OrderServlet),处理请求、调度逻辑;
- Model:JavaBean(数据载体,如User)+ Service(业务逻辑,如UserService);
- View:JSP 页面,只做数据渲染,不用业务逻辑。
当年做电商的 “商品详情页”,就是这么拆的:
- 浏览器请求/productDetail?id=1 → 被ProductServlet(Controller)接收;
- ProductServlet调用ProductService.getProductById(1)(Model),从数据库查商品数据;
- ProductServlet把商品对象存到request.setAttribute("product", product);
- 转发到productDetail.jsp(View);
- productDetail.jsp用${product.name}、${product.price}渲染页面。
这套流程走下来,代码逻辑清清楚楚:改业务逻辑只动 Service,改页面只动 JSP,改请求路径只动 Servlet,再也不用在一个文件里翻来翻去。
3. 对比 Model1
没接触 MVC 前,我们都用 Model1 架构 ——JSP 一把梭,又当 Controller 又当 View 还当 Model,代码就是 “一锅粥”。当年维护的 Model1 项目,一个order.jsp里既有request.getParameter()接参数,又有JDBC查数据库,还有out.println拼 HTML,改个订单状态字段,要在 300 行代码里找三个地方改,简直是 “拆弹”。
Model1 和 MVC 的区别,就像 “一个人干所有活” 和 “三个人分工干”:
对比维度 | Model1(JSP 一把梭) | MVC(Servlet+JSP+JavaBean) |
代码组织 | 所有逻辑堆在 JSP,混乱难维护 | 按职责拆分到 Servlet/JSP/JavaBean,清晰 |
团队协作 | 前后端必须改同一个文件,冲突多 | 后端写 Servlet/Service,前端写 JSP,并行开发 |
复用性 | 业务逻辑嵌在 JSP 里,没法复用 | Service 和 JavaBean 可复用,多个 Servlet 能调用 |
扩展性 | 加功能要改原有 JSP,容易出 bug | 加功能只加 Servlet/Service,不影响旧代码 |
当年把 Model1 的 OA 系统重构为 MVC 后,团队协作效率提升了一倍,改需求时再也不用 “牵一发而动全身”—— 这就是架构的力量。
二、RequestDispatcher
MVC 里,Controller(Servlet)处理完逻辑后,怎么把数据传给 View(JSP)?靠的就是RequestDispatcher—— 这个 Servlet API 里的 “连接器”,负责把请求从 Servlet “转发” 到 JSP,还能带着数据一起走。当年第一次用它时,才明白 “转发” 和 “重定向” 的区别,也终于解决了 Model1 里 “表单重复提交” 的老问题。
1. 请求转发
forward()是 MVC 里用得最多的方法,它是 “服务器内部跳转”:浏览器不知道跳转,地址栏不变,request域里的数据能带着走。
当年写LoginServlet的登录成功逻辑,就是用forward():
@WebServlet("/login")
public class LoginServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 1. 接参数
String username = req.getParameter("username");
String password = req.getParameter("password");
// 2. 调用Model验证
UserService userService = new UserService();
User user = userService.login(username, password);
// 3. 处理结果
if (user != null) {
// 登录成功,把用户信息存到request
req.setAttribute("loginUser", user);
// 转发到首页(index.jsp)
RequestDispatcher dispatcher = req.getRequestDispatcher("/index.jsp");
dispatcher.forward(req, resp);
} else {
// 登录失败,存错误信息
req.setAttribute("errorMsg", "用户名或密码错误");
// 转发回登录页(login.jsp)
req.getRequestDispatcher("/login.jsp").forward(req, resp);
}
}
}
forward()的关键特点:
- 地址栏不变:浏览器请求的是/login,转发后地址栏还是/login,用户看不到跳转;
- 共享 request 域:Servlet 里setAttribute的数据,JSP 里能通过${loginUser}取到;
- 只能转发到内部资源:只能跳转到当前 Web 应用里的页面(如/index.jsp),不能跳转到http://baidu.com;
- 执行一次响应:服务器只给浏览器返回一次响应(转发后的页面),不会像重定向那样发两次请求。
当年踩过一个坑:转发前给resp写了内容(比如resp.getWriter().print("success")),结果forward()时报 “已经提交响应” 的错 —— 因为forward()要求转发前不能有任何响应输出,这是新手常犯的错误。
2. 包含转发
include()比forward()用得少,它是 “包含转发”:把被包含页面的输出内容,拼到当前页面的输出里,相当于 “服务器端的包含”,和<jsp:include>有点像,但include()是在 Servlet 里手动调用。
当年做 “页面统一标题” 时用过include():在index.jsp对应的 Servlet 里,包含header.jsp的内容:
// 在某个Servlet里
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.setContentType("text/html;charset=UTF-8");
PrintWriter out = resp.getWriter();
// 先输出页面头部
out.println("首页 ");
// 包含header.jsp(页面标题和导航)
RequestDispatcher dispatcher = req.getRequestDispatcher("/header.jsp");
dispatcher.include(req, resp);
// 再输出首页内容
out.println("首页内容
");
out.println("");
}
include()的特点:
- 内容拼接:被包含页面(header.jsp)的 HTML 会拼到当前输出里,用户看到的是一个完整页面;
- 共享 request 域:和forward()一样,request里的数据能在包含和被包含页面间共享;
- 适合静态片段:当年主要用它包含页头、页脚这些静态内容,后来被<jsp:include>替代,因为在 JSP 里写标签比在 Servlet 里写代码更方便。
3. 转发 和 重定向
新手学 MVC 时,总会问 “什么时候用forward(),什么时候用response.sendRedirect()”—— 这两个方法的区别,当年我们总结了一个 “口诀”:“成功用重定向,失败用转发;传数据用转发,防重复提交用重定向”。
具体区别看这张表,当年贴在工位上天天看:
对比维度 | forward ()(转发) | sendRedirect ()(重定向) |
跳转方式 | 服务器内部跳转,浏览器不知情 | 服务器告诉浏览器 “去新地址”,浏览器发新请求 |
地址栏 | 不变 | 变成新地址 |
request 域共享 | 共享(能传数据) | 不共享(新请求,旧 request 失效) |
跳转范围 | 只能跳当前 Web 应用内部资源 | 能跳外部地址(如http://baidu.com) |
应用场景 | 处理失败后回显数据(如登录失败) | 处理成功后跳转(如登录成功跳首页) |
当年做登录功能时,一开始用forward()跳首页,结果用户刷新页面会重新提交登录表单,导致重复登录 —— 后来改成登录成功后resp.sendRedirect("/index.jsp"),问题立马解决。这就是 “成功用重定向” 的道理:重定向后,旧的请求已经结束,刷新不会重复提交。
三、用户登录的 MVC 实现
当年面试时,面试官常让 “用 MVC 写一个登录功能”,这是最基础也最能体现 MVC 思想的案例。咱们一步步拆解,还原当年的实战代码。
1. 第一步:写 Model
① 数据载体:User.java(JavaBean)
// 纯数据类,只存属性和getter/setter
public class User {
private String username;
private String password;
private String nickname;
// 无参构造器(必须有)
public User() {}
// getter和setter
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getNickname() { return nickname; }
public void setNickname(String nickname) { this.nickname = nickname; }
}
② 业务逻辑:UserService.java(Service 层)
// 处理登录业务,模拟数据库查询
public class UserService {
// 登录方法:返回User表示成功,null表示失败
public User login(String username, String password) {
// 模拟数据库查询(实际项目用JDBC/MyBatis)
if ("admin".equals(username) && "123456".equals(password)) {
User user = new User();
user.setUsername(username);
user.setPassword(password);
user.setNickname("管理员");
return user;
}
// 模拟密码错误
return null;
}
}
2. 第二步:写 View(JSP 页面)
① 登录表单:login.jsp
只做展示,用 EL 取错误信息:
<%@ page contentType="text/html;charset=UTF-8" %>
用户登录
${errorMsg}
② 首页:index.jsp
展示登录用户信息,用 EL 取loginUser:
<%@ page contentType="text/html;charset=UTF-8" %>
欢迎您,${loginUser.nickname}!
用户名:${loginUser.username}
退出登录
3. 第三步:写 Controller(Servlet)
登录控制器:LoginServlet.java
处理登录请求,调度 Model 和 View:
@WebServlet("/login")
public class LoginServlet extends HttpServlet {
private UserService userService = new UserService(); // 初始化Service
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 1. 处理中文乱码(POST请求必须加)
req.setCharacterEncoding("UTF-8");
// 2. 接收请求参数
String username = req.getParameter("username");
String password = req.getParameter("password");
// 3. 调用Model(UserService)处理业务
User user = userService.login(username, password);
// 4. 处理结果,调度View
if (user != null) {
// 登录成功:用session存用户状态(跨请求共享),重定向到首页
req.getSession().setAttribute("loginUser", user);
resp.sendRedirect("/index.jsp"); // 成功用重定向,防重复提交
} else {
// 登录失败:用request存错误信息,转发回登录页
req.setAttribute("errorMsg", "用户名或密码错误");
req.getRequestDispatcher("/login.jsp").forward(req, resp); // 失败用转发,传错误信息
}
}
// 处理GET请求(直接访问/login时跳登录页)
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
req.getRequestDispatcher("/login.jsp").forward(req, resp);
}
}
退出控制器:LogoutServlet.java
清空 session,重定向到登录页:
@WebServlet("/logout")
public class LogoutServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 销毁session,清空登录状态
req.getSession().invalidate();
// 重定向到登录页
resp.sendRedirect("/login");
}
}
4. 运行流程:MVC 的完整闭环
当年每次写完代码,都会对着浏览器一步步走流程,确认每个环节都通 —— 这是避免上线出 bug 的 “笨办法”,但特别管用。咱们把登录案例的完整流程拆解开,就像当年调试时那样:
第一步:访问登录页
浏览器输入 http://localhost:8080/your-project/login(your-project是你的项目名),发送 GET 请求。
- 这个请求会被LoginServlet的doGet()方法接收(因为@WebServlet("/login")映射了这个路径);
- doGet()里没有复杂逻辑,直接通过req.getRequestDispatcher("/login.jsp").forward(req, resp)转发到登录页;
- 浏览器收到login.jsp的 HTML,渲染出带用户名、密码输入框的登录表单。
第二步:提交登录表单
输入用户名 “admin”、密码 “123456”,点击 “登录” 按钮 —— 表单通过 POST 方式把数据提交到/login路径。
- 此时请求进入LoginServlet的doPost()方法:
① 先执行req.setCharacterEncoding("UTF-8"),解决 POST 请求的中文乱码(当年没加这句,输入中文用户名就会变成 “???”);
② 用req.getParameter()取出表单里的username和password;
③ 调用userService.login(),Service 层模拟数据库查询,返回封装好的User对象(包含昵称 “管理员”)。
第三步:登录成功跳转
因为user不为 null,进入成功分支:
- 用req.getSession().setAttribute("loginUser", user)把用户信息存到 session—— 这一步很关键,因为首页index.jsp要通过 session 判断用户是否登录,而且 session 的数据能跨请求共享(比如用户跳转到订单页,还能拿到登录状态);
- 执行resp.sendRedirect("/your-project/index.jsp"),服务器给浏览器返回一个 “302 重定向” 响应,告诉浏览器 “去访问首页”;
- 浏览器收到响应后,自动发送新请求访问index.jsp,地址栏变成http://localhost:8080/your-project/index.jsp。
第四步:渲染首页
访问index.jsp时,JSP 会自动解析 EL 表达式:
- \${loginUser.nickname}从 session 里取出用户昵称,渲染成 “欢迎您,管理员!”;
- \${loginUser.username}渲染出用户名 “admin”;
- 页面上的 “退出登录” 链接指向/logout,为后续退出功能铺路。
第五步:退出登录
点击 “退出登录”,请求进入LogoutServlet的doGet()方法:
- 执行req.getSession().invalidate()销毁当前 session—— 这会清空 session 里的loginUser,用户登录状态消失;
- 用resp.sendRedirect("/your-project/login")重定向回登录页,完成整个流程闭环。
当年每次走通这个流程,都会下意识地检查:地址栏有没有变?刷新首页会不会提示未登录?退出后再访问index.jsp能不能被拦截 —— 这些细节,都是保证系统安全和用户体验的关键。
5. 实战注意事项
写 MVC 登录案例时,看似简单,但新手很容易栽在几个细节上 —— 这些都是当年我们团队踩过的坑,总结出来帮大家避坑:
① 项目路径别写错
当年有个实习生写重定向时,把resp.sendRedirect("/index.jsp")写成resp.sendRedirect("index.jsp")(少了开头的 “/”),结果登录成功后跳转到http://localhost:8080/your-project/login/index.jsp—— 显然不对,因为login是 Servlet 路径,不是文件夹。
这里的规则是:
- forward()和sendRedirect()里的路径,如果以 “/” 开头,表示 “当前 Web 应用的根路径”(即http://localhost:8080/your-project/);
- 如果不以 “/” 开头,表示 “相对于当前请求路径”(比如在/login路径下,"index.jsp"会解析成/login/index.jsp)。
所以正确写法是:
resp.sendRedirect("/your-project/index.jsp")(完整路径,推荐,避免歧义)
或 resp.sendRedirect(req.getContextPath() + "/index.jsp")(用req.getContextPath()动态获取项目根路径,部署时不用改路径)。
② 中文乱码:POST 请求必须处理
当年测试时,输入中文用户名 “张三”,后台拿到的是 “??? ”—— 因为 POST 请求的参数默认是 “ISO-8859-1” 编码,不支持中文。
解决办法只有一个:在doPost()方法最开头执行req.setCharacterEncoding("UTF-8"),而且必须在req.getParameter()之前执行 —— 如果先取参数再设编码,等于白设。
另外,JSP 页面的pageEncoding和contentType也要设为 UTF-8,形成 “前端输入→后台接收→页面渲染” 的全链路 UTF-8 编码:
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>。
③ 未登录拦截:别让用户直接访问首页
当年写完代码,发现直接在地址栏输入index.jsp的路径,不用登录也能访问 —— 这是严重的安全漏洞,必须拦截未登录用户。
解决办法是写一个 “登录过滤器”(Filter),拦截所有需要登录的页面(如index.jsp、order.jsp):
@WebFilter("/*") // 拦截所有请求
public class LoginFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
// 1. 排除不需要拦截的路径:登录页、登录Servlet、静态资源(CSS/JS)
String url = req.getRequestURI();
if (url.contains("login.jsp") || url.contains("login") || url.contains("css/") || url.contains("js/")) {
chain.doFilter(request, response); // 放行
return;
}
// 2. 检查session里有没有登录用户
User loginUser = (User) req.getSession().getAttribute("loginUser");
if (loginUser == null) {
// 未登录,重定向到登录页
resp.sendRedirect(req.getContextPath() + "/login.jsp");
return;
}
// 3. 已登录,放行
chain.doFilter(request, response);
}
}
这个过滤器当年救了我们很多次 —— 如果没加,用户直接访问index.jsp就能绕过登录,泄露敏感信息。
④ Service 层别写死:留着扩展数据库
案例里的UserService.login()是硬编码判断 “admin” 和 “123456”,但实际项目中,肯定要连数据库查真实数据。当年我们会把 Service 层设计成 “接口 + 实现类”,方便后续扩展:
// 接口:定义登录方法
public interface UserService {
User login(String username, String password);
}
// 实现类1:模拟数据库(测试用)
public class UserServiceImplMock implements UserService {
@Override
public User login(String username, String password) {
if ("admin".equals(username) && "123456".equals(password)) {
User user = new User();
user.setUsername(username);
user.setNickname("管理员");
return user;
}
return null;
}
}
// 实现类2:真实数据库(生产用,用JDBC/MyBatis)
public class UserServiceImpl implements UserService {
@Override
public User login(String username, String password) {
// 真实数据库查询逻辑:JDBC连接数据库,查user表
// ...
}
}
这样一来,测试时用UserServiceImplMock,上线时换成UserServiceImpl,不用改 Servlet 的代码 —— 这就是 MVC “职责分离” 的优势,改 Model 层不影响 Controller 层。
最后小结:
当年写 MVC 时,总以为学会 “Servlet 当 Controller、JSP 当 View、JavaBean 当 Model” 就够了。后来接触了 Spring MVC、Spring Boot,才发现 MVC 的本质不是固定的技术组合,而是 “职责分离、关注点分离” 的思想 —— 这种思想,一直贯穿在 Web 开发的演进中。
比如现在的前后端分离项目:
- 后端的Controller(Spring MVC 的@RestController)对应当年的 Servlet,负责接收请求、调用 Service;
- 后端的Service+Entity(实体类)对应当年的 Model,负责业务逻辑和数据承载;
- 前端的 Vue/React 项目对应当年的 JSP,负责页面渲染和用户交互;
- 当年的RequestDispatcher转发,变成了现在的 “后端返回 JSON,前端异步渲染”—— 形式变了,但 “Controller 调度、Model 处理、View 展示” 的核心逻辑没变。
所以,理解 JSP 时代的 MVC,不只是学一门过时的技术,更是掌握 Web 开发的 “底层逻辑”。就像老木匠先学用刨子,再学用电锯,工具会更新,但 “把材料加工成有用的东西” 的核心思路,永远不会过时。MVC教会我们的 “分而治之” 的智慧,依然在每次写代码时发挥着作用 —— 这,就是 MVC 留给我们最宝贵的财富。未完待续.........
浙公网安备 33010602011771号