实际开发过程中,JDBC编程用到的类和接口并不多,并且编程往往遵循一定的套路,本小节将讲解JDBC编程基本技术。
18.2.1 JDBC常用接口和类简介
JDBC包含一组类和接口,这些类和接口共同构成了JDBC编程的架构体系,这些类和接口位于java.sql包,下面介绍这些类和接口。在介绍这些类和接口时会一并把它们所定义的主要方法以表格形式列出,列出的方法比较多,读者不必记忆这些方法,之后在讲解JDBC编程步骤时会讲解这些方法。
DriverManager:DriverManager是一个类,用于管理JDBC驱动的服务类。程序中使用该类的主要功能是获取Connection对象,该类包含一个静态方法getConnection() ,这个方法用于获得url对应数据库的连接。
Connection:代表数据库连接对象,每个Connection代表一个物理连接会话。要想访问数据库必须先获得数据库连接。Connection是一个接口,这个接口主要包含的主要方法如表18-1所示。
表18-1 Connection接口的主要方法
方法 | 功能 |
---|---|
Statement createStatement() | 返回一个Statement对象 |
PreparedStatement prepareStatement(String sql) |
返回预编译的Statement对象,即将SQL语句提交到数据库进行预编译。 |
CallableStatement prepareCall(String sql) | 返回CallableStatement对象,该对象用于调用存储过程 |
Savepoint setSavepoint() | 创建一个保存点 |
Savepoint setSavepoint(String name) | 以指定名字来创建一个保存点 |
void setTransactionIsolation(int level) | 设置事务的隔离级别 |
void rollback() | 回滚事务 |
void rollback(Savepoint savepoint) | 将事务回滚到指定的保存点 |
void setAutoCommit(boolean autoCommit) | 关闭自动提交,打开事务 |
void commit() | 提交事务 |
setSchema(String schema) | 设置Connection访问的数据库 |
StringgetSchema() | 设置Connection访问的数据库名称 |
Statement:用于执行SQL语句的工具接口,该接口的实现类对象能够执行SQL语句。Statement接口的主要方法如表18-2所示。
表18-2 Statement接口的主要方法
方法 | 功能 |
---|---|
ResultSet executeQuery(String sql) | 执行查询语句,并返回查询结果对应的ResultSet对象,该方法只能用于执行查询语句 |
int executeUpdate(String sql) | 用于执行数据的增加、删除、修改的语句以及定义数据表等数据定义语句 |
boolean execute(Stringsql) | 该方法可执行任何SQL语句。如果执行后第一个结果为ResultSet对象,则返回true,如果执行后第一个结果为受影响的行数或没有任何结果,则返回false |
PreparedStatement:预编译的Statement,PreparedStatement 是Statement的子接口,它允许数据库预编译SQL语句(这些SQL语句通常带有参数),以后每次只改变SQL命令的参数,避免数据库每次都需要编译SQL语句,因此性能更好。相对于Statement 而言,使用PreparedStatement执行SQL语句时,无须再传入SQL语句,只要为预编译的SQL语句传入参数值即可。用于向SQL语句传入参数的方法是setXxx(),Xxx可以用Int、Double、String等数据类型的名称代替,该方法根据传入参数值的类型不同,需要使用不同的方法。
ResultSet:是一个接口,它表示结果集。该接口包含访问查询结果的方法,ResultSet可以通过列索引或列名获得列数据。ResultSet接口的主要方法如表18-3所示。
表18-3 ResultSet接口的主要方法
方法 | 功能 |
---|---|
void close() | 释放ResultSet对象 |
gexXxx(int columnIndex) | 获取记录指针所指向行的第columnIndex列上的值 |
getXxx(StringcolumnLabel) | 获取记录指针所指向行的名称为columnLabel列上的值 |
boolean absolute(int row) | 将结果集的记录指针移动到第row行,如果row是负数,则移动到倒数第row行。如果移动后的记录指针指向一条有效记录,则该方法返回true |
void beforeFirst() | 将ResultSet的记录指针定位到首行之前,这是ResultSet结果集记录指针的初始状态。 |
boolean first() | 将ResultSet的记录指针定位到首行。如果移动后的记录指针指向一条有效记录,则该方法返回true |
boolean previous() | 将ResultSet的记录指针定位到上一行。如果移动后的记录指针指向一条有效记录,则该方法返回true |
boolean next() | 将ResultSet的记录指针定位到下一行,如果移动后的记录指针指向一条有效记录,则该方法返回true |
boolean last() |
将ResultSet的记录指针定位到最后一行,如果移动后的记录指针指向一条有效记录,则该方法返回tru |
void afterLast() | 将ResultSet的记录指针定位到最后一行之后 |
初学的读者可能不太理解这些方法的含义,后面会通过实际案例帮助读者深入理解它们。
18.2.2 JDBC编程的基本步骤
JDBC编程通常按照以下5个步骤进行:
- 加载驱动类
- 建立数据库连接
- 创建Statement对象
- 操作数据库
- 释放资源
JDBC的第一步是加载驱动程序,通常情况下都会调用Class类的forName()静态方法完成驱动程序的加载,关于类的加载以及Class类的使用,本书将在第19章进行详细讲解,此处读者只需要知道驱动类的加载是JDBC编程必不可少的步骤即可。对于MySQL8.0数据库而言,类的加载代码为:
Class.forName("com.mysql.cj.jdbc.Driver");
但是MySQL数据库的驱动类并不是JDK中自带的,而是MySQL数据库厂商开发并维护的,因此需要程序员手动把驱动类添加到CLASSPATH中。数据库驱动类需要很多与之相关的类配合其工作,它们共同形成一个字节码文件体系,这个字节码文件体系一般会被压缩成jar文件,程序员需要把jar文件添加到IDE中才能正确运行程序。本书使用的MySQL版本号是8.0.29,与之配套的驱动类jar文件是mysql-connector-java-8.0.29.jar,读者可以从MySQL官网上下载这个jar文件,也可以直接从本书提供的案例文件夹中下载。
对于IDEA来说,把jar文件添加到CLASSPATH的步骤是:首先在IDEA左侧的面板中用鼠标左键单击项目名称,这样项目就处于选中状态。接下来打开菜单栏选择“File”菜单并选择“New”菜单项,在弹出的子菜单中单击“Directory”新建一个文件夹,通常这种存放第三方jar文件的文件夹一般会命名为“lib”,意为“类库”。之后把本书提供的mysql-connector-java-8.0.29.jar文件复制到lib文件夹下。接下来再次打开“File”菜单并单击“Project Structure”菜单项,在弹出的对话框的左侧选择“Modules”,之后再选择右侧面板中的“Dependencies”,之后单击面板上的“+”按钮,在弹出的选择中单击“1JARs or Directories”如图18-1所示。
图18-1添加jar文件操作步骤
在图18-1所示界面中单击“1JARs or Directories”后会弹出一个文件选择对话框,在这个对话框中找到当前项目lesson18,然后从其lib文件夹中选择之前粘贴的mysql-connector-java-8.0.29.jar文件,如图18-2所示。
图18-2选择jar文件
在图18-2所示的界面上单击“OK”按钮,之后会退回到18-1所示的界面,在那个界面上再次单击“OK”按钮即可完成jar文件的添加。
加载驱动程序后,接下来要建立一个数据库连接。之所以要建立连接是因为应用程序和数据库是相互独立的,应用程序要把SQL语句发送到数据库中执行或者数据库把执行结果传递回应用程序,就必须在二者之间建立一个信息通道,这个信息通道就是数据库连接。在JDBC API中,数据库连接用Connection接口表示,它的对象由DriverManager类的getConnection()静态方法创建,这个方法有三个重载版本,实际开发过程中最常用的一个版本如下:
Connection getConnection(String url, String user, String password) |
---|
可以看到,这个版本的方法有三个参数,其中url表示数据库的地址,user表示用户名,password表示密码。对于MySQL数据库而言,url的格式为:
jdbc:mysql:/ /hostname:port/databasename |
---|
在以上格式中开头的“jdbc”是通信协议名称,“mysql”是子协议名称,这两部分在指明数据库地址时都是固定不变的。接下来的“hostname”表示数据库所在主机名称或IP地址,“port”表示端口号,“databasename”表示数据库名称,这一部分信息要按实际情况填写,如果是连接到本地计算机上的数据库,则可以把数据库名称写为“localhost”。下面的url表示本地计算机上端口号为3306的且数据库名称为demo的地址。
jdbc:mysql://localhost:3306/demo
创建数据库连接时还要提供用户名和密码参数,用户名通常由数据库管理员分配,读者可以用安装数据库时系统内置的超级管理员root作为用户名,密码则是安装数据库时所设置的密码。
当创建好数据库连接,也就是Connection对象后就可以进入JDBC编程的第三步:创建Statement对象。在JDBC API中,Statement是执行SQL语句的对象,读者也可以把它理解为操作数据库的对象。此处所说的Statement对象是一个广义的概念,它由Connection对象调用其方法创建的,总共分为三种,分别是:
- 调用createStatement()方法创建的Statement对象
- 调用prepareStaterment()方法创建的PreparedStatement对象
- 调用prepareCall()方法创建的CallableStatement对象
这三种Statement对象的特性不同,Statement对象能够执行SQL语句,PreparedStatement对象也能执行SQL语句,但与Statement不同的是它有预编译特性,也就是说在执行SQL语句之前PrepareStaterment对象就能提前把要执行SQL语句编译好。CallableStatement对象是专门用于执行存储过程的Statement对象。
创建好Statement对象之后,JDBC的编程就进入了第四步,这一步是通过Statement对象操作数据库,例如对数据库进行增、删、改、查这样的操作。Statement提供了以下三个方法来操作数据库。
- execute():可以执行任何SQL语句。
- executeUpdate():主要用于执行DML和DDL语句。执行DML语句返回受SQL语句影响的行数,执行DDL语句返回0。
- executeQuery():只能执行查询语句,执行后返回代表查询结果的ResultSet对象。
实际开发过程中,执行查询的操作一般都会调用executeQuery()方法完成,这个方法返回一个ResultSet对象。ResultSet表示结果集,程序员必须掌握ResultSet的工作原理才能正确的获取SQL语句所查询到的数据。ResultSet自带一个记录指针,这个指针也被称为“游标”,游标可以指向一行数据,程序员通过ResultSet只能获取游标所指向的那一行数据。当一个ResultSet对象刚产生时,它的游标并不是指向结果集的第一行数据,而是指向第一行数据之上的位置,如图18-3所示。
图18-3初始状态下游标位于第一数据之上
程序员通过调用ResultSet的next()方法就可以把游标向下移动一行,如果移动到下一行后,这一行中有数据,那么这一行就是一条有效记录,在这种情况下next()方法返回true,反之如果这一行没有数据,这一行就是一条无效记录,next()方法会返回false。实际上,与next()方法作用相似是方法有很多,例如previous()方法的作用是让游标向上移动一行并判断这一行是否有数据。读者可以从表18-3中查阅这些移动游标的方法。
当游标移动到某一行有效数据时,可以通过getXxx()方法获取这一行中某个列的数据,getXxx()方法有两个重载版本,第一个版本以列的序号作为参数,第二个版本以列的名称作为参数。需要说明,getXxx()方法实际上是对一系列方法的统称,这些方法有getInt()、getDouble()、getString()等,它们用于取出不同种类型的数据,例如在数据库中为char或varchar类型的数据就可以用getString()方法取出,而int型数据就可以用getInt()取出。在第17章所用的案例中有一个Users表,其第一列的名称是“USER_QQ”,假设结果集对象是rs,当游标指向某一行时,获取这一行USER_QQ列的值可以用以下语句实现。
rs.getString(1);
这种方式以列的序号为参数获取某一列上的值,如果以列的名称为参数获取某一个列上的值可以把语句写为。
rs.getString("USER_QQ ");
在实际开发过程中,建议读者用列的名称为参数获取数据,因为这样的写法具有更强的可读性。
当完成了数据库的操作之后就进入了JDBC编程的第五步,这一步是释放资源。所谓释放资源实际上就是关闭被创建出的各种对象,这与第12章中所讲解的关闭流对象的操作类似。需要注意:在进行关闭操作时,要先关闭后创建的对象,而那些先被创建的对象则要后关闭。在JDBC编程步骤中,最先创建的对象Connection对象,接下来是Statement对象,如果Statement对象执行的是查询操作,那么还会创建出ResultSet对象,那么在关闭这些对象时其正确顺序是ResultSet、Statement、Connection。
为了确保在运行过程中出现异常而导致无法执行关闭对象的语句,关闭语句要在finally块中执行。当对象被关闭后就不能再操作该对象,例如程序员无法通过一个已关闭的ResultSet对象的getXxx()方法获取数据,因此在获取数据时ResultSet对象必须保持打开状态。
18.2.3使用JDBC完成数据查询
18.2.2小节讲述了JDBC编程的基本步骤,本节以一个实际的案例演示如何使用JDBC完成数据查询。这个例子查询的是17章所创建的Users表中的全部数据,程序代码中以注释详细标注了各个操作步骤。
【例18_01使用JDBC查询数据】
Exam18_01.java
import java.sql.*;
public class Exam18_01 {
public static void main(String[] args) {
String url = "jdbc:mysql://localhost:3306/demo";// 数据库地址
String user = "root";// 用户名
String password = "123456";// 密码
Connection con = null;//
Statement stm = null;
ResultSet rs = null;
try {
// 1、加载驱动类
Class.forName("com.mysql.cj.jdbc.Driver");
//2、建立数据库连接
con = DriverManager.getConnection(url, user, password);
//3、创建Statement对象
stm = con.createStatement();
//要执行的SQL语句
String sql = "select * from users";
//4、操作数据库,本例为查询数据
rs = stm.executeQuery(sql);
System.out.printf("%-10s%-10s%-12s%-15s%-18s\n",
"玩家QQ号","玩家昵称","玩家性别","玩家生日","玩家手机号");
//从结果集中获取数据
while (rs.next()) {
String userQQ = rs.getString("user_qq");
String userName = rs.getString("user_name");
String userSex = rs.getString("user_sex");
String userBirthday = rs.getString("user_birthday");
String userPhone = rs.getString("user_phone");
System.out.printf("%-15s%-15s%-15s%-18s%-18s\n",
userQQ,userName,userSex,userBirthday,userPhone);
}
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (SQLException e) {
e.printStackTrace();
}finally {
//5、释放资源
if(rs!=null){
try {
rs.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if(stm!=null){
try {
stm.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if(con!=null){
try {
con.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
}
在【例18_01】中,使用了经典的5步操作完成了用JDBC对数据库的查询操作。需要说明的是:Class类的forName()方法在执行时可能会抛出ClassNotFoundException,而ResultSet的getXxx()方法可能会抛出SQLException,因此对这两个方法的调用都要用try-catch处理异常。在查询产生结果集后,如果希望取出结果集中的全部数据,一般都用while循环实现。while循环在每次执行前都会执行rs对象的next()方法用来把游标移动到下一行,如果下一行是一行有效数据,那么next()方法的返回值是true,这样正式进入本轮循环并逐一取出各列上的数据并输出,而一旦next()方法返回了false,说明游标指向了一行无效数据,while循环自动退出。
关闭对象的操作要放到finally块中执行,如果try块中抛出异常可能导致con、stm和rs没有真正指向实际对象,所以必须在关闭它们前判断这三个对象是否是空对象,如果不是空对象的情况下才能调用其close()方法进行关闭,close()方法也声明了SQLException,因此在调用时也要用try-catch处理异常。【例18_01】的运行结果如图18-4所示。
图18-4【例18_01】运行结果
18.2.4 ResultSet的类型和特性
从【例18_01】可以看出:ResultSet可以通过next()方法把游标向下移动一行,但在默认情况下,无论游标移动到哪一行,通过previous()方法把游标向上移动一行的操作都会出现异常,这是因为在默认情况下查询所产生的结果集是不可滚动的,也就是说默认情况下ResultSet对象只能调用next()方法逐步向下移动游标,而调用任何其他移动游标的方法如previous()、first()、last()等都会抛出异常。为使游标能够随意移动,在创建Statement对象时必须传递相应的参数。程序员在创建Statement或PreparedStatement时还可额外传入如下两个参数,分别是resultSetType和resultSetConcurrency。
resultSetType参数用于控制ResultSet的类型,该参数可以取如下三个值,它们以静态属性形式呈现。
- ResultSet.TYPE_ FORWARD_ONLY:该常量控制记录指针只能向前移动,它是resultSetType参数的默认值。
- ResultSet.TYPE_ SCROLL_ INSENSITIVE:该常量控制记录指针可以自由移动(可滚动结果集),但底层数据的改变不会影响ResultSet的内容。
- ResultSet.TYPE_ SCROLL_ SENSITIVE:该常量控制记录指针可以自由移动(可滚动结果集),而且底层数据的改变会影响ResultSet的内容。
resultSetConcurrency参数用于控制ResultSet的并发类型,该参数可以接收如下两个值。
- ResultSet.CONCUR_ READ_ ONLY:该常量指示ResultSet是只读的并发模式(默认)。
- ResultSet.CONCUR_ UPDATABLE: 该常量指示ResultSet是可更新的并发模式。
需要指出的是:可更新的结果集还需要满足如下两个条件。
- 所有数据都应该来自一个表。
- 选出的数据集必须包含主键列。
实际开发过程中,程序员可以先把游标移动到某一行,然后调用ResultSet的updateXxx()方法来修改游标所指记录、特定列的值,最后调用ResultSet的updateRow()方法来提交修改。下面的【例18_02】展示了如何创建一个可滚动、可更新的结果集并修改结果集中的数据。
【例18_02 创建可滚动可更新的结果集】
Exam18_02.java
import java.sql.*;
public class Exam18_02 {
public static void main(String[] args) {
String url = "jdbc:mysql://localhost:3306/demo";// 数据库地址
String user = "root";// 用户名
String password = "123456";// 密码
Connection con = null;//
Statement stm = null;
ResultSet rs1 = null;
ResultSet rs2 = null;
try {
Class.forName("com.mysql.cj.jdbc.Driver");
con = DriverManager.getConnection(url, user, password);
//创建Statement对象,创建时指定结果集可滚动可更新
stm = con.createStatement(ResultSet.TYPE_SCROLL_INSENSITIVE,
ResultSet.CONCUR_UPDATABLE );//①
String sql = "select * from users";
rs1 = stm.executeQuery(sql);//②
//把游标定位到结果集的第3行
rs1.absolute(3);
//修改结果集中游标所指向的user_phone列值为13700005555
rs1.updateString("user_phone","13700005555");
//提交修改结果
rs1.updateRow();
//再次从数据库中查询users表中的数据
rs2 = stm.executeQuery(sql);//③
System.out.printf("%-10s%-10s%-12s%-15s%-18s\n",
"玩家QQ号","玩家昵称","玩家性别","玩家生日","玩家手机号");
//从结果集中获取数据
while (rs2.next()) {
String userQQ = rs2.getString("user_qq");
String userName = rs2.getString("user_name");
String userSex = rs2.getString("user_sex");
String userBirthday = rs2.getString("user_birthday");
String userPhone = rs2.getString("user_phone");
System.out.printf("%-15s%-15s%-15s%-18s%-18s\n",
userQQ,userName,userSex,userBirthday,userPhone);
}
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (SQLException e) {
e.printStackTrace();
}finally {
//5、释放资源
if(rs1!=null){
try {
rs1.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if(rs2!=null){
try {
rs2.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if(stm!=null){
try {
stm.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if(con!=null){
try {
con.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
}
【例18_02】中,语句①在创建Statement对象时传入了两个参数,这样的创建出的Statement对象在执行查询操作时产生的ResultSet就是一个可滚动可更新的结果集。按照参数的设置,产生的结果集rs1可滚动可更新,当产生了结果集之后调用了updateString()更新了结果集中第3行数据中的玩家手机号值,之后再次查询数据库,并用另一个结果集rs2存放数据,需要特别强调:rs1和rs2是两个独立的结果集,rs2存放了最新从数据库中查询到的数据而不是简单的复制了rs1。程序的最后遍历了rs2中的所有数据并输出到控制台上,【例18_02】的运行结果如图18-5所示。
图18-5【例18_02】运行结果
从图18-5可以看出:第3行中的数据中玩家手机号已经被更新,这说明updateXxx()方法不仅仅更新了结果集本身的数据,还更新了数据库中的数据,理由是rs2保存了从数据库中查询到的最新数据,图18-5展示的就是从数据库中查询到的最新数据。
18.2.5 RowSet接口
ResultSet接口表示结果集,但这个接口所表示的结果集有一个巨大的缺陷,那就是在结果集在被关闭后就无法从该结果集中取出任何数据。这就导致程序员在编程时必须要在结果集被关闭之前把数据取走,通常情况下程序员都是创建一个List类型的集合,把结果集中的每一条数据都按顺序存放到结合中,这样做显然增加了编程工作量。
为解决这个问题,ResultSet接口又派生出了RowSet子接口。而RowSet接口又继续派生出JdbcRowSet、CachedRowSet、FilteredRowSet、JoinRowSet和WebRowSet这些子接口。这些子接口中,除JdbcRowSet需要保持与数据库的连接之外,其余4个子接口都是离线的RowSet,无须保持与数据库的连接,因此程序员可以把这些子接口的对象随意的传递到程序的各个部分而无需担心它们的关闭问题。这些子接口之所以能够在与数据库断开连接后依然能从中取出数据,就是因为程序在创建它们时已经把数据从底层数据库读取到了内存中。与ResultSet相比,RowSet 默认就是可滚动、可更新、可序列化的结果集,无需在创建时以专门的参数来指定可滚动、可更新特性。
为创建这些离线保存数据的子接口,Java语言提供RowSetProvider类和RowSetFactory接口,其中RowSetProvider 负责创建RowSetFactory对象,而RowSetFactory则提供了一些方法来创建RowSet的各种子接口对象,这些方法如表18-4所示。
表18-4 RowSetFactory创建RowSet子接口对象的方法
方法 | 功能 |
---|---|
CachedRowSet createCachedRowSet() | 创建一个默认的CachedRowSet对象 |
FilteredRowSet createFilteredRowSet() | 创建一个默认的FilteredRowSet对象 |
JdbcRowSet createJdbcRowSet() | 创建一个默认的JdbcRowSet对象 |
JoinRowSet createJoinRowSet() | 创建一个默认的JoinRowSet对象 |
WebRowSet createWebRowSet() | 创建一个默认的WebRowSet对象 |
需要提醒各位读者:表18-4中所列出的各个方法的返回值类型都是接口,这些接口连同RowSetProvider类和RowSetFactory都位于javax.sql.rowset包下,使用它们时需要用import关键字对其进行引入。此外,从表18-4可以看出:使用RowSetFactory创建的RowSet对象其实并没有装填数据,表格中所列出的这些方法只是创建了存储数据的容器。为了能够让这些被创建出的对象能够真正的装满数据,还需要执行一次查询操作,为此RowSet接口专门提供了一些方法用来设置连接数据库的url、用户名、密码以及完成查询操作,这些方法如表18-5所示。
表18-5 RowSet接口的常用方法
方法 | 功能 |
---|---|
setUrl(String url): | 设置该RowSet要访问的数据库的URL。 |
setUsername(String name) | 设置该RowSet要访问的数据库的用户名 |
setPassword(Stringpassword) | 设置该RowSet要访问的数据库的密码 |
setCommand(String sql) | 设置使用该sql语句的查询结果来装填该RowSet。 |
execute() | 执行查询 |
下面的【例18_03】展示了如何使用RowSet对象充当离线结果集。
【例18_03 RowSet的使用】
Exam18_03.java
import javax.sql.rowset.*;
import java.sql.*;
public class Exam18_03 {
public static void main(String[] args) {
String url = "jdbc:mysql://localhost:3306/demo";// 数据库地址
String user = "root";// 用户名
String password = "123456";// 密码
String sql = "select * from users";
try {
Class.forName("com.mysql.cj.jdbc.Driver");
//创建一个RowSetFactory对象
RowSetFactory factory = RowSetProvider.newFactory() ;
//创建CachedRowSet对象
CachedRowSet crs = factory.createCachedRowSet();
crs.setUrl(url);//设置连接数据库的地址
crs.setUsername(user);//设置用户名
crs.setPassword(password);//设置密码
crs.setCommand(sql);//设置要执行的sql语句
crs.execute();//执行sql语句
crs.afterLast();//①把结果集游标移动到最后一行之后
System.out.printf("%-10s%-10s%-12s%-15s%-18s\n",
"玩家QQ号","玩家昵称","玩家性别","玩家生日","玩家手机号");
while (crs.previous()){//逆向遍历结果集
String userQQ = crs.getString("user_qq");
String userName = crs.getString("user_name");
String userSex = crs.getString("user_sex");
String userBirthday = crs.getString("user_birthday");
String userPhone = crs.getString("user_phone");
System.out.printf("%-15s%-15s%-15s%-18s%-18s\n",
userQQ,userName,userSex,userBirthday,userPhone);
}
}catch (ClassNotFoundException e){
e.printStackTrace();
}catch (SQLException e){
e.printStackTrace();
}
}
}
【例18_03】完成的也是查询Users表中的全部数据,可以看出:由于是使用RowSet的子接口进行查询,所以程序中没有创建Connection和Statement对象的语句,直接通过RowSet设置了连接数据库的地址、用户名和密码并直接用RowSet进行了查询,这样能够减少一部分代码量。在遍历查询结果时,是先把游标移动到最后一行之后,再向上逐一取出数据进行反向遍历。【例18_03】的运行结果如图18-6所示。
图18-6【例18_03】运行结果
图18-6所示的运行结果证实了创建RowSet时不需要设置任何参数的情况下默认就是可滚动的,读者可以自行验证RowSet对象默认也是可更新的。
RowSet并不是只能减少代码量,实际上离线的RowSet对于很多开发有很重要的意义,例如在web开发中,数据查询的结果是Java代码产生的,而数据的展示在页面上完成,如果直接把ResultSet传递到页面上,将导致Connection和Statement对象无法关闭,这是因为如果在发送ResultSet到页面之前关闭Connection和Statement对象,ResultSet将无法取出数据,因此只能保持Connection和Statement对象处于打开状态,但程序运行页面代码时已经无法再关闭Connection和Statement对象。
18.2.6 PreparedStatement接口
前面几个小节讲解的都是通过Statement接口完成查询,但Statement接口在查询条件为动态值的时候需要通过拼接字符串的方式组成一个SQL语句,例如:由用户从控制台输入一个QQ号,由程序查询这个QQ号的玩家信息可以用以下代码片段实现。
...
Scanner sc = new Scanner(System.in);
System.out.println();
String userQQ = sc.nextLine("请输入QQ号");
...
String sql = "select * from users where user_qq='"+userQQ+ "'";
...
stm. executeQuery(sql);
从以上代码片段可以看出:把一些动态值加入SQL语句中要进行字符串的拼接,拼接字符串不仅仅增加了工作量,更主要的是SQL语句中的字符串是用单引号表示的,而Java语言的字符串是双引号表示的,这样单引号和双引号混合使用,导致拼接字符串出错的可能性变得很大。在JDBC中,有一个PreparedStatement接口能够很好的解决这个问题。
PreparedStatement被称为“预编译语句”,它也是一个接口,并且是Statement接口的子接口。它可以在编写SQL语句时先用问号(?)给暂时不确定的动态值占位,然后在调用setXxx()方法用真实的值替换SQL语句中的那些问号。需要注意:真实的值无论是否带有单引号,在用问号进行占位时都不需要在问号两侧添加单引号,这样大大减少了SQL语句的拼写概率。
实际上,PreparedStatement能够避免拼写字符串出错只是它各种优良性能中的“最不起眼”的一个。它最主要的优点是能够在SQL语句还没执行时提前把语句发送到数据库中进行预编译操作,这样在执行SQL语句时不用等待编译的过程。此外,使用PreparedStatement执行SQL语句还能防止SQL注入,这大大提高了代码安全性。关于“SQL注入”本书不做过多讲解,读者只需要它是一种利用SQL语句的拼接完成非法查询数据库的操作即可。由此可以总结出PreparedStatement有以下三个优点:
- 预编译SQL语句,性能更好。
- 无须拼接SQL语句,编程更简单。
- 可以防止SQL注入,安全性更好。
下面的【例18_04】展示了如何使用PreparedStatement完成查询Users表中查询生日在某一段时间内且用户指定性别的玩家信息。
【例18_04 PreparedStatement的使用】
Exam18_04.java
import java.sql.*;
import java.util.Scanner;
public class Exam18_04 {
public static void main(String[] args) {
String url = "jdbc:mysql://localhost:3306/demo";// 数据库地址
String user = "root";// 用户名
String password = "123456";// 密码
Connection con = null;//
PreparedStatement pstm = null;
ResultSet rs = null;
try {
Scanner sc = new Scanner(System.in);
Class.forName("com.mysql.cj.jdbc.Driver");
con = DriverManager.getConnection(url, user, password);
//要执行的SQL语句,包含表示占位符的问号
String sql = "select * from users " +
"where user_birthday between ? and ? and user_sex=?";
//创建PreparedStatement对象并预编译要执行的SQL语句
pstm = con.prepareStatement(sql);//①
String startBirthday;//起始生日
String endBirthday;//结束生日
String sex;
System.out.println("请输入起始生日日期");
startBirthday = sc.nextLine();
System.out.println("请输入结束生日日期");
endBirthday = sc.nextLine();
System.out.println("请输入玩家性别");
sex = sc.nextLine();
pstm.setString(1,startBirthday);//②为第1个问号设置真实值
pstm.setString(2,endBirthday);//③为第2个问号设置真实值
pstm.setString(3,sex);//④为第3个问号设置真实值
rs = pstm.executeQuery();//⑤执行查询
System.out.print("生日为"+startBirthday+"到"+endBirthday);
System.out.println("且性别为"+sex+"的所有玩家信息如下");
System.out.printf("%-10s%-10s%-12s%-15s%-18s\n",
"玩家QQ号","玩家昵称","玩家性别","玩家生日","玩家手机号");
//从结果集中获取数据
while (rs.next()) {
String userQQ = rs.getString("user_qq");
String userName = rs.getString("user_name");
String userSex = rs.getString("user_sex");
String userBirthday = rs.getString("user_birthday");
String userPhone = rs.getString("user_phone");
System.out.printf("%-15s%-15s%-15s%-18s%-18s\n",
userQQ,userName,userSex,userBirthday,userPhone);
}
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (SQLException e) {
e.printStackTrace();
}finally {
//5、释放资源
if(rs!=null){
try {
rs.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if(pstm!=null){
try {
pstm.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if(con!=null){
try {
con.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
}
【例18_04】的语句①创建了一个PreparedStatement对象pstm,这个对象被创建时已经传入了要执行的SQL语句,传入SQL语句后就能对它进行预编译,这样在真正执行时就不需要程序等待编译的过程。传入的SQL语句中因为有一部分值还没有被确定,因此需要用问号作为占位符暂代替真实值,后面的语句②、③、④就是用真实值替换占位符的操作。需要注意:替换操作是调用setXxx()方法完成的,setXxx()方法的第一个参数是一个整数,它表示问号出现在SQL语句中的顺序,也就是说参数为1时表示要替换第1个问号,参数为2时表示替换第1个问号,以此类推。如果问号占位符代替的是一个日期时间类型,JDBC允许用setString()方法来设置这个日期时间,也就是把日期时间作为一个字符串,本例就是这样操作的。语句⑤是执行查询的操作,可以看出:执行查询时已经不需要指明要执行的SQL语句,这是因为在创建PreparedStatement时已经做了指定。【例18_04】的运行结果如图18-7所示。
图18-7【例18_04】运行结果
由于PreparedStatement比Statement具有更好的特性,并且能够完成Statement能做的所有操作,因此建议各位读者在实际开发过程中尽量使用PreparedStatement作为执行SQL语句的对象。
PreparedStatement能够产生一个结果集,但这个结果集是ResultSet,前面讲过ResultSet不具有离线特性,必须在打开状态下才能从中取出数据。如果程序员希望获得一个具有离线特性的RowSet来保存数据,可以先创建出一个RowSet对象,然后调用populate()方法来用一个ResultSet为其填充数据,填充完成之后RowSet中就保存了与ResultSet完全相同的数据,填充的代码如下:
CachedRowSet crs = factory.createCachedRowSet () ;//创建一个RowSet对象
//使用ResultSet填充RowSet,其中rs是一个ResultSet对象
crs.populate(rs) ;
以上代码片段中rs是一个ResultSet对象,使用这个对象作为populate()方法的参数即可完成填充数据的操作,完成填充数据操作后即可关闭ResultSet对象从而保证代码安全。
18.2.7 DML语句的执行
DML表示数据操纵语言,例如对数据的增加、删除、修改的语句都属于DML,DML语句的执行不会产生结果集。在JDBC API中Statement和PreparedStatement都可以执行DML语句,只是执行时不再调用executeQuery()方法,而是调用executeUpdate()方法,这个方法的返回值是一个整数,表示受影响的行数。所谓“受影响的行数”在不同的DML语句执行后意义不同:如果执行的是新增数据的语句,受影响的行数表示新增数据的条数,如果执行的是删除数据的语句则受影响的行数表示删除数据的条数,而如果执行更新数据的语句则受影响的行数表示更新数据的条数。
下面的【例18_05】展示了用PreparedStatement执行修改数据的操作。
【例18_05 DML语句的执行】
Exam18_05.java
import java.sql.*;
import java.util.Scanner;
public class Exam18_05 {
public static void main(String[] args) {
String url = "jdbc:mysql://localhost:3306/demo";// 数据库地址
String user = "root";// 用户名
String password = "123456";// 密码
Connection con = null;//
PreparedStatement pstm = null;
try {
Scanner sc = new Scanner(System.in);
Class.forName("com.mysql.cj.jdbc.Driver");
con = DriverManager.getConnection(url, user, password);
//要执行的SQL语句,包含表示占位符的问号
String sql = "update users set user_birthday=? where user_name='孙悟空'";
pstm = con.prepareStatement(sql);
String birthday;//起始生日
System.out.println("请输入一个生日日期");
birthday = sc.nextLine();
pstm.setString(1,birthday);//为第1个问号设置真实值
int rows = pstm.executeUpdate();//执行更新语句
System.out.print(rows+"行数据被修改");
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (SQLException e) {
e.printStackTrace();
}finally {
if(pstm!=null){
try {
pstm.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if(con!=null){
try {
con.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
}
【例18_05】为昵称为孙悟空的玩家动态设置了生日,它的运行结果如图18-8所示。
图18-8【例18_05】运行结果
同图18-8可以看出:运行executeUpdate()方法时产生的返回值为1,这说明一条数据被修改,如果再次执行本例程序并输入相同的日期作为生日,那么运行executeUpdate()方法时产生的返回值将是0。
18.2.8使用execute()方法执行SQL语句
前面几个小节介绍的执行SQL语句的方法分别是executeQuery()和executeUpdate(),它们分别用来执行DQL和DML语句。有时候,程序员在编程时可能并不能确定要执行的是哪一种类型的SQL语句,在这种情况下就可以调用Statement和PreparedStatement所提供的execute()方法来执行SQL语句,这个方法既能执行DQL,也能执行DML。execute()方法的返回值是boolean型,如果方法的返回值是true,说明方法执行的是DQL,产生了结果集,如果方法的返回值是false,则说明方法执行的DML,程序员由返回值就可以判断出execute()方法执行的是哪种类型的SQL语句。
如果execute()方法执行的是DQL,就可以通过Statement或PreparedStatement的getResultSet()方法获得结果集,而如果execute()方法执行的是DML,则可以通过getUpdateCount()方法来获得执行语句受影响的行数。下面的【例18_06】展示了调用execute()方法执行DQL的操作。
【例18_06 execute()方法的使用】
Exam18_06.java
import java.sql.*;
public class Exam18_06 {
public static void main(String[] args) {
String url = "jdbc:mysql://localhost:3306/demo";// 数据库地址
String user = "root";// 用户名
String password = "123456";// 密码
Connection con = null;//
Statement stm = null;
ResultSet rs = null;
try {
Class.forName("com.mysql.cj.jdbc.Driver");
con = DriverManager.getConnection(url, user, password);
stm = con.createStatement();
String sql = "select * from users";
boolean flag = stm.execute(sql);//flag是execute()方法的返回值
if(flag ==true){
rs = stm.getResultSet();
System.out.printf("%-10s%-10s%-12s%-15s%-18s\n",
"玩家QQ号","玩家昵称","玩家性别","玩家生日","玩家手机号");
//从结果集中获取数据
while (rs.next()) {
String userQQ = rs.getString("user_qq");
String userName = rs.getString("user_name");
String userSex = rs.getString("user_sex");
String userBirthday = rs.getString("user_birthday");
String userPhone = rs.getString("user_phone");
System.out.printf("%-15s%-15s%-15s%-18s%-18s\n",
userQQ,userName,userSex,userBirthday,userPhone);
}
}
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (SQLException e) {
e.printStackTrace();
}finally {
//5、释放资源
if(rs!=null){
try {
rs.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if(stm!=null){
try {
stm.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if(con!=null){
try {
con.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
}
【例18_06】的运行结果与【例18_01】的运行结果完全相同。
18.2.9 JDBC编程的优化方案
到目前为止本书已列举出多个JDBC编程的案例,细心的读者不难发现:在这些案例中出现了大量的重复代码,这些代码主要是定义url、用户名和密码的字符串,例如:
String url = "jdbc:mysql://localhost:3306/demo";// 数据库地址
String user = "root";// 用户名
String password = "123456";// 密码
此外,加载驱动程序类的代码也多次在各个案例出现。实际上仔细观察代码可以发现:这些重复的代码的核心目的就是创建出一个Connection对象,因此可以把这些重复代码抽取出来,形成一个获取Connection对象的方法,这个方法可以被定义在一个独立的类中,并且为了方便调用,程序员可以把这个方法定义为静态方法,这样其他类就可以通过类名直接来调用它,下面Util1类的getConnection()方法能够返回一个Connection对象。
Util1.java
import java.sql.*;
public class Util1 {
public static String driverName = "com.mysql.cj.jdbc.Driver";//驱动程序类的名称
public static String url = "jdbc:mysql://localhost:3306/demo";// 数据库地址
public static String user = "root";// 用户名
public static String password = "123456";// 密码
public static Connection getConneciton() throws ClassNotFoundException,SQLException{
Connection con = null;
Class.forName(driverName);//加载驱动程序类
con = DriverManager.getConnection(url, user, password);//创建Connection对象
return con;
}
//释放资源
public static void close(ResultSet rs, Statement stm, Connection con)
throws SQLException {
if (rs != null) {
rs.close();
}
if (stm != null) {
stm.close();
}
if (con != null) {
con.close();
}
}
}
以上的Util1类中,定义了一个返回Connection对象的方法getConneciton(),这个方法是静态的,因此在所有类中都可以直接通过类名调用这个方法从而获得一个Connection对象,大大降低了代码的重复性。
但观察getConneciton()方法还可以看到:为了获得一个Connection对象,需要用到Util1类中的几个静态属性driverName、url、user和password,这几个静态属性都是创建Connection对象必需的参数。如果Java程序所访问的数据库从MySQL迁移到了其他数据库,那么驱动程序类的名称就需要做相应的修改,同理,数据库的名称、用户名、密码做了修改时,这些静态属性也必须做出相应的修改。
读者都知道,只要修改Java代码就必须重新编译才能生效,如何实现在修改了连接数据库参数的情况下又不必重新编译代码呢?实际上,程序员可以把这些参数写在一个配置文件中,每次创建Connection对象时都先读取配置文件中的参数,这样的话在参数发生变动时只需要修改配置文件中的数据就可以,即使修改了各种参数也无需重新编译代码。
配置文件有很多种,比较简单的有ini文件,这种文件以键值对保存数据,而由第13章中介绍过的Properties类读取其中的内容,按照这个思路可以定义出下面进一步优化的Util2类。
Util2.java
import java.sql.*;
import java.io.*;
import java.util.Properties;
public class Util2 {
public static Connection getConneciton() throws Exception {
String driverName;//驱动程序类的名称
String url;// 数据库地址
String user;// 用户名
String password;// 密码
Connection con = null;
Properties props = new Properties() ;
props.load (new FileInputStream ("config.ini")) ;//读取配置文件
driverName = props.getProperty ("driverName") ;
url = props. getProperty ("url") ;
user = props .getProperty("user") ;
password = props.getProperty ("password") ;
Class.forName(driverName);//加载驱动程序类
con = DriverManager.getConnection(url, user, password);//创建Connection对象
return con;
}
//释放资源
public static void close(ResultSet rs, Statement stm, Connection con)
throws SQLException {
if (rs != null) {
rs.close();
}
if (stm != null) {
stm.close();
}
if (con != null) {
con.close();
}
}
}
为使Util2类中的getConneciton()方法能够正确运行,需要读者在项目文件夹中创建一个config.ini文件,其内容如下:
config.ini
driverName = com.mysql.cj.jdbc.Driver
url = jdbc:mysql://localhost:3306/demo
user = root
password = 123456
需要注意:读者要把配置文件中的密码改成自己数据库的真实密码才能成功创建Connection对象。
仔细阅读案例代码,读者还会发现那些释放资源的代码也是重复的,所以这些代码也可以被抽取出来形成一个独立的方法。下面的代码就定义了一个释放资源的方法close(),这个方法可以被添加到Util1和Util2这两个类中。
public static void close(ResultSet rs, Statement stm, Connection con)throws SQLException {
if (rs != null) {
rs.close();
}
if (stm != null) {
stm.close();
}
if (con != null) {
con.close();
}
}
从以上代码片段可以看出:close()方法按照顺序依次关闭了3个对象。如果在程序中Statement对象执行的DML,并没有产生结果集,这中情况下只需要用null作为close()方法的第一个参数即可,这是因为代码中关闭任何都进行了对象是否为空的判断。此外,如果使用的是PreparedStatement对象执行SQL语句,这个PreparedStatement对象仍然能够当作第二个参数传入close()方法中进行关闭,这是因为PreparedStatement是Statement的子接口,它也继承了Statement接口中定义的close()方法。
Util1和Util2类的源代码文件放在了代码文件夹中,读者可以下载这两个文件,用它们来优化【例18_01】等多个例子的程序代码,优化之后能够很大程度的减少代码量。