JDBC03实战:从零实现控制台用户管理CRUD系统 JDBC 这个名字对很多学 Java 的人来说是数据库编程的入门石也是绕不过去的一道坎。尤其是像“JDBC03”这种编号式的课程作业做完之后才算真正摸到 Java 连接数据库的门。这个标题对应的是某课程数据库编程的第三次作业核心任务是写一个基于 JDBC 的控制台用户管理系统实现用户信息的增删改查。项目本身不复杂但它几乎覆盖了 JDBC 最核心的环节加载驱动、建立连接、执行 SQL、处理结果集、释放资源。你把它啃透了后面再接触连接池、MyBatis、Spring Data 这些东西会发现底子就是在这里打下的。这篇文章我打算从需求拆解、关键原理、完整实操、问题排查四个维度把这个项目掰开揉碎讲清楚。不管你是刚学完 Java 语法还没碰过数据库的新手还是写了好几年业务代码但 JDBC 细节已经模糊的老手这篇文章都会有一些值得你停留的内容。我尽量用大白话解释原理同时给出可以直接抄作业的代码和步骤。1. 内容整体设计与思路拆解1.1 JDBC 到底解决了什么问题很多人第一次接触 JDBC 的时候容易被那套 Class.forName、DriverManager、Connection、Statement 的流程搞得一头雾水。其实 JDBC 的本质非常简单它是 Java 定义的一套访问数据库的标准接口。你可以把它理解成一个万能插座而各种数据库的驱动比如 MySQL 的驱动包就是对应的插头。你只需要面向 JDBC 接口写代码背后具体怎么跟 MySQL 或者 Oracle 通信驱动自己去处理你的 Java 代码不需要关心。回到 JDBC03 这个项目。第三次作业的要求一般是基于前面学过的 Java 基础加上一个 MySQL 数据库实现一个完整的 CRUD增删改查功能。它之所以叫“03”说明前面大概率还有 JDBC01 和 JDBC02可能是讲连接和查询也可能是讲配置文件。到了 03 这个阶段通常要求你把增删改查做全并且开始考虑代码的复用性和安全性比如用 PreparedStatement 替代 Statement、用工具类封装连接逻辑、用 DAO 模式分层。这些要求背后的意图非常明确让你从“能跑就行”过渡到“写得像个正经项目”。1.2 项目需求拆解JDBC03 到底要做什么以我见过的类似课程设计来看JDBC03 的典型需求包括以下几条连接本机 MySQL 数据库库名一般叫db_jdbc或者user_db表结构通常是用户表字段有 id、姓名、年龄、邮箱之类。实现用户新增通过控制台输入用户信息插入数据库。实现用户查询支持按 id 精确查询、按姓名模糊查询、查询全部列表。实现用户修改根据 id 更新姓名、年龄、邮箱等信息。实现用户删除根据 id 删除记录。代码风格要求不允许把数据库连接代码散落在每一个方法里需要抽取工具类不允许直接用字符串拼接 SQL必须用 PreparedStatement 防止注入。这几条需求拆开看难度不大但把它们组合起来就逼着你必须想清楚代码结构。比如连接和释放资源的部分每个方法都要用那就应该写一个DBUtil工具类。比如每个实体对应一张表那就应该建一个User类。比如数据操作逻辑和界面输入输出要分开那 DAO 层和 Main 层的职责划分就成了评分点。这个设计思路本身就是 JDBC03 想要训练你的核心能力。1.3 方案选型背后的考量可能有人会问为什么作业不直接用 MyBatis为什么不直接用 Spring Boot原因很简单框架封装了太多细节你根本不知道底层在做什么。JDBC 是这一切的根基连 JDBC 都没搞明白就去用框架遇到问题你会完全无从下手。另外一个选型点是数据库这类作业绝大多数用 MySQL因为它开源、轻量、文档多最关键的是网上的资料一抓一大把出问题了容易排查。如果你是自己在练习我建议也别整花活就用 MySQL 5.7 或 8.0别用 Oracle否则驱动和语法差异会白白消耗你的精力。2. 核心细节解析与实操要点2.1 驱动加载为什么不能省略第一步永远是加载驱动。很多人不理解这一行Class.forName(com.mysql.cj.jdbc.Driver)到底干了什么甚至有同学觉得删掉也能跑就不写了。实际上这行代码做的事情是把驱动类加载到 JVM 中触发驱动类的静态代码块在静态代码块里创建驱动实例并注册到DriverManager。MySQL 8.x 的驱动类名是com.mysql.cj.jdbc.DriverMySQL 5.x 则是com.mysql.jdbc.Driver这个区别在排查报错时非常关键。如果你用的是较新版本的 JDBC 驱动比如 8.0.11 以上其实不写Class.forName也可能能跑因为驱动包里的META-INF/services/java.sql.Driver文件会自动加载。但我建议你还是写上原因有两点一是让代码的意图更明显读代码的人一眼就知道这里加载了 MySQL 驱动二是兼容性考虑老版本驱动还是需要手动注册的万一项目换了环境少这一行就是报错。2.2 连接参数里的时区和编码问题连接数据库的 URL 是新手最容易踩坑的地方。一个典型的 JDBC URL 长这样jdbc:mysql://localhost:3306/db_jdbc?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8这里面的每一个参数都是有讲究的。useSSLfalse表示不使用 SSL 加密连接本地开发环境没有必要开而且开了在某些 MySQL 版本下会报一大堆警告。serverTimezoneAsia/Shanghai指定服务器时区这个必须加因为 MySQL 8.x 默认使用 UTC 时区如果你不指定连接时会直接报错错误信息大概是The server time zone value Öйú±ê׼ʱ¼ä is unrecognized很多人看到这个中文乱码就懵了其实只是时区问题。characterEncodingutf8指定字符编码如果不加插入中文数据到数据库后很可能是乱码。这三个参数在本地开发时建议直接加上不用纠结。2.3 PreparedStatement 不是可选优化JDBC03 到了第三个作业老师大概率会强制要求使用PreparedStatement而不是Statement。这两者的区别我用一句话概括Statement是直接拼接 SQL 字符串发出去PreparedStatement是先发送 SQL 模板让数据库预编译再传入参数。你可能会觉得用Statement写起来更简单比如Statement stmt conn.createStatement(); String sql SELECT * FROM user WHERE name name ; ResultSet rs stmt.executeQuery(sql);这种方式确实代码量少但它有两个致命弱点。第一是性能问题每次执行都要重新编译 SQL如果是循环插入一万条数据性能差距会非常明显。第二是安全问题如果用户在输入框里输入的姓名是 OR 11拼接出来的 SQL 就变成了SELECT * FROM user WHERE name OR 11这就把整张表的数据全部查出来了这就是臭名昭著的 SQL 注入。而PreparedStatement会把用户输入的内容当作纯参数处理单引号会被转义从根本上杜绝了注入问题。所以 JDBC03 之后的所有项目我都建议你形成肌肉记忆SQL 里有变量一律用PreparedStatement。3. 实操过程与核心环节实现3.1 数据库建表与连接准备动手写代码之前先把环境准备好。你需要在本机装好 MySQL然后建库建表。我用 Navicat 或者命令行都可以以下是我推荐的表结构字段比较简洁但能覆盖所有操作类型CREATE DATABASE IF NOT EXISTS db_jdbc DEFAULT CHARACTER SET utf8mb4; USE db_jdbc; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, age INT, email VARCHAR(100) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里我特意用了utf8mb4而不是utf8原因是utf8mb4是真正的四字节 UTF-8 编码能支持表情符号等特殊字符而且它是 MySQL 8.x 的默认推荐字符集。如果你用utf8在某些场景下插入生僻字或 emoji 会报错。建好表之后插入几条测试数据方便后面验证查询功能。然后下载 MySQL 的 JDBC 驱动 jar 包mysql-connector-java我用的是 8.0.33 版本下载后放到项目的lib目录下并在 IDE 中把它添加到依赖中。如果你用的是 Maven 项目直接在pom.xml里加上坐标即可这一步就不再赘述了。3.2 工具类 DBUtil 的完整写法工具类是所有 JDBC 项目的地基。我的建议是写一个DBUtil类里面至少包含两个方法一个返回Connection连接对象一个关闭所需资源。这里有一个细节很多人做不好释放资源的时候rs、stmt、conn这三个对象的关闭顺序必须是结果集先关、语句后关、连接最后关而且每个都要单独判断不为空再 close。直接看代码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/db_jdbc?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; private static final String USER root; private static final String PASSWORD 123456; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { e.printStackTrace(); } } 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 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(); } } } }注意一个细节getConnection()方法声明了throws SQLException而静态代码块里的Class.forName只能捕获处理。原因是检查性异常和非检查性异常的处理策略不同连接失败属于可恢复的运行时异常让调用方决定怎么处理更合理。这个设计思路值得你体会。3.3 实体类 User 的定义JDBC 操作的是关系型数据库的表但 Java 代码是面向对象的所以需要一个实体类来“映射”表中的一行数据。字段对应列名类型对应 SQL 类型。这其实就是 ORM对象关系映射的思想雏形只不过 JDBC 需要你手动完成映射MyBatis 和 Hibernate 帮你自动做。User 类的写法很简单public class User { private Integer id; private String name; private Integer age; private String email; public User() { } public User(Integer id, String name, Integer age, String email) { this.id id; this.name name; this.age age; this.email email; } // 每个字段的 getter 和 setter 方法 // 这里省略IDE 可以自动生成 }为什么要写无参构造器因为很多框架和工具类在反射创建对象时默认调用无参构造器。虽然 JDBC 03 阶段你用不到反射但养成交叉习惯没坏处。另外建议实体类实现java.io.Serializable接口这个阶段可有可无但在后续学习分布式项目时是标准操作现在加上成本为零以后少踩坑。3.4 DAO 层的接口设计与实现DAOData Access Object层的意义就是把数据库操作和业务逻辑分隔开。我习惯先定义接口再写实现类。接口的意义在于约束实现类必须提供哪些能力以后你学 Spring 的依赖注入会深刻体会到面向接口编程的价值。JDBC03 的 DAO 接口定义如下public interface UserDao { int add(User user); int delete(Integer id); int update(User user); User findById(Integer id); ListUser findByName(String name); ListUser findAll(); }接口定义好了实现类UserDaoImpl就是整个项目代码量最大、最核心的部分。先说新增操作。新增用户时SQL 语句是INSERT INTO user(name, age, email) VALUES(?, ?, ?)注意 id 是自增主键不需要出现在 SQL 中。完整的实现代码如下public class UserDaoImpl implements UserDao { Override public int add(User user) { String sql INSERT INTO user(name, age, email) VALUES(?, ?, ?); try (Connection conn DBUtil.getConnection(); PreparedStatement pstmt conn.prepareStatement(sql)) { pstmt.setString(1, user.getName()); pstmt.setInt(2, user.getAge()); pstmt.setString(3, user.getEmail()); return pstmt.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } } }这里我用了 try-with-resources 语法。这个语法是 Java 7 引入的它允许你在 try 后面的括号里声明资源对象代码块执行完毕后 JVM 会自动调用 close 方法关闭资源。用这个语法DBUtil 里的 close 方法在 DAO 层就不需要手动调了代码会清爽不少。不过我在前面还是写了传统的 close 方法因为这是 JDBC03 的考察点而且以后看老项目代码时经常会看到这种传统写法你得看得懂。来看看PreparedStatement的参数设置。setString和setInt的第一个参数是问号占位符的位置从 1 开始计数跟 SQL 里?出现的顺序一一对应。这里有一个常见的坑如果User对象的 age 是 Integer 类型而且值为 null直接调用setInt会报错。因为setInt接受的是基本类型 int不能接 null。这种情况下应该用setObject或者根据条件判断。删除操作相对简单按主键删除即可。查询操作复杂一些因为需要把ResultSet结果集转换成 Java 对象。关键代码如下Override public User findById(Integer id) { String sql SELECT id, name, age, email FROM user WHERE id ?; try (Connection conn DBUtil.getConnection(); PreparedStatement pstmt conn.prepareStatement(sql)) { pstmt.setInt(1, id); try (ResultSet rs pstmt.executeQuery()) { if (rs.next()) { User user new User(); user.setId(rs.getInt(id)); user.setName(rs.getString(name)); user.setAge(rs.getInt(age)); user.setEmail(rs.getString(email)); return user; } } } catch (SQLException e) { e.printStackTrace(); } return null; }这里有个关键点rs.next()的作用是让结果集的游标向后移动一行并返回是否有数据。如果查询结果只有一行你需要先调用一次rs.next()才能读到数据这是新手最容易犯的错误之一——拿到ResultSet就直接rs.getXxx()结果抛出一个Before start of result set的异常。这个异常翻译过来就是“你还没把游标移到第一行就想取数据”牢记这个逻辑至少能解决一半的 JDBC 报错。模糊查询和查询全部列表的核心逻辑相同区别只在于 SQL 语句。模糊查询的 SQL 是SELECT ... WHERE name LIKE ?传参的时候要手动拼接上通配符pstmt.setString(1, % name %);注意通配符是加在参数里的不是加在 SQL 里的。你要是写成LIKE %?%后面的问号就变成了一个字符串字面量永远匹配不到任何数据因为数据库会把?当作普通字符而不是占位符。3.5 控制台界面与主程序封装DAO 层写完最后一步是写控制台界面。这一步在 JDBC03 里通常是被忽略的部分但它其实是完整项目体验的关键。用Scanner读取用户输入再用switch分支处理菜单选项形成了完整的人机交互闭环。主程序逻辑框架如下public class Main { private static UserDao userDao new UserDaoImpl(); public static void main(String[] args) { Scanner sc new Scanner(System.in); while (true) { System.out.println( 用户管理系统 ); System.out.println(1. 新增用户); System.out.println(2. 查看全部用户); System.out.println(3. 根据ID查询用户); System.out.println(4. 根据姓名模糊查询); System.out.println(5. 修改用户); System.out.println(6. 删除用户); System.out.println(0. 退出系统); System.out.print(请选择操作: ); int choice sc.nextInt(); sc.nextLine(); switch (choice) { case 1: addUser(sc); break; case 2: listAllUsers(); break; // 后续分支类似 case 0: System.out.println(感谢使用再见); return; default: System.out.println(输入有误请重新选择); } } } }我在这里特意加了sc.nextLine()来消费掉nextInt()之后残留的换行符。这个细节不处理的话你会在“输入姓名”的时候发现程序直接跳过了输入什么都不等你键入就直接打印下一行提示了。这个坑坑过无数人我当年一困惑了很久后来才明白是 Scanner 的缓冲机制导致的。代码里每一次nextInt之后缓冲区里会留下一个回车符号如果不调用nextLine把这个回车消耗掉下一次nextLine读到的是空字符串而不是你期望的用户输入。新增用户的控制台代码如下private static void addUser(Scanner sc) { System.out.print(请输入姓名: ); String name sc.nextLine(); System.out.print(请输入年龄: ); int age sc.nextInt(); sc.nextLine(); System.out.print(请输入邮箱: ); String email sc.nextLine(); User user new User(null, name, age, email); int rows userDao.add(user); if (rows 0) { System.out.println(新增成功); } else { System.out.println(新增失败); } }这里我特意把id传为null因为数据库自增主键会自动生成我们不需要在代码里手动指定 id。另外添加完用户之后立刻回显新增记录也是一个很好的习惯能让用户直观地确认数据落库成功。4. 常见问题与排查技巧实录4.1 驱动加载失败的经典报错JDBC 入门阶段最常见的报错是ClassNotFoundException: com.mysql.cj.jdbc.Driver。这个报错的意思很明确JVM 根本找不到驱动类。排查路径有三条。第一确认 jar 包是否真的放到了 classpath 里这个在 IDE 里检查项目的 Libraries 列表。第二确认 jar 包的版本如果 jar 包是 5.x 版本你却写了 8.x 的类名那肯定找不到反过来的情况也一样。第三如果用的是 Maven 项目检查pom.xml里依赖的 groupId 和 artifactId 是否写对。我见过一个同学把mysql-connector-java的版本号从 8.0.33 改成了 8.0.3结果一直报 Java 版本不兼容的错误。版本号每一个数字都有意义不要随便乱改。连接失败时报Communications link failure也经常出现这通常不是代码问题而是 MySQL 服务没启动或者端口号不对本地开发时检查一下3306端口是否被占用或者 MySQL 服务是否正常启动。4.2 中文乱码的三种可能插入中文后数据显示乱码这个问题的排查顺序应该是先检查数据库字符集再检查连接 URL最后检查控制台编码。数据库层面执行SHOW CREATE TABLE user确认表的字符集是utf8mb4。如果建表时没指定可以用ALTER TABLE user CONVERT TO CHARACTER SET utf8mb4修改。连接 URL 层面确认characterEncoding参数是utf8而非gbk。控制台层面如果是 Windows 系统IDE 的控制台默认可能用 GBK 编码读取字节流导致打印出来的中文是乱码但这只是显示问题不影响数据库中存储的正确数据。你可以用 Navicat 查看表里的实际值如果数据库里是正常中文而控制台显示乱码那就是 IDE 编码设置的问题改一下输出编码即可。4.3 SQL 注入实验与防御验证说一个我实测过的经典案例。如果代码里用的是字符串拼接的Statement在姓名输入框里输入 OR 11 --注意最后有一个空格拼接出来的 SQL 会变成SELECT * FROM user WHERE name OR 11 -- AND ...--是 MySQL 的注释符它会把后面的所有内容注释掉OR 11则让条件永远为真于是整张表的数据全部被查出来。这个实验用在你自己的测试库上验证一下比看十篇理论文章都有冲击力。然后你换成PreparedStatement再输入同样的内容你会发现它被当作了普通字符串去匹配查不出任何数据因为引号被转义成了普通字符。转个弯说这就是为什么我说PreparedStatement不是优化而是必须安全无小事尤其是涉及数据库的代码。4.4 忘记关闭连接导致连接耗尽还有一种问题不容易当场暴露但运行一段时间后会出现Too many connections报错。原因是之前没关连接或者关闭顺序错了。MySQL 的连接数是有限制的默认max_connections是 151。如果你每次操作都新建连接而不关闭连接数很快就被占满后续所有程序都连不上数据库。解决方案就是写一个正确的 close 方法并且确保它一定被执行。这也是我推荐 try-with-resources 的原因它会保证资源释放。但如果你要兼容老代码风格用finally块手动 close 也行关键是finally块里不能写return这句话你细品。5. 工程化思维从 JDBC03 到真实项目5.1 为什么现在还要学 JDBC这就回到了一个非常本质的问题既然 MyBatis 和 JPA 已经把数据库操作封装得极其简单为什么 JDBC 依然是必修课我的看法是JDBC 是一种底层能力的体现。如果你只知道 MyBatis 的Select注解而不知道底层连接如何管理、事务如何提交、异常如何抛出那你写出来的代码只会越来越脆弱。框架总是有性能瓶颈和特殊场景的遇到高级需求时必须通过原生 SQL 或 JDBC 去补充这时候你如果连 JDBC 都不懂那就真的只能干瞪眼了。另外JDBC03 这个项目如果你真的自己一行代码一个报错地走下来了你会建立对 Java Web 项目初始化的整体认知数据库怎么建、JDBC URL 怎么配、DAO 层怎么分层、控制层怎么输入输出这一整套流程和工程思维才是这个项目真正想教的。5.2 项目后续还能如何扩展完成了 JDBC03 之后如果你想把它改造成一个更像真实项目的系统这里有几个拓展方向可以参考。第一个方向是加一个Service层把事务控制放到 Service 层因为一个业务操作可能涉及多个 DAO 方法比如转账操作需要同时扣减转出账户和增加转入账户任何一个失败都要整体回滚。第二个方向是用数据库连接池替换DriverManager比如 DBCP 或 C3P0连接池的核心思想是预先创建一批连接放着用的时候拿用完归还而不是每次都新建。这能显著提升性能也是实际项目中的标准做法。第三个方向是改用 Java 控制台自带的交互方式为图形界面你可以用 Swing 或者 JavaFX 写个窗体把控制台的输入输出搬到界面上让系统的可用性上一个台阶。哪个方向都有价值但我个人的建议是先把 JDBC03 本身吃透再做扩展不要贪多嚼不烂。5.3 我踩过的那些坑希望你别再踩前面零零散散讲了一些坑这里我再集中梳理一下个人体会最深的几点。第一JDBC URL 中的参数一个都不要省特别是时区参数这个参数报错的信息特别具有迷惑性全是中文乱码你会误以为是字符编码问题在错误的方向上折腾大半天。第二ResultSet游标是单向前进且不可逆的不要指望先判断有没有数据再重新读取如果你需要多次遍历把数据先放到List里。第三写代码时连接和释放资源要成对出现就像开锁和锁门的逻辑一样每次getConnection都要对应一次close不要在 finally 块里写业务逻辑更不要在 finally 里调用可能抛异常的方法而不处理。第四不建议在开发阶段把密码和 URL 硬编码虽然 JDBC03 项目基本都是这么干的但你可以顺手了解一下Properties配置文件的用法把数据库连接参数放到 properties 文件里用ResourceBundle来读取这样一个举动就能让你的代码评分上一个档次。第五如果你的项目要提交到 git 仓库不要把包含密码的配置文件提交上去这是一个好习惯从现在做起成本极低。5.4 最后再分享一个小技巧如果你用PreparedStatement做批量插入千万别用循环里一次executeUpdate的做法而是用addBatch()和executeBatch()。比如你要插入一万个用户一次一次提交需要建立一万次网络往返而批量提交只需要一次往返。示例代码如下String sql INSERT INTO user(name, age, email) VALUES(?, ?, ?); try (Connection conn DBUtil.getConnection(); PreparedStatement pstmt conn.prepareStatement(sql)) { for (int i 0; i 10000; i) { pstmt.setString(1, 用户 i); pstmt.setInt(2, 20 i % 30); pstmt.setString(3, user i test.com); pstmt.addBatch(); if (i % 500 0) { pstmt.executeBatch(); } } pstmt.executeBatch(); }这里每 500 条批量执行一次是为了控制单批次的规模避免一次性提交数据量太大把数据库资源耗尽。这个技巧在 JDBC03 里用不上超大规模的数据但是理解了批量提交的工作机制以后你写数据迁移脚本、造测试数据、做性能压测时会非常管用。JDBC 的魅力就在这里——它表面上是一种落伍的繁重劳动实际上每一种设计都隐含了数据库通信的底层智慧。把这些细节一点点积累下来你后续的学习道路会越走越顺。