2012 11 29

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

再びデータアクセス層に戻って、放置していた大量データアクセスへの対応について。

ResultSetを全部DTOに変換してリストに入れて OutOfMemoryError というのが陥りがちなパターンで、先日はこれに対処するのにResultSetを返すなんてちらっと考えてスルーしたけど、ResultSetを返しちゃ駄目だよな。 せっかくCmnDaoを作ってその辺りを隠蔽したのに。

で、改めて考える。

要はメモリ上に蓄積しないこと。 ResultSetの1レコードを取得する毎に処理すればいいのだ。 具体的にどう処理をするかはその場その場で変わることなので決め打ち出来ないけど、1レコード分を取得してはその処理をするという構造は共通。 って、何処かで辿った道筋だと思ったら、ほんの数日前から昨日にかけて、サービス層やデータアクセス層を処理内容から独立させようとして、同じようなことを考えたんだった。 つまり、その時と同じ考え方で行けるってことか。

サービス層でトランザクション制御という共通処理を隠蔽するために使ったのが、動的プロキシ。 この場合は、ResultSetからのデータ取得ループをプロキシに隠蔽して、1レコード分の処理をするメソッドをもつオブジェクトを操作することになるだろう。

けど、この程度の処理で動的プロキシってのは大袈裟過ぎる気がするな。 動的プロキシを選択したのは、対象となるクラスに複数のメソッドがあったから。 サービス層の場合は、関連のある処理を一つのクラスにまとめていたからそうだったんだけど、今回の場合は逆にバラバラにすべきなだよな。 というのも、主な用途になるだろうファイル出力なんかだと、1レコード毎の処理の他に出力するファイルの準備や後処理などがきっとあって、オブジェクトとしてまとめるべきはこれらだろうから。

ということで、動的プロキシの前段で考えてたインターフェースを使用するパターンを採用することにする。

まずはインターフェースの定義。 とりあえず前処理と1レコード毎の処理と後処理があればいいだろうと思ったのだが、もうちょっと考えると、後処理も例外発生時とfinallyで実行するような後始末レベルの処理とがあるのだな。 じゃあメソッドを4つ準備するか。 いや、別に準備するのを否定はしないが、それをDAOの中で実行する必要は無いような気がするな。 特に例外発生時の処理は、呼び出し元で処理した方が楽な気がする。 やっぱりばっさり割り切って1レコード毎の処理だけにするか。 いやいや、実行する場所がどこであれ、前処理と後処理をすることに変わりは無いだろう。 と、二転三転した結果、ルールなんて少ない方がいいという反抗期の子供のような結論に着地したのだった。

そんなこんなでインターフェース Each を定義する。

package framework.dao; import framework.exception.SystemException; public interface Each<T> { public void doEach(final T dto) throws SystemException; }

微妙な名前だが、 doEach が1レコード毎の処理をするメソッド。 ResultSetをそのままではなく、DTOに変換して渡すものとしている。

次に、これを使ってレコード毎に処理をさせる部分。 CmnDao に、メソッド selectEach を追加する。

package framework.dao; import java.lang.reflect.InvocationTargetException; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.List; import framework.exception.SystemException; public class CmnDao { private final Connection conn; private final SqlDefLoader loader; public CmnDao(final Connection conn, final SqlDefLoader loader) { this.conn = conn; this.loader = loader; } private static <E> void setPrms(PreparedStatement ps, final List<String> prms, final E criteria) throws SQLException { try { int i = 1; for (String prm : prms) { ps.setString(i++, (String) criteria.getClass().getMethod(toGetterName(prm)).invoke(criteria)); } } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException | SecurityException e) { throw new SystemException(e); } } private static String toGetterName(final String fieldName) { return "get" + fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1); } public <T, E> List<T> select(final String sqlId, final E criteria, Class<T> dtoClass) throws SQLException { List<T> list = new ArrayList<>(); SqlDef sql = loader.getSql(sqlId); try (PreparedStatement ps = conn.prepareStatement(sql.getText())) { setPrms(ps, sql.getPrms(), criteria); ResultSet rs = ps.executeQuery(); DtoConverter<T> cv = new DtoConverter<>(rs, dtoClass); while (rs.next()) { list.add(cv.toDTO(rs, dtoClass.newInstance())); } } catch (InstantiationException | IllegalAccessException e) { throw new SystemException(e); } return list; } public <T> List<T> select(final String sqlId, Class<T> dtoClass) throws SQLException { return select(sqlId, null, dtoClass); } public <T, E> void selectEach(final String sqlId, final E criteria, Class<T> dtoClass, final Each<T> handler) throws SQLException { SqlDef sql = loader.getSql(sqlId); try (PreparedStatement ps = conn.prepareStatement(sql.getText())) { setPrms(ps, sql.getPrms(), criteria); ResultSet rs = ps.executeQuery(); DtoConverter<T> cv = new DtoConverter<>(rs, dtoClass); while (rs.next()) { handler.doEach(cv.toDTO(rs, dtoClass.newInstance())); } } catch (InstantiationException | IllegalAccessException e) { throw new SystemException(e); } } public <T> void selectEach(final String sqlId, Class<T> dtoClass, final Each<T> handler) throws SQLException { selectEach(sqlId, null, dtoClass, handler); } public <E> Integer executeUpdate(final String sqlId, final E criteria) throws SQLException { SqlDef sql = loader.getSql(sqlId); try (PreparedStatement ps = conn.prepareStatement(sql.getText())) { setPrms(ps, sql.getPrms(), criteria); return ps.executeUpdate(); } } public Integer executeUpdate(final String sqlId) throws SQLException { return executeUpdate(sqlId, null); } }

自分で決めたことではあるが、どうなんだろう doEach とか selectEach って名前。 そんなのでいいのかと疑問が湧いてくるのだが、かと言ってピンと来る名前が浮かぶでも無し、今は目を瞑るのだった。

selectEach の引数末尾の handler が、実際に1レコード毎の処理を行うオブジェクト。 既存の select とシグネチャ以外で違うのは doEach を実行しているループの中の1行のみ。 なので、巧くやれば重複を無くせるのではないかと思ったのだが、思っただけで目を瞑るのだった。

あと、こいつが投げる例外が SystemException つまりは RuntimeException なのには、疑問が湧いて更に沸いたりもするのだが、これにも目を瞑るのだった。

目を瞑ってばかり。 早く目を覚まして欲しいものだな。

せっかくだからサンプルを。

public class BooksDaoImpl implements BooksDao { // 中略 @Override public void printAll() throws SQLException { dao.selectEacch("selectAll", BookDto.class, new Each<BookDto>() { @Override public void doEach(BookDto dto) { System.out.println(dto.toString()); } }); } }

1レコード毎に内容を標準出力に出力する。

って、サンプルとは言え、これじゃ簡単過ぎるか。 主用途と想定しているファイル出力を書いてみよう。

ここで想定しているのは、DBの内容をCSV出力するような場合。 だったのだが、CSVはカンマがあったらとか引用符があったらとかで面倒なのでTAB区切りのTSVにする。 作るのが楽ってだけじゃなく、作った後の取り回しもTSVの方が便利だと思うのだが、世間的にはCSVの方がよく使われるのは何故だろう。

さてTSVだが、

という構成になる。 で、考えるのだが、これって各行の構成とは独立してるんだよね。 各行がカンマ区切りだろうがTAB区切りだろうが、先頭行と2行目以降という構成は同じ。 逆に言うと、各行のデータを作る部分を独立させて入れ替えることが出来るようにすれば、CSVでもTSVでも同じように作れるということ。

ということで、行の形式からは独立したファイル出力処理 LineFileWriter の定義。

package framework.util; import java.io.IOException; import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.util.Arrays; import java.util.List; import framework.exception.SystemException; public class LineFileWriter<T> implements Each<T> { private final LineConverter<T> lineConverter; private final Path file; private String[] abuff = new String[1]; private List<String> lbuff = Arrays.asList(abuff); public LineFileWriter(final String fileName, final LineConverter<T> lineConverter) { this.lineConverter = lineConverter; this.file = Paths.get(fileName); } public void doInit() { write(lineConverter.getTitle()); } public void doException() { try { Files.delete(file); } catch (IOException e) { throw new SystemException(e); } } public void doFinally() { // do nothing } @Override public void doEach(T dto) throws SystemException { write(lineConverter.toLineString(dto)); } private void write(final String s) throws SystemException { try { abuff[0] = s; Files.write(file, lbuff, Charset.defaultCharset(), StandardOpenOption.CREATE, StandardOpenOption.APPEND); } catch (IOException e) { throw new SystemException(e); } } }

せっかく汎用っぽく作ったので、これもフレームワークに入れることにした。 framework.util というパッケージを作って、そこに置く。 ついでに、先に作った Each インターフェースもこちらに移動している。

コンストラクタで、出力ファイル名とデータ変換オブジェクトを渡す。 データ変換オブジェクトは、この後で示すインターフェースを指定している。

メソッドの doException は、例外発生時に呼ばれることを想定して、作りかけ/作ったファイルを削除する。 削除とか、呼び出し元に任せた方がいいのかもしれないが、失敗したらどうせ削除するだろうからね。 で、こっちでファイルの削除をしたら、終了処理のつもりでせっかく用意した doFinally でやることがなくなった。 まあ、そのうちこれを継承して拡張したくなることもあるだろうし、とりあえず残しておく。

次にTSV変換処理だが、汎用化のためにまずインターフェース LineConverter を宣言しておく。

package framework.util; public interface LineConverter<T> { public String getTitle(); public String toLineString(final T dto); }

そしてこのインターフェースを実装する、TSV変換のための TsvConverter

package framework.util; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import framework.exception.SystemException; public class TsvConverter<T> implements LineConverter<T> { private final String SP = "\t"; private final String title; private final List<Method> getters = new ArrayList<>(); public TsvConverter(final LinkedHashMap<String, String> map, Class<T> dtoClass) { StringBuilder sb = new StringBuilder(); try { for (String fieldName : map.keySet()) { getters.add(dtoClass.getMethod(StringUtil.toGetterName(fieldName))); sb.append(SP + map.get(fieldName)); } sb.delete(0, SP.length()); title = sb.toString(); } catch (SecurityException | NoSuchMethodException e) { throw new SystemException(e); } } @Override public String getTitle() { return title; } @Override public String toLineString(final T dto) { StringBuilder sb = new StringBuilder(); try { for (Method getter : getters) { sb.append(SP + getter.invoke(dto)); } sb.delete(0, SP.length()); return sb.toString(); } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { throw new SystemException(e); } } }

コンストラクタに渡しているのは、出力定義と行データの型。 出力定義は、出力する列に対応するDTOのフィールド名をキー、その列のタイトル文字列を値とした順序保持のマップなのだが、これは流石に説明が無いと分からないか。 渡された定義とDTOの型から使うメソッドをキャッシュするのは、DtoConverterと同じ。 ついでに先頭行に出力するための文字列も作っておく。

インターフェースだけでなくTSV出力の実装も、何となく共用できそうなので framework.util に置いている。

最後に、これを使った実際の出力処理。 さっきのサンプルで、無名クラスで実装していたところを変更する。

public class BooksDaoImpl implements BooksDao { // 中略 @Override public void printAll() throws SQLException { LinkedHashMap<String, String> map = new LinkedHashMap<String, String>() {{ this.put("isbn", "ISBN"); this.put("title", "書名"); this.put("author", "著者"); this.put("price", "価格"); }}; LineFileWriter<BookDto> writer = new LineFileWriter<>("test.txt", new TsvConverter<BookDto>(map, BookDto.class)); try { writer.doInit(); dao.selectEach("selectAll", BookDto.class, writer); } catch (Exception e) { writer.doException(); } finally { writer.doFinally(); } } }

こんな風に書くと、出力定義に何が必要かがよく判るな。 そして、これをXMLで別定義してidを指定して読み込んで… みたいな方向に行ってしまうのだな。

現在の構成は以下の通り。

apl Main.java service BooksService.java impl BooksServiceImpl.java dao BooksDao.java impl BooksDaoImpl.java dto BookDto.java util DbUtil.java framework annotation Commit.java Rollback.java service ServiceClassLoader.java ServiceDef.java ServiceDefLoader.java ServiceFactory.java TransactionInvocationHandler.java dao CmnDao.java DtoConverter.java SqlDefLoader.java SqlDef.java exception BusinessException.java SystemException.java util Each.java LineConverter.java LineFileWriter.java StringUtil.java TsvConverter.java serviceDef.xml sqlDef.xml

util の中でインターフェースとその実装が同居しているのが微妙に気になるけど、まあ、微妙ぐらいならこのままでいいか。 しかし、昨日も思ったことだけど、随分と周辺のコードが増えたな。 そして、やればやる程、次が出てきて切りがないのだな。 この辺りで一旦終了にするか。