MySQL的JDBC驅動原始碼解析
一、背景
MySQL是一箇中小型關係型資料庫管理系統,目前我們淘寶也使用的也非常廣泛。為了對開發中間DAO持久層的問題能有更深的理解以及最近在使用的phoenix on Hbase的SQL也是實現的JDBC規範,在遇到問題的時候能夠有更多的思路,於是研究了一下MySQL_JDBC驅動的原始碼,大家都知道JDBC是Java訪問資料庫的一套規範,具體訪問資料庫的細節有各個資料庫廠商自己實現,看驅動實現也有助有我們更好的理解JDBC規範,並且在這過程中也發現了一直以來對於PreparedStatement常識理解上的錯誤,與大家分享(MySQl版本5.1.39,JDBC驅動版本5.1.7,JDK版本1.6)。
二、JDBC典型應用
下面是個最簡單的使用JDBC取得資料的應用。主要能分成幾個步驟,分別是①載入資料庫驅動,②獲取資料庫連線,③建立PreparedStatement,並且設定引數 ④ 執行查詢 ,來分步分析這個過程。基本上每個步驟的原始碼分析我都畫了時序圖,如果不想看文字的話,可以對著時序圖看。最後我還會分析關於PreparedStatement預編譯的話題,有興趣的同學可以仔細看下。
Java程式碼
1. public class PreparedStatement_Select {
2. private Connection conn = null;
3. private PreparedStatement pstmt = null;
4. private ResultSet rs = null;
5. private String sql = "SELECT * FROM user WHERE id = ?";
7. public void selectStudent(int id) {
8. try {
9. // step1:載入資料庫廠商提供的驅動程式
10. Class.forName(“ com.mysql.jdbc.Driver
11. } catch (ClassNotFoundException e) {
12. e.printStackTrace();
13. }
15. String url = "jdbc:mysql://localhost:3306/studb";
16. try {
17. // step2:提供資料庫連線的URL,通過DriverManager獲得資料庫的一個連線物件
18. conn = DriverManager.getConnection(url, "root", "root");
19. } catch (SQLException e) {
20. e.printStackTrace();
21. }
23. try {
24. // step3:建立Statement(SQL的執行環境)
25. pstmt = conn.prepareStatement(sql);
26. pstmt.setInt(1, id);
28. // step4: 執行SQL語句
29. rs = pstmt.executeQuery();
31. // step5: 處理結果
32. while (rs.next()) {
33. int i = 1;
34. System.out.print(" 學員編號: " + rs.getInt(i++));
35. System.out.print(", 學員使用者名稱: " + rs.getString(i++));
36. System.out.print(", 學員密碼: " + rs.getString(i++));
37. System.out.println(", 學員年齡: " + rs.getInt(i++));
38. }
39. } catch (SQLException e) {
40. e.printStackTrace();
41. } finally {
42. // step6: 關閉資料庫連線
43. DbClose.close(rs, pstmt, conn);
44. }
45. }
46.}
三、JDBC驅動原始碼解析
Java資料庫連線(JDBC)由一組用 Java 程式語言編寫的類和介面組成。JDBC 為工具/資料庫開發人員提供了一個標準的 API,使他們能夠用純Java API 來編寫資料庫應用程式。說白了一套Java訪問資料庫的統一規範,如下圖,具體與資料庫互動的還是由驅動實現,JDBC規範之於驅動的關係,也類似於Servlet規範與Servlet容器(Tomcat)的關係,本質就是一套介面和一套實現的關係。如下類圖所示,我們平時開發JDBC時熟悉的Connection介面在Mysql驅動中的實現類是com.mysql.jdbc.JDBC4Connection類,PreparedStatement介面在Mysql驅動中的實現類是com.mysql.jdbc.JDBC4Connection, ResultSet介面在Mysql驅動中的實現類是 com.mysql.jdbc.JDBC4ResultSet,下面的原始碼解析也是通過這幾個類展開。
1:載入資料庫廠商提供的驅動程式
首先我們通過Class.forName("com.mysql.jdbc.Driver")來載入mysql的jdbc驅動。 Mysql的com.mysql.jdbc.Driver類實現了java.sql.Driver介面,任何資料庫提供商的驅動類都必須實現這個介面。在DriverManager類中使用的都是介面Driver型別的驅動,也就是說驅動的使用不依賴於具體的實現,這無疑給我們的使用帶來很大的方便。如果需要換用其他的資料庫的話,只需要把Class.forName()中的引數換掉就可以了,可以說是非常方便的,com.mysql.jdbc.Driver類也是驅動實現JDBC規範的第一步。
Java程式碼
1. public class Driver extends NonRegisteringDriver implements java.sql.Driver {
2. static {
3. try {
4. //往DriverManager中註冊自身驅動
5. java.sql.DriverManager.registerDriver(new Driver());
6. } catch (SQLException E) {
7. throw new RuntimeException("Can't register driver!");
8. }
9. }
10. public Driver() throws SQLException {
11. }
12. }
在com.mysql.jdbc.Driver類的靜態初始化塊中會向java.sql.DriverManager註冊它自己 ,註冊驅動首先就是初始化,然後把驅動的資訊封裝一下放進一個叫做DriverInfo的驅動資訊類中,最後放入一個驅動的集合中, 到此Mysql的驅動類com.mysql.jdbc.Driver也就已經註冊到DriverManager中了。
Java程式碼
1. public static synchronized void registerDriver(java.sql.Driver driver) throws SQLException {
2. if (!initialized) {
3. initialize();
4. }
6. DriverInfo di = new DriverInfo();
8. //把driver的資訊封裝一下,組成一個DriverInfo物件
9. di.driver = driver;
10. di.driverClass = driver.getClass();
11. di.driverClassName = di.driverClass.getName();
13. writeDrivers.addElement(di);
14. println("registerDriver: " + di);
16. readDrivers = (java.util.Vector) writeDrivers.clone();
17. }
註冊驅動的具體過程式列圖如下:
2.獲取資料庫連線
資料庫連線的本質其實就是客戶端維持了一個和遠端MySQL伺服器的一個TCP長連線,並且在此連線上維護了一些資訊。
通過 DriverManager.getConnection(url, "root", "root")獲取資料庫連線物件時,由於之前已經在 DriverManager中註冊了驅動類 ,所有會找到那個驅動類來連線資料庫com.mysql.jdbc.Driver.connect
Java程式碼
1. private static Connection getConnection(
2. String url, java.util.Properties info, ClassLoader callerCL) throws SQLException {
3. java.util.Vector drivers = null;
5. if (!initialized) {
6. initialize();
7. }
8. //取得連線使用的driver從readDrivers中取
9. synchronized (DriverManager.class){
10. drivers = readDrivers;
11. }
13. SQLException reason = null;
14. for (int i = 0; i < drivers.size(); i++) {
15. DriverInfo di = (DriverInfo)drivers.elementAt(i);
17. if ( getCallerClass(callerCL, di.driverClassName ) != di.driverClass ) {
18. continue;
19. }
20. try {
21. // 找到可供使用的驅動,連線資料庫server
22. Connection result = di.driver.connect(url, info);
23. if (result != null) {
24. return (result);
25. }
26. } catch (SQLException ex) {
27. if (reason == null) {
28. reason = ex;
29. }
}
接著看com.mysql.jdbc.Driver.connect是如何建立連線返回資料庫連線物件的, 寫法很簡潔,就是建立了一個MySQL的資料庫連線物件, 傳入host, port, database等連線資訊,在com.mysql.jdbc.Connection的構造方法裡面有個createNewIO()方法,主要會做兩件事情,一、建立和MysqlServer的Socket連線,二、連線成功後,進行登入校驗, 傳送使用者名稱、密碼、當前資料庫連線預設選擇的資料庫名。
Java程式碼
1. public java.sql.Connection connect(String url, Properties info)
2. throws SQLException {
3. Properties props = null;
4. try {
5. // 傳入host,port,database等連線資訊建立資料庫連線物件
6. Connection newConn = new com.mysql.jdbc.ConnectionImpl(host(props),
7. port(props), props, database(props), url);
9. return newConn;
10. } catch (SQLException sqlEx) {
11. throw sqlEx;
12. } catch (Exception ex) {
13. throw SQLError.createSQLException(...);
14. }
15. }
繼續往下看ConnectionImpl構造器中的實現,會呼叫 createNewIO()方法來建立一個MysqlIO物件,維護在Connection中。
Java程式碼
1. protected ConnectionImpl(String hostToConnectTo, int portToConnectTo, Properties info,
2. String databaseToConnectTo, String url)
3. throws SQLException {
4. try {
5. this.dbmd = getMetaData(false, false);
6. //建立MysqlIO物件,建立和MySql服務端的連線,並且進行登入校驗工作
7. createNewIO(false);
8. initializeStatementInterceptors();
9. this.io.setStatementInterceptors(this.statementInterceptors);
10. } catch (SQLException ex) {
11. cleanup(ex);
13. throw ex;
14. }
15. }
16. }
緊接著createNewIO()會建了一個com.mysql.jdbc.MysqlIO,利用com.mysql.jdbc.StandardSocketFactory來建立一個Socket建立與MySQL伺服器的連線,然後就由這個mySqlIO來與MySql伺服器進行握手(doHandshake()),這個doHandshake主要用來初始化與MySQL server的連線,負責登陸伺服器和處理連線錯誤。在其中會分析所連線的mysql server的版本,根據不同的版本以及是否使用SSL加密資料都有不同的處理方式,並把要傳輸給資料庫server的資料都放在一個叫做packet的buffer中,呼叫send()方法往outputStream中寫入要傳送的資料。
Java程式碼
1. protected void createNewIO(boolean isForReconnect)
2. throws SQLException {
4. // 建立一個MysqlIO物件,建立與Mysql伺服器的Socket連線
5. this.io = new MysqlIO(newHost, newPort, mergedProps,
6. getSocketFactoryClassName(), this,
7. getSocketTimeout(),
8. this.largeRowSizeThreshold.getValueAsInt());
10. // 登入校驗MySql Server, 傳送使用者名稱、密碼、當前資料庫連線預設選擇的資料庫名
11. this.io.doHandshake(this.user, this.password,
12. this.database);
14. // 獲取MySql資料庫連線的連線ID
15. this.connectionId = this.io.getThreadId();
16. this.isClosed = false;
17. }
具體的跟Mysql Server建立連線的程式碼如下:
Java程式碼
1. public MysqlIO(String host, int port, Properties props,
2. String socketFactoryClassName, ConnectionImpl conn,
3. int socketTimeout, int useBufferRowSizeThreshold) throws IOException, SQLException {
4. this.connection = conn;
6. try {
7. // 建立Socket物件,和MySql伺服器建立連線
8. this.mysqlConnection = this.socketFactory.connect(this.host,
9. this.port, props);
11. // 獲取Socket物件
12. this.mysqlConnection = this.socketFactory.beforeHandshake();
14. //封裝SocketInputStream輸入流
15. if (this.connection.getUseReadAheadInput()) {
16. this.mysqlInput = new ReadAheadInputStream(this.mysqlConnection.getInputStream(), 16384,
17. this.connection.getTraceProtocol(),
18. this.connection.getLog());
19. } else {
20. this.mysqlInput = new BufferedInputStream(this.mysqlConnection.getInputStream(),
21. 16384);
22. }
23. //封裝ScoketOutputStream輸出流
24. this.mysqlOutput = new BufferedOutputStream(this.mysqlConnection.getOutputStream(),
25. 16384);
26. }
具體的跟MySQL Server互動登入校驗的程式碼如下:
Java程式碼
1. void secureAuth411(Buffer packet, int packLength, String user,
2. String password, String database, boolean writeClientParams)
3. throws SQLException {
5. // 設定使用者名稱
6. packet.writeString(user, "utf-8", this.connection);
8. if (password.length() != 0) {
9. packet.writeByte((byte) 0x14);
10. try {
11. // 設定密碼
12. packet.writeBytesNoNull(Security.scramble411(password, this.seed, this.connection));
13. } catch (NoSuchAlgorithmException nse) {
14. }
16. if (this.useConnectWithDb) {
17. // 設定連線資料庫名
18. packet.writeString(database, "utf-8", this.connection);
19. }
21. // 向Mysql伺服器傳送登入資訊包(使用者名稱、密碼、此Socket連線預設選擇的資料庫)
22. send(packet, packet.getPosition());
24. byte savePacketSequence = this.packetSequence++;
26. // 讀取Mysql伺服器登入檢驗後傳送的狀態資訊,如果成功就返回,如果登入失敗則丟擲異常
27. Buffer reply = checkErrorPacket();
28. }
最終由SocketOutputStream經過一次RPC傳送給MySQLServer進行驗證。
Java程式碼
1. private final void send(Buffer packet, int packetLen)
2. throws SQLException {
3. try {
4. //把登入資訊的位元組流傳送給MySQL Server
5. this.mysqlOutput.write(packetToSend.getByteBuffer(), 0,
6. packetLen);
7. this.mysqlOutput.flush();
8. }
9. } catch (IOException ioEx) {
10. throw SQLError.createCommunicationsException(this.connection,
11. this.lastPacketSentTimeMs, this.lastPacketReceivedTimeMs, ioEx);
12. }
13. }
具體的獲取資料庫連線的序列圖如下:
3.建立PreparedStatement,並設定引數
當建立完資料庫連線之後,就可以通過conn.prepareStatement(sql) 來獲取SQL執行環境PreparedStatement了,獲取PreparedStatement的邏輯非常簡單,會根據需要編譯的SQL語句和Connection連線物件來建立一個JDBC4PreparedStatement物件,也就是相應SQL的執行環境了,具體程式碼如下:
Java程式碼
1. public java.sql.PreparedStatement prepareStatement(String sql,
2. int resultSetType, int resultSetConcurrency) throws SQLException {
3. checkClosed();
4. PreparedStatement pStmt = null;
6. //需要預編譯的SQL語句
7. String nativeSql = getProcessEscapeCodesForPrepStmts() ? nativeSQL(sql): sql;
9. if (this.useServerPreparedStmts && getEmulateUnsupportedPstmts()) {
10. canServerPrepare = canHandleAsServerPreparedStatement(nativeSql);
11. }
12. // 建立JDBC4PreapareStatement物件,這個SQL環境中持有預編譯SQL語句及對應的資料庫連線物件
13. pStmt = com.mysql.jdbc.PreparedStatement.getInstance(this, nativeSql,
14. this.database);
15. return pStmt;
16. }
當建立完SQL執行環境PreparedStatement物件之後,就可以設定一些自定義的引數了,最終會把引數值儲存在JDBC4PreapareStatement的parameterValues欄位,引數型別儲存在parameterTypes中,如下程式碼:
Java程式碼
1. public void setInt(int parameterIndex, int x) throws SQLException {
2. int parameterIndexOffset = getParameterIndexOffset();
4. checkBounds(paramIndex, parameterIndexOffset);
5. byte[] parameterAsBytes = StringUtils.getBytes(String.value(x), this.charConverter,
6. this.charEncoding, this.connection
7. .getServerCharacterEncoding(), this.connection
8. .parserKnowsUnicode());
9. this.parameterStreams[paramIndex - 1 + parameterIndexOffset] = null;
10. //設定引數值
11. this.parameterValues[paramIndex - 1 + parameterIndexOffset] = parameterAsBytes;
12. //設定引數型別
13. this.parameterTypes[parameterIndex - 1 + getParameterIndexOffset()] = Types.INTEGER;
14. }
具體的建立PreparedStatement的序列圖如下:
3.執行查詢
建立完PreparedStatement之後,就一切準備就緒了,就可以通過 pstmt.executeQuery()來執行查詢了。主要思路是根據SQL模板和設定的引數,解析成一條完整的SQL語句,最後根據MySQL協議,序列化成位元組流,RPC傳送給MySQL服務端。主要的處理過程如下:
Java程式碼
1. public java.sql.ResultSet executeQuery() throws SQLException {
2. checkClosed();
3. ConnectionImpl locallyScopedConn = this.connection;
4. CachedResultSetMetaData cachedMetadata = null;
5. synchronized (locallyScopedConn.getMutex()) {
6. if (doStreaming
7. && this.connection.getNetTimeoutForStreamingResults() > 0) {
8. locallyScopedConn.execSQL(this, "SET net_write_timeout="
9. + this.connection.getNetTimeoutForStreamingResults(),
10. -1, null, ResultSet.TYPE_FORWARD_ONLY,
11. ResultSet.CONCUR_READ_ONLY, false, this.currentCatalog,
12. null, false);
13. }
14. //解析封裝需要傳送的sql語句,序列化成MySQL協議對應的位元組流
15. Buffer sendPacket = fillSendPacket();
17. if (locallyScopedConn.getCacheResultSetMetadata()) {
18. cachedMetadata = locallyScopedConn.getCachedMetaData(this.originalSql);
19. }
21. Field[] metadataFromCache = null;
23. // 執行sql語句,並獲取MySQL傳送過來的結果位元組流,根據MySQL協議反序列化成ResultSet
24. this.results = executeInternal(-1, sendPacket,
25. doStreaming, true,
26. metadataFromCache, false);
27.
28. if (oldCatalog != null) {
29. locallyScopedConn.setCatalog(oldCatalog);
30. }
32. }
33. this.lastInsertId = this.results.getUpdateID();
34. return this.results;
35. }
接下來看下 fillSendPacket() 方法怎麼來序列化成二進位制位元組流的,請看下面的程式碼分析
Java程式碼
1. protected Buffer fillSendPacket(byte[][] batchedParameterStrings,
2. InputStream[] batchedParameterStreams, boolean[] batchedIsStream,
3. int[] batchedStreamLengths) throws SQLException {
4. // 從connection的IO中得到傳送資料包,首先清空其中的資料
5. Buffer sendPacket = this.connection.getIO().getSharedSendPacket();
6. sendPacket.clear();
8. //資料包的第一位為一個操作識別符號(MysqlDefs.QUERY),表示驅動向伺服器傳送的連線的操作訊號,包括有QUERY, PING, RELOAD, SHUTDOWN, PROCESS_INFO, QUIT, SLEEP等等,這個操作訊號並不是針對sql語句操作而言的CRUD操作,從提供的幾種引數來看,這個操作是針對伺服器的一個操作。一般而言,使用到的都是MysqlDefs.QUERY,表示傳送的是要執行sql語句的操作。
9. sendPacket.writeByte((byte) MysqlDefs.QUERY);
11. boolean useStreamLengths = this.connection
12. .getUseStreamLengthsInPrepStmts();
14. int ensurePacketSize = 0;
15. for (int i = 0; i < batchedParameterStrings.length; i++) {
16. if (batchedIsStream[i] && useStreamLengths) {
17. ensurePacketSize += batchedStreamLengths[i];
18. }
19. }
21. //判斷這個sendPacket的byte buffer夠不夠大,不夠大的話,按照1.25倍來擴充buffer
22. if (ensurePacketSize != 0) {
23. sendPacket.ensureCapacity(ensurePacketSize);
24. }
26. //遍歷所有的引數。在prepareStatement階段的new ParseInfo()中,驅動曾經對sql語句進行過分割,如果含有以問號標識的引數佔位符的話,就記錄下這個佔位符的位置,依據這個位置把sql分割成多段,放在了一個名為staticSql的字串中。這裡就開始把sql語句進行拼裝,把staticSql和parameterValues進行組合,放在操作符的後面
27. for (int i = 0; i < batchedParameterStrings.length; i++) {
28. if ((batchedParameterStrings[i] == null)
29. && (batchedParameterStreams[i] == null)) {
30. throw SQLError.createSQLException(Messages
31. .getString("PreparedStatement.40") //$NON-NLS-1$
32. + (i + 1), SQLError.SQL_STATE_WRONG_NO_OF_PARAMETERS);
33. }
35. //在sendPacket中加入staticSql陣列中的元素,就是分割出來的沒有”?”的sql語句,並把字串轉換成byte
36. sendPacket.writeBytesNoNull(this.staticSqlStrings[i]);
38. if (batchedIsStream[i]) {
39. streamToBytes(sendPacket, batchedParameterStreams[i], true,
40. batchedStreamLengths[i], useStreamLengths);
41. } else {
43. //用batchedParamet