做了十几年Java开发,带过不少实习生和刚转行的同事,我发现大家对Java Web项目的认知断层几乎都出现在同一个地方:刚学会JSP的时候,觉得这东西太好用了,一行<%= ... %>就能把后台数据打上页面,什么Servlet、JavaBean、MVC根本没必要学。但真到了要维护一个超过十个页面的项目,或者要和别人协作开发时,那叠满Java脚本的JSP会瞬间变成灾难现场。这一章在课程里排在第7章,位置很讲究,你刚掌握了JSP的基本语法,还没被“一个页面打天下”的思路洗脑太久,正是把思维掰回规范轨道的最好时机。
我的目标很明确:把JavaBean、Servlet、MVC这三个概念在经典Java Web项目里的真实分工讲透,再带大家把一个人信息展示页面从“纯JSP野路子”改造成符合Model 2规范的结构。整篇文章按照我平时带新人的节奏来写,不堆概念,每个结论都配得上代码和场景。适合正在学Java Web、做课程设计或者准备重拾基础的工程师阅读,看完可以直接照着改造自己的老项目。
1. 从JSP页面到MVC:一次必要的思维转变
1.1 纯JSP页面开发的后遗症
先还原一个最常见的“原始现场”。很多人第一个动态页面是这样写的:JSP文件顶部用<%@ page import="java.sql.*" %>导包,接着在HTML代码里嵌一堆<% ... %>脚本片段,连数据库、执行查询、遍历结果集、拼接表格,全部放在同一个文件里。
<%@ page import="java.sql.*" %> <% Class.forName("com.mysql.cj.jdbc.Driver"); Connection conn = DriverManager.getConnection( "jdbc:mysql://localhost:3306/demo", "root", "123456"); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery("SELECT * FROM user_info"); %> <table> <% while (rs.next()) { %> <tr> <td><%= rs.getInt("id") %></td> <td><%= rs.getString("username") %></td> </tr> <% } %> </table> <% rs.close(); stmt.close(); conn.close(); %>单看这一页,能跑、能满足作业要求。可只要项目规模上来,问题立刻暴露:页面里Java逻辑一多,前端同事根本不敢碰这个文件,改个样式都要小心翼翼躲开脚本片段;数据库结构一变,要找的是散落在各个JSP里的SQL,而不是某个集中的数据访问类;更别说想做个单元测试了,JSP只能在容器里跑,压根没法做自动化验证。我把这种情况称为“一页跑通,十页爆炸”,不是危言耸听,是我接过太多这样的老项目后的切身感受。
1.2 MVC三个角色到底怎么分工
MVC这种思想之所以能统治Web开发这么多年,是因为它把人处理复杂事情的方式抽象成了三个明确角色:
Model负责数据和业务规则,相当于“原料和配方”。在一个典型Java Web项目里,Model由两部分组成:一个是数据结构,对应JavaBean,用来承载用户信息、订单记录这类业务实体;另一个是数据操作逻辑,比如查询数据库的DAO类,负责把业务数据从数据库里捞出来,再塞进JavaBean。
View负责把数据展示给用户,相当于“端上桌的菜”。在经典Java Web里,View就是JSP,专门接收Controller塞过来的数据,用EL表达式和JSTL标签渲染成HTML,不掺任何业务逻辑和数据库访问。
Controller负责接收请求、调度资源、决定下一步跳转到哪里,相当于“点菜的传菜员”。在经典Java Web里,Controller就是Servlet,它会解析参数、调用Model层拿数据、再决定把请求转发给哪个View去渲染,自己绝不直接拼接HTML输出。
这三个角色各干各的活,彼此通过固定的接口协作,这就是MVC的核心价值:职责单一,各层可以被独立修改和测试。
1.3 经典Java Web里的Model 2架构
在Servlet和JSP时代,MVC并不是一个抽象口号,Sun公司给出了具体的落地方案,叫作Model 2架构。与之相对的是Model 1,也就是前面那种纯JSP包揽一切的玩法。
Model 2的分工是这样的:请求先到达Servlet,Servlet作为Controller调用JavaBean或DAO组成的Model层处理业务,拿到结果后把数据放入request或者session作用域,再通过请求转发把控制权交给JSP视图,JSP只负责读取数据并输出HTML。这个“请求进Servlet、视图找JSP”的流转模型,就是后来Struts和Spring MVC的鼻祖。
哪怕放到今天,你去看ASP.NET Core MVC、Go语言Gin框架的目录结构,会发现核心思路都是一模一样的:一个Controller接收请求,一个Model承载数据,一个View渲染页面,只是换了一门语言和一套API而已。所以这一章学的不只是Servlet和JSP怎么配合,更是一种跨语言、跨框架的通用分层思维。
2. JavaBean:让数据从“散装”变“整装”
2.1 先分清JavaBean和POJO
很多初学者听到JavaBean就以为是普通Java类,这其实不准确。POJO是Plain Old Java Object,就是一个干净的、不继承任何框架类的普通对象。而JavaBean是POJO的一个更严格版本,它要遵守三条规范:
第一,属性必须私有,通过public的getter/setter方法访问;第二,要有一个公开的无参构造方法;第三,最好实现java.io.Serializable接口,并定义serialVersionUID。
为什么这些约定很重要?因为在Model 2架构里,JavaBean不只是用来装数据的容器,还要被各种工具和框架操作。JSP的EL表达式写${user.username}时,容器底层调用的是user.getUsername(),不是直接访问字段;Java的反射机制、对象序列化、日志输出工具,也都依赖这种标准的命名和构造方式。你可以理解为JavaBean是一个“按规矩办证”的对象,POJO则是“自由裸奔”的对象。
2.2 为什么不用Map强行代替
有人会问:既然只是装数据,我用Map<String, Object>不也行吗?行,但代价很大。
用Map的时候,存取数据的代码长这样:map.get("username")。字段名是字符串,一旦写错,运行期才知道,IDE和编译器都帮不了你。更要命的是,Map里什么类型的值都能放,你拿到的可能是个String也可能是个Integer,所有读取处都得自己做类型判断和强转,代码又乱又容易出故障。
换成JavaBean,user.getUsername()是编译期就能检查的强类型方法,字段名错了直接编译报错,根本不给你运行的机会。IDE还能提供自动补全和重构支持,字段重命名时所有调用点跟着变。随着项目变复杂,JavaBean还能加一些辅助方法,比如返回用户全名、检查数据有效性,这些都不是Map能优雅承载的。从Map到JavaBean,本质是从“一串散装数据”走向“一个受约束的业务对象”。
2.3 一个规范的JavaBean长什么样
以一个用户信息实体为例,规范的JavaBean写法如下:
package com.course.model; import java.io.Serializable; import java.util.Date; public class UserInfo implements Serializable { private static final long serialVersionUID = 1L; private Long id; private String username; private String email; private Date registerTime; public UserInfo() { } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public Date getRegisterTime() { return registerTime; } public void setRegisterTime(Date registerTime) { this.registerTime = registerTime; } }几个细节要注意。一是serialVersionUID别偷懒,虽然编译器允许不写,但类结构一变,反序列化可能直接报错,显式声明是习惯问题。二是布尔类型的字段命名有个坑:如果属性叫active,有的框架会找isActive(),有的会找getActive(),风格混用最容易被序列化和反射工具折腾,我的建议是团队里统一规范,要么全部遵循基本类型的isXxx()约定,要么统一用包装类型加getXxx()。三是日期字段如果未来要做JSON传输,建议在类上声明格式化策略,否则输出格式很可能不是你想要的。一个JavaBean写得规范,后面所有层面都省心。
3. Servlet:接管请求的“总调度”
3.1 生命周期与线程安全
Servlet不是“处理请求的类”这么简单,它有明确的生命周期,掌控好它,才能理解为什么某些代码该放在哪里。
Servlet生命周期分四段:实例化、初始化、服务、销毁。实例化发生在第一次请求到来时,除非配置了load-on-startup让它随容器启动;随后容器调用init(ServletConfig)做初始化,这里适合加载资源、初始化连接池;每次请求进来调用service(),HttpServlet已经把方法分发好,你只需要覆盖doGet和doPost;容器关闭时调用destroy()释放资源。
这里有一个很容易踩的坑:Servlet是单实例多线程的。也就是说,整个应用里某个Servlet只有一个对象,所有请求共享它。如果你在Servlet里定义了可变的成员变量并写数据,比如private int count = 0;然后每个请求都count++,多线程并发下会出现严重的竞争问题,数据直接脏掉。成员变量只能放不修改状态的依赖对象,比如DAO实例、配置常量。需要保持状态的数据,放到request、session或者数据库里去,而不是塞进Servlet自身。
3.2 注解配置与请求处理骨架
Servlet 3.0之前,每个Servlet都要在web.xml里写一段冗长的注册和映射配置,维护起来非常痛苦。3.0之后可以用@WebServlet注解直接搞定,宝里宝气的web.xml终于可以退居二线。
一个用户信息查询的Servlet,完整骨架长这样:
package com.course.servlet; import com.course.dao.UserInfoDao; import com.course.model.UserInfo; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.List; @WebServlet(name = "InfoServlet", urlPatterns = "/user/info") public class InfoServlet extends HttpServlet { private final UserInfoDao userInfoDao = new UserInfoDao(); @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); List<UserInfo> userList = userInfoDao.findAll(); request.setAttribute("userList", userList); request.getRequestDispatcher("/jsp/infoView.jsp").forward(request, response); } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doGet(request, response); } }@WebServlet里的urlPatterns就是外部访问路径,/user/info这样的语义化URL比一堆数字编号要清晰得多。在doGet里,第一件事就是设置请求和响应的编码,这一点太容易忘,忘的结果就是中文乱码,下文我会单独讲。然后调用DAO查数据、把结果用setAttribute放入request作用域、转发给JSP,注意:Servlet不直接out.println输出HTML,它只负责调度,这是MVC规矩的核心。
3.3 转发、重定向与数据的传递
Servlet里最让新手犯晕的就是forward和sendRedirect到底有什么区别。我直接给一个明确对比:
| 对比维度 | forward(请求转发) | sendRedirect(重定向) |
|---|---|---|
| 地址栏URL | 不变,还是Servlet的路径 | 变为目标页面的路径 |
| 浏览器请求次数 | 1次,服务器内部跳转 | 2次,浏览器发起第二次请求 |
| request作用域数据 | 保留,View可以读取 | 丢失,永远带不到目标页 |
| 可以跳转到外部站点 | 不可以 | 可以 |
| 典型应用场景 | Controller把请求交给JSP渲染 | 登录成功后防刷新重复提交、跳外部链接 |
在MVC流程里,Servlet拿到数据后要交给JSP去渲染,必须用forward,因为userList放在request里,转发是同一次请求,JSP才能把它取出来。如果误用sendRedirect,浏览器会重新请求JSP地址,request里的属性全部丢失,页面上什么都拿不到。
还有一个特别容易混的点:request.getParameter("id")读的是浏览器请求参数,request.getAttribute("userList")读的是Controller显式塞进作用域的数据,两者不是一回事。参数来自URL或表单,属性来自代码逻辑的传递,别搞混,否则要么拿不到值,要么拿到的是字符串和对象的类型错乱。
4. MVC落地:以用户信息展示为例做一次完整改造
4.1 改造前的“野路子”JSP长什么样
为了让这套规范真正落地,我准备把一个实际教学里反复用的案例完整走一遍。某天产品提了一个需求:做一个用户信息展示页面,后台数据库有user_info表,前端要在一个表格里列出所有用户的ID、用户名、邮箱和注册时间。
如果按纯JSP的思路,大部分人会把数据查询、HTML渲染全塞进一个info.jsp里,就像第一章展示的那样。这么做,新手能交差,页面能打开,但隐患已经埋下。我见过最夸张的版本是300行的JSP里,脚本片段占了240行,里面甚至有三段几乎一模一样的查库逻辑,只因为页面三个区块需要不同过滤条件。这种文件,我连重构的欲望都没有,只想推倒重来。
4.2 改造后的目录和分层设计
按MVC规范改造,我建议你把项目划分成清晰的包结构:
src/ ├── com/course/model/ # JavaBean实体层 │ └── UserInfo.java ├── com/course/dao/ # 数据访问层 │ └── UserInfoDao.java ├── com/course/servlet/ # Servlet控制器层 │ └── InfoServlet.java web/ ├── jsp/ # 视图层,只放JSP │ └── infoView.jsp ├── css/ # 静态资源 └── WEB-INF/ └── web.xmlpackage的命名规范是公司域名倒置 + 项目名 + 分层名,我演示里用com.course示意。实体类放model,数据库操作放dao,请求控制放servlet,JSP单独放到jsp目录而不是和静态资源混在一起。这套分层的价值在于:以后加一个新页面,你就知道该往哪个包加什么东西,不需要靠记忆和经验找文件。
数据访问层我单独提取成UserInfoDao,专门负责JDBC操作:
package com.course.dao; import com.course.model.UserInfo; import java.sql.*; import java.util.ArrayList; import java.util.List; public class UserInfoDao { private static final String URL = "jdbc:mysql://localhost:3306/demo?useUnicode=true&characterEncoding=UTF-8"; private static final String USERNAME = "root"; private static final String PASSWORD = "123456"; public List<UserInfo> findAll() { List<UserInfo> list = new ArrayList<>(); String sql = "SELECT id, username, email, register_time FROM user_info ORDER BY register_time DESC"; try (Connection conn = DriverManager.getConnection(URL, USERNAME, PASSWORD); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { UserInfo user = new UserInfo(); user.setId(rs.getLong("id")); user.setUsername(rs.getString("username")); user.setEmail(rs.getString("email")); user.setRegisterTime(rs.getTimestamp("register_time")); list.add(user); } } catch (SQLException e) { throw new RuntimeException("查询用户信息失败", e); } return list; } }我用PreparedStatement而不是Statement,一是预编译可以防SQL注入,二是带参数查询时写法更安全。try-with-resources能确保连接、语句、结果集自动关闭,不用再像老代码那样必须手动在finally里写一堆close()。
4.3 从请求到响应的完整链路
改造后的请求流转路径是:浏览器访问/user/info,容器找到InfoServlet,Servlet调用UserInfoDao.findAll()获取数据列表,把列表放入request作用域,然后转发到/jsp/infoView.jsp,JSP用EL表达式和JSTL把数据渲染成表格。
JSP视图层的代码不再出现任何Java脚本,长这样:
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>个人信息展示</title> </head> <body> <div class="container"> <h2>用户信息列表</h2> <c:if test="${empty userList}"> <p class="empty-tip">暂无用户数据</p> </c:if> <c:if test="${not empty userList}"> <table class="user-table"> <thead> <tr> <th>ID</th> <th>用户名</th> <th>邮箱</th> <th>注册时间</th> </tr> </thead> <tbody> <c:forEach var="user" items="${userList}"> <tr> <td>${user.id}</td> <td>${user.username}</td> <td>${user.email}</td> <td><fmt:formatDate value="${user.registerTime}" pattern="yyyy-MM-dd HH:mm:ss"/></td> </tr> </c:forEach> </tbody> </table> </c:if> </div> </body> </html>注意几个细节。${user.username}看起来在直接访问属性,实际上EL表达式会在底层调用UserInfo的getUsername()方法,这就是我前面强调JavaBean规范命名重要性的原因。日期字段直接用${user.registerTime}会输出一个不舒服的完整时间戳,所以用fmt:formatDate指定格式。c:if判空避免数据为空时页面出现空白表格,c:forEach负责循环遍历。整个JSP里没有任何一行<% %>,前后端标记清晰,前端同事拿到这个文件可以放心调整样式而不用担心弄坏Java代码。
4.4 改造前后的本质区别
把改造前后的代码放在一起看,差距立竿见影:
| 维度 | 改造前(纯JSP) | 改造后(MVC) |
|---|---|---|
| 职责分布 | JSP同时干查库、写逻辑、拼HTML | Servlet接请求、DAO查数据、JavaBean装数据、JSP只显示 |
| 改动风险 | 动一个样式可能误伤Java脚本 | 各层独立修改,互不干扰 |
| 复用能力 | 同样的查库代码需要在多个页面复制 | DAO方法可以被任意Controller多次调用 |
| 测试性 | JSP必须在容器里运行,无法单元测试 | DAO和JavaBean脱离Web环境即可测试 |
| 团队协作 | 前端和后端挤在一个文件里互相踩脚 | 定好接口数据格式后可以并行开发 |
这套结构还有一个巨大的附加价值:为将来接框架做铺垫。现在很多人已经转向Spring Boot + MyBatis这种组合,但从概念上一看,Spring MVC的Controller不就是这里的Servlet吗?MyBatis的操作不就是这里DAO的升级版吗?你在这个经典版本里把MVC思路和分层习惯养成了,学习新框架就是换一套API的事,不用再重头理解一遍架构。
5. 常见问题与排查实录
5.1 乱码问题:九成初学者的第一道坎
中文乱码绝对是Java Web开发里出现频率最高的问题,而且每次的根源可能完全不同,按位置分有四类。第一类是POST请求参数乱码,解决方式是调用request.setCharacterEncoding("UTF-8"),而且必须在所有getParameter之前执行,放后面的等于白写。第二类是GET请求参数乱码,Tomcat 8及以上版本默认UTF-8,不用管;如果你还在用老古董Tomcat 7及以下,要去server.xml的Connector节点加一句URIEncoding="UTF-8"。第三类是响应输出乱码,在写任何输出前设置response.setContentType("text/html;charset=UTF-8")。第四类是JSP页面静态部分乱码,需要同时保证page指令里声明了contentType="text/html;charset=UTF-8" pageEncoding="UTF-8",并且文件本身用UTF-8编码保存。
我见过的惨案常常是一口气踩了三个:请求没设编码、响应没设编码、页面文件还是GBK,于是调了一下午。建议把编码设置养成条件反射,直接在Servlet的doGet入口最先写两行。
5.2 “加载完自动刷新一次”的正确打开方式
有人搜“jsp页面让加载完后刷新一次”,十有八九是遇到了表单重复提交或者数据同步的困扰,但这里要分场景处理。
如果你的需求是让某个页面在加载完成后自动跳到另一个地址,或者定时刷新当前页面,可以用HTML的meta标签:
<meta http-equiv="refresh" content="5;url=/user/info">这段代码表示页面加载后5秒自动请求/user/info,把content设为1就是1秒后刷新当前页。这个方案在数据大屏、订单状态轮询里合理。
但如果你是因为表单提交后怕页面过期,想“刷新一下”来更新数据,用meta refresh是大错特错。正确做法是PRG模式:表单提交到Servlet,Servlet处理完成后不要forward到结果页,而是调用response.sendRedirect(request.getContextPath() + "/user/info")做一次重定向。这样浏览器地址栏会变成/user/info,用户再按F5刷新时触发的是一次干净的GET请求,不会重复提交表单数据。记住两个词:表单处理完要重定向,渲染交给转发。
5.3 转发和重定向搞反之后的连锁反应
转发和重定向选错,表面症状千奇百怪,本质原因就那么几条。
症状一是页面能打开但表格没有数据。原因基本是Servlet用了sendRedirect,request里的userList在第二次请求里彻底丢失。排查时先看地址栏,如果跳转后URL变成了/jsp/infoView.jsp而不是原来的/user/info,基本就是重定向干的。症状二是路径明明能访问但经常404,原因多半是重定向时路径没加request.getContextPath(),写成了绝对路径的/user/info,在打成war包部署时丢失了上下文前缀。症状三是按F5刷新会弹“重新发送表单”的吓人提示,这就是表单提交后没有重定向的结果。
我把规则总结成一句口诀:Controller要把活交给View去干,用转发;浏览器要去一个新的地址重新开始,用重定向。
5.4 EL表达式不显示和JSTL标签不生效
另一个高频疑难杂症是:代码看起来没问题,但页面上的${user.username}原样输出成字符串,或者<c:forEach>直接变成普通标签打印在页面上,没有任何循环效果。
先检查JSP开头是否加了<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>,这是JSTL使用的前提。接着检查项目WEB-INF/lib下有没有JSTL实现包,常见的是jstl-1.2.jar,没有它标签库根本解析不了。还有一个更隐蔽的原因是老容器或web.xml配置导致EL被禁用,在页面上显式声明<%@ page isELIgnored="false" %>可以强行打开。最后顺一遍依赖冲突:如果项目里同时出现了多个版本的el-api、jsp-api,优先把它们统一到容器自带的版本。
调试这类问题,最快的办法是浏览器右键查看源代码,看看JSP输出的到底是${user.username}原文还是空字符串。输出原文说明EL没解析,输出空字符串说明EL解析了但取不到值,排查方向完全不同。
还有一类和JavaBean强相关的坑:页面报Property 'username' not found on type UserInfo,或者一直输出null。要么是JSP里写的属性名和JavaBean里不一致,要么是JavaBean的getter方法名命名不规范。例如字段叫uName,getter写的是getUname(),EL表达式写${user.uName}就找不到。我遇到这种问题都是先打开JavaBean,把字段名和getter逐个核对一遍,比在页面里瞎调试快得多。
写在后面的一点感受
这一章的内容,说到底就是帮人完成一次思维升级:把“代码能跑就行”变成“代码跑得让人放心”。我在带人的时候从不直接逼着新人用MVC,反而让他们先写一个纯JSP的大杂烩项目,等他们自己吃到改需求的苦头,再回头理解Servlet和JavaBean的分工,那种茅塞顿开的效果远胜于一开始就灌输一堆架构理论。规范这个东西,不是用来束缚你的条条框框,而是前人用无数次线上故障换来的防线。你写出的代码不只是给编译器看,更多时候是给下一个接手的人看,而那个下一个接手的人,很可能就是三个月后的你自己。如果你手头正好有一个塞满脚本的旧JSP页面,别急着丢,按照这一章的步骤拆一遍,拆完你会回来告诉我:早该这么干了。