Il y a 5 années · 12 minutes · Java / JEE

Le gestionnaire de cache de Guava

L’objectif de cet article est de présenter le gestionnaire de cache disponible dans la bibliothèque Guava.
Guava est une bibliothèque utilitaire publiée par Google qui couvre un grand nombre de domaines (collections, IO, reflections, programmation fonctionnelle, etc …) et est souvent déjà présente dans un grand nombre de nos projets.
Cependant, la raison de l’inclusion de cette bibliothèque est rarement l’utilisation du gestionnaire de cache alors qu’il a toute sa place dans de nombreuses situations.

Rappels sur les gestionnaires de caches

Utiliser un gestionnaire de cache est une problématique qui revient très souvent lors de nos développements et nous avons alors à disposition de nombreuses solutions disponibles :

  • L’interface Map lorsque le besoin est très simple :

    • La quantité d’objets à stocker est plus ou moins connue à l’avance et ne risque pas d’impacter la mémoire de la JVM ;
    • Une fois calculés, ces objets sont valables pendant toute la durée de vie de l’exécution de l’application. Il n’y a donc jamais de raison de les calculer à nouveau.
  • L’interface ConcurrentMap lorsque les besoins sont similaires à Map mais avec un accès multi-threads
  • Ehcache: le plus populaire des frameworks de cache dans l’écosystème Java, avec entre autre comme fonctionnalités :

    • Gestion des TTL
    • Gestion d’une taille max avec l’algorithme LRU (Least Recently Used)
    • Persistance des données sur disque
    • Synchronisation des caches entre plusieurs JVM
  • Memcached/Redis : cette fois-ci il s’agit d’éléments d’infrastructure qui permettent à plusieurs applications/JVM d’y accéder de manière centralisée

Le gestionnaire de cache de Guava vient s’intercaler entre l’utilisation d’une ConcurrentMap et Ehcache en ne reprenant par défaut que les fonctionnalités basiques de ce dernier :

  • Gestion des TTL
  • Gestion des évictions (ex: nombre max d’éléments)

Dans de nombreux cas, ce sont les seules caractéristiques qui nous intéressent et il s’agit alors du domaine de prédilection de l’utilisation de Guava.

Utilisation de Guava

Utilisation classique

Il est possible d’utiliser le gestionnaire de cache de Guava avec un algorithme classique comme nous le ferions avec Ehcache ou Memcached :       

//Creation d'un cache classique avec des String comme clefs et valeurs
Cache<String, String> cache = CacheBuilder.newBuilder()
    .maximumSize(100) // Taille Max
    .expireAfterWrite(1, TimeUnit.MINUTES) // TTL
    .build();

String myValue = cache.getIfPresent("myKey_1");
if (myValue == null) {
    //Calcul de la valeur et mise en cache
    myValue = computeValue("myKey_1");
    cache.put("myKey_1", myValue);
}

Cependant, il est préférable de l’utiliser de manière beaucoup plus élégante en utilisant un CacheLoader ou un Callable.

Utilisation d’un CacheLoader

Dans cette situation, nous allons indiquer au gestionnaire de cache comment calculer les valeurs directement lors de l’initialisation du cache.
Ainsi, tout appel au cache entraînera un calcul de la valeur si celle ci n’est pas déjà présente.

LoadingCache<String,String> cache = CacheBuilder.newBuilder()
    .maximumSize(100)
    .expireAfterWrite(1, TimeUnit.MINUTES)
    .build(new CacheLoader<String, String>() {
        @Override
        public String load(String s){
            // Warning : must never return null  !
            return computeValue(s);
        }
    });

//Never null
String myValue = cache.get("myKey_1");

A noter que deux appels en simultanés de la recherche de la même clef ne provoquera pas un deuxième calcul de la valeur comme cela peut être le cas avec la méthode classique : le second appel sera mis en attente du résultat du premier appel.
Dans cet exemple, j’utilise la méthode de l’interface LoadingCache

V get(K key) throws ExecutionException;

mais il existe aussi la méthode

V getUnchecked(K key);

qui cette fois ne provoque pas la levée d’exception et donc ne vous oblige pas à faire de blocs try/catch. À utiliser donc, suivant vos cas d’utilisations.

Cette méthode est bien adaptée lorsque les données du cache sont homogènes et du même type. Par exemple, lorsqu’il s’agit de cacher des objets en provenance d’une base de données.
Cependant, il arrive que nous utilisions un cache pour y stocker des données hétérogènes comme par exemple des résultats de web service avec des appels différents suivant la clef. Dans ce cas, l’utilisation d’un Callable est adaptée.

Utilisation d’un Callable

Imaginons le cas d’utilisation suivant :
Vous avez une application de type E-Commerce qui en plus de vendre sur votre site va publier vos objets sur EBay. Afin de créer une enchère, vous avez dans un premier temps besoin de récupérer certains types de base : les durées de mise en vente disponibles pour une catégorie, les conditions des objets (neuf, occasion, HS, etc ..), les types de transports disponibles, etc.

Bien entendu, vous n’allez pas aller chercher la liste de ces valeurs disponibles à chaque fois que vous voulez créer une enchère, vous allez garder ces données en cache.
Cependant, les web services à appeler sont différents et il n’est donc pas possible d’utiliser un CacheLoader générique car les données sont hétérogènes.
Pour résoudre ce problème, le gestionnaire de cache de Guava met à notre disposition le chargement des données par Callable :  

public class EbayCacheService {
    //Service appelant les web services EBay
    EbayService ebayService = new EbayService();

    //Notre cache
    Cache<String, List> cache = CacheBuilder.newBuilder()
            .maximumSize(20)
            .expireAfterWrite(1, TimeUnit.DAYS) // Expire après une journée
            .build();

    public List getConditionValues(final String categoryId) throws ExecutionException {
        return cache.get("conditions:" + categoryId, new Callable<List>() {
            public List call() {
                return ebayService.getConditionValues(categoryId);
            }
        });
    }

    public List getListingDurations(final String categoryId) throws ExecutionException {
        return cache.get("durations:" + categoryId, new Callable<List>() {
            public List call() {
                return ebayService.getListingDurations(categoryId);
            }
        });
    }

    public List getShippingDetailsTypes() throws ExecutionException {
        return cache.get("shippingDetailsTypes", new Callable<List>() {
            public List call() {
                return ebayService.getShippingDetailsTypes();
            }
        });
    }
}

Pour chaque type d’objet, nos méthodes de chargement vont donc être différentes.

Évictions

L’éviction est la principale caractéristique qui différencie l’utilisation d’un cache basé sur une Map et l’utilisation d’un gestionnaire de cache plus évolué. Elle permet de garantir que l’utilisation mémoire et la fraîcheur des données seront toujours sous contrôle.

Time to live

Lors des exemples précédant, nous avons utilisé une éviction basée sur la date d’insertion des données dans le cache avec la méthode

public CacheBuilder<K, V> expireAfterWrite(long duration, TimeUnit unit) {}

Il est aussi possible de se baser sur la date du dernier accès (écriture et/ou lecture) :

public CacheBuilder<K, V> expireAfterAccess(long duration, TimeUnit unit){}

Poids des objets

Au lieu de se baser sur le nombre d’éléments dans le cache, il est aussi possible de se baser sur le poids des objets. Ceci est particulièrement pertinent dans le cas d’utilisation d’objet hétérogènes où des objets très légers peuvent côtoyer d’importantes collections.
Dans cette situation, il est difficile de déterminer le nombre maximum d’éléments à garder en cache sans risquer une utilisation mémoire incontrôlée.

Contrairement à ce que l’on pourrait penser, cette utilisation ne se base pas sur la taille en octets (difficile à calculer en Java) mais à un poids déterminé par l’utilisateur. En reprenant l’exemple utilisé pour les Callable, il est possible de se baser par exemple sur le nombre d’objets présent dans chaque liste :

Cache<String, List> cache = CacheBuilder.newBuilder()
            .maximumSize(20)
            .expireAfterWrite(1, TimeUnit.DAYS)
            .maximumWeight(1000) // Nombre maximum d’objet au total contenu dans les listes
            .weigher(new Weigher<String, List>() {
                @Override
                public int weigh(String key, List value) {
                    return value.size();
                }
            })
            .build();

Weaks et Softs references

Même si ce n’est pas conseillé, il est possible de faire appel à des références weaks pour les clefs et les valeurs ce qui transfert la gestion de l’éviction au Garbage Collector.

Des softs références peuvent aussi être utilisées pour les valeurs. Bien que non testé, en théorie, il doit être possible de créer un cache qui va utiliser près de 100% de la heap et libérer la mémoire seulement lorsque le Garbage Collector en aura besoin pour allouer de nouveaux objets.

Un très bon article pour comprendre les subtilités des weaks et softs références est disponible sur java.net : http://weblogs.java.net/blog/2006/05/04/understanding-weak-references

Fonctionnalités avancées

Le gestionnaire de cache de Guava possède des fonctionnalités avancées mais celles-ci sont désactivées par défaut. La philosophie derrière Guava étant de laisser ces choix entre les mains des développeurs.

Nettoyage des données

Contrairement à Ehcache, Guava n’utilise pas un thread séparé pour supprimer les données expirées du cache.
En effet, le gestionnaire de cache de Guava va effectuer sa maintenance lors de chaque écriture ou lecture si les écritures sont trop rares.
Par conséquent, les objets arrivés à expiration seront quand même conservés si le cache n’est pas/plus utilisé.
Si besoin, il est possible de gérer manuellement ce nettoyage en appelant la méthode Cache.cleanUp() voire de créer un thread qui va appeler cette méthode à intervalle régulier.

Statistiques

Il est possible d’activer les statistiques lors de la création d’un cache avec la méthode Cachebuilder.recordStats().
Il sera alors possible d’utiliser la méthode Cache.stats() pour accéder à un nombre important de métriques comme le taux de hits et le nombre d’évictions et ainsi pouvoir publier ces informations en JMX très facilement.

Remove Listener

Afin de monitorer les suppressions d’éléments du cache, il est possible de rajouter un RemoveListener lors de la création du cache et ainsi de garder une trace de toutes les évictions.

Cache<String, List> cache = CacheBuilder.newBuilder()
    .removalListener(new RemovalListener<String, List>() {
        @Override
        public void onRemoval(RemovalNotification<String, List> removal) {
            //removal.getValue();
        }
    })
    .build();

Guava en tant que Cache de second niveau

Bien que totalement autonome, il est facilement possible d’utiliser Guava en tant que cache de second niveau qui va chercher ses informations dans un premier temps dans un gestionnaire de cache de type infrastructure. L’utilisation d’un CacheLoader est tout à fait adapté à cette situation. Voici un exemple utilisant Redis et Guava en tant que cache de second niveau :

CacheLoader<String, String> redisCacheLoader = new CacheLoader<String, String>() {
    @Override
    public String load(String key) throws Exception {
        //Check value in Redis
        Jedis jedis = new Jedis("redis-hostname", 6379);
        String value = jedis.get(key);

        //If not present, compute and put it in Redis
        if (value == null) {
            value = computeValue(key);
            jedis.set(key, value);
        }
        return value;
    }
    };

Cache<String, String> cache = CacheBuilder.newBuilder()
    .maximumSize(100)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build(redisCacheLoader);

Le fonctionnement est donc le suivant :

  • Dans un premier temps, les valeurs seront issues de Guava ;
  • Si la clef recherchée n’est pas présente dans le cache Guava, une recherche aura lieu dans Redis ;
  • Si la clef n’est pas trouvée dans Redis, la valeur associée sera calculée et ajoutée à Redis.

Grâce à ce mécanisme, nous bénéficions aussi de la recherche unique proposée par Guava : si deux appels ont lieu avec la même clef en parallèles, le calcul ne sera fait qu’une seule fois et le second thread sera mis en attente du premier.

Refresh

Guava propose le refresh du cache par le biais des clés déjà présentes. La force de ce cache réside dans le fait qu’il s’effectuera de manière asynchrone. Pour ce faire, nous devons surcharger le méthode CacheLoader.reload(K, V) :

final ListeningExecutorService listeningExecutorService = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(2));


LoadingCache<User, List<Product>> cache = CacheBuilder
 .newBuilder()
 .refreshAfterWrite(2, TimeUnit.MINUTES)
 .build(new CacheLoader<User, List<Product>>() {
  @Override
  public List<Product> load(User user) throws Exception {
   return loadUserFromDatabase(user);
  }
  @Override
  public ListenableFuture<List<Product>> reload(final User user, List<Product> oldValue) throws Exception {
   return listeningExecutorService.submit(new Callable<List<Product>>() {
    public List<Product> call() throws Exception {
     return load(user);
    }
   });
  }
 });

Comment et quand est appelée cette méthode ?

Lors de la déclaration de notre cache, nous avons défini la propriété refreshAfterWrite. Cette méthode permet de rendre une clé éligible au refresh après la période spécifiée en paramètre (2 minutes dans notre exemple).  Attention, cela signifie que la valeur de la clé sera rafraîchie lors du prochain accès au cache, qui peut être bien au delà des deux minutes. Dans cette situation, le premier appel d’une clé déjà en cache ayant lieu après 2 minutes retournera l’ancienne valeur et la nouvelle sera calculée pour un appel ultérieur.

Combiné avec la méthode expireAfterWrite, il est possible d’avoir un cache « intelligent » :

CacheBuilder.newBuilder().refreshAfterWrite(2,TimeUnit.MINUTES).expireAfterAccess(4, TimeUnit.MINUTES).build();

Dans cet exemple, une clé peut devenir éligible à un refresh au bout de 2 minutes et si aucun accès au cache n’est effectué au bout des 4 minutes, cette clé sera déclarée obsolète.

Asynchrone

Nous voyons que la méthode reload renvoie un ListenableFuture. Cet objet est similaire à java.concurrent.Future mais permet en plus d’appeler une callback qui est exécutée dans un ExecutorService. Ce dernier initialise un ThreadPool permettant ainsi des accès à notre cache non bloquants.

Conclusion

Bien que très léger, Guava reprend les principales caractéristiques que l’on est en droit d’attendre d’un gestionnaire de cache moderne. Certaines ne sont pas activées par défaut et le choix reste entre les mains des développeurs.
Si vos projets utilisent déjà Guava alors vous n’avez aucune excuse pour ne pas utiliser son mécanisme de cache lorsque le besoin s’en fera sentir.
Si ce n’est pas le cas, il s’agit peut être de l’opportunité parfaite pour l’inclure et par la suite bénéficier des très nombreux outils disponibles dans cette bibliothèque.
Cependant, il s’agit d’un gestionnaire de cache “In Process” qui n’est pas forcément adapté à toutes les situations : taille importante, accès à partir de plusieurs JVM, sharding, persistance.
Comme souvent dans le monde Java, la complexité revient à choisir le bon outil dans la bonne situation.

Cette article est grandement inspiré de la documentation officielle : http://code.google.com/p/guava-libraries/wiki/CachesExplained

Charles Blonde
Charles a rejoint Xebia en 2011 après 7 années d'expériences en développement Java JEE. Il intervient sur des missions d'expertise liées à la performance. Passionné de développement mais aussi d'infrastructure, il est un fervent défenseur de la philosophie DevOps et des technologies Opensource et Cloud.
Nicolas Jozwiak
Nicolas est delivery manager disposant de 12 ans d’expérience en conception et développement. Son parcours chez un éditeur avant son entrée chez Xebia lui a notamment permis de développer de solides compétences dans le domaine de la qualité et de l’industrialisation (tests, intégration continue, gestion de configuration, contrôle qualité). Bénéficiant d’une expérience très solide de mise en place des méthodes agiles et d’accompagnement d’équipes sur le terrain, il s’attache à mettre à profit quotidiennement son expérience qui est reconnue pour son approche pragmatique, proactive et pédagogique.

One thought on “Le gestionnaire de cache de Guava”

  1. Publié par Sebastien Lorber, Il y a 5 années

    A noter aussi la présence de la méthode « Suppliers.memoizeWithExpiration »

    C’est très utile quand on veut « cacher » un seul objet stocké en base et qui ne change que très rarement.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *