Construction d'une architecture monolithique de production avec Spring Boot 3.x (JDK17, Nacos, JWT, Docker)

Dans le contexte du développement logiciel d'entreprise, il est fréquent de constater que les phases initiales d'un projet ne nécessitent pas une architecture en microservices complexe. Une application monolithique robuste, standardisée et extensible représente souvent le choix optimal.

Une grande partie des ressources disponibles en ligne demeure ancrée dans l'écosystème Spring Boot 2.x voire JDK 8. Leurs configurations s'apparentent souvent à des prototypes pédagogiques. Les composants essentiels à la production tels qu'une authentification RSA+JWT solide, une gestion centralisée des exceptions, la configuration des CORS, un système de double cache Redis ainsi qu'un déploiement Docker normalisé sont rarement intégrés de manière complète et prête à l'emploi.

Pallier cette lacune est l'ojbectif de cet article : assembler une fondation technique éprouvée. L'idée est de disposer d'un socle de production sur lequel il suffit d'adapter le package de base pour démarrer immédiatement le développement fonctionnel.

Prérequis environnementaux

  • JDK : version 17 ou supérieure (exigence formelle de Spring Boot 3.x)
  • Maven : version 3.8+
  • MySQL : version 8.0+
  • Redis : version 5.0+
  • Nacos : version 2.x (comme centre de configuration, optionnel mais recommandé)

Démarche de mise en œuvre

1. Gestion des dépendances fondamentales

Création d'un projet Maven standard pour Spring Boot. Voici un exemple de fichier pom.xml structuré différemment, avec des versions et commentaires propres.

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.3</version>
</parent>

<properties>
    <java.version>17</java.version>
    <mbp.version>3.5.8</mbp.version>
    <sca.version>2023.0.1.0</sca.version>
    <openapi.version>2.6.0</openapi.version>
</properties>

<dependencies>
    <!-- Module Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Configuration Nacos (Spring Cloud Alibaba) -->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
        <version>${sca.version}</version>
    </dependency>

    <!-- Découverte Nacos -->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        <version>${sca.version}</version>
    </dependency>

    <!-- ORM -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
        <version>${mbp.version}</version>
    </dependency>

    <!-- Cache -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <!-- Jeton d'authentification -->
    <dependency>
        <groupId>com.auth0</groupId>
        <artifactId>java-jwt</artifactId>
        <version>3.19.2</version>
    </dependency>

    <!-- Documentation API -->
    <dependency>
        <groupId>org.springdoc</groupId>
        <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
        <version>${openapi.version}</version>
    </dependency>
</dependencies>

2. Intégration et configuration de Nacos

Depuis Spring Boot 2.4, la méthode privilégiée pour importer les configurations distantes utilise la directive import, en délaissant le fichier bootstrap.yml.

Fichier de configuration local (application.yml) :

server:
  port: 8089
  shutdown: graceful

spring:
  profiles:
    active: dev
  application:
    name: prod-app-core
  cloud:
    nacos:
      config:
        server-addr: ${NACOS_HOST:127.0.0.1:8848}
        file-extension: yml
        group: MAIN_GROUP
        namespace: VOTRE_ID_ESPACE
  config:
    import:
      - nacos:${spring.application.name}-${spring.profiles.active}.${spring.cloud.nacos.config.file-extension}

Configuration distante Nacos (prod-app-core-dev.yml) :
Créer ce fichier de configuration et y coller les paramètres ci-dessous. Notez l'utilisation d'une section app.global pour stocker des secrets comme la clé RSA.

spring:
  datasource:
    dynamic:
      primary: master
      hikari:
        maximum-pool-size: 8
      datasource:
        master:
          url: jdbc:mysql://dbhost:3306/prod_db?serverTimezone=Europe/Paris&characterEncoding=utf8mb4
          username: prod_user
          password: ${DB_PASSWORD}
          driver-class-name: com.mysql.cj.jdbc.Driver
  data:
    redis:
      host: redis-host
      port: 6379
      lettuce:
        pool:
          max-active: 50
          max-idle: 10

# Configuration applicative globale
app:
  global:
    # Clé privée RSA (utilisée pour le déchiffrement côté serveur)
    rsa-private-key: MIICdwIBADANBgkqhkiG9w0BAQEFAASC...

3. Configurations d'infrastructure essentielles

CORS (Cross-Origin Resource Sharing) :

@Configuration
public class CorsSettings {
    @Bean
    public WebMvcConfigurer corsConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                registry.addMapping("/**")
                        .allowedOriginPatterns("*")
                        .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                        .allowedHeaders("*")
                        .allowCredentials(true)
                        .maxAge(3600);
            }
        };
    }
}

Pagination avec MyBatis Plus :

@Configuration
@MapperScan("com.example.project.repository")
public class PersistenceConfig {
    @Bean
    public MybatisPlusInterceptor paginationInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        PaginationInnerInterceptor pageInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
        pageInterceptor.setMaxLimit(500L);
        interceptor.addInnerInterceptor(pageInterceptor);
        return interceptor;
    }
}

Documentation API (OpenAPI 3) :

@Configuration
public class ApiDocumentationConfig {
    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("API de production")
                        .version("1.0")
                        .description("Documentation des endpoints de l'application"))
                .addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
                .components(new Components()
                        .addSecuritySchemes("bearerAuth",
                                new SecurityScheme()
                                        .type(SecurityScheme.Type.HTTP)
                                        .scheme("bearer")
                                        .bearerFormat("JWT")));
    }
}

Sérialisation pour Redis :

@Configuration
@EnableCaching
public class CacheConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        
        GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
        StringRedisSerializer stringSerializer = new StringRedisSerializer();
        
        template.setKeySerializer(stringSerializer);
        template.setHashKeySerializer(stringSerializer);
        template.setValueSerializer(jsonSerializer);
        template.setHashValueSerializer(jsonSerializer);
        template.afterPropertiesSet();
        
        return template;
    }
}

4. Gestion centralisée des exceptions

Un gestionnaire global est crucial pour la stabilité et la cohérence des réponses d'erreur.

@Slf4j
@RestControllerAdvice
public class GlobalErrorAdvisor {

    @ExceptionHandler(AuthenticationException.class)
    public ResponseEntity<ErrorDto> handleAuthFailure(AuthenticationException ex) {
        log.warn("Échec d'authentification: {}", ex.getMessage());
        ErrorDto error = new ErrorDto("AUTH_FAILURE", ex.getMessage());
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorDto> handleValidation(MethodArgumentNotValidException ex) {
        String message = ex.getBindingResult().getFieldErrors().stream()
                .map(DefaultMessageSourceResolvable::getDefaultMessage)
                .collect(Collectors.joining(", "));
        ErrorDto error = new ErrorDto("VALIDATION_ERROR", message);
        return ResponseEntity.badRequest().body(error);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorDto> handleGenericException(Exception ex, WebRequest request) {
        log.error("Erreur interne sur {}", request.getDescription(false), ex);
        ErrorDto error = new ErrorDto("SERVER_ERROR", "Une erreur interne est survenue.");
        return ResponseEntity.internalServerError().body(error);
    }
}

5. Chaîne complète d'authentification sécurisée

Le flux sécurisé implémente : chiffrement RSA côté client → déchiffrement côté serveur → vérfiication → génération JWT → mise en cache Redis.

Service d'authentification :

@Service
@Slf4j
public class AuthenticationServiceImpl implements AuthenticationService {
    private final UserRepository userRepository;
    private final JwtTokenProvider tokenProvider;
    private final RsaKeyProperties rsaKeys;
    private final RedisTemplate<String, String> redisTemplate;

    // Injection via constructeur
    public AuthenticationServiceImpl(UserRepository userRepository,
                                     JwtTokenProvider tokenProvider,
                                     RsaKeyProperties rsaKeys,
                                     RedisTemplate<String, String> redisTemplate) {
        this.userRepository = userRepository;
        this.tokenProvider = tokenProvider;
        this.rsaKeys = rsaKeys;
        this.redisTemplate = redisTemplate;
    }

    @Override
    public AuthResponse authenticate(EncryptedLoginRequest request) {
        try {
            // Déchiffrement du payload
            String decryptedJson = CryptoUtil.decryptWithPrivateKey(
                    request.getPayload(),
                    rsaKeys.getPrivateKey()
            );
            LoginCredentials creds = new ObjectMapper().readValue(decryptedJson, LoginCredentials.class);

            // Vérification des identifiants
            User user = userRepository.findByLogin(creds.username())
                    .orElseThrow(() -> new BadCredentialsException("Identifiants invalides"));

            if (!passwordEncoder.matches(creds.password(), user.getPasswordHash())) {
                throw new BadCredentialsException("Identifiants invalides");
            }

            // Création et stockage du jeton
            String jwt = tokenProvider.generateToken(user.getId());
            String sessionKey = "session:" + jwt;
            String userSessionKey = "user_session:" + user.getId();

            redisTemplate.opsForValue().set(sessionKey, user.getId(), Duration.ofHours(2));
            redisTemplate.opsForValue().set(userSessionKey, jwt, Duration.ofHours(2));

            return new AuthResponse(jwt, user.getDisplayName());
        } catch (Exception e) {
            log.error("Échec du processus d'authentification", e);
            throw new AuthenticationServiceException("Échec de l'authentification", e);
        }
    }
}

Filtre d'interception des requêtes :

@Component
@Order(1)
public class JwtValidationFilter extends OncePerRequestFilter {
    private static final String BEARER_PREFIX = "Bearer ";
    private final RedisTemplate<String, String> redisTemplate;

    public JwtValidationFilter(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
        
        if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) {
            String jwt = authHeader.substring(BEARER_PREFIX.length());
            String userId = redisTemplate.opsForValue().get("session:" + jwt);

            if (userId != null) {
                SecurityContext context = SecurityContextHolder.createEmptyContext();
                Authentication authentication = new UsernamePasswordAuthenticationToken(userId, jwt, Collections.emptyList());
                context.setAuthentication(authentication);
                SecurityContextHolder.setContext(context);
                
                // Renouvellement de la session
                redisTemplate.expire("session:" + jwt, Duration.ofHours(2));
            }
        }

        filterChain.doFilter(request, response);
    }
}

6. Déploiement conteneurisé

Spring Boot 3.x impose l'usage de JDK 17+. Le Dockerfile doit en tenir compte.

FROM eclipse-temurin:17-jre-alpine
WORKDIR /app

COPY --chown=1001:1001 target/*.jar app.jar
EXPOSE 8080

USER 1001
ENTRYPOINT ["java", \
            "-XX:+UseContainerSupport", \
            "-XX:MaxRAMPercentage=75.0", \
            "-Djava.security.egd=file:/dev/./urandom", \
            "-jar", "app.jar"]

Commandes pour construire et exécuter :

# Construction de l'image
docker build -t myapp:1.0.0 .

# Exécution avec injection de variables d'environnement
docker run -d --name app-instance -p 8080:8080 \
  -e SPRING_PROFILES_ACTIVE=prod \
  -e NACOS_HOST=nacos.example.com:8848 \
  -e DB_PASSWORD=secret \
  myapp:1.0.0

Étiquettes: Spring Boot 3 JDK 17 Nacos JWT Docker

Publié le 12 juin à 00h52