Bạn đã có một project chạy được và trả lời được một request. Bài này nói về phần còn lại: mười hai file mà generator vừa ghi ra, mỗi file để làm gì, và build biến chúng thành cái gì. Đó chính là ranh giới giữa "bấm nút tam giác xanh thì nó chạy" và việc tự sửa được một build hỏng.
Mọi thứ bên dưới đều lấy từ terminal: project được sinh hai lần — một bản Gradle, một bản Maven — cả hai đều được build thật, file jar được unzip ra xem, và các lỗi đều được dựng lại có chủ đích. Không có cây thư mục, manifest, dependency report hay thông báo lỗi nào được viết theo trí nhớ.
![]()
Toolchain xuyên suốt là Spring Boot 4.1.1 (kéo theo Spring Framework 7.0.9 và embedded Tomcat 11.0.24) trên OpenJDK 21.0.6 arm64, với Gradle 9.7.1 và Maven 3.9.16 — cả hai đều do wrapper trong project cung cấp, vì máy này không cài gradle lẫn mvn. Series này dùng Gradle làm build tool chính; Maven xuất hiện ở đây để bạn đọc được một pom.xml khi gặp, và bạn sẽ gặp.
Một project vừa sinh ra gồm những gì
Sinh hai bản chỉ là hai lệnh curl. Khác nhau duy nhất trong query string là type:
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web" -o g.zip
curl -s "https://start.spring.io/starter.zip?type=maven-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web" -o m.zipBản Gradle giải nén ra đúng thế này — output thật của tree -a --dirsfirst, không cắt bớt dòng nào:
.
├── gradle
│ └── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── src
│ ├── main
│ │ ├── java
│ │ │ └── com
│ │ │ └── example
│ │ │ └── demo
│ │ │ └── DemoApplication.java
│ │ └── resources
│ │ ├── static
│ │ ├── templates
│ │ └── application.properties
│ └── test
│ └── java
│ └── com
│ └── example
│ └── demo
│ └── DemoApplicationTests.java
├── .gitattributes
├── .gitignore
├── HELP.md
├── build.gradle
├── gradlew
├── gradlew.bat
└── settings.gradle
17 directories, 12 filesBản Maven là cùng cây thư mục đó với bốn file bị thay: build.gradle và settings.gradle thành pom.xml, còn gradle/wrapper/ cùng gradlew/gradlew.bat thành .mvn/wrapper/maven-wrapper.properties cùng mvnw/mvnw.cmd. Mọi thứ dưới src/ giống nhau từng byte. Đó mới là điều đáng nhớ: đổi build tool là đổi bốn file, không hơn.

| Đường dẫn | Chứa gì |
|---|---|
src/main/java/ | Toàn bộ Java production. Package của class mang @SpringBootApplication trở thành scan root cho component scan — quan trọng hơn vẻ ngoài của nó rất nhiều. |
src/main/resources/ | Mọi thứ không phải Java nhưng đi kèm application: application.properties, script SQL, message bundle. Được chép nguyên vẹn vào file jar. |
src/main/resources/static/ | File phục vụ nguyên trạng ở URL root. File static/app.css truy cập được tại /app.css. |
src/main/resources/templates/ | View render phía server cho template engine (Thymeleaf, FreeMarker). Rỗng và vô dụng cho tới khi bạn thêm một engine. |
src/test/java/ | Class test. Compile bằng classpath riêng và không bao giờ được đóng gói vào jar của application. |
src/test/resources/ | Không được sinh sẵn, nhưng hãy tạo ngay khi test cần application.properties riêng. |
build.gradle / pom.xml | Plugin, toạ độ artifact, Java toolchain, dependency, cách đóng gói. |
settings.gradle | Đúng một dòng rootProject.name = 'demo', quyết định tên file jar. Maven không có file tương đương; artifactId làm việc đó. |
gradle/wrapper/ | gradle-wrapper.jar cộng file .properties ghim version Gradle. Commit cả hai. |
gradlew, gradlew.bat | Script wrapper. Commit cả hai; bản .bat là thứ đồng nghiệp dùng Windows sẽ chạy. |
.gitattributes | Ba dòng ép gradlew dùng LF, *.bat dùng CRLF và *.jar là binary. Xoá nó là wrapper hỏng trên các máy Windows. |
.gitignore | Bỏ qua build/, .gradle/, target/ và các thư mục IDE. Chú ý dòng phủ định !gradle/wrapper/gradle-wrapper.jar — file jar của wrapper cố tình không bị bỏ qua. |
HELP.md | Chỉ là link tài liệu được sinh ra. Nó nằm trong .gitignore, nên xoá thoải mái. |
Những gì tuyệt đối không để trong src/main/resources
Mọi file dưới src/main/resources đều được chép vào file jar, mà jar là một file zip: ai có file đó đều mở được. Đây không phải rủi ro lý thuyết — chỉ một lệnh:
unzip -p build/libs/demo-0.0.1-SNAPSHOT.jar BOOT-INF/classes/application.propertiesspring.application.name=demoVậy nên: không password database, không API key, không certificate riêng, không chép file .env vào "tạm thôi mà". Những thứ đó đến từ environment variable hoặc secret store lúc runtime. Cũng đừng để build output, thứ gì được sinh tự động (nó sẽ cũ đi và bị commit nhầm), binary lớn làm phình mỗi lần deploy, và fixture cho test — fixture thuộc về src/test/resources, nơi không bị đóng gói.
Vì sao gradlew và mvnw phải được commit
Wrapper là file bị xem nhẹ nhất trong project. Nó là một script nhỏ cộng một file properties ghi đúng version build tool; lần chạy đầu nó tải version đó về và cache lại, rồi chạy build bằng chính version đó. Nhờ vậy mọi developer và mọi máy CI đều build bằng cùng một Gradle, bất kể máy đó cài gì.
Bằng chứng là chính máy này. Không có build tool nào được cài:
command -v gradle || echo "no gradle on PATH"
command -v mvn || echo "no mvn on PATH"no gradle on PATH
no mvn on PATHVậy mà:
./gradlew --version------------------------------------------------------------
Gradle 9.7.1
------------------------------------------------------------
Build time: 2026-08-19 14:16:09 UTC
Revision: 92f0512e7f06d84621afba191f75e265363890cf
Kotlin: 2.4.0
Groovy: 4.0.32
Ant: Apache Ant(TM) version 1.10.17 compiled on April 6 2026
Launcher JVM: 21.0.6 (Homebrew 21.0.6)
Daemon JVM: /opt/homebrew/Cellar/openjdk@21/21.0.6/libexec/openjdk.jdk/Contents/Home
OS: Mac OS X 26.4.1 aarch64Version đó đến từ một dòng trong gradle/wrapper/gradle-wrapper.properties:
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zipWrapper của Maven hoạt động y hệt, đọc từ .mvn/wrapper/maven-wrapper.properties:
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip./mvnw --versionApache Maven 3.9.16 (2bdd9fddda4b155ebf8000e807eb73fd829a51d5)
Maven home: /Users/hoangth/.m2/wrapper/dists/apache-maven-3.9.16/56ba1f9f
Java version: 21.0.6, vendor: Homebrew, runtime: /opt/homebrew/Cellar/openjdk@21/21.0.6/libexec/openjdk.jdk/Contents/HomeBa nguyên tắc rút ra. Luôn gọi ./gradlew hoặc ./mvnw, đừng gọi gradle/mvn toàn cục, nếu không thì mất luôn cái đảm bảo vừa nói. Commit cả thư mục wrapper kể cả gradle-wrapper.jar, đó là lý do .gitignore có dòng phủ định kia. Và khi nâng version, chạy ./gradlew wrapper --gradle-version 9.8 thay vì sửa tay URL, để script và dòng checksum được sinh lại cùng nhau.
Đọc build.gradle từng dòng
Đây là toàn bộ file được sinh ra. Hai mươi tám dòng, và dòng nào cũng có việc:
plugins {
id 'java'
id 'org.springframework.boot' version '4.1.1'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}id 'java' là Gradle Java plugin. Nó tạo ra hai source set main và test — đó mới là lý do compiler chỉ nhìn vào src/main/java chứ không nơi nào khác — cùng các task compileJava, processResources, classes, test, jar, assemble, build, và extension java { } được dùng ngay hai block bên dưới.
id 'org.springframework.boot' version '4.1.1' thêm các task riêng của Boot. ./gradlew tasks liệt kê chúng:
bootBuildImage - Builds an OCI image of the application using the output of the bootJar task
bootJar - Assembles an executable jar archive containing the main classes and their dependencies.
bootRun - Runs this project as a Spring Boot application.
resolveMainClassName - Resolves the name of the application's main class.resolveMainClassName là cái ít ai ngờ tới: plugin quét class đã compile để tìm public static void main rồi ghi kết quả vào manifest của file jar, nên bạn không bao giờ phải khai báo main class bằng tay.
id 'io.spring.dependency-management' version '1.1.7' là thứ cho phép block dependencies bỏ trống version. Nó import BOM spring-boot-dependencies và áp version trong đó cho mọi dependency được khai báo. Bỏ plugin này đi thì build chết ngay ở dòng dependency đầu tiên không có version.
group và version trở thành toạ độ Maven và, cùng với rootProject.name, thành tên file artifact: demo-0.0.1-SNAPSHOT.jar.
Block toolchain không đồng nghĩa với "JDK tôi đang chạy". Bản thân Gradle chạy trên JVM nào khởi động nó cũng được; toolchain khai báo rằng project này được compile và test bằng Java 21, và Gradle sẽ tìm — hoặc tải về — một JDK khớp. Đây là thứ giúp build chạy đúng trên máy có java mặc định là 17 hay 25.
repositories { mavenCentral() } là nơi artifact được resolve. Lưu ý nó không áp dụng cho block plugins, vốn resolve từ Gradle Plugin Portal thông qua settings.gradle.
implementation, runtimeOnly, compileOnly và những cái còn lại
Dependency được khai báo vào một configuration, và configuration quyết định nó nằm trên classpath nào. Project một dependency chỉ cho thấy hai trong số đó, nên đây là một file phong phú hơn — vẫn do Initializr sinh, chỉ thêm JPA, PostgreSQL, Lombok, DevTools và configuration processor:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'org.postgresql:postgresql'
annotationProcessor 'org.projectlombok:lombok'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testCompileOnly 'org.projectlombok:lombok'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
testAnnotationProcessor 'org.projectlombok:lombok'
}| Configuration | Trên compile classpath | Trên runtime classpath | Vào file jar | Dùng khi nào |
|---|---|---|---|---|
implementation | có | có | có | Library thông thường. Lựa chọn mặc định. |
runtimeOnly | không | có | có | JDBC driver, logging backend — cần lúc chạy, không bao giờ import. |
compileOnly | có | không | không | Annotation bị xử lý hết lúc compile, như Lombok. |
annotationProcessor | không (processor path) | không | không | Bộ sinh code do javac chạy. |
developmentOnly | không | có, chỉ ở máy bạn | không | DevTools. Bị loại khỏi bootJar nên không thể lọt lên production. |
testImplementation | chỉ test | chỉ test | không | JUnit, AssertJ, test starter của Boot. |
testRuntimeOnly | không | chỉ test | không | junit-platform-launcher. |
Hai dòng đáng để ý. runtimeOnly cho JDBC driver là đúng và có chủ đích: code của bạn nên nói chuyện với java.sql, không bao giờ với org.postgresql, và runtimeOnly bắt compiler ép đúng điều đó. Còn developmentOnly là một cơ chế an toàn thật sự — DevTools restart application mỗi khi class đổi, rất tuyệt ở máy local và thảm hoạ trên production, nên plugin của Boot loại nó khỏi file jar đóng gói.
Vì sao phải có useJUnitPlatform()
Task test của Gradle đến giờ vẫn mặc định là JUnit 4. Test JUnit 5 chạy trên JUnit Platform, nên thiếu đúng một dòng đó thì Gradle compile test xong, không tìm thấy test JUnit 4 nào, và fail. Xoá block đi rồi chạy ./gradlew test cho ra đúng thế này:
> Task :compileTestJava
> Task :test FAILED
> There are test sources present and no filters are applied, but the test task did not
discover any tests to execute. This is likely due to a misconfiguration. Please check
your test configuration.Class test compile được mà số test tìm thấy bằng không thì gần như luôn là lỗi này, hoặc thiếu junit-platform-launcher ở testRuntimeOnly.
Cũng project đó viết bằng pom.xml
Đây là bản Maven của đúng project trên. File được sinh ra còn có thêm mấy block rỗng licenses, developers và scm làm chỗ giữ chỗ, chúng không làm gì cả và xoá được; phần còn lại nguyên văn ở dưới:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo</name>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>spring-boot-starter-parent làm ba việc cùng lúc, và cách kiểm chứng là mở nó ra trong local repository. Nó kế thừa từ spring-boot-dependencies, tức là BOM — chỗ các version number bị thiếu được lấp vào. Nó đặt giá trị mặc định:
<properties>
<java.version>17</java.version>
<resource.delimiter>@</resource.delimiter>
<maven.compiler.release>${java.version}</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>Nên <java.version>21</java.version> trong properties của bạn không phải một key thần kỳ của Spring — nó ghi đè giá trị mặc định 17 của parent rồi chảy vào maven.compiler.release. Và nó ghim version cùng cấu hình hợp lý cho các plugin phổ biến, nên maven-compiler-plugin và maven-surefire-plugin cũng không cần <version>.
spring-boot-maven-plugin đóng góp goal repackage, gắn vào phase package. Đó là bước biến file jar bình thường Maven vừa tạo thành file jar chạy được — và là lý do ./mvnw package để lại hai file trong target/, sẽ nói ở phần đóng gói.
Không có gì tương đương settings.gradle: tên file jar đến từ artifactId và version.
Đối chiếu configuration của Gradle và scope của Maven
Hai mô hình không tương ứng một-một, và chỗ lệch nhau chính là nơi người ta dịch sai giữa hai build file:
| Configuration của Gradle | Tương đương bên Maven | Ghi chú |
|---|---|---|
implementation | <scope>compile</scope> (mặc định) | Maven cho dependency lọt sang module phía dưới; implementation của Gradle thì không. api mới là configuration làm việc đó. |
runtimeOnly | <scope>runtime</scope> | Khớp trực tiếp. |
compileOnly | <scope>provided</scope> hoặc <optional>true</optional> | Initializr dùng <optional>true</optional> cho Lombok. |
annotationProcessor | annotationProcessorPaths của maven-compiler-plugin | Maven không có scope cho việc này; nó là cấu hình plugin. |
developmentOnly | <scope>runtime</scope> + <optional>true</optional> | Cùng hiệu ứng với DevTools: có ở local, bị loại khỏi jar sau repackage. |
testImplementation | <scope>test</scope> | Khớp trực tiếp. |
testRuntimeOnly | <scope>test</scope> | Maven không tách hai cái này. |
Nên chọn Maven hay Gradle cho project Spring Boot?
Cả hai đều là công dân hạng nhất trong Spring Boot: Initializr, tài liệu tham khảo và hệ sinh thái plugin đều hỗ trợ cả hai, và không tính năng Spring nào thiếu ở một bên. Nên đây là lựa chọn về cách làm việc, không phải về việc bạn build được gì.
| Maven | Gradle | |
|---|---|---|
| Build file | XML, khai báo, 54 dòng được sinh ra cho app nhỏ | DSL Groovy hoặc Kotlin, 28 dòng cho cùng app đó |
| Mô hình | Lifecycle cố định (validate → compile → test → package → install → deploy). Project nào cũng build theo một kiểu | Một task graph mở rộng được. Project nào cũng có thể build theo kiểu riêng |
| Build tăng dần | Chạy lại các phase; maven-compiler-plugin bỏ qua class không đổi, phần lớn plugin khác thì không | Kiểm tra up-to-date theo từng task dựa trên hash nội dung input và output |
| Đo trên máy này (build lại, không đổi gì) | 1.45–1.65 s | 0.32–0.35 s, 7 actionable tasks: 7 up-to-date |
| Build cache | Không có sẵn | org.gradle.caching=true tái dùng output của task qua các lần clean build và qua nhiều máy |
| JVM còn nóng | Mỗi lần gọi là một JVM mới (mvnd là một project daemon riêng) | Một daemon giữ ấm giữa các lần build theo mặc định |
| Hỗ trợ IDE | Xuất sắc và gần như tuyệt đối; file POM cực dễ cho tool phân tích | Xuất sắc trong IntelliJ; DSL Kotlin có gợi ý code, DSL Groovy thì kém hơn |
| Độ khó ban đầu | Thấp. Mọi thứ là một element trong schema | Cao hơn. Build script là một chương trình, và có thể viết tệ |
| Hệ sinh thái | Kho plugin lớn hơn và nhiều câu trả lời copy được hơn hẳn | Nhỏ hơn, nhưng plugin phổ biến nào cũng có |
| Thắng ở đâu | Project một module, team muốn build nhàm chán và giống hệt nhau | Build nhiều module, codebase lớn, mọi nơi mà thời gian build là chi phí hằng ngày |
Nói thẳng: với một service một module build hết hai giây thì khác biệt chỉ là sai số, và sự cứng nhắc của Maven lại là ưu điểm thật — không ai bịa ra được một quy trình build riêng. Các con số phía trên đo trên máy này, với project có đúng một dependency; hãy xem chúng là hình dạng của khác biệt chứ không phải benchmark. Lợi thế của Gradle lớn dần theo kích thước codebase, vì up-to-date check và build cache tỉ lệ thuận với lượng việc chúng giúp bỏ qua. Rủi ro của Gradle cũng lớn dần theo quy mô team, vì build script là code và code build thiếu kỷ luật thì mục như mọi loại code khác. Series này dùng Gradle, và có một kết quả cache đáng xem: bật org.gradle.caching=true rồi clean build lần hai sẽ báo 7 actionable tasks: 4 executed, 3 from cache.
Vì sao dependency không có version number?
Vì một BOM — "bill of materials" — đã khai báo giúp. spring-boot-dependencies liệt kê version đã được kiểm thử cho 652 artifact trong Boot 4.1.1 — mọi library mà Spring Boot tích hợp, và cả hai build tool đều kéo nó vào: Maven qua spring-boot-starter-parent, Gradle qua io.spring.dependency-management. Bạn viết toạ độ; BOM điền version; và mọi library trong tập đó đều là version mà bản Boot này thực sự đã test cùng.
Muốn biết cái gì được resolve, hãy hỏi build tool. Gradle:
./gradlew dependencies --configuration compileClasspathcompileClasspath - Compile classpath for source set 'main'.
\--- org.springframework.boot:spring-boot-starter-webmvc -> 4.1.1
+--- org.springframework.boot:spring-boot-starter:4.1.1
| +--- org.springframework.boot:spring-boot-starter-logging:4.1.1
| | +--- ch.qos.logback:logback-classic:1.5.38
| | | +--- ch.qos.logback:logback-core:1.5.38
| | | \--- org.slf4j:slf4j-api:2.0.17 -> 2.0.18
| +--- org.springframework.boot:spring-boot-autoconfigure:4.1.1
| | \--- org.springframework.boot:spring-boot:4.1.1
| | +--- org.springframework:spring-core:7.0.9Có hai loại mũi tên cần đọc. spring-boot-starter-webmvc -> 4.1.1 là BOM điền version bạn đã bỏ trống. slf4j-api:2.0.17 -> 2.0.18 là conflict resolution: hai nhánh trong graph đòi hai version khác nhau và Gradle chọn bản mới hơn.
Maven in ra cùng graph đó kèm scope:
./mvnw dependency:tree[INFO] com.example:demo: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: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] | +- org.springframework.boot:spring-boot-starter-tomcat:jar:4.1.1:compile
[INFO] | | +- org.springframework.boot:spring-boot-starter-tomcat-runtime:jar:4.1.1:compile
[INFO] | | | +- org.apache.tomcat.embed:tomcat-embed-core:jar:11.0.24:compile
[INFO] | \- org.springframework.boot:spring-boot-webmvc:jar:4.1.1:compile
[INFO] | \- org.springframework:spring-webmvc:jar:7.0.9:compile
[INFO] \- org.springframework.boot:spring-boot-starter-webmvc-test:jar:4.1.1:testĐây cũng là chỗ nhìn đầu tiên khi một security scanner cảnh báo về library transitive: cây dependency cho biết dependency trực tiếp nào đã kéo nó vào.
Ghi đè version do BOM quản lý
Đôi khi bạn cần một version mà BOM không chọn — thường là bản vá CVE ra sau bản Boot. Cả hai tool đều ghi đè theo tên property, dùng đúng property mà BOM khai báo.
Gradle, đặt trước dòng group:
ext['snakeyaml.version'] = '2.4'\--- org.yaml:snakeyaml:2.6 -> 2.4Maven, trong properties:
<properties>
<java.version>21</java.version>
<snakeyaml.version>2.4</snakeyaml.version>
</properties>[INFO] | | \- org.yaml:snakeyaml:jar:2.4:compile⚠️ Kéo lệch một library ra khỏi tập đã được test cùng nhau chính là cách để nhận
NoSuchMethodErrorlúc runtime thay vì lúc compile. Chỉ làm khi có lý do cụ thể, ghi lý do đó thành comment ngay cạnh property, và gỡ bỏ ở lần nâng Boot kế tiếp.
Tách @SpringBootApplication ra thành gì
Nó không phải một annotation được framework đối xử thần kỳ. Nó là annotation tổ hợp, và javap -v trên file class bên trong spring-boot-autoconfigure-4.1.1.jar in ra chính xác nó gồm những gì:
RuntimeVisibleAnnotations:
4: org.springframework.boot.SpringBootConfiguration
5: org.springframework.boot.autoconfigure.EnableAutoConfiguration
6: org.springframework.context.annotation.ComponentScan(
excludeFilters=[TypeExcludeFilter, AutoConfigurationExcludeFilter]
)
@SpringBootConfiguration bản thân nó là @Configuration cộng @Indexed — cũng chiêu javap đó xác nhận. Nó đánh dấu class là config class chính của application, và ý nghĩa nằm ở phần test: @SpringBootTest tìm config bằng cách đi ngược lên cây package từ class test cho tới khi gặp một @SpringBootConfiguration. Log build của Maven nói thẳng ra:
Found @SpringBootConfiguration com.example.demo.DemoApplication for test class com.example.demo.DemoApplicationTestsĐó cũng là lý do một test nằm ở package phía trên main class sẽ fail với "Unable to find a @SpringBootConfiguration": không còn gì ở trên để đi ngược lên nữa.
@EnableAutoConfiguration là công tắc bật auto-configuration: nó import các configuration class ứng viên do mọi jar trên classpath đăng ký rồi giữ lại cái nào thoả điều kiện. Bài này không đi xa hơn thế; báo cáo điều kiện là chủ đề riêng.
@ComponentScan mới là cái sẽ cắn bạn. Nó quét các class được annotate như component rồi đăng ký những gì tìm thấy — và hai exclude filter ở trên là thứ ngăn nó vơ nhầm config dùng cho test và các class auto-configuration. Quét ở đâu mới là phần quan trọng, và nó xứng đáng một mục riêng.
Luật base package, chứng minh bằng một lỗi 404
@ComponentScan không có argument sẽ quét package của class được annotate và mọi package nằm bên dưới. Không quét package cha, không quét package ngang hàng. Nên đặt controller vào một package ngang hàng là nó không bao giờ được đăng ký.
Bối cảnh như sau. DemoApplication nằm ở com.example.demo đúng như được sinh ra, còn controller đặt cạnh nó thay vì bên dưới nó:
src/main/java
└── com
└── example
├── demo
│ └── DemoApplication.java
└── other
└── HelloController.javapackage com.example.other;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello from com.example.other";
}
}Application khởi động hoàn hảo. Không lỗi, không warning, không có gì trong log gợi ý là đang có vấn đề:
o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'
com.example.demo.DemoApplication : Started DemoApplication in 0.429 secondsRồi thì:
curl -i http://localhost:8080/helloHTTP/1.1 404
Content-Type: application/json
{"timestamp":"2026-09-11T03:10:19.532Z","status":404,"error":"Not Found","path":"/hello"}Đây là câu hỏi "Spring Boot bỏ qua controller của tôi" phổ biến nhất, và chính sự im lặng làm nó khó: một class không được scan thì không phải lỗi, nó chỉ là một class bình thường mà không ai cần tới.
Cách sửa là chuyển controller xuống dưới package của main class — com.example.demo.web — và không đổi gì khác:
src/main/java
└── com
└── example
└── demo
├── DemoApplication.java
└── web
└── HelloController.javacurl -i http://localhost:8080/helloHTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 31
Hello from com.example.demo.webNếu thật sự không di chuyển được class — thường là một library dùng chung có package root riêng — thì scanBasePackages là lối thoát, và nó thay thế mặc định chứ không cộng thêm, nên nhớ liệt kê cả package của bạn:
@SpringBootApplication(scanBasePackages = { "com.example.demo", "com.example.other" })
public class DemoApplication { /* ... */ }HTTP/1.1 200
Content-Length: 28
Hello from com.example.otherDùng dè chừng thôi. Chín mươi phần trăm trường hợp, câu trả lời đúng là main class nên nằm ở gốc cây package của bạn và mọi thứ khác nằm bên dưới — đúng y cấu trúc Initializr đưa cho bạn.
Vì sao main class không được nằm ở default package
Cũng luật đó nhưng ở trường hợp bệnh lý. Nếu DemoApplication.java không có dòng package, nó nằm ở default package, "package của nó và mọi thứ bên dưới" trở thành toàn bộ classpath, và Spring bắt đầu quét mọi class trong mọi jar. Boot đoán trước được và cảnh báo:
WARN ionWarningsApplicationContextInitializer :
** WARNING ** : Your ApplicationContext is unlikely to start due to a @ComponentScan of the default package.Cảnh báo đó chính xác. Nửa giây sau, trên Boot 4.1.1, lần quét vơ trúng class của framework vốn cũng được đăng ký bằng method @Bean và context từ chối khởi động:
***************************
APPLICATION FAILED TO START
***************************
Description:
The bean 'errorPageRegistrarBeanPostProcessor', defined in class path resource
[org/springframework/boot/web/servlet/support/ErrorPageFilterConfiguration.class], could not
be registered. A bean with that name has already been defined and overriding is disabled.Đừng "sửa" bằng spring.main.allow-bean-definition-overriding=true, vì như thế chỉ là che đi việc đang quét cả classpath. Hãy cho class một package.
SpringApplication.run trả về cái gì
SpringApplication.run không phải void. javap xác nhận signature:
public static org.springframework.context.ConfigurableApplicationContext run(java.lang.Class<?>, java.lang.String...);Context trả về chính là application đang chạy, và giữ lại reference đó là cách nhanh nhất để hỏi xem cái gì đã thực sự được đăng ký:
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(DemoApplication.class, args);
System.out.println("beans = " + context.getBeanDefinitionCount());
System.out.println("controller = " + context.containsBean("helloController"));
context.close();
}
}beans = 146
controller = true146 bean definition từ một annotation và một dependency — đó là auto-configuration, và containsBean là cách kiểm tra nhanh nhất xem class bạn đang truy tìm có được scan hay không. Chú ý context.close() ở cuối: thiếu nó thì embedded server cứ chạy tiếp, điều bạn muốn trong application thật nhưng không muốn trong một đoạn chẩn đoán như thế này.
Đóng gói: ./gradlew build tạo ra những gì
./gradlew build
ls -la build/libs/-rw-r--r-- 1 hoangth wheel 1474 Sep 11 10:08 demo-0.0.1-SNAPSHOT-plain.jar
-rw-r--r-- 1 hoangth wheel 19902861 Sep 11 10:08 demo-0.0.1-SNAPSHOT.jarHai file jar, và chênh lệch dung lượng nói cho bạn biết cái nào là cái nào. Plain jar là sản phẩm của task jar bình thường thuộc Java plugin: class và resource của bạn, không gì khác, 1.474 byte. Executable jar là sản phẩm của bootJar: class của bạn cộng mọi dependency, 19 MB.
Plain jar không chạy được, và manifest là lý do — nó dài 25 byte và chỉ chứa một dòng version:
unzip -p build/libs/demo-0.0.1-SNAPSHOT-plain.jar META-INF/MANIFEST.MF
java -jar build/libs/demo-0.0.1-SNAPSHOT-plain.jarManifest-Version: 1.0
no main manifest attribute, in build/libs/demo-0.0.1-SNAPSHOT-plain.jarChỉ đích danh class cũng không cứu được, vì không có tí Spring nào trên classpath:
Exception in thread "main" java.lang.NoClassDefFoundError: org/springframework/boot/SpringApplication
at com.example.demo.DemoApplication.main(DemoApplication.java:10)Hãy deploy file không có -plain. Plain jar tồn tại cho trường hợp project này là một library được project khác dùng, còn nếu không phải vậy thì nó chỉ là file người ta deploy nhầm. Tắt nó đi:
tasks.named('jar') {
enabled = false
}build/libs/
└── demo-0.0.1-SNAPSHOT.jarMaven làm đúng việc đó với tên khác. ./mvnw package để lại target/demo-0.0.1-SNAPSHOT.jar (chạy được, do spring-boot-maven-plugin repackage) và target/demo-0.0.1-SNAPSHOT.jar.original (bản plain, bị đổi tên cho khuất mắt). Không cần tắt gì cả — hậu tố .original đã đủ khiến không ai deploy nhầm.
Bên trong file JAR chạy được
unzip -l build/libs/demo-0.0.1-SNAPSHOT.jar166 entry, chia làm bốn nhóm:
Length Date Time Name
--------- ---------- ----- ----
424 02-01-1980 00:00 META-INF/MANIFEST.MF
855 02-01-1980 00:00 org/springframework/boot/loader/launch/JarLauncher.class
15710 02-01-1980 00:00 org/springframework/boot/loader/jar/NestedJarFile.class
1446 02-01-1980 00:00 BOOT-INF/classpath.idx
212 02-01-1980 00:00 BOOT-INF/layers.idx
733 02-01-1980 00:00 BOOT-INF/classes/com/example/demo/DemoApplication.class
29 02-01-1980 00:00 BOOT-INF/classes/application.properties
175435 02-01-1980 00:00 BOOT-INF/lib/spring-boot-webmvc-4.1.1.jar
1107415 02-01-1980 00:00 BOOT-INF/lib/spring-webmvc-7.0.9.jar
340068 02-01-1980 00:00 BOOT-INF/lib/snakeyaml-2.6.jar
Manifest là nơi quá trình khởi chạy bắt đầu:
Manifest-Version: 1.0
Main-Class: org.springframework.boot.loader.launch.JarLauncher
Start-Class: com.example.demo.DemoApplication
Spring-Boot-Version: 4.1.1
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Spring-Boot-Layers-Index: BOOT-INF/layers.idx
Build-Jdk-Spec: 21
Implementation-Title: demo
Implementation-Version: 0.0.1-SNAPSHOTMain-Class là thứ JVM chạy, và nó không phải class của bạn — nó là JarLauncher của Spring Boot. Start-Class mới là class của bạn, được launcher đọc sau đó.
Bốn nhóm, cụ thể:
META-INF/MANIFEST.MF— manifest ở trên.org/springframework/boot/loader/— 99 file class, bộ máy launcher, do build chép vào. Không có gì của bạn ở đây.BOOT-INF/classes/— class đã compile của bạn và mọi thứ từsrc/main/resources, kể cả fileapplication.propertieskia.BOOT-INF/lib/— 34 file jar dependency, nguyên vẹn và không bị sửa.
Hai file index phục vụ cho launcher. BOOT-INF/classpath.idx liệt kê 33 trong 34 file jar theo thứ tự classpath — spring-boot-jarmode-tools cố tình vắng mặt vì nó là công cụ chứ không phải dependency. BOOT-INF/layers.idx gom entry lại để chia layer cho Docker:
- "dependencies":
- "BOOT-INF/lib/"
- "spring-boot-loader":
- "org/"
- "snapshot-dependencies":
- "application":
- "BOOT-INF/classes/"Một chi tiết đáng chú ý trong unzip -v: các jar lồng bên trong được lưu nguyên, không nén, còn class của bạn thì bị deflate:
175435 Stored 175435 0% BOOT-INF/lib/spring-boot-webmvc-4.1.1.jar
733 Defl:N 420 43% BOOT-INF/classes/com/example/demo/DemoApplication.classĐó là chủ ý. Vì mỗi jar lồng nằm trong archive như một dải byte không nén, launcher đọc thẳng entry bên trong nó được, không cần giải nén ra thư mục tạm trước.
Vì sao java -jar thường không load được jar nằm trong jar
Đây là ràng buộc mà cả thiết kế kia sinh ra để giải quyết, và nên xem nó fail một lần. Tạo hai file jar tí hon — inner.jar chứa một class, outer.jar chứa inner.jar ở lib/inner.jar cộng một class gọi vào đó:
jar --create --file lib/inner.jar -C out-inner .
jar --create --file outer.jar --main-class outer.Outer -C out-outer . -C . lib/inner.jar
unzip -l outer.jar 81 META-INF/MANIFEST.MF
463 outer/Outer.class
754 lib/inner.jarjava -jar outer.jarException in thread "main" java.lang.NoClassDefFoundError: inner/Inner
at outer.Outer.main(Outer.java:7)
Caused by: java.lang.ClassNotFoundException: inner.Inner
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188)Application class loader của JDK đọc các entry trong file jar bạn truyền cho -jar. Nó không chui xuống một entry mà bản thân entry đó lại là một file zip. Thuộc tính Class-Path trong manifest cũng không cứu được: các mục của nó được resolve theo vị trí trên filesystem của file jar, không phải theo nội dung bên trong jar.
Trong lịch sử có hai cách lách, và Spring Boot từ chối cả hai. Bạn có thể bung hết dependency rồi trộn mọi file class vào một archive phẳng — kiểu "shaded jar" — cách này làm mất danh tính từng library và làm hỏng bất kỳ hai jar nào cùng chứa một đường dẫn resource, nạn nhân kinh điển là các entry META-INF/services. Hoặc bạn giải nén ra thư mục tạm lúc khởi động, vừa chậm vừa cần một chỗ ghi được.
Câu trả lời của Spring Boot là cách thứ ba: giữ nguyên các file jar và cấp một class loader hiểu được cấu trúc đó. JarLauncher chạy trước, đọc classpath.idx, mở từng jar lồng ngay tại chỗ qua NestedJarFile — chính vì vậy chúng mới được lưu không nén — rồi dựng một class loader trên kết quả đó. Xong xuôi nó mới load Start-Class và gọi main. Hệ quả với bạn: class đang chạy main được load bởi class loader của Spring Boot chứ không phải class loader hệ thống, nên code nào giả định ClassLoader.getSystemClassLoader() hoặc giả định protocol file: cho resource của chính nó sẽ hành xử lạ khi nằm trong fat jar. Dùng getClass().getResourceAsStream(...) thì chạy đúng ở cả hai nơi.
Và xác nhận rằng class của bạn thật sự không với tới được theo cách thông thường:
java -cp build/libs/demo-0.0.1-SNAPSHOT.jar com.example.demo.DemoApplicationError: Could not find or load main class com.example.demo.DemoApplication
Caused by: java.lang.ClassNotFoundException: com.example.demo.DemoApplicationNó nằm ở BOOT-INF/classes/com/example/demo/DemoApplication.class, và chỉ JarLauncher mới biết phải tìm ở đó.
Chạy file jar và truyền tham số
java -jar build/libs/demo-0.0.1-SNAPSHOT.jarMọi thứ đứng sau tên file jar được truyền vào main(String[] args) rồi đi thẳng vào SpringApplication, nơi đọc các argument dạng --key=value như property source có độ ưu tiên cao nhất. Hai cái bạn sẽ dùng suốt:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8081 --spring.profiles.active=prodcom.example.demo.DemoApplication : Starting DemoApplication v0.0.1-SNAPSHOT using Java 21.0.6
com.example.demo.DemoApplication : The following 1 profile is active: "prod"
o.s.boot.tomcat.TomcatWebServer : Tomcat initialized with port 8081 (http)
o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 8081 (http) with context path '/'
com.example.demo.DemoApplication : Started DemoApplication in 0.521 secondsChú ý chỗ hay làm người ta vấp: --server.port=8081 đặt sau tên jar là argument của application; -Dserver.port=8081 đặt trước -jar là system property của JVM. Ở đây cả hai đều chạy, nhưng đó là hai cơ chế khác nhau với thứ tự ưu tiên khác nhau, và chỉ dạng JVM mới dùng được cho những thứ như -Xmx512m. Config và thứ tự ưu tiên có hẳn một chương riêng ở phần sau của series.
FAQ
Có nên commit gradle-wrapper.jar lên Git không?
Có. Thiếu nó thì ./gradlew không bootstrap được, và CI sẽ phải cài sẵn Gradle — đúng thứ mà wrapper sinh ra để tránh. File .gitignore được sinh ra có dòng phủ định !gradle/wrapper/gradle-wrapper.jar chính là để một rule *.jar rộng không loại nhầm nó. Nâng version bằng ./gradlew wrapper --gradle-version X thay vì sửa tay.
Sau này chuyển từ Maven sang Gradle được không?
Được, và với một service Spring Boot một module thì đó là việc nhỏ, vì src/ không đổi tí nào. Bạn dịch danh sách dependency theo bảng scope phía trên, viết lại Java version thành toolchain, và thêm hai plugin. Phần khó chịu là các plugin execution tuỳ biến gắn vào phase của Maven, chúng không có tương đương trực tiếp bên Gradle và phải viết lại thành task.
Vì sao @RestController của tôi trả về 404?
Theo thứ tự khả năng: class nằm trong package không thuộc nhánh dưới package của class @SpringBootApplication, nên không bao giờ được scan; class thiếu hẳn @RestController hoặc @Controller; path không khớp, kể cả thiếu hoặc thừa dấu gạch chéo đầu; hoặc application chưa restart sau khi sửa. Trường hợp base package phổ biến hơn hẳn và hoàn toàn không có warning nào.
demo-0.0.1-SNAPSHOT-plain.jar là gì và xoá được không?
Đó là file jar thường chỉ chứa class của bạn, hữu ích khi project được dùng như một library. Với một application để deploy thì nó là gánh nặng và là cái bẫy deploy nhầm. Tắt bằng tasks.named('jar') { enabled = false }. Artifact tương đương bên Maven là target/*.jar.original.
Có cần cài Tomcat để chạy file jar không?
Không. spring-boot-starter-webmvc mang embedded Tomcat vào sẵn — tomcat-embed-core-11.0.24.jar là một trong 34 file jar ở BOOT-INF/lib/. Máy đích chỉ cần JRE 21 trở lên. Đây chính là lý do artifact để deploy là file jar chứ không phải WAR.
Đổi tên file jar được sinh ra như thế nào?
Với Gradle, rootProject.name trong settings.gradle cho phần tên gốc và version cho phần hậu tố; đặt archiveFileName trên task bootJar nếu muốn kiểm soát hoàn toàn. Với Maven là artifactId cộng version, hoặc <finalName> trong element build. Giữ version trong tên file là việc nên làm — nhiều khi đó là cách duy nhất để biết trên server đang chạy bản nào.
Kết luận
Một project Spring Boot vừa sinh ra có mười hai file và không file nào là trang trí. src/main/java là scan root, src/main/resources đi thẳng vào file jar nên đừng để gì bí mật ở đó, wrapper là thứ khiến build lặp lại được trên máy không cài build tool, và BOM là lý do danh sách dependency của bạn không có version number. @SpringBootApplication là ba annotation khoác chung một áo, và một trong ba — @ComponentScan — lặng lẽ quyết định class nào của bạn tồn tại, nên một controller lệch sang package bên cạnh sẽ trả 404 mà không có lỗi nào trong log. ./gradlew build ghi ra hai file jar; hãy deploy file không có -plain, vì nó mang manifest trỏ tới JarLauncher và 34 jar lồng không nén mà class loader của JDK không bao giờ đọc nổi.
Bài kế tiếp mở đầu Chương 1 bằng chính ý tưởng mà cả framework dựng lên trên đó: IoC và Dependency Injection — vì sao cần container, và nó cho bạn cái gì mà việc tự new object không có.