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

# はじめに

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

## 関連記事の一覧（予定）

- [埋め込みTomcatでJerseyを動かしてみる](https://qiita.com/atti/items/fa3843f75c4bd669059a)
- [HK2でDIしてみる](https://qiita.com/atti/items/3f6f43c5168323344427)
- [BeanValidationで入力値検証してみる](https://qiita.com/atti/items/2267a4b52b033dfabcca)
- TomcatとJerseyでトランザクション管理してみる ← イマココ

## リポジトリ

https://github.com/at6ue/tomcat_embed-jersey

# データベースを準備する

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

```gradle: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` としました。

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

```xml: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/h2db` は `java:comp/env/` 以下に見つかります。

```java: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]
動作の様子がわかるように、コンソールに状態を書き出しています。

[^2]: [公式ドキュメントの実装例](https://eclipse-ee4j.github.io/jersey.github.io/documentation/latest/ioc.html#d0e17332)では HK2 の `Factory` インターフェースが使われていますが、 バージョン 2.26 で Jersey 独自の `Supplier` インターフェースの導入に伴い廃止されました。その後、 バージョン 2.29 で後方互換性のために `Factory` のサポートが復活しましたが、[いずれは廃止予定](https://eclipse-ee4j.github.io/jersey.github.io/documentation/latest/migration.html#mig-2.26-injection-manager)のようです。また、 `Supplier` を登録するためには HK2 の `AbstractBinder` ではなく、 **Jersey 独自の同名クラス**を利用する必要があります。

```java: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);
        }
    }
}
```

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

```java: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` メソッドを作成し、データベースにアクセスできることを確認してみます。

```java: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 アプリケーションを起動して、アクセスしてみましょう。

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

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

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

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

# DI でトランザクション

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

```java: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` を渡していません。呼び出しの途中で例外が発生した場合 `Connection` は `ConnectionSupplier#dispose` でクローズされますが、この前にロールバックを追加します。[^3]

[^3]: `Connection#setAutoCommit(false)` のとき、[コミットせずにクローズした場合のふるまいは実装依存](https://docs.oracle.com/javase/jp/11/docs/api/java.sql/java/sql/Connection.html#close())です。多くの DBMS ではロールバックされますが、例えば [Oracle ではコミットされる](https://in.relation.to/2005/10/20/pop-quiz-does-connectionclose-result-in-commit-or-rollback/)ようです。なお、記事中では `add` メソッドの最後で `Connection` をクローズしていますが、一般には先にコミットされた内容はロールバックしても取り消されないため、クローズしなくても問題はないはずです。

```java: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` リクエストで値を更新してみましょう。

```bash: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` が保持されていることがわかります。
サーバーのログを見てみましょう。

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

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

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

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

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

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

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

```java: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]

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

```java: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();
    }
}
```

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

[^5]: [JavaEE屈指の便利機能、CDIを触ってみよう - 技術ブログ | 株式会社クラウディア](https://cloudear.jp/blog/?p=1945) を参照。

```java: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` で注釈します。これでコミットやクローズ処理は不要になったので、コメントアウトしておきます。

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

            return getMessage(sum);
        }
    }
...
```

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

```bash: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!
```

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

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

# 参考

- [Chapter 24. Custom Injection and Lifecycle Management](https://eclipse-ee4j.github.io/jersey.github.io/documentation/latest/ioc.html)
- [Jerseyでリソースメソッドをトランザクション境界にする — 裏紙](https://backpaper0.github.io/2015/03/09/jersey_hk2_aop.html)