CLOVER🍀

That was when it all began.

SpringのCache AbstractionとTransaction

SpringのCache Abstractionには、トランザクションと連携する機能があります。

ドキュメントにそれっぽいことは見当たらない気がしますが…。

36. Cache Abstraction

関連するクラスはorg.springframework.cache.transactionパッケージに入っていて、主要なクラスは

の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は、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を
見るとなんとなくわかります。

https://github.com/spring-projects/spring-framework/blob/v4.3.6.RELEASE/spring-context-support/src/main/java/org/springframework/cache/transaction/TransactionAwareCacheDecorator.java

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関係のアノテーションと、付与するクラス階層を分けましたが、
これは同じ階層に付けていると例外がスローされた時にキャッシュには反映されない状態になるからです。

https://github.com/spring-projects/spring-framework/blob/v4.3.6.RELEASE/spring-context/src/main/java/org/springframework/cache/interceptor/CacheAspectSupport.java#L408

CacheAspectSupportで、インターセプトしたメソッドの戻り値をキャッシュに登録するようになっているので、
キャッシュ関係のアノテーションを付与したメソッド自体が例外を投げてしまうとキャッシュに反映されなくなります。

このあたりは、どういう仕掛けになっているか把握しておかないと混乱するかも?

まとめ

SpringのCache Abstractionで、トランザクション対応を試してみつつ、その中身を追ってみました。

使うかどうかはさておき、仕組みとしては把握しておきましょう。