C#  -  Implementações do Cache na plataforma .NET


 Hoje veremos as implementações do Cache na plataforma .NET

Antes de entrar no assunto vejamos o conceito de Cache e Caching.

Cache refere-se à técnica de armazenar dados em um local de acesso rápido, de modo que esses dados possam ser recuperados rapidamente em uma solicitação subsequente, em vez de ter que ser recuperados novamente de um local mais lento. Caching, por sua vez, refere-se ao processo de armazenar esses dados em cache e gerenciar o acesso a eles.

O Cache é um tipo de memória relativamente pequena, mas que pode ser acessada muito rapidamente. Essencialmente, armazena informações que provavelmente serão usadas novamente. Por exemplo, os navegadores da Web normalmente usam um cache para fazer com que as páginas da Web sejam carregadas mais rapidamente armazenando uma cópia dos arquivos da página da Web localmente, como em seu computador local.

O Caching é o processo de armazenamento de dados em cache sendo que o armazenamento em cache com a linguagem C# é muito fácil pois o namespace System.Runtime.Caching.dll fornece os recursos para trabalhar com cache em C#.

É um conceito simples, mas muito eficaz. A ideia é reaproveitar os resultados de uma operação. Ao realizar uma operação pesada, salvaremos o resultado em nosso contêiner de cache. Na próxima vez que precisarmos desse resultado, vamos obtê-lo do contêiner de cache, em vez de executar a operação pesada novamente.

Por exemplo, para obter uma imagem de uma pessoa, podemos acessar um  banco de dados. Em vez de realizar essa viagem todas as vezes, podemos salvar a imagem no cache, obtendo-o da memória sempre que precisarmos.

O armazenamento em cache funciona muito bem para dados que não são alterados com frequência. Ou melhor ainda, dados que nunca mudam. Dados que mudam constantemente, como a hora da máquina atual, não devem ser armazenados em cache ou você obterá resultados errados.

Na plataforma .NET, o caching é usado para melhorar o desempenho de aplicativos, reduzindo o número de solicitações feitas a serviços externos ou recursos de armazenamento de dados, como bancos de dados. Em vez disso, o cache pode ser usado para armazenar dados que não mudam com frequência ou que podem ser pré-carregados para melhorar a resposta do aplicativo.

Existem 5 tipos de cache :

  1. O Cache na Memória é usado quando você deseja implementar o cache em um único processo. Quando o processo morre, o cache morre com ele. Se você estiver executando o mesmo processo em vários servidores, terá um cache separado para cada servidor.
     
  2. O Cache persistente no processo é quando você faz backup do cache fora da memória do processo. Pode estar em um arquivo ou em um banco de dados. Isso é mais difícil, mas se o processo for reiniciado, o cache não será perdido. Melhor usado quando obter o item em cache é custoso e seu processo tende a reiniciar muito.
     
  3. O Cache distribuído é quando você deseja ter cache compartilhado para várias máquinas. Normalmente, serão vários servidores. Com um cache distribuído, ele é armazenado em um serviço externo. Isso significa que se um servidor salvou um item de cache, outros servidores também poderão usá-lo. Serviços como o Redis são ótimos para isso.
     
  4. O Caching de saída onde o resultado de uma solicitação é armazenado em cache para que possa ser reutilizado em solicitações futuras que tenham os mesmos parâmetros. O ASP.NET Core inclui recursos de caching de saída integrados que podem ser usados para esse fim.
     
  5. O Caching de fragmento em que partes de uma página da web são armazenadas em cache para que possam ser reutilizadas em outras páginas ou solicitações subsequentes.

Vou focar nos 3 primeiros iniciando com o Cache persistente no processo apresentando uma implementação bem simples:

namespace Cache_Exemplos;

public class CacheInProcess<TItem>
{
    Dictionary<object, TItem> _cache = new Dictionary<object, TItem>();

    public TItem GetOrCreate(object key, Func<TItem> createItem)
    {
        if (!_cache.ContainsKey(key))
        {
            _cache[key] = createItem();
        }
        return _cache[key];
    }
}

Exemplo de uso do cache:

using Cache_Exemplos;

var _imagemCache = new CacheInProcess<byte[]>();
// ...
var minhaImagem = _imagemCache.GetOrCreate(userId, () => _database.GetImagem(userId));
 

Este código é bem simples mas resolve um problema crucial. Para obter a imagem de um usuário, apenas a primeira solicitação realizará uma viagem ao banco de dados. Os dados da imagem (byte[]) são então salvos na memória do processo. Todas as solicitações a seguir para a imagem serão extraídas da memória, economizando tempo e recursos.

No entanto o código acima tem um problema, ele não é thread-safe e exceções podem ocorrer quando usado em vários threads. Além disso, os itens armazenados em cache ficarão na memória para sempre, o que na verdade é muito ruim.

Mas porque é tão ruim assim ficar com dados no Cache ?

Veremos a seguir as razões para isso:

Para lidar com esses problemas, as estruturas de cache têm políticas de despejo (também conhecidas como políticas de remoção que são regras para remover itens do cache de acordo com alguma lógica. As políticas comuns de despejo são:

Agora que sabemos o que precisamos, podemos procurar por soluções melhores.

Na plataforma .NET temos uma implementação para cache muito boa e veremos como usá-la com eficiência e como melhorá-la em alguns cenários. Na verdade temos duas soluções diferentes que usam os pacotes :

  1. System.Runtime.Caching/MemoryCache
  2. Microsoft.Extensions.Caching.Memory

A recomendação é usar o pacote Microsoft.Extensions.Caching.Memory porque ele se integra melhor com a ASP. NET Core e pode ser facilmente injetado no mecanismo de injeção de dependência.

Aqui está um exemplo básico com Microsoft.Extensions.Caching.Memory:

using Microsoft.Extensions.Caching.Memory;

namespace Cache_Exemplos;

public class MemoryCacheSimples<TItem>
{
    private MemoryCache _cache = new MemoryCache(new MemoryCacheOptions());

    public TItem GetOrCreate(object key, Func<TItem> createItem)
    {
        TItem cacheEntry;

        if (!_cache.TryGetValue(key, out cacheEntry))
        {
            // Chave não esta no cache então pega
            cacheEntry = createItem();

            // Salva dados no cache
            _cache.Set(key, cacheEntry);
        }
        return cacheEntry;
    }
}

Uso:

var _imagemCache = new MemoryCacheSimples<byte[]>();
// ...
var minhaImagem = _imagemCache.GetOrCreate(userId, () => _database.GetImagem(userId));
 

Dois destaques nesta implementação : Ela é uma implementação thread-safe e você pode chamar isso com segurança de vários threads ao mesmo tempo, e , a segunda coisa é que o MemoryCache permite todas as políticas de despejo.

A seguir temos um exemplo de código mostrando o uso destas políticas:

public class MemoryCacheComPolicitas<TItem>
{
    private MemoryCache _cache = new MemoryCache(new MemoryCacheOptions()
    {
        SizeLimit = 1024
    });

    public TItem GetOrCreate(object key, Func<TItem> createItem)
    {
        TItem cacheEntry;
        if (!_cache.TryGetValue(key, out cacheEntry)) // procura chave no cache
        {
            // chave não esta no cache, obtem dados
            cacheEntry = createItem();
 
            var cacheEntryOptions = new MemoryCacheEntryOptions()
                .SetSize(1)  //tamanho
                //Prioridade em remover quando alcancar o tamanho limite
                .SetPriority(CacheItemPriority.High)
                // Mantem no cache, reseta o tempo se acessado
                .SetSlidingExpiration(TimeSpan.FromSeconds(2))
                // Remove do cache depois deste tempo,
                .SetAbsoluteExpiration(TimeSpan.FromSeconds(10));

            // Salva dados no cache.
            _cache.Set(key, cacheEntry, cacheEntryOptions);
        }
        return cacheEntry;
    }
}

Vamos entender o código:

1 - O tamanho limite foi adicionado em MemoryCacheOptions e isso adiciona uma política baseada em tamanho ao nosso contêiner de cache. O tamanho não tem uma unidade. Em vez disso, precisamos definir a quantidade de tamanho em cada entrada de cache. Nesse caso, definimos o valor como 1 a cada vez com SetSize(1). Isso significa que o cache está limitado a 1024 itens.

2- Quando atingirmos o limite de tamanho, qual item de cache deve ser removido? Você pode realmente definir a prioridade com .SetPriority(CacheItemPriority.High). Os níveis são Baixo, Normal, Alto e NeverRemove.

3- Incluímos SetSlidingExpiration(TimeSpan.FromSeconds(2))  e isto define a expiração deslizante para 2 segundos. Isso significa que se um item não for acessado em mais de 2 segundos, ele será removido.

4- foi adicionado SetAbsoluteExpiration(TimeSpan.FromSeconds(10))  que define a expiração absoluta para 10 segundos. Isso significa que o item será despejado em 10 segundos, se ainda não o foi.

Além das opções no exemplo, você também pode definir um delegate RegisterPostEvictionCallback, que será chamado quando um item for despejado e onde o retorno de chamada fornecido será acionado depois que a entrada de cache for removida do cache.

Esse é um conjunto de recursos bastante abrangente e faz você se perguntar se há mais alguma coisa a acrescentar ou podemos parar por aqui. 

Sempre há algo a acrescentar ....

Mas isso é assunto para outro artigo.

"O Senhor estabeleceu o seu trono nos céus, e como rei domina sobre tudo o que existe."
Salmos 103:19

Referências:


José Carlos Macoratti