Maven¶
Maven est le Cargo de Java : il décrit le projet, télécharge les dépendances, compile, lance les tests et produit l'archive à déployer. Il est plus ancien (2004) et parle XML, mais tu retrouveras vite tes repères. C'est lui qui fait tourner tous les projets Spring de la partie 2.
Dans ce chapitre
- Les correspondances Cargo ↔ Maven, les coordonnées
groupId:artifactId:versionet l'arborescence standard - Un
pom.xmlminimal commenté, et les scopes de dépendances - Le cycle de vie : phases et goals, et le wrapper
./mvnw - Les dépendances transitives, le dépôt local
~/.m2, le classpath etjava -jar - Le parent POM et le BOM de Spring Boot, qui fixent les versions à ta place
- Gradle en deux mots
Maven, c'est Cargo¶
| Cargo | Maven | Remarque |
|---|---|---|
Cargo.toml |
pom.xml |
POM = Project Object Model |
| crates.io | Maven Central | Le dépôt public par défaut |
~/.cargo/registry |
~/.m2/repository |
Le cache local des dépendances |
[dev-dependencies] |
<scope>test</scope> |
Dépendances des tests seulement |
cargo build |
./mvnw compile |
|
cargo test |
./mvnw test |
|
cargo build --release |
./mvnw package |
Produit le .jar dans target/ |
cargo run |
./mvnw spring-boot:run |
Fourni par le plugin Spring Boot |
cargo tree |
./mvnw dependency:tree |
|
target/ |
target/ |
Même nom, même rôle |
rust-toolchain.toml |
.mvn/wrapper/ |
Fixe la version de l'outil |
Cargo.lock |
(aucun) | Les versions sont exactes dans le POM |
Trois différences de fond :
- Pas de mode debug / release. Le compilateur
javacn'optimise presque rien : c'est le JIT de la JVM qui optimise à l'exécution. - Pas de fichier de verrouillage. Par convention, on écrit des versions exactes (
1.12.0, pas^1.12), donc le POM suffit à reproduire le build. - Une seule version par bibliothèque. Cargo peut lier deux versions incompatibles d'une même crate. En Java, toutes les classes vivent dans un seul classpath : Maven doit choisir une version (voir plus bas).
Les coordonnées : groupId:artifactId:version¶
Chaque projet et chaque dépendance est identifié par trois coordonnées, souvent écrites org.apache.commons:commons-text:1.12.0 :
groupId: l'organisation, en domaine inversé (xyz.dunkan,org.springframework.boot). Contrairement à crates.io, les noms sont donc rangés par propriétaire : pas de squatting de noms courts.artifactId: le nom du projet (commons-text,todo).version:1.12.0. Le suffixe-SNAPSHOT(1.0.0-SNAPSHOT) désigne une version en cours de développement, qui peut changer ; une version sans suffixe est figée pour toujours.
L'arborescence standard¶
Maven impose une organisation (convention over configuration) : si tu la respectes, il n'y a rien à configurer.
bonjour/
├── pom.xml
├── mvnw, mvnw.cmd, .mvn/wrapper/ ← le wrapper (à commiter)
├── src/
│ ├── main/
│ │ ├── java/xyz/dunkan/bonjour/
│ │ │ └── Main.java ← le code (dossiers = paquets)
│ │ └── resources/ ← copiés tels quels (application.properties...)
│ └── test/
│ └── java/xyz/dunkan/bonjour/
│ └── MainTest.java ← les tests, même paquet que le code testé
└── target/ ← généré, à ignorer dans git
Un pom.xml minimal¶
Ce POM a été compilé et testé avec Maven 3.10 et le JDK 25 :
<?xml version="1.0" encoding="UTF-8"?>
<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion> <!-- toujours 4.0.0 -->
<!-- les coordonnées du projet -->
<groupId>xyz.dunkan</groupId>
<artifactId>bonjour</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging> <!-- jar par défaut -->
<properties>
<!-- version de Java visée (langage et API) -->
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- dépendance normale : compilation et exécution -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-text</artifactId>
<version>1.12.0</version>
</dependency>
<!-- dépendance de test seulement : absente du jar final -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.0.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- configure un plugin : ici, la classe principale du jar -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.5.1</version>
<configuration>
<archive>
<manifest>
<mainClass>xyz.dunkan.bonjour.Main</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
Avec le code et le test qui vont avec :
Les tests JUnit sont détaillés dans Tests.
Les scopes¶
| Scope | Compilation | Tests | Dans l'appli finale |
|---|---|---|---|
compile (défaut) |
oui | oui | oui |
runtime |
non | oui | oui |
test |
non (code principal) | oui | non |
provided |
oui | oui | non (fourni par l'environnement) |
runtime sert pour ce dont ton code ne dépend pas directement : le projet Spring généré déclare ainsi les pilotes h2 et postgresql, chargés à l'exécution.
Le cycle de vie : phases et goals¶
Maven ne connaît pas des commandes, mais des phases ordonnées. Demander une phase exécute toutes les précédentes :
flowchart LR
validate --> compile --> test --> package --> verify --> install --> deploy
| Phase | Ce qui se passe |
|---|---|
validate |
Vérifie que le POM est correct |
compile |
Compile src/main/java vers target/classes |
test |
Compile et lance les tests de src/test/java |
package |
Produit target/bonjour-1.0.0-SNAPSHOT.jar |
verify |
Contrôles supplémentaires (tests d'intégration...) |
install |
Copie le jar dans ~/.m2/repository, pour tes autres projets |
deploy |
Publie dans un dépôt distant |
Chaque phase déclenche des goals de plugins (notation plugin:goal). La sortie réelle de ./mvnw clean package le montre (raccourcie) :
[INFO] --- clean:3.5.0:clean (default-clean) @ bonjour ---
[INFO] --- resources:3.5.0:resources (default-resources) @ bonjour ---
[INFO] --- compiler:3.16.0:compile (default-compile) @ bonjour ---
[INFO] Compiling 1 source file with javac [debug release 25] to target/classes
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ bonjour ---
[INFO] --- surefire:3.6.0:test (default-test) @ bonjour ---
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] --- jar:3.5.1:jar (default-jar) @ bonjour ---
[INFO] Building jar: .../bonjour/target/bonjour-1.0.0-SNAPSHOT.jar
[INFO] BUILD SUCCESS
clean (vider target/) appartient à un cycle séparé, d'où le classique ./mvnw clean package. On peut aussi appeler un goal directement, sans phase : ./mvnw dependency:tree, ./mvnw spring-boot:run.
Options utiles
-DskipTests: ne lance pas les tests (ils sont quand même compilés) ;-Dtest=MainTest: un seul test ;-q: silencieux ;-ntp: sans les barres de téléchargement ;-o: hors ligne, uniquement avec ce qui est dans~/.m2.
Le wrapper ./mvnw¶
Tu n'installes pas Maven : chaque projet embarque un petit script, mvnw (et mvnw.cmd pour Windows), plus le fichier .mvn/wrapper/maven-wrapper.properties qui contient la version voulue :
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.10.0/apache-maven-3.10.0-bin.zip
Au premier lancement, ./mvnw télécharge cette version dans ~/.m2/wrapper/dists/, puis l'utilise. Toute l'équipe, la CI et le Dockerfile utilisent ainsi le même Maven. Commite mvnw, mvnw.cmd et .mvn/.
Piège : ./mvnw: Permission denied
Certains outils de décompression (dont le module zipfile de Python) perdent le droit d'exécution du script. Un chmod +x mvnw règle le problème.
Les dépendances transitives¶
Une dépendance a ses propres dépendances, qui deviennent les tiennes. dependency:tree les montre :
$ ./mvnw dependency:tree
[INFO] xyz.dunkan:bonjour:jar:1.0.0-SNAPSHOT
[INFO] +- org.apache.commons:commons-text:jar:1.12.0:compile
[INFO] | \- org.apache.commons:commons-lang3:jar:3.14.0:compile
[INFO] \- org.junit.jupiter:junit-jupiter:jar:6.0.3:test
[INFO] +- org.junit.jupiter:junit-jupiter-api:jar:6.0.3:test
[INFO] | +- org.opentest4j:opentest4j:jar:1.3.0:test
[INFO] ...
Ça grossit vite : le projet Spring généré pour la partie 2 déclare 11 dépendances, soit 131 jars au total une fois les transitives résolues (dont 91 hors tests). Pour retrouver d'où vient une bibliothèque, filtre : ./mvnw dependency:tree -Dincludes=org.apache.commons.
Quand deux chemins demandent deux versions différentes d'une même bibliothèque, Maven en garde une seule : la plus proche de ton projet dans l'arbre (à égalité, la première déclarée). Ce n'est pas forcément la plus récente. Pour imposer une version, on utilise le dependencyManagement, vu plus bas avec le BOM.
Le dépôt local ~/.m2¶
Tout ce que Maven télécharge est rangé dans ~/.m2/repository, selon les coordonnées (les points du groupId deviennent des dossiers) :
Le premier build d'un projet télécharge beaucoup ; les suivants sont rapides et peuvent même se faire hors ligne. ./mvnw install dépose ton propre jar au même endroit (~/.m2/repository/xyz/dunkan/bonjour/1.0.0-SNAPSHOT/), pour qu'un autre de tes projets puisse en dépendre.
Jar, classpath et java -jar¶
Un jar (Java ARchive) est un zip contenant des .class, des ressources et un fichier META-INF/MANIFEST.MF. Le classpath est la liste des dossiers et des jars où la JVM cherche les classes, l'équivalent des chemins d'édition de liens, mais consulté à l'exécution :
java -cp target/classes:$HOME/.m2/repository/org/apache/commons/commons-text/1.12.0/commons-text-1.12.0.jar:$HOME/.m2/repository/org/apache/commons/commons-lang3/3.14.0/commons-lang3-3.14.0.jar xyz.dunkan.bonjour.Main
# Bonjour Depuis Maven
Grâce au mainClass du manifeste, java -jar trouve la classe principale tout seul... mais pas les dépendances, que le jar ne contient pas :
$ java -jar target/bonjour-1.0.0-SNAPSHOT.jar
Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/text/WordUtils
at xyz.dunkan.bonjour.Main.titre(Main.java:7)
at xyz.dunkan.bonjour.Main.main(Main.java:11)
Caused by: java.lang.ClassNotFoundException: org.apache.commons.text.WordUtils
Sous le capot : le chargement paresseux des classes
Contrairement à un exécutable Rust ou C++, rien n'est lié à l'avance : la JVM charge chaque classe la première fois qu'elle sert. Le programme démarre donc normalement et ne plante qu'à la première utilisation de WordUtils. Une dépendance manquante peut ainsi rester invisible jusqu'au jour où le code concerné s'exécute.
Spring Boot règle ce problème avec son plugin, qui reconstruit le jar en y embarquant toutes les dépendances (un fat jar, ou jar exécutable) :
[INFO] --- spring-boot:4.1.1:repackage (repackage) @ bonjour ---
[INFO] Replacing main artifact ... with repackaged archive, adding nested dependencies in BOOT-INF/.
$ java -jar target/bonjour-1.0.0-SNAPSHOT.jar
Bonjour Depuis Maven
Le jar contient alors BOOT-INF/classes/ (ton code) et BOOT-INF/lib/ (les jars des dépendances), et son manifeste lance un chargeur Spring (Main-Class: org.springframework.boot.loader.launch.JarLauncher) qui démarre ensuite ta classe (Start-Class). Un seul fichier à déployer, qui n'a besoin que d'une JVM : c'est ce qu'on mettra dans un conteneur dans Déployer.
Parent POM et BOM : les versions fixées par Spring Boot¶
Voici le début du pom.xml généré par start.spring.io pour la partie 2 :
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>xyz.dunkan</groupId>
<artifactId>todo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
<!-- pas de <version> ! -->
</dependency>
...
Un POM peut hériter d'un autre, le parent POM. spring-boot-starter-parent apporte :
- des réglages :
java.version(qui alimentemaven.compiler.release), l'encodage UTF-8, les versions et la configuration des plugins (dontspring-boot-maven-plugin) ; - par son propre parent,
spring-boot-dependencies, une section<dependencyManagement>qui fixe les versions de plusieurs centaines de bibliothèques testées ensemble : Spring, Hibernate, Jackson, JUnit, les pilotes de bases de données... C'est ce qu'on appelle un BOM (Bill of Materials).
Le dependencyManagement n'ajoute aucune dépendance : il dit seulement « si cette bibliothèque apparaît, voici sa version ». D'où les <dependency> sans <version>. Et il s'applique aussi aux dépendances transitives : avec le parent Spring Boot, le même commons-text 1.12.0 n'utilise plus commons-lang3 3.14.0 mais la version choisie par Spring Boot :
[INFO] +- org.apache.commons:commons-text:jar:1.12.0:compile
[INFO] | \- org.apache.commons:commons-lang3:jar:3.20.0:compile
C'est un peu comme si Spring publiait un Cargo.lock de versions compatibles entre elles, que tu partages. Monter de version de Spring Boot (changer 4.1.1 dans le parent) met tout à jour d'un coup, de façon cohérente.
Si ton projet a déjà un autre parent, tu peux importer le BOM seul :
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>4.1.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Et Gradle ?¶
Gradle est l'autre outil de build majeur (celui d'Android). Il décrit le build par un script Kotlin ou Groovy plutôt qu'en XML, et il est plus rapide sur les gros projets (build incrémental, cache, démon). L'équivalent de notre POM, pour comparaison :
plugins {
application
}
group = "xyz.dunkan"
version = "1.0.0-SNAPSHOT"
java { toolchain { languageVersion = JavaLanguageVersion.of(25) } }
repositories { mavenCentral() }
dependencies {
implementation("org.apache.commons:commons-text:1.12.0")
testImplementation(platform("org.junit:junit-bom:6.0.3"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
application { mainClass = "xyz.dunkan.bonjour.Main" }
tasks.test { useJUnitPlatform() }
Ce site utilise Maven : il est déclaratif (un POM se lit sans exécuter de code), très stable, omniprésent dans la documentation Spring et les projets d'entreprise, et ses concepts (coordonnées, scopes, BOM, Maven Central) servent aussi avec Gradle.
Exercices¶
1. Dans le projet bonjour, change l'attendu du test en "Hello WORLD" et lance ./mvnw package. Que se passe-t-il ? Le jar est-il produit ? Comment le produire quand même ?
Solution
La phase test échoue, donc Maven s'arrête avant package : pas de nouveau jar.
[ERROR] Failures:
[ERROR] MainTest.metUneMajusculeAChaqueMot:10 expected: <Hello WORLD> but was: <Hello World>
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
[ERROR] BUILD FAILURE
./mvnw package -DskipTests produit le jar sans lancer les tests (Tests are skipped.). À réserver aux cas où tu sais pourquoi : un test rouge est une information.
2. Dans le projet todo généré par start.spring.io (voir Premier projet), trouve quelle version de jackson-databind est utilisée, et par quel chemin elle arrive.
Solution
$ ./mvnw dependency:tree -Dincludes=tools.jackson.core
[INFO] xyz.dunkan:todo:jar:0.0.1-SNAPSHOT
[INFO] \- org.springframework.boot:spring-boot-starter-webmvc:jar:4.1.1:compile
[INFO] \- org.springframework.boot:spring-boot-starter-jackson:jar:4.1.1:compile
[INFO] \- org.springframework.boot:spring-boot-jackson:jar:4.1.1:compile
[INFO] \- tools.jackson.core:jackson-databind:jar:3.1.5:compile
[INFO] \- tools.jackson.core:jackson-core:jar:3.1.5:compile
Jackson 3.1.5 arrive par le starter web : tu ne l'as jamais déclaré, et sa version vient du BOM de Spring Boot.
3. Avec le parent Spring Boot, commons-lang3 est en 3.20.0. Imagine que tu doives forcer la 3.19.0 : comment faire sans ajouter de dépendance ?
Solution
Le BOM de Spring Boot définit ses versions par des propriétés (<commons-lang3.version>, <hibernate.version>...). Il suffit de la redéfinir dans ton POM :
<properties>
<java.version>25</java.version>
<commons-lang3.version>3.19.0</commons-lang3.version>
</properties>
[INFO] \- org.apache.commons:commons-text:jar:1.12.0:compile
[INFO] \- org.apache.commons:commons-lang3:jar:3.19.0:compile
À utiliser avec parcimonie (correctif de sécurité urgent, bug précis) : tu sors de la combinaison testée par Spring.
4. On ajoute System.out.println("Démarrage..."); au début du main de bonjour, puis on lance java -jar sur le jar produit sans le plugin Spring Boot. Qu'est-ce qui s'affiche ?
Solution
Démarrage...
Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/text/WordUtils
at xyz.dunkan.bonjour.Main.titre(Main.java:7)
Le jar ne contient pas commons-text, mais la JVM ne s'en aperçoit qu'au premier usage de WordUtils : la première ligne s'affiche normalement.
À retenir¶
- Maven ≈ Cargo :
pom.xml≈Cargo.toml, Maven Central ≈ crates.io,~/.m2≈ le cache de Cargo,target/identique. - Une dépendance =
groupId:artifactId:version;-SNAPSHOT= version en développement. - Respecte l'arborescence
src/main/java,src/main/resources,src/test/java: il n'y a alors rien à configurer. - Une phase exécute toutes les précédentes :
./mvnw packagecompile, teste, puis emballe. - Utilise toujours
./mvnw(le wrapper), jamais un Maven installé à la main. ./mvnw dependency:treepour comprendre d'où vient une bibliothèque ; une seule version par bibliothèque sur le classpath.- Un jar classique ne contient pas ses dépendances ; le plugin Spring Boot produit un jar exécutable autonome.
- Le parent
spring-boot-starter-parentet son BOM fixent les versions de tout l'écosystème : pas de<version>sur les dépendances gérées.