Article original : How to Build Multi-Module Projects in Spring Boot for Scalable Microservices

À mesure que les applications logicielles deviennent plus complexes, la gestion de l'évolutivité, de la modularité et de la clarté devient essentielle.

La structure multi-modules de Spring Boot vous permet de gérer différentes parties de l'application indépendamment, ce qui permet à votre équipe de développer, tester et déployer des composants séparément. Cette structure maintient le code organisé et modulaire, ce qui est utile pour les microservices et les grands systèmes monolithiques.

Dans ce tutoriel, vous allez construire un projet Spring Boot multi-modules, chaque module étant dédié à une responsabilité spécifique. Vous apprendrez à configurer les modules, à configurer la communication inter-modules, à gérer les erreurs, à implémenter la sécurité basée sur JWT et à déployer en utilisant Docker.

Prérequis :

  • Connaissance de base de Spring Boot et Maven.

  • Familiarité avec Docker et les concepts CI/CD (optionnel mais utile).

Table des matières

  1. Pourquoi des projets multi-modules ?

  2. Structure et architecture du projet

  3. Comment configurer le projet parent

  4. Comment créer les modules

  5. Communication inter-modules

  6. Problèmes courants et solutions

  7. Stratégie de test

  8. Gestion des erreurs et journalisation

  9. Sécurité et intégration JWT

  10. Déploiement avec Docker et CI/CD

  11. Bonnes pratiques et cas d'utilisation avancés

  12. Conclusion et points clés

1. Pourquoi des projets multi-modules ?

Dans les projets à module unique, les composants sont souvent étroitement couplés, ce qui rend difficile la mise à l'échelle et la gestion de bases de code complexes. Une structure multi-modules offre plusieurs avantages :

  • Modularité : Chaque module est dédié à une tâche spécifique, telle que la gestion des utilisateurs ou des stocks, simplifiant ainsi la gestion et le dépannage.

  • Évolutivité de l'équipe : Les équipes peuvent travailler indépendamment sur différents modules, minimisant les conflits et améliorant la productivité.

  • Déploiement flexible : Les modules peuvent être déployés ou mis à jour indépendamment, ce qui est particulièrement bénéfique pour les microservices ou les grandes applications avec de nombreuses fonctionnalités.

Exemple concret

Considérons une grande application de commerce électronique. Son architecture peut être divisée en modules distincts :

  • Gestion des clients : Responsable de la gestion des profils, des préférences et de l'authentification des clients.

  • Gestion des produits : Se concentre sur la gestion des détails des produits, des stocks et des prix.

  • Traitement des commandes : Gère les commandes, les paiements et le suivi des commandes.

  • Gestion des stocks : Supervise les niveaux de stock et les commandes des fournisseurs.

Étude de cas : Netflix

Pour illustrer ces avantages, examinons comment Netflix utilise une architecture multi-modules.

Netflix est un exemple phare d'une entreprise qui utilise efficacement cette approche grâce à son architecture de microservices. Chaque microservice chez Netflix est dédié à une fonction spécifique, telle que l'authentification des utilisateurs, les recommandations de contenu ou les services de streaming.

Cette structure modulaire permet à Netflix de mettre à l'échelle ses opérations de manière efficace, de déployer des mises à jour indépendamment et de maintenir une haute disponibilité et performance. En découplant les services, Netflix peut gérer des millions d'utilisateurs et diffuser du contenu de manière transparente dans le monde entier, assurant ainsi un système robuste et flexible qui soutient sa vaste et dynamique plateforme.

Cette architecture non seulement améliore l'évolutivité, mais aussi l'isolation des pannes, permettant à Netflix d'innover rapidement et de répondre efficacement aux demandes des utilisateurs.

2. Structure et architecture du projet

Revenons maintenant à notre projet d'exemple. Votre projet Spring Boot multi-modules utilisera cinq modules clés. Voici la structure :

codespring-boot-multi-module/
  common/               # Utilitaires et constantes partagés
  domain/               # Entités de domaine
  repository/           # Couche d'accès aux données (DAL)
  service/              # Logique métier
  web/                  # Application principale Spring Boot et contrôleurs

Chaque module a un rôle spécifique :

  • common : Stocke les utilitaires partagés, les constantes et les fichiers de configuration utilisés dans d'autres modules.

  • domain : Contient les modèles de données de votre application.

  • repository : Gère les opérations de base de données.

  • service : Encapsule la logique métier.

  • web : Définit les points de terminaison de l'API REST et sert de point d'entrée de l'application.

Cette structure est alignée avec les principes de séparation des préoccupations, où chaque couche est indépendante et gère sa propre logique.

Le diagramme ci-dessous illustre les différents modules :

Diagramme montrant une architecture logicielle avec cinq modules : Web, Service, Repository, Domain et Common, connectés par des flèches indiquant les relations.

3. Comment configurer le projet parent

Étape 1 : Créer le projet racine

Exécutons ces commandes pour créer le projet parent Maven :

mvn archetype:generate -DgroupId=com.example -DartifactId=spring-boot-multi-module -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false
cd spring-boot-multi-module

Étape 2 : Configurer le pom.xml parent

Dans le pom.xml, définissons nos dépendances et modules :

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://www.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example</groupId>
    <artifactId>spring-boot-multi-module</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>pom</packaging>
    <modules>
        <module>common</module>
        <module>domain</module>
        <module>repository</module>
        <module>service</module>
        <module>web</module>
    </modules>
    <properties>
        <java.version>11</java.version>
        <spring.boot.version>2.5.4</spring.boot.version>
    </properties>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring.boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Ce fichier pom.xml centralise les dépendances et les configurations, facilitant ainsi la gestion des paramètres partagés entre les modules.

4. Comment créer les modules

Module Common

Créons un module common pour définir des utilitaires partagés comme les formatteurs de date. Créez ce module et ajoutez une classe d'utilitaire d'exemple :

mvn archetype:generate -DgroupId=com.example.common -DartifactId=common -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false

Utilitaire de formatage de date :

package com.example.common;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

public class DateUtils {
    public static String formatDate(LocalDate date) {
        return date.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
    }
}

Module Domain

Dans le module domain, vous définirez vos modèles de données.

package com.example.domain;

import javax.persistence.Entity;
import javax.persistence.Id;

@Entity
public class User {
    @Id
    private Long id;
    private String name;

    // Getters et Setters
}

Module Repository

Créons le module repository pour gérer l'accès aux données. Voici une interface de dépôt de base :

package com.example.repository;

import com.example.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {}

Module Service

Créons le module service pour contenir votre logique métier. Voici un exemple de classe de service :

package com.example.service;

import com.example.domain.User;
import com.example.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class UserService {

    @Autowired
    private UserRepository userRepository;

    public User getUserById(Long id) {
        return userRepository.findById(id).orElse(null);
    }
}

Module Web

Le module web sert de couche d'API REST.

@RestController
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping("/users/{id}")
    public User getUserById(@PathVariable Long id) {
        return userService.getUserById(id);
    }
}

5. Communication inter-modules

Pour éviter les dépendances directes, vous pouvez utiliser des API REST ou des courtiers de messages (comme Kafka) pour la communication inter-modules. Cela garantit un couplage lâche et permet à chaque module de communiquer indépendamment.

Le diagramme ci-dessous montre comment les modules communiquent entre eux :

Organigramme montrant l'interaction entre les modules dans l'architecture logicielle : le module Web gère les points de terminaison de l'API et retourne des réponses, le module Service exécute la logique métier, le module Repository accède aux données et retourne des données traitées, et se connecte à une base de données.

Le diagramme illustre comment différents composants du système communiquent pour traiter les requêtes efficacement.

Le module Web traite les requêtes API entrantes et les transmet au module Service, qui contient la logique métier. Le module Service interagit ensuite avec le module Repository pour récupérer ou mettre à jour les données dans la base de données. Cette approche en couches garantit que chaque module fonctionne indépendamment, favorisant la flexibilité et une maintenance plus facile.

Exemple utilisant Feign Client :

Dans le contexte de la communication inter-modules, l'utilisation d'outils comme Feign Clients est un moyen puissant d'atteindre un couplage lâche entre les services.

Le client Feign permet à un module de communiquer de manière transparente avec un autre via des appels d'API REST, sans nécessiter de dépendances directes. Cette approche s'intègre parfaitement dans l'architecture en couches décrite précédemment, où le module Service peut récupérer des données à partir d'autres services ou microservices en utilisant des clients Feign, plutôt que d'accéder directement aux bases de données ou de coder en dur des requêtes HTTP.

Cela simplifie non seulement le code, mais améliore également l'évolutivité et la maintenabilité en isolant les dépendances de service.

@FeignClient(name = "userServiceClient", url = "http://localhost:8081")
public interface UserServiceClient {
    @GetMapping("/users/{id}")
    User getUserById(@PathVariable("id") Long id);
}

6. Problèmes courants et solutions

Lors de la mise en œuvre d'une architecture multi-modules, vous pouvez rencontrer plusieurs défis. Voici quelques problèmes courants et leurs solutions :

  • Dépendances circulaires : Les modules peuvent dépendre involontairement les uns des autres, créant une dépendance circulaire qui complique les builds et les déploiements.

    • Solution : Concevez soigneusement les interfaces des modules et utilisez des outils de gestion des dépendances pour détecter et résoudre les dépendances circulaires tôt dans le processus de développement.
  • Sur-ingénierie : Il existe un risque de créer trop de modules, entraînant une complexité inutile.

    • Solution : Commencez avec un ensemble minimal de modules et ne les divisez davantage que lorsqu'il y a un besoin clair, en veillant à ce que chaque module ait une responsabilité distincte.
  • Configurations incohérentes : La gestion des configurations dans plusieurs modules peut entraîner des incohérences.

    • Solution : Utilisez des outils de gestion de configuration centralisés, tels que Spring Cloud Config, pour maintenir la cohérence entre les modules.
  • Surcoût de communication : La communication inter-modules peut introduire de la latence et de la complexité.

    • Solution : Optimisez la communication en utilisant des protocoles efficaces et envisagez la messagerie asynchrone lorsque cela est approprié pour réduire la latence.
  • Complexité des tests : Tester un projet multi-modules peut être plus complexe en raison des interactions entre les modules.

    • Solution : Mettez en œuvre une stratégie de test robuste qui inclut des tests unitaires pour les modules individuels et des tests d'intégration pour les interactions inter-modules.

En étant conscient de ces pièges et en appliquant ces solutions, vous pouvez gérer efficacement les complexités d'une architecture multi-modules et assurer un processus de développement fluide.

7. Stratégie de test et configuration

Tester chaque module indépendamment et en tant qu'unité est crucial dans les configurations multi-modules.

Tests unitaires

Ici, nous utiliserons JUnit et Mockito pour effectuer des tests unitaires :

@RunWith(MockitoJUnitRunner.class)
public class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

    @Test
    public void testGetUserById() {
        User user = new User();
        user.setId(1L);
        user.setName("John");

        Mockito.when(userRepository.findById(1L)).thenReturn(Optional.of(user));

        User result = userService.getUserById(1L);
        assertEquals("John", result.getName());
    }
}

Tests d'intégration

Et nous utiliserons Testcontainers avec une base de données en mémoire pour les tests d'intégration :

@Testcontainers
@ExtendWith(SpringExtension.class)
@SpringBootTest
public class UserServiceIntegrationTest {

    @Container
    private static PostgreSQLContainer<?> postgresqlContainer = new PostgreSQLContainer<>("postgres:latest");

    @Autowired
    private UserService userService;

    @Test
    public void testFindById() {
        User user = userService.getUserById(1L);
        assertNotNull(user);
    }
}

8. Gestion des erreurs et journalisation

La gestion des erreurs et la journalisation assurent une application robuste et débogable.

Gestion des erreurs

Dans cette section, nous explorerons comment gérer les erreurs de manière élégante dans votre application Spring Boot en utilisant un gestionnaire d'exceptions global. En utilisant @ControllerAdvice, nous mettrons en place une manière centralisée de capturer et de répondre aux erreurs, gardant ainsi notre code propre et nos réponses cohérentes.

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<String> handleUserNotFoundException(UserNotFoundException ex) {
        return new ResponseEntity<>("User not found", HttpStatus.NOT_FOUND);
    }
}

Dans l'exemple de code ci-dessus, nous définissons un GlobalExceptionHandler qui capture toute UserNotFoundException et retourne un message convivial comme "User not found" avec un statut 404. Ainsi, vous n'avez pas à gérer cette exception dans chaque contrôleur—vous l'avez couverte en un seul endroit !

Organigramme représentant une séquence d'interactions entre un Client, un Module Web, un Gestionnaire d'erreurs global et un Journaliseur. Le processus implique la gestion des requêtes, leur traitement et la gestion des exceptions. Si une exception se produit, elle est gérée et journalisée, suivie d'une réponse d'erreur. Si aucune exception ne se produit, une réponse réussie est retournée.

Maintenant, examinons le diagramme. Voici comment tout cela se déroule : lorsqu'un client envoie une requête à notre Module Web, si tout se passe bien, vous obtiendrez une réponse réussie. Mais si quelque chose ne va pas, comme un utilisateur non trouvé, l'erreur sera capturée par notre Gestionnaire d'erreurs global. Ce gestionnaire journalise le problème et retourne une réponse propre et structurée au client.

Cette approche garantit que les utilisateurs reçoivent des messages d'erreur clairs tout en gardant les détails internes de votre application cachés et sécurisés.

Journalisation

La journalisation structurée dans chaque module améliore la traçabilité et le débogage. Vous pouvez utiliser un système de journalisation centralisé comme Logback et inclure des identifiants de corrélation pour tracer les requêtes.

9. Sécurité et intégration JWT

Dans cette section, nous allons configurer les JSON Web Tokens (JWT) pour sécuriser nos points de terminaison et contrôler l'accès en fonction des rôles des utilisateurs. Nous allons configurer cela dans la classe SecurityConfig, ce qui nous aidera à appliquer qui peut accéder à quelles parties de notre application.

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/admin/**").hasRole("ADMIN")
            .antMatchers("/user/**").hasAnyRole("USER", "ADMIN")
            .anyRequest().authenticated()
            .and()
            .oauth2ResourceServer().jwt();
    }
}

Dans l'exemple de code ci-dessus, vous pouvez voir comment nous avons défini les règles d'accès :

  • Les points de terminaison /admin/** sont restreints aux utilisateurs ayant le rôle ADMIN.

  • Les points de terminaison /user/** peuvent être accessibles par les utilisateurs ayant le rôle USER ou ADMIN.

  • Toute autre requête nécessitera que l'utilisateur soit authentifié.

Ensuite, nous configurons notre application pour valider les jetons entrants en utilisant .oauth2ResourceServer().jwt();. Cela garantit que seules les requêtes avec un jeton valide peuvent accéder à nos points de terminaison sécurisés.

Diagramme représentant une séquence d'interactions entre cinq composants : Client, Module Web, Filtre de sécurité, Service et Repository. Les flèches représentent les étapes d'authentification par jeton et d'accès à un service, y compris les requêtes, les validations, la récupération des données et les réponses, avec des résultats pour les jetons valides et invalides.

Maintenant, parcourons le diagramme. Lorsqu'un client envoie une requête pour accéder à une ressource, le Filtre de sécurité vérifie d'abord si le jeton JWT fourni est valide. Si le jeton est valide, la requête est transmise au Module de service pour récupérer ou traiter les données. Si ce n'est pas le cas, l'accès est refusé immédiatement et le client reçoit une réponse d'erreur.

Ce flux garantit que seuls les utilisateurs authentifiés peuvent accéder aux ressources sensibles, gardant ainsi notre application sécurisée.

10. Déploiement avec Docker et CI/CD

Dans cette section, nous allons containeriser chaque module en utilisant Docker pour rendre notre application plus facile à déployer et à exécuter de manière cohérente dans différents environnements. Nous allons également configurer un pipeline CI/CD en utilisant GitHub Actions (mais vous pouvez utiliser Jenkins si vous préférez). Automatiser ce processus garantit que toute modification que vous poussez est automatiquement construite, testée et déployée.

Étape 1 : Containerisation avec Docker

Nous commençons par créer un Dockerfile pour le Module Web :

FROM openjdk:11-jre-slim
COPY target/web-1.0-SNAPSHOT.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

Ici, nous utilisons une version légère de Java 11 pour garder notre image petite. Nous copions le fichier .jar compilé dans le conteneur et le configurons pour qu'il s'exécute lorsque le conteneur démarre.

Étape 2 : Utilisation de Docker Compose pour le déploiement multi-modules

Maintenant, nous allons utiliser un fichier Docker Compose pour orchestrer plusieurs modules ensemble :

version: '3'
services:
  web:
    build: ./web
    ports:
      - "8080:8080"
  service:
    build: ./service
    ports:
      - "8081:8081"

Avec cette configuration, nous pouvons exécuter à la fois le Module Web et le Module de Service en même temps, ce qui facilite le démarrage de l'ensemble de l'application avec une seule commande. Chaque service est construit séparément à partir de son propre répertoire, et nous exposons les ports nécessaires pour y accéder.

Exemple de CI/CD avec GitHub Actions

name: CI Pipeline

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - name: Set up JDK 11
      uses: actions/setup-java@v2
      with:
        java-version: '11'
    - name: Build with Maven
      run: mvn clean install

Ce pipeline se déclenche automatiquement chaque fois que vous poussez du nouveau code ou créez une pull request. Il extrait votre code, configure Java et exécute une construction Maven pour s'assurer que tout fonctionne correctement.

11. Bonnes pratiques et cas d'utilisation avancés

Les bonnes pratiques suivantes assurent la maintenabilité et l'évolutivité.

Bonnes pratiques

  • Éviter les dépendances circulaires : Assurez-vous que les modules n'ont pas de références circulaires pour éviter les problèmes de construction.

  • Séparer clairement les préoccupations : Chaque module doit se concentrer sur une responsabilité.

  • Configurations centralisées : Gérez les configurations de manière centralisée pour des configurations cohérentes.

Cas d'utilisation avancés

  1. Messagerie asynchrone avec Kafka : Utilisez Kafka pour une communication découplée entre les services. Les modules peuvent publier et s'abonner à des événements de manière asynchrone.

  2. Client REST avec Feign : Utilisez Feign pour appeler des services au sein des modules. Définissez une interface de client Feign pour la communication.

  3. Mise en cache pour la performance : Utilisez Spring Cache dans le module de service pour optimiser la récupération des données.

Conclusion et points clés

Un projet Spring Boot multi-modules offre modularité, évolutivité et facilité de maintenance.

Dans ce tutoriel, vous avez appris à configurer des modules, gérer la communication inter-modules, gérer les erreurs, ajouter de la sécurité et déployer avec Docker.

En suivant les bonnes pratiques et en utilisant des techniques avancées comme la messagerie et la mise en cache, vous optimiserez davantage votre architecture multi-modules pour une utilisation en production.