En este post vamos a ver qué es caché cuando hablamos de software y cómo implementar un servidor caché de forma distribuida con Redis y C#.
Índice
1 - Qué es el almacenamiento en caché
Cuando utilizamos el término caché podemos referirnos a dos cosas principalmente, la primera es la memoria caché que se ubica en el procesador de nuestra máquina.
La segunda y la que vamos a tratar hoy, la caché que nos referimos cuando desarrollamos software. Esta memoria caché es un almacenamiento en memoria de nuestros datos.
Por ejemplo en el entorno web, mantener una Lista/Diccionario en memoria todo el tiempo podría considerarse “cache”; obviamente no es la mejor solución pero para hacernos una idea.
2 - Para qué queremos utilizar la caché
Como he mencionado la información que tenemos en la cache se encuentra ubicada en memoria, lo que quiere decir que su acceso es prácticamente instantáneo.
Con esto en mente podemos suponer que el uso de la caché es para datos que vamos a utilizar múltiples veces.
Por supuesto utilizar únicamente la memoria reduce los recursos que la aplicación va a utilizar, por ejemplo si accedemos a otro microservicio consumimos un servicio de red.
Otro beneficio es que al no consumir el servicio externo, libramos a ese servicio de carga, por lo que es menos probable que falle o que se colapse.
2.1 - Caso de uso de caché
Muchas veces necesitamos mantener esta información de caché en una única aplicación.
Por ejemplo, tenemos una aplicación que ejecuta sobre todos nuestros usuarios cierta acción y luego lo imprime junto con el nombre de la empresa.
en un mundo de microservicios, la información de la empresa la conseguimos haciendo una llamada a al microservicio de la empresa.
Pero claro esta acción, puede ser una locura, ya que nada nos asegura que solo hagamos una llamada al otro microservicio por empresa, sino que realizaremos una por usuario, lo que quiere decir que si dos usuarios están en la misma empresa, recibiremos esta información múltiples veces.
Para solucionar este escenario, solemos realizar la llamada a dicho microservicio con anterioridad, justo antes del bucle, pero si este proceso va a ser ejecutado más de una vez no solo en una única request sino en múltiples.
Nota: El código no está estructurado de la mejor forma posible, sino que está enfocado en la utilización y la muestra del funcionamiento de la caché.
Para ver como estructurar tus aplicaciones correctamente visita el siguiente enlace: Estructura de una aplicación.
Un ejemplo muy común es tener una lista de usuarios y querer imprimir por pantalla el usuario junto con la empresa en la que trabajan, pero en nuestro microservicio de usuario tenemos el ID de la empresa, no los datos de la empresa:
public record UsuarioEntity
{
public string Nombre { get; init; }
public string Apellido { get; init; }
public int IdEmpresa { get; init; }
}
Para obtener los datos de la empresa, los consultaremos en el microservicio de la empresa que nos devolverá el siguiente objeto:
public record EmpresaDto
{
public int Id { get; init; }
public string Nombre { get; init; }
public string Ciudad { get; init; }
public string Pais { get; init; }
}
La lógica es muy sencilla, leemos todos los usuarios y los recorremos, haciendo una llamada para cada uno.
public async Task<List<UsuarioDto>> GetAllUsuarioDto()
{
List<UsuarioDto> resultUsuariosDto = new List<UsuarioDto>();
List<UsuarioEntity> usuarios = await _dependencies.GetAllUsers();
foreach(var usuario in usuarios)
{
EmpresaDto empresa =await _dependencies.GetEmpresa(usuario.IdEmpresa);
UsuarioDto usuarioDto = new UsuarioDto
{
Nombre = usuario.Nombre,
Apellido = usuario.Apellido,
NombreEmpresa = empresa.Nombre
};
resultUsuariosDto.Add(usuarioDto);
}
return resultUsuariosDto;
}
Por supuesto debemos implementar _dependencias
que es donde realizaremos las llamadas tanto a la base de datos como al microservicio de empresas:
public class ListUsersWithCompanyNameDependencies : IListUsersWithCompanyNameDependencies
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IUsuarioRepository _userRepo;
public ListUsersWithCompanyNameDependencies(IHttpClientFactory httpClientFactory, IUsuarioRepository userRepo)
{
_httpClientFactory = httpClientFactory;
_userRepo = userRepo;
}
public async Task<List<UsuarioEntity>> GetAllUsers()
{
return await _userRepo.GetAllUsers();
}
public async Task<EmpresaDto> GetEmpresa(int id)
{
HttpClient client = _httpClientFactory.CreateClient("EmpresaMS");
return await client.GetFromJsonAsync<EmpresaDto>($"empresa/{id}");
}
}
El resultado sería como en la siguiente imagen, una consulta a un microservicio por cada usuario que necesitamos consultar.
A la larga, esta solución es completamente inviable ya que añade mucha carga en la red, latencia, etc.
Nota: Visita el siguiente enlace para aprender a implementar correctamente HttpClient.
3 - Caché en memoria para una aplicación individual
Para solucionar el problema de hacer tantas llamadas, podemos implementar caché en memoria dentro de nuestra aplicación, para ello Microsoft nos proporciona una clase que nos permite hacer caché, llamada MemoryCache
.
Con esta funcionalidad lo que pretendemos es reducir el número de llamadas a nuestro microservicio de empresa, reduciendo a una llamada por empresa y utilizando la caché para el resto.
Para implementar dicha funcionalidad únicamente debemos crear un servicio que encapsule nuestras llamadas al segundo microservicio.
Para ello en nuestro nuevo servicio instamos la caché en el constructor:
public class EmpresaServicio
{
private readonly MemoryCache _cache;
public EmpresaServicio()
{
_cache = new MemoryCache(new MemoryCacheOptions());
}
}
Posteriormente implementamos el servicio, donde vamos a realizar una llamada HTTP al microservicio si el elemento que estamos buscando en la caché no existe:
public interface IEmpresaServicio
{
Task<EmpresaDto> GetEmpresa(int id);
}
public class EmpresaServicio : IEmpresaServicio
{
private readonly MemoryCache _cache;
private readonly IHttpClientFactory _httpClientFactory;
public EmpresaServicio(IHttpClientFactory httpClientFactory)
{
_cache = new MemoryCache(new MemoryCacheOptions());
_httpClientFactory = httpClientFactory;
}
public async Task<EmpresaDto> GetEmpresa(int id)
{
//Comprobar si existe
if(!_cache.TryGetValue(id, out EmpresaDto empresa))
{
//Conslutar el elemenot en el microservicio
empresa = await GetFromMicroservicio(id);
_cache.Set(id, empresa);
return empresa;
}
return empresa;
}
private async Task<EmpresaDto> GetFromMicroservicio(int id)
{
HttpClient client = _httpClientFactory.CreateClient("EmpresaMS");
return await client.GetFromJsonAsync<EmpresaDto>($"empresa/{id}");
}
}
Recuerda crearlo como singleton en el inyector de dependencias, ya que queremos mantener dicha caché entre todas las request que nos llegan.
services.AddSingleton<IEmpresaServicio, EmpresaServicio>();
Finalmente únicamente debemos actualizar nuestras dependencias para utilizar el EmrpesaServicio
que acabamos de crear en vez de utilizar las llamadas HTTP
public async Task<EmpresaDto> GetEmpresa(int id)
{
HttpClient client = _httpClientFactory.CreateClient("EmpresaMS");
return await client.GetFromJsonAsync<EmpresaDto>($"empresa/{id}");
}
//Nuevo GET Empresa
private readonly IEmpresaServicio _empersaServicio;
public async Task<EmpresaDto> GetEmpresa(int id)
{
return await _empersaServicio.GetEmpresa(id);
}
Si ejecutas la aplicación podrás observar cómo de cara al cliente es el mismo resultado mientras que para nuestra red interna es una carga mucho menor así como la velocidad.
3.1 - MemoryCache vs Dictionary<string, T>
Antiguamente era muy común solucionar este problema utilizando un diccionario en C#, pero no es la mejor solución ya que cuando definimos MemoryCache
definimos algunas opciones.
Estas opciones son tales como la habilidad para poder eliminar objetos expirados o el tamaño máximo de nuestra caché.
Mientras que si utilizamos un diccionario, los datos están ahí para siempre, a no ser que los borremos manualmente.
Además, MemoryCache es thread-safe.
4 - Caché distribuida para múltiples microservicios
Pero qué sucede si queremos acceder a esta misma información desde otro microservicio.
Para este ejemplo suponemos que tenemos otro microservicio coches, que realiza un proceso similar, muestra la marca y modelo del coche, así como a la empresa a la que pertenece.
Para ello podemos realizar el mismo proceso que hemos realizado anteriormente, que sería tener dentro de nuestro microservicio de los coches una caché para las empresas.
Pero esta no es la mejor forma de realizar dicho funcionamiento, ya que esa información va a ser leída, no por uno, sino por dos microservicios. Así que, ¿por qué tenerla duplicada?
El funcionamiento ideal sería una caché común para todos los servicios que necesitan acceder a dicha información.
El esquema sería el siguiente:
Como podemos observar únicamente se realiza una llamada hacia el microservicio, mientras que el resto van al servidor cache.
Y para este servidor caché utilizaremos Redis.
5 - Qué es redis?
Redis es un software open source que nos permite almacenar en memoria estructuras de datos, hacer una capa de cache o utilizarlo como message broker.
A diferencia de la caché en memoria que acabamos de ver, Redis es un "servidor" en sí mismo y por lo tanto lo podemos describir y configurar como tal.
Nota: Técnicamente no es un servidor, es una aplicación en el servidor, pero se suele utilzar todo el servidor para redis.
La información en Redis se almacena en la memoria ram, así que nuestra limitación es la memoria, no el disco. Esta configuración permite que sea un acceso mucho más rápido a los datos.
Redis además nos proporciona persistencia de datos, si la necesitamos, o políticas para la expiración de los datos.
6 - Implementar un servidor caché redis en C#
Primero de todo y antes de empezar, quiero indicar que para poder seguir con el ejemplo debemos tener un servidor redis o un contenedor de docker con redis.
En mi caso he creado un pequeño fichero docker-compose que contiene la información de redis.
version: '2'
services:
redis:
image: 'bitnami/redis:latest'
ports:
- 6379:6379
environment:
- REDIS_PASSWORD=password123
Una vez tenemos nuestro servidor montado y corriendo vamos a seguir con el código.
Primero de todo debemos instalar desde nuget el paquete Microsoft.Extensions.Caching.Redis
que esta construido sobre StackExchange.Redis
el cual es un paquete open source mantenido por el equipo de StackOverflow. Y lo instalamos en todos los proyectos que vayamos a utilizar dicha caché
Este proceso es similar al mostrado anteriormente, pero debemos cambiar nuestro tipo MemoryCache
por IDistributedCache
pero esta vez debemos inyectarlo en las dependencias en vez de instanciarlo.
public interface IDistributedEmpresaServicio
{
Task<EmpresaDto> GetEmpresa(int id);
}
public class EmpresaServicio : IDistributedEmpresaServicio
{
private readonly IDistributedCache _cache;
private readonly IHttpClientFactory _httpClientFactory;
public EmpresaServicio(IHttpClientFactory httpClientFactory, IDistributedCache cache)
{
_cache = cache;
_httpClientFactory = httpClientFactory;
}
public Task<EmpresaDto> GetEmpresa(int id)
{
throw new System.NotImplementedException();
}
}
La lógica de nuestro método GetEmpresa
es la misma. Debemos consultar primero la caché y si no existe llamamos al microservicio de empresa e insertamos en la caché.
Nota a tener en cuenta, cuando insertamos en IDistributedCache
lo hacemos con el método SetAsync
el cual recibe como Key un tipo string
y como valor un tipo byte[]
.
Esto quiere decir que antes de insertar el valor en nuestra caché debemos convertirlo a bytes, así como cuando lo leamos.
public async Task<EmpresaDto> GetEmpresa(int id)
{
byte[] value = await _cache.GetAsync(id.ToString());
if (value == null)
{
EmpresaDto empresaDto = await GetFromMicroservicio(id);
if (empresaDto != null)
await AddToCache(empresaDto);
return empresaDto;
}
return FromByteArray(value);
}
private async Task<EmpresaDto> GetFromMicroservicio(int id)
{
HttpClient client = _httpClientFactory.CreateClient("EmpresaMS");
return await client.GetFromJsonAsync<EmpresaDto>($"empresa/{id}");
}
private async Task AddToCache(EmpresaDto empesa)
{
await _cache.SetAsync(empesa.Id.ToString(), ToByteArray(empesa));
}
private byte[] ToByteArray(EmpresaDto obj)
{
return JsonSerializer.SerializeToUtf8Bytes(obj);
}
private EmpresaDto FromByteArray(byte[] data)
{
return JsonSerializer.Deserialize<EmpresaDto>(data);
}
por supuesto debemos instanciar IDistributedCache
como uno de los servicios:
//valores "hardcoded" para la muestra del funcionamiento
services.AddDistributedRedisCache(options =>
{
options.Configuration = "localhost:6379,password=password123";
options.InstanceName = "localhost";
});
Y ahora en caso de que tuviéramos otro microservicio deberíamos utilizar un código similar donde primero comprobamos la caché y después llamamos al microservicio e insertamos en la caché.
7 - Implementar Caché en memoria y redis
Otra solución a este problema, para información que se consulta constantemente es utilizar ambas opciones, en cada microservicio tener nuestra caché en memoria, y si no encuentra los datos consultar en redis, y finalmente utilizar la llamada HTTP
Conclusión
- En este post hemos visto por qué utilizar caché en nuestros microservicios.
- Hemos visto cómo implementar caché en memoria cuando tenemos una sola Aplicación que va a acceder a esa información.
- Hemos visto una introducción a redis y como utilizar redis en nuestros microservicios para disponer de caché distribuida.