Mise en cache dans .NET

Dans cet article, vous allez découvrir différents mécanismes de mise en cache. La mise en cache consiste à stocker des données dans une couche intermédiaire, ce qui accélère les extractions de données suivantes. D’un point de vue conceptuel, 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 un exemple de code source pour les deux :

Important

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

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

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

Mise en cache en mémoire

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

Conseil

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 contrôle serveur consommateur du cache contrôle à la fois 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 alloué. Les contrôles serveur consommateur disposent d’options supplémentaires pour contrôler les entrées de cache, par le biais de MemoryCacheEntryOptions. Chaque ICacheEntry est associé à MemoryCacheEntryOptions ce qui expose la fonctionnalité d’éviction d’expiration avec IChangeToken, les paramètres de priorité avec CacheItemPriority et le contrôle de ICacheEntry.Size. Considérez les méthodes d’extension suivantes :

Exemple de cache en mémoire

Pour utiliser l’implémentation IMemoryCache par défaut, appelez la méthode d’extension AddMemoryCache pour inscrire tous les services requis avec DI. Dans l’exemple de code suivant, l’hôte générique est utilisé pour exposer la fonctionnalité 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 de manière différente à IMemoryCache, comme avec l’injection de constructeur. Dans cet exemple, vous utilisez l’instance IServiceProvider sur host et appelez une méthode d’extension générique GetRequiredService<T>(IServiceProvider) :

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

Avec les services de mise en cache en mémoire inscrits et résolus par l’injection de dépendances, vous êtes prêt à commencer la mise en cache. Cet exemple itère à travers les lettres de l’alphabet anglais « A » à « Z ». Le type record AlphabetLetter 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

Le modificateur d’accès file est utilisé sur le type AlphabetLetter, car il est défini dans le fichier Program.cs et n’est accessible que depuis ce dernier. Pour plus d’informations, consultez Fichier (Référence C#). Pour consultez 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 :

  • le Func<char, Task> asyncFunc est attendu à chaque itération, en passant le letter actuel.
  • 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 des API Create ou 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;

Dans le code C# précédent :

  • La variable addLettersToCacheTask délègue à IterateAlphabetAsync et est attendue.
  • Le Func<char, Task> asyncFunc est argumenté avec un lambda.
  • Le MemoryCacheEntryOptions est instancié avec une expiration absolue par rapport à maintenant.
  • Un rappel post-éviction est enregistré.
  • Un objet AlphabetLetter est instancié et passé dans Set avec letter et options.
  • La lettre est écrite dans la console en tant que 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 à IterateAlphabetAsync est attendu, mais cette fois, vous allez appeler 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 cache contient la clé letter et value s’il s’agit d’une instance d’un AlphabetLetter, il est écrit dans la console. Lorsque la clé letter 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

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

Assemblage

L’ensemble de l’exemple de code source d’application est un programme de niveau supérieur qui 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 valeurs MillisecondsDelayAfterAdd et MillisecondsAbsoluteExpiration pour observer les changements de comportement à l’expiration et à l’éviction des entrées mises en cache. L’exemple suivant illustre une 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 de mise en cache des données consiste à mettre à jour le cache indépendamment des services de données qui consomment. Le modèle Service Worker est un excellent exemple, car le BackgroundService s’exécute indépendamment (ou en arrière-plan) de l’autre code d’application. Lorsqu’une application commence à s’exécuter et héberge une implémentation du IHostedService, l’implémentation correspondante (dans ce cas le BackgroundService ou « Worker ») commence à s’exécuter dans le même processus. Ces services hébergés sont inscrits auprès de l’ID en tant que singletons, via la méthode d’extension AddHostedService<THostedService>(IServiceCollection). D’autres services peuvent être inscrits auprès de l’ID avec n’importe quelle durée de 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 une 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 de DI. Chaque service a une seule 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();

Dans le code C# précédent :

Le 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 méthode GetPhotosAsync :
    • Définit un paramètre Func<Photo, bool> filter et retourne un IAsyncEnumerable<Photo>.
    • Appelle et attend le _cacheSignal.WaitAsync() pour mettre en production, ce qui garantit que le cache est rempli avant d’accéder au cache.
    • Appelle _cache.GetOrCreateAsync(), obtenant de manière asynchrone toutes les photos dans le cache.
    • L’argument factory consigne un avertissement et retourne un tableau de photos vide. Cela ne doit jamais se produire.
    • Chaque photo du cache est itérée, filtrée et matérialisée avec yield return.
    • Enfin, le signal de cache est réinitialisé.

Les contrôles serveur consommateur de ce service sont libres d’appeler la méthode GetPhotosAsync et de gérer les photos en conséquence. Aucun HttpClient n’est requis, car le cache contient les photos.

Le signal asynchrone est basé sur une instance encapsulée SemaphoreSlim, dans un singleton contraint de type générique. Le CacheSignal<T> s’appuie 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 de décorateur est utilisé pour envelopper une instance du SemaphoreSlim. Étant donné que le CacheSignal<T> est inscrit en tant que singleton, il peut être utilisé sur toutes les durées de vie du service avec n’importe quel type générique, dans ce cas, le Photo. Il est chargé de signaler l’amorçage du cache.

Le CacheWorker est 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, HttpClient et IMemoryCache.
  • Le _updateInterval est défini pendant trois heures.
  • La méthode ExecuteAsync :
    • Boucles pendant l’exécution de l’application.
    • Effectue une requête HTTP à "https://jsonplaceholder.typicode.com/photos" et mappe la réponse sous la forme d’un tableau d’objets Photo.
    • Le tableau de photos est placé dans le IMemoryCache sous la clé "Photos".
    • Le _cacheSignal.Release() est appelé, libérant tous les contrôles serveur consommateur qui attendaient le signal.
    • L’appel à Task.Delay est attendu, compte tenu de l’intervalle de mise à jour.
    • Trois heures plus tard, le cache est à nouveau mis à jour.

Les contrôles serveur consommateur dans le même processus peuvent demander le IMemoryCache pour les photos, mais le CacheWorker est responsable de la mise à jour du cache.

Mise en cache distribuée

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

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

Attention

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

Considérez l’une des implémentations disponibles de IDistributedCache à partir des 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 basiques. 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 de la mise en cache distribuée sont conservées en tant que byte[]. Cela ne veut pas dire que les différentes implémentations n’exposent pas de valeurs génériques fortement typées, mais qu’il s’agit 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 de jeu :

À l’aide de l’enregistrement AlphabetLetter de l’exemple de cache en mémoire, vous pouvez sérialiser l’objet au format JSON, puis encoder le 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, qui permettent d’éviter l’encodage des représentations d’objets string dans un byte[] :

Lire des valeurs

Pour lire les valeurs 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 du byte[]

LIre des méthodes d’extension

Il existe plusieurs méthodes d’extension basées sur la commodité pour lire des valeurs, qui permettent d’éviter l’encodage de byte[] dans des représentations d’objets string :

Mettre à jour des 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 devez supprimer la valeur, puis la rajouter.

Supprimer des valeurs

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

Conseil

Bien qu’il existe des versions synchrones des API susmentionnées, 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 d’utiliser plus souvent les API asynchrones.

Voir aussi