简介:这是一套面向Java Web初学者的完整入门项目,以用户登录、注册、信息修改和删除四项核心功能为主线,适合正在学习Servlet、JSP、JDBC及基础MVC分层的学生或自学者。包体共89个文件,主要包括14个Java源码与对应的class文件、8个JSP页面、8个CSS样式、8个JavaScript脚本、3个Jar依赖库以及web.xml配置文件等,压缩包大小4.67MB,目录结构规范,便于按控制层、实体类、DAO、Service、工具类和WebContent模块对照学习。项目采用Bootstrap前端框架搭建页面,并引入jQuery处理交互,登录后可通过Session管理会话状态,数据库操作使用PreparedStatement预编译方式,便于理解SQL防注入的基本思路。通过阅读源码并运行项目,可以掌握从请求分发、业务逻辑处理到数据库增删改查的完整开发流程,也能学习到JSP页面中header、footer等公共部分的复用方式。资源已有6300余人学习下载,适合作为课程设计参考或实训入门素材。
1. 一个“简单”的 JavaWeb 登录注册项目,卡住新手的从来不是代码
把“登录注册修改删除”这几个词拆开看,每个功能单独拎出来都不难,但放在同一个 JavaWeb 项目里跑通,新手几乎都要在几个固定环节翻车:要么表单提交后一片空白,要么中文乱码,要么查出了用户却跳转不过去,要么改了密码后发现 Session 里的旧数据还在。这个标题描述的,其实是一个最小但完整的 JavaWeb 闭环:Servlet 接收请求、JDBC 操作数据库、JSP 渲染页面、Session 维持登录态,外加对用户表的增删改查。它能解决的问题也很明确:让你在没接触 Spring 全家桶之前,先把 HTTP、会话、请求参数、状态管理这些地基打牢。适合两类人:一类是刚学完 Java 基础、想找个项目练手的在校生,另一类是能写业务代码但没独立从零搭过一个 Web 工程、想补课的在职开发者。本文不教你怎么背框架,只讲怎么用最直白的方式,把这个项目从零到可部署完整走一遍,并说清每一步为什么这么设计。
2. 技术选型:为什么是 JSP + Servlet + JDBC,而不是一上来就 Spring Boot
2.1 传统三层结构的边界与价值
标题里写着“简单的 javaweb 项目”,所以这里我默认你还没到必须上框架的阶段。常见做法是用 JSP + Servlet + JDBC 做一个小型应用,配合 Tomcat 运行,数据库选 MySQL。这个组合在今天看起来确实“老”,但它最大的价值是让 Servlet 容器帮你把 HTTP 协议细节遮住,同时又把请求、响应、会话这些核心对象暴露在你面前,你能亲眼看见一次请求从浏览器走到服务端再返回的全部过程。用 Spring Boot 当然也能写同样功能,但自动配置把很多关键决策替你做了,比如过滤器顺序、字符编码、连接池初始化,出问题时排查成本反而更高。
我一般会建议:凡是这个项目,都先用纯 Servlet 分层写一版。Controller 用 Servlet 承担,Service 包一层业务逻辑,DAO 里只写 SQL 操作。某个公司里带的新人,一开始想把所有代码塞进一个 Servlet 里,一个方法处理登录、一个方法处理注册,页面跳转全靠 out.println 拼 HTML。我把它拆开后,他看懂请求流向的速度反而快了一倍。所谓“简单项目”,结构简单不等于逻辑可以乱,恰恰相反,越简单的项目越应该用清晰的分层把代码边界划出来。
2.2 所需环境与基础组件清单
在动手写代码前,先把环境列清楚。JDK 用 8 或 11 都行,这个项目没有用到高版本特性;Tomcat 用 9.x 配 Servlet 4.0 足够,没必要追新;MySQL 5.7 或 8.0 均可;IDE 用哪个顺手都行,但要把“部署到 Tomcat 运行”这一套跑起来,IDEA 社区版加本地 Tomcat 的配置步骤要提前确认好。整个工程不建议用 Maven 的,一个简单项目手工复制 jar 包反而更直观,等你熟悉了 jar 的位置和依赖关系,再换 Maven 心智负担会小很多。
需要准备的 jar 包有三个:MySQL 驱动、JSTL 标签库、Servlet API(编译期用)。Tomcat 自带 servlet-api.jar,路径在 Tomcat 安装目录的 lib 下,编译时引入即可。JDBC 连接建议抽成一个 DBUtil 类统一管理连接和释放,而不是在每个 Servlet 里重复写 Class.forName。下面是最小可用的工具类写法。
import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/testdb?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "你的数据库密码"; static { try { Class.forName("com.mysql.jdbc.Driver"); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement stmt, ResultSet rs) { try { if (rs != null) rs.close(); } catch (SQLException ignored) {} try { if (stmt != null) stmt.close(); } catch (SQLException ignored) {} try { if (conn != null) conn.close(); } catch (SQLException ignored) {} } }这里有三点要说明。URL 里的 characterEncoding=utf8 是保证中文不乱码的前提之一,但注意这只是数据库连接层的一部分,完整解决乱码还需要配置 Servlet 的请求和响应编码,这在后面避坑章里会详细讲。serverTimezone 是 MySQL 8.0 的时区要求,5.7 下写上也无害,不写新版驱动会报错。close 方法用空 catch 忽略异常,是因为关闭操作本身失败不影响主流程,但连接池方案里不能这样随手关,那是后话。
2.3 为什么登录状态必须依赖 Session,而不是反复查数据库
登录功能写起来很简单,比较用户名密码,正确就放行。但“放行”这个动作在 Web 项目里要谨慎设计。HTTP 协议是无状态的,浏览器每次请求都是“陌生人”,如果你不做任何处理,用户登录一次后,下次刷新页面又要重新登录。常见做法是用 Cookie 配合 Session:用户登录成功后,把用户 ID 或用户名存进 Session,服务器通过一个 JSESSIONID 的 Cookie 识别同一个浏览器的后续请求。
新手容易犯的错误是把密码或整个用户对象直接塞到 Cookie 里。Cookie 存在浏览器端,用户能看能改,把敏感信息放进去等于把密码贴在额头上。正确做法是只往 Session 里放用户标识,页面通过 Session 判断是否已登录。Session 本身存放在服务器内存里,相对安全,但要注意它的过期时间,默认 30 分钟无操作会被回收。这个配置可以在 web.xml 里显式声明,后面避坑章会提到。理解了这个机制,后面所有需要判断“当前登录的是谁”的功能才写得自然。
3. 建立用户表与登录注册的完整实现:从建表到 Session 写入
3.1 用户表的 DDL 设计:字段不该多,但关键约束不能少
用户表是这个项目的地基,字段设计不需要像大系统那么复杂,但有两个原则必须守:唯一标识必须有主键,用户名必须加唯一约束。否则注册接口重复提交时,你会查出两条相同的用户名记录。下面这个建表语句在实践中够用。
CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL, `password` VARCHAR(255) NOT NULL, `email` VARCHAR(100) DEFAULT NULL, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;密码字段用 VARCHAR(255),不是因为它需要那么长,而是给后面升级密码加密算法留空间。如果你现在用明文密码,50 长度确实够,但一旦改成加密字符串存储,长度可能超过 60,到时再 ALTER 表就是多余的麻烦。username 上的唯一索引同时承担了查询加速和约束两件事,注册逻辑里判断用户名是否存在的 SQL 就能直接利用这个索引。create_time 设默认值,插入时少写一个字段。
3.2 注册功能:后端校验比前端校验重要得多
注册流程看起来就是“填表单 → 提交 → 插入数据库”,但生产环境里必须加一道后端校验:用户名是否为空、是否已存在、两次密码是否一致。前端的 JS 校验只能提升用户体验,不能作为安全屏障,因为请求可以被绕过前端直接构造后发出。这里用一个最典型的 RegisterServlet 演示完整流程。
@WebServlet("/register") public class RegisterServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("utf-8"); String username = request.getParameter("username"); String password = request.getParameter("password"); String password2 = request.getParameter("password2"); if (username == null || username.trim().isEmpty() || password == null || password.isEmpty()) { request.setAttribute("msg", "用户名和密码不能为空"); request.getRequestDispatcher("/register.jsp").forward(request, response); return; } if (!password.equals(password2)) { request.setAttribute("msg", "两次密码不一致"); request.getRequestDispatcher("/register.jsp").forward(request, response); return; } try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement( "INSERT INTO user(username, password, email) VALUES(?,?,?)")) { ps.setString(1, username.trim()); ps.setString(2, password); ps.setString(3, request.getParameter("email")); int rows = ps.executeUpdate(); if (rows > 0) { response.sendRedirect("login.jsp?registered=1"); } else { request.setAttribute("msg", "注册失败,请重试"); request.getRequestDispatcher("/register.jsp").forward(request, response); } } catch (SQLException e) { if (e.getMessage().contains("Duplicate")) { request.setAttribute("msg", "用户名已存在"); request.getRequestDispatcher("/register.jsp").forward(request, response); } else { throw new ServletException(e); } } } }这段代码有四个细节值得单独说。第一,request.setCharacterEncoding("utf-8") 必须放在读取任何参数之前,否则 POST 请求的中文会乱码。第二,SQL 用 PreparedStatement 而不是拼接字符串,这是防 SQL 注入的最基础手段,setString 会把输入作为数据而不是 SQL 语句的一部分。第三,唯一索引冲突是通过捕获 SQLException 并检查 Duplicate 关键字实现的,这种方式不算优雅,但对你现在这个阶段来说,比先查一遍再插入更稳妥——查询不是原子的,两个并发请求可能同时查到不存在然后同时插入。第四,注册成功用 sendRedirect 而不是 forward,因为 forward 后用户刷新页面会重复提交表单,重定向能避免这个经典问题。
3.3 登录功能:验证密码后写入 Session,这是整个项目最关键的三行代码
登录的 SQL 和注册很接近,但逻辑不止“查出来就行”,而是查出来之后还要做密码比对。有些人偷懒写 SELECT * FROM user WHERE username = ? AND password = ?,这样也行,但项目升级到密码加密后这种写法必须改,所以更规范的做法是分开:先按用户名查出记录,再比对密码字段。为了演示完整链路,这里把两种方式折中一下,查出来的记录用于比对,然后写入 Session。
@WebServlet("/login") public class LoginServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("utf-8"); String username = request.getParameter("username"); String password = request.getParameter("password"); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement( "SELECT id, username, email FROM user WHERE username = ? AND password = ?")) { ps.setString(1, username); ps.setString(2, password); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { User user = new User(); user.setId(rs.getInt("id")); user.setUsername(rs.getString("username")); user.setEmail(rs.getString("email")); request.getSession().setAttribute("loginUser", user); response.sendRedirect("list"); } else { request.setAttribute("msg", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); } } } catch (SQLException e) { throw new ServletException(e); } } }登录成功后这三行不要省:new User() 组装对象、setAttribute 存入 Session、sendRedirect 跳转。把 User 对象整个放 Session 而不是只放用户名,是为了后续在页面顶部显示“欢迎你,XXX”时不用再查一次数据库。跳到 list 而不是直接去某个静态页面,是因为列表页需要从数据库取数据,得走 Servlet 逻辑。这里注意 response.sendRedirect("list") 是一个相对路径,浏览器会把它解析成相对于当前 URL 目录的地址,如果当前路径是 /login,那么目标会是 /list。如果你在 Servlet 里配了 @WebServlet("/login") 且项目名是 demo,整个 URL 是 /demo/login,重定向逻辑仍然正确,因为相对路径的基点是当前请求的目录部分。
3.4 登录状态的守卫:一个简单的 Filter 解决“未登录不能访问”的问题
登录写好后,你会发现一个问题:用户不登录,直接输 URL 也能访问列表页和修改页。这就是为什么必须有一个过滤器统一守门。它的逻辑非常简单:放行登录页、注册页、静态资源,其他请求一律检查 Session 里有没有 loginUser。
@WebFilter("/*") public class LoginFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; String uri = req.getRequestURI(); if (uri.endsWith("/login") || uri.endsWith("/register") || uri.endsWith(".css") || uri.endsWith(".js") || uri.endsWith(".png") || uri.endsWith("login.jsp") || uri.endsWith("register.jsp")) { chain.doFilter(request, response); return; } // 这里校验 Session 是否已登录 Object loginUser = req.getSession().getAttribute("loginUser"); if (loginUser == null) { resp.sendRedirect("login.jsp"); return; } chain.doFilter(request, response); } }实现 Filter 接口时,需要同时重写 init、doFilter、destroy 三个方法,其中 init 和 destroy 写空实现即可,否则会因为抽象方法没实现而报错。@WebFilter("/*") 表示拦所有请求,包括 JSP 和图片,所以白名单判断必不可少。这里用一个 String 的 endsWith 链做判断,代码直观但不够严谨。例如用户 ID 里含 register 的业务路径会被误放行,但现阶段项目小,这个粒度足够。你如果愿意,也可以把白名单抽成一个 List,用 contains 判断,代码会好维护一些。
4. 用户列表、修改与删除:把 CRUD 的 C、R、U、D 全部补齐
4.1 用户列表查询与 JSP 页面渲染:JSTL 让页面不再藏着 Java 代码
登录后跳转的路由是 list,它对应的就是一个查询所有用户的 Servlet。SQL 很简单,SELECT * FROM user ORDER BY id DESC,把结果放进 request 域,然后 forward 到 userList.jsp 渲染。问题在于 JSP 页面怎么写。如果你在 JSP 里直接写 <% for(...) { %>,代码是能跑,但页面会乱成一锅粥。这里推荐 JSTL 标签库配合 EL 表达式,页面里可以完全不出现 Java 代码。
@WebServlet("/list") public class ListUserServlet extends HttpServlet { protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { List<User> users = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery("SELECT id, username, email, create_time FROM user ORDER BY id DESC")) { while (rs.next()) { User u = new User(); u.setId(rs.getInt("id")); u.setUsername(rs.getString("username")); u.setEmail(rs.getString("email")); u.setCreateTime(rs.getTimestamp("create_time")); users.add(u); } } catch (SQLException e) { throw new ServletException(e); } request.setAttribute("userList", users); request.getRequestDispatcher("/userList.jsp").forward(request, response); } }Servlet 只负责收集数据,不做任何输出。页面部分用 JSTL 的 c:forEach 循环生成表格,每一行的末尾放“修改”和“删除”两个操作链接。修改链接要带上用户的 id 作为参数,这是列表页最常见的设计。删除链接同理,但这里有一个隐患是“删除用链接”意味着 HTTP GET 请求,GET 请求的副作用是能被浏览器预加载、能被搜索引擎爬虫触发,所以一个严谨的项目不会用链接做删除,但很多简单项目都这么干了。如果你要保持简单,至少要在删除前加 confirm 二次确认。
4.2 修改用户信息:回显表单与更新时的两个安全问题
修改功能分成两步:第一步根据 id 查出用户信息回显到表单,第二步提交更新。查回显比较简单,GET 请求到 /edit?id=1,Service 层拿到用户对象放到 request 域,跳转 edit.jsp 把值填到 value 属性。这里需要特别说明一个新手常踩的坑:JSP 里 value="${user.username}" 如果 user 为 null,有些容器会输出字符串 "null" 而不是空值,所以回显前必须判断对象是否存在。
@WebServlet("/update") public class UpdateUserServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("utf-8"); int id = Integer.parseInt(request.getParameter("id")); String username = request.getParameter("username"); String email = request.getParameter("email"); String password = request.getParameter("password"); String sql = "UPDATE user SET username=?, email=?" + (password != null && !password.isEmpty() ? ", password=?" : "") + " WHERE id=?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, email); if (password != null && !password.isEmpty()) { ps.setString(3, password); ps.setInt(4, id); } else { ps.setInt(3, id); } ps.executeUpdate(); response.sendRedirect("list"); } catch (SQLException e) { throw new ServletException(e); } } }这段代码处理了一个容易被忽略的细节:修改用户信息时密码可能留空,留空代表不修改密码。如果 SQL 里总是包含 password 字段,一个空字符串就会把原密码清掉,造成用户无法登录。所以我把密码字段的条件判断揉进了 SQL 拼接。这里要注意,SQL 拼接虽然带了条件分支,但拼接的是结构而不是用户输入,用户名和密码仍然通过参数传递,所以不违反防注入原则。更清晰的写法是分成两个 SQL 分支,一个带密码,一个不带,逻辑都一样。Integer.parseInt 放在 try 之前是为了让参数格式错误时能抛到容器层面,避免进入数据库操作。
4.3 删除用户:事务边界与关联数据的处理思路
删除是最容易写出“烂代码”的操作,因为表面看就一行 DELETE FROM user WHERE id=?。但实际项目中,用户数据往往会被其他表引用,比如订单表里有 user_id 外键。如果直接删用户,要么被外键约束拦住抛异常,要么留下孤儿数据。这个简单项目里没有订单表,但正确的思维方式要从现在养成:删除之前想一想还有谁引用了这条记录。常见做法是两步走:先删除关联子表,再删除主表记录,并且把这两步包在同一个事务里。
@WebServlet("/delete") public class DeleteUserServlet extends HttpServlet { protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { int id = Integer.parseInt(request.getParameter("id")); Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 try (PreparedStatement ps = conn.prepareStatement("DELETE FROM user_log WHERE user_id=?")) { ps.setInt(1, id); ps.executeUpdate(); } try (PreparedStatement ps = conn.prepareStatement("DELETE FROM user WHERE id=?")) { ps.setInt(1, id); ps.executeUpdate(); } conn.commit(); } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } throw new ServletException(e); } finally { DBUtil.close(conn, null, null); } response.sendRedirect("list"); } }这里 DBUtil.close 的第二个和第三个参数传 null,是因为 PreparedStatement 在 try-with-resources 里已经自动关了,如果工具类在 close 方法里对 null 做了判空,这样写没问题。事务的关键点是 conn.setAutoCommit(false) 之后,所有的 SQL 都在同一个事务里执行,要么全部成功,要么全部回滚。如果删主表失败,日志表已经删了,正好回滚恢复。这个 delete 用一个 GET 请求处理的,实际操作中我会把它改成 POST,或者用一个隐藏表单加确认弹窗,但那样代码量会多一倍,这个度你自己把握。
5. JavaWeb 登录注册项目常见问题排查:从 500 到中文乱码再到 Session 丢失
5.1 前端的请求到了 Servlet 但读到的中文是乱码,前端里明明显示正常
现象是:注册时用户名填“张三”,插入数据库后变成“å¼ ä¸‰”,数据库查询结果也是一堆乱码。原因有三层,每一层都可能出问题:页面本身是 UTF-8 编码,但浏览器提交时没有按 UTF-8 编码参数;Tomcat 8 及更高版本对 GET 请求默认就是 UTF-8,但对 POST 请求需要显式指定;数据库表的字符集如果不是 utf8mb4,存进去的数据在读写过程中会丢失字符。解决方法是三层都立规矩:JSP 页面顶部加 <%@ page contentType="text/html;charset=UTF-8" %>,所有处理 POST 的 Servlet 第一行写 request.setCharacterEncoding("utf-8"),数据库连接 URL 带 characterEncoding=utf8,建表用 utf8mb4。做完这三步,乱码九成能解决。最后那一成的排查方向是 Tomcat 的 server.xml 里 connector 的 URIEncoding 属性,默认 UTF-8 就不用改。
5.2 登录后点击“修改”跳到 500 错误,异常信息显示 id 转换失败
现象是:从列表页点“修改”按钮,URL 确实变成了 /edit?id=3,但 Servlet 里 parseInt 直接抛 NumberFormatException,页面白屏。原因是拼接超链接时用了 JSP 里的 EL 表达式,比如 ,正常情况下这个 id 是个数字字符串,不会出问题。出问题的地方在于,列表里某些用户是通过注册功能创建的,注册成功后被重定向到登录页,用户 ID 在数据库里是自增的,不会为空。真正的原因是修改链接被包在了 HTML 表格里,而这一行数据渲染时 item 为 null——循环变量作用域写错了,比如 c:forEach 里用的 varStatus 和 value 搞混。排查方法很简单:在 JSP 里先用一个隐藏的 td 输出 ${item.id},再看页面源码。如果为空,回头看循环的 items 属性是不是写错了,比如写成 userList 而不是 users,容器会把它当成一个不存在的属性名,EL 表达式不会报错但输出空白。
5.3 登录后手动把浏览器的 Cookie 清掉,再刷新就跳回登录页,这正常吗
现象是:登录成功跳到了列表页,一切正常,但用户手动清了浏览器 Cookie 或在新的无痕窗口打开页面,立刻又被过滤器拦回登录页。这个现象很多人觉得是自己代码写错了,其实这是 Session 机制的正常表现。Session 的实现依赖 JSESSIONID 这个 Cookie,Cookie 没了,服务器内存里的 Session 就“找不到了”,它不会立刻销毁,但后续请求携带不了 SessionID,服务器会创建一个新的空 Session,loginUser 属性自然不存在。解决办法有两种:一种是接受这个行为,它本来就是安全的默认策略;另一种是把记住登录状态的逻辑做成“Remember Me”,用独立的 Cookie 存一个随机 Token,Token 在数据库里对应到用户 ID,下次访问时通过 Token 重建 Session。显然第二种已经超出了简单项目范围,所以遇到这个现象不用紧张,检查代码逻辑没问题就可以。
5.4 数据库连接没关,运行一段时间后页面突然全部报错
现象是:项目刚启动时一切正常,连续操作几十次后,某个页面开始报 Connection refused 或者 Too many connections。原因几乎可以肯定是连接没释放。Java 的 Connection 如果不显式 close,最终会被 GC 回收,但数据库连接是资源,GC 来不及回收时连接池或数据库服务器会先耗尽连接。这个项目用的 DriverManager 直接创建连接,没有连接池,所以只要有一条连接漏关,用完不还,累积到 MySQL 的 max_connections 上限,整个应用就挂了。解决方法是所有读写操作都放进 try-with-resources,连接、语句、结果集三层都要关,或者统一走 DBUtil.close。强调一下 try-with-resources 的闭合顺序:先关内层 ResultSet,再关 Statement,最后关 Connection,这个顺序在多层嵌套时很重要,倒过来关有时会在连接还占着资源时提前把外层关掉,引发接口报错。
5.5 修改密码后旧密码还能登录,排查半天发现是 Session 里放了旧值
现象是:用户改了密码,后台数据库里也确认是新的,但页面上的登录校验仍然用旧密码能过。这个现象的原因有两种。第一种是修改操作根本没执行成功,UPDATE 语句被某个条件堵住了,用日志打印 rows 的值能快速排除。第二种是登录校验压根没查数据库,代码在某个版本里为了“性能”把登录状态判断简化成了 Session 里有没有 loginUser,而修改密码这个动作没有同步更新 Session 里的 User 对象。密码改完后,Session 里存放的 User 还是旧密码对应的那一条,但因为登录状态判断只看 Session 对象存在与否,旧密码在当前会话里就一直有效。解决方法是修改密码成功后,要把 Session 里的 loginUser 取出来更新密码字段,或者干脆重新登录一次。这个坑藏得比较深,因为它只在“修改密码后不重新登录”这个场景出现,而不少人测试时是先退出再登录,所以没暴露出来。
6. 项目跑通后再往前走一步:密码存储、连接池与分页查询的三个技巧
登录注册修改删除跑通之后,这个项目已经能作为课设或简历里的作品了,但距离“真正能拿去上生产”还有三件事值得做,而且每一件的成本都很低。密码明文存储是当前最大的安全隐患。哪怕是最基础的改进,也要用加盐哈希代替明文,例如用 SHA-256 加随机盐值,存储格式约定为 盐值:哈希值。注册时生成盐值,登录时取出盐值重新计算哈希再比对。Java 标准库的 MessageDigest 就能实现,不用引第三方包。升级到 PBKDF2 或 BCrypt 也只是换一个算法实现,存储结构不变。这一步做完,就算数据库泄露了,攻击者拿到也只是哈希串,不能直接登录。
数据库连接换成连接池,比如 HikariCP,是对项目稳定性提升最明显的一件事。改动也不大,把 DBUtil 的 DriverManager 换成 HikariDataSource,初始化时配置好最大连接数和超时时间。连接池的价值不只是性能,更是让“连接用完就还”成为一种纪律性约束:从池子里借连接,用完归还,池子负责维护连接的健康状态。这样上面第五节的 5.4 类报错会大幅减少。
最后是列表页加分页。现在的 list 是查出全部用户,数据到几十条时还好,到几千条时页面会明显变卡。常见做法是用 LIMIT ? OFFSET ? 配合请求参数 page、pageSize,查询总数用 COUNT(*) 单独执行一次,然后算出总页数在页面上渲染页码。注意 PreparedStatement 的 LIMIT 参数需要 setInt 绑定,MySQL 支持这个用法,但一些老版本驱动有坑,建议先在数据库客户端里手工执行一遍确认语法兼容。
这三个技巧我从“能跑”的项目向“能演示”的项目过渡时依次加进去的。第一个技巧让我避开了数据库泄露后的尴尬,第二个技巧让我不再半夜被“连接数满了”的短信吵醒,第三个技巧最实在——我第一次给模拟项目X做演示时,数据三千条直接卡了页面,从那次之后,任何列表页我都习惯性带上分页,哪怕现在是“简单项目”。如果你的登录注册项目已经跑通,建议从密码加盐开始改,收益最明确,改动范围又最小。希望帮到你。
本文还有配套的精品资源,点击获取