これは、なにをしたくて書いたもの?
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
を呼び出している処理はこのあたりですね。
実際に呼び出しを行うのはTransactionSynchronizationUtils
ですね。
また、TransactionSynchronization#beforeCompletion
やTransactionSynchronization#afterCompletion
で例外がスローされても
呼び出し元に伝播しないのは、ふつうにtry
〜catch
しているからですね。
オマケ: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
を試してみました。
存在は知っていましたが、ちゃんと使ったことがなかったので今回確認しておいてよかったです。
思っていたよりも、動作にバリエーションがあったなという感じがしました。