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.

Memorizzazione nella cache in memoria

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.

API di memorizzazione nella cache in memoria

L'utente consumer della cache ha il controllo sulle scadenze sia mobili che assolute:

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:

Esempio di cache in memoria

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'oggetto letter corrente.
  • Dopo l'elaborazione di tutte le lettere, viene scritta una riga vuota nella console.

Per aggiungere elementi alla cache, chiamare una delle API Createo 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 a IterateAlphabetAsync 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 a letter e options.
  • 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.

Metodi di estensione aggiuntivi

Viene fornito IMemoryCache con molti metodi di estensione basati sulla convenienza, tra cui un GetOrCreateAsync asincrono :

Combinare tutti gli elementi

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.

Memorizzazione nella cache del servizio del ruolo di lavoro

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.

Scenario del servizio foto

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:

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>e ILogger.
  • Il metodo GetPhotosAsync:
    • Definisce un parametro Func<Photo, bool> filter e restituisce un IAsyncEnumerable<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.

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, HttpCliente IMemoryCache.
  • _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 oggetti Photo.
    • 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.

Memorizzazione nella cache distribuita

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:

API di memorizzazione nella cache distribuita

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.

Creare valori

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.

Creare metodi di estensione

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[]:

Leggere i valori

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[]

Leggere i metodi di estensione

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:

Aggiornare i valori

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.

Eliminare valori

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.

Vedi anche