Introduction
La programmation réactive est en train de faire le Ramdam. Mais souvent, quand on attaque ce domaine, on est freiné par des notions purement mathématiques et des démonstrations qui jonglent avec des terminologies fonctionnelles.
Les Iteratees présentent un exemple parfait d’un outil super-puissant néanmoins difficile à avaler par le commun des mortels.
Le but de cet article est d’expliquer les Iteratees pour les passionnés de JAVA en évitant au maximum les notions fonctionnelles très complexes.
Définition brute
Un Iteratee est une machine à états immutables qui produit son résultat d’une manière asynchrone et non bloquante.
La philosophie
Les Iteratees ressemblent beaucoup aux norias, ils manipulent des flux d'éléments et à chaque entrée, ils produisent un nouveau résultat.
Exemple
Calculer la somme de la liste d’entiers [ 2 , 7 , 5 ] avec un Iteratee SumIteratee :
-
Itération 0 : SumIteratee contient la somme 0 et n’a pas encore consommé d'éléments de la liste.
-
Itération 1 : SumIteratee consomme [2] de la liste et produit un SumIteratee[2].
-
Itération 2 : SumIteratee[2] consomme 7 de la liste et produit un SumIteratee[9].
-
Itération 3 : SumIteratee[9] consomme 5 de la liste et produit un SumIteratee[14].
-
Itération 4 : SumIteratee[14] atteint la fin de la liste et contient la somme 14.
Une définition d’un Iteratee sur cette base sera :
public interface Iteratee<E> {
Iteratee<E> handle(E e);
}
A chaque pas d’itération, l’Iteratee traite un élément E et produit un nouveau Iteratee. Notre SumIteratee sera :
public class SumIteratee implements Iteratee<Integer>{
final Integer sum;
public SumIteratee(Integer sum) {
this.sum = sum;
}
public SumIteratee() {
this(0);
}
@Override
public SumIteratee handle(Integer e) {
return new SumIteratee(sum +e);
}
public Integer getSum() {
return sum;
}
}
Afin de tester notre SumIteratee on aura à pousser l’itération :
List<Integer> l = Arrays.asList(2,7,5);
SumIteratee Iteratee = new SumIteratee();
for (Integer elm : l) {
Iteratee = Iteratee.handle(elm);
}
Integer expResult = 14;
Integer result = Iteratee.getSum();
assertEquals(expResult, result);
De la même manière, un Iteratee qui calcule la valeur maximale d’une liste est défini comme suit :
public class MaxIteratee implements Iteratee<Integer>{
private final Integer max ;
public MaxIteratee(Integer max) {
this.max = max;
}
public MaxIteratee() {
this.max= Integer.MIN_VALUE;
}
@Override
public MaxIteratee handle(Integer e) {
return new MaxIteratee(e> max ? e : max);
}
public Integer getMax() {
return max;
}
}
Pour le tester, on pourra de même appliquer l’itération sur notre liste :
List<Integer> l = Arrays.asList(2,7,5);
MaxIteratee Iteratee = new MaxIteratee();
for (Integer elm : l) {
Iteratee = Iteratee.handle(elm);
}
Integer expResult = 7;
Integer result = Iteratee.getMax();
assertEquals(expResult, result);
Les Inputs
Nous avons exécuté l’itération sur une liste d’entiers, mais en réalité, on traite des streams et des flux. Pour les listes finies, on sait d’avance leurs tailles, ce qui n’est pas le cas si on traite des flux d’octets transmis sur le réseau.
Pour y remédier, nous allons introduire un conteneur d'éléments Input :
interface Input<E> {
InputState state();
}
qui peut avoir trois états ( on peut avoir plus d’un état selon les cas )
enum InputState {
EOF, Empty, EL
}
Avec :
- EOF : signaler fin du flux
- Empty : silence
- EL : conteneur d’élément
L’interface complète sera :
public interface Input<E> {
enum InputState {
EOF, Empty, EL
}
InputState state();
Input EOF = new Input() {
@Override
public InputState state() {
return InputState.EOF;
}
};
Input EMPTY = new Input() {
@Override
public InputState state() {
return InputState.Empty;
}
};
static <E> Input<E> el(E e ) {
return new El(e);
};
class El<E> implements Input<E> {
final E e;
public El(E e) {
this.e = e;
}
@Override
public InputState state() {
return InputState.EL;
}
public E getE() {
return e;
}
@Override
public String toString() {
return "El{" + "e=" + e + '}';
}
};
}
Il est à noter que toutes les implémentations de notre input sont immutables et que EMPTY et EOF sont créés une seule fois comme ils peuvent être partagés. Entre temps, el(E e) est une factory pour simplifier la construction des inputs.
Cette facilité de définition des méthodes statiques au sein de l’interface Input est faite grâce aux nouveautés de Java 8 (utilisé dans la suite de cet article, mais je vais m’attarder sur l’utilisation des expressions lambda comme promis ).
Notre Iteratee devient :
public interface Iteratee<E> {
Iteratee<E> handle(Input<E> e);
}
Et notre SumIteratee est redéfini de cette manière :
public SumIteratee handle(Input<Integer> e) {
switch (e.state()) {
case EL:
Input.El<Integer> el = (Input.El) e;
Integer elem = el.getE();
return new SumIteratee(sum + elem);
default:
return this;
}
}
Pour tester, on a enrichi notre liste avec des EMPTY et des EOF :
List<Input<Integer>> l = Arrays.asList(Input.el(2),Input.EMPTY,Input.el(7),Input.el(5), Input.EOF, Input.el(100));
SumIteratee Iteratee = new SumIteratee();
Iterator<Input<Integer>> iterator = l.iterator();
boolean stop =false;
while(iterator.hasNext()&& !stop) {
Input<Integer> elm = iterator.next();
switch(elm.state()){
case EL : Iteratee = Iteratee.handle(elm); break;
case Empty : break;
default : stop =true;
}
}
Integer expResult = 14;
Integer result = Iteratee.getSum();
assertEquals(expResult, result);
Accumulator
Dans les exemples précédents, SumIteratee et MaxIteratee traitent et produisent des entiers. Que faire si, par exemple, on veut traiter un flux de String et on voudrait générer un type autre que String ? Par exemple, une liste de String qui contient tout les éléments que l’Iteratee a déjà pu intercepter ?
Il est essentiel de faire adapter l’interface de notre Iteratee afin de supporter un deuxième type : le type de la valeur accumulée.
public interface Iteratee<E,A> {
Iteratee<E,? extends A> handle(Input<E> e);
}
A titre d’exemple, StringIteratee est un Iteratee qui accumule les chaînes de caractères :
public class StringIteratee implements Iteratee <String,String[]> {
final String[] acc;
public StringIteratee(String[] acc) {
this.acc = acc;
}
public StringIteratee() {
this(new String[0]);
}
@Override
public StringIteratee handle(Input<String> e) {
switch (e.state()) {
case EL:
Input.El<String> el = (Input.El) e;
String elem = el.getE();
String[] newAcc = new String[acc.length+1];
System.arraycopy(acc, 0, newAcc, 0, acc.length);
newAcc[acc.length]= elem;
return new StringIteratee(newAcc);
default:
return this;
}
}
public String[] getAcc() {
return acc;
}
}
Tester StringIteratee
List<Input<String>> l = Arrays.asList(Input.el("a"),Input.EMPTY,Input.el("b"),Input.el("c"), Input.EOF, Input.el("ZZZ"));
StringIteratee Iteratee = new StringIteratee();
Iterator<Input<String>> iterator = l.iterator();
boolean stop =false;
while(iterator.hasNext()&& !stop) {
Input<String> elm = iterator.next();
switch(elm.state()){
case EL : Iteratee = Iteratee.handle(elm); break;
case Empty : break;
default : stop =true;
}
}
String[] expResult = {"a","b","c"};
String[] result = Iteratee.getAcc();
assertArrayEquals(expResult, result);
Les états
Tout au long des précédents tests, nous avions à traiter les éléments jusqu’à détecter EOF. Mais, y a-t-il moyen d’informer l’itération suivante de son état pour s’arrêter ou bien alerter qu’une exception est survenue en cours du parcours ?
Il est à noter que les Iteratees sont des machines à état : à chaque étape, ils changent de statut.
public interface Iteratee<E, A> {
…..
enum StepState {
Done, CONT, ERROR
}
default StepState onState() {
return StepState.CONT;
}
….
}
Désormais, il sera possible d’implémenter des méthodes non statiques dans les interfaces à partir de java 8 : c’est fini les casses-têtes qui existaient depuis plus de 15 ans et les histoires d’héritage multiple !
default StepState onState()
onstate, est l’implémentation qui sera invoquée par défaut à condition que les classes filles ne la redéfinissent pas.
Vous avez remarqué que tout au long des tests précédents, on a remplacé à chaque étape notre Iteratee avec :
iteratee = Iteratee.handle(elm);
Manipuler des objets mutables rompt les notions de la programmation fonctionnelle d'où hérite le concept des Iteratees.
Les fonctions
Une fonction est simplement une interface qui ressemble à :
public interface Function<T, R> {
R apply(T t);
}
Java 8 embarquera une panoplie de classes pour des besoins fonctionnels. Cette richesse nous permet de répondre à notre besoin et supprimer la mutabilité d’usage des iteratees.
L’Iteratee va consommer l’Iteratee de l’étape précédente et selon l’état, il devra décider.
Ainsi l’interface de l’Iteratee sera :
public interface Iteratee<E, A> {
<B> B handle(Function<Iteratee<E, A>, B> folder);
enum StepState {
Done, CONT, ERROR
}
default StepState onState() {
return StepState.CONT;
}
}
Mais, d'où vient <B> ? Pour l’instant, penser que B sera utilisée pour des besoins de chaînage et qu’on n’a pas à s'inquiéter vu qu’elle sera la valeur de retour de la méthode apply de folder.
Cette méthode prend en paramètre un Iteratee dans un statut particulier et s’il se place dans un état Cont (apte à consommer encore plus d'éléments), elle lui injecte l'élément suivant.
Pour simplifier la construction des Iteratees on propose trois implémentations relatives à chaque état :
Done
Classe immutable qui contient la valeur finale de l’itération "a". S’il reste des données non encore traitées "input", la méthode handle construit un nouveau Iteratee avec un état Done et l’applique à la méthode apply. Encore une fois, on n’a pas à se soucier du <B>.
class Done<E, A> implements Iteratee<E, A> {
final A a;
final Input<E> input;
@Override
public StepState onState() {
return StepState.Done;
}
public A getA() {
return a;
}
public Input<E> getInput() {
return input;
}
public Done(A a, Input<E> e) {
this.a = a;
this.input = e;
}
public Done(A a) {
this(a, Input.EMPTY);
}
@Override
public <B> B handle(Function<Iteratee<E, A>, B> folder) {
Iteratee<E, A> done = new Iteratee.Done(a, input);
return folder.apply(done);
}
}
Error
L’Iteratee en état d’exception :
class Error<E> implements Iteratee<E, Object> {
final String msg;
final Input<E> input;
public Error(String msg, Input<E> input) {
this.msg = msg;
this.input = input;
}
@Override
public StepState onState() {
return StepState.ERROR;
}
public String getMsg() {
return msg;
}
public Input<E> getInput() {
return input;
}
@Override
public <B> B handle(Function<Iteratee<E, Object>, B> folder) {
Iteratee<E, Object> s = new Iteratee.Error(msg, input);
return folder.apply(s);
}
@Override
public String toString() {
return "Error{" + "msg=" + msg + ", input=" + input + '}';
}
}
Cont
Elle nécessite plus d'intérêt. A chaque Input, elle génère un nouveau Iteratee. Ce qui se traduit par la fonction :
Function<Input<E>, Iteratee<E, A>> k;
D’ou :
class Cont<E, A> implements Iteratee<E, A> {
Function<Input<E>, Iteratee<E, A>> k;
public Cont(Function<Input<E>, Iteratee<E, A>> k) {
this.k = k;
}
public Function<Input<E>, Iteratee<E, A>> getK() {
return k;
}
@Override
public StepState onState() {
return StepState.CONT;
}
@Override
public <B> B handle(Function<Iteratee<E, A>, B> folder) {
Iteratee<E, A> s = new Iteratee.Cont(k);
return folder.apply(s);
}
}
Ces classes sont les bases de notre Iteratee. On va essayer de simplifier leurs créations avec des factories.
static <E> Error<E> Error(String msg, Input<E> input) {
return new Error(msg, input);
}
static <E, A> Cont<E, A> Cont(Function<Input<E>, Iteratee<E, A>> k) {
return new Cont(k);
}
static <E, A> Done<E, A> Done(A a, Input<E> e) {
return new Done(a, e);
}
static <E, A> Done<E, A> Done(A a) {
return new Done(a);
}
Notre StringIteratee devient :
public Iteratee<String, String[]> buildIteratee(Input<String> e) {
switch (e.state()) {
case EL:
Input.El<String> el = (Input.El) e;
String elem = el.getE();
String[] newAcc = new String[acc.length + 1];
System.arraycopy(acc, 0, newAcc, 0, acc.length);
newAcc[acc.length] = elem;
return new StringIteratee(newAcc);
case EOF:
return new Iteratee.Done(acc);
case Empty:
return this;
default:
throw new IllegalStateException();
}
}
@Override
public <B> B handle(Function<Iteratee<String, String[]>, B> folder) {
Function<Input<String>, Iteratee<String, String[]>> handler = new Function<Input<String>, Iteratee<String, String[]>>() {
@Override
public Iteratee<String, String[]> apply(Input<String> e) {
return buildIteratee(e);
}
};
Iteratee.Cont<String, String[]> stCont = Iteratee.Cont(handler);
return folder.apply(stCont);
}
Le même principe à appliquer pour les autres Iteratees.
Pour ceux qui ne font pas d'allergie aux “->” notre handle sera simplifié avec les expressions lambda en :
@Override
public <B> B handle(Function<Iteratee<String, String[]>, B> folder) {
Function<Input<String>, Iteratee<String, String[]>> handler = (Input<String> e) -> buildIteratee(e);
Iteratee.Cont<String, String[]> stCont = Iteratee.Cont(handler);
return folder.apply(stCont);
}
En une seule ligne :
@Override
public <B> B handle(Function<Iteratee<String, String[]>, B> folder) {
return folder.apply(Iteratee.Cont((Input<String> e) -> buildIteratee(e)));
}
Si on se réfère à la définition brute d’un Iteratee, il nous reste encore un dernier point à vérifier : asynchrone et non bloquante.
Future et CompletableFuture
Si on revient à la déclaration de l’interface Iteratee, la méthode handle est bloquante :
<B> B handle(Function<Iteratee<E, A>, B> folder);
Première essai: Utiliser les Futures java
La méthode handle va produire un Future de B au lieu de B.
<B> Future<B> handle(Function<Iteratee<E, A>, B> folder);
Mais cette solution manque d'homogénéité : on a avancé qu’on n’a pas à s'inquiéter de la génération de B et qu’elle sera utilisée telle qu’elle comme valeur de retour.
Deuxième essai : Future<B>
Une proposition plus homogène :
<B> Future<B> handle(Function<Iteratee<E, A>, Future<B>> folder);
Troisième essai : CompletableFuture<B>
Le problème avec les Futures c’est qu’ils ne sont pas composables : on ne peut pas chaîner les Futures en java. L'idée sera d’utiliser une structure monadique des Futures qui soit composable et qui permet de travailler en toute simplicité. Désormais, Java 8 embarque CompletableFuture qui implémente Future et qui présente une panoplie de méthodes.
Ci dessous la version finale de notre méthode handle :
<B> CompletableFuture<B> handle(Function<Iteratee<E, A>, CompletableFuture<B>> step);
Tout le code source de l’Iteratee :
Voila on a fini avec les Iteratees. Le suivant est à retenir :
- Les Iteratees sont immutables.
- Les Iteratees sont des machines à états (ici StepState).
- Les Iteratees réagissent aux inputs mais en donnant naissance à un nouveau Iteratee avec un état et probablement une valeur accumulée.
- Les Iteratees sont autonomes et indépendants : ne sont ni produits ni reliés à un stream particulier (tel est le cas pour les Iterators).
- Les Iteratees sont non bloquants et le modèle de génération à chaque événement est asynchrone.
Enumerator
C’est le moment de tester nos Iteratees. Pour simplifier notre tâche, on va construire une classe utilitaire qui permet de générer un flux d’input à partir d’un Stream, un fichier ou simplement une Liste. Également, donner à cette classe la possibilité de déléguer à un ensemble d’Iteratees de la parcourir et la consommer. Cette classe sera nommée simplement Enumerator.
Générateur de flux :
Pour satisfaire ce premier rôle, Enumerator est une interface à implémenter pour : les Streams, Fichiers, Sockets, Lists,...
public interface Enumerator<E>
Elle est générique avec E le type de donnée qu’elle manipule.
Consommation des Iteratees :
Pour ce second rôle, cette interface déclare la méthode abstraite apply comme suit :
<A> CompletableFuture<Iteratee<E, A>> apply(Iteratee<E, A> it);
Et pour les impatients, avoir le résultat final du parcours revient à utiliser la méthode run implémentée par défaut au niveau de l’interface Enumerator :
default <B> Input<B> run(Iteratee<E, B> it) {
switch (it.onState()) {
case CONT:
return apply(it).thenApplyAsync(x -> run (x)).join();
case ERROR:
Iteratee.Error e = (Iteratee.Error) it;
throw new RuntimeException(e.getMsg());
case Done:
Iteratee.Done<E, B> d = (Iteratee.Done) it;
return Input.el(d.getA());
default:
throw new IllegalStateException();
}
}
C’est une méthode récursive qui agit selon l'état de l’Iteratee :
- Si elle est à l’état Done : elle retourne le résultat calculé
- Si elle est à l’état Error elle génère une exception
- Si elle est à à l’état Cont : appel à la methode apply (consommation de l’élément suivant) ensuite, appel à run pour la suite des éléments. Il à noter que thenApplyAsync utilise ForkJoinPool.commonPool() par défaut pour les traitements asynchrones.
Pour les tests, on aura recours à une factory qui permet, à partir d’une liste, de construire un enumerator.
static <B> Enumerator<B> enumInput(final Input<B>... input)
L’implémentation de cette méthode revient à traiter 3 cas :
Cas 1 : une liste vide
On aura besoin de générer un Enumerator Vide
static <E> Enumerator<E> empty() {
return new Enumerator<E>() {
@Override
public <A> CompletableFuture<Iteratee<E, A>> apply(Iteratee<E, A> it) {
return CompletableFuture.completedFuture(it);
}
};
}
return Enumerator.empty();
Cas 2 : une liste avec un seul élément
On va retourner un Enumerator et on aura besoin d’implementer la méthode apply.
return new Enumerator<B>() {
@Override
public <A> CompletableFuture<Iteratee<B, A>> apply(Iteratee<B, A> i) {
return i.handle((Iteratee<B, A> t) -> {
switch (t.onState()) {
case CONT:
return CompletableFuture.supplyAsync(() -> {
Iteratee.Cont<B, A> c = (Iteratee.Cont) t;
return c.getK().apply(input[0]);
});
default:
return CompletableFuture.completedFuture(t);
}
});
}
};
Cas 3 : plus qu’un élément
On va déléguer à enumSeq le process du traitement
List<Input<B>> of = Arrays.asList(input);
return new Enumerator<B>() {
@Override
public <A> CompletableFuture<Iteratee<B, A>> apply(Iteratee<B, A> it) {
return enumSeq(of, it);
}
};
avec enumSeq
static <E, A> CompletableFuture<Iteratee<E, A>> enumSeq(List<Input<E>> l, Iteratee<E, A> i) {
BiFunction<Iteratee<E, A>,Input<E> , CompletableFuture<Iteratee<E, A>>> f = ( Iteratee<E, A> it,Input<E> a) -> {
switch (it.onState()) {
case CONT:
return CompletableFuture.supplyAsync(() -> it.handler().apply(a));
default:
return CompletableFuture.completedFuture(it);
}
};
return CollectionUtils.leftFoldM( i, l,f);
}
BiFunction est une interface qui accepte deux types d’inputs et génère un résultat. Elle ressemble à :
public interface BiFunction<T, U, R> {
R apply(T t, U u);
}
Tests
L’exercice est de mettre à jour nos Iteratees avant de lancer les tests.
SumIteratee
Notre SumIteratee sera légèrement modifiée comme suit :
public class SumIteratee implements Iteratee<Integer, Integer> {
private final Integer sum;
private final Function<Input<Integer>, Iteratee<Integer, Integer>> handler;
public SumIteratee(Integer sum, Function<Input<Integer>, Iteratee<Integer, Integer>> handler) {
this.sum = sum;
this.handler = handler;
}
public SumIteratee(Integer sum) {
this(0, handler(sum));
}
public SumIteratee() {
this(0);
}
public Integer getSum() {
return sum;
}
@Override
public <B> CompletableFuture<B> handle(Function<Iteratee<Integer, Integer>, CompletableFuture<B>> step) {
return step.apply(Iteratee.Cont(handler));
}
@Override
public Function<Input<Integer>, Iteratee<Integer, Integer>> handler() {
return handler;
}
private static Function<Input<Integer>, Iteratee<Integer, Integer>> handler(int initValue) {
return (Input<Integer> e) -> {
switch (e.onState()) {
case EL:
Input.El<Integer> el = (Input.El) e;
Integer elem = el.getE();
return new SumIteratee(initValue + elem);
case EOF:
return Iteratee.Done(initValue);
case Empty:
default:
return new SumIteratee(initValue);
}
};
}
}
MaxIteratee
De même pour maxIteratee :
public class MaxIteratee implements Iteratee<Integer, Integer> {
private final Function<Input<Integer>, Iteratee<Integer, Integer>> handler;
private final Integer max;
public MaxIteratee(Integer max) {
this.max = max;
this.handler = handler(max);
}
public MaxIteratee() {
this(Integer.MIN_VALUE);
}
@Override
public <B> CompletableFuture<B> handle(Function<Iteratee<Integer, Integer>, CompletableFuture<B>> folder) {
return folder.apply(Iteratee.Cont(handler));
}
private static Integer max(Integer a, Integer b) {
return (a > b) ? a : b;
}
@Override
public Function<Input<Integer>, Iteratee<Integer, Integer>> handler() {
return handler;
}
private Function<Input<Integer>, Iteratee<Integer, Integer>> handler(int max) {
return (Input<Integer> e) -> {
switch (e.onState()) {
case EL:
Input.El<Integer> el = (Input.El) e;
Integer elem = el.getE();
return new MaxIteratee(max(elem, max));
case EOF:
return Iteratee.Done(max);
case Empty:
default:
return new MaxIteratee(max);
}
};
}
}
@Test
On pourra rejouer nos tests avec :
@Test
public void complexeSumMaxer(){
Input[] in1 = {Input.el(5),Input.EMPTY ,Input.el(2),Input.EOF };
Input[] in2 = {Input.el(1),Input.EMPTY ,Input.el(4), Input.EMPTY , Input.el(3) , Input.EOF };
Enumerator enumerator1 = Enumerator.enumInput(in1);
Enumerator enumerator2 = Enumerator.enumInput(in2);
Iteratee<Integer,Integer> maxer = new MaxIteratee();
Iteratee<Integer,Integer> summer = new SumIteratee();
Input result1 = enumerator1.run(summer);
Input result2 = enumerator2.run(maxer);
Input result3 = enumerator1.run(maxer);
Input result4 = enumerator2.run(summer);
org.junit.Assert.assertSame(result1.onState(), Input.InputState.EL);
org.junit.Assert.assertSame(result2.onState(), Input.InputState.EL);
org.junit.Assert.assertSame(result3.onState(), Input.InputState.EL);
org.junit.Assert.assertSame(result4.onState(), Input.InputState.EL);
org.junit.Assert.assertSame("should be same",((Input.El) result1).getE(), 7);
org.junit.Assert.assertSame("should be same",((Input.El) result2).getE(), 4);
org.junit.Assert.assertSame("should be same",((Input.El) result3).getE(), 5);
org.junit.Assert.assertSame("should be same",((Input.El) result4).getE(), 8);
}
La nature immutable des Iteratees permet de les rejouer plusieurs fois sur différents Streams de données. Il est à noter qu’on a injecté des EOF à la fin des listes pour obliger explicitement l'arrêt des Iteratees.
Conclusion
Les Iteratees constituent un outil super puissant pour le traitement des flux de données d’une manière asynchrone et non bloquante. Tout au long de cet article, on a construit pas à pas des Iteratees et on a manipulé des flux.
Le code source de l’Iteratee est disponible sous Github : https://github.com/ouertani/ouertanitee que je vous invite à l'enrichir avec des Iteratees ainsi que des factory d’Enumerator.
Encore un point qu’on n’a pas encore discuté : ce sont les Enumeratees, bien expliqués sur le web.
Références
-Understanding Play2 Iteratees for Normal Humans
-Iteratees for imperative programmers
A propos de l'auteur
Slim OUERTANI est un architecte logiciel avec une expérience dans le monde télécoms et systèmes d’information. Il a participé à la construction et la mise en place de plusieurs solutions notamment au sein de multinationales. Certifié JAVA, SPRING et MongoDB, Slim est passionné par Scala et JEE.
Vous pouvez en savoir plus sur ses récents travaux sur son blog et le suivre sur Twitter : @ouertani.