CLOVER🍀

That was when it all began.

Spring Frameworkの@Transactinalのロールバックに関する設定を確認する

これは、なにをしたくて書いたもの?

Spring Frameworkで@Transactionalアノテーションを使った時の、ロールバックに関する設定を確認しておきたいな、と
思いまして。

@Transactionalアノテーションを使ったトランザクション管理

Spring Frameworkのドキュメントとしては、こちらに記述があります。

Using @Transactional

メソッド、またはクラスに@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アノテーションに設定可能な項目は、こちら。

@Transactional Settings

トランザクション分離レベル、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.

@Transactional Settings

ちなみに、宣言的トランザクションの説明のところには、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アノテーションのロールバック対象の例外のデフォルト値は、特になにも指定がないのです。

https://github.com/spring-projects/spring-framework/blob/v5.3.6/spring-tx/src/main/java/org/springframework/transaction/annotation/Transactional.java#L166-L200

Spring Frameworkのソースコードを追ってみます。

@Transactional…つまり、TransactionInterceptorが適用されたメソッドが実行され、例外がスローされた時にロールバック
するかどうかを確認しているのはこちらのようです。

https://github.com/spring-projects/spring-framework/blob/v5.3.6/spring-tx/src/main/java/org/springframework/transaction/interceptor/TransactionAspectSupport.java#L670

もうちょっと追っていくと、RuleBasedTransactionAttributeクラスにたどり着きます。@Transactionalにロールバック対象の
例外を明示的に設定している場合は、ここで確認するようです。

https://github.com/spring-projects/spring-framework/blob/v5.3.6/spring-tx/src/main/java/org/springframework/transaction/interceptor/RuleBasedTransactionAttribute.java#L140-L146

なにも設定していない場合は、親クラスを呼び出します。

https://github.com/spring-projects/spring-framework/blob/v5.3.6/spring-tx/src/main/java/org/springframework/transaction/interceptor/RuleBasedTransactionAttribute.java#L156

親クラスであるDefaultTransactionAttributeクラスのrollbackOnメソッドを見ると、RuntimeExceptionまたはErrorであれば
trueを返すようになっています。

https://github.com/spring-projects/spring-framework/blob/v5.3.6/spring-tx/src/main/java/org/springframework/transaction/interceptor/DefaultTransactionAttribute.java#L186-L188

というわけで、デフォルトの挙動は、設定ではなくてデフォルト実装で実現されているということになります。

また、RuleBasedTransactionAttributeクラスのインスタンスを作成しているのは、こちらですね。

https://github.com/spring-projects/spring-framework/blob/v5.3.6/spring-tx/src/main/java/org/springframework/transaction/annotation/SpringTransactionAnnotationParser.java#L68-L102

@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でトランザクション管理する場合は、どうすればよいのでしょう?

アノテーションを合成すれば良さそうです。

Custom Composed Annotations

こちらのドキュメントに従い、@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アノテーションから
トランザクション管理の設定を読み取るこのあたりのソースコードを見ていると、これしか方法なさそうな感じが?

https://github.com/spring-projects/spring-framework/blob/v5.3.6/spring-tx/src/main/java/org/springframework/transaction/annotation/SpringTransactionAnnotationParser.java#L68-L102

まとめ

今回は、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");
    }
}