10. Maven y Gradle a fondo
Ejemplo completo: code/java-for-csharp/l10. El mismo build de tres módulos existe para Maven y para Gradle, con un equivalente NuGet. check.sh ejecuta en CI cada comando de abajo en los tres sistemas operativos y compara la salida con la lección.
De las soluciones a los builds
Sección titulada «De las soluciones a los builds»La lección 1 compiló un solo proyecto. El código real son varios proyectos que dependen unos de otros y de bibliotecas que a su vez dependen de otras bibliotecas. Todas las piezas de .NET tienen un equivalente, pero hay una regla que difiere de un modo que rompe los programas en tiempo de ejecución: qué versión gana cuando se piden dos.
| .NET | Maven | Gradle |
|---|---|---|
.sln que enumera los proyectos |
POM padre con <modules> |
settings.gradle.kts con include(...) |
Directory.Build.props |
el POM <parent> |
un plugin de convenciones en buildSrc |
Directory.Packages.props |
<dependencyManagement>, BOM |
catálogo de versiones, plataformas |
ProjectReference |
una dependencia sobre las coordenadas del módulo | project(":lib") |
PrivateAssets="all" |
<optional>true</optional> |
compileOnly |
| sin equivalente | sin equivalente | implementation: oculta al compilador de los consumidores |
| error de degradación NU1605 | enforcer requireUpperBoundDeps |
failOnVersionConflict() |
packages.lock.json |
sin equivalente | gradle.lockfile |
global.json |
Maven Wrapper | Gradle Wrapper |
dotnet list package --include-transitive |
mvn dependency:tree |
gradle dependencies |
Un build de tres módulos
Sección titulada «Un build de tres módulos»El ejemplo es una pequeña cadena de bibliotecas. core da formato a títulos de libros con Apache Commons Lang, lib construye slugs de URL sobre core, y app usa lib más Commons Text:
maven/ gradle/├── pom.xml ← padre ├── settings.gradle.kts ← como el .sln├── mvnw, mvnw.cmd ← wrapper ├── gradlew, gradlew.bat ← wrapper├── core/pom.xml ├── gradle/libs.versions.toml├── lib/pom.xml ├── buildSrc/ ← lógica de build compartida└── app/pom.xml ├── core/build.gradle.kts ├── lib/build.gradle.kts └── app/build.gradle.ktsMaven: un padre y sus módulos
Sección titulada «Maven: un padre y sus módulos»Un build multimódulo tiene un POM padre con empaquetado pom. Este enumera los módulos, y los módulos lo nombran como <parent> para heredar sus propiedades y sus versiones de plugins:
<groupId>com.example.books</groupId> <artifactId>books-parent</artifactId> <version>1.0-SNAPSHOT</version> <packaging>pom</packaging>
<modules> <module>core</module> <module>lib</module> <module>app</module> </modules>
<properties> <maven.compiler.release>25</maven.compiler.release> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <parent> <groupId>com.example.books</groupId> <artifactId>books-parent</artifactId> <version>1.0-SNAPSHOT</version> </parent>
<artifactId>app</artifactId>
<dependencies> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-text</artifactId> <version>1.12.0</version> </dependency> <dependency> <groupId>com.example.books</groupId> <artifactId>lib</artifactId> <version>${project.version}</version> </dependency> </dependencies>El padre de Maven cumple dos papeles que .NET mantiene separados: el .sln (qué proyectos se compilan juntos, lo que se llama el reactor) y Directory.Build.props (los ajustes compartidos). Una dependencia sobre otro módulo usa sus coordenadas, como si fuera un paquete publicado; dentro del reactor, Maven usa la salida recién compilada del módulo en lugar de descargarla. mvn -pl app -am package compila app y los módulos que necesita («also make»), igual que dotnet build App.csproj compila sus referencias de proyecto.
Gradle: ajustes, un catálogo y un plugin de convenciones
Sección titulada «Gradle: ajustes, un catálogo y un plugin de convenciones»rootProject.name = "books"
dependencyResolutionManagement { repositories { mavenCentral() }}
include("core", "lib", "app")Las versiones viven en el catálogo de versiones, gradle/libs.versions.toml, donde los scripts de build las ven como libs.commons.text:
[versions]commons-lang3 = "3.20.0"commons-text = "1.12.0"
[libraries]commons-lang3 = { module = "org.apache.commons:commons-lang3", version.ref = "commons-lang3" }commons-text = { module = "org.apache.commons:commons-text", version.ref = "commons-text" }Los ajustes compartidos van en un plugin de convenciones, un script de build en buildSrc que cada proyecto aplica por su nombre. Hace el papel de Directory.Build.props, salvo que un proyecto solo recibe los ajustes cuando los pide:
// Ajustes compartidos por todos los proyectos que aplican este plugin, como Directory.Build.propsplugins { java}
java { toolchain { languageVersion = JavaLanguageVersion.of(25) }}plugins { id("books.java-conventions") application}
dependencies { implementation(libs.commons.text) implementation(project(":lib"))}
application { mainClass = "app.Main"}Mi primera versión ponía estos ajustes en un build.gradle.kts raíz con un bloque subprojects { }, como hacen muchos builds antiguos. La documentación de Gradle llama ahora a esa configuración entre proyectos «an improper way to share build logic», porque el script de un subproyecto ya no muestra todo lo que se le aplica.
Ámbitos y configuraciones
Sección titulada «Ámbitos y configuraciones»Una dependencia no está simplemente «referenciada». Cada herramienta indica en qué class path va, y si se transmite a los consumidores:
| Necesaria para | NuGet | Ámbito de Maven | Configuración de Gradle |
|---|---|---|---|
| compilar y ejecutar, visible para los consumidores | PackageReference (por defecto) |
compile (por defecto) |
api (plugin java-library) |
| compilar y ejecutar, oculta al compilador de los consumidores | ninguno | ninguno | implementation |
| solo compilar (el runtime la proporciona) | ExcludeAssets="runtime" |
provided |
compileOnly |
| solo ejecutar (un driver JDBC) | ExcludeAssets="compile" |
runtime |
runtimeOnly |
| pruebas | un proyecto de pruebas aparte | test |
testImplementation |
| solo tu build, nunca se transmite (analizadores) | PrivateAssets="all" |
<optional>true</optional> |
compileOnly, annotationProcessor |
La fila que importa es la segunda. El implementation de Gradle pone una dependencia en el class path de ejecución del consumidor, pero no en su class path de compilación. lib declara implementation(project(":core")), así que app no puede usar core sin declararlo. Un segundo source set de app lo intenta:
package app;
import core.Titles;
public class Leak {
public static void main(String[] args) { System.out.println(Titles.withoutArticle("the pragmatic programmer")); }}Leak.java:3: error: package core does not existimport core.Titles; ^Leak.java:8: error: cannot find symbol System.out.println(Titles.withoutArticle("the pragmatic programmer")); ^ symbol: variable Titles location: class Leak2 errorsMaven no hace esa distinción: una dependencia compile está en el class path de compilación de todos los consumidores, de forma transitiva. NuGet se comporta igual, y por eso un proyecto C# puede usar un paquete que solo declara un proyecto referenciado. app/Main.java hace exactamente eso: importa org.apache.commons.lang3.StringUtils sin declarar Commons Lang, y compila. El goal dependency:analyze lo detecta:
[WARNING] Used undeclared dependencies found:[WARNING] org.apache.commons:commons-lang3:jar:3.20.0:compileApoyarse en una dependencia transitiva funciona hasta que la biblioteca intermedia la abandona.
Cuando se encuentran dos versiones
Sección titulada «Cuando se encuentran dos versiones»Aquí está la trampa. core necesita Commons Lang 3.20.0: usa la clase Strings, añadida en la 3.18.0. app depende además de Commons Text 1.12.0, que depende de Commons Lang 3.14.0. Solo puede haber un JAR por biblioteca en el class path.
Maven: gana la definición más cercana
Sección titulada «Maven: gana la definición más cercana»Maven elige la «definición más cercana»: la versión más próxima a tu proyecto en el árbol y, en caso de empate, la primera declarada. Commons Text es una dependencia directa de app, así que su Commons Lang está a profundidad 2; la de core está a profundidad 3. Gana la versión más antigua:
.\mvnw package dependency:tree -Dverbose./mvnw package dependency:tree -Dverbose./mvnw package dependency:tree -Dverbosecom.example.books:app:jar:1.0-SNAPSHOT+- org.apache.commons:commons-text:jar:1.12.0:compile| \- org.apache.commons:commons-lang3:jar:3.14.0:compile\- com.example.books:lib:jar:1.0-SNAPSHOT:compile \- com.example.books:core:jar:1.0-SNAPSHOT:compile \- (org.apache.commons:commons-lang3:jar:3.20.0:compile - omitted for conflict with 3.14.0)Todo compiló, porque core se compiló contra su propia versión declarada. El fallo llega en tiempo de ejecución, cuando core busca una clase que la 3.14.0 no tiene:
commons-lang3 on the class path: 3.14.0The Art Of Computer ProgrammingException in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/lang3/Strings-Dverbose es lo que muestra la versión omitida; sin él, el árbol solo enumera la ganadora.
Gradle: gana la versión más alta
Sección titulada «Gradle: gana la versión más alta»Gradle considera todas las versiones pedidas y selecciona la más alta. El mismo grafo da 3.20.0, y la flecha muestra la sustitución:
.\gradlew :app:dependencies --configuration runtimeClasspath./gradlew :app:dependencies --configuration runtimeClasspath./gradlew :app:dependencies --configuration runtimeClasspathruntimeClasspath - Runtime classpath of source set 'main'.+--- org.apache.commons:commons-text:1.12.0| \--- org.apache.commons:commons-lang3:3.14.0 -> 3.20.0\--- project ':lib' \--- project ':core' \--- org.apache.commons:commons-lang3:3.20.0commons-lang3 on the class path: 3.20.0The Art Of Computer Programmingart-of-computer-programmingdependencyInsight explica una elección:
Selection reasons: - By conflict resolution: between versions 3.20.0 and 3.14.0
org.apache.commons:commons-lang3:3.20.0\--- project ':core' \--- project ':lib' \--- runtimeClasspath
org.apache.commons:commons-lang3:3.14.0 -> 3.20.0\--- org.apache.commons:commons-text:1.12.0 \--- runtimeClasspathElegir la versión más alta tampoco es siempre seguro: una versión mayor puede eliminar lo que llama un consumidor más antiguo. Pero con las bibliotecas que mantienen la compatibilidad hacia atrás, como Commons Lang, ese modo de fallo es mucho más raro.
NuGet: la más baja aplicable, pero nunca una degradación silenciosa
Sección titulada «NuGet: la más baja aplicable, pero nunca una degradación silenciosa»NuGet tiene cuatro reglas: versión aplicable más baja, versiones flotantes, gana la dependencia directa y dependencias primas. La carpeta nuget reproduce la misma forma. App referencia Microsoft.Extensions.Logging 8.0.0, que necesita Microsoft.Extensions.DependencyInjection.Abstractions como mínimo en 8.0.0, y Lib → Core referencia ese paquete en 9.0.0:
Project 'App' has the following package references [net10.0]: Top-level Package Requested Resolved > Microsoft.Extensions.Logging 8.0.0 8.0.0
Transitive Package Resolved > Microsoft.Extensions.DependencyInjection 8.0.0 > Microsoft.Extensions.DependencyInjection.Abstractions 9.0.0 > Microsoft.Extensions.Logging.Abstractions 8.0.0 > Microsoft.Extensions.Options 8.0.0 > Microsoft.Extensions.Primitives 8.0.0
Microsoft.Extensions.DependencyInjection.Abstractions loaded: 9.0.0.0Los números de versión de NuGet son mínimos (>= 8.0.0), así que la regla de las dependencias primas toma la versión más baja que satisface todos los requisitos, que aquí es la más alta de los mínimos, 9.0.0. Donde Maven tomaría la más cercana, NuGet llega al mismo resultado que Gradle.
«Gana la dependencia directa» todavía puede producir el resultado de Maven: añade en App una referencia directa al paquete 8.0.0 (dotnet restore -p:Downgrade=true en el ejemplo). La diferencia es que NuGet se niega a hacerlo en silencio. NU1605 es una advertencia que el SDK de .NET trata como error:
error NU1605: Warning As Error: Detected package downgrade: Microsoft.Extensions.DependencyInjection.Abstractions from 9.0.0 to 8.0.0. Reference the package directly from the project to select a different version.error NU1605: App -> Lib -> Core -> Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.0)error NU1605: App -> Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.0)| Regla | Maven | Gradle | NuGet |
|---|---|---|---|
| Elección por defecto | la más cercana, luego la primera declarada | la más alta | la versión más baja que satisface todos los mínimos |
| Declaración directa | gana | gana solo si es la más alta | gana |
| Degradación por debajo de un requisito transitivo | silenciosa | imposible por defecto | error NU1605 |
Tomar el control
Sección titulada «Tomar el control»Fijar la versión para todo el build
Sección titulada «Fijar la versión para todo el build»<dependencyManagement> en el padre fija la versión de una biblioteca allí donde aparezca en el árbol, sea transitiva o no, sin añadirla como dependencia. El ejemplo la pone en un perfil pin para mostrar los dos estados:
<!-- mvn -Ppin: una sola versión para todo el build, como Directory.Packages.props --> <profile> <id>pin</id> <dependencyManagement> <dependencies> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.20.0</version> </dependency> </dependencies> </dependencyManagement> </profile>com.example.books:app:jar:1.0-SNAPSHOT+- org.apache.commons:commons-text:jar:1.12.0:compile| \- org.apache.commons:commons-lang3:jar:3.20.0:compile (version managed from 3.14.0)\- com.example.books:lib:jar:1.0-SNAPSHOT:compile \- com.example.books:core:jar:1.0-SNAPSHOT:compile \- (org.apache.commons:commons-lang3:jar:3.20.0:compile - version managed from 3.20.0; omitted for duplicate)commons-lang3 on the class path: 3.20.0The Art Of Computer Programmingart-of-computer-programmingEs Central Package Management con la fijación transitiva activada. En .NET, Directory.Packages.props solo fija los paquetes transitivos cuando CentralPackageTransitivePinningEnabled es true. En Maven, las versiones gestionadas se aplican siempre a las dependencias transitivas.
En un proyecto real, el <dependencyManagement> del padre no está en un perfil. Los perfiles son los bloques condicionales de Maven, el equivalente de un Condition de MSBuild, y aquí solo sirven para mantener los dos estados en un mismo ejemplo.
Un BOM (bill of materials, lista de materiales) es un POM que solo contiene <dependencyManagement>, publicado para que otros builds puedan importarlo. El propio pom.xml de este curso importa el BOM de JUnit, y por eso su dependencia de JUnit no lleva versión:
<dependencyManagement> <dependencies> <dependency> <groupId>org.junit</groupId> <artifactId>junit-bom</artifactId> <version>${junit.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>El ámbito import solo existe dentro de <dependencyManagement>: copia las versiones gestionadas del BOM en este POM. El spring-boot-starter-parent de Spring Boot, en el ejemplo del curso de WSL, va más lejos: es un padre cuyo propio padre es el BOM de Spring Boot, spring-boot-dependencies, y además configura plugins. Gradle importa los mismos BOM con testImplementation(platform("org.junit:junit-bom:6.1.3")). NuGet no tiene el concepto de BOM: un Directory.Packages.props se queda en el repositorio que lo usa.
Hacer fallar el build
Sección titulada «Hacer fallar el build»Fijar una versión arregla un conflicto que conoces. La regla requireUpperBoundDeps del enforcer encuentra los que no conoces: falla cuando una dependencia se resuelve a una versión más baja que la que pidió algo del árbol. Es lo más parecido a NU1605:
[ERROR] Require upper bound dependencies error for org.apache.commons:commons-lang3:3.14.0. Paths to dependency are:[ERROR] +-com.example.books:app:1.0-SNAPSHOT[ERROR] +-org.apache.commons:commons-text:1.12.0[ERROR] +-org.apache.commons:commons-lang3:3.14.0[ERROR] and[ERROR] +-com.example.books:app:1.0-SNAPSHOT[ERROR] +-com.example.books:lib:1.0-SNAPSHOT[ERROR] +-com.example.books:core:1.0-SNAPSHOT[ERROR] +-org.apache.commons:commons-lang3:3.20.0[ERROR] ]Con el perfil pin además (-Ppin,enforce), la regla pasa.
Wrappers y lock files
Sección titulada «Wrappers y lock files»El wrapper fija la versión de la herramienta de build, igual que global.json fija la del SDK. mvn wrapper:wrapper genera mvnw, mvnw.cmd y un archivo de propiedades, que hay que versionar todos:
wrapperVersion=3.3.4distributionType=only-scriptdistributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zipgradle wrapper genera gradlew, gradlew.bat y gradle/wrapper/gradle-wrapper.jar junto al archivo de propiedades. El diario describe un repositorio donde solo se versionó el archivo de propiedades, lo que deja el wrapper inutilizable.
Un lock file (archivo de bloqueo) registra las versiones exactas que produjo una resolución, para que el siguiente build obtenga las mismas. NuGet escribe packages.lock.json cuando RestorePackagesWithLockFile es true; Gradle escribe un gradle.lockfile por proyecto cuando se define dependencyLocking { lockAllConfigurations() } y un build se ejecuta con --write-locks. Maven no tiene ninguno: su resolución es determinista para un POM dado, salvo que el POM use rangos de versiones o dependencias -SNAPSHOT, lo que es un motivo más para evitar ambos en las versiones publicadas.
Puntos clave
Sección titulada «Puntos clave»- Un POM padre de Maven es a la vez el
.slnyDirectory.Build.props; Gradle los separa ensettings.gradle.ktsy plugins de convenciones. - El
implementationde Gradle oculta una dependencia a los compiladores de los consumidores; Maven y NuGet dejan que las dependencias transitivas se filtren en la compilación.mvn dependency:analyzeencuentra esas filtraciones. - Ante un conflicto de versiones, Maven toma la versión más cercana, Gradle la más alta, y NuGet la más baja que satisface todos los mínimos, con un error ante una degradación. La regla de Maven puede poner en silencio una biblioteca más antigua en el class path, y el programa solo falla en tiempo de ejecución.
mvn dependency:tree -Dverbose,gradle dependenciesydependencyInsightmuestran qué ganó y por qué.- Fija las versiones con
<dependencyManagement>y BOM, o con un catálogo de versiones y plataformas; haz que los conflictos rompan el build con el enforcer o confailOnVersionConflict(). - Versiona el wrapper. Gradle y NuGet pueden bloquear las versiones resueltas; Maven se apoya en versiones exactas.
Ejercicios
Sección titulada «Ejercicios»- Haz que el build de Maven funcione sin el perfil
piny sin tocarcorenilib.
Solución
Declara Commons Lang en el propio app. Una dependencia directa está a profundidad 1, la más cercana posible. El ejemplo guarda esta solución en un perfil direct de app/pom.xml:
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.20.0</version> </dependency>com.example.books:app:jar:1.0-SNAPSHOT+- org.apache.commons:commons-text:jar:1.12.0:compile| \- (org.apache.commons:commons-lang3:jar:3.14.0:compile - omitted for conflict with 3.20.0)+- com.example.books:lib:jar:1.0-SNAPSHOT:compile| \- com.example.books:core:jar:1.0-SNAPSHOT:compile| \- (org.apache.commons:commons-lang3:jar:3.20.0:compile - omitted for duplicate)\- org.apache.commons:commons-lang3:jar:3.20.0:compilecommons-lang3 on the class path: 3.20.0The Art Of Computer Programmingart-of-computer-programmingFunciona, y además corrige la advertencia de dependencia no declarada, ya que app usa StringUtils. El inconveniente: cada aplicación que use lib debe conocer el requisito de core y repetirlo. Una versión gestionada en un padre o un BOM compartidos escala mejor.
- Haz que el build de Gradle falle ante este conflicto, como hace el enforcer, en lugar de elegir en silencio la versión más alta.
Solución
// Ejercicio 2: ./gradlew -PfailOnConflict falla en lugar de elegir la versión más altaif (providers.gradleProperty("failOnConflict").isPresent) { configurations.all { resolutionStrategy.failOnVersionConflict() }}* What went wrong:Execution failed for task ':app:run' (registered by plugin 'org.gradle.application').> Could not resolve all files for configuration ':app:runtimeClasspath'. > Could not resolve org.apache.commons:commons-lang3:3.14.0. Required by: project ':app' > org.apache.commons:commons-text:1.12.0 > Conflict found for module 'org.apache.commons:commons-lang3': between versions 3.20.0 and 3.14.0> There is 1 more failure with an identical cause.failOnVersionConflict() se aplica a cada configuración en la que se define. compileJava sigue funcionando, porque el class path de compilación de app solo contiene la 3.14.0: core y su Commons Lang son dependencias implementation de lib, así que el conflicto solo existe en el class path de ejecución. Esa es la diferencia práctica con el enforcer, que comprueba el árbol entero.
- Reproduce en Gradle el fallo de Maven: fuerza Commons Lang 3.14.0 desde
app. ¿Qué muestra el árbol, y qué haría NuGet con la misma petición?
Solución
// Ejercicio 3: ./gradlew -PstrictLang3 fuerza la versión antigua, como hizo la regla de la más cercana de Mavenif (providers.gradleProperty("strictLang3").isPresent) { dependencies { implementation(libs.commons.lang3) { version { strictly("3.14.0") } } }}runtimeClasspath - Runtime classpath of source set 'main'.+--- org.apache.commons:commons-text:1.12.0| \--- org.apache.commons:commons-lang3:3.14.0+--- project ':lib'| \--- project ':core'| \--- org.apache.commons:commons-lang3:3.20.0 -> 3.14.0\--- org.apache.commons:commons-lang3:{strictly 3.14.0} -> 3.14.0commons-lang3 on the class path: 3.14.0The Art Of Computer ProgrammingException in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/lang3/StringsUn simple implementation("…:3.14.0") perdería frente a 3.20.0: en Gradle, una declaración directa es un candidato más, no una imposición. strictly es la forma de degradar, y el árbol lo marca como {strictly 3.14.0} para que quien lo lea vea que fue deliberado. NuGet, ante una referencia directa a 8.0.0 por debajo de un requisito transitivo de 9.0.0, se detiene con NU1605. De las tres herramientas, solo Maven hace la degradación sin que se lo pidan y sin avisar.
Fuentes
Sección titulada «Fuentes»- Maven: introducción al mecanismo de dependencias, builds multimódulo, perfiles,
requireUpperBoundDeps, Maven Wrapper - Gradle: resolución del grafo, el plugin Java Library, builds multiproyecto, compartir la lógica de build, bloqueo de dependencias
- NuGet: resolución de dependencias, Central Package Management, PackageReference en los archivos de proyecto, NU1605