Grails 2.1 から標準でインストールされているCache Pluginを試す

http://grails.org/plugin/cachegrails 2.1からデフォルトでインストールされています。実態としてはSpringのCache Abstractionを薄くラップしたような形です。Serviceのメソッドや、Controller、またはGSPのレンダリングをキャッシュできます。

メソッドに付与する使い方は非常にシンプルで、grails.plugin.cacheパッケージにある以下の3つのアノテーションを使用します。

  • @Cacheable
  • @CacheEvict
  • @CachePut

それぞれ個別に見ていきます。

@Cacheable

このアノテーションを付与するとメソッドの戻り値をキャッシュします。例えば以下のような形です。

class BookService {

    @Cacheable('books')
    Book get(id) {
        Book.get(id)
    }

}

@Cacheableにはvalue属性を設定しますが、これはキャッシュする情報に任意の名前を設定します。この名前毎にキャッシュ領域が作られることになります。

また、@Cacheableはkey属性を持ちます。これは省略可能で、省略した場合は自動的にメソッドの仮引数がkeyに設定されます。

上記の例の

    @Cacheable('books')
    Book get(id) {

    @Cacheable(value = 'books', key = '#id')
    Book get(id) {

と同じ意味になります。#idとしているようにkeyの指定はSpEL(Spring Expression Language)が使用できます。これを使用することで仮引数がオブジェクトの場合にプロパティをたぐっていき#obj.idといったような指定も可能です。詳しくはSpringのドキュメントを参照してください。

簡単なテストをGrailsのIntegrationTestで書いてみます。

class Book {
    String title
}
import grails.plugin.spock.IntegrationSpec

class BookServiceSpec extends IntegrationSpec {

    def bookService

    def grailsCacheManager

    def cleanup() {
        grailsCacheManager.destroyCache('books')
    }

    def "キャッシュ追加の動作確認"() {
        given:
        def book = new Book(title: 'dummyTitle').save(flush: true)
        book.discard() // hibernateの1次キャッシュから取得しないようdiscard()する。

        and: "キャッシュには何も保存されていない"
        assert booksCache.size() == 0

        when:
        def cachedBook = bookService.get(book.id)

        then:
        cachedBook.title == 'dummyTitle'

        and: "キャッシュ取得したオブジェクトがキャッシュされているはず"
        booksCache == [(book.id): cachedBook]

        when: "直接永続化されている情報を変更する"
        cachedBook.discard() // book.save()した際にbookがhibernateの1キャッシュに乗るで二重ロードにならないようcachedBookはdiscard()する。
        book.title = 'newTitle'
        book.save(flush: true)

        then: "キャッシュには影響がない"
        booksCache[book.id].title == 'dummyTitle'

        when: "メソッドを経由してちゃんとキャッシュした情報が取得されるか確認"
        cachedBook = bookService.get(book.id)

        then: "キャッシュの値は変更されていない"
        cachedBook.title == 'dummyTitle'
    }

    private getBooksCache() {
        grailsCacheManager.getCache('books').getNativeCache()
    }

}

まずは

class BookServiceSpec extends IntegrationSpec {
    ...
    def grailsCacheManager
    ...
    private getBooksCache() {
        grailsCacheManager.getCache('books').getNativeCache()
    }

}

について。これはSpringのCacheManagerインタフェースを継承したGrialsCacheManagerインタフェースの実装がSpringBeanとして登録されます。Cache Pluginは cache-ehcache、cache-redisといったプラグインと併用することで拡張可能ですが、とく指定しなければデフォルトではGrailsConcurrentMapCacheManagerが使われます。名前の通りConcurrentHashMapを使用した単純な実装です。

上記のコードはgrailsCacheManagerにGrailsConcurrentMapCacheManagerのインスタンスがイジェクトされ、grailsCacheManager.getCache('books').getNativeCache()でbooksをキャッシュしているConcurrentHashMapのインスタンスを取得できます。getNativeCache()の戻り値はObjectで定義されており、GrailsConcurrentMapCacheManagerの場合、ConcurrentHashMapのインスタンスになるだけで、他の実装を使用する場合はきっと違う何かがとれるでしょう(よくわからない)。

テストコードの方ではbookService.getを初めて呼び出したタイミングで、その取得されたオブジェクトがキャッシュされます。以後、何度 bookService.getを呼び出しても、@CacheEvict使ってキャッシュをクリアするか、@CachePutを使ってキャッシュを更新するまで、そのキャッシュの値が使用されます(cache-ehcache、cache-redisといった連携プラグインで独自のクリアの仕組みがある可能性はある)。

@CacheEvict

@CacheEvictはキャッシュをクリアする仕組みです。以下のように使います。

class BookService {
    ...
 
    @CacheEvict('books')
    void clear(id) {
    }

    @CacheEvict(value = 'books', allEntries = true)
    void clearAll() {
    }

}

一つ目のメソッドclear(id)を呼び出すと'books'キャッシュのkeyがidのキャッシュをクリアします。keyの指定は先程と一緒で、上記は以下のように定義しているのと同じです。

    @CacheEvict(value = 'books', key = '#id')
    void clear(id) {
    }

2つ目のメソッドでは@CacheEvictの属性にallEntries = trueを指定しています。これを指定すと特定のkeyのキャッシュ削除ではなく、そのキャッシュすべてを削除します。上記では'books'のキャッシュすべてが削除されます。

テストを書いてみます。

    def "キャッシュ削除の動作確認"() {
        given:
        def book1 = new Book(title: 'title1').save(flush: true)
        def book2 = new Book(title: 'title2').save(flush: true)

        and: "キャッシュには何も保存されていない"
        assert booksCache == [:]

        when: "値を一度取得してキャッシュさせる"
        bookService.get(book1.id)
        bookService.get(book2.id)

        then: "キャッシュにbook1、book2が入っているはず"
        booksCache == [(book1.id): book1, (book2.id): book2]

        when: "book1をキャッシュから削除"
        bookService.clear(book1.id)

        then: "キャッシュからbook1が削除されている"
        booksCache == [(book2.id): book2]
    }

    def "キャッシュ全削除の動作確認"() {
        given:
        def book1 = new Book(title: 'title1').save(flush: true)
        def book2 = new Book(title: 'title2').save(flush: true)

        and: "キャッシュには何も保存されていない"
        assert booksCache == [:]

        when: "値を一度取得してキャッシュさせる"
        bookService.get(book1.id)
        bookService.get(book2.id)

        then: "キャッシュにbook1、book2が入っているはず"
        booksCache == [(book1.id): book1, (book2.id): book2]

        when: "キャッシュを全削除"
        bookService.clearAll()

        then: "キャッシュすべて削除されている"
        booksCache == [:]
    }

@CachePut

最後は@CachePutで、これは@CacheEvictと、@Cacheable組み合わせたような動作で、指定されたkeyに対するキャッシュをクリアし、keyに対して戻り値をキャッシュします。以下のように使用します。

class BookService {
    ....

    @CachePut(value = 'books', key = '#book.id')
    Book put(book) {
        book
    }

}

テストを書いてみます。

    def "キャッシュ更新の動作確認"() {
        given:
        def book = new Book(title: 'dummyTitle').save(flush: true)

        and: "キャッシュには何も保存されていない"
        assert booksCache == [:]

        when:
        def cachedBook = bookService.get(book.id)

        then: "キャッシュ取得したオブジェクトがキャッシュされているはず"
        booksCache == [(book.id): book]
        booksCache[book.id].title == 'dummyTitle'

        when: "キャッシュの値を更新する"
        cachedBook.title = 'updatedTitle'
        bookService.put(cachedBook)

        then: "キャッシュの値が更新されているはず"
        booksCache[book.id].title == 'updatedTitle'
    }

ちなみに対象のkeyのキャッシュが存在しない場合は、単に新たにキャッシュされるだけです。

    def "現在キャッシュされていない状態でのキャッシュ更新"() {
        given:
        def book = new Book(title: 'dummyTitle').save(flush: true)

        and: "キャッシュには何も保存されていない"
        assert booksCache == [:]

        when: "キャッシュがない状態でPUTする"
        bookService.put(book)

        then: "キャッシュされている"
        booksCache == [(book.id): book]
    }

condition属性で条件を指定する

@Cacheable、@CacheEvict、@CachePutのアノテーションはすべてcondition属性を持っています。名前から推測できるように、@Cacheable、@CacheEvict、@CachePutが動作する条件を指定できます。これはkeyと同様にSpELが使用できます。以下のように使います。

class Author {
    String name
}
class AuthorService {

    @Cacheable(value = 'authors', condition = "#name.length() > 5")
    def get(name) {
        Author.findByName(name)
    }

}

この例では仮引数のnameの長さが5文字以上の場合キャッシュします。
テストを書いてみます。

class AuthorServiceSpec extends IntegrationSpec {

    def authorService
    def grailsCacheManager

    def cleanup() {
        grailsCacheManager.destroyCache('authors')
    }

    def "conditionでキャッシュ条件を指定する"() {
        given:
        def author1 = new Author(name: "4444").save(flush: true)
        def author2 = new Author(name: "55555").save(flush: true)
        def author3 = new Author(name: "666666").save(flush: true)
        def author4 = new Author(name: "7777777").save(flush: true)

        when:
        authorService.get(author1.name)
        authorService.get(author2.name)
        authorService.get(author3.name)
        authorService.get(author4.name)

        then: "名前が5文字以上のAuthorのみキャッシュされている"
        authorsCache == [(author3.name): author3, (author4.name): author4]
    }

    private getAuthorsCache() {
        grailsCacheManager.getCache('authors').nativeCache
    }

}

アノテーションのvalueの型はStringではなくString[]である

これは何を意味するかというという、複数にキャッシュをすると複数にキャッシュに対して操作ができます。例えば複数キャシュを同時に削除するには以下のようにします。

class AuthorService {
    ....
    @CacheEvict(value = ['authors', 'books'], allEntries = true)
    def clearAll() {}

}

'authors'のキャッシュと、'books'のキャッシュが同時に消えます。テストを書いてみます。

    def "複数のキャッシュを同時に削除する"() {
        given: "authorのキャッシュを準備"
        def author = new Author(name: "xxxxxx").save(flush: true)
        authorService.get(author.name)

        and: "authorがキャッシュされていること"
        assert authorsCache == [(author.name): author]

        and: "bookのキャッシュを準備"
        def book = new Book(title: 'yyyy').save(flush: true)
        bookService.get(book.id)

        and: "bookがキャッシュされていること"
        assert booksCache == [(book.id): book]

        when: "authorsとbooksのキャッシュを同時に削除"
        authorService.clearAll()

        then: "両方のキャッシュが削除されていること"
        authorsCache == [:]
        booksCache == [:]
    }

ではでは、長くなりましたがおわり。