CLOVER🍀

That was when it all began.

Spring FrameworkのTransactionSynchronizationを試す

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

Spring Frameworkで、トランザクションの完了時に処理を行うことができるTransactionSynchronizationというものがあります。

存在は知っていたものの、ちゃんと使ったことがなかったので今回試してみることにしました。

TransactionSynchronization

TransactionSynchronizationは、Spring Frameworkのドキュメントには登場しません。

Transaction Management :: Spring Framework

Javadocを見ることになります。

TransactionSynchronization (Spring Framework 6.0.9 API)

TransactionSynchronizationを使うと、トランザクションの完了時になんらかの処理を行うことができます。

Interface for transaction synchronization callbacks.

TransactionSynchronizationインターフェースを実装して、以下の4種類のメソッドをオーバーライドします。

  • beforeCommit(boolean readOnly) … トランザクションのコミット前に呼び出される
    • readOnlyは、読み取り専用のトランザクションかどうか
    • RuntimeExceptionをスローすると、呼び出し元に伝播する(TransactionExceptionのサブクラスをスローしてはいけない)
  • beforeCompletion … トランザクションのコミット/ロールバックの前に呼び出される
    • RuntimeExceptionをスローしても、呼び出し元に伝播しない(TransactionExceptionのサブクラスをスローしてはいけない)
  • afterCommit … トランザクションのコミット後に呼び出される
    • RuntimeExceptionをスローすると、呼び出し元に伝播する(TransactionExceptionのサブクラスをスローしてはいけない)
  • afterCompletion(int status) … トランザクションのコミット/ロールバックの後に呼び出される
    • statusは、定数定義(STATUS_COMMITTED、STATUS_ROLLED_BACK、STATUS_UNKNOWN
    • RuntimeExceptionをスローしても、呼び出し元に伝播しない(TransactionExceptionのサブクラスをスローしてはいけない)

TransactionSynchronizationは、TransactionSynchronizationManager#registerSynchronizationで登録して使います。

TransactionSynchronizationManager (Spring Framework 6.0.9 API)

TransactionSynchronizationは複数登録できます。その順序は、getOrderメソッドを実装していない場合は追加順になります。

説明はこんなところにして、実際に使ってみましょう。

環境

今回の環境は、こちら。

$ java --version
openjdk 17.0.7 2023-04-18
OpenJDK Runtime Environment (build 17.0.7+7-Ubuntu-0ubuntu122.04.2)
OpenJDK 64-Bit Server VM (build 17.0.7+7-Ubuntu-0ubuntu122.04.2, mixed mode, sharing)


$ mvn --version
Apache Maven 3.9.2 (c9616018c7a021c1c39be70fb2843d6f5f9b8a1c)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.7, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.15.0-73-generic", arch: "amd64", family: "unix"

データベースにはMySQLを用意しました。

 MySQL  localhost:3306 ssl  practice  SQL > select version();
+-----------+
| version() |
+-----------+
| 8.0.33    |
+-----------+
1 row in set (0.0370 sec)

MySQLは172.17.0.2で動作しているものとし、データベースpractice、アカウントはkazuhira/passwordで接続できるものと します。

Spring Bootプロジェクトを作成する

まずはSpring Bootプロジェクトを作成します。依存関係には、web、jdbc、mysqlを追加。

$ curl -s https://start.spring.io/starter.tgz \
  -d bootVersion=3.1.0 \
  -d javaVersion=17 \
  -d type=maven-project \
  -d name=transaction-synchronization-example \
  -d groupId=org.littlewings \
  -d artifactId=transaction-synchronization-example \
  -d version=0.0.1-SNAPSHOT \
  -d packageName=org.littlewings.spring.tx \
  -d dependencies=web,jdbc,mysql \
  -d baseDir=transaction-synchronization-example | tar zxvf -

ディレクトリ内に移動。

$ cd transaction-synchronization-example

生成されたMaven依存関係など。

        <properties>
                <java.version>17</java.version>
        </properties>
        <dependencies>
                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-jdbc</artifactId>
                </dependency>
                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-web</artifactId>
                </dependency>

                <dependency>
                        <groupId>com.mysql</groupId>
                        <artifactId>mysql-connector-j</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>

自動生成されたソースコードは削除しておきます。

$ rm src/main/java/org/littlewings/spring/tx/TransactionSynchronizationExampleApplication.java src/test/java/org/littlewings/spring/tx/TransactionSynchronizationExampleApplicationTests.java

テーブル定義。お題は書籍にしました。

src/main/resources/schema.sql

drop table if exists book;

create table book(
  isbn varchar(14),
  title varchar(100),
  price int,
  primary key(isbn)
);

このSQLは、アプリケーション起動時に毎回実行されるように設定。

src/main/resources/application.properties

spring.datasource.url=jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&connectionCollation=utf8mb4_0900_bin
spring.datasource.username=kazuhira
spring.datasource.password=password

spring.sql.init.mode=always

エンティティ相当のクラス。

src/main/java/org/littlewings/spring/tx/Book.java

package org.littlewings.spring.tx;

public class Book {
    String isbn;
    String title;
    Integer price;

    public static Book create(String isbn, String title, Integer price) {
        Book book = new Book();
        book.setIsbn(isbn);
        book.setTitle(title);
        book.setPrice(price);

        return book;
    }

    // getter/setterは省略
}

TransactionSynchronizationを使ったServiceクラス。

src/main/java/org/littlewings/spring/tx/BookService.java

package org.littlewings.spring.tx;

import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import java.util.List;
import java.util.Map;

@Transactional
@Service
public class BookService {
    NamedParameterJdbcTemplate jdbcTemplate;

    LoggingService loggingService;

    public BookService(NamedParameterJdbcTemplate jdbcTemplate, LoggingService loggingService) {
        this.jdbcTemplate = jdbcTemplate;
        this.loggingService = loggingService;
    }

    public Book findByIsbn(String isbn) {
        return jdbcTemplate.queryForObject("""
                        select
                          isbn, title, price
                        from
                          book
                        where
                          isbn = :isbn""",
                Map.of("isbn", isbn),
                new BeanPropertyRowMapper<>(Book.class));
    }

    public List<Book> findAll() {
        return jdbcTemplate.query("""
                        select
                          isbn, title, price
                        from
                          book
                        order by
                          price asc""",
                new BeanPropertyRowMapper<>(Book.class));
    }

    public void insertAfterCommit(Book book) {
        jdbcTemplate.update("""
                        insert into book(isbn, title, price)
                        values(:isbn, :title, :price)""",
                new BeanPropertySqlParameterSource(book));

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                loggingService.log("after commit");
            }
        });
    }

    public void insertAfterCommitRollback(Book book) {
        jdbcTemplate.update("""
                        insert into book(isbn, title, price)
                        values(:isbn, :title, :price)""",
                new BeanPropertySqlParameterSource(book));

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                loggingService.log("after commit");
            }
        });

        throw new RuntimeException("Oops!!");
    }

    public void insertAfterCommitThrowException(Book book) {
        jdbcTemplate.update("""
                        insert into book(isbn, title, price)
                        values(:isbn, :title, :price)""",
                new BeanPropertySqlParameterSource(book));

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                throw new RuntimeException("Oops!!, after commit");
            }
        });
    }

    public void insertAfterCompletion(Book book) {
        jdbcTemplate.update("""
                        insert into book(isbn, title, price)
                        values(:isbn, :title, :price)""",
                new BeanPropertySqlParameterSource(book));

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCompletion(int status) {
                loggingService.log("after completion, status = " + status);
            }
        });
    }

    public void insertAfterCompletionRollback(Book book) {
        jdbcTemplate.update("""
                        insert into book(isbn, title, price)
                        values(:isbn, :title, :price)""",
                new BeanPropertySqlParameterSource(book));

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCompletion(int status) {
                loggingService.log("after completion, status = " + status);
            }
        });

        throw new RuntimeException("Oops!!");
    }

    public void insertAfterCompletionThrowException(Book book) {
        jdbcTemplate.update("""
                        insert into book(isbn, title, price)
                        values(:isbn, :title, :price)""",
                new BeanPropertySqlParameterSource(book));

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCompletion(int status) {
                throw new RuntimeException("Oops!!, after completion, status = " + status);
            }
        });
    }
}

今回は、クラス自体に@Transactionalアノテーションを付与しています。

@Transactional
@Service
public class BookService {

TransactionSynchronizationは、こんな感じでTransactionSynchronizationクラスを継承したクラスを作成し、処理を行いたいタイミングに
応じたメソッドをオーバーライドします。そして、TransactionSynchronizationManager#registerSynchronizationで登録します。

    public void insertAfterCommit(Book book) {
        jdbcTemplate.update("""
                        insert into book(isbn, title, price)
                        values(:isbn, :title, :price)""",
                new BeanPropertySqlParameterSource(book));

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                loggingService.log("after commit");
            }
        });
    }

TransactionSynchronizationの部分は、また後で説明します。

メソッド内で呼び出しているServiceクラスは、こんな感じのものです。

src/main/java/org/littlewings/spring/tx/LoggingService.java

package org.littlewings.spring.tx;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

@Service
public class LoggingService {
    Logger logger = LoggerFactory.getLogger(LoggingService.class);
    public void log(String message) {
        logger.info(message);
    }
}

RestController。

src/main/java/org/littlewings/spring/tx/BookController.java

package org.littlewings.spring.tx;

import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.util.UriComponentsBuilder;

import java.util.List;

@RestController
@RequestMapping("books")
public class BookController {
    BookService bookService;

    public BookController(BookService bookService) {
        this.bookService = bookService;
    }

    @GetMapping(value = "{isbn}", produces = MediaType.APPLICATION_JSON_VALUE)
    public Book findByIsbn(@PathVariable String isbn) {
        return bookService.findByIsbn(isbn);
    }

    @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)
    public List<Book> findAll() {
        return bookService.findAll();
    }

    @PostMapping(value = "after-commit", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Void> registerAfterCommit(@RequestBody Book book, UriComponentsBuilder uriComponentsBuilder) {
        bookService.insertAfterCommit(book);

        return ResponseEntity.created(uriComponentsBuilder.path("books/{isbn}").build(book.getIsbn())).build();
    }

    @PostMapping(value = "after-commit-rollback", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Void> registerAfterCommitRollback(@RequestBody Book book, UriComponentsBuilder uriComponentsBuilder) {
        bookService.insertAfterCommitRollback(book);

        return ResponseEntity.created(uriComponentsBuilder.path("books/{isbn}").build(book.getIsbn())).build();
    }

    @PostMapping(value = "after-commit-throw", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Void> registerAfterCommitThrowException(@RequestBody Book book, UriComponentsBuilder uriComponentsBuilder) {
        bookService.insertAfterCommitThrowException(book);

        return ResponseEntity.created(uriComponentsBuilder.path("books/{isbn}").build(book.getIsbn())).build();
    }

    @PostMapping(value = "after-completion", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Void> registerAfterCompletion(@RequestBody Book book, UriComponentsBuilder uriComponentsBuilder) {
        bookService.insertAfterCompletion(book);

        return ResponseEntity.created(uriComponentsBuilder.path("books/{isbn}").build(book.getIsbn())).build();
    }

    @PostMapping(value = "after-completion-rollback", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Void> registerAfterCompletionRollback(@RequestBody Book book, UriComponentsBuilder uriComponentsBuilder) {
        bookService.insertAfterCompletionRollback(book);

        return ResponseEntity.created(uriComponentsBuilder.path("books/{isbn}").build(book.getIsbn())).build();
    }

    @PostMapping(value = "after-completion-throw", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Void> registerAfterCompletionThrowException(@RequestBody Book book, UriComponentsBuilder uriComponentsBuilder) {
        bookService.insertAfterCompletionThrowException(book);

        return ResponseEntity.created(uriComponentsBuilder.path("books/{isbn}").build(book.getIsbn())).build();
    }
}

mainメソッドを定義したクラス。

src/main/java/org/littlewings/spring/tx/App.java

package org.littlewings.spring.tx;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class App {
    public static void main(String... args) {
        SpringApplication.run(App.class, args);
    }
}

ここまでで、準備は完了です。

動かしてみる

では、アプリケーションをパッケージングして起動します。

$ mvn package
$ java -jar target/transaction-synchronization-example-0.0.1-SNAPSHOT.jar

データを登録するのは、以下の4つのエンドポイントがありました。

    @PostMapping(value = "after-commit", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Void> registerAfterCommit(@RequestBody Book book, UriComponentsBuilder uriComponentsBuilder) {
        bookService.insertAfterCommit(book);

        return ResponseEntity.created(uriComponentsBuilder.path("books/{isbn}").build(book.getIsbn())).build();
    }

    @PostMapping(value = "after-commit-rollback", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Void> registerAfterCommitRollback(@RequestBody Book book, UriComponentsBuilder uriComponentsBuilder) {
        bookService.insertAfterCommitRollback(book);

        return ResponseEntity.created(uriComponentsBuilder.path("books/{isbn}").build(book.getIsbn())).build();
    }

    @PostMapping(value = "after-commit-throw", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Void> registerAfterCommitThrowException(@RequestBody Book book, UriComponentsBuilder uriComponentsBuilder) {
        bookService.insertAfterCommitThrowException(book);

        return ResponseEntity.created(uriComponentsBuilder.path("books/{isbn}").build(book.getIsbn())).build();
    }

    @PostMapping(value = "after-completion", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Void> registerAfterCompletion(@RequestBody Book book, UriComponentsBuilder uriComponentsBuilder) {
        bookService.insertAfterCompletion(book);

        return ResponseEntity.created(uriComponentsBuilder.path("books/{isbn}").build(book.getIsbn())).build();
    }

    @PostMapping(value = "after-completion-rollback", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Void> registerAfterCompletionRollback(@RequestBody Book book, UriComponentsBuilder uriComponentsBuilder) {
        bookService.insertAfterCompletionRollback(book);

        return ResponseEntity.created(uriComponentsBuilder.path("books/{isbn}").build(book.getIsbn())).build();
    }

    @PostMapping(value = "after-completion-throw", consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Void> registerAfterCompletionThrowException(@RequestBody Book book, UriComponentsBuilder uriComponentsBuilder) {
        bookService.insertAfterCompletionThrowException(book);

        return ResponseEntity.created(uriComponentsBuilder.path("books/{isbn}").build(book.getIsbn())).build();
    }

これらを、それぞれ対応する(TransactionSynchronizationを使った)Serviceクラスのメソッドと合わせて見ていきます。

まずはトランザクションのコミット後に動作するTransactionSynchronization#afterCommitから。

    public void insertAfterCommit(Book book) {
        jdbcTemplate.update("""
                        insert into book(isbn, title, price)
                        values(:isbn, :title, :price)""",
                new BeanPropertySqlParameterSource(book));

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                loggingService.log("after commit");
            }
        });
    }

リクエストを送信。

$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/books/after-commit -d '{"isbn": "978-4621303252", "title": "Effective Java 第3版", "price": 4400}'
HTTP/1.1 201
Location: http://localhost:8080/books/978-4621303252
Content-Length: 0
Date: Sun, 04 Jun 2023 13:13:46 GMT

アプリケーション側には、ログが出力されます。これはコミット後に動作したことになります。

2023-06-04T22:13:46.844+09:00  INFO 22000 --- [nio-8080-exec-1] o.littlewings.spring.tx.LoggingService   : after commit

データも入りました。

$ curl -s localhost:8080/books | jq
[
  {
    "isbn": "978-4621303252",
    "title": "Effective Java 第3版",
    "price": 4400
  }
]

次は、TransactionSynchronization#afterCommitを使いつつ、例外をスローしてロールバックさせてみましょう。

    public void insertAfterCommitRollback(Book book) {
        jdbcTemplate.update("""
                        insert into book(isbn, title, price)
                        values(:isbn, :title, :price)""",
                new BeanPropertySqlParameterSource(book));

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                loggingService.log("after commit");
            }
        });

        throw new RuntimeException("Oops!!");
    }

リクエストを送信。

$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/books/after-commit-rollback -d '{"isbn": "978-4297126858", "title": "プロになるJava―仕事で必要なプログラミングの知識がゼロから身につく最高の指南書", "price": 3278}'
HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 04 Jun 2023 13:14:10 GMT
Connection: close

{"timestamp":"2023-06-04T13:14:10.905+00:00","status":500,"error":"Internal Server Error","path":"/books/after-commit-rollback"}

エラーになりました。

スタックトレース。

2023-06-04T22:14:10.893+09:00 ERROR 22000 --- [nio-8080-exec-3] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.RuntimeException: Oops!!] with root cause

java.lang.RuntimeException: Oops!!
        at org.littlewings.spring.tx.BookService.insertAfterCommitRollback(BookService.java:76) ~[classes!/:0.0.1-SNAPSHOT]
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
        at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
        at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:343) ~[spring-aop-6.0.9.jar!/:6.0.9]
        at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196) ~[spring-aop-6.0.9.jar!/:6.0.9]
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) ~[spring-aop-6.0.9.jar!/:6.0.9]
        at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:750) ~[spring-aop-6.0.9.jar!/:6.0.9]
        at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:123) ~[spring-tx-6.0.9.jar!/:6.0.9]
        at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:391) ~[spring-tx-6.0.9.jar!/:6.0.9]
        at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) ~[spring-tx-6.0.9.jar!/:6.0.9]
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.0.9.jar!/:6.0.9]
        at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:750) ~[spring-aop-6.0.9.jar!/:6.0.9]
        at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:702) ~[spring-aop-6.0.9.jar!/:6.0.9]
        at org.littlewings.spring.tx.BookService$$SpringCGLIB$$0.insertAfterCommitRollback(<generated>) ~[classes!/:0.0.1-SNAPSHOT]
        at org.littlewings.spring.tx.BookController.registerAfterCommitRollback(BookController.java:38) ~[classes!/:0.0.1-SNAPSHOT]
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
        at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
        at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:207) ~[spring-web-6.0.9.jar!/:6.0.9]
        at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:152) ~[spring-web-6.0.9.jar!/:6.0.9]
        at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118) ~[spring-webmvc-6.0.9.jar!/:6.0.9]
        at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:884) ~[spring-webmvc-6.0.9.jar!/:6.0.9]
        at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:797) ~[spring-webmvc-6.0.9.jar!/:6.0.9]
        at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) ~[spring-webmvc-6.0.9.jar!/:6.0.9]
        at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1081) ~[spring-webmvc-6.0.9.jar!/:6.0.9]
        at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:974) ~[spring-webmvc-6.0.9.jar!/:6.0.9]

〜省略〜

ロールバックされているので、以下の箇所に相当するログは出力されていません。

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                loggingService.log("after commit");
            }
        });

データも増えていませんね。

$ curl -s localhost:8080/books | jq
[
  {
    "isbn": "978-4621303252",
    "title": "Effective Java 第3版",
    "price": 4400
  }
]

TransactionSynchronization#afterCommitから例外を投げてみます。

    public void insertAfterCommitThrowException(Book book) {
        jdbcTemplate.update("""
                        insert into book(isbn, title, price)
                        values(:isbn, :title, :price)""",
                new BeanPropertySqlParameterSource(book));

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                throw new RuntimeException("Oops!!, after commit");
            }
        });
    }

リクエスト送信。

$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/books/after-commit-throw -d '{"isbn": "978-4798161488", "title": "MySQL徹底入門 第4版 MySQL 8.0対応", "price": 4180}'
HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 04 Jun 2023 13:16:45 GMT
Connection: close

{"timestamp":"2023-06-04T13:16:45.006+00:00","status":500,"error":"Internal Server Error","path":"/books/after-commit-throw"}

エラーになりました。

この時のスタックトレース。

2023-06-04T22:16:45.003+09:00 ERROR 22000 --- [nio-8080-exec-5] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.RuntimeException: Oops!!, after commit] with root cause

java.lang.RuntimeException: Oops!!, after commit
        at org.littlewings.spring.tx.BookService$3.afterCommit(BookService.java:88) ~[classes!/:0.0.1-SNAPSHOT]
        at org.springframework.transaction.support.TransactionSynchronizationUtils.invokeAfterCommit(TransactionSynchronizationUtils.java:135) ~[spring-tx-6.0.9.jar!/:6.0.9]
        at org.springframework.transaction.support.TransactionSynchronizationUtils.triggerAfterCommit(TransactionSynchronizationUtils.java:123) ~[spring-tx-6.0.9.jar!/:6.0.9]
        at org.springframework.transaction.support.AbstractPlatformTransactionManager.triggerAfterCommit(AbstractPlatformTransactionManager.java:936) ~[spring-tx-6.0.9.jar!/:6.0.9]
        at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:782) ~[spring-tx-6.0.9.jar!/:6.0.9]
        at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:711) ~[spring-tx-6.0.9.jar!/:6.0.9]
        at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:660) ~[spring-tx-6.0.9.jar!/:6.0.9]
        at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:410) ~[spring-tx-6.0.9.jar!/:6.0.9]
        at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) ~[spring-tx-6.0.9.jar!/:6.0.9]
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.0.9.jar!/:6.0.9]
        at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:750) ~[spring-aop-6.0.9.jar!/:6.0.9]
        at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:702) ~[spring-aop-6.0.9.jar!/:6.0.9]
        at org.littlewings.spring.tx.BookService$$SpringCGLIB$$0.insertAfterCommitThrowException(<generated>) ~[classes!/:0.0.1-SNAPSHOT]
        at org.littlewings.spring.tx.BookController.registerAfterCommitThrowException(BookController.java:45) ~[classes!/:0.0.1-SNAPSHOT]
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
        at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
        at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:207) ~[spring-web-6.0.9.jar!/:6.0.9]
        at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:152) ~[spring-web-6.0.9.jar!/:6.0.9]
        at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118) ~[spring-webmvc-6.0.9.jar!/:6.0.9]
        at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:884) ~[spring-webmvc-6.0.9.jar!/:6.0.9]
        at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:797) ~[spring-webmvc-6.0.9.jar!/:6.0.9]
        at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) ~[spring-webmvc-6.0.9.jar!/:6.0.9]
        at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1081) ~[spring-webmvc-6.0.9.jar!/:6.0.9]
        at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:974) ~[spring-webmvc-6.0.9.jar!/:6.0.9]

〜省略〜

今回はTransactionSynchronization#afterCommit内から例外をスローしたわけですが。

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                throw new RuntimeException("Oops!!, after commit");
            }
        });

スタックトレースを見ると、このトランザクション境界のメソッドから例外がスローされたことになっていますね。

        at org.littlewings.spring.tx.BookService$$SpringCGLIB$$0.insertAfterCommitThrowException(<generated>) ~[classes!/:0.0.1-SNAPSHOT]
        at org.littlewings.spring.tx.BookController.registerAfterCommitThrowException(BookController.java:38) ~[classes!/:0.0.1-SNAPSHOT]

これが、呼び出し元に伝播するということですね。

一方で、この処理はコミット後に動作しているので、例外を投げてもロールバックされません。

$ curl -s localhost:8080/books | jq
[
  {
    "isbn": "978-4798161488",
    "title": "MySQL徹底入門 第4版 MySQL 8.0対応",
    "price": 4180
  },
  {
    "isbn": "978-4621303252",
    "title": "Effective Java 第3版",
    "price": 4400
  }
]

トランザクションの完了後に動作するTransactionSynchronization#afterCompletion。

    public void insertAfterCompletion(Book book) {
        jdbcTemplate.update("""
                        insert into book(isbn, title, price)
                        values(:isbn, :title, :price)""",
                new BeanPropertySqlParameterSource(book));

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCompletion(int status) {
                loggingService.log("after completion, status = " + status);
            }
        });
    }

リクエストを送信。

$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/books/after-completion -d '{"isbn": "978-4297124298", "title": "Spring Framework超入門 〜やさしくわかるWebアプリ開発", "price": 3058}'
HTTP/1.1 201
Location: http://localhost:8080/books/978-4297124298
Content-Length: 0
Date: Sun, 04 Jun 2023 13:19:16 GMT

こちらも、ログが出力されました。

2023-06-04T22:19:16.678+09:00  INFO 22000 --- [nio-8080-exec-7] o.littlewings.spring.tx.LoggingService   : after completion, status = 0

0というのは、TransactionSynchronization#STATUS_COMMITTEDの値ですね。

データも追加されました。

$ curl -s localhost:8080/books | jq
[
  {
    "isbn": "978-4297124298",
    "title": "Spring Framework超入門 〜やさしくわかるWebアプリ開発",
    "price": 3058
  },
  {
    "isbn": "978-4798161488",
    "title": "MySQL徹底入門 第4版 MySQL 8.0対応",
    "price": 4180
  },
  {
    "isbn": "978-4621303252",
    "title": "Effective Java 第3版",
    "price": 4400
  }
]

TransactionSynchronization#afterCompletionを使いつつ、例外をスローしてみます。

    public void insertAfterCompletionRollback(Book book) {
        jdbcTemplate.update("""
                        insert into book(isbn, title, price)
                        values(:isbn, :title, :price)""",
                new BeanPropertySqlParameterSource(book));

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCompletion(int status) {
                loggingService.log("after completion, status = " + status);
            }
        });

        throw new RuntimeException("Oops!!");
    }

リクエストを送信。

$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/books/after-completion-rollback -d '{"isbn": "978-4774189093", "title": "Java本格入門 ~モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", "price": 3278}'
HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Sun, 04 Jun 2023 13:21:11 GMT
Connection: close

{"timestamp":"2023-06-04T13:21:11.856+00:00","status":500,"error":"Internal Server Error","path":"/books/after-completion-rollback"}

エラーになりました。

この時のログとスタックトレース。

2023-06-04T22:21:11.853+09:00  INFO 22000 --- [io-8080-exec-10] o.littlewings.spring.tx.LoggingService   : after completion, status = 1
2023-06-04T22:21:11.854+09:00 ERROR 22000 --- [io-8080-exec-10] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.RuntimeException: Oops!!] with root cause

java.lang.RuntimeException: Oops!!
        at org.littlewings.spring.tx.BookService.insertAfterCompletionRollback(BookService.java:120) ~[classes!/:0.0.1-SNAPSHOT]
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
        at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
        at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:343) ~[spring-aop-6.0.9.jar!/:6.0.9]
        at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196) ~[spring-aop-6.0.9.jar!/:6.0.9]
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) ~[spring-aop-6.0.9.jar!/:6.0.9]
        at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:750) ~[spring-aop-6.0.9.jar!/:6.0.9]
        at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:123) ~[spring-tx-6.0.9.jar!/:6.0.9]
        at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:391) ~[spring-tx-6.0.9.jar!/:6.0.9]
        at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:119) ~[spring-tx-6.0.9.jar!/:6.0.9]
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184) ~[spring-aop-6.0.9.jar!/:6.0.9]
        at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:750) ~[spring-aop-6.0.9.jar!/:6.0.9]
        at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:702) ~[spring-aop-6.0.9.jar!/:6.0.9]
        at org.littlewings.spring.tx.BookService$$SpringCGLIB$$0.insertAfterCompletionRollback(<generated>) ~[classes!/:0.0.1-SNAPSHOT]
        at org.littlewings.spring.tx.BookController.registerAfterCompletionRollback(BookController.java:59) ~[classes!/:0.0.1-SNAPSHOT]
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na]
        at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na]
        at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na]
        at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na]
        at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:207) ~[spring-web-6.0.9.jar!/:6.0.9]
        at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:152) ~[spring-web-6.0.9.jar!/:6.0.9]
        at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118) ~[spring-webmvc-6.0.9.jar!/:6.0.9]
        at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:884) ~[spring-webmvc-6.0.9.jar!/:6.0.9]
        at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:797) ~[spring-webmvc-6.0.9.jar!/:6.0.9]
        at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) ~[spring-webmvc-6.0.9.jar!/:6.0.9]
        at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1081) ~[spring-webmvc-6.0.9.jar!/:6.0.9]
        at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:974) ~[spring-webmvc-6.0.9.jar!/:6.0.9]

〜省略〜

ポイントは、TransactionSynchronization#afterCompletionはロールバック時も呼び出されるので、以下のログが出力されていることです。

2023-06-04T22:21:11.853+09:00  INFO 22000 --- [io-8080-exec-10] o.littlewings.spring.tx.LoggingService   : after completion, status = 1

0というのは、TransactionSynchronization#STATUS_ROLLED_BACKの値ですね。

ロールバックされているので、データは増えていません。

$ curl -s localhost:8080/books | jq
[
  {
    "isbn": "978-4297124298",
    "title": "Spring Framework超入門 〜やさしくわかるWebアプリ開発",
    "price": 3058
  },
  {
    "isbn": "978-4798161488",
    "title": "MySQL徹底入門 第4版 MySQL 8.0対応",
    "price": 4180
  },
  {
    "isbn": "978-4621303252",
    "title": "Effective Java 第3版",
    "price": 4400
  }
]

最後は、TransactionSynchronization#afterCompletion内から例外をスローしてみます。

    public void insertAfterCompletionThrowException(Book book) {
        jdbcTemplate.update("""
                        insert into book(isbn, title, price)
                        values(:isbn, :title, :price)""",
                new BeanPropertySqlParameterSource(book));

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCompletion(int status) {
                throw new RuntimeException("Oops!!, after completion, status = " + status);
            }
        });
    }

リクエストを送信。

$ curl -i -XPOST -H 'Content-Type: application/json' localhost:8080/books/after-completion-throw -d '{"isbn": "978-4297131425", "title": "実践Redis入門 技術の仕組みから現場の活用まで", "price": 4180}'
HTTP/1.1 201
Location: http://localhost:8080/books/978-4297131425
Content-Length: 0
Date: Sun, 04 Jun 2023 13:30:19 GMT

ふつうに処理が終了しました。

この時、アプリケーション側にはスタックトレースもなにも出力されていませんでした。

データはしっかり増えています。

$ curl -s localhost:8080/books | jq
[
  {
    "isbn": "978-4297124298",
    "title": "Spring Framework超入門 〜やさしくわかるWebアプリ開発",
    "price": 3058
  },
  {
    "isbn": "978-4297131425",
    "title": "実践Redis入門 技術の仕組みから現場の活用まで",
    "price": 4180
  },
  {
    "isbn": "978-4798161488",
    "title": "MySQL徹底入門 第4版 MySQL 8.0対応",
    "price": 4180
  },
  {
    "isbn": "978-4621303252",
    "title": "Effective Java 第3版",
    "price": 4400
  }
]

これで、TransactionSynchronization#afterCompletion内で例外をスローしても呼び出し元に影響しない(伝播しない)ことが
確認できました。

TestTransactionを使ってテストで確認する

ところで、TransactionSynchronizationは時には便利な機能ですが、TransactionSynchronizationManagerに登録した
TransactionSynchronizationが動作していることを確認するにはどうしたらいいのでしょうか?

TestTransactionを使うのが良さそうです。

Transaction Management / Programmatic Transaction Management

TestTransactionを使うと、テストで使われているトランザクションのコミット、ロールバックをメソッド呼び出しで制御することが
できます。

For example, you can use TestTransaction within test methods, before methods, and after methods to start or end the current test-managed transaction or to configure the current test-managed transaction for rollback or commit.

TestTransaction (Spring Framework 6.0.9 API)

作成したテストは、こんな感じです。

src/test/java/org/littlewings/spring/tx/BookServiceTest.java

package org.littlewings.spring.tx;

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.boot.test.mock.mockito.MockBean;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.transaction.TestTransaction;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;

import java.util.Collections;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.*;

@DirtiesContext
@SpringBootTest
class BookServiceTest {
    @Autowired
    BookService bookService;

    @MockBean
    LoggingService loggingService;

    @Autowired
    NamedParameterJdbcTemplate jdbcTemplate;

    @BeforeEach
    void setUp() {
        reset(loggingService);

        jdbcTemplate.update("truncate table book", Collections.emptyMap());
    }

    @Transactional
    @Test
    void query() {
        bookService.insertAfterCommit(Book.create("978-4621303252", "Effective Java 第3版", 4400));
        bookService.insertAfterCommit(Book.create("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", 4180));

        Book javaBook = bookService.findByIsbn("978-4621303252");
        assertThat(javaBook.getIsbn()).isEqualTo("978-4621303252");
        assertThat(javaBook.getTitle()).isEqualTo("Effective Java 第3版");
        assertThat(javaBook.getPrice()).isEqualTo(4400);

        List<Book> books = bookService.findAll();
        assertThat(books).hasSize(2);
        assertThat(books.get(0).getTitle()).isEqualTo("MySQL徹底入門 第4版 MySQL 8.0対応");
        assertThat(books.get(1).getTitle()).isEqualTo("Effective Java 第3版");
    }

    @Transactional
    @Test
    void insertAfterCommitWithCommit() {
        doNothing().when(loggingService).log("after commit");

        bookService.insertAfterCommit(Book.create("978-4621303252", "Effective Java 第3版", 4400));

        TestTransaction.flagForCommit();
        TestTransaction.end();

        verify(loggingService, times(1))
                .log("after commit");
    }

    @Transactional
    @Test
    void insertAfterCommitWithRollback() {
        bookService.insertAfterCommit(Book.create("978-4621303252", "Effective Java 第3版", 4400));

        TestTransaction.flagForRollback();
        TestTransaction.end();

        verify(loggingService, never())
                .log(anyString());
    }

    @Transactional
    @Test
    void insertAfterCommitWithThrowExceptionRollback() {
        assertThatThrownBy(() ->
                bookService.insertAfterCommitRollback(Book.create("978-4297126858", "プロになるJava―仕事で必要なプログラミングの知識がゼロから身につく最高の指南書", 3278)))
                .isExactlyInstanceOf(RuntimeException.class)
                .hasMessage("Oops!!");

        verify(loggingService, never())
                .log(anyString());
    }

    @Transactional
    @Test
    void insertAfterCommitThrowExceptionWithCommit() {
        bookService.insertAfterCommitThrowException(Book.create("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", 4180));

        TestTransaction.flagForCommit();
        assertThatThrownBy(() -> TestTransaction.end())
                .isExactlyInstanceOf(RuntimeException.class)
                .hasMessage("Oops!!, after commit");

        verify(loggingService, never())
                .log(anyString());
    }

    @Transactional
    @Test
    void insertAfterCommitThrowExceptionWithRollback() {
        bookService.insertAfterCommitThrowException(Book.create("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", 4180));

        TestTransaction.flagForRollback();
        TestTransaction.end();
    }

    @Transactional
    @Test
    void insertAfterCompletionWithCommit() {
        doNothing().when(loggingService).log("after completion, status = 0");

        bookService.insertAfterCompletion(Book.create("978-4297124298", "Spring Framework超入門 〜やさしくわかるWebアプリ開発", 3058));

        TestTransaction.flagForCommit();
        TestTransaction.end();

        verify(loggingService, times(1))
                .log("after completion, status = 0");
    }

    @Transactional
    @Test
    void insertAfterCompletionWithRollback() {
        doNothing().when(loggingService).log("after completion, status = 1");

        bookService.insertAfterCompletion(Book.create("978-4774189093", "Java本格入門 ~モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278));

        TestTransaction.flagForRollback();
        TestTransaction.end();

        verify(loggingService, times(1))
                .log("after completion, status = 1");
    }

    @Transactional
      @Test
      void insertAfterCompletionThrowExceptionRollback() {
          doNothing().when(loggingService).log("after completion, status = 1");

          assertThatThrownBy(() ->
                  bookService.insertAfterCompletionRollback(Book.create("978-4774189093", "Java本格入門 ~モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278)));

          verify(loggingService, times(1))
                  .log("after completion, status = 1");
      }

    @Transactional
    @Test
    void insertAfterCompletionThrowExceptionWithCommit() {
        bookService.insertAfterCompletionThrowException(Book.create("978-4297131425", "実践Redis入門 技術の仕組みから現場の活用まで", 4180));

        TestTransaction.flagForCommit();
        TestTransaction.end();
    }

    @Test
    void transactionCompletionStatus() {
        assertThat(TransactionSynchronization.STATUS_COMMITTED).isEqualTo(0);
        assertThat(TransactionSynchronization.STATUS_ROLLED_BACK).isEqualTo(1);
        assertThat(TransactionSynchronization.STATUS_UNKNOWN).isEqualTo(2);
    }
}

少し、ピックアップして見てみましょう。

TransactionSynchronization#afterCommitでコミットさせる場合。TestTransaction#flagForCommitを呼び出してトランザクションの
コミット用のフラグを立てて、TestTransaction#endで確定します。

    @Transactional
    @Test
    void insertAfterCommitWithCommit() {
        doNothing().when(loggingService).log("after commit");

        bookService.insertAfterCommit(Book.create("978-4621303252", "Effective Java 第3版", 4400));

        TestTransaction.flagForCommit();
        TestTransaction.end();

        verify(loggingService, times(1))
                .log("after commit");
    }

実際に呼び出されているかどうかの確認は、モックを使いました。

なお、「コミット用のフラグを立てて」と言っているように、このテストで実行したトランザクションはコミットされます。
@Transactionalを付けているからといってロールバックされなくなるので、その点には注意が必要です。

なお、TestTransactionを使うにはトランザクションを開始しておく必要があるようです。このテストから@Transactionalを削除すると
テストが実行できなくなります。

ロールバックさせるには、TestTransaction#flagForRollbackを呼び出してロールバック用のフラグを立てます。

    @Transactional
    @Test
    void insertAfterCommitWithRollback() {
        bookService.insertAfterCommit(Book.create("978-4621303252", "Effective Java 第3版", 4400));

        TestTransaction.flagForRollback();
        TestTransaction.end();

        verify(loggingService, never())
                .log(anyString());
    }

TestTransaction#endで確定させても、TransactionSynchronization#afterCommitの処理が呼び出されないことが確認できます。

TransactionSynchronization#afterCommit内で例外をスローした場合は、TestTransaction#endを呼び出した時に例外がスローされます。

    @Transactional
    @Test
    void insertAfterCommitThrowExceptionWithCommit() {
        bookService.insertAfterCommitThrowException(Book.create("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", 4180));

        TestTransaction.flagForCommit();
        assertThatThrownBy(() -> TestTransaction.end())
                .isExactlyInstanceOf(RuntimeException.class)
                .hasMessage("Oops!!, after commit");

        verify(loggingService, never())
                .log(anyString());
    }

TransactionSynchronization#afterCompletionの場合は、コミットしてもロールバックしても処理が呼び出されます。

    @Transactional
    @Test
    void insertAfterCompletionWithCommit() {
        doNothing().when(loggingService).log("after completion, status = 0");

        bookService.insertAfterCompletion(Book.create("978-4297124298", "Spring Framework超入門 〜やさしくわかるWebアプリ開発", 3058));

        TestTransaction.flagForCommit();
        TestTransaction.end();

        verify(loggingService, times(1))
                .log("after completion, status = 0");
    }

    @Transactional
    @Test
    void insertAfterCompletionWithRollback() {
        doNothing().when(loggingService).log("after completion, status = 1");

        bookService.insertAfterCompletion(Book.create("978-4774189093", "Java本格入門 ~モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278));

        TestTransaction.flagForRollback();
        TestTransaction.end();

        verify(loggingService, times(1))
                .log("after completion, status = 1");
    }

ステータスとして渡ってくる値が変わりますね。

そして、TransactionSynchronization#afterCompletion内で例外をスローしても、コミット時に例外は呼び出し元に伝播しません。

    @Transactional
    @Test
    void insertAfterCompletionThrowExceptionWithCommit() {
        bookService.insertAfterCompletionThrowException(Book.create("978-4297131425", "実践Redis入門 技術の仕組みから現場の活用まで", 4180));

        TestTransaction.flagForCommit();
        TestTransaction.end();
    }

やや脱線していますが、TransactionSynchronizationのステータス値の確認も。

    @Test
    void transactionCompletionStatus() {
        assertThat(TransactionSynchronization.STATUS_COMMITTED).isEqualTo(0);
        assertThat(TransactionSynchronization.STATUS_ROLLED_BACK).isEqualTo(1);
        assertThat(TransactionSynchronization.STATUS_UNKNOWN).isEqualTo(2);
    }

実装を見る

先程の動作確認時のスタックトレースを見るとわかりますが、TransactionSynchronizationを呼び出している処理はこのあたりですね。

https://github.com/spring-projects/spring-framework/blob/v6.0.9/spring-tx/src/main/java/org/springframework/transaction/support/AbstractPlatformTransactionManager.java#L720-L786

実際に呼び出しを行うのはTransactionSynchronizationUtilsですね。

https://github.com/spring-projects/spring-framework/blob/v6.0.9/spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronizationUtils.java

また、TransactionSynchronization#beforeCompletionやTransactionSynchronization#afterCompletionで例外がスローされても
呼び出し元に伝播しないのは、ふつうにtry〜catchしているからですね。

https://github.com/spring-projects/spring-framework/blob/v6.0.9/spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronizationUtils.java#L105-L114

https://github.com/spring-projects/spring-framework/blob/v6.0.9/spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronizationUtils.java#L166-L179

オマケ:RestControllerのテストを書く

最後に、RestControllerのテストも書いておいたので載せておきます。curlで実行していた内容を確認しています。
※データが増えていないことの確認まではしていませんが

src/test/java/org/littlewings/spring/tx/BookControllerTest.java

package org.littlewings.spring.tx;

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.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;

import java.net.URI;
import java.util.Collections;
import java.util.Map;

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

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class BookControllerTest {
    @Autowired
    TestRestTemplate restTemplate;

    @LocalServerPort
    int port;

    @Autowired
    NamedParameterJdbcTemplate jdbcTemplate;

    @BeforeEach
    void setUp() {
        jdbcTemplate.update("truncate table book", Collections.emptyMap());
    }

    @Test
    void insertAfterCommit() {
        ResponseEntity<Void> response =
                restTemplate.postForEntity(
                        "/books/after-commit",
                        new HttpEntity<>(Book.create("978-4621303252", "Effective Java 第3版", 4400)),
                        Void.class
                );

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getHeaders().getLocation()).isEqualTo(URI.create(String.format("http://localhost:%d/books/978-4621303252", port)));

        Book book = restTemplate.getForObject("/books/978-4621303252", Book.class);
        assertThat(book.getIsbn()).isEqualTo("978-4621303252");
        assertThat(book.getTitle()).isEqualTo("Effective Java 第3版");
        assertThat(book.getPrice()).isEqualTo(4400);
    }

    @Test
    void insertAfterCommitRollback() {
        ResponseEntity<Map<String, Object>> response =
                restTemplate.exchange(
                        "/books/after-commit-rollback",
                        HttpMethod.POST,
                        new HttpEntity<>(Book.create("978-4297126858", "プロになるJava―仕事で必要なプログラミングの知識がゼロから身につく最高の指南書", 3278)),
                        new ParameterizedTypeReference<>() {
                        }
                );

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
        assertThat(response.getBody())
                .containsOnlyKeys("timestamp", "status", "error", "path")
                .contains(entry("status", 500), entry("error", "Internal Server Error"), entry("path", "/books/after-commit-rollback"));

        Book book = restTemplate.getForObject("/books/978-4297126858", Book.class);
        assertThat(book.getIsbn()).isNull();
        assertThat(book.getTitle()).isNull();
        assertThat(book.getPrice()).isNull();
    }

    @Test
    void insertAfterCommitThrowException() {
        ResponseEntity<Map<String, Object>> response =
                restTemplate.exchange(
                        "/books/after-commit-throw",
                        HttpMethod.POST,
                        new HttpEntity<>(Book.create("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", 4180)),
                        new ParameterizedTypeReference<>() {
                        }
                );

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
        assertThat(response.getBody())
                .containsOnlyKeys("timestamp", "status", "error", "path")
                .contains(entry("status", 500), entry("error", "Internal Server Error"), entry("path", "/books/after-commit-throw"));

        Book book = restTemplate.getForObject("/books/978-4798161488", Book.class);
        assertThat(book.getIsbn()).isEqualTo("978-4798161488");
        assertThat(book.getTitle()).isEqualTo("MySQL徹底入門 第4版 MySQL 8.0対応");
        assertThat(book.getPrice()).isEqualTo(4180);
    }

    @Test
    void insertAfterCompletion() {
        ResponseEntity<Void> response =
                restTemplate.postForEntity(
                        "/books/after-completion",
                        new HttpEntity<>(Book.create("978-4297124298", "Spring Framework超入門 〜やさしくわかるWebアプリ開発", 3058)),
                        Void.class
                );

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getHeaders().getLocation()).isEqualTo(URI.create(String.format("http://localhost:%d/books/978-4297124298", port)));

        Book book = restTemplate.getForObject("/books/978-4297124298", Book.class);
        assertThat(book.getIsbn()).isEqualTo("978-4297124298");
        assertThat(book.getTitle()).isEqualTo("Spring Framework超入門 〜やさしくわかるWebアプリ開発");
        assertThat(book.getPrice()).isEqualTo(3058);
    }

    @Test
    void insertAfterCompletionRollback() {
        ResponseEntity<Map<String, Object>> response =
                restTemplate.exchange(
                        "/books/after-completion-rollback",
                        HttpMethod.POST,
                        new HttpEntity<>(Book.create("978-4774189093", "Java本格入門 ~モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278)),
                        new ParameterizedTypeReference<>() {
                        }
                );

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
        assertThat(response.getBody())
                .containsOnlyKeys("timestamp", "status", "error", "path")
                .contains(entry("status", 500), entry("error", "Internal Server Error"), entry("path", "/books/after-commit-throw"));

        Book book = restTemplate.getForObject("/books/978-4774189093", Book.class);
        assertThat(book.getIsbn()).isNull();
        assertThat(book.getTitle()).isNull();
        assertThat(book.getPrice()).isNull();
    }

    @Test
    void insertAfterCompletionThrowException() {
        ResponseEntity<Map<String, Object>> response =
                restTemplate.exchange(
                        "/books/after-completion-throw",
                        HttpMethod.POST,
                        new HttpEntity<>(Book.create("978-4297131425", "実践Redis入門 技術の仕組みから現場の活用まで", 3696)),
                        new ParameterizedTypeReference<>() {
                        }
                );

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getHeaders().getLocation()).isEqualTo(URI.create(String.format("http://localhost:%d/books/978-4297131425", port)));

        Book book = restTemplate.getForObject("/books/978-4297131425", Book.class);
        assertThat(book.getIsbn()).isEqualTo("978-4297131425");
        assertThat(book.getTitle()).isEqualTo("実践Redis入門 技術の仕組みから現場の活用まで");
        assertThat(book.getPrice()).isEqualTo(3696);
    }
}

まとめ

Spring FrameworkのTransactionSynchronizationを試してみました。

存在は知っていましたが、ちゃんと使ったことがなかったので今回確認しておいてよかったです。
思っていたよりも、動作にバリエーションがあったなという感じがしました。