lundi 17 février 2014

Traitements asynchrones avec Spring @Async

Dans une application Web, l'utilisateur ne doit pas attendre inutilement. Si une requête HTTP déclenche un traitement long et que le résultat de ce traitement n’a aucun impact sur la réponse à renvoyer, pourquoi attendre qu'il se termine ?

Un traitement long ne devrait jamais être appelé de manière synchrone. Cependant, le rendre asynchrone demande un travail plus ou moins important. Heureusement, Spring vient à la rescousse avec une annotation, @Async, qui fait tout le boulot.

Traitements asynchrones


L'exemple ci-dessus parle d'une requête Web, mais n'importe quel processus peut tirer parti de l'asynchronisme. Web ou non, il y a deux cas de figure:
  • Le premier, c’est celui évoqué ci-dessus : un processus (peut-être une requête Web) entraîne un traitement dont l’issue lui importe peu. On peut imaginer un log d’accès à des fins de statistiques. Ce n’est pas nécessairement un long traitement, mais le processus parent peut suivre son cours, indépendamment de son résultat (le log). Ce besoin peut être implémenté de différentes manières, mais le rendre asynchrone, c’est-à-dire créer un thread séparé du processus parent et y faire s’exécuter le traitement, est une solution.
  • Le deuxième cas de figure est que le résultat d'un processus dépend du résultat d’un long traitement. Il est intéressant de lancer ce dernier de manière asynchrone car, pendant qu’il s’exécute dans son thread, le processus principal peut suivre son cours en parallèle. Ce système est plus difficile à mettre en place et implique typiquement l’utilisation d’un Future, un objet qui, comme son nom l’indique, contiendra une réponse dans le futur.

Spring @Async


Avec Spring, il suffit de placer l'annotation @Async sur la méthode englobant le traitement long pour le rendre asynchrone. Bon, c’est vrai, il faut un peu de configuration aussi.

Pour commencer, Spring aura besoin d’un TaskExecutor qui se chargera de l’exécution asynchrone de la méthode. Spring propose plusieurs implémentations de cette interface, que je ne vais pas examiner ici. Il suffit de savoir que, dans la plupart des cas, sa création sera très simple.

Dans le fichier xml de configuration de Spring, il suffit d’indiquer:
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:task="http://www.springframework.org/schema/task"
    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
    http://www.springframework.org/schema/task http://www.springframework.org/schema/task/spring-task-3.0.xsd" 
    default-autowire="byName">

    <task:executor id="executor" pool-size="10"/>
    <task:annotation-driven executor="executor" />

    <!--A suivre…-->
Cette configuration définit un bean nommé "executor", instance de la classe ThreadPoolTaskExecutor (un pool de 10 threads dans ce cas).

Il faut ensuite activer la définition de l'asynchronisme via annotation, en précisant le TaskExecutor à utiliser, dans la ligne <task:annotation-driven />.

A noter que cette dernière entrée permet aussi de configurer des tâches planifiées (des Schedulers, avec l’annotation @Scheduled). Il n’en sera pas question ici.

Ecriture de services asynchrones


Pour continuer notre exploration, je vais utiliser une méthode de longue : le calcul d’une factorielle utilisant des BigInteger.

Quelques remarques sur ce choix et sur son implémentation :

  • Pour le moment, sur une machine standard, ce traitement est "long". Je ne sais pas si ce sera toujours le cas dans quelques années.
  • Le calcul de la factorielle sert généralement de démonstration à la récursivité. Néanmoins, l’implémentation récursive est un peu particulière dans le cas présent. J’y reviendrai plus tard, mais dans l’immédiat, le traitement sera non récursif.
  • Le bean contenant le calcul de la factorielle implémentera une interface. C’est effectivement une bonne pratique de masquer l’implémentation, d’autant plus que Spring doit créer un proxy pour gérer l'asynchronisme, un proxy implémentant l'interface et interceptant les appels vers le véritable bean. Ceci étant dit, la création du proxy fonctionne également sans interface… tant que la classe n’est pas "final".
  • Enfin, pour cette démonstration, j’ai choisi un traitement qui retourne une valeur. Cela implique donc l’utilisation d’un Future. Comment faire si aucun retour n’est attendu ? Renvoyer void tout simplement.

L’interface est simple :
public interface AsyncBean {
     Future<BigInteger> asyncFact(BigInteger n);
}
A noter que la méthode renvoie un Future<BigInteger>. La réponse sera un BigInteger, mais dans le futur…

Comment gère-t-on un Future ? Plusieurs méthodes de Future<T> sont utiles :

  • isDone renvoie true si le résultat est disponible;
  • get() permet de récupérer la valeur (le résultat) du Future. Cette fonction est synchrone : elle attend que le résultat soit disponible. Il est possible d’y ajouter un timeout. Dans ce cas, si la réponse n’est pas disponible dans le délai imparti, une exception est lancée;
  • cancel() permet d’annuler la tâche;
  • isCancelled permet de vérifier si la tâche a été annulée.

Une implémentation asynchrone (et non récursive) de la méthode est la suivante :
@Component
public class AsyncBeanImpl implements AsyncBean {
 
     @Async
     public Future<BigInteger> asyncFact(final BigInteger n) {
          BigInteger accu = BigInteger.ONE;
          BigInteger counter = BigInteger.ONE;
          while(counter.compareTo(n) != 1){
               accu = accu.multiply(counter);
               counter = counter.add(BigInteger.ONE);
          }
          return new AsyncResult<BigInteger>(accu);
     }
}
C’est grâce à l’annotation @Async que l’appel de la méthode sera asynchrone. Quant à l’annotation @Component, elle fera de notre classe un bean Spring.

La valeur de retour est un AsyncResult, une implémentation Spring de Future. Elle est remplie avec le résultat du calcul de la factoriel.

On peut tester l’implémentation avec la classe de test (TestNg) suivante :
@ContextConfiguration(locations="classpath:async/test-async-spring.xml")
public class TestAsyncExecution extends AbstractTestNGSpringContextTests{
     @Autowired
     private AsyncBean asyncBean;

     @Test
     public void testCalculationIsRealyAsynchronous(){
          //La valeur attendue, soit 1000!, il fallait bien un BigInteger
          BigInteger response = new BigInteger("402387260077093773543702433923003985719374864210714632543799910429938512398629020592044208486969404800479988610197196058631666872994808558901323829669944590997424504087073759918823627727188732519779505950995276120874975462497043601418278094646496291056393887437886487337119181045825783647849977012476632889835955735432513185323958463075557409114262417474349347553428646576611667797396668820291207379143853719588249808126867838374559731746136085379534524221586593201928090878297308431392844403281231558611036976801357304216168747609675871348312025478589320767169132448426236131412508780208000261683151027341827977704784635868170164365024153691398281264810213092761244896359928705114964975419909342221566832572080821333186116811553615836546984046708975602900950537616475847728421889679646244945160765353408198901385442487984959953319101723355556602139450399736280750137837615307127761926849034352625200015888535147331611702103968175921510907788019393178114194545257223865541461062892187960223838971476088506276862967146674697562911234082439208160153780889893964518263243671616762179168909779911903754031274622289988005195444414282012187361745992642956581746628302955570299024324153181617210465832036786906117260158783520751516284225540265170483304226143974286933061690897968482590125458327168226458066526769958652682272807075781391858178889652208164348344825993266043367660176999612831860788386150279465955131156552036093988180612138558600301435694527224206344631797460594682573103790084024432438465657245014402821885252470935190620929023136493273497565513958720559654228749774011413346962715422845862377387538230483865688976461927383814900140767310446640259899490222221765904339901886018566526485061799702356193897017860040811889729918311021171229845901641921068884387121855646124960798722908519296819372388642614839657382291123125024186649353143970137428531926649875337218940694281434118520158014123344828015051399694290153483077644569099073152433278288269864602789864321139083506217095002597389863554277196742822248757586765752344220207573630569498825087968928162753848863396909959826280956121450994871701244516461260379029309120889086942028510640182154399457156805941872748998094254742173582401063677404595741785160829230135358081840096996372524230560855903700624271243416909004153690105933983835777939410970027753472000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000");
          Future<BigInteger> r = asyncBean.asyncFact(BigInteger.valueOf(1000L));
          assertFalse(r.isDone(),"Pas encore calculé");
          BigInteger result = null;
          try{
               result = r.get();
          } catch(Exception e){
               fail("Exception durant l’exécution", e);
          }
          assertNotNull(result,"Le résultat ne peut être null ");
          assertTrue(r.isDone(),"Maintenant, la tâche est terminée ");
          assertEquals(result, response,"Le résultat doit évidemment être correct");
     }
}
Le fichier test-async-spring.xml est le suivant :
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:context="http://www.springframework.org/schema/context"
    xmlns:task="http://www.springframework.org/schema/task"
    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
    http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.0.xsd
    http://www.springframework.org/schema/task http://www.springframework.org/schema/task/spring-task-3.0.xsd" 
    default-autowire="byName">

    <context:component-scan base-package="*" />

    <!-- Crée un ThreadPoolTaskExecutor -->
    <task:executor id="executor" pool-size="10"/>

    <task:annotation-driven executor="executor" />

</beans>
L’implémentation peut paraître curieuse car l’instanciation du Future ne sera faite qu’après le calcul. Pourtant, dans notre test, nous recevons bien un Future "tout de suite", alors que notre méthode n’a pas encore terminé son calcul et donc créé son Future avec un résultat.

En fait, Spring utilise l’AOP pour décorer notre classe avec un AsyncExecutionInterceptor, lequel crée un Callable et le soumet au TaskExecutor (qui gère les threads et les exécutions). Cette soumission renvoie un Future qui ne sera initialisé que lors du retour de notre méthode en prenant la valeur de notre Future.

Il est assez simple de vérifier (en mode debug) que le Future reçu par le test, lors de l’appel asyncBean.asyncFact(BigInteger.valueOf(1000L)) n’est pas le même que celui renvoyé par notre implémentation : le premier est une java.util.concurrent.FutureTask (renvoyée par l'AsyncExecutionInterceptor), alors que le deuxième, que nous avons instancié, est org.springframework.scheduling.annotation.AsyncResult.

Au final, le Future renvoyé par notre méthode n’est pas si « futur » que ça puisqu’il est créé directement avec la valeur de retour. Mais il est obligatoire de renvoyer un Future. Si ce n’était pas le cas et que la méthode renvoyait un BigInteger directement, l’intercepteur de Spring renverrait null et jamais la bonne valeur.

La version récursive


La version récursive n’est pas complexe, si ce n’est que sa valeur de retour doit être un Future. On a donc une implémentation comme suit :
@Async
public Future<BigInteger> asyncRecursiveFact(final BigInteger n) throws InterruptedException, ExecutionException {
     if(n.equals(BigInteger.ZERO)){
          return new AsyncResult<BigInteger>(BigInteger.ONE);
     } else {
     return new AsyncResult<BigInteger>(n.multiply(asyncRecursiveFact(n.subtract(BigInteger.ONE)).get()));
     }
}
Elle fonctionne correctement et est beaucoup plus rapide que la première. Mais elle est un peu lourde.

A chaque itération, un objet Future est créé. Comme nous l’avons vu, ce n’est qu’un container, initialisé dès sa création avec une valeur. Il n’a donc aucun intérêt, si ce n’est de surcharger l’écriture de la méthode : récupération de la valeur par un get(), gestion des exceptions (ici simplement transmises dans un throws, mais ce n'est pas très propre).

C’est pourquoi je préfère la deuxième implémentation, plus claire (et aussi 20% plus rapide que la précédente) :
@Async
public Future<BigInteger> asyncRecursiveFactOther(final BigInteger n) {
     return new AsyncResult<BigInteger>(fact(n));
}
 
private BigInteger fact(BigInteger n){
     if(n.equals(BigInteger.ZERO)){
          return BigInteger.ONE;
     } else {
          return n.multiply(fact(n.subtract(BigInteger.ONE)));
     }
}
Le calcul récursif se fait sans utiliser les Future, mais est appelé de manière asynchrone. L’objet Future n’est créé qu'à la fin, que lorsque le calcul est terminé.

Code source


Comme expliqué dans l’article "Deux projets prototypes et didactiques", les sources de cet article sont disponibles sur GitHub :

  • les beans sont ici
  • la classe de test est ici 
  • le fichier xml de configuration ici


1 commentaire:

  1. Bel article, je reconnais bien ta plume :)
    Btw, @EnableAsync peut être utilisé à partir de spring 3.1 pour obtenir une config par défaut du task executor :)
    C'est un peut plus sympa pour un POC

    Yannick

    RépondreSupprimer