第一个 Spring Boot 应用:三分钟跑通 Hello World

bee2026-10-0866 分钟0 次阅读
从 start.spring.io 到浏览器刷新:项目怎么建、启动类每行代码什么意思、内嵌 Tomcat 从哪来、为什么不用配置 web.xml——把第一次运行讲得明明白白。
1 / 136
小节
〇、30 秒看懂
2 / 136

上一模块你用纯 Spring 搭过一个 Web 应用:web.xml、applicationContext.xml、外部 Tomcat,一个都不能少。Spring Boot 做的事只有一件——把这些「为了让框架跑起来」的仪式全部搬进 jar 包里预先写好。你只需要 new 一个项目、加一个依赖、写一个带 main 方法的类,剩下的它全包了。所以这一篇的重点不是「Boot 有多少功能」,而是第一次运行时,那行 SpringApplication.run(...) 到底替你干了哪八件事,以及你怎么确认它真的干成了。

3 / 136
类比

纯 Spring 像毛坯房——水电要自己排、墙要自己刷、门禁要自己装,住进去之前你得先当三个月装修工。Spring Boot 是精装房:水电网线、热水器、门锁全都配好了,你只带一个行李箱(你的业务代码)就能入住。而且它很懂分寸:你自己买了沙发摆进客厅,它就不会再往那儿放它的沙发。这篇末尾我们会把这句话拆开验证。

4 / 136
架构图
图 · 本篇地图:main 一行背后的八步
图 · 本篇地图:main 一行背后的八步
5 / 136

学完这一篇,你应该能回答三个问题:

6 / 136
  • 为什么 Boot 项目里没有 web.xml?内嵌 Tomcat 究竟从哪个依赖里冒出来的?
  • SpringApplication.run() 那一行背后依次发生哪八步?我在启动日志的哪一行能确认「现在可以访问了」?
  • mvn package 产出的 fat jar 为什么单靠 java -jar 就能跑?目标机器需要预装什么?
7 / 136
小节
一、Spring Boot 到底解决了什么
8 / 136

在写第一行 Boot 代码之前,先回头看上一模块留下的「配置负担清单」。用纯 Spring 起一个最普通的 Web 应用,你要准备这些东西:

9 / 136
  • 一个 web.xml,注册 DispatcherServlet 并配置它的 init-param
  • 一个 applicationContext.xml,声明组件扫描、数据源、事务管理器
  • 一个 spring-mvc.xml,注册视图解析器、消息转换器
  • 把上面的 war 包丢进一个外部的 Tomcat,装上、启动、看日志
10 / 136

这四件事里,没有一件是你的业务。 它们全是「让框架跑起来」的仪式。Spring Boot 解决的就是这层仪式——它的官方说明只有一句话:约定优于配置(Convention over Configuration)。落到实际,就是三件事:

11 / 136
对照表
纯 Spring 的负担Spring Boot 的答案
手工管理几十个依赖及其版本starter 一站式依赖,版本由父 POM 统一裁决
手写 xml / Java 配置类自动配置:按 classpath 和已有 Bean 条件式装配
部署到外部 Tomcat内嵌 Tomcat,java -jar 直接跑
12 / 136
架构图
图 1 · 一次 Boot 启动的骨架
图 1 · 一次 Boot 启动的骨架
13 / 136
小节
二、用 start.spring.io 生成项目
14 / 136

官方脚手架 start.spring.io 是上手的标准入口(IDEA 里 New Project → Spring Initializr 走的是同一个服务)。填选项时对着下表照抄即可:

15 / 136
对照表
选项选择说明
ProjectMavenGradle 亦可,本文用 Maven
LanguageJava—
Spring Boot3.2.x与 JDK 17 及以上匹配
Groupcom.example包名前缀
Artifactdemo项目名 / 构件名
PackagingJar不再需要 war
Java17与上一模块的结论一致
DependenciesSpring Web只要这一个,够跑 Hello World
16 / 136

点击 Generate 下载 zip,解压后用 IDEA 打开即可。注意 Artifact 会决定启动类的名字:填 demo,得到的就是 DemoApplication。

17 / 136
提示

Dependencies 里只加 Spring Web 是刻意的。Boot 的依赖是「按需加载」的,加得越多、自动配置装配得越多、启动越慢。第一次上手时保持最小依赖,才能把「加一个 starter 带来什么」观察清楚。

18 / 136
小节
先别急着点 Generate:勾一遍,看 pom 会长成什么样
19 / 136

Initializr 上那一排复选框,本质就是往 <dependencies> 里塞条目。下面这台生成器和课文用的是同一套拼接规则:勾一项,pom 立刻变;产物下面会写清这一行为什么存在、去掉会怎样。

20 / 136

练习路径:只勾 Spring Web → 看 <parent> 之外为什么一个版本号都没有;再加 Data Jpa 和 MySQL 驱动 → 数一数多了哪三个条目、为什么驱动的 <scope> 是 runtime;最后加 Lombok → 看它的 scope 为什么是 provided。

21 / 136
生成器
生成器starter 勾选器(与课文同源)pom.xml1 / 11
只勾你现在需要的。勾得越多,启动期自动配置跑得越多,报错面也越大
产物
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>demo-service</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>17</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
勾了这些,代价与理由在这里
parent继承 3.3.4 的 starter-parent 之后,所有 spring-boot-starter-* 都不用写版本号;一旦有人手写给某个 starter 加 version,就以那条为准——这是依赖版本漂移最常见的原因。
Web做接口就绕不开它: DispatcherServlet、内嵌 Tomcat、JSON 序列化全在这个 starter 里。
22 / 136
小节
三、项目结构:逐个文件讲清楚
23 / 136

打开项目,目录长这样(这是 Boot 的标准骨架):

24 / 136
text
demo/├── pom.xml                                  # 依赖与构建配置├── src/│   ├── main/│   │   ├── java/com/example/demo/│   │   │   └── DemoApplication.java         # 启动类:唯一必需的类│   │   └── resources/│   │       ├── application.properties       # 全局配置(默认几乎是空的)│   │       ├── static/                       # 静态资源(css / js / 图片)│   │       └── templates/                    # 模板文件(Thymeleaf 等)│   └── test/java/com/example/demo/│       └── DemoApplicationTests.java        # 自动生成的测试类└── mvnw / mvnw.cmd                          # Maven Wrapper,无需预装 Maven
25 / 136

先看 pom.xml 里最关键的两块——parent 和 starter:

26 / 136
代码对照
代码xml
<!-- 1. parent:继承 Spring Boot 的默认配置,版本由此统一裁决 --><parent>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-parent</artifactId>    <version>3.2.5</version>    <relativePath/></parent><dependencies>    <!-- 2. starter:一个依赖,开启一整族 Web 能力 -->    <dependency>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-starter-web</artifactId>    </dependency></dependencies>
解读
  • spring-boot-starter-parent 是依赖版本的唯一事实源:你在下面加依赖时不用写 <version>,它替你锁定一整套兼容版本
  • spring-boot-starter-web 是 starter 的典型代表:它本身几乎不含代码,作用是把「Web 开发所需的一族依赖」一次性拉进来
  • application.properties 默认几乎是空的,因为默认值全在自动配置里,只有你要「偏离默认」时才需要写

说明:那个 mvnw(Maven Wrapper)是个贴心设计——它会自动下载合适版本的 Maven,所以队友和你自己的机器就算没装 Maven 也能构建,团队环境从此不会再出现「我这能跑你那不能」的版本差异。

27 / 136
小节
四、启动类:逐行解读
28 / 136

整个项目里,只有启动类是「必须由你写」的。它短得离谱,但每一行都值得讲:

29 / 136
java
package com.example.demo;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplicationpublic class DemoApplication {    public static void main(String[] args) {        SpringApplication.run(DemoApplication.class, args);    }}
30 / 136

@SpringBootApplication 是个「三合一」注解,拆开看就是三件事:

31 / 136
对照表
组成注解作用
@SpringBootConfiguration本质是 @Configuration,声明这是一个配置类
@EnableAutoConfiguration开启自动配置,让 Boot 按条件装配 Bean
@ComponentScan扫描当前包及其子包下的 @Component / @Service / @Controller
32 / 136
  • SpringApplication.run(DemoApplication.class, args) 的返回值是一个 ApplicationContext,也就是 IoC 容器本身。你完全可以接住它,比如 ConfigurableApplicationContext ctx = SpringApplication.run(...),然后 ctx.getBean(...) 取 Bean 来验证装配结果
  • main 方法里只写了这一行,但真正发生的事远不止一行:创建容器、加载自动配置、启动内嵌 Tomcat、发布就绪事件,全是这一行的连锁反应
33 / 136
要点

@ComponentScan 默认只扫「启动类所在包及其子包」。所以启动类要放在最外层的包(如 com.example.demo),业务代码放子包(com.example.demo.controller)。如果把启动类塞进 controller 子包,同级或外层的 Bean 就扫不到了——这是新手第一周必踩的坑。

34 / 136
小节
五、写下第一个 Controller
35 / 136

在 com.example.demo.controller 包下新建一个类:

36 / 136
代码对照
代码java
package com.example.demo.controller;import org.springframework.web.bind.annotation.*;@RestControllerpublic class HelloController {    @GetMapping("/hello")    public String hello() {        return "Hello, Spring Boot!";    }    // 带路径变量:/hello/张三 会返回 Hello, 张三!    @GetMapping("/hello/{name}")    public String helloTo(@PathVariable String name) {        return "Hello, " + name + "!";    }}
解读
  • @RestController = @Controller + @ResponseBody:方法返回的字符串直接写进响应体,不再找视图
  • @GetMapping("/hello") 把这个方法映射到 GET /hello
  • @PathVariable 把 URL 里 {name} 这一段绑定到形参
37 / 136

@RestController 和 @Controller 的区别值得单独记一句:要返回视图(HTML 页面)用 @Controller,要返回 JSON 或纯文本用 @RestController。混用会让「明明返回了对象,浏览器却报 404 找不到页面」。

38 / 136

这个区别不是背出来的,是按出来的。下面这个内核实验把四种情形各跑一遍:view 看 @Controller 返回 hello 时 Spring 怎样把它当视图名去找模板、找不到就给你一张白标页;json 看同一个方法换成 @RestController 之后写进响应体的是什么;missing 看那张 404 白页到底是哪个环节判的;string 看 return "ok" 在两种注解下分别被解释成什么——这也是新手「接口明明返回了字符串却 404」的真正答案。

39 / 136
内核实验
TeaVM同样是返回字符串,两种注解两种下场未启动
按 view → json → missing → string 的顺序点;点 string 时对照上面那句「404 找不到页面」
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
40 / 136
小节
六、启动日志:逐行读
41 / 136

运行 DemoApplication,控制台会吐出这样一段(已按真实格式简化):

42 / 136
代码对照
代码text
  .   ____          _            __ _ _ /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \ \\/  ___)| |_)| | | | | || (_| |  ) ) ) )  '  |____| .__|_| |_|_| |_\__, | / / / / =========|_|==============|___/=/_/_/_/ :: Spring Boot ::                (v3.2.5)2024-05-20T10:00:00.123  INFO --- [main] o.s.b.w.embedded.tomcat.TomcatWebServer :  Tomcat initialized with port(s): 8080 (http)2024-05-20T10:00:01.456  INFO --- [main] o.s.b.w.embedded.tomcat.TomcatWebServer :  Tomcat started on port(s): 8080 (http) with context path ''2024-05-20T10:00:01.789  INFO --- [main] com.example.demo.DemoApplication :  Started DemoApplication in 1.234 seconds (process running for 2.101)
解读
  • 那块 ASCII 图案是 banner,可以通过 resources/banner.txt 自定义,或用 spring.main.banner-mode=off 关掉
  • Tomcat initialized with port(s): 8080 说明内嵌 Tomcat 已经绑定端口
  • Started DemoApplication in 1.234 seconds 是「就绪」的信号——看到这一行,浏览器才可能访问成功
43 / 136

内嵌 Tomcat 到底从哪来? 它藏在 spring-boot-starter-web 的传递依赖里。执行 mvn dependency:tree 能看到这棵树:

44 / 136
text
spring-boot-starter-web ├── spring-boot-starter │    ├── spring-boot │    ├── spring-boot-autoconfigure │    └── spring-boot-starter-logging ├── spring-boot-starter-json ├── spring-boot-starter-tomcat        <-- 内嵌 Tomcat 从这里来 │    ├── tomcat-embed-core │    ├── tomcat-embed-el │    └── tomcat-embed-websocket └── spring-webmvc      └── spring-web
45 / 136

这就解释了为什么不用 web.xml:外部 Tomcat 场景下,是 Servlet 容器先启动、再加载你的 war 并读取 web.xml;而 Boot 把顺序倒了过来——是你的 main 先启动,亲手把 Tomcat 拉起来(TomcatServletWebServerFactory 在自动配置里完成),Servlet 的注册也因此变成了 Java 代码,不再需要一份 xml 描述文件。

46 / 136
架构图
图 · 内嵌 Tomcat 到底从哪来
图 · 内嵌 Tomcat 到底从哪来
47 / 136

「勾一个框 → 8080 能访问」中间那段路,被这张图压成了两列。要看清它是怎么一格接一格传下去的,点下面这条路径——第 ③ 格是整条链的咽喉,条件不成立,后面四格根本不会发生:

48 / 136
交互图解
流程从勾中 Spring Web 到 8080 可访问(点着看)1 / 6
从 ① 点到 ⑥,重点是第 ③ 格:条件判定发生在装配之前,这是 Boot 全部魔法的开关
→
→
→
→
→
① pom 里那一行 starter
你写的只有 `<artifactId>spring-boot-starter-web</artifactId>` 这一行,连版本号都没有——版本由 parent 统一裁决。这一行的唯一职责是「声明意图:我要做 Web」,它不含任何代码。
全部看懂了一句话:starter 铺 classpath,条件判定决定装不装,refresh() 才真的把 Tomcat 拉起来。
49 / 136
小节
七、三种运行方式
50 / 136

同一个启动类,有三种跑法,各自适用不同场景:

51 / 136
对照表
方式命令 / 操作适用场景
IDEA 直接运行点 main 旁的绿色三角日常开发,断点调试最方便
Maven 插件mvn spring-boot:run命令行开发,或 IDE 不好用的环境
可执行 jarmvn package 后 java -jar target/demo-0.0.1-SNAPSHOT.jar部署、CI 验证、贴近生产
52 / 136
小节
八、打包运行:为部署埋线
53 / 136

先在项目根目录执行打包:

54 / 136
代码对照
代码bash
# 跳过测试先跑通,测试类会在下一模块单独讲mvn clean package -DskipTests# 产物是一个「可执行 jar」(fat jar),内嵌了 Tomcat 和全部依赖java -jar target/demo-0.0.1-SNAPSHOT.jar
解读
  • Boot 的 spring-boot-maven-plugin 会把项目打成 fat jar:一个 jar 里塞进了应用代码、第三方依赖和内嵌 Tomcat,所以 java -jar 就能独立运行
  • 这个 jar 是后面「Docker 部署」「CI/CD」两节的基础——部署到服务器时,目标机只需要一个 JRE,不需要预装 Tomcat

提示:-DskipTests 只是先跳过,不能当长期习惯。打包前的测试是最后一道防线,等下一模块讲完 JUnit 5 就把这一项去掉。

55 / 136
小节
九、坑:三个第一次运行必踩的问题
56 / 136
对照表
现象根因排查 / 解决
Port 8080 was already in use8080 被其他进程占用改 server.port=8081,或杀掉占用进程
浏览器访问 404路径拼错 / 类没在扫描范围 / 忘了 @RestController依次核对:URL、所在包是否在启动类子包内、用的是不是 @RestController
改了代码访问结果没变用的旧进程 / 没重新编译停掉旧进程重跑;确认为何「热部署」没生效
57 / 136
坑

404 的排查一定要有顺序,别乱试。第一步看 URL 是否和方法映射完全一致(含大小写、前缀);第二步看 Controller 所在的包是不是启动类的子包——@ComponentScan 只扫子包;第三步看注解是不是 @RestController(用 @Controller 而没配视图,就会 404)。按这三步,绝大多数 404 都能定位。

58 / 136

这三步之所以要按顺序,是因为它们各自排掉一整类原因:URL 不对是「你人走错了门」,包不在子树里是「容器根本不认识这个类」,注解用错是「返回值被当成视图名扔掉了」。这条动画把六帧走完,你会得到肌肉记忆而不是口诀:

59 / 136
原理动画
动图 · 第一次 404 的排查路线
动图 · 第一次 404 的排查路线
60 / 136
小节
现场:第一次启动就崩了,那张横幅到底在读哪一行
61 / 136

Boot 的失败分析器(FailureAnalyzer)会把根因排成一张居中的横幅。新手在这里的典型动作是从上往下逐行读完,或者一慌去翻下面几百行堆栈——两种都低效。这张横幅里只有三行有信息量,你来点出最关键的那一行:

62 / 136
报错急救
报错急救APPLICATION FAILED TO START
读失败横幅:哪一行才是能动手的地方

你照课文写完 HelloController,命令行执行 ./mvnw spring-boot:run。日志滚了十几秒,突然在中间打出这张横幅。

APPLICATION FAILED TO START
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
63 / 136
小节
十、必须知道的:改了代码为什么要重启
64 / 136

Boot 默认不会在你改代码后自动重启,因为运行中的 JVM 已经把类加载进内存了。想要「改完立刻生效」,需要加 spring-boot-devtools:

65 / 136
xml
<dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-devtools</artifactId>    <optional>true</optional>   <!-- 关键:optional,避免传递到下游依赖 --></dependency>
66 / 136

「改了没生效」这件事读十遍解释都不如走一遍。下面这台单步台把六行摊开,右边同步刷新「此刻内存里有几个 HelloController 的 Class 对象」和「谁在加载它」——重点盯第 3 步与第 6 步,那两个数字一个不变、一个归零:

67 / 136
单步调试台
单步台逐行走一遍:改了代码,为什么刷新还是旧的1 / 6
按下一步走六拍,盯住右边的「方法区里的 Class 对象」这一格——它决定你看到什么
被调试的代码
1// 你在 IDEA 里把 hello() 的返回值改成新的文案,按下 Ctrl+S
2// 运行中的 JVM:方法区里已经躺着 HelloController 这个 Class 对象
3// javac 只重写了磁盘上的 target/classes/HelloController.class
4// 老的那个 Class 不会回头去读磁盘
5// 浏览器刷新 → 容器里的同一个 Bean 实例 → 同一个 Class → 旧文案
6// devtools 的做法:扔掉 RestartClassLoader,用新的加载器把磁盘上的字节码再读一遍
此刻的变量
磁盘上源码已更新
内存里还是旧字节码
进程同一个,没重启
调用栈
1IDEA → Save → javac
1保存这个动作只影响源码和(可选的)编译产物。此刻磁盘和内存是两份不同的真相,而 JVM 只认内存那一份。
68 / 136
内核实验
TeaVM从保存到运行:IDEA 到底替你重做了哪一步未启动
先点「编译产物」看 .java 变成 target/classes 里的 .class,再点「热重载」看 devtools 换掉了哪一层加载器;顺序反了,你就亲眼得到一次「跑了个旧的」
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
69 / 136
小节
十一、动手体验:Boot 启动时容器里发生的事
70 / 136

下面这个演示把 Boot 启动时的 Bean 装配过程做成了可交互的。切换条件开关,观察「装配的连锁反应」——哪一个 Bean 因为条件不满足而被跳过:

71 / 136
内核实验
72 / 136

上面这个实验看的是「条件不满足时 Bean 怎么被跳过」。再往回退一步,有个更基本的问题:你写的那个 HelloController 到底是怎么进容器的?beanin 场景把四条路各演一遍——组件扫描(@Component 系)、@Bean 方法、@Import 直送、自动配置替你注册。最后一档「扫不到的现场」正是第十五节沙盘和第十六节速查表里那条 required a bean that could not be found 的由来:包不在启动类的子树里,扫描这一步压根没看见它。

73 / 136
内核实验
TeaVM一个对象进容器的四条路未启动
依次点四条路,看清每条路上「谁在什么时候读到了你的类」;最后切「扫不到的现场」,那就是 404 之外第二种最常见的启动失败
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
74 / 136
原理动画
动图 · 三分钟跑通 Hello World
动图 · 三分钟跑通 Hello World
75 / 136
小节
再进一层:不用点按钮,直接对这台容器下命令
76 / 136

上面那个沙盘把条件开关做成了三个按钮。下面换成命令行——你敲,浏览器里那份 Java 内核真跑真答:beans 列出的是容器此刻的真实状态,cond 改完之后的装配日志是重新算出来的,curl 打到的是内核内存里的虚拟 8080。

77 / 136

按这个顺序走一遍,你会亲眼看到「条件不满足」是怎么一层层传染的:

78 / 136
  1. boot —— 建容器,看装配日志与条件求值记录
  2. beans —— 五个 Bean 全在,状态都是「已创建」
  3. cond jdbcOnClasspath false —— 假装 JDBC 不在类路径上
  4. beans —— 再问一次:这次 已跳过 和 创建失败 分别落在谁头上?为什么?
  5. curl /api/kernel/query —— 请求打到 Repository,看它回你什么
  6. cond jdbcOnClasspath true 把开关恢复,再 beans 确认一切回来了
79 / 136
内核控制台
80 / 136
提示

lab <场景> <参数> 是控制台通向全部内核实验的后门——课文里每个按钮,你在这里都能自己敲出来,比如 lab proxy jdk、lab refresh fail、lab tx requires_new。忘了有哪些场景就敲 help,再回看第十一到十四篇。

81 / 136
小节
十二、决策:为什么生产环境不推荐 devtools
82 / 136
决策
决策`spring-boot-devtools` 能让改完代码自动重启,开发时很爽。现在要把项目部署到生产环境,这个依赖该怎么处理?
83 / 136
小节
十三、动手体验一:`SpringApplication.run()` 的八步现场
84 / 136

前面那张时间轴是「应该发生什么」。下面这个内核实验把它真的跑一遍——bootrun 场景模拟的是 SpringApplication.run() 的完整流程,四个按钮各看一条支线:

85 / 136
  • 八步主线(full):按顺序走完准备启动器 → Banner → 建容器 → 准备环境 → 后置处理 → refresh() → 起 Tomcat → 调用 Runner,每一步都会打出该步产出的东西
  • 事件广播(event):ApplicationStartingEvent → EnvironmentPreparedEvent → ApplicationPreparedEvent → ApplicationStartedEvent → ApplicationReadyEvent。日志里那句 Started DemoApplication in 1.234 seconds 就是 ApplicationStartedEvent 的产物,这也是你在代码里挂「启动后做一次预热」的正确时机
  • Runner 执行(runners):ApplicationRunner 与 CommandLineRunner 都在容器就绪之后、ApplicationReadyEvent 之前执行;前者参数被包装成 ApplicationArguments(能区分选项参数和非选项参数),后者拿到裸 String[]
  • 启动失败怎么读(fail):Boot 会把异常整理成一段带边框的报告,先给 APPLICATION FAILED TO START,再分 Description: / Action: 两块。这一档一定要点开看,它直接对应第十六节的速查表
86 / 136
内核实验
TeaVM亲手跑一遍 SpringApplication.run():八步 · 事件 · Runner · 失败未启动
按 full → event → runners → fail 的顺序点;点 fail 时对照第十六节的报错速查表
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
87 / 136

顺手确认一件事:run() 是有返回值的。上一模块你学过 refresh() 十二步,其实 Boot 就是把那十二步塞进这里的第 6 步。想亲眼看到容器,把 main 改三行就够:

88 / 136
java
public static void main(String[] args) {    ConfigurableApplicationContext ctx =            SpringApplication.run(DemoApplication.class, args);    // 容器里有几个 Bean?取一个出来看看是不是你想要的类型    System.out.println("beanDefinitionCount = " + ctx.getBeanDefinitionCount());    Object hello = ctx.getBean("helloController");    System.out.println("helloController -> " + hello.getClass().getName());}
89 / 136

第一次跑这行打印,你会得到一个比预期大得多的数字——因为自动配置已经悄悄注册了几十个 Bean。这正是下一篇《@SpringBootApplication 三合一》和第三篇《自动配置原理》要解释的现象。

90 / 136
小节
十四、动手体验二:fat jar 里面到底装了什么
91 / 136

第八节说 mvn package 产出「内嵌一切的 fat jar」。它凭什么能被 java -jar 直接跑?普通 jar 的 META-INF/MANIFEST.MF 里没有 Main-Class,JVM 不知道该从哪个类开始执行;而且第三方依赖也不可能被系统类加载器看见——它们被压在 jar 内部,不是散在磁盘上的目录。Boot 用两个动作解决这两件事:

92 / 136
代码对照
代码text
target/demo-0.0.1-SNAPSHOT.jar├── META-INF/│   ├── MANIFEST.MF          # Main-Class: org.springframework.boot.loader.launch.JarLauncher│   └── ...                  # Start-Class: com.example.demo.DemoApplication├── BOOT-INF/│   ├── classes/             # 你自己的 class 与 application.properties│   └── lib/                 # 全部第三方依赖,以「嵌套 jar」原样塞进来│       ├── spring-boot-starter-web-3.2.5.jar│       ├── tomcat-embed-core-10.1.x.jar│       └── ...└── org/springframework/boot/loader/launch/   # 启动器自己(JarLauncher 等)
解读
  • JarLauncher 是真正的入口。它新建一个 LaunchedClassLoader,把 BOOT-INF/classes/ 和 BOOT-INF/lib/*.jar 都交给这个加载器管——这就是 JDK 标准 classpath 规则做不到的「jar 套 jar」(类加载器的概念在第 1 篇讲过)
  • Start-Class 才是你写的那个 DemoApplication。JarLauncher 用反射调用它的 main,于是控制权交回给你,第八步时间轴继续往下走
  • 拆包验证一下:jar tf target/demo-0.0.1-SNAPSHOT.jar | more,能看到上面三层目录
93 / 136

这五样东西各自管什么,光看目录树是记不住的——来配一遍。左边是 jar 里的位置或清单属性,右边点它的职责,配错了当场告诉你为什么:

94 / 136
配对闯关
闯关fat jar 里每个位置各管一件事已配对 0/6 · 配错 0
六组都是「位置 ↔ 职责」的硬映射,两列都打乱了,别靠顺序猜
先点左边一个
95 / 136

fatjar 场景把这四段各自演一遍:layout 看目录结构与 MANIFEST.MF 的两行关键属性;loader 看 LaunchedClassLoader 的查找顺序(父委派 → BOOT-INF/classes → BOOT-INF/lib);run 看从 java -jar 到你 main 的第一行之间的完整启动序列;war 看换成 war 部署时的差异——多出一个 WEB-INF/lib-provided,devtools 和外置容器用的 Tomcat 会被丢进那里,从而在外置 Tomcat 里不会和你的容器版本冲突。

96 / 136
内核实验
TeaVM拆开 fat jar:清单、LaunchedClassLoader、启动序列、war 差异未启动
先看 layout 认目录,再看 loader 明白为什么 jar 能套 jar;最后切 war 看 lib-provided 的用途
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
97 / 136
原理动画
动图 · java -jar 之后的一百毫秒
动图 · java -jar 之后的一百毫秒
98 / 136
提示

目标机器只需要一个 JRE,不需要预装 Tomcat——这句话的技术根据就是上面这段:Tomcat 的 class 就在 BOOT-INF/lib 里,由 LaunchedClassLoader 自己负责加载。

99 / 136
小节
十五、沙盘:端口和包位置,两处一改就见效
100 / 136

第一次跑 Boot,八成概率会栽在这两件事上:端口撞车,或者包放错层导致 404。左边改一行配置,右边立刻给出启动日志的那几行关键输出:

101 / 136
沙盘
沙盘端口怎么改 · 启动类放哪一层
运行结果
Tomcat started on port 8080 (http) with context path ''
GET /hello -> 200 "Hello, Spring Boot!"
#startedAt=1.2s beanDefinitionCount 约 60+
默认值全在自动配置里:端口 8080、上下文路径为空、字符集 UTF-8。
102 / 136
说明

沙盘第二列的三个包位置,正好对应三种结果——放根包一切正常、放子包 Bean 集体消失、放父包能用但扫描面失控。这三格请务必亲手点一遍,比读十遍文字管用。

103 / 136
小节
十六、常见报错速查
104 / 136

下面的「报错原文」都可以整段粘进搜索框,别缩写、别意译:

105 / 136
对照表
报错原文(片段)真实原因30 秒自救深挖看第几篇
Web server failed to start. Port 8080 was already in use. / java.net.BindException: Address already in use: bind上一次运行没停干净,或别的程序占了 8080改 server.port=8081;Windows 用 `netstat -ano \findstr :8080 找到 PID 再 taskkill /PID <pid> /F,macOS/Linux 用 lsof -i:8080`本篇第十五节沙盘
APPLICATION FAILED TO START + Description: Field xxx in com.example.demo.web.UserController required a bean of type '...' that could not be found. + Action: Consider a component scan capable of placing your SpringBootApplication...@ComponentScan 只扫「启动类所在包及子包」,那个 Bean 在兄弟包或父包里先读 Description 里的完整类名,对比启动类的包名;把启动类提到根包,或用 scanBasePackages 显式列出第 17 篇第四节 · 本篇第十八节第一档
Consider defining a bean of type 'org.springframework.jdbc.datasource.DataSource' in your configuration引了 starter 却没配连接信息,或压根没加 spring-boot-starter-jdbc 这类依赖加依赖;或补 spring.datasource.url/username/password第 18 篇第七节(--debug 看 Negative matches)
Caused by: java.lang.ClassNotFoundException: com.fasterxml.jackson.databind.ObjectMapper / 运行时 NoClassDefFoundError代码 import 了某个类,但对应的 jar 不在 classpath 上看报错第一个类名属于哪个 artifact,把它加进 pom;mvn dependency:tree 核对第 2 篇 Maven 依赖 · 第 18 篇
The following 1 problem(s) were found while reading the POM: ... Some problems may be related to the parent project 'spring-boot-starter-parent' / spring-boot-starter-parent version not foundparent 的版本号写错、仓库里不存在,或公司私服没有该版本打开 pom.xml 顶部核对 <version> 是否为真实存在的 Boot 版本;首次构建需联网下载第 2 篇 · 本篇第三节
UnsupportedClassVersionError: ... has been compiled by a more recent version of the Java Runtime (class file version 61.0), this version ... only recognizes ... 52.0用 JDK 8 去跑 Boot 3 需要的 JDK 17 字节码java -version 与 IDEA 的 Project SDK 统一到 17+第 1 篇第六节
no main manifest attribute, in target/demo-0.0.1-SNAPSHOT.jar这个 jar 没被 spring-boot-maven-plugin 重打包过,不是可执行 jarpom 里补 spring-boot-maven-plugin 的 build 插件声明,重新 mvn package本篇第十四节 fatjar 实验
106 / 136
坑

Boot 3 的启动失败报告一定会分成 Description: 和 Action: 两段。先读 Action,再读 Description——Action 是官方给的修法方向(例如「考虑调整组件扫描」「检查某个属性」),Description 里则带着出问题的完整类名和属性名,两者合起来通常就能定位。千万别只看第一行的 APPLICATION FAILED TO START 就去猜。

107 / 136
小节
十七、随堂自测
108 / 136
随堂自测
随堂自测你把项目打成 fat jar,在服务器上敲 `java -jar app.jar`,控制台立刻报 `no main manifest attribute, in app.jar`。这说明什么?
先自己选一个,选中立刻告诉你对不对
109 / 136
随堂自测
随堂自测同事嫌 8080 常被占,直接在启动参数里加了 `--server.port=abc`。应用会怎样?
先自己选一个,选中立刻告诉你对不对
110 / 136
小节
十八、动手练习
111 / 136
小节
第一档 · 照做
112 / 136

目标:从零跑通一个 Hello World,并亲眼确认它做了什么。三步:生成、跑、验。

113 / 136

第一步,pom.xml 全文(可以直接覆盖 Initializr 生成的那份来对照):

114 / 136
xml
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0"         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">    <modelVersion>4.0.0</modelVersion>    <!-- 版本的唯一事实源:下面所有 starter 都不写 <version> -->    <parent>        <groupId>org.springframework.boot</groupId>        <artifactId>spring-boot-starter-parent</artifactId>        <version>3.2.5</version>        <relativePath/>    </parent>    <groupId>com.example</groupId>    <artifactId>demo</artifactId>    <version>0.0.1-SNAPSHOT</version>    <name>demo</name>    <properties>        <java.version>17</java.version>    </properties>    <dependencies>        <dependency>            <groupId>org.springframework.boot</groupId>            <artifactId>spring-boot-starter-web</artifactId>        </dependency>    </dependencies>    <build>        <plugins>            <plugin>                <groupId>org.springframework.boot</groupId>                <artifactId>spring-boot-maven-plugin</artifactId>            </plugin>        </plugins>    </build></project>
115 / 136

第二步,主类必须叫这个名字、待在这个包里(src/main/java/com/example/demo/DemoApplication.java):

116 / 136
java
package com.example.demo;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.PathVariable;import org.springframework.web.bind.annotation.RestController;@SpringBootApplication@RestController           // 初学图省事,可以把 Controller 写在同一个文件里public class DemoApplication {    public static void main(String[] args) {        SpringApplication.run(DemoApplication.class, args);    }    @GetMapping("/hello")    public String hello() {        return "Hello, Spring Boot!";    }    @GetMapping("/hello/{name}")    public String helloTo(@PathVariable String name) {        return "Hello, " + name + "!";    }}
117 / 136

第三步,运行 mvn spring-boot:run,预期启动日志长这样(时间戳和版本号会随你的机器变):

118 / 136
text
  .   ____          _            __ _ _ /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \ \\/  ___)| |_)| | | | | || (_| |  ) ) ) )  '  |____| .__|_| |_|_| |_\__, | / / / / =========|_|==============|___/=/_/_/_/ :: Spring Boot ::                (v3.2.5)2026-xx-xxT10:00:00.123  INFO 12345 --- [  main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port 8080 (http)2026-xx-xxT10:00:00.456  INFO 12345 --- [  main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port 8080 (http) with context path ''2026-xx-xxT10:00:00.789  INFO 12345 --- [  main] com.example.demo.DemoApplication         : Started DemoApplication in 1.234 seconds (process running for 1.567)
119 / 136

再开一个终端验收:

120 / 136
bash
curl http://localhost:8080/hello          # 期望:Hello, Spring Boot!curl http://localhost:8080/hello/张三      # 期望:Hello, 张三!
121 / 136

验收清单:① 日志里能指出哪一行代表「端口已绑定」、哪一行代表「可以访问了」;② 两条 curl 都返回 200;③ mvn package 后 jar tf target/demo-0.0.1-SNAPSHOT.jar 能看到 BOOT-INF/lib/ 与 META-INF/MANIFEST.MF。

122 / 136
小节
第二档 · 变体
123 / 136

每个变体只动一处,做完请用自己的话说出观察结论:

124 / 136
  1. 端口三连改:依次试 server.port=8081、server.port=0、server.port=abc。你会观察到 前两行换端口成功、第三行直接启动失败并报 Failed to bind properties under 'server.port' to int。这一条做完,第十五节的沙盘就有实感了。
  2. 故意把主类挪进子包:把 DemoApplication 移到 com.example.demo.web,另建一个 com.example.demo.service.HelloService(带 @Service),在主类里 @Autowired 它。你会观察到 启动即失败,Description: 里写着 required a bean of type 'com.example.demo.service.HelloService' that could not be found——这就是速查表第二行。
  3. 关掉 banner 看看少了几行:加 spring.main.banner-mode=off。你会观察到 那块 ASCII 图案消失,其余日志一字不变——说明 banner 只是装饰,不参与任何启动步骤。
  4. 加上 devtools 再改代码:加依赖后改 hello() 的返回值并保存。你会观察到 控制台出现一段 restartedMain 线程的日志——重启是在一个新线程上做的,这正是第 9 篇讲的容器重建。
125 / 136
小节
第三档 · 造一个
126 / 136

做一个「我的第一个 Boot 工作台」workbench,要求全部用上本篇的知识点。

127 / 136
  • 依赖只有 spring-boot-starter-web;parent 锁一个真实存在的 Boot 3 版本,java.version 设 17
  • 包结构:根包 com.example.workbench 放 WorkbenchApplication,子包 controller / service / common
  • 三个接口:GET /ping 返回 pong;GET /time 返回当前时间字符串;GET /echo/{msg} 原样返回路径变量
  • 一个自定义 banner.txt,里面至少写出自己的名字和一个数字
  • 一个 CommandLineRunner(写在主类里当 @Bean 就行),启动完成后打印容器里的 Bean 总数
128 / 136

验收清单:① mvn clean package 通过,java -jar 能起;② 三个接口在浏览器和 curl 下都返回 200;③ 启动日志末尾能看到你那行 Bean 总数;④ 把 WorkbenchApplication 临时挪进 controller 包再启动,你能贴出那段 APPLICATION FAILED TO START 报告并说出根因,然后改回来;⑤ README 里写下「本机能跑需要哪三样东西」(JDK、Maven 或 mvnw、网络)。

129 / 136
小节
十九、要点自查
130 / 136
自检

不看上文,说出 SpringApplication.run() 的八步里,哪一步之后端口才可访问、哪一步真正实例化了单例 Bean。

131 / 136
自检

web.xml 为什么在 Boot 项目里消失了?用「谁先启动」这句话说清因果。

132 / 136
自检

fat jar 的 MANIFEST.MF 里有哪两行关键属性?分别指向谁?

133 / 136
自检

启动失败报告的 Description: 与 Action: 各自告诉你什么?为什么建议先读 Action:?

134 / 136
自检

为什么说 Boot 是「精装房」而不是「什么都没有的空房」?用 @ConditionalOnMissingBean 那一层含义解释「你放了沙发它就不放」。

135 / 136
口诀

starter 拉依赖、parent 定版本、主类住根包、日志盯两行(Tomcat started on port 与 Started XxxApplication)。

136 / 136
总结

第一次跑通 Spring Boot 只需记住这几件事——项目从 start.spring.io 生成,pom.xml 里 parent 统一裁决版本、starter 一站式拉依赖;启动类靠三合一的 @SpringBootApplication(@SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan),SpringApplication.run 返回的是 IoC 容器本身;内嵌 Tomcat 来自 spring-boot-starter-web 的传递依赖,main 先启动、再由它拉起 Tomcat,所以 web.xml 消失了。运行时留意日志里的 Tomcat started on port 8080 与 Started XxxApplication,运行时可用 IDEA、mvn spring-boot:run 或 java -jar 三种方式,打包产物是内嵌一切的 fat jar。第一次的 404、端口占用、改了代码没重启,是必经的三道小坎,按「URL → 包路径 → 注解」的顺序排查即可。