如果你以前只写过普通 Java 程序,第一次打开一个 Spring Boot 项目,心里多半会冒出两个问题:写个后端为什么要分出 Controller、Service、Repository,还要放这么多注解?更奇怪的是,代码里明明没写多少 new,对象却都能在运行时出现,甚至连 Web 服务器也跟着启动了。
这种困惑很正常。Spring Boot 把不少重复工作接走了,代价是你得先认识它的工作方式。只背 @Service、@RestController 这些注解,确实能把第一个接口跑起来,但一遇到“找不到 Bean”“循环依赖”“为什么自动配置没生效”,就会完全没有排查方向。所以这一篇先不急着搭环境,我们先把框架背后的参与者认清楚。
贯穿这套课程的项目叫 TaskHub。它是一个任务管理后端:客户端可以创建任务、查看任务、修改状态和删除任务。接下来的每一篇都会在同一个项目上增加能力,而不是每次重新写一个 Hello World。现在先看 TaskHub 最终会长成什么样,再拆解 Spring Boot 在其中承担的工作。
从使用者的视角看,创建任务只是一条 HTTP 请求。客户端把标题、说明、优先级和截止日期发给服务端,服务端返回已经创建的任务:
POST /api/tasks
Content-Type: application/json
{
"title": "补上接口测试",
"description": "固定创建任务的状态码与响应字段",
"priority": 5,
"dueDate": "2026-08-20"
}响应会像这样:
HTTP/1.1 201 Created
Content-Type: application/json
{
"id": 3,
"title": "补上接口测试",
"description": "固定创建任务的状态码与响应字段",
"status": "TODO",
"priority": 5,
"dueDate": "2026-08-20",
"createdAt": "2026-08-17T08:30:00Z",
"version": 0
}但服务端收到请求以后,并不是直接把这段 JSON 塞进数据库。TaskHub 会把工作拆成几段:Controller 负责理解 HTTP 请求,Service 执行业务规则,Repository 负责读写数据,数据库保存最终结果。响应返回时则按相反方向,把领域对象整理成稳定的 JSON。

这张图先记住方向就够了,不用急着背每层的注解。它表达的是职责边界:HTTP 的事情留在 Controller,任务能不能创建、状态能不能变化由 Service 判断,数据放在哪里由 Repository 决定。
这几层并不是 Spring Boot 强制规定的目录。你完全可以把所有类放进一个包,框架照样有机会运行。我们选择分层,是因为一个稍微真实的后端很快就会遇到修改、复用和测试。如果边界清楚,改动通常只落在对应位置;如果边界混乱,一条业务规则会从请求入口一直牵扯到数据库细节。
Spring Boot 不要求固定的代码布局,但主应用类通常放在根包 com.welearn.taskhub,业务代码放在它的子包里。这样组件扫描从根包向下工作时,能够覆盖 TaskHub 自己的类,又不会漫无目的地扫描所有依赖。
新手第一次看到三层结构时,很容易觉得这是形式主义:“创建一条任务而已,一个方法不能写完吗?”当然能。下面这个控制器甚至看起来很直接:接收请求、检查标题、生成编号、保存数据、拼响应,一条链都在眼前。
@RestController
@RequestMapping("/api/tasks")
public class BadTaskController {
private final Map<Long, TaskResponse> tasks = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong();
@PostMapping
public ResponseEntity<TaskResponse> create(@RequestBody CreateTaskRequest request) {
问题不在于这段代码今天能不能工作,而在于明天会发生什么。需求通常不会停在“保存一条记录”:标题要限制长度,优先级要在 1 到 5 之间,找不到任务要返回统一错误,列表要分页,数据要从内存换到 PostgreSQL,运营页面也想复用创建和查询逻辑。
如果所有逻辑都在 Controller,换数据库就要改 HTTP 入口;增加管理页面时,要么直接调用另一个 Controller,要么复制一遍业务规则;测试“标题会去掉首尾空格”时,还得带着完整的 Web 环境一起测。类越来越大以后,任何人改它都得先理解整条链路。

分层后的 Controller 会薄很多。它只把 HTTP 输入交给 Service,再把结果转换成合适的 HTTP 响应:
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService service;
public TaskController(TaskService service) {
this.service = service;
}
@PostMapping
public ResponseEntity<TaskResponse> create(
@Valid @RequestBody CreateTaskRequest request
Service 接手“创建任务”这件业务操作:
@Service
public class TaskService {
private final TaskRepository repository;
public TaskService(TaskRepository repository) {
this.repository = repository;
}
@Transactional
public TaskResponse create(CreateTaskRequest request) {
Task task = new Task(
request.title().trim(),
request.
Repository 则只暴露数据访问能力:
public interface TaskRepository extends JpaRepository<Task, Long> {
}你暂时不需要理解 @Valid、@Transactional 或 JpaRepository。这里只看边界:Controller 不知道任务最终放进 H2 还是 PostgreSQL,Service 不知道请求来自 REST 接口还是浏览器页面,Repository 也不关心 HTTP 状态码。以后替换数据库,主要改数据访问和配置;测试业务规则时,可以给 Service 换一个轻量的假 Repository。
分层确实增加了文件数。这个成本是真实的,不必假装不存在。它换来的不是“目录看起来专业”,而是依赖方向更清楚、改动范围更可控。很小的一次性程序可能用不到这些层;TaskHub 会继续接数据库、页面、安全和测试,提前划清边界会在后面不断回本。
先把一个误会排除掉:Controller、Service、Repository 这种分层不是 Spring Boot 发明的,依赖注入也不是 Spring Boot 才有。Spring Framework 很早就提供了 IoC 容器、Spring MVC、数据访问和事务管理。即使不用 Boot,我们仍然可以使用这些能力,只是要自己做更多装配。
假设要从普通 Spring Framework 搭一个 Web 应用,开发者需要选定并协调 Spring 各模块与第三方库的版本,创建应用上下文,注册 MVC 基础组件,准备 JSON 转换器,配置 Servlet 容器,再决定怎样打包和部署。每件事都有合理做法,但新项目会一遍遍重复这些决定。两支团队都想做 REST 服务,却可能从两套不同依赖和几十行近似配置开始。
Spring Boot 做的事,可以概括成三层:
用依赖管理给常见库提供协调好的版本,减少“这个 Spring 模块能不能配那个 Jackson 版本”的试错。
用 Starter 表达一类开发场景需要的常用依赖,让项目不用逐项拼装。
用自动配置观察当前环境,按条件提供一套可以起步的基础设施 Bean。
它省掉的是重复选择和重复接线,没有删掉底层机制。请求仍由 Spring MVC 分发,对象仍由 ApplicationContext 管理,JSON 仍由消息转换器处理,事务仍要经过代理。理解这些参与者以后,Boot 的默认值就不再像一个封闭黑箱。
“约定优于配置”也不是“永远不要配置”。默认端口、默认服务器和默认 JSON 支持让项目先跑起来;如果 TaskHub 需要换端口、切数据库或提供自己的 Bean,我们仍然可以通过属性与 Java 配置接管相应部分。好用的默认值和可覆盖的边界必须同时存在,Boot 才能既适合入门,也能进入真实项目。
Spring Boot 解决的是应用启动与基础设施装配成本,不会替项目做架构判断。把全部业务塞进 Controller,再交给 Boot 启动,仍然会得到一个难维护的项目。分层和自动配置是两条不同的问题线。
再看刚才的 TaskService:
private final TaskRepository repository;
public TaskService(TaskRepository repository) {
this.repository = repository;
}我们没有在构造器里写 new TaskRepository(),而且 TaskRepository 还是一个接口。程序运行时,repository 却会得到一个可用对象。这个对象不是 Java 凭空造出来的,真正负责创建和组装它的是 Spring 的 IoC 容器。
IoC 是 Inversion of Control,中文通常叫控制反转。普通 Java 代码会自己决定“创建哪个依赖、什么时候创建、怎么把它传进来”:
TaskRepository repository = new InMemoryTaskRepository();
TaskService service = new TaskService(repository);
TaskController controller = new TaskController(service);这三行没有错,小程序这样写很清楚。项目变大以后,组装链会越来越长:Repository 需要数据源,数据源需要连接信息,Service 可能还需要时钟、权限检查器和事件发布器。如果每个业务对象都自行查找或创建依赖,它们就会同时承担业务和装配两份责任。
交给 Spring 后,控制权换了方向。类只声明“我需要什么”,容器决定“用哪个对象满足它”。这种由外部把依赖交给对象的方式叫依赖注入,也就是 DI。IoC 描述的是整体控制权的反转,DI 是 Spring 实现这种反转的核心手段。
你可以先把容器想成一个装配中心:它读取配置线索,登记零件,创建对象,再按照构造器参数把对象连接起来。类比到这里要收回来。在 Spring 里的正式名称是 ApplicationContext,它代表应用上下文,是常见的 IoC 容器入口。容器管理的对象叫 Bean。Bean 并不是一种特殊 Java 语法,它仍然是普通对象,只是创建、组装和生命周期交给了容器。

因此,“Spring 帮我把对象变出来”更准确的说法是:应用启动时,ApplicationContext 根据 Bean 定义和配置元数据创建 Bean,解析 Bean 之间的依赖,再完成注入。之后 Controller 收到请求时,使用的是已经装配好的 Service,而不是每次请求都临时 new 一个。
容器开始工作时,通常先收集 Bean 定义。Bean 定义可以理解成创建说明:目标类型是什么、名字是什么、作用域是什么、需要哪些依赖、是否需要延迟创建。组件扫描、配置类里的 @Bean 方法以及自动配置,都可能向容器提供这样的说明。
登记完成后,容器才按依赖关系创建对象。创建 TaskService 时,它发现构造器需要 TaskRepository,便去候选 Bean 中按类型解析。找到唯一匹配项后,先准备 Repository,再把它传进 TaskService 构造器。接着,Bean 后处理器还可能检查注解、包装代理或执行初始化回调。后面看到事务代理、安全代理时,它们都能放回这条生命周期里理解。
Spring 管理的 Bean 默认常用单例作用域,但这里的“单例”是“每个 ApplicationContext 中通常只有一个 Bean 实例”,不是说整个 JVM、所有应用上下文乃至所有服务器永远共用一个对象。Web 请求对象等场景还可以使用其他作用域。TaskHub 的 Service 通常没有保存某个用户请求的可变状态,因此一个实例可以服务多次调用。
按类型注入也有明确规则。容器里恰好有一个 TaskRepository 候选项时,选择很自然;一个都没有会报缺失依赖;同时存在内存版和数据库版两个实现,又没有提供选择线索时,会报候选项不唯一。以后我们可以用配置条件、主要候选项或限定名称解决,但第一步永远是先看容器中究竟有哪些候选 Bean。
依赖注入最实在的收益之一,是业务类不必把具体基础设施焊死。假设我们只想检查标题会不会去掉空格,可以给 Service 一个测试用 Repository:
TaskRepository fakeRepository = new InMemoryTaskRepository();
TaskService service = new TaskService(fakeRepository);
TaskResponse result = service.create(
new CreateTaskRequest(" 补上接口测试 ", null, 5, null)
);
assertEquals("补上接口测试", result.title());这里没有启动服务器,也没有连接数据库。TaskService 依赖的是 TaskRepository 这份能力,而不是某个固定数据库实现,所以测试能换成内存实现。生产运行时,再由容器注入真正的数据访问 Bean。所谓“解耦”如果一直停在抽象名词上,很难体会;能用普通构造器换依赖、让业务测试变得更小,就是可以直接感受到的结果。
你在旧教程里很容易看到字段注入:
@Service
public class TaskService {
@Autowired
private TaskRepository repository;
}@Autowired 会告诉 Spring,这个注入点需要一个匹配类型的 Bean。它能工作,但依赖被藏在私有字段里。离开容器以后,普通 Java 代码没办法在构造对象时保证依赖已经提供;字段也不能自然声明为 final。单元测试若想直接传一个假的 Repository,往往还得启动 Spring 或借助反射修改字段。
构造器注入把依赖放在类的入口上:
@Service
public class TaskService {
private final TaskRepository repository;
public TaskService(TaskRepository repository) {
this.repository = repository;
}
}这个类只有一个构造器时,Spring 会选择它完成注入,不需要再写 @Autowired。final 表明依赖在对象创建后不会被换掉,测试也可以直接 new TaskService(fakeRepository)。更重要的是,构造器让“这个类没有 Repository 就无法工作”成为代码结构的一部分。
构造器注入也会更早暴露循环依赖。假如 TaskService 需要 NotificationService,而 NotificationService 又需要 TaskService,容器无法先完整创建其中任何一个。这通常说明职责缠在了一起,应该拆出更合适的边界,而不是到处加延迟加载把问题藏起来。
“找不到 Bean”不是注解失灵的同义词。先检查目标类型有没有成为 Bean、是否位于扫描范围、是否有多个实现导致无法选择、构造器参数类型是否写对。沿着“容器里到底登记了什么”排查,比反复增删 @Autowired 更有效。
Java 注解本质上是附加在类、方法、字段或参数上的元数据。@Service 不会自己执行创建对象的代码,@RestController 也不会自己打开网络端口。真正行动的是读取这些元数据的框架组件。
Spring 在组件扫描时会检查类路径中的类元数据,找出带有组件标记的候选类,登记对应的 Bean 定义。扫描阶段不等于粗暴地把每个类都实例化一遍;框架可以先读取类文件中的注解信息,再决定哪些类需要进入容器。进入创建阶段后,容器才加载类型、选择构造器、解析参数并实例化对象。反射会参与类型检查、构造器调用、方法发现等工作,但“注解有效”背后并不只有反射一个步骤。
常见的几个标记有各自的语义:
@Component 是通用组件标记。
@Service 表示业务服务,它本身是 @Component 的专门形式。
@Repository 表示数据访问组件,还能参与持久化异常转换。
@Controller 表示 MVC 控制器,通常返回视图名。
@RestController 表示 REST 控制器,返回值通常经消息转换器写入 HTTP 响应体。
这些名字不是为了把同一种东西换五套写法。它们给读代码的人和框架扩展点提供了职责信息。比如看到 @Service,我们就知道业务规则应该在这里;看到 @RestController,就知道这个类位于 HTTP 边界。
TaskHub 的 TaskRepository 又多一层机制:它是一个接口,不能直接 new。后面加入 Spring Data JPA 后,Spring Data 会扫描 Repository 接口并在运行时创建代理对象,把这个代理注册成 Bean。TaskService 需要 TaskRepository 时,容器注入的正是这个代理。到了数据库篇,我们会拆开代理怎样把 save、findById 变成真实数据库操作。
把所有注解都理解成“启动时扫描一下”也不准确。它们的生效时机不同:
@Service、@RestController 主要帮助容器发现组件并登记 Bean。
@GetMapping、@PostMapping 会被 Web 框架读取,用来建立请求路径与方法之间的映射。
@Valid 会让参数解析流程调用校验器检查请求对象。
@Transactional 通常由代理在方法调用前后开启、提交或回滚事务。
所以遇到注解问题时要先问两个问题:谁读取它?在什么时候读取?如果是组件发现失败,就查扫描范围和 Bean 注册;如果是事务没有生效,就查调用有没有经过代理和事务边界。把所有问题归结成“Spring 反射了我的代码”,调试时仍然太模糊。
XML 配置并没有从 Spring Framework 中消失,但现代 Spring Boot 项目通常使用组件扫描、Java 配置和外部化属性。若搜索结果里满是 <bean>、web.xml 和手工部署 WAR,那多半来自较早的项目风格,不适合直接复制到本课程的 Spring Boot 4 项目里。
容器启动完成后,客户端发来的 POST /api/tasks 要先被内嵌 Tomcat 接收。Tomcat 处理网络连接与 Servlet 调用,把请求交给 Spring MVC 的入口 DispatcherServlet。它不会遍历所有 Controller 挨个试,而是查询启动阶段已经建立好的请求映射。
TaskController 上的 @RequestMapping("/api/tasks") 提供类级路径,方法上的 @PostMapping 提供 HTTP 方法与剩余路径。Spring MVC 在启动阶段读取这些元数据,建立“POST /api/tasks 对应 TaskController.create”的映射。若两个方法声明了无法区分的同一路由,应用往往会在启动阶段直接失败,而不是等请求来了随机选一个。
找到目标方法以后,参数解析器开始准备方法参数。@RequestBody 表明内容来自请求体,消息转换器根据 Content-Type: application/json 把 JSON 转成 CreateTaskRequest。@Valid 再触发校验。只有请求形状和约束都通过,Controller 方法才会被调用。
Controller 接着调用已经注入的 TaskService。Service 执行业务规则并访问 Repository,返回 TaskResponse。Controller 用 ResponseEntity 指定 201 状态,消息转换器再把 TaskResponse 序列化成 JSON,最终经 Tomcat 写回客户端。
这条链说明分层并不是把一个请求拆成互不相干的文件。每层仍在同一次调用中协作,只是各自只保留该负责的知识:
Tomcat
→ DispatcherServlet
→ 请求映射与参数解析
→ TaskController
→ TaskService
→ TaskRepository
→ 数据库以后出现问题时,这也是一条排查路线。404 可能是路由没匹配,400 可能是 JSON 转换或校验失败,业务异常来自 Service,数据库错误则沿 Repository 往上冒。统一异常处理会把这些错误整理成稳定 HTTP 响应,而不是让每个 Controller 写一套 try/catch。
现在只需要看清参与者,不用记住全部扩展点。REST API 篇会亲手实现这条链,并通过 curl 观察 201、400、404 和 204 等不同结果。
Spring Framework 提供 IoC 容器、依赖注入、Web MVC、事务等基础能力。Spring Boot 建立在这些能力之上,重点解决“怎样用一套合理默认值快速把应用组装起来”。它不是另一套与 Spring 无关的框架,也没有取代 ApplicationContext。
TaskHub 的最小入口只有很短几行。后续配置篇会在这里增加类型安全配置扫描,但启动主线不会变化:
package com.welearn.taskhub;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TaskHubApplication {
public static void main(String[] args) {
SpringApplication.run(TaskHubApplication.class, args);
}
}main 仍然是普通 Java 程序的入口。SpringApplication.run 会引导应用启动,选择合适的 ApplicationContext 类型,准备环境与配置源,加载 Bean 定义,刷新上下文,并在 Web 应用里启动内嵌服务器。成功返回的也是一个已经启动的应用上下文。
@SpringBootApplication 是一个组合注解。它把三个常用意图放在一起:
@SpringBootConfiguration:把主类标记成 Spring Boot 的配置类,允许在上下文中注册额外 Bean 或导入其他配置。
@EnableAutoConfiguration:开启 Spring Boot 自动配置。
@ComponentScan:从主类所在包开始扫描组件。
这里有一个很实际的目录规则。主类位于 com.welearn.taskhub,TaskController 位于 com.welearn.taskhub.task,后者是前者的子包,所以默认扫描能找到它。若把主类随手放到 com.welearn.bootstrap,业务类仍在 com.welearn.taskhub,扫描范围就会错开。很多“明明写了 @Service 却找不到 Bean”的问题,根源只是包的位置不对。

启动链路里有两个容易混淆的动作。组件扫描主要寻找我们写的组件;自动配置主要根据依赖、配置属性和已有 Bean,决定基础设施该怎样装配。二者最终都会向同一个 ApplicationContext 提供 Bean 定义,共同组成可以运行的 TaskHub。
你不必一开始记住完整生命周期,但应该保留一条排查主线:先进入 main,再创建应用上下文;扫描与配置把 Bean 定义放进容器;容器实例化并连接 Bean;Web 服务器开始监听端口;启动完成后请求才能进入 Controller。
Spring Boot 教程里经常把 Starter 与自动配置一起说,久而久之很容易把它们当成同一个东西。其实它们解决的是两个不同问题。
Starter 是一组有明确用途的依赖清单。我们要做 Spring MVC Web 应用,可以在 Maven 中引入:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>这一项会把 Spring MVC、JSON 处理和默认的内嵌 Servlet 容器等常用依赖带进项目。我们不需要逐个寻找库,也通常不用在每个依赖上手写版本。Spring Boot 的依赖管理提供了一套经过协调的版本组合;升级 Boot 时,受管理的依赖会一起移动到对应组合。
Starter 本身不会观察你的业务代码,也不会替你生成 Controller。它首先改变的是项目类路径:原来没有的框架类,现在可以被加载了。
自动配置接着读取这些条件。它会综合判断类路径里有没有某些类、配置属性是什么、用户是否已经提供某种 Bean,然后决定要不要注册默认组件。以 Web 应用为例,MVC 相关类在类路径中时,自动配置才有条件准备请求分发、消息转换和服务器工厂等基础设施。

如果自动配置只看“项目使用 Spring Boot”,然后把数据库、消息队列、安全、模板引擎全部启动,它反而会制造巨大负担。条件化装配让它只处理当前项目可能需要的部分。
常见判断可以概括为三类:
类路径条件:相关库存在,说明这项能力有机会使用。
属性条件:某个配置开启、关闭或提供了必要信息。
Bean 条件:容器中还没有用户自定义实现时,才提供默认值。
第三点常被叫作“退让”。例如自动配置准备提供一个默认组件,而你已经通过配置类声明了同类型 Bean,它就可以不再重复创建。这样项目刚开始时能依靠默认值快速启动,需求变复杂后又能逐步接管局部配置。
把 TaskHub 的 Web 启动拆开看,条件之间会形成一条链。spring-boot-starter-webmvc 先把 Spring MVC 和默认 Tomcat 等依赖放进类路径。SpringApplication 发现 MVC 技术存在,会把当前应用判断为 Servlet Web 应用,并选择能管理内嵌 Servlet 服务器的应用上下文。
接下来,相关自动配置分别检查自己的条件:MVC 基础类型是否存在,当前是否确实是 Web 应用,容器里是否已经有请求分发器、消息转换相关组件和服务器工厂。条件满足且用户没有完全接管时,默认 Bean 才会进入容器。Tomcat 相关依赖存在后,默认服务器工厂能够创建内嵌 Tomcat;若项目排除 Tomcat 并引入另一种受支持的服务器依赖,对应工厂才会成为候选。
server.port=9090 这种属性并不会重新实现一台服务器。它只是进入环境配置,在服务器创建时被读取,用来改变监听端口。类似地,引入 Validation Starter 是让校验实现可用,字段上的约束注解则描述具体规则。依赖、属性、Bean 和业务元数据各自承担一部分,最终才得到我们看到的运行结果。
自动配置类的真实条件会比这段概述更细,也会考虑配置顺序和不同技术栈。这里的重点不是背内部类名,而是学会提出可验证的问题:相关依赖在不在类路径?应用类型判断对不对?属性有没有被覆盖?是否已有 Bean 让默认配置退让?条件报告正是围绕这些问题给出证据。
组件扫描与自动配置失败时,症状也不太一样。自己写的 TaskService 消失,优先检查包位置与组件标记;Tomcat、数据源或消息转换器没有按预期出现,优先检查 Starter、属性和自动配置条件。当然二者最终都会表现为容器缺少某个 Bean,所以还要顺着异常里的依赖链找到最初缺失的那一环。
因此,“自动配置”不等于“框架猜业务”。它不会知道 TaskHub 的任务标题要不要去空格,也不会替我们决定状态流转规则。它擅长的是可由环境条件判断的基础设施装配,业务规则仍然由我们写。
如果以后遇到“为什么这个配置生效了”或“为什么没生效”,可以用 --debug 启动并查看条件评估报告。报告会列出哪些自动配置匹配、哪些条件没有满足。我们会在配置篇实际读一次,不需要现在把自动配置类名背下来。
本课程统一使用 Spring Boot 4.1.0、Java 25 和 Maven Wrapper。POM 的关键部分会是:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
<relativePath/>
</parent>
<properties>
<java.version>25</java.version>
</properties>Spring Boot 4.1 本身最低可以运行在 Java 17 上,但课程选择 Java 25 作为统一基线。这样你看到的构建文件、包名和输出都能对齐,不会在不同章节间来回切换版本。
Boot 4 的 Initializr 生成结果还使用了更细的 Starter 名称。比如课程里的 Spring MVC 依赖是 spring-boot-starter-webmvc,而许多 Boot 3 教程使用 spring-boot-starter-web。看到旧名字不代表 Java 突然不能写 Web,而是依赖组织方式随大版本发生了变化。照着本课程的 pom.xml 做,不要把不同大版本的片段拼在一起。
传统 Java Web 部署常见的做法是先安装一台外部 Tomcat,再把应用打成 WAR 放进去。应用和服务器分开管理,每台环境都要保证服务器版本、配置和应用要求能够对上。
Spring Boot 更常见的做法是把服务器作为应用依赖带进来。对 TaskHub 这样的 Spring MVC 项目,默认内嵌服务器是 Tomcat。执行 main 方法时,Spring Boot 创建服务器工厂并启动 Tomcat;应用关闭时,服务器也随这个 Java 进程一起关闭。
这并不是“没有服务器”。浏览器发出的 HTTP 请求仍然要经过真正的网络端口,也仍然由 Servlet 容器接收。变化在于服务器从外部部署目标变成了应用的一部分,因此开发机、测试环境和生产环境更容易运行同一个制品。

项目打包后会得到可执行 JAR。Spring Boot 的打包插件把应用类放进 JAR 的应用目录,把依赖 JAR 保留在嵌套依赖目录,并加入加载这些内容所需的支持。运行命令保持简单:
java -jar target/taskhub-0.0.1-SNAPSHOT.jar“可执行 JAR”也不意味着它是一个孤零零的二进制文件。机器上仍然需要兼容的 Java 运行时,数据库和外部服务仍需要独立准备,端口、账号等配置仍要通过环境提供。它解决的是应用代码与 Java 依赖的交付一致性,不会自动解决所有部署问题。
“内嵌”强调的是组装与生命周期,并不表示服务器源码被复制进 TaskHub。Tomcat 仍以依赖 JAR 的形式存在,由服务器工厂创建并由 Spring 应用上下文管理。启动上下文时服务器启动,关闭上下文时服务器执行优雅停机。测试如果选择不启动真实 Web 环境,也可以创建不带服务器的应用上下文,业务 Bean 不必因此消失。
默认 Tomcat 也不是写死在业务代码里的。若项目确有理由改用另一种受支持的 Servlet 服务器,可以调整依赖,让相应的服务器实现进入类路径。Controller 和 Service 不应感知这种替换,因为它们依赖的是 Spring MVC 提供的编程模型,而不是直接调用 Tomcat API。这又是依赖方向带来的收益:基础设施变化尽量停留在构建与配置边界。
外部容器部署也没有从世界上消失,某些既有组织仍会选择 WAR。课程采用可执行 JAR,是因为它能把服务器版本与应用版本放进同一份构建结果,更适合我们后续的容器部署。选择哪种方式要看交付环境,不必把“新”简单理解成“所有旧方式都错误”。
这一点也解释了为什么 Spring Boot 很适合容器化:容器里可以直接启动同一个 JAR,不需要先在镜像里维护一套独立 Tomcat 安装。到了部署篇,我们会真正打包 TaskHub,并把数据库连接、健康检查和非 root 运行用户一起处理。
概念都讲完以后,最好的收束方式是看一次启动。第 2 篇会实际创建并运行项目,使用 Maven Wrapper 执行:
./mvnw spring-boot:run控制台完整日志会比下面长,我们只截取现在看得懂的几行:
:: Spring Boot :: (v4.1.0)
Starting TaskHubApplication v0.0.1-SNAPSHOT using Java 25.0.4
Tomcat started on port 8080 (http) with context path '/'
Started TaskHubApplication in 3.019 seconds第一行来自 Spring Boot,确认当前 Boot 版本。Starting 行告诉我们 Java 进程正从哪个主应用类启动,也能核对 Java 版本。Tomcat started 说明内嵌服务器已经绑定 8080 端口。最后的 Started 表示应用上下文完成刷新,启动阶段的 runner 也已执行,应用进入可以处理请求的状态。
真正运行时,每行前面还会有时间、日志级别、进程号、线程名和记录日志的类,启动耗时也会随机器变化。不要拿动态数字逐字比对。判断是否成功,先抓住应用类、Java/Boot 版本、端口和最终的 Started;若中间出现异常堆栈且没有最后一行,就沿着最底层的 Caused by 查失败原因。
启动失败不一定是坏事。容器会在刷新上下文时尽量把必需依赖解析完整:某个 Service 的构造器缺少 Bean、两个路由发生冲突、端口已被占用,都可能阻止应用进入 Started。这让结构问题在接收真实请求前暴露出来。相比让一个依赖为 null 的对象先混进系统、等用户访问时再报错,启动阶段明确失败更容易定位,也更适合自动化部署判断新版本是否健康。
看到一长串异常时,不要只盯最上面的“Application run failed”。最上层通常只说明启动被中止,真正线索在后面的因果链。比如“创建 TaskController 失败”可能是因为“创建 TaskService 失败”,继续往下才看到“没有 TaskRepository 候选 Bean”。沿构造器依赖从外向内追到第一处具体原因,正好也是在还原容器的装配过程。
这几行背后正好对应前面的启动链:main 调用 SpringApplication.run,Boot 准备应用环境和自动配置,Spring 创建并刷新 ApplicationContext,容器注册和装配 Bean,Tomcat 开始监听,TaskHub 随后可以接收请求。
现在再看到“一个 @SpringBootApplication 就启动了整个后端”,可以把它翻译成一条具体链路:组合注解提供配置入口,SpringApplication 引导启动,ApplicationContext 管理 Bean,组件扫描发现业务组件,自动配置准备基础设施,内嵌 Tomcat 接收 HTTP 请求。
Spring 生态存在时间很长,搜索结果里同时混着多个时代的写法。很多文章当年完全正确,放进 Boot 4 项目却会让人走弯路。开始之前,先建立几个过滤条件。
如果教程要求在 applicationContext.xml 里写大量 <bean>,再手工配置组件扫描,它讲的是 Spring Framework 的一种配置方式。理解旧项目时这些知识仍然有用,但 TaskHub 使用注解、Java 配置和 Spring Boot 自动配置,不会从 XML 模板起步。
javax.* 与 jakarta.*现代 Spring 项目使用 Jakarta 命名空间。以后看到校验、持久化或 Servlet API,课程代码会使用 jakarta.validation.*、jakarta.persistence.* 等包。把旧文章里的 javax.* import 直接复制进来,常见结果是编译失败或依赖不匹配。
Spring Boot 已经管理大量常用依赖的兼容版本。除非确实理解覆盖版本的原因和后果,否则不要在 Starter 带来的库上逐个强行写版本。一个项目同时混入不同教程里的 Spring、Jackson、Tomcat 版本,比少写几个版本号更容易出问题。
注解多不等于每个注解都会“启动一个东西”。有些注解用于组件发现,有些用于请求映射,有些只提供校验约束。读到一个新注解时,先找它的读取者、生效阶段和产生的结果。这样知识会落在机制上,而不是落在一张越来越长的注解清单上。
我们还没有创建项目,但已经能回答开头那两个问题。
文件夹变多,是因为 TaskHub 主动把 HTTP、业务和数据访问职责分开。这个结构不是框架硬性要求,也不是为了摆样子;它让数据库替换、页面复用和单元测试有清楚边界。注解之所以生效,是因为 Spring 与 Spring Boot 在不同阶段读取元数据,登记 Bean、建立请求映射或创建代理。对象没有凭空出现,它们由 ApplicationContext 创建,再通过构造器完成依赖注入。
Starter 把一组兼容依赖带进项目,自动配置根据类路径、属性和已有 Bean 决定怎样装配基础设施。Web 依赖到位后,Spring Boot 可以启动内嵌 Tomcat,让 TaskHub 成为一个直接从 main 方法运行的 Java 应用。
这张地图还有一个用法:遇到问题时,不要立刻上网复制注解。先判断问题落在哪一段。编译期找不到类,通常先看依赖和版本;自己的组件没有进入容器,先看包位置与 Bean 定义;构造器无法注入,先看候选 Bean;端口和服务器行为不对,先看自动配置与属性;请求没有进入方法,再看路由和参数解析。把故障放回链路,搜索到的答案才有上下文,也更容易排除旧版本写法。后续每增加一项能力,我们都会沿这条链标出它真正插入的位置,并说明这样安排的原因。
你可以用下面三道题检查自己的理解。它们不考注解拼写,只检查这张机制地图有没有建立起来。
下一篇,我们会把这张地图变成磁盘上的真实项目:选择 Java 25 与 Maven,用 Spring Initializr 生成 TaskHub,读懂 pom.xml 和标准目录,再从 TaskHubApplication.main 启动应用。到那时你会亲眼看到,最小项目究竟需要哪些文件,SpringApplication.run 又怎样把这里讲过的机制真正串起来。