I. Introduction▲
Cet article est le cinquième d'une série consacrée à la bibliothèque Guava :
- introduction et installation ;
- collections ;
- programmation fonctionnelle ;
- utilitaires ;
- cache et concurrence ;
- tout pour vos Strings et primitifs ;
- un peu de math ;
- hash et I/O.
I-A. Versions des logiciels et bibliothèques utilisés▲
Pour écrire ce document, j'ai utilisé les versions suivantes :
- Java JDK 1.6.0_24-b07 ;
- Eclipse Indigo 3.7 JEE 64b ;
- Maven 3.0.3 ;
- JUnit 4.10 ;
- Guava 14.0.
J'utilise Java 6, car Java 7 n'est pas encore très répandu en entreprise. C'est ce que je vérifie durant mes conférences lorsque je demande qui utilise Java 7 sur ses serveurs de production, mais que très peu de mains se lèvent…
I-B. Mises à jour▲
11 décembre 2013 : création
18 janvier 2014 : ajout du type composite en clé de cache
II. Caches▲
Les caches sont quasi indispensables dans les applications modernes, qui sont souvent interconnectées. Il existe plusieurs bibliothèques de cache comme EhCache, et même des serveurs de cache distribués comme Terracotta. Guava propose également sa solution, dont la spécificité est d'être un cache en mémoire, qui ne fait donc pas de sérialisation. Le contenu du cache n'est pas persisté et il est perdu à l'arrêt du programme. La conservation des données dans le cache consomme donc de la mémoire. En revanche, le cache de Guava est très rapide.
On choisit donc le cache de Guava lorsqu'on veut une solution très rapide, non persisté et qu'on est prêt à payer un coût en mémoire.
Dans le cadre d'un cache, on peut stocker une valeur unique (on parlera de « mémoization ») ou un ensemble de valeurs identifiées par des clés. En première approche un cache pourrait être assimilé à une map, mais c'est une vision un peu trop simple. En effet, un cache doit :
- supporter la concurrence (comme une « ConcurrentMap ») ;
- gérer une date de validité sur ses données, contrairement aux maps qui conservent les données tant qu'elles ne sont pas explicitement supprimées ;
- être capable de charger/recharger les données qu'elle ne possède pas/plus ;
- pouvoir limiter le nombre d'éléments à l'aide d'une stratégie telle que FIFO.
II-A. Création▲
Pour créer un cache Guava pour un élément unique, il faut employer un « Supplier » :
private
Supplier<
Integer>
nbOfDogsSoldSupplier =
Suppliers.memoize
(
new
Supplier<
Integer>(
) {
public
Integer get
(
) {
return
monWebService.checkNombreAnimalVendusHier
(
"dog"
);
}
}
);
Pour utiliser ce cache, il suffit d'appeler la méthode « get() ». Si Guava connaît déjà la valeur alors il la renvoie directement. Sinon il réalise la recherche, ici un appel de Web Service :
@Test
public
void
testSupplier
(
) {
// Arrange
final
int
expected =
31
; // ici 31 chiens ont ete vendus hier.
// Act
final
int
nb=
nbOfDogsSoldSupplier.get
(
);
// Assert
Assert.assertEquals
(
expected, nb);
}
Pour créer un cache avec plusieurs valeurs, c'est à peine plus complexe. Il faut déclarer un « CacheLoader » dont le travail sera de faire le chargement si besoin, et un « LoadingCache » qui sera le cache à proprement parler :
private
static
CacheLoader<
String, Chien>
chienLoader =
new
CacheLoader<
String, Chien>(
) {
@Override
public
Chien load
(
String prenom) throws
Exception {
return
monWebService.searchChien
(
prenom);
}
}
;
private
static
LoadingCache<
String, Chien>
chienCache =
CacheBuilder
.newBuilder
(
)
.build
(
chienLoader);
Il faut gérer le cas où le CacheLoader n'est pas capable de charger la donnée. C'est typiquement le cas lors d'une recherche en base sur un élément qui n'existe pas (ie. null).
Là encore, pour demander une valeur, il suffit d'appeler la méthode « get() » :
@Test
public
void
testMilou
(
) {
// Arrange
final
String prenom =
"Milou"
;
final
SexeEnum expected =
SexeEnum.MALE;
// Act
Chien chien =
null
;
try
{
chien =
chienCache.get
(
prenom);
}
catch
(
ExecutionException e) {
...
}
// Assert
Assert.assertEquals
(
expected, chien.getSex
(
));
}
En fait, ces deux types de caches sont quasi identiques (vu de l'extérieur) au delta des noms de classes. Dans la suite de ce document, c'est le second cache qui sera présenté. Toutefois, on retiendra que les méthodes fonctionnent pour les deux, lorsque ça a du sens.
Bien entendu, on peut aussi insérer des méthodes manuellement (comme dans une map) :
final
Chien lassie =
new
Chien
(
"Lassie"
, FEMALE);
...
chienCache.put
(
"Lassie"
, lassie);
Et on peut demander un « export » du cache sous forme de map concurrente :
final
ConcurrentMap<
String, Chien>
map =
chienCache.asMap
(
);
Imaginons maintenant qu'on utilise un web service (ou une méthode) ayant besoin de plusieurs paramètres. Comme le cache ne prend qu'un seul objet en clé, on doit utiliser un type composite (c'est-à-dire composé de plusieurs champs). Voici un exemple d'un tel objet :
public
class
ChienCriteria {
private
String couleur;
private
SexeEnum sexe;
private
boolean
lof;
// + constructeur(s)
// + hashCode() + equals()
// + getters/setters
Pour que ça fonctionne, il ne faut surtout pas oublier les méthodes « hashCode » et « equals ». Pour le reste, ça marche comme un cache classique.
private
static
CacheLoader<
ChienCriteria, Chien>
chienCriteriaLoader =
new
CacheLoader<
ChienCriteria, Chien>(
) {
@Override
public
Chien load
(
ChienCriteria criteria) throws
Exception {
return
monWebService.get
(
criteria.getCouleur
(
), criteria.getSexe
(
), criteria.isLof
(
));
}
}
;
private
static
LoadingCache<
ChienCriteria, Chien>
chienCriteriaCache =
CacheBuilder//
.newBuilder
(
)//
.build
(
chienCriteriaLoader);
II-B. Éviction▲
Dans le cache, on va parfois devoir supprimer des données, car :
- il n'y a plus de place (ie. on veut économiser de la mémoire) ;
- les données sont périmées.
La limite du nombre d'éléments dans le cache doit être indiquée lors de la déclaration du cache :
final
static
private
int
LIMIT =
1000
;
private
static
LoadingCache<
String, Chien>
chienCache =
CacheBuilder
.newBuilder
(
)
.maximumSize
(
LIMIT)
.build
(
chienLoader);
Il est aussi possible de préciser le poids (en octets) maximum du cache :
final
static
private
int
POIDS_MAX =
2000000
;
private
static
LoadingCache<
String, Chien>
chienCache =
CacheBuilder
.newBuilder
(
)
.maximumWeight
(
POIDS_MAX)
.build
(
chienLoader);
L'empreinte mémoire d'un élément (ie. son poids) est calculée lors de l'insertion dans le cache. Si le chien grossit, cela ne sera pas pris en compte.
Guava supprime des éléments avant d'atteindre la limite. Il commence par les éléments qui sont peu (ou pas) utilisés. Enfin il essaie…
On peut préciser sa propre fonction de calcul du poids à l'aide d'un « weighter » :
final
static
private
int
POIDS_MAX =
2000000
;
private
static
LoadingCache<
String, Chien>
chienCache =
CacheBuilder
.newBuilder
(
)
.maximumWeight
(
POIDS_MAX)
.weigher
(
new
Weigher<
String, Chien>(
) {
@Override
public
int
weigh
(
String name, Chien chien) {
return
something;
}
}
)
...
.build
(
chienLoader);
Voici d'ailleurs ce qu'en dit la documentation : « If your cache should not grow beyond a certain size, just use CacheBuilder.maximumSize(long). The cache will try to evict entries that haven't been used recently or very often. Warning: the cache may evict entries before this limit is exceeded -- typically when the cache size is approaching the limit. Alternately, if different cache entries have different weights -- for example, if your cache values have radically different memory footprints -- you may specify a weight function with CacheBuilder.weigher(Weigher) and a maximum cache weight with CacheBuilder.maximumWeight(long). In addition to the same caveats as maximumSize requires, be aware that weights are computed at entry creation time, and are static thereafter. »
Notez tout de même une petite subtilité. Bien qu'on parle de poids, rien n'oblige à l'exprimer en kilogrammes ou en octets. Prenons en exemple le code suivant :
public
final
static
int
poidsDunRavioliPekinois =
26
; // en gramme
public
final
static
int
radioViandeMale =
38
; // en pourcent
public
final
static
int
radioViandeFemale =
32
; // en pour cent
private
static
LoadingCache<
String, Chien>
chienCache =
CacheBuilder//
.newBuilder
(
)
.maximumWeight
(
12345
) // en nb de raviolis
.weigher
(
new
Weigher<
String, Chien>(
) {
@Override
public
int
weigh
(
String name, Chien chien) {
final
int
ratio =
(
chien.getSex
(
) ==
SexeEnum.MALE) ? radioViandeMale : radioViandeFemale;
final
double
poids =
chien.getWeight
(
); // en kg
final
double
viande =
poids *
ratio; // en gramme
final
int
nbRaviolis =
(
int
) viande /
poidsDunRavioliPekinois;
return
nbRaviolis;
}
}
)
...
Vous l'aurez compris, ici, je convertis mes chiens en unités de raviolis à l'aide d'une formule. J'ai conservé un lien avec le poids physique des chiens, mais on aurait tout aussi pu dire que le poids à utiliser dans le cache dépendait de la couleur…
On peut aussi préciser la durée de validité des données dans le cache. Cette durée est exprimée soit à partir de la date d'insertion, soit à partir de la date de dernière consultation, soit les deux :
private
static
LoadingCache<
String, Chien>
chienCache =
CacheBuilder
.newBuilder
(
)
.expireAfterAccess
(
10
, TimeUnit.MINUTES)
.expireAfterWrite
(
60
, TimeUnit.MINUTES)
.build
(
chienLoader);
Dans l'exemple, les données seront périmées (et supprimées du cache), dès qu'elles n'auront pas été consultées depuis plus de dix minutes. Et dans tous les cas, même si elles sont fréquemment consultées, elles seront périmées une heure après l'insertion (ou la dernière modification).
De même qu'on peut ajouter manuellement une entrée dans le cache, on peut aussi supprimer des éléments manuellement :
chienCache.invalidate
(
"Lassie"
);
Guava permet également d'écouter les événements de suppression des éléments du cache pour lancer une opération (log, rechargement, notification, etc.) :
private
static
RemovalListener<
String, Chien>
chienRemovalListener =
new
RemovalListener<
String, Chien>(
) {
@Override
public
void
onRemoval
(
RemovalNotification<
String, Chien>
removal) {
System.out.println
(
"Le chien "
+
removal.getValue
(
).getName
(
) +
" n'est plus dans le cache..."
);
}
}
;
private
static
LoadingCache<
String, Chien>
chienCache =
CacheBuilder
.newBuilder
(
)
.removalListener
(
chienRemovalListener)
.build
(
chienLoader);
II-C. Rechargement▲
Il ne faut pas confondre « éviction » et rechargement. L'éviction supprime les données (et leurs clés) du cache. Un rechargement recharge les données, si bien que les clés sont toujours présentes :
chienCache.refresh
(
"Lassie"
);
Le rechargement est asynchrone.
Il peut être intéressant de recharger automatiquement les données après leur éviction (dans le listener) ou tout simplement lorsqu'elles sont périmées :
private
static
LoadingCache<
String, Chien>
chienCache =
CacheBuilder
.newBuilder
(
)
.refreshAfterWrite
(
45
, TimeUnit.MINUTES)
.build
(
chienLoader);
II-D. Statistiques▲
On sous-estime trop souvent l'importance de vérifier le fonctionnement des caches, pour bien le régler. Avec Guava, c'est relativement simple :
CacheStats stats=
chienCache.stats
(
);
final
long
hits =
stats.hitCount
(
);
final
long
loads =
stats.loadCount
(
);
final
long
loadSuccess =
stats.loadSuccessCount
(
);
final
double
miss =
stats.missRate
(
);
III. Concurrence▲
La concurrence est une question qui se pose de plus en plus en programmation. Les dernières versions du JDK ont chacune apporté leur pierre à l'édifice, mais ça reste relativement complexe. Guava va simplifier/clarifier cela, tout en allant bien plus loin.
Le fait que Guava travaille avec des immutables le rend particulièrement adapté à la programmation concurrente.
III-A. Les Futures du JDK▲
Le problème de la concurrence se pose généralement, quand on a besoin de faire plusieurs traitements, indépendants, qui prennent du temps. Or on aimerait lancer ces traitements de manière parallèle pour gagner un peu de temps. Prenons un exemple simple ; on veut employer des Web services lents pour rechercher les chiens Milou et Lassie :
public
List<
Chien>
chercheMilouEtLassie
(
) {
final
Chien milou =
service1
(
"Milou"
); // 3 secondes
final
Chien lassie =
service2
(
"Lassie"
, FEMALE); // 3 secondes
return
ImmutableList.of
(
milou,lassie);
}
Si chaque appel de service prend trois secondes, l'ensemble de la recherche va donc prendre six secondes. Pour que les appels soient parallélisés, on peut utiliser les interfaces « Callable » et « Future » du JDK :
public
List<
Chien>
chercheMilouEtLassieParallele
(
) {
final
Future<
Chien>
milouFuture =
service1b
(
"Milou"
);
final
Future<
Chien>
lassieFuture =
service2b
(
"Lassie"
, FEMALE);
try
{
return
ImmutableList.of
(
milouFuture.get
(
), lassieFuture.get
(
));
}
catch
(
Exception e) {
...
}
}
Lorsqu'on demande un « Future » en Java, on dit juste qu'on veut un « service » qui renverra un résultat. Et c'est lors de l'appel à la méthode « get() » qu'on va réellement demander le résultat.
Pour créer un « Future », le plus simple est de demander un « ExecutorService » sous forme de singleton :
private
static
ExecutorService executor =
Executors.newSingleThreadExecutor
(
);
private
static
Future<
Chien>
service1b
(
final
String prenom) {
return
executor.submit
(
new
Callable<
Chien>(
) {
@Override
public
Chien call
(
) throws
Exception {
Thread.sleep
(
3000
); // pour que ca dure 3 secondes.
return
new
Chien
(
prenom);
}
}
);
}
III-B. ListenableFuture▲
Les « Futures » fonctionnent très bien et devraient vous suffire dans la plupart des cas. Ils ont néanmoins un défaut, car on ne sait pas dans quel état est le « future », en particulier s'il a déjà obtenu son résultat. C'est pour cela que Guava a introduit les « ListenableFuture » dont le surcoût est négligeable. Pour le créer, il suffit de décorer un « executor » :
private
static
ExecutorService executor =
Executors.newSingleThreadExecutor
(
);
private
static
ListeningExecutorService listeningExecutor =
MoreExecutors.listeningDecorator
(
executor);
Encore une fois, il n'y a pas beaucoup de changements sur les méthodes :
private
static
ListenableFuture<
Chien>
service1c
(
final
String prenom) {
return
listeningExecutor.submit
(
new
Callable<
Chien>(
) {
@Override
public
Chien call
(
) throws
Exception {
Thread.sleep
(
3000
); // pour que ca dure longtemps.
return
new
Chien
(
prenom);
}
}
);
}
Le grand avantage des « ListenableFutures » est qu'ils permettent l'utilisation combinée de « callbacks » qui seront appelés, dès que le traitement sera fini (ou immédiatement s'il est terminé) :
final
ListenableFuture<
Chien>
milouFuture =
service1c
(
"Milou"
);
Futures.addCallback
(
milouFuture, new
FutureCallback<
Chien>(
) {
@Override
public
void
onFailure
(
Throwable arg0) {
...
}
@Override
public
void
onSuccess
(
Chien arg0) {
...
}
}
);
On peut, par exemple, mettre le résultat en cache :
private
static
Map<
String, Chien>
chienCache =
Maps.newHashMap
(
);
...
final
String prenom =
"Milou"
;
final
ListenableFuture<
Chien>
chienFuture =
service1c
(
prenom);
Futures.addCallback
(
chienFuture, new
FutureCallback<
Chien>(
) {
@Override
public
void
onFailure
(
Throwable t) {
...
}
@Override
public
void
onSuccess
(
Chien chien) {
chienCache.put
(
prenom, chien);
}
}
);
Puisqu'on a abordé la classe utilitaire « Futures », il convient de préciser qu'elle travaille principalement avec des « ListenableFuture » et non avec des « Future » simples. Là où cela devient intéressant, c'est que l'appel à la méthode « get() » des « Future » standards, dans le cadre d'un renvoi de liste, bloque l'ensemble du retour. Avec un « ListenableFuture », on évite donc ce blocage :
public
ListenableFuture<
List<
Chien>>
chercheMilouEtLassieListenable
(
) {
final
String prenom =
"Milou"
;
final
ListenableFuture<
Chien>
chienFuture =
service1c
(
prenom);
...
final
ListenableFuture<
Chien>
fooFuture =
service2c
(
"Lassie"
, FEMALE);
...
return
Futures.allAsList
(
chienFuture, fooFuture);
}
Elle permet également :
- transform, qui renvoie un ListenableFuture qui est le résultat de l'application d'une AsyncFunction sur les éléments initialement remontés ;
- allAsList, qui permet de renvoyer une liste de ListenableFuture ;
- successfulAsList, qui renvoie une liste des ListenableFuture qui ont réussi (cf. plus haut).
Une AsyncFunction nécessite d'implémenter la méthode « apply(..) » qui est appelée de manière asynchrone et qui permet de transformer une valeur.
Il est également possible d'utiliser une « CheckedFuture », qui étend les fonctionnalités de « ListenableFuture » avec une méthode « get » pouvant lancer une exception si tout ne se passe pas comme prévu : interuption, timeout, etc. Les méthodes additionnelles sont :
- checkedGet(), qui renvoie une exception spécifique correspondant aux InterruptedException, CancellationException et ExecutionException ;
- checkedGet(long timeout, TimeUnit unit), qui renvoie des exceptions comme checkedGet(), mais qui renvoie aussi une TimeoutException quand le temps est dépassé.
Et si les exceptions proposées ne conviennent pas, il est simple de partir d'un « ListenableFuture » déjà existant pour en faire un « CheckedFuture » qui lève une exception maison :
final
String prenom =
"Milou"
;
final
ListenableFuture<
Chien>
chienFuture =
service1c
(
prenom);
final
Function<
Exception , MaChienException>
func =
new
Function<
Exception, MaChienException>(
) {
@Override
public
MaChienException apply
(
Exception e) {
return
new
MaChienException
(
e.getMessage
(
));
}
}
;
final
CheckedFuture<
Chien,MaChienException>
cf =
Futures.makeChecked
(
chienFuture, func);
IV. Conclusion▲
Maintenant que vous savez mettre en cache et jouer sur la concurrence, vous êtes en mesure de développer des applications super rapides, grâce à Guava. N'hésitez pas à consulter les autres épisodes de cette série pour découvrir les fonctionnalités fantastiques de la bibliothèque.
Vos retours nous aident à améliorer nos publications. N'hésitez donc pas à commenter cet article sur le forum : 2 commentaires
V. Remerciements▲
D'abord, j'adresse mes remerciements à l'équipe Guava, chez Google, pour avoir développé une bibliothèque aussi utile et pour la maintenir. Je n'oublie pas tous les contributeurs qui participent notamment sur le forum Guava.
Plus spécifiquement en ce qui concerne cet article, je tiens à remercier l'équipe de Developpez.com et plus particulièrement Bernard Le Roux, Ricky81, Mickael Baron, Yann Caron, Logan, et Vincent Viale.
VI. Annexes▲
VI-A. Liens▲
Guava : https://code.google.com/p/guava-libraries/
Article « Simplifier le code de vos beans Java à l'aide de Commons Lang, Guava et Lombok » :
https://thierry-leriche-dessirier.developpez.com/tutoriels/java/simplifier-code-guava-lombok/
Blog sur Guava : https://blog.developpez.com/guava/
VI-B. Liens personnels▲
Retrouvez ma page et mes autres articles sur Developpez.com à l'adresse
https://thierry-leriche-dessirier.developpez.com/#page_articlesTutoriels
Suivez-moi sur Twitter : @thierryleriche(https://twitter.com/thierryleriche)@thierryleriche