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.
![]()
Toàn bộ nội dung dưới đây chạy trên OpenJDK 21.0.6 (arm64) với Apache Maven 3.9.9 và Gradle 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:
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 dependency | Bạn khai Commons Text, tool kéo về những gì Commons Text cần |
| Build lặp lại được | Cùng một khai báo cho ra cùng bộ jar trên mọi máy |
| Layout chuẩn | Source, test và resource nằm đúng chỗ mọi tool đều mong đợi |
| Chạy test | Test 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:
src/main/java/com/example/App.java
src/main/resources/app.properties
src/test/java/com/example/AppTest.javasrc/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, artifactId và version. Project của bạn cũng có coordinates.
<?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 đó:
[INFO] --- compiler:3.13.0:compile (default-compile) @ build-tools-demo ---
[INFO] Compiling 1 source file with javac [debug release 21] to target/classesCá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] đó:
<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:
[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.
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:
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:
mvn dependency:tree[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:testHai 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 scope | Gradle configuration | Có lúc compile | Có lúc runtime | Được ship |
|---|---|---|---|---|
compile (mặc định) | implementation | có | có | có |
test | testImplementation | chỉ test | chỉ test | không |
provided | compileOnly | có | không | không |
runtime | runtimeOnly | không | có | có |
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:
gradle dependencies --configuration compileClasspath
gradle dependencies --configuration runtimeClasspath
gradle dependencies --configuration testRuntimeClasspathcompileClasspath
+--- 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.3Library 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.

Maven chọn khai báo gần nhất. Cờ -Dverbose cho thấy nó đã bỏ cái gì:
mvn dependency:tree -Dverbose -Dincludes=org.apache.commons[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:
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.0Khô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:
mvn test -> resolved commons-lang3 = 3.9
gradle test -> resolved commons-lang3 = 3.12.0Hai 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
dependencycó 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:
<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:
[ERROR] .../App.java:[3,32] package org.apache.commons.lang3 does not exist
[ERROR] .../App.java:[9,26] cannot find symbolPin 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:
<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 đè:
[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:
dependencies {
implementation('org.apache.commons:commons-lang3') {
version { strictly '3.9' }
}
implementation 'org.apache.commons:commons-text:1.10.0'
}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.9Yê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:
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.

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ệnh | Số plugin goal chạy | Cái gì đã chạy |
|---|---|---|
mvn validate | 0 | mặc định không có gì gắn vào validate |
mvn compile | 2 | resources, compile |
mvn test | 5 | như trên, cộng testResources, testCompile, test |
mvn package | 6 | như trên, cộng jar |
mvn verify | 6 | verify ở đây cũng không có gì gắn vào |
mvn install | 7 | như 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:
[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.jarmvn 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:
[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.jardeploy 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ì:
: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 SKIPPEDstartScripts, distTar và distZip 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ì:
> Task :compileJava
> Task :processResources
> Task :classes
> Task :jar
...
> Task :build
8 actionable tasks: 8 executed> 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-dateHã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:
<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:
[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: 0Gradle 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:
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:
> Task :test
AppTest > readsSomeLang3Version() STANDARD_OUT
resolved commons-lang3 = 3.12.0
> Task :check
> Task :buildCả 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:
| Tool | Kết quả XML | Report cho người đọc |
|---|---|---|
| Maven | target/surefire-reports/TEST-com.example.AppTest.xml | target/surefire-reports/com.example.AppTest.txt |
| Gradle | build/test-results/test/TEST-com.example.AppTest.xml | build/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:
java -jar target/build-tools-demo-1.0.0.jarno main manifest attribute, in target/build-tools-demo-1.0.0.jarMột jar là file zip kèm manifest, và manifest mặc định không chỉ ra entry point nào:
Manifest-Version: 1.0
Created-By: Maven JAR Plugin 3.4.1
Build-Jdk-Spec: 21Thêm nó vào qua jar plugin:
<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:
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.StringUtilsjava -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:
<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>[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:
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:
The Build Tool Does This For You
commons-lang3 on the classpath: nullChữ 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 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 collect và zipTree: nó là code thật.
| Maven | Gradle | |
|---|---|---|
| File build | document XML | chương trình Groovy hoặc Kotlin |
| Model thực thi | lifecycle phase cố định | task graph |
| Xử lý conflict | khai báo gần nhất thắng | version cao nhất thắng |
| Repository | mặc định có Central | phải khai báo |
| Build incremental | compile lại theo module | theo dõi input/output từng task |
| Mở rộng | viết hoặc config plugin | viết plugin, hoặc viết code luôn |
| Khi hỏng | dài dòng nhưng đoán được | linh 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.
mvn wrapper:wrapper -Dmaven=3.9.9
gradle wrapper --gradle-version 8.10.2Maven ghi ra ba file, và file properties là nơi giữ version:
mvnw
mvnw.cmd
.mvn/wrapper/maven-wrapper.propertieswrapperVersion=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.zipGradle ghi ra bốn file, trong đó có một jar nhỏ:
gradlew
gradlew.bat
gradle/wrapper/gradle-wrapper.jar
gradle/wrapper/gradle-wrapper.propertiesdistributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000
validateDistributionUrl=trueCommit 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.