简介:一份基于JSP+JavaBean+Servlet的JavaWeb入门级MVC项目,面向刚学习JavaWeb分层开发的学生或开发者,解决登录注册、留言板与分页展示等典型需求。资源共78个文件,涵盖14个Java类、14个JSP页面、14个class文件以及SQL脚本、XML配置、CSS样式、JAR依赖等,其中model层通过JavaBean封装数据库读写,view层用JSP负责信息展示,controller层以Servlet完成请求转发与业务交互,整体仅1.86MB,结构清晰便于阅读。已有1610人学习下载。通过该资源可拿到可直接运行的NetBeans工程源码,包含数据库脚本、分页实现、会话管理、表单校验等,适合对照MVC流程理解各层职责,也可作为课程设计或毕业设计的基础模板进行二次开发。 刚把 Java 基础学完,很多人的第一反应是直接冲 Spring Boot。但我的看法不一样:JSP+JavaBean+Servlet 这三个老伙计组合起来写一个登录注册加留言板,反而是把 Web 开发的底子打得最扎实的一条路。
为什么这么说?因为这几个技术正好覆盖了一个 Web 应用最核心的完整链路:浏览器发请求、Servlet 接收和处理、JavaBean 承载数据和业务、JSP 渲染页面、JDBC 操作数据库。把这套链路走通一遍,你以后再去看 Spring MVC、MyBatis,会发现那些框架无非是在这个骨架上做了一层又一层的封装。这篇文章就用一个完整的登录注册加留言板项目,把这条链路从头到尾拆开揉碎讲清楚。
先说清楚这个项目最终能做什么:用户可以在注册页填写用户名和密码完成注册,用注册的账号登录,登录成功后进入留言板页面,看到所有用户的留言,也能提交自己的留言,留言会持久化到数据库。项目里还会涉及 Session 会话管理、表单数据校验、中文乱码处理、SQL 注入防御这些实际开发里躲不开的问题。
如果你正在做课程设计,或者想找一个能完整跑通的 MVC 练手项目,这篇文章应该能给你省下不少时间。
1. 为什么我坚持用 JSP+JavaBean+Servlet 练这个项目
很多初学者会觉得 JSP 已经过时了,现在谁还直接写 JSP?这话对了一半。JSP 在大型商业项目里确实用得少了,但作为教学项目,它有一个无法替代的价值:它能让你肉眼看到请求、数据、页面三者之间是怎么流动的。
1.1 这个组合其实是 MVC 最朴素的样子
你可以把这三个角色对应到 MVC 模式里:
- JSP承担视图层 View 的角色,负责展示数据、收集用户输入;
- Servlet承担控制器 Controller 的角色,负责接收请求、调用业务、跳转页面;
- JavaBean承担模型 Model 的角色,负责封装数据表对应的实体,同时承载部分业务逻辑。
用这个项目做例子:用户在注册页面填完信息点提交,表单数据被送到注册的 Servlet;Servlet 把数据封装成 User 这个 JavaBean 对象;然后调用 DAO 方法把用户信息写入数据库;最后跳转到登录页面提示注册成功。整个过程一目了然,没有框架替你隐藏任何细节。
等你理解了这层关系,再去看 Spring MVC 里面@Controller、@RequestMapping这些注解,本质上是把 Servlet 的活换了一种更简洁的方式来做而已。
1.2 这个项目适合谁来写
我建议三类人认真做一遍:刚学完 JSP/Servlet 正在找综合练习的在校学生;准备面试但发现框架项目说不出底层原理的转行新人;以及想带学生做课设、需要一套干净可跑的参考代码的老师和助教。这个项目的代码量不大,大概十几个 Java 文件加四五个 JSP 页面,但麻雀虽小五脏俱全,数据库、会话、异常处理、中文编码全都能练到。
2. 搭骨架:项目目录结构、数据库设计、依赖清单
工程开工最重要的不是写代码,而是先把骨架搭对。很多人写到一半乱得不行,问题就出在目录结构和依赖上。
2.1 项目目录结构
我习惯用 Eclipse 或者 IDEA 的 Dynamic Web Project 结构,下面这个目录是经过多个项目验证、最清晰的布置方式:
src ├── com.demo.bean // 实体类:User.java、Message.java ├── com.demo.dao // 数据库访问层:UserDao.java、MessageDao.java ├── com.demo.servlet // 控制器:RegisterServlet、LoginServlet、LogoutServlet、AddMessageServlet └── com.demo.util // 工具类:DBUtil.java(数据库连接) WebContent ├── index.jsp // 首页/登录页 ├── register.jsp // 注册页 ├── list.jsp // 留言板页面 ├── failure.jsp // 操作失败提示页 └── WEB-INF ├── web.xml └── lib // mysql-connector-java.jar、jstl.jar这里要提醒一句:JSP 页面尽量不要放到 WEB-INF 目录里,因为放在那里只能通过 Servlet 转发访问,不能直接通过 URL 请求。本项目为了演示直接访问跳转,所以把 JSP 放在 WebContent 根目录下。如果你后面要做到更严格的权限控制,再考虑把部分页面挪到 WEB-INF 也不迟。
2.2 数据库设计
这个项目只需要两张表,逻辑非常清晰:
| 表名 | 字段 | 说明 |
|---|---|---|
| user | id(自增主键)、username(唯一)、password、created_at | 用户表 |
| message | id(自增主键)、user_id(外键关联 user.id)、content、create_time | 留言表 |
建表 SQL 如下:
CREATE DATABASE IF NOT EXISTS demo_web DEFAULT CHARSET utf8mb4; USE demo_web; CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(64) NOT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `message` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `user_id` INT NOT NULL, `content` TEXT NOT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_message_user FOREIGN KEY (user_id) REFERENCES `user`(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;两个设计细节值得注意。第一,密码字段我留了 64 位,是为了给后面做 MD5/SHA-256 加固定位。第二,user_id 外键保证了留言一定对应一个真实存在的用户,不会出现孤儿数据。
2.3 数据库连接工具类
不要每写一个 Dao 方法就重复一次DriverManager.getConnection(),封装一个工具类,后面所有方法都能复用:
package com.demo.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/demo_web?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }注意 URL 里我带了characterEncoding=utf8和serverTimezone=Asia/Shanghai,这两个参数分别解决中文乱码和 MySQL 8.x 时区报错的问题。如果你用的是 MySQL 5.7,驱动类名要写com.mysql.jdbc.Driver,这个坑后面第五节细说。
3. 登录注册模块:Servlet 接收请求、JavaBean 封装数据、Session 贯穿全程
这是整个项目的核心模块,也是最容易写乱的部分。我的经验是:把一条用户请求的完整生命周期想清楚再动手,代码自然有条理。
3.1 注册流程:表单到 Servlet 再到数据库
注册页面register.jsp的表单不必花哨,重点要保证 name 属性与 Servlet 端拿参数的名字一致:
<form action="register" method="post"> <input type="text" name="username" placeholder="用户名" required> <input type="password" name="password" placeholder="密码" required> <button type="submit">注册</button> </form>对应的 RegisterServlet 是整个流程的枢纽:
package com.demo.servlet; import java.io.IOException; 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 com.demo.bean.User; import com.demo.dao.UserDao; @WebServlet("/register") public class RegisterServlet extends HttpServlet { private static final long serialVersionUID = 1L; private UserDao userDao = new UserDao(); protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("utf-8"); String username = request.getParameter("username"); String password = request.getParameter("password"); if (username == null || username.trim().isEmpty() || password == null || password.isEmpty()) { request.setAttribute("msg", "用户名和密码不能为空"); request.getRequestDispatcher("register.jsp").forward(request, response); return; } User user = new User(); user.setUsername(username.trim()); user.setPassword(password); boolean exists = userDao.findByUsername(username.trim()) != null; if (exists) { request.setAttribute("msg", "用户名已存在,换一个试试"); request.getRequestDispatcher("register.jsp").forward(request, response); return; } boolean ok = userDao.addUser(user); if (ok) { response.sendRedirect("login.jsp?reg=success"); } else { request.setAttribute("msg", "注册失败,请稍后重试"); request.getRequestDispatcher("register.jsp").forward(request, response); } } }有几个关键点说一下。
- 第一行
request.setCharacterEncoding("utf-8")必须放在读取参数之前,否则 POST 提交的中文用户名全会乱码。 - 注册成功后我用的是
sendRedirect而不是forward,这是一个很重要的区别:重定向是浏览器重新发起一次新请求,地址栏会变成 login.jsp,用户刷新页面不会重复提交注册表单;转发则是服务端内部跳转,地址栏不变,刷新会重复提交。 - 用户名重复的检查放在 UserDao 里,这样后面登录模块也能复用同一个方法。
3.2 JavaBean 实体类是怎么写的
User 这个 JavaBean 非常简单,但要注意一个规范:必须提供无参构造、私有属性、公有 getter/setter。Servlet 里new User()的时候调的就是无参构造,框架时代很多序列化工具也依赖这个约定,所以从现在就要养成习惯。
package com.demo.bean; public class User { private int id; private String username; private String password; public User() {} public int getId() { return id; } public void setId(int id) { this.id = id; } 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; } }3.3 登录流程与 Session 会话
登录比注册多了一件重要的事,就是 Session 的管理。用户登录成功后,把用户信息放进 Session,这样后续访问留言板时才能识别"你是谁"。
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("utf-8"); String username = request.getParameter("username"); String password = request.getParameter("password"); User loginUser = userDao.findByUsernameAndPassword(username, password); if (loginUser != null) { HttpSession session = request.getSession(); session.setAttribute("loginUser", loginUser); session.setMaxInactiveInterval(30 * 60); response.sendRedirect("list"); } else { request.setAttribute("msg", "用户名或密码错误"); request.getRequestDispatcher("login.jsp").forward(request, response); } }登录成功后跳转的不是list.jsp而是list,这是一个 Servlet 的 URL 映射,它去查询所有留言再转发给 JSP。为什么要绕这一圈?因为留言列表的数据不可能写死在 JSP 里,必须由后端查库之后"喂"给页面,这正是 MVC 的核心思想。
session.setMaxInactiveInterval(30 * 60)表示会话 30 分钟不操作就失效。这个值可以根据需求调整,但不要设得太长,否则用户离开电脑后别人可以继续操作他的账号。
4. 留言板核心:从表单提交到 JDBC 持久化,数据是怎么流起来的
留言板是这个项目的第二个重点,它的技术含量主要在 DAO 层和数据安全上。
4.1 留言的添加流程
AddMessageServlet 接收 JSP 表单传过来的留言内容,随手补上当前登录人的 user_id,然后插入数据库:
@WebServlet("/addMessage") public class AddMessageServlet extends HttpServlet { private MessageDao messageDao = new MessageDao(); protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("utf-8"); HttpSession session = request.getSession(); User loginUser = (User) session.getAttribute("loginUser"); if (loginUser == null) { response.sendRedirect("login.jsp"); return; } String content = request.getParameter("content"); if (content == null || content.trim().isEmpty()) { request.setAttribute("msg", "留言内容不能为空"); request.getRequestDispatcher("list").forward(request, response); return; } Message message = new Message(); message.setUserId(loginUser.getId()); message.setContent(content.trim()); boolean ok = messageDao.addMessage(message); response.sendRedirect("list"); } }这里有一个容易被忽视的安全点:绝对不能信任前端传过来的 user_id。如果表单里隐藏域传 userId 到后端,别人伪造一个请求就能冒充任何人留言。正确做法就是代码里这样,从 Session 里取当前登录人的 ID。
4.2 查询留言并用 JSP 展示
MessageDao 里查询留言列表时,往往需要把用户名的信息也带出来,这样才能在页面上显示"谁留的言"。所以 SQL 要用 JOIN 而不是只查 message 表:
public List<Message> findAllWithUsername() { List<Message> list = new ArrayList<>(); String sql = "SELECT m.id, m.user_id, m.content, m.create_time, u.username " + "FROM message m JOIN `user` u ON m.user_id = u.id " + "ORDER BY m.id DESC"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { Message msg = new Message(); msg.setId(rs.getInt("id")); msg.setUserId(rs.getInt("user_id")); msg.setContent(rs.getString("content")); msg.setCreateTime(rs.getTimestamp("create_time")); msg.setUsername(rs.getString("username")); list.add(msg); } } catch (SQLException e) { e.printStackTrace(); } return list; }注意我在 Message 这个 JavaBean 里额外加了一个username字段,它不属于 message 表,但为了方便页面展示而存在。JavaBean 不是数据库表的死板映射,它可以根据业务需要扩展。
ListServlet 查询完把结果放到 request 里再转发:
@WebServlet("/list") public class ListServlet extends HttpServlet { private MessageDao messageDao = new MessageDao(); protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { HttpSession session = request.getSession(); User loginUser = (User) session.getAttribute("loginUser"); if (loginUser == null) { response.sendRedirect("login.jsp"); return; } List<Message> messages = messageDao.findAllWithUsername(); request.setAttribute("messages", messages); request.getRequestDispatcher("list.jsp").forward(request, response); } protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doGet(request, response); } }这里 doPost 直接转发给 doGet 是一个实用技巧,因为列表页可能从表单跳转过来,也可能从链接点过来,保证两种请求都能正确处理。
4.3 用 PreparedStatement 而不是 Statement
这是整个项目里最重要的安全隐患点。看下面这段反面教材:
// 千万不要这样写 String sql = "SELECT * FROM user WHERE username='" + username + "' AND password='" + password + "'"; Statement st = conn.createStatement(); ResultSet rs = st.executeQuery(sql);如果用户名输入' OR '1'='1,SQL 就变成了WHERE username='' OR '1'='1' AND password='',攻击者不需要密码就能登录,这就是最经典的 SQL 注入。所有 SQL 都必须用 PreparedStatement 的?占位符传参,就像上面 findAllWithUsername 写的那样,数据库驱动会自动转义特殊字符。
我见过不少课设代码犯了这个问题,自己本地怎么测都没事,但真要上线不出半小时就会被扫描工具攻破。这个习惯从现在开始养成,后面用 MyBatis 时你也会感谢现在的自己。
5. 乱码、路径与 Session 失效:新手最容易翻车的三个位置
这一节的内容是我自己踩坑踩出来的,每个问题都值得单独讲清楚。
5.1 中文乱码问题的完整解决思路
乱码是 JSP 项目里出现频率最高的问题,但它的解决思路其实很固定。
- 数据库连接 URL 带
characterEncoding=utf8(对应 MySQL 的 utf8/utf8mb4); - 所有 Servlet 处理 POST 请求前执行
request.setCharacterEncoding("utf-8"); - 页面顶部写上
<%@ page contentType="text/html;charset=UTF-8" language="java" %>; - 如果用了 Tomcat 8 以上版本,GET 请求的中文乱码可以用
server.xml里配置 URIEncoding 解决,或者干脆约定 GET 请求不传中文。
Get 参数中文乱码的底层原因是 URL 编码默认是 ISO-8859-1,Tomcat 8+ 默认改成了 UTF-8,所以用新版本 Tomcat 基本不用操心。
5.2 路径问题:相对路径为什么总出错
新手写跳转最常见的问题是用了相对路径,比如在list.jsp里写href="login.jsp",看起来没问题,但如果当前浏览器地址是/demo/list,点这个链接会跳到/demo/login.jsp,没问题;可如果你把 JSP 放在 WEB-INF 下通过其他路径访问,相对路径就会指向错误的位置。
我的建议是项目中统一使用绝对路径,在 JSP 页面顶部引入:
<% String path = request.getContextPath(); String basePath = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort() + path + "/"; %> <base href="<%=basePath%>">这样页面里所有href和src都以项目根路径开头,无论请求从哪来都不会找不到资源。模板引擎时代这个需求被<c:url>标签和 Thymeleaf 的@{}表达式优雅解决了,但原理是相通的。
5.3 Session 失效后的页面跳转
当用户长时间不操作、Session 过期后,他手里的页面不会自动消失,但访问受保护资源时会因为取不到loginUser而空指针。
我的处理方式是全局统一判断:在每个需要登录才能访问的 Servlet 里,开头就检查 Session 是否为空、loginUser是否存在。上面 ListServlet 就是这么做的。更优雅的方案是写一个 Filter 统一拦截,但在本项目中手动判断更直观,适合学习。
同时注意 JSP 页面直接访问时也可能取到 null 的 Session 属性,展示用户信息时最好加一个非空判断:
<c:if test="${not empty sessionScope.loginUser}"> 欢迎你,${sessionScope.loginUser.username} </c:if>为了让${}表达式在 JSP 里生效,记得到 WEB-INF/lib 里放 jstl.jar 和 standard.jar,或者用 Tomcat 自带的 EL 解析。不过既然 JSP 等级默认支持 EL,只要不用成纯脚本%>,直接用${}就行,前提是 web.xml 里isELIgnored不设为 true。
6. 从能用到高分:日志、分页、权限这三个升级最值得做
如果你把这个项目作为课程设计交上去,做到目前这一步已经能及格了。但想拿高分,或者想在面试时有一个"我主动思考过优化"的谈资,下面这三个升级性价比最高。
6.1 日志打印取代 System.out.println
调试时用System.out.println很方便,但项目里全是这类输出会显得很业余。至少用java.util.logging或者直接引入 Log4j 2,把关键节点(用户注册成功、登录成功、留言新增、数据库异常)记录下来。这样出问题的时候你能从日志里还原完整的操作轨迹,而不是盯着控制台黑窗口猜。
6.2 留言分页
现在留言一多,list.jsp 会越拉越长。加一个分页逻辑其实不复杂:
- 查询总数
SELECT COUNT(*) FROM message; - 根据当前页码计算出 LIMIT 的 offset 和 size;
- 页面底部渲染上一页/下一页链接,URL 携带
page=2这样的参数; - ListServlet 里通过
Integer.parseInt(request.getParameter("page"))接收即可。
这是面试里出现频率很高的考点,核心就一条 SQL:SELECT ... LIMIT ?, ?,高度配合 PreparedStatement 占位符。
6.3 删除留言的权限控制
目前我还没有给大家写删除留言的功能。如果想加,关键点就是:不仅登录用户能删,还要判断这条留言是否属于他本人。删除的 SQL 可以这样写:
DELETE FROM message WHERE id=? AND user_id=?先把 user_id 从 Session 里取出来作为第二个条件,这样就算别人猜到了留言 ID,也没法删除不属于自己的留言。这个细节在数据库层面做了一层权限控制,比单纯在 JSP 里判断要可靠得多。
6.4 密码加密存储
第五点升级是安全方向的重中之重:把密码明文存数据库是大忌。最简单的做法是注册时用MessageDigest做一次 SHA-256 哈希再入库,登录时把用户输入的密码做同样的哈希后比对:
public static String sha256(String input) throws NoSuchAlgorithmException { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : hash) { sb.append(String.format("%02x", b)); } return sb.toString(); }正式项目通常还会加盐(salt)处理,但这套思路已经能挡住大部分简单风险了。把密码字段从 64 位改成 128 位也完全够用,就看你选 SHA-256 还是更长的算法。
我在做这个项目的过程中最大的一个体会是:写代码本身不是最难的部分,最花时间的是把每一条数据从表单到数据库再回到页面的路径理顺,并且守住每一个可能出错的边界情况。空值判断、Session 判断、编码统一、权限校验,这些看起来繁琐的细节,恰恰是 Web 开发里衡量一个程序员是否靠谱的分水岭。
最后再分享一个调试上的小技巧:如果某个功能"一模一样照着写"却不生效,别急着怀疑代码逻辑,先在浏览器按 F12 打开 Network 标签页,看请求是否真的发出去了、状态码是 404 还是 500、表单参数是否完整。我之前见过一个学员改了三个小时都没解决的 bug,最后发现是表单按钮没有写 type="submit",点击之后只是触发了 JS 校验失败,请求压根就没发到 Servlet 那里。Web 开发这条路上,九成的"灵异现象"都出在请求根本没到达你预期的地方。
本文还有配套的精品资源,点击获取