4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

じゃんけんAdvent Calendar 2020

Day 11

【Day 11】データアクセスのライブラリを実装【じゃんけんアドカレ】

Last updated at Posted at 2020-12-11

じゃんけんアドベントカレンダー の 11 日目です。


前回 DAO のコードの課題を整理した中で、ボイラープレートが多いことを挙げました。

そこで今回は JDBC をラップしたデータアクセスのライブラリ (広義の OR マッパ) を実装し、ボイラープレートをできるだけ減らそうと思います

OR マッパの種類

どこからどこまでを OR マッパと呼ぶかは人によって異なりますが、「Java ORマッパー選定のポイント #jsug」というスライドでのまとめ方が非常に分かりやすいです。

こちらのスライドでは、広義の OR マッパの中に

  • JDBC ラッパ型
  • SQL マッパ型
  • クエリビルダ型
  • (狭義の) OR マッパ型

の 4 種類があるとまとめられています。

今回は実装するのはこの中で一番簡単な JDBC ラッパ型になります。

find 系の実装

現状の DAO のコード

それでは、実装を進める前に現状のコードを確認したいと思います。
まずは find 系の処理だけ考えようと思います。

JankenMySQLDao の find 系のメソッドは以下のようになっています。

public class JankenMySQLDao implements JankenDao {
    :
    @Override
    public List<Janken> findAllOrderById(Transaction tx) {
        val conn = ((JDBCTransaction) tx).getConn();

        try (val stmt = conn.prepareStatement(SELECT_ALL_ORDER_BY_ID_QUERY)) {

            try (val rs = stmt.executeQuery()) {
                return resultSet2Jankens(rs);
            }

        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public Optional<Janken> findById(Transaction tx, long id) {
        val conn = ((JDBCTransaction) tx).getConn();

        try (val stmt = conn.prepareStatement(SELECT_WHERE_ID_EQUALS_QUERY)) {

            stmt.setLong(1, id);

            try (val rs = stmt.executeQuery()) {
                return resultSet2Jankens(rs).stream().findFirst();
            }

        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }
    :

JankenDetailMySQLDao の find 系のメソッドは以下のようになっています。

public class JankenDetailMySQLDao implements JankenDetailDao {
    :
    @Override
    public List<Janken> findAllOrderById(Transaction tx) {
        val conn = ((JDBCTransaction) tx).getConn();

        try (val stmt = conn.prepareStatement(SELECT_ALL_ORDER_BY_ID_QUERY)) {

            try (val rs = stmt.executeQuery()) {
                return resultSet2Jankens(rs);
            }

        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public Optional<Janken> findById(Transaction tx, long id) {
        val conn = ((JDBCTransaction) tx).getConn();

        try (val stmt = conn.prepareStatement(SELECT_WHERE_ID_EQUALS_QUERY)) {

            stmt.setLong(1, id);

            try (val rs = stmt.executeQuery()) {
                return resultSet2Jankens(rs).stream().findFirst();
            }

        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }
    :

この 4 つメソッドを見比べると、ほぼ同じコードになっていないでしょうか。

これらのメソッドの違いは以下の点です。

  • 実行している SQL が違う
  • PreparedStatement に設定する変数が違う
  • ResultSet をマッピングする先のクラス (= 戻り値のクラス) が違う
  • List を返す場合と Optional を返す場合がある

逆に言えば、他の点は全て同じです。

したがって、上記の 4 つを与えるインタフェースを考え実装すれば、ボイラープレートをなくすことができるはずです。1

SimpleJDBCWrapper クラスのインタフェース検討

上記の 4 点を踏まえて、必要なものを全て引数で渡すことを考えると、以下のようなメソッドになるのではないかと思います。

public class SimpleJDBCWrapper {

    public <T> List<T> findList(Transaction tx,
                                Function<ResultSet, T> mapper,
                                String sql,
                                Object... params) {
        :
    }

    public <T> Optional<T> findFirst(Transaction tx,
                                     Function<ResultSet, T> mapper,
                                     String sql,
                                     Object... params) {
        :
    }

}

戻り値が List か Optional かでメソッドを分けており、引数は

  • Transaction
  • ResultSet を任意の型に変換する関数
  • SQL
  • SQL のパラメータ

となっています。

DAO クラスでの呼び出しのイメージは以下のようになります。

        val jankens = simpleJDBCWrapper.findList(tx,
                rs -> new Janken(
                        rs.getLong(1),
                        rs.getTimestamp(2).toLocalDateTime()),
                "SELECT id, played_at FROM jankens ORDER BY id");

        val janken = simpleJDBCWrapper.findFirst(tx,
                rs -> new Janken(
                        rs.getLong(1),
                        rs.getTimestamp(2).toLocalDateTime()),
                "SELECT id, played_at FROM jankens WHERE id = ?",
                id);

こうすることで、DAO クラス内のボイラープレートはなくなりました

RowMapper インタフェースの検討

上記のコードの DAO から SimpleJDBCWrapper のメソッドを呼び出す箇所では、ResultSet を Janken 型に変換する処理が find の条件によらず同じになりそうです。

そこで、RowMapper<T> というインタフェースを用意して、それを一度実装すれば使いまわせるようにしようと思います

public interface RowMapper<T> {

    T map(ResultSet rs) throws SQLException;

}

Function<ResultSet, T> を実装しても同じなのですが、このように別途 RowMapper<T> というインタフェースを用意した方が、実装クラスを作る人にも分かりやすくなります

RowMapper を使うと、SimpleJDBCWrapper クラスのメソッドは以下のようになります。

public class SimpleJDBCWrapper {

    public <T> List<T> findList(Transaction tx,
                                RowMapper<T> mapper,
                                String sql,
                                Object... params) {
        :
    }

    public <T> Optional<T> findFirst(Transaction tx,
                                     RowMapper<T> mapper,
                                     String sql,
                                     Object... params) {
        :
    }

}

この findList メソッドや findFirst メソッドは内部で RowMapper クラスのメソッドを呼び出すわけですが、このように処理 (の一部) を他のクラスに任せることを「委譲」と言います
GoF のデザインパターンで言うところの Strategy パターンに相当します。

このようにあるクラスの処理の一部を変更することは、継承 (例えば Template Method パターン) で実装することもできます
ですが、実は継承を使っても良い場面の判断は結構難しく、「継承より委譲」と言われることもあります

初心者にとっては継承の方が委譲より理解しやすいこともあり、継承を使っている場面をよく見かけます。
委譲は最初は少し分かりにくいかもしれませんが、使い慣れれば簡単です。
委譲をうまく使うことで、よりメンテナンスしやすいコードになることも多いのではないかと思います。

DAO の実装

さて、話を戻して、最終的な DAO 側の実装を進めてみると以下のようになります。

public class JankenMySQLDao implements JankenDao {

    private SimpleJDBCWrapper simpleJDBCWrapper = new SimpleJDBCWrapper();
    private JankenRowMapper rowMapper = new JankenRowMapper();

    @Override
    public List<Janken> findAllOrderById(Transaction tx) {
        return simpleJDBCWrapper.findList(tx, rowMapper, "SELECT id, played_at FROM jankens ORDER BY id");
    }

    @Override
    public Optional<Janken> findById(Transaction tx, long id) {
        return simpleJDBCWrapper.findFirst(tx, rowMapper, "SELECT id, played_at FROM jankens WHERE id = ?", id);
    }
    :
}

class JankenRowMapper implements RowMapper<Janken> {

    @Override
    public Janken map(ResultSet rs) throws SQLException {
        val id = rs.getLong(1);
        val playedAt = rs.getTimestamp(2).toLocalDateTime();

        return new Janken(id, playedAt);
    }

}

コードの冗長だった箇所がなくなり、非常に見通しがよくなりました。

また、WHERE 句の異なる find メソッドを追加するときも SQL とパラメータを変更するだけであり、以前よりはるかに簡単に実装できるようになっています。

SimpleJDBCWrapper の実装

では、SimpleJDBCWrapper の呼び出し方が決まったので、それを満たす SimpleJDBCWrapper クラスを実装しようと思います。

実装結果は以下のようになりました。

public class SimpleJDBCWrapper {

    public <T> List<T> findList(Transaction tx,
                                RowMapper<T> mapper,
                                String sql,
                                Object... params) {

        val conn = ((JDBCTransaction) tx).getConn();

        try (val stmt = conn.prepareStatement(sql)) {

            setParams(stmt, params);

            try (val rs = stmt.executeQuery()) {
                return resultSet2Objects(rs, mapper);
            }

        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    public <T> Optional<T> findFirst(Transaction tx,
                                     RowMapper<T> mapper,
                                     String sql,
                                     Object... params) {

        return findList(tx, mapper, sql, params)
                .stream()
                .findFirst();
    }

    private void setParams(PreparedStatement stmt, Object... params) throws SQLException {
        for (int i = 0; i < params.length; i++) {
            stmt.setObject(i + 1, params[i]);
        }
    }

    private <T> List<T> resultSet2Objects(ResultSet rs, RowMapper<T> mapper) throws SQLException {
        val list = new ArrayList<T>();
        while (rs.next()) {
            val obj = mapper.map(rs);
            list.add(obj);
        }
        return list;
    }

}

もとの DAO のコードを移動してきたような、結構簡単な実装になりました。

ジェネリクスを活用しているため戻り値を受け取る側でのキャストも不要で、安全なコーディングが可能になっています。

ここまでで SELECT 文を発行するメソッドのボイラープレートをなくしてきました。
じゃんけんアプリケーションでは INSERT 文も発行しているので、INSERT 側についても少し考えてみます。

insert 系の実装

INSERT 文はテーブル名とカラム名、各カラムの値さえあれば決まるので、SimpleJDBCWrapper の内部で SQL の文字列を組み立てることができるはずです。

つまり、以下のような呼び出し方にできるはずです。

public class JankenMySQLDao implements JankenDao {
    :
    private SimpleJDBCWrapper simpleJDBCWrapper = new SimpleJDBCWrapper();
    :
    private JankenInsertMapper insertMapper = new JankenInsertMapper();
    :
    @Override
    public Janken insert(Transaction tx, Janken janken) {
        return simpleJDBCWrapper.insertOneAndReturnObjectWithKey(tx, insertMapper, "jankens", janken);
    }

}
    :
class JankenInsertMapper implements InsertMapper<Janken> {

    @Override
    public Map<String, Object> object2InsertParams(Janken object) {
        return Map.of("played_at", Timestamp.valueOf(object.getPlayedAt()));
    }

    @Override
    public Janken zipWithKey(long key, Janken objectWithoutKey) {
        return new Janken(key, objectWithoutKey.getPlayedAt());
    }

}

SimpleJDBCWrapper クラスにこのようなメソッドを実装できれば、呼び出し側の実装は非常に簡単になります。

といっても、文字列をがんばって組み立てるだけなので、特別なことをする必要はありません。
コードは少し長くなるので、気になる方は こちら を参照ください。

SimpleJDBCWrapper の完成

これで JDBC ラッパ型のライブラリを必要な範囲で実装できました。
リフレクションを使うことでより高度な OR マッパに近づけていくこともできますが、今回はここまでにしておきます。

今回実装したデータアクセスのライブラリの使い心地は、Spring Framework の JDBCTemplate などとよく似ています。
実際にはこういった処理は自作せずにライブラリを使うことが多いと思いますし、その方が望ましいです。

独自フレームワークやライブラリへのロックインは、最も良くないロックインだと言われることもあります。

このアドベントカレンダーでも、ゆくゆく OSS の OR マッパを導入しようと思います。

インタフェースを決めてから実装することについて

さて、今回実装を進めるにあたり、まずインタフェース (メソッドのシグネチャなど) を決めてから、それが十分良いと思ったら内部実装を進めるという方法をとりました。

きれいなプログラムの設計という観点では、内部の構造よりどう呼び出すか (= どんなインタフェースか) の方がより重要なので、まずどのように呼び出すかを考え、そのあとで細かい実装を進めることをオススメします。2

プログラミング初心者の方から、「オブジェクト指向のクラス設計をどうしたらいいか分からない」と言われることがしばしばあります。
そんな方にお話を伺うと、「このクラスにはこんなメソッドがあるはず」と、1 つのクラスに注目して設計しようとしていることが少なくありません。

実は、実装したいクラスに注目するだけでは、そのクラスの設計はできません
実装したいクラスをどう使うのかに注目して、どんな風に使えるべきか、どんな風に使えたら便利か、といった外側の視点で考えることで、インタフェース (メソッドのシグネチャなど) をブラッシュアップすることができます

書籍『実践テスト駆動開発』に書かれているような、事前にテスト (呼び出し側) を書いてから実装を進める TDD のアプローチによりクラス設計がよりきれいになる、というのも同じ話です。

もちろん、呼び出し方の設計を進めるには、前提としてメソッドの内部を実装するスキルは必要です。
また、リファクタリング的なアプローチでインタフェースを整理する場合もあるので、一概にこのアプローチだけを使うわけではありません。
ですが、一つの考え方として、「外部から見たクラスの使い心地に注目する」というのは、クラスの設計を考える上で重要です。

次回のテーマ

今回でデータアクセス周りはある程度整理できたので、いったん話を戻し、次回は再度サービスクラスの整理をしようと思います。
現状、サービスクラスがまだファットな状態なので、モデルと役割分担してサービスを軽くしていこうと思います

それでは、今回の記事はここまでにします。最後まで読んでくださりありがとうございました。
じゃんけんアドベントカレンダー に興味を持ってくださった方は、是非購読お願いします。

次回の記事

【Day 12】トランザクションスクリプトからドメインモデルへ【じゃんけんアドカレ】

現時点のコード

コードは GitHub の この時点のコミット を参照ください。

  1. ここでは「インタフェース」という言葉を Java の機能の interface ではなく、メソッドのシグネチャなどを含むより広い意味で使っています

  2. ここでは処理のパフォーマンスについては考慮していません

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?