Como implementar cache de objetos de forma eficiente e sem duplicar códigos?

2015/04/01

Sistemas de vendas costumam ter bases com grande volume de dados e muitas vezes, para evitar que os sistemas façam muitas consultas desnecessárias à estas bases, implementamos caches de objetos.

Existem "N" formas de implementar um cache, a mais simples é utilizar uma implementação de java.util.Map, como por exemplo um HashMap como veremos exemplo a seguir:

Imagine uma classe hipotética Pedido que possui uma lista de objetos do tipo Item, no nosso exemplo, vamos assumir que consultar a base de dados para obter os itens de um pedido, é uma operação extremamente demorada, por isso vamos utilizar um HashMap como cache para os itens de um pedido.

import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class Pedido {

	// cache dos itens do pedido
	private static final Map<Pedido, List<Item>> CACHE = new HashMap<Pedido, List<Item>>();

	// numero do pedido
	private int numero;

	// itens do pedido
	private List<Item> itens;

	// método que obtém os itens deste pedido
	public List<Item> getItens() {
		Pedido pedido = this;
		// Tentamos obter os itens do cache
		itens = CACHE.get(pedido);
		// encontramos itens para este pedido?
		if (itens == null) {
			// não, então sincronizamos o acesso ao cache
			synchronized (CACHE) {
				//  e tentamos de novo
				itens = CACHE.get(pedido);
				// encontramos itens para este pedido?
				if (itens == null) {
					// ainda não
					// então acessamos a base de dados para obter os itens deste pedido
					itens = BaseDados.carregarItensPedido(pedido.getNumero());
					// uma vez obtidos os itens, armazenamos eles no cache
					CACHE.put(pedido, itens);
				}
			}
		}
		// e devolvemos os itens para quem solicitou
		return itens;
	}

	public int getNumero() {
		return numero;
	}

	public void setNumero(int numero) {
		this.numero = numero;
	}
}

Perceba que a implementação do cache é algo relativamente simples, um HashMap onde a chave é o próprio Pedido e o valor é uma lista de objetos do tipo Item. O problema é quando começamos a ter objetos com mais tipos de dados, por exemplo uma nota fiscal com uma lista de itens correspondentes à produtos e outra lista correspondente a serviços. Aí precisaremos duplicar códigos parecidos com os do método getItens() para verificar o cache e carregar os objetos se necessário. Aos poucos teremos um código gigante, duplicado e pouco robusto.

Uma alternativa elegante, é desacoplar o algoritmo que verifica o cache do algorimo que faz o carregamento dos dados.

Primeiro vamos definir uma interface para os algoritmos responsáveis por realizar a carga de dados, ou qualquer outra operação que possa ser custosa. Chamaremos esta interface de CacheLoader:

package br.com.staroski.tools.cache;

/**
 * Interface para carregar objetos para dentro de um {@link Cache cache}.
 * 
 * @author Ricardo Artur Staroski
 *
 * @param <K> Tipo de dado da chave de busca do {@link Cache cache}.
 * @param <V> Tipo de dado do objeto carregado a partir da chave de busca.
 * @see Cache
 */
public interface CacheLoader<K, V> {

    /**
     * Utiliza a chave de busca informada para carregar um valor.
     * 
     * @param key A chave de busca.
     * @return O valor carregado a partir da chave de busca.
     */
    public V load(K key);
}

Para armazenar os objetos carregados pelo CacheLoader, vamos precisar de uma chave no nosso Cache, não se preocupe com os detalhes da implementação desta chave, você entenderá mais a frente, vamos chamar esta classe de MultiKey:

package br.com.staroski.tools.cache;

import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;

/**
 * Esta classe representa uma chave composta por mais de um objeto.
 * 
 * @author Ricardo Artur Staroski
 */
@SuppressWarnings("unchecked")
public final class MultiKey implements Comparable<MultiKey> {

    private final int hashCode;
    private final List<Object> objects;

    /**
     * Instancia uma nova {@link MultiKey chave composta}.
     * 
     * @param key1 O primeiro objeto da chave
     * @param key2 O segundo objeto da chave.
     * @param moreKeys Os outros objetos da chave. (<B><I>Opcional</I></B>)
     */
    public MultiKey(Object key1, Object key2, Object... moreKeys) {
        List<Object> objects = new LinkedList<>();
        objects.add(key1);
        objects.add(key2);
        objects.addAll(Arrays.asList(moreKeys));
        this.objects = objects;
        this.hashCode = objects.hashCode();
    }

    @Override
    public int compareTo(MultiKey other) {
        return this.hashCode - (other == null ? 0 : other.hashCode);
    }

    @Override
    public boolean equals(Object object) {
        if (this == object) {
            return true;
        }
        if (object instanceof MultiKey) {
            final MultiKey that = (MultiKey) object;
            // se os hashes são diferentes, então de cara já sei que os objetos também são diferentes
            if (this.hashCode != that.hashCode) {
                return false;
            }
            // objetos diferentes podem ter hashes iguais, então comparo eles
            return this.objects.equals(that.objects);
        }
        return false;
    }

    /**
     * Obtém o objeto do índice informado desta {@link MultiKey chave composta}.
     * 
     * @param index O índece do objeto que se deseja obter.
     * @return O objeto no índice informado.
     */
    public <T> T get(int index) {
        return (T) objects.get(index);
    }

    @Override
    public int hashCode() {
        return this.hashCode;
    }

    /**
     * @return A quantidade de objetos que compõe esta {@link MultiKey chave composta}.
     */
    public int size() {
        return objects.size();
    }
}

Agora que temos a implementação de uma chave para nosso Cache, vamos implementar o algorimo de consulta ao cache numa classe com um nome mais do que apropriado, Cache:

package br.com.staroski.tools.cache;

import java.util.HashMap;
import java.util.Map;

/**
 * Implementação de um cache de objetos.<BR>
 * Instâncias desta classe dependem de um {@link CacheLoader} para carregar os objetos sob demanda.<BR>
 * Como já dizia o véio deitado: <I>Se cache, cache! Se não cache, diz!</I>
 * 
 * @author Ricardo Artur Staroski
 *
 * @see CacheLoader
 */
@SuppressWarnings("unchecked")
public final class Cache {

    // mapa que armazena os objetos carregados
    private final Map<MultiKey, Object> map = new HashMap<>();

    /**
     * Limpa este {@link Cache cache}.
     */
    public void clear() {
        map.clear();
    }

    /**
     * Obtém o valor a partir da chave de busca informada.<BR>
     * Se o valor ainda não tiver sido carregado, então o {@link CacheLoader} informado será utilizado para carregá-lo e armazenar no {@link Cache cache}.
     * 
     * @param loader O {@link CacheLoader} responsável por carregar o valor.
     * @param key A chave de busca utilizada para obter o valor.
     * @return O valor encontrado para a chave informada, se a chave for <code>null</code>, o retorno será <code>null</code>.
     * @throws IllegalArgumentException se o {@link CacheLoader} informado for <code>null</code>
     */
    public <K, V> V get(final CacheLoader<K, V> loader, final K key) {
        if (loader == null) throw new IllegalArgumentException("loader cannot be null");
        if (key == null) {
            return null;
        }
        final MultiKey pair = new MultiKey(loader, key);
        V value = (V) map.get(pair);
        if (value == null) {
            synchronized(this) { // os 2 if's são realmente iguais, não é idiotice não, é um "double-checked locking"
                value = (V) map.get(pair);
                if (value == null) {
                    value = loader.load((K) key);
                    map.put(pair, value);
                }
            }
        }
        return value;
    }
}

Perceba que o algoritmo implementado no método get do Cache é idêntico à verificação realizada no método getItens do nosso primeiro exemplo. A única diferença é que nossa classe Cache espera dois parâmetros: o primeiro é o objeto CacheLoader e o segundo é o objeto que será utilizado como chave de busca no cache. Agora você vai entender a necessidade da classe MultiKey, a classe Cache utiliza como chave de busca uma composição entre o CacheLoader informado e a própria chave de busca. Desta forma podemos, por exemplo ter diferentes CacheLoaders para uma mesma chave de busca.

Vejamos agora como ficaria o exemplo do Pedido e sua lista de Itens:

import java.util.List;

import br.com.staroski.tools.cache.Cache;
import br.com.staroski.tools.cache.CacheLoader;

public class Pedido {

	// agora temos uma intância de Cache ao invés de HashMap
	private static final Cache CACHE = new Cache();

	// temos também a implementação de um CacheLoader que será responsável por carregar os itens do pedido recebido como parâmetro
	private final CacheLoader<Pedido, List<Item>> itens = new CacheLoader<Pedido, List<Item>>() {

		public List<Item> load(Pedido pedido) {
			return BaseDados.carregarItensPedido(pedido.getNumero());
		}
	};

	// numero do pedido
	private int numero;

	// método que obtém os itens deste pedido
	public List<Item> getItens() {
		// agora basta utilzar nosso objeto CACHE, passando o CacheLoader que chamamos de 'itens' e o próprio Pedido (this)
		return CACHE.get(itens, this);
	}

	public int getNumero() {
		return numero;
	}

	public void setNumero(int numero) {
		this.numero = numero;
	}
}

Agora não há mais necessidade de escrever a lógica de verificação do cache, ela está incorporada na classe Cache e pode ser reaproveitada. Para cada tipo de dado diferente que desejarmos carregar, basta implementar um CacheLoader diferente, poderíamos por exemplo ter um CacheLoader para produtos, outro CacheLoader para serviços e assim por diante.

É isso, espero que o exemplo possa ser útil.