IK.AM

@making's tech note


SpringのCache Abstractionについて

🗃 {Programming/Java/org/springframework/cache}
🏷 JCache 🏷 Spring 
🗓 Updated at 2015-05-16T16:00:57Z  🗓 Created at 2015-05-16T16:00:57Z   🌎 English Page

便利だけれどあまり知られていない、Springがもつキャッシュ抽象化機構について説明します。

Springのキャッシュ機能では

  • org.springframework.cache.CacheManagerによるキャッシュ製品共通API
  • AOP(@Cacheable)による透過的キャッシュ

がサポートされています。

CacheManagerの使い方

org.springframework.cache.CacheManagerインターフェースを通じて、様々なキャッシュ(ConcurrentHashMapや EhCacheなどなど)を同じAPIでアクセスすることができます。

使い方は

// キャッシュ取得
Cache cache = cacheManager.getCache("foo");
// キャシュにキー=値を追加
cache.put("hoge", "test");
// キャシュから値を取得
System.out.println(cache.get("hoge", String.class)); // => test
// キャシュから値を削除
cache.evict("hoge");
System.out.println(cache.get("hoge", String.class)); // => null
// キャシュをクリア
cache.clear();

このAPIを使う事で製品ごとのAPIを使う必要がなくなるので、製品を入れ替えが容易になります。 例えば開発・テスト中はConcurrentHashMapを使い、本番はキャッシュ製品を使うというような使い方が可能です。

Spring Bootから使う

Spring Bootで動くプログラム全文を載せておきます。

package demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.concurrent.ConcurrentMapCache;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;

import java.util.Arrays;

@SpringBootApplication
public class DemoApplication implements CommandLineRunner {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    // Bean定義

    @Bean // CacheManagerをBean定義する必要がある
    CacheManager cacheManager() {
        // org.springframework.cache.Cacheの実装をマニュアルで登録するCacheManagerクラス
        SimpleCacheManager cacheManager = new SimpleCacheManager();
        // ConcurrentHashMapを使ったorg.springframework.cache.Cacheの実装を登録する
        cacheManager.setCaches(Arrays.asList(new ConcurrentMapCache("foo")));
        return cacheManager;
    }

    // ここから主プログラム

    @Autowired
    CacheManager cacheManager;

    @Override
    public void run(String... strings) throws Exception {
        Cache cache = cacheManager.getCache("foo");
        cache.put("hoge", "test");
        System.out.println(cache.get("hoge", String.class));
        cache.evict("hoge");
        System.out.println(cache.get("hoge", String.class));
    }
}

様々なCacheManager実装

先の例では、単純なSimpleCacheManagerを紹介しましたが、これ以外にもたくさんのCacheManager実装があります。 対応製品(Springまたは製品側にCacheManagerの実装があるもの)は以下の通りです。

JCacheManagerjavax.cache.CacheManagerのアダプターです。したがってJCache対応製品(Oracle Coherenceなど)はこのアダプターを通じてSpringのキャッシュ機構から利用可能です。

そのほか、CompositeCacheManagerは複数のCacheManagerを混合することができますし、AbstractTransactionSupportingCacheManager実装クラスは@Transactionalで(対応していれば)トランザクション管理が可能です。

クラス図貼っておきます。

  • CacheManger

image

  • Cache

image

AOPによる透過的キャッシュ

AOPを使うことで、先の例のように明示的にCacheを使うことなくキャッシュを利用することができます。 @Cacheableアノテーションをつけたメソッドの返り値を自動でCacheに登録できます。

まずはキャッシュを使わない例を紹介します。外部Webサービス(Open Weather Map API)にアクセスするプログラムです。

package demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.time.LocalDateTime;
import java.util.Map;

@SpringBootApplication
public class DemoApplication implements CommandLineRunner {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    // Bean定義
    @Bean
    RestTemplate restTemplate() {
        return new RestTemplate();
    }

    // ここから主プログラム
    @Autowired
    WeatherService weatherService;

    @Override
    public void run(String... strings) throws Exception {
        perfMon(() -> System.out.println(weatherService.getWeather("Tokyo")));
        perfMon(() -> System.out.println(weatherService.getWeather("Osaka")));
        perfMon(() -> System.out.println(weatherService.getWeather("Tokyo")));
    }

    void perfMon(Runnable runnable) {
        long start = System.currentTimeMillis();
        runnable.run();
        long elapsed = System.currentTimeMillis() - start;
        System.out.println("took " + elapsed + " [ms]");
    }
}

@Service
class WeatherService {
    @Autowired
    RestTemplate restTemplate;

    @SuppressWarnings("unchecked")
    public String getWeather(String where) {
        Map<String, Object> result = (Map<String, Object>) restTemplate.getForObject("http://api.openweathermap.org/data/2.5/weather?q=" + where, Map.class);
        Double temperature = (Double) ((Map<String, Object>) result.get("main")).get("temp") - 273;
        Double wind = (Double) ((Map<String, Object>) result.get("wind")).get("speed") * 3.6;
        return "The current temperature " + temperature + " degrees and the wind is " + wind + " km/h. (" + LocalDateTime.now() + ")";
    }
}

実行すると、

The current temperature 16.12299999999999 degrees and the wind is 22.32 km/h. (2015-05-17T04:23:53.125)
took 374 [ms]
The current temperature 11.12299999999999 degrees and the wind is 7.02 km/h. (2015-05-17T04:23:53.209)
took 82 [ms]
The current temperature 16.12299999999999 degrees and the wind is 22.32 km/h. (2015-05-17T04:23:53.293)
took 84 [ms]

と出力されます。WebサービスへHTTPアクセスしているので遅いです。

天気の情報はそう頻繁に変わらないので、同じ場所の結果はキャッシュさせるようにしましょう。ここで@Cacheableの登場です。

package demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCache;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Map;

@SpringBootApplication
@EnableCaching // AOPによるキャッシュアクセスを有効にする
public class DemoApplication implements CommandLineRunner {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    // Bean定義
    @Bean // 使用するCacheManagerの定義
    CacheManager cacheManager() {
        SimpleCacheManager cacheManager = new SimpleCacheManager();
        cacheManager.setCaches(Arrays.asList(new ConcurrentMapCache("weather")));
        return cacheManager;
    }

    @Bean
    RestTemplate restTemplate() {
        return new RestTemplate();
    }

    // ここから主プログラム
    @Autowired
    WeatherService weatherService;

    @Override
    public void run(String... strings) throws Exception {
        perfMon(() -> System.out.println(weatherService.getWeather("Tokyo")));
        perfMon(() -> System.out.println(weatherService.getWeather("Osaka")));
        perfMon(() -> System.out.println(weatherService.getWeather("Tokyo")));
    }

    void perfMon(Runnable runnable) {
        long start = System.currentTimeMillis();
        runnable.run();
        long elapsed = System.currentTimeMillis() - start;
        System.out.println("took " + elapsed + " [ms]");
    }
}

@CacheConfig(cacheNames = "weather") // キャッシュ名を指定
@Service
class WeatherService {
    @Autowired
    RestTemplate restTemplate;

    @Cacheable // キャッシュさせたいメソッドにつける
    @SuppressWarnings("unchecked")
    public String getWeather(String where) {
        Map<String, Object> result = (Map<String, Object>) restTemplate.getForObject("http://api.openweathermap.org/data/2.5/weather?q=" + where, Map.class);
        Double temperature = (Double) ((Map<String, Object>) result.get("main")).get("temp") - 273;
        Double wind = (Double) ((Map<String, Object>) result.get("wind")).get("speed") * 3.6;
        return "The current temperature " + temperature + " degrees and the wind is " + wind + " km/h. (" + LocalDateTime.now() + ")";
    }
}

実行してみます。

The current temperature 16.12299999999999 degrees and the wind is 22.32 km/h. (2015-05-17T04:30:24.011)
took 344 [ms]
The current temperature 11.12299999999999 degrees and the wind is 7.02 km/h. (2015-05-17T04:30:24.346)
took 334 [ms]
The current temperature 16.12299999999999 degrees and the wind is 22.32 km/h. (2015-05-17T04:30:24.011)
took 1 [ms]

2回目のTokyoの結果が速くなっているのがわかります。また、1回目と同じ時刻になっているので、初回の結果がキャッシュされていることもわかります。

既存の処理を簡単に、透過的に高速化できることが実感できたのではないでしょうか。

ちなみに、@CacheConfigを使わなくても@Cacheable("weather")というように一つ一つのメソッド毎にキャッシュ名を指定することも可能です。

デフォルトではキャッシュのキーは引数のオブジェクトです。複数ある場合はjava.util.Arrays#deepHashCodeの結果が使用されます。キャッシュのキーはSpEL式を使って柔軟に表現することができます。詳細はマニュアルを参照してください。

キャッシュのサイズや生存期間を指定するAPIはSpringには用意されておらず、キャッシュ製品依存になります。 ここではConcurrentHash並みに簡単に使えるGoogle Guavaのキャッシュ機構を利用した例を紹介します。

@Bean
CacheManager cacheManager() {
    GuavaCacheManager cacheManager = new GuavaCacheManager("weather", "...");
    cacheManager.setCacheBuilder(CacheBuilder.newBuilder()
            .maximumSize(1000) // 最大1000件キャッシュ
            .expireAfterAccess(1, TimeUnit.SECONDS) // 最後のアクセスから1秒後に破棄
            .removalListener(e -> System.out.println("==> " + e.getKey() + " has been removed!")));
    return cacheManager;
}

この設定を使い、APIにアクセスするプログラムを以下のように修正します。

@Override
public void run(String... strings) throws Exception {
    perfMon(() -> System.out.println(weatherService.getWeather("Tokyo")));
    perfMon(() -> System.out.println(weatherService.getWeather("Osaka")));
    perfMon(() -> System.out.println(weatherService.getWeather("Tokyo")));
    TimeUnit.SECONDS.sleep(1);
    perfMon(() -> System.out.println(weatherService.getWeather("Tokyo")));
}

結果は以下のようになります。

The current temperature 16.12299999999999 degrees and the wind is 22.32 km/h. (2015-05-17T04:56:03.441)
took 492 [ms]
The current temperature 11.478000000000009 degrees and the wind is 4.716 km/h. (2015-05-17T04:56:03.608)
took 161 [ms]
The current temperature 16.12299999999999 degrees and the wind is 22.32 km/h. (2015-05-17T04:56:03.441)
took 1 [ms]
==> Tokyo has been removed!
The current temperature 16.12299999999999 degrees and the wind is 22.32 km/h. (2015-05-17T04:56:04.706)
took 91 [ms]

最後のアクセス時にはキャッシュがなくなっているので再度Web APIにアクセスしているのがわかります。

明示的なキャッシュの更新、破棄は@CachePut@CacheEvictを使えます。

// import org.springframework.cache.annotation.*;

@CacheConfig(cacheNames = "weather")
@Service
class WeatherService {
    @Autowired
    RestTemplate restTemplate;

    @Cacheable
    @SuppressWarnings("unchecked")
    public String getWeather(String where) {
        Map<String, Object> result = (Map<String, Object>) restTemplate.getForObject("http://api.openweathermap.org/data/2.5/weather?q=" + where, Map.class);
        Double temperature = (Double) ((Map<String, Object>) result.get("main")).get("temp") - 273;
        Double wind = (Double) ((Map<String, Object>) result.get("wind")).get("speed") * 3.6;
        return "The current temperature " + temperature + " degrees and the wind is " + wind + " km/h. (" + LocalDateTime.now() + ")";
    }

    @CachePut
    public String update(String where) {
        return getWeather(where);
    }

    @CacheEvict
    public String refresh(String where) {
        return getWeather(where);
    }

    @CacheEvict(allEntries = true)
    public void clear() {

    }
}

アクセス頻度の高いメソッドは積極的にこの機能の利用を検討してもよいでしょう。特に更新頻度が低い場合に有効です。 一件取得処理なんかはほとんど@Cachebaleつけてもよいと思います。

JCache(JSR-107)アノテーションのサポート

Springではorg.springframework.cache.annotation.Cacheableの代わりに、Java EE 8から導入されるJCacheのアノテーション(@javax.cache.annotation.CacheResultなど)も使えます。Springではjavax.cache.CacheManagerの実装クラスは存在する必要はありません。

この機能を有効にするにはorg.springframework:spring-context-supportを依存関係に追加する必要があります。

先のWeatherServiceの例をJCacheアノテーションを使うと以下のようになります。

// import javax.cache.annotation.*;

@CacheDefaults(cacheName = "weather")
@Service
class WeatherService {
    @Autowired
    RestTemplate restTemplate;

    @CacheResult
    @SuppressWarnings("unchecked")
    public String getWeather(String where) {
        Map<String, Object> result = (Map<String, Object>) restTemplate.getForObject("http://api.openweathermap.org/data/2.5/weather?q=" + where, Map.class);
        Double temperature = (Double) ((Map<String, Object>) result.get("main")).get("temp") - 273;
        Double wind = (Double) ((Map<String, Object>) result.get("wind")).get("speed") * 3.6;
        return "The current temperature " + temperature + " degrees and the wind is " + wind + " km/h. (" + LocalDateTime.now() + ")";
    }

//    @CachePut
//    // 引数から更新値を@CacheValueで指定する必要があるので先の例を表現できない
//    public String update(String where) {
//        return getWeather(where);
//    }

    @CacheRemove
    public String refresh(String where) {
        return getWeather(where);
    }

    @CacheRemoveAll
    public void clear() {

    }
}

JCacheアノテーションはSpringのものとほとんど同じように使えることがわかります。比較表はマニュアルに載っています。

@javax.cache.annotation.CachePut@org.springframework.cache.annotation.CachePutの仕様に少し違いがあるのが注意です。

あとはSpringの方がキーの指定のSpELが使える分、柔軟かなと思います。


Springのキャッシュ機構、かなり便利なのでアプリケーションの高速化、DBアクセス負荷低減のために積極的に検討しましょう!

追記

Spring Boot 1.3からAutoConfiguration対応するらしい。


✒️️ Edit  ⏰ History  🗑 Delete