疑問
@Transactional
アノテーションをつけたメソッドを同じクラス内の別のメソッドから呼び出した時に、途中で例外が発生した場合ロールバックされるのか?されないのか?気になったので調べてみました。
結論から言えという方はこちら
@Transactional
アノテーションをつけたとしても、同じクラスの別のメソッドからの呼び出された場合、@Transactional
アノテーションがない場合と全く同じ挙動になる。
手を動かしてみる
まず、公式チュートリアルからコードを拝借してきましょう。
チュートリアル完了後のコードをVSCodeで開いてみます。
# コードをクローンしてくる
git clone https://github.com/spring-guides/gs-managing-transactions.git
# VSCodeでプロジェクトを開く
code ./gs-managing-transactions/complete
使用されているテーブルの定義はとてもシンプルで、以下のようになっています。
drop table BOOKINGS if exists;
create table BOOKINGS(ID serial, FIRST_NAME varchar(5) NOT NULL);
初めに定義されているサービスクラスは以下のようになっています。
これを改造して遊んでいきましょう。
package com.example.managingtransactions;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
public class BookingService {
private final static Logger logger = LoggerFactory.getLogger(BookingService.class);
private final JdbcTemplate jdbcTemplate;
public BookingService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional
public void book(String... persons) {
for (String person : persons) {
logger.info("Booking " + person + " in a seat...");
jdbcTemplate.update("insert into BOOKINGS(FIRST_NAME) values (?)", person);
}
}
public List<String> findAllBookings() {
return jdbcTemplate.query("select FIRST_NAME from BOOKINGS",
(rs, rowNum) -> rs.getString("FIRST_NAME"));
}
}
試しに一番簡単なものに書き直してみましょう。
insertBooking1()
メソッドで1つのFIRST_NAME
カラムの値が"one"
のレコードを登録して、findAllBookings()
メソッドで全てのレコードのFIRST_NAME
カラムの値をList<String>
の形式で取得しています。
@Component
public class BookingService {
private final JdbcTemplate jdbcTemplate;
public BookingService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional
public void insertBooking1(){
jdbcTemplate.update("insert into BOOKINGS(FIRST_NAME) values (?)", "one");
}
public List<String> findAllBookings() {
return jdbcTemplate.query("select FIRST_NAME from BOOKINGS",
(rs, rowNum) -> rs.getString("FIRST_NAME"));
}
}
これを呼び出すAppRunner
クラスも修正して以下のようにします。
package com.example.managingtransactions;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
@Component
class AppRunner implements CommandLineRunner {
private final BookingService bookingService;
public AppRunner(BookingService bookingService) {
this.bookingService = bookingService;
}
@Override
public void run(String... args) throws Exception {
bookingService.insertBooking1();
Assert.isTrue(bookingService.findAllBookings().size()==1);
}
}
では、ここで一度実行してみましょう。データベースの準備をしていませんが、心配しなくて大丈夫です。インメモリーデータベースのH2 Databaseがデフォルトで使用されるようになっています。以下のコマンドで実行してみてエラーが出なければ成功です。
./gradlew bootRun
どんなエラーが出るのか確認してみたければ、上記のコードのbookingService.findAllBookings().size()==1
の1
を他の数字に変えてみるといいでしょう。(あまりわかりやすいエラーではないですね。)
これでbookingService.insertBooking1()
でデータベースに1つのレコードが登録されることが確認できました。
ではBookingService
の他のメソッドからinsertBooking1
を呼び出してみたいと思います。
BookingService
クラスを以下のように変更してください。
insertMultipleBookings()
メソッドが2つのレコードを登録するようになっています。
@Component
public class BookingService {
private final JdbcTemplate jdbcTemplate;
public BookingService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional
public void insertBooking1(){
jdbcTemplate.update("insert into BOOKINGS(FIRST_NAME) values (?)", "one");
}
@Transactional
public void insertBooking2(){
jdbcTemplate.update("insert into BOOKINGS(FIRST_NAME) values (?)", "two");
}
/**
* 複数のBookingを登録する
*/
@Transactional
public void insertMultipleBookings(){
insertBooking1();
insertBooking2();
}
public List<String> findAllBookings() {
return jdbcTemplate.query("select FIRST_NAME from BOOKINGS",
(rs, rowNum) -> rs.getString("FIRST_NAME"));
}
}
続いてAppRunner
クラスを変更してinsertMultipleBookings()
メソッドを呼び出すようにしてみましょう。
@Component
class AppRunner implements CommandLineRunner {
private final BookingService bookingService;
public AppRunner(BookingService bookingService) {
this.bookingService = bookingService;
}
@Override
public void run(String... args) throws Exception {
bookingService.insertMultipleBookings();
Assert.isTrue(bookingService.findAllBookings().size()==2);
}
}
この状態で先ほどと同じく./gradlew bootRun
を実行すると成功することが確認できます。
ここでやっと本題のinsertMultipleBookings()
メソッドの途中でエラーが発生した時にロールバックされるか?されないのか?確かめていきたいと思います。
insertMultipleBookings()
メソッドを以下のように変更します。
@Transactional
public void insertMultipleBookings(){
insertBooking1();
insertBooking2();
throw new RuntimeException("ロールバックしてほしいエラー");
}
AppRunner
クラスのrun()
メソッドも以下のように修正します。
@Override
public void run(String... args) throws Exception {
try {
bookingService.insertMultipleBookings();
} catch (Exception e) {
// TODO: handle exception
}
Assert.isTrue(bookingService.findAllBookings().size()==0);
}
さて、ロールバックされているでしょうか。./gradlew bootRun
を実行してみましょう。どうやらロールバックされているようですね。
巷で「同じクラスのメソッドから呼び出されると@Transactional
をつけていてもロールバックされない」というような話を風の噂で聞いたような気がしなくもないのですが、そんなことはないようです。
ちなにみ、今のinsertBooking1()
メソッドinsertBooking2()
メソッド@Transactional
をつけてなくてもロールバックされます。
試してみましょう。
@Component
public class BookingService {
private final JdbcTemplate jdbcTemplate;
public BookingService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public void insertBooking1(){
jdbcTemplate.update("insert into BOOKINGS(FIRST_NAME) values (?)", "one");
}
public void insertBooking2(){
jdbcTemplate.update("insert into BOOKINGS(FIRST_NAME) values (?)", "two");
}
/**
* 複数のBookingを登録する
*/
@Transactional
public void insertMultipleBookings(){
insertBooking1();
insertBooking2();
throw new RuntimeException("ロールバックしてほしいエラー");
}
public List<String> findAllBookings() {
return jdbcTemplate.query("select FIRST_NAME from BOOKINGS",
(rs, rowNum) -> rs.getString("FIRST_NAME"));
}
}
まあ、これは以下のように書いた場合と全く同じなので、当然といっちゃ当然です。
@Component
public class BookingService {
private final JdbcTemplate jdbcTemplate;
public BookingService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
/**
* 複数のBookingを登録する
*/
@Transactional
public void insertMultipleBookings(){
jdbcTemplate.update("insert into BOOKINGS(FIRST_NAME) values (?)", "one");
jdbcTemplate.update("insert into BOOKINGS(FIRST_NAME) values (?)", "two");
throw new RuntimeException("ロールバックしてほしいエラー");
}
public List<String> findAllBookings() {
return jdbcTemplate.query("select FIRST_NAME from BOOKINGS",
(rs, rowNum) -> rs.getString("FIRST_NAME"));
}
}
Spring frameworkは@Transactional
がついたメソッドが実行された時に内部で何やかんやしてる(詳しくはこの辺を参照するとわかった気になれるかも)からです。要するに同じクラス内のメソッドから呼び出されたときは@Transactional
メソッドは何の意味もないということです。
これは寧ろ好ましいことだと思います。同じサービスクラス内で何度も呼び出されるようなメソッドは普通トランザクションを分離させないのではないでしょうか。他のSQLと同じタイミングでロールバックさせたいのではないでしょうか。もし、そうでないなら他のサービスに分けた方がいいのではと思います(強い主張)。そういった意味で、同じクラス内のメソッドから呼び出された際に普通のメソッドのように振る舞うのは良いことだと思えます。
混乱を避けるために呼び出される側のメソッド(今回の場合はinsertBooking1()
とinsertBooking2()
)からは@Transactional
アノテーションを外しましょう。
そうはいっても別トランザクションで実行したいんだということもあるかと思います。
その場合は上述のように別のクラスで実装してDIされたインスタンスを呼び出すしかないです。
試しにinsertBooking1()
メソッドに@Transactional(propagation=Propagation.REQUIRES_NEW)
をつけてみましょう。(insertBooking2()
メソッドにも@Transactional
を戻しています。)
このコードを読んだなら「insertMultipleBookings()
メソッドが実行されたら別トランザクションで実行されているinsertBooking1()
だけがコミットされてレコードが一件登録されるだろうな。」という気持ちになるかと思います(でも、そうはならなかった)。
@Component
public class BookingService {
private final JdbcTemplate jdbcTemplate;
public BookingService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional(propagation=Propagation.REQUIRES_NEW)
public void insertBooking1(){
jdbcTemplate.update("insert into BOOKINGS(FIRST_NAME) values (?)", "one");
}
@Transactional
public void insertBooking2(){
jdbcTemplate.update("insert into BOOKINGS(FIRST_NAME) values (?)", "two");
}
/**
* 複数のBookingを登録する
*/
@Transactional
public void insertMultipleBookings(){
insertBooking1();
insertBooking2();
throw new RuntimeException("ロールバックしてほしいエラー");
}
public List<String> findAllBookings() {
return jdbcTemplate.query("select FIRST_NAME from BOOKINGS",
(rs, rowNum) -> rs.getString("FIRST_NAME"));
}
}
AppRunner
の方も先程の予想に従って変更してみましょう。
@Component
class AppRunner implements CommandLineRunner {
private final BookingService bookingService;
public AppRunner(BookingService bookingService) {
this.bookingService = bookingService;
}
@Override
public void run(String... args) throws Exception {
try {
bookingService.insertMultipleBookings();
} catch (Exception e) {
// TODO: handle exception
}
Assert.isTrue(bookingService.findAllBookings().size()==1);
}
}
実行してみるとエラーになるのが確認できますね。
では、別のクラスにinsertBooking1()
メソッドとinsertBooking2()
メソッドを移動させてみましょう。新しくOtherService
クラスを作ります。
package com.example.managingtransactions;
import java.util.List;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.annotation.Propagation;
@Component
public class OtherService {
private final JdbcTemplate jdbcTemplate;
public OtherService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional(propagation=Propagation.REQUIRES_NEW)
public void insertBooking1(){
jdbcTemplate.update("insert into BOOKINGS(FIRST_NAME) values (?)", "one");
}
@Transactional
public void insertBooking2(){
jdbcTemplate.update("insert into BOOKINGS(FIRST_NAME) values (?)", "two");
}
}
BookingService
クラスからも新しく作ったOtherService
クラスを呼び出すように変更してみます。
package com.example.managingtransactions;
import java.util.List;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.annotation.Propagation;
@Component
public class BookingService {
private final JdbcTemplate jdbcTemplate;
private final OtherService otherService;
public BookingService(JdbcTemplate jdbcTemplate,OtherService otherService) {
this.jdbcTemplate = jdbcTemplate;
this.otherService = otherService;
}
/**
* 複数のBookingを登録する
*/
@Transactional
public void insertMultipleBookings(){
otherService.insertBooking1();
otherService.insertBooking2();
throw new RuntimeException("ロールバックしてほしいエラー");
}
public List<String> findAllBookings() {
return jdbcTemplate.query("select FIRST_NAME from BOOKINGS",
(rs, rowNum) -> rs.getString("FIRST_NAME"));
}
}
先程のinsertBooking1()
メソッドのみをコミットさせたいという予想を叶えるための修正なのでAppRunner
クラスには修正を加える必要はありません。では、改めて./gradlew bootRun
を実行してみましょう。
今度はエラーにないはずです。
まとめ
結局何が言いたかったかというと「@Transactional
アノテーションをつけたとしても、同じクラスの別のメソッドからの呼び出された場合、@Transactional
アノテーションがない場合と全く同じ挙動になる。」ということです。
まあ、この挙動はassertj
モードというのを使えば変えられるのですが、あまり使うことはないかと思うので詳細は割愛します(私も詳しくはしらないので)。
感想
実際に手を動かして実験するのは大切だな〜〜〜と染み染み。。。
「しらんけど、たぶん、こんな仕様やろ。」となっていた昨日より、だいぶ理解が深まった気がします。