资讯详情

JavaWeb通讯录JDBC实战:从建表到分页的完整避坑指南

📅 2026/10/6 11:09:55 | 华诺云谱 👁 阅读
JavaWeb通讯录JDBC实战:从建表到分页的完整避坑指南
简介这份资源面向JavaWeb初学者与需要巩固JDBC基础的开发者围绕通讯录场景讲解如何用JDBC连接MySQL并实现联系人的增、删、改、查。内容以Eclipse与Navicat为开发环境从新建Web项目、配置Tomcat与web.xml入手逐步搭建BaseDao基类封装驱动加载、连接获取、executeUpdate与executeQuery等通用方法并针对MySQL时区设置等易错点给出提醒。在此基础上设计Contact实体与ContactDao演示PreparedStatement参数绑定、ResultSet结果集遍历、按ID更新与删除等具体操作前端则结合HTML、CSS、JavaScript与AJAX完成异步交互并简要提及Hibernate、MyBatis等ORM框架的替代思路。资源包为1个PDF文件约111KB内容紧凑、代码示例完整适合作为课程设计或入门练手参考。目前已有1880人学习可帮助读者快速理清DAO分层结构与数据库操作流程。1. JavaWeb 通讯录为什么总在 JDBC 这一层翻车很多人第一次做 JavaWeb 通讯录页面能打开、表单能提交但数据就是进不了 MySQL或者一刷新列表全是空。问题几乎都出在 JDBC 这一层驱动没加载、连接没关、SQL 拼接写错、事务没提交。通讯录的增删改查看着简单但它把「前端表单 → Servlet 接收 → JDBC 操作 → MySQL 落库 → 页面回显」这条链路完整串了一遍任何一个环节断掉功能就废。这篇笔记面向两类人一是刚学完 Servlet 和 JSP想找一个完整案例把 JDBC 增删改查跑通的新手二是写过 CRUD 但总在连接泄漏、中文乱码、主键回填上踩坑的熟手。我会用一个联系人表 contact 贯穿始终把建库建表、JDBC 工具类、DAO 层、Servlet 层、JSP 展示层的代码全部给出来参数怎么设、坑在哪、报错怎么查都落到具体命令和代码上。你照着敲完能拿到一个真正能跑、能改、能扩展的通讯录。2. 建库建表与 JDBC 连接 MySQL 的最小闭环2.1 通讯录表结构怎么设计才够用通讯录的核心字段不多但设计时要想清楚哪些字段允许为空、主键怎么生成、字符集怎么选。我一般用下面这张表字段覆盖姓名、电话、邮箱、地址、分组主键用自增整数字符集统一 utf8mb4避免中文和特殊符号乱码。-- 创建数据库字符集用 utf8mb4兼容 emoji 和生僻字 CREATE DATABASE contact_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE contact_db; -- 联系人表id 自增主键name 和 phone 必填其余可空 CREATE TABLE contact ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 姓名, phone VARCHAR(20) NOT NULL COMMENT 手机号, email VARCHAR(100) DEFAULT NULL COMMENT 邮箱, address VARCHAR(200) DEFAULT NULL COMMENT 地址, group_name VARCHAR(30) DEFAULT 默认分组 COMMENT 分组, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建表语句里几个参数值得说明。ENGINEInnoDB是必须的因为后面要用事务MyISAM 不支持。AUTO_INCREMENT让 MySQL 自己管主键插入时不用传 id新增后还能通过getGeneratedKeys()拿回刚生成的 id。DEFAULT CURRENT_TIMESTAMP让创建时间自动填充省掉一层 Java 代码。group_name给默认值「默认分组」这样前端不选分组时也不会插入 null。提示如果你的 MySQL 是 8.0 以上连接 URL 里必须加时区和 SSL 参数否则会报The server time zone value或 SSL 警告。5.7 版本相对宽松但建议统一加上。2.2 JDBC 连接 MySQL 的 URL、驱动与工具类JDBC 连接 MySQL 的完整要素有四个驱动类名、URL、用户名、密码。驱动类名在 MySQL 8.0 是com.mysql.cj.jdbc.Driver5.7 是com.mysql.jdbc.Driver写错会直接抛ClassNotFoundException。URL 里要带useSSLfalse、serverTimezoneAsia/Shanghai、characterEncodingutf8这三个参数是新手最容易漏的。package com.contact.util; import java.sql.*; /** * JDBC 工具类统一管理连接、释放资源 * 注意生产环境应换成连接池这里用 DriverManager 是为了让新手看清连接过程 */ public class DBUtil { // MySQL 8.0 驱动类名5.7 请改成 com.mysql.jdbc.Driver private static final String DRIVER com.mysql.cj.jdbc.Driver; // 连接 URL关闭 SSL、指定时区、指定编码 private static final String URL jdbc:mysql://localhost:3306/contact_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8allowPublicKeyRetrievaltrue; private static final String USER root; private static final String PASSWORD 你的密码; // 静态代码块只加载一次驱动 static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new RuntimeException(MySQL 驱动加载失败请检查 jar 包是否导入, e); } } // 获取连接 public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } // 释放资源顺序必须是 ResultSet - Statement - Connection public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs ! null) { try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } if (stmt ! null) { try { stmt.close(); } catch (SQLException e) { e.printStackTrace(); } } if (conn ! null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }这段代码的关键点在静态代码块和关闭顺序。静态代码块保证驱动只加载一次不用每次拿连接都Class.forName。关闭顺序必须是 ResultSet 先关、Statement 再关、Connection 最后关反了会报错或导致游标泄漏。allowPublicKeyRetrievaltrue是 MySQL 8.0 用 caching_sha2_password 认证时需要的不加会报Public Key Retrieval is not allowed。参数怎么改换数据库地址改 URL 里的localhost:3306换库名改contact_db换账号密码改USER和PASSWORD。如果你用 IDEA 跑 JavaWeb 项目MySQL 驱动 jar 要放到WEB-INF/lib下并且 Add as Library否则 Tomcat 启动时找不到驱动。2.3 用一条查询验证连接是否真的通了在写 DAO 之前先写一个 main 方法验证连接这一步能省掉后面大量排查时间。public static void main(String[] args) { try (Connection conn DBUtil.getConnection()) { System.out.println(连接成功 conn); // 顺便查一下表是否存在 try (Statement stmt conn.createStatement(); ResultSet rs stmt.executeQuery(SELECT COUNT(*) FROM contact)) { if (rs.next()) { System.out.println(contact 表记录数 rs.getInt(1)); } } } catch (SQLException e) { System.out.println(连接失败 e.getMessage()); e.printStackTrace(); } }这里用了 try-with-resources连接会自动关闭比手动 close 更安全。如果输出「连接成功」和记录数说明驱动、URL、账号密码、库表全部正确。如果报Access denied检查密码报Unknown database检查库名报Table doesnt exist检查建表语句有没有执行。这一步跑通后面的 DAO 才有意义。3. DAO 层增删改查PreparedStatement 与主键回填3.1 实体类与 DAO 接口的对应关系实体类 Contact 的字段要和表字段一一对应类型也要匹配。id 用 intcreate_time 用java.util.Date或LocalDateTime其余用 String。DAO 接口定义五个方法新增、按 id 删除、更新、按 id 查询、查询全部。接口先定下来实现类再填这样 Servlet 层调用时不会乱。package com.contact.entity; import java.util.Date; public class Contact { private int id; private String name; private String phone; private String email; private String address; private String groupName; private Date createTime; // 省略 getter/setter实际项目必须补全 public int getId() { return id; } public void setId(int id) { this.id id; } public String getName() { return name; } public void setName(String name) { this.name name; } public String getPhone() { return phone; } public void setPhone(String phone) { this.phone phone; } public String getEmail() { return email; } public void setEmail(String email) { this.email email; } public String getAddress() { return address; } public void setAddress(String address) { this.address address; } public String getGroupName() { return groupName; } public void setGroupName(String groupName) { this.groupName groupName; } public Date getCreateTime() { return createTime; } public void setCreateTime(Date createTime) { this.createTime createTime; } }实体类字段名和表字段名的对应关系是group_name对应groupNamecreate_time对应createTime。JDBC 不会自动做下划线转驼峰所以要么在 SQL 里用别名group_name AS groupName要么在rs.getString(group_name)里写原始列名。我一般用后者直观且不容易错。3.2 新增与主键回填为什么你的 id 总是 0新增联系人时如果插入后需要立刻拿到生成的 id必须用Statement.RETURN_GENERATED_KEYS。很多人插入成功但 id 是 0就是因为没加这个参数或者加了但没调getGeneratedKeys()。public int insert(Contact c) { String sql INSERT INTO contact(name, phone, email, address, group_name) VALUES(?,?,?,?,?); Connection conn null; PreparedStatement ps null; ResultSet rs null; try { conn DBUtil.getConnection(); // 第二个参数告诉 JDBC 需要返回自增主键 ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); ps.setString(1, c.getName()); ps.setString(2, c.getPhone()); ps.setString(3, c.getEmail()); ps.setString(4, c.getAddress()); ps.setString(5, c.getGroupName()); int rows ps.executeUpdate(); if (rows 0) { rs ps.getGeneratedKeys(); if (rs.next()) { c.setId(rs.getInt(1)); // 把生成的 id 回填到实体 } } return rows; } catch (SQLException e) { e.printStackTrace(); return 0; } finally { DBUtil.close(conn, ps, rs); } }参数说明ps.setString(1, ...)的索引从 1 开始不是 0这是新手最常见的越界错误。executeUpdate()返回受影响行数插入成功返回 1。getGeneratedKeys()返回的结果集只有一列就是自增 id。如果插入后 id 还是 0检查建表时 id 有没有AUTO_INCREMENT以及prepareStatement有没有传RETURN_GENERATED_KEYS。3.3 删除、更新与查询参数绑定和结果集映射删除和更新都用PreparedStatement不要用字符串拼接否则会有 SQL 注入风险而且中文和特殊字符容易出错。查询分两种按 id 查单条返回 Contact 对象查全部返回 List。// 按 id 删除 public int deleteById(int id) { String sql DELETE FROM contact WHERE id ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, id); return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } } // 更新按 id 修改全部字段 public int update(Contact c) { String sql UPDATE contact SET name?, phone?, email?, address?, group_name? WHERE id?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, c.getName()); ps.setString(2, c.getPhone()); ps.setString(3, c.getEmail()); ps.setString(4, c.getAddress()); ps.setString(5, c.getGroupName()); ps.setInt(6, c.getId()); return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } } // 查询全部返回 List public ListContact findAll() { ListContact list new ArrayList(); String sql SELECT id, name, phone, email, address, group_name, create_time FROM contact ORDER BY id DESC; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { Contact c new Contact(); c.setId(rs.getInt(id)); c.setName(rs.getString(name)); c.setPhone(rs.getString(phone)); c.setEmail(rs.getString(email)); c.setAddress(rs.getString(address)); c.setGroupName(rs.getString(group_name)); c.setCreateTime(rs.getTimestamp(create_time)); list.add(c); } } catch (SQLException e) { e.printStackTrace(); } return list; }ORDER BY id DESC让最新添加的联系人排在最前面符合通讯录的使用习惯。rs.getTimestamp(create_time)返回的是java.sql.Timestamp它是java.util.Date的子类可以直接赋给实体类的 Date 字段。如果查询结果为空findAll返回空 List 而不是 nullServlet 层就不用做 null 判断少一层坑。4. Servlet JSP 串起页面请求转发与中文乱码4.1 一个 Servlet 处理列表、新增、删除、编辑Servlet 层负责接收请求、调 DAO、转发页面。我用一个 ContactServlet通过action参数区分操作避免建五个 Servlet。doGet处理查询和删除doPost处理新增和更新。package com.contact.servlet; import com.contact.dao.ContactDao; import com.contact.entity.Contact; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; import java.util.List; WebServlet(/contact) public class ContactServlet extends HttpServlet { private ContactDao dao new ContactDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 设置请求和响应编码防止中文乱码 req.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetUTF-8); String action req.getParameter(action); if (action null || list.equals(action)) { ListContact list dao.findAll(); req.setAttribute(contacts, list); req.getRequestDispatcher(/list.jsp).forward(req, resp); } else if (delete.equals(action)) { int id Integer.parseInt(req.getParameter(id)); dao.deleteById(id); resp.sendRedirect(req.getContextPath() /contact?actionlist); } else if (edit.equals(action)) { int id Integer.parseInt(req.getParameter(id)); Contact c dao.findById(id); req.setAttribute(contact, c); req.getRequestDispatcher(/edit.jsp).forward(req, resp); } } Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetUTF-8); String action req.getParameter(action); Contact c new Contact(); c.setName(req.getParameter(name)); c.setPhone(req.getParameter(phone)); c.setEmail(req.getParameter(email)); c.setAddress(req.getParameter(address)); c.setGroupName(req.getParameter(groupName)); if (add.equals(action)) { dao.insert(c); } else if (update.equals(action)) { c.setId(Integer.parseInt(req.getParameter(id))); dao.update(c); } // 重定向回列表避免刷新重复提交 resp.sendRedirect(req.getContextPath() /contact?actionlist); } }关键点有三个。第一req.setCharacterEncoding(UTF-8)必须在getParameter之前调用否则 POST 中文会乱码。第二新增和更新后用sendRedirect而不是forward这样浏览器地址栏变成列表页刷新不会重复提交表单。第三req.getContextPath()拿到项目上下文路径部署到 Tomcat 后路径不会写死。4.2 JSP 列表页与表单页的写法列表页用 JSTL 遍历 contacts表单页用 EL 表达式回填编辑数据。JSTL 需要在WEB-INF/lib下放jstl.jar和standard.jar并在页面顶部声明 taglib。% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html headtitle通讯录/title/head body h2联系人列表/h2 a hrefadd.jsp新增联系人/a table border1 cellpadding6 tr thID/thth姓名/thth电话/thth邮箱/thth分组/thth操作/th /tr c:forEach items${contacts} varc tr td${c.id}/td td${c.name}/td td${c.phone}/td td${c.email}/td td${c.groupName}/td td a hrefcontact?actioneditid${c.id}编辑/a a hrefcontact?actiondeleteid${c.id} onclickreturn confirm(确定删除)删除/a /td /tr /c:forEach /table /body /html${c.groupName}能取到值是因为实体类有getGroupName()方法EL 表达式会自动调用 getter。删除链接加了confirm确认避免误删。如果列表页显示空白先看contacts属性有没有设进去再看 JSTL jar 有没有导入最后看c:forEach的items拼写。4.3 编辑页的数据回填与表单提交编辑页需要先根据 id 查出联系人再把值填进 input 的 value 里。用 EL 表达式${contact.name}直接回填注意 null 值会显示成空字符串不会报错。% page contentTypetext/html;charsetUTF-8 languagejava % html headtitle编辑联系人/title/head body h2编辑联系人/h2 form actioncontact methodpost input typehidden nameaction valueupdate input typehidden nameid value${contact.id} 姓名input typetext namename value${contact.name}br 电话input typetext namephone value${contact.phone}br 邮箱input typetext nameemail value${contact.email}br 地址input typetext nameaddress value${contact.address}br 分组input typetext namegroupName value${contact.groupName}br input typesubmit value保存 /form /body /html表单里用 hidden 传 action 和 id提交到contact这个 ServletdoPost里根据 action 判断是更新。注意namegroupName要和 Servlet 里req.getParameter(groupName)一致大小写敏感。如果更新后数据没变检查 id 有没有传进去以及 SQL 的WHERE id?有没有绑上。5. 通讯录 JDBC 实操避坑5 个血泪翻车现场5.1 中文乱码数据库、连接、请求三处都要 UTF-8现象新增联系人后列表里姓名显示成???或乱码。原因通常有三处数据库字符集不是 utf8mb4、JDBC URL 没加characterEncodingutf8、Servlet 没设req.setCharacterEncoding(UTF-8)。解决建库时指定DEFAULT CHARACTER SET utf8mb4URL 加characterEncodingutf8Servlet 在取参数前设编码。三处缺一不可只改一处往往还是乱码。5.2 连接泄漏Tomcat 跑一会儿就报 Too many connections现象本地测试正常部署后访问几次就报Too many connections。原因DAO 里拿了 Connection 没关或者异常路径下没走到 close。解决用 try-with-resources 自动关闭或者在 finally 里调DBUtil.close。更彻底的做法是引入连接池比如 Druid 或 HikariCP但新手阶段先把手动关闭写对。检查方法在 MySQL 里执行SHOW PROCESSLIST;看有多少 Sleep 连接堆积。5.3 SQL 注入与拼接为什么必须用 PreparedStatement现象用字符串拼接写查询输入 OR 11能把整张表查出来。原因拼接 SQL 让用户输入变成了 SQL 语法的一部分。解决所有带参数的 SQL 一律用PreparedStatement和?占位符不要用Statement拼接。PreparedStatement还会预编译 SQL批量执行时性能更好。这条没有例外通讯录虽然数据不敏感但习惯要从第一个项目养成。5.4 主键回填失败插入成功但 id 为 0现象新增后想跳转到详情页但 id 是 0查不到数据。原因prepareStatement没传RETURN_GENERATED_KEYS或者没调getGeneratedKeys()。解决conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)执行executeUpdate()后调ps.getGeneratedKeys()取第一列。还要确认建表时 id 是AUTO_INCREMENT否则没有生成键可返回。5.5 时区与 SSL 报错MySQL 8.0 连接的两个拦路虎现象启动时报The server time zone value ?D1ú±ê×?ê±?? is unrecognized或Public Key Retrieval is not allowed。原因MySQL 8.0 默认时区不是东八区且 caching_sha2_password 认证需要公钥检索。解决URL 加serverTimezoneAsia/Shanghai和allowPublicKeyRetrievaltrue。如果还报 SSL 警告加useSSLfalse。这三个参数在 MySQL 8.0 环境基本是标配5.7 可以不加但加了也不报错。6. 从能跑到好用分页查询与批量删除的进阶写法通讯录数据一多findAll全量查询就会变慢页面也会很长。这时候需要分页。分页的核心是LIMIT offset, sizeoffset 是跳过的行数size 是每页条数。我一般再配一个COUNT(*)查总数算出总页数。// 分页查询page 从 1 开始size 每页条数 public ListContact findByPage(int page, int size) { ListContact list new ArrayList(); String sql SELECT * FROM contact ORDER BY id DESC LIMIT ?, ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, (page - 1) * size); // offset ps.setInt(2, size); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Contact c new Contact(); c.setId(rs.getInt(id)); c.setName(rs.getString(name)); c.setPhone(rs.getString(phone)); c.setEmail(rs.getString(email)); c.setAddress(rs.getString(address)); c.setGroupName(rs.getString(group_name)); list.add(c); } } } catch (SQLException e) { e.printStackTrace(); } return list; } // 查总数用于算总页数 public int count() { String sql SELECT COUNT(*) FROM contact; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { if (rs.next()) return rs.getInt(1); } catch (SQLException e) { e.printStackTrace(); } return 0; }LIMIT ?, ?的两个参数都是 int第一个是 offset第二个是 size。(page - 1) * size这个公式要记牢page 从 1 开始第一页 offset 是 0。总页数用(count size - 1) / size向上取整。Servlet 里接收page参数默认 1传给 DAO再把 list、当前页、总页数一起放进 requestJSP 里渲染上一页/下一页链接。批量删除用IN加动态占位符但要注意占位符数量要和 id 数量一致。public int deleteBatch(int[] ids) { if (ids null || ids.length 0) return 0; // 拼接 ?,?,? 占位符 StringBuilder sql new StringBuilder(DELETE FROM contact WHERE id IN (); for (int i 0; i ids.length; i) { sql.append(i 0 ? ? : ,?); } sql.append()); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql.toString())) { for (int i 0; i ids.length; i) { ps.setInt(i 1, ids[i]); } return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } }这里拼接的是占位符而不是值所以没有注入风险。ps.setInt(i 1, ids[i])的索引从 1 开始和数组下标差 1容易写错。批量删除前最好在页面上做二次确认删错了没有后悔药。最后说一个我自己的习惯每写完一个 DAO 方法先在 main 里单独跑一遍确认 SQL 和参数都对再接到 Servlet 里。这样出问题时能快速定位是 DAO 的锅还是 Servlet 的锅。通讯录这个案例虽小但把 JDBC 的连接、参数绑定、结果集映射、事务边界、分页都练了一遍后面换任何表都是同一套骨架。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑