
做了十几年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 importjava.sql.* %导包接着在HTML代码里嵌一堆% ... %脚本片段连数据库、执行查询、遍历结果集、拼接表格全部放在同一个文件里。% page importjava.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的分工是这样的请求先到达ServletServlet作为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强行代替有人会问既然只是装数据我用MapString, Object不也行吗行但代价很大。用Map的时候存取数据的代码长这样map.get(username)。字段名是字符串一旦写错运行期才知道IDE和编译器都帮不了你。更要命的是Map里什么类型的值都能放你拿到的可能是个String也可能是个Integer所有读取处都得自己做类型判断和强转代码又乱又容易出故障。换成JavaBeanuser.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;charsetUTF-8); ListUserInfo 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请求控制放servletJSP单独放到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?useUnicodetruecharacterEncodingUTF-8; private static final String USERNAME root; private static final String PASSWORD 123456; public ListUserInfo findAll() { ListUserInfo 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容器找到InfoServletServlet调用UserInfoDao.findAll()获取数据列表把列表放入request作用域然后转发到/jsp/infoView.jspJSP用EL表达式和JSTL把数据渲染成表格。JSP视图层的代码不再出现任何Java脚本长这样% page contentTypetext/html;charsetUTF-8 pageEncodingUTF-8 % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % !DOCTYPE html html langzh-CN head meta charsetUTF-8 title个人信息展示/title /head body div classcontainer h2用户信息列表/h2 c:if test${empty userList} p classempty-tip暂无用户数据/p /c:if c:if test${not empty userList} table classuser-table thead tr thID/th th用户名/th th邮箱/th th注册时间/th /tr /thead tbody c:forEach varuser items${userList} tr td${user.id}/td td${user.username}/td td${user.email}/td tdfmt:formatDate value${user.registerTime} patternyyyy-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同时干查库、写逻辑、拼HTMLServlet接请求、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节点加一句URIEncodingUTF-8。第三类是响应输出乱码在写任何输出前设置response.setContentType(text/html;charsetUTF-8)。第四类是JSP页面静态部分乱码需要同时保证page指令里声明了contentTypetext/html;charsetUTF-8 pageEncodingUTF-8并且文件本身用UTF-8编码保存。我见过的惨案常常是一口气踩了三个请求没设编码、响应没设编码、页面文件还是GBK于是调了一下午。建议把编码设置养成条件反射直接在Servlet的doGet入口最先写两行。5.2 “加载完自动刷新一次”的正确打开方式有人搜“jsp页面让加载完后刷新一次”十有八九是遇到了表单重复提交或者数据同步的困扰但这里要分场景处理。如果你的需求是让某个页面在加载完成后自动跳到另一个地址或者定时刷新当前页面可以用HTML的meta标签meta http-equivrefresh content5;url/user/info这段代码表示页面加载后5秒自动请求/user/info把content设为1就是1秒后刷新当前页。这个方案在数据大屏、订单状态轮询里合理。但如果你是因为表单提交后怕页面过期想“刷新一下”来更新数据用meta refresh是大错特错。正确做法是PRG模式表单提交到ServletServlet处理完成后不要forward到结果页而是调用response.sendRedirect(request.getContextPath() /user/info)做一次重定向。这样浏览器地址栏会变成/user/info用户再按F5刷新时触发的是一次干净的GET请求不会重复提交表单数据。记住两个词表单处理完要重定向渲染交给转发。5.3 转发和重定向搞反之后的连锁反应转发和重定向选错表面症状千奇百怪本质原因就那么几条。症状一是页面能打开但表格没有数据。原因基本是Servlet用了sendRedirectrequest里的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 prefixc urihttp://java.sun.com/jsp/jstl/core %这是JSTL使用的前提。接着检查项目WEB-INF/lib下有没有JSTL实现包常见的是jstl-1.2.jar没有它标签库根本解析不了。还有一个更隐蔽的原因是老容器或web.xml配置导致EL被禁用在页面上显式声明% page isELIgnoredfalse %可以强行打开。最后顺一遍依赖冲突如果项目里同时出现了多个版本的el-api、jsp-api优先把它们统一到容器自带的版本。调试这类问题最快的办法是浏览器右键查看源代码看看JSP输出的到底是${user.username}原文还是空字符串。输出原文说明EL没解析输出空字符串说明EL解析了但取不到值排查方向完全不同。还有一类和JavaBean强相关的坑页面报Property username not found on type UserInfo或者一直输出null。要么是JSP里写的属性名和JavaBean里不一致要么是JavaBean的getter方法名命名不规范。例如字段叫uNamegetter写的是getUname()EL表达式写${user.uName}就找不到。我遇到这种问题都是先打开JavaBean把字段名和getter逐个核对一遍比在页面里瞎调试快得多。写在后面的一点感受这一章的内容说到底就是帮人完成一次思维升级把“代码能跑就行”变成“代码跑得让人放心”。我在带人的时候从不直接逼着新人用MVC反而让他们先写一个纯JSP的大杂烩项目等他们自己吃到改需求的苦头再回头理解Servlet和JavaBean的分工那种茅塞顿开的效果远胜于一开始就灌输一堆架构理论。规范这个东西不是用来束缚你的条条框框而是前人用无数次线上故障换来的防线。你写出的代码不只是给编译器看更多时候是给下一个接手的人看而那个下一个接手的人很可能就是三个月后的你自己。如果你手头正好有一个塞满脚本的旧JSP页面别急着丢按照这一章的步骤拆一遍拆完你会回来告诉我早该这么干了。