Command Palette

Search for a command to run...

[Spring Boot Basics] Cấu trúc project Spring Boot: Gradle và Maven, @SpringBootApplication và file JAR

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ớ.

Thư mục source và một build file trở thành một file jar chạy được

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.1Maven 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:

Bash
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.zip

Bả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:

Tree
.
├── 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 files

Bản Maven là cùng cây thư mục đó với bốn file bị thay: build.gradlesettings.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.

Cây thư mục sinh ra với chú thích mục đích của từng thư mục

Đường dẫnChứ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.xmlPlugin, 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.batScript wrapper. Commit cả hai; bản .bat là thứ đồng nghiệp dùng Windows sẽ chạy.
.gitattributesBa 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.
.gitignoreBỏ 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.mdChỉ 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:

Bash
unzip -p build/libs/demo-0.0.1-SNAPSHOT.jar BOOT-INF/classes/application.properties
Text
spring.application.name=demo

Vậ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:

Bash
command -v gradle || echo "no gradle on PATH"
command -v mvn    || echo "no mvn on PATH"
Text
no gradle on PATH
no mvn on PATH

Vậy mà:

Bash
./gradlew --version
Text
------------------------------------------------------------
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 aarch64

Version đó đến từ một dòng trong gradle/wrapper/gradle-wrapper.properties:

gradle/wrapper/gradle-wrapper.properties
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip

Wrapper của Maven hoạt động y hệt, đọc từ .mvn/wrapper/maven-wrapper.properties:

.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
Bash
./mvnw --version
Text
Apache 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/Home

Ba 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:

build.gradle
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 maintestđó 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:

Text
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.

groupversion 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:

build.gradle
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'
}
ConfigurationTrên compile classpathTrên runtime classpathVào file jarDùng khi nào
implementationLibrary thông thường. Lựa chọn mặc định.
runtimeOnlykhôngJDBC driver, logging backend — cần lúc chạy, không bao giờ import.
compileOnlykhôngkhôngAnnotation bị xử lý hết lúc compile, như Lombok.
annotationProcessorkhông (processor path)khôngkhôngBộ sinh code do javac chạy.
developmentOnlykhôngcó, chỉ ở máy bạnkhôngDevTools. Bị loại khỏi bootJar nên không thể lọt lên production.
testImplementationchỉ testchỉ testkhôngJUnit, AssertJ, test starter của Boot.
testRuntimeOnlykhôngchỉ testkhôngjunit-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:

Text
> 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-launchertestRuntimeOnly.

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, developersscm 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:

pom.xml
<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:

pom.xml
<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-pluginmaven-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ừ artifactIdversion.

Đố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 GradleTương đương bên MavenGhi 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.
annotationProcessorannotationProcessorPaths của maven-compiler-pluginMaven 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ì.

MavenGradle
Build fileXML, 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ìnhLifecycle cố định (validatecompiletestpackageinstalldeploy). Project nào cũng build theo một kiểuMột task graph mở rộng được. Project nào cũng có thể build theo kiểu riêng
Build tăng dầnChạy lại các phase; maven-compiler-plugin bỏ qua class không đổi, phần lớn plugin khác thì khôngKiể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 s0.32–0.35 s, 7 actionable tasks: 7 up-to-date
Build cacheKhông có sẵnorg.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óngMỗ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ợ IDEXuất sắc và gần như tuyệt đối; file POM cực dễ cho tool phân tíchXuất sắc trong IntelliJ; DSL Kotlin có gợi ý code, DSL Groovy thì kém hơn
Độ khó ban đầuThấp. Mọi thứ là một element trong schemaCao hơn. Build script là một chương trình, và có thể viết tệ
Hệ sinh tháiKho plugin lớn hơn và nhiều câu trả lời copy được hơn hẳnNhỏ hơn, nhưng plugin phổ biến nào cũng có
Thắng ở đâuProject một module, team muốn build nhàm chán và giống hệt nhauBuild 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:

Bash
./gradlew dependencies --configuration compileClasspath
Text
compileClasspath - 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.9

Có 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:

Bash
./mvnw dependency:tree
Text
[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:

build.gradle
ext['snakeyaml.version'] = '2.4'
Text
\--- org.yaml:snakeyaml:2.6 -> 2.4

Maven, trong properties:

pom.xml
<properties>
    <java.version>21</java.version>
    <snakeyaml.version>2.4</snakeyaml.version>
</properties>
Text
[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 NoSuchMethodError lú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ì:

Text
RuntimeVisibleAnnotations:
  4: org.springframework.boot.SpringBootConfiguration
  5: org.springframework.boot.autoconfigure.EnableAutoConfiguration
  6: org.springframework.context.annotation.ComponentScan(
      excludeFilters=[TypeExcludeFilter, AutoConfigurationExcludeFilter]
     )

@SpringBootApplication tách thành ba meta-annotation, kèm bán kính scan lan xuống từ package được annotate

@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:

Text
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ó:

Tree
src/main/java
└── com
    └── example
        ├── demo
        │   └── DemoApplication.java
        └── other
            └── HelloController.java
src/main/java/com/example/other/HelloController.java
package 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 đề:

Text
o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'
com.example.demo.DemoApplication : Started DemoApplication in 0.429 seconds

Rồi thì:

Bash
curl -i http://localhost:8080/hello
Text
HTTP/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:

Tree
src/main/java
└── com
    └── example
        └── demo
            ├── DemoApplication.java
            └── web
                └── HelloController.java
Bash
curl -i http://localhost:8080/hello
Text
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Content-Length: 31
 
Hello from com.example.demo.web

Nế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:

DemoApplication.java
@SpringBootApplication(scanBasePackages = { "com.example.demo", "com.example.other" })
public class DemoApplication { /* ... */ }
Text
HTTP/1.1 200
Content-Length: 28
 
Hello from com.example.other

Dù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:

Text
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:

Text
***************************
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:

Text
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ý:

src/main/java/com/example/demo/DemoApplication.java
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();
    }
}
Text
beans      = 146
controller = true

146 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ì

Bash
./gradlew build
ls -la build/libs/
Text
-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.jar

Hai 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:

Bash
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.jar
Text
Manifest-Version: 1.0
 
no main manifest attribute, in build/libs/demo-0.0.1-SNAPSHOT-plain.jar

Chỉ đích danh class cũng không cứu được, vì không có tí Spring nào trên classpath:

Text
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:

build.gradle
tasks.named('jar') {
	enabled = false
}
Tree
build/libs/
└── demo-0.0.1-SNAPSHOT.jar

Maven 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

Bash
unzip -l build/libs/demo-0.0.1-SNAPSHOT.jar

166 entry, chia làm bốn nhóm:

Text
  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

Cấu trúc file jar chạy được và cách JarLauncher với tới các jar lồng bên trong

Manifest là nơi quá trình khởi chạy bắt đầu:

Text
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-SNAPSHOT

Main-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ả file application.properties kia.
  • 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:

Text
- "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:

Text
  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.jarlib/inner.jar cộng một class gọi vào đó:

Bash
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
Text
       81  META-INF/MANIFEST.MF
      463  outer/Outer.class
      754  lib/inner.jar
Bash
java -jar outer.jar
Text
Exception 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:

Bash
java -cp build/libs/demo-0.0.1-SNAPSHOT.jar com.example.demo.DemoApplication
Text
Error: Could not find or load main class com.example.demo.DemoApplication
Caused by: java.lang.ClassNotFoundException: com.example.demo.DemoApplication

Nó 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ố

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar

Mọ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:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8081 --spring.profiles.active=prod
Text
com.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 seconds

Chú ý 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ó.

Bài viết liên quan

[Spring Boot Basics] Validation trong Spring Boot: Bean Validation, @Valid và custom validator

Bean Validation trong Spring Boot 4.1.1 với Hibernate Validator 9.1.3, kiểm chứng bằng các lần chạy thật: spring-boot-starter-validation, @NotNull, @NotEmpty và @NotBlank khác nhau ra sao, @Size, @DecimalMin, @Digits, @Email và @Pattern trên DTO record, @Valid với @RequestBody và response 400 mặc định, object lồng nhau và list, validate @PathVariable và @RequestParam cùng cái bẫy 500 của @Validated, validation group, ValidationMessages.properties và Accept-Language, custom ConstraintValidator và constraint liên quan nhiều field, và validation ở service layer.

[Spring Boot Basics] Tài liệu API trong Spring Boot với springdoc-openapi và Swagger UI

springdoc-openapi 3.1.1 trên Spring Boot 4.1.1, kiểm chứng trên jar đang chạy: document OpenAPI 3.1 ở /v3/api-docs, Swagger UI và Try it out, những gì springdoc suy ra từ controller, DTO record và Bean Validation constraint, response nào của @RestControllerAdvice được thêm vào, @Tag, @Operation, @ApiResponse, @Parameter và @Schema trên record, bean OpenAPI và customizer toàn cục, GroupedOpenApi, property của springdoc và tắt tài liệu trong profile prod.

[Spring Boot Basics] Logging trong Spring Boot: SLF4J, Logback, log level và ghi log ra file

Logging trong Spring Boot 4.1.1 kiểm chứng trên project thật: SLF4J là facade và Logback 1.5.38 là implementation, hai bridge jul-to-slf4j và log4j-to-slf4j, parameterised và fluent logging, ghi log exception, log level, cây logger và log group, --debug so với --trace, pattern dòng log mặc định, logging.file.name kèm rotation, logback-spring.xml với springProfile, MDC và chuyển sang Log4j2.

[Spring Boot Basics] Cài đặt Spring Boot: JDK, IDE, Spring Initializr và ứng dụng đầu tiên

Cài JDK 21 trên macOS, Windows và Linux, sửa JAVA_HOME trỏ nhầm JDK, so sánh IntelliJ IDEA với VS Code, tạo project Spring Boot 4.1.1 bằng Spring Initializr hoặc một câu lệnh curl, chạy bằng Gradle wrapper, đọc log khởi động từng dòng, viết @RestController trả về JSON, đổi server.port và xử lý năm lỗi mà người mới nào cũng gặp.