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 :

Importante

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

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 se trouvent dans l’espace de noms Microsoft.Extensions.Caching.

Tous les Microsoft.Extensions.* paquets sont prêts pour l'injection de dépendances (DI), et les interfaces IMemoryCache et IDistributedCache 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 est une enveloppe autour du ConcurrentDictionary<TKey,TValue>, offrant 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. 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. 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 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 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 pourriez accéder différemment à IMemoryCache, comme l’injection de constructeur. Dans cet exemple, vous utilisez l'instance IServiceProvider sur la host et appelez la 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 le biais de DI, 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 :

  • 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 suivantes : 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 AlphabetLetter objet est instancié et passé dans Set avec letter 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 à IterateAlphabetAsync est attendu, mais cette fois-ci, 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 cache contient la clé letter, et que value est une instance de AlphabetLetter, elle est écrite dans la console. Lorsque la clé letter n’est pas dans le cache, elle a été évincée et son callback après éviction a été déclenché.

Méthodes d’extension supplémentaires

Le IMemoryCache est accompagné de nombreuses méthodes d'extension pratiques, 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 valeurs MillisecondsDelayAfterAdd et MillisecondsAbsoluteExpiration pour voir les changements de comportement liés à l'expiration et à l'éviction des entrées cachées. 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 Worker Service est un excellent exemple, car le BackgroundService fonctionne de manière indépendante (ou en arrière-plan) par rapport au reste du code de l'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 DI 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.

Importante

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 de 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 :

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 Func<Photo, bool> filter paramètre 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 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 utilisateurs de ce service sont libres d'appeler la méthode GetPhotosAsync pour gérer les photos en conséquence. Aucune HttpClient n'est 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. Le CacheSignal<T> repose 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 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.

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, HttpClient et IMemoryCache.
  • La _updateInterval valeur est définie pendant trois heures.
  • La méthode ExecuteAsync :
    • Boucles pendant l’exécution de l’application.
    • Effectue une requête HTTP vers "https://jsonplaceholder.typicode.com/photos" et renvoie la réponse sous forme de tableau d'objets Photo.
    • Le tableau de photos est placé dans 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.
    • Après un délai de trois heures, 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, c’est-à-dire avec plusieurs serveurs d’applications. Un cache distribué prend en charge une évolutivité horizontale supérieure à l'approche de mise en cache en mémoire vive. 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.

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 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 c’est 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 optimiser leur présence dans le cache, dans ce cas, le DistributedCacheEntryOptions.

Créer des méthodes d’extension

Il existe plusieurs méthodes d'extension pratiques pour créer des valeurs, ce qui permet d'éviter d'encoder les représentations d'objets dans un string:

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 à partir du cache, vous pouvez obtenir la représentation string encodée 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