Condividi tramite


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 significativamente le prestazioni delle app rendendo i dati che cambiano raramente (o costosi da recuperare) più facilmente disponibili. Questo articolo presenta i due tipi principali di memorizzazione nella cache e fornisce codice sorgente di esempio per entrambi:

Importante

Esistono due MemoryCache classi 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 System.Runtime.Caching pacchetto NuGet. Tutti i riferimenti a MemoryCache si trovano all'interno dello spazio dei nomi Microsoft.Extensions.Caching.

Tutti i pacchetti sono pronti per l'iniezione delle dipendenze (DI), sia le interfacce Microsoft.Extensions.* che le interfacce IMemoryCache 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>, che espone un'API ricca di funzionalità. Le voci all'interno della cache sono rappresentate da ICacheEntrye possono essere qualsiasi object. La soluzione di cache nella memoria è ideale per le applicazioni eseguite su un singolo server, in cui tutti i dati memorizzati nella cache occupano memoria nel processo dell'applicazione.

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 cache in memoria

Il consumatore della cache ha il controllo sulle scadenze sia scorrevoli che assolute.

Se si imposta una scadenza, le voci nella cache verranno rimosse se non sono accessibili entro l'ora di scadenza. I consumatori hanno opzioni aggiuntive per controllare le voci della cache, tramite MemoryCacheEntryOptions. Ogni ICacheEntry oggetto è associato a MemoryCacheEntryOptions che espone la funzionalità di rimozione della scadenza con IChangeToken, le impostazioni di priorità con CacheItemPrioritye il controllo di ICacheEntry.Size. Considerare i metodi di estensione seguenti:

Esempio di cache in memoria

Per usare l'implementazione predefinita IMemoryCache , chiamare il AddMemoryCache metodo di estensione 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 .NET, potresti accedere a IMemoryCache in modo diverso, ad esempio tramite injection del costruttore. In questo esempio, si utilizza l'istanza IServiceProvider su 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 Dependency Injection (DI), è possibile iniziare la memorizzazione nella cache. Questo esempio naviga tra le lettere dell'alfabeto inglese da 'A' a 'Z'. Il record AlphabetLetter tipo 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 file modificatore di accesso viene utilizzato sul AlphabetLetter tipo, poiché è definito all'interno e può essere 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 di supporto che itera attraverso le 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:

  • Il Func<char, Task> asyncFunc è atteso ad ogni iterazione, mentre si passa il corrente letter.
  • Dopo l'elaborazione di tutte le lettere, viene scritta una riga vuota nella console.

Per aggiungere elementi alla cache, chiamare una delle CreateAPI 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 a IterateAlphabetAsync ed è attesa.
  • Il Func<char, Task> asyncFunc viene argomentato con una lambda.
  • L'istanza MemoryCacheEntryOptions viene creata con una scadenza assoluta rispetto al momento attuale.
  • Viene registrato un callback post-rimozione.
  • Viene istanziato un oggetto AlphabetLetter e passato in Set insieme a letter e options.
  • La lettera viene scritta nella console come memorizzata nella cache.
  • Infine, viene restituito un oggetto Task.Delay .

Per ogni lettera nell'alfabeto, una voce della cache viene scritta con una scadenza e dopo il callback di rimozione.

Il callback post-rimozione scrive nella console i dettagli del valore rimosso.

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 AlphabetLetter, viene scritto nella console. Quando la letter chiave non è presente nella cache, è stata rimossa e il callback post rimozione è stato richiamato.

Metodi di estensione aggiuntivi

IMemoryCache è dotato di numerosi metodi di estensione basati sulla comodità, tra cui un metodo asincrono GetOrCreateAsync.

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 di MillisecondsDelayAfterAdd e MillisecondsAbsoluteExpiration per osservare i cambiamenti nel comportamento riguardo la scadenza e la rimozione delle voci memorizzate nella cache. Di seguito è riportato l'output di esempio 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é la scadenza assoluta (MemoryCacheEntryOptions.AbsoluteExpirationRelativeToNow) è impostata, tutti gli elementi memorizzati nella cache verranno eliminati.

Memorizzazione nella cache del servizio 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 servizio di lavoro è un ottimo esempio, perché viene BackgroundService eseguito indipendente (o in background) dall'altro codice dell'applicazione. Quando un'applicazione inizia a funzionare ospitando un'implementazione di IHostedService, l'implementazione corrispondente (in questo caso BackgroundService o "lavoratore") viene eseguita nello stesso processo. Questi servizi ospitati vengono registrati con DI come singleton, tramite il AddHostedService<THostedService>(IServiceCollection) metodo di estensione. Altri servizi possono essere registrati tramite DI con qualsiasi durata di vita del servizio.

Importante

La durata del servizio è molto importante da comprendere. Quando si chiama AddMemoryCache per registrare tutti i servizi di caching in memoria, questi vengono registrati come singleton.

Scenario del servizio foto

Si supponga di sviluppare un servizio fotografico che si basa sull'API di terze parti accessibile tramite HTTP. Questi dati fotografici non cambiano molto spesso, ma c'è molto di esso. 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, vedrai diversi servizi registrati in DI. Ogni servizio ha una singola 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'oggetto PhotoService è responsabile dell'ottenimento di foto che corrispondono ai criteri dati (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 Func<Photo, bool> filter parametro e restituisce un oggetto IAsyncEnumerable<Photo>.
    • Chiama e attende che il _cacheSignal.WaitAsync() venga rilasciato, in modo che la cache sia popolata prima dell'accesso.
    • Richiama _cache.GetOrCreateAsync(), in modo asincrono ottenendo tutte le foto nella cache.
    • L'argomento factory registra un avviso e restituisce una matrice di foto vuota. Questo non dovrebbe mai verificarsi.
    • Ogni foto nella cache viene iterata, filtrata e materializzata con yield return.
    • Infine, il segnale della cache viene reimpostato.

Gli utenti di questo servizio sono liberi di chiamare il metodo GetPhotosAsync e gestire le foto di conseguenza. Non HttpClient è necessario perché la cache contiene le foto.

Il segnale asincrono si basa su un'istanza incapsulata, all'interno di un singleton vincolato SemaphoreSlim di tipo generico. Si basa su un'istanza di 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();
}

Nel codice C# precedente, il modello decorator viene utilizzato per avvolgere un'istanza di SemaphoreSlim. Poiché CacheSignal<T> è registrato come singleton, può essere utilizzato 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 e IMemoryCache.
  • L'oggetto _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"e esegue il mapping della risposta come matrice di Photo oggetti.
    • La matrice di foto è collocata nel IMemoryCache sotto la chiave "Photos".
    • Viene _cacheSignal.Release() chiamato , rilasciando tutti i consumatori che erano in attesa del segnale.
    • La chiamata a Task.Delay è in attesa, considerando l'intervallo di aggiornamento.
    • Dopo aver ritardato per tre ore, la cache viene aggiornata di nuovo.

I consumatori 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, ad esempio con più server app. Una cache distribuita supporta una maggiore scalabilità 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 operazioni di I/O di rete aggiuntive e introduce una latenza maggiore (anche se nominale).

Le astrazioni di memorizzazione nella cache distribuite fanno parte del Microsoft.Extensions.Caching.Memory pacchetto NuGet e c'è persino un metodo di AddDistributedMemoryCache estensione.

Attenzione

Deve AddDistributedMemoryCache essere usato solo negli scenari di sviluppo e/o test e non è una soluzione valida per la produzione.

Prendere in considerazione una 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 rispetto alle controparti dell'API di memorizzazione nella cache in memoria. Le coppie chiave-valore sono un po' più di base. Le chiavi della cache in memoria sono basate su un object, mentre quelle distribuite sono basate su un 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 mantenuti come byte[]. Non si tratta di dire che varie implementazioni non espongono valori generici fortemente tipizzati, ma che sarebbe un dettaglio di implementazione.

Creare valori

Per creare valori nella cache distribuita, chiamare una delle API set:

Usando il AlphabetLetter record dell'esempio di cache in memoria, è possibile serializzare l'oggetto in JSON e 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 opzioni per ottimizzare la loro esistenza nella cache, in questo caso .DistributedCacheEntryOptions

Creare metodi di estensione

Esistono diversi metodi di estensione pratici per creare valori, che aiutano a evitare di codificare string le rappresentazioni 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 recuperare la rappresentazione codificata in UTF8 string da byte[].

Leggi i metodi di estensione

Esistono diversi metodi di estensione basati su praticità per la lettura dei valori, che consentono di evitare la decodifica byte[] in string rappresentazioni di oggetti:

Aggiornare i valori

Non è possibile aggiornare i valori nella cache distribuita con una singola chiamata API, ma i valori possono reimpostare le scadenze scorrevoli con una delle API di aggiornamento:

Se il valore effettivo deve essere aggiornato, è necessario eliminare il valore e quindi aggiungerlo nuovamente.

Elimina 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, considerare il fatto che le implementazioni delle cache distribuite siano dipendenti dall'I/O di rete. Per questo motivo, è preferibile usare più spesso le API asincrone.

Vedere anche