Design Pattern 應用: PreparedStatement Proxy
物件導向的特性使得寫起程式變得優雅,且更有威力。但是,如果缺乏完美的設計模式,則會變得非常的難以維護。
Java內定的 API 中,就使用了許許多多的設計模式,讓我們可以便利的使用。
這篇文章要介紹的,則是Proxy的設計模式。Proxy的實作即是利用介面與委讓的方式達成。
我們知道在 Java 中可以利用 JDBC 與各種異質資料庫作溝通,不會因為所使用的資料庫的不同而必須更換連線或使用方式。因為我們面對的是介面,而非實作物件。這篇文章的目的在於顯示 PreparedStatement 中執行後的完整 SQL 語法。
先舉簡單的例子如下:
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
ConnectionPool.getConnection();
String sqlstr = "SELECT * FROM CUST WHERE ID = ?";
PreparedStatement pstmt = conn.prepareStatement(sqlstr);
pstmt.setString(1, "1000");
ResultSet rs = pstmt.executeQuery();
if (rs.next()) {
System.out.println("Hi " + rs.getString("NAME") + "!");
} else {
System.out.println("Hi Sir!");
}
} catch(SQLException e) {
System.err.println("Occure SQLException: " + e);
} finally {
//...釋放資源...略...
}
上述的程式碼會從 ConnectionPool 物件取得一條連線,並從 CUST 資料表中抓取 ID 為 1000 的紀錄後顯示在螢幕上,若無則印出一般訊息。最後,釋放資源並歸還連線。我們現在遇到的問題在於,PreparedStatement 中給定的 SQL 中,以 ? 代表傳入的參數,若我們要 Debug,除了印出 SQL 之外,還必須印出參數,非常的不方便。因此我希望能以更方便的方式來解決此問題:在不改變使用方式之下,只要執行 SQL 指令,自動印出組好的 SQL 字串。以上例而言,就是印出 SELECT * FROM CUST WHERE ID = '1000'。
上述程式碼中的 ConnectionPool 是一個包裝物件,由於有許多的 connection pool 產品可以選擇使用,所以我新增此物件當作中介角色,所有的程式都透過此 ConnectionPool物件 取得 Connection 並歸還連線,若以後想換其他的 connection pool 產品,只要更改 ConnectionPool.java 這支程式。為方便我們討論,以下是簡單的程式碼:
public Class ConnectionPool {
public Connection getConnection() {
return DriverManager.getConnection("url", "user", "password");
}
public void pushConnection(Connection conn) {
try {
conn.close();
} catch (SQLException e) {
System.err.println("cannot close the connection.");
}
}
}
因為我們的重點不在 connection pool 上,以上的程式並沒有真正的 connection pool 功能,僅是示範使用。若想得知 Connection Pool 的相關資訊,可以參考 Jakarta Commons 專案之 DBCP 子專案或 Proxool 專案。
我們的解決方向在於 Connection 與 PreparedStatement 都是介面,於是我們可以去 implement 這幾個介面,抓取我們所要知道的資訊,再委讓給廠商的實作物件真正去做事。
首先,我們先處理 PreparedStatement 的實作,在此實作之中,我們必須包含一個真正的實作物件,和一個 SQL 敘述的變數。如下:
public class MyPreparedStatement implements PreparedStatement {
private PreparedStatement pstmt = null;
private String sqlstr = null;
... 略 ...
public MyPreparedStatement(PreparedStatement pstmt, String sqlstr) {
this.pstmt = pstmt;
this.sqlstr = sqlstr;
parserSQLParams(); //說明在後面
}
}
上述的 sqlstr 是存放有參數的 SQL 敘述,如 "SELECT * FROM CUST WHERE ID = ?",首先,我們將 parse 此一字串,來判斷共有多少個參數,及得知每個參數在 sqlstr 中的位置。
String[] params = null; //存放參數內容
int[] paramsPos = null; //參數所在位置
int bits = 0; //用來判斷還有哪些參數未設定(與 mask 作 and 運算)
int mask = 0; //用來判斷還有哪些參數未設定
private void parserSQLParams() {
if (sqlstr != null) {
bits = 0;
int cnt = 0;
int idx = 0;
int[] tmp = new int[100]; //應設為最大值,可用 ArrayList 來做。
while ((idx = sqlstr.indexOf("?", idx)) != -1) {
tmp[cnt] = idx; //此參數在 sqlstr 中的位置。
idx++;
cnt++;
}
params = new String[cnt];
paramsPos = new int[cnt];
System.arraycopy(tmp, 0, paramsPos, 0, cnt);
mask = (1 << cnt) - 1;
}
}
我們的想法在於將使用者所設定的參數以字串來表示,所以我們宣告一個陣列和方法來處理。
String[] params = null;
private void setParam(int pos, String value) {
if (params != null && pos > 0 && pos <= params.length) {
bits += 1 << (pos - 1); //將該參數標示成已設定過。
params[pos-1] = value;
}
}
如此,我們就可以動手將參數轉換成字串,且要記得讓真正的實作物件去執行,如下所示(只列出幾個):
public void setString(int parameterIndex, String x) throws SQLException {
setParam(parameterIndex, "'" + x + "'");
pstmt.setString(parameterIndex, x);
}
public void setBoolean(int parameterIndex, boolean x) throws SQLException {
setParam(parameterIndex, String.valueOf(x));
pstmt.setBoolean(parameterIndex, x);
}
public void setDouble(int parameterIndex, double x) throws SQLException {
setParam(parameterIndex, String.valueOf(x));
pstmt.setDouble(parameterIndex, x);
}
public void setDate(int parameterIndex, Date x) throws SQLException {
setParam(parameterIndex, "'" + x.toString() + "'");
pstmt.setDate(parameterIndex, x);
}
此外,我們可以將其他不感興趣的參數型態,忽略掉,保持 "?" 即可,如下(只列出幾個):
public void setByte(int parameterIndex, byte x) throws SQLException {
setParam(parameterIndex, "?");
pstmt.setByte(parameterIndex, x);
}
public void setBinaryStream(int parameterIndex, InputStream x, int length)
throws SQLException {
setParam(parameterIndex, "?");
pstmt.setBinaryStream(parameterIndex, x, length);
}
我們的目標是產生比較有親和力的 SQL 敘述,以下的 method 即是執行此轉換步驟:
private boolean isOk() { //判斷是否達到可執行轉換的條件(每個參數必須都有設定)
return (sqlstr != null && params != null && (bits & mask) == mask);
}
private String generateRealSQL() {
if (!isOk()) {
return null;
}
StringBuffer sb = new StringBuffer(sqlstr);
for (int i = params.length - 1; i >= 0; i--) {
//將 ? 轉換成所設定的字串。
sb.replace(paramsPos[i], paramsPos[i] + 1, params[i]);
}
return sb.toString();
}
什麼時候要顯示出轉換後的 SQL 敘述呢?就是當使用者執行此 SQL 時(只列出一部份):
public boolean execute() throws SQLException {
if (!isOk()) { //如果沒達到可執行轉換的條件,讓實際物件去執行即可(會丟出例外)。
return pstmt.execute();
}
boolean b = false;
String sql = generateRealSQL();
try {
b = pstmt.execute();
} catch(SQLException e) {
System.out.println("Occure SQLException: " + e + ". SQL==>" + sql);
throw e;
}
System.out.println("SQL==>" + sql);
return b;
}
上面可以看到,我們若執行 SQL 發生例外,先 catch 住之後印出發生例外的 SQL 敘述,再將例外拋出。若沒有發生例外,也印出所執行的 SQL 敘述供參考。
看到這哩,相信大家都很清楚我們做了哪些事,也知道轉換的過程。但是,我們該如何使用此 MyPreparedStatement 物件呢?我們可以這樣做:
Connection conn = ConnectionPool.getConnection();
String sqlstr = "SELECT * FROM CUST WHERE ID = ?";
PrepareStatement pstmt = conn.prepareStatement(sqlstr);
MyPreparedStatement myPstmt = new MyPreparedStatement(pstmt, sqlstr);
myPstmt.setString(1, "1000");
ResultSet rs = myPstmt.executeQuery();
由此,我們可以看到螢幕上會顯示出我們要的 SQL 敘述。
但是,這樣的做法會暴露出許多缺點,如程式中不小心利用被委託物件執行 pstmt.setXXX(); 等,我們的 MyPreparedStatement 就無法控制到。且使用起來也跟一般的習慣不同。若已經有現成的程式,還必須修改才行。為此,我們可以繼續套用今天提到的觀念,將 Connection 物件也包裝起來。
public class WrappedConnection implements Connection {
Connection conn = null;
public WrappedConnection(Connection conn) {
this.conn = conn;
}
public PreparedStatement prepareStatement(String sql) throws SQLException {
return new WrappedPreparedStatement(conn.prepareStatement(sql), sql);
}
...略...
}
現在我們只需要改 ConnectionPool,讓他傳回的 Connection 以我們的物件封裝起來:
public Class ConnectionPool {
public Connection getConnection() {
Connection conn = DriverManager.getConnection("url", "user", "password");
return new WrappedConnection(conn);
}
public void pushConnection(Connection conn) {
try {
conn.close();
} catch (SQLException e) {
System.err.println("cannot close the connection.");
}
}
}
如此,前端程式都不用更改任何程式,即可看到每次執行過後的 SQL 敘述。
此篇的靈感由 Proxool 的 Source Code 中得到,Proxool 實作了封裝 Connection Pool 的 Driver,所以其使用 Connection Pool 的方式與一般的 JDBC 取得 Connection 的方法一模一樣(利用 URL 中宣告實際 JDBC 的 URL),當然,其中也有此篇文章提到的功能,實做方法不一樣,當初還沒看到時,自己先動手試出此方法,後來看到 Proxool 的實作方式,感覺很簡單,我大概把事情想的太複雜了 :p。
<< Home