Command Palette

Search for a command to run...

[Advanced Java] Maven và Gradle: Quản lý Dependency và Build Project Java

Suốt 30 bài của series này, mọi ví dụ đều được compile theo đúng một cách: javac với một chuỗi -cp gõ tay, jar tải về từng file một qua trình duyệt, và một dòng java -cp cứ dài thêm mỗi lần ví dụ cần thêm một library. Cách đó ổn với một file. Nó hết ổn ngay khi library bạn dùng lại cần đến một library khác.

Build tool sinh ra để chấm dứt chuyện đó. Nó resolve dependency và cả dependency của dependency, áp một layout project mà mọi Java developer đều quen, compile, chạy test, rồi đóng gói kết quả — từ một file khai báo duy nhất, trên bất kỳ máy nào, theo đúng một cách.

Chuỗi javac classpath gõ tay bị gạch bỏ, thay bằng pom.xml và build.gradle

Toàn bộ nội dung dưới đây chạy trên OpenJDK 21.0.6 (arm64) với Apache Maven 3.9.9Gradle 8.10.2, trên cùng một source tree. Các transcript là output thật, chỉ sửa hai chỗ: bỏ ANSI colour code, và bỏ những dòng thời gian mà bản thân mỗi tool tự in ra (Total time, BUILD SUCCESSFUL in ...). Những con số đó chỉ đo một máy dưới một mức tải cụ thể nên vô nghĩa với bạn, vì vậy bài này không so sánh tốc độ hai tool.

Vì sao cần một build tool

Lấy một chương trình dùng hai library. App.java gọi StringUtils của Apache Commons Lang và WordUtils của Apache Commons Text:

Java
package com.example;
 
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.text.WordUtils;
 
public class App {
 
    public static String headline(String raw) {
        String trimmed = StringUtils.normalizeSpace(raw);
        return WordUtils.capitalizeFully(trimmed);
    }
 
    public static String lang3Version() {
        Package p = StringUtils.class.getPackage();
        return p == null ? "unknown" : String.valueOf(p.getImplementationVersion());
    }
 
    public static void main(String[] args) {
        System.out.println(headline("  the   build   tool   does  this  for  you "));
        System.out.println("commons-lang3 on the classpath: " + lang3Version());
    }
}

Làm tay nghĩa là: tìm hai jar, phát hiện ra Commons Text còn cần Commons Lang, chọn một version, tải về, rồi giữ chuỗi -cp đồng bộ mãi mãi. Bốn vấn đề nảy ra từ đó, và build tool giải quyết từng cái:

Vấn đềBuild tool làm gì
Transitive dependencyBạn khai Commons Text, tool kéo về những gì Commons Text cần
Build lặp lại đượcCùng một khai báo cho ra cùng bộ jar trên mọi máy
Layout chuẩnSource, test và resource nằm đúng chỗ mọi tool đều mong đợi
Chạy testTest framework được wire sẵn vào build, không phải gọi tay

Cái thứ hai hay bị xem nhẹ nhất. javac -cp chỉ lặp lại được nếu tất cả mọi người gõ đúng cùng một chuỗi.

Layout chuẩn của một project

Cả hai tool dùng chung một convention, nên bạn đọc được cấu trúc một project Java trước cả khi mở file build:

Text
src/main/java/com/example/App.java
src/main/resources/app.properties
src/test/java/com/example/AppTest.java

src/main/java được compile ra output chính, src/test/java ra một output test riêng và không bao giờ được đóng gói, còn src/main/resources được copy nguyên vẹn lên classpath. Chính điều cuối khiến việc đọc một file config chạy giống hệt nhau trong IDE và bên trong jar.

Không tool nào bắt buộc layout này — cả hai đều cho phép đổi — nhưng đổi nó là bạn mất trực giác của mọi người đọc code và chẳng được lại gì.

Maven: pom.xml và coordinates

File build của Maven là pom.xml, và ý tưởng trung tâm của nó là coordinates: mọi artifact trên đời đều được định danh bằng groupId, artifactIdversion. Project của bạn cũng có coordinates.

XML
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
 
  <groupId>com.example</groupId>
  <artifactId>build-tools-demo</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>
 
  <properties>
    <maven.compiler.release>21</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <junit.version>5.11.3</junit.version>
  </properties>
 
  <dependencies>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-lang3</artifactId>
      <version>3.9</version>
    </dependency>
 
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-text</artifactId>
      <version>1.10.0</version>
    </dependency>
 
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>${junit.version}</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
 
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.13.0</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.2</version>
      </plugin>
    </plugins>
  </build>
</project>

Có ba chỗ đáng gọi tên.

properties chỉ là phép thay chuỗi, tham chiếu bằng ${junit.version}. Khai một version một lần rồi dùng lại ở nhiều dependency chính là lý do chúng tồn tại. Một số tên property còn được plugin đọc trực tiếp: maven.compiler.release là thứ compiler plugin dùng cho --release, và output khi compile xác nhận điều đó:

Text
[INFO] --- compiler:3.13.0:compile (default-compile) @ build-tools-demo ---
[INFO] Compiling 1 source file with javac [debug release 21] to target/classes

Cách viết tường minh tương đương là config thẳng vào plugin, và cho ra đúng dòng [debug release 21] đó:

XML
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <version>3.13.0</version>
  <configuration>
    <release>21</release>
  </configuration>
</plugin>

project.build.sourceEncoding trông như boilerplate nhưng không phải. Bỏ nó đi thì Maven nhắc ở mọi lần build:

Text
[WARNING] File encoding has not been set, using platform encoding UTF-8, i.e. build is platform dependent!

Cuối cùng, việc pin version cho plugin không phải là kỷ luật tuỳ chọn — nó là khác biệt giữa một build năm sau vẫn chạy y hệt và một build âm thầm đổi hành vi khi plugin ra bản mới.

Gradle: build.gradle và task graph

File build của Gradle là build.gradle viết bằng Groovy DSL, hoặc build.gradle.kts bằng Kotlin. Toàn bộ bài này dùng Groovy DSL, vì đó là thứ bạn gặp nhiều nhất trong các project Java có sẵn; Kotlin DSL diễn đạt cùng model nhưng IDE gợi ý tốt hơn.

Groovy
plugins {
    id 'java'
    id 'application'
}
 
group = 'com.example'
version = '1.0.0'
 
java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}
 
repositories {
    mavenCentral()
}
 
dependencies {
    implementation 'org.apache.commons:commons-lang3:3.9'
    implementation 'org.apache.commons:commons-text:1.10.0'
 
    testImplementation 'org.junit.jupiter:junit-jupiter:5.11.3'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
 
application {
    mainClass = 'com.example.App'
}
 
test {
    useJUnitPlatform()
}

Bốn block gánh cả file. plugins áp hành vi vào project — java là thứ tạo ra compileJava, test, jar và phần còn lại; application thêm task run cùng các start script. repositories nói lấy artifact từ đâu, và khác Maven, Gradle không có repository mặc định — xoá mavenCentral() đi là build dừng lại với Cannot resolve external dependency org.apache.commons:commons-lang3:3.9 because no repositories are defined. dependencies dùng đúng coordinates như Maven, viết gọn thành một chuỗi ngăn bằng dấu hai chấm. Còn java { toolchain { ... } } là câu trả lời của Gradle cho release: nó chọn hẳn JDK chứ không chỉ chọn target level.

Bên cạnh đó project cần settings.gradle để đặt tên cho build:

Groovy
rootProject.name = 'build-tools-demo'

Những task mà plugin java tạo ra không phải một danh sách cố định phải chạy tuần tự — chúng là một graph, và gradle build chỉ chọn một node trong đó. Khác biệt này có hệ quả thật, và nó có riêng một phần bên dưới.

Dependency resolution và version conflict

Đây là phần trả giá cho cả bài viết. Hai file build ở trên đều khai đúng hai library. Cả hai đều kết thúc với ba jar trên classpath, vì Commons Text phụ thuộc Commons Lang. Đó là một dependency transitive, và bạn luôn xem được toàn cảnh:

Bash
mvn dependency:tree
Text
[INFO] com.example:build-tools-demo:jar:1.0.0
[INFO] +- org.apache.commons:commons-lang3:jar:3.9:compile
[INFO] +- org.apache.commons:commons-text:jar:1.10.0:compile
[INFO] \- org.junit.jupiter:junit-jupiter:jar:5.11.3:test
[INFO]    +- org.junit.jupiter:junit-jupiter-api:jar:5.11.3:test
[INFO]    |  +- org.opentest4j:opentest4j:jar:1.3.0:test
[INFO]    |  +- org.junit.platform:junit-platform-commons:jar:1.11.3:test
[INFO]    |  \- org.apiguardian:apiguardian-api:jar:1.1.2:test
[INFO]    +- org.junit.jupiter:junit-jupiter-params:jar:5.11.3:test
[INFO]    \- org.junit.jupiter:junit-jupiter-engine:jar:5.11.3:test
[INFO]       \- org.junit.platform:junit-platform-engine:jar:1.11.3:test

Hai dependency khai báo, mười một artifact. Dựng lại đúng chuỗi đó bằng javac -cp gõ tay chính là thứ bạn đang bỏ tiền ra để khỏi phải làm.

Scope và configuration

Không phải dependency nào cũng thuộc về mọi classpath. JUnit phải có mặt khi compile và chạy test, và tuyệt đối không được ship. Maven gọi đó là scope; Gradle gọi là configuration.

Maven scopeGradle configurationCó lúc compileCó lúc runtimeĐược ship
compile (mặc định)implementation
testtestImplementationchỉ testchỉ testkhông
providedcompileOnlykhôngkhông
runtimeruntimeOnlykhông

Bảng đó kiểm chứng được. Thêm compileOnly 'org.jspecify:jspecify:1.0.0' vào build Gradle rồi hỏi ba configuration khác nhau sẽ cho ba câu trả lời khác nhau:

Bash
gradle dependencies --configuration compileClasspath
gradle dependencies --configuration runtimeClasspath
gradle dependencies --configuration testRuntimeClasspath
Text
compileClasspath
+--- org.jspecify:jspecify:1.0.0
+--- org.apache.commons:commons-lang3:3.9 -> 3.12.0
\--- org.apache.commons:commons-text:1.10.0
 
runtimeClasspath
+--- org.apache.commons:commons-lang3:3.9 -> 3.12.0
\--- org.apache.commons:commons-text:1.10.0
 
testRuntimeClasspath
+--- org.apache.commons:commons-lang3:3.9 -> 3.12.0
+--- org.apache.commons:commons-text:1.10.0
+--- org.junit.jupiter:junit-jupiter:5.11.3
\--- org.junit.platform:junit-platform-launcher -> 1.11.3

Library annotation biến mất ở runtime, còn JUnit chỉ xuất hiện trong classpath của test. Đó là ba lần resolve riêng biệt, không phải một danh sách gắn cờ.

Khi hai version đụng nhau

Nhìn lại mũi tên trong output Gradle ở trên: commons-lang3:3.9 -> 3.12.0. File build xin 3.9. Gradle đặt 3.12.0 lên classpath.

Đây là một conflict thật, và dựng lại nó rất dễ. Commons Text 1.10.0 phụ thuộc Commons Lang 3.12.0. Khai thẳng Commons Lang 3.9 là graph lập tức yêu cầu hai version của cùng một module: 3.9 ở depth 1, 3.12.0 ở depth 2.

Một dependency graph có version conflict, Maven resolve ra 3.9 còn Gradle ra 3.12.0

Maven chọn khai báo gần nhất. Cờ -Dverbose cho thấy nó đã bỏ cái gì:

Bash
mvn dependency:tree -Dverbose -Dincludes=org.apache.commons
Text
[INFO] com.example:build-tools-demo:jar:1.0.0
[INFO] +- org.apache.commons:commons-lang3:jar:3.9:compile
[INFO] \- org.apache.commons:commons-text:jar:1.10.0:compile
[INFO]    \- (org.apache.commons:commons-lang3:jar:3.12.0:compile - omitted for conflict with 3.9)

Gradle chọn version cao nhất. Cùng hai khai báo, kết quả ngược lại:

Text
runtimeClasspath - Runtime classpath of source set 'main'.
+--- org.apache.commons:commons-lang3:3.9 -> 3.12.0
\--- org.apache.commons:commons-text:1.10.0
     \--- org.apache.commons:commons-lang3:3.12.0

Không tool nào cảnh báo. Không tool nào fail. Và bạn không cần tin suông — chương trình demo in ra version đã resolve ngay lúc chạy bằng cách đọc Implementation-Version từ manifest của jar, và cùng một class test nói hai điều khác nhau dưới hai tool:

Text
mvn test     ->  resolved commons-lang3 = 3.9
gradle test  ->  resolved commons-lang3 = 3.12.0

Hai file build với khai báo y hệt, hai jar khác nhau, và một test nhìn thấy điều đó. Khi một project được chuyển qua lại giữa hai tool rồi hỏng mà không rõ nguyên nhân, đây thường là lý do.

⚠️ Luật của Maven là gần nhất, không phải mới nhất. Ở cùng độ sâu nó lấy cái được khai trước trong POM, nghĩa là đổi chỗ hai block dependency có thể âm thầm đổi thứ bạn ship.

Exclude và pin version

Hai nút vặn, và nên biết chính xác từng cái làm gì.

Exclude cắt bỏ một cạnh transitive. Bên Maven:

XML
<dependency>
  <groupId>org.apache.commons</groupId>
  <artifactId>commons-text</artifactId>
  <version>1.10.0</version>
  <exclusions>
    <exclusion>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-lang3</artifactId>
    </exclusion>
  </exclusions>
</dependency>

Nhánh đó biến mất khỏi cây, và nếu không còn nguồn nào cung cấp các class kia thì build fail ngay lúc compile — một kiểu fail to và rõ, đúng như mong muốn:

Text
[ERROR] .../App.java:[3,32] package org.apache.commons.lang3 does not exist
[ERROR] .../App.java:[9,26] cannot find symbol

Pin giữ nguyên cạnh và ghim version. dependencyManagement của Maven ấn định version cho một module ở mọi nơi nó xuất hiện trong graph, ở bất kỳ độ sâu nào — lý do thường gặp là một bản vá bạn cần áp khắp nơi:

XML
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-lang3</artifactId>
      <version>3.14.0</version>
    </dependency>
  </dependencies>
</dependencyManagement>

Với Commons Text là dependency duy nhất được khai, cây báo lại chỗ đã bị ghi đè:

Text
[INFO] com.example:build-tools-demo:jar:1.0.0
[INFO] \- org.apache.commons:commons-text:jar:1.10.0:compile
[INFO]    \- org.apache.commons:commons-lang3:jar:3.14.0:compile (version managed from 3.12.0)

Tương đương bên Gradle là strict version, và vì mặc định của Gradle là chọn cao nhất nên đây cũng là cách duy nhất để ép version xuống:

Groovy
dependencies {
    implementation('org.apache.commons:commons-lang3') {
        version { strictly '3.9' }
    }
    implementation 'org.apache.commons:commons-text:1.10.0'
}
Text
runtimeClasspath - Runtime classpath of source set 'main'.
+--- org.apache.commons:commons-lang3:{strictly 3.9} -> 3.9
\--- org.apache.commons:commons-text:1.10.0
     \--- org.apache.commons:commons-lang3:3.12.0 -> 3.9

Yêu cầu transitive cho 3.12.0 giờ bị hạ xuống 3.9 thay vì thắng. Gradle exclude bằng một block gắn vào dependency:

Groovy
implementation('org.apache.commons:commons-text:1.10.0') {
    exclude group: 'org.apache.commons', module: 'commons-lang3'
}

Hãy ưu tiên pin trước. Exclude nói "không ai được dùng cái này"; pin nói "tất cả dùng đúng bản này", và vế thứ hai gần như luôn là ý bạn thật sự muốn.

Lifecycle và task graph

Hai tool bất đồng ngay ở chỗ build là cái gì, và đây là nơi điều đó lộ ra.

Chuỗi phase cộng dồn của Maven đặt cạnh task graph của Gradle

Maven: một phase chạy hết mọi phase phía trước

Lifecycle mặc định của Maven là một danh sách phase cố định và có thứ tự: validate, compile, test, package, verify, install, deploy. Bạn không bao giờ gọi tên plugin goal — bạn gọi một phase, và Maven chạy phase đó cùng mọi phase đứng trước nó.

Đó là một khẳng định đo được. Đếm số plugin goal mà mỗi lệnh thực sự chạy trên POM ở trên — chỉ có compiler và Surefire, chưa thêm plugin nào khác:

LệnhSố plugin goal chạyCái gì đã chạy
mvn validate0mặc định không có gì gắn vào validate
mvn compile2resources, compile
mvn test5như trên, cộng testResources, testCompile, test
mvn package6như trên, cộng jar
mvn verify6verify ở đây cũng không có gì gắn vào
mvn install7như trên, cộng install

Các con số chỉ có tăng. mvn package in ra các goal theo thứ tự, chính là danh sách đó đọc từ trên xuống:

Text
[INFO] --- resources:3.3.1:resources (default-resources) @ build-tools-demo ---
[INFO] Copying 1 resource from src/main/resources to target/classes
[INFO] --- compiler:3.13.0:compile (default-compile) @ build-tools-demo ---
[INFO] Compiling 1 source file with javac [debug release 21] to target/classes
[INFO] --- resources:3.3.1:testResources (default-testResources) @ build-tools-demo ---
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ build-tools-demo ---
[INFO] Compiling 1 source file with javac [debug release 21] to target/test-classes
[INFO] --- surefire:3.5.2:test (default-test) @ build-tools-demo ---
[INFO] --- jar:3.4.1:jar (default-jar) @ build-tools-demo ---
[INFO] Building jar: .../target/build-tools-demo-1.0.0.jar

mvn install thêm đúng một goal vào đó và ghi artifact vào local repository, để một project khác trên cùng máy có thể phụ thuộc vào nó qua coordinates:

Text
[INFO] --- install:3.1.2:install (default-install) @ build-tools-demo ---
[INFO] Installing .../build-tools-demo-1.0.0.jar to ~/.m2/repository/com/example/build-tools-demo/1.0.0/build-tools-demo-1.0.0.jar

deploy là phase duy nhất không chạy ở đây: nó publish lên một remote repository và cần cấu hình sẵn, thứ mà một project demo không nên đụng vào.

Cũng để ý clean không nằm trong danh sách trên. Nó thuộc một lifecycle riêng, nên mvn clean package mới phải gọi tên hai thứ.

Gradle: chỉ chạy đúng thứ task cần

Gradle không có chuỗi phase. Plugin java đăng ký các task, mỗi task khai nó phụ thuộc vào task nào, và gọi một task sẽ chạy các dependency của nó trước. gradle build --dry-run in ra thứ tự đã resolve mà không thực thi gì:

Text
:compileJava SKIPPED
:processResources SKIPPED
:classes SKIPPED
:jar SKIPPED
:startScripts SKIPPED
:distTar SKIPPED
:distZip SKIPPED
:assemble SKIPPED
:compileTestJava SKIPPED
:processTestResources SKIPPED
:testClasses SKIPPED
:test SKIPPED
:check SKIPPED
:build SKIPPED

startScripts, distTardistZip có mặt vì plugin application móc chúng vào assemble. Đó chính là khác biệt thực dụng: chuỗi của Maven là cố định và plugin gắn goal vào trong nó, còn graph của Gradle mọc thêm một nhánh mới và build tự động kéo theo.

Hệ quả thứ hai là công việc incremental. Gradle theo dõi input và output của từng task, nên build lại lần nữa sẽ không chạy lại gì. So gradle build lần đầu với lần thứ hai ngay sau đó, không sửa gì:

Text
> Task :compileJava
> Task :processResources
> Task :classes
> Task :jar
...
> Task :build
 
8 actionable tasks: 8 executed
Text
> Task :compileJava UP-TO-DATE
> Task :processResources UP-TO-DATE
> Task :classes UP-TO-DATE
> Task :jar UP-TO-DATE
> Task :assemble UP-TO-DATE
> Task :compileTestJava UP-TO-DATE
> Task :test UP-TO-DATE
> Task :check UP-TO-DATE
> Task :build UP-TO-DATE
 
8 actionable tasks: 8 up-to-date

Hãy đọc dòng cuối chứ đừng nhìn đồng hồ: 8 executed thành 8 up-to-date, và mọi task đều mang dấu UP-TO-DATE. Sửa một file source thì chỉ những task nằm sau nó mất dấu đó. NO-SOURCE xuất hiện khi task hoàn toàn không có gì để làm — processTestResources báo vậy ở đây, vì src/test/resources không tồn tại.

Chạy test từ build

Một bài trước trong series này đã chạy JUnit 5 qua console launcher, tự ghép các jar của platform vào một chuỗi -cp. Đóng góp của build tool là toàn bộ phần wiring đó thu lại thành một dependency.

Maven cần Jupiter ở scope test và một bản Surefire đủ mới; Surefire tự nhận ra platform:

XML
<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter</artifactId>
  <version>5.11.3</version>
  <scope>test</scope>
</dependency>

mvn test compile test rồi chạy chúng, ở đây đã bỏ phần thời gian trong dòng kết quả từng class:

Text
[INFO] --- surefire:3.5.2:test (default-test) @ build-tools-demo ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.example.AppTest
resolved commons-lang3 = 3.9
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 -- in com.example.AppTest
[INFO] Results:
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0

Gradle cần dependency cộng thêm một dòng nói cho task test biết dùng engine nào, vì task test trên JVM vẫn mặc định theo kiểu JUnit 4:

Groovy
dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.11.3'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
 
test {
    useJUnitPlatform()
    testLogging {
        showStandardStreams = true
    }
}

Quên useJUnitPlatform() thì build vẫn thành công trong khi chạy đúng không test nào. Gradle 8.10.2 có nhận ra, nhưng rất khẽ: phần tổng kết mặc định chỉ nói Deprecated Gradle features were used in this build, phải chạy --warning-mode all mới thấy câu thật — There are test sources present but no test was executed. Cũng không có file kết quả XML nào được ghi ra. Gradle 9.0 sẽ biến chuyện này thành lỗi; còn đến lúc đó thì đây là một build xanh mà chẳng test gì. Có dòng đó rồi, gradle build báo:

Text
> Task :test
 
AppTest > readsSomeLang3Version() STANDARD_OUT
    resolved commons-lang3 = 3.12.0
 
> Task :check
> Task :build

Cả hai tool đều ghi kết quả ở dạng máy đọc được và đều để chúng dưới thư mục output của build:

ToolKết quả XMLReport cho người đọc
Maventarget/surefire-reports/TEST-com.example.AppTest.xmltarget/surefire-reports/com.example.AppTest.txt
Gradlebuild/test-results/test/TEST-com.example.AppTest.xmlbuild/reports/tests/test/index.html

File XML dùng đúng format JUnit mà các CI server đọc được, và đó là cách một build server biến một test fail thành report mà không cần biết gì về project của bạn.

Đóng gói một artifact chạy được

mvn package đã tạo ra một jar. Nó không chạy:

Bash
java -jar target/build-tools-demo-1.0.0.jar
Text
no main manifest attribute, in target/build-tools-demo-1.0.0.jar

Một jar là file zip kèm manifest, và manifest mặc định không chỉ ra entry point nào:

Text
Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.4.1
Build-Jdk-Spec: 21

Thêm nó vào qua jar plugin:

XML
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-jar-plugin</artifactId>
  <version>3.4.1</version>
  <configuration>
    <archive>
      <manifest>
        <mainClass>com.example.App</mainClass>
      </manifest>
    </archive>
  </configuration>
</plugin>

Giờ nó khởi động được, và fail ngay vì lý do thứ hai — jar chỉ chứa class của bạn chứ không chứa gì khác:

Text
Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/lang3/StringUtils
	at com.example.App.headline(App.java:9)
	at com.example.App.main(App.java:19)
Caused by: java.lang.ClassNotFoundException: org.apache.commons.lang3.StringUtils

java -jar bỏ qua hoàn toàn -cp, nên dependency buộc phải nằm bên trong archive. Đó chính là jar dạng shaded, hay còn gọi là "fat jar". Bên Maven là maven-shade-plugin gắn vào package:

XML
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-shade-plugin</artifactId>
  <version>3.6.0</version>
  <executions>
    <execution>
      <phase>package</phase>
      <goals>
        <goal>shade</goal>
      </goals>
      <configuration>
        <transformers>
          <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
            <mainClass>com.example.App</mainClass>
          </transformer>
        </transformers>
      </configuration>
    </execution>
  </executions>
</plugin>
Text
[INFO] --- shade:3.6.0:shade (default) @ build-tools-demo ---
[INFO] Including org.apache.commons:commons-lang3:jar:3.9 in the shaded jar.
[INFO] Including org.apache.commons:commons-text:jar:1.10.0 in the shaded jar.
[INFO] Replacing original artifact with shaded artifact.

Gradle đạt kết quả đó bằng cách config chính task jar có sẵn, không cần plugin nào thêm:

Groovy
jar {
    manifest {
        attributes 'Main-Class': 'com.example.App'
    }
    duplicatesStrategy = DuplicatesStrategy.EXCLUDE
    from {
        configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
    }
}

Cả hai cách đều chạy được. Cả hai đều cho ra một jar khởi động thành công:

Text
The Build Tool Does This For You
commons-lang3 on the classpath: null

Chữ null đó vừa trung thực vừa đáng học. Giải nén mọi dependency vào chung một archive sẽ trộn manifest của chúng lại, nên Implementation-Version mà chương trình đang đọc biến mất. Fat jar tiện chứ không miễn phí — duplicatesStrategy tồn tại đúng vì nhóm xung đột này, và dependency có chữ ký số có thể hỏng hẳn. Với một library, hãy publish jar thường và để build tool của người dùng tự resolve dependency; chỉ dùng fat jar khi bạn ship một application mà người ta chạy bằng java -jar.

Maven hay Gradle?

Không cái nào là câu trả lời. Chúng khác nhau trên đúng một trục, và mọi khác biệt còn lại đều đi ra từ đó.

pom.xml được đọc như dữ liệu, còn build.gradle được chạy như một chương trình

pom.xml là một document. Maven đọc nó, gắn các plugin goal vào các phase của lifecycle, rồi chạy các phase. Không dòng nào trong file tự quyết định được gì, và chính vì thế mọi project Maven đều dễ đọc với bất kỳ ai biết Maven.

build.gradle là một chương trình. Gradle chạy toàn bộ script trong configuration phase — tạo và config mọi task, kể cả những task bạn không hề gọi — rồi mới chạy subgraph được yêu cầu. Đó là lý do block jar ở trên gọi được collectzipTree: nó là code thật.

MavenGradle
File builddocument XMLchương trình Groovy hoặc Kotlin
Model thực thilifecycle phase cố địnhtask graph
Xử lý conflictkhai báo gần nhất thắngversion cao nhất thắng
Repositorymặc định có Centralphải khai báo
Build incrementalcompile lại theo moduletheo dõi input/output từng task
Mở rộngviết hoặc config pluginviết plugin, hoặc viết code luôn
Khi hỏngdài dòng nhưng đoán đượclinh hoạt, và khó lần ra hơn

Khi nào nên chọn cái nào

Chọn Maven khi project là một application hay library Java thông thường, khi team đông hoặc thay người liên tục, hoặc khi bạn cố tình muốn build thật nhàm chán. Trần của nó có thật nhưng phần lớn project không bao giờ chạm tới, và một người lạ vẫn đọc được POM.

Chọn Gradle khi bản thân build có yêu cầu khó — nhiều module, sinh code, đóng gói tuỳ biến, Android, hoặc một repository nhiều ngôn ngữ — hoặc khi hành vi incremental thực sự quan trọng vì project đã lớn.

Lời cảnh báo trung thực cũng chạy theo chiều ngược lại. Sức mạnh của Gradle nằm ở chỗ file build chứa được logic tuỳ ý; cái bẫy của nó cũng đúng là câu đó. Một build.gradle có thể rẽ nhánh theo hostname, đọc giờ hệ thống, hoặc sửa config của một task khác từ cách đó sáu dòng, và khi build chạy khác nhau trên từng máy thì không có file khai báo nào để đọc — chỉ có một chương trình để debug. Đừng để logic trong file build trừ khi nó thật sự xứng đáng.

Wrapper: mvnw và gradlew

Một build chỉ chạy được nếu ai cũng cài đúng version của tool thì không phải build lặp lại được. Cả hai project đều kèm theo một wrapper: một script nhỏ, commit vào repository, tự tải về và chạy đúng version tool đã ghim.

Bash
mvn wrapper:wrapper -Dmaven=3.9.9
gradle wrapper --gradle-version 8.10.2

Maven ghi ra ba file, và file properties là nơi giữ version:

Text
mvnw
mvnw.cmd
.mvn/wrapper/maven-wrapper.properties
Text
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip

Gradle ghi ra bốn file, trong đó có một jar nhỏ:

Text
gradlew
gradlew.bat
gradle/wrapper/gradle-wrapper.jar
gradle/wrapper/gradle-wrapper.properties
Text
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true

Commit hết tất cả, kể cả file jar. Từ đó trở đi project được build bằng ./mvnw package hoặc ./gradlew build, và mọi developer, mọi CI runner, mọi lần checkout sau này đều dùng đúng version mà repository quy định — không có bước cài đặt, không lệch version. Chạy ./mvnw -v ở đây báo về 3.9.9, đúng con số file properties đã ghim.

Nâng version tool khi đó thành một commit một dòng trong file properties và review được, đúng như nó nên vậy.

FAQ

Maven và Gradle khác nhau thế nào?

Maven build từ một document XML dựa trên một lifecycle phase cố định (validate, compile, test, package, verify, install, deploy), trong đó chạy một phase là chạy hết mọi phase trước nó. Gradle chạy một script Groovy hoặc Kotlin để dựng nên task graph, rồi chỉ chạy những task mà task được gọi phụ thuộc vào, bỏ qua những task đã UP-TO-DATE. Maven đánh đổi linh hoạt lấy dễ đọc; Gradle đánh đổi dễ đọc lấy tầm với. Hai tool còn resolve version conflict khác nhau, và trong thực tế điều đó quan trọng hơn cú pháp nhiều.

Vì sao dependency của tôi lại ra một version tôi không hề khai?

Vì một transitive dependency xin version khác và tool đã chọn ra kẻ thắng. Maven lấy khai báo gần nhất trong graph — dependency trực tiếp của bạn thắng mọi thứ nằm sâu hơn, và ở cùng độ sâu thì cái khai trước thắng. Gradle lấy version cao nhất mà bất kỳ ai yêu cầu. Cùng hai khai báo đó cho ra 3.9 dưới Maven và 3.12.0 dưới Gradle trong bài này. Hãy chạy mvn dependency:tree -Dverbose hoặc gradle dependencies --configuration runtimeClasspath trước khi đoán.

Transitive dependency là gì?

Là dependency của một dependency. Khai Commons Text thì Commons Lang cũng được kéo về, vì Commons Text cần nó. Trong project demo, hai dependency khai báo resolve ra mười một artifact. Cả hai tool đều tự kéo hết, và đó là lý do chính để dùng build tool.

Scope provided hay compileOnly nghĩa là gì?

Dependency có mặt trên classpath lúc compile nhưng không có lúc runtime, và không bao giờ được đóng gói. Dùng nó cho thứ mà môi trường sẽ cung cấp — một servlet API do container đưa vào, hoặc một library annotation mà sau khi compile không cần nữa. Thêm compileOnly 'org.jspecify:jspecify:1.0.0' sẽ đưa nó vào compileClasspath và không đụng gì tới runtimeClasspath.

Vì sao java -jar báo NoClassDefFoundError?

Vì một jar thường chỉ chứa class của bạn, còn java -jar thì bỏ qua -cp. Hai lối ra là Class-Path trong manifest, hoặc một fat jar. Gói kèm dependency bằng maven-shade-plugin, hoặc bên Gradle bằng cách thêm configurations.runtimeClasspath vào task jar. Nhớ rằng trộn archive cũng trộn manifest, nên các attribute manifest riêng của từng dependency sẽ mất.

Có nên commit mvnw và gradlew lên git không?

Có — cả script, file properties lẫn gradle-wrapper.jar. Wrapper chính là thứ khiến build không phụ thuộc vào version tool cài trên máy, nên commit nó là toàn bộ mục đích. Một lần checkout phải build được bằng ./gradlew build và không cần gì thêm.

Làm sao ép một version cụ thể cho transitive dependency?

Bên Maven, khai nó trong dependencyManagement; cây dependency sau đó sẽ báo (version managed from ...) ở chỗ đã áp dụng. Bên Gradle, dùng strict version — version { strictly '3.9' } — và đó cũng là cách duy nhất để ép version xuống, vì mặc định Gradle luôn ưu tiên cao nhất. Hãy ưu tiên pin hơn exclude: exclude xoá hẳn các class đi và build sẽ fail lúc compile nếu vẫn còn chỗ nào cần đến chúng.

Kết luận

Đó là điểm kết của Part 6 trong series này. Mạch công cụ mà nó bao phủ — viết test với JUnit 5, cô lập collaborator bằng Mockito, tạo ra log dùng được, và giờ là resolve dependency cùng build từ một file khai báo duy nhất — chính là thứ ngăn cách code chạy được trên máy bạn với code mà cả team build, test và ship được.

Ý duy nhất đáng mang theo: build tool là thứ quyết định jar nào nằm trên classpath của bạn, và luật quyết định đó không giống nhau giữa hai tool. Hãy đọc cây dependency trước khi ngồi debug triệu chứng.

Part 7 mở đầu bằng kiến trúc phân lớp — Controller, Service và Repository — và việc tách một application theo các lớp đó thay đổi chỗ đứng của từng phần logic ra sao.

Bài viết liên quan

[Advanced Java] ExecutorService và Thread Pool trong Java

ExecutorService và thread pool trên OpenJDK 21: bảy argument của ThreadPoolExecutor, vì sao queue không giới hạn khiến maximumPoolSize vô dụng, default bị giấu sau mỗi factory của Executors, submit khác execute và exception mà submit nuốt mất, Callable, Future, cancel, invokeAll và invokeAny, trình tự shutdown đúng, và cách chọn số thread một cách trung thực.

[Advanced Java] Queue, Deque, Stack và PriorityQueue trong Java

Queue, Deque, Stack và PriorityQueue trên OpenJDK 21: hai họ method của Queue và chính xác từng cái làm gì khi queue rỗng hoặc đầy, bảng method đầy đủ của Deque cùng cách dùng như stack, vì sao Stack extends Vector là một sai lầm thiết kế với cả hai bất ngờ được chứng minh, và bằng chứng rằng PriorityQueue là một binary heap mà toString cùng iterator đều không theo thứ tự priority.

[Advanced Java] Best Practice, Tối Ưu Hiệu Năng và Chuẩn Bị Phỏng Vấn Java

Best practice Java chứng minh bằng code chạy thật trên OpenJDK 21: sáu performance trap đo bằng allocation byte, số lần gọi equals() và số câu SQL thay vì millisecond, vì sao vòng lặp nanoTime nói dối, checklist code review gắn với đúng defect nó bắt được, và các câu phỏng vấn trả lời bằng transcript thật.

[Advanced Java] Buffered Stream và Object Serialization trong Java

java.io nâng cao trên OpenJDK 21: bốn abstract root của stream và đúng chỗ charset được chọn, decorator chain và vì sao thứ tự bọc quan trọng, buffer đo bằng số lần gọi method chứ không đo mili giây, DataOutputStream với layout big-endian, và object serialization từ đầu tới cuối — format byte thật, transient, serialVersionUID, writeObject, Externalizable, object graph, và ObjectInputFilter tồn tại vì format này không an toàn.