1. 从Spring到Spring Boot:当初我们为什么非换不可
我在刚接触Java服务端开发的时候,用的还是Spring MVC那套老写法。每次新开一个项目,第一件事不是写业务代码,而是花半天去凑配置:web.xml要配、spring-context.xml要配、spring-mvc.xml要配,还要把各种jar包按版本一个个对齐。运气好的时候一次跑通,运气不好就陷入ClassNotFoundException和版本冲突的泥潭里,一折腾就是一整天。
后来Spring Boot出现,我第一次用的时候心里是存疑的——一个main方法就能把Web服务跑起来?连Tomcat都不用装?直到自己亲手建了个工程、写了三行代码、启动、浏览器里看到输出,才意识到这东西确实把Java后端开发的体验彻底改写了。
Spring Boot本质上做的事情可以用一句话概括:把Spring生态里那些需要你手动组装的东西,变成了一套开箱即用的默认配置,同时保留了按需覆盖的能力。它不是替代Spring的新框架,而是基于Spring Framework的一种开发范式,目的就是让你把精力从“配置框架”挪回“写业务”。
这一篇我先从整体讲清楚Spring Boot解决了什么痛点、怎么快速起一个项目、核心注解都干了什么、配置到底怎么管理,以及自动配置背后的逻辑。后续如果有机会,再往深里拆启动过程源码、数据访问、安全控制这些东西。第一篇的定位是:看完你能自己从零建一个能跑、能调、能配置的Spring Boot工程,并且知道每一步为什么要这么干。
如果你之前只写过Servlet、JSP或者用过SSM但没试过Spring Boot,这篇的内容正好就是你的过渡桥梁。如果你已经写过一段时间Spring Boot,也可以当查漏补缺,看看有没有理解偏差的地方。
2. 第一个Spring Boot项目:从创建到跑起来全流程
2.1 项目初始化:推荐用脚手架而不是手动建
创建一个Spring Boot工程,我最推荐的方式是去 start.spring.io 这个网站生成基础工程,然后导入IDE。你也可以用IDE自带的Spring Initializr入口,本质是同一个东西。为什么不推荐自己手动建目录、自己加依赖?因为Spring Boot的工程结构虽然不复杂,但有一堆约定俗成的细节,比如主类的位置、配置文件的命名、测试类的放法,手搭很容易踩坑,而脚手架生成的工程正好全部符合默认约定。
选择构建工具时,Maven和Gradle都可以,我身边用Maven的居多,这篇的示例都以Maven为例。Java版本按你自己环境来,Spring Boot 3.x要求Java 17以上,如果你还在用Java 8,就只能选Spring Boot 2.7.x这条线。这一点第一次接触的人特别容易踩坑:用Java 8环境去跑Spring Boot 3.x项目,编译直接报错,而且报错信息不太友好。
依赖选择上,一个最简单的Web项目只需要加一个Spring Web依赖,它会把Spring MVC、内嵌Tomcat、Jackson序列化这些一并带进来。这块就体现了“起步依赖”的设计理念——以后你要加什么功能,就加一个对应的starter,比如操作数据库加spring-boot-starter-data-jpa,做接口文档加spring-boot-starter-validation,不用自己逐个找jar包。
2.2 工程目录结构与主类的职责
生成好的工程大致长这样:
src/main/java/com/example/demo DemoApplication.java src/main/resources application.properties src/test/java/com/example/demo DemoApplicationTests.java pom.xml那个带main方法的类就是入口,一般放在根包位置。为什么要放在根包?因为Spring Boot默认会扫描主类所在包及其子包下的所有组件(Controller、Service、Repository这些)。如果你把主类放在com.example.demo,而Controller放在com.example.controller这种和主类不在同一层级甚至没有包含关系的包里,扫描就会漏掉它,接口全部404。这是一个非常常见的启动“成功”但请求“失败”的案例,几乎每个群里都有人问过。
主类的代码很简单,核心就一个注解加一个启动方法:
package com.example.demo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }SpringApplication.run(...)这个调用干了很多事:初始化Spring容器、触发自动配置、启动内嵌Web服务器、加载配置文件,等等。初学者现阶段只需要记住:这是整个应用的起点,它一执行,你的服务就起来了。
2.3 跑一个最简单的接口验证环境
工程建好后,先在pom.xml里看一眼依赖是不是加载完整,然后直接在IDE里运行DemoApplication的main方法。控制台会出现类似下面这样的一段日志:
Tomcat started on port(s): 8080 (http) with context path '' Started DemoApplication in 2.345 seconds看到Tomcat started on port 8080,说明内嵌Tomcat已经被启动,Spring Boot在背后已经把Web环境全部搭好,而你并没有安装过任何独立的Tomcat,也没有写一行配置。这就是内嵌容器的魅力。
然后写一个最简单的HTTP接口验证一下:
package com.example.demo; 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, Spring Boot!"; } }重启应用,浏览器访问http://localhost:8080/hello,看到返回的字符串就算通关了。这里必须留意一个细节:一定得重启,因为Spring Boot默认没有开热部署。热部署要额外引入spring-boot-devtools依赖,那又是后话。很多新手改了代码不重启,浏览器里还是旧结果,就以为代码写错了,其实只是没生效而已。
3. 核心注解逐个拆解:用得熟,更要知道为什么这样写
3.1 @SpringBootApplication:三个注解缝在一起的复合注解
@SpringBootApplication是使用频率最高的注解,它本身是一个复合注解,相当于同时使用了@Configuration、@EnableAutoConfiguration、@ComponentScan三个注解。
@Configuration表示这个类会作为配置来源,里面可以定义@Bean方法;@ComponentScan让Spring扫描并注册组件;@EnableAutoConfiguration是Spring Boot最核心的开关,它开启自动配置机制,后面专门用一节讲。你可以把这三个注解拆开单独写,效果是一样的,但没人这么干,因为一个@SpringBootApplication就能搞定。
理解这个复合注解对排查问题特别有帮助。比如你发现某个Service没有被Spring管理,调用时报空指针,第一反应就应该是检查这个Service所在的包是否在主类所在包的子包下面。如果不在,即使你在类上标了@Service,扫描器也看不到它。
3.2 控制层注解:@RestController与请求映射
@RestController是@Controller和@ResponseBody的组合,表示这个类的所有方法返回值直接写进HTTP响应体,不走视图解析。写纯接口服务时用@RestController,写页面跳转时才用@Controller配合模板引擎。
请求映射注解有@GetMapping、@PostMapping、@PutMapping、@DeleteMapping、@RequestMapping五兄弟。前四个是@Mapping的语义化封装,比如@GetMapping等于@RequestMapping(method = RequestMethod.GET)。大多数情况下按语义直接用就行,只有在你需要同时匹配多种请求方式时,才用@RequestBody和@PathVariable这些参数绑定注解。举个例子:
@RestController @RequestMapping("/api/users") public class UserController { @GetMapping("/{id}") public String getUser(@PathVariable Long id) { return "user id: " + id; } @PostMapping public String createUser(@RequestBody User user) { return "create: " + user.getName(); } }@PathVariable把URL路径中的{id}绑定到方法参数;@RequestBody把请求体里的JSON反序列化成User对象。这两个是最常用的绑定方式,剩下的@RequestParam用于?name=xxx这种查询参数,@RequestHeader用于读取请求头,各自分工明确。
3.3 业务层与持久层注解:让Spring托管对象
Service层常用@Service标注,数据访问层用@Repository,两者都是@Component的衍生注解,作用都是让Spring扫描到并创建Bean。区别在于语义和附加行为:@Repository会把数据访问层的异常统一转换成Spring的DataAccessException,@Service则纯粹是语义标记,方便开发者一眼看懂分层。
这里想多说一句依赖注入的一个常见误区。新手经常在字段上直接加@Autowired:
@Autowired private UserService userService;这样写能跑,但在构造器注入面前,它不算好的实践。推荐的做法是构造器注入:
private final UserService userService; public UserController(UserService userService) { this.userService = userService; }如果类只有一个构造器,连@Autowired都可以省掉。Spring 4.3之后单个构造器会自动完成注入。这样做的好处是依赖关系清晰。构造时就能发现缺失的依赖,也方便写单元测试的时候直接new对象传Mock。字段注入让你测试的时候想替换依赖还得动用反射,麻烦得很。
4. 配置管理的细节:properties、yaml与多环境切换
4.1 配置文件怎么写:两种格式的取舍
Spring Boot默认的配置文件是application.properties,但你经常看到别人用application.yml,两者可以并存,同时存在时properties优先级更高。我的建议是只挑一个用,别混着写,不然排查顺序问题的时候容易绕晕。
properties是key=value的扁平行式:
server.port=8081 server.servlet.context-path=/api spring.datasource.url=jdbc:mysql://localhost:3306/demoyaml靠缩进表达层级,更适合描述结构性配置:
server: port: 8081 servlet: context-path: /api spring: datasource: url: jdbc:mysql://localhost:3306/demo username: root password: 123456两种格式都有不少人用。我个人偏向yaml,因为配置项多了以后层级关系一目了然,不像properties那样前缀重复写一长串。但yaml有一个小坑:它严格的缩进规则和键值对写法容易犯格式错误,少一个空格整个配置就解析失败。没有特殊偏好就用properties,反而省心。
4.2 读取配置的两种姿势:@Value与@ConfigurationProperties
配置写进文件后,代码里怎么读?最直接的方式是@Value:
@Value("${server.port}") private String serverPort;这种方式适合读单个配置项,快捷轻便。但当你要读一组关联配置,比如数据源的url、username、password,@Value就要写三个字段,比较啰嗦。更好的做法是用@ConfigurationProperties绑定到一个Java类:
@Component @ConfigurationProperties(prefix = "spring.datasource") public class DataSourceProperties { private String url; private String username; private String password; // 必须提供getter/setter }Spring会把application.yml里所有以spring.datasource开头的配置项自动填到这个类的同名属性中。好处是配置集中管理、类型安全,编译期就能发现类型不匹配的问题。注意用@ConfigurationProperties的类需要提供getter和setter,因为Spring是通过setter完成绑定的。如果你用的是Java 17环境加Spring Boot 3.x,还可以用record代替class,体验更好。
4.3 多环境配置:dev、test、prod怎么切
开发环境、测试环境、生产环境的配置几乎不可能完全一致,数据库地址、日志级别、接口开关都可能不同。Spring Boot的Profile机制就是为此设计的。
命名规则很简单:application-{profile}.properties。比如:
application.properties // 公共配置 application-dev.properties // 开发环境 application-test.properties // 测试环境 application-prod.properties // 生产环境在application.properties里通过spring.profiles.active指定使用哪个环境:
spring.profiles.active=dev启动的时候也可以临时覆盖:java -jar demo.jar --spring.profiles.active=prod。命令行的优先级比配置文件高,这在部署的时候特别有用。你可以把一个jar包同时部署到测试和生产服务器,通过启动参数决定跑哪个环境,不用再为了换环境去改配置文件重新打包。
我实际项目里经常用组合:基础配置放公共文件,环境相关的差异放各自的profile文件,敏感信息(数据库密码、密钥)不走配置文件,而是通过环境变量注入。Spring Boot对环境变量有天然的映射规则,比如环境变量SPRING_DATASOURCE_PASSWORD会被自动绑定到spring.datasource.password。这样就能把机密信息从代码仓库里摘出去,降低泄露风险。
5. 自动配置的秘密:一个注解怎么拉起一整套运行环境
5.1 启动日志里的AutoConfiguration线索
每次Spring Boot启动时,控制台会有一段DEBUG级别的日志,记录了大量自动配置类的条件评估结果。初学者常常忽略这段日志,但它恰恰是理解自动配置最好的入口。
你可以在application.yml里临时开启自动配置报告:
debug: true启动后日志里会出现Positive matches和Negative matches两个区块。Positive列出了那些生效的自动配置,Negative列出了哪些自动配置因为条件不满足没生效,后面跟着原因。比如你没引入DataSource相关的依赖,数据源的自动配置就会出现在Negative matches里,原因是@ConditionalOnClass判断DataSource类不在classpath中。
这个报告是排查问题的利器。有时候你觉得某个配置“应该生效”但实际没有,去Negative matches里看一眼条件就能找到原因。我第一次排查一个数据源配置不生效的问题时,就是靠这份报告发现是缺了连接池的jar包,而不是配置写错了。
5.2 自动配置的底层机制:条件注解
自动配置的核心是一系列条件注解,最常用的有四个:
- @ConditionalOnClass:classpath中存在指定类才生效
- @ConditionalOnMissingBean:容器里没有指定Bean才生效
- @ConditionalOnProperty:配置项满足条件才生效
- @ConditionalOnWebApplication:当应用是Web应用时才生效
整个自动配置的过程可以简化成三步:Spring Boot在classpath中寻找所有自动配置类;用条件注解逐个判断这个配置该不该生效;生效的配置向容器注册Bean。默认值全部由配置类里的属性绑定而来,而你又可以通过配置文件覆盖这些默认值,形成“约定优先,按需覆盖”的机制。
以内嵌Tomcat为例。Spring Boot检测到classpath里有spring-boot-starter-web提供的Tomcat相关类,@ConditionalOnClass成立,于是自动配置类ServletWebServerFactoryAutoConfiguration生效,创建出一个TomcatServletWebServerFactory Bean。这个工厂负责创建并启动内嵌Tomcat实例。你全程没有写一行配置,但整个Web服务器就位了。
5.3 排除不需要的自动配置
自动配置也不是越多越好。有些场景下某些自动配置会干扰你,比如你想自己实现一套数据源逻辑,或者你引入了某些依赖但不想触发它对应的自动配置。这时有两个办法:
方法一:在启动类上排除指定类型
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})方法二:通过配置文件排除
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration我实际遇到的场景是:工程里引入了数据库驱动的jar包,但暂时不想真正连数据库,只做纯接口开发。这时DataSourceAutoConfiguration会尝试创建数据源,因为没有配置url而启动报错。排除掉它之后,工程就能在没有任何数据库配置的情况下正常启动。
这个例子说明了自动配置的一个特点:它默认假设你引入依赖就是想用它,当这个假设和你的实际意图不一致时,你要学会干预。自动配置省了你手动搭环境的时间,但同时要求你对“什么时候该排除”有判断力。
6. 内嵌Tomcat与打包部署:开发、测试、生产三套思路
6.1 内嵌容器的优势与切换
Spring Boot应用默认把Tomcat打进jar包,用一个java命令就能启动整个服务。这和传统的外部Tomcat部署模式比,最大的优势是环境一致性——开发、测试、生产用的都是同一个启动方式,不再有“本机能跑服务器跑不了”的环境差异问题。
如果你想用别的容器,比如Jetty或者Undertow,只需要在pom.xml里排除spring-boot-starter-tomcat,换成对应的starter即可。Undertow在高并发场景下内存占用表现更好;Jetty的某些特性在长连接场景有优势。大多数业务系统用默认的Tomcat就够了,没有特殊性能测试数据支撑前,不建议为了赶时髦去换容器。
6.2 打包成可执行jar并运行
在项目根目录执行:
mvn clean package -DskipTests构建完成后,target目录下会出现一个demo-0.0.1-SNAPSHOT.jar。这个jar和普通的依赖jar不一样,它内部包含了内嵌容器和所有依赖,因此被称为fat jar。运行极其简单:
java -jar target/demo-0.0.1-SNAPSHOT.jar默认端口8080,如果想换端口,可以用参数覆盖:
java -jar target/demo-0.0.1-SNAPSHOT.jar --server.port=9090这种打包方式非常适合容器化部署,因为镜像里只需要包含一个JRE和一个jar包,启动命令就一行。
6.3 war包部署的传统场景
虽然Spring Boot官方推荐可执行jar的方式,但有些公司的基础设施还沿用着外部Servlet容器统一管理多个应用的架构。这时需要将打包方式改成war:
在pom.xml里改一行:
<packaging>war</packaging>同时让启动类继承SpringBootServletInitializer并重写configure方法:
@SpringBootApplication public class DemoApplication extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(DemoApplication.class); } public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }双保险是既能打成war放进外部Tomcat,也能继续用main方法直接跑开发环境。是否需要打成war,取决于团队现有的运维体系。没有历史包袱的话,纯jar方案会省很多事。
6.4 常见启动问题:端口占用与OutOfMemory
启动阶段最常遇到的两个问题,我觉得值得单独列出来。
端口占用是最常见的。Error starting ApplicationContext,提示Port 8080 was already in use。排查方式:确认是不是自己上一次启动的应用没关干净,在IDE里看有没有残留的Java进程;再确认有没有其他服务占了8080端口。临时解决是在启动参数加--server.port=8081或者直接在配置文件里改端口。
内存溢出相对隐蔽一些,表现为启动卡住不动或者运行一段时间后抛OutOfMemoryError。内嵌Tomcat默认在容器内工作,堆内存大小取决于Java进程的启动参数。生产部署时建议在启动脚本里显式设置JVM参数,比如:
java -Xms512m -Xmx1024m -jar demo.jar-Xms是初始堆大小,-Xmx是最大堆大小。这两个值要根据机器内存和业务量来调,不是越大越好。设得太大反而会拖慢启动速度,因为JVM需要分配并初始化那么大一片内存。
7. 从零到能用:一个完整的示例串联所有知识点
前面各节都是零散的,这一节把它们串成一个完整的“增删改查”小接口,模拟一个非常常见的用户管理场景。你可以直接照着敲一遍,跑通之后再回头看细节,理解会深很多。
先建一个用户实体:
package com.example.demo.entity; public class User { private Long id; private String name; private Integer age; // 构造器、getter/setter省略,IDE可以自动生成 // 建议至少保留无参构造器,JSON反序列化时需要 }再写一个Service类,先用一个本地集合模拟数据存储,后续换数据库时只改这一层:
package com.example.demo.service; import com.example.demo.entity.User; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.List; import java.util.Optional; @Service public class UserService { private final List<User> userStore = new ArrayList<>(); private Long idCounter = 1L; public User createUser(User user) { user.setId(idCounter++); userStore.add(user); return user; } public List<User> findAll() { return new ArrayList<>(userStore); } public Optional<User> findById(Long id) { return userStore.stream() .filter(u -> u.getId().equals(id)) .findFirst(); } }Controller层负责接收HTTP请求、调用Service、返回JSON:
package com.example.demo.controller; import com.example.demo.entity.User; import com.example.demo.service.UserService; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/api/users") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping public List<User> listUsers() { return userService.findAll(); } @GetMapping("/{id}") public User getUser(@PathVariable Long id) { return userService.findById(id) .orElseThrow(() -> new RuntimeException("用户不存在")); } @PostMapping public User createUser(@RequestBody User user) { return userService.createUser(user); } }注意这里用的是构造器注入,UserService字段用final修饰。这种写法省掉了@Autowired注解,依赖关系一目了然。
可以配合curl命令来验证:
curl -X POST http://localhost:8080/api/users \ -H "Content-Type: application/json" \ -d '{"name":"张三","age":25}' curl http://localhost:8080/api/users第一个curl会返回创建成功的用户JSON,第二个curl返回包含该用户的列表。整个过程从启动到请求再到返回,完全没碰数据库,也没改端口,你体会到的就是Spring Boot“快速起一个Web服务”的能力。这个示例如果后面接入数据库,只需要增加对应的starter和配置,再把UserService里的集合换成Repository调用,Controller层一行都不用改——分层的好处就在这里。
打开http://localhost:8080/api/users,如果能看到列表数据,说明整个链路是通的。这个“Controller → Service → 数据存取”的三层结构,其实就是绝大多数Java后端项目的基础骨架,后面所有的复杂业务都是在这个骨架上长出来的。
我第一次用这个示例跑通的时候,最大的感触不是代码写得有多好,而是“原来不用配web.xml、不用装Tomcat、不用管jar包版本,一个main方法就能跑一个Web项目”。这种体验上的改变,远大于任何技术细节上的优化。后来我帮别人搭项目,也永远是从这样一个最小可运行的骨架开始,先把链路跑通,再逐步往上加功能。如果一上来就想着把所有框架整合齐了再启动,反而会遇到一堆彼此纠缠的配置问题,连错误根源都找不到。