徹底理解 JDBC

想要更好的閱讀體驗,可以轉我的個人博客: nonlinearthink

JDBC (Java Database Connectivity) ,屬於 Java 應用編程中比較基礎的一塊,我們會從最基本的開始,由淺入深地解釋 JDBC 中的各種問題。

連接數據庫

驅動初始化

基於 Class.forName 的初始化

想要開始 JDBC 編程,第一步是需要把 數據庫驅動程序 的代碼加載進來。

可以利用 Class.forName 函數,它原本的功能是返回一個 類或者接口的 Class 對象 ,也就是相當於初始化了一個類,一般用它執行這個類的靜態代碼段

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

這樣,com.mysql.jdbc.Driver 類中的靜態代碼段就會被執行,進行初始化。

不過,驅動初始化還有另外一種寫法。

基於 registerDriver 的初始化

我們知道 Class.forName 是執行類的靜態代碼段,那我們把 com.mysql.jdbc.Driver 裏面的靜態代碼段照着樣子抄一遍不也可以實現初始化嗎?

這是 com.mysql.jdbc.Driver 靜態代碼段的源代碼:

static {
	try {
		java.sql.DriverManager.registerDriver(new Driver());
	} catch (SQLException E) {
		throw new RuntimeException("Can't register driver!");
	}
}

這段代碼可以完全代替上一小節的代碼,我們可以認爲 DriverManager.registerDriver(new Driver())Class.forName 的寫法是等效的。

什麼是 數據庫驅動程序

JDK 提供了一個 JDBC 的接口(Interface)。但是,因爲是接口,所以還是沒有實現的,每個數據庫的廠商需要自己實現這個接口,這樣用戶才能正常調用。

以 mysql 爲例,下載地址: dev.mysql.com/downloads/connector/j

接口變動

mysql5 和 mysql6 的驅動程序接口有區別,從 com.mysql.jdbc.Driver 換成了 com.mysql.cj.jdbc.Driver,再往上的版本同 mysql6 。

建立連接

數據庫打開後會在本地開一個端口,運行進程,我們可以通過這個端口的 URL 來訪問數據庫。

當然,還需要數據庫的用戶名和密碼。

java.sql.DriverManager.getConnection(url, user, password);

JDBC 的 URL 格式: jdbc:[數據庫連接名]://localhost:[端口號]/[數據庫名]

  • 數據庫連接名
    mysql、sqlserver
  • 端口號
    3306(mysql)、1433(sqlserver)
  • 數據庫名
    業務相關的數據庫名,自定義

一個 JDBC 連接數據庫的例子:

try {
    java.sql.Connection conn = java.sql.DriverManager.getConnection("jdbc:mysql://localhost:3306/booklib",
            "root", "123456");
} catch (SQLException e) {
    e.printStackTrace();
}

JDBC 基礎

Statement

Statement 可以根據給出的一條 SQL 字符串,然後調用運行。

借用上一節連接得到的 conn 對象,它有一個 createStatement 函數,可以創建一個 Statement。

try {
    java.sql.Connection conn = java.sql.DriverManager.getConnection("jdbc:mysql://localhost:3306/booklib",
            "root", "123456");
    java.sql.Statement st =  conn.createStatement();
} catch (SQLException e) {
    e.printStackTrace();
}

execute、executeQuery、executeUpdate

創建完 Statement,就要把 SQL 語句交給 Statement 對象去執行了。

executeQuery

一般如果是運行 查詢(select) 語句,推薦使用 第一節中獲得的conn 對象的 executeQuery 函數,executeQuery只能返回單個結果集,但是應對大部分的查詢已經足夠。

添加下面的代碼到上面的 try 代碼塊中。

String sql = "select * from BeanBook";
st.executeQuery(sql);

executeUpdate

如果你需要運行 insertupdatedelete 等等語句,則可以使用 executeUpdate 函數,它不會返回結果集,但是會返回一個整數,代表受到影響的行數,如果是 0,代表了你沒有改變任何數據庫的內容,即調用失敗了。

使用 executeUpdate :

String sql = "delete from BeanBook where price>50";
st.executeUpdate(sql);

execute

execute 是更加通用和強大的函數,但是它也比較複雜。它的返回值類型有很多。

execute 不僅可以做到 executeQuery 能做的事,也能做到 executeUpdate 能做到的事情。而且,它還能返回多個結果集。

正因爲如此,它一般被用在一些執行未知 SQL 字符串的情況下,如果 SQL 語句能夠確定,請儘可能不用 execute

它的返回值比較複雜,我們一般使用 getResultSetgetUpdateCount 獲取,而不是直接把 st.execute(sql); 的結果拿來。

以下兩段代碼和上面兩小節的 demo 的效果是一樣的。

//代碼段1
String sql = "select * from BeanBook";
st.execute(sql);
st.getResultSet();

//代碼段2
String sql = "delete from BeanBook where price>50";
st.execute(sql);
st.getUpdateCount();

ResultSet

前面一直沒提 ResultSet,ResultSet 就是 executeQuery 的返回值。

舉個例子,使用 ResultSet 遍歷 SQL 的結果:

String sql = "select * from BeanBook";
java.sql.ResultSet rs = st.executeQuery(sql);
while (rs.next()) {
    System.out.println(rs.getString(1) + "\t" + rs.getString(2) + "\t" + rs.getDouble(4));
}

ResultSet 通過 next 函數來遍歷,next 從一條記錄跳轉到下一條記錄。

getStringgetDouble 等等函數接受一個數字 n 作爲參數,獲得當前記錄的第 n 個屬性的值,並對這個值進行轉換。

比如 getString(1) 獲取第一個屬性,轉換成 String 類型;getDouble(4) 獲取第四個屬性,轉換成 Double 類型。

下面是一些 get 函數 (不全):

  • 原始類型相關
    getString、getBoolean、getByte、getShort、getInt、getLong、getFloat、getDouble
  • 日期相關
    getDate、getTime、getTimestamp

PreparedStatement

通過 conn.prepareStatement 可以來創建一個 PrepareStatement 對象 (conn 是一個 java.sql.Connection)。

但是這個函數必須要給出一個 SQL 語句作爲參數。

這裏也可以看出 PrepareStatement 與 Statement 的一個比較大的區別。Statement 可以一直被複用,但是 PrepareStatement 每執行一次 SQL,都要創建新的 PrepareStatement。

//Statement
java.sql.Statement st =  conn.createStatement();
Int limit = 50;
String sql = "delete from BeanBook where price>50";
st.executeUpdate(sql);
String sql2 = "select * from BeanBook";
st.executeQuery(sql2);

//PrepareStatement
String sql = "delete from BeanBook where price>50";
java.sql.PreparedStatement pst = conn.prepareStatement(sql);
st.executeUpdate();
String sql2 = "select * from BeanBook";
java.sql.PreparedStatement pst2 = conn.prepareStatement(sql2);
st.executeQuery();

PreparedStatement 和 Statement 一樣是用來執行 SQL 語句的,但是 Statement 有很多問題。

字符串拼接問題

假如我們希望可以動態地設置 SQL 語句,比如,動態改變 where 從句的條件,在 Statement 中,我們需要這樣寫:

java.sql.Statement st =  conn.createStatement();
Int limit = 50;
String sql = "delete from BeanBook where price>"+limit;
st.executeUpdate(sql);

PrepareStatement 允許一種可讀性非常好的參數設置語法:

String sql = "delete from BeanBook where price>?";
java.sql.PreparedStatement pst = conn.prepareStatement(sql);
pst.setInt(1, 50);
st.executeUpdate(sql);

這個 ? 語法可不止可讀性好,還有一個更重要的是,它支持了 預編譯,這在我們接下來提到的性能問題中會被具體討論。

還需要提一下的是 setInt 等等的 set 相關的函數,前面已經提過 get 函數了,基本上把 get 改成 set 就行了。

性能問題

Statement 的想法是對象只需要創建一次,後續只要傳入不同的 SQL 就行了。但是在面對重複都比較高的工作的時候,這可能並不是很好。

比如,我執行了一次 insert into Beanbook(barcode, bookname, pubid, price, state) values(1, 'Java', 3, 56, '在庫')

現在我稍微變一下,要插入 insert into Beanbook(barcode, bookname, pubid, price, state) values(2, 'C++', 2, 34, '在庫')

這兩條命令幾乎一樣,但是我卻要編譯兩次!!!

我們來看看 PrepareStaement,我們完全可以使用 ? 語法,創建一次模版,因爲存在預編譯機制,當我們第二次插入的時候節省了一次編譯的開銷。

也就是說,在可以使用 ? 語法替換的一系列 SQL 操作中,使用 PrepareStatement 將會節省一大筆開銷。

SQL 注入式攻擊

SQL 注入式攻擊其實很簡單,這是完全可以避免的,但是使用 Statement 的時候你要格外小心。

假設不懷好意的用戶對你的數據庫請求刪除一些合法的東西,比如刪除 bookname 等於 'Java' 的書,但是他傳給你的字符串做了一些手腳:

//用戶的數據
String name="'Java' OR price>0";

//你的代碼
String sql = "delete from BeanBook where bookname="+name;

好了,你完了,因爲字符串拼接的時候,後面的 OR price>0 沒有被當作是 bookname 的一部分,而是被當成是 SQL 命令的一部分!!! 在這裏,你的數據庫已經被清空了。

但是使用 ? 語法你完全不用擔心,因爲 PrepareStatement 是預編譯的,後面只會插入數據,插入的內容不會被當作是 SQL 命令。

close

在數據庫的最後不能忘記,關閉連接。

在原先代碼的基礎上,在最後的 finally 語句塊中加入 close() 函數。

public static void main(String[] args) throws SQLException {
    java.sql.Connection conn = java.sql.DriverManager.getConnection("jdbc:mysql://localhost:3306/booklib", "root",
            "123456");
    try {
        conn.setAutoCommit(false);
        String sql = "select * from BeanBook";
        java.sql.PreparedStatement pst = conn.prepareStatement(sql);
        pst.executeQuery();
    } catch (SQLException e) {
        e.printStackTrace();
    } finally {
        if (conn != null)
            try {
                conn.close();
            } catch (SQLException e) {
                e.printStackTrace();
            }
    }
}

JDBC時間類型的處理

從之前的 get 函數中,我們看到了日期相關的 API。

JDBC 中一共有三種時間類型:

  • Date
  • Time
  • Timestamp

System.currentTimeMillis()

這個函數可以獲得 系統當前的一個時間戳。

時間戳是 1970年1月1日0點0分 到現在的毫秒數。

設置Timestamp

數據庫裏面存的都是 Timestamp,一般建議存取都用 Timestamp。

設置當前的時間爲 Timestamp:

pst.setTimestamp(5, new java.sql.Timestamp(System.currentTimeMillis()));

通過 Date類 來創建 Timestamp:

java.utl.Date date = new java.util.Date();
pst.setTimestamp(5, new java.sql.Timestamp(date.getTime()));

事實上,一般這裏直接用字符串也能設置,但對於數據庫存在性能問題,一般不建議這樣做:

pst.setString(5, "2020-06-27 00:00:00");

取出Timestamp

因爲從數據庫直接取出的是 Timestamp,需要使用 SimpleDateFormat 來格式化,才能打印出我們可以識別的時間字符串。

Timestamp timestamp = rs.getTimestamp(5);
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
System.out.println(sdf.format(timestamp));

事務控制

爲什麼需要事務

一個事務是一系列用戶定義的數據庫操作序列(CRUD)。它的目的是把數據庫的多個操作合併抽象成一個操作。

事務的設計哲學: 要麼都成功,要麼都失敗。這就是事務的原子性

事務是隔離的,併發執行的事務之間不互相干擾。

如何實現

事務的實現靠的是回滾機制。

當你做完一個操作的時候,都有日誌文件記錄下你修改的數據。如果你接下來的操作出現了問題,那麼數據庫就能根據日誌文件,運行逆操作,回到原來的狀態。

JDBC中的事務編程

可以在最開始使用 setAutoCommit(false) 來關閉自動提交。

所謂的自動提交就是,mysql 的 JDBC實現 默認是一旦運行了 execute 相關的那三個函數,就會自動運行 commit() 函數,以更新數據庫。

然後在 try 語句的最後使用 commit() 提交。

最後不要忘記,異常處理,如果發生了異常,就要使用 rollback() 函數回滾,使前面的操作全部無效。

我們來看一個結合來前面所有知識的例子:

import java.sql.SQLException;

public class Test {
    static {
        try {
            Class.forName("com.mysql.cj.jdbc.Driver");
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws SQLException {
        java.sql.Connection conn = java.sql.DriverManager.getConnection("jdbc:mysql://localhost:3306/booklib", "root",
                "123456");
        try {
            conn.setAutoCommit(false);
            String sql = "select * from BeanBook";
            java.sql.PreparedStatement pst = conn.prepareStatement(sql);
            java.sql.ResultSet rs = pst.executeQuery();
            while (rs.next()) {
                System.out.println(rs.getString(1) + "\t" + rs.getString(2) + "\t" + rs.getDouble(4));
            }
            sql = "delete from BeanBook where price>50";
            pst = conn.prepareStatement(sql);
            conn.commit();
        } catch (SQLException e) {
            e.printStackTrace();
        } finally {
            if (conn != null)
                try {
                    conn.rollback();
                    conn.close();
                } catch (SQLException e) {
                    e.printStackTrace();
                }
        }
    }

}

mysql 中的存儲引擎有: InnoDBMyISAM等等,但是事務控制只有 InnoDB 支持。


連接池

連接池的概念

之前每次我們使用數據庫 CRUD 的時候,我們每次都需要新建一個連接 Connection。

創建連接和關閉連接的過程也是比較消耗時間的,當線程數量很大的時候,系統就會變得卡頓。

連接池就是爲了解決這個問題。連接池的設計哲學是: 總是借,而不創建

我們在一開始先創建一定數量的連接 Connection,然後每次有請求連接的時候,就找空閒的連接分配過去。如果沒有空閒,則需要等待。

實現連接池

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;

public class ConnectionPool {
    int size;
    List<Connection> conns = new ArrayList<Connection>();

    public ConnectionPool(int size) {
        //構造器
        this.size = size;
        init();
    }

    public void init() {
        //初始化連接池
        try {
            // Class.forName("com.mysql.jdbc.Driver"); //mysql5
            Class.forName("com.mysql.cj.jdbc.Driver"); // mysql6+
            for (int i = 0; i < size; i++) {
                Connection conn = DriverManager
                        .getConnection("jdbc:mysql://127.0.0.1:3306/booklib", "root", "123456");
                conns.add(conn);
            }
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

    public synchronized Connection getConnection() {
        //獲得一個連接
        while (conns.isEmpty()) {
            try {
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        Connection conn = conns.remove(0);
        return conn;
    }

    public synchronized void returnConnection(Connection conn) {
        //返還一個連接
        conns.add(conn);
        this.notifyAll();
    }
    
}

使用開源連接池(以 C3P0 爲例)

一些著名的開源連接池

  • DBCP
  • C3P0

C3P0連接池 的使用:

import java.beans.PropertyVetoException;
import java.sql.Connection;
import java.sql.SQLException;
import com.mchange.v2.c3p0.ComboPooledDataSource;

public class ConnectionPoo {
	private static final String url="jdbc:mysql://localhost:3306/booklib";
	private static final String user="root";
	private static final String password="123456";
	private static ComboPooledDataSource dataSource;

	static{
		try {
			dataSource = new ComboPooledDataSource();
			dataSource.setUser(user);
			dataSource.setPassword(password);
			dataSource.setJdbcUrl(url);
			//dataSource.setDriverClass("com.mysql.jdbc.Driver");
            dataSource.setDriverClass("com.mysql.cj.jdbc.Driver");
			dataSource.setInitialPoolSize(5);
			dataSource.setMinPoolSize(1);
			dataSource.setMaxPoolSize(10);
			dataSource.setMaxStatements(50);
			dataSource.setMaxIdleTime(60);
		} catch (PropertyVetoException e) {
			throw new RuntimeException(e);
		}
	}
	public static Connection getConnection() throws SQLException{
		return dataSource.getConnection();
	}
}

c3p0 v0.9.2版本 之後,從中分離了一個 mchange-commons-java 包,作爲使用 c3p0 的輔助包。我們這裏調用的就是輔助包。


OR映射

JavaBean

一個 JavaBean 對象需要滿足的條件:

  • 提供一個默認的無參構造函數。
  • 需要被序列化並且實現 Serializable 接口。
  • 一系列可讀寫屬性。
  • 一系列的 getter 或 setter 方法。

所有對 JavaBean 屬性的訪問都應當使用 getter 和 setter 方法。

JavaBean 是一個可複用的組件,把應用的業務邏輯和顯示邏輯分離開,降低了開發的複雜程度和維護成本。

POJO (Plain Ordinary Java Object)

POJO 是純粹的 JavaBean。

JavaBean除了滿足上面的條件,沒有規定你不能定義其他東西,就算你把一些業務的代碼加入進來也沒關係。

POJO 不允許有業務方法,也不能攜帶 Connection 之類的方法。

一個簡單的 POJO 對象:

public class BeanPublisher {
	private String pubid;
	private String publisherName;
	private String address;
	public String getPubid() {
		return pubid;
	}
	public void setPubid(String pubid) {
		this.pubid = pubid;
	}
	
	public String getPublisherName() {
		return publisherName;
	}
	public void setPublisherName(String publisherName) {
		this.publisherName = publisherName;
	}
	public String getAddress() {
		return address;
	}
	public void setAddress(String address) {
		this.address = address;
	}
}

EntityBean 與 OR映射

OR映射 是把數據庫對象和編程語言中的對象映射在一起,他們擁有一樣的屬性。

EntityBean 一般用於ORM對象關係映射,一個實體映射成一張表。

它能執行很多自動化操作:

  • 創建一個 EntityBean 對象相當於創建一條記錄
  • 刪除一個 EntityBean 對象會同時從數據庫中刪除對應記錄
  • 修改一個 EntityBean 時,容器會自動將 EntityBean 的狀態和數據庫同步

一些流行的 ORM 框架:

  • Hibernate
  • MyBati
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章