4
2

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 10

【Day 10】トランザクションを実現【じゃんけんアドカレ】

Last updated at Posted at 2020-12-10

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


前回 MySQL を導入したので、今回はトランザクションを実現するようコードを修正しようと思います。

コードの該当箇所

トランザクションを使う必要があるのは、JankenService の以下の箇所です。

        // じゃんけんを保存

        val jankenWithId = jankenCsvDao.insert(janken);

        // じゃんけん明細を生成

        val jankenDetail1 = new JankenDetail(null, jankenWithId.getId(), player1.getId(), player1Hand, player1Result);
        val jankenDetail2 = new JankenDetail(null, jankenWithId.getId(), player2.getId(), player2Hand, player2Result);
        val jankenDetails = List.of(jankenDetail1, jankenDetail2);

        // じゃんけん明細を保存

        jankenDetailCsvDao.insertAll(jankenDetails);

じゃんけん保存後、じゃんけん明細保存時にエラーが発生したら、じゃんけんの保存がロールバックされるようになれば OK です。

自動テスト

トランザクションを導入するにあたり、今回もまずは自動テストを書こうと思います。

エラーの発生については、JankenDetailDao を実装する JankenDetailErrorDao を新たに作成して、モックとして使うことで対応します

class JankenServiceTest {

    @Test
    public void じゃんけん明細保存時に例外が発生した場合じゃんけんも保存されない() {

        // 準備

        ServiceLocator.register(JankenDao.class, JankenMySQLDao.class);
        ServiceLocator.register(JankenDetailDao.class, JankenDetailErrorDao.class);

        val service = new JankenService();
        val jankenDao = ServiceLocator.resolve(JankenDao.class);

        val player1 = new Player(1, "Alice");
        val player2 = new Player(2, "Bob");
        val player1Hand = Hand.STONE;
        val player2Hand = Hand.STONE;

        val jankenCountBeforeTest = jankenDao.count();

        // 実行

        try {
            service.play(player1, player1Hand, player2, player2Hand);

            // 例外が発生しない場合はテスト失敗
            fail();
        } catch (UnsupportedOperationException e) {
            // Do nothing
        }

        // 検証

        val jankenCountAfterTest = jankenDao.count();
        assertEquals(jankenCountBeforeTest, jankenCountAfterTest, "じゃんけんの件数が増えていない");
    }

}

@NoArgsConstructor
class JankenDetailErrorDao implements JankenDetailDao {

    @Override
    public Optional<JankenDetail> findById(long id) {
        throw new UnsupportedOperationException();
    }

    @Override
    public long count() {
        throw new UnsupportedOperationException();
    }

    @Override
    public List<JankenDetail> insertAll(List<JankenDetail> jankenDetails) {
        throw new UnsupportedOperationException();
    }

}

この状態でテストを実行すると、以下のように失敗します。

$ ./gradlew test --tests JankenServiceTest

> Task :app:test

JankenServiceTest > じゃんけん明細保存時に例外が発生した場合じゃんけんは保存されない() FAILED
    org.opentest4j.AssertionFailedError at JankenServiceTest.java:54
    :

これで実装の準備が整いました。

修正の方針

トランザクションを実現するためには、JankenDao と JankenDetailDao の間でコネクションを持ち回る必要があります。

例えば

  • グローバル変数で解決する
  • メソッドの引数で持ち回る

といった方法で実現できます。

基本的にグローバル変数は避けたいので、今回はメソッドの引数で持ち回る方針で進めます。

実装

まずは DAO のインタフェースをどう変更すればいいか考えてみます。

ストレートに考えると、以下のように DAO の各メソッドの引数に Connection を追加することになります。

public interface JankenDao {

    Optional<Janken> findById(Connection conn, long id);

    long count(Connection conn);

    Janken insert(Connection conn, Janken janken);

}

しかし、これでは呼び出し側の JankenService が

        val jankenWithId = jankenCsvDao.insert(conn, janken);
        :
        jankenDetailCsvDao.insertAll(conn, jankenDetails);

のようになり、データの保存先が RDB であることを知っている状態になってしまいます。

これでもダメではないのですが、せっかく依存の向きを整理してアプリケーション層がデータアクセス層に依存しないようにしたので、ここも隠蔽したいところです。

そこで、Connection をラップした Transaction というインタフェースを作成し、サービスは Transaction という抽象的な存在にだけ依存するようにしようと思います

Transaction と JDBCTransaction の実装

作成した Transaction インタフェースは以下の通りです。

public interface Transaction extends AutoCloseable {

    void commit();

    void close();

}

AutoCloseable インタフェースを継承することで、try-with-resources でクローズできるようにしています。

これを実装した JDBCTransaction クラスは以下の通りです。

@Getter
public class JDBCTransaction implements Transaction {

    private static final String MYSQL_URL = "jdbc:mysql://localhost:3306/janken";
    private static final String MYSQL_USER = "user";
    private static final String MYSQL_PASSWORD = "password";

    private Connection conn;

    public JDBCTransaction() {
        try {
            this.conn = DriverManager.getConnection(MYSQL_URL, MYSQL_USER, MYSQL_PASSWORD);
            conn.setAutoCommit(false);

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

    @Override
    public void commit() {
        try {
            conn.commit();
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void close() {
        try {
            conn.close();
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }

}

あとはどのようにしてこの JDBCTransaction クラスを new するかです。
サービスクラスから JDBC のコネクションであることを隠蔽することを考えると、サービスクラスの内部で new JDBCTransaction() とすることはできません。

そこで GoF のデザインパターンの 1 つである AbstractFactory パターンを使います。

TransactionManager と JDBCTransactionManager の実装

Transaction を生成するインタフェースを以下のようにしました。

public interface TransactionManager {

    Transaction startTransaction();

}

その実装クラスとして、JDBCTransaction を生成するクラスを実装しました。

public class JDBCTransactionManager implements TransactionManager {

    @Override
    public Transaction startTransaction() {
        return new JDBCTransaction();
    }

}

これを ServiceLocator に登録することにより、JankenService では TransactionManager というインタフェースだけを知っていて、具体的に JDBC のトランザクションであることは知らない状態になります。

ServiceLocator への登録は以下のようになります。

public class App {

    public static void main(String[] args) {

        // 依存解決の設定

        ServiceLocator.register(TransactionManager.class, JDBCTransactionManager.class);
        :

JankenService での利用は以下のようになります。

public class JankenService {

    private TransactionManager tm = ServiceLocator.resolve(TransactionManager.class);

    private JankenDao jankenDao = ServiceLocator.resolve(JankenDao.class);
    private JankenDetailDao jankenDetailDao = ServiceLocator.resolve(JankenDetailDao.class);

    /**
     * じゃんけんを実行し、勝者を返します。
     */
    public Optional<Player> play(Player player1, Hand player1Hand,
                                 Player player2, Hand player2Hand) {

        try (val tx = tm.startTransaction()) {
            :
            // じゃんけんを保存

            val jankenWithId = jankenDao.insert(tx, janken);

            // じゃんけん明細を生成

            val jankenDetail1 = new JankenDetail(null, jankenWithId.getId(), player1.getId(), player1Hand, player1Result);
            val jankenDetail2 = new JankenDetail(null, jankenWithId.getId(), player2.getId(), player2Hand, player2Result);
            val jankenDetails = List.of(jankenDetail1, jankenDetail2);

            // じゃんけん明細を保存

            jankenDetailDao.insertAll(tx, jankenDetails);

            tx.commit();
            :
        }
    }

}

これでアプリケーション層でトランザクションが管理できるようになりました。1

DAO の修正

DAO のインタフェースが変わったので、実装クラスを修正することになります。

修正した JankenMySQLDao は以下のようになりました。

public class JankenMySQLDao implements JankenDao {
    :
    private static final String INSERT_COMMAND = "INSERT INTO jankens (playedAt) VALUES (?)";
    :
    @Override
    public Janken insert(Transaction tx, Janken janken) {
        val conn = ((JDBCTransaction) tx).getConn();

        try (val stmt = conn.prepareStatement(INSERT_COMMAND, Statement.RETURN_GENERATED_KEYS)) {

            stmt.setTimestamp(1, Timestamp.valueOf(janken.getPlayedAt()));

            stmt.executeUpdate();

            try (val rs = stmt.getGeneratedKeys()) {
                rs.next();
                val id = rs.getLong(1);

                return new Janken(id, janken.getPlayedAt());
            }

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

今までメソッド内で作成していた Connection の代わりに、引数で受け取った Transaction から取り出した Connection を使うようになっています。

課題点

これでトランザクションを実現するための実装が一通り完了したのですが、上記の DAO のコードには

  • トランザクション型のキャスト
  • ID の採番問題
  • DAO のコードにボイラテンプレートが多い
  • コミットの実行を忘れやすい

といった課題があります。

これらの課題について順に解説していきます。

トランザクション型のキャスト

DAO のコードでは

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

のようにして Transaction 型をキャストしています。

キャストは基本的に危険な処理なので、可能な限り使いたくありません。
しかし、ここでキャストが発生する根本原因は、TransactionManager や DAO の実装クラスを解決するときの設定にあります。

        ServiceLocator.register(TransactionManager.class, JDBCTransactionManager.class);
        :
        ServiceLocator.register(JankenDao.class, JankenMySQLDao.class);
        ServiceLocator.register(JankenDetailDao.class, JankenDetailMySQLDao.class);

このように ServiceLocator に別々で登録する以上、Transaction を扱う箇所を安全なコードにすることはできません。
仮にキャストを使わないとしても、何らかの例外が発生しうる処理を書くことになってしまいます。

どうしてもこれを解決したい場合は、TransactionManager や DAO の生成を一元管理するインタフェースと実装クラスを作成し、それを ServiceLocator に登録するような方針になるのではないかと思います。
こちらについては、アドベントカレンダーの終盤で時間があれば挑戦したいと思います。

ID の採番問題

次に、ID の採番問題について説明していきます。
DAO の insert メソッドのコードを見て違和感を覚える方は少なくないのではないでしょうか。

public class JankenMySQLDao implements JankenDao {
    :
    private static final String INSERT_COMMAND = "INSERT INTO jankens (playedAt) VALUES (?)";
    :
    @Override
    public Janken insert(Transaction tx, Janken janken) {
        val conn = ((JDBCTransaction) tx).getConn();

        try (val stmt = conn.prepareStatement(INSERT_COMMAND, Statement.RETURN_GENERATED_KEYS)) {

            stmt.setTimestamp(1, Timestamp.valueOf(janken.getPlayedAt()));

            stmt.executeUpdate();

            try (val rs = stmt.getGeneratedKeys()) {
                rs.next();
                val id = rs.getLong(1);

                return new Janken(id, janken.getPlayedAt());
            }

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

SQL を実行したあと、MySQL の自動採番で生成された ID を取得し、新しい Janken インスタンスを返しています。

これは、Janken クラスをイミュータブルに設計し、かつ DB の自動採番機能を使ったことによる弊害です。

このメソッドを呼び出す側も

            val jankenWithId = jankenDao.insert(tx, janken);

のようになり、引数に与えた janken の id フィールドは NULL で、戻り値の jankenWithId の id フィールドには値が入っています。
これも結構不自然なコードではないでしょうか。

ID の採番に関するこの問題は、対応パターンも色々あり少し複雑な話になるので、後日改めて取り扱おうと思います。

DAO のコードにボイラテンプレートが多い

現状の DAO のコードでは、Connection や PrepareStatement を扱う箇所が、全てのメソッドでほぼ同じように書かれています。
こういったボイラテンプレートは生産性を下げますし、避けたいものです。

この点は次回解決しようと思います。

コミットの実行を忘れやすい

最後に、コミットの実行を忘れやすいという点について説明します。

現状のコードでは、以下のようにサービスクラスで明示的に commit メソッドを呼び出す必要があります。

public class JankenService {
    :
    public Optional<Player> play(Player player1, Hand player1Hand,
                                 Player player2, Hand player2Hand) {

        try (val tx = tm.startTransaction()) {
            :
            tx.commit();
            :
        }
    }

}

これでもいいのですが、サービスクラスの書き手に commit メソッドの呼び出しを委ねない方がより安全です。
例外が発生したときはコミットせず、例外が発生しなければコミットするような動作を保証したいです。

これは、「Loan Pattern (ローンパターン)」というデザインパターンで解決できます。

Loan Pattern の導入は簡単なので、これだけ実施してこの記事は終わりにしようと思います。

Loan Pattern の導入

高階関数を使えれば、Loan Pattern の実装は簡単です。

TransactionManager インタフェースを以下のように変更します。

public interface TransactionManager {

    <U> U transactional(Function<Transaction, U> f);

    void transactional(Consumer<Transaction> c);

}

さらに、JDBCTransactionManager クラスを以下のように変更します。

public class JDBCTransactionManager implements TransactionManager {

    @Override
    public <T> T transactional(Function<Transaction, T> f) {
        try (val t = new JDBCTransaction()) {
            T result = f.apply(t);
            t.commit();
            return result;
        }
    }

    @Override
    public void transactional(Consumer<Transaction> c) {
        try (val t = new JDBCTransaction()) {
            c.accept(t);
            t.commit();
        }
    }

}

あとはサービスクラスを修正するだけです。
JankenService のコードは以下のようになります。

public class JankenService {
    :
    public Optional<Player> play(Player player1, Hand player1Hand,
                                 Player player2, Hand player2Hand) {

        return tm.transactional(tx -> {
            :
            // じゃんけんを保存

            val jankenWithId = jankenDao.insert(tx, janken);

            // じゃんけん明細を生成

            val jankenDetail1 = new JankenDetail(null, jankenWithId.getId(), player1.getId(), player1Hand, player1Result);
            val jankenDetail2 = new JankenDetail(null, jankenWithId.getId(), player2.getId(), player2Hand, player2Result);
            val jankenDetails = List.of(jankenDetail1, jankenDetail2);

            // じゃんけん明細を保存

            jankenDetailDao.insertAll(tx, jankenDetails);
            :
        });

    }

}

JankenService の中でコミットを呼び出さずとも、自動でコミットされるようになりました。

もちろん、例外が発生した際はコミットされないようになっています。
(例外発生時の挙動は、この記事の最初に書いた自動テストで保証されています)

Loan Pattern は Java で見かけることは多くないですが、Scala などでは非常によく使われるデザインパターンです。
実際の開発ではフレームワークの機能でトランザクション管理することが多いですが、自前で実装する際はこういった手法を使うことで、より安全なコードにできます。

次回のテーマ

今回トランザクションを実現しましたが、DAO のコードにはまだまだ色々な課題が残っています。
全部解決するのは大変ですが、ひとまず次回は**「DAO のコードにボイラテンプレートが多い」という点をなんとかしようと思います**。

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

次回の記事

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

現時点のコード

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

  1. トランザクション管理はアプリケーション層の役割の 1 つです

4
2
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
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?