.NET でのキャッシュ

この記事では、さまざまなキャッシュ メカニズムについて説明します。 キャッシュとは、中間層にデータを格納することです。これにより、後続のデータ取得が高速になります。 概念的には、キャッシュはパフォーマンスの最適化戦略と設計上の考慮事項です。 キャッシュを使用すると、変更頻度が低い (または取得に負荷がかかる) データがすぐに使えるようになるため、アプリのパフォーマンスを大幅に向上させることができます。 この記事では、2 つの主要なキャッシュの種類について説明し、両方のサンプル ソース コードを示します。

重要

.NET 内には 2 つの MemoryCache クラスがあります。1 つは System.Runtime.Caching 名前空間に、もう 1 つは Microsoft.Extensions.Caching 名前空間にあります。

この記事ではキャッシュに焦点を当てていますが、System.Runtime.Caching NuGet パッケージは含まれていません。 MemoryCache へのすべての参照は、Microsoft.Extensions.Caching 名前空間内にあります。

すべての Microsoft.Extensions.* パッケージは依存関係の挿入 (DI) の準備が整っており、IMemoryCacheIDistributedCache の両方のインターフェイスをサービスとして使用できます。

メモリ内キャッシュ

このセクションでは、Microsoft.Extensions.Caching.Memory パッケージについて学習します。 IMemoryCache の現在の実装は、ConcurrentDictionary<TKey,TValue> のラッパーであり、機能豊富な API を公開しています。 キャッシュ内のエントリは、ICacheEntry によって表され、どの object も指定できます。 メモリ内キャッシュ ソリューションは、1 台のサーバーで実行されるアプリに適しています。ここでは、すべてのキャッシュされたデータがアプリのプロセス内のメモリをレンタルします。

ヒント

マルチサーバー キャッシュのシナリオでは、メモリ内キャッシュの代わりに分散キャッシュによる方法を検討してください。

メモリ内キャッシュ API

キャッシュのコンシューマーは、スライド式と絶対の両方の有効期限を制御できます。

有効期限を設定すると、有効期限内にアクセスされなかったキャッシュ内のエントリは "削除" されます。 コンシューマーには、MemoryCacheEntryOptions を使用してキャッシュ エントリを制御するための追加のオプションがあります。 それぞれの ICacheEntryMemoryCacheEntryOptions とペアになっています。これにより IChangeToken で有効期限削除機能が、CacheItemPriority で優先順位設定が、ICacheEntry.Size で制御が公開されます。 次の拡張メソッドについて考えてみましょう。

メモリ内キャッシュの例

既定の IMemoryCache 実装を使用するには、AddMemoryCache 拡張メソッドを呼び出して必要なすべてのサービスを DI に登録します。 次のコード サンプルでは、汎用ホストを使用して DI 機能を公開します。

using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddMemoryCache();
using IHost host = builder.Build();

.NET ワークロードによっては、コンストラクターの挿入などで、異なる方法で IMemoryCache にアクセスする場合があります。 このサンプルでは、hostIServiceProvider インスタンスを使用し、汎用の GetRequiredService<T>(IServiceProvider) 拡張メソッドを呼び出します。

IMemoryCache cache =
    host.Services.GetRequiredService<IMemoryCache>();

メモリ内キャッシュ サービスが登録され、DI によって解決されたら、キャッシュを開始する準備ができています。 このサンプルでは、英語のアルファベットの 'A' から 'Z' の文字を反復処理します。 record AlphabetLetter 型は文字への参照を保持し、メッセージを生成します。

file record AlphabetLetter(char Letter)
{
    internal string Message =>
        $"The '{Letter}' character is the {Letter - 64} letter in the English alphabet.";
}

このサンプルには、アルファベット文字を反復処理するヘルパー関数が含まれています。

static async ValueTask IterateAlphabetAsync(
    Func<char, Task> asyncFunc)
{
    for (char letter = 'A'; letter <= 'Z'; ++letter)
    {
        await asyncFunc(letter);
    }

    Console.WriteLine();
}

前述の C# コードでは:

  • Func<char, Task> asyncFunc は、反復ごとに待機し、現在の letter を渡します。
  • すべての文字が処理された後、空白行がコンソールに書き込まれます。

キャッシュに項目を追加するには、Create または Set の API のいずれかを呼び出します。

var addLettersToCacheTask = IterateAlphabetAsync(letter =>
{
    MemoryCacheEntryOptions options = new()
    {
        AbsoluteExpirationRelativeToNow =
            TimeSpan.FromMilliseconds(MillisecondsAbsoluteExpiration)
    };

    _ = options.RegisterPostEvictionCallback(OnPostEviction);

    AlphabetLetter alphabetLetter =
        cache.Set(
            letter, new AlphabetLetter(letter), options);

    Console.WriteLine($"{alphabetLetter.Letter} was cached.");

    return Task.Delay(
        TimeSpan.FromMilliseconds(MillisecondsDelayAfterAdd));
});
await addLettersToCacheTask;

前述の C# コードでは:

  • 変数 addLettersToCacheTaskIterateAlphabetAsync に委任され、待機されます。
  • Func<char, Task> asyncFunc がラムダ式と競合しています。
  • MemoryCacheEntryOptions は、現在に対する絶対有効期限でインスタンス化されます。
  • 削除後のコールバックが登録されます。
  • AlphabetLetter オブジェクトがインスタンス化され、letteroptions と共に Set に渡されます。
  • 文字は、キャッシュされているものとしてコンソールに書き込まれます。
  • 最後に、Task.Delay が返されます。

アルファベットの各文字について、キャッシュ エントリが有効期限、および削除後のコールバックと共に書き込まれます。

削除後のコールバックにより、コンソールに削除された値の詳細が書き込まれます。

static void OnPostEviction(
    object key, object? letter, EvictionReason reason, object? state)
{
    if (letter is AlphabetLetter alphabetLetter)
    {
        Console.WriteLine($"{alphabetLetter.Letter} was evicted for {reason}.");
    }
};

キャッシュが作成されたので、IterateAlphabetAsync へのもう 1 つの呼び出しが待機状態になりますが、今回は IMemoryCache.TryGetValue を呼び出します。

var readLettersFromCacheTask = IterateAlphabetAsync(letter =>
{
    if (cache.TryGetValue(letter, out object? value) &&
        value is AlphabetLetter alphabetLetter)
    {
        Console.WriteLine($"{letter} is still in cache. {alphabetLetter.Message}");
    }

    return Task.CompletedTask;
});
await readLettersFromCacheTask;

cacheletter キーが含まれていて、valueAlphabetLetter のインスタンスである場合は、コンソールに書き込まれます。 letter キーがキャッシュにない場合は削除され、削除後のコールバックが呼び出されました。

その他の拡張メソッド

IMemoryCache には、非同期の GetOrCreateAsync を含む、便利な拡張メソッドが数多く用意されています。

すべてをまとめた配置

サンプル アプリのソース コード全体が最上位のプログラムであり、次の 2 つの NuGet パッケージが必要です。

using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddMemoryCache();
using IHost host = builder.Build();

IMemoryCache cache =
    host.Services.GetRequiredService<IMemoryCache>();

const int MillisecondsDelayAfterAdd = 50;
const int MillisecondsAbsoluteExpiration = 750;

static void OnPostEviction(
    object key, object? letter, EvictionReason reason, object? state)
{
    if (letter is AlphabetLetter alphabetLetter)
    {
        Console.WriteLine($"{alphabetLetter.Letter} was evicted for {reason}.");
    }
};

static async ValueTask IterateAlphabetAsync(
    Func<char, Task> asyncFunc)
{
    for (char letter = 'A'; letter <= 'Z'; ++letter)
    {
        await asyncFunc(letter);
    }

    Console.WriteLine();
}

var addLettersToCacheTask = IterateAlphabetAsync(letter =>
{
    MemoryCacheEntryOptions options = new()
    {
        AbsoluteExpirationRelativeToNow =
            TimeSpan.FromMilliseconds(MillisecondsAbsoluteExpiration)
    };

    _ = options.RegisterPostEvictionCallback(OnPostEviction);

    AlphabetLetter alphabetLetter =
        cache.Set(
            letter, new AlphabetLetter(letter), options);

    Console.WriteLine($"{alphabetLetter.Letter} was cached.");

    return Task.Delay(
        TimeSpan.FromMilliseconds(MillisecondsDelayAfterAdd));
});
await addLettersToCacheTask;

var readLettersFromCacheTask = IterateAlphabetAsync(letter =>
{
    if (cache.TryGetValue(letter, out object? value) &&
        value is AlphabetLetter alphabetLetter)
    {
        Console.WriteLine($"{letter} is still in cache. {alphabetLetter.Message}");
    }

    return Task.CompletedTask;
});
await readLettersFromCacheTask;

await host.RunAsync();

file record AlphabetLetter(char Letter)
{
    internal string Message =>
        $"The '{Letter}' character is the {Letter - 64} letter in the English alphabet.";
}

MillisecondsDelayAfterAddMillisecondsAbsoluteExpiration の値を自由に調整して、キャッシュされたエントリの有効期限と削除の動作の変化を確認してください。 このコードを実行した場合の出力例を次に示します。 .NET イベントには非決定的な性質があるため、異なる出力になる場合があります。

A was cached.
B was cached.
C was cached.
D was cached.
E was cached.
F was cached.
G was cached.
H was cached.
I was cached.
J was cached.
K was cached.
L was cached.
M was cached.
N was cached.
O was cached.
P was cached.
Q was cached.
R was cached.
S was cached.
T was cached.
U was cached.
V was cached.
W was cached.
X was cached.
Y was cached.
Z was cached.

A was evicted for Expired.
C was evicted for Expired.
B was evicted for Expired.
E was evicted for Expired.
D was evicted for Expired.
F was evicted for Expired.
H was evicted for Expired.
K was evicted for Expired.
L was evicted for Expired.
J was evicted for Expired.
G was evicted for Expired.
M was evicted for Expired.
N was evicted for Expired.
I was evicted for Expired.
P was evicted for Expired.
R was evicted for Expired.
O was evicted for Expired.
Q was evicted for Expired.
S is still in cache. The 'S' character is the 19 letter in the English alphabet.
T is still in cache. The 'T' character is the 20 letter in the English alphabet.
U is still in cache. The 'U' character is the 21 letter in the English alphabet.
V is still in cache. The 'V' character is the 22 letter in the English alphabet.
W is still in cache. The 'W' character is the 23 letter in the English alphabet.
X is still in cache. The 'X' character is the 24 letter in the English alphabet.
Y is still in cache. The 'Y' character is the 25 letter in the English alphabet.
Z is still in cache. The 'Z' character is the 26 letter in the English alphabet.

絶対有効期限 (MemoryCacheEntryOptions.AbsoluteExpirationRelativeToNow) が設定されているため、キャッシュされたすべての項目が最終的に削除されます。

Worker サービスのキャッシュ

データをキャッシュするための一般的な戦略の 1 つが、使用するデータ サービスとは別にキャッシュを更新することです。 BackgroundService は他のアプリケーション コードから独立して (またはバックグラウンドで) 実行されるため、"Worker サービス" テンプレートが優れた例です。 IHostedService の実装をホストするアプリケーションの実行が開始されると、対応する実装 (この場合 BackgroundService または "worker") が同じプロセスで実行を開始します。 これらのホステッド サービスは、AddHostedService<THostedService>(IServiceCollection) 拡張メソッドを介してシングルトンとして DI に登録されます。 他のサービスは、任意のサービス有効期間で DI に登録できます。

重要

サービスの有効期間を理解しておくことは、非常に重要です。 AddMemoryCache を呼び出して、すべてのメモリ内キャッシュ サービスを登録すると、サービスはシングルトンとして登録されます。

写真サービスのシナリオ

HTTP 経由でアクセスできるサードパーティの API に依存する写真サービスを開発しているとしましょう。 この写真データの変更は頻繁ではありませんが、珍しくもありません。 それぞれの写真は、単純な record で表現されます。

namespace CachingExamples.Memory;

public readonly record struct Photo(
    int AlbumId,
    int Id,
    string Title,
    string Url,
    string ThumbnailUrl);

次の例では、いくつかのサービスが DI に登録されています。 各サービスが 1 つの役割を持っていること。

using CachingExamples.Memory;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.AddMemoryCache();
builder.Services.AddHttpClient<CacheWorker>();
builder.Services.AddHostedService<CacheWorker>();
builder.Services.AddScoped<PhotoService>();
builder.Services.AddSingleton(typeof(CacheSignal<>));

using IHost host = builder.Build();

await host.StartAsync();

前述の C# コードでは:

PhotoService は、指定された条件 (または filter) に一致する写真を取得する役割を担います。

using Microsoft.Extensions.Caching.Memory;

namespace CachingExamples.Memory;

public sealed class PhotoService(
        IMemoryCache cache,
        CacheSignal<Photo> cacheSignal,
        ILogger<PhotoService> logger)
{
    public async IAsyncEnumerable<Photo> GetPhotosAsync(Func<Photo, bool>? filter = default)
    {
        try
        {
            await cacheSignal.WaitAsync();

            Photo[] photos =
                (await cache.GetOrCreateAsync(
                    "Photos", _ =>
                    {
                        logger.LogWarning("This should never happen!");

                        return Task.FromResult(Array.Empty<Photo>());
                    }))!;

            // If no filter is provided, use a pass-thru.
            filter ??= _ => true;

            foreach (Photo photo in photos)
            {
                if (!default(Photo).Equals(photo) && filter(photo))
                {
                    yield return photo;
                }
            }
        }
        finally
        {
            cacheSignal.Release();
        }
    }
}

前述の C# コードでは:

  • コンストラクターには IMemoryCacheCacheSignal<Photo>、および ILogger が必要です。
  • GetPhotosAsync メソッド:
    • Func<Photo, bool> filter パラメーターを定義し、IAsyncEnumerable<Photo> を返します。
    • _cacheSignal.WaitAsync() を呼び出して、解放されるまで待機します。これにより、キャッシュにアクセスする前にキャッシュが設定されます。
    • _cache.GetOrCreateAsync() を呼び出し、キャッシュ内のすべての写真を非同期に取得します。
    • factory 引数は警告をログし、空の写真配列を返しますが、これが発生することはありません。
    • キャッシュ内の各写真は反復処理され、フィルター処理され、yield return で具体化されます。
    • 最後に、キャッシュ シグナルがリセットされます。

このサービスのコンシューマーは自由に GetPhotosAsync メソッドを呼び出し、それに応じて写真を処理することができます。 キャッシュには写真が含まれているため、HttpClient は不要です。

非同期シグナルは、ジェネリック型の制約付きシングルトン内のカプセル化された SemaphoreSlim インスタンスに基づきます。 CacheSignal<T> は、SemaphoreSlim のインスタンスに依存します。

namespace CachingExamples.Memory;

public sealed class CacheSignal<T>
{
    private readonly SemaphoreSlim _semaphore = new(1, 1);

    /// <summary>
    /// Exposes a <see cref="Task"/> that represents the asynchronous wait operation.
    /// When signaled (consumer calls <see cref="Release"/>), the 
    /// <see cref="Task.Status"/> is set as <see cref="TaskStatus.RanToCompletion"/>.
    /// </summary>
    public Task WaitAsync() => _semaphore.WaitAsync();

    /// <summary>
    /// Exposes the ability to signal the release of the <see cref="WaitAsync"/>'s operation.
    /// Callers who were waiting, will be able to continue.
    /// </summary>
    public void Release() => _semaphore.Release();
}

前の C# コードでは、デコレーター パターンを使用して SemaphoreSlim のインスタンスがラップされます。 CacheSignal<T> はシングルトンとして登録されます。そのため、任意のジェネリック型 (この場合は Photo) を使って、すべてのサービスの有効期間にわたって使用できます。 キャッシュのシード処理をシグナル処理する必要があります。

CacheWorkerBackgroundService のサブクラスです。

using System.Net.Http.Json;
using Microsoft.Extensions.Caching.Memory;

namespace CachingExamples.Memory;

public sealed class CacheWorker(
    ILogger<CacheWorker> logger,
    HttpClient httpClient,
    CacheSignal<Photo> cacheSignal,
    IMemoryCache cache) : BackgroundService
{
    private readonly TimeSpan _updateInterval = TimeSpan.FromHours(3);

    private bool _isCacheInitialized = false;

    private const string Url = "https://jsonplaceholder.typicode.com/photos";

    public override async Task StartAsync(CancellationToken cancellationToken)
    {
        await cacheSignal.WaitAsync();
        await base.StartAsync(cancellationToken);
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            logger.LogInformation("Updating cache.");

            try
            {
                Photo[]? photos =
                    await httpClient.GetFromJsonAsync<Photo[]>(
                        Url, stoppingToken);

                if (photos is { Length: > 0 })
                {
                    cache.Set("Photos", photos);
                    logger.LogInformation(
                        "Cache updated with {Count:#,#} photos.", photos.Length);
                }
                else
                {
                    logger.LogWarning(
                        "Unable to fetch photos to update cache.");
                }
            }
            finally
            {
                if (!_isCacheInitialized)
                {
                    cacheSignal.Release();
                    _isCacheInitialized = true;
                }
            }

            try
            {
                logger.LogInformation(
                    "Will attempt to update the cache in {Hours} hours from now.",
                    _updateInterval.Hours);

                await Task.Delay(_updateInterval, stoppingToken);
            }
            catch (OperationCanceledException)
            {
                logger.LogWarning("Cancellation acknowledged: shutting down.");
                break;
            }
        }
    }
}

前述の C# コードでは:

  • コンストラクターには ILoggerHttpClient、および IMemoryCache が必要です。
  • _updateInterval は 3 時間定義されます。
  • ExecuteAsync メソッド:
    • アプリの実行中はループします。
    • "https://jsonplaceholder.typicode.com/photos" に対して HTTP 要求を行い、応答を Photo オブジェクトの配列としてマップします。
    • 写真の配列は、"Photos" キーの下の IMemoryCache に配置されます。
    • _cacheSignal.Release() が呼び出され、シグナルを待機していたコンシューマーが解放されます。
    • 更新間隔を指定すると、Task.Delay の呼び出しが待機されます。
    • 3 時間遅延した後、キャッシュは再び更新されます。

同じプロセスのコンシューマーは、IMemoryCache に写真を要求できますが、CacheWorker がキャッシュの更新を担当します。

分散キャッシュ

一部のシナリオでは、分散キャッシュが必要です。たとえば、複数のアプリ サーバーの場合です。 分散キャッシュは、メモリ内キャッシュ アプローチよりも高いスケールアウトをサポートします。 分散キャッシュを使用すると、キャッシュ メモリが外部プロセスにオフロードされますが、追加のネットワーク I/O が必要であり、(標準の場合でも) 待機時間が少し長くなります。

分散キャッシュの抽象化は、Microsoft.Extensions.Caching.Memory NuGet パッケージの一部であり、AddDistributedMemoryCache 拡張メソッドも含まれています。

注意事項

AddDistributedMemoryCache は、開発やテストのシナリオでのみ使用する必要があり、実行可能な実稼働の実装ではありません

次のパッケージから、IDistributedCache の使用可能な実装を検討してください。

分散キャッシュ API

分散キャッシュ API は、メモリ内キャッシュ API の対応するものよりも少しプリミティブです。 キーと値のペアの方がより基本的です。 メモリ内キャッシュ キーは object に基づいており、分散キーは string です。 メモリ内キャッシュでは、値は任意の型指定されたジェネリックにできます。一方、分散キャッシュの値は byte[] として保持されます。 異なる実装で厳密に型指定されたジェネリック値が公開されないわけではありませんが、その場合は実装の詳細になります。

値を作成する

分散キャッシュに値を作成するには、設定 API のいずれかを呼び出します。

メモリ内キャッシュの例の AlphabetLetter レコードを使用して、オブジェクトを JSON にシリアル化し、stringbyte[] としてエンコードできます。

DistributedCacheEntryOptions options = new()
{
    AbsoluteExpirationRelativeToNow =
        TimeSpan.FromMilliseconds(MillisecondsAbsoluteExpiration)
};

AlphabetLetter alphabetLetter = new(letter);
string json = JsonSerializer.Serialize(alphabetLetter);
byte[] bytes = Encoding.UTF8.GetBytes(json);

await cache.SetAsync(letter.ToString(), bytes, options);

メモリ内キャッシュと同様に、キャッシュ エントリには、キャッシュ内の存在を微調整するのに役立つオプション (この場合は DistributedCacheEntryOptions) があります。

拡張メソッドを作成する

値を作成するための便利な拡張メソッドがいくつかあります。これは、byte[] へのオブジェクトの string 表現のエンコードを回避するのに役立ちます。

値を読み取る

分散キャッシュから値を読み取るには、取得 API のいずれかを呼び出します。

AlphabetLetter? alphabetLetter = null;
byte[]? bytes = await cache.GetAsync(letter.ToString());
if (bytes is { Length: > 0 })
{
    string json = Encoding.UTF8.GetString(bytes);
    alphabetLetter = JsonSerializer.Deserialize<AlphabetLetter>(json);
}

キャッシュ エントリをキャッシュから読み取った後は、byte[] から UTF8 でエンコードされた string 表現を取得できます

拡張メソッドを読み取る

値を読み取るための便利な拡張メソッドがいくつかあります。これは、オブジェクトの string 表現への byte[] のエンコードを回避するのに役立ちます。

値を更新する

分散キャッシュ内の値を 1 回の API 呼び出しで更新する方法はありません。代わりに、値のスライド式有効期限を次のいずれかの更新 API でリセットできます。

実際の値を更新する必要がある場合は、値を削除してから再追加する必要があります。

値を削除する

分散キャッシュの値を削除するには、削除 API のいずれかを呼び出します。

ヒント

前述の API には同期バージョンがありますが、分散キャッシュの実装はネットワーク I/O に依存しているという事実を考慮してください。 このため、非同期 API は使用しないよりは頻繁に使用する方が推奨されます。

関連項目