この記事では、さまざまなキャッシュ メカニズムについて説明します。 キャッシュは、中間レイヤーにデータを格納し、後続のデータ取得を高速化する動作です。 概念的には、キャッシュはパフォーマンス最適化戦略と設計上の考慮事項です。 キャッシュを使用すると、データの変更頻度が低い (または取得にコストがかかる) ため、アプリのパフォーマンスが大幅に向上します。 この記事では、2 つの主な種類のキャッシュについて説明し、両方のサンプル ソース コードを提供します。
Von Bedeutung
.NET には 2 つの MemoryCache
クラスがあり、1 つは System.Runtime.Caching
名前空間に、もう 1 つは Microsoft.Extensions.Caching
名前空間にあります。
この記事ではキャッシュに焦点を当てていますが、 System.Runtime.Caching
NuGet パッケージは含まれていません。
MemoryCache
へのすべての参照は、Microsoft.Extensions.Caching
名前空間内にあります。
すべての Microsoft.Extensions.*
パッケージには依存関係挿入 (DI) が用意されています。 IMemoryCache インターフェイスと IDistributedCache インターフェイスの両方をサービスとして使用できます。
メモリ内キャッシュ
このセクションでは、 Microsoft.Extensions.Caching.Memory パッケージについて説明します。
IMemoryCacheの現在の実装は、機能豊富な API を公開する、ConcurrentDictionary<TKey,TValue>のラッパーです。 キャッシュ内のエントリは ICacheEntryによって表され、任意の object
にすることができます。 メモリ内キャッシュ ソリューションは、キャッシュされたすべてのデータがアプリのプロセスでメモリを借りる単一のサーバー上で実行されるアプリに適しています。
ヒント
マルチサーバー キャッシュのシナリオでは、メモリ内キャッシュの代わりに 分散キャッシュ アプローチを検討してください。
メモリ内キャッシュ API
キャッシュのコンシューマーは、スライディング有効期限と絶対有効期限の両方を制御できます。
- ICacheEntry.AbsoluteExpiration
- ICacheEntry.AbsoluteExpirationRelativeToNow
- ICacheEntry.SlidingExpiration
有効期限を設定すると、有効期限内にアクセスできない場合、キャッシュ内のエントリは 削除 されます。 コンシューマーには、 MemoryCacheEntryOptionsを介してキャッシュ エントリを制御するための追加のオプションがあります。 各 ICacheEntry は MemoryCacheEntryOptions と組み合わせられ、 IChangeTokenによる有効期限の削除機能、 CacheItemPriorityによる優先順位の設定、および ICacheEntry.Sizeの制御が公開されます。 次の拡張メソッドについて考えてみましょう。
- MemoryCacheEntryExtensions.AddExpirationToken
- MemoryCacheEntryExtensions.RegisterPostEvictionCallback
- MemoryCacheEntryExtensions.SetSize
- MemoryCacheEntryExtensions.SetPriority
メモリ内キャッシュの例
既定の 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
に異なる方法でアクセスする場合があります。 このサンプルでは、IServiceProvider
で host
インスタンスを使用し、ジェネリック 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.";
}
ヒント
file
アクセス修飾子は、Program.cs ファイル内で定義され、Program.cs ファイルからのみアクセスされるため、AlphabetLetter
型で使用されます。 詳細については、 ファイル (C# リファレンス) を参照してください。 完全なソース コードについては、「 Program.cs 」セクションを参照してください。
このサンプルには、アルファベットを反復処理するヘルパー関数が含まれています。
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# コードでは:
- 変数
addLettersToCacheTask
はIterateAlphabetAsync
に委任され、その結果を待機します。 -
Func<char, Task> asyncFunc
はラムダと検討されます。 -
MemoryCacheEntryOptions
は、現在に対する絶対有効期限でインスタンス化されます。 - エヴィクション後のコールバックが登録されます。
-
AlphabetLetter
オブジェクトがインスタンス化され、Setとletter
と共にoptions
に渡されます。 - この文字は、キャッシュ中としてコンソールに書き込まれます。
- 最後に、 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
への別の呼び出しが待たれますが、今回は 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;
cache
にletter
キーが含まれており、value
がコンソールに書き込まれるAlphabetLetter
のインスタンスである場合。
letter
キーがキャッシュにない場合は、削除され、削除後のコールバックが呼び出されました。
その他の拡張メソッド
IMemoryCache
には、非同期GetOrCreateAsync
など、便利なベースの拡張メソッドが多数用意されています。
- CacheExtensions.Get
- CacheExtensions.GetOrCreate
- CacheExtensions.GetOrCreateAsync
- CacheExtensions.Set
- CacheExtensions.TryGetValue
すべてをまとめる
サンプル アプリのソース コード全体は最上位レベルのプログラムであり、次の 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.";
}
キャッシュされたエントリの有効期限と削除に対する動作の変化を観察するために、 MillisecondsDelayAfterAdd
と MillisecondsAbsoluteExpiration
の値を自由に調整してください。 このコードを実行した場合の出力例を次に示します。 .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が他のアプリケーション コードから独立して (またはバックグラウンドで) 実行されるため、優れた例です。
IHostedServiceの実装をホストするアプリケーションの実行を開始すると、対応する実装 (この場合は BackgroundService
または "worker") が同じプロセスで実行を開始します。 これらのホステッド サービスは、 AddHostedService<THostedService>(IServiceCollection) 拡張メソッドを使用して、シングルトンとして DI に登録されます。 その他のサービスは、任意の サービス有効期間で DI に登録できます。
Von Bedeutung
サービスの有効期間を理解することは非常に重要です。 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# コードでは:
- 汎用ホストは既定値で作成 されます。
- メモリ内キャッシュ サービスは、 AddMemoryCacheに登録されます。
-
HttpClient
インスタンスは、CacheWorker
を使用してAddHttpClient<TClient>(IServiceCollection) クラスに登録されます。 -
CacheWorker
クラスは、AddHostedService<THostedService>(IServiceCollection)に登録されます。 -
PhotoService
クラスは、AddScoped<TService>(IServiceCollection)に登録されます。 -
CacheSignal<T>
クラスは、AddSingletonに登録されます。 -
host
はビルダーからインスタンス化され、非同期的に開始されます。
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# コードでは:
- コンストラクターには、
IMemoryCache
、CacheSignal<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
) で使用できます。 キャッシュのシード処理を通知する役割を担います。
CacheWorker
は、BackgroundServiceのサブクラスです。
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# コードでは:
- コンストラクターには、
ILogger
、HttpClient
、およびIMemoryCache
が必要です。 -
_updateInterval
は3時間のために定義されています。 -
ExecuteAsync
メソッド:- アプリの実行中にループします。
-
"https://jsonplaceholder.typicode.com/photos"
への HTTP 要求を行い、応答をPhoto
オブジェクトの配列としてマップします。 - 写真の配列は、
IMemoryCache
キーの下の"Photos"
に配置されます。 -
_cacheSignal.Release()
が呼び出され、シグナルを待機していたコンシューマーが解放されます。 - 更新間隔を指定すると、 Task.Delay の呼び出しが待機されます。
- 3 時間遅延した後、キャッシュは再び更新されます。
同じプロセスのコンシューマーは写真の IMemoryCache
を要求できますが、 CacheWorker
はキャッシュの更新を担当します。
分散キャッシュ
一部のシナリオでは、分散キャッシュが必要です。複数のアプリ サーバーの場合などです。 分散キャッシュでは、メモリ内キャッシュアプローチよりも高いスケールアウトがサポートされます。 分散キャッシュを使用すると、キャッシュ メモリが外部プロセスにオフロードされますが、追加のネットワーク I/O が必要になり、(わずかな場合でも) 少し待ち時間が長くなります。
分散キャッシュの抽象化は、 Microsoft.Extensions.Caching.Memory
NuGet パッケージの一部であり、 AddDistributedMemoryCache
拡張メソッドもあります。
注意事項
AddDistributedMemoryCacheは、開発やテストのシナリオでのみ使用する必要があり、実行可能な運用環境の実装ではありません。
次のパッケージから IDistributedCache
の使用可能な実装を検討してください。
Microsoft.Extensions.Caching.SqlServer
Microsoft.Extensions.Caching.StackExchangeRedis
NCache.Microsoft.Extensions.Caching.OpenSource
分散キャッシュ API
分散キャッシュ API は、メモリ内キャッシュ API に対応する API よりも少しプリミティブです。 キーと値のペアは、もう少し基本的なものです。 メモリ内キャッシュ キーは object
に基づいていますが、分散キーは string
です。 メモリ内キャッシュでは、値は厳密に型指定された任意のジェネリックにすることができますが、分散キャッシュの値は byte[]
として保持されます。 異なる実装で厳密に型指定されたジェネリック値が公開されないわけではありませんが、その場合は実装の詳細になります。
値を作成する
分散キャッシュに値を作成するには、次のいずれかのセット API を呼び出します。
メモリ内キャッシュの例の AlphabetLetter
レコードを使用して、オブジェクトを JSON にシリアル化し、 string
を byte[]
としてエンコードできます。
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) があります。
拡張メソッドを作成する
値を作成する際に、オブジェクトの表現をstring
からbyte[]
にエンコードすることを回避するのに役立つ、便利な拡張メソッドがいくつかあります。
値の読み取り
分散キャッシュから値を読み取るには、get 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);
}
キャッシュエントリがキャッシュから読み出された後、UTF8でエンコードされたstring
の表現をbyte[]
から取得できます。
拡張メソッドの読み取り
値を読み取るための便利な拡張メソッドがいくつかあります。これは、オブジェクトのbyte[]
表現へのstring
のデコードを回避するのに役立ちます。
値を更新する
分散キャッシュ内の値を 1 つの API 呼び出しで更新する方法はありません。代わりに、値のスライディング有効期限を更新 API のいずれかでリセットできます。
実際の値を更新する必要がある場合は、値を削除してから再度追加する必要があります。
値を削除する
分散キャッシュ内の値を削除するには、次のいずれかの remove API を呼び出します。
ヒント
前述の API には同期バージョンがありますが、分散キャッシュの実装はネットワーク I/O に依存するという事実を考慮してください。 このため、非同期 API を使用しないよりも頻繁に使用することをお勧めします。
こちらも参照ください
.NET