资讯专栏INFORMATION COLUMN

【JDBC系列】从源码角度理解JDBC和Mysql的预编译特性

longshengwang / 2761人阅读

摘要:我们对语句做适当改变,就完成了注入,因为普通的不会对做任何处理,该例中单引号后的生效,拉出了所有数据。查询资料后,发现还要开启一个参数,让端缓存,缓存是级别的。结论是个好东西。

背景

最近因为工作调整的关系,都在和数据库打交道,增加了许多和JDBC亲密接触的机会,其实我们用的是Mybatis啦。知其然,知其所以然,是我们工程师童鞋们应该追求的事情,能够帮助你更好的理解这个技术,面对问题时更游刃有余。所以呢,最近就在业务时间对JDBC进行了小小的研究,有一些小收获,在此做个记录。

我们都知道市面上有很多数据库,比如Oracle,Sqlserver以及Mysql等,因为Mysql开放性以及可定制性比较强,平时在学校里或者在互联网从业的开发人员应该接触Mysql最多,本文后续的讲解也主要针对的是JDBC在Mysql驱动中的相关实现。

提纲

本文简单介绍了JDBC的由来,介绍了JDBC使用过程中的驱动加载代码,介绍了几个常用的接口,着重分析了Statement和Preparement使用上以及他们对待SQL注入上的区别。最后着重分析了PrepareStatement开启预编译前后,防SQL注入以及具体执行上的区别。

为什么需要JDBC

我们都知道,每家数据库的具体实现都会有所不同,如果开发者每接触一种新的数据库,都需要对其具体实现进行编程了,那我估计真正的代码还没开始写,先累死在底层的开发上了,同时这也不符合Java面向接口编程的特点。于是就有了JDBC。

JDBC(Java Data Base Connectivity,java数据库连接)是一种用于执行SQL语句的Java API,可以为多种关系数据库提供统一访问,它由一组用Java语言编写的类和接口组成。


如果用图来表示的话,如上图所示,开发者不必为每家数据通信协议的不同而疲于奔命,只需要面向JDBC提供的接口编程,在运行时,由对应的驱动程序操作对应的DB。

示例代码

光说不练假把式,奉上一段简单的示例代码,主要完成了获取数据库连接,执行SQL语句,打印返回结果,释放连接的过程。

package jdbc;

import java.sql.*;

/**
 * @author cenkailun
 * @Date 17/5/20
 * @Time 下午5:09
 */
public class Main {

    private static final String url = "jdbc:mysql://127.0.0.1:3306/demo";
    private static final String user = "root";
    private static final String password = "123456";

    static {
        try {
            Class.forName("com.mysql.jdbc.Driver");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws SQLException {
        Connection connection = DriverManager.getConnection(url, user, password);

        System.out.println("Statement 语句结果: ");
        Statement statement = connection.createStatement();
        statement.execute("SELECT * FROM SU_City limit 3");
        ResultSet resultSet = statement.getResultSet();
        printResultSet(resultSet);
        resultSet.close();
        statement.close();
        System.out.println();

        System.out.println("PreparedStatement 语句结果: ");
        PreparedStatement preparedStatement = connection
                .prepareStatement("SELECT * FROM SU_City WHERE city_en_name = ? limit 3");
        preparedStatement.setString(1, "beijing");
        preparedStatement.execute();
        resultSet = preparedStatement.getResultSet();
        printResultSet(resultSet);
        resultSet.close();
        preparedStatement.close();
        connection.close();

    }

    /**
     * 处理返回结果集
     */
    private static void printResultSet(ResultSet rs) {
        try {
            ResultSetMetaData meta = rs.getMetaData();
            int cols = meta.getColumnCount();
            StringBuffer b = new StringBuffer();
            while (rs.next()) {
                for (int i = 1; i <= cols; i++) {
                    b.append(meta.getColumnName(i) + "=");
                    b.append(rs.getString(i) + "	");
                }
                b.append("
");
            }
            System.out.print(b.toString());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
主要接口:

DriverManager: 管理驱动程序,主要用于调用驱动从数据库获取连接。

Connection: 代表了一个数据库连接。

Statement: 持有Sql语句,执行并返回执行后的结果。

ResulSet: Sql执行完毕,返回的记过持有

代码分析

接下来我们对示例代码进行分析,阐述相关的知识点,具体实现均针对


      mysql
      mysql-connector-java
      5.1.42
驱动加载

在示例代码的static代码块,我们执行了

Class.forName("com.mysql.jdbc.Driver"); 

Class.forName会通过反射,初始化一个类。在com.mysql.jdbc.Driver,目测来说这是mysql对于JDBC中Driver接口的一个具体实现,在这个类里面,在其static代码块,它向DriverManager注册了自己。

public class Driver extends NonRegisteringDriver implements java.sql.Driver {
    //
    // Register ourselves with the DriverManager
    //
    static {
        try {
            java.sql.DriverManager.registerDriver(new Driver());
        } catch (SQLException E) {
            throw new RuntimeException("Can"t register driver!");
        }
    }

    /**
     * Construct a new driver and register it with DriverManager
     * 
     * @throws SQLException
     *             if a database error occurs.
     */
    public Driver() throws SQLException {
        // Required for Class.forName().newInstance()
    }
}

在DriverManger有一个CopyOnWriterArrayList,保存了注册驱动,以后可以再介绍一下它,它是在写的时候复制一份出去写,写完再复制回去。

private final static CopyOnWriteArrayList registeredDrivers = new CopyOnWriteArrayList(); 

注册完驱动后,我们可以通过DriverManager拿到Connection,这里有一个疑问,如果注册了多个驱动怎么办? JDBC对这种也有应对方法,在选择使用哪个驱动的时候,会调用每个驱动实现的acceptsURL,判断这个驱动是不是符合条件。

public static Driver getDriver(String url)
        throws SQLException {
        Class callerClass = Reflection.getCallerClass();
        for (DriverInfo aDriver : registeredDrivers) {
            if(isDriverAllowed(aDriver.driver, callerClass)) {
                try {
                    if(aDriver.driver.acceptsURL(url)) {
                         return (aDriver.driver);
                    }
..............................................

如果有多个符合条件的驱动,就先到先得呗~
接下来是构建Sql语句。statement有三个具体的实现类:

PreparedStatement: PreparedStatement创建时就传过去一个sql语句,开始预编译的话,会返回语句ID,下次传语句ID和参数过去,就少了一次编译过程。

Statement: Statement用Connection得到一个空的执行器,在执行的时候给它传拼好的死的sql ,因为是整一个SQL,所以完全匹配的概率低,每次都需要重新解析编译。

CallableStatement 用于执行存储过程,目前没遇到过。

下文主要讲StatementPreparedStatement。

前提:mysql执行脚本的大致过程如下:prepare(准备)-> optimize(优化)-> exec(物理执行),其中,prepare也就是我们所说的编译。前面已经说过,对于同一个sql模板,如果能将prepare的结果缓存,以后如果再执行相同模板而参数不同的sql,就可以节省掉prepare(准备)的环节,从而节省sql执行的成本

Statement

Statement可以理解为,每次都会把SQL语句,完整传输到Mysql端,被人一直诟病的,就是其难以防止最简单的Sql注入。

2017-05-20T10:07:20.439856Z       15 Query    SET NAMES latin1
2017-05-20T10:07:20.440138Z       15 Query    SET character_set_results = NULL
2017-05-20T10:07:20.440733Z       15 Query    SET autocommit=1
2017-05-20T10:07:20.445518Z       15 Query    SELECT * FROM SU_City limit 3

我们对statement语句做适当改变,city_en_name = ""beijing" OR 1 = 1",就完成了SQL注入,因为普通的statement不会对SQL做任何处理,该例中单引号后的OR 生效,拉出了所有数据。

2017-05-20T10:10:02.739761Z 17 Query SELECT * FROM SU_City WHERE city_en_name = "beijing" OR 1 = 1 limit 3
PreparedStatement

对于PreparedStatement,之前的认识是因为使用了这个,它会预编译,所以能防止SQL注入,所以为什么它能防止呢,说不清楚。我们先来看一下效果。

2017-05-20T10:14:16.841835Z 19 Query SELECT * FROM SU_City WHERE city_en_name = ""beijing" OR 1 = 1 " limit 3

同样的代码,单引号被转义了,所以没被SQL注入。

但我希望大家注意到,在这里,我们并没有开启预编译哦。所以说因为开启预编译,能防止SQL注入是不对的。

围观了下代码,发现在未开启预编译的时候,在setString时,使用的是mysql驱动的PreparedStatement,在这个方法里,会对参数进行处理。

publicvoidsetString(intparameterIndex, String x)throwsSQLException {

大致是在这里。

  for (int i = 0; i < stringLength; ++i) {
                        char c = x.charAt(i);

                        switch (c) {
                            case 0: /* Must be escaped for "mysql" */
                                buf.append("");
                                buf.append("0");

                                break;

                            case "
": /* Must be escaped for logs */
                                buf.append("");
                                buf.append("n");

                                break;

                            case "
":
                                buf.append("");
                                buf.append("r");

                                break;

                            case "":
                                buf.append("");
                                buf.append("");

                                break;

                            case """:
                                buf.append("");
                                buf.append(""");

                                break;

所以因为开启预编译才防止SQL注入是不对的,当然开启预编译后,确实也能防止。
Mysql其实是支持预编译的。你需要在JDBCURL里指定,这样就开启预编译成功。

"jdbc:mysql://127.0.0.1:3306/demo?useServerPrepStmts=true" 

同时我们可以证明开启服务端预编译后,参数是在Mysql端进行转义了。下文是开启服务端预编译后,具体的日志情况。开启wireshark,可以看到传参数时是没有转义的,所以在服务端Mysql也能够对个别字符进行转义处理。

2017-05-20T10:27:53.618269Z       20 Prepare    SELECT * FROM SU_City WHERE city_en_name = ? limit 3
2017-05-20T10:27:53.619532Z       20 Execute    SELECT * FROM SU_City WHERE city_en_name = ""beijing" OR 1 = 1 " limit 3


再深入一点,如果是新开启一个PrepareStatement,会看到,还是要预编译两次,那预编译的意义就没有了,等于每次都多了一次网络传输。

2017-05-20T10:33:26.206977Z       23 Prepare    SELECT * FROM SU_City WHERE city_en_name = ? limit 3
2017-05-20T10:33:26.208019Z       23 Execute    SELECT * FROM SU_City WHERE city_en_name = ""beijing" OR 1 = 1 " limit 3
2017-05-20T10:33:26.208829Z       23 Prepare    SELECT * FROM SU_City WHERE city_en_name = ? limit 3
2017-05-20T10:33:26.209098Z       23 Execute    SELECT * FROM SU_City WHERE city_en_name = ""beijing" OR 1 = 1 " limit 3

查询资料后,发现还要开启一个参数,让JVM端缓存,缓存是Connection级别的。然后看效果。

"jdbc:mysql://127.0.0.1:3306/demo?useServerPrepStmts=true&cachePrepStmts=true"; 

查看日志,发现还是两次,?我了。

2017-05-20T10:34:51.540301Z       25 Prepare    SELECT * FROM SU_City WHERE city_en_name = ? limit 3
2017-05-20T10:34:51.541307Z       25 Execute    SELECT * FROM SU_City WHERE city_en_name = ""beijing" OR 1 = 1 " limit 3
2017-05-20T10:34:51.542025Z       25 Prepare    SELECT * FROM SU_City WHERE city_en_name = ? limit 3
2017-05-20T10:34:51.542278Z       25 Execute    SELECT * FROM SU_City WHERE city_en_name = ""beijing" OR 1 = 1 " limit 3

阴差阳错,点进PrepareStatement的close方法,才看到如下代码,恍然大悟,一定要关闭,缓存才会生效。

public void close() throws SQLException {
        MySQLConnection locallyScopedConn = this.connection;

        if (locallyScopedConn == null) {
            return; // already closed
        }
        synchronized (locallyScopedConn.getConnectionMutex()) {
            if (this.isCached && isPoolable() && !this.isClosed) {
                clearParameters();
                this.isClosed = true;
                this.connection.recachePreparedStatement(this);
                return;
            }

            realClose(true, true);
        }
    }

其实是假装关闭了statement,其实是把statement塞进缓存了。然后我们再看看效果,完美。

2017-05-20T10:39:39.410584Z       26 Prepare    SELECT * FROM SU_City WHERE city_en_name = ? limit 3
2017-05-20T10:39:39.411715Z       26 Execute    SELECT * FROM SU_City WHERE city_en_name = ""beijing" OR 1 = 1 " limit 3
2017-05-20T10:39:39.412388Z       26 Execute    SELECT * FROM SU_City WHERE city_en_name = ""beijing" OR 1 = 1 " limit 3
结论

JDBC是个好东西。

Statement没有防止SQL注入的能力。

PrepareStatement在没有开启预编译时,在本地对SQL进行参数化处理,对个别字符进行转移,开启预编译时,交由mysql端进行转移处理。

建议都使用PrepareStatement,因为其在本地也可以进行防SQL注入的简单处理,传输时和statement一样传输一条完整的sql。

如果开启PrepareStatement的useServerPrepStmts=true特性,请同时开启cachePrepStmts=true,否则同样的SQL模板,每次要进行一次编译,一次执行,网络开销成倍了,影响效率。

想进一步了解更多,可以关注我的微信公众号

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/67418.html

相关文章

  • mybatis深入理解(一)之 # 与 $ 区别以及 sql 预编译

    摘要:在动态解析阶段,和会有不同的表现解析为一个预编译语句的参数标记符。其次,在预编译之前已经被变量替换了,这会存在注入问题。预编译语句对象可以重复利用。默认情况下,将对所有的进行预编译。总结本文主要深入探究了对和的不同处理方式,并了解了预编译。 mybatis 中使用 sqlMap 进行 sql 查询时,经常需要动态传递参数,例如我们需要根据用户的姓名来筛选用户时,sql 如下: sele...

    shadowbook 评论0 收藏0
  • database

    摘要:它是第一个把数据分布在全球范围内的系统,并且支持外部一致性的分布式事务。目的是使得开发者阅读之后,能对项目有一个初步了解,更好的参与进入的开发中。深度探索数据库并发控制技术并发控制技术是数据库事务处理的核心技术。 存储过程高级篇 讲解了一些存储过程的高级特性,包括 cursor、schema、控制语句、事务等。 数据库索引与事务管理 本篇文章为对数据库知识的查缺补漏,从索引,事务管理,...

    csRyan 评论0 收藏0
  • 【Mybatis系列源码角度理解Mybatis的$#的作用

    摘要:原因就是传入的和原有的单引号,正好组成了,而后面恒等于,所以等于对这个库执行了查所有的操作。类比的执行流程和原有的我们使用的方法就是。可以理解为就是用来解析定制的符号的语句。后续的流程,就和正常的流程一致了。 前言 在JDBC中,主要使用的是两种语句,一种是支持参数化和预编译的PrepareStatement,能够支持原生的Sql,也支持设置占位符的方式,参数化输入的参数,防止Sql注...

    yanwei 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<