はじめに
Tomcat + Jersey という構成の Web アプリケーションの動作を理解するために、Tomcat Embed を使って実験をしていきます。
いまどきこんな構成で開発を始めることは少ないかと思いますが、レガシーソフトウェアと戦う人たちの助けになれば幸いです。
関連記事の一覧(予定)
- 埋め込みTomcatでJerseyを動かしてみる
- HK2でDIしてみる
- BeanValidationで入力値検証してみる
- TomcatとJerseyでトランザクション管理してみる ← イマココ
リポジトリ
データベースを準備する
本記事では検証用のデータベースとしてインメモリの H2 Database を使用します。
build.gradle
に以下の依存を追加します。 tomcat-dbcp
は埋め込み Tomcat でデータベースにアクセスするために必要です。
...
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>
<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/
以下に見つかります。
...
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
動作の様子がわかるように、コンソールに状態を書き出しています。
...
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
をクローズできることを想定して実装しています。
@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
メソッドを作成し、データベースにアクセスできることを確認してみます。
@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 アプリケーションを起動して、アクセスしてみましょう。
curl localhost:8080/app/positive/
# Current value is 0. Never be negative!
初期値 0
を取得できていますね。サーバーのログも見てみましょう。
情報: Starting ProtocolHandler ["http-nio-8080"]
Supplied
Closing...
リクエストごとに Connection
のクローズ処理が実行されていることがわかります。
DI でトランザクション
注入された Connection
を使ってデータベースを更新した後に例外が発生したとき、トランザクションがロールバックされるように変更してみましょう。
リソースに PUT
メソッドを定義して値を更新できるようにします。ここで、現在値と入力値の合計が負数になるとき、あえてレコードを合計値で更新してから例外を発生させます。
...
@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
...
@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
リクエストで値を更新してみましょう。
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
を流用します。
...
implementation group: 'javax.transaction', name: 'javax.transaction-api', version: '1.3'
...
実際にコミット・ロールバック処理を行う MethodInterceptor
を実装します。 メソッドの呼び出しが MethodInvocation
として引数に与えられるので、その実行の前後に目的の処理を定義します。
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
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
@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
で注釈します。これでコミットやクローズ処理は不要になったので、コメントアウトしておきます。
...
@Transactional // 追加
@PUT
public String add(String n) throws SQLException {
...
// System.out.println("Commit");
// connection.commit();
// connection.close();
return getMessage(sum);
}
}
...
Web アプリケーションを再起動して、PUT
リクエストで値を更新してみましょう。
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 でコミットとロールバックを実行できていることがわかります。
参考
-
本記事では
webapp
をアプリケーションのルートディレクトリに指定しています。最初の記事 を参照。 ここでcontext.xml
に定義した接続設定は、アプリケーションのエントリポイントとなるクラスのなかでも定義できます。 ↩ -
公式ドキュメントの実装例では HK2 の
Factory
インターフェースが使われていますが、 バージョン 2.26 で Jersey 独自のSupplier
インターフェースの導入に伴い廃止されました。その後、 バージョン 2.29 で後方互換性のためにFactory
のサポートが復活しましたが、いずれは廃止予定のようです。また、Supplier
を登録するためには HK2 のAbstractBinder
ではなく、 Jersey 独自の同名クラスを利用する必要があります。 ↩ -
Connection#setAutoCommit(false)
のとき、コミットせずにクローズした場合のふるまいは実装依存です。多くの DBMS ではロールバックされますが、例えば Oracle ではコミットされるようです。なお、記事中ではadd
メソッドの最後でConnection
をクローズしていますが、一般には先にコミットされた内容はロールバックしても取り消されないため、クローズしなくても問題はないはずです。 ↩ -
InterceptionService
はあらゆる依存解決で実行されるため、オーバーヘッドを考慮に入れるべきでしょう。getDescriptorFilter
で対象となるオブジェクトを絞り込むこともできますが、現実的には例えばクラスの完全修飾名のような大雑把な条件しか与えられないうえ、この絞り込み結果はキャッシュされません。また、意図しないメソッドやコンストラクタにインターセプターを供給してしまうリスクにも注意が必要です。 ↩