Memorizzazione nella cache in .NET
In questo articolo verranno illustrati vari meccanismi di memorizzazione nella cache. La memorizzazione nella cache è l'atto di archiviare i dati in un livello intermedio, rendendo più veloce il recupero dei dati successivo. Concettualmente, la memorizzazione nella cache è una strategia di ottimizzazione delle prestazioni e una considerazione sulla progettazione. La memorizzazione nella cache può migliorare in modo significativo le prestazioni delle app, rendendo più facilmente disponibili i dati che vengono modificati di rado (o costosi da recuperare). Questo articolo introduce i due tipi principali di memorizzazione nella cache e fornisce un esempio di codice sorgente per entrambi:
Importante
Esistono due classi MemoryCache
all'interno di .NET, una nello spazio dei nomi System.Runtime.Caching
e l'altra nello spazio dei nomi Microsoft.Extensions.Caching
:
Anche se questo articolo incentrato sulla memorizzazione nella cache, non include il pacchetto NuGet System.Runtime.Caching
. Tutti i riferimenti a MemoryCache
si trovano all'interno dello spazio dei nomi Microsoft.Extensions.Caching
.
Tutti i pacchetti Microsoft.Extensions.*
sono pronti per l'inserimento delle dipendenze (DI), sia le interfacce IMemoryCache che quelle IDistributedCache possono essere usate come servizi.
In questa sezione verranno fornite informazioni sul pacchetto Microsoft.Extensions.Caching.Memory. L'implementazione corrente di IMemoryCache è un wrapper intorno a ConcurrentDictionary<TKey,TValue>, esponendo un'API dalle funzionalità avanzate. Le voci all'interno della cache sono rappresentate da ICacheEntry e possono essere qualsiasi object
. La soluzione della cache in memoria è ideale per le app che vengono eseguite in un singolo server, in cui tutti i dati memorizzati nella cache noleggiano memoria nel processo dell'app.
Suggerimento
Per gli scenari di memorizzazione nella cache multiserver, prendere in considerazione l'approccio di memorizzazione nella cache distribuita come alternativa alla memorizzazione nella cache in memoria.
L'utente consumer della cache ha il controllo sulle scadenze sia mobili che assolute:
- ICacheEntry.AbsoluteExpiration
- ICacheEntry.AbsoluteExpirationRelativeToNow
- ICacheEntry.SlidingExpiration
Se si imposta una scadenza, le voci della cache verranno rimosse se non sono accessibili entro il tempo assegnato. Gli utenti consumer dispongono di opzioni aggiuntive per controllare le voci della cache, tramite MemoryCacheEntryOptions. Ogni ICacheEntry è associato a MemoryCacheEntryOptions che espone la funzionalità di rimozione della scadenza con IChangeToken, le impostazioni di priorità con CacheItemPriority e il controllo di ICacheEntry.Size. Prendere in considerazione il seguente metodo di estensione:
- MemoryCacheEntryExtensions.AddExpirationToken
- MemoryCacheEntryExtensions.RegisterPostEvictionCallback
- MemoryCacheEntryExtensions.SetSize
- MemoryCacheEntryExtensions.SetPriority
Per usare l'implementazione predefinita di IMemoryCache, chiamare il metodo di estensione AddMemoryCache per registrare tutti i servizi necessari con l'inserimento delle dipendenze. Nell'esempio di codice seguente, l'host generico viene usato per esporre la funzionalità di inserimento delle dipendenze:
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();
A seconda del carico di lavoro di .NET, è possibile accedere a IMemoryCache
in modo diverso, ad esempio con l'inserimento di costruttori. In questo esempio si usa l'istanza IServiceProvider
in host
e si chiama il metodo di estensione generico GetRequiredService<T>(IServiceProvider):
IMemoryCache cache =
host.Services.GetRequiredService<IMemoryCache>();
Con i servizi di memorizzazione nella cache in memoria registrati e risolti tramite inserimento delle dipendenze, è possibile avviare la memorizzazione nella cache. Questo esempio esegue l'iterazione delle lettere dell'alfabeto inglese dalla 'A' alla 'Z'. Il tipo record AlphabetLetter
contiene il riferimento alla lettera e genera un messaggio.
file record AlphabetLetter(char Letter)
{
internal string Message =>
$"The '{Letter}' character is the {Letter - 64} letter in the English alphabet.";
}
Suggerimento
Il modificatore di accesso file
viene usato nel tipo AlphabetLetter
, come definito all'interno ed è accessibile solo dal file Program.cs. Per altre informazioni, vedere file (Riferimenti per C#). Per visualizzare il codice sorgente completo, vedere la sezione Program.cs.
L'esempio include una funzione helper che esegue l'iterazione delle lettere dell'alfabeto:
static async ValueTask IterateAlphabetAsync(
Func<char, Task> asyncFunc)
{
for (char letter = 'A'; letter <= 'Z'; ++letter)
{
await asyncFunc(letter);
}
Console.WriteLine();
}
Nel codice C# precedente:
- L'oggetto
Func<char, Task> asyncFunc
è atteso a ogni iterazione, passando l'oggettoletter
corrente. - Dopo l'elaborazione di tutte le lettere, viene scritta una riga vuota nella console.
Per aggiungere elementi alla cache, chiamare una delle API Create
o Set
:
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;
Nel codice C# precedente:
- La variabile
addLettersToCacheTask
delega aIterateAlphabetAsync
ed è in attesa. - L'oggetto
Func<char, Task> asyncFunc
è sostenuto con un'espressione lambda. - Viene creata un'istanza del tipo
MemoryCacheEntryOptions
con una scadenza assoluta rispetto al presente. - Viene registrato un callback post-rimozione.
- Viene creata l'istanza di un oggetto
AlphabetLetter
e passata in Set insieme aletter
eoptions
. - La lettera viene scritta nella console come memorizzata nella cache.
- Infine, viene restituito un Task.Delay.
Per ogni lettera dell'alfabeto, viene scritta una voce della cache con una scadenza e un callback post-rimozione.
Il callback post-rimozione scrive i dettagli del valore rimosso nella console:
static void OnPostEviction(
object key, object? letter, EvictionReason reason, object? state)
{
if (letter is AlphabetLetter alphabetLetter)
{
Console.WriteLine($"{alphabetLetter.Letter} was evicted for {reason}.");
}
};
Ora che la cache è popolata, è attesa un'altra chiamata a IterateAlphabetAsync
, ma questa volta si chiamerà 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;
Se cache
contiene la chiave letter
e value
è un'istanza di un oggetto AlphabetLetter
, viene scritto nella console. Quando la chiave letter
non è presente nella cache, è stata rimossa e il callback post rimozione è stato richiamato.
Viene fornito IMemoryCache
con molti metodi di estensione basati sulla convenienza, tra cui un GetOrCreateAsync
asincrono :
- CacheExtensions.Get
- CacheExtensions.GetOrCreate
- CacheExtensions.GetOrCreateAsync
- CacheExtensions.Set
- CacheExtensions.TryGetValue
L'intero codice sorgente dell'app di esempio è un programma di primo livello e richiede due pacchetti 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.";
}
È possibile modificare i valori MillisecondsDelayAfterAdd
e MillisecondsAbsoluteExpiration
per osservare le modifiche nel comportamento alla scadenza e alla rimozione delle voci memorizzate nella cache. Di seguito sono riportati gli esempi di output dell'esecuzione di questo codice. A causa della natura non deterministica degli eventi .NET, l'output potrebbe essere diverso.
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.
Poiché è stata impostata la scadenza assoluta (MemoryCacheEntryOptions.AbsoluteExpirationRelativeToNow), tutti gli elementi memorizzati nella cache verranno eliminati.
Una strategia comune per la memorizzazione nella cache dei dati consiste nell'aggiornare la cache in modo indipendente dai servizi dati che usano. Il modello del Servizio del ruolo di lavoro è un ottimo esempio, perché BackgroundService viene eseguito indipendente (o in background) dall'altro codice dell'applicazione. Quando un'applicazione avvia l'esecuzione che ospita un'implementazione di IHostedService, l'implementazione corrispondente (in questo caso BackgroundService
o il "ruolo di lavoro") inizia a essere eseguita nello stesso processo. Questi servizi ospitati vengono registrati con l'inserimento delle dipendenze come singleton, tramite il metodo di estensione AddHostedService<THostedService>(IServiceCollection). Altri servizi possono essere registrati con l'inserimento delle dipendenze con qualsiasi durata di servizio.
Importante
La durata di servizio è molto importante da comprendere. Quando si chiama AddMemoryCache per registrare tutti i servizi di memorizzazione nella cache in memoria, i servizi vengono registrati come singleton.
Si supponga di sviluppare un servizio fotografico che si basa su API di terze parti accessibili tramite HTTP. Questi dati fotografici non cambiano molto spesso, ma sono numerosi. Ogni foto è rappresentata da un semplice record
:
namespace CachingExamples.Memory;
public readonly record struct Photo(
int AlbumId,
int Id,
string Title,
string Url,
string ThumbnailUrl);
Nell'esempio seguente vengono visualizzati diversi servizi registrati con l'inserimento delle dipendenze. Ogni servizio ha un'unica responsabilità.
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();
Nel codice C# precedente:
- L'host generico viene creato con le impostazioni predefinite.
- I servizi di memorizzazione nella cache in memoria vengono registrati con AddMemoryCache.
- Un'istanza
HttpClient
viene registrata per la classeCacheWorker
con AddHttpClient<TClient>(IServiceCollection). - La classe
CacheWorker
viene registrata con AddHostedService<THostedService>(IServiceCollection). - La classe
PhotoService
viene registrata con AddScoped<TService>(IServiceCollection). - La classe
CacheSignal<T>
viene registrata con AddSingleton. host
viene creata un'istanza dal generatore e avviata in modo asincrono.
PhotoService
è responsabile del recupero di foto che soddisfano i criteri specificati (o 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();
}
}
}
Nel codice C# precedente:
- Il costruttore richiede un
IMemoryCache
,CacheSignal<Photo>
eILogger
. - Il metodo
GetPhotosAsync
:- Definisce un parametro
Func<Photo, bool> filter
e restituisce unIAsyncEnumerable<Photo>
. - Chiama e attende il rilascio di
_cacheSignal.WaitAsync()
, in modo che la cache venga popolata prima di accedervi. - Chiama
_cache.GetOrCreateAsync()
, ottenendo in modo asincrono tutte le foto nella cache. - L'argomento
factory
genera un avviso e restituisce una matrice di foto vuota: questo non dovrebbe mai accadere. - Viene eseguita l'iterazione di ogni foto nella cache, filtrata e materializzata con
yield return
. - Infine, il segnale della cache viene reimpostato.
- Definisce un parametro
I consumatori di questo servizio sono liberi di chiamare il metodo GetPhotosAsync
e gestire le foto di conseguenza. Non è necessario alcun HttpClient
perché la cache contiene le foto.
Il segnale asincrono si basa su un'istanza SemaphoreSlim incapsulata, all'interno di un singleton vincolato di tipo generico. CacheSignal<T>
si basa su un'istanza di 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();
}
Nel codice C# precedente, il modello dell'elemento decorator viene usato per eseguire il wrapping di un'istanza di SemaphoreSlim
. Poiché CacheSignal<T>
è registrato come singleton, può essere usato per tutta la durata del servizio con qualsiasi tipo generico; in questo caso Photo
. È responsabile della segnalazione del seeding della cache.
CacheWorker
è una sottoclasse di 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;
}
}
}
}
Nel codice C# precedente:
- Il costruttore richiede un
ILogger
,HttpClient
eIMemoryCache
. _updateInterval
viene definito per tre ore.- Il metodo
ExecuteAsync
:- Cicli durante l'esecuzione dell'app.
- Effettua una richiesta HTTP a
"https://jsonplaceholder.typicode.com/photos"
ed esegue il mapping della risposta come matrice di oggettiPhoto
. - La matrice di foto viene posizionata in
IMemoryCache
nella chiave"Photos"
. _cacheSignal.Release()
viene chiamato, rilasciando tutti i consumatori in attesa del segnale.- La chiamata a Task.Delay è attesa, in base all'intervallo di aggiornamento.
- Dopo un ritardo di tre ore, la cache viene aggiornata di nuovo.
Gli utenti consumer nello stesso processo potrebbero chiedere le foto a IMemoryCache
, ma CacheWorker
è responsabile dell'aggiornamento della cache.
In alcuni scenari è necessaria una cache distribuita, come nel caso di più server app. Una cache distribuita supporta uno scale-out maggiore rispetto all'approccio di memorizzazione nella cache in memoria. L'uso di una cache distribuita esegue l'offload della memoria della cache in un processo esterno, ma richiede un I/O di rete aggiuntivo e introduce una latenza maggiore (anche se nominale).
Le astrazioni della memorizzazione nella cache distribuita fanno parte del pacchetto NuGet Microsoft.Extensions.Caching.Memory
ed esiste anche un metodo di estensione AddDistributedMemoryCache
.
Attenzione
AddDistributedMemoryCache deve essere usato solo negli scenari di sviluppo e/o di test e non è un'implementazione valida per la produzione.
Prendere in considerazione una qualsiasi delle implementazioni disponibili di IDistributedCache
dai pacchetti seguenti:
Microsoft.Extensions.Caching.SqlServer
Microsoft.Extensions.Caching.StackExchangeRedis
NCache.Microsoft.Extensions.Caching.OpenSource
Le API di memorizzazione nella cache distribuita sono un po' più primitive delle loro controparti API di memorizzazione nella cache in memoria. Le coppie chiave-valore sono un po' più semplici. Le chiavi della memorizzazione nella cache in memoria sono basate su object
, mentre le chiavi distribuite sono string
. Con la memorizzazione nella cache in memoria, il valore può essere qualsiasi generico fortemente tipizzato, mentre i valori nella memorizzazione nella cache distribuita vengono resi persistenti come byte[]
. Questo non vuol dire che varie implementazioni non espongano valori generici fortemente tipizzati, ma questo sarebbe un dettaglio di implementazione.
Per creare valori nella cache distribuita, chiamare uno dei set di API:
Usando il record AlphabetLetter
dell'esempio di cache in memoria, è possibile serializzare l'oggetto in JSON, quindi codificare string
come 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);
Analogamente alla memorizzazione nella cache in memoria, le voci della cache possono avere delle opzioni che consentono di ottimizzare la loro presenza nella cache; in questo caso, la DistributedCacheEntryOptions.
Esistono diversi metodi di estensione basati sulla convenienza per la creazione di valori, che consentono di evitare di codificare le rappresentazioni string
degli oggetti in un byte[]
:
Per leggere i valori dalla cache distribuita, chiamare una delle API get:
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);
}
Una volta che una voce della cache viene letta, è possibile ottenere la rappresentazione codificata UTF8 string
da byte[]
Esistono diversi metodi di estensione basati sulla convenienza per la lettura dei valori, che aiutano a evitare la decodifica di byte[]
nelle rappresentazioni string
degli oggetti:
Non è possibile aggiornare i valori nella cache distribuita con una singola chiamata API, ma i valori possono reimpostare le scadenze le scadenze mobili con una delle API di aggiornamento:
Se il valore effettivo deve essere aggiornato, è necessario eliminarlo quindi aggiungerlo nuovamente.
Per eliminare i valori nella cache distribuita, chiamare una delle API di rimozione:
Suggerimento
Anche se sono presenti versioni sincrone delle API sopra indicate, si tenga conto del fatto che le implementazioni delle cache distribuite siano dipendenti dall'I/O di rete. Per questo motivo, è preferibile usare più spesso le API asincrone.
Feedback su .NET
.NET è un progetto di open source. Selezionare un collegamento per fornire feedback: