Implémenter un mécanisme d’AOP, sans framework !

dans aop, java, oop par Renaud Humbert-Labeaumaz

L'Aspect Oriented Programming est un paradigme de programmation qui permet de traiter les préoccupations transverses (ou cross cutting concerns en anglais) telles que le logging, le cache ou les transactions séparément du code métier.

L'AOP repose la plupart du temps sur l'utilisation de librairies ou de frameworks qui rendent ce type de programmation assez obscur et parfois difficile à comprendre. L'objectif de cet article est d'expliquer comment cela fonctionne afin de le démystifier.

L'ensemble du code montré dans cet article est accessible sur ce repo GitHub.

Pourquoi l'AOP ?

Pour cet article, nous allons partir d'une classe qui implémente la suite de Fibonacci et dont on veut calculer le temps d'exécution. De manière naïve, il serait possible de calculer et logger ce temps d'exécution directement dans la méthode (voir le commit):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class FibonacciCalculatorImpl implements FibonacciCalculator {

    private static final Logger LOG = LoggerFactory.getLogger(FibonacciCalculatorImpl.class);

    @Override
    public int calculate(int n) {
        StopWatch watch = new StopWatch(Clock.systemDefaultZone());
        watch.start();
        LOG.info("Start calculation for number {}", n);
        int result = n;
        if (n > 1) {
            result = calculate(n-1) + calculate(n-2);
        }
        LOG.info("End calculation for number {} ({} ms)", n, watch.elapsed());
        return result;
    }
}

Ce code pose plusieurs problèmes : le tangling et le scattering.

Code tangling

Le code tangling signifie que le code contient différentes notions qui sont mélangées : la mesure du temps et le métier. On pourrait en imaginer d'autres comme la gestion des transactions, le cache ou le monitoring.

Ceci est problématique car le code métier, qui apporte la vraie valeur, est noyé dans du code de “plomberie” qui peut finir par prendre beaucoup plus de place que le code initial !

Code scattering

Le code scattering signifie que le code non métier sera sans nul doute réécrit dans beaucoup de méthodes. On voit alors émerger un grand nombre de duplications de code, qui le rendent très difficile à maintenir.

Le pattern Proxy

La première amélioration évidente qu'il est possible d'apporter est d'extraire la logique de calcul du temps dans une classe spécifique, qui va implémenter la même interface que FibonacciCalculatorImpl et déléguer l'appel de la méthode calculate à une instance de FibonacciCalculatorImpl. Il s'agit du pattern Proxy (voir le commit).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class TimedFibonacciCalculator implements FibonacciCalculator {

    private static final Logger LOG = LoggerFactory.getLogger(FibonacciCalculatorImpl.class);

    private final FibonacciCalculator calculator;

    public TimedFibonacciCalculator(FibonacciCalculator calculator) {
        this.calculator = calculator;
    }

    @Override
    public int calculate(int n) {
        StopWatch watch = new StopWatch(Clock.systemDefaultZone());
        watch.start();
        LOG.info("Start calculation for number {}", n);
        int result = calculator.calculate(n); // Delegate calculation
        LOG.info("End calculation for number {} ({} ms)", n, watch.elapsed());
        return result;
    }
}

L'utilisation de cette classe se fait comme suit :

1
2
FibonacciCalculator calculator = new TimedFibonacciCalculator(new FibonacciCalculatorImpl());
LOG.info("10th fibonacci number : {}", calculator.calculate(10));

L'utilisation de ce pattern règle le problème du code tangling mais pas du tout celui du code scattering. En effet, cette classe TimedFibonacciCalculator n'est pas vraiment réutilisable car elle est fortement couplée à l'interface FibonacciCalculator.

Externalisation de la logique de chronométrage

Pour résoudre le code scattering, il faut extraire la logique de mesure du temps et lui permettre d'appeler n'importe quelle méthode (voir le commit). On peut, pour cela, définir une interface fonctionnelle générique qui contient une méthode invoke.

1
2
3
4
5
@FunctionalInterface
public interface InvocationPoint<T> {

    T invoke() throws Exception;
}

Cette interface pourra alors être injectée dans une classe réutilisable, permettant de mesurer le temps nécessaire à l'exécution de la méthode invoke :

1
2
3
4
5
6
7
8
9
10
11
12
13
public class TimedInvocation {

    private static final Logger LOG = LoggerFactory.getLogger(TimedInvocation.class);

    public <T> T invoke(InvocationPoint<T> invocationPoint) {
        StopWatch watch = new StopWatch(Clock.systemDefaultZone());
        watch.start();
        LOG.info("Start calculation");
        T result = invocationPoint.invoke();
        LOG.info("End calculation ({} ms)", watch.elapsed());
        return result;
    }
}

Le proxy ne sert donc plus que de passe-plat pour appeler cette classe (l'interface InvocationPoint peut aisément être implémentée par un lambda):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class TimedFibonacciCalculator implements FibonacciCalculator {

    private final FibonacciCalculator calculator;

    public TimedFibonacciCalculator(FibonacciCalculator calculator) {
        this.calculator = calculator;
    }

    @Override
    public int calculate(int n) {
        return new TimedInvocation().invoke(
                () -> calculator.calculate(n)
        );
    }
}

On remarque ici que le message de log est plus générique : il n'est plus possible de tracer l'index de la suite que l'on veut calculer. En effet, ce log est très générique et peut permettre de mesurer le temps d'exécution de n'importe quelle méthode.

Génération automatique de proxy

Le tangling et le scattering sont éliminés mais il faut tout de même créer une classe de proxy pour chaque méthode que l'on souhaite chronométrer. Le mécanisme de JDKProxy, inclus directement dans le JDK, permet de créer des proxys dynamiques à partir d'une interface (FibonacciCalculator) et d'une implémentation de InvocationHandler (voir le commit). Celle-ci contient une méthode invoke(Object proxy, Method method, Object[] args): Object qui prend en paramètre le proxy, la méthode qui a été apelée ainsi que ses arguments et doit retourner le résultat de l'appel de la méthode.

Le plus simple des InvocationHandler, qui ne fait rien, peut s'écrire comme ceci :

1
2
3
4
5
6
7
8
9
10
11
12
13
public class SimpleInvocationHandler implements InvocationHandler {

    private final Object target;

    public SimpleInvocationHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        return method.invoke(target, args);
    }
}

La création du proxy se fait ainsi :

1
2
3
4
5
FibonacciCalculator calculator = (FibonacciCalculator) Proxy.newProxyInstance(
  FibonacciCalculator.class.getClassLoader(),
  new Class[] {FibonacciCalculator.class},
  new SimpleInvocationHandler(new FibonacciCalculatorImpl())
);

Dans notre cas, le handler va devoir calculer le temps d'exécution de la méthode. Il s'écrit donc ainsi :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class TimedInvocationHandler implements InvocationHandler {

    private final Object target;

    public TimedInvocationHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        return new TimedInvocation().invoke(
                () -> method.invoke(target, args)
        );
    }
  }
}

Activer ou désactiver la mesure du temps

Pour faire comme les pros (sic !), il est possible d'activer ou désactiver la mesure du temps sur une méthode en y ajoutant une annotation @Timed qui servira de marqueur.

1
2
3
4
5
6
7
8
9
10
11
12
public class FibonacciCalculatorImpl implements FibonacciCalculator {

    @Timed
    @Override
    public int calculate(int n) {
        int result = n;
        if (n > 1) {
            result = calculate(n-1) + calculate(n-2);
        }
        return result;
    }
}

Dans le handler, il suffit alors d'utiliser la reflection pour déterminer si la méthode est annotée ou non, et mesure le temps en conséquence :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class TimedInvocationHandler implements InvocationHandler {
    private static final Class<Timed> ANNOTATION_CLASS = Timed.class;

    private final Object target;

    public TimedInvocationHandler(Object target) {
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if (isTimed(method)) {
            return new TimedInvocation().invoke(
                    () -> method.invoke(target, args)
            );
        }

        // Method not annotated, just invoke target
        return method.invoke(target, args);
    }

    private boolean isTimed(Method method) throws NoSuchMethodException {
        // Is timed if the interface method or the target method is annotated
        return method.getDeclaredAnnotation(ANNOTATION_CLASS) != null
                || target.getClass().getDeclaredMethod(method.getName(), method.getParameterTypes()).getDeclaredAnnotation(ANNOTATION_CLASS) != null;
    }
}

Pour rappel, une annotation n'est qu'un marqueur. La magie n'est pas dans l'annotation mais dans le code qui l'utilise !

Conclusion

A propos de l'AOP

Cet article a, de manière assez rudimentaire, permis d’implémenter un mécanisme d’AOP sans avoir recours à aucun framework externe. Néanmoins, il permet de comprendre certains aspects et limitations de l’AOP.

En effet, il est très simple de créer des proxys dynamiques avec JDKProxy lorsque la classe cible implémente une interface. Lorsque ce n’est pas le cas, le proxy doit étendre cette classe et surcharger la méthode à proxifier (il est possible d’utiliser une librairie comme CGLib pour faire ça automatiquement). Par aileurs, l’AOP implémenté grâce à ce mécanisme ne peut pas fonctionner sur des méthodes ou des classes privées ou finales. En effet, la méthode doit être définie dans une interface ou pouvoir être surchargée. De plus, si la classe à proxifier appelle directement une méthode de sa propre classe, notre AOP ne s’applique pas. Ici, la méthode calculate est récursive. Cependant, la mesure du temps ne s’opère que si la méthode est appelée à travers le proxy. Donc, seul l’appel de la méthode par “l’extérieur” est chronométré.

Certains autres mécanismes permettent de s’affranchir de ces contraintes en modifiant directement le code généré. Cette opération, appelée tissage, peut être effectuée à la compilation ou au runtime grâce à des librairies et instrumentations diverses.

AspectJ et Spring AOP

AspectJ est un framework qui permet de faire de l’AOP grâce au tissage. Spring, quant à lui, utilise abondamment l’AOP grâce au mécanisme de proxy décrit dans cet article (avec les annotations @Transactional, @Cacheable, etc.). Il va parcourir l’ensemble des beans du contexte d’application et les remplacer par des proxys dynamiques. Ainsi, les beans que l’application manipule ne sont pas les implémentations brutes, mais des proxys (plus d’information sur les proxys AOP avec Spring ici). Spring permet également de faire de l’AspectJ.

Note finale

L'AOP n'est pas magique, ni très complexe. Cependant, il est très fortement utilisé dans les framework de type Spring. Une bonne compréhension de ses mécanismes et de son implémentation permet de faciliter grandement le dévelopement et le debug des applications d'entreprise.

Renaud Humbert-Labeaumaz

Renaud est développeur Java. Il pratique le TDD, le refactoring et les revues de code au quotidien pour produire du code propre et maintenable à forte valeur métier.

Contactez-nous

Crafties réalise des audits de code, des formations, des accompagnements et des développements. Vous pouvez nous contacter à contact@crafties.fr pour tout besoin.