LoginSignup
2
3

More than 1 year has passed since last update.

TomcatとJerseyでトランザクション管理してみる

Posted at

はじめに

Tomcat + Jersey という構成の Web アプリケーションの動作を理解するために、Tomcat Embed を使って実験をしていきます。
いまどきこんな構成で開発を始めることは少ないかと思いますが、レガシーソフトウェアと戦う人たちの助けになれば幸いです。

関連記事の一覧(予定)

リポジトリ

データベースを準備する

本記事では検証用のデータベースとしてインメモリの H2 Database を使用します。
build.gradle に以下の依存を追加します。 tomcat-dbcp は埋め込み Tomcat でデータベースにアクセスするために必要です。

build.gradle
...
    implementation group: 'com.h2database', name: 'h2', version: H2_VERSION
    implementation group: 'org.apache.tomcat', name: 'tomcat-dbcp', version: TOMCAT_VERSION
...

データベースへの接続設定を context.xml に記述し、 META-INF 内に配置します。1
名前は jdbc/h2db としました。

context.xml
<Context>
  <Resource name="jdbc/h2db"
    auth="Container"
    type="javax.sql.DataSource"
    factory="org.apache.tomcat.dbcp.dbcp2.BasicDataSourceFactory"
    driverClassName="org.h2.Driver"
    url="jdbc:h2:mem:test" />
</Context>
ファイル構成
src/main
├── (省略)
└── webapp
    ├── META-INF
    │   └── context.xml
    └── index.html

アプリケーションの起動時にはデータベースは空の状態です。Web アプリケーションの初期化時に実行されるイベントリスナーを登録し、データベースにテーブルを作成します。
さきほど定義した jdbc/h2dbjava:comp/env/ 以下に見つかります。

DatabaseInitializer.java
...
public class DatabaseInitializer implements ApplicationEventListener {

    @Override
    public void onEvent(ApplicationEvent event) {
        if (event.getType().equals(ApplicationEvent.Type.INITIALIZATION_START)) {
            createTable();
        }
    }

    private void createTable() {
        try {
            var ds = (DataSource) InitialContext.doLookup("java:comp/env/jdbc/h2db");
            try (var con = ds.getConnection(); var stmnt = con.createStatement()) {
                stmnt.execute("create table positive(value integer)");
                stmnt.executeUpdate("insert into positive values(0)");
            }
        } catch (NamingException | SQLException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public RequestEventListener onRequest(RequestEvent requestEvent) {
        return null;
    }
}

positive テーブルを作成し、初期値として value カラムに 0 を挿入しました。

リソースにデータベース接続を渡す

リソースからデータベースにアクセスするために、 Connection を供給する DisposableSupplier を作成します。2
動作の様子がわかるように、コンソールに状態を書き出しています。

ConnectionSupplier.java
...
public class ConnectionSupplier implements DisposableSupplier<Connection> {

    @Override
    public Connection get() {
        try {
            var ds = (DataSource) InitialContext.doLookup("java:comp/env/jdbc/h2db");
            var con = ds.getConnection();
            con.setAutoCommit(false);
            System.out.println("Supplied");
            return con;
        } catch (NamingException | SQLException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void dispose(Connection instance) {
        System.out.println("Closing...");
        try {
            if (!instance.isClosed()) {
                instance.close();
            }
        } catch (SQLException e) {
            e.printStackTrace();
            throw new RuntimeException(e);
        }
    }
}

この ConnectionSupplierConnection のファクトリとして登録します。
スコープには RequestScoped を設定しないと、なぜか dispose が呼ばれないようです。なお、この dispose はリソースで予期しない例外が発生した場合にも Connection をクローズできることを想定して実装しています。

AppConfig.java
@ApplicationPath("app")
public class AppConfig extends ResourceConfig {
    public AppConfig() {
        packages(getClass().getPackage().getName());
        register(DatabaseInitializer.class);

        register(new AbstractBinder() {
            @Override
            protected void configure() {
                bindFactory(ConnectionSupplier.class).to(Connection.class)
                        .in(RequestScoped.class);
            }
        });
    }
}

リソースに現在の値を返す GET メソッドを作成し、データベースにアクセスできることを確認してみます。

Positive.java
@Path("positive")
public class Positive {

    private static final String SELECT_VALUE_FROM_POSITIVE = "select value from positive";

    @Inject
    private Connection connection;

    @GET
    public String query() throws SQLException {
        try (var con = connection; var select = con.prepareStatement(SELECT_VALUE_FROM_POSITIVE)) {
            var result = select.executeQuery();
            result.next();
            var current = result.getInt("value");
            return getMessage(current);
        }
    }

    private String getMessage(int value) {
        return String.format("Current value is %d. Never be negative!", value);
    }
}

Web アプリケーションを起動して、アクセスしてみましょう。

Terminal
curl localhost:8080/app/positive/
# Current value is 0. Never be negative!

初期値 0 を取得できていますね。サーバーのログも見てみましょう。

サーバー
情報: Starting ProtocolHandler ["http-nio-8080"]
Supplied
Closing...

リクエストごとに Connection のクローズ処理が実行されていることがわかります。

DI でトランザクション

注入された Connection を使ってデータベースを更新した後に例外が発生したとき、トランザクションがロールバックされるように変更してみましょう。
リソースに PUT メソッドを定義して値を更新できるようにします。ここで、現在値と入力値の合計が負数になるとき、あえてレコードを合計値で更新してから例外を発生させます。

Positive.java
...
    @PUT
    public String add(String n) throws SQLException {
        try (var select = connection.prepareStatement(SELECT_VALUE_FROM_POSITIVE);
                var update = connection.prepareStatement("update positive set value = ?")) {
            var result = select.executeQuery();
            result.next();
            var current = result.getInt("value");
            var sum = current + Integer.parseInt(n);

            update.setInt(1, sum);
            update.executeUpdate();

            if (sum < 0) {
                throw new RuntimeException();
            }

            System.out.println("Commit");
            connection.commit();
            connection.close();

            return getMessage(sum);
        }
    }
...

add メソッドでは try-with-resources 文に Connection を渡していません。呼び出しの途中で例外が発生した場合 ConnectionConnectionSupplier#dispose でクローズされますが、この前にロールバックを追加します。3

ConnectionSupplier.java
...
    @Override
    public void dispose(Connection instance) {
        System.out.println("Closing...");
        try {
            if (!instance.isClosed()) {
                System.out.println("Roll back");  // 追加
                instance.rollback(); // 追加
                instance.close();
            }
...

リソースの中で Connection がクローズされなかった場合に、ロールバックが実行されるようになりました。
Web アプリケーションを再起動して、 PUT リクエストで値を更新してみましょう。

Terminal
curl -X PUT localhost:8080/app/positive -d 1
# Current value is 1. Never be negative!
curl -X PUT localhost:8080/app/positive -d -3
# <!doctype html><html lang="en"><head><title>HTTP Status 500 – Internal Server Error</title>...
curl localhost:8080/app/positive/
# Current value is 1. Never be negative!

合計が負数になるリクエストで例外が発生していますが、例外発生前の 1 が保持されていることがわかります。
サーバーのログを見てみましょう。

サーバー
情報: Starting ProtocolHandler ["http-nio-8080"]
Supplied
Commit
Closing...
Supplied
Closing...
Roll back
...
java.lang.RuntimeException
...
Supplied
Closing...

例外が発生したリクエストで、ロールバックが実行されたことがわかります。

DI と AOP でトランザクション

DI を使ったトランザクションでは、データベースを更新する呼び出しのなかで明示的にコミットを実行する必要がありました。次はこの規約を廃し、単に呼び出しが正常終了すればコミット、例外発生時にはロールバックが実行される仕組みを実現します。

トランザクション境界を示すアノテーションを定義します。独自に実装しても良いのですが、ここでは JTA で使われている @Transactional を流用します。

build.gradle
...
    implementation group: 'javax.transaction', name: 'javax.transaction-api', version: '1.3'
...

実際にコミット・ロールバック処理を行う MethodInterceptor を実装します。 メソッドの呼び出しが MethodInvocation として引数に与えられるので、その実行の前後に目的の処理を定義します。

TransactionInterceptor.java
public class TransactionInterceptor implements MethodInterceptor {

    @Inject
    private Connection connection;

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        try {
            var ret = invocation.proceed();
            System.out.println("[AOP] Commit");
            if (!connection.isClosed()) {
                connection.commit();
                connection.close();
            }
            return ret;
        } catch (Throwable t) {
            System.out.println("[AOP] Roll back");
            if (!connection.isClosed()) {
                connection.rollback();
                connection.close();
            }
            throw t;
        }
    }
}

次に、この TransactionInterceptor@Transactional で注釈されたメソッドに供給する InterceptionService を作成します。 InterceptionService は Jersey 内部の HK2 がオブジェクトを注入するたびに、そのオブジェクトのクラスに定義されたメソッドおよびコンストラクタに対してインターセプターを供給します。4

TransactionInterceptionService
public class TransactionInterceptionService implements InterceptionService {

    @Inject
    private TransactionInterceptor interceptor;

    @Override
    public Filter getDescriptorFilter() {
        return BuilderHelper.allFilter();
    }

    @Override
    public List<MethodInterceptor> getMethodInterceptors(Method method) {
        return method.isAnnotationPresent(Transactional.class) ? List.of(interceptor) : Collections.emptyList();
    }

    @Override
    public List<ConstructorInterceptor> getConstructorInterceptors(Constructor<?> constructor) {
        return Collections.emptyList();
    }
}

TransactionInterceptorTransactionInterceptionService を依存関係に登録します。TransactionInterceptionServiceSingleton でなければなりません。一方、この内部に注入される TransactionInterceptor 内の ConnectionRequestScoped で、ライフサイクルが一致しません。そのため、 RequestScoped のスコープで生成された Connection を、プロキシを経由して Singleton のスコープからも参照できるように設定します。5

AppConfig.java
@ApplicationPath("app")
public class AppConfig extends ResourceConfig {
    public AppConfig() {
        packages(getClass().getPackage().getName());
        register(DatabaseInitializer.class);

        register(new AbstractBinder() {
            @Override
            protected void configure() {
                bindFactory(ConnectionSupplier.class).to(Connection.class)
                        .proxy(true).proxyForSameScope(false) // 追加
                        .in(RequestScoped.class);

                // 追加
                bindAsContract(TransactionInterceptor.class);
                bind(TransactionInterceptionService.class).to(InterceptionService.class).in(Singleton.class);
            }
        });
    }
}

最後に、リソースの呼び出しを @Transactional で注釈します。これでコミットやクローズ処理は不要になったので、コメントアウトしておきます。

Positive.java
...
    @Transactional // 追加
    @PUT
    public String add(String n) throws SQLException {
...
            // System.out.println("Commit");
            // connection.commit();
            // connection.close();

            return getMessage(sum);
        }
    }
...

Web アプリケーションを再起動して、PUT リクエストで値を更新してみましょう。

Terminal(クライアント)
curl -X PUT "localhost:8080/app/positive" -d "-3"
# Current value is 1. Never be negative!
curl -X PUT "localhost:8080/app/positive" -d "-3"
# <!doctype html><html lang="en"><head><title>HTTP Status 500 – Internal Server Error...
curl localhost:8080/app/positive/
# Current value is 1. Never be negative!
サーバー
情報: Starting ProtocolHandler ["http-nio-8080"]
Supplied
[AOP] Commit
Closing...
Supplied
[AOP] Roll back
Closing...
...
java.lang.RuntimeException
...
Supplied
Closing...

AOP でコミットとロールバックを実行できていることがわかります。

参考


  1. 本記事では webapp をアプリケーションのルートディレクトリに指定しています。最初の記事 を参照。 ここで context.xml に定義した接続設定は、アプリケーションのエントリポイントとなるクラスのなかでも定義できます。 

  2. 公式ドキュメントの実装例では HK2 の Factory インターフェースが使われていますが、 バージョン 2.26 で Jersey 独自の Supplier インターフェースの導入に伴い廃止されました。その後、 バージョン 2.29 で後方互換性のために Factory のサポートが復活しましたが、いずれは廃止予定のようです。また、 Supplier を登録するためには HK2 の AbstractBinder ではなく、 Jersey 独自の同名クラスを利用する必要があります。 

  3. Connection#setAutoCommit(false) のとき、コミットせずにクローズした場合のふるまいは実装依存です。多くの DBMS ではロールバックされますが、例えば Oracle ではコミットされるようです。なお、記事中では add メソッドの最後で Connection をクローズしていますが、一般には先にコミットされた内容はロールバックしても取り消されないため、クローズしなくても問題はないはずです。 

  4. InterceptionService はあらゆる依存解決で実行されるため、オーバーヘッドを考慮に入れるべきでしょう。 getDescriptorFilter で対象となるオブジェクトを絞り込むこともできますが、現実的には例えばクラスの完全修飾名のような大雑把な条件しか与えられないうえ、この絞り込み結果はキャッシュされません。また、意図しないメソッドやコンストラクタにインターセプターを供給してしまうリスクにも注意が必要です。 

  5. JavaEE屈指の便利機能、CDIを触ってみよう - 技術ブログ | 株式会社クラウディア を参照。 

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