logo

Patrón Cache Aside desde .NET 10 con Redis


Continúo revisando los patrones más útiles para nuestras aplicaciones en la nube. En este caso, hoy vamos a hablar del patrón Cache-Aside, el cual nos permite limitar el número de solicitudes que nuestros sistemas de almacenamiento externo reciben desde nuestras aplicaciones.

La explicación de este patrón es bastante sencilla: cuando accedemos a información almacenada en cualquier tipo de repositorio de datos, primero intentamos obtenerla desde la caché. Si existe, recuperamos los datos directamente de la caché; si no existe, los obtenemos del motor de almacenamiento y los insertamos en la caché para futuras solicitudes.

En primer lugar, debemos considerar algunos aspectos sobre dónde y cómo implementar este patrón:

Vamos a programar

Antes de presentar mi solución para este patrón, quiero aclarar un par de aspectos:

El proveedor de caché

El proveedor de caché será un contrato de un servicio de alto nivel para realizar implementaciones de caché. En nuestro caso he elegido Redis, pero esto nos da la oportunidad de implementar otro tipo de motor y sustituirlo únicamente en nuestras definiciones de inyección de dependencias (por ejemplo, podríamos implementar un almacenamiento en caché local en lugar de uno distribuido).

public interface ICacheProvider
{
    /// <summary>
    /// Generic method to get an item from the cache given a key.
    /// The result should be serialized to the type T.
    /// </summary>
    /// <typeparam name="T">Type of expected result</typeparam>
    /// <param name="key">Given key</param>
    /// <returns>Returns an object of type T or null</returns>
    Task<T?> GetValueAsync<T>(string key) where T : class;
    /// <summary>
    /// Sets an object on the cache.
    /// This method is generic to store any object type.
    /// </summary>
    /// <typeparam name="T">Type of object to save</typeparam>
    /// <param name="key">Given key</param>
    /// <param name="value">Object to save</param>
    /// <param name="duration">Timespan that represents the duration of the object in the cache</param>
    Task<bool> SetValueAsync<T>(string key, T value, TimeSpan duration) where T : class;
    /// <summary>
    /// Method that gets or initialize a cache item with a given key.
    /// </summary>
    /// <typeparam name="T">Type of object</typeparam>
    /// <param name="key">Given key</param>
    /// <param name="functionToObtain">Function to obtain a valid value in case of null</param>
    /// <param name="duration">Timespan that represents the duration of the object in the cache</param>
    /// <returns>Returns a string object</returns>
    Task<T?> GetValueOrInitializeAsync<T>(string key, Func<Task<T>> functionToObtain, TimeSpan duration) where T : class;
    /// <summary>
    /// Removes items from the cache by a given pattern
    /// </summary>
    /// <param name="key">Given key</param>
    /// <returns>Returns true if success, false if not</returns>
    bool RemoveByPattern(string pattern);
    /// <summary>
    /// Check if an item exists given its key.
    /// </summary>
    /// <param name="key">Key to be checked</param>
    /// <returns>Returns a boolean that indicates if exists or not</returns>
    Task<bool> Exists(string key);
}

Nuestra implementación para esta interfaz tendrá el siguiente aspecto. Utilizaremos la librería StackExchange.Redis para ejecutar las acciones contra el motor de caché.

public class RedisCacheProvider : ICacheProvider
{
ConnectionMultiplexer? _connection;
readonly RedisConnectionConfiguration _configuration;

public RedisCacheProvider(RedisConnectionConfiguration configuration)
{
_configuration = configuration;
}

ConnectionMultiplexer GetConnection()
{
if (_connection == null)
{
_connection = ConnectionMultiplexer.Connect(_configuration.ConnectionString, config =>
{
config.ConnectTimeout = _configuration.ConnectionTimeout;
});
}
return _connection;
}

IDatabase? GetDatabase() => GetConnection()?.GetDatabase();
IServer? GetServer() => GetConnection()?.GetServers().LastOrDefault();

public Task<bool> Exists(string key) => GetDatabase()?.KeyExistsAsync(new RedisKey(key)) ?? Task.FromResult(false);

public async Task<T?> GetValueAsync<T>(string key) where T : class
{
var database = GetDatabase();
if (database != null)
{
var data = await database.StringGetAsync(new RedisKey(key));
if (data.HasValue && !data.IsNullOrEmpty)
return JsonSerializer.Deserialize<T>(data.ToString());
}

return null;
}

public async Task<T?> GetValueOrInitializeAsync<T>(string key, Func<Task<T>> functionToObtain, TimeSpan duration) where T : class
{
var value = await GetValueAsync<T>(key);

if (value == null)
{
value = await functionToObtain.Invoke();
if (value != null)
await SetValueAsync(key, value, duration);
}

return value;
}

public bool RemoveByPattern(string pattern)
{
var keys = GetServer()?.Keys(pattern:new RedisValue(pattern));
bool result = true;
if (keys != null)
{
foreach (var key in keys)
{
if (!GetDatabase()?.KeyDelete(key) ?? false)
result = false;
}
}

return result;
}

public Task<bool> SetValueAsync<T>(string key, T value, TimeSpan duration) where T : class =>
GetDatabase()?.StringSetAsync(new RedisKey(key), new RedisValue(JsonSerializer.Serialize(value)), duration) ??
Task.FromResult(false);

}

El patrón Repository

Como mencioné anteriormente, el patrón Repository nos permite abstraer nuestro código del motor subyacente utilizado por nuestras aplicaciones para acceder a los datos. Desde un punto de vista purista, podríamos decir que realizaremos una consulta que accede a una base de datos relacional, una base de datos no relacional o incluso a un archivo, utilizando el mismo código en las capas de alto nivel de nuestra aplicación.

Con este objetivo, definimos las siguientes interfaces para leer y escribir en nuestros sistemas de almacenamiento de datos.

public interface IReaderRepository<T> where T : class
{
    /// <summary>
    /// Get all items of type T
    /// </summary>
    /// <returns>Collection of items of type T</returns>
    Task<List<T>> GetAll();
    /// <summary>
    /// Get items of type T filtered by a Lambda expression
    /// </summary>
    /// <param name="expression">Filter to apply</param>
    /// <returns>Collection of items of type T that matches with the filter specification</returns>
    Task<List<T>> GetAll(Expression<Func<T, bool>> expression);
    /// <summary>
    /// Get first found item filtered by an expression
    /// </summary>
    /// <param name="expression">Lambda filter expression</param>
    /// <returns>First object found</returns>
    Task<T?> GetOne(Expression<Func<T, bool>> expression);
    /// <summary>
    /// Asks if there is any item filtered by a Lambda expression
    /// </summary>
    /// <param name="expression">Lambda filter expression</param>
    /// <returns>Return true if it finds any, otherwise false</returns>
    Task<bool> Any(Expression<Func<T, bool>> expression);
}
public interface IWriterRepository<T> where T : class
{
    /// <summary>
    /// Add item to the storage
    /// </summary>
    /// <param name="item">Item to be added</param>
    void Add(T item);
    /// <summary>
    /// Remove an item placed at the remote storage
    /// </summary>
    /// <param name="item">Item to remove</param>
    void Remove(T item);
    /// <summary>
    /// Updates an item placed at the remote storage
    /// </summary>
    /// <param name="item">Item to remove</param>
    void Update(T item);
    /// <summary>
    /// Gets a transaction where the actions are going to be executed
    /// </summary>
    /// <returns>Returns an object fo type ITransaction</returns>
    ITransaction BeginTransaction(Action? transactionCommited = null);
}

Una implementación para Entity Framework

La siguiente implementación de nuestras interfaces previamente definidas está orientada a trabajar con Entity Framework, por lo que necesitaremos utilizar una instancia de DbContext para interoperar con el motor de datos.

public class EFReaderRepository<T>(DbContext context) : IReaderRepository<T> where T : class
{
    public virtual async Task<bool> Any(Expression<Func<T, bool>> expression) => (await GetAll(expression))?.Any() ?? false;

    public virtual Task<List<T>> GetAll() => context.Set<T>().ToListAsync();

    public virtual Task<List<T>> GetAll(Expression<Func<T, bool>> expression) => context.Set<T>().Where(expression).ToListAsync();

    public virtual Task<T?> GetOne(Expression<Func<T, bool>> expression) => context.Set<T>().FirstOrDefaultAsync(expression);
}

public class EFWriterRepository<T>(DbContext context) : IWriterRepository<T> where T : class
{
protected EFTransaction? _currentTransaction;

public virtual void Add(T item)
{
context.Add(item);
context.SaveChanges();
}

public virtual ITransaction BeginTransaction(Action? transactionCommited = null)
{
_currentTransaction = new EFTransaction(
context.Database.BeginTransaction(),
() => transactionCommited?.Invoke()
);
return _currentTransaction;
}

public virtual void Remove(T item)
{
context.Remove(item);
context.SaveChanges();
}

public virtual void Update(T item)
{
context.Update(item);
context.SaveChanges();
}

}

Una sobrecarga para implementar el patrón Cache-Aside

Vamos a sobrescribir las implementaciones anteriores de lectura/escritura para aplicar el patrón Cache-Aside.

Esta implementación cumplirá los requisitos de la siguiente manera:

public class EFCacheReaderRepository<T> : EFReaderRepository<T>, IReaderRepository<T> where T : class
{
    ICacheProvider _cacheProvider;
    TimeSpan _dataTTL;

    public EFCacheReaderRepository(DbContext context, ICacheProvider cacheProvider, TimeSpan dataTTL) : base(context) {
        _cacheProvider = cacheProvider;
        _dataTTL = dataTTL;
    }

    public override async Task<bool> Any(Expression<Func<T, bool>> expression)
    {
        return (await GetAll(expression))?.Any() ?? false;
    }

    private string GetCacheName([CallerMemberName] string methodName = "", params string[] keys)
    {
        return $"{typeof(T).Name}:{methodName}:{String.Join(':', keys)}";
    }

    public override Task<List<T>> GetAll()
    {
        var cacheName = GetCacheName();
        return _cacheProvider.GetValueOrInitializeAsync<List<T>>(cacheName, () => base.GetAll(), _dataTTL)!;
    }

    public override Task<List<T>> GetAll(Expression<Func<T, bool>> expression)
    {
        var cacheName = GetCacheName(keys:expression.ToString());
        return _cacheProvider.GetValueOrInitializeAsync<List<T>>(
            cacheName, () => base.GetAll(expression), _dataTTL
        )!;
    }

    public override Task<T?> GetOne(Expression<Func<T, bool>> expression)
    {
        var cacheName = GetCacheName(keys: expression.ToString());
        return _cacheProvider.GetValueOrInitializeAsync<T>(
            cacheName,
            () => base.GetOne(expression),
            _dataTTL
        );
    }
}
public class EFCacheWriterRepository<T> : EFWriterRepository<T>, IWriterRepository<T> where T : class
{
    ICacheProvider _cacheProvider;

    public EFCacheWriterRepository(DbContext context, ICacheProvider cacheProvider) : base(context)
    {
        _cacheProvider = cacheProvider;
    }

    public override void Add(T item)
    {
        base.Add(item);
        if (_currentTransaction == null || _currentTransaction.Completed)
            ClearCache();
    }

    private void ClearCache() => _cacheProvider.RemoveByPattern($"{typeof(T).Name}:*");

    public override ITransaction BeginTransaction(Action? transactionCommited = null) =>
        base.BeginTransaction(() =>
        {
            ClearCache();
            transactionCommited?.Invoke();
        });

    public override void Remove(T item)
    {
        base.Remove(item);
        if (_currentTransaction == null || _currentTransaction.Completed)
            ClearCache();
    }

    public override void Update(T item)
    {
        base.Update(item);
        if (_currentTransaction == null || _currentTransaction.Completed)
            ClearCache();
    }
}

Uso dentro de nuestras aplicaciones

Supongamos que partimos de una aplicación ya desarrollada que cuenta con una implementación de DbContext y algunas entidades asociadas a ella.

En nuestro ejemplo, voy a utilizar las siguientes clases de modelo y el contexto correspondiente.

public record Person
{
    [Key]
    public int Id { get; set; }
    [Required]
    public string Name { get; set; }
    [Required]
    public string Surname { get; set; }
}
public class Context : DbContext
{
    public DbSet<Person> People { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder.UseMySQL("Server=localhost;Port=3306;Database=sample;Uid=root;Pwd=secretpassword123;");
        base.OnConfiguring(optionsBuilder);
    }
}

Ajustar nuestras definiciones de Inyección de Dependencias (DI)

Es momento de ajustar nuestras definiciones de DI. En primer lugar, añadiremos el RedisCacheProvider para resolver todas las referencias a la interfaz ICacheProvider dentro de nuestra aplicación. Lo definiremos como Singleton, ya que el objeto de caché puede ser único para toda la aplicación.

serviceCollection.AddSingleton<ICacheProvider>(sp =>
{
    return new RedisCacheProvider(new RedisConnectionConfiguration("localhost:6379", 60));
});

Para definir los métodos de lectura y escritura de nuestras entidades, lo haremos de la siguiente manera:

serviceCollection.AddScoped<IReaderRepository<Person>>(sp =>
{
    return new EFCacheReaderRepository<Person>(
        sp.GetRequiredService<Context>(),
        sp.GetRequiredService<ICacheProvider>(),
        TimeSpan.FromMinutes(60)
    );
});

serviceCollection.AddScoped<IWriterRepository<Person>, EFCacheWriterRepository<Person>>();

Una vez que hemos definido estas entradas dentro del motor de Inyección de Dependencias (DI), podemos inyectarlas en nuestros servicios de aplicación y comenzar a utilizar nuestro nuevo patrón.

public interface IPeopleReaderService
{
    Task<IEnumerable<Person>> GetAllPeopleByName(string name);
}

public class PeopleReaderService(IReaderRepository<Person> repository) : IPeopleReaderService
{

    public async Task<IEnumerable<Person>> GetAllPeopleByName(string name)
    {
        return await repository.GetAll(x => x.Name == name);
    }

}

public interface IPeopleWriterService
{
    bool AddNewPerson(string name, string surname);
}

public class PeopleWriterService(IWriterRepository<Person> repository) : IPeopleWriterService
{
    public bool AddNewPerson(string name, string surname)
    {
        repository.Add(new Person { Name = name, Surname = surname });
        return true;
    }
}

Este es un ejemplo bastante sencillo, pero ilustra cómo trabajar con este patrón dentro de nuestras capas de alto nivel.

Reflexiones finales

Cuando desplegamos nuestras aplicaciones en la nube, debemos ser muy cuidadosos al diseñar nuestra infraestructura. Este tipo de patrones, como Cache-Aside, pueden resultar útiles para reducir la carga en nuestros motores de almacenamiento y, en consecuencia, disminuir los costes. Sin embargo, debemos tener en cuenta que los motores de caché no son gratuitos, por lo que es fundamental encontrar el punto ideal entre coste y rendimiento.