Utilisation du middleware OutputCache “in memory” ou via un fournisseur externe

Définition

.NET 7.0 a été l’occasion de découvrir de nouveau middlewares et un de ceux qui m’a le plus fait plaisir est le middleware d’OutputCache.

Nous avions précédemment le ResponseCache qui permettait de décorer nos endpoints et de mettre en cache les données renvoyées.

Donc pourquoi créer un nouveau middleware pour faire la même chose ?

Tout simplement parce que ça ne fait pas la même chose 😉

Le ResponseCache existant a été conçu pour implémenter la sémantique de mise en cache HTTP standard. Par exemple. il est mis en cache en fonction des en-têtes de cache HTTP comme le ferait un proxy (et peut être utile dans YARP). Il honore également les en-têtes de cache de requête du client, comme l’utilisation de no-cache pour contourner le cache.

Un OutputCache est quelque chose de différent. Plutôt que de vérifier les en-têtes de cache HTTP pour décider ce qui doit et ne doit pas être mis en cache, la décision est plutôt prise directement par l’application serveur, Le client n’est pas censé savoir qu’il reçoit une réponse mise en cache.

En effet, le ResponseCache mettait en cache du coté du client et l’OutputCache permet une gestion du cache coté serveur et ça fait une énorme différence.

Voici donc les différences majeures :

– Le comportement de l’OutputCache est configurable sur le serveur, le ResponseCache utilise le cache du navigateur.

– Les entrées de l’OutputCache peuvent être invalidées par le code, le ResponseCache ne le prend pas en charge.

– l’OutputCache obtient uniquement les réponses réussies, mais le ResponseCache stocke même si la réponse est mauvaise

. – Le support de stockage de l’OutputCache est extensible.C’est à dire que nous pouvons configurer celui-ci pour sortir vers un Redis par exemple.

Cas concret

Maintenant que nous savons comment fonctionne l’OutputCaching, nous allons voir un cas d’exemple.

Le cas d’école pour ce middleware est le CRUD parce que nous requêtons des entités, nous en mettons à jour, en ajoutons et en supprimons.

Dans cette cinématique, lorsque nous requêtons une ou plusieurs entités , nous pouvons mettre en cache les entités pour les servir au client de manière rapide.

Pour commencer, j’ai donc créé un projet d’API ASP .Net Core 7. en minimal Api.

Ensuite comme tout middleware, il faut le rajouter au conteneur d’injecteur de dépendances et notifier à l’app de l’utiliser donc nous allons rajouter ces deux lignes dans le fichier Program.cs :

........
builder.Services.AddOutputCache();
//all your services
//build your app with builder.Build();
app.UseOutputCache();

J’ai au préalable supprimé tout ce qui a trait à l’API par défaut WeatherForecast.

Ensuite créons une classe contenant un modèle de données de produits :

public class Product
    {
        public int Id { get; set; }
        public required string Name { get; init; }
        public decimal Price { get; init; }
    }

Et maintenant une classe de service ainsi que son interface pour pouvoir renvoyer des produits via nos endpoints d’API :

using Microsoft.AspNetCore.OutputCaching;
using OutputCaching.Model;
using System.Threading;

namespace OutputCaching.Service
{
    public interface IProductService
    {
        Task AddProduct(Product product, CancellationToken cancellationToken);
        Product GetProductById(int id);
        List<Product> GetProducts();
        void RemoveProduct(Product product);
    }

    public class ProductService : IProductService
    {
        private List<Product> products = new List<Product>
        {
            new Product { Id = 1 , Name = "Book", Price = 20.5m},
            new Product { Id = 2 , Name = "Computer", Price = 999.99m},
            new Product { Id = 3 , Name = "Keyboard", Price = 39.99m},
            new Product { Id = 4 , Name = "Mouse", Price = 19.99m},
        };


        public ProductService(IOutputCacheStore cache)
        {
        }

        public List<Product> GetProducts()
        {
            return products;
        }

        public Product GetProductById(int id)
        {
            return products.Where(c => c.Id == id).FirstOrDefault()!;

        }

        public async Task AddProduct(Product product, CancellationToken cancellationToken)
        {
            products.Add(product);
        }

        public void RemoveProduct(Product product)
        {
            products.Remove(product);
        }
    }
}

Cette classe ne contient que 4 méthodes pour

  • Récupérer tous les produits
  • Récupérer un produit via son identifiant
  • Rajouter un produit
  • Supprimer un produit

Ici nous sommes dans un CRUD (sans l’update 😊) plutôt classique.

N’oubliez pas de rajouter votre service dans l’IoC :

builder.Services.AddSingleton<IProductService,ProductService>();

Pour finir nos endpoints d’API :

public static class ProductEndpoint
    {
        public static RouteGroupBuilder MapProductEndpoint(this IEndpointRouteBuilder routes)
        {
            var group = routes.MapGroup("/products");

            group.WithTags("Product");

            group.MapGet("/", async (IProductService service) =>
            {
                await Task.Delay(1000);
                return TypedResults.Ok(service.GetProducts());
            });

            group.MapGet("/{id}", async (IProductService service, [FromRoute] int id) =>
            {
                await Task.Delay(1000);
                return TypedResults.Ok(service.GetProductById(id));
            });

            group.MapPost("/", async (IProductService service, [FromBody] Product product) =>
            {
                var cancellationtokesource = new CancellationTokenSource();
                await Task.Delay(1000);
                await service.AddProduct(product,cancellationtokesource.Token);
                return TypedResults.Created($"/products/{product.Id}");
            });


            return group;
        }
    }

J’ai volontairement rajouté un délai pour que l’on voit la différence entre le cache et le non cache 😉

Et bien sur pour l’instant, il n’y a pas de cache mis en place.

Pour afficher nos endpoints, il va suffire de rajouter à la fin de votre fichier “Program.cs” avant l’instruction app.Run();

app.MapProductEndpoint();

app.Run();

En lançant votre solution, vous devriez avoir ceci :

Il est temps de rajouter du caching 😎

La manière la plus simple est de rajouter un .CacheOutput() sur votre endpoint comme ceci :

group.MapGet("/", async (IProductService service) =>
            {
                await Task.Delay(1000);
                return TypedResults.Ok(service.GetProducts());
            })
            .CacheOutput();

Ici nous rajoutons du cache sans délai d’expiration sur une route donnée.

Si vous testez votre endpoint “GetAll”, la première requête devrait prendre 1seconde et la suivante devrait être immédiate.

Cette façon de faire va très bien pour un endpoint qui récupère toutes les données mais si nous avons un paramètre comme sur notre endpoint “GetById”, nous devons mettre en cache pour un paramètre donné et non pas pour les autres.

Pour ce cas précis nous allons donc utiliser .CacheOutput(c => c.SetVaryByRouteValue(“id”)).

group.MapGet("/{id}", async (IProductService service, [FromRoute] int id) =>
            {
                await Task.Delay(1000);
                return TypedResults.Ok(service.GetProductById(id));
            }).CacheOutput(c => c.SetVaryByRouteValue("id"));

Notre méthode prend un paramètre de route donc nous mettons en cache une clé qui sera notre Id et en valeur nous aurons le résultat mis en cache.

Si nous avions un paramètre de query,nous aurions .CacheOutput(c => c.SetVaryByQuery(“id”));

Il existe d’autres possibilités de mise en cache :

  • via les param de header : SetVaryByHeader
  • via les param de query : SetVaryByQuery
  • via les param de route : SetVaryByRouteValue

Si vous testez le code, ci-dessus, pour un identifiant donné, comme précédemment, la première requête répondra en 1 seconde et la suivante instantanément. Et si vous requêtez un autre identifiant, il ne sera pas mis en cache donc le scénario sera le même.

Il est possible d’utiliser des Policy pour gérer le cache de manière générale pour toute votre application ou créer des stratégies nommées à utiliser.

Lorsque vous rajouter votre cache dans l’IoC, vous pouvez le paramétrer avec des options.

Ici une policy de base qui spécifie qu’il n’y a pas de cache par défaut :

builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(x => x.NoCache()); 
});

Ensuite vous pouvez rajouter des stratégies personnalisées pour les appliquer sur vos endpoints :

builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(x => x.NoCache());

    options.AddPolicy("with_cache", x =>
    {
        x.Cache();
        x.Expire(TimeSpan.FromSeconds(10)); 
    });
});

Ici nous avons nommé une stratégie “with_cache” qui met en cache et garde les réponses en cache 10 secondes. Pour l’appliquer il suffit de l’ajouter comme ceci sur vos endpoints :

group.MapGet("/{id}", async (IProductService service, [FromRoute] int id) =>
            {
                await Task.Delay(1000);
                return TypedResults.Ok(service.GetProductById(id));
            }).CacheOutput("with_cache");

Maintenant que nous avons vu tout cela, il reste un souci. En effet lorsque je vais rajouter un élément à ma collection, mon cache ne sera pas rafraichi et même pire, nous allons avoir des données périmées.

Pour résoudre cela, Microsoft/Asp .Net Core ont mis en place un système de tags et de purge du cache. C’est la que l’Output caching prend tout son sens car il rend la main au serveur sur la gestion du cache.

Pour cela, il suffit de rajouter un tag sur les endpoints qui l’on met en cache comme par exemple :

 group.MapGet("/", async (IProductService service) =>
            {
                await Task.Delay(1000);
                return TypedResults.Ok(service.GetProducts());
            })
            .CacheOutput(c=>c.Tag("Product"));

            group.MapGet("/{id}", async (IProductService service, [FromRoute] int id) =>
            {
                await Task.Delay(1000);
                return TypedResults.Ok(service.GetProductById(id));
            }).CacheOutput(
                c => { 
                    c.SetVaryByQuery("id"); 
                    c.Tag("Product");
                });

Ici j’ai rajouté un tag “Product” sur mes endpoints qui mettent en cache.

Il va falloir invalider ce cache lorsque je vais rajouter un nouvel élément à ma collection.

Pour celà, il faut utiliser l’interface IOutputCacheStore qui permet d’invalider du cache pour des tags donnés.

 group.MapPost("/", async (IProductService service, IOutputCacheStore _cache,[FromBody] Product product, CancellationToken cancellationToken) =>
            {
                await Task.Delay(1000);
                await service.AddProduct(product, cancellationToken);
                await _cache.EvictByTagAsync("Product", cancellationToken);
                return TypedResults.Created($"/products/{product.Id}");
            });

Nous avons invalidé le cache pour le tag “Product” via la méthode EvictByTagAsync.

Utiliser un magasin de stockage externe

Par défaut, Output caching utilise un cache local de type « In-memory ». La émthode utilisée dans l’assembly est la suivante :

/// <summary>
    /// Add output caching services.
    /// </summary>
    /// <param name="services">The <see cref="IServiceCollection"/> for adding services.</param>
    /// <returns></returns>
    public static IServiceCollection AddOutputCache(this IServiceCollection services)
    {
        ArgumentNullException.ThrowIfNull(services);

        services.AddTransient<IConfigureOptions<OutputCacheOptions>, OutputCacheOptionsSetup>();

        services.TryAddSingleton<ObjectPoolProvider, DefaultObjectPoolProvider>();

        services.TryAddSingleton<IOutputCacheStore>(sp =>
        {
            var outputCacheOptions = sp.GetRequiredService<IOptions<OutputCacheOptions>>();
            return new MemoryOutputCacheStore(new MemoryCache(new MemoryCacheOptions
            {
                SizeLimit = outputCacheOptions.Value.SizeLimit
            }));
        });
        return services;
    }

Il est explicitement écrit qu’il utilise un cache en mémoire. Celui-ci a des limites en termes de stockage donc il est possible de l’étendre avec un magasin externe comme Redis.

Nous allons l’implémenter pour tester celà et commençons par rajouter une référence au package Microsoft.Extensions.Caching.StackExchangeRedis .

Ensuite lancez la commande suivante qui requiert que vous ayez docker installé sur votre machine :

docker run -p 6379:6379 --name dev-redis-server -d redis --requirepass mylocalredispassword

Vous pourrez ensuite vous connecter à votre instance locale de Redis pour vérifier les clés (de mon coté j’utilise Docker Desktop pour me connecter à mon instance locale ) :

si l’on vous demande de vous authentifier, il suffira de rentrer la commande suivante :

auth "mylocalredispassword"

Maintenant nous allons devoir implémenter, notre classe qui va hériter de IOutputCacheStore pour remplacer le cache en mémoire :

using Microsoft.AspNetCore.DataProtection.KeyManagement;
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.Extensions.Caching.Distributed;
using StackExchange.Redis;

namespace OutputCaching.Sample.ExternalStore
{
    public interface IRedisOutputCacheStore : IOutputCacheStore
    {

    }

    internal class RedisOutputCacheStore : IRedisOutputCacheStore
    {
        private readonly IConnectionMultiplexer _connection; 

        private readonly Dictionary<string, HashSet<string>> _taggedEntries = new();

        public RedisOutputCacheStore(IConnectionMultiplexer connection)
        {
            _connection= connection;
        }

        public async ValueTask EvictByTagAsync(string tag, CancellationToken cancellationToken)
        {
            ArgumentNullException.ThrowIfNull(tag);
            var db = _connection.GetDatabase();
            //récup des membres avec le tag passé en paramètre
            var cachedKeys = await db.SetMembersAsync(tag);
            var keys = cachedKeys
                .Select(x => (RedisKey)x.ToString())
                .Concat(new[] { (RedisKey)tag})
                .ToArray();

            await db.KeyDeleteAsync(keys);
                      
        }

        public async ValueTask<byte[]?> GetAsync(string key, CancellationToken cancellationToken)
        {
            ArgumentNullException.ThrowIfNull(key);
            var db = _connection.GetDatabase();
            return await db.StringGetAsync(key);

        }

        public async ValueTask SetAsync(string key, byte[] value, string[]? tags, TimeSpan validFor, CancellationToken cancellationToken)
        {
            ArgumentNullException.ThrowIfNull(key);
            ArgumentNullException.ThrowIfNull(value);
            var db = _connection.GetDatabase();

            foreach (var tag in tags ?? Array.Empty<string>())
            {
                //association de la clé avec un tag pour invalidation
                await db.SetAddAsync(tag, key);
            }
            await db.StringSetAsync(key, value, validFor);
        }
    }
}

Et l’extension qui va nous permettrre d’utiliser cette classe pour l’ajouter dans l’IoC :

public static class RedisExtension
    {
        public static IServiceCollection AddRedisOutputCache(this IServiceCollection services)
        {
            ArgumentNullException.ThrowIfNull(services);

            // Add required services        
            services.AddOutputCache();

            // Remove default IOutputCacheStore
            services.RemoveAll<IOutputCacheStore>();

            // Add custom IOutputCacheStore
            services.AddSingleton<IOutputCacheStore, RedisOutputCacheStore>();

            return services;
        }
    }

N’oublions pas de rajouter notre connection au cache Redis avec l’ajout du service dans l’IoC dans le “Program.cs” :

builder.Services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect("localhost:6379,password=mylocalredispassword"));

Si maintenant vous essayez de requêter puis ensuite regarder ce qui est stocké dans votre cache Redis :

Il y a bien notre clé avec son tag et les valeurs sont stockées bien sur en base64.

Et voila c’est terminé!

Nous avons connecté notre OutputCache sur un cache Redis local géré par docker ! 😎

Cette solution est plutôt simple à implémenter mais j’espère tout de même que Microsoft et les équipes Asp .Net Core rajouteront des options au middleware actuel ou même la possibilité d’ajouter d’autres magasins !

Le code complet de cet article est disponible comme d’habitude sur mon Github ici :

https://github.com/AlexCastroAlex/OutputCaching.Sample

Have fun coding !