01 · Spring Boot 基础与项目结构
01 · Spring Boot 基础与项目结构
本篇目标是让你建立一个整体印象:一个 Spring Boot 后端项目由哪些部分组成,请求是如何被处理的,以及 Spring 在背后帮你做了什么。读完你应该能回答三个问题:
- 为什么我只写了一个
main方法,应用就能对外提供 HTTP 服务? - 我写的 Controller / Service 为什么不用
new,它们是从哪来的? - 同一份代码为什么既能在本地跑、又能在 Docker 里跑?
1.1 程序入口:@SpringBootApplication
Spring Boot 应用的入口是一个带 @SpringBootApplication 的主类:
// backend/src/main/java/com/yang/backend/BackendApplication.java
package com.yang.backend;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@MapperScan("com.yang.backend.mapper")
@SpringBootApplication
public class BackendApplication {
public static void main(String[] args) {
SpringApplication.run(BackendApplication.class, args);
}
}只有两行关键代码:@SpringBootApplication 注解 和 SpringApplication.run(...)。main 方法本身没有任何逻辑,它把控制权完全交给了 Spring。
@SpringBootApplication 内部到底做了什么
@SpringBootApplication 是一个"组合注解"(它在定义时又标注了其他注解),本质是三件事的合体:
| 组合注解 | 作用 |
|---|---|
@Configuration | 声明当前类本身就是一个 配置类,Spring 会读取它里面的 @Bean 方法(项目里还有一个更明显的例子见 1.7)。 |
@ComponentScan | 扫描 com.yang.backend 及其所有子包,把带 @Component 及其派生注解的类注册成容器里的 Bean。 |
@EnableAutoConfiguration | 启用"自动配置"——根据 classpath 上有什么依赖,自动装配好对应的框架(见 1.2)。 |
概念:
Bean是 Spring 容器管理的对象。你平时new UserService()是自己造对象;在 Spring 里,对象由容器"生产并保管",你只要"伸手去要"。
BackendApplication.java:8 上还有一个 @MapperScan("com.yang.backend.mapper")。它告诉 MyBatis-Plus 去哪个包下找 Mapper 接口并自动生成实现类。注意它和 @ComponentScan 的区别:@ComponentScan 扫的是加了 @Component 系列注解的类,而 Mapper 是接口,不是类,接口不能靠 @ComponentScan 实例化,所以 MyBatis 需要专有的 @MapperScan。
SpringApplication.run 启动流程(简化版)
java -jar app.jar
│
└─ SpringApplication.run(BackendApplication.class, args)
│
├─ 1. 创建 Spring 容器(ApplicationContext)
├─ 2. 读取 @ComponentScan → 扫描 com.yang.backend 下所有 Bean
├─ 3. 读取 @EnableAutoConfiguration → 加载 starter 对应的自动配置
├─ 4. 把所有 Bean 实例化、并注入彼此的依赖
├─ 5. 如果 classpath 有 Tomcat → 内嵌 Tomcat 启动,监听端口
└─ 6. 应用就绪,开始接收 HTTP 请求学习重点:你的控制器、服务类之所以不用手动
new,是因为 Spring 在启动时自动「扫描 → 实例化 → 注入」了它们。你写的main方法只是"按下启动键"。
1.2 自动配置:为什么引入 starter-web 就自动有了 Tomcat + MVC
@EnableAutoConfiguration 是 Spring Boot "约定优于配置" 的核心。它背后的机制是:
- 每个
spring-boot-starter-*依赖里都带有一个META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件,里面列了一串"候选自动配置类"。 - Spring 启动时看 classpath 上存在哪些类,决定要不要启用对应配置(这叫"条件化装配",用
@ConditionalOnClass等条件注解实现)。
对应到本项目 pom.xml(backend/pom.xml:36-39):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>spring-boot-starter-web 一次性带来:
- 内嵌 Tomcat:应用自己携带一个 Servlet 容器,所以打成 jar 后
java -jar app.jar就能直接对外服务,不需要外挂 Tomcat。 - Spring MVC:包括
DispatcherServlet、消息转换器、JSON 序列化等。 - 默认 JSON 库(Jackson):能把 Java 对象自动序列化成 JSON。
同理,pom.xml:46-49 的 spring-boot-starter-data-redis 会自动配置好 Redis 连接工厂(它的连接参数正是来自 application.yml:10-14 的 spring.data.redis.*);pom.xml:57-61 的 mybatis-plus-spring-boot3-starter 会自动配置好 SqlSessionFactory 等。
坑:自动配置是"尽力而为"。如果 classpath 上缺少对应依赖(比如没引
starter-web),那部分能力就不会被装配,应用不会报错,只是功能不存在。所以"引了哪个 starter"直接决定了应用具备哪些能力。
练习:在
pom.xml里把spring-boot-starter-web注释掉重新mvn package,再运行 jar,观察还听不听 8080 端口?为什么?(答案:Tomcat 不会被装配,应用启动后立刻退出,不再是一个 Web 服务。)
1.3 分层架构
本项目(以及绝大多数后端项目)采用经典分层:
HTTP 请求
→ Controller 接收请求、做参数校验、组装响应
→ Service 业务逻辑、事务
→ Mapper 数据库访问
→ PostgreSQL
→ DTO 请求 / 响应模型对应源码目录 com.yang.backend:
| 包 | 职责 | 注解 |
|---|---|---|
controller | 暴露 REST 接口 | @RestController @RequestMapping |
service | 业务逻辑 | @Service |
mapper | 数据库访问(MyBatis-Plus) | 接口继承 BaseMapper(由 @MapperScan 加载) |
entity | 数据库表映射 | @TableName @TableId |
dto | 请求/响应模型 | 普通 record/class |
config | 框架配置 | @Configuration |
exception | 统一异常处理 | @RestControllerAdvice |
各层的" stereotype 注解"(原型注解)本质都是 @Component 的别名,只是名字不同、语义不同,方便人和框架区分用途:
| 注解 | 本质 | 用途 |
|---|---|---|
@Component | 通用组件 | 最泛化的 Bean |
@Service | @Component | 放在业务逻辑层,语义化 |
@Controller | @Component | 传统 MVC 页面控制器(返回视图名) |
@RestController | @Controller + @ResponseBody | REST 接口控制器,方法返回值直接写成 HTTP body(JSON) |
@Configuration | @Component | 配置类,里面 @Bean 方法返回的对象也进容器 |
@RestControllerAdvice | @Component + 通知 | 全局异常处理,集中兜底所有 Controller 抛出的异常 |
解析:因为
@Service、@RestController最终都"是"@Component,所以@ComponentScan一把扫过它们,统统注册成 Bean。命名不同只是为了让代码可读性更好,框架也能针对性处理(比如@RestController会默认把返回值当响应体)。
1.4 请求生命周期:一个 HTTP 请求是怎么走到你的方法的
理解这条链路,是理解 Spring MVC 的关键。以项目里最简单的 HealthController 为例:
// backend/src/main/java/com/yang/backend/controller/HealthController.java
@RestController
@RequestMapping("/api")
public class HealthController {
@Operation(summary = "健康检查")
@GetMapping("/health")
public Map<String, String> health() {
return Map.of("status", "ok");
}
}GET /api/health 的完整生命周期:
浏览器 / curl
│ GET /api/health
▼
Tomcat (内嵌 Servlet 容器,监听 8080)
│
▼
DispatcherServlet ← Spring MVC 的总入口(前端控制器)
│ 把请求交给 HandlerMapping
▼
HandlerMapping ← 根据 URL "/api/health" 找到 HealthController.health()
│
▼
HandlerAdapter ← 反射调用 health() 方法
│
▼
HealthController.health() ← 你的代码:返回 Map{"status":"ok"}
│
▼
HttpMessageConverter ← 把 Map 转成 JSON(Jackson)
│
▼
Tomcat 把 JSON 写回 HTTP 响应体拆开讲:
- DispatcherServlet(前端控制器):所有请求先到它这,它是 Spring MVC 的"调度中心"。它由
starter-web自动配置好,默认映射到/。 - HandlerMapping:一张"URL → 方法"的路由表。
@RequestMapping("/api")+@GetMapping("/health")共同声明了/api/health这个映射,启动时被登记进这张表。 - HandlerAdapter:真正去"调用"你的方法(反射执行)。
- HttpMessageConverter:把返回值(
Map)用 Jackson 序列化成{"status":"ok"}。因为类上有@RestController(=@Controller+@ResponseBody),所以返回值直接写入响应体,而不是去找视图模板。
坑:
@Controller和@RestController差别很大。用@Controller时方法返回值会被当成"视图名"(去渲染 HTML 页面),除非方法上加@ResponseBody。本项目是纯 JSON API,所以用@RestController一步到位。如果你误写成@Controller,浏览器会收到 404/500(找不到名为{"status":"ok"}的视图)。
练习:用 curl 访问
curl http://localhost:8080/api/health,确认返回{"status":"ok"}。试着把@RestController改成@Controller再启动,访问会怎样?(配合 1.10 的 Actuator 扩展理解。)
1.5 依赖注入(DI)深入
Spring 通过构造器注入把依赖"塞"给组件。回顾入口类上方的 @MapperScan 和本项目典型的 Controller/Service 写法:
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
private final PermissionGuard permissionGuard;
public UserController(UserService userService, PermissionGuard permissionGuard) {
this.userService = userService;
this.permissionGuard = permissionGuard;
}
// ...
}- 类上加
@RestController,Spring 会把它创建成一个 Bean。 - 构造器的参数(
UserService、PermissionGuard)也是 Bean,Spring 在启动阶段解析依赖图,自动找出这些 Bean 传进来。 - 这种「由框架提供依赖」的方式叫控制反转(IoC)/ 依赖注入(DI)。
构造器注入 vs 字段注入(@Autowired)
初学者常看到另一种写法:
@RestController
public class UserController {
@Autowired
private UserService userService; // 字段注入
}两种写法的对比:
| 维度 | 构造器注入(推荐) | @Autowired 字段注入 |
|---|---|---|
| 不可变性 | 字段可声明 final,依赖不可变 | 字段不能是 final |
| 必填性 | 编译期即确定,少一个依赖直接编译/启动报错 | 运行到用到时才可能为 null |
| 可测试性 | 单测里 new UserController(mockService) 即可,无需 Spring | 必须靠反射或 Spring 容器才能注入 |
| 失败时机 | 启动即失败(fail-fast) | 可能启动成功,运行到一半才 NPE |
结论:为什么本项目用
final字段 + 构造器注入?因为依赖不可变、且必填,启动时若缺某个 Bean 会立刻报错,比运行到一半才 NPE 好定位。这是 Spring 官方现在推荐的写法(从 4.3 起,单构造器 Bean 甚至可省略@Autowired)。
Spring 如何解析依赖图
扫描到 Bean 定义:
UserService → 需要 UserMapper
UserMapper → 由 @MapperScan 生成
PermissionGuard → 需要 XxxService ...
│
Spring 按依赖关系做"拓扑排序"后依次实例化,
遇到循环依赖会有专门机制(本篇不展开)。
│
最终所有 Bean 就绪,互相之间的引用都填好了。Bean 的作用域(Scope)
默认所有 Bean 都是 singleton(单例):整个应用生命周期里只创建一个实例,所有请求共享。
- 为什么 Service 是单例没问题?因为 Service 通常是"无状态"的——它不保存某个用户的数据在字段里,数据都通过方法参数和返回值流动。
- 如果你写了
private int count = 0;然后在方法里count++,在多请求并发下就会出 bug(所有请求共享同一个计数器)。这种"有状态"需求要用@Scope("prototype")或把状态放进方法局部变量 / 数据库。
坑:单例 Bean 里不要放可变的成员变量当"请求级状态"。需要请求级数据请用方法参数传递,或用
ThreadLocal(谨慎)、或数据库/Redis。
1.6 配置外置:${环境变量:默认值} 与 12-factor
application.yml 用 ${环境变量:默认值} 的形式读取配置(本项目 application.yml:4-22):
spring:
application:
name: backend
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/uas}
username: ${DB_USERNAME:uas}
password: ${DB_PASSWORD:uas123456}
driver-class-name: org.postgresql.Driver
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
server:
port: ${SERVER_PORT:8080}含义:优先取环境变量 DB_URL,没有就用冒号后的默认值。
${ DB_URL : jdbc:postgresql://localhost:5432/uas }
│ │
│ └── 默认值(本地开发用)
└─────────── 环境变量名(部署时用)这样同一份代码既能本地跑(用默认值),也能在 Docker 里跑(docker-compose.yml:38-44 注入了 DB_URL=jdbc:postgresql://postgres:5432/uas 等环境变量)。注意 Docker 里 postgres 是容器服务名,不是 localhost——这正是用环境变量切换的好处。
@Value 与 @ConfigurationProperties 两种注入方式
application.yml 里的配置,代码里可以用两种方式读到:
方式一:@Value(适合零散的单值)
@Value("${server.port:8080}")
private int serverPort;方式二:@ConfigurationProperties(适合一组相关配置,类型安全)
@ConfigurationProperties(prefix = "spring.data.redis")
public class RedisProperties {
private String host;
private int port;
private String password;
// getters / setters
}本项目当前是直接让各 starter 的自动配置去读取这些 key(比如 spring.data.redis.* 由 spring-boot-starter-data-redis 自动绑定)。你自己的业务配置,建议用 @ConfigurationProperties 绑定成强类型对象,比到处 @Value 更易维护。
概念(12-factor 配置):把配置(数据库连接、端口、密钥)从代码中剥离,通过环境变量注入。好处是:代码可提交到 Git 但不泄露密码;同一镜像能在 dev / test / prod 切换。本项目正是这一实践。
坑:不要把真实密码、密钥写死在
application.yml提交进仓库。本项目application.yml里的uas123456只是本地默认值,生产环境必须由docker-compose.yml或云平台环境变量覆盖。
1.7 配置类与 @Bean:用代码声明 Bean
除了 @ComponentScan 扫出来的 Bean,还可以用 @Configuration 类里的 @Bean 方法"手动"把一个对象交给容器。看项目里三个真实例子:
MybatisPlusConfig —— 注册分页插件
// backend/src/main/java/com/yang/backend/config/MybatisPlusConfig.java
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL));
return interceptor;
}
}@Bean 标注的方法返回值(MybatisPlusInterceptor)会被放进 Spring 容器。MyBatis-Plus 启动后会拿到它,从而获得分页能力。DbType.POSTGRE_SQL 告诉分页插件底层是 PostgreSQL,生成对应方言的 SQL。
ShiroConfig —— Bean 之间的依赖注入
// backend/src/main/java/com/yang/backend/config/ShiroConfig.java
@Configuration
public class ShiroConfig {
@Bean
public SecurityManager securityManager(ShiroUserRealm shiroUserRealm) {
DefaultSecurityManager securityManager = new DefaultSecurityManager();
securityManager.setRealm(shiroUserRealm);
return securityManager;
}
}这里能看到 DI 的另一种体现:securityManager(...) 方法的参数 ShiroUserRealm 也是 Bean,Spring 自动把它传进来。这证明 @Bean 方法之间也可以互相注入——和构造器注入是同一套机制。
WebConfig —— 通过实现接口定制 MVC
// backend/src/main/java/com/yang/backend/config/WebConfig.java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173", "http://127.0.0.1:5173",
"https://uas.aaai.pp.ua", "http://uas.aaai.pp.ua")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*");
}
}这个配置类实现了 WebMvcConfigurer 接口,重写方法就能"勾选"Spring MVC 的可选能力。addCorsMappings 配置了跨域(CORS):允许前端 localhost:5173(Vite 默认端口)和线上域名访问 /api/** 下的接口。这是前后端分离项目必配的——浏览器同源策略会拦截跨域请求,靠它放行。
解析:
@Configuration类里的@Bean方法,Spring 会拦截调用、做"单例缓存"——多次调用同一@Bean方法拿到的是同一个实例。这也是为什么@Configuration类最好别写成普通new,要用@Bean返回值,享受容器的单例管理。
1.8 Spring Profiles:一套代码,多套环境(扩展了解)
有时不同环境需要不同配置。Spring 用 Profile(环境) 区分:
# application.yml(公共配置)
server:
port: ${SERVER_PORT:8080}
---
# application-prod.yml(仅 prod 环境生效)
spring:
config:
activate:
on-profile: prod
datasource:
url: ${DB_URL}
password: ${DB_PASSWORD}启动时指定环境:
java -jar app.jar --spring.profiles.active=prod
# 或在 docker-compose.yml 的 environment 里加 SPRING_PROFILES_ACTIVE=prod本项目当前只有一个 application.yml,所有环境共用。但若以后要区分 dev / prod(比如生产关掉 Swagger、调大连接池),就可以新增 application-prod.yml 并加 Profile。这是配置外置的自然延伸。
1.9 应用与 JVM 进程的关系
理解"Spring Boot 应用 = 一个 JVM 进程",有助于排查问题:
mvn package把项目打成可执行 jar(靠pom.xml:90-93的spring-boot-maven-plugin把依赖和内嵌 Tomcat 一起打进 fat jar)。java -jar app.jar启动一个 JVM 进程,Spring 在里面初始化容器、启动 Tomcat。- 容器内一切 Bean 都活在这个进程的内存里;进程退出,所有状态(除数据库/Redis 里的)都没了。
- 端口
8080(application.yml:22)是这个进程监听的 TCP 端口;docker-compose.yml:44的SERVER_PORT=8080即在容器里指定它。
操作系统
└─ JVM 进程 (java -jar app.jar)
├─ Spring 容器(管理所有 Bean 的单例)
└─ 内嵌 Tomcat(监听 8080,转发请求给 DispatcherServlet)坑:改了
application.yml必须重启进程才生效(除非用 Spring Cloud Config / DevTools 热刷新,本项目未用)。改环境变量也要重启容器(docker compose up -d --force-recreate backend)。
1.10 健康检查与监控(扩展了解:Spring Boot Actuator)
本项目已经自带一个最简单的健康检查接口 GET /api/health(见 1.4 的 HealthController,返回 {"status":"ok"})。它只能说明"应用进程活着"。
更专业的是 Spring Boot Actuator(本项目 pom.xml 未引入,作为扩展了解):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>引入后可访问 /actuator/health(检测 DB、Redis 等组件是否真的可用)、/actuator/metrics、/actuator/info 等。Docker 的 healthcheck(docker-compose.yml:15-19 的 pg_isready)是容器层健康检查(只查数据库),和应用层 /api/health 是不同层面的东西。生产部署通常两者都要有。
1.11 常见坑汇总
- 包位置放错:
@ComponentScan只扫com.yang.backend及其子包。如果你把某个类放在com.yang或别的顶层包,它不会被扫描到,启动时注入会失败(No qualifying bean)。入口类必须放在最顶层公共包。 @Controller误当@RestController:返回 JSON 的 API 要用@RestController,否则返回值被当成视图名。- 单例 Bean 存可变状态:见 1.5,不要在单例里用成员变量记请求数据。
- 配置写死密码:见 1.6,生产环境必须靠环境变量覆盖。
- 改配置不重启:yml / 环境变量改动需要重启进程或容器才生效。
- CORS 没配导致前端调不通:本地前端在
5173,若WebConfig.java:13的allowedOrigins忘了加该域名,浏览器会拦截响应(见 1.7)。 - 循环依赖:A 注入 B、B 又注入 A。Spring 对单例构造器注入会直接启动报错(这也是为什么构造器注入 fail-fast 好),需重构解开依赖。
1.12 小结与练习
回顾要点
@SpringBootApplication=@Configuration+@ComponentScan+@EnableAutoConfiguration。starter-web自动带来内嵌 Tomcat + Spring MVC + Jackson。- 请求经
DispatcherServlet → HandlerMapping → Controller → HttpMessageConverter(JSON)。 - 依赖由 Spring 构造器注入,默认单例;配置用
${ENV:default}外置。
练习
- 打开
BackendApplication.java,确认启动类所在包是com.yang.backend,其下所有子包都会被扫描。 - 访问
GET /api/health,看它如何返回{"status":"ok"}(结合 1.4 生命周期理解)。 - 思考:如果
UserService没有被@Service标注,启动会报什么错?(答案:找不到 Bean,无法满足构造器注入,报No qualifying bean of type 'UserService'。) - 进阶:在
docker-compose.yml里把SERVER_PORT改成9090并执行docker compose up -d --force-recreate backend,再用curl http://localhost:9090/api/health验证端口已改变——体会配置外置。 - 进阶:在
WebConfig.java:13加一个你自己的本地域名到allowedOrigins,理解 CORS 配置如何随前端变化。 - 进阶(扩展):尝试引入
spring-boot-starter-actuator并访问/actuator/health,对比它与本项目/api/health的区别。