Spring AOP : programmation orientée aspect

En programmation orientée objet, il arrive souvent que l’on trouve des portions de code qui se répètent à travers différentes méthodes. Par exemple, pour une application qui accède à une base de données, chaque interaction avec la base de données doit s’inscrire dans une transaction. Même si nous concevons une architecture objet avec une classe spécialisée pour gérer une transaction, nous allons devoir faire appel à une instance de cette classe dans chaque méthode qui interagit avec la base de données. Nous allons devoir répéter les actions dans notre code.

Dans des applications pour les entreprises, ce type de problématiques est souvent lié à des services techniques : gestion des connexions à un service tiers, gestion des transactions, écriture de fichiers de log ou encore sécurisation des accès.

La programmation orientée objet ne fournit pas de solution élégante à ces problématiques. C’est pour résoudre spécifiquement ces cas de figure que la Programmation Orientée Aspect (POA ou AOP pour Aspect Oriented Programming) a été introduite.

La POA n’est pas une notion propre au Spring Framework. Dans la communauté Java, le projet AspectJ est le projet le plus avancé pour intégrer la programmation orientée aspect au langage. Spring AOP est le module Spring chargé d’ajouter le support de la programmation aspect en se basant en grande partie sur AspectJ.

Principe de la programmation orientée aspect

La programmation orientée aspect (POA) est utilisée pour implémenter des fonctionnalités transverses (cross-cutting concerns). Elle permet de rendre l’architecture plus modulaire. Un aspect représente une catégorie d’actions à réaliser dans certaines conditions. Par exemple, gérer les transactions, produire des informations de log, mettre en cache des informations… Plutôt que de répéter (ou d’appeler) ce code dans les différentes classes de l’application, nous allons définir des points à partir desquels l’aspect devra s’exécuter. Puis à l’aide d’un tisseur d’aspects (weaver), le flot normal d’exécution de l’application va être modifié afin d’exécuter les actions de cet aspect aux points voulus.

Dans la POA, on distingue l’approche statique et l’approche dynamique. L’approche statique modifie le code au moment de la compilation afin d’introduire aux points voulus l’exécution des aspects. Cette approche est complexe à prendre en charge car elle nécessite une étape supplémentaire à la compilation. L’approche dynamique est réalisée au moment de l’exécution de l’application. Elle ne nécessite donc pas de compilation particulière. Elle peut néanmoins nécessiter une instrumentation du code au moment du chargement des classes dans la JVM mais ce processus est transparent pour le développeur. L’approche dynamique introduit un coût supplémentaire à l’exécution puisqu’une bibliothèque tierce doit prendre en charge l’exécution des aspects. En pratique, ce surcoût est négligeable.

Note

Comme toujours avec le Spring Framework, nous avons le choix dans l’intégration des technologies. Ainsi le Spring Framework supporte à la fois l’approche statique et l’approche dynamique. Pour cette dernière, il supporte même plusieurs techniques. Pour simplifier notre présentation, nous nous limiterons à une approche dynamique basée sur la création de classes proxy grâce à la bibliothèque CGLIB et sur l’utilisation des annotations AspectJ qui sont, de fait, devenues le standard en Java.

Notez cependant que le support de la programmation aspect par Spring est limité. Il ne permet d’appliquer les principes de la programmation qu’à l’appel de méthodes. Nous allons pouvoir exécuter du code supplémentaire avant, après l’appel d’une méthode. Nous avons même la possibilité de remplacer totalement l’exécution d’une méthode.

La création d’aspect est très certainement réservé à un usage avancé du Spring Framework. Néanmoins, comprendre les bases de la programmation orientée aspect vous permettra de mieux comprendre la manière donc le Spring Framework (et d’autres frameworks) peuvent instrumenter le code d’une application. Un exemple courant est la gestion des transactions que nous aborderons dans le chapitre Spring Transaction.

Terminologie de la programmation orientée aspect

La POA introduit un vocabulaire spécifique pour décrire le mécanisme de traitement de problématiques transverses (cross-cutting concerns) :

Aspect

La problématique spécifique que l’on veut ajouter transversalement à notre architecture : par exemple la gestion des transactions avec la base de données.

Point de jonction (JoinPoint)

Le point dans le flot d’exécution d’un programme à partir duquel on souhaite ajouter la logique d’exécution de l’aspect.

Greffon (Advice)

L’action particulière de l’aspect à exécuter quand le programme atteint le point de jonction. Avec Spring AOP, le point de jonction correspond toujours à l’appel d’une méthode. Le greffon peut spécifier si le code doit s’exécuter avant l’appel à la méthode, après l’appel à la méthode ou s’il doit encapsuler l’appel à la méthode ou ne s’exécuter que si une exception survient.

Coupe (Pointcut)

Une expression qui définit l’ensemble des points de jonctions éligibles pour le greffon. Par exemple :

execution(User dev.gayerie.Service.*(...))

La coupe ci-dessus désigne l’appel à n’importe quelle méthode qui retourne un objet de type User de la classe Service qui appartient au package dev.gayerie.

Objet cible (Target object)

L’objet sur lequel est appliqué l’aspect.

Tissage (Weaving)

Le processus qui permet de réaliser l’insertion de l’aspect soit au moment de la compilation (POA statique) soit au moment de l’exécution (POA dynamique). Dans le cas de Spring AOP, le tissage se fait au moment de la création du contexte d’application. Ce processus est donc transparent pour le développeur de l’application.

Introduction

Le processus qui permet à un aspect d’ajouter une méthode ou un attribut à un objet lors du tissage. Ce processus n’est pas supporté par Spring AOP pour un tissage dynamique. Nous n’aborderons donc pas ce point de ce chapitre.

Intégration du module Spring AOP

L’intégration du module Spring AOP est légèrement différente suivant que vous développiez une application avec ou sans Spring Boot.

Intégration dans une application Spring Boot

Spring Boot est conçu pour simplifier la configuration d’une application Spring. Donc, pour intégrer Spring AOP, il suffit d’ajouter dans votre projet une dépendance à spring-boot-starter-aop.

Déclaration de la dépendance dans le fichier pom.xml pour Maven
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

Intégration dans une application sans Spring Boot

Dans une application Spring, vous devez déclarer les dépendances au module spring-aop et également aux modules de AspectJ nécessaires.

Déclaration des dépendances dans le fichier pom.xml pour Maven
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-aop</artifactId>
  <version>5.3.1</version>
</dependency>

<dependency>
  <groupId>org.aspectj</groupId>
  <artifactId>aspectjrt</artifactId>
  <version>1.9.6</version>
</dependency>

<dependency>
  <groupId>org.aspectj</groupId>
  <artifactId>aspectjweaver</artifactId>
  <version>1.9.6</version>
</dependency>

Puis, sur une classe de configuration de votre contexte d’application, vous devez ajouter l’annotation @EnableAspectJAutoProxy :

Un exemple d’activation de Spring AOP
package dev.gayerie;

import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;

@EnableAspectJAutoProxy
@Configuration
@ComponentScan
public class Application {

  public static void main(String[] args) throws InterruptedException {
    try (AnnotationConfigApplicationContext appCtx =
                  new AnnotationConfigApplicationContext(Application.class)) {
      // ...
    }
  }

}

Exemple de programmation orientée aspect avec Spring AOP

Imaginons que notre application contienne une classe BusinessService qui réalise un traitement important pour notre application :

package dev.gayerie;

import org.springframework.stereotype.Service;

@Service
public class BusinessService {

  public void doSomething() {
    System.out.println("réalise un traitement important pour l'application");
    // ...
  }

}

Imaginons que nous souhaitions introduire un aspect de logging. Nous voulons tracer les appels aux méthodes de toutes les classes dont le nom se termine par Service. Sans la programmation orientée aspect, nous pouvons bien sûr ajouter du code supplémentaire dans chacune des méthodes de ces classes… au risque d’en oublier et de dupliquer du code. Avec la programmation orientée aspect et Spring AOP nous allons pouvoir centraliser ces traitements transversaux dans une seule classe qui servira à définir notre aspect et qui aura l’annotation @Aspect.

Un aspect pour écrire des logs sur la sortie standard
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package dev.gayerie;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class LogAspect {

  @Before("execution(public * dev.gayerie.*Service.*(..))")
  public void log(JoinPoint joinPoint) {
    System.out.printf("Appel de %s avec %d paramètres%n",
                      joinPoint.toShortString(),
                      joinPoint.getArgs().length);
  }

}

La classe LogAspect ci-dessus est annotée avec @Aspect mais également avec @Component pour être prise en charge par Spring. Elle déclare une méthode qui représente le greffon (advice), c’est-à-dire l’action à réaliser. Ce greffon est annoté avec @Before. Cette annotation indique que ce greffon doit s’appliquer avant l’appel à une méthode. L’attribut de l’annotation @Before correspond à la coupe (pointcut) indiquant les méthodes qui sont impactées par ce greffon.

"execution(public * dev.gayerie.*Service.*(..))"

Une coupe commence par un designator qui précise la cible définit. Dans l’exemple ci-dessus, on utilise execution() qui indique que la coupe décrit l’appel à une méthode. Entre les parenthèses, on fournit la description des méthodes concernées. Dans notre exemple, il s’agit des méthodes publiques qui retournent n’importe quoi (y compris void), qui appartiennent à une classe du package dev.gayerie dont le nom se termine par Service et quels que soient les paramètres déclarés.

Ci-dessous, le tableau des designators supportés par Spring AOP :

Designator

Cible

execution()

L’appel à une méthode décrite par la coupe

within()

L’appel à une méthode d’un type décrit par la coupe

target()

L’appel à une méthode d’un type donné par la coupe

args()

L’appel à une méthode dont les types des paramètres correspondent à ceux donnés par la coupe

@target()

L’appel à une méthode d’un type portant l’annotation donnée par la coupe

@args()

L’appel à une méthode dont les arguments portent les annotations données par la coupe

@within()

L’appel à une méthode d’un type portant l’annotation décrite par la coupe

@annotation()

L’appel à une méthode portant elle-même l’annotation décrite par la coupe

La méthode de greffon log attend un paramètre de type JoinPoint. Ce paramètre représente le point de jonction et permet d’accéder à des informations en utilisant notamment l’API de réflexivité de Java.

Si nous exécutons notre application et que la méthode BusinessService.doSomething est appelée à une moment, nous verrons sur la sortie standard :

Appel de execution(BusinessService.doSomething()) avec 0 paramètres
réalise un traitement important pour l'application

Note

L’exemple précédent est très simple. Mais remarquez qu’un aspect est un bean Spring. Cela veut dire que nous pouvons injecter n’importe quelle dépendance dans ce bean. Nous pouvons donc réaliser facilement des traitements beaucoup plus complexes.

Nous pouvons compléter notre aspect et le complexifier un peut :

Un aspect pour écrire des logs sur la sortie standard
 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
28
29
30
package dev.gayerie;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class LogAspect {

  @Pointcut("execution (public * dev.gayerie.*Service.*(..))")
  public void methodCall() {}

  @Before("methodCall()")
  public void log(JoinPoint joinPoint) {
    System.out.printf("Appel de %s avec %d paramètres%n",
                      joinPoint.toShortString(),
                      joinPoint.getArgs().length);
  }

  @AfterThrowing(pointcut = "methodCall()", throwing = "e")
  public void log(JoinPoint joinPoint, Throwable e) {
    System.out.printf("Retour de %s avec une exception %s%n",
                      joinPoint.toShortString(),
                      e.getClass().getSimpleName());
  }
}

Dans l’exemple ci-dessus, nous avons ajouté un nouveau greffon (lignes 24 à 29) avec l’annotation @AfterThrowing pour signaler qu’il doit être exécuté uniquement si la méthode se termine par une exception. Comme nous voulons utiliser la même coupe pour les deux greffons, nous avons ajouté une méthode annotée avec @Pointcut. Cette méthode ne contient pas de code car elle ne sera jamais appelée. C’est une convention pour déclarer une coupe et lui donner le même nom que la méthode. Ainsi nous déclarons une coupe que nous appelons methodCall (lignes 14 et 15). Nous pouvons ainsi nous servir de ce nom dans la déclaration des coupes pour les annotation @Before et @AfterThrowing aux lignes 17 et 24.

Concernant le greffon devant tracer la levée d’une exception, nous souhaitons capturer l’exception pour tracer son type. Dans l’annotation @AfterThrowing, nous utilisons l’attribut throwing pour nommer le paramètre du greffon qui correspond à l’exception. Dans notre exemple, c’est le paramètre que nous appelons e. Ainsi, nous pouvons ajouter un paramètre à notre greffon et recevoir en paramètre l’exception qui a été levée.

La programmation orientée Aspect pour gérer les annotations

Une utilisation intéressante des aspects est d’introduire des coupes en se basant sur la présence d’annotations sur les méthodes et/ou les classes. Une annotation en Java est un type qui peut contenir des attributs. Il s’agit d’une méta-information mais qui n’a pas d’influence sur l’exécution du programme. Il faut qu’il existe du code capable de réagir à la présence d’une annotation. L’idée même des annotations est en fait très proche des principes de la programmation orientée aspect. On ajoute souvent une annotation sur une méthode ou une classe pour changer de manière transversale la façon dont cette classe ou cette méthode va se comporter.

Dans ce cas là, la programmation orientée aspect est très utile. Elle peut nous permettre de déclencher un greffon (advice) lorsqu’on appel une méthode portant une annotation (ou appartenant à une classe portant une annotation ou ayant des paramètres portant des annotations).

Note

Ce type d’usage est exactement celui proposé par Spring Transaction avec l’annotation @Transactional. Nous verrons comment utiliser cette annotation dans le chapitre Spring Transaction.

Nous souhaitons mettre en place un système de supervision des performances de notre application. Pour cela, nous souhaitons tracer des alertes lorsque l’exécution de certaines méthodes dure trop longtemps. Nous allons définir l’annotation @Supervision avec l’attribut dureeMillis qui permet de préciser la durée maximale d’exécution en millisecondes :

L’annotation @Supervision
package dev.gayerie;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Supervision {

  int dureeMillis();

}

Note

Si vous n’avez pas l’habitude de créer vos propres annotations, vous pouvez en apprendre plus en consultant cette page.

Nous pouvons maintenant modifier l’implémentation de notre classe de service prise en exemple :

package dev.gayerie;

import org.springframework.stereotype.Service;

@Service
public class BusinessService {

  @Supervision(dureeMillis = 5)
  public void doSomething() {
    System.out.println("réalise un traitement important pour l'application");
    // ...
  }

}

Nous voyons qu’ici nous devons mesurer la durée d’exécution de la méthode. Pour cela, nous déclarer un greffon pour envelopper son appel :

L’aspect de gestion de la supervision
 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
28
29
package dev.gayerie.aop;

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Aspect
@Component
public class SupervisionAspect {

  @Around("@annotation(supervision)")
  public Object superviser(ProceedingJoinPoint joinPoint, Supervision supervision)
                      throws Throwable {
    long maxDuree = supervision.dureeMillis();
    long start = System.currentTimeMillis();
    try {
      return joinPoint.proceed(joinPoint.getArgs());
    } finally {
      long end = System.currentTimeMillis();
      long duree = end - start;
      if (duree > maxDuree) {
        System.out.printf("Attention l'appel à %s à durée %dms soit %dms de plus qu'attendu%n",
                          joinPoint.toShortString(), duree, duree - maxDuree);
      }
    }
  }

}

L’annotation @Around permet de définir un greffon qui doit envelopper l’appel à une méthode. La coupe est définie par :

"@annotation(supervision)"

Cette déclaration se lit : lors de l’appel à une méthode qui porte l’annotation donnée par supervision. Ce dernier correspond au paramètre du même nom de la méthode et qui est du type Supervision. Donc ce greffon concerne l’appel à toutes les méthodes annotées avec @Supervision. Pour un greffon annoté avec @Around, nous pouvons recevoir un paramètre de type ProceedingJoinPoint. Cette classe hérite de JoinPoint et fournit des méthodes supplémentaires : notamment la méthode proceed qui nous permet d’appeler la méthode désignée par la coupe.

Astuce

On se rend compte qu’avec l’annotation @Around, nous pouvons décider du moment ou la méthode désignée par la coupe doit être appelée. Nous pouvons même ne pas l’appeler ou remplacer ses paramètres au moment de l’appel. Ce type de greffon peut permettre de réaliser des aspects très complexes.

Pour notre exemple, le greffon récupère la durée attendue directement de l’annotation reçue en paramètre. Il mémorise le temps avant le lancement de la méthode. Il appelle ensuite la méthode (ligne 18) grâce à la méthode proceed. Enfin, il ne reste plus qu’a récupérer le temps après le retour de la méthode, à calculer la durée d’exécution et à éventuellement afficher un message si le temps d’exécution a été trop long.

Si nous exécutons notre programme et que la durée d’exécution à la méthode BusinessService.doSomething() est trop longue, nous verrons sur la sortie standard :

réalise un traitement important pour l'application
Attention l'appel à execution(BusinessService.doSomething()) à durée 16ms soit 11ms de plus qu'attendu