@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.java
のsneakyThrow(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