JavaWeb实战:Servlet+JSP+JDBC实现登录注册与增删改查

发布时间:2026/10/11 12:45:22
JavaWeb实战:Servlet+JSP+JDBC实现登录注册与增删改查
简介一套适合Java Web初学者的用户管理实战项目围绕登录、注册、信息修改与删除等核心功能完整演示Servlet处理请求、JSP生成动态页面、JDBC访问数据库的协同流程代码结构清晰直击入门常见痛点。压缩包共89个文件大小仅4.67MB包含Java源码、JSP页面、前端样式与脚本、Bootstrap静态资源、数据库连接依赖以及项目配置信息目录按源码目录、Web页面目录、部署描述文件等标准组织方便边看边学。目前已有6302人参与学习与下载适合用作课程设计、毕业设计或实训参考。通过该项目可理清用户表的增删改查实现、登录会话维持、注册参数校验、数据库连接封装等关键问题同时能学会基本的表单处理、请求转发与重定向、SQL语句编写和简单防御思路稍加改造即可扩展为更完整的管理后台。1. 一个 JavaWeb 练手项目能装下多少东西登录、注册、改资料、删账号的完整闭环很多人在学完 Servlet、JSP 和 JDBC 之后最容易出现一个尴尬局面每个知识点单独看都懂但要把它们拼成一个能跑起来的 JavaWeb 项目就不知道该从哪一行开始写。学校作业或入门练手时最常被要求的就是“做一个简单的javaweb项目实现登陆注册修改删除等”——这四个动作恰好覆盖了 Web 开发里最核心的 CRUD 与状态管理。这篇文章就是把这套 JavaWeb 项目拆开讲透从建表、连接数据库到登录会话维护、过滤器权限拦截再到修改回显和删除边界每一段都有能直接抄走的代码坑也提前帮你踩一遍。2. 技术选型与项目骨架为什么会选 Servlet JSP 原生 JDBC2.1 选型理由这个复杂度用框架反而更难看透如果你搜过网上的解决方案大概率会看到很多人直接建议你学 Spring Boot用 MyBatis-Plus 几行代码就把增删改查写完了。这个建议对工作党没错但对一个目标是“理解 JavaWeb 请求处理链路”的项目来说就有点跳过步骤了。我们用 Servlet JSP JDBC 来搭有三个理由第一每个请求如何被容器解析、如何路由到 Java 代码、如何写回浏览器这些在 Servlet 里是一条清晰的线第二SQL 和 JDBC 的步骤完全裸露能看到连接、执行、关闭的全过程以后排查生产问题会有手感第三课程设计和多数入门场景的要求就是“跑通一个标准 JavaWeb 项目”框架不是必需项。这个选型的边界也很明确并发不高、逻辑简单、数据量小。如果目标是学框架那这套代码确实过时了但如果目标是搞懂 Web 请求是怎么绕了一圈又回来的那它比任何框架都适合作为第一份代码资产。项目里所有业务函数都不超过二十行出了问题能顺着代码一步步追到数据库这种排查体验在大型框架项目里是体会不到的。2.2 项目结构三层分包每个类放在哪里一眼清楚我一般会把项目按 entity、dao、service、servlet、filter 五层拆开webapp 下只放 JSP 和静态资源。一个典型的目录结构是这样的src/main/java ├─ com.demo.entity.User.java ├─ com.demo.dao.UserDao.java ├─ com.demo.service.UserService.java ├─ com.demo.servlet/ │ ├─ RegisterServlet.java │ ├─ LoginServlet.java │ ├─ UserListServlet.java │ ├─ EditServlet.java │ └─ DeleteServlet.java ├─ com.demo.filter.LoginFilter.java └─ com.demo.util.DBUtil.java src/main/webapp ├─ login.jsp ├─ register.jsp ├─ list.jsp ├─ edit.jsp └─ WEB-INF/web.xmlentity 只放字段和 getter/setter对应数据库表结构dao 层只写 SQL负责和数据库打交道service 层处理业务规则比如注册前查重、密码加密servlet 只做参数接收、调用 service、决定跳转到哪个页面filter 统一处理登录拦截。这样分的好处是以后想换成框架dao 层改起来最省事servlet 可以被 controller 替换service 和 entity 几乎不用动。请求流转也很直观。以注册为例浏览器提交 register.jsp 的表单Tomcat 根据 web.xml 里的映射找到 RegisterServletServlet 取出参数交给 UserServiceUserService 先调用 UserDao.findByUsername 做重名检查再调用 UserDao.insert 插入记录最后 Servlet 用重定向把浏览器带到登录页。整个过程就是一个从 web 层到数据库再回到 web 层的闭环。web.xml 里 Servlet 的映射配置仍是入门阶段最稳的做法注解方式虽然省事但路径一多容易漏。核心配置片段servlet servlet-nameRegisterServlet/servlet-name servlet-classcom.demo.servlet.RegisterServlet/servlet-class /servlet servlet-mapping servlet-nameRegisterServlet/servlet-name url-pattern/register/url-pattern /servlet-mapping这里 url-pattern 的写法要注意/register不带后缀访问时通过项目名/register命中如果写成/register.do也行但前后端要统一。Tomcat 8 及以上版本对 URL 映射的匹配规则是“最长匹配优先”配置了/register就不会被/*的过滤器之外的映射抢走但如果过滤器也配置成/*它仍然会拦截所有请求这一点到第 5 章展开讲。2.3 数据库连接统一管理从工具类开始避免玄学报错JDBC 的第一步是拿到 Connection。新手最常见的写法是在每个 DAO 方法里重复写驱动加载和连接获取结果就是代码冗余改一个数据库地址要全局替换。更危险的是把 Connection 定义成静态成员变量多线程环境下多个请求共享一个连接事务互相污染查出来的数据张冠李戴。建议的做法是用一个线程独立的连接工具类每次请求 getConnection() 都返回新连接。常见做法是写一个静态代码块负责驱动加载连接不缓存public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/javaweb_demo ?useUnicodetruecharacterEncodingutf8 useSSLfalseserverTimezoneAsia/Shanghai; private static final String USER root; private static final String PASSWORD 123456; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new RuntimeException(MySQL 驱动加载失败检查 jar 包是否放到 WEB-INF/lib, e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(ResultSet rs, Statement stmt, Connection conn) { if (rs ! null) { try { rs.close(); } catch (SQLException ignored) {} } if (stmt ! null) { try { stmt.close(); } catch (SQLException ignored) {} } if (conn ! null) { try { conn.close(); } catch (SQLException ignored) {} } } }URL 上的三个参数是血泪经验换来的useUnicodetruecharacterEncodingutf8保证中文能正确写入数据库useSSLfalse避免本机 MySQL 8 的 SSL 握手报错serverTimezoneAsia/Shanghai解决时区差导致的时间字段错乱。close方法里的关闭顺序必须是先 ResultSet、再 Statement、最后 Connection反过来会先断开连接后面两个明明还占着资源。这套工具类写一次所有 DAO 都能复用。3. 登录与注册模块从建表到会话校验的完整链路3.1 用户表设计字段长度和唯一约束别拍脑袋定登录注册的核心是用户表它的设计直接影响后面所有功能的复杂度。我用的表结构很朴素但每个字段都有讲究CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, salt CHAR(32) NOT NULL, nickname VARCHAR(50), created_at DATETIME DEFAULT CURRENT_TIMESTAMP );username 加 UNIQUE 约束是在数据库层面挡住重名注册这比只在 Java 代码里查一遍更保险。两个请求同时提交同一个用户名时先查后插的代码可能双双通过检查但数据库唯一索引会直接让第二个插入失败。password 长度定 64 而不是 32是为了给加盐后的 MD5 留余量如果以后换 SHA-256 也不用改表。salt 字段存每个用户独立的随机盐值这是防彩虹表的关键后面代码里会用到。nickname 允许为空这样注册表单至少不用逼用户填一堆必填项。3.2 注册功能先查重再插入密码必须加盐后存储注册逻辑看起来是“接收参数 → 插入数据库”但中间夹着两个不能省的步骤。第一是重名检查第二是密码加密。很多人图省事直接把明文密码插进表里查数据库时一眼看到密码是裸的这在交作业时会被问得很尴尬工作时更是安全隐患。我给的方案是在 Service 层做这两件事public boolean register(User user) { User exists userDao.findByUsername(user.getUsername()); if (exists ! null) { return false; // 用户名已被占用 } String salt generateSalt(); String encoded md5(user.getPassword() salt); user.setPassword(encoded); user.setSalt(salt); return userDao.insert(user) 1; }generateSalt 通常用 UUID 去掉横线取 32 位或者用 SecureRandom 生成随机字节再转十六进制。md5 方法内部用 MessageDigest 对“密码 盐”整体做摘要而不是只对密码本身做摘要。这一步的关键在于相同密码在不同用户身上得到不同的密文即使用户密码简单攻击者拿到数据库也匹配不上彩虹表。查重和加密都放在 Service 层而不是 Servlet 层是因为 Servlet 只负责参数传递万一以后要加“用户名只能包含字母数字”的规则只需要改 Service 一处。注册成功后Servlet 必须用重定向而不是转发跳到登录页。转发会让浏览器地址栏停留在 /register用户按 F5 就重复提交表单。重定向之后浏览器重新发起一次 GET 请求到登录页提交动作已经结束刷新不会触发二次注册。这个习惯从第一个功能就要养成后面章节还会讲到。3.3 登录功能比对密文用 Session 保存登录态登录接口的校验不能写成一刀切的 SQL 拼接。常见错误是 dao 层写成“select * from t_user where username? and password?”然后把明文密码传进去这样盐值机制就形同虚设。正确做法是先根据用户名查出用户取出这个人的 salt把输入的密码加盐做 MD5再和库里的 password 字段比对public User login(String username, String rawPassword) { User user userDao.findByUsername(username); if (user null) { return null; } String encoded md5(rawPassword user.getSalt()); if (encoded.equals(user.getPassword())) { user.setPassword(null); user.setSalt(null); return user; } return null; }登录成功后把 user 对象塞进 Session之后页面判断登录态就靠它HttpSession session request.getSession(); session.setAttribute(loginUser, user); response.sendRedirect(request.getContextPath() /list);这里 setAttribute 的键名最好统一维护一个常量比如 LoginFilter 里判断时也引用同一个常量避免手写字符串拼错。request.getContextPath()必须带着项目部署到 Tomcat 时根路径可能不是/少了它重定向会 404。session 的默认超时时间是 30 分钟可以在 web.xml 里调整但对这个项目来说默认值够了。未登录的请求需要过滤器统一拦截。我在第 2 章提过过滤器要放行静态资源和公开接口代码是长这样的public class LoginFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; String uri request.getRequestURI(); if (uri.endsWith(login.jsp) || uri.endsWith(register.jsp) || uri.contains(/login) || uri.contains(/register) || uri.endsWith(.css) || uri.endsWith(.js) || uri.endsWith(.png) || uri.endsWith(.jpg)) { chain.doFilter(req, resp); return; } HttpSession session request.getSession(false); if (session null || session.getAttribute(loginUser) null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, resp); } }注意request.getSession(false)里的 false它表示当前没有 Session 就不新建。如果用request.getSession()所有被拦截的请求都会被动创建一个新 Session等于是给服务器制造垃圾。过滤器里判断静态资源用 endsWith 有个坑URL 里带查询参数时 endsWith 可能失效稳妥做法是先request.getRequestURI()拿到不含参数的路径再判断。4. 修改与删除功能列表查询、回显更新与删除边界4.1 用户列表分页与条件查询的 SQL 参数顺序用户管理里最常看的是列表页。列表页要做两件事分页和按用户名或昵称模糊搜索。DAO 层的关键 SQL 用占位符拼参数避免直接字符串拼接。占位符既防 SQL 注入也让 MySQL 能复用执行计划public ListUser findPage(int pageNo, int pageSize, String keyword) { StringBuilder sql new StringBuilder( SELECT id, username, nickname, created_at FROM t_user WHERE 11); ListObject params new ArrayList(); if (keyword ! null !keyword.trim().isEmpty()) { sql.append( AND (username LIKE ? OR nickname LIKE ?)); String like % keyword.trim() %; params.add(like); params.add(like); } sql.append( ORDER BY id DESC LIMIT ?, ?); params.add((pageNo - 1) * pageSize); params.add(pageSize); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql.toString())) { for (int i 0; i params.size(); i) { ps.setObject(i 1, params.get(i)); } // 执行查询并封装结果 } catch (SQLException e) { throw new RuntimeException(分页查询失败, e); } }这里有两个细节值得说。第一列表查询的 SQL 不查 password 和 salt列表页根本用不到这些敏感字段查出来反而增加内存压力万一前端调试时把整个 JSON 打印出来就泄露了。第二LIMIT 的参数不能写进 SQL 字符串里必须用 setObject 设置因为LIMIT 0, 10的 0 和 10 如果直接拼字符串一旦被注入成1; DROP TABLE后果不堪设想。WHERE 11是为了动态拼接 AND 条件时不判断“是否已有 WHERE”虽然看起来不太优雅但在这个复杂度下是实用的。pageNo 从 1 开始所以计算偏移量时要先减 1。需要注意LIMIT ?, ?在 MySQL 的 PreparedStatement 里是支持的但在某些数据库驱动版本下会报语法错误。如果遇到可以把两个参数改成一个LIMIT ? OFFSET ?的形式写法不同效果一致。4.2 修改资料两段式流程密码框留空不动原密码修改功能在 Web 项目里通常不是一步完成的而是“先查后改”。点列表页的“编辑”链接带着 id 跳到 EditServlet它负责查出这条记录并转发到 edit.jsp用户改完表单提交再由另一个 Servlet 处理更新。注意这第一步必须用转发而不是重定向因为查询结果要放在 request 作用域里给 JSP 回显重定向会丢掉 request 属性String idStr request.getParameter(id); int id Integer.parseInt(idStr); User user userDao.findById(id); request.setAttribute(user, user); request.getRequestDispatcher(/edit.jsp).forward(request, response);这里从 request 取 id 时要对 null 做判断否则直接 parseInt 会抛 NumberFormatException页面直接白屏。我一般会先判空再 try-catch 包一层捕获到异常就重定向回列表页并附带一个错误提示参数。更新资料的 SQL 必须避开一个经典坑——密码字段。列表页有昵称、用户名可改但密码框一旦留空提交如果 SQL 无脑写成UPDATE t_user SET username?, password?, nickname? WHERE id?空字符串就会覆盖原密码用户下一次登录直接失败。我在生产里见过不止一次这种翻车都是改完资料就再也登不进去。正确逻辑是密码参数为空则不更新密码public int updateProfile(User user) { if (user.getPassword() null || user.getPassword().isEmpty()) { String sql UPDATE t_user SET username?, nickname? WHERE id?; // 只设置三个参数 } else { // 需要重新加盐更新 password 和 salt } }前端表单还能在 JSP 里用${user.username}这种 EL 表达式回显省得在 JSP 里写一堆 Java 代码。回显之后用户改动昵称提交时后端拿到的就是表单里的值。如果密码框留空后端保留原值如果填了新密码后端重新生成盐再做 MD5。这个判断要在 Service 层把关不能只靠前端判断“密码框为空就不提交 password 字段”因为恶意请求可以直接 POST 一个空字符串过来。4.3 删除功能物理删除一时爽软删除才是后悔药删除功能在这个项目里最简单的写法就是按 id 执行 DELETE。代码本身不复杂String idStr request.getParameter(id); if (idStr ! null !idStr.trim().isEmpty()) { int id Integer.parseInt(idStr.trim()); userDao.deleteById(id); } response.sendRedirect(request.getContextPath() /list);删除后必须重定向。如果删除后 forward 回列表页用户一刷新浏览器重新提交同一个删除请求那条记录已经被删了再删一次就会报错或影响其他数据。重定向让地址栏变成 /list刷新只是重新查询列表不会重复删除。但从数据安全角度看我更推荐软删除给 t_user 表加一个 deleted 字段删除操作变成 UPDATE查询操作统一过滤掉已删除记录。这样用户误删账号后还能恢复相当于给自己的项目留了一颗后悔药。ALTER TABLE t_user ADD COLUMN deleted TINYINT DEFAULT 0; UPDATE t_user SET deleted 1 WHERE id ?;软删除之后所有查询列表的 SQL 都要加上AND deleted 0登录、注册查重时也要过滤否则已删除的用户还能正常登录。分页查询里如果忘了加这个条件就会出现“删除后刷新记录又回来了”的灵异现象其实是被软删除的数据还在表里。5. JavaWeb 实战排查清单五个高频翻车点与解决办法5.1 中文乱码请求编码和响应编码对不齐现象注册表单提交中文昵称存进数据库变成“??”列表页显示的名字全是问号或者页面源码里中文正常浏览器显示乱码。原因Tomcat 8 及以上的 GET 请求默认用 UTF-8 解码但 POST 请求的编码取决于浏览器 form 的 enctype 和 Servlet 是否设置了 request.setCharacterEncoding。即使请求端对了MySQL 连接串里如果没有 characterEncodingutf8数据库写入时又会按默认的 latin1 处理三重原因叠加起来就是乱码。解决三个地方必须对齐。第一Servlet 里在获取任何参数之前调用request.setCharacterEncoding(UTF-8)第二response 设置setContentType(text/html; charsetUTF-8)第三JDBC 连接串带上 characterEncodingutf8。外加 JSP 页面顶部声明% page contentTypetext/html; charsetUTF-8 %。这四处一致后乱码基本绝迹。GET 请求的中文如果还是乱检查 Tomcat 的 server.xml 里 Connector 是否配置了 URIEncodingUTF-8在新版 Tomcat 里这已经是默认值不用手动改。5.2 JDBC 连接耗尽页面卡死报错请求超时现象项目本地跑二三十个请求后Tomcat 日志报“Connection is not available, request timed out”页面一直在转圈重启 Tomcat 才能恢复。原因DAO 里用了连接但没关。DriverManager 每次 getConnection 都新建物理连接用完不 close数据库端连接数很快被占满。这是新手最常踩的资源泄漏坑而且不会一开始就报错是累积到阈值才突然崩排查起来很像玄学。解决每条 DAO 方法的 finally 块里关闭 ResultSet、Statement、Connection用第 2 章那个 DBUtil.close 方法可以少写很多代码。Java 7 的 try-with-resources 更省事因为 PreparedStatement 和 Connection 都实现了 AutoCloseable。如果项目以后并发上来就该引入 DBCP 或 Druid 连接池让池子管理连接的创建和回收应用层只负责借和还。检查自己的代码有没有泄漏看 Tomcat 日志里有没有“communication link failure”或者数据库端show processlist里 Sleep 连接越来越多基本就能定位。5.3 过滤器拦截了静态资源登录页 CSS 全丢反复跳转现象登录页能打开但页面没有任何样式纯 HTML 硬排或者访问登录接口时被一遍遍重定向回 login.jsp浏览器提示“网页有重定向循环”。原因LoginFilter 映射为 /* 后CSS、JS、图片请求也进入 doFilter而白名单里没放行静态资源结果这些请求拿不到 session 里的 loginUser被重定向到登录页登录页本身又要加载 CSSCSS 又被拦截于是循环跳转或者资源 404。解决doFilter 里放行静态资源后缀同时放行公开接口。前面代码里的 endsWith 判断要覆盖 .css、.js、.png、.jpg、.ico。优先级是先判断是否公开资源公开就直接放行再做登录校验。顺序不能反否则登录校验先把请求拦下了。另外要注意放行的判断最好基于request.getRequestURI()拿到的路径不能用 getRequestURL 或 getServletPath因为它们拿到的内容不一样容易误判。5.4 重复提交刷新一下数据被插了两遍或者删了两次现象注册成功后按 F5同样一条用户记录出现两条删除一条数据后刷新列表页同一条记录被删除两次并报错。原因Servlet 处理后用了 forward 跳转地址栏 URL 仍然是 /register 或 /delete浏览器刷新时重新发起同一个 POST 或 GET 请求处理逻辑又执行了一遍。写操作接口不够“幂等”重复执行就出问题。解决写操作完成后一律response.sendRedirect()到列表页或登录页这就是经典的 POST-Redirect-GET 模式。重定向后地址栏变成本来要展示的页面 URL刷新执行的只是查询不会重复写库。听起来简单但很多人写完注册跳转发、写完删除跳转发就是因为没理解转发不改变地址栏这个特性。后面每写一个写操作都要先问一句“刷新会发生什么”。5.5 改资料后密码失效或者列表页查出了敏感字段现象用户修改昵称后用原密码登录失败或者前端调试接口时看到了 password 和 salt 字段的值。原因更新 SQL 把密码字段直接覆盖成空或错误的值列表查询用SELECT *把全字段查出来又塞进 JSON 或 request 属性里敏感字段跟着泄露。解决资料修改和密码修改拆开资料修改永远不更新 password列表查询显式列出需要的字段不用SELECT *Entity 在控制器返回之前手动把密码和盐置空防止 JSP 或接口意外输出。密码修改单独走一个“输入原密码 → 校验 → 新密码加盐更新”的流程两件事混在一起最容易互相踩。这个坑我在带某同学调项目时遇到不止一次每次都是先怀疑加密算法最后发现是更新语句把密码字段写空了。6. 把这个项目再往前推一步权限分层与验证方法很多人以为跑通了登录、注册、修改、删除这个 JavaWeb 项目就算结束了。其实还有两个很值得做的小升级成本低但效果明显。第一个是给过滤器加角色判断。现在 LoginFilter 只校验“是否登录”不管“是谁登录”。如果想把项目升级成一个带权限的后台可以在 t_user 表加一个 role 字段取值 admin 或 user然后在过滤器里多判断一层当请求路径以 /admin 开头时检查 session 里的 loginUser 是否具备 admin 标识没有就直接返回 403。这段逻辑不超过十行但能让项目从“能用”变成“像个系统”。我一般会把它写在 original 的 LoginFilter 后面再挂一个 AdminFilter而不是塞进同一个过滤器职责分清楚。第二个是给 Service 层加一条计时日志。在 UserService 的每个方法进出时打印方法名和执行耗时能看到哪条 SQL 是性能瓶颈。简单做法是用 System.currentTimeMillis 前后相减项目结构不变。复现完成后建议按下面这条链路完整验证一遍步骤操作预期结果1不登录直接访问 list.jsp被过滤器重定向到 login.jsp2注册一个中文昵称的新账号数据库中文显示正常无乱码3用同一个用户名再注册一次提示用户名已存在4新账号登录修改昵称并提交列表页昵称立即更新5删除该账号列表页不再显示该记录6刷新列表页数据保持不变不重复删除第 6 步最容易暴露问题如果删除用的不是重定向刷新就会再次触发删除逻辑报错或者产生异常行为。这套项目我前后带人跑了几个版本从那以后我自己每次把一个 JavaWeb 项目部署到新环境都会强制走一遍“注册 → 退出 → 登录 → 修改 → 删除 → 刷新”的闭环全程盯着 Tomcat 日志和数据库里的数据变化。这是最笨也最有效的验证方式很多问题在第五步之前就会现出原形。希望这份拆解能帮你把登录、注册、修改、删除这四件事真正揉进自己的代码习惯里祝你好运。本文还有配套的精品资源点击获取