SpringのCache Abstractionには、トランザクションと連携する機能があります。
ドキュメントにそれっぽいことは見当たらない気がしますが…。
関連するクラスはorg.springframework.cache.transactionパッケージに入っていて、主要なクラスは
- AbstractTransactionSupportingCacheManager (Spring Framework 4.3.6.RELEASE API)
- TransactionAwareCacheDecorator (Spring Framework 4.3.6.RELEASE API)
- TransactionAwareCacheManagerProxy (Spring Framework 4.3.6.RELEASE API)
の3つです。
使用するキャッシュが、TransactionAwareCacheDecoratorでラップされたものになっていると、Springの
トランザクションに参加することができます。
使うかどうかは微妙なところですが、とりあえず仕組みを把握するという意味で内容を追っていこうかなと
思います。
Cacheをトランザクションに参加させるには、CacheManagerがAbstractTransactionSupportingCacheManagerクラスのサブクラスであること、
もしくはTransactionAwareCacheManagerProxyクラスでCacheManagerを包んであげればOKです。
それぞれ順次見ていきましょう。
準備
まずは、Maven依存関係から。スタート地点では、こんな感じにしておきました。
<properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <java.version>1.8</java.version> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <spring.boot.version>1.5.1.RELEASE</spring.boot.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring.boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>test</scope> </dependency> </dependencies>
TransactionManagerが欲しいので、JPA、データベースはH2を使用することにしました。
また、CacheManagerの実装は最初はライブラリ不要のConcurrentMapCacheManagerとしましょう。
サンプルコード
では、サンプルコードを用意します。
JPAを使うということで、Entity。テーマは書籍とします。
src/main/java/org/littlewings/spring/cache/Book.java
package org.littlewings.spring.cache; import java.io.Serializable; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.Id; import javax.persistence.Table; @Entity @Table(name = "book") public class Book implements Serializable { private static final long serialVersionUID = 1L; @Id private String isbn; private String title; private Integer price; public Book() { } public Book(String isbn, String title, Integer price) { this.isbn = isbn; this.title = title; this.price = price; } // getter/setterは省略 }
Repository。
src/main/java/org/littlewings/spring/cache/BookRepository.java
package org.littlewings.spring.cache; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface BookRepository extends JpaRepository<Book, String> { }
Service。ここで、キャッシュを使用します。@Cacheableを付与したメソッドでは、キャッシュがないと3秒間スリープ
させます。Entityの保存時には、一緒にキャッシュを更新。あと、確認の都合上キャッシュのみを削除するメソッドも
用意しました。
src/main/java/org/littlewings/spring/cache/BookService.java
package org.littlewings.spring.cache; import java.util.concurrent.TimeUnit; import org.springframework.cache.annotation.CacheConfig; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.CachePut; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; @Service @CacheConfig(cacheNames = "entityCache") public class BookService { BookRepository bookRepository; public BookService(BookRepository bookRepository) { this.bookRepository = bookRepository; } @Cacheable public Book find(String isbn) { try { TimeUnit.SECONDS.sleep(3L); } catch (InterruptedException e) { // ignore } return bookRepository.findOne(isbn); } @CachePut(key = "#book.isbn") public Book save(Book book) { return bookRepository.save(book); } @CacheEvict(key = "#book.isbn") public void cacheEvict(Book book) { } }
Controller。こちらは、あとでメソッドを足していきますが、最初は参照、追加、キャッシュの削除が
できるようにしてあります。個人的にはあまり好みではありませんが、Controller自体が@Transactionalです。
src/main/java/org/littlewings/spring/cache/BookController.java
package org.littlewings.spring.cache; import org.springframework.http.HttpStatus; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @RestController @Transactional public class BookController { BookService bookService; public BookController(BookService bookService) { this.bookService = bookService; } @GetMapping("book/{isbn}") public Book find(@PathVariable String isbn) { return bookService.find(isbn); } @PutMapping("book/{isbn}") @ResponseStatus(HttpStatus.CREATED) public void register(@RequestBody Book book) { bookService.save(book); } @DeleteMapping("book/cache/{isbn}") @ResponseStatus(HttpStatus.NOT_FOUND) public void cacheDelete(@PathVariable String isbn) { Book book = bookService.find(isbn); bookService.cacheEvict(book); } }
なんでここで@Transactionalか?
Serviceクラスで
@Transactional @CachePut(key = "#book.isbn") public Book save(Book book) { return bookRepository.save(book); }
みたいに書いてないのは、一応理由があります。それはまた後で。
Cache Abstractionの有効化。
src/main/java/org/littlewings/spring/cache/CachingConfig.java
package org.littlewings.spring.cache; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.concurrent.ConcurrentMapCacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration @EnableCaching public class CachingConfig { @Bean public CacheManager cacheManager() { ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager(); return cacheManager; } }
エントリポイント。
src/main/java/org/littlewings/spring/cache/App.java
package org.littlewings.spring.cache; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class App { }
ここまでが、今回確認するアプリケーションの基本形になります。
ですが、この時点ではまだキャッシュはトランザクションに対応していません。ここから、
順次変更しつつ動作を見ていきましょう。
テストコードの大枠
それでは、ここからはテストコードに沿って動作確認していきます。
まず雛形としては、このような形で用意。
src/test/java/org/littlewings/spring/cache/TransactionalCacheTest.java
package org.littlewings.spring.cache; import java.util.HashMap; import java.util.Map; import org.junit.Test; import org.junit.runner.RunWith; 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.test.context.junit4.SpringRunner; import org.springframework.util.StopWatch; import static org.assertj.core.api.Assertions.assertThat; @RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class TransactionalCacheTest { @Autowired TestRestTemplate restTemplate; // ここに、テストコードを書く! }
H2やJPAの設定はこちら。Hibernateが発行するSQLを表示するようにしてあります。
src/test/resources/application.properties
spring.datasource.url=jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true
では、ここで用意したクラスにテストを追加して確認していきましょう。
トランザクション未対応の場合
基本形ということで、トランザクション未対応のまま動作させてみます。
データの登録、データの取得(キャッシュに乗っているので高速)、キャッシュの削除、最後にデータの取得
(キャッシュからデータがなくなったので低速)を確認しています。
@Test public void success() { Book book = new Book("978-4798142470", "Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発", 4320); Map<String, String> variables = new HashMap<>(); variables.put("isbn", book.getIsbn()); // 登録 restTemplate.put("/book/{isbn}", book, variables); StopWatch stopWatch = new StopWatch(); // 取得 stopWatch.start(); Book responseBook1 = restTemplate.getForObject("/book/{isbn}", Book.class, variables); stopWatch.stop(); // キャッシュに乗っているので高速 assertThat((long) stopWatch.getLastTaskInfo().getTimeSeconds()) .isEqualTo(0L); assertThat(responseBook1.getIsbn()) .isEqualTo(book.getIsbn()); assertThat(responseBook1.getTitle()) .isEqualTo(book.getTitle()); assertThat(responseBook1.getPrice()) .isEqualTo(book.getPrice()); // キャッシュ削除 restTemplate.delete("/book/cache/{isbn}", variables); // 取得 stopWatch.start(); Book responseBook2 = restTemplate.getForObject("/book/{isbn}", Book.class, variables); stopWatch.stop(); // キャッシュがないので低速に assertThat((long) stopWatch.getLastTaskInfo().getTimeSeconds()) .isGreaterThanOrEqualTo(3L); assertThat(responseBook2.getIsbn()) .isEqualTo(book.getIsbn()); assertThat(responseBook2.getTitle()) .isEqualTo(book.getTitle()); assertThat(responseBook2.getPrice()) .isEqualTo(book.getPrice()); }
次に、Controllerにデータの登録に失敗するようなメソッドを追加します。Controllerが@Transactionalなので、
H2へのデータの登録はロールバックされるようなコードです。
@PutMapping("book/fail/{isbn}") @ResponseStatus(HttpStatus.CREATED) public void registerFail(@RequestBody Book book) { bookService.save(book); throw new RuntimeException("Oops!!"); }
で、こんなテストコードを書いて確認してみます。
@Test public void failAsCacheBroken() { Book book = new Book("978-4774182179", "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ", 4104); Map<String, String> variables = new HashMap<>(); variables.put("isbn", book.getIsbn()); // 登録(失敗する) restTemplate.put("/book/fail/{isbn}", book, variables); StopWatch stopWatch = new StopWatch(); // 取得 stopWatch.start(); Book responseBook1 = restTemplate.getForObject("/book/{isbn}", Book.class, variables); stopWatch.stop(); // キャッシュに乗っているので高速 assertThat((long) stopWatch.getLastTaskInfo().getTimeSeconds()) .isEqualTo(0L); // しかも結果を持っている assertThat(responseBook1.getIsbn()) .isEqualTo(book.getIsbn()); assertThat(responseBook1.getTitle()) .isEqualTo(book.getTitle()); assertThat(responseBook1.getPrice()) .isEqualTo(book.getPrice()); // キャッシュ削除 restTemplate.delete("/book/cache/{isbn}", variables); // 取得 stopWatch.start(); Book responseBook2 = restTemplate.getForObject("/book/{isbn}", Book.class, variables); stopWatch.stop(); // 今度はキャッシュに乗っていない assertThat((long) stopWatch.getLastTaskInfo().getTimeSeconds()) .isGreaterThanOrEqualTo(3L); // 登録に失敗しているので、結果はなし assertThat(responseBook2) .isNull(); }
とまあ、キャッシュにだけエントリが登録され、実際のデータベースにはデータが保存されていないという
状態になります。
TransactionAwareCacheManagerProxyを使ってCacheManagerをトランザクションに対応させる
ここで、CacheManagerの設定を変更します。CacheManagerを、TransactionAwareCacheManagerProxyで包むように
変更します。
import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.concurrent.ConcurrentMapCacheManager; import org.springframework.cache.transaction.TransactionAwareCacheManagerProxy; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration @EnableCaching public class CachingConfig { @Bean public CacheManager cacheManager() { ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager(); return new TransactionAwareCacheManagerProxy(cacheManager); } }
こうすると、キャッシュがトランザクションに参加するようになります。
正確に言うと、CacheManager#getCacheで返されるCacheがTransactionAwareCacheDecoratorにラップされて
返されるようになります。
この状態で次のようなコードで動かしてみると、トランザクションがロールバックした際にキャッシュへの反映も
行われなくなっていることが確認できます。
@Test public void fail() { Book book = new Book("978-4777519699", "はじめてのSpring Boot―スプリング・フレームワークで簡単Javaアプリ開発", 2700); Map<String, String> variables = new HashMap<>(); variables.put("isbn", book.getIsbn()); // 登録(失敗する) restTemplate.put("/book/fail/{isbn}", book, variables); StopWatch stopWatch = new StopWatch(); // 取得 stopWatch.start(); Book responseBook1 = restTemplate.getForObject("/book/{isbn}", Book.class, variables); stopWatch.stop(); // 今度はキャッシュに乗っていない assertThat((long) stopWatch.getLastTaskInfo().getTimeSeconds()) .isGreaterThanOrEqualTo(3L); // 登録に失敗しているので、結果はなし assertThat(responseBook1) .isNull(); }
TransactionAwareCacheManagerProxyを使った設定だと、先ほどの
@Test public void failAsCacheBroken() { Book book = new Book("978-4774182179", "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ", 4104); Map<String, String> variables = new HashMap<>(); variables.put("isbn", book.getIsbn()); // 登録(失敗する) restTemplate.put("/book/fail/{isbn}", book, variables); StopWatch stopWatch = new StopWatch(); // 取得 stopWatch.start(); Book responseBook1 = restTemplate.getForObject("/book/{isbn}", Book.class, variables); stopWatch.stop(); // キャッシュに乗っているので高速 assertThat((long) stopWatch.getLastTaskInfo().getTimeSeconds()) .isEqualTo(0L); // しかも結果を持っている assertThat(responseBook1.getIsbn()) .isEqualTo(book.getIsbn()); 〜省略〜
といったコードは、動作が変わるのでテストを通らなくなります。
AbstractTransactionSupportingCacheManagerのサブクラスを使用する
先ほどは、TransactionAwareCacheManagerProxyを使ってCacheManagerをトランザクションに参加させて
みました。
他にも、提供されているAbstractTransactionSupportingCacheManagerのサブクラスを使用することで
トランザクションに参加するCacheManagerを使うことができます。
提供されているAbstractTransactionSupportingCacheManagerのサブクラスは、以下があります。
- EhCacheCacheManager (Spring Framework 4.3.6.RELEASE API)
- JCacheCacheManager (Spring Framework 4.3.6.RELEASE API)
- RedisCacheManager (Spring Data Redis 1.8.0.RELEASE API)
※EhCacheCacheManagerは、Ehcache 2.xのCacheManagerです
今回は、JCacheCacheManagerを使ってみます。
JCacheの実装が必要になるので、今回はJCache RIを使うことにします。
<dependency> <groupId>org.jsr107.ri</groupId> <artifactId>cache-ri-impl</artifactId> <version>1.0.0</version> </dependency>
Cache Abstractionでの設定を、以下のように変更してJCacheCacheManagerを使うようにします。
src/main/java/org/littlewings/spring/cache/CachingConfig.java
package org.littlewings.spring.cache; import javax.cache.Caching; import javax.cache.configuration.MutableConfiguration; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; import org.springframework.cache.jcache.JCacheCacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration @EnableCaching public class CachingConfig { @Bean public CacheManager cacheManager() { javax.cache.CacheManager jcacheNativeCacheManager = Caching.getCachingProvider().getCacheManager(); javax.cache.configuration.Configuration<Object, Object> cacheConfiguration = new MutableConfiguration<>(); jcacheNativeCacheManager.createCache("entityCache", cacheConfiguration); JCacheCacheManager cacheManager = new JCacheCacheManager(jcacheNativeCacheManager); cacheManager.setTransactionAware(true); // これを入れないとトランザクション対応にならない cacheManager.afterPropertiesSet(); return cacheManager; } }
ポイントは、AbstractTransactionSupportingCacheManagerのサブクラスを使っただけでトランザクションに
対応するのではなくて、AbstractTransactionSupportingCacheManager#setTransactionAwareにtrueを設定することで
はじめてトランザクションに対応します。
AbstractTransactionSupportingCacheManager#setTransactionAwareにtrueを設定することで、TransactionAwareCacheManagerProxyを
使った時と同様、CacheがTransactionAwareCacheDecoratorにラップされて返されるようになります。
JCacheCacheManagerだけでなく、EhCacheCacheManager、RedisCacheManagerのいずれを使っても同じです。
結果は割愛。
Cache Abstractionのトランザクション対応の仕組み
Cache Abstractionのトランザクション対応ですが、どういう仕掛けになっているかというとTransactionAwareCacheDecoratorを
見るとなんとなくわかります。
Cache#put、Cache#evict、Cache#clearの3つに対して、TransactionSynchronizationManager#registerSynchronizationを
使ってトランザクションのコミット時に実体のキャッシュに結果を反映するような実装になっています。
例えば、putメソッドでは以下のように実装されています。
@Override public void put(final Object key, final Object value) { if (TransactionSynchronizationManager.isSynchronizationActive()) { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() { @Override public void afterCommit() { targetCache.put(key, value); } }); } else { this.targetCache.put(key, value); } }
トランザクションが存在する場合はTransactionSynchronizationManager#registerSynchronizationを使用し、
そうでない場合はいきなり実体のキャッシュに反映します。
こういう性格なので、トランザクション中でキャッシュにデータを登録しても、トランザクションが
完了するまでは登録したつもりのキャッシュエントリを参照することができません。
あまり意味はありませんが、こういうメソッドをControllerに追加して
※このServiceのfindメソッドは3秒スリープしますが、@Cacheableをつけているので本来2度目の呼び出しは高速になる
@GetMapping("book/doubling/{isbn}") public Book findDoubling(@PathVariable String isbn) { bookService.find(isbn); return bookService.find(isbn); }
こんな感じのコード(データ登録後、1度キャッシュを削除)を動かすとServiceのメソッドに@Cacheableを
付与しているにもかかわらず2回スリープしただけの時間がかかることになります。トランザクションが
コミットされていないので、キャッシュに登録した(つもり)のデータが見えないからですね。
@Test public void doubling() { Book book = new Book("978-4774183169", "パーフェクト Java EE", 3456); Map<String, String> variables = new HashMap<>(); variables.put("isbn", book.getIsbn()); // 登録 restTemplate.put("/book/{isbn}", book, variables); // キャッシュ削除 restTemplate.delete("/book/cache/{isbn}", variables); StopWatch stopWatch = new StopWatch(); // 取得 stopWatch.start(); Book responseBook1 = restTemplate.getForObject("/book/doubling/{isbn}", Book.class, variables); stopWatch.stop(); // キャッシュがないので低速に assertThat((long) stopWatch.getLastTaskInfo().getTimeSeconds()) .isGreaterThanOrEqualTo(6L); // 2倍 assertThat(responseBook1.getIsbn()) .isEqualTo(book.getIsbn()); assertThat(responseBook1.getTitle()) .isEqualTo(book.getTitle()); assertThat(responseBook1.getPrice()) .isEqualTo(book.getPrice()); }
また、今回Controllerに@Transactional、ServiceにCache関係のアノテーションと、付与するクラス階層を分けましたが、
これは同じ階層に付けていると例外がスローされた時にキャッシュには反映されない状態になるからです。
CacheAspectSupportで、インターセプトしたメソッドの戻り値をキャッシュに登録するようになっているので、
キャッシュ関係のアノテーションを付与したメソッド自体が例外を投げてしまうとキャッシュに反映されなくなります。
このあたりは、どういう仕掛けになっているか把握しておかないと混乱するかも?