ORM を使わないデータアクセス

最近は、データベースをアクセスする時は以下のようにしています。

  • 属性をメモリ(インスタンス変数)に格納した「レコード」クラスを基本作らない
    • ORM ではなく SQL を抽象化したライブラリを使う
    • データを取得する時は DSL を使ってクエリを構築する
  • 参照用オブジェクトと更新用オブジェクトを分離する

以前は ORM をそのまま使う事が多かったのですが、上のようにスタイルを変えた結果、今の所結構うまく行っているので、紹介してみようと思います。

なお、今回は JavaSQL を使った前提で書いていますが、基本的には言語や DB によらない物だと思っています。

SQL のラッパーライブラリ

DB アクセス用ライブラリには、 SQL をラップしてデータのやり取りを便利にするもの (Apache Commons DbUtils 等) と、クラスとテーブルをマッピングさせる ORM (JPA等) があると思います。Ruby on RailsActiveRecord が出た辺りから、一部の言語では例外はあるものの、全体としては後者が主流なように思います。

一方で、前者のライブラリは、静的型付け言語の型チェックを有効活用しつつ SQL を構築していくような物が最近はメジャーになってきています。例えば、今回の手法ではベースのライブラリとして QueryDSL という物を使っているのですが、 QueryDSL では以下のように書いて問い合わせを実行します。

    // query を取得
    SQLQuery query = new SQLQuery(connection, new Configuration(new H2Templates()));

    // select を実行
    // 結果は new UserSummary(...) を呼び出して DTO のリストに変換
    QEvent e  = new QEvent("e");
    QUser u = new QUser("u");
    List<UserSummary> usersSummary = query.from(e)
        .innerJoin(e.user, u)
        .groupBy(u.id, u.name)
        .list(select(u.name, e.count()).then((n,c) -> new UserSummary(n, c)));

各オブジェクトは以下のようになります。

  • SQLQuery … SQLのクエリを構築する為のビルダーです。 where(…) や groupBy(…) 等のメソッドを用意しています。最後に list(…) メソッド等で問い合わせを実行して、結果を返します。
  • QEvent、QUesr … event や user テーブルを表すオブジェクトです。これらのクラスは自動生成されます。
  • u.id, u.name, e.count() … 各テーブルの属性を表すオブジェクトです。 e.count() は count(*) 等に変換されます。

ちなみに Java 8 のサポートはまだ開発中らしく、ラムダのサポート等はありません。上の例では、 list(…) メソッド内の select(…).then(…) はこちらで独自で作成しています。

「レコード」クラスを使わない問い合わせ処理

SQL のラッパーライブラリを使うとは言っても、コントローラーから直接 QueryDSL を使って select 文を構築するような事はせず、中間層を設けています。

具体的には、以下のように問い合わせを行う為の「クエリ」クラスを通しています。

/**
 * クエリクラス
 */
public class EventQuery {
    // SQLQuery と Q〜 のインスタンスを保持
    private final SQLQuery query;
    private final QEvent e = new QEvent("e");

    //
    // 各種属性オブジェクト
    //

    public final Expression<String> title = e.title;
    public final Expression<Date> date = e.scheduledAt;

    …

    //
    // 各種フィルタリング
    //

    public EventQuery before(Date date) {
        // "where e.scheduledAt < ?" を追加
        query.where(e.scheduledAt.lt(date));
        return this;
    }
   
    public EventQuery user(long userId) { /* "where e.user_id = ?" を追加 */ }

    public EventQuery latest() { /* "order by date desc" を追加 */ }
   
    …
   
    //
    // 問い合わせの実行
    //

    public <T> List<T> list(Expression<T> ex) {
        // ex は QueryDSL が提供。必要な属性と加工方法を提供
        return query.list(ex);
    }
}

… 

//
// 呼び出し側
//

// 過去のユーザーのイベントを10件取得
long userId = 12345;
EventQuery e = new EventQuery(connection);
List<DateAndNameDTO> datesAndNames = e
    .user(userId)
    .before(new Date())
    .latest()
    .limit(10)
    .list(
        select(e.title, e.scheduledAt).then((n, d) -> new DateAndNameDTO(d, n))
    );

クエリクラスは SQLQuery や Q〜 クラスのインスタンスをラップし、以下の機能を提供しています。

  • テーブルに対するフィルタリングメソッド
  • 属性オブジェクト
  • list(…) のような問い合わせ実行メソッド
    • 1件だけ取得したい場合やマップを取得したい場合は singleResult(…) や map(…) 等のメソッドを用意する

特定のレコードや DTO に依存するような問い合わせメソッドを用意していません。代わりに、必要な属性とそれを使ってオブジェクトを生成する処理は、list(…)の引数に渡しています (select… の行)。任意のデータ構造を呼び出し側が自由に構築できるため、例えば DTO の形式毎にメソッドを用意する、といった事は必要ありません。

また、どの属性が必要とされるかがクエリオブジェクト側に渡されるので、取得する属性に応じて最適化をしやすくしています。

更新用オブジェクトを別途用意する

更新をする場合は、全く別のクラスを用意しています。

/**
 * イベントのリポジトリ
 */
public class EventRepository {
    /** 登録して ID を返します。 */
    public long register(String title, Date scheduledAt) {
        // insert 文を発行、自動生成された ID を取得
        QEvent e = new QEvent("e");
        return new SQLInsertClause(connection, new Configuration(new H2Templates()), e)
            .set(e.title, title)
            .set(e.scheduledAt, scheduledAt)
            .executeWithKey(e.id);
    }

    /** Event オブジェクトを取得 */
    public Event find(long id) {
        return new Event(id);
    }

    …
}

/**
 * 1イベントに対応する「エンティティ」
 */
public class Event {
    private final long id;

    …

    /** 日付変更 */

    public void reschedule(Date scheduledAt) {
        // update 文を発行
        QEvent e = new QEvent("e");
        new SQLUpdateClause(connection, new Configuration(new H2Templates()), e)
            .where(e.id.eq(id))
            .set(e.scheduledAt, scheduledAt)
            .execute();
    }

    …
}

更新処理用のクラスは以下のように分割しています。

  • リポジトリ … レコードの登録/削除の機能を提供。また、IDからエンティティを生成
  • エンティティ … 特定のレコードに対する操作や更新処理を提供
    • ORM のレコード/エンティティに近いが、参照系の機能は提供しない。

利点

以下のような利点があると思っています。

問い合わせ処理の挙動が予想しやすくなる

ORM の「レコード」クラスは、高速化やオブジェクト/DB間の一貫性の為に SQL の呼び出しを遅延実行したり、必要の無い属性を取得したりする事があると思います。便利ではあるのですが、開発者としては挙動が予想しにくく、複雑な問い合わせ処理をしようとすると結構はまってしまいます。

さらに、この暗黙の SQL 実行の挙動のコントロールは ORM によって異なり、きちんと理解するにはドキュメントを読む必要があります…が、そこまで勤勉な開発者は正直な所少ないのではないかと思います。また、メジャーでない ORM だとそもそもドキュメントがなかったり実際の挙動と異なる事もあります。

今回の方法だと、取得処理の呼び出しは SQL そのものなので、書かれた通りに実行されます。意図しない動作がされる事は非常に少ないと思います。

DTO 等の変換処理が楽になる

これは主に Java (7以下)の表現力の問題なのですが、関数リテラルが使えない為にデータの加工がどうしても面倒になります。例えば、一覧取得の為に、for ループを回しながら「データを変換 → List に追加」といった流れのコードが増えます。

一方で、 QueryDSL で問い合わせ結果を加工する場合、Tuple (QueryDSL が用意している型付けされた連想配列) から任意のオブジェクトに変換する為の(関数?)オブジェクトを生成し、渡すことになります。これらのオブジェクトは自分で作る事も出来ますし、「コンストラクタ呼び出し」等の一部の処理は用意されています。

属性がメソッドになってしまっている多くの Java 用 ORM と違って、属性が変数として渡せるので、再利用性が高くなっています。例えば、Web アプリで JSON を返すような物を用意する場合等は、以下のような DTO や変換メソッドを一切書かずに構築する汎用的な JsonProjection クラスが作れます。

// 過去のイベントを10件取得
// 各要素は { "name": (タイトル), "date": (日付) } の形式
EventQuery e = new EventQuery(connection);
List<DateAndNameDTO> datesAndNames = e
    .latest()
    .limit(10)
    .list(
        new JsonProjection()
            .set(e.title, "name")
            .set(e.date, "date")
    );

上記に加え、以下のような傾向があるように思います。明確な特徴とは言い辛いのですが、どちらかと言うと、こちらの方が効果が現れるとうれしいです。

コードの依存性が減る

上記の DTO と変換処理の作成作業が煩雑になると、DTO や変換処理を使い回すコードが増える傾向があるように思います。一般的にコードの再利用は良い事とされますが、 DTO や変換処理を使い回すようになると、以下のような弊害が出てくるように思います。

  • DTO が使用ケースに対して最小公倍数的に属性を持つことになる。その結果、無駄な属性が増えてDTOがふくれあがってくる。下手すると、本当は使用していない属性を至る所で渡すことになる。
  • DTO の修正に対する影響箇所が増え、修正が難しくなる。互換性を保ちながら DTO を修正することになり、結果クラスがふくれあがってくる。

変換処理の作成方法が簡単になれば、必要な変換処理をその都度指定すれば済むようになり、結果、複雑な DTO の数やそれに対する依存性が減る事が期待できます。

コードの凝集性が向上する

多くの ORM では、レコードクラスに対してメソッドを追加することができます。レコードクラスにメソッドを追加して「リッチな」モデルを構築する、というアイデアは非常に直感的ではありますが、いざレコードクラスにロジックを書いてみると意外とコードが長く、締まりのない物になったりします。

一因として、更新系のコードと参照系のコードは共有できるコードが薄い事あると思っています。具体的には、参照系と更新系の処理は以下のような物になるでしょう。

  • 参照系の処理 … 属性を取得しながらの複雑な加工、データ構造の加工(map、groupBy 等)
  • 更新系の処理 … DB や他のストレージ(キャッシュや検索エンジン等)の更新、ログ、通知

参照系は変換ロジックが複雑になる傾向にあり、一方で更新系は様々な外部サービスに依存する傾向があります。結果として、全く違うコードになり、共有が難しくなります。

今回の方法では更新と参照は全く別のクラスとして扱う事で、クラス内のコードの凝集性が向上します。

速くなる

必要な属性だけを取得するので、無駄な問い合わせ処理を行わなくなります。そのため、問い合わせが速くなる傾向にあると思います。上記のふくれあがった DTO やレコードクラスが無くなるだけでも無駄な join 等が減ったりする事があります(した)。

欠点

逆に欠点を考えると、以下のものがあるように思います。

  • ORM が用意した恩恵が得られない。CRUD を行う画面や DDL の自動生成ツール等。
  • SQL のテーブルからクラスを生成しているので、そのための設定がいる。ビルドツールによっては難しくなる。
  • より多くのクラス(クエリ/リポジトリ/エンティティ)を自分で作成する必要がある

全体的な傾向として、ORM より素朴なツールを使うため、最初の作り込みのコストがかかると思います。フレームワークが ORM をサポートしていて、コードをメンテナンスし続ける必要性の少ない場合は、あまり考えずに ORM を使った方が良いように思います。

ちなみに、QueryDSL は特に SQL に特化したツールではなく、 JPA (および NoSQL の DB 等)の上に構築する事もできるようです。ORM の基盤を再利用したい場合は、 ORM をベースに上のようなクエリ/リポジトリ/エンティティを作る事も出来ると思います。

あと、これは欠点というわけではありませんが、動的な型付け言語では、若干メリットは薄いかもしれません。 QueryDSL のようなコンパイラによる構文チェックのメリットは無く、また一般にリスト等の加工 (map や groupBy 等)が強力なので十分な場合が比較的多いと思います。しかし、問い合わせ処理の見通し易さの向上や、コードの凝集性の向上等は動的言語でも同様に得られるのではないかと思います。

まとめ

というわけで、DB をアクセスする為に ORM を使う以外の方法として、最近実践している方法を書いてみました。

ところで、ここで書いた方法はオリジナルで考えた方法ではなく、基本的には CQRS (http://cqrs.nu/ http://martinfowler.com/bliki/CQRS.html) と呼ばれる方法を拝借して簡易的にした物です。

CQRS 自体は「コマンドクエリ分離」の考えを拡張し、操作と参照をクラスで分離するという意味以上の物はないようです。ところが実際の例を探すと、少し別の Event Sourcing と呼ばれるアーキテクチャの話とセットで語られる事が多く、こちらを含めるとコーディング方法がかなり変わってきてしまうため、採用はしていません。