これは、なにをしたくて書いたもの?
Spring Frameworkで@Transactionalアノテーションを使った時の、ロールバックに関する設定を確認しておきたいな、と
思いまして。
@Transactionalアノテーションを使ったトランザクション管理
Spring Frameworkのドキュメントとしては、こちらに記述があります。
メソッド、またはクラスに@Transactionalアノテーションを付与することで、そのメソッド、またはクラス(およびサブクラス)の
メソッドをトランザクションに参加させることができます。
Used at the class level as above, the annotation indicates a default for all methods of the declaring class (as well as its subclasses).
@Transactionalアノテーションに設定可能な項目は、こちら。
トランザクション分離レベル、Read-Only、ロールバックに関する設定が可能です。
Javadocを見てもよいでしょう。
Transactional (Spring Framework 5.3.6 API)
ちなみに、従来のXMLで設定する方法についてはこちらに記述があります。
Example of Declarative Transaction Implementation
ロールバックについて
さて、ちょっとロールバックに関する説明を見てみましょう。デフォルトではRuntimeExceptionがスローされるとロールバックし、
チェック例外ではそうならない、と記述があります。
Any RuntimeException triggers rollback, and any checked Exception does not.
ちなみに、宣言的トランザクションの説明のところには、Errorも対象になることが書かれています。
That is, when the thrown exception is an instance or subclass of RuntimeException. ( Error instances also, by default, result in a rollback).
Rolling Back a Declarative Transaction
@TransactionalのJavadocを見てみましょう。
If no custom rollback rules apply, the transaction will roll back on RuntimeException and Error but not on checked exceptions.
Transactional (Spring Framework 5.3.6 API)
デフォルトではRuntimeExceptionとErrorが対象となる、で良さそうです。
このあたりをソースコードを見つつ、設定を変えたりしてみようというのが今回のお題です。
ちなみに、今回は扱いませんが「ロールバックしない」例外を定義することも可能ですね。
環境
今回の環境は、こちら。
$ java --version openjdk 11.0.11 2021-04-20 OpenJDK Runtime Environment (build 11.0.11+9-Ubuntu-0ubuntu2.20.04) OpenJDK 64-Bit Server VM (build 11.0.11+9-Ubuntu-0ubuntu2.20.04, mixed mode, sharing) $ mvn --version Apache Maven 3.8.1 (05c21c65bdfed0f71a2f2ada8b84da59348c4c5d) Maven home: $HOME/.sdkman/candidates/maven/current Java version: 11.0.11, vendor: Ubuntu, runtime: /usr/lib/jvm/java-11-openjdk-amd64 Default locale: ja_JP, platform encoding: UTF-8 OS name: "linux", version: "5.4.0-72-generic", arch: "amd64", family: "unix"
データベースにはMySQL 8.0.24を使い、172.17.0.2で動作しているものとします。
準備
Spring Initializrでプロジェクトを作成します。jdbcとmysqlを依存関係に含めました。
$ curl -s https://start.spring.io/starter.tgz \ -d bootVersion=2.4.5 \ -d javaVersion=11 \ -d name=transactional-rollback-rule \ -d groupId=org.littlewings \ -d artifactId=transactional-rollback-rule \ -d version=0.0.1-SNAPSHOT \ -d packageName=org.littlewings.spring.jdbc \ -d dependencies=jdbc,mysql \ -d baseDir=transactional-rollback-rule | tar zxvf - $ cd transactional-rollback-rule
Spring Bootのバージョンは2.4.5、Spring Frameworkのバージョンは5.3.6です。
できあがったプロジェクトの依存関係等は、こちら。
<properties> <java.version>11</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>
動作確認はテストコードで行うことにします。
テストコードを実行するために、@SpringBootApplicationアノテーションが付与されたソースコードを用意。
src/main/java/org/littlewings/spring/jdbc/App.java
package org.littlewings.spring.jdbc; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class App { }
テストコードの雛形を作製。
src/test/java/org/littlewings/spring/jdbc/TransactionalRollbackTest.java
package org.littlewings.spring.jdbc; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.jdbc.core.JdbcTemplate; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @SpringBootTest class TransactionalRollbackTest { @Autowired JdbcTemplate jdbcTemplate; @BeforeEach public void createTable() { jdbcTemplate.execute("drop table if exists sample"); jdbcTemplate.execute("create table sample(word varchar(10));"); } // ここに、Beanのインジェクションとテストを書く! }
テストの実行ごとに、テーブルをdrop、createするようにしています。
@BeforeEach public void createTable() { jdbcTemplate.execute("drop table if exists sample"); jdbcTemplate.execute("create table sample(word varchar(10));"); }
アプリケーションの設定は、こちらだけです。
src/test/resources/application.properties
spring.datasource.url=jdbc:mysql://172.17.0.2:3306/practice spring.datasource.username=kazuhira spring.datasource.password=password
まずは単純に使ってみる
最初は、@Transactionalを単純に使ってみましょう。
@Transactinalを付与したメソッドを持つ、こんなクラスを用意。
src/main/java/org/littlewings/spring/jdbc/SimpleTransactionalService.java
package org.littlewings.spring.jdbc; import java.util.List; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class SimpleTransactionalService { JdbcTemplate jdbcTemplate; public SimpleTransactionalService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Transactional(readOnly = true) public List<String> findAll() { return jdbcTemplate.queryForList("select word from sample order by word", String.class); } @Transactional public void insertSuccess(String value) { jdbcTemplate.update("insert into sample(word) values(?)", value); } @Transactional public void insertWithRuntimeException(String value) { jdbcTemplate.update("insert into sample(word) values(?)", value); throw new RuntimeException("Oops!!"); } @Transactional public void insertWithException(String value) throws Exception { jdbcTemplate.update("insert into sample(word) values(?)", value); throw new Exception("Commit?"); } }
@Transactionalをつけた、insertが成功するメソッド、RuntimeExceptionをスローするメソッド、Exceptionをスローする
メソッド、そしてデータを取得するメソッドを定義しています。
テストコード。
@Autowired SimpleTransactionalService simpleTransactionalService; @Test public void simply() { simpleTransactionalService.insertSuccess("foo"); assertThatThrownBy(() -> simpleTransactionalService.insertWithRuntimeException("bar")).isInstanceOf(RuntimeException.class).hasMessage("Oops!!"); assertThatThrownBy(() -> simpleTransactionalService.insertWithException("hoge")).isInstanceOf(Exception.class).hasMessage("Commit?"); assertThat(simpleTransactionalService.findAll()).containsExactly("foo", "hoge"); }
結果を見ると、RuntimeExceptionをスローしたメソッドはロールバックされていますが、Exceptionをスローしたメソッドは
ロールバックされていない(コミットされている)ことが確認できます。
実装を確認する
さて、このあたりはどこで定義されているのでしょう?
@Transactionalアノテーションのロールバック対象の例外のデフォルト値は、特になにも指定がないのです。
Spring Frameworkのソースコードを追ってみます。
@Transactional…つまり、TransactionInterceptorが適用されたメソッドが実行され、例外がスローされた時にロールバック
するかどうかを確認しているのはこちらのようです。
もうちょっと追っていくと、RuleBasedTransactionAttributeクラスにたどり着きます。@Transactionalにロールバック対象の
例外を明示的に設定している場合は、ここで確認するようです。
なにも設定していない場合は、親クラスを呼び出します。
親クラスであるDefaultTransactionAttributeクラスのrollbackOnメソッドを見ると、RuntimeExceptionまたはErrorであれば
trueを返すようになっています。
というわけで、デフォルトの挙動は、設定ではなくてデフォルト実装で実現されているということになります。
また、RuleBasedTransactionAttributeクラスのインスタンスを作成しているのは、こちらですね。
@Transactionalアノテーションの内容を使って、インスタンスを設定しているようです。
設定してみる
実装を見たところで、ちょっと設定を変えてみましょう。
以下のように、@TransactionalのrollbackForにExceptionを指定してみます。
src/main/java/org/littlewings/spring/jdbc/WithRollbackForTransactionalService.java
package org.littlewings.spring.jdbc; import java.util.List; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class WithRollbackForTransactionalService { JdbcTemplate jdbcTemplate; public WithRollbackForTransactionalService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Transactional(readOnly = true) public List<String> findAll() { return jdbcTemplate.queryForList("select word from sample order by word", String.class); } @Transactional(rollbackFor = Exception.class) public void insertSuccess(String value) { jdbcTemplate.update("insert into sample(word) values(?)", value); } @Transactional(rollbackFor = Exception.class) public void insertWithRuntimeException(String value) { jdbcTemplate.update("insert into sample(word) values(?)", value); throw new RuntimeException("Oops!!"); } @Transactional(rollbackFor = Exception.class) public void insertWithException(String value) throws Exception { jdbcTemplate.update("insert into sample(word) values(?)", value); throw new Exception("Rollback!"); } }
テストコードで確認。
@Autowired WithRollbackForTransactionalService withRollbackForTransactionalService; @Test public void withRollbackFor() { withRollbackForTransactionalService.insertSuccess("foo"); assertThatThrownBy(() -> withRollbackForTransactionalService.insertWithRuntimeException("bar")).isInstanceOf(RuntimeException.class).hasMessage("Oops!!"); assertThatThrownBy(() -> withRollbackForTransactionalService.insertWithException("hoge")).isInstanceOf(Exception.class).hasMessage("Rollback!"); assertThat(withRollbackForTransactionalService.findAll()).containsExactly("foo"); }
今度は、スローするのがExceptionでもロールバックされるようになりました。
アノテーションを合成する
ところで、先ほどのような設定にすると、個々のメソッドやクラスに@Transactionalの設定をそれぞれ書いていくため
設定ミスなどが気になるところです。
XMLで設定していた時は、ある意味一括で設定できていた感じがします。
Rolling Back a Declarative Transaction
@Transactionalでトランザクション管理する場合は、どうすればよいのでしょう?
アノテーションを合成すれば良さそうです。
こちらのドキュメントに従い、@Transactionalをカスタマイズした新しいアノテーションを定義します。
Using Meta-annotations and Composed Annotations
新しいアノテーションでは、@AliasForアノテーションを使って@Transactionalの設定へ反映させます。
AliasFor (Spring Framework 5.3.6 API)
こんな感じのものを作成。
src/main/java/org/littlewings/spring/jdbc/MyTransactional.java
package org.littlewings.spring.jdbc; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import org.springframework.core.annotation.AliasFor; import org.springframework.transaction.annotation.Transactional; @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented @Transactional public @interface MyTransactional { @AliasFor(annotation = Transactional.class, attribute = "readOnly") boolean readOnly() default false; @AliasFor(annotation = Transactional.class, attribute = "rollbackFor") Class<? extends Throwable>[] rollbackFor() default Exception.class; }
@Transactionalをメタアノテーションとして使い、新しいアノテーションを定義します。
@Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented @Transactional public @interface MyTransactional {
rollbackForの設定は、デフォルトでExceptionをロールバック対象にしてみました。
@AliasFor(annotation = Transactional.class, attribute = "rollbackFor") Class<? extends Throwable>[] rollbackFor() default Exception.class;
あとは、readOnlyが設定できるようにしています。
@AliasFor(annotation = Transactional.class, attribute = "readOnly") boolean readOnly() default false;
作成したアノテーションを使ったコード。
src/main/java/org/littlewings/spring/jdbc/WithComposedAnnotationTransactionalService.java
package org.littlewings.spring.jdbc; import java.util.List; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @Service public class WithComposedAnnotationTransactionalService { JdbcTemplate jdbcTemplate; public WithComposedAnnotationTransactionalService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @MyTransactional(readOnly = true) public List<String> findAll() { return jdbcTemplate.queryForList("select word from sample order by word", String.class); } @MyTransactional public void insertSuccess(String value) { jdbcTemplate.update("insert into sample(word) values(?)", value); } @MyTransactional public void insertWithRuntimeException(String value) { jdbcTemplate.update("insert into sample(word) values(?)", value); throw new RuntimeException("Oops!!"); } @MyTransactional public void insertWithException(String value) throws Exception { jdbcTemplate.update("insert into sample(word) values(?)", value); throw new Exception("Rollback!"); } }
こちらには、rollbackForの設定はありません。
確認用のテストコード。
@Autowired WithComposedAnnotationTransactionalService withComposedAnnotationTransactionalService; @Test public void withComposedAnnotation() { withComposedAnnotationTransactionalService.insertSuccess("foo"); assertThatThrownBy(() -> withComposedAnnotationTransactionalService.insertWithRuntimeException("bar")).isInstanceOf(RuntimeException.class).hasMessage("Oops!!"); assertThatThrownBy(() -> withComposedAnnotationTransactionalService.insertWithException("hoge")).isInstanceOf(Exception.class).hasMessage("Rollback!"); assertThat(withComposedAnnotationTransactionalService.findAll()).containsExactly("foo"); }
先ほどの、@Transactional+rollbackForでExceptionを指定した時と同じ結果が得られました。
というわけで、設定を共通化したかったらこういう感じにしたらよさそう…というか、@Transactionalアノテーションから
トランザクション管理の設定を読み取るこのあたりのソースコードを見ていると、これしか方法なさそうな感じが?
まとめ
今回は、Spring Frameworkの@Transactionalのロールバックに関する設定を見たり、その実装部分を見たり、設定を変えて
確認したりしてみました。
どうなんでしょう?@Transactionalをそのまま使うのならいいのですが、rollbackForあたりを一律カスタマイズしたい場合は
合成アノテーションを使った方が良さそうなような、そうでもないような。
Spring Frameworkを使うなら、トランザクション管理は@Transactionalでしょう、みたいなところありそうですからね。
変に自前のものを持ち込むと混乱しそうな感じも。
ツールやテストで確認できたらいいのでしょうか。
XMLで埋め込んでいた時は、メソッド名のルールなどで一律適用といった感じですからね。ちょっと違った悩み?
ちなみに、XMLで埋め込んでいた内容を、JavaConfigで行う場合はこのあたりを使うことになりそうです。
BeanFactoryTransactionAttributeSourceAdvisor (Spring Framework 5.3.6 API)
MethodMapTransactionAttributeSource (Spring Framework 5.3.6 API)
RuleBasedTransactionAttribute (Spring Framework 5.3.6 API)
最後に、作成したテストコード全体を載せておきましょう。
src/test/java/org/littlewings/spring/jdbc/TransactionalRollbackTest.java
package org.littlewings.spring.jdbc; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.jdbc.core.JdbcTemplate; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @SpringBootTest class TransactionalRollbackTest { @Autowired JdbcTemplate jdbcTemplate; @BeforeEach public void createTable() { jdbcTemplate.execute("drop table if exists sample"); jdbcTemplate.execute("create table sample(word varchar(10));"); } @Autowired SimpleTransactionalService simpleTransactionalService; @Test public void simply() { simpleTransactionalService.insertSuccess("foo"); assertThatThrownBy(() -> simpleTransactionalService.insertWithRuntimeException("bar")).isInstanceOf(RuntimeException.class).hasMessage("Oops!!"); assertThatThrownBy(() -> simpleTransactionalService.insertWithException("hoge")).isInstanceOf(Exception.class).hasMessage("Commit?"); assertThat(simpleTransactionalService.findAll()).containsExactly("foo", "hoge"); } @Autowired WithRollbackForTransactionalService withRollbackForTransactionalService; @Test public void withRollbackFor() { withRollbackForTransactionalService.insertSuccess("foo"); assertThatThrownBy(() -> withRollbackForTransactionalService.insertWithRuntimeException("bar")).isInstanceOf(RuntimeException.class).hasMessage("Oops!!"); assertThatThrownBy(() -> withRollbackForTransactionalService.insertWithException("hoge")).isInstanceOf(Exception.class).hasMessage("Rollback!"); assertThat(withRollbackForTransactionalService.findAll()).containsExactly("foo"); } @Autowired WithComposedAnnotationTransactionalService withComposedAnnotationTransactionalService; @Test public void withComposedAnnotation() { withComposedAnnotationTransactionalService.insertSuccess("foo"); assertThatThrownBy(() -> withComposedAnnotationTransactionalService.insertWithRuntimeException("bar")).isInstanceOf(RuntimeException.class).hasMessage("Oops!!"); assertThatThrownBy(() -> withComposedAnnotationTransactionalService.insertWithException("hoge")).isInstanceOf(Exception.class).hasMessage("Rollback!"); assertThat(withComposedAnnotationTransactionalService.findAll()).containsExactly("foo"); } }