1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Springで@Transactionalを付与したメソッドを別のメソッドから呼んでみる。ロールバックされるか?されないか?

Posted at

疑問

@Transactionalアノテーションをつけたメソッドを同じクラス内の別のメソッドから呼び出した時に、途中で例外が発生した場合ロールバックされるのか?されないのか?気になったので調べてみました。

結論から言えという方はこちら @Transactionalアノテーションをつけたとしても、同じクラスの別のメソッドからの呼び出された場合、@Transactionalアノテーションがない場合と全く同じ挙動になる。

手を動かしてみる

まず、公式チュートリアルからコードを拝借してきましょう。
チュートリアル完了後のコードをVSCodeで開いてみます。

# コードをクローンしてくる
git clone https://github.com/spring-guides/gs-managing-transactions.git
# VSCodeでプロジェクトを開く
code ./gs-managing-transactions/complete

使用されているテーブルの定義はとてもシンプルで、以下のようになっています。

src/main/resources/schema.sql
drop table BOOKINGS if exists;
create table BOOKINGS(ID serial, FIRST_NAME varchar(5) NOT NULL);

初めに定義されているサービスクラスは以下のようになっています。
これを改造して遊んでいきましょう。

src/main/java/com/example/managingtransactions/BookingService.java
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クラスも修正して以下のようにします。

src/main/java/com/example/managingtransactions/AppRunner.java
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()==11を他の数字に変えてみるといいでしょう。(あまりわかりやすいエラーではないですね。)

これで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クラスを作ります。

src/main/java/com/example/managingtransactions/OtherService.java
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モードというのを使えば変えられるのですが、あまり使うことはないかと思うので詳細は割愛します(私も詳しくはしらないので)。

感想

実際に手を動かして実験するのは大切だな〜〜〜と染み染み。。。
「しらんけど、たぶん、こんな仕様やろ。」となっていた昨日より、だいぶ理解が深まった気がします。

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?