Partager via


Mise en cache dans .NET

Dans cet article, vous allez découvrir différents mécanismes de mise en cache. La mise en cache est l’acte de stockage des données dans une couche intermédiaire, ce qui accélère les récupérations de données suivantes. Conceptuellement, la mise en cache est une stratégie d’optimisation des performances et une considération de conception. La mise en cache peut améliorer considérablement les performances des applications en rendant les données peu modifiées (ou coûteuses à récupérer) plus facilement disponibles. Cet article présente les deux principaux types de mise en cache et fournit des exemples de code source pour les deux :

Important

Il existe deux MemoryCache classes dans .NET, une dans l’espace System.Runtime.Caching de noms et l’autre dans l’espace Microsoft.Extensions.Caching de noms :

Bien que cet article se concentre sur la mise en cache, il n’inclut pas le System.Runtime.Caching package NuGet. Toutes les références à MemoryCache faire se trouvent dans l’espace Microsoft.Extensions.Caching de noms.

Tous les Microsoft.Extensions.* packages sont prêts pour l’injection de dépendances ,les interfaces et IDistributedCache les IMemoryCache interfaces peuvent être utilisées comme services.

Mise en cache en mémoire

Dans cette section, vous allez découvrir le package Microsoft.Extensions.Caching.Memory . L’implémentation actuelle du IMemoryCache wrapper est un wrapper autour du ConcurrentDictionary<TKey,TValue>, exposant une API riche en fonctionnalités. Les entrées dans le cache sont représentées par le ICacheEntry, et peuvent être n’importe quelle object. La solution de cache en mémoire est idéale pour les applications qui s’exécutent sur un seul serveur, où toutes les données mises en cache louent de la mémoire dans le processus de l’application.

Conseil / Astuce

Pour les scénarios de mise en cache multiserveur, envisagez l’approche de mise en cache distribuée comme alternative à la mise en cache en mémoire.

API de mise en cache en mémoire

Le consommateur du cache a un contrôle sur les expirations glissantes et absolues :

La définition d’une expiration entraîne la suppression des entrées dans le cache si elles ne sont pas accessibles dans le délai d’expiration. Les consommateurs disposent d’options supplémentaires pour contrôler les entrées du cache, via le MemoryCacheEntryOptions. Chacun ICacheEntry d’eux est jumelé MemoryCacheEntryOptions avec lequel expose la fonctionnalité d’éviction d’expiration avec IChangeToken, les paramètres de priorité avec CacheItemPriorityet le contrôle du ICacheEntry.Size. Tenez compte des méthodes d’extension suivantes :

Exemple de cache en mémoire

Pour utiliser l’implémentation par défaut IMemoryCache , appelez la AddMemoryCache méthode d’extension pour inscrire tous les services requis avec di. Dans l’exemple de code suivant, l’hôte générique est utilisé pour exposer les fonctionnalités d’i 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();

Selon votre charge de travail .NET, vous pouvez accéder différemment IMemoryCache , comme l’injection de constructeur. Dans cet exemple, vous utilisez l’instance IServiceProvider sur la host méthode d’extension générique GetRequiredService<T>(IServiceProvider) et appelez :

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

Avec les services de mise en cache en mémoire inscrits et résolus par le biais de l’utilisation de l’aide à distance, vous êtes prêt à commencer la mise en cache. Cet exemple itère les lettres de l’alphabet anglais « A » à « Z ». Le record AlphabetLetter type contient la référence à la lettre et génère un message.

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

Conseil / Astuce

Le file modificateur d’accès est utilisé sur le AlphabetLetter type, car il est défini dans le fichier Program.cs et accessible uniquement. Pour plus d’informations, consultez le fichier (référence C#). Pour afficher le code source complet, consultez la section Program.cs .

L’exemple inclut une fonction d’assistance qui itère à travers les lettres alphabétiques :

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

    Console.WriteLine();
}

Dans le code C# précédent :

  • La Func<char, Task> asyncFunc valeur est attendue sur chaque itération, en passant le courant letter.
  • Une fois toutes les lettres traitées, une ligne vide est écrite dans la console.

Pour ajouter des éléments au cache, appelez l’une Createdes API ou Set les 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;

Dans le code C# précédent :

  • Les délégués de variable addLettersToCacheTask vers IterateAlphabetAsync et sont attendus.
  • Il Func<char, Task> asyncFunc est argumenté avec une lambda.
  • L’instanciation MemoryCacheEntryOptions est instanciée avec une expiration absolue par rapport à l’heure actuelle.
  • Un rappel post-éviction est inscrit.
  • Un AlphabetLetter objet est instancié et passé avec Setletter et options.
  • La lettre est écrite dans la console comme étant mise en cache.
  • Enfin, un Task.Delay est retourné.

Pour chaque lettre de l’alphabet, une entrée de cache est écrite avec une expiration et un rappel post-éviction.

Le rappel post-éviction écrit les détails de la valeur qui a été supprimée dans la 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}.");
    }
};

Maintenant que le cache est rempli, un autre appel est IterateAlphabetAsync attendu, mais cette fois, vous appelez 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;

Si la cacheletter clé contient et qu’elle value est une instance d’une AlphabetLetter clé écrite dans la console. Lorsque la letter clé n’est pas dans le cache, elle a été supprimée et son rappel post-éviction a été appelé.

Méthodes d’extension supplémentaires

Il IMemoryCache est fourni avec de nombreuses méthodes d’extension basées sur la commodité, y compris une méthode asynchrone GetOrCreateAsync:

Mets tout ensemble

L’exemple de code source d’application entier est un programme de niveau supérieur et nécessite deux packages 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.";
}

N’hésitez pas à ajuster les MillisecondsDelayAfterAdd valeurs et MillisecondsAbsoluteExpiration à observer les changements de comportement en fonction de l’expiration et de l’éviction des entrées mises en cache. Voici un exemple de sortie de l’exécution de ce code. En raison de la nature non déterministe des événements .NET, votre sortie peut être différente.

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.

Étant donné que l’expiration absolue (MemoryCacheEntryOptions.AbsoluteExpirationRelativeToNow) est définie, tous les éléments mis en cache seront finalement supprimés.

Mise en cache du service Worker

Une stratégie courante pour la mise en cache des données consiste à mettre à jour le cache indépendamment des services de données consommants. Le modèle de service Worker est un excellent exemple, car les BackgroundService exécutions sont indépendantes (ou en arrière-plan) de l’autre code d’application. Lorsqu’une application démarre en cours d’exécution qui héberge une implémentation du IHostedService, l’implémentation correspondante (dans ce cas, le BackgroundService « worker ») commence à s’exécuter dans le même processus. Ces services hébergés sont inscrits auprès de l’authentification unique en tant que singletons, par le biais de la méthode d’extension AddHostedService<THostedService>(IServiceCollection) . D’autres services peuvent être inscrits auprès d’un di avec n’importe quelle durée de vie du service.

Important

La durée de vie du service est très importante à comprendre. Lorsque vous appelez AddMemoryCache pour inscrire tous les services de mise en cache en mémoire, les services sont inscrits en tant que singletons.

Scénario de service photo

Imaginez que vous développez un service photo qui s’appuie sur l’API tierce accessible via HTTP. Ces données photo ne changent pas très souvent, mais il y en a beaucoup. Chaque photo est représentée par un simple record:

namespace CachingExamples.Memory;

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

Dans l’exemple suivant, vous verrez que plusieurs services sont inscrits auprès d’un di. Chaque service a une responsabilité unique.

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();

Dans le code C# précédent :

Il PhotoService est responsable de l’obtention de photos qui correspondent à des critères donnés (ou 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();
        }
    }
}

Dans le code C# précédent :

  • Le constructeur nécessite un IMemoryCache, CacheSignal<Photo>et ILogger.
  • La GetPhotosAsync méthode :
    • Définit un Func<Photo, bool> filter paramètre et retourne un IAsyncEnumerable<Photo>.
    • Appelle et attend la _cacheSignal.WaitAsync() mise en production, cela garantit que le cache est rempli avant d’accéder au cache.
    • Appels _cache.GetOrCreateAsync(), obtention asynchrone de toutes les photos dans le cache.
    • L’argument factory enregistre un avertissement et retourne un tableau de photos vide . Cela ne doit jamais se produire.
    • Chaque photo dans le cache est itérée, filtrée et matérialisée avec yield return.
    • Enfin, le signal de cache est réinitialisé.

Les consommateurs de ce service sont libres d’appeler GetPhotosAsync la méthode et de gérer les photos en conséquence. Aucune valeur n’est HttpClient requise, car le cache contient les photos.

Le signal asynchrone est basé sur une instance encapsulée SemaphoreSlim , au sein d’un singleton limité de type générique. S’appuie CacheSignal<T> sur une instance de 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();
}

Dans le code C# précédent, le modèle décoratif est utilisé pour encapsuler une instance du SemaphoreSlim. Étant donné que l’objet CacheSignal<T> est inscrit en tant que singleton, il peut être utilisé dans toutes les durées de vie du service avec n’importe quel type générique , dans ce cas, le Photo. Il est responsable de la signalisation de l’amorçage du cache.

Il CacheWorker s’agit d’une sous-classe de 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;
            }
        }
    }
}

Dans le code C# précédent :

  • Le constructeur nécessite un ILogger, HttpClientet IMemoryCache.
  • La _updateInterval valeur est définie pendant trois heures.
  • La ExecuteAsync méthode :
    • Boucles pendant l’exécution de l’application.
    • Effectue une requête HTTP et "https://jsonplaceholder.typicode.com/photos"mappe la réponse en tant que tableau d’objets Photo .
    • Le tableau de photos est placé sous IMemoryCache la "Photos" clé.
    • Le _cacheSignal.Release() message est appelé, mettant en liberté tous les consommateurs qui attendaient le signal.
    • L’appel à est Task.Delay attendu, en fonction de l’intervalle de mise à jour.
    • Après un délai de trois heures, le cache est à nouveau mis à jour.

Les consommateurs du même processus peuvent demander les IMemoryCache photos, mais il CacheWorker est responsable de la mise à jour du cache.

Mise en cache distribuée

Dans certains scénarios, un cache distribué est requis, c’est-à-dire avec plusieurs serveurs d’applications. Un cache distribué prend en charge un scale-out supérieur à l’approche de mise en cache en mémoire. L’utilisation d’un cache distribué décharge la mémoire du cache dans un processus externe, mais nécessite des E/S réseau supplémentaires et introduit un peu plus de latence (même si nominale).

Les abstractions de mise en cache distribuée font partie du Microsoft.Extensions.Caching.Memory package NuGet, et il existe même une méthode d’extension AddDistributedMemoryCache .

Avertissement

Il AddDistributedMemoryCache ne doit être utilisé que dans les scénarios de développement et/ou de test, et n’est pas une implémentation de production viable.

Tenez compte de l’une des implémentations disponibles des IDistributedCache packages suivants :

API de mise en cache distribuée

Les API de mise en cache distribuée sont un peu plus primitives que leurs équivalents d’API de mise en cache en mémoire. Les paires clé-valeur sont un peu plus simples. Les clés de mise en cache en mémoire sont basées sur un object, tandis que les clés distribuées sont un string. Avec la mise en cache en mémoire, la valeur peut être n’importe quel générique fortement typé, tandis que les valeurs dans la mise en cache distribuée sont conservées en tant que byte[]. Cela ne veut pas dire que différentes implémentations n’exposent pas de valeurs génériques fortement typées, mais qu’il s’agirait d’un détail d’implémentation.

Créer des valeurs

Pour créer des valeurs dans le cache distribué, appelez l’une des API set :

À l’aide de l’enregistrement AlphabetLetter à partir de l’exemple de cache en mémoire, vous pouvez sérialiser l’objet au format JSON, puis encoder l’objet string en tant que :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);

Tout comme la mise en cache en mémoire, les entrées de cache peuvent avoir des options pour affiner leur existence dans le cache , dans ce cas, le DistributedCacheEntryOptions.

Créer des méthodes d’extension

Il existe plusieurs méthodes d’extension basées sur la commodité pour créer des valeurs, ce qui permet d’éviter les représentations d’encodage string d’objets dans un byte[]:

Lire les valeurs

Pour lire des valeurs à partir du cache distribué, appelez l’une des 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);
}

Une fois qu’une entrée de cache est lue dans le cache, vous pouvez obtenir la représentation encodée string UTF8 à partir de la byte[]

Lire les méthodes d’extension

Il existe plusieurs méthodes d’extension basées sur la commodité pour lire des valeurs, ce qui permet d’éviter le décodage byte[] en string représentations d’objets :

Mettre à jour les valeurs

Il n’existe aucun moyen de mettre à jour les valeurs dans le cache distribué avec un seul appel d’API, au lieu de cela, les valeurs peuvent avoir leurs expirations glissantes réinitialisées avec l’une des API d’actualisation :

Si la valeur réelle doit être mise à jour, vous devrez supprimer la valeur, puis la rajouter.

Supprimer des valeurs

Pour supprimer des valeurs dans le cache distribué, appelez l’une des API de suppression :

Conseil / Astuce

Bien qu’il existe des versions synchrones des API mentionnées ci-dessus, tenez compte du fait que les implémentations de caches distribués dépendent des E/S réseau. Pour cette raison, il est préférable plus souvent d’utiliser les API asynchrones.

Voir aussi