【JDBC系列】從源碼角度理解JDBC和Mysql的預編譯特性

背景

最近由於工做調整的關係,都在和數據庫打交道,增長了許多和JDBC親密接觸的機會,其實咱們用的是Mybatis啦。知其然,知其因此然,是咱們工程師童鞋們應該追求的事情,可以幫助你更好的理解這個技術,面對問題時更遊刃有餘。因此呢,最近就在業務時間對JDBC進行了小小的研究,有一些小收穫,在此作個記錄。java

咱們都知道市面上有不少數據庫,好比Oracle,Sqlserver以及Mysql等,由於Mysql開放性以及可定製性比較強,平時在學校裏或者在互聯網從業的開發人員應該接觸Mysql最多,本文後續的講解也主要針對的是JDBC在Mysql驅動中的相關實現。mysql

提綱

本文簡單介紹了JDBC的由來,介紹了JDBC使用過程當中的驅動加載代碼,介紹了幾個經常使用的接口,着重分析了Statement和Preparement使用上以及他們對待SQL注入上的區別。最後着重分析了PrepareStatement開啓預編譯先後,防SQL注入以及具體執行上的區別。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) + "\t");
                }
                b.append("\n");
            }
            System.out.print(b.toString());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

主要接口:

  • DriverManager: 管理驅動程序,主要用於調用驅動從數據庫獲取鏈接。
  • Connection: 表明了一個數據庫鏈接。
  • Statement: 持有Sql語句,執行並返回執行後的結果。
  • ResulSet: Sql執行完畢,返回的記過持有

代碼分析

接下來咱們對示例代碼進行分析,闡述相關的知識點,具體實現均針對網絡

<dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
      <version>5.1.42</version>
</dependency>

驅動加載

在示例代碼的static代碼塊,咱們執行了app

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<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<DriverInfo>();

註冊完驅動後,咱們能夠經過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有三個具體的實現類:

  1. PreparedStatement: PreparedStatement建立時就傳過去一個sql語句,開始預編譯的話,會返回語句ID,下次傳語句ID和參數過去,就少了一次編譯過程。
  2. Statement: Statement用Connection獲得一個空的執行器,在執行的時候給它傳拼好的死的sql ,由於是整一個SQL,因此徹底匹配的機率低,每次都須要從新解析編譯。
  3. 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 '\n': /* Must be escaped for logs */
                                buf.append('\\');
                                buf.append('n');

                                break;

                            case '\r':
                                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

結論

  1. JDBC是個好東西。
  2. Statement沒有防止SQL注入的能力。
  3. PrepareStatement在沒有開啓預編譯時,在本地對SQL進行參數化處理,對個別字符進行轉移,開啓預編譯時,交由mysql端進行轉移處理。
  4. 建議都使用PrepareStatement,由於其在本地也能夠進行防SQL注入的簡單處理,傳輸時和statement同樣傳輸一條完整的sql。
  5. 若是開啓PrepareStatement的useServerPrepStmts=true特性,請同時開啓cachePrepStmts=true,不然一樣的SQL模板,每次要進行一次編譯,一次執行,網絡開銷成倍了,影響效率。

想進一步瞭解更多,能夠關注個人微信公衆號
想進一步瞭解更多,能夠關注個人微信公衆號

相關文章
相關標籤/搜索