Accueil Nos publications Blog Spring 3.1 : Utiliser l’abstraction de cache

Spring 3.1 : Utiliser l’abstraction de cache


Avec sa version 3.1, Spring apporte de nombreuses nouvelles fonctionnalités, parmi lesquelles se trouve une abstraction de cache. Cette dernière permet de s’absoudre des dépendances explicites au cache utilisé.

En effet, une fois cette fonctionnalité configurée, les développeurs auront à leur disposition quatre annotations permettant d’effectuer les opérations principales sur le cache, à savoir insertion, récupération et suppression. La documentation officielle se trouve ici.

Nous allons voir ici un cas d’école afin de se familiariser avec l’utilisation des annotations suivantes :

L’annotation @Caching n’étant qu’une encapsulation permettant d’utiliser plusieurs fois les annotations précédentes sur une même méthode, elle ne sera pas utilisée dans cet exemple.

Le contexte

Dans le cadre d’un projet web affichant des statistiques par pays, vous devez utiliser un web-service remontant le Bonheur national brut. Dans un premier temps, vous générez le client de votre web-service et l’utilisez dans un service métier. En effet, l’interface du web-service client étant générée, nous ne pouvons la modifier. Nous avons donc en jeu deux interfaces, celle du service métier et celle du web-service :


public interface GnhWebService {
Integer getGrossNationalHappiness(String country);
}

public interface CountryService {
Integer retrieveGrossNationalHappiness(String country);
void setGnhWebService(GnhWebService meteoWebService);
}

Test first !

Un test simple permettra de vérifier que notre service fonctionne correctement. Nul besoin à ce niveau d’avoir et d’utiliser les stubs du client web-service, car il s’agit d’un test unitaire.


@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration
public class CountryServiceTest {

private GnhWebService gnhWebService;

@Autowired
private CountryService service;

@Before
public void before() {
gnhWebService = PowerMockito.mock(GnhWebService.class);      PowerMockito.when(gnhWebService.getGrossNationalHappiness("france")).thenReturn(2000);
service.setGnhWebService(gnhWebService);
}

@Test
public void testRetrieveGrossNationalHappiness() {
String country = "france";
Integer gnh = service.retrieveGrossNationalHappiness(country);
assertThat(gnh, CoreMatchers.equalTo(2000));
}
}

Ci-dessous, vous trouverez le fichier de configuration de Spring.


<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="https://www.springframework.org/schema/beans"
xmlns:cache="https://www.springframework.org/schema/cache"
xmlns:context="https://www.springframework.org/schema/context" xmlns:p="https://www.springframework.org/schema/p"
xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans-3.0.xsd https://www.springframework.org/schema/cache https://www.springframework.org/schema/cache/spring-cache.xsd https://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context-3.0.xsd">

<context:component-scan base-package="fr.soat.spring.cache.service" />
</beans>

Comme vous pouvez le voir, le setup est le plus simple possible et le test trivial, néanmoins il pose les bases de notre service. En effet, on utilise un mock dynamique (ici avec la version Mockito de PowerMock) afin de simuler la réponse du web-service.

Afin de faire fonctionner notre test, il nous reste à implémenter le service qui se résumera à ça :


@Component
public class CountryServiceImpl implements CountryService {
@Autowired(required = false)
private GnhWebService gnhWebService;

public Integer retrieveGrossNationalHappiness(String country) {
return gnhWebService.getGrossNationalHappiness(country);
}

public void setGnhWebService(GnhWebService gnhWebService) {
this.gnhWebService = gnhWebService;
}
}

Amélioration

On vous informe à présent que le Bonheur national brut de chaque pays est mis à jour tous les ans. Il n’est donc pas nécessaire de rappeler le web-service alors que l’on sait que la valeur retournée sera la même, c’est l’occasion de mettre cette dernière en cache !

Pour ce faire, nous allons dans un premier temps configurer Spring afin que le framework utilise l’implémentation la plus simple fournie (ConcurrentMap). Pour cela il faut déclarer un cache Manager :


<cache:annotation-driven />
<bean id="cacheManager" class="org.springframework.cache.support.SimpleCacheManager">
<property name="caches">
<set>
<bean class="org.springframework.cache.concurrent.ConcurrentMapCacheFactoryBean"
p:name="cache-gnh" />
</set>
</property>
</bean>

Le principe du test est de vérifier que le web-service n’est pas appelé alors que la donnée est considérée comme valide. Pour ce faire, on “espionnera” le mock dynamique grâce à Mockito :


@Test
public void testRetrieveGrossNationalHappiness() throws Exception {
String country = "france";
// First call, the web-service is expected to be called
Integer gnh = service.retrieveGrossNationalHappiness(country);
// Second call, the web-service is not expected to be called
gnh = service.retrieveGrossNationalHappiness(country);
// Assert that the web-service has been called once whereas the service was called twice
PowerMockito.verifyPrivate(gnhWebService, Mockito.times(1)).invoke("getTemperature", country);
}

A ce stade, le test échoue avec une erreur très explicite :
Test en erreur

Nous allons donc ajouter l’annotation de cache @Cacheable sur la méthode du service :


@Cacheable(value = "cache-gnh", key = "#country")
public Integer retrieveGrossNationalHappiness(String country) {
return gnhWebService.getGrossNationalHappiness(country);
}

Ré-exécutez le test et vous remarquerez que tout se déroule sans encombre. En effet, lors du premier appel, la valeur retournée est mise en cache avec une clé correspondant au hashcode du paramètre country. Au moment du deuxième appel, la même clé étant présente dans le cache, la valeur correspondante est retournée sans que la méthode soit exécutée.

Aller plus loin

Nous avons jusque-là abordé l’insertion et la récupération de données dans le cache au travers de la couche d’abstraction fournie par Spring. Nous allons à présent voir la suppression de donnée (@CacheEvict) ainsi que l’alimentation du cache sans utilisation directe (@CachePut).

On nous demande d’ajouter à notre service métier, deux nouvelles méthodes faisant appel à un nouveau web-service remontant cette fois la température moyenne d’un pays. La première méthode, qui sera utilisée sur la page principale du site, affichera la donnée mise en cache avec une durée de validité de 1 seconde (durée réduite pour faciliter notre test). La seconde méthode remontera l’information en temps réel et alimentera le cache avec la dernière donnée : cette donnée actualisée sera donc exploitée par la première méthode. On passera sur la mise en place du nouveau web-service et de l’injection correspondante.

Pour tester ces deux comportements, nous allons ajouter quelques méthodes à notre test :


@Test
public void testGetCachedTemperature() throws Exception {

String country = "france";
// First call, the web-service is expected to be called
service.retrieveCachedTemperature(country);
// We wait for cache value to expire
Thread.sleep(1020);
// Second call, the web-service is expected to be called
service.retrieveCachedTemperature(country);
// Assert that the web-service has been called twice as value expire between the two calls
PowerMockito.verifyPrivate(meteoWebService, Mockito.times(2)).invoke("getTemperature", country);
}

@Test
public void testGetRealTimeTemperature() throws Exception {
String country = "france";
// RealTime call, the web-service is expected to be called
service.retrieveRealTimeTemperature(country);
// RealTime call, the web-service is expected to be called
Temperature firstTemperature = service.retrieveRealTimeTemperature(country);
// Assert that the web-service has been called twice
PowerMockito.verifyPrivate(meteoWebService, Mockito.times(2)).invoke("getTemperature", country);
// Second cached call, the web-service is not expected to be called as value have been cached by previous RealTime call
Temperature secondTemperature = service.retrieveCachedTemperature(country);
// Assert that the web-service has been called twice whereas the service was called 3 times
PowerMockito.verifyPrivate(meteoWebService, Mockito.times(2)).invoke("getTemperature", country);
// Assert values are the same between first RealTime call and the cached one
assertThat(firstTemperature, CoreMatchers.equalTo(secondTemperature));
// RealTime call, the web-service is expected to be called
service.retrieveRealTimeTemperature(country);
// Assert that the web-service has been call each time the real time method was
PowerMockito.verifyPrivate(meteoWebService, Mockito.times(3)).invoke("getTemperature", country);
}

Au niveau du premier test, les deux premiers appels, effectués à quelques millisecondes d’intervalle, sont sensés retourner le même objet, alors que le troisième, effectué après avoir attendu plus que la durée fatidique d’une seconde doit nous retourner un nouvel objet. Le test vérifiera également que le web-service n’a été appelé qu’une seule fois à la suite des 2 premiers appels, et une fois de plus au moment du troisième.

A ce stade, le test ne fonctionne évidemment pas, le service principal n’ayant pas été implémenté.
Ce dernier se résumera alors à l’appel du web-service et à l’utilisation des annotations de cache suivantes :


@Cacheable(value = "cache-temperature", key = "#country")
@CacheEvict(value = "cache-temperature", beforeInvocation = true, condition = "#root.caches[0].get(#country) != null and T(System).currentTimeMillis() - #root.caches[0].get(#country).get().getValidAt() > 1000")
public Temperature retrieveCachedTemperature(String country) {
return retrieveTemperatureInternal(country);
}

@CachePut(value = "cache-temperature", key = "#country")
public Temperature retrieveRealTimeTemperature(String country) {
return retrieveTemperatureInternal(country);
}

À noter l’utilisation du champ beforeInvocation de l’annotation @CacheEvict qui permet de réaliser la suppression des données avant l’appel à la méthode. On remarque que la condition de suppression fait explicitement référence au cache : ici on ne peut pas s’affranchir de la connaissance de l’implémentation.

Cet exemple montre la simplicité d’utilisation de l’abstraction de cache de Spring. Il met également en lumière son défaut majeur, la non-gestion directe de la durée de validité des données (TTL : Time To Live), relative à l’implémentation du cache. La durée de vie est gérée dans notre exemple par le biais d’une astuce : on stocke également la date d’ajout dans le cache afin de pouvoir la réutiliser. Ce contournement a cependant ses limites vu que l’on accède au cache par le biais de son index dans une collection et non par son nom.

Une solution, parmi d’autres, pour résoudre ce problème est d’utiliser la seconde implémentation fournie par Spring (Ehcache) où le TTL peut être configuré dans le fichier ehcache.xml.

Vous pouvez trouver les sources à l’adresse suivante : https://github.com/soatexpert/spring31CacheDemo