本系列教程适用于JavaWeb初学者、爱好者,小白白。我们的天赋并不高,可贵在努力,坚持不放弃。坚信量最终引发质变,厚积薄发。
文中白话居多,尽量以小白视角呈现,帮助大家快速入门。
我是 蜗牛老师,之前网名是 Ongoing蜗牛,人如其名,干啥都慢,所以更新也慢。希望大家多多支持,让我动力十足!
上篇文章我们学习了 JDBC 编程基本步骤,步骤中使用 Statement
执行 SQL 语句。其实还有一个更好的方式,就是 PreparedStatement
,预编译语句。与 Statement
相比有诸多优势,目前开发中一般使用 PreparedStatement
。所以我们非常有必要进行学习,而且日后的持久层框架底层也是使用 PreparedStatement
。
在 JDBC 编程基本步骤中,我们创建 Statement
(语句)对象,向数据库发送要执行的 SQL 语句。Statement
一般用于实现不带参数的简单 SQL 语句,也就是说它执行的是静态 SQL 语句。每次执行 SQL 语句时,都会将 SQL 语句编译为数据库可以理解的格式。Statement
的工作原理是将 SQL 语句发送给数据库,然后数据库执行该语句并返回结果。
那么 PreparedStatement
是什么呢?大家应该也发现了,PreparedStatement
比 Statement
多了单词 Prepared
,理解的重点就是 Prepared
,它是预编译的意思,所以平时被称为预编译的 Statement
(语句)。
PreparedStatement
是一个接口,而且它是 Statement
接口的子接口。
// Statement接口
public interface Statement extends Wrapper, AutoCloseable {}
// PreparedStatement接口
public interface PreparedStatement extends Statement {}
PreparedStatement
接口用于执行动态 SQL 语句。它允许在 SQL 语句中使用占位符(?
英文格式问号),然后在执行之前将这些占位符(?
)替换为实际的值。PreparedStatement
在执行之前会对 SQL 语句进行预编译,这样可以提高执行效率。工作原理是将 SQL 语句发送给数据库之前,先将其编译为可执行的格式,然后将实际的参数值传递给占位符(?
)。
具体解决什么问题呢?我们来看一个需求:一个博客网站要根据某个文章编号(id
)查询该文章内容进行展示,文章编号100和101分别代表不同的文章。我们来看 SQL 语句:
select * from article where id = 100;
select * from article where id = 101;
在上述需求中,我们会反复执行一条结构相似的 SQL 语句。其实在日常需求当中,经常需要执行 SQL 语句结构基本相似,但是执行时的参数值不同。这种 SQL 语句我们可以叫做动态 SQL 语句,就可以使用 PreparedStatement
接口,SQL 语句中的参数可以使用占位符(?
)代替,也就是说占位符(?
)的位置参数未知,可以是100,可以是101或是其他。带有占位符(?
)SQL 语句如下:
select * from article where id = ?;
需要注意的是 Statement
执行 SQL 语句时是不允许带有占位符(?
)参数的,而且 PreparedStatement
执行带有占位符参数的 SQL 语句时,参数必须要传入实际的值才可以。
大概知道 PreparedStatement
是干什么的之后,我们来重点理解一下预编译。
预编译是指在执行 SQL 语句之前,将 SQL 语句编译为一个预定义的内部格式,以便数据库能够更有效地执行。
预编译的过程包括以下几个步骤:
语法分析:数据库系统会对传入的 SQL 语句进行语法分析,检查其是否符合语法规则。
语义分析:数据库系统会对 SQL 语句进行语义分析,检查表和列是否存在、权限是否足够等。
优化和执行计划生成:数据库系统会对 SQL 语句进行优化,生成一个最佳的执行计划,以便在执行时能够高效地获取数据。
在预编译完成后,数据库会将编译后的执行计划存储在缓存中,以便下次执行相同的预编译语句时可以直接使用执行计划,从而节省了编译的时间和资源。这也是 PreparedStatement
相较于 Statement
的一个优势所在。
当多次执行相同的预编译语句时,由于已经完成了编译和优化的步骤,预编译的语句可以更快速地执行,因为只需传递参数值并执行执行计划,而不需要再进行语法分析、语义分析和执行计划生成等步骤。
需要注意的是,预编译功能主要适用于需要多次执行相同的 SQL
语句的场景,因为预编译的语句在编译时会占用一定的资源。如果只需要执行一次或是每次 SQL
语句都不同,那么使用 Statement
可能会更合适。
在对 PreparedStatement
有了基本了解后,我们进行简单使用。在 JDBC 编程基本步骤中查询了 teacher
表的全部数据,在这里将再次使用 teacher
表,这次我们往表中添加数据。
在 test-jdbc
项目中新建类 TestPreparedStatement
,仍然生成 main()
方法。方法中重新编写 JDBC 代码,顺带巩固。
JDBC 编程的第一步就是加载驱动,第二步是连接 Connection
连接。大家还记得代码如何编写吗?
/**
* 敲入main,根据提示自动生成主函数main()方法
* @param args
*/
public static void main(String[] args) {
try {
// ①动态加载指定路径下的MySQL JDBC驱动,将其注册到DriverManager中。
Class.forName("com.mysql.cj.jdbc.Driver");
// ②建立到给定数据库URL的连接。
Connection connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/test_jdbc?serverTimezone=Asia/Shanghai", "root", "root");
} catch (ClassNotFoundException | SQLException e) {
e.printStackTrace();
}
}
之前第三步是创建一个 Statement
对象,用于向数据库发送 SQL 语句。现在我们改用 PreparedStatement
。和 Statement
一样我们需要先拿到 PreparedStatement
对象,使用 Connection
中的 prepareStatement(String)
方法获得。我们来看 API:
/**
* 创建一个PreparedStatement对象,用于向数据库发送参数化的SQL语句。
* 可以预编译带有或不带有IN参数的SQL语句,并将其存储在PreparedStatement对象中。然后可以使用该对象多次有效地执行该语句。
*/
PreparedStatement prepareStatement(String sql) throws SQLException;
接下来我们来编写第三步:使用 Connection
来创建 PreparedStatement
对象。
/**
* 敲入main,根据提示自动生成主函数main()方法
* @param args
*
*/
public static void main(String[] args) {
try {
// ①动态加载指定路径下的MySQL JDBC驱动,将其注册到DriverManager中。
Class.forName("com.mysql.cj.jdbc.Driver");
// ②建立到给定数据库URL的连接。
Connection connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/test_jdbc?serverTimezone=Asia/Shanghai", "root", "root");
// ③使用Connection来创建PreparedStatement对象,将把语句发送到数据库进行预编译。
PreparedStatement preparedStatement = connection.prepareStatement("insert into teacher (name, sex, age) value (?, ?, ?)");
} catch (ClassNotFoundException | SQLException e) {
e.printStackTrace();
}
}
上述③代码处,使用 Connection
的 prepareStatement(String sql)
方法来创建 PreparedStatement
对象,该方法需要传入一个 SQL 语句字符串,可以包含占位符。上述 SQL 语句向 teacher
表中插入一条数据,由于表中 id
为自增,所以插入时不需要给 id
指定值。对于 name
、sex
、age
三个字段的值使用占位符(?
)进行占位。
PreparedStatement
也提供了 execute()
、executeUpdate()
、executeQuery()
和 executeLargeUpdate()
(1.8版本新增)方法去执行 SQL 语句。这里我们使用 executeUpdate()
方法去执行 insert
语句。由于执行的 SQL 语句带有占位符参数,因此在执行语句前必须为这些参数传入参数值,PreparedStatement
提供了一系列的 setXxx(int index, Xxx value)
方法来传入参数值。
/**
* 敲入main,根据提示自动生成主函数main()方法
* @param args
*
*/
public static void main(String[] args) {
try {
// ①动态加载指定路径下的MySQL JDBC驱动,将其注册到DriverManager中。
Class.forName("com.mysql.cj.jdbc.Driver");
// ②建立到给定数据库URL的连接。
Connection connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/test_jdbc?serverTimezone=Asia/Shanghai", "root", "root");
// ③使用Connection来创建PreparedStatement对象,将把语句发送到数据库进行预编译。
PreparedStatement preparedStatement = connection.prepareStatement("insert into teacher (name, sex, age) value (?, ?, ?)");
// ④设置参数并执行SQL语句
preparedStatement.setString(1, "赵六");
preparedStatement.setString(2, "女");
preparedStatement.setInt(3, 20);
// 执行SQL语句
preparedStatement.executeUpdate();
} catch (ClassNotFoundException | SQLException e) {
e.printStackTrace();
}
}
SQL 语句中的第一个占位符(?
),是表中 name
字段的值,为字符串,所以这里使用 setString()
方法,方法中需要两个参数,一个是占位符(?
)的位置,从1开始,第二个参数是具体的值。其他占位符(?
)操作一样,需要注意的是占位符(?
)的位置及该位置传入的参数类型。如果编程时不清楚预编译 SQL 语句中各参数的类型,我们可以使用 setObject()
方法传入参数,然后由 PreparedStatement
负责类型转换。
最后一步就是关闭资源,使用到哪些资源就关闭哪些资源,注意当前代码中我们没有使用 Statement
和 ResultSet
。
/**
* 敲入main,根据提示自动生成主函数main()方法
* @param args
*
*/
public static void main(String[] args) {
try {
// ①动态加载指定路径下的MySQL JDBC驱动,将其注册到DriverManager中。
Class.forName("com.mysql.cj.jdbc.Driver");
// ②建立到给定数据库URL的连接。
Connection connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/test_jdbc?serverTimezone=Asia/Shanghai", "root", "root");
// ③使用Connection来创建PreparedStatement对象,将把语句发送到数据库进行预编译。
PreparedStatement preparedStatement = connection.prepareStatement("insert into teacher (name, sex, age) value (?, ?, ?)");
// ④设置参数并执行SQL语句
preparedStatement.setString(1, "赵六");
preparedStatement.setString(2, "女");
preparedStatement.setInt(3, 20);
// 执行SQL语句
preparedStatement.executeUpdate();
// ⑤ 关闭资源
preparedStatement.close();
connection.close();
} catch (ClassNotFoundException | SQLException e) {
e.printStackTrace();
}
}
我们运行程序,控制台无报错,通过客户端查看表中记录,是否有刚刚插入的赵六数据。
Statement
和 PreparedStatement
的相同之处在于它们都是用于执行 SQL 语句的接口。它们都可以执行查询和更新操作,并且都可以接收参数。不同之处在于 PreparedStatement
可以预编译 SQL 语句并使用占位符(?
),这样可以提高执行效率,并且可以防止 SQL 注入攻击。
我们进行简单测试,向 teacher
表分别插入1000条记录进行对比。
import java.sql.*;
/**
* PreparedStatementVsStatement PreparedStatement和Statement比较
*
* @author Ongoing蜗牛
* @since 2023/8/24 14:25
*/
public class PreparedStatementVsStatement {
/**
* 使用Statement执行SQL语句
*
* @param connection 数据库连接对象
*/
public void insertByStatement(Connection connection){
// 以毫秒为单位返回当前时间,记录开始时间
long start = System.currentTimeMillis();
try {
// 创建Statement对象
Statement statement = connection.createStatement();
// 使用for循环执行插入1000条记录
for (int i = 0; i < 1000; i++) {
statement.executeUpdate("insert into teacher (name, sex, age) value ('张某" + i + "', '男', 20)");
}
} catch (SQLException throwables) {
throwables.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.println("使用Statement执行耗时:" + (end - start));
}
/**
* 使用PreparedStatement执行SQL语句
*
* @param connection 数据库连接对象
*/
public void insertByPreparedStatement(Connection connection){
// 以毫秒为单位返回当前时间,记录开始时间
long start = System.currentTimeMillis();
try {
// 创建Statement对象
PreparedStatement preparedStatement = connection.prepareStatement("insert into teacher (name, sex, age) value (?, ?, ?)");
// 使用for循环设置参数值并执行
for (int i = 0; i < 1000; i++) {
preparedStatement.setString(1, "李某" + i);
preparedStatement.setString(2, "女");
preparedStatement.setInt(3, 23);
preparedStatement.executeUpdate();
}
} catch (SQLException throwables) {
throwables.printStackTrace();
}
long end = System.currentTimeMillis();
System.out.println("使用PreparedStatement执行耗时:" + (end - start));
}
/**
* 测试
*
* @param args
*/
public static void main(String[] args) {
PreparedStatementVsStatement psvs = new PreparedStatementVsStatement();
try {
// 注册驱动
Class.forName("com.mysql.cj.jdbc.Driver");
// 建立数据库连接。
Connection connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/test_jdbc?serverTimezone=Asia/Shanghai", "root", "root");
// 调用 使用Statement执行SQL语句 方法
psvs.insertByStatement(connection);
// 调用 使用PreparedStatement执行SQL语句 方法
psvs.insertByPreparedStatement(connection);
} catch (ClassNotFoundException | SQLException e) {
e.printStackTrace();
}
}
}
执行结果如下:
使用Statement执行耗时:751
使用PreparedStatement执行耗时:637
通过上述测试代码可知,同样是插入1000条记录,使用 Statement
需要传入1000条 SQL 语句,而使用 PreparedStatement
其实只需要传入一条预编译的 SQL 语句,然后对其进行1000次设置参数。从执行多用时间也可以看出 PreparedStatement
的执行效率要高于 Statement
。
其实 PreparedStatement
还有一个优势是在使用参数时,不需要拼接,减少 SQL 语句的复杂的。我们看下面的伪代码:
// 教师信息
String name = "张三";
String sex = "男";
int age = 20;
// 使用Statement执行,需要拼接参数
statement.executeUpdate("insert into teacher (name, sex, age) value ('"+name+"','"+sex+"',"+age+")");
// 使用PreparedStatement执行,不需要拼接参数
PreparedStatement preparedStatement = connection.prepareStatement("insert into teacher (name, sex, age) value (?, ?, ?)");
preparedStatement.setString(1, name);
preparedStatement.setString(2, sex);
preparedStatement.setInt(3, age);
preparedStatement.executeUpdate();
SQL注入即是指web应用程序对用户输入数据的合法性没有判断或过滤不严,攻击者可以在web应用程序中事先定义好的查询语句的结尾上添加额外的SQL语句,在管理员不知情的情况下实现非法操作,以此来实现欺骗数据库服务器执行非授权的任意查询,从而进一步得到相应的数据信息。
经典的例子就是登录操作,一般正常用户会输入用户和密码进行登录操作,系统根据用户输入到数据库表中进行匹配,如果找到相应的记录则登录成功,否则登录失败。那么我们要通过 SQL 语句的执行去进行匹配操作。这里还是使用 teacher
表简单模拟,要求输入的姓名在表中存在就登录成功。正常用户输入姓名,系统执行 SQL 匹配。非正常用户会输入特殊字符串匹配。代码如下:
import java.sql.*;
/**
* TestSqlInjection SQL注入
*
* @author Ongoing蜗牛
* @since 2023/8/24 15:22
*/
public class TestSqlInjection {
/**
* 使用姓名登录,Statement执行SQL语句
* @param name 姓名
*/
public void loginByStatement(String name){
// 打印输入的姓名
System.out.println("登录姓名:" + name);
// 登录标识
boolean flag = false;
try {
// 注册驱动
Class.forName("com.mysql.cj.jdbc.Driver");
// 建立数据库连接。
Connection connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/test_jdbc?serverTimezone=Asia/Shanghai", "root", "root");
// 创建Statement对象
Statement statement = connection.createStatement();
// 执行SQL语句并返回结果
ResultSet resultSet = statement.executeQuery("select * from teacher where name = '"+ name +"'");
// 打印执行的SQL语句
System.out.println("select * from teacher where name = '"+ name +"'");
// 处理结果
while (resultSet.next()){
// 返回至少一条记录就登录成功
flag = true;
break;
}
// 关闭资源
resultSet.close();
statement.close();
connection.close();
// 打印输出结果
if (flag){
System.out.println("登录成功!");
}else{
System.out.println("登录失败!");
}
} catch (ClassNotFoundException | SQLException e) {
e.printStackTrace();
}
}
/**
* 主函数测试
* @param args
*/
public static void main(String[] args) {
TestSqlInjection testSqlInjection = new TestSqlInjection();
//String loginName = "张三"; //数据库中可以匹配,登录成功
//String loginName = "某某"; //数据库中匹配不到,登录失败
String loginName = "' or true or '"; //数据库中匹配不到,但是登录成功
testSqlInjection.loginByStatement(loginName);
}
}
执行结果如下:
登录姓名:' or true or '
select * from teacher where name = '' or true or ''
登录成功!
这回大家明白什么是 SQL 注入了吧。那么使用 PreparedStatement
进行同样的登录操作,也会遭遇 SQL 注入登录成功吗?
/**
* 使用姓名登录,PreparedStatement执行SQL语句
* @param name 姓名
*/
public void loginByPreparedStatement(String name){
// 打印输入的姓名
System.out.println("登录姓名:" + name);
// 登录标识
boolean flag = false;
try {
// 注册驱动
Class.forName("com.mysql.cj.jdbc.Driver");
// 建立数据库连接。
Connection connection = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/test_jdbc?serverTimezone=Asia/Shanghai", "root", "root");
// 创建PreparedStatement对象,预编译SQL语句
PreparedStatement preparedStatement = connection.prepareStatement("select * from teacher where name = ?");
// 设置参数
preparedStatement.setString(1, name);
// 执行SQL语句
ResultSet resultSet = preparedStatement.executeQuery();
// 打印执行的SQL语句
System.out.println(preparedStatement.toString());
// 处理结果
while (resultSet.next()){
// 返回至少一条记录就登录成功
flag = true;
break;
}
// 关闭资源
resultSet.close();
preparedStatement.close();
connection.close();
// 打印输出结果
if (flag){
System.out.println("登录成功!");
}else{
System.out.println("登录失败!");
}
} catch (ClassNotFoundException | SQLException e) {
e.printStackTrace();
}
}
/**
* 主函数测试
* @param args
*/
public static void main(String[] args) {
TestSqlInjection testSqlInjection = new TestSqlInjection();
//String loginName = "张三"; //数据库中可以匹配,登录成功
//String loginName = "某某"; //数据库中匹配不到,登录失败
String loginName = "' or true or '"; //数据库中匹配不到,但是登录成功
//testSqlInjection.loginByStatement(loginName);
testSqlInjection.loginByPreparedStatement(loginName);
}
我们查看执行结果,为登录失败,也就是说 PreparedStatement
可以防止 SQL 注入。究其原因很简单,PreparedStatement
将 ' or true or '
作为一个参数值传给 name
,数据库中当然没有该姓名了。
登录姓名:' or true or '
com.mysql.cj.jdbc.ClientPreparedStatement: select * from teacher where name = '' or true or ''
登录失败!
PreparedStatement
接口用于执行动态SQL语句。 它允许在 SQL 语句中使用占位符,然后在执行之前将这些占位符替换为实际的值。
预编译是指在执行 SQL 语句之前,将 SQL 语句编译为一个预定义的内部格式,以便数据库能够更有效地执行。
PreparedStatement
关键步骤:
// 使用Connection来创建PreparedStatement对象,将把语句发送到数据库进行预编译。
PreparedStatement preparedStatement = connection.prepareStatement("insert into teacher (name, sex, age) value (?, ?, ?)");
// 设置参数并执行SQL语句
preparedStatement.setString(1, "赵六");
preparedStatement.setString(2, "女");
preparedStatement.setInt(3, 20);
// 执行SQL语句
preparedStatement.executeUpdate();
PreparedStatement
优势:
日后 JDBC 编程使用 PreparedStatement
执行 SQL 语句。