Saltar al contenido

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript... y lo que venga ;)

17 años online

el blog de José M. Aguilar

Inicio El autor Contactar

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web
ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript...

¡Microsoft MVP!
martes, 20 de septiembre de 2022
.NET

En algunas ocasiones me he topado con escenarios en los que necesitaba contar, o incluso enumerar, las claves de los elementos presentes en una caché en memoria, inyectada en mis servicios en forma de objeto IMemoryCache.

Aunque a priori pueda parecer sencillo, esta interfaz no proporciona métodos o propiedades que permitan acceder a la colección que actúa como almacén de los mismos, por lo que nos veremos obligados a usar una estructura de datos adicional (normalmente algún tipo de diccionario o hashset paralelo) para almacenar estos elementos.

¿O quizás tenemos otras fórmulas?

1. Un poco de contexto: la clase MemoryCache

En la inmensa mayoría de casos, cuando uno de nuestros servicios tiene una dependencia de tipo IMemoryCache, el inyector nos suministrará la instancia de MemoryCache que es registrada como singleton en el sistema mediante una llamada al extensor AddMemoryCache() en el código de arranque de la aplicación:

// En Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMemoryCache(); 
...

Si echáis un vistazo al código fuente de esta clase, veréis que su funcionamiento no es demasiado complicado, pues básicamente utiliza un ConcurrentDictionary para almacenar las entradas de caché según su clave.

Cada vez que leemos o escribimos un elemento, se comprueba la última vez que se escaneó la colección en busca de elementos caducados y, si ha transcurrido más de un tiempo determinado, se lanzará una tarea en segundo plano que recorrerá todos los elementos y eliminará aquellos cuyo contenido ya no sea válido en función de las condiciones o tiempo de vida especificado en el momento de añadirlo a la caché.

Ese tiempo de espera entre escaneos está configurado por defecto en un minuto, pero podemos cambiarlo en el momento del registro del servicio:

// En Program.cs:
builder.Services.AddMemoryCache(
    o => o.ExpirationScanFrequency = TimeSpan.FromSeconds(1)
);

Volviendo al tema que nos ocupa, el problema de la clase MemoryCache es que el diccionario concurrente que almacena los elementos es privado, por lo que no hay forma de acceder a él desde fuera de la clase.

2. Obtener el número de elementos de un objeto IMemoryCache

Como hemos comentado antes, las dependencias IMemoryCache que necesitemos en nuestros objetos serán normalmente satisfechas con instancias de objetos MemoryCache. Saber esto es una ventaja importante, porque dicha clase dispone de una propiedad que retorna el número de elementos almacenados en la caché; por tanto, podríamos hacer directamente un casting desde IMemoryCache hacia MemoryCache y usar la propiedad Count para obtener el valor.

Veamos un ejemplo práctico. El siguiente código muestra un controlador MVC con una acción para establecer elementos en caché, y otra para mostrar el número de ítems almacenados:

public class CacheController : Controller
{
    private readonly IMemoryCache _cache;

    public CacheController(IMemoryCache cache) => _cache = cache;

    [HttpGet("{key}")]
    public void Set(string key) => 
        _cache.Set(key, Guid.NewGuid(), TimeSpan.FromSeconds(10));

    [HttpGet("/")]
    public string Get()
    {
        _cache.Get("whatever"); // Just to force the cache cleaning
        return "Items: " + (_cache as MemoryCache)?.Count;
    }
}

Dado este controlador, si hacemos peticiones consecutivas hacia "/one", "/two" y "three", y luego accedemos a "/", podremos ver que la caché está almacenando y eliminando los valores de forma correcta.

Nota: la lectura del elemento "whatever" en el método Get() la hacemos simplemente para forzar la comprobación de elementos caducados (antes hemos comentado cómo funciona esto). Si vais a probar este código y queréis ir más rápido, simplemente configurad el tiempo de espera a unos segundos en el momento de registrar el servicio.

3. Obtener los elementos almacenados en un objeto MemoryCache

Si en lugar de el número de elementos queremos obtener las claves o valores de los elementos almacenados, la cosa se complica un poco. Dado que no tenemos acceso al diccionario concurrente porque es un miembro privado, hemos de recurrir a hacks que nos permiten saltarnos esta restricción, como el uso de los mecanismos de reflexión de .NET.

Asumiendo que estamos hablando de un objeto de tipo MemoryCache, podemos usar reflection para acceder a su miembro EntriesCollection, un objeto de tipo ICollection<KeyValuePair<object, CacheEntry>>, y de ahí obtener las claves de los elementos guardados.

Usando como punto de partida un código de StackOverflow, he creado tres extensores de IMemoryCache que pueden sernos útiles cuando estemos ante este escenario:

  • GetCount(), un simple atajo hacia la propiedad Count de MemoryCache, por tenerlo más a mano.
  • GetKeys() retorna la colección de claves almacenadas en la caché del MemoryCache.
  • GetValues() retorna la colección de objetos de tipo ICacheEntry almacenados en la caché. De este objeto puede obtenerse la clave, el valor, fecha de expiración, etc.

El código es el siguiente:

public static class MemoryCacheExtensions
{
    private static readonly Func<MemoryCache, object>? _getter;

    static MemoryCacheExtensions()
    {
        var entriesCollectionGetter = typeof(MemoryCache)
            .GetProperty("EntriesCollection", BindingFlags.NonPublic | BindingFlags.Instance)?
            .GetGetMethod(true);

        _getter = (Func<MemoryCache, object>)Delegate.CreateDelegate(
            type: typeof(Func<MemoryCache, object>),
            method: entriesCollectionGetter!,
            throwOnBindFailure: true);
    }

    public static int GetCount(this IMemoryCache cache)
    {
        if (cache is not MemoryCache memoryCache)
            return 0;
        return memoryCache.Count;
    }

    public static IEnumerable<object> GetKeys(this IMemoryCache cache)
    {
        if (cache is not MemoryCache memoryCache)
            return Enumerable.Empty<object>();

        var dictionary = (IDictionary) _getter(memoryCache);
        return (IEnumerable<object>)dictionary.Keys;
    }

    public static IEnumerable<ICacheEntry> GetValues(this IMemoryCache cache)
    {
        if (cache is not MemoryCache memoryCache)
            return Enumerable.Empty<ICacheEntry>();

        var dictionary = (IDictionary)_getter(memoryCache);
        return (IEnumerable<ICacheEntry>)dictionary.Values;
    }
}

Como podéis ver, aunque hay que hacer algunas piruetas con los tipos debido a que la clase CacheEntry es interna, al final el código es relativamente simple. Eso sí, tened en cuenta que es totalmente dependiente de la implementación interna de MemoryCache, por lo que funciona exclusivamente en versiones que usen la misma estructura interna y, por tanto, dispongan de la propiedad EntriesCollection, como .NET 6 y .NET 5. Sin embargo, .NET 7 cambiará la implementación, así que llegado el momento tendremos que actualizar esta solución ;)

Una forma de probar fácilmente estos extensores es usando el siguiente controlador, donde establecemos una acción para añadir entradas a la caché y tres más para obtener el número de elementos, sus claves y sus valores, respectivamente:

public class CacheController : Controller
{
    private readonly IMemoryCache _cache;

    public CacheController(IMemoryCache cache) => _cache = cache;

    [HttpGet("{key}")]
    public void Set(string key) 
      => _cache.Set(key, Guid.NewGuid(), TimeSpan.FromSeconds(10));

    [HttpGet("/count")]
    public string Get() 
      => "Items: " + _cache.GetCount();

    [HttpGet("/keys")]
    public string GetKeys() 
      => string.Join(",", _cache.GetKeys());


    [HttpGet("/values")]
    public string GetValues()
      => string.Join(",", _cache.GetValues().Select(v => $"{v.Key}={v.Value} ({v.AbsoluteExpiration})"));
}

Aunque en todos los ejemplos hemos usado MVC, en realidad funcionaría exactamente igual si estuviéramos hablando de endpoints creados con minimal APIs. Por ejemplo, el siguiente endpoint es equivalente a la anterior acción GetKeys():

app.MapGet("keys", (IMemoryCache cache) => string.Join(",", cache.GetKeys()));

¡Espero que os resulte útil!

Publicado en Variable not found.

Aún no hay comentarios, ¡sé el primero!