2012 11 20

フレームワークを作るの2

昨日のJDBC剥き出しの状態のコードで、同じようなことを繰り返していないか眺めて見ると、まず目につくのが ResultSet から DTO への移し替え。 これは、ちょっと前に作った DtoConverter を使うことにする。

まずはフレームワーク化の準備。 パッケージ構成をちょっと変える。

apl service dao dto util framework service dao exception

framework というパッケージを作り、その下に service や dao を作る。 ここには、それぞれ各層向けにフレームワークが提供する機能モジュールを置く。 例外も framework の下に移動しておく。

で DtoConverter だが、これは framework.dao に置く。

package framework.dao; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import framework.exception.SystemException; public class DtoConverter<T> { private final Map<String, Method> setters = new HashMap<>(); public DtoConverter(final ResultSet rs, final Class<T> dtoClass) throws SystemException { try { for (String column : getColumns(rs)) { setters.put(column, dtoClass.getMethod(toSetterName(column), new Class[] { String.class })); } } catch (SecurityException | NoSuchMethodException| SQLException e) { throw new SystemException(e); } } public T toDTO(final ResultSet rs, final T dto) throws SystemException { Object[] parameter = new Object[1]; try { for (String column : setters.keySet()) { parameter[0] = rs.getString(column); setters.get(column).invoke(dto, parameter); } } catch (SQLException | IllegalArgumentException | IllegalAccessException | InvocationTargetException e) { throw new SystemException(e); } return dto; } private static List<String> getColumns(final ResultSet rs) throws SQLException { List<String> list = new ArrayList<>(); int columnCount = rs.getMetaData().getColumnCount(); for (int i = 1; i <= columnCount; i++) { list.add(rs.getMetaData().getColumnName(i)); } return list; } private static String toSetterName(final String columnName) { StringBuilder setterName = new StringBuilder("set"); for (String part : columnName.split("_")) { setterName.append(part.substring(0, 1).toUpperCase()); setterName.append(part.substring(1).toLowerCase()); } return setterName.toString(); } }

これを使うように BooksDao を変更する。 変更の対象となるのは検索系のメソッドで、具体的には selectByISBN と selectAll が次のようになる。

public BookDto selectByISBN(final String isbn) throws SQLException { BookDto book = new BookDto(); try (PreparedStatement ps = conn.prepareStatement(selectByIsbnSql)) { ps.setString(1, isbn); ResultSet rs = ps.executeQuery(); DtoConverter<BookDto> cv = new DtoConverter<>(rs, BookDto.class); if (rs.next()) { book = cv.toDTO(rs, book); } } return book; } public List<BookDto> selectAll() throws SQLException { List<BookDto> books = new ArrayList<>(); try (PreparedStatement ps = conn.prepareStatement(selectAllSql)) { ResultSet rs = ps.executeQuery(); DtoConverter<BookDto> cv = new DtoConverter<>(rs, BookDto.class); while (rs.next()) { books.add(cv.toDTO(rs, new BookDto())); } } return books; }

DtoConverter を使うためのに、次の規則を課す。

データの型は、DtoConverter の方で自動変換させてもよかったのだが、種類がたくさんあって面倒そうなんだよね。 かと言って、よく使うものだけを変換できるようにしておくと、逆に何が出来るか出来ないかで戸惑いそうだし、SQL側で数値の書式を設定したりすることはよくあるし、ここは文字列のみと割り切ってしまうのが正解なのだ。 だぶん。 昨日も書いたが、全部文字列にした方が何かと便利だしね。

あと、命名規則は、割と自然なので戸惑うことも無いだろう。 少なくとも俺は問題無い。 と言うか、これらは俺が普段からやっているルールなんだよね。

さて、出口をシンプルにすると入り口が目立ってくる。 具体的には、SQLの中にあるプレースホルダー 「?」 に対して、出現順に値を設定する部分。 これらは、個々のSQLによって設定内容は変わるが、構造としては同じ。 なので、これらを一々書かなくても済む方法を考える。

考えた。

考えた結果、このままじゃ駄目だという結論になった。 SQLによってプレースホルダーの数や内容が変わるので、SQLと切り離すわけにはいかず、結局SQL個別に設定しなければならないのだ。

ということで、SQLとプレースホルダーの内容とをセットで定義することを考える。 DAOのメソッドは、SQLの内容を問わず、この定義のみに従って処理をするというパターン。 とは言っても、検索系と更新系だと構造自体が違うので、このレベルの内容の違いは考慮することにする。

で、その定義。 javaのコードの中にSQLのような長い文字列を定義するのは奇麗じゃないし、変更する時も面倒だし、ここはやっぱりXMLだろう。 こんな感じだろうか。

<?xml version="1.0" encoding="UTF-8"?> <defs> <def id="insert"> <sql><![CDATA[ INSERT INTO BOOKS ( ISBN , TITLE , AUTHOR , PRICE , VERSION ) VALUES ( ? , ? , ? , TO_NUMBER( ? ) , SYSTIMESTAMP ) ]]></sql> <prms> <prm name="isbn" /> <prm name="title" /> <prm name="author" /> <prm name="price" /> </prms> </def> … </defs>

SQL内のプレースホルダーの順番に、prms 内に prm 要素を並べている。 prm 要素の name 属性で、それが何かを定義する。

使う側は id を指定して SQL とプレースホルダーの定義を取得し、DtoConverter の逆にリフレクションで DTO から値を取得してプレースホルダーに設定する。

一瞬これでいいかと思ったのだが、やっぱり駄目だな。

この程度の単純なSQLならいいが、もっと複雑でプレースホルダーがたくさんあると、対応をとるのが難しい。 最初に書く時に整合性をとるのも面倒だが、SQLを変更したときに対応を崩さないのは更に面倒だ。 強いて良いところを挙げるなら、型の指定をしたくなった時に拡張が楽ってことぐらいか。 これでは最初の状態と何も変わらない。

なんで面倒なのかと考えてみるに、問題はプレースホルダーと内容の定義が離れていることだよな。 ということで近付けてみた。

<?xml version="1.0" encoding="UTF-8"?> <sqls> <sql id="insert"><![CDATA[ INSERT INTO BOOKS ( ISBN , TITLE , AUTHOR , PRICE , VERSION ) VALUES ( ? /* isbn */ , ? /* title */ , ? /* author */ , TO_NUMBER( ? /* price */ ) , SYSTIMESTAMP ) ]]></sql> … </sqls>

パラメータを設定する必要がないので、要素の階層が一つ節約できた。

で、中身だが、プレースホルダーの後ろにコメントとして内容を記述する。 使う側は、正規表現なんかでプレースホルダーの順番に内容を取り出して設定する。 本当のコメントと間違えないように、コメントの書式に特徴を持たせた方がいいかもしれない。 例えば /*= isbn */ のような。

と思ったが、これも駄目だな。 近付けることで、どれが何かで迷うことはなくなると思うが、見た目がちょっと煩いし汚い。 このレベルだとまだ読めるが、関数の中だったりすると厳しいだろう。

いっそプレースホルダーを無くしてしまうか。 要は内容と対応が取れればいいのだ。 だったらSQLの定義の中では名前で設定しておき、それを取得しながらプレースホルダーに置き換えて行けばいいのではないか。

という方針で書き直してみたのが、これ。

<?xml version="1.0" encoding="UTF-8"?> <sqls> <sql id="insert"><![CDATA[ INSERT INTO BOOKS ( ISBN , TITLE , AUTHOR , PRICE , VERSION ) VALUES ( :isbn , :title , :author , TO_NUMBER( :price ) , SYSTIMESTAMP ) ]]></sql> … </sqls>

これまでと比べてかなりすっきりしているし、分かりやすい。 パラメータの形式を :prm としたのは何となくなのだが、これだとSQLそのものの動作確認をする時に変数扱いできていい感じ。 sqlplusでテストして、問題無ければSQLを特に変更することなくXML内にコピーするという流れになるのだな。

SQLの定義をCDATAにしてるのは、不等号が出て来た時に &gt; などと書いて汚くなるのを避けるため。 このお陰で、SQLそのものに対してXMLの便利さを適用することはちょっと難しくなったけど、見た目の方が大事だよな。 XMLの便利さというのは、例えば、共通部分を別に定義しておいて他のSQLに差し込むみたいなこと。 副問い合わせを共通定義するとか、検索条件は違うけど検索列が同じ場合に共通定義とするとか、そんな感じ。

そうそう、検索条件を変えること、もっと言えば動的にSQLを変更することだけど、とりあえず後回し。 というのも、たいていの場合はSQLを工夫することで何とかなるから。

例えば、検索条件に著者が指定されていたら著者の先頭一致を検索条件に追加するというような場合、XMLで条件適用を定義するなら

<sql id="select"><![CDATA[ SELECT ISBN , TITLE , AUTHOR , TO_CHAR( PRICE ) PRICE , TO_CHAR( VERSION, 'YYYYMMDDHH24MISSFF6' ) VERSION FROM BOOKS ]]></sql> <add id="select"> <if prm="author"><![CDATA[ WHERE AUTHOR LIKE :author || '%' ]]></if> </add>

となるだろうか。 これを

<sql id="select"><![CDATA[ SELECT ISBN , TITLE , AUTHOR , TO_CHAR( PRICE ) PRICE , TO_CHAR( VERSION, 'YYYYMMDDHH24MISSFF6' ) VERSION FROM BOOKS WHERE AUTHOR LIKE :author || '%' AND :author IS NOT NULL OR :author IS NULL ]]></sql>

とSQL側で定義しても同じことが実現できる。 まあ、結局は複雑さがSQLとXMLのどっちに行くかなのだが、このレベルだったら、個人的にはSQL側で定義したい。

ということで、BooksDao の中にあるSQLを、sqlDef.xml を作ってそこに移動する。

<?xml version="1.0" encoding="UTF-8"?> <sqls> <sql id="insert"><![CDATA[ INSERT INTO BOOKS ( ISBN , TITLE , AUTHOR , PRICE , VERSION ) VALUES ( :isbn , :title , :author , TO_NUMBER( :price ) , SYSTIMESTAMP ) ]]></sql> <sql id="selectByIsbn"><![CDATA[ SELECT ISBN , TITLE , AUTHOR , TO_CHAR( PRICE ) PRICE , TO_CHAR( VERSION, 'YYYYMMDDHH24MISSFF6' ) VERSION FROM BOOKS WHERE ISBN = :isbn ]]></sql> <sql id="selectAll"><![CDATA[ SELECT ISBN , TITLE , AUTHOR , TO_CHAR( PRICE ) PRICE , TO_CHAR( VERSION, 'YYYYMMDDHH24MISSFF6' ) VERSION FROM BOOKS ]]></sql> <sql id="updateByIsbn"><![CDATA[ UPDATE BOOKS SET TITLE = :title , AUTHOR = :author , PRICE = TO_NUMBER( :price ) , VERSION = SYSTIMESTAMP WHERE ISBN = :isbn AND VERSION = TO_TIMESTAMP( :version, 'YYYYMMDDHH24MISSFF6' ) ]]></sql> <sql id="deleteByIsbn"><![CDATA[ DELETE FROM BOOKS WHERE ISBN = :isbn AND VERSION = TO_TIMESTAMP( :version, 'YYYYMMDDHH24MISSFF6' ) ]]></sql> </sqls>

動的云々で考えたように selectByIsbn と selectAll を一緒にしてもよかったのだが、元との比較のためにそのままにしてある。

わざわざ言うまでもないと思うが、id 属性値は同じ要素に対して一意であることが必須。

今日はここまで。 次はこのSQL定義を読んで処理する部分を実装する。