星期四, 10月 14, 2004

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。

星期二, 10月 05, 2004

文件的重要性

在 JAVA 的 Web 開發領域上,充滿著許許多多的 framework 可以使用,然而,對於每家公司而言,擁有一個屬於自己的 framework 更是重要,此 framework 可以架設在現有的 framework 之上,而其目的是為了更簡便的開發。

由 於此 framework 已經與原本的架構不一樣了,開發者當然可以很熟悉並快速的使用此 framework,但是對於新手而言,若缺乏人的指導,則必須自己慢慢去摸索,在現在資訊人員快速流動的情況下,交接也總是不清不楚的情況下,多人經手 之後,原本建立的 framework 慢慢的失去了原本的精神。讓最後接手的人,越來越難以消化。

當面臨此種狀況,第一,當然是抱怨。第二,迫於現實,還是得慢慢去摸索。第三,有心得了,反正大概知道怎麼改了,再多加一點架構來方便使用。

問題在於,一個人學會了東西以後,並不會去把它用文件的方式紀錄下來,原因是因為忙。沒錯,寫文件會花時間,但是一定是值得的。

我認為,一個好的技術人員,除了在技術方面有所成就之外,更重要的是,經驗的傳承。人的價值不在於他擁有了多少的核心技術,而在於經驗能否傳承,才是令人佩服的。
在經驗傳承中,若能一直教導別人當然是很好,但是若能以文件保留下來,則是更好的。因為文件的努力只是剛開頭的初期,文件有了,新人就可以自己去讀,去了解。省去了反覆問問題的時間。當新人看不懂,可以加強文件。

所以,如果是原創者,負責任的就要寫好文件說明,如果是接觸別人的架構,曾經困擾自己很久的,在知道以後,動手寫一份說明吧。

現在 Wiki 的觀念出現,就是因為文件的重要性,資訊會慢慢成長,文件也必須隨之更新。
做任何事,找替手先。是一個準則,所強調的觀念,也是經驗的傳承。

星期五, 10月 01, 2004

術業有專攻

前幾天測試了一個 SQL Query,大約 5 萬多筆資料,利用 Primary Key(cust_id, cust_type) 去找一筆紀錄:

select * from customer_data where cust_id = ? and cust_type = ?

此 SQL Statement 竟然跑了半分鐘之久 ( DB2 的 Bug? )。
若換成另一種下法:

select * from customer_data where cust_id = ?

速度則非常快,看來問題出在 cust_type 的條件上,試過多種組合,若有搭配 cust_type 使用,都會非常的慢,若只單純找 cust_type = ? ,速度又變快。最後的做法是將 cust_type 建立索引,速度則正常了。

經過此次的經驗,讓我知道,調整 DB 與否,竟然存在著如此大的差異。雖然知道沒用索引會慢,但慢的速度真的太令人訝異了。我想可能是 DB2 的問題吧,理論上 Primary Key 是一定會有索引的,不知為何 DB2 是這樣的結果。

術業有專攻,DBA 是很重要的。經驗的累積更是重要。