@Transactionalと検査例外・非検査例外

ロールバック

Spring Bootの@Transactionalは、デフォルトでは検査例外が発生したときはロールバックせず、非検査例外が発生したときにロールバックする。

@SneakyThrowsと検査例外

Lombokの@SneakyThrowsをメソッドに付与すると、検査例外を投げるメソッドでもthrowsを書かなくてよくなるし、メソッドを利用する側もcatchする必要がなくなる。

それでは@SneakyThrowsを付けたメソッドを@Transactionalをつけたメソッドが呼び出すか、あるいは@SneakyThrows@Transactionalをともに同じメソッドにつけた場合、例外が実際に発生するとロールバックされるのかされないのかがよく分からなかったので調査してみる。

実装

まずは簡単なSpring Bootのコマンドラインアプリを実装する。

起動クラス、データベースのEntity、リポジトリ

トランザクション制御を試すためにまずは起動クラスやDB関連の特にロジックがない雛形的な部分を記載していく。

Spring Data JPAを使っているだけなので流し読みしてほしい。

起動クラス: Application.java

package com.example.sneakytran;

import com.example.sneakytran.component.SneakyThrowAndTransaction;
import com.example.sneakytran.entity.Order;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        var ctx = SpringApplication.run(Application.class, args);

        Order order = ctx.getBean(SneakyThrowAndTransaction.class).insert();
        System.out.println(order.getId());
    }

}

application.properties

logging.level.root=DEBUG

# MySQL
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost/test
spring.datasource.username=root
spring.datasource.password=パスワード
spring.jpa.database=MYSQL
spring.jpa.hibernate.ddl-auto=update

エンティティ: Order.java(注文エンティティ)

package com.example.sneakytran.entity;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import java.time.LocalDateTime;

@Entity(name = "orders")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, columnDefinition = "DATETIME")
    private LocalDateTime orderedAt;
}

リポジトリ: OrderRepository.java

package com.example.sneakytran.repository;

import com.example.sneakytran.entity.Order;
import org.springframework.data.repository.CrudRepository;

public interface OrderRepository extends CrudRepository<Order, Long> {
}

トランザクション調査用クラス

調査用に非検査例外を投げるメソッド、検査例外を投げるメソッド、検査例外を投げるが@SneakyThrowsでそれを隠しているメソッドをそれぞれ用意する。

package com.example.sneakytran.component;

import com.example.sneakytran.entity.Order;
import com.example.sneakytran.repository.OrderRepository;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;

@RequiredArgsConstructor
@Component
public class SneakyThrowAndTransaction {

    private final OrderRepository orderRepository;

    public long count() {
        return orderRepository.count();
    }

    @Transactional
    public void insertAndThrowRuntimeException() {
        insert();
        throw new RuntimeException();
    }

    @Transactional
    public void insertAndThrowException() throws Exception {
        insert();
        throw new Exception();
    }

    @SneakyThrows
    @Transactional
    public void insertAndThrowSneakyException() {
        insert();
        throw new Exception();
    }

    public Order insert() {
        return orderRepository.save(new Order(null, LocalDateTime.now()));
    }

}

JUnitで挙動を確認する

非検査例外・検査例外

まずは普通の非検査例外と検査例外を投げるメソッドがそれぞれ正しくロールバックする・しないの挙動になるかをチェックする。

package com.example.sneakytran.component;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.lang.reflect.UndeclaredThrowableException;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@SpringBootTest
class SneakyThrowAndTransactionTest {

    @Autowired
    SneakyThrowAndTransaction sneakyThrowAndTransaction;

    @Test
    void insertAndThrowRuntimeException() {
        long before = sneakyThrowAndTransaction.count();

        assertThatThrownBy(() -> sneakyThrowAndTransaction.insertAndThrowRuntimeException())
                .isExactlyInstanceOf(RuntimeException.class);

        long after = sneakyThrowAndTransaction.count();

        assertThat(after).isEqualTo(before); // rolled back
    }

    @Test
    void insertAndThrowException() {
        long before = sneakyThrowAndTransaction.count();

        assertThatThrownBy(() -> sneakyThrowAndTransaction.insertAndThrowException())
                .isExactlyInstanceOf(Exception.class);

        long after = sneakyThrowAndTransaction.count();

        assertThat(after).isEqualTo(before + 1); // not rolled back
    }
}

検査例外 + @SneakyThrows

次に@SneakyThrowsのテストも追加する。

実際に何の例外が投げられるのか(isExactlyInstanceOfには何を指定するのか)は分からなかったので、まずは適当にRuntimeExceptionを指定して実行してみた結果、UndeclaredThrowableException.classが投げられるとわかった。

またロールバックされるかどうかも分からなかったので、ロールバックされる前提で、insert前後で件数が変わらないとしてみたら、テストが成功し、ロールバックされることがわかった。

    @Test
    void insertAndThrowSneakyException() {
        long before = sneakyThrowAndTransaction.count();

        assertThatThrownBy(() -> sneakyThrowAndTransaction.insertAndThrowSneakyException())
                .isExactlyInstanceOf(UndeclaredThrowableException.class);

        long after = sneakyThrowAndTransaction.count();

        assertThat(after).isEqualTo(before); // rolled back
    }

なぜ検査例外 + @SneakyThrowsでロールバックされるのか

UndeclaredThrowableException

先程の調査でわかった通り、 @SneakyThrowsをつけている場合に検査例外が発生するとjava.lang.reflect.UndeclaredThrowableExceptionが投げられる。

UndeclaredThrowableExceptionは以下の通り非検査例外なので、ロールバックされたようだ。

public class UndeclaredThrowableException extends RuntimeException {

sneakyThrow(Throwable t)

メソッドが検査例外を投げるとき、@SneakyThrowsのおかげでLombok.javasneakyThrow(Throwable t)が実行される。

	public static RuntimeException sneakyThrow(Throwable t) {
		if (t == null) throw new NullPointerException("t");
		return Lombok.<RuntimeException>sneakyThrow0(t);
	}
	
	@SuppressWarnings("unchecked")
	private static <T extends Throwable> T sneakyThrow0(Throwable t) throws T {
		throw (T)t;
	}

Lombok.<RuntimeException>の部分とthrow (T)t;の部分を見ればわかるとおり、検査例外を非検査例外でラップして投げているわけではなく、無理矢理キャストしているだけ。

そのため、コンパイル時にコンパイラを騙せても、実際に例外が発生したら検査例外がそのまま投げられてしまう。

ただJavaの言語仕様としてthrowsがないのに検査例外が投げられているというおかしな状態になっているので、UndeclaredThrowableExceptionに繋がっている。

@Transactional内で@SneakyThrowsは避けた方が良い

ユニットテスト等であれば問題ないが、プロダクションコードで@SneakyThrowsを使うのはやめた方がよさそう。

調査して「ロールバックされる」と結論は出たものの、以下の点からあまり使用すべきではないと思う。

  • UndeclaredThrowableExceptionが投げられるという異常な状態
  • コードを見てすぐにロールバックされるかどうか判断し難い

環境

  • Java 11
  • Spring Boot 2.5.1
  • Lombok 1.18.20