第10章 JDBC 与数据库访问
难度:★★★☆☆ | 前置知识:第5章 Tomcat 与 Servlet、第6章 Request 与 Response、MySQL 基础(建库建表、增删改查、事务) | 本章解决:让 Servlet 真正读写 MySQL:用 JDBC 打通 Java 与数据库之间的那条链路,再把连接、SQL、结果集收敛成可复用的工具类与 DAO
本章导读
- 回想一下前 9 章我们写过的所有数据:第 6 章登录案例里的用户名密码是
if ("zhangsan".equals(username) && "1234".equals(password))写死在 Servlet 里的;第 7 章购物车里的商品是new ArrayList<>()临时拼出来的;第 8 章品牌列表页的List<Brand>是在 Servlet 里手动add了三条假数据。这些数据一关机就没了,一改就要重新编译。 而真实系统的数据住在 MySQL 里:它是独立的一个进程,有自己的磁盘文件、自己的账号体系,Java 程序关掉了它还在。本章要解决的第一个问题就是:Servlet 怎么跟 MySQL 说上话? - 答案是 JDBC。这是一套由 Sun 公司定下的接口标准,并不限于某个具体工具,它规定:"要连数据库得先有
Connection""要执行 SQL 得用Statement""查询结果装在ResultSet里"。MySQL 厂商按这套标准写了实现类(叫驱动),我们写的代码只面向接口编程。于是同一套 Java 代码,今天连 MySQL、明天连 Oracle,只换驱动包、不换业务代码。JDBC 就是 Servlet 与数据库之间的那座桥。 - 整条链路长这样,请把这张图记在心里,第 11 章的综合案例就是沿着它跑通的:
浏览器 ──HTTP 请求──▶ Servlet(第 5~7 章的 request / response / session)
│
│ JDBC(本章):DriverManager → Connection
│ → PreparedStatement → ResultSet
▼
MySQL(数据真正住的地方:db1 库、tb_brand 表)- 本章的知识分三层,难度是递增的。第一层是"能跑":六步流程(注册驱动、获取连接、定义 SQL、获取执行对象、执行 SQL、处理结果、释放资源)写出第一个
update。第二层是"跑得好":用PreparedStatement防住 SQL 注入、用ResultSet把结果封装成 Java 对象、用Connection控制事务、用 Druid 连接池避免频繁创建连接。第三层是"跑得住":把重复了七八遍的连接代码抽成JDBCUtils工具类,再把 SQL 从测试方法里搬进BrandDao,让 Servlet 只说"我要品牌列表",不说"我要执行哪条 SQL"。 - 最后一层尤其重要,因为本章写的 DAO 会在第 11 章被直接复用。第 11 章的品牌管理综合案例里,Servlet 拿到
request.getParameter("brandName")之后,不会再自己拼 SQL、自己关连接,而是调用brandDao.add(brand);JSP 页面上的表格,数据来自brandDao.selectAll()。如果你本章的 DAO 写得规范,第 11 章就只是"加一个 Servlet、加一个 JSP"的工作量;如果本章偷懒把 SQL 全写在 Servlet 里,第 11 章会变成一场灾难。 - 有两个常见误解要先破掉。第一,"JDBC 就是 MySQL 的 API",这个说法是错的:JDBC 是标准接口,MySQL 只是提供了它的一个实现(
com.mysql.cj.jdbc.Driver)。第二,"用了PreparedStatement就一定安全",这个说法也不完全对:它防的是"参数值里的注入",如果你把表名、列名、order by的字段也用?去占位,那是占不上的,只能拼接,而拼接就有风险(10.4 节会细讲)。
【考点】本章在卷子上的形态非常固定:一道"JDBC 六步/七步流程"的简答或填空,一道"
Statement与PreparedStatement区别"的对比题,一道"SQL 注入原理与防范"的问答题,一道"连接池好处"的概念题,最后是一道tb_brand的编程题(补全 DAO 方法或找错)。流程与两套 API 的对比是必拿分,事务与连接池是拉开差距的地方。
10.1 JDBC 概述
10.1.1 为什么需要 JDBC(原文 1)
在开发中我们使用的是 Java 语言,那么势必要通过 Java 语言操作数据库中的数据,这就是接下来要学习的 JDBC。
先把"痛点"讲清楚。假设没有 JDBC,我们要用 Java 连 MySQL,会发生什么?MySQL 的客户端协议是二进制协议,握手、认证、发包、解析结果集,每一步都有自己的一套规则;换成 Oracle,这套规则从头到尾都不一样,连"包"的格式都不同。如果每个数据库厂商都自己定义一套 Java 调用方式,那么"换数据库"就等于"重写整个数据访问层"。
这在公司里是真实会发生的场景:开发阶段图省事用 MySQL,上线时公司统一采购的是 Oracle,于是就要对代码进行大批量修改,这显然并不是我们想看到的。我们要做到的是同一套 Java 代码操作不同的关系型数据库,而此时 Sun 公司就指定了一套标准接口(JDBC),JDBC 中定义了所有操作关系型数据库的规则。
这里有一个关键的类比:JDBC 是一张"插座标准",驱动是"各国插头"。墙上的插座孔位是标准(接口),中国、美国、欧洲的电器各自做适配的插头(实现类),你换一台电器不需要砸墙重装插座。接口本身无法直接使用,我们需要使用接口的实现类,而这套实现类(称之为:驱动)就由各自的数据库厂商给出。
10.1.2 JDBC 的概念与本质(原文 1.1、1.2)
JDBC(全称:Java DataBase Connectivity,Java 数据库连接)就是使用 Java 语言操作关系型数据库的一套 API。
它的本质可以拆成三句话,这三句话几乎就是简答题的标准答案(原文 1.2):
- 官方(Sun 公司)定义的一套操作所有关系型数据库的规则,即接口;
- 各个数据库厂商去实现这套接口,提供数据库驱动 jar 包;
- 我们可以使用这套接口(JDBC)编程,真正执行的代码是驱动 jar 包中的实现类。
第三句话是理解 JDBC 的钥匙。你写的 conn.createStatement(),编译时看到的是 java.sql.Connection 这个接口,运行时跑的是 MySQL 驱动里 ConnectionImpl 的 createStatement()。这就是"面向接口编程"最典型的一次落地:你的代码依赖抽象,具体实现由 jar 包在运行时注入。
10.1.3 JDBC 的好处(原文 1.3)
- 各数据库厂商使用相同的接口,Java 代码不需要针对不同数据库分别开发;
- 可随时替换底层数据库,访问数据库的 Java 代码基本不变。
以后编写操作数据库的代码只需要面向 JDBC(接口),操作哪个关系型数据库就需要导入该数据库的驱动包。如需要操作 MySQL 数据库,就需要在项目中导入 MySQL 数据库的驱动包。
【对比】"面向 JDBC 编程"和"面向 MySQL 编程"的区别,考试常拿来出判断题。
维度 面向 JDBC 接口编程(正确) 面向 MySQL 实现编程(错误) 声明的类型 Connection conn、PreparedStatement pstmtcom.mysql.cj.jdbc.ConnectionImpl导入的包 java.sql.*、javax.sql.DataSourcecom.mysql.cj.jdbc.*换数据库时 只换驱动 jar 与 url 整个数据访问层重写 编译期依赖 只依赖 JDK 自带的 java.sql强依赖厂商 jar,jar 缺失就编译不过 结论是:变量类型永远写接口,只有
Class.forName里的那个字符串是厂商名。
10.1.4 本章要用的依赖(补充 pom)
原文用 IDEA 的"Add as Library"把 jar 包挂进项目,今天更常见的做法是用 Maven 管依赖。两种方式等价,本质都是把驱动 jar 放进 classpath。若用 Maven,pom.xml 里至少要有这三样:
<dependencies>
<!-- MySQL 驱动:8.x 版本,驱动类为 com.mysql.cj.jdbc.Driver -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.30</version>
</dependency>
<!-- Druid 连接池 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.1.12</version>
</dependency>
<!-- 单元测试,@Test 要靠它 -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
</dependencies>如果你跟原文一样用"普通 Java 项目 + lib 目录",那么把 mysql-connector-java-8.0.30.jar、druid-1.1.12.jar、junit-4.13.2.jar 拷进模块下的 lib 目录,全选后右键"Add as Library"即可。
【易错点】jar 包放进了
lib目录不等于加进了 classpath。只把文件拖进目录、没有执行"Add as Library",现象是编译期报java: 程序包com.mysql.cj.jdbc不存在,或者运行时抛java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver。判断方法:IDEA 里lib目录下的 jar 图标上有一个小箭头(表示已作为库),外部库(External Libraries)里能看到它。
10.2 JDBC 快速入门
10.2.1 先看流程,再写代码(原文 2)
先来看看通过 Java 操作数据库的流程,一共四步:
- 第一步:编写 Java 代码;
- 第二步:Java 代码将 SQL 发送到 MySQL 服务端;
- 第三步:MySQL 服务端接收到 SQL 语句并执行该 SQL 语句;
- 第四步:将 SQL 语句执行的结果返回给 Java 代码。
这四步里,第二步和第四步是 JDBC 干的活,第三步是 MySQL 干的活,第一步是我们干的活。把这个流程和 10.1 节的链路图对上:Java 代码(Servlet 或测试类)→ JDBC 驱动 → MySQL 服务端 → 结果集原路返回。
10.2.2 编写代码的七个步骤(原文 2.1)
原文把入门代码拆成七步,这七步是整章的主干,后面所有代码都是它的变体:
- 创建工程,导入驱动 jar 包;
- 注册驱动:
Class.forName("com.mysql.jdbc.Driver"); - 获取连接:Java 代码需要发送 SQL 给 MySQL 服务端,就需要先建立连接。
Connection conn = DriverManager.getConnection(url, username, password);- 定义 SQL 语句:
String sql = "update...";- 获取执行 SQL 对象:执行 SQL 语句需要 SQL 执行对象,而这个执行对象就是
Statement对象。
Statement stmt = conn.createStatement();- 执行 SQL:
stmt.executeUpdate(sql);- 处理返回结果,然后释放资源。
【考点】这七步的顺口溜:注册、连接、定 SQL、取对象、执行、处理、关闭。注意"定义 SQL"在"获取执行对象"之前:因为
PreparedStatement的获取需要把 SQL 传进去(conn.prepareStatement(sql)),顺序反了编译就过不去。原文入门代码里第 3 步确实排在createStatement()之前,这正是为 10.4 节埋伏笔。
10.2.3 具体操作:建工程与导驱动包(原文 2.2)
- 创建新的空的项目,定义项目的名称,并指定位置;
- 对项目进行设置,JDK 版本、编译版本;
- 创建模块,指定模块的名称及位置;
- 导入驱动包:将 MySQL 的驱动包放在模块下的
lib目录(随意命名)下,并将该 jar 包添加为库文件。
在添加为库文件的时候,有如下三个选项:
- Global Library:全局有效(所有项目都能用);
- Project Library:项目有效;
- Module Library:模块有效。
【补充】这三个选项的实际区别,很多同学按默认一路"OK"过去,进了公司才发现这里最容易出错。Global Library 把路径写进 IDEA 的全局配置,换一台机器、或者把项目发给同学,jar 路径就失效了;Project Library 记在项目的
.idea配置里,跟着项目走,但一个项目有多个模块时所有模块共享同一份;Module Library 只对当前模块生效,最不容易互相污染。课程作业建议选 Module Library,因为一个项目里常常有多个模块各自依赖不同版本的驱动。另外,Maven 项目根本不需要手动选这个:
pom.xml里写dependency,Maven 自动下载到本地仓库并加进 classpath,scope决定它是编译期、测试期还是运行期可见。
10.2.4 入门案例完整代码(原文 2.2)
在 src 下创建类,编写代码如下。这段代码的作用是把 db1 库 account 表里 id 为 1 的记录的 money 改成 2000,并把受影响的行数打印出来:
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
/**
* JDBC 快速入门
*/
public class JDBCDemo {
public static void main(String[] args) throws Exception {
//1. 注册驱动
//Class.forName("com.mysql.jdbc.Driver");
//2. 获取连接
String url = "jdbc:mysql://127.0.0.1:3306/db1";
String username = "root";
String password = "1234";
Connection conn = DriverManager.getConnection(url, username, password);
//3. 定义sql
String sql = "update account set money = 2000 where id = 1";
//4. 获取执行sql的对象 Statement
Statement stmt = conn.createStatement();
//5. 执行sql
int count = stmt.executeUpdate(sql);//受影响的行数
//6. 处理结果
System.out.println(count);
//7. 释放资源
stmt.close();
conn.close();
}
}跑通这段代码,需要三个前置条件同时成立,缺一个就报错,而且报的错完全不一样,正好可以当排错清单用:
| 缺什么 | 报错现象 | 怎么补 |
|---|---|---|
| 驱动 jar 没进 classpath | java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver | 把 jar 加为库文件,或补 pom 依赖 |
| MySQL 服务没启动 | Communications link failure / Connection refused: connect | 启动 MySQL 服务(net start mysql) |
| 库或表不存在 | Unknown database 'db1' / Table 'db1.account' doesn't exist | 先建库建表,表里要有 id=1 这行数据 |
| 账号密码错 | Access denied for user 'root'@'localhost' | 改 password,确认 MySQL 的认证方式 |
【易错点】入门代码里
Class.forName("com.mysql.jdbc.Driver")被注释掉了,而count打印出来是 1,程序照跑。很多同学由此得出"注册驱动这一步可以删"的结论。结论对,理由错。10.3.1 节会讲清楚:能删是因为 MySQL 5 之后的驱动包自带 SPI 自动注册,不是因为"这行代码没用"。而且如果你用的是 MySQL 8 的驱动包,com.mysql.jdbc.Driver这个类根本不存在,写上去反而抛ClassNotFoundException,正确写法是com.mysql.cj.jdbc.Driver。
10.2.5 连接 URL 的写法与两个常见错误
入门代码里 url 写的是完整形式 jdbc:mysql://127.0.0.1:3306/db1。它的通用语法是:
jdbc:mysql://ip地址(域名):端口号/数据库名称?参数键值对1&参数键值对2...两个细节(原文 3.1):
- 如果连接的是本机 MySQL 服务器,并且 MySQL 服务默认端口是 3306,则 url 可以简写为
jdbc:mysql:///数据库名称?参数键值对,注意是三个斜杠,前两个是协议分隔符,第三个后面直接跟库名,主机与端口被省略; - 配置
useSSL=false参数,可以禁用安全连接方式,解决启动时的警告提示。
String url = "jdbc:mysql:///db1?useSSL=false";连接时有两个参数,几乎人人都会在这里出错:
【易错点】
useSSL与serverTimezone这两个参数,是 JDBC 连接报错的两大元凶。第一个参数:
useSSL。MySQL 5.7/8.0 默认要求 SSL 加密连接,而驱动在没配证书时会打印一大段红色警告:WARN: Establishing SSL connection without server's identity verification is not recommended...。它只是警告,程序照跑,但看着吓人。加上useSSL=false就不打印了。注意它是"关掉加密",生产环境不应该关,学习环境图省事关掉没问题。第二个参数:
serverTimezone。MySQL 8 的驱动(com.mysql.cj.jdbc.Driver)要求明确时区,不配就抛java.sql.SQLException: The server time zone value '�й���ʱ��' is unrecognized or represents more than one time zone(那段乱码是中文"中国标准时间"被按错误编码解出来的)。解决办法是在 url 后面加serverTimezone=Asia/Shanghai或serverTimezone=UTC。两个参数用
&连接,写成一条完整的 url:String url = "jdbc:mysql:///db1?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8";还有一个容易被忽略的错误:
&在 XML 里是特殊字符。如果你把这条 url 写进druid.properties没问题,但写进web.xml、pom.xml或 MyBatis 的 XML 映射文件时,必须写成&,否则 XML 解析器会报 "The reference to entity ... must end with the ';' delimiter"。
10.3 JDBC 核心 API
入门案例里出现了三个名字:DriverManager、Connection、Statement,加上查询时才会出现的 ResultSet,这四个对象就是 JDBC 的全部家当。本节按"谁来创建谁"的顺序讲:DriverManager 造 Connection,Connection 造 Statement,Statement 造 ResultSet。
DriverManager ──getConnection()──▶ Connection ──createStatement()──────▶ Statement ──executeQuery()──▶ ResultSet
└─prepareStatement(sql)──▶ PreparedStatement10.3.1 DriverManager:注册驱动
DriverManager(驱动管理类)有两个作用:注册驱动、获取数据库连接。
先看第一个。DriverManager 里确实有一个 registerDriver 方法用于注册驱动,但是我们之前做的入门案例没有这样写,它用的是 Class.forName("com.mysql.jdbc.Driver")。为什么?
因为 MySQL 驱动类 com.mysql.jdbc.Driver 自己的静态代码块里,已经调用了 DriverManager.registerDriver()。 它的源码长这样:
public class Driver extends NonRegisteringDriver implements java.sql.Driver {
static {
try {
java.sql.DriverManager.registerDriver(new Driver());
} catch (SQLException E) {
throw new RuntimeException("Can't register driver!");
}
}
public Driver() throws SQLException {
// Required for Class.forName().newInstance()
}
}在该类中的静态代码块中已经执行了 DriverManager 对象的 registerDriver() 方法进行驱动的注册了,那么我们只需要加载 Driver 类,该静态代码块就会执行。而 Class.forName("com.mysql.jdbc.Driver") 的作用正是加载这个类:它把类加载进 JVM,触发静态代码块执行,注册就完成了。
【补充】那为什么后来这行代码可以删掉?MySQL 5 之后的驱动包,可以省略注册驱动的步骤,因为驱动 jar 包里有 SPI 机制:jar 包的
META-INF/services/java.sql.Driver文件里写着一行驱动类的全限定名,DriverManager的静态代码块会通过ServiceLoader自动扫描 classpath 下所有 jar 的这个文件,把驱动类加载进来。"加载驱动类"这件事从"你手动做"变成了"DriverManager 自动做"。这就解释了两件事:
- 为什么入门代码把它注释掉还能跑:SPI 兜住了;
- 为什么
Class.forName("com.mysql.jdbc.Driver")在 MySQL 8 下会抛ClassNotFoundException:com.mysql.jdbc这个包名在 8.x 里已经改成com.mysql.cj.jdbc了。能删就删,别留着这行过时的代码。【易错点】 用 Druid 时配置文件里的
driverClassName写错,现象很迷惑:程序不报"类找不到",而是报java.sql.SQLException: config error或连接直接失败。原因是 Druid 解析配置时对驱动类名做了宽容处理。正确写法:MySQL 5.x 用com.mysql.jdbc.Driver,MySQL 8.x 用com.mysql.cj.jdbc.Driver,两者不能混用:用 8 的 jar 配 5 的类名会抛ClassNotFoundException,用 5 的 jar 配 8 的类名同理。
10.3.2 DriverManager:获取数据库连接
第二个作用是获取连接,方法签名是 DriverManager.getConnection(url, username, password),三个参数的含义如下(原文 3.1):
| 参数 | 含义 | 说明 |
|---|---|---|
url | 连接路径 | 语法:jdbc:mysql://ip地址(域名):端口号/数据库名称?参数键值对1&参数键值对2... |
user | 用户名 | 一般是 root |
password | 密码 | 安装 MySQL 时设的那个 |
url 的两个细节在 10.2.5 节已经展开:本机 + 默认端口可以简写成 jdbc:mysql:///db1?参数;useSSL=false 用于禁用安全连接、消掉警告。
【考点】"
Class.forName在 JDBC 4 之后为什么还能删掉?"标准答法两句:一是 JDBC 4.0 起支持 SPI 自动加载,驱动 jar 的META-INF/services/java.sql.Driver会被DriverManager自动读取;二是删掉之后仍建议保留Class.forName的唯一理由是"想尽早暴露驱动缺失":类加载失败会在启动时立刻报错,而不是等到第一次getConnection才报。 现代项目普遍靠连接池和框架管这件事,手写代码里基本都删了。
10.3.3 Connection:获取执行 SQL 的对象
Connection(数据库连接对象)有两个作用:获取执行 SQL 的对象、管理事务。先看第一个,它有三个方法,对应三种执行对象(原文 3.2.1):
- 普通执行 SQL 对象:
Statement createStatement()入门案例中就是通过该方法获取的执行对象。
- 预编译 SQL 的执行 SQL 对象:防止 SQL 注入:
PreparedStatement prepareStatement(String sql)通过这种方式获取的 PreparedStatement 是我们一会重点要进行讲解的,它可以防止 SQL 注入。
- 执行存储过程的对象:
CallableStatement prepareCall(String sql)通过这种方式获取的 CallableStatement 执行对象是用来执行存储过程的,而存储过程在 MySQL 中不常用,所以这个我们将不进行讲解。
【对比】三个执行对象的分工,一张表记住:
对象 获取方式 用来执行 是否预编译 本章地位 Statementconn.createStatement()静态 SQL(SQL 已拼好) 否 入门用,之后被取代 PreparedStatementconn.prepareStatement(sql)带 ?参数的 SQL是 主力,10.4 节主角 CallableStatementconn.prepareCall(sql)存储过程 {call proc(?)}是 了解即可 注意三者是继承关系:
PreparedStatement extends Statement,CallableStatement extends PreparedStatement。所以PreparedStatement能调Statement的所有方法,反过来不行。
10.3.4 Connection:事务管理
先回顾一下 MySQL 事务管理的操作(原文 3.2.2):
- 开启事务:
BEGIN;或者START TRANSACTION; - 提交事务:
COMMIT; - 回滚事务:
ROLLBACK;
MySQL 默认是自动提交事务。你执行一条 update,它立刻落盘,没有"反悔"的机会。所以 JDBC 里要手动控制事务,第一步就是把自动提交关掉。
Connection 接口中定义了 3 个对应的方法:
| 方法 | 作用 | 说明 |
|---|---|---|
setAutoCommit(boolean autoCommit) | 开启事务 | 参数表示是否自动提交事务,true 表示自动提交,false 表示手动提交。开启事务需要将该参数设为 false |
commit() | 提交事务 | 程序运行到此处,说明没有出现任何问题,则提交事务 |
rollback() | 回滚事务 | 程序在出现异常时会执行到这个地方,此时就需要回滚事务 |
具体代码实现如下(原文 JDBCDemo3_Connection)。这段代码的业务是"把 id=1 和 id=2 两个账户的余额都改成 3000",中间故意插了一句 int i = 3/0; 制造异常,用来验证"要么全成功、要么全失败":
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
/**
* JDBC API 详解:Connection
*/
public class JDBCDemo3_Connection {
public static void main(String[] args) throws Exception {
//1. 注册驱动
//Class.forName("com.mysql.jdbc.Driver");
//2. 获取连接:如果连接的是本机mysql并且端口是默认的 3306 可以简化书写
String url = "jdbc:mysql:///db1?useSSL=false";
String username = "root";
String password = "1234";
Connection conn = DriverManager.getConnection(url, username, password);
//3. 定义sql
String sql1 = "update account set money = 3000 where id = 1";
String sql2 = "update account set money = 3000 where id = 2";
//4. 获取执行sql的对象 Statement
Statement stmt = conn.createStatement();
try {
// ============开启事务==========
conn.setAutoCommit(false);
//5. 执行sql
int count1 = stmt.executeUpdate(sql1);//受影响的行数
//6. 处理结果
System.out.println(count1);
int i = 3 / 0;
//5. 执行sql
int count2 = stmt.executeUpdate(sql2);//受影响的行数
//6. 处理结果
System.out.println(count2);
// ============提交事务==========
//程序运行到此处,说明没有出现任何问题,则需求提交事务
conn.commit();
} catch (Exception e) {
// ============回滚事务==========
//程序在出现异常时会执行到这个地方,此时就需要回滚事务
conn.rollback();
e.printStackTrace();
}
//7. 释放资源
stmt.close();
conn.close();
}
}运行结果很关键:控制台打印了 1,然后抛出 ArithmeticException: / by zero,但数据库里 id=1 的 money 仍然是原值,不是 3000。因为第一条 update 虽然执行成功了,但它处在事务里、还没 commit,异常一来就 rollback 把它撤销了。
【补充】把这段代码改造成"转账",就是事务最经典的应用场景。需求:张三(id=1)给李四(id=2)转 1000 元。它必须由两条 SQL 组成:张三减 1000、李四加 1000。这两条必须同生共死。中途断电、抛异常、数据库连接断了,只要有一条没执行,钱就凭空消失或凭空多出来。
public void transfer(int fromId, int toId, double money) { Connection conn = null; PreparedStatement pstmt1 = null; PreparedStatement pstmt2 = null; try { conn = JDBCUtils.getConnection(); // 开启事务:关掉自动提交 conn.setAutoCommit(false); String sql1 = "update account set money = money - ? where id = ?"; String sql2 = "update account set money = money + ? where id = ?"; pstmt1 = conn.prepareStatement(sql1); pstmt1.setDouble(1, money); pstmt1.setInt(2, fromId); pstmt1.executeUpdate(); // 模拟转账途中的意外:网络超时、服务宕机、余额不足的判断失败…… // int i = 1 / 0; pstmt2 = conn.prepareStatement(sql2); pstmt2.setDouble(1, money); pstmt2.setInt(2, toId); pstmt2.executeUpdate(); // 两条都成功,才提交 conn.commit(); } catch (Exception e) { // 任何一条失败,整体回滚 if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); } finally { // 先开后关:pstmt2 → pstmt1 → conn JDBCUtils.close(pstmt2, null); JDBCUtils.close(pstmt1, null); JDBCUtils.close(null, conn); } }事务要满足的四个特性就是 ACID,用转账这个例子最好记:
特性 含义 转账里的体现 原子性 Atomicity 事务里的操作要么全做、要么全不做 张三减钱和李四加钱不能只成功一半 一致性 Consistency 事务前后数据满足业务约束 两人余额之和不变,总数不能凭空多出 1000 隔离性 Isolation 并发事务之间互不干扰 转账进行中,别人查张三余额不能看到"减了但没到账"的中间态 持久性 Durability 提交后永久生效,宕机也不丢 commit()返回后,即使 MySQL 立刻断电,钱也确实转了只有
commit()成功返回,事务才算真正落地;rollback()之后一切回到setAutoCommit(false)那一刻的状态。【易错点】 事务代码有三个高频错误:
setAutoCommit(false)写在try外面,一旦它自己抛异常,catch里的rollback会再抛一个异常,把真实错误盖掉。正确做法是它也放进try,rollback前先判空。commit()之后又rollback():finally里无脑回滚是经典错误,会把已经提交的事务"再回滚一次",直接抛SQLException: Can't call rollback when autocommit=true(连接池回收时已重置)。正确做法是只在catch里回滚。- 连接池的
close()会重置事务状态:conn.close()归还给连接池时,池子会把autoCommit恢复成true。所以不要指望"不 commit 就不生效"来偷懒,该 commit 就 commit。顺带一提:
finally里close()一旦抛异常,try里的 return 结果就被吞了。所以资源关闭最好用try-with-resources(见 10.3.10)。
10.3.5 Statement:执行 DDL 与 DML
Statement 对象的作用就是用来执行 SQL 语句。而针对不同类型的 SQL 语句使用的方法也不一样(原文 3.3):
| 方法 | 用于 | 返回值 |
|---|---|---|
int executeUpdate(String sql) | 执行 DDL、DML 语句 | 受影响的行数(DDL 可能是 0) |
ResultSet executeQuery(String sql) | 执行 DQL 语句 | ResultSet 结果集对象 |
executeQuery 涉及到了 ResultSet 对象,而这个对象我们还没有学习,一会再重点讲解。
执行 DML 语句的代码如下:
import org.junit.Test;
/**
* 执行DML语句
* @throws Exception
*/
@Test
public void testDML() throws Exception {
//1. 注册驱动
//Class.forName("com.mysql.jdbc.Driver");
//2. 获取连接:如果连接的是本机mysql并且端口是默认的 3306 可以简化书写
String url = "jdbc:mysql:///db1?useSSL=false";
String username = "root";
String password = "1234";
Connection conn = DriverManager.getConnection(url, username, password);
//3. 定义sql
String sql = "update account set money = 3000 where id = 1";
//4. 获取执行sql的对象 Statement
Statement stmt = conn.createStatement();
//5. 执行sql
int count = stmt.executeUpdate(sql);//执行完DML语句,受影响的行数
//6. 处理结果
//System.out.println(count);
if (count > 0) {
System.out.println("修改成功~");
} else {
System.out.println("修改失败~");
}
//7. 释放资源
stmt.close();
conn.close();
}执行 DDL 语句的代码如下,注意 SQL 换成了 drop database db2,而且 count 可能是 0:
/**
* 执行DDL语句
* @throws Exception
*/
@Test
public void testDDL() throws Exception {
//1. 注册驱动
//Class.forName("com.mysql.jdbc.Driver");
//2. 获取连接:如果连接的是本机mysql并且端口是默认的 3306 可以简化书写
String url = "jdbc:mysql:///db1?useSSL=false";
String username = "root";
String password = "1234";
Connection conn = DriverManager.getConnection(url, username, password);
//3. 定义sql
String sql = "drop database db2";
//4. 获取执行sql的对象 Statement
Statement stmt = conn.createStatement();
//5. 执行sql
int count = stmt.executeUpdate(sql);//执行完DDL语句,可能是0
//6. 处理结果
System.out.println(count);
//7. 释放资源
stmt.close();
conn.close();
}注意: 以后开发很少使用 Java 代码操作 DDL 语句。建表、建库、删库这类操作属于"运维/部署"的范畴,用 SQL 脚本在 MySQL 客户端里执行更安全、可追溯;Java 代码的职责是操作数据(DML)和读取数据(DQL)。想象一下:线上程序里藏着一句
drop database db2,某天误跑一次,整个库就没了。
10.3.6 Statement:批量操作 addBatch 与 executeBatch
原文没有讲批量操作,但它在"添加 100 条品牌数据"这种场景里是必选项,也是 Statement 与 PreparedStatement 共有的能力,这里补上。
【补充】批量操作指的是把多条 SQL 攒在客户端,一次性发给 MySQL 执行,而不是发一条等一条。两个方法:
addBatch(String sql)(Statement)或addBatch()(PreparedStatement,无参):把当前 SQL 加入批次;int[] executeBatch():一次性提交整批,返回每条语句影响的行数数组;clearBatch():清空批次(可省略,executeBatch之后批次会自动清空)。// 用 PreparedStatement 批量插入 1000 条品牌 String sql = "insert into tb_brand(brand_name, company_name, ordered, description, status) values(?,?,?,?,?)"; PreparedStatement pstmt = conn.prepareStatement(sql); for (int i = 1; i <= 1000; i++) { pstmt.setString(1, "品牌" + i); pstmt.setString(2, "公司" + i); pstmt.setInt(3, i); pstmt.setString(4, "描述" + i); pstmt.setInt(5, 1); pstmt.addBatch(); // 攒起来,不执行 if (i % 500 == 0) { pstmt.executeBatch(); // 每 500 条发一次,避免内存爆掉 pstmt.clearBatch(); } } pstmt.executeBatch(); // 收尾想让批量插入真正快起来,url 里还要加一个参数
rewriteBatchedStatements=true。不加的话,MySQL 驱动会把 1000 条insert拆成 1000 次网络往返,批量等于没批量;加上之后,驱动会把它们改写成一条insert into ... values (...),(...),(...),速度能差几倍到几十倍。这是面试里很爱问的一个细节。
10.3.7 ResultSet:结果集对象
ResultSet(结果集对象)的作用是:封装了 SQL 查询语句的结果。而执行了 DQL 语句后就会返回该对象,对应的方法是 ResultSet executeQuery(String sql):执行 DQL 语句,返回 ResultSet 对象。
那么我们就需要从 ResultSet 对象中获取我们想要的数据。ResultSet 对象提供了操作查询结果数据的方法,如下(原文 3.4):
boolean next()
- 将光标从当前位置向前移动一行;
- 判断当前行是否为有效行。
方法返回值说明:
true:有效行,当前行有数据;false:无效行,当前行没有数据。
xxx getXxx(参数):获取数据
xxx:数据类型,如int getInt(参数)、String getString(参数);- 参数有两种写法:
int类型的参数:列的编号,从 1 开始;String类型的参数:列的名称。
光标的行为值得单独说清楚。一开始光标指向第一行之前(表头那一行),此时不能取数据;当我们调用了 next() 方法后,光标就下移到第一行数据,并且方法返回 true,此时就可以通过 getInt("id") 获取当前行 id 字段的值,也可以通过 getString("name") 获取当前行 name 字段的值。如果想获取下一行的数据,继续调用 next() 方法,以此类推。当 next() 返回 false 时,说明已经没有下一行了,循环结束。
用一张示意图表示光标的位置变化:
id name money
光标初始位置 → (表头) —— 此时 getXxx() 会抛异常
next() 第1次 → 1 张三 1000 ← 返回 true,可取数据
next() 第2次 → 2 李四 2000 ← 返回 true,可取数据
next() 第3次 → (无) —— —— ← 返回 false,循环结束【易错点】三个和
ResultSet有关的常见错误,每一个都能让程序"看起来正常但结果是错的":
- 不调用
next()就直接getXxx():抛SQLException: Before start of result set。ResultSet一创建光标就在第一行之前,必须先next()。getXxx(1)以为是从 0 开始:JDBC 的列编号从 1 开始,写getInt(0)抛SQLException: Column Index out of range。而数组、List是从 0 开始,两个习惯打架,这是最高频的低级错误。ResultSet用完必须关:rs底层持有 MySQL 返回的结果集游标和服务端资源,不关就会一直占着连接。更隐蔽的是:rs.close()不写,conn.close()一执行,rs自动失效;但如果用了连接池,conn.close()只是"归还",rs没关会导致这条连接下次被借出时状态异常。所以释放顺序必须是rs → stmt → conn。
10.3.8 ResultSet:遍历查询结果
完整代码如下(原文 3.4.2)。注释掉的那段是用列编号取值,下面那段是用列名取值,两种写法效果一样,推荐用列名(改表结构时不容易错位):
/**
* 执行DQL
* @throws Exception
*/
@Test
public void testResultSet() throws Exception {
//1. 注册驱动
//Class.forName("com.mysql.jdbc.Driver");
//2. 获取连接:如果连接的是本机mysql并且端口是默认的 3306 可以简化书写
String url = "jdbc:mysql:///db1?useSSL=false";
String username = "root";
String password = "1234";
Connection conn = DriverManager.getConnection(url, username, password);
//3. 定义sql
String sql = "select * from account";
//4. 获取statement对象
Statement stmt = conn.createStatement();
//5. 执行sql
ResultSet rs = stmt.executeQuery(sql);
//6. 处理结果, 遍历rs中的所有数据
/* // 6.1 光标向下移动一行,并且判断当前行是否有数据
while (rs.next()){
//6.2 获取数据 getXxx()
int id = rs.getInt(1);
String name = rs.getString(2);
double money = rs.getDouble(3);
System.out.println(id);
System.out.println(name);
System.out.println(money);
System.out.println("--------------");
}*/
// 6.1 光标向下移动一行,并且判断当前行是否有数据
while (rs.next()) {
//6.2 获取数据 getXxx()
int id = rs.getInt("id");
String name = rs.getString("name");
double money = rs.getDouble("money");
System.out.println(id);
System.out.println(name);
System.out.println(money);
System.out.println("--------------");
}
//7. 释放资源
rs.close();
stmt.close();
conn.close();
}10.3.9 getInt 与 getObject 对 NULL 的差异
这是原文没提、但一写真实业务就会撞上的问题:数据库里的字段允许为 NULL,而 Java 的 int 不允许为 null。
【易错点】
rs.getInt("status")遇到数据库里是NULL的status时,不会抛异常,而是返回0。这个 0 和"数据库里真的存了 0"完全无法区分,业务上会出大错:比如status的语义是"0 禁用、1 启用",一个"没填状态"的商品会被当成"禁用"。更危险的是给实体类的Integer字段赋值:brand.setStatus(rs.getInt("status"))会把 null 变成 0。正确写法是用
getObject或wasNull():// 写法一:getObject 返回包装类型,NULL 就是 null Integer status = (Integer) rs.getObject("status"); brand.setStatus(status); // 数据库是 NULL,Java 里也是 null // 写法二:getInt 之后用 wasNull() 判断 int statusInt = rs.getInt("status"); brand.setStatus(rs.wasNull() ? null : statusInt);这也是实体类的基本数据类型建议使用其对应的包装类型(
Integer而不是int)的根本原因:Brand类的注释里写了这句话,原因就在这里。表字段可空,Java 侧就必须用能表达"空"的类型。
10.3.10 案例:把结果封装成对象集合
需求:查询 account 账户表数据,封装为 Account 对象中,并且存储到 ArrayList 集合中。这是"数据库行 → Java 对象"的第一次转换,也是后面所有 DAO 方法的原型。
思路分三步:定义实体类 Account、查询数据封装到 Account 对象中、将 Account 对象存入 ArrayList 集合中。
/**
* 查询account账户表数据,封装为Account对象中,并且存储到ArrayList集合中
* 1. 定义实体类Account
* 2. 查询数据,封装到Account对象中
* 3. 将Account对象存入ArrayList集合中
*/
@Test
public void testResultSet2() throws Exception {
//1. 注册驱动
//Class.forName("com.mysql.jdbc.Driver");
//2. 获取连接:如果连接的是本机mysql并且端口是默认的 3306 可以简化书写
String url = "jdbc:mysql:///db1?useSSL=false";
String username = "root";
String password = "1234";
Connection conn = DriverManager.getConnection(url, username, password);
//3. 定义sql
String sql = "select * from account";
//4. 获取statement对象
Statement stmt = conn.createStatement();
//5. 执行sql
ResultSet rs = stmt.executeQuery(sql);
// 创建集合
List<Account> list = new ArrayList<>();
// 6.1 光标向下移动一行,并且判断当前行是否有数据
while (rs.next()) {
Account account = new Account();
//6.2 获取数据 getXxx()
int id = rs.getInt("id");
String name = rs.getString("name");
double money = rs.getDouble("money");
//赋值
account.setId(id);
account.setName(name);
account.setMoney(money);
// 存入集合
list.add(account);
}
System.out.println(list);
//7. 释放资源
rs.close();
stmt.close();
conn.close();
}对应实体类 Account:
/**
* 账户实体类
*/
public class Account {
private Integer id;
private String name;
private Double money;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Double getMoney() {
return money;
}
public void setMoney(Double money) {
this.money = money;
}
@Override
public String toString() {
return "Account{" +
"id=" + id +
", name='" + name + '\'' +
", money=" + money +
'}';
}
}注意 list.add(account) 的位置在 while 循环内部,而 new Account() 也在循环内部。如果把 new Account() 提到循环外面,所有元素会指向同一个对象,最后集合里全是最后一行的数据。这是集合遍历封装最经典的 bug。
10.3.11 资源释放顺序与 try-with-resources
到这里,每个方法结尾都是三行 close()。它们为什么必须是这个顺序?原文没有解释,这里补上。
【补充】释放顺序:先开后关,后开先关(像叠盘子,后放上去的先拿下来)。理由有两条:
- 依赖关系:
ResultSet由Statement产生,Statement由Connection产生。关掉Connection之后,它上面的Statement和ResultSet都会失效,此时再调rs.close()可能抛SQLException: Operation not allowed after ResultSet closed。- 资源层级:每个
Statement在 MySQL 服务端都有对应的预处理语句缓存,连接关掉时这些缓存才被统一清理。先关小的、再关大的,能保证每一步都关在"还有效"的状态。所以顺序固定为
rs → stmt/pstmt → conn,且必须放在finally里:否则中间一抛异常,后面的close()全被跳过,连接就泄漏了。手写finally的完整版本是这样的:Connection conn = null; PreparedStatement pstmt = null; ResultSet rs = null; try { conn = DriverManager.getConnection(url, username, password); String sql = "select * from tb_brand"; pstmt = conn.prepareStatement(sql); rs = pstmt.executeQuery(); while (rs.next()) { // 处理结果 } } catch (SQLException e) { e.printStackTrace(); } finally { // 后开先关,每个 close 单独 try-catch,避免一个失败影响后面 if (rs != null) { try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } if (pstmt != null) { try { pstmt.close(); } catch (SQLException e) { e.printStackTrace(); } } if (conn != null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }三层
if + try-catch嵌套,写完二十行,谁写谁烦。JDK 7 之后有了 try-with-resources,前提是资源实现了AutoCloseable接口:Connection、Statement、ResultSet全都实现了。上面的代码可以压缩成:String sql = "select * from tb_brand"; try (Connection conn = DriverManager.getConnection(url, username, password); PreparedStatement pstmt = conn.prepareStatement(sql); ResultSet rs = pstmt.executeQuery()) { while (rs.next()) { System.out.println(rs.getString("brand_name")); } } catch (SQLException e) { e.printStackTrace(); }三个资源写在
try的圆括号里,用分号隔开。关闭顺序由编译器自动按"倒序"生成:先关rs、再关pstmt、最后关conn,正好就是"先开后关"。而且即使pstmt的关闭抛了异常,也不会影响conn的关闭(编译器生成的代码里做了addSuppressed处理)。注意:try-with-resources 里的变量在
try块结束后就不可见了,所以如果需要把conn传出去(比如事务要在try外面 commit),就不能用它。事务场景下,conn通常还是手写finally关闭,因为commit/rollback的时机比资源作用域更复杂。
10.4 PreparedStatement 与 SQL 注入
10.4.1 SQL 注入是什么
SQL 注入是通过操作输入来修改事先定义好的 SQL 语句,用以达到执行代码对服务器进行攻击的方法。
这句话有点绕,翻译成大白话:用户输入的内容,被当成了 SQL 语法的一部分。 你本来只想让用户填一个"密码值",结果他填的字符串里带了引号和 or,把你精心写的 where 条件整个改写了。
原文给了一个现成的演示程序,步骤是这样的:
- 在今天资料下的
day03-JDBC\资料\2. sql注入演示中修改application.properties文件中的用户名和密码,文件内容如下:
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/test?useSSL=false&useUnicode=true&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=1234- 在 MySQL 中创建名为
test的数据库:
create database test;- 在命令提示符中运行今天资料下的
day03-JDBC\资料\2. sql注入演示\sql.jar这个 jar 包。此时我们就能在数据库中看到user表; - 接下来在浏览器的地址栏输入
localhost:8080/login.html就能看到一个登录页面; - 我们就可以在页面中输入用户名和密码进行登录。用户名和密码输入正确就登录成功,跳转到首页;输入错误则给出错误提示;
- 但是,我可以通过输入一些特殊的字符登录到首页:用户名随意写,密码写成
' or '1' = '1。
这就是 SQL 注入漏洞,也是很危险的。当然现在市面上的系统都不会存在这种问题了,所以也不要尝试用这种方式去试其他的系统。未经授权对他人系统做注入测试是违法行为。
那么该如何解决呢?这里就可以将 SQL 执行对象 Statement 换成 PreparedStatement 对象。
10.4.2 用代码模拟 SQL 注入
先不看 PreparedStatement,用一段代码把注入"复现"出来,你才会真正理解它为什么能成功(原文 3.6.2):
@Test
public void testLogin() throws Exception {
//2. 获取连接:如果连接的是本机mysql并且端口是默认的 3306 可以简化书写
String url = "jdbc:mysql:///db1?useSSL=false";
String username = "root";
String password = "1234";
Connection conn = DriverManager.getConnection(url, username, password);
// 接收用户输入 用户名和密码
String name = "sjdljfld";
String pwd = "' or '1' = '1";
String sql = "select * from tb_user where username = '" + name + "' and password = '" + pwd + "'";
// 获取stmt对象
Statement stmt = conn.createStatement();
// 执行sql
ResultSet rs = stmt.executeQuery(sql);
// 判断登录是否成功
if (rs.next()) {
System.out.println("登录成功~");
} else {
System.out.println("登录失败~");
}
//7. 释放资源
rs.close();
stmt.close();
conn.close();
}上面代码是将用户名和密码拼接到 SQL 语句中,拼接后的 SQL 语句如下:
select * from tb_user where username = 'sjdljfld' and password = ''or '1' = '1'从上面语句可以看出,条件 username = 'sjdljfld' and password = '' 不管是否满足,而 or 后面的 '1' = '1' 是始终满足的,最终整个条件成立,就可以正常地进行登录了。
把拼接过程拆开看,注入的每一步都清清楚楚:
原始 SQL 模板:
select * from tb_user where username = '___' and password = '___'
用户输入 pwd = ' or '1' = '1 ,拼进去之后:
select * from tb_user where username = 'sjdljfld' and password = '' or '1' = '1'
└──┬──┘
密码变成了空字符串,or 后面恒为真 ──────────┘
最终 WHERE 等价于:(username='sjdljfld' AND password='') OR TRUE
由于 OR TRUE,整个条件恒成立,查出了表里所有行,rs.next() 返回 true。注意 rs.next() 只判断"有没有返回行",只要有任意一行就认为登录成功。这正是漏洞被放大的地方。
【补充】除了
' or '1' = '1,注入还有很多变种,认识它们才知道危害边界:
输入 拼接后的效果 后果 ' or '1' = '1条件恒成立 绕过登录 admin' ----把后面的密码判断注释掉只要知道用户名就能登录 ' ; drop table tb_user; --在支持多语句时执行第二条 SQL 删表 ' union select username, password from tb_user --联合查询把别的表数据带出来 拖库 防御不只靠
PreparedStatement一条:数据库账号最小权限(业务账号不给drop权限)、输入校验(id 必须是数字)、错误信息不暴露给前端(不要把 SQL 异常原文打印到页面)。但第一道防线永远是"不要把用户输入拼进 SQL"。
10.4.3 PreparedStatement 概述
PreparedStatement 的作用是:预编译 SQL 语句并执行,预防 SQL 注入问题。
获取 PreparedStatement 对象:SQL 语句中的参数值,使用 ? 占位符替代。
// SQL语句中的参数值,使用?占位符替代
String sql = "select * from user where username = ? and password = ?";
// 通过Connection对象获取,并传入对应的sql语句
PreparedStatement pstmt = conn.prepareStatement(sql);设置参数值:上面的 SQL 语句中参数使用 ? 进行占位,在执行之前肯定要设置这些 ? 的值。PreparedStatement 对象提供了 setXxx(参数1, 参数2) 给 ? 赋值:
Xxx:数据类型,如setInt(参数1, 参数2);- 参数 1:
?的位置编号,从 1 开始; - 参数 2:
?的值。
执行 SQL 语句:
executeUpdate():执行 DDL 语句和 DML 语句;executeQuery():执行 DQL 语句。
注意: 调用这两个方法时不需要传递 SQL 语句,因为获取 SQL 语句执行对象时已经对 SQL 语句进行预编译了。这是
PreparedStatement和Statement在方法签名上最直观的区别:stmt.executeQuery(sql)要传 SQL,pstmt.executeQuery()不传。
10.4.4 用 PreparedStatement 改进登录
@Test
public void testPreparedStatement() throws Exception {
//2. 获取连接:如果连接的是本机mysql并且端口是默认的 3306 可以简化书写
String url = "jdbc:mysql:///db1?useSSL=false";
String username = "root";
String password = "1234";
Connection conn = DriverManager.getConnection(url, username, password);
// 接收用户输入 用户名和密码
String name = "zhangsan";
String pwd = "' or '1' = '1";
// 定义sql
String sql = "select * from tb_user where username = ? and password = ?";
// 获取pstmt对象
PreparedStatement pstmt = conn.prepareStatement(sql);
// 设置?的值
pstmt.setString(1, name);
pstmt.setString(2, pwd);
// 执行sql
ResultSet rs = pstmt.executeQuery();
// 判断登录是否成功
if (rs.next()) {
System.out.println("登录成功~");
} else {
System.out.println("登录失败~");
}
//7. 释放资源
rs.close();
pstmt.close();
conn.close();
}执行上面代码就可以发现不会出现 SQL 注入漏洞问题了,控制台打印的是"登录失败~"。注意:注入的字符串还是那个 ' or '1' = '1,代码里一个字都没改,只是换了执行对象。
那么 PreparedStatement 又是如何解决的?原文的说法是:它是将特殊字符进行了转义,转义后的 SQL 如下:
select * from tb_user where username = 'sjdljfld' and password = '\'or \'1\' = \'1'看这条语句:pwd 里的三个单引号全都被加上了反斜杠变成 \',于是它们在 MySQL 眼里只是字符串内部的一个普通字符,不再具备"闭合字符串字面量"的语法能力。整个 or '1' = '1 变成了密码值的一部分,条件变成 password = "' or '1' = '1",自然查不到人。
10.4.5 为什么能防住:预编译与参数分离
原文用"转义"解释,这是结果层面的解释;面试如果追问"为什么转义就安全",就要讲到机制层面。
【补充】
PreparedStatement防注入的真正机制是两个动作,缺一不可:第一,SQL 模板先被"预编译",语法结构在参数到达之前就已经固定。
conn.prepareStatement(sql)这一步,驱动就把带?的 SQL 模板发给了 MySQL。MySQL 对它做语法检查、编译成可执行计划,此时这条 SQL 有几个条件、哪些是字符串、哪些是数字,已经全部定死了。注意:这时候参数还没来。第二,参数是"另一条通道"送进去的,永远只被当作值,不会被当作语法。
pstmt.setString(2, pwd)设置的值,走的是协议里的"参数绑定"字段,不是拼进 SQL 文本里。MySQL 收到它时,只知道"第 2 个占位符的值是这串字符",不会再去解析这串字符里有没有引号、有没有 or。用一张图对比两条路径:
Statement(拼接): Java 字符串拼接 → 一整条 SQL 文本 → MySQL 解析语法 → 执行 ↑ 用户输入在这一步就混进了语法里,能被"读懂" PreparedStatement: ① SQL 模板(含 ?) → MySQL 解析语法、编译 → 结构固定 ② 参数值 → 走绑定通道 → 只当值用 两路分开,用户输入永远进不了"语法层"这就解释了原文那句"将敏感字符进行转义"是从哪来的:驱动在把参数值编码进协议时,会对引号、反斜杠等字符做转义处理,保证它们不会破坏外层结构。转义是"参数绑定"这个机制在实现层面的一个细节,而不是"先拼好再替换敏感字符":顺序完全不同,效果天差地别。
顺带说清一个常被混淆的点:默认情况下 MySQL 的预编译是"客户端预编译",
useServerPrepStmts=false。此时?的替换在驱动内部完成,服务端收到的仍是一条完整 SQL。不过驱动会负责正确地转义参数,所以注入依然防得住。开了useServerPrepStmts=true才是"服务端预编译",SQL 模板与参数分两次发给 MySQL。两者安全性相同,性能特性不同。
10.4.6 PreparedStatement 的性能:预编译为什么更快
PreparedStatement 的好处有两条:预编译 SQL,性能更高;防止 SQL 注入。防注入已经讲透,这里讲性能。
Java 代码操作数据库的流程是这样的:
- 将 SQL 语句发送到 MySQL 服务器端;
- MySQL 服务端会对 SQL 语句进行如下操作:
- 检查 SQL 语句:检查 SQL 语句的语法是否正确;
- 编译 SQL 语句:将 SQL 语句编译成可执行的函数;
- 执行 SQL 语句。
原因是:检查 SQL 和编译 SQL 花费的时间比执行 SQL 的时间还要长。 如果我们只是重新设置参数,那么检查 SQL 语句和编译 SQL 语句将不需要重复执行,这样就提高了性能。
接下来通过查询日志来看一下原理。
第一步,开启预编译功能。 在代码中编写 url 时需要加上参数 useServerPrepStmts=true。而我们之前根本就没有开启预编译功能,只是解决了 SQL 注入漏洞。这句话很重要,防注入和预编译性能是两个独立的收益,不开这个参数也防得住注入。
// useServerPrepStmts=true 参数开启预编译功能
String url = "jdbc:mysql:///db1?useSSL=false&useServerPrepStmts=true";第二步,配置 MySQL 执行日志(重启 MySQL 服务后生效)。在 MySQL 配置文件(my.ini)中添加如下配置:
log-output=FILE
general-log=1
general_log_file="D:\mysql.log"
slow-query-log=1
slow_query_log_file="D:\mysql_slow.log"
long_query_time=2第三步,Java 测试代码如下(原文 3.6.5):
/**
* PreparedStatement原理
* @throws Exception
*/
@Test
public void testPreparedStatement2() throws Exception {
//2. 获取连接:如果连接的是本机mysql并且端口是默认的 3306 可以简化书写
// useServerPrepStmts=true 参数开启预编译功能
String url = "jdbc:mysql:///db1?useSSL=false&useServerPrepStmts=true";
String username = "root";
String password = "1234";
Connection conn = DriverManager.getConnection(url, username, password);
// 接收用户输入 用户名和密码
String name = "zhangsan";
String pwd = "' or '1' = '1";
// 定义sql
String sql = "select * from tb_user where username = ? and password = ?";
// 获取pstmt对象
PreparedStatement pstmt = conn.prepareStatement(sql);
Thread.sleep(10000);
// 设置?的值
pstmt.setString(1, name);
pstmt.setString(2, pwd);
ResultSet rs = null;
// 执行sql
rs = pstmt.executeQuery();
// 设置?的值
pstmt.setString(1, "aaa");
pstmt.setString(2, "bbb");
// 执行sql
rs = pstmt.executeQuery();
// 判断登录是否成功
if (rs.next()) {
System.out.println("登录成功~");
} else {
System.out.println("登录失败~");
}
//7. 释放资源
rs.close();
pstmt.close();
conn.close();
}执行 SQL 语句,查看 D:\mysql.log 日志,会看到类似这样的三行:
Prepare select * from tb_user where username = ? and password = ?
Execute select * from tb_user where username = 'zhangsan' and password = '\'or \'1\' = \'1'
Execute select * from tb_user where username = 'aaa' and password = 'bbb'其中第一行 Prepare 是对 SQL 语句进行预编译;后面两行是执行了两次 SQL 语句,而第二次执行前并没有对 SQL 进行预编译。中间那个 Thread.sleep(10000) 就是为了让你在日志里清楚地看到"编译"和"执行"被时间隔开了。
小结:
- 在获取
PreparedStatement对象时,将 SQL 语句发送给 MySQL 服务器进行检查、编译(这些步骤很耗时); - 执行时就不用再进行这些步骤了,速度更快;
- 如果 SQL 模板一样,则只需要进行一次检查、编译。
所以在 for 循环里插入一万条数据时,PreparedStatement 只编译一次、执行一万次;而 Statement 会编译一万次。这就是性能差距的来源。
10.4.7 Statement 与 PreparedStatement 的完整对比
【对比】这张表是本章最该背下来的一张表,简答、选择、面试全覆盖。
维度 StatementPreparedStatement获取方式 conn.createStatement()conn.prepareStatement(sql)SQL 形式 完整 SQL,参数靠字符串拼接 SQL 模板,参数用 ?占位执行方法 executeUpdate(sql)、executeQuery(sql),要传 SQLexecuteUpdate()、executeQuery(),不传 SQL是否预编译 否,每次执行都检查 + 编译 是,模板只编译一次 SQL 注入 存在风险 可防止(参数只当值) 性能(循环执行) 差,N 次编译 好,1 次编译 + N 次执行 可读性 拼接长 SQL 时可读性差、易漏引号 模板清晰,参数集中设置 参数类型安全 无,全靠手拼 有, setInt传字符串会直接报错二进制/大文本 不支持 setBinaryStream支持 setBinaryStream、setCharacterStream适用场景 一次性执行的静态 SQL(如建表脚本) 绝大多数业务 SQL,尤其是带用户输入的 结论:除了"执行不带参数的静态 SQL"这种场景,其他一律用
PreparedStatement。【易错点】
PreparedStatement有两个能力边界,误用就会出错:边界一:
?只能占位"值",不能占位"标识符"。 下面两种写法都是错的:// 错误:想动态指定表名 String sql = "select * from ? "; // 错误:想动态指定排序字段 String sql = "select * from tb_brand order by ? ";
setString(1, "tb_brand")执行后会变成select * from 'tb_brand',MySQL 把表名当成字符串字面量,直接报语法错误。表名、列名、order by的字段、asc/desc只能拼接,拼接前必须用白名单校验(只允许约定的几个值),绝不能把用户输入直接拼进去。边界二:
like查询的%要放在参数值里,不能放模板里。// 错误:? 在单引号内部,会被当成普通字符 String sql = "select * from tb_brand where brand_name like '%?%'"; // 正确:模板里只有一个 ?,% 拼在参数值上 String sql = "select * from tb_brand where brand_name like ?"; pstmt.setString(1, "%" + keyword + "%");
10.5 数据库连接池与工具类封装
到这里,每个测试方法都以这四行开头:
Properties prop = new Properties();
prop.load(new FileInputStream("jdbc-demo/src/druid.properties"));
DataSource dataSource = DruidDataSourceFactory.createDataSource(prop);
Connection conn = dataSource.getConnection();这段代码会在本章出现六次。出现三次以上就该抽取,这是工程上的一条基本纪律。本节先讲清楚连接池是什么、为什么快,再把这段模板代码收进一个 JDBCUtils 工具类。
10.5.1 数据库连接池的概念(原文 4.1)
数据库连接池是个容器,负责分配、管理数据库连接(Connection)。
它的三条核心行为是:
- 它允许应用程序重复使用一个现有的数据库连接,而不是再重新建立一个;
- 释放空闲时间超过最大空闲时间的数据库连接,来避免因为没有释放数据库连接而引起的数据库连接遗漏;
- 好处有三条:资源重用、提升系统响应速度、避免数据库连接遗漏。
之前我们代码中使用连接都是"没有使用就创建一个 Connection 对象,使用完毕就会将其销毁"。这样重复创建销毁的过程是特别耗费计算机性能的,也消耗时间。
而数据库使用了数据库连接池后,就能达到 Connection 对象的复用。连接池是在一开始就创建好了一些连接(Connection)对象存储起来。用户需要连接数据库时,不需要自己创建连接,而只需要从连接池中获取一个连接进行使用,使用完毕后再将连接对象归还给连接池;这样就可以起到资源重用,也节省了频繁创建连接销毁连接所花费的时间,从而提升了系统响应的速度。
用图表示"没有池"与"有池"的区别:
没有连接池(每次请求都新建):
请求1 → 创建连接(TCP 握手 + 认证)→ 执行 SQL → 销毁连接
请求2 → 创建连接(TCP 握手 + 认证)→ 执行 SQL → 销毁连接
请求3 → 创建连接(TCP 握手 + 认证)→ 执行 SQL → 销毁连接
有连接池(启动时建好,之后反复借还):
启动 → 预先创建 5 个连接,放进池子
请求1 → 借出 conn1 → 执行 SQL → 归还 conn1
请求2 → 借出 conn2 → 执行 SQL → 归还 conn2
请求3 → 借出 conn1(复用) → 执行 SQL → 归还 conn110.5.2 连接池为什么快:被省掉的那几次往返
原文说"节省了频繁创建连接销毁连接所花费的时间",但没说清这些时间花在哪。这正是面试追问的点。
【补充】建立一个 MySQL 连接的完整开销分三段,每一段都是网络往返(RTT):
- TCP 三次握手:客户端发
SYN、服务端回SYN+ACK、客户端回ACK,1.5 个 RTT 之后连接才建立。局域网内一次 RTT 约 0.1~0.5 ms,跨机房就是几毫秒到几十毫秒。- MySQL 握手与认证:服务端先发握手包(带协议版本、
salt),客户端回认证包(用户名 + 密码哈希 + 库名),服务端回OK。这是至少 2 个 RTT,而且密码要做哈希运算(MySQL 8 默认caching_sha2_password,比 5.7 的mysql_native_password更慢,首次认证还可能多一次公钥交换往返)。- 会话初始化:设置字符集、时区、
autocommit、sql_mode等,每项都是一次往返。加起来,一次"从零建连"通常要 3~10 个 RTT,耗时 5~50 ms;而一次简单的
select执行可能只要 1 ms。建连的开销是执行 SQL 的几十倍。在一个 QPS 1000 的网站上,如果每次都新建连接,光建连就要吃掉几十毫秒的延迟和大量 CPU(TLS 握手、密码哈希都是 CPU 密集的)。连接池把这笔开销从"每次请求"摊销成"每个连接一次":启动时建好 N 个连接,之后所有请求只是"借"和"还",借还是内存操作,纳秒级。这就是"提升系统响应速度"的真实含义。
顺带解释另外两个好处:
- 资源重用:每个 MySQL 连接在服务端都对应一个线程和一块会话内存。不池化的话,高并发时连接数暴涨,MySQL 的
max_connections(默认 151)很快被打满,直接报Too many connections。- 避免连接遗漏:手写代码时,一旦
close()漏了(比如异常路径没走到finally),连接就永久泄漏。连接池能检测"借出超过removeAbandonedTimeout还没还"的连接并强制回收,还能按minEvictableIdleTimeMillis淘汰空闲太久的连接。这就是原文说的"释放空闲时间超过最大空闲时间的数据库连接"。
10.5.3 标准接口 DataSource(原文 4.2)
- 标准接口:
DataSource。这是官方(Sun)提供的数据库连接池标准接口,由第三方组织实现此接口。该接口提供了获取连接的功能:
Connection getConnection()那么以后就不需要通过 DriverManager 对象获取 Connection 对象,而是通过连接池(DataSource)获取 Connection 对象。
这句话是理解连接池的关键:DataSource 是 javax.sql 包下的接口,和 java.sql.Connection 一样属于 JDBC 标准的一部分。换连接池实现(Druid 换 C3P0)只需要换一行 DruidDataSourceFactory.createDataSource(prop),业务代码一行不用改。
常见的数据库连接池:
| 连接池 | 出品方 | 特点 |
|---|---|---|
| DBCP | Apache | 老牌,Tomcat 内置的就是它,性能一般 |
| C3P0 | 开源社区 | 老牌,配置项多,Hibernate 早期默认 |
| Druid | 阿里巴巴 | 功能强大、性能优秀,自带监控页面,国内主流 |
我们现在使用更多的是 Druid,它的性能比其他两个会好一些。Druid(德鲁伊) 是阿里巴巴开源的数据库连接池项目,功能强大、性能优秀,是 Java 语言最好的数据库连接池之一。
【考点】"连接池的
close()到底关了什么?"它没有真的关闭 TCP 连接,它只是把连接归还给池子。 连接池返回给你的Connection是一个代理对象(Druid 里是DruidPooledConnection),它的close()方法被改写成了"回收"逻辑:重置autoCommit、清空会话状态、把连接放回空闲队列。所以用了连接池之后,conn.close()照常写,但不会真的断开数据库,这也是为什么关闭资源依然不能省。
10.5.4 Druid 的使用步骤(原文 4.3)
使用 Druid 一共五步:
- 导入 jar 包:
druid-1.1.12.jar; - 定义配置文件;
- 加载配置文件;
- 获取数据库连接池对象;
- 获取连接。
首先需要先将 Druid 的 jar 包放到项目下的 lib 下并添加为库文件。项目结构大致如下:
jdbc-demo
├── lib
│ ├── druid-1.1.12.jar
│ └── mysql-connector-java-8.0.30.jar
└── src
├── druid.properties ← 配置文件放在 src 根目录
└── com.itheima.jdbc
└── DruidDemo.java编写配置文件 druid.properties 如下:
driverClassName=com.mysql.jdbc.Driver
url=jdbc:mysql:///db1?useSSL=false&useServerPrepStmts=true
username=root
password=1234
# 初始化连接数量
initialSize=5
# 最大连接数
maxActive=10
# 最大等待时间
maxWait=3000使用 Druid 的代码如下(原文 DruidDemo):
import com.alibaba.druid.pool.DruidDataSourceFactory;
import javax.sql.DataSource;
import java.io.FileInputStream;
import java.sql.Connection;
import java.util.Properties;
/**
* Druid数据库连接池演示
*/
public class DruidDemo {
public static void main(String[] args) throws Exception {
//1.导入jar包
//2.定义配置文件
//3. 加载配置文件
Properties prop = new Properties();
prop.load(new FileInputStream("jdbc-demo/src/druid.properties"));
//4. 获取连接池对象
DataSource dataSource = DruidDataSourceFactory.createDataSource(prop);
//5. 获取数据库连接 Connection
Connection connection = dataSource.getConnection();
System.out.println(connection); //获取到了连接后就可以继续做其他操作了
//System.out.println(System.getProperty("user.dir"));
}
}注意最后注释掉的那行 System.getProperty("user.dir"),它是用来排查路径问题的:相对路径 jdbc-demo/src/druid.properties 是相对于"工作目录"的,而工作目录在 IDEA 里默认是项目根目录,在命令行里是你执行 java 命令的目录。 两者不一致,FileInputStream 就抛 FileNotFoundException。打印一下 user.dir 就知道到底从哪开始找。
10.5.5 配置文件为什么必须放在 src 根目录
这是原文简单带过、但考试特别爱问的一个细节。
【补充】
druid.properties放在src根目录,原因有两层:第一层:为了用 classpath 相对路径读取,而不是文件系统绝对路径。 项目编译后,
src根目录下的非 Java 文件会被原样复制到target/classes(或 IDEA 的out/production/classes)的根目录下。这个目录就是 classpath 的根。于是可以这样读:InputStream is = DruidDemo.class.getClassLoader().getResourceAsStream("druid.properties");用
getResourceAsStream("druid.properties")(前面不能加/,加了就是从 classpath 根开始的绝对路径,效果一样,但语义不同)读,与项目在哪个盘、哪个目录、谁的工作目录无关,打成 jar 包之后照样能读到,因为配置文件也被打进 jar 了。而原文的
new FileInputStream("jdbc-demo/src/druid.properties")是文件系统路径,一旦项目改名、换目录、打包成 jar 就失效了。生产代码不要这么写。第二层:约定优于配置。 Druid 本身不强制文件名和位置,但"配置文件放
src根目录、取名druid.properties"是整个生态(包括后面的 MyBatis、Spring Boot)的通行约定,工具类里硬编码这个名字时不用解释。补充一个打包后容易出错的地方:如果配置文件放在
src外面的目录(比如项目根目录),它不会被拷进target/classes,getResourceAsStream就返回null,接着抛NullPointerException。判断方法:编译后在target/classes里看一眼有没有这个文件。
10.5.6 封装 JDBCUtils 工具类
现在把"加载配置、拿连接、关资源"这三件事收进一个类。这个类本章后面所有 DAO 都要用,也是第 11 章的基础设施。
import com.alibaba.druid.pool.DruidDataSourceFactory;
import javax.sql.DataSource;
import java.io.IOException;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Properties;
/**
* JDBC 工具类
* 1. 提供获取连接的方法
* 2. 提供释放资源的方法
* 3. 提供获取连接池对象的方法
*/
public class JDBCUtils {
// 连接池对象:整个应用只需要一个,用 static 保存
private static DataSource dataSource;
// 静态代码块:类加载时执行一次,只加载一次配置文件
static {
try {
// 1. 加载配置文件(classpath 相对路径,配置在 src 根目录)
Properties prop = new Properties();
InputStream is = JDBCUtils.class.getClassLoader()
.getResourceAsStream("druid.properties");
prop.load(is);
// 2. 获取连接池对象
dataSource = DruidDataSourceFactory.createDataSource(prop);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 获取连接池对象
*/
public static DataSource getDataSource() {
return dataSource;
}
/**
* 获取连接
*/
public static Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
/**
* 释放资源(先开后关:rs → stmt → conn)
* 三个参数都可以传 null,方法内部做了判空
*/
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();
}
}
}
/**
* 重载:只关连接(增删改场景没有 ResultSet)
*/
public static void close(Statement stmt, Connection conn) {
close(null, stmt, conn);
}
}三个设计要点值得说清楚:
DataSource必须是static的。连接池里存着 N 个已经建好的物理连接,如果每次调用都createDataSource(prop)新建一个池子,等于每个方法都重新建 N 个连接,比不用池还慢,而且很快打满max_connections。整个应用只能有一个池子。- 加载配置文件要放在
static代码块里。类加载时执行且只执行一次,天然保证"只初始化一次池子"。 getResourceAsStream读配置,这样打包成 jar 也能跑(见 10.5.5)。
10.5.7 Druid 常用配置参数
druid.properties 里除了原文那五项,还有几个参数值得知道:
| 参数 | 含义 | 参考值 | 说明 |
|---|---|---|---|
driverClassName | 驱动类名 | com.mysql.cj.jdbc.Driver | MySQL 8 的包名,别写成 5.x 的 |
url | 连接地址 | jdbc:mysql:///db1?useSSL=false | 参数用 & 连接 |
username / password | 账号密码 | root / 1234 | |
initialSize | 初始化连接数量 | 5 | 池子一创建就先建 5 个 |
maxActive | 最大连接数 | 10 | 借完 10 个后再借就要等 |
maxWait | 最大等待时间(毫秒) | 3000 | 等 3 秒还借不到就抛 GetConnectionTimeoutException |
minIdle | 最小空闲连接数 | 5 | 空闲少于它就开始补 |
timeBetweenEvictionRunsMillis | 检测空闲连接的间隔 | 60000 | 配合 minEvictableIdleTimeMillis 淘汰空闲连接 |
【易错点】三个连接池相关的报错,看到就知道该改哪个参数:
报错信息 原因 改法 GetConnectionTimeoutException: wait millis 3000, active 10, maxActive 10连接全被借走没归还(多半是漏了 close())查 close(),或临时调大maxActiveFileNotFoundException: druid.properties路径不对,或没放 src根目录改用 getResourceAsStream,确认编译后classes下有这个文件ClassNotFoundException: com.mysql.cj.jdbc.DriverdriverClassName与 jar 版本不匹配5.x jar 配 com.mysql.jdbc.Driver,8.x 配com.mysql.cj.jdbc.Driver特别注意第一条:
active 10, maxActive 10的意思是"池子里 10 个连接全借出去了,一个都没还"。这说明代码里有连接泄漏,调大maxActive只是掩盖问题,正确做法是检查每个方法是否在finally里关了连接。
10.6 综合练习:tb_brand 的增删改查
10.6.1 需求(原文 5.1)
完成商品品牌数据的增删改查操作:
- 查询:查询所有数据;
- 添加:添加品牌;
- 修改:根据 id 修改;
- 删除:根据 id 删除。
这四件事对应 SQL 里的 select、insert、update、delete,也就是常说的 CRUD。它们是所有业务系统里出现频率最高的四种操作,把这四个方法写熟,后面无论换什么框架都是在重复这个模式。
10.6.2 环境准备:建表与初始数据(原文 5.2.1)
数据库表 tb_brand:
-- 删除tb_brand表
drop table if exists tb_brand;
-- 创建tb_brand表
create table tb_brand (
-- id 主键
id int primary key auto_increment,
-- 品牌名称
brand_name varchar(20),
-- 企业名称
company_name varchar(20),
-- 排序字段
ordered int,
-- 描述信息
description varchar(100),
-- 状态:0:禁用 1:启用
status int
);
-- 添加数据
insert into tb_brand (brand_name, company_name, ordered, description, status)
values ('三只松鼠', '三只松鼠股份有限公司', 5, '好吃不上火', 0),
('华为', '华为技术有限公司', 100, '华为致力于把数字世界带入每个人、每个家庭、每个组织,构建万物互联的智能世界', 1),
('小米', '小米科技有限公司', 50, 'are you ok', 1);三个细节值得注意:
id int primary key auto_increment:主键自增。所以添加数据时不用给 id 赋值,MySQL 自己生成。brand_name、company_name用的是下划线命名,而 Java 实体类里是驼峰brandName、companyName。数据库与 Java 的命名风格不同,这是常态,映射关系靠rs.getString("brand_name")和brand.setBrandName(...)这一对语句来建立。description varchar(100)里华为那条描述有 40 多个汉字,接近上限。如果插入报Data too long for column 'description',就是字段长度不够,改varchar(500)即可。
10.6.3 实体类 Brand(原文 5.2.1)
在 pojo 包下定义实体类 Brand。实体类的作用是承载一行数据:一个 Brand 对象对应 tb_brand 表里的一行记录,对象的每个属性对应一个字段。
/**
* 品牌
* alt + 鼠标左键:整列编辑
* 在实体类中,基本数据类型建议使用其对应的包装类型
*/
public class Brand {
// id 主键
private Integer id;
// 品牌名称
private String brandName;
// 企业名称
private String companyName;
// 排序字段
private Integer ordered;
// 描述信息
private String description;
// 状态:0:禁用 1:启用
private Integer status;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getBrandName() {
return brandName;
}
public void setBrandName(String brandName) {
this.brandName = brandName;
}
public String getCompanyName() {
return companyName;
}
public void setCompanyName(String companyName) {
this.companyName = companyName;
}
public Integer getOrdered() {
return ordered;
}
public void setOrdered(Integer ordered) {
this.ordered = ordered;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getStatus() {
return status;
}
public void setStatus(Integer status) {
this.status = status;
}
@Override
public String toString() {
return "Brand{" +
"id=" + id +
", brandName='" + brandName + '\'' +
", companyName='" + companyName + '\'' +
", ordered=" + ordered +
", description='" + description + '\'' +
", status=" + status +
'}';
}
}注释里那句"在实体类中,基本数据类型建议使用其对应的包装类型"是这一节的精华,原因在 10.3.9 节已经讲过:数据库字段可以为 NULL,int 表达不了 NULL。Integer 的默认值是 null,正好对应"数据库里没值";而 int 的默认值是 0,会和真实的 0 混淆。
10.6.4 查询所有(原文 5.2.2)
先想清楚三件事再动手,这是原文每个案例开头都写的注释格式,非常值得学:
- SQL:
select * from tb_brand; - 参数:不需要;
- 结果:
List<Brand>
/**
* 查询所有
* 1. SQL:select * from tb_brand;
* 2. 参数:不需要
* 3. 结果:List<Brand>
*/
@Test
public void testSelectAll() throws Exception {
//1. 获取Connection
//3. 加载配置文件
Properties prop = new Properties();
prop.load(new FileInputStream("jdbc-demo/src/druid.properties"));
//4. 获取连接池对象
DataSource dataSource = DruidDataSourceFactory.createDataSource(prop);
//5. 获取数据库连接 Connection
Connection conn = dataSource.getConnection();
//2. 定义SQL
String sql = "select * from tb_brand;";
//3. 获取pstmt对象
PreparedStatement pstmt = conn.prepareStatement(sql);
//4. 设置参数
//5. 执行SQL
ResultSet rs = pstmt.executeQuery();
//6. 处理结果 List<Brand> 封装Brand对象,装载List集合
Brand brand = null;
List<Brand> brands = new ArrayList<>();
while (rs.next()) {
//获取数据
int id = rs.getInt("id");
String brandName = rs.getString("brand_name");
String companyName = rs.getString("company_name");
int ordered = rs.getInt("ordered");
String description = rs.getString("description");
int status = rs.getInt("status");
//封装Brand对象
brand = new Brand();
brand.setId(id);
brand.setBrandName(brandName);
brand.setCompanyName(companyName);
brand.setOrdered(ordered);
brand.setDescription(description);
brand.setStatus(status);
//装载集合
brands.add(brand);
}
System.out.println(brands);
//7. 释放资源
rs.close();
pstmt.close();
conn.close();
}【易错点】这段代码里
Brand brand = null;声明在while外面,brand = new Brand();在while里面,位置不能换。如果new Brand()也提到外面,brands里存的六个元素会指向同一个对象,打印出来是六个"小米"。这是集合封装最经典的 bug,判卷时一眼就能看出来。反过来,如果在while里写Brand brand = new Brand();(声明在内部),也是对的,而且更不容易错。
10.6.5 添加品牌(原文 5.2.3)
- SQL:
insert into tb_brand(brand_name, company_name, ordered, description, status) values(?,?,?,?,?); - 参数:需要,除了 id 之外的所有参数信息(id 是自增的,数据库自己生成);
- 结果:
boolean
/**
* 添加
* 1. SQL:insert into tb_brand(brand_name, company_name, ordered, description, status) values(?,?,?,?,?);
* 2. 参数:需要,除了id之外的所有参数信息
* 3. 结果:boolean
*/
@Test
public void testAdd() throws Exception {
// 接收页面提交的参数
String brandName = "香飘飘";
String companyName = "香飘飘";
int ordered = 1;
String description = "绕地球一圈";
int status = 1;
//1. 获取Connection
//3. 加载配置文件
Properties prop = new Properties();
prop.load(new FileInputStream("jdbc-demo/src/druid.properties"));
//4. 获取连接池对象
DataSource dataSource = DruidDataSourceFactory.createDataSource(prop);
//5. 获取数据库连接 Connection
Connection conn = dataSource.getConnection();
//2. 定义SQL
String sql = "insert into tb_brand(brand_name, company_name, ordered, description, status) values(?,?,?,?,?);";
//3. 获取pstmt对象
PreparedStatement pstmt = conn.prepareStatement(sql);
//4. 设置参数
pstmt.setString(1, brandName);
pstmt.setString(2, companyName);
pstmt.setInt(3, ordered);
pstmt.setString(4, description);
pstmt.setInt(5, status);
//5. 执行SQL
int count = pstmt.executeUpdate(); // 影响的行数
//6. 处理结果
System.out.println(count > 0);
//7. 释放资源
pstmt.close();
conn.close();
}count > 0 打印 true 表示插入成功。注意添加、修改、删除用的是 executeUpdate() 而不是 executeQuery(),因为它们是 DML 语句,不返回结果集,只返回受影响的行数。
如果想知道新插入记录的 id(比如插入订单后要拿订单号去做后续操作),用这个写法:
PreparedStatement pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
pstmt.executeUpdate();
ResultSet keys = pstmt.getGeneratedKeys(); // 取出数据库生成的主键
if (keys.next()) {
int newId = keys.getInt(1);
System.out.println("新插入的品牌 id 是:" + newId);
}10.6.6 修改品牌(原文 5.2.4)
- SQL:
update tb_brand
set brand_name = ?,
company_name = ?,
ordered = ?,
description = ?,
status = ?
where id = ?- 参数:需要,所有数据(6 个
?,最后一个是 id); - 结果:
boolean
/**
* 修改
* 1. SQL:update tb_brand set brand_name = ?, company_name = ?, ordered = ?, description = ?, status = ? where id = ?
* 2. 参数:需要,所有数据
* 3. 结果:boolean
*/
@Test
public void testUpdate() throws Exception {
// 接收页面提交的参数
String brandName = "香飘飘";
String companyName = "香飘飘";
int ordered = 1000;
String description = "绕地球三圈";
int status = 1;
int id = 4;
//1. 获取Connection
//3. 加载配置文件
Properties prop = new Properties();
prop.load(new FileInputStream("jdbc-demo/src/druid.properties"));
//4. 获取连接池对象
DataSource dataSource = DruidDataSourceFactory.createDataSource(prop);
//5. 获取数据库连接 Connection
Connection conn = dataSource.getConnection();
//2. 定义SQL
String sql = " update tb_brand\n" +
" set brand_name = ?,\n" +
" company_name= ?,\n" +
" ordered = ?,\n" +
" description = ?,\n" +
" status = ?\n" +
" where id = ?";
//3. 获取pstmt对象
PreparedStatement pstmt = conn.prepareStatement(sql);
//4. 设置参数
pstmt.setString(1, brandName);
pstmt.setString(2, companyName);
pstmt.setInt(3, ordered);
pstmt.setString(4, description);
pstmt.setInt(5, status);
pstmt.setInt(6, id);
//5. 执行SQL
int count = pstmt.executeUpdate(); // 影响的行数
//6. 处理结果
System.out.println(count > 0);
//7. 释放资源
pstmt.close();
conn.close();
}【易错点】修改数据最容易犯的错是忘了写
where,这一条比 SQL 写错更常见。update tb_brand set status = 0少一个where id = ?,整张表的品牌全部被禁用,而且 MySQL 默认自动提交、没有回滚的机会。三个习惯可以救命:
- 写
update/delete时先写where,再回头写set;- 在测试库执行前,先把同一条
where套进select count(*)里看一眼命中几行;- 重要操作包在事务里(
setAutoCommit(false)+ 确认后commit)。还有一个隐蔽的错:
pstmt.setInt(6, id)的编号写成了 5,与status撞车,MySQL 不报错,但会抛SQLException: No value specified for parameter 6。参数个数必须与?的个数严格一致。
10.6.7 根据 id 删除(原文 5.2.5)
- SQL:
delete from tb_brand where id = ? - 参数:需要,id;
- 结果:
boolean
/**
* 删除
* 1. SQL:delete from tb_brand where id = ?
* 2. 参数:需要,id
* 3. 结果:boolean
*/
@Test
public void testDeleteById() throws Exception {
// 接收页面提交的参数
int id = 4;
//1. 获取Connection
//3. 加载配置文件
Properties prop = new Properties();
prop.load(new FileInputStream("jdbc-demo/src/druid.properties"));
//4. 获取连接池对象
DataSource dataSource = DruidDataSourceFactory.createDataSource(prop);
//5. 获取数据库连接 Connection
Connection conn = dataSource.getConnection();
//2. 定义SQL
String sql = " delete from tb_brand where id = ?";
//3. 获取pstmt对象
PreparedStatement pstmt = conn.prepareStatement(sql);
//4. 设置参数
pstmt.setInt(1, id);
//5. 执行SQL
int count = pstmt.executeUpdate(); // 影响的行数
//6. 处理结果
System.out.println(count > 0);
//7. 释放资源
pstmt.close();
conn.close();
}到这里,四个方法都写完了,但把它们放在一起看就会发现一个问题:每个方法开头都有那四行连接池代码,结尾都有两到三行 close(),中间才是真正干活的十几行。重复率超过 50%。 而且每个方法都 DruidDataSourceFactory.createDataSource(prop) 一次,等于每次都新建一个连接池。这正是 10.5.6 节 JDBCUtils 要解决的问题。
10.6.8 用 JDBCUtils 改写
用工具类重写"查询所有",对比一下:
/**
* 查询所有(工具类版本)
*/
@Test
public void testSelectAllByUtils() {
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
List<Brand> brands = new ArrayList<>();
try {
//1. 获取连接(工具类一行搞定)
conn = JDBCUtils.getConnection();
//2. 定义SQL
String sql = "select * from tb_brand";
//3. 获取pstmt对象
pstmt = conn.prepareStatement(sql);
//4. 执行SQL
rs = pstmt.executeQuery();
//5. 处理结果
while (rs.next()) {
Brand brand = new Brand();
brand.setId(rs.getInt("id"));
brand.setBrandName(rs.getString("brand_name"));
brand.setCompanyName(rs.getString("company_name"));
brand.setOrdered(rs.getInt("ordered"));
brand.setDescription(rs.getString("description"));
brand.setStatus(rs.getInt("status"));
brands.add(brand);
}
} catch (SQLException e) {
e.printStackTrace();
} finally {
//6. 释放资源:先开后关
JDBCUtils.close(rs, pstmt, conn);
}
System.out.println(brands);
}"添加"用工具类改写后,可以短到这样:
@Test
public void testAddByUtils() {
String sql = "insert into tb_brand(brand_name, company_name, ordered, description, status) values(?,?,?,?,?)";
try (Connection conn = JDBCUtils.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, "香飘飘");
pstmt.setString(2, "香飘飘");
pstmt.setInt(3, 1);
pstmt.setString(4, "绕地球一圈");
pstmt.setInt(5, 1);
int count = pstmt.executeUpdate();
System.out.println(count > 0);
} catch (SQLException e) {
e.printStackTrace();
}
}对比一下:原来 40 多行,现在 15 行;而且没有任何一行是"连接管理"的样板代码,全部注意力都在 SQL 和参数上。这就是封装的价值:把"不变的"抽走,只留下"会变的"。
【对比】三处改进前后对照,一眼看出工具类的收益:
关注点 改进前(每个方法手写) 改进后(JDBCUtils) 获取连接 4 行(加载配置 + 建池 + 取连接) 1 行 JDBCUtils.getConnection()连接池创建次数 每个方法 1 次(严重错误) 全应用 1 次(静态代码块) 释放资源 2~3 行 close(),异常时不执行1 行 JDBCUtils.close(...),在finally里配置文件路径 硬编码文件系统路径,打包后失效 getResourceAsStream,jar 内可读方法行数 约 45 行 约 15 行
10.7 分层与 DAO 模式
10.7.1 问题:SQL 该住在哪儿
10.6 节写完,四个测试方法都能跑通。但请想象第 11 章的场面:品牌管理要做"查询所有""添加""修改""回显""删除"五个功能,每个功能对应一个 Servlet。如果按 10.6 的写法,每个 Servlet 里都会出现这么一段:
String sql = "select * from tb_brand";
PreparedStatement pstmt = conn.prepareStatement(sql);
ResultSet rs = pstmt.executeQuery();
while (rs.next()) {
Brand brand = new Brand();
brand.setId(rs.getInt("id"));
// ……再抄 12 行赋值
}五个功能 = 五份几乎一样的复制粘贴。问题随之而来:
- 改一处要改五处:表加了一个字段
brand_logo,五个 Servlet 全要加一行brand.setBrandLogo(rs.getString("brand_logo")),漏一个就是线上 bug; - Servlet 被 SQL 淹没:Servlet 的职责本来是"取参数、调业务、转发页面",现在一半代码在拼 SQL、关连接,业务逻辑根本看不清;
- 没法测试:想单独测"查询所有"这段逻辑,必须启动 Tomcat、发 HTTP 请求,因为 SQL 和 Servlet 焊死在一起了;
- 没法复用:第 11 章另一个地方也想查品牌列表,只能再抄一遍。
这就是"分层"要解决的问题:把"数据怎么存"和"页面怎么展示"彻底分开。
10.7.2 DAO 模式是什么
DAO(Data Access Object,数据访问对象)模式:把对某一张表的全部数据库操作(增删改查)集中到一个类里,外界只调用这个类的方法,不关心 SQL 长什么样、连接怎么关。
它由两部分组成,接口 + 实现类:
com.itheima
├── pojo
│ └── Brand.java ← 实体类:一行数据
├── dao
│ ├── BrandDao.java ← 接口:定义"能做什么"
│ └── impl
│ └── BrandDaoImpl.java ← 实现类:定义"怎么做"(SQL 全在这里)
└── util
└── JDBCUtils.java ← 工具类:连接与释放为什么要有接口?因为第 11 章里 Servlet 只依赖 BrandDao 这个接口。将来如果换成 MyBatis 实现、或者换一个假的实现做单元测试,Servlet 一行都不用改。依赖抽象而不是依赖实现,这是 10.1 节 JDBC 本身就在示范的道理:你面向 Connection 接口编程,MySQL 驱动在运行时注入。
10.7.3 BrandDao 接口
接口里只写"能做什么",方法签名要能覆盖 10.6 节的全部需求:
import com.itheima.pojo.Brand;
import java.util.List;
/**
* 品牌 Dao 接口
* 针对 tb_brand 表的 CRUD 操作
*/
public interface BrandDao {
/**
* 查询所有品牌
* @return 品牌列表,查不到时返回空集合而不是 null
*/
List<Brand> selectAll();
/**
* 根据 id 查询品牌(修改前的回显要用)
* @param id 主键
* @return 品牌对象,查不到返回 null
*/
Brand selectById(int id);
/**
* 添加品牌
* @param brand 品牌对象(id 由数据库自增生成,不用传)
* @return 是否添加成功
*/
boolean add(Brand brand);
/**
* 修改品牌
* @param brand 品牌对象(必须带 id)
* @return 是否修改成功
*/
boolean update(Brand brand);
/**
* 根据 id 删除品牌
* @param id 主键
* @return 是否删除成功
*/
boolean deleteById(int id);
}方法签名有三个约定,值得记住:
selectAll返回List<Brand>,永远不返回null。查不到就返回空集合new ArrayList<>(),这样调用方可以放心for循环和size(),不用到处判空。selectById查不到返回null,因为"一个对象"没有"空对象"的自然表示,用null表达"没有"是 Java 的惯例。- 增删改返回
boolean,语义是"影响行数是否大于 0"。
10.7.4 BrandDaoImpl 实现类
实现类的每个方法都是 10.6 节测试方法的"去重版":去掉 @Test,把写死的参数换成方法入参,把 System.out.println 换成返回值,把连接释放交给 finally。
import com.itheima.pojo.Brand;
import com.itheima.util.JDBCUtils;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
/**
* 品牌 Dao 实现类
* 所有 SQL 集中在这里,Servlet 不需要知道表结构
*/
public class BrandDaoImpl implements BrandDao {
@Override
public List<Brand> selectAll() {
List<Brand> brands = new ArrayList<>();
String sql = "select * from tb_brand";
try (Connection conn = JDBCUtils.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql);
ResultSet rs = pstmt.executeQuery()) {
while (rs.next()) {
brands.add(rsToBrand(rs));
}
} catch (SQLException e) {
e.printStackTrace();
}
return brands; // 查不到时是空集合,不是 null
}
@Override
public Brand selectById(int id) {
Brand brand = null;
String sql = "select * from tb_brand where id = ?";
try (Connection conn = JDBCUtils.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setInt(1, id);
try (ResultSet rs = pstmt.executeQuery()) {
if (rs.next()) {
brand = rsToBrand(rs);
}
}
} catch (SQLException e) {
e.printStackTrace();
}
return brand;
}
@Override
public boolean add(Brand brand) {
String sql = "insert into tb_brand(brand_name, company_name, ordered, description, status) values(?,?,?,?,?)";
try (Connection conn = JDBCUtils.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, brand.getBrandName());
pstmt.setString(2, brand.getCompanyName());
pstmt.setInt(3, brand.getOrdered());
pstmt.setString(4, brand.getDescription());
pstmt.setInt(5, brand.getStatus());
return pstmt.executeUpdate() > 0;
} catch (SQLException e) {
e.printStackTrace();
return false;
}
}
@Override
public boolean update(Brand brand) {
String sql = "update tb_brand set brand_name = ?, company_name = ?, ordered = ?, description = ?, status = ? where id = ?";
try (Connection conn = JDBCUtils.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setString(1, brand.getBrandName());
pstmt.setString(2, brand.getCompanyName());
pstmt.setInt(3, brand.getOrdered());
pstmt.setString(4, brand.getDescription());
pstmt.setInt(5, brand.getStatus());
pstmt.setInt(6, brand.getId());
return pstmt.executeUpdate() > 0;
} catch (SQLException e) {
e.printStackTrace();
return false;
}
}
@Override
public boolean deleteById(int id) {
String sql = "delete from tb_brand where id = ?";
try (Connection conn = JDBCUtils.getConnection();
PreparedStatement pstmt = conn.prepareStatement(sql)) {
pstmt.setInt(1, id);
return pstmt.executeUpdate() > 0;
} catch (SQLException e) {
e.printStackTrace();
return false;
}
}
/**
* 把结果集当前行封装成 Brand 对象
* 抽成私有方法,避免五处重复
*/
private Brand rsToBrand(ResultSet rs) throws SQLException {
Brand brand = new Brand();
brand.setId(rs.getInt("id"));
brand.setBrandName(rs.getString("brand_name"));
brand.setCompanyName(rs.getString("company_name"));
brand.setOrdered(rs.getObject("ordered") == null ? null : rs.getInt("ordered"));
brand.setDescription(rs.getString("description"));
brand.setStatus(rs.getObject("status") == null ? null : rs.getInt("status"));
return brand;
}
}这个实现类里有五个设计细节,都直接影响第 11 章的顺利程度:
rsToBrand(rs)私有方法:原来要抄五遍的 12 行赋值,现在只有一份。表结构变化时只改这一处。这就是"改一处"的兑现。try-with-resources三件套:Connection、PreparedStatement、ResultSet按声明顺序打开、按倒序关闭,等价于手写finally里的"先开后关"(见 10.3.11)。rs.getObject(...) == null判断:对应 10.3.9 节讲的 NULL 问题。ordered和status是Integer,数据库里可能为 NULL,不能直接用getInt把 null 变成 0。- SQL 语句写成局部变量:不要拼字符串,不要在
pstmt.prepareStatement里直接塞一长串字面量。写成String sql = "..."的好处是报错时能System.out.println(sql)打印出来看,而且 IDE 有 SQL 语法高亮。 - 异常处理
catch之后返回兜底值:selectAll返回空集合,增删改返回false。不要让异常穿过 DAO 抛给 Servlet,因为 Servlet 层不懂SQLException,它只想知道"成功没成功"。
【补充】这里刻意没有把
SQLException继续往上抛,是"教学版"的选择,好处是调用方简单,坏处是丢失了错误细节。工程上更规范的做法是:DAO 层把SQLException包成自定义运行时异常再抛,比如throw new RuntimeException("查询品牌失败", e),由最外层的 Filter 或全局异常处理器统一处理。"不吞异常、不裸抛受检异常" 是一条值得记住的工程准则。本章为了和第 11 章的 JSP 直连风格衔接,采用"捕获 + 兜底返回值"。
10.7.5 DAO 在三层架构里的位置
加上 DAO 之后,整条链路变成四层,每一层只跟相邻的层说话:
浏览器
│ HTTP 请求(brandName=华为&ordered=100)
▼
Servlet(第 5~7 章) ← 只管取参数、调方法、转发页面
│ Brand brand = new Brand(); …… brandDao.add(brand);
▼
BrandDao(本章 10.7) ← 只管 SQL 与结果集映射,不管请求不管页面
│ insert into tb_brand(...) values(?,?,?,?,?)
▼
MySQL(数据)用代码看 Servlet 那一侧的变化。没有 DAO 时,Servlet 要自己写 SQL、自己关连接,六十行里四十行是 JDBC;有了 DAO 之后,Servlet 的 doPost 长这样:
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 1. 接收参数
String brandName = request.getParameter("brandName");
String companyName = request.getParameter("companyName");
String orderedStr = request.getParameter("ordered");
String description = request.getParameter("description");
String statusStr = request.getParameter("status");
// 2. 封装对象
Brand brand = new Brand();
brand.setBrandName(brandName);
brand.setCompanyName(companyName);
brand.setOrdered(Integer.parseInt(orderedStr));
brand.setDescription(description);
brand.setStatus(Integer.parseInt(statusStr));
// 3. 调 DAO(一行)
BrandDao brandDao = new BrandDaoImpl();
boolean success = brandDao.add(brand);
// 4. 根据结果跳转
if (success) {
response.sendRedirect(request.getContextPath() + "/brand?method=selectAll");
} else {
request.setAttribute("error", "添加失败");
request.getRequestDispatcher("/addBrand.jsp").forward(request, response);
}
}注意 request.getParameter 拿到的是 String,而 ordered、status 是 int,所以需要 Integer.parseInt 转换。这里有一个必然会遇到的错误:如果用户没填 ordered,getParameter 返回 null,Integer.parseInt(null) 直接抛 NumberFormatException。第 11 章会在 Servlet 里做空值校验,这也是"参数校验属于 Servlet 层、类型转换属于 Servlet 层、SQL 属于 DAO 层"的分工体现。
10.7.6 这套 DAO 在第 11 章怎么被复用
第 11 章"从页面到数据库贯通"会把本章所有零件拼起来,复用关系如下:
| 第 11 章要做的功能 | 直接复用本章的什么 | 是否需要改 |
|---|---|---|
| 品牌列表页 | BrandDaoImpl.selectAll() + Brand 实体 | 不用改,直接调 |
| 添加品牌 | BrandDaoImpl.add(brand) | 不用改 |
| 修改品牌(回显) | BrandDaoImpl.selectById(id) | 不用改 |
| 修改品牌(提交) | BrandDaoImpl.update(brand) | 不用改 |
| 删除品牌 | BrandDaoImpl.deleteById(id) | 不用改 |
| 所有 Servlet 取连接 | JDBCUtils.getConnection() | 不用改 |
页面上的 ${brand.brandName} | Brand 的 getter(EL 靠 getter 取值) | 不用改 |
| 过滤器统一编码 | 与本章无关,第 9 章内容 | 无 |
第 11 章真正要新写的只有三样:Servlet(接收参数、调 DAO、转发)、JSP(用 EL/JSTL 渲染)、过滤器(解决中文乱码)。数据访问层一行都不用动。这就是分层的回报。
【考点】"为什么要分层 / DAO 模式的好处"标准答法四条:职责单一(Servlet 管请求,DAO 管数据)、代码复用(同一张表的操作只写一次)、易于维护(改表结构只改 DAO 一处)、便于测试与替换(面向接口,可换实现、可 mock)。答题时每条配一句具体场景,比干背词条得分高。
10.7.7 从测试方法到工程代码:迁移清单
如果你想把 10.6 的四个 @Test 方法"升级"成 10.7 的 DAO,按这个清单逐条改,一条都不会漏:
- 删掉
@Test注解,方法改成public并加@Override; - 参数从"方法内写死"改成"方法入参"(
int id、Brand brand); - 返回值从
System.out.println(count > 0)改成return count > 0; - 连接代码从四行换成
JDBCUtils.getConnection(); - 结尾的
close()换成try-with-resources(或finally里的JDBCUtils.close); - 结果集遍历的赋值代码抽成
rsToBrand(rs); - 异常从
throws Exception改成catch (SQLException e)+ 兜底返回; - 把所有方法搬进
BrandDaoImpl,再补一个BrandDao接口。
本章小结
本章从"数据在 MySQL 里"这个事实出发,把 Java 与数据库之间的桥搭了起来。用一张表收口:
| 知识点 | 结论 | 关键 API / 写法 |
|---|---|---|
| JDBC 是什么 | Sun 定义的操作关系型数据库的接口标准,驱动是厂商给的实现 | java.sql.* |
| 七步流程 | 注册、连接、定 SQL、取对象、执行、处理、关闭 | DriverManager → Connection → Statement |
| 注册驱动 | MySQL 5+ 可省略(SPI 自动加载);Class.forName 只是触发静态代码块 | com.mysql.cj.jdbc.Driver |
| 连接 URL | jdbc:mysql://ip:port/库名?参数,本机可写 jdbc:mysql:///库名 | useSSL=false、serverTimezone |
| 事务 | setAutoCommit(false) 开启,commit() 提交,rollback() 回滚 | 转账案例、ACID |
| Statement | 执行静态 SQL,executeUpdate 管 DDL/DML,executeQuery 管 DQL | 有注入风险 |
| ResultSet | 光标在首行之前,next() 下移并判有效,getXxx 取值(列号从 1 开始) | rs.getInt("id") |
| PreparedStatement | ? 占位 + setXxx 赋值 + 执行时不传 SQL | 防注入 + 预编译 |
| SQL 注入 | 用户输入被当成语法;' or '1'='1 让条件恒真 | 参数分离才能根治 |
| 连接池 | DataSource 标准接口,复用连接,避免反复握手认证 | Druid、initialSize/maxActive/maxWait |
| 工具类 | 连接池 static 一次创建,getResourceAsStream 读配置 | JDBCUtils |
| DAO | 一张表一个接口 + 一个实现,SQL 与页面彻底分离 | BrandDao / BrandDaoImpl |
再用六条要点把"最容易忘"的钉住:
- 释放顺序是"先开后关":
rs → pstmt → conn,且必须在finally或try-with-resources里。 - 列编号从 1 开始,不是 0。数组习惯在这里会害你。
getInt遇到 NULL 返回 0,要区分 NULL 就用getObject或wasNull();实体类用包装类型。PreparedStatement防注入靠"预编译 + 参数分离",转义只是实现细节;?不能占位表名和列名。- 连接池的
close()是归还不是断开,但依然必须关,否则池子会被借空(active 10, maxActive 10)。 druid.properties放src根目录,是为了用 classpath 相对路径读,打包后仍可读。
最后回答"这章的知识在下章哪里被用到":第 11 章的品牌管理综合案例,每一个功能都是本章零件的重新组装:JDBCUtils 提供连接,Brand 承载数据,BrandDao 执行 SQL,Servlet 只负责把 request 里的参数封装进 Brand、把 Brand 交给 DAO、把 List<Brand> 放进 request 域,最后 JSP 用 <c:forEach> 渲染成表格。本章写的是"基础层",第 11 章盖的是"业务层"。
自测
基础题
1. 写出 JDBC 操作数据库的七个步骤,并说明每一步用到的核心类或方法。
2. 下面这行代码在 MySQL 5 之后为什么可以注释掉?注释掉之后驱动是怎么被注册的?
Class.forName("com.mysql.jdbc.Driver");3. 阅读代码,写出运行结果,并说明数据库里 account 表发生了什么变化:
String url = "jdbc:mysql:///db1?useSSL=false";
Connection conn = DriverManager.getConnection(url, "root", "1234");
String sql = "update account set money = 3000 where id = 1";
Statement stmt = conn.createStatement();
int count = stmt.executeUpdate(sql);
System.out.println(count);
stmt.close();
conn.close();4. 判断对错并说明理由:ResultSet 的 getXxx(int) 方法中,列编号从 0 开始,和数组一样。
5. 写出 Statement 与 PreparedStatement 在"获取方式、执行方法、是否预编译、能否防注入"四个方面的区别。
进阶题
6. 下面的登录代码存在 SQL 注入漏洞,请指出漏洞点、写出攻击者会输入的密码,并改成安全的写法:
String sql = "select * from tb_user where username = '" + name + "' and password = '" + pwd + "'";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
if (rs.next()) {
System.out.println("登录成功");
}7. 找错:下面这段事务代码有三处问题,请指出并改正。
Connection conn = DriverManager.getConnection(url, "root", "1234");
conn.setAutoCommit(false);
Statement stmt = conn.createStatement();
stmt.executeUpdate("update account set money = money - 1000 where id = 1");
int i = 1 / 0;
stmt.executeUpdate("update account set money = money + 1000 where id = 2");
conn.commit();
conn.rollback();
stmt.close();
conn.close();8. 简答:数据库连接池为什么能提升系统响应速度?请从"建立一条 MySQL 连接要花哪些网络往返"的角度说明。
9. 阅读代码,指出 brands 集合最终有几个元素、每个元素是什么,并说明原因:
List<Brand> brands = new ArrayList<>();
Brand brand = new Brand();
while (rs.next()) {
brand.setId(rs.getInt("id"));
brand.setBrandName(rs.getString("brand_name"));
brands.add(brand);
}10. 设计题:tb_brand 表新增一个字段 brand_logo varchar(200),请说明用 DAO 分层的情况下,需要改哪几个文件、每个文件改什么;如果不分层(SQL 写在 Servlet 里),又要改几处?
参考答案
1. ① 导入驱动 jar;② 注册驱动(
Class.forName,MySQL 5+ 可省);③ 获取连接(DriverManager.getConnection(url, user, password));④ 定义 SQL(String sql);⑤ 获取执行对象(conn.createStatement()或conn.prepareStatement(sql));⑥ 执行 SQL(executeUpdate/executeQuery);⑦ 处理结果并释放资源(rs/stmt/conn依次close())。2. 因为 MySQL 5 之后的驱动 jar 里有
META-INF/services/java.sql.Driver文件,DriverManager通过 SPI(ServiceLoader)自动发现并加载驱动类,静态代码块自动完成注册。Class.forName的作用只是"手动加载类以触发静态代码块",SPI 已经代劳了。注意 MySQL 8 的类名是com.mysql.cj.jdbc.Driver。3. 打印
1(受影响 1 行)。数据库里 id=1 的记录money被改成 3000,且因为 MySQL 默认自动提交,立即生效、无法回滚。4. 错。JDBC 的列编号从 1 开始,
getInt(0)会抛SQLException: Column Index out of range。数组和集合才是从 0 开始。5. 获取方式:
createStatement()vsprepareStatement(sql);执行方法:executeUpdate(sql)要传 SQL vsexecuteUpdate()不传;预编译:否 vs 是(模板只编译一次);防注入:不能 vs 能(参数只当值,不参与语法解析)。6. 漏洞点是用字符串拼接把用户输入混进 SQL 语法。攻击者密码输入
' or '1' = '1,拼接后变成... and password = '' or '1' = '1',or '1'='1'恒真,rs.next()返回 true,绕过登录。修法:改成?占位 +setString:String sql = "select * from tb_user where username = ? and password = ?"; PreparedStatement pstmt = conn.prepareStatement(sql); pstmt.setString(1, name); pstmt.setString(2, pwd); ResultSet rs = pstmt.executeQuery();7. ①
setAutoCommit(false)和两条update没放在try里,1/0抛异常后直接跳出方法,没有回滚,第一条 update 会随连接关闭而悬空(连接池场景下可能被自动提交);②commit()之后又调rollback(),逻辑矛盾,会抛异常;③ 没有catch/finally,异常时stmt、conn都不关,连接泄漏。改法:try { setAutoCommit(false); ... commit(); } catch (Exception e) { rollback(); } finally { close(); }。8. 一条连接从零建立要经历:TCP 三次握手(约 1.5 个 RTT)、MySQL 握手与密码认证(至少 2 个 RTT,MySQL 8 的
caching_sha2_password更慢)、会话初始化(字符集、时区等,各 1 个 RTT),合计 3~10 个 RTT、5~50 ms;而执行一条简单 SQL 可能只要 1 ms。连接池在启动时一次性建好 N 条连接,之后请求只是"借/还"(内存操作),把建连开销从"每次请求"摊销成"每个连接一次",所以响应更快,也避免了连接数暴涨打满max_connections。9. 集合里有 N 个元素(等于结果集行数),但全部指向同一个
Brand对象,最终每个元素的内容都是最后一行的数据。因为new Brand()写在while外面,循环里反复set的是同一个对象,add进去的是同一个引用。修法:把Brand brand = new Brand();移到while循环内部。10. 分层时改 3 个文件:
tb_brand建表 SQL(加字段)、Brand.java(加brandLogo属性 + getter/setter)、BrandDaoImpl.java(selectAll/selectById的取值加一行、add/update的 SQL 与setString各加一处;由于封装已抽成rsToBrand,实际只改这一处映射方法)。不分层时,每个涉及品牌的 Servlet 都要改(查询列表、添加、修改回显、修改提交至少 4 处),漏一处就是 bug。这正是 DAO 分层的价值。