2012 11 19

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

もう10年以上前の WEB+DB Press を読み返していて、JDBCの入門記事を見つけた。 DB周りは、この10年の間に Hibernate や iBatis が生まれ、Hibernate の流儀は Java Persistence API として標準に取り込まれてしまった。 なので、入門用の記事のサンプルコードを見たら懐かしさを感じるかと思ったのだが、全然そんなことはなかったな。 これが JavaScript だと、今とはまるで違うスタイルのコードにちょっとした感動があったりするのだが、そういうの一切無し。 フレームワークを使う一方で素のJDBCを使う機会も多いのだが、こっちのスタイルはほとんど変わっていないように見える。

で、記事を見ているうちに、何かちょっと自分でもDB周りのフレームワークを作ってみたくなった。 仕事じゃないのでいくらでも時間をかけられることだし、今の知識からいきなり完成形で作るのではなく、JDBCそのままから一歩一歩面倒なところを楽になるように変えて行くという姿勢で、どう進化するのかを試してみよう。 一人ぶらり旅。

まずは出発点。

入門記事のDB接続サンプルよりも少し規模の大きいWebアプリケーション辺りを想定して、一応以下の階層を考える。

アプリケーション層
この層のクラスが外部からの要求を受けてDBアクセス処理を呼び出す。
サービス層
この層のクラスには、少し大きめの意味を持ったデータアクセスを実装する。 と言うと曖昧だが、更新系の処理ならトランザクションに1対1でメソッドを対応させるイメージ。 アプリケーション層のクラスから呼び出される。
データアクセス層
この層のクラスには、個々のテーブルに対する操作を実装する。 発行するSQLの種類に1対1でメソッドを対応させるイメージ。 サービス層のクラスから呼び出される。

これをそのままパッケージ構成とする。 その他、データの受け渡しのためのオブジェクトや、色々必要になるであろうユーティリティを想定して、パッケージの構成はこんな感じ。

apl service dao dto util exception

直下フラット。 真面目にやるなら、ドメインをひっくり返したのが先にくる構成になるのだろうが、自分ローカルで閉じているので、そういうのは無し。 あと、アプリケーション層用に一応 apl を置いているけど、ここに入るのはサンプルのサンプルになる。

さて、サンプルとして、本のテーブルを考える。 こんな感じ。

create table BOOKS ( ISBN varchar2(17) primary key, TITLE varchar2(50) not null, AUTHOR varchar2(50) not null, PRICE number(7) not null, VERSION timestamp default systimestamp );

データ型に varchar2 なんてあることからも判る通り、DBは oracle を対象としている。

VERSION 列は最終更新日時。 意味そのままに LAST_UPDATED_DATE とかの方が判りやすいのだが、名前が長くなるのが嫌なんだよね。

ISBN は International Standard Book Number で、本を一意に識別する番号。 ちゃんと規格があって番号があるのだが、見通しが甘かったせいで番号が一度枯渇し、仕方なく新規格で桁数を拡張したというグダグダなもの。

このテーブルのデータ受け渡しのためのクラスとして BookDto を定義する。

package dto; public class BookDto { private String isbn; private String title; private String author; private String price; private String version; public String getIsbn() { return isbn; } public void setIsbn(String isbn) { this.isbn = isbn; } public String getTitle() { return title; } public void setTitle(String title) { this.title = title; } public String getAuthor() { return author; } public void setAuthor(String author) { this.author = author; } public String getPrice() { return price; } public void setPrice(String price) { this.price = price; } public String getVersion() { return version; } public void setVersion(String version) { this.version = version; } public String toString() { return "ISBN :" + isbn + "\n" + "TITLE :" + title + "\n" + "AUTHOR :" + author + "\n" + "PRICE :" + price + "\n" + "VERSION:" + version + "\n" ; } }

WebアプリのモデルやGUIの裏側としてなら、数値型や日付型よりも文字列型の方が取り回しが便利なことが多いので、 price も version も文字列にしている。

次いでデータアクセス層で BooksDao の定義。

package dao; 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 dto.BookDto; public class BooksDao { private final Connection conn; public BooksDao(Connection conn) { this.conn = conn; } private static final String insertSql = " INSERT INTO BOOKS ( " + " ISBN " + " , TITLE " + " , AUTHOR " + " , PRICE " + " , VERSION " + " ) VALUES ( " + " ? " + " , ? " + " , ? " + " , TO_NUMBER( ? ) " + " , SYSTIMESTAMP " + " ) "; public Integer insert(final BookDto book) throws SQLException { try (PreparedStatement ps = conn.prepareStatement(insertSql)) { ps.setString(1, book.getIsbn()); ps.setString(2, book.getTitle()); ps.setString(3, book.getAuthor()); ps.setString(4, book.getPrice()); return ps.executeUpdate(); } } private static final String selectByIsbnSql = " SELECT ISBN " + " , TITLE " + " , AUTHOR " + " , TO_CHAR( PRICE ) PRICE " + " , TO_CHAR( VERSION, 'YYYYMMDDHH24MISSFF6' ) VERSION " + " FROM BOOKS " + " WHERE ISBN = ? "; 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(); if (rs.next()) { book.setIsbn(rs.getString("ISBN")); book.setTitle(rs.getString("TITLE")); book.setAuthor(rs.getString("AUTHOR")); book.setPrice(rs.getString("PRICE")); book.setVersion(rs.getString("VERSION")); } } return book; } private static final String selectAllSql = " SELECT ISBN " + " , TITLE " + " , AUTHOR " + " , TO_CHAR( PRICE ) PRICE " + " , TO_CHAR( VERSION, 'YYYYMMDDHH24MISSFF6' ) VERSION " + " FROM BOOKS "; public List<BookDto> selectAll() throws SQLException { List<BookDto> books = new ArrayList<>(); try (PreparedStatement ps = conn.prepareStatement(selectAllSql)) { ResultSet rs = ps.executeQuery(); while (rs.next()) { BookDto book = new BookDto(); book.setIsbn(rs.getString("ISBN")); book.setTitle(rs.getString("TITLE")); book.setAuthor(rs.getString("AUTHOR")); book.setPrice(rs.getString("PRICE")); book.setVersion(rs.getString("VERSION")); books.add(book); } } return books; } private static final String updateByIsbnSql = " UPDATE BOOKS " + " SET TITLE = ? " + " , AUTHOR = ? " + " , PRICE = TO_NUMBER( ? ) " + " , VERSION = SYSTIMESTAMP " + " WHERE ISBN = ? " + " AND VERSION = TO_TIMESTAMP( ?, 'YYYYMMDDHH24MISSFF6' ) "; public Integer updateByISBN(final BookDto book) throws SQLException { try (PreparedStatement ps = conn.prepareStatement(updateByIsbnSql)) { ps.setString(1, book.getTitle()); ps.setString(2, book.getAuthor()); ps.setString(3, book.getPrice()); ps.setString(4, book.getIsbn()); ps.setString(5, book.getVersion()); return ps.executeUpdate(); } } private static final String deleteByIsbnSql = " DELETE FROM BOOKS " + " WHERE ISBN = ? " + " AND VERSION = TO_TIMESTAMP( ?, 'YYYYMMDDHH24MISSFF6' ) "; public Integer deleteByISBN(final BookDto book) throws SQLException { try (PreparedStatement ps = conn.prepareStatement(deleteByIsbnSql)) { ps.setString(1, book.getIsbn()); ps.setString(2, book.getVersion()); return ps.executeUpdate(); } } }

本来なら用途からメソッドが決まるのだが、サンプルなのでCRUDを一通り揃えてみた。

数値や日付の変換はSQLに任せて、java側は基本的に文字列。 SQLの定義に無駄に空白があるのは、俺の趣味で見た目を優先した結果。

更新や削除で version を条件に入れているのは、いわゆる楽観的排他の簡易実装。 予め取得しておいたデータと version が違ったら、誰か他の人が隙をついて更新なり削除なりを実行したものとして、自分の更新はしない。 要求した側は、更新または削除の実行件数が0かどうかで結果を知る。

そしてサービス層で BooksService の定義。

package service; import java.sql.Connection; import java.sql.SQLException; import java.util.List; import dao.BooksDao; import dto.BookDto; import exception.BusinessException; public class BooksService { private final Connection conn; private final BooksDao dao; public BooksService(Connection conn) { this.conn = conn; this.dao = new BooksDao(conn); } public void insertBook(final BookDto book) throws SQLException { try { dao.insert(book); conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } } public BookDto selectBook(final String isbn) throws SQLException { return dao.selectByISBN(isbn); } public List<BookDto> selectAllBooks() throws SQLException { return dao.selectAll(); } public void updateBook(final BookDto book) throws SQLException, BusinessException { if (dao.updateByISBN(book).equals(1)) { conn.commit(); } else { conn.rollback(); throw new BusinessException(book.getIsbn() + " : deleted or updated by someone else."); } } public void deleteBook(final BookDto book) throws SQLException, BusinessException { if (dao.deleteByISBN(book).equals(1)) { conn.commit(); } else { conn.rollback(); throw new BusinessException(book.getIsbn() + " : deleted or updated by someone else."); } } }

これも本来なら用途からメソッドが決まるのだが、ここではDAOのCRUDに1対1で対応する形で定義している。 DAOと違うのは、更新系でトランザクションの制御をしていること。 また、楽観的排他の実装として、更新件数が1だったらコミット、違ったらロールバックして例外を投げている。

例外は、細かくすると面倒なので、大雑把に2つ定義して、どちらかに収斂させることにする。 具体的には SystemException と BusinessException の2つ。

SystemException は設定関連やデバッグレベルの問題が発生した場合に使う。 こうした問題に対しては、設定を見直すかプログラムを修正するしかないだろうから、 RuntimeException を継承して投げっ放しの姿勢。

package exception; public class SystemException extends RuntimeException { public SystemException(final String message) { super(message); } public SystemException(final Throwable cause) { super(cause); } public SystemException(final String message, final Throwable cause) { super(message, cause); } }

BusinessException はデータの整合性の問題などで使用する。 こちらは対処が必須。

package exception; public class BusinessException extends Exception { public BusinessException(final String message) { super(message); } public BusinessException(final Throwable cause) { super(cause); } public BusinessException(final String message, final Throwable cause) { super(message, cause); } }

しかし、適当に決めたとはいえ BusinessException って名前はどうよ。 ビジネスって、ねえ。

最後にアプリケーション層で、サービスを使用したサンプルコード。

package apl; import java.sql.Connection; import service.BooksService; import util.DBUtil; import dto.BookDto; public class Main { public static void main(String[] args) { try (Connection conn = DBUtil.getConnection()) { BooksService service = new BooksService(conn); BookDto book = service.selectBook("4-635-09016-7"); System.out.println(book.toString()); book.setPrice("3400"); service.updateBook(book); book = service.selectBook("4-635-09016-7"); System.out.println(book.toString()); } catch (Exception e) { e.printStackTrace(); } } }

ISBNを指定して本のデータを取得し、価格に3400円を設定して更新。 その後に同じ本のデータを再取得して表示している。 ちなみに ISBN4-635-09016-7 は 「日本の野草」 という植物図鑑。

最上位でDB接続を取得してそれをコンストラクタで下位層に次々渡して行くスタイルは、なんかちょっと微妙な感じだけど、アプリケーションサーバーが管理するプールから取得するパターンなんかを考えると、これはこれで有りだろう。

今日はここまで。