ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JavaEE入门实战:从零建库到JDBC连接跑通学生信息Demo

JavaEE入门实战:从零建库到JDBC连接跑通学生信息Demo

1. 从一张白纸到能跑通的库:这个Demo到底在练什么

很多刚接触JavaEE的朋友,学到JDBC那一章就开始发懵——书上的例子东一榔头西一棒子,建表语句给一半,连接代码给一半,跑起来不是报ClassNotFoundException就是Access denied。我当年也是这样,对着屏幕折腾了一下午,最后发现是驱动包压根没放进WEB-INF/lib。所以这个“小Demo——数据库建立”看着不起眼,实际上它是整个JavaEE学习路线上第一个真正意义上的“闭环”:从设计表结构、写建库脚本、配连接参数,到用一段最小可运行的代码验证“我的程序真的能跟数据库说上话”。这件事做通了,后面的DAO、连接池、事务才有落脚点;做不通,后面全是空中楼阁。

这篇文章面向的是刚上手JavaEE、手里有一个能跑起来的Tomcat或普通Java工程、但还没独立建过一个业务库的读者。我会拿一个最典型的场景来贯穿全文——一个简易的“学生信息管理”小Demo,包含学生表和班级表两张核心表。别小看这两张表,主外键、索引、字符集、自增主键这些基础概念全都能覆盖到。我会把建库建表的完整SQL、连接参数的每一项含义、驱动加载的两种写法、以及我实际踩过的坑,一条一条摊开讲。你照着做,大概率能一次跑通;跑不通,第四节的排查表基本能兜住你。

需要先说明一点:本文所有表名、库名、示例数据都是虚构的,你实际做的时候按自己的业务改就行,重点是理解每一步“为什么这么干”,而不是死记某条语句。数据库我以MySQL 8.x为主来讲,因为这是目前教学和中小项目里最常见的组合,其他数据库的差异我会在关键处点出来。

2. 建库之前的功课:表结构设计与脚本组织

2.1 先想清楚“存什么”,再动手写CREATE

我见过太多新手一上来就CREATE DATABASE,然后边写代码边ALTER TABLE加字段,最后表结构乱得像一锅粥。正确的顺序是反过来的:先在纸上(或者任何文本编辑器里)把实体和关系画清楚。拿学生信息这个Demo来说,核心实体就两个——学生和班级。一个学生属于一个班级,一个班级有多个学生,这是一对多关系。那么学生表里放一个class_id作为外键指向班级表的主键,这是最朴素也最够用的设计。

字段类型的选择有几个容易翻车的点,我逐个说。学生姓名用VARCHAR(50)足够,别用CHAR,CHAR是定长,存“张三”这种两个字的名字会补空格,取出来比较的时候容易出幺蛾子。学号这种固定格式的标识,如果确定永远是10位数字,用CHAR(10)反而更合适,定长查询效率略好,但如果你不确定长度就别逞强。年龄用TINYINT,范围-128到127,存人的年龄绰绰有余,比INT省空间。入学日期用DATE,别用VARCHAR存字符串,否则后面按日期范围查询你会哭。性别这种只有几个固定值的,用TINYINT存0/1/2或者用ENUM都行,我倾向于TINYINT加注释,因为ENUM后期加选项要改表结构,不够灵活。

主键我统一用BIGINT AUTO_INCREMENT,也就是自增长整型。为什么不用业务字段当主键?因为学号可能会变(比如录入错误要改),而主键一旦被外键引用就不能随便动。自增主键的好处是插入时不用管它,数据库自己分配,简单可靠。代价是分布式场景下会有冲突风险,但这个Demo是单库单表,完全不用考虑那一步。

2.2 建库建表脚本的完整写法与逐行注释

下面是我实际用的建库脚本,你可以直接复制到Navicat、DBeaver或者命令行的mysql客户端里执行。我习惯把库名、字符集、排序规则一次性定死,避免后面出现中文乱码。

-- 建库:字符集用utf8mb4,排序规则用通用ci CREATE DATABASE IF NOT EXISTS demo_school DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci; USE demo_school; -- 班级表:先建被引用的一方 CREATE TABLE t_class ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '班级主键', class_name VARCHAR(50) NOT NULL COMMENT '班级名称', grade_year SMALLINT NOT NULL COMMENT '入学年份', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_class_name (class_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='班级信息表'; -- 学生表:再建引用的一方 CREATE TABLE t_student ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '学生主键', student_no CHAR(10) NOT NULL COMMENT '学号,固定10位', student_name VARCHAR(50) NOT NULL COMMENT '姓名', age TINYINT UNSIGNED DEFAULT NULL COMMENT '年龄', gender TINYINT NOT NULL DEFAULT 0 COMMENT '性别:0未知 1男 2女', class_id BIGINT NOT NULL COMMENT '所属班级ID', enroll_date DATE DEFAULT NULL COMMENT '入学日期', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_student_no (student_no), KEY idx_class_id (class_id), CONSTRAINT fk_student_class FOREIGN KEY (class_id) REFERENCES t_class (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生信息表';

逐行说几个关键决策。字符集选utf8mb4而不是utf8,是因为MySQL里的utf8其实是阉割版,最多存3字节,存不了某些特殊字符和emoji,utf8mb4才是真正的完整UTF-8。排序规则utf8mb4_general_ci里的ci是case insensitive,比较时不区分大小写,对姓名、班级名这类字段够用;如果你对排序精度有要求可以换utf8mb4_0900_ai_ci(MySQL 8默认),但教学Demo用general就挺好。

存储引擎必须用InnoDB,因为只有它支持外键和事务。MySQL 5.5之后默认就是InnoDB了,但显式写出来是个好习惯,免得在别人的老环境里跑出问题。外键约束fk_student_class的作用是:往学生表插数据时,class_id必须在班级表里存在,否则报错。这能防止出现“孤儿学生”。但要注意,外键会让删除班级变得麻烦——得先删该班级下的学生,或者用ON DELETE CASCADE级联删除。教学Demo里我建议先不加级联,让你亲手体会一下约束的存在感。

idx_class_id这个普通索引是给外键字段加的。MySQL在创建外键时会自动给引用列建索引,但显式写出来意图更清晰,而且有些版本的行为不完全一致,自己加上更保险。uk_student_no唯一索引保证学号不重复,这是业务规则在数据库层的落地。

2.3 初始化数据的插入顺序有讲究

建完表插数据,顺序不能乱:先插班级,再插学生。因为学生表的class_id依赖班级表的主键。如果你先插学生,外键约束会直接把你拦下来。

INSERT INTO t_class (class_name, grade_year) VALUES ('软件一班', 2023), ('软件二班', 2023), ('网络一班', 2022); INSERT INTO t_student (student_no, student_name, age, gender, class_id, enroll_date) VALUES ('2023000001', '张小明', 20, 1, 1, '2023-09-01'), ('2023000002', '李小红', 19, 2, 1, '2023-09-01'), ('2023000003', '王大力', 21, 1, 2, '2023-09-01');

这里有个小技巧:插入班级时我没写id,让自增分配,所以软件一班的id是1,软件二班是2。学生数据里class_id写1和2就对应上了。如果你中途删过数据再插,自增ID可能不连续,这时候别硬编码class_id,先SELECT id FROM t_class WHERE class_name='软件一班'查出来再用。我见过有人删了又插,结果class_id对不上,查出来的学生全挂到错误的班级下,排查半天才发现是自增ID跳号了。

3. 让Java程序连上库:驱动、URL与连接代码

3.1 驱动包怎么放、放哪里,这一步错后面全白搭

Java连MySQL靠的是JDBC驱动,本质是一个jar包,里面实现了JDBC规范定义的接口。MySQL 8.x对应的驱动是mysql-connector-j-8.x.x.jar(老版本叫mysql-connector-java)。这个包必须出现在你程序的类路径(classpath)里,否则运行时报java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver。

普通Java工程(非Maven)的话,把jar包放到项目根目录下的lib文件夹,然后在IDE里右键jar包选“Add as Library”。如果是Web工程跑在Tomcat里,jar包要放到WEB-INF/lib目录下,而不是项目的普通lib目录——因为Tomcat有自己的类加载器体系,放错位置容器启动时找不到驱动。这个坑我踩过不止一次,明明IDE里代码不报红,一部署到Tomcat就报驱动找不到,折腾半天才发现是jar包位置的问题。

用Maven的话就省心了,在pom.xml里加依赖:

<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.3.0</version> </dependency>

版本号选和你数据库大版本匹配的即可,8.x的驱动连8.x的库没问题,连5.7的库也基本兼容,但反过来5.x的驱动连8.x的库会报认证插件错误,因为8.x默认用caching_sha2_password,老驱动不认。所以原则是:驱动版本不低于数据库大版本。

3.2 JDBC URL的每个参数都值得掰开看

连接字符串长这样:

jdbc:mysql://localhost:3306/demo_school?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true

我逐个解释,因为这里每个参数都对应一个真实的坑。jdbc:mysql://是协议前缀,固定写法。localhost:3306是主机和端口,本机默认3306,如果你改过端口就换成实际的。demo_school是库名,必须和你建库时的名字一致。

useUnicode=true&characterEncoding=utf8这一对是告诉驱动用UTF-8编码传输数据,防止中文乱码。虽然8.x驱动默认就是UTF-8,但显式写上不亏,尤其是连老库的时候。useSSL=false是关闭SSL加密连接,本地开发环境没必要开,开了反而可能因为证书问题连不上;生产环境该开还是要开,但那是后话。serverTimezone=Asia/Shanghai这个参数在8.x驱动里几乎是必填的,因为驱动需要知道数据库服务器所在的时区来正确转换时间类型,不写的话可能报The server time zone value '...' is unrecognized,或者存进去的时间差8小时。allowPublicKeyRetrieval=true是配合8.x的caching_sha2_password认证插件用的,不写在某些环境下会报公钥检索失败。

提示:URL里的&在XML配置文件(比如某些框架的配置)里要写成&amp;,在Java字符串里直接写&就行。这个细节坑过不少人,配置文件里写&会导致解析错误。

3.3 一段最小可运行的连接验证代码

别急着写DAO,先用一段最朴素的代码验证“能连上”。这段代码的作用相当于电工合闸前的验电笔。

import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.Statement; public class DbConnectDemo { private static final String URL = "jdbc:mysql://localhost:3306/demo_school" + "?useUnicode=true&characterEncoding=utf8&useSSL=false" + "&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true"; private static final String USER = "root"; private static final String PASSWORD = "你的密码"; public static void main(String[] args) { // 方式一:显式加载驱动(JDBC 4.0之后可省略,但写上更直观) try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { System.err.println("驱动没找到,检查jar包是否在classpath里"); e.printStackTrace(); return; } // try-with-resources自动关闭连接,避免资源泄漏 try (Connection conn = DriverManager.getConnection(URL, USER, PASSWORD); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery("SELECT id, student_name FROM t_student")) { System.out.println("连接成功,查询结果:"); while (rs.next()) { System.out.println(rs.getLong("id") + " - " + rs.getString("student_name")); } } catch (Exception e) { System.err.println("连接或查询失败"); e.printStackTrace(); } } }

关于Class.forName要不要写,这里多说两句。JDBC 4.0之后引入了SPI机制,驱动jar包里的META-INF/services/java.sql.Driver文件会被自动发现,理论上不写Class.forName也能连。但实际项目里我建议保留,原因有二:一是显式加载能让“驱动缺失”这个错误在启动阶段就暴露,而不是等到第一次getConnection才报;二是一些老容器或特殊类加载环境下SPI可能失效,显式加载更稳。代价几乎为零,何乐而不为。

try-with-resources这个写法必须养成习惯。Connection、Statement、ResultSet都是需要关闭的资源,传统写法要在finally里逐个判空关闭,又臭又长还容易漏。try-with-resources会在代码块结束时按声明的逆序自动关闭,干净利落。注意关闭顺序是ResultSet→Statement→Connection,声明顺序反过来正好对应。

4. 实操全流程复盘与高频问题排查

4.1 从零到跑通的完整操作序列

我把整个流程按顺序列一遍,你对照着走,哪一步卡住了就停下来查。

第一步,确认MySQL服务在跑。命令行执行mysql -u root -p能进去就说明服务正常。进不去先查服务状态,Windows在服务管理器里看MySQL服务是否启动,Linux用systemctl status mysql。

第二步,执行第2节的建库建表脚本。建议把脚本存成一个.sql文件,用source命令执行,比一行行粘贴靠谱。执行完用SHOW TABLES;确认两张表都在,用DESC t_student;看看字段类型对不对。

第三步,确认驱动jar包位置正确。普通工程看lib目录,Web工程看WEB-INF/lib。用Maven的话在IDE的依赖树里能看到就行。

第四步,把第3节的验证代码跑起来。用户名密码换成你自己的。如果打印出学生列表,恭喜,闭环打通了。

第五步,故意改错一个参数试试。比如把库名改成不存在的demo_schoolx,看报什么错;把密码改错,看报什么错。这一步是让你建立“错误信息→原因”的映射,后面真出问题时不慌。

4.2 常见报错速查表

下面这张表是我这些年攒下来的,基本覆盖了这个Demo阶段能遇到的所有典型错误。看到报错先来这张表里对号入座。

报错信息关键词大概率原因解决方向
ClassNotFoundException: com.mysql.cj.jdbc.Driver驱动jar不在classpath检查lib或WEB-INF/lib,Maven检查依赖是否下载成功
Access denied for user 'root'@'localhost'用户名或密码错核对密码;确认该用户有远程/本地访问权限
Unknown database 'xxx'库名写错或库没建SHOW DATABASES;确认库存在,核对URL里的库名
The server time zone value ... is unrecognized没配serverTimezoneURL里加serverTimezone=Asia/Shanghai
Public Key Retrieval is not allowed8.x认证插件需要公钥URL里加allowPublicKeyRetrieval=true
Cannot add or update a child row: a foreign key constraint fails插入的class_id在班级表不存在先插班级,或核对class_id值
Duplicate entry 'xxx' for key 'uk_student_no'学号重复换一个学号,或先删掉重复记录
中文显示成问号或乱码字符集不一致库、表、连接三处字符集统一为utf8mb4
Communications link failure数据库服务没起或端口不通检查服务状态和端口,防火墙放行

4.3 几个只有踩过才知道的细节

第一个,关于allowPublicKeyRetrieval=true的安全性。这个参数让客户端在需要时向服务器请求公钥,方便但不加密传输公钥,理论上存在中间人风险。本地开发无所谓,生产环境应该配合SSL使用,或者改用mysql_native_password认证插件(不推荐,8.x已标记废弃)。教学阶段先跑通,安全意识后面再补。

第二个,关于自增主键的回填。插入一条学生记录后,如果你需要立刻拿到它的自增ID(比如要往关联表插数据),用Statement.RETURN_GENERATED_KEYS配合getGeneratedKeys()。别用SELECT MAX(id)去查,并发下会拿到别人的ID。这个技巧在Demo阶段用不上,但提前知道能少走弯路。

第三个,关于连接不关的后果。我见过有同学在循环里反复getConnection却从不关闭,跑几百次之后报Too many connections。MySQL默认最大连接数是151,每个连接都占内存。try-with-resources是底线,再往上就是连接池的事了,那是下一个阶段的内容。

第四个,关于DROP DATABASE的肌肉记忆。练习时反复建库删库很正常,但养成“删之前先SELECT确认一下”的习惯。我有一次在测试环境手滑,DROP了一个名字很像的库,虽然数据能重建,但那半天白干了。现在我的习惯是删库语句先注释掉,确认无误再取消注释执行。

5. 这个Demo往后能怎么长

把数据库建起来、连接跑通,只是JavaEE这条线的起点。接下来最自然的延伸是把这段裸JDBC代码封装成DAO层——一个StudentDao类,把增删改查包成方法,让业务代码不直接碰SQL。再往后是引入连接池(比如HikariCP),解决频繁建连的性能问题;然后是事务管理,保证多条SQL要么全成功要么全回滚。每一步都建立在这个Demo打好的地基上。

我个人在实际操作中的体会是,数据库这块最忌讳“跳步”。表结构没设计好就急着写代码,后面改字段改到怀疑人生;连接参数没搞明白就上框架,框架报的错比裸JDBC还难懂。反而是像这样老老实实建两张表、写一段验证代码、把每个报错都亲手触发一遍,基础打得扎实,后面学什么都快。最后分享一个小习惯:每次新建一个Demo库,我都会在库名后面加日期后缀,比如demo_school_0315,练完不删留着,过段时间回头看,能清楚看到自己当时的设计思路和现在的差距,比任何教程都直观。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进