noxi雑記

.NET、Angularまわりの小ネタブログ

ASP.NET Coreでインメモリキャッシュを使用する

Webアプリを開発する際に、取得したデータを一時的にアプリ内にキャッシュしておきたいことは多々あります。ASP.NET Coreではそういったオブジェクトのキャッシュする手段が標準で2つ用意されています。1つは IMemoryCache で得られるアプリ内のメモリ空間を使用するもの、もう1つが IDistributedCache で得られる分散型のものです。この記事では前者の、インメモリキャッシュについて特徴や使用方法について簡単に紹介します。

前提条件

この記事は以下のバージョンで検証しています。

ASP.NET Coreのインメモリキャッシュ

ASP.NET Coreでインメモリキャッシュの機能を持つ IMemoryCache は、簡単に言ってしまえば Webアプリ内でシングルトンなオブジェクトキャッシュ用のDictionary です(実際に内部実装は ConcurrentDictonary )。Dictionaryなのでオブジェクトをそのまま出し入れすることができます。またキャッシュの保持期限を絶対・相対時間で指定できるため、自分でDictionaryにキャッシュするよりも楽にオブジェクトが管理できます。

インメモリキャッシュを使用する

インメモリキャッシュを使用するには Startup.csConfigureServices メソッド内で以下のように設定を行います。

public void ConfigureServices(IServiceCollection services)
{
    // インメモリキャッシュを使用する
    services.AddMemoryCache();
}

次に、インメモリキャッシュを使用したい場所で IMemoryCache をDIコンテナから受け取ります。MVCコントローラーではアクションメソッドの引数で、サービス等ではコンストラクタに記述します。

using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;

namespace CacheWebApp1.Controllers
{
    [Route("sample")]
    public class SampleController : Controller
    {
        public IMemoryCache Cache { get; }

        public SampleController(IMemoryCache cache)
        {
            Cache = cache;  // コンストラクタで受け取る場合
        }

        [HttpGet("")]
        public ActionResult<IEnumerable<string>> GetList([FromServices] IMemoryCache cache)  // MVCコントローラーのアクションメソッドの引数で受け取る場合
        {
            return Ok(new List<string> {"aaa", "bbb", "ccc"});
        }
    }
}

そして実際にインメモリキャッシュを使うのはこんな感じになります。キャッシュの有効期限を設定するには SetAbsoluteExpiration を使用します。 SetAbsoluteExpiration の引数に TimeSpan を渡した場合は現在時刻からの相対時間、 DateTimeOffset を渡した場合はその時間まで有効となります。なお、最後の Dispose を呼び忘れるとキャッシュに登録されませんので注意が必要です。

[HttpGet("")]
public ActionResult<IEnumerable<string>> GetList([FromServices] IMemoryCache cache) 
{
    // キャッシュ有無をチェック
    if (cache.TryGetValue("CacheKey", out var cached))
    {
        // キャッシュが存在する場合はキャッシュから返す
        return Ok((IEnumerable<string>)cached);
    }

    var data = new List<string> {"aaa", "bbb", "ccc"};

    cache.CreateEntry("CacheKey")
        .SetAbsoluteExpiration(TimeSpan.FromMinutes(5)) // キャッシュの生存期間は5分間
        .SetValue(data)                                 // キャッシュするオブジェクトを設定
        .Dispose();                                     // キャッシュ反映

    return Ok(data);
}

ただ、この呼び方は毎回チェックして取得して、、と記述しなければならないためちょっと面倒です。 GetOrCreate (非同期の場合は GetOrCreateAsync )拡張メソッドを使用するとキャッシュの登録と取得を簡潔に記述できます。 なおこの書き方で非同期の方の場合 CacheEntry の生成タイミングとキャッシュへの登録タイミングがずれる結果なのか、複数のリクエストから同時にこのメソッドが呼ばれるとキャッシュの生存期間がうまく設定されずにキャッシュに登録されないことがあります。以下のように AbsoluteExpirationRelativeToNow AbsoluteExpiration の両方をキャッシュするオブジェクトを返す直前に設定すると失敗しづらいです(もしくはキャッシュをいじる範囲をロックしましょう)。

[HttpGet("")]
public ActionResult<IEnumerable<string>> GetList([FromServices] IMemoryCache cache)
{
    // キャッシュがあればキャッシュされたオブジェクトが返り、キャッシュが無ければ渡した関数が実行される
    var cached = cache.GetOrCreate("CacheKey", entry =>
    {
        var data = new List<string> {"aaa", "bbb", "ccc"};
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
        entry.AbsoluteExpiration = null;
        return data;
    });
    return Ok(cached);
}

インメモリキャッシュのメモリ使用量を制限する

インメモリキャッシュ登録時にキャッシュ期限を設定しても、大量にオブジェクトが登録された場合は、キャッシュの期限が来るまでアプリケーションのメモリに残り続けます。場合によってはインメモリキャッシュだけでほとんどのメモリを消費してしまうことも考えられます。この自体を防ぐため IMemoryCache にはメモリ使用量を制限する機能が実装されています。メモリ使用量制限を使用するには Startup.cs を次の様に変更します。

public void ConfigureServices(IServiceCollection services)
{
    services.AddMemoryCache(options =>
    {
        options.SizeLimit = 10 * 1024 * 1024;  // 使用量上限(バイト、キャッシュされるオブジェクト数など、なんでもよい)
        options.CompactionPercentage = 0.30;   // 使用量上限に達した時に空ける容量
    });
}

メモリ使用量の上限を設定すると、キャッシュ登録時に、登録するオブジェクトがどの程度メモリを使用しているのかを手動で設定する必要があります。この設定を忘れると InvalidOperationException がスローされてしまいます。また使用量と同時に Priority を設定することで、インメモリキャッシュが溢れた時の削除優先順位を設定します。

[HttpGet("")]
public ActionResult<IEnumerable<string>> GetList([FromServices] IMemoryCache cache)
{
    // JSON文字列をキャッシュして、容量を明示的に指定する
    var cached = cache.GetOrCreate("CacheKey", entry =>
    {
        var data = JsonConvert.SerializeObject(new List<string> { "aaa", "bbb", "ccc" });
        entry.Size = Encoding.Unicode.GetByteCount(data);
        entry.Priority = CacheItemPriority.Low;
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
        return data;
    });
    return Ok(JsonConvert.DeserializeObject<List<string>>(cached));
}

この例ではオブジェクトのバイト数を使用量にカウントしていますが、面倒なので、 entry.Size = 1 を設定すると登録するオブジェクト数の制限となります。

インメモリキャッシュの注意点

インメモリキャッシュはアプリ内で永続化しない一時的に保持しておきたいデータを格納するのにぴったりの機能ですが、何点か注意が必要です。 まずただのDictionaryにインスタンスを格納しているだけのため、Immutableではないオブジェクトを格納している場合どこかでうっかりプロパティなどを書き換えるとアプリ全体に影響します。キャッシュから取得したオブジェクトの扱いは要注意です。 次にインメモリキャッシュはそのサーバーのメモリ上にしか存在していないため、ユーザーセッションなど、複数台のサーバーで共有すべきデータを格納してしまうと不可解な動作を引き起こします。複数台のサーバーで共有するべきキャッシュ・データはきちんとDBなどで永続化するか、分散型キャッシュ( IDistributedCache )を使用すべきです。 そして最後に、とりあえずと何でも入れていると、ほとんど使用しないにも関わらずGCされないオブジェクトが貯まってしまいます。格納するものは使用頻度が高く生成するのにコストがかかるものだけにする、期限や上限を設定するなど、用法用量には気をつけましょう。