ASP.NET - API usando In-Memory Cache


 Neste artigo vamos rever a implementação do cache em memória em uma API ASP.NET Core usando uma implementação com System.Runtime.Caching.

Se você ainda não sabe, o armazenamento em cache pode melhorar significativamente o desempenho e a escalabilidade de sua aplicação, reduzindo o trabalho necessário para gerar e acessar conteúdo.

O armazenamento em cache funciona melhor com dados que mudam com pouca frequência e são difícies de gerar e acessar. O processo funciona assim:  É feita uma cópia dos dados em cache de forma que eles possam ser retornados em uma consulta futura ao invés de ter que ir sempre ao banco de dados buscar os dados.

1- Sem usar o cache

2- Usando o cache

É importante destacar que você não deve confiar nos dados em cache, e assim, sua aplicação deve ser projetada e testada para nunca depender dos dados armazenados em cache.

A ASP.NET Core oferece suporte a vários tipos de caches diferentes. O cache mais simples é baseado no IMemoryCache que representa um cache armazenado na memória do servidor web.

O cache na memória pode armazenar qualquer objeto, e, a interface do cache distribuído é limitada ao tipo byte[]. O cache na memória e o cache distribuído armazenam itens de cache como pares chave-valor.

Podemos implementar o cache in Memory de duas formas principais

  1. Usando o System.Runtim.Caching/MemoryCache
    - Pode ser usado com : .NET Standard 2.0 ou superior e .NET Framework 4.5 ou superior;
    - É indicado como ponte de compatibilidade ao portar código da ASP.NET 4.x para ASP.NET Core;
     
  2. Usando o Microsoft.Extensions.Caching.Memory
    - É a abordagem recomendada pois possui uma integração melhor com a ASP .NET Core atuando nativamente com o contâiner de injeção de dependência;

Como eu já apresentei uma introdução deste assunto (neste artigo) usando o namespace Microsoft.Extensions.Caching.Memory; neste artigo vou usar a primeira opção.

recursos usados:

Criando o projeto API

Abra o VS 2022 e acione o menu Create New Project;

Na janela Add New Project selecione o template ASP.NET Core Web API informe o nome ApiMemoryCache e a seguir defina as seguintes configurações para criar o projeto:

No projeto criado inclua os seguintes pacotes nugets:

  1. Microsoft.EntityFrameworkcore.SqlServer
  2. Microsoft.EntityFrameworkcore.Tools
  3. System.Runtime.Caching
  4. Bogus

Para incluir os pacotes você pode usar o comando : dotnet add package <NOME_PACOTE>

O pacote nuget do EntityFramework vai ser usado para acessar um banco de dados SQL Server e realizar o Migrations e o pacote Bogus vai ser usado para gerar dados de testes fake na tabela do banco de dados.

Nota: Para saber mais sobre o Bogus veja o meu artigo:  C# - Gerando Dados Fake com Bogus

Crie no projeto a pasta Entities e nesta pasta crie a classe Produto que representa nosso modelo de domínio:

public class Produto
{
  [Key]
  
public int ProdutoId { get; set; }
   [StringLength(100)]
  
public string ProdutoNome { get; set; } = string.Empty!;
   [StringLength(200)]
  
public string ProdutoDescricao { get; set; } = string.Empty!;
   [Column(TypeName =
"decimal(10,2)")]
  
public decimal ProdutoPreco { get; set; } = decimal.Zero!;
  
public int Estoque { get; set; } = 1;
}

Agora crie a pasta Data e nesta pasta crie a classe AppDbContext que representa o contexto do EF Core onde vamos mapear as entidades :

using ApiMemoryCache.Entities;
using
Bogus;
using
Microsoft.EntityFrameworkCore;

namespace ApiMemoryCache.Data;

public class AppDbContext : DbContext
{
  
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
   {}

   public DbSet<Produto>? Produtos { get; set; }

   protected override void OnModelCreating(ModelBuilder modelBuilder)
   {

     var produtoId = 1;
     var
fakeProdutos = new Faker<Produto>().StrictMode(true)
         .RuleFor(c => c.ProdutoId, f => produtoId++)
         .RuleFor(c => c.ProdutoNome, f => f.Commerce.ProductName())
         .RuleFor(c => c.ProdutoDescricao, f => f.Commerce.ProductDescription())
         .RuleFor(o => o.ProdutoPreco, f => f.Random.Decimal(500, 2000))
         .RuleFor(o => o.Estoque, f => f.Random.Int(1, 3000));

    
var produtos = fakeProdutos.Generate(5000);

     modelBuilder.Entity<Produto>().HasData(produtos);
}

Neste código estamos definindo no método OnModelCreating a geração de 5000 dados de testes na tabela Produtos que será criada no Migrations. Para isso usamos o pacote Bogus.

Vamos definir no arquivo appsettings.json a string de conexão com o banco de dados SQL Server e definir o nome do banco que será criado :

{
  
"ConnectionStrings": {
     
"DefaultConnection": "Data Source=DESKTOP-DK57UNP\\SQLEXPRESS;Initial Catalog=Cadastro;Integrated 
        Security=True;TrustServerCertificate=True;"

},
...
 

Observe que estamos definindo a propriedade :  TrustServerCertificate=True;  (Veja o meu artigo - EF Core 7 - Conexões SQL Server com Encrypt igual a true )

Criando o serviço do Cache na memória

Vamos criar o serviço para implementar o cache na memória.

Crie no projeto a pasta Services e nesta pasta crie a interface ICacheService :

public interface ICacheService
{

  //
Obtem dados usando uma chave
  T GetData<
T>(string key);

  // Atribui dados com valor e hora de expiração e uma chave
  bool SetData<T>(string key, T value, DateTimeOffset expirationTime);

  // Remove Dados usando a chave
  object RemoveData(string key);
}

A seguir vamos criar a classe concreta CacheService que implementa esta interface usando os recursos do namespace System.Runtime.Caching :

using System.Runtime.Caching;
namespace ApiMemoryCache.Services;
public class CacheService : ICacheService
{
    ObjectCache _memoryCache = MemoryCache.Default;
    public T GetData<T>(string key)
    {
        try
        {
            T item = (T)_memoryCache.Get(key);
            return item;
        }
        catch (Exception e)
        {
            throw;
        }
    }
    public bool SetData<T>(string key, T value, 
                           DateTimeOffset expirationTime)
    {
        bool res = true;
        try
        {
            if (!string.IsNullOrEmpty(key))
            {
                _memoryCache.Set(key, value, expirationTime);
            }
        }
        catch (Exception)
        {
            throw;
        }
        return res;
    }
    public object RemoveData(string key)
    {
        try
        {
            if (!string.IsNullOrEmpty(key))
            {
                return _memoryCache.Remove(key);
            }
        }
        catch (Exception)
        {
            throw;
        }
        return false;
    }
}

 

Agora precisamos registrar o serviço no container DI e vamos aproveitar e registrar também o contexto representado pela classe AppDbContext. Fazemos isso na classe Program:

using ApiMemoryCache.Data;
using
ApiMemoryCache.Services;
using
Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
// Add services to the container.

builder.Services.AddControllers();

builder.Services.AddDbContext<AppDbContext>(options =>options.UseSqlServer  (builder.Configuration.GetConnectionString("DefaultConnection")));

builder.Services.AddSingleton<ICacheService, CacheService>();

var app = builder.Build();

// Configure the HTTP request pipeline.

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

Neste momento podemos realizar a migração aplicando o Migrations para criar o banco e a tabela definidos. Para isso usamos os seguintes comandos :

Ao final teremos o banco Cadastro.mdf criado e a tabela Produtos contendo os dados fake gerados pelo Bogus.

Criando o controlador ProdutosController

Vamos criar na pasta Controllers o controlador ProdutosController e definir os endpoints da API. Vamos implementar um CRUD básico criando os endpoints para obter todos os produtos, obter um produto pelo id, incluir, alterar e excluir um produto.

using ApiMemoryCache.Data;
using ApiMemoryCache.Entities;
using ApiMemoryCache.Services;
using Microsoft.AspNetCore.Mvc;
namespace ApiMemoryCache.Controllers;
[Route("api/[controller]")]
[ApiController]
public class ProdutosController : ControllerBase
{
    private readonly AppDbContext _dbContext;
    private readonly ICacheService _cacheService;

    public ProdutosController(AppDbContext dbContext,
                                        ICacheService cacheService)
    {
        _dbContext = dbContext;
        _cacheService = cacheService;
    }
    [HttpGet()]
    public IEnumerable<Produto> Get()
    {
        var cacheData = _cacheService.GetData<IEnumerable<Produto>>("produto");
        if (cacheData != null)
        {
            return cacheData;
        }
        var tempoExpiracao = DateTimeOffset.Now.AddMinutes(5.0);
        cacheData = _dbContext.Produtos.ToList();
        _cacheService.SetData<IEnumerable<Produto>>("produto", 
                                                                         cacheData, tempoExpiracao);
        return cacheData;
    }
    [HttpGet("{id}")]
    public Produto Get(int id)
    {
        Produto dadosFiltrados;
        var cacheData = _cacheService.GetData<IEnumerable<Produto>>("produto");
        if (cacheData != null)
        {
            dadosFiltrados = cacheData.Where(x => x.ProdutoId == id).FirstOrDefault();
            return dadosFiltrados;
        }
        dadosFiltrados = _dbContext.Produtos.Where(x => x.ProdutoId == id).FirstOrDefault();
        return dadosFiltrados;
    }
    [HttpPost()]
    public async Task<Produto> Post(Produto value)
    {
        var obj = await _dbContext.Produtos.AddAsync(value);
        _cacheService.RemoveData("produto");
        _dbContext.SaveChanges();
        return obj.Entity;
    }
    [HttpPut()]
    public void Put(Produto produto)
    {
        _dbContext.Produtos.Update(produto);
        _cacheService.RemoveData("produto");
        _dbContext.SaveChanges();
    }
    [HttpDelete("{id}")]
    public void Delete(int Id)
    {
        var dadosFiltrados = _dbContext.Produtos.Where(x => x.ProdutoId == Id).FirstOrDefault();
        _dbContext.Remove(dadosFiltrados);
        _cacheService.RemoveData("produto");
        _dbContext.SaveChanges();
    }
}

Observe que para os endpoints Get() e Get(int id) tentamos obter os dados do cache e a seguir verificamos se o cache contém dados. Se o cache não for null retornamos os dados do cache caso contrário usando o contexto _dbcontext para ir ao banco de dados obter os dados.

Nos demais métodos após realizar a operação removemos os dados do cache.

Pronto !!! Podemos testar a nossa implementação do cache em memória.

Testando a implementação do cache

Para testar a aplicação e nossa implementação do cache em memória vamos usar o Postman.

Abriando o Postman e enviando um request GET para https://localhost:7197/api/produtos  teremos o seguinte resultado:

Neste primeiro acesso ainda não temos os dados no cache e o tempo gasto para obter os dados a partir do banco foi de 42 ms.

Vamos agora repetir o request, lembrando que agora os dados estão no cache,  e com isso o resultado obtido será o seguinte:

Como os dados estão vindo do cache o tempo gasto foi de apenas 7 ms.

Você pode realizar as demais operações e verificar o funcionamento do cache, e assim  existem muitos cenários e uso de cache de memória que você pode usar de acordo com suas necessidades e requisitos.

Para incluir podemos usar o método POST e enviamos no body do request os dados do produto no formato JSON :

{
  "produtoNome""Produto nome XXXXXXXXX",
  "produtoDescricao""Produto Descricao xxxxxxxxxxxx xxxxxxxxxxxxxxxxxx",
  "produtoPreco"99,
  "estoque"77
}

Para atualizar usamos o método PUT e enviamos no body do request o Id e os dados do produto no formato JSON :

{

   "produtoId" : 5000,
  
"produtoNome""Produto nome XXXXXXXXX",
  
"produtoDescricao""Produto Descricao xxxxxxxxxxxx xxxxxxxxxxxxxxxxxx",
  
"produtoPreco"99,
  
"estoque"643
}

Entretanto, existe um cenário para o qual você deve atentar quando for usar o cache.

Suponha que existem dois usuários usando sua API.

O primeiro usuário envia o request para buscar os dados de todos os produtos, verificamos se os dados estão presentes no cache ou não; se os dados estiverem presentes no cache serão retornados, senão iremos ao banco e buscamos os dados e retornamos e colocamos os dados no cache;

Enquanto isso um segundo usuário envia um request para obter dados de um produto pelo seu id, então o que pode acontecer é que este request também chegue ao banco de dados antes da conclusão do primeiro request e, por causa disso, o segundo usuário também acessa o banco de dados para buscar os detalhes do produto.

Uma solução para isso é usar o Mecanismo de Bloqueio conforme mostrado a seguir.

Vamos alterar o código do controlador ProdutosController usando o código abaixo:

[Route("api/[controller]")]
[ApiController]
public class ProdutosController : ControllerBase
{
    private readonly AppDbContext _dbContext;
    private readonly ICacheService _cacheService;    
    private static object _lock = new object();
    public ProdutosController(AppDbContext dbContext, ICacheService cacheService)
    {
        _dbContext = dbContext;
        _cacheService = cacheService;
    }
    [HttpGet()]
    public IEnumerable<Produto> Get()
    {
        var cacheData = _cacheService.GetData<IEnumerable<Produto>>("produto"); 
        if (cacheData != null)
        {
            return cacheData;
        }
        lock (_lock)
        {
            var expirationTime = DateTimeOffset.Now.AddMinutes(5.0);
            cacheData = _dbContext.Produtos.ToList();
            _cacheService.SetData<IEnumerable<Produto>>("produto", 
                                                                cacheData, expirationTime);
        }
        return cacheData;
    }
...

Neste código verificamos se os dados estão presentes no cache ou não, se os dados estão disponíveis, então retornamos os dados.

Em seguida, se o valor não estiver presente no cache, aplicamos o bloqueio e, em seguida, a solicitação é bloqueada e inserida na seção e buscamos os detalhes do produto no banco de dados e, em seguida, também o configuramos no cache e, em seguida, retornar os dados.

Agora,  quando o segundo usuário enviar um request antes que o request do primeiro usuário estiver concluído,  o segundo request vai ficar na fila, e, após concluir o primeiro request do usuário, o segundo request entra em cena.

Pegue o projeto aqui: ApiMemoryCache.zip ...

"Amai, pois, a vossos inimigos, e fazei bem, e emprestai, sem nada esperardes, e será grande o vosso galardão, e sereis filhos do Altíssimo; porque ele é benigno até para com os ingratos e maus."
Lucas 6:36

Referências:


José Carlos Macoratti