简介面向Java Web初学者与在校学生的完整入门项目整合用户登录、注册、信息修改与删除等常见模块覆盖从表单到后台的完整请求链路帮助理解Servlet、JSP、DAO分层与JDBC数据库操作流程。压缩包共89个文件包含14个Java源文件、14个编译后的class文件、8个JSP页面、8个CSS样式与8个JS脚本以及JAR依赖库、XML配置、图片字体等资源整体仅4.67MB结构清晰便于对照学习。项目源码按control、bean、dao、service、util等包划分控制层负责请求分发业务层封装校验逻辑数据访问层使用JDBC连接数据库并配有Bootstrap前端页面可直接在Eclipse中导入运行。已有6302人浏览学习适合快速搭建本地Java Web环境并完成增删改查实践。通过各层职责划分清晰的源码读者可以掌握用户表的增删改查实现、注册登录验证逻辑、数据库连接封装方法以及Bootstrap前端页面的基础用法是巩固Java Web基本功的实用参考资料。1. 一个最简单的 JavaWeb 项目为什么值得你亲手做一遍很多人觉得用 Spring Boot 几分钟就能生成一个带登录注册的后端工程为什么还要回头写 Servlet JSP JDBC 这种“老古董”但恰恰是在这类最简单的 JavaWeb 项目里你才会真正搞懂 HTTP 请求是怎么被接住的、Session 是怎么维持登录状态的、一个表单提交的数据又是怎么一路落到数据库里的。这些底层的来龙去脉被 Spring Boot 的注解和自动配置层层包裹之后你很难再有机会亲眼看见。这个项目讲的就是一回事不依赖任何重量级框架用传统的 JavaWeb 技术栈实现登录、注册、用户信息的修改和删除。它能解决你的实际诉求——如果你正在做课程设计、准备实习面试、或者刚学完 Java 基础想找一个小而完整的练手项目这类项目是最合适的起步点。做完之后你会得到一套自己动手搭建的 MVC 分层代码而不是一个自动生成的脚手架。整套东西跑起来只需要三样一个 JDK、一个 Tomcat、一个 MySQL。下文会按照“技术选型 → 数据库与登录 → 增删改查 → 踩坑记录 → 还能怎么升级”的顺序逐步拆给你看。每个步骤我都会给出可以直接抄的代码和参数说明你不用再去翻那些讲得云里雾里的教程。2. 动手之前技术选型与项目骨架为什么还是 Servlet JSP 这条路2.1 三个方案摆在一起你为什么该选传统 Servlet 栈现在做一个 JavaWeb 项目你面前通常有三条路最传统的 Servlet JSP JDBC稍重一点的 SSMSpring Spring MVC MyBatis以及目前最主流的 Spring Boot。大多数教程会直接把你推向 Spring Boot但如果你是一个需要把登录注册 CRUD 真正讲清楚的初学者直接上 Spring Boot 反而会让排查问题变得困难。举个例子在 Spring Boot 里你写一个RequestMapping(/login)注解框架就帮你把请求分发到对应方法了。但注解背后是谁解析了 URL谁调用了你的方法返回值又是如何被渲染成页面的这些过程对你来说是个黑匣子。而在传统的 Servlet 方案里你要自己写web.xml或使用WebServlet注解去声明一个 Servlet 的访问路径自己从HttpServletRequest里取参数自己forward到 JSP 页面。每一步都是显式的跑通了你对 JavaWeb 的请求处理链路就有了肌肉记忆。还有一个很现实的原因很多高校的课程设计和实验环境明确要求使用 JSP Servlet JDBC 完成不允许使用框架。即便没有这个限制先做一遍传统的 JavaWeb 项目再回头学 Spring Boot你对DispatcherServlet、ModelAndView这些概念的理解会轻松很多。所以在这个项目里我们选型的原则是Servlet 负责接收请求Service 层负责业务逻辑DAO 层负责和数据库打交道JSP 负责页面展示。不引入任何框架不引入 Maven 以外的额外依赖。2.2 目录结构一个让新手少走弯路的包划分方式很多人的第一个 JavaWeb 项目翻车不是因为代码写不出来而是项目结构一团乱Servlet 里直接写 JDBC 代码JSP 里嵌了一大段 Java 脚本片段最后改一个需求要动七八个文件。与其等到那时候后悔不如在一开始就把分层结构定好。我常用的包名划分方式如下src ├── main │ ├── java │ │ └── com.example.demo │ │ ├── dao // 数据库访问层只写 SQL 和 ResultSet 处理 │ │ ├── entity // 实体类对应数据库表结构 │ │ ├── service // 业务逻辑层如密码校验、用户状态判断 │ │ ├── servlet // 控制层接收请求、调用服务、转发页面 │ │ └── util // 工具类如 JDBC 连接、MD5 加密 │ ├── resources │ │ └── db.properties // 数据库连接配置文件 │ └── webapp │ ├── WEB-INF │ │ └── web.xml // Servlet 映射配置3.0 可用注解替代 │ ├── css // 静态资源 │ ├── js │ └── jsp // 存放登录、注册、用户列表等页面这里的核心原则是JSP 里不写 Java 业务代码Servlet 里不写 SQLDAO 层不处理业务判断。每一层只做自己职责范围内的事出问题的时候顺着“页面 → 控制层 → 业务层 → 数据层”一条链查下去很快就能定位。依赖方面如果你的项目使用 Maven 管理只需要在pom.xml里声明javax.servlet-api或jakarta.servlet-api取决于你的 Tomcat 版本和 MySQL 驱动即可。不推荐引入任何 ORM 框架目的就是让你先亲手体会一次PreparedStatement和ResultSet的用法这也是面试中经常被问到的点。3. 从数据库到登录注册把一条用户数据的完整旅程打通3.1 用户表设计不要只建三个字段这些细节决定后续好不好改登录注册功能的第一步不是写代码而是设计表结构。很多人图省事直接CREATE TABLE user (id INT, username VARCHAR(20), password VARCHAR(20))等做到修改密码、忘记密码、用户状态禁用功能时才发现字段不够用又回头改表连带改实体类、改 DAO、改 JSP非常折腾。我一般会在一开始就把基础字段设计得相对完整但不刻意堆砌。下面这份建表脚本足够支撑登录、注册、修改、删除这四个核心功能同时也给后续扩展留了余地CREATE DATABASE IF NOT EXISTS javaweb_demo DEFAULT CHARACTER SET utf8mb4; USE javaweb_demo; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL UNIQUE COMMENT 用户名唯一索引, password CHAR(32) NOT NULL COMMENT 密码MD5加密后的32位字符串, nickname VARCHAR(50) DEFAULT NULL COMMENT 昵称可为空, email VARCHAR(100) DEFAULT NULL COMMENT 邮箱, status TINYINT DEFAULT 1 COMMENT 状态1正常0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;有几个设计上的考虑需要说明。密码字段使用CHAR(32)而不是VARCHAR(20)是因为我们不打算明文存储密码而是统一用 MD5 加密成 32 位十六进制字符串。username加唯一索引是防止注册时出现重名用户从数据库层面做了兜底校验。status字段做一个软删除和账号禁用的预留这样后面做“删除用户”功能时可以选择物理删除也可以选择把status置为 0灵活度更高。create_time和update_time加上默认值省去应用层手动维护时间的麻烦。3.2 手写 JDBC 连接工具类把连接池的兼容性问题提前解决传统 JavaWeb 项目里最常见的 JDBC 写法是在每个 DAO 方法里DriverManager.getConnection()用完之后关闭。这种写法在小项目里没有问题但有一个隐患每次请求都会创建新的数据库连接在高并发场景下性能不好。引入连接池是更专业的做法但初学者直接上手连接池又容易遇到版本兼容问题。这里我提供一个偏稳妥的做法使用 C3P0 连接池配合配置文件管理数据库连接。版本选择上c3p0 0.9.5.5 和 mysql-connector-java 5.1.49 的组合是我验证过比较稳定的不会出现高版本 MySQL 驱动在旧连接池上认证方式不兼容的问题。package com.example.util; import com.mchange.v2.c3p0.ComboPooledDataSource; import java.sql.Connection; import java.sql.SQLException; public class JdbcUtil { // c3p0-config.xml 中配置了数据库连接信息 private static ComboPooledDataSource dataSource new ComboPooledDataSource(); /** * 从连接池获取一个数据库连接 */ public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } /** * 关闭资源注意关闭顺序ResultSet - Statement - Connection */ public static void close(AutoCloseable... resources) { for (AutoCloseable resource : resources) { if (resource ! null) { try { resource.close(); } catch (Exception e) { e.printStackTrace(); } } } } }代码逻辑很直观ComboPooledDataSource在类加载时初始化从c3p0-config.xml读取数据库地址、用户名和密码。getConnection()每次从连接池借出一个连接close()方法接收不定长参数按ResultSet → Statement → Connection的逆序关闭。参数说明连接池的核心配置在c3p0-config.xml里重点关注这几个参数——initialPoolSize表示启动时创建的连接数一般设 5 左右maxPoolSize是连接池上限太大容易耗尽数据库资源checkoutTimeout是获取连接的超时时间单位毫秒连接池满了之后等待多久会抛出异常。c3p0-config default-config property namejdbcUrljdbc:mysql://localhost:3306/javaweb_demo?useSSLfalseamp;characterEncodingutf8/property property nameuserroot/property property namepassword123456/property property namedriverClasscom.mysql.jdbc.Driver/property property nameinitialPoolSize5/property property namemaxPoolSize20/property property namecheckoutTimeout3000/property /default-config /c3p0-config配置中的useSSLfalse参数值得留意MySQL 8.0 版本默认开启 SSL 认证如果不显式关闭控制台会刷出一大堆 warning 日志虽然不影响运行但非常干扰观察错误信息。characterEncodingutf8必须加否则插入中文数据会变成乱码这是新手踩得最多的坑之一。3.3 注册功能实现JDBC 防注入写法与用户名重复校验注册功能的核心逻辑是接收表单参数 → 校验用户名是否已存在 → 密码加密 → 插入数据库。这里有两个不能妥协的细节——使用PreparedStatement而不是拼接 SQL以及密码不能明文入库。package com.example.dao; import com.example.entity.User; import com.example.util.JdbcUtil; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; public class UserDao { /** * 根据用户名查询用户用于注册时的重复校验和登录时的用户查询 */ public User findByUsername(String username) { String sql SELECT id, username, password, nickname, email, status FROM t_user WHERE username ?; try (Connection conn JdbcUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { User user new User(); user.setId(rs.getInt(id)); user.setUsername(rs.getString(username)); user.setPassword(rs.getString(password)); user.setNickname(rs.getString(nickname)); user.setEmail(rs.getString(email)); user.setStatus(rs.getInt(status)); return user; } } } catch (Exception e) { e.printStackTrace(); } return null; } /** * 插入新用户返回受影响行数 */ public int insertUser(User user) { String sql INSERT INTO t_user (username, password, nickname, email) VALUES (?, ?, ?, ?); try (Connection conn JdbcUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, user.getUsername()); ps.setString(2, user.getPassword()); ps.setString(3, user.getNickname()); ps.setString(4, user.getEmail()); return ps.executeUpdate(); } catch (Exception e) { e.printStackTrace(); return 0; } } }代码中用到的try-with-resources语法是 Java 7 引入的可以自动关闭连接和语句对象不需要手动调用close()有效避免忘记关连接导致的内存泄漏问题。PreparedStatement的?占位符配合setString()赋值是防止 SQL 注入的标准写法——绝对不能写成SELECT ... WHERE username username 否则用户名传入 OR 11这类值时会直接绕过校验。注册的 Service 层逻辑中先调用findByUsername判断用户是否已存在如果已存在则返回一个错误提示不存在则对密码做 MD5 加密后再调用insertUser。MD5 加密可以放在一个独立的工具类里避免在 Servlet 中直接写加密逻辑。需要说明的是MD5 并非最安全的加密算法但在这个入门级的项目里它的价值在于让你理解哈希的概念——存储的是密文即使数据库泄露原始密码也不会直接暴露。4. 登录会话与增删改查把用户管理的 CRUD 完整落地4.1 登录功能与 Session 会话管理拦截器该拦哪些路径登录功能的核心不在于验证用户名密码而在于登录成功之后如何让系统认识这个用户。Servlet 规范提供的方案是HttpSession用户登录成功后把用户信息放进 Session之后的请求里只要 Session 还在就认为用户已经登录。package com.example.servlet; import com.example.entity.User; import com.example.service.UserService; 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 javax.servlet.http.HttpSession; import java.io.IOException; WebServlet(/login) public class LoginServlet extends HttpServlet { private UserService userService new UserService(); Override 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 user userService.login(username, password); if (user ! null) { HttpSession session request.getSession(); session.setAttribute(loginUser, user); // 重定向到用户列表页避免表单重复提交 response.sendRedirect(request.getContextPath() /user/list); } else { request.setAttribute(errorMsg, 用户名或密码错误); request.getRequestDispatcher(/jsp/login.jsp).forward(request, response); } } }这段代码里包含三个值得细看的点。第一doPost方法开头设置request.setCharacterEncoding(UTF-8)必须写在取参数之前否则 POST 请求中的中文参数会出现乱码。第二登录成功使用sendRedirect而不是forward这是防止 F5 刷新导致重复提交的关键做法因为重定向是新的请求浏览器不会重复执行上一次的 POST 提交。第三Session 中存的键名loginUser要统一后面所有需要登录才能访问的页面都从 Session 中取这个键判断。有了登录状态就还需要一个控制访问权限的过滤器。常见的做法是创建一个LoginFilter在doFilter里判断请求路径是否涉及登录相关接口如果不是并且 Session 中没有loginUser就跳回登录页。过滤器的拦截路径需要仔细设计/login和/register必须放行静态资源如 CSS、图片也需要放行否则登录页会因为没有样式而非常难看。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; String path req.getRequestURI().substring(req.getContextPath().length()); // 无需登录即可访问的路径 if (path.startsWith(/login) || path.startsWith(/register) || path.startsWith(/css) || path.startsWith(/js)) { chain.doFilter(request, response); return; } HttpSession session req.getSession(false); if (session ! null session.getAttribute(loginUser) ! null) { chain.doFilter(request, response); } else { // 未登录重定向到登录页 resp.sendRedirect(req.getContextPath() /jsp/login.jsp); } } }值得注意的是req.getSession(false)和req.getSession()的区别带false参数时如果当前请求没有关联的 Session会返回null而不是新建一个。在过滤器里用false是合理的因为如果一个未登录用户只是访问首页没必要为他浪费服务器内存维护一个空 Session。4.2 用户列表展示与分页查询不要SELECT *一次全查出来用户列表是增删改查里最容易被轻视的部分。很多入门教程直接SELECT * FROM t_user然后遍历展示全部数据这在数据量只有几条时毫无问题一旦用户表到了几千条甚至几万条页面渲染就会变得卡顿。分页查询是任何管理系统都绕不开的需求最标准的实现方式是LIMIT加偏移量。public ListUser findPage(int pageNum, int pageSize) { String sql SELECT id, username, nickname, email, status, create_time FROM t_user ORDER BY id DESC LIMIT ?, ?; ListUser list new ArrayList(); try (Connection conn JdbcUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, (pageNum - 1) * pageSize); ps.setInt(2, pageSize); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { User user new User(); user.setId(rs.getInt(id)); user.setUsername(rs.getString(username)); user.setNickname(rs.getString(nickname)); user.setEmail(rs.getString(email)); user.setStatus(rs.getInt(status)); user.setCreateTime(rs.getTimestamp(create_time)); list.add(user); } } } catch (Exception e) { e.printStackTrace(); } return list; }这里有一个参数细节LIMIT的第一个参数如果直接传pageNum那么第一页会跳过第一条数据。正确的偏移量计算方式是(pageNum - 1) * pageSize因为第一页的偏移量应该是 0而不是 1。我在实际指导中见过不止一次这个错误现象是第一页少一条数据后续每一页都会重复上一条。除了查列表还需要写一个count()方法统计总记录数用总记录数除以每页条数就能得到总页码数这一步必不可少因为 JSP 底部的页码导航需要这个值。4.3 修改与删除操作的请求路径设计URL 里传参的两种安全姿势修改和删除在请求方式上有一个常见的争论用 GET 还是 POST从功能实现的角度两种方式都能完成操作但从安全角度删除操作用 GET 会有一个风险——如果页面里有图片或者其他资源请求不小心访问到了删除 URL就会触发一次删除操作。比较稳妥的做法是修改用 POST删除用 POST或者至少把删除操作放到单独的 Servlet 里不要直接暴露在普通链接的href中。删除功能的实现逻辑相对简单根据 URL 传入的 id 删除对应记录。但在实际项目中我更推荐使用软删除也就是把status字段从 1 改为 0而不是执行DELETE FROM语句。软删除的好处是数据不会真正消失误操作时还有后悔药吃后续做数据统计时也能保留历史记录。WebServlet(/user/delete) public class UserDeleteServlet extends HttpServlet { private UserService userService new UserService(); Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 兼容表单提交和 AJAX 请求取参数方式相同 String idStr request.getParameter(id); if (idStr null || !idStr.matches(\\d)) { response.sendError(HttpServletResponse.SC_BAD_REQUEST, 参数不合法); return; } int id Integer.parseInt(idStr); boolean success userService.deleteById(id); if (success) { // 重定向回列表页刷新数据 response.sendRedirect(request.getContextPath() /user/list?pageNum1); } else { request.setAttribute(errorMsg, 删除失败); request.getRequestDispatcher(/jsp/error.jsp).forward(request, response); } } }参数校验的细节写在注释里idStr.matches(\\d)是一个正则表达式要求字符串只包含数字这从根本上排除了传字母、特殊符号导致的类型转换异常。当用户通过浏览器地址栏手动访问删除接口并传入恶意参数时这个校验是第一道防线。修改操作也是类似的流程差别在于查询出原有数据回显到编辑页面用户提交后执行UPDATE语句SQL 中同样使用PreparedStatement占位符不再赘述。5. 避坑指南登录注册 CRUD 项目中最常见的 6 个翻车现场5.1 数据库连接失败时区问题、驱动问题和中文乱码现象项目启动后访问任意一个需要查询数据库的接口控制台报Cannot create PoolableConnectionFactory或Communications link failure有时还伴随The server time zone value й׼ʱ is unrecognized这种乱码信息。原因这个报错涉及三个叠加的问题但最常见的是 MySQL 8.0 的驱动需要显式声明时区。旧版 5.x 驱动不校验时区而 8.0 驱动默认会校验连接时区如果连接串里没有serverTimezoneAsia/Shanghai就会直接拒绝连接。解决在jdbcUrl中追加时区参数jdbc:mysql://localhost:3306/javaweb_demo?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8。同时确认驱动版本和 MySQL 服务端版本匹配MySQL 8.0 服务端建议使用com.mysql.cj.jdbc.Driver这个新驱动类名。5.2 表单提交后页面 URL 不变刷新时弹出“重新提交表单”提示现象登录成功后浏览器地址栏还是/login按 F5 刷新页面浏览器弹出确认框询问是否重新提交表单点确定后竟然重复执行了登录操作。原因这是 Servlet 中使用了forward转发到目标页面的典型副作用。forward是服务器内部跳转浏览器并不知道页面已经换了地址栏不会更新刷新时浏览器认为“上次请求是表单提交”于是再次提交。解决登录成功后应使用response.sendRedirect()重定向到列表页而不是request.getRequestDispatcher().forward()。sendRedirect会向浏览器返回 302 响应和一个新的地址浏览器自动发起 GET 请求访问新地址。数据提交类的业务操作增加、修改、删除成功之后都应该遵循这个 PRGPost-Redirect-Get模式。5.3 获取 POST 参数中文乱码改代码也没用现象注册时在表单里输入“张三”存入数据库后变成“å¼ ä¸”或“???”登录页的提示信息也乱码。原因request.setCharacterEncoding(UTF-8)没有放在取参数之前或者 set 的编码与页面表单声明的编码不一致。还有一种隐蔽情况是 Tomcat 8 之前的版本对 GET 请求的参数编码处理方式不同需要额外配置URIEncoding。解决在 Servlet 的doPost开头第一行写request.setCharacterEncoding(UTF-8)JSP 页面顶部声明pageEncodingUTF-8在 Tomcat 的server.xml中给 Connector 增加URIEncodingUTF-8属性Tomcat 8 默认已是 UTF-8无需修改。数据库侧的characterEncodingutf8同样不可省略三处缺一不可。5.4 Session 失效判断错误每次请求都跳回登录页现象登录成功后访问列表页正常但点击任意一个操作按钮就跳到登录页回到登录页后明明输对了用户名密码却又提示错误。原因最常见的原因是req.getSession()在过滤器和 Servlet 里被混用。过滤器中使用req.getSession(true)会强制创建一个新 Session而这个新 Session 中不可能有之前登录时存入的用户信息于是判定为未登录。另一个可能原因是session.setAttribute存进去的是一个刚查出来的新对象而后续判断时取不到值比如两个不同的键名。解决全项目统一 Session 键名常量例如在一个公共类里定义public static final String LOGIN_USER loginUser。过滤器判断时使用req.getSession(false)取不到 Session 或取不到用户信息都跳转登录页。如果需要排查在过滤器中加一行日志输出session.getId()和session.getAttribute的值观察每次请求是不是同一个 Session。5.5 页面点击删除列表中的多条数据被同时删掉现象用户列表每条记录都有一个删除按钮点击某一条的删除刷新后发现这个用户还在反而是其他记录被删掉了。原因这是一个经典的 HTML 嵌套标签错误——删除按钮的onclick事件或form标签没有正确包裹导致点击时事件冒泡到了相邻的行或者所有删除按钮共用一个form第一个按钮的submit事件把整个列表的隐藏域id都提交了。解决每一行单独使用一个form且表单必须完整包裹当前行的隐藏id字段。推荐做法是删除按钮用onclickif(confirm(确认删除)) location.href${pageContext.request.contextPath}/user/delete?id${user.id}让每个按钮直接跳转到独立的删除地址避免表单嵌套问题。5.6 修改操作提交后数据库中的create_time被清零现象编辑一个用户资料只改了昵称保存后发现这条记录的创建时间变成了当前时间或 NULL。原因修改操作默认执行UPDATE t_user SET username?, nickname?, email?这类语句如果没有在 SQL 中带上create_time字段而实体类User中该字段值为null执行 setter 时就会覆盖原值。或者使用了ON UPDATE CURRENT_TIMESTAMP任何字段更新都会触发时间重写。解决修改操作的 SQL 语句要明确列出所有需要更新的字段保持字段列表和页面表单一致不需要更新的字段不要出现在SET子句中。另外建表时如果不需要更新时间字段就不要加ON UPDATE CURRENT_TIMESTAMP或者接受它的语义是“自动记录最后更新时间”。6. 再进一步把登录注册 CRUD 项目升级成拿得出手的作品做完上面这套基础版本你已经有了一个完整的、能跑通的 JavaWeb 项目。但说实话能登录、能增删改查只是起点——如果你想拿它去面试、去答辩还需要加几个性价比高的增强功能。从一个面试官的角度这套登录注册 CRUD 代码能体现的能力分水岭通常在以下三个方向上。第一个方向是登录安全性的增强。比如给密码加盐再哈希替代目前简单的 MD5给登录接口增加验证码校验防止暴力破解用HttpSession记录登录失败次数连续失败 5 次锁定账号 15 分钟。这些功能在代码层面改动不大但能体现你对安全的理解。第二个方向是用户体验的增强。注册页面把表单校验从前端搬到后端并返回准确的中文提示用户列表增加关键字搜索在 DAO 中动态拼接WHERE username LIKE ?条件编辑页面使用 AJAX 校验用户名是否已存在。AJAX 和 JSON 是现代 Web 项目的标配在 Servlet 里返回 JSON 只需要引入一个jackson-databind依赖序列化对象为 JSON 字符串后response.getWriter().write(json)即可。第三个方向是工程化体验的增强。把 JSP 中的Java 脚本片段% %彻底移除改用 JSTL 标签和 EL 表达式引入Filter统一处理请求编码把数据库连接信息放到 JNDI 数据源中脱离代码配置。最后一个最有意思你可以在项目里加一个简单的WebListener实现的ApplicationListener统计在线人数展示在一个 JSP 页面上这是一个非常出彩的小设计。至于那个“修改密码”的功能表面上也是 CRUD 的一种它让你重新审视“修改”这个操作密码字段需要单独表单旧密码要先校验新密码要二次确认如果当前 Session 中的用户与操作者不一致还要校验权限。这比我前面写的“修改用户资料”更考验边界处理能力很适合作为自我测验的练习。完成这个功能的时候你会发现自己对 Servlet JSP JDBC 的控制力已经和刚开始完全不同了。最后说一个我自己的习惯。做这类入门项目时我总是在每完成一个功能后手动测试一遍完整链路注册一个带中文的用户、退出登录、重新登录、修改用户信息、删除用户、查看列表数据与数据库中的记录是否一致。这些操作看似琐碎但往往是定位隐藏 bug 最快的方式——等你亲眼见过一次“数据库里少了一条记录”和“多了一个乱码用户名”以后再遇到类似问题就不会慌了。希望这套方案能帮你在 JavaWeb 的起步阶段少走些弯路。本文还有配套的精品资源点击获取