☰
JavaWeb通讯录案例:JDBC增删改查实战与避坑指南
2026/9/30 7:30:47 网站建设 项目流程

简介:面向JavaWeb初学者的通讯录案例文档,基于Eclipse+Tomcat+MySQL环境,完整讲解JDBC连接数据库、BaseDao工具类封装、DAO层完成联系人增删改查的全过程,并重点提示MySQL时区设置等连接常见坑点。资源包为单个PDF文件,大小仅111KB,适合随查随用。已有1880人学习下载。文档从Contact实体类、BaseDao封装到ContactDao操作均有代码呈现,还结合前端AJAX请求与Servlet调用梳理了完整调用链路,可帮助读者快速搭建可运行的联系人管理模块,巩固Servlet+JDBC基础,也可作为课堂作业或毕设的起步模板。

1. 一个 JavaWeb 通讯录案例,为什么值得先抄一个 JDBC 版再谈框架

如果你正在学 JavaWeb 的 JDBC + MySQL 增删改查,手头最缺的不是文档,而是一个「能跑起来、看得见数据变化」的完整项目。这个通讯录案例恰好就是这么个东西:Eclipse 建的 Web 项目、Tomcat 跑服务、MySQL 存数据、Servlet 收请求、JSP 渲染页面,一条请求从浏览器点按钮到数据库再回到页面的完整链路都有。它不是那种只贴几段 SQL 的碎片教程,而是一个能直接部署、点新增、点修改、点删除都能看到效果的闭环。适合两类人:一是刚学完 Servlet 和 JSP 语法、想找个小项目练手的新手;二是想快速回顾 JDBC 原生写法、或者准备给新人做培训的熟手。我的建议很直接:先把这个 JDBC 手写版跑通,再去碰 MyBatis,否则你根本不知道 ORM 帮你省了哪些事。

2. 先拆项目结构:三层分包 + 六个核心类的职责分配

2.1 分包设计:先看清楚每个类该放哪

拿到项目先别急着部署,先扫一眼包结构。整个工程分了四个包:pers.contact.dao放数据库访问层,pers.contact.entity放实体类,pers.contact.service放业务逻辑层,pers.contact.servlet放控制器层。这个分层在练手项目里不算复杂,但已经够你看明白一件事:实体类只存数据,不碰数据库;DAO 层只写 SQL,不写业务判断;Service 层在 Spring 没出现之前,其实和 DAO 层的边界是模糊的——这个项目里ContactService直接继承了BaseDao,严格说它既是 Service 又是 DAO。对新手来说这不是缺点,反而方便,因为少绕一层就能看清 SQL 是怎么被调起来的。

我还注意到一个细节:Eclipse 里新建 Dynamic Web Project 之后,WEB-INF/lib目录默认是空的,需要手动把 MySQL 驱动 jar 和 JSTL 的 jar 放进去。很多新手在这里翻车,项目跑起来报ClassNotFoundException,第一反应是代码写错了,其实只是 jar 没到位。后面避坑章节我会把这个问题展开说。

2.2 实体类 Contact:字段定义直接对应表结构

Contact这个类的六个字段——id、name、age、phone、date、favorite,每一行都对应contact表的一列。代码本身没什么技术含量,但它定了整个项目的基调:数据库里有什么,Java 里就有什么。

public class Contact { private int id; private String name; private int age; private String phone; private Date date; private String favorite; // 全参构造方法 + getter/setter public Contact(int id, String name, int age, String phone, Date date, String favorite) { super(); this.id = id; this.name = name; this.age = age; this.phone = phone; this.date = date; this.favorite = favorite; } }

全参构造方法在这个项目里承担的角色很关键:ContactService里查完ResultSet之后,直接new Contact(rs.getInt(1), rs.getString(2), ...),一行代码把结果集转成对象。我建议你留意date字段的类型是java.util.Date,不是java.sql.Date——这个区分在写 Servlet 时特别容易踩坑,后面日期格式化那一节我会专门讲。

2.3 BaseDao:把连接和释放写成模板方法

先看第三段核心代码——BaseDao。这个类是整套 JDBC 操作的底座,它把「加载驱动、拿连接、执行 SQL、关资源」四件事封装成了模板方法:

public class BaseDao { private static final String DRIVER = "com.mysql.jdbc.Driver"; public static final String URL = "jdbc:mysql://localhost:3306/demo?" + "rewriteBatchedStatements=true&useUnicode=true" + "&characterEncoding=utf8&allowMultiQueries=true" + "&serverTimezone=GMT%2B8"; public static final String USER = "root"; public static final String PASSWORD = "sasa"; Connection conn = null; PreparedStatement pstmt = null; ResultSet rs = null; public void getConnection() { try { Class.forName(DRIVER); conn = DriverManager.getConnection(URL, USER, PASSWORD); } catch (ClassNotFoundException e) { e.printStackTrace(); } catch (SQLException e) { e.printStackTrace(); } } public int executeUpdate(String sql, Object... obj) { int num = 0; getConnection(); try { PreparedStatement pstmt = conn.prepareStatement(sql); for (int i = 0; i < obj.length; i++) { pstmt.setObject(i + 1, obj[i]); } num = pstmt.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); } finally { closeAll(); } return num; } public ResultSet executeQuery(String sql, Object... obj) { getConnection(); try { PreparedStatement pstmt = conn.prepareStatement(sql); for (int i = 0; i < obj.length; i++) { pstmt.setObject(i + 1, obj[i]); } rs = pstmt.executeQuery(); } catch (SQLException e) { e.printStackTrace(); } return rs; } }

这套写法有两个点值得你细看。第一,executeUpdate和executeQuery都接收可变参数Object... obj,调用方传几个参数都行,循环里用pstmt.setObject(i + 1, obj[i])按顺序绑定占位符。这就是 PreparedStatement 相比 Statement 的核心优势——参数化查询,SQL 注入被挡在语法层面。第二,getConnection()在每个方法内部调用,不是构造方法里调,这样每个操作都拿新连接,虽然性能差,但练手项目里逻辑清晰。

提示:serverTimezone=GMT%2B8里的%2B是 URL 编码的加号,意思是 GMT+8。MySQL 8.x 默认时区是 UTC,如果不加这个参数,连接时会报时区错误,这是新手第一个翻车点。

2.4 Service 层:继承 BaseDao,SQL 与业务混在一起

ContactService直接extends BaseDao,所以它能直接调executeQuery和executeUpdate。再看一眼它写的五个方法:GetAllContact查全部、AddContact插入、DeleteContact按 id 删、GetContact按 id 查单个、UpdateContact按 id 改。SQL 就写在方法体里,没有单独的 mapper 文件,也没有DAO接口——这是原生 JDBC 最朴素的形态。

public int AddContact(Contact contact) { int num = 0; String sql = "insert into contact(name,age,phone,date,favorite) values(?,?,?,?,?)"; try { num = executeUpdate(sql, contact.getName(), contact.getAge(), contact.getPhone(), contact.getDate(), contact.getFavorite()); } catch (Exception e) { e.printStackTrace(); } return num; } public int UpdateContact(Contact contact) { int num = 0; String sql = "update contact set name = ?,age = ?,phone = ?,date = ?,favorite = ? where id = ?"; try { num = executeUpdate(sql, contact.getName(), contact.getAge(), contact.getPhone(), contact.getDate(), contact.getFavorite(), contact.getId()); } catch (Exception ex) { ex.printStackTrace(); } return num; }

注意UpdateContact的 SQL 顺序:set子句里 5 个字段,where里 1 个 id,一共 6 个占位符,调用时参数顺序必须严格对应。这是我见过新手最容易写错的地方——参数个数对不上,或者顺序写反,运行时不报错但数据更新错列。写这类方法时建议对着 SQL 数一遍占位符,再数一遍实参,养成习惯。

3. 数据库连接是第一步:URL 参数、时区、驱动缺一不可

3.1 建表:把 JSP 页面字段翻译成 SQL

项目跑起来之前,数据库得先准备好。连接 URL 里写的是localhost:3306/demo,所以你要在 MySQL 里建一个名为demo的库,再建一张contact表。表字段根据实体类和页面表单反推即可:

CREATE DATABASE IF NOT EXISTS demo DEFAULT CHARACTER SET utf8; USE demo; CREATE TABLE IF NOT EXISTS contact ( id INT NOT NULL AUTO_INCREMENT, name VARCHAR(50) NOT NULL, age INT NOT NULL, phone VARCHAR(20) NOT NULL, date DATE DEFAULT NULL, favorite VARCHAR(100) DEFAULT NULL, PRIMARY KEY (id) );

id设成AUTO_INCREMENT自增主键,新增时不需要传 id,数据库自动生成。date字段用DATE类型,对应页面上<input type="date">提交的值。如果你用 Navicat 操作,直接新建数据库demo,字符集选utf8mb4,再执行上面的建表语句就行。utf8mb4比utf8多支持 emoji,现在写新库我一般直接用utf8mb4。

3.2 连接 URL 参数逐个拆解

连接串是这套代码里含金量最高的部分,我拆给你看:

URL 参数作用备注
rewriteBatchedStatements=true开启批处理语句重写批量操作时性能提升明显
useUnicode=true&characterEncoding=utf8指定编码为 UTF-8不设会乱码
allowMultiQueries=true允许一次执行多条 SQL用分号分隔
serverTimezone=GMT%2B8指定服务器时区为东八区解决 MySQL 默认时区与本地相差 8 小时的问题

我自己写项目时还会额外加两个参数:useSSL=false和allowPublicKeyRetrieval=true。MySQL 8.x 默认开启 SSL,本地开发环境不配证书就会报 SSL 连接错误;allowPublicKeyRetrieval是为了配合useSSL=false使用,避免 RSA 公钥获取失败。这两个坑在避坑章节会细说。

3.3 驱动 jar 的引入方式:三类典型错误

驱动类com.mysql.jdbc.Driver需要 mysql-connector jar 包才能加载。常见的引入方式有三种,我建议按下面的顺序排查:

  1. 把mysql-connector-java-x.x.x.jar放到WEB-INF/lib下,这是动态 Web 项目最稳妥的方式,打包部署时 jar 会自动跟着走。
  2. 如果你用的是 Maven 工程,在pom.xml里加mysql-connector-java依赖,然后让 IDE 自动下载。注意 8.x 版本的驱动类名是com.mysql.cj.jdbc.Driver,和 5.x 的com.mysql.jdbc.Driver不完全一样。
  3. 只在项目构建路径里添加引用、但没复制进WEB-INF/lib——Tomcat 运行时会找不到类,启动不报错,一访问页面就抛ClassNotFoundException。

我见过太多人栽在第三种情况上,Eclipse 里 Build Path 配好了,代码不报红,一跑起来就翻车。检查办法很简单:展开项目的Deployment Assembly,看 jar 是否被发布到了WEB-INF/lib。这个项目不是 Maven 工程,所以老老实实走第一种方式最省心。

4. 增删改查的实现套路:PreparedStatement、参数绑定与 Servlet 分发

4.1 查全部与查单个:ResultSet 转实体类的两种姿势

GetAllContact和GetContact都用executeQuery拿ResultSet,区别只在于有没有where条件。看查全部这段:

public List<Contact> GetAllContact() { List<Contact> list = new ArrayList(); String sql = "select * from contact"; rs = executeQuery(sql); try { while (rs.next()) { Contact f = new Contact(rs.getInt(1), rs.getString(2), rs.getInt(3), rs.getString(4), rs.getDate(5), rs.getString(6)); list.add(f); } } catch (SQLException e) { e.printStackTrace(); } return list; }

我提醒两个小毛病,你可以对比着自己的代码看看。第一,new ArrayList()没写泛型,编译器会报警告,建议写成new ArrayList<Contact>()。第二,rs.getInt(1)这种按列索引取值的方式,阅读性不如rs.getInt("id"),一旦 SQL 里调整了列顺序,索引全错。虽然这个项目暂时不会出问题,但你要知道这两种写法的差异。rs.next()是游标移动,返回true表示有下一行,while循环里每次new一个Contact对象塞进List,这是 JDBC 查询的标准姿势。

GetContact与之类似,只是 SQL 变成了select * from contact where id = ?,调用时传 id 参数。注意它返回的是单个Contact,没查到就返回null,调用方要处理空指针风险。

4.2 新增与修改:表单参数到实体类的映射

新增的入口在 Servlet 的do=add分支里。前端add.jsp把五个字段通过post提交到ContactServlet?do=add,Servlet 逐个getParameter取值,组装成Contact对象,再调ud.AddContact(contact)。关键是日期字段的处理:

String dates = request.getParameter("date"); SimpleDateFormat sdf = new SimpleDateFormat("yy-MM-dd"); Date date = null; try { date = sdf.parse(dates); } catch (ParseException e) { e.printStackTrace(); }

这段代码有坑:SimpleDateFormat("yy-MM-dd")里的yy是两位年份,虽然 Java 解析时会按「80 年问题」的规则把24解析为2024,但如果你输入的是1998,四位年份会被yy模式截断解析,结果就是错误年份。更规范的做法是用yyyy-MM-dd,和 HTML 表单<input type="date">的提交格式完全对齐。这个坑我在避坑章节里会再讲一次,因为它值得讲。

修改的流程稍微绕一点:点「修改」链接先走do=editbefore,Servlet 里查出这条联系人存进 session,然后sendRedirect("edit.jsp"),JSP 页面从 session 里取edituser回显到表单,提交时再走do=edit分支执行UpdateContact。这种「先查后改」的两步走设计在练手项目里很常见,但也暴露了一个问题:数据塞在 session 里,刷新页面或者开多个标签页时容易读到旧数据。等你做了 Spring MVC 再回头看,会发现这里用 request 转发 + 隐藏表单域更干净。

4.3 Servlet 分发:一个入口用 do 参数区分动作

ContactServlet只重写了doGet和doPost,doGet里直接调doPost,所以表单提交和链接跳转共用同一套逻辑。请求进来后,先取do参数,然后按照index / add / del / editbefore / edit五个值走不同分支:

String dos = request.getParameter("do"); if (dos == null || dos.equals("")) { dos = "index"; } if (dos.equals("index")) { List<Contact> ulist = ud.GetAllContact(); request.setAttribute("ulist", ulist); request.getRequestDispatcher("/index.jsp").forward(request, response); return; } if (dos.equals("add")) { // 取参数 -> 组 Contact -> AddContact -> 输出 script 脚本跳转 }

删除操作值得单独看一眼,因为它的设计很典型——前端直接发链接请求:

if (dos.equals("del")) { String ids = request.getParameter("id"); int id = Integer.parseInt(ids); ud.DeleteContact(id); out.print("<script>alert('删除成功!');window.location='ContactServlet?do=index';</script>"); }

href="ContactServlet?do=del&id=${U.id}"这种写法简单粗暴,适合练手。但你要知道这在生产环境里是禁止的:GET 请求不该做删除操作,因为搜索引擎爬虫或者浏览器预加载都可能触发删数据。你要是在简历里写这个项目,面试官很可能会追问「你有没有避免 GET 删除」,提前想好怎么回答。

4.4 页面层:JSTL 遍历 + Foundation 样式

index.jsp用 JSTL 的<c:forEach>遍历ulist渲染表格,这是最基础的 JSP + JSTL 组合。要注意的是它引用了http://java.sun.com/jsp/jstl/core这个 taglib,必须把jstl.jar和standard.jar放到WEB-INF/lib下,否则 JSP 编译阶段直接报错。页面样式用的是 Foundation 5.5.3 的 CDN,jQuery 2.1.1 也是 CDN 引入,所以运行时需要能访问外部 CDN。

index.jsp里有一行ContactService ud = new ContactService(); List<Contact> ulist = ud.GetAllContact(); request.setAttribute("ulist", ulist);。这段 scriptlet 重复查了一次数据库——Servlet 的index分支已经查了一次,JSP 又查了一次。功能上没毛病,但你要是想进阶,第一件事就是把 JSP 顶部的 scriptlet 删干净,只靠 Servlet 转发过来的数据渲染页面。这是 JSP 规范里被强调过无数次的原则:JSP 只负责展示,不写 Java 逻辑。

5. 实战避坑:从时区报错到日期消失,四类高频问题的复盘

5.1 时区报错:The server time zone value 与本地相差 8 小时

  • 现象:第一次连接 MySQL 抛出The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized,或者数据插入后查出来时间差 8 小时。
  • 原因:项目的 MySQL 是 8.x,驱动是 5.x 风格(com.mysql.jdbc.Driver),而 MySQL 8.x 安装时默认时区是美国时区,和本地中国时区差 8 个小时。更糟的是如果 MySQL 端时区没设置,驱动无法识别服务器时区,连接直接被拒。
  • 解决:URL 里加serverTimezone=GMT%2B8,或者在建库时执行set global time_zone = '+8:00'。如果仍然报错,把 URL 里的GMT%2B8换成Asia/Shanghai试试,这是另一个常见写法。这条几乎是 JDBC + MySQL 8 的必踩坑,网上检索量极大,我在这里标记一下:它出现在任何一篇 MySQL 8 连接教程里都不意外。

5.2 日期消失或变成 0000-00-00:SimpleDateFormat 格式与表单格式不一致

  • 现象:新增联系人时,页面上input type="date"选择生日,提交后数据库存的是空值或者0000-00-00。
  • 原因:SimpleDateFormat("yy-MM-dd")的格式是两位年份,而 HTML5 的日期控件提交格式是yyyy-MM-dd(比如2024-08-15)。yy模式解析时优先取前两位,2024-08-15会被解析成20-08-15这种怪异结果,如果严格模式下还会直接抛ParseException,导致date变量为null。
  • 解决:把SimpleDateFormat的模式统一改为yyyy-MM-dd,同时确保 MySQL 表里date字段允许 NULL 或设置默认值。这个坑隐蔽之处在于:本地单独测日期解析可能看不出问题,一旦走了 Web 表单,浏览器自动格式化的日期字符串就会和yy模式打架。

5.3 JSP taglib 无法解析:jstl.jar 和 standard.jar 缺失

  • 现象:部署到 Tomcat 后,访问index.jsp报错:org.apache.jasper.JasperException: The absolute uri: http://java.sun.com/jsp/jstl/core cannot be resolved。
  • 原因:JSP 页面<%@ taglib %>声明要使用 JSTL,但WEB-INF/lib下没有 JSTL 实现包。Eclipse 的 Dynamic Web Project 不会自动引入这两个 jar。
  • 解决:下载jstl-1.2.jar(新版)或jstl.jar+standard.jar(旧版)放进WEB-INF/lib,刷新项目重启 Tomcat。如果你用的 Servlet 容器是 Tomcat 9 以上,建议直接上jakarta.servlet.jsp.jstl相关的 3.x 包,注意包名有变化。检查方式:在 Eclipse 的 Deployment Assembly 里确认 jar 已被发布。

5.4 中文乱码:response 设置编码的时机太晚

  • 现象:页面提交中文姓名后,数据库里存的是???,或者返回的提示信息乱码。
  • 原因:request.getParameter()拿到的是乱码,说明请求体的编码没被正确解码;response.setContentType设置在getWriter()之后,输出的响应也会乱码。
  • 解决:在 Servlet 的方法最前面、任何参数读取之前写上这两行:
request.setCharacterEncoding("utf-8"); response.setContentType("text/html;charset=utf-8");

同时保证 MySQL 库表字符集是utf8或utf8mb4,连接 URL 里有characterEncoding=utf8,三处一致才能彻底解决中文问题。我用 Navicat 操作时会额外检查表字段的字符集,因为建表语句里如果漏了DEFAULT CHARACTER SET utf8,表会继承数据库默认字符集,而 MySQL 8 的默认字符集是utf8mb4,两者混用偶尔会出怪问题。

5.5 驱动类找不到:mysql-connector jar 没进 WEB-INF/lib

  • 现象:启动正常,一点「新增」按钮就抛java.lang.ClassNotFoundException: com.mysql.jdbc.Driver。
  • 原因:jar 只在 Build Path 里被引用,没有被发布到WEB-INF/lib。Tomcat 运行时不认 Eclipse 的构建路径配置,只认WEB-INF/lib下的物理文件。
  • 解决:把mysql-connector-java的 jar 文件直接复制到WEB-INF/lib目录,刷新项目后重新发布。还有一个隐藏点:如果你用的驱动 jar 是 8.x 版本,com.mysql.jdbc.Driver这个类名虽然还在,但属于兼容保留,官方推荐的是com.mysql.cj.jdbc.Driver。原项目用的是旧类名,能跑但会打警告日志,顺手改成新类名更干净。

6. 案例跑通之后:验证清单与三条进阶改造思路

项目部署到 Tomcat、数据库导入、jar 放齐之后,第一件事不是急着写新功能,而是把增删改查四个动作完整走一遍,并且有意识地制造故障来验证你的理解是否正确。我的验证清单是这样的:

  1. 打开首页http://localhost:8080/项目名/ContactServlet?do=index,看列表是否有数据,没有数据先走「新增」。
  2. 点「新增小伙伴」,填一条完整记录提交,确认页面alert提示新增成功并跳回首列表页。
  3. 回到列表页确认新记录出现在表格里,刷新浏览器再来一次,验证数据确实落库。
  4. 点「修改」,看edit.jsp是否把原数据回显在表单里,改一个字段提交,确认列表里对应字段变化。
  5. 点「删除」,确认记录消失,同时到 Navicat 里执行select * from contact验证数据库状态。
  6. 关闭 Tomcat,重启后再访问首页,确认数据还在——这一步是验证数据持久化。

然后就是进阶改造了。我建议按三条路径选一条走,难度递增:

第一,把BaseDao换成连接池版。Druid 或 HikariCP 都行,加一个druid.properties配置文件,把getConnection改成从连接池拿。改造完成后,你再对比原版每次新建连接的方式,会非常直观地理解「连接池为什么快」。注意DruidDataSource初始化时要设validationQuery为select 1,并且设置合理的空闲连接检测间隔,否则连接池可能拿不到连接时报错。

第二,把do参数分发的 Servlet 改造成 RESTful 风格。原项目用?do=add、?do=del区分动作,你可以改成POST /contact新增、PUT /contact修改、DELETE /contact/{id}删除,Servlet 3.1 开始支持@WebServlet配多个路径。改造过程中你会发现前端表单和链接都要跟着改,这一步能帮你理解 HTTP 方法语义。

第三,把 JDBC 换成 MyBatis 重写一遍。这个跨度最大,但你已经有原版做对照了——对比ContactService里的 SQL 和 MyBatis Mapper XML 里的 SQL,你会发现 SQL 几乎不用改,变的是参数绑定和结果映射方式。面试时你就可以说:「我用原生 JDBC 写过完整的增删改查,理解参数化查询和连接管理,后来用 MyBatis 重构过,知道框架解决了什么问题。」

这三条路径不需要都走,挑一条最贴近你当前目标的。从那以后我每次带新人做这个练习,都强制要求他们先把BaseDao里getConnection和closeAll的调用顺序默写一遍,再让他们把新增和修改的 SQL 占位符数量数三遍,确认无误才放行做页面。这套土办法看起来笨,但确实帮不少人避开了参数顺序错乱和资源泄漏的坑。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询