【阶段 2】Spring Boot 入门:自动配置与配置管理

93次阅读
没有评论

版本:Spring Boot 3.2.x / JDK 17 / Maven 3.8+ 前置阶段:阶段
1(Spring Core) 预计篇幅:2200–3000 行


1. 导语

在阶段 1 里,你学会了 Spring 最核心的两件事:IoC
容器
负责创建和管理对象(Bean),AOP
负责横切逻辑(事务、日志、权限)。但你也一定发现了一个让人抓狂的问题:写一个能跑的
Web 项目,光 XML 配置就要写几百行——DispatcherServlet
要配、视图解析器要配、数据源要配、事务管理器要配……这些配置既重复又容易抄错。

Spring Boot 要解决的,就是「配置地狱」这个问题。
它的核心武器是一个叫「自动配置(Auto-configuration)」的机制:你只要引入一个
starter 依赖,Spring Boot 就会根据你 classpath 里有什么类、容器里缺什么
Bean,自动帮你把该配的东西配好。你写一个「Hello
World」接口,从建项目到跑起来,可以不超过 10 分钟。

但「能跑起来」和「懂它为什么能跑起来」是两回事。真实生产中,你一定遇到过这些情况:改了个配置项却不生效、引入的
starter 没按预期工作、线上日志乱成一团没法排查、打包后的 jar
换环境跑就报错。这些问题的根子,都在于你对「自动配置」和「配置管理」的理解停在表面。

学完本篇,你将能够:

  • 说清 @SpringBootApplication
    这个注解背后到底做了什么,以及为什么启动类必须放在包根目录;
  • --debug
    启动参数,像医生看化验单一样读懂「自动配置报告」,独立定位「配置不生效」的根因;
  • @ConfigurationProperties + @Validated
    写出类型安全、可校验的配置类,而不是到处散落 @Value
  • 配置一套生产级日志(滚动文件、按包隔离级别、traceId
    链路追踪);
  • 从零写一个自己的 starter,把通用能力沉淀成「引入即用」的模块;
  • 用 Maven 打出可执行 jar,并做到多环境切换与优雅停机。

本篇是后续所有阶段的地基:阶段 3 的 Web 开发、阶段 4 的数据访问、阶段
10 的分布式基础组件,全都建立在「懂自动配置 +
懂配置管理」之上。读完这一篇,你才算真正从「Spring 使用者」跨入「Spring
Boot 开发者」。


2. 学习目标与前置要求

学完本章,你能:

  1. 独立用 Spring Initializr 创建一个 Spring Boot 3.2.x 项目并跑通第一个
    REST 接口;
  2. 完整拆解 @SpringBootApplication
    的三个子注解,并能解释为什么启动类要放在根包;
  3. 说清自动配置的完整链路(@EnableAutoConfiguration
    AutoConfigurationImportSelector
    AutoConfiguration.imports@Conditional
    家族),并能用 --debug 排查「配置不生效」;
  4. 写出带 @Validated 校验的
    @ConfigurationProperties 配置类,并理解配置优先级、Profile
    切换、敏感信息外部化;
  5. 配置一份生产级 logback-spring.xml(滚动文件 + 按包隔离
    + MDC traceId);
  6. 独立创建一个自定义
    starter,并被打包部署用到多环境运行与优雅停机。

前置依赖

  • 必须完成阶段 1(Spring Core),理解 IoC 容器、Bean
    生命周期、@Component/@Configuration/@Bean
    注解;
  • 若对「Bean 的注册方式」或「@Conditional
    条件装配」不熟,建议先回看阶段 1 中「注解驱动的 Bean
    注册」与「条件化装配」两节;
  • 环境方面需要 JDK 17 与 Maven 3.8+(下一节给出精确版本)。

3. 环境准备

3.1 精确版本

组件 版本 说明
JDK 17(建议 17.0.10+) Spring Boot 3.x 最低要求 Java 17
Spring Boot 3.2.x(本文用 3.2.5) 3.x 使用 Jakarta EE 9+,包名从 javax.* 变为
jakarta.*
Maven 3.8.x / 3.9.x 用于构建与打包
IDE IntelliJ IDEA 2023.2+ 或 VS Code + Spring Boot Extension 二选一

3.2 初始化项目(两种方式)

方式 A:Spring Initializr 网页(推荐新手)

打开 start.spring.io,按下表填写:

Project Maven
Language Java
Spring Boot 3.2.5
Group com.example
Artifact demo
Dependencies Spring Web、Spring Boot DevTools(可选)、Validation

点击 GENERATE 下载压缩包,解压后用 IDE 打开。

方式 B:命令行创建(可复现)

# 确认 JDK 版本
java -version
# openjdk version "17.0.10" 2024-01-16

# 确认 Maven 版本
mvn -version
# Apache Maven 3.9.6

若没有网络访问 start.spring.io 的环境,可以用下面的
pom.xml 直接手建项目,效果完全一致。

3.3 项目结构(本篇统一使用)

demo
├── pom.xml
├── src
│   ├── main
│   │   ├── java
│   │   │   └── com
│   │   │       └── example
│   │   │           └── demo
│   │   │               ├── DemoApplication.java      # 启动类(放根包)
│   │   │               ├── controller/               # 接口层
│   │   │               ├── service/                  # 业务层
│   │   │               ├── config/                   # 配置类
│   │   │               ├── properties/               # @ConfigurationProperties 类
│   │   │               ├── exception/                # BizException / GlobalExceptionHandler
│   │   │               └── common/                   # ApiResult / ErrorCode
│   │   └── resources
│   │       ├── application.yml
│   │       ├── application-dev.yml
│   │       ├── application-prod.yml
│   │       └── logback-spring.xml
│   └── test
│       └── java/.../DemoApplicationTests.java

3.4 基础 pom.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>

    <!-- 关键:继承 Spring Boot 父 POM,统一管理所有依赖版本 -->
    <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>
    <description>Spring Boot basics tutorial</description>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <!-- Web:内嵌 Tomcat + Spring MVC + Jackson -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- 校验:@Validated / @NotBlank 等 JSR-380 注解需要它 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!-- 测试:JUnit 5 + MockMvc -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!-- 打可执行 fat jar 的关键插件(第 6 章详解) -->
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

为什么继承父 POM 如此重要?
spring-boot-starter-parent 里通过
dependencyManagement 锁定了上千个依赖的版本,你引入
spring-boot-starter-web
不需要写版本号,Spring Boot
会自动选一个经过兼容性测试的版本。一旦你不继承它、自己乱写版本,就容易出现
NoSuchMethodError 这类运行时冲突(第 6
章「常见坑」会再讲)。


4. 正文章节


第 1 章 快速开始与
@SpringBootApplication

1.1 第一个 REST 接口

先建立「能跑起来」的体感,再回头拆原理。在
com.example.demo.controller 包下新建
HelloController

package com.example.demo.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController                       // = @Controller + @ResponseBody,方法返回值直接写进响应体
@RequestMapping("/api/hello")
public class HelloController {

    @GetMapping
    public String hello() {
        // 先返回最简字符串,下一节再换成统一响应体 ApiResult
        return "Hello, Spring Boot!";
    }
}

启动类(Initializr 生成,位于 com.example.demo
包根):

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) {
        // run 方法做了三件事:创建容器 → 触发自动配置 → 启动内嵌 Tomcat
        SpringApplication.run(DemoApplication.class, args);
    }
}

运行并验证:

# 在项目根目录
mvn spring-boot:run

# 另开一个终端验证
curl http://localhost:8080/api/hello
# 输出:Hello, Spring Boot!

启动日志里最关键的几行:

Tomcat started on port 8080 (http) with context path ''
Started DemoApplication in 1.834 seconds (process running for 2.2)

看到 Started DemoApplication 就说明容器启动完成、内嵌
Tomcat 已监听 8080 端口。

1.2 @SpringBootApplication
三注解拆解

@SpringBootApplication
是一个组合注解,它等价于同时标注了下面三个注解(点进源码就能看到):

@SpringBootConfiguration        // ① 本质是 @Configuration,把启动类标记为配置类
@EnableAutoConfiguration        // ② 开启自动配置(本篇核心,第 2 章详解)
@ComponentScan                  // ③ 组件扫描
public @interface SpringBootApplication {
    // ...
}

逐个拆解:

@SpringBootConfiguration

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Configuration                  // 注意:它本身就是被 @Configuration 标注的
@Indexed
public @interface SpringBootConfiguration {
}

它的作用很单纯:把启动类本身变成一个配置类。所以你可以直接在启动类上写
@Bean 方法注册 Bean。Spring Boot 约定「一个应用只允许有一个
@SpringBootConfiguration」,多写会报错。

@EnableAutoConfiguration ——
本篇的灵魂,第 2 章用一整章讲。它通过
@Import(AutoConfigurationImportSelector.class) 把成百上千个
xxxAutoConfiguration 类加载进来,再由
@Conditional 家族决定谁生效。

@ComponentScan —— 阶段 1
学过的组件扫描。它默认扫描启动类所在包及其所有子包,把
@Component/@Service/@Controller/@Repository
标注的类注册成 Bean。

1.3
为什么启动类必须放在包根目录

这是 Spring Boot
新手最容易踩、也最容易在面试里被问的坑。看下面的错误结构:

com.example.demo
├── web
│   └── DemoApplication.java        # ❌ 启动类放在 web 子包里
├── controller
│   └── UserController.java         # 在 com.example.demo.controller 包

启动类在 com.example.demo.web 包,那
@ComponentScan 只会扫描 com.example.demo.web
及其子包,而 com.example.demo.controller
不在这个范围内。结果:UserController 根本没被注册成
Bean,启动时不会报错,但访问接口时抛:

Field userController in com.example.demo.web.DemoApplication required a bean of type
'com.example.demo.controller.UserController' that could not be found.

修复方式有三种(按推荐度排序):

  1. 把启动类上移到根包
    com.example.demo(最推荐,一劳永逸);
  2. @SpringBootApplication 上补
    @ComponentScan(basePackages = "com.example.demo")
    显式指定扫描范围;
  3. @Import(UserController.class)
    显式导入单个类(适合个别跨包的第三方类)。

关键点提示@SpringBootApplication
@ComponentScan没有指定 basePackages
,它使用「标注类的包」作为默认扫描根。所以「启动类放根包」这个约定,本质是让默认扫描范围覆盖你的整个业务代码。

1.4 换成统一响应体 ApiResult

生产接口不会直接返回裸字符串。按系列统一约定,把返回值包成
ApiResult

package com.example.demo.common;

import lombok.Data;
import org.slf4j.MDC;

@Data
public class ApiResult<T> {
    private int code;        // 0=成功,非 0=错误码
    private String message;  // 提示信息
    private T data;          // 业务数据
    private String traceId;  // 链路追踪 id

    public static <T> ApiResult<T> ok(T data) {
        ApiResult<T> r = new ApiResult<>();
        r.setCode(ErrorCode.SUCCESS.getCode());
        r.setMessage(ErrorCode.SUCCESS.getMessage());
        r.setData(data);
        r.setTraceId(MDC.get("traceId"));
        return r;
    }
    public static <T> ApiResult<T> ok() {
        return ok(null);
    }
    public static <T> ApiResult<T> fail(int code, String message) {
        ApiResult<T> r = new ApiResult<>();
        r.setCode(code);
        r.setMessage(message);
        r.setTraceId(MDC.get("traceId"));
        return r;
    }
    public static <T> ApiResult<T> fail(ErrorCode ec) {
        return fail(ec.getCode(), ec.getMessage());
    }
}

配套的错误码枚举与业务异常(同样为系列公共类):

package com.example.demo.common;

/** 统一错误码(基础码数值全系列固定不变,各篇可扩展) */
public enum ErrorCode {
    SUCCESS(0, "success"),
    PARAM_ERROR(40001, "参数错误"),
    UNAUTHORIZED(40101, "未登录或登录已过期"),
    FORBIDDEN(40301, "无权限访问"),
    USER_NOT_FOUND(40401, "用户不存在"),
    SYSTEM_ERROR(50000, "系统繁忙,请稍后重试"),
    ;

    private final int code;
    private final String message;

    ErrorCode(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public int getCode() { return code; }
    public String getMessage() { return message; }
}
package com.example.demo.exception;

import com.example.demo.common.ErrorCode;

/** 统一业务异常:业务代码主动抛出,由 GlobalExceptionHandler 统一转成 ApiResult */
public class BizException extends RuntimeException {
    private final int code;

    public BizException(ErrorCode ec) {
        super(ec.getMessage());
        this.code = ec.getCode();
    }

    public BizException(int code, String message) {
        super(message);
        this.code = code;
    }

    public int getCode() { return code; }
}
package com.example.demo.exception;

import com.example.demo.common.ApiResult;
import com.example.demo.common.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/** 全局异常处理器:统一把异常转成 ApiResult,避免堆栈直接暴露给前端 */
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // 业务异常:code/message 透传给前端
    @ExceptionHandler(BizException.class)
    public ApiResult<Void> handleBiz(BizException e) {
        log.warn("业务异常 code={}, message={}", e.getCode(), e.getMessage());
        return ApiResult.fail(e.getCode(), e.getMessage());
    }

    // 参数校验异常:取第一个校验失败的字段提示
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ApiResult<Void> handleValid(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldErrors().stream()
            .findFirst()
            .map(f -> f.getField() + " " + f.getDefaultMessage())
            .orElse(ErrorCode.PARAM_ERROR.getMessage());
        return ApiResult.fail(ErrorCode.PARAM_ERROR.getCode(), msg);
    }

    // 兜底异常:对外返回模糊提示,详细堆栈只进日志,防止泄露内部结构
    @ExceptionHandler(Exception.class)
    public ApiResult<Void> handleOther(Exception e) {
        log.error("系统异常", e);
        return ApiResult.fail(ErrorCode.SYSTEM_ERROR);
    }
}

改造后的 HelloController

@RestController
@RequestMapping("/api/hello")
public class HelloController {

    @GetMapping
    public ApiResult<String> hello() {
        return ApiResult.ok("Hello, Spring Boot!");
    }
}

再次访问,返回:

{"code":0,"message":"success","data":"Hello, Spring Boot!","traceId":null}

traceId 现在还是 null,等第 4 章配好 MDC
过滤器后就有值了。

本章小结@SpringBootApplication =
@SpringBootConfiguration +
@EnableAutoConfiguration +
@ComponentScan,启动类放根包是为了让默认组件扫描覆盖全部业务代码;接口返回统一
ApiResult 是贯穿全系列的规范。


第 2 章
自动配置原理(面试必考,务必吃透)

这一章是整篇的重头戏。先记住一句话:自动配置不是「魔法」,它是一套可预测的、基于
classpath 和已有 Bean 的条件判断机制。

你要做的不是背每个配置类,而是掌握它的判断流程,从而能读懂它的「体检报告」。

2.1 完整链路

自动配置的触发链路由四步组成:

启动类上的 @EnableAutoConfiguration
        │
        ▼
@Import(AutoConfigurationImportSelector.class)   // ① 通过 @Import 引入一个"选择器"
        │
        ▼
AutoConfigurationImportSelector.selectImports()
        │  ② 读取 classpath 下的文件
        ▼
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
        │  ③ 拿到一长串 xxxAutoConfiguration 类名列表(Spring Boot 3.2 约 140+ 个)
        ▼
对每个 AutoConfiguration 类逐个判断其上的 @Conditional 注解
        │  ④ 满足全部条件 → 生效(Positive match);任一不满足 → 跳过(Negative match)
        ▼
生效的配置类向容器注册默认 Bean(如 DispatcherServlet、ObjectMapper、DataSource)

第 ② 步是 Spring Boot 3.x 的关键变化:老版本(2.7
之前)用的是 META-INF/spring.factories 文件,而 3.x
改成了
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
。如果你在排障时翻旧博客看到
spring.factories,注意那是过时写法,自定义 starter 时(第 5
章)也一定要用新文件名。

你可以在本地依赖里看到这个文件:

# 找到 spring-boot-autoconfigure 的 jar,解压后看这个文件
jar xf spring-boot-autoconfigure-3.2.5.jar 
    META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

cat META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
# 输出片段:
# org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration
# org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
# org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration
# ...(约 140+ 行)

2.2 @Conditional
家族:生效判断的规则

每个 xxxAutoConfiguration
类上,都会叠一堆条件注解。判断流程(面试常让画这个图):

某个 AutoConfiguration 类被加载
        │
        ▼
@ConditionalOnClass        ── 不满足 ──▶ 跳过(类不在 classpath,如没引 starter-web 就没有 DispatcherServlet)
        │ 满足
        ▼
@ConditionalOnBean         ── 不满足 ──▶ 跳过(它依赖的 Bean 不存在)
        │ 满足
        ▼
@ConditionalOnMissingBean  ── 已存在 ──▶ 跳过(用户已自定义同类 Bean,自动配置尊重用户、让位)
        │ 不存在
        ▼
@ConditionalOnProperty     ── 不满足 ──▶ 跳过(配置开关为 false / 未满足 havingValue)
        │ 满足
        ▼
@ConditionalOnWebApplication ─ 不满足 ─▶ 跳过(当前不是目标 Web 环境,如缺 Servlet)
        │ 满足
        ▼
✅ 该配置类生效,向容器注册默认 Bean

常用的条件注解一览:

注解 生效条件 典型用途
@ConditionalOnClass classpath 存在指定类 有没有引对应依赖
@ConditionalOnMissingClass classpath 不存在指定类 排除冲突场景
@ConditionalOnBean 容器存在指定 Bean 依赖别的自动配置先就绪
@ConditionalOnMissingBean 容器不存在指定 Bean 用户自定义了就让位
@ConditionalOnProperty 配置项存在且满足 havingValue 功能开关
@ConditionalOnWebApplication 是 Web 应用(Servlet / Reactive) 区分 Web 与纯后台
@ConditionalOnExpression SpEL 表达式为 true 复杂组合判断

两个关键细节

  1. @ConditionalOnClass
    是通过字节码(ASM)判断的,不是反射加载
    。也就是说它只看「这个类的字节码文件在不在
    classpath」,不会真的去初始化那个类,避免类加载的副作用(比如静态初始化抛异常)。所以
    @ConditionalOnClass(name = "com.xxx.SomeClass")
    @ConditionalOnClass(SomeClass.class)
    的区别只是写法,前者可以避开编译期必须能解析到该类的问题。

  2. @ConditionalOnMissingBean
    是「自动配置让位用户」的核心机制
    。它保证:只要你自己定义了一个同类型的
    Bean(比如自定义
    ObjectMapper),自动配置就不会再覆盖你。这就是为什么「自定义
    Bean 覆盖默认配置」能工作的原因。

2.3
实例拆解:WebMvcAutoConfiguration 为什么生效

引入 spring-boot-starter-web 后,「零配置」就能跑 Web
应用,本质是下面这条判断链全部成立。看
WebMvcAutoConfiguration 源码(节选):

@AutoConfiguration
@ConditionalOnWebApplication(type = Type.SERVLET)       // ① 是 Servlet Web 环境
@ConditionalOnClass({ Servlet.class, DispatcherServlet.class, WebMvcConfigurer.class }) // ② 相关类在 classpath
@ConditionalOnMissingBean(WebMvcConfigurationSupport.class) // ③ 用户没自己定义 WebMvcConfigurationSupport
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE + 10)
public class WebMvcAutoConfiguration {
    // ...
    @Bean
    @ConditionalOnMissingBean
    public ViewResolver mvcViewResolver(...) { /* 默认视图解析器 */ }

    @Bean
    @ConditionalOnMissingBean
    public ObjectMapper jacksonObjectMapper(...) { /* 默认 JSON 序列化器 */ }
}

逐条对号入座:

  • ① 成立:你跑的是内嵌 Tomcat 的 Servlet 应用;
  • ② 成立spring-boot-starter-web
    传递依赖了 spring-webmvc
    spring-boot-starter-tomcat,所以
    DispatcherServletServlet 都在
    classpath;
  • ③ 成立:你一般不会自己去定义
    WebMvcConfigurationSupport(定义了就表示「我要完全接管
    WebMvc 配置」,自动配置会整体让位)。

于是 WebMvcAutoConfiguration 生效,向容器注册了默认的
DispatcherServletViewResolverObjectMapper
等 Bean——这就是「零配置可用」的全部秘密。

反过来想:假如你没有引入
spring-boot-starter-web,②
不成立,WebMvcAutoConfiguration 就是一个 Negative
match
,容器里就不会有
DispatcherServlet,自然也就没有 Web 能力。「引入
starter = 凑齐 classpath 条件 =
触发对应自动配置」
,这是贯穿全篇的核心心智模型。

2.4 用 –debug
排查「配置不生效」(核心实操)

当「某个功能没按预期生效」时,不要靠猜,直接看自动配置报告。启动时加
--debug

# 方式一:命令行参数
java -jar app.jar --debug

# 方式二:mvn 启动时
mvn spring-boot:run -Dspring-boot.run.arguments=--debug

# 方式三:写进 application.yml(会一直打印,生产不建议常开)
debug: true

加了 --debug 后,启动日志里会多出一段 CONDITIONS
EVALUATION REPORT
,核心是两部分:

============================
CONDITIONS EVALUATION REPORT
============================

Positive matches:          # ✅ 生效的自动配置(及它为什么生效)
-----------------
   WebMvcAutoConfiguration matched:
      - @ConditionalOnWebApplication (required) found 'session' scope
      - @ConditionalOnClass found required classes
        'org.springframework.web.servlet.DispatcherServlet',
        'jakarta.servlet.Servlet'
      - @ConditionalOnMissingBean (types: WebMvcConfigurationSupport; ...)
        did not find any beans

Negative matches:          # ❌ 被跳过的自动配置(及它为什么没生效)
-----------------
   DataSourceAutoConfiguration:
      Did not match:
         - @ConditionalOnClass did not find required class 'javax.sql.DataSource'
           (你没引 spring-boot-starter-jdbc,所以数据源不自动配)

   RedisAutoConfiguration:
      Did not match:
         - @ConditionalOnClass did not find required class
           'org.springframework.data.redis.core.RedisOperations'

读报告的三步法

  1. 先想「我期望生效的功能对应哪个
    AutoConfiguration」
    ——不确定类名就在报告里按关键词搜(如
    WebJdbcRedis);
  2. 它出现在 Positive 还是 Negative? 在 Negative
    就看它 Did not match 里列的是哪一条条件;
  3. 对症下药
    • @ConditionalOnClass did not find required class xxx
      少依赖,补上对应 starter;
    • @ConditionalOnMissingBean ... found beans of type xxx
      你自己定义的 Bean 把默认的顶掉了(多半是误写);
    • @ConditionalOnProperty ... did not find property
      配置项缺失或值不对,检查 yml。

关键点提示:这份报告是排查「自动配置不生效」的第一现场证据,比问人、比搜博客都快且准确。养成「先看报告、再动手」的习惯,能省掉大量无效试错。

2.5 自己写一个条件
Bean,验证条件注解

读懂了原理,动手验证一下。写一个「只有存在 redis
相关类才注册」的演示 Bean(只是演示,别真当生产用):

package com.example.demo.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisOperations;

@Configuration
@Slf4j
public class DemoConditionConfig {

    // 只有当 classpath 存在 RedisOperations(即引了 redis starter)、
    // 且容器里还没有同名 Bean、且 demo.redis.enabled 为 true 时,才注册
    @Bean
    @ConditionalOnClass(RedisOperations.class)
    @ConditionalOnMissingBean(name = "demoRedisMarker")
    @ConditionalOnProperty(prefix = "demo.redis", name = "enabled", havingValue = "true", matchIfMissing = true)
    public String demoRedisMarker() {
        log.info("demoRedisMarker 已注册:说明 redis 条件满足");
        return "redis-present";
    }
}

分别做两个实验:

  • 不引 redis 依赖:用 --debug 启动,在
    Negative matches 里能看到 demoRedisMarker
    @ConditionalOnClass 不满足而被跳过;
  • 引入 spring-boot-starter-data-redis
    :同样的启动,demoRedisMarker 进入 Positive
    matches。

2.6
排除与排序:进一步控制自动配置

(1)AutoConfigurationImportSelector
内部到底做了什么

2.1 节的链路里,selectImports()
只是一句话,展开后它内部的 getAutoConfigurationEntry()
大致做了五步:

① getCandidateConfigurations()
   读所有 jar 里 META-INF/spring/...AutoConfiguration.imports,收集候选类名
        │
        ▼
② removeDuplicates()
   去重(多个 jar 可能重复注册同一个类)
        │
        ▼
③ getExclusions()
   应用排除项:@SpringBootApplication(exclude=...) 或 spring.autoconfigure.exclude 配置
        │
        ▼
④ sortAutoConfigurations()
   按 @AutoConfigureOrder / @AutoConfigureBefore / @AutoConfigureAfter 排序
        │
        ▼
⑤ 逐个交给条件评估器(ConditionEvaluator),生成 Positive/Negative 报告

(2)三种排除自动配置的方式(当你不想让某个自动配置生效时)

// 方式一:注解排除(精确到类)
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
public class DemoApplication {
    // ...
}
# 方式二:配置排除(不用改代码,运维可临时禁用)
spring:
  autoconfigure:
    exclude:
      - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
      - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
// 方式三:@EnableAutoConfiguration 上排除(与方式一等效,写法不同)
@EnableAutoConfiguration(exclude = DataSourceAutoConfiguration.class)

(3)控制自动配置的先后顺序

多个自动配置类之间有依赖关系(比如「数据源」要先于「JPA」配置)。Spring
Boot 用三个注解声明顺序:

@AutoConfiguration
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)          // 数字越小越先加载
@AutoConfigureBefore(WebMvcAutoConfiguration.class)      // 声明自己必须在某类之前
@AutoConfigureAfter(DataSourceAutoConfiguration.class)   // 声明自己必须在某类之后
public class MyAutoConfiguration {
    // ...
}

自定义 starter 时,如果你的自动配置依赖别的自动配置先就绪(例如要用到
DataSource),务必用
@AutoConfigureAfter(DataSourceAutoConfiguration.class)
声明顺序,否则可能拿到未就绪的依赖。

关键点提示:排除(exclude)和排序(order)是自动配置的「两个旋钮」——默认值覆盖不了你的需求时,先用
exclude 关掉默认、再用自己的 @Bean +
排序补上,这比硬改框架源码干净得多。

本章小结:自动配置 =
@EnableAutoConfiguration 拉取
AutoConfiguration.imports 里的配置类列表,再逐个用
@Conditional 家族做「classpath 有没有类、容器缺不缺
Bean、配置开关开没开」的条件判断;--debug
报告是排查一切「配置不生效」的第一现场;用 exclude
关默认、用排序注解控制先后。


第 3 章 配置管理

自动配置解决了「默认怎么配」,但真实项目里有大量业务自有配置(对象存储地址、线程池大小、第三方密钥)。这一章讲清
Spring Boot 的配置体系:怎么写、谁优先、怎么安全地读。

3.1 YAML 语法要点

Spring Boot 同时支持 application.properties
application.yml生产统一推荐
yml
(层级清晰、支持多行、不易写错)。核心语法:

# ① 层级用缩进表示(不能用 Tab,必须空格,且缩进一致)
server:
  port: 8080          # 冒号后必须有一个空格

# ② 三种值类型
app:
  name: order-service      # 字符串
  thread-pool: 16          # 数字(会被绑定成 int)
  feature-enabled: true    # 布尔

# ③ 数组写法(两种等价)
app:
  tags:
    - java
    - spring
  # 或行内:tags: [java, spring]

# ④ 占位符引用
app:
  desc: "服务 ${app.name} 的配置"    # 引用同文件里的值

# ⑤ 多行字符串(保留换行)
app:
  banner: |
    第一行
    第二行

注意thread-pool
这种中划线命名,会通过「松散绑定」对应 Java 字段
threadPool(下文 3.3 详讲)。

3.2
配置优先级(必须记住的排序)

同一个配置项可以有多个来源,Spring Boot
固定优先级从高到低取值(高覆盖低):

① 命令行参数                 --server.port=9090
② Java 系统属性               -Dserver.port=9090
③ 操作系统环境变量            SERVER_PORT=9090
④ application-{profile}.yml  指定环境的配置文件
⑤ application.yml             主配置文件
⑥ application.properties      主配置文件(同位置下 yml 优先于 properties)
⑦ 代码里 @PropertySource      自定义属性源
⑧ 默认值                      @ConfigurationProperties 里的字段默认值 / 代码硬编码

补充说明:完整优先级还有「当前目录的 config 子目录
> 当前目录 > classpath 的 config 子目录 > classpath
根」这一层「文件位置优先级」,但日常开发记住上面 8 级足以解决 99%
的问题。另外老版本用 bootstrap.ymlSpring Boot 3.x
已废弃
,改用
spring.config.import(如需配置中心,阶段 10 会讲)。

验证优先级(动手)

# application.yml 里写 server.port: 8080
# 用命令行参数覆盖成 9090
mvn spring-boot:run -Dspring-boot.run.arguments=--server.port=9090
# 或直接
java -jar app.jar --server.port=9090

# 观察启动日志:Tomcat started on port 9090

再用环境变量覆盖验证:

export SERVER_PORT=7070
java -jar app.jar        # 此时命令行 > 环境变量,但这里没传命令行,所以是 7070

为什么优先级要设计成这样?
因为「越靠近运行时、越晚出现的值,越应该覆盖更早的默认值」——开发者在 yml
里写默认值,运维在部署时用环境变量/命令行做最后覆盖,双方不用改代码也不用改文件。

3.3 @Value vs
@ConfigurationProperties

读配置有三种方式,生产选择有明确结论:

方式 场景 优点 缺点
@Value("${key}") 读单个配置 简单直接 类型不安全、无校验、不支持松散绑定、散落各处难维护
@ConfigurationProperties 读一组相关配置 类型安全、可校验、支持松散绑定、可复用 需要建一个类 + 注册
Environment 接口 运行时动态探测 灵活 少用,容易绕过类型安全

先看 @Value 的局限:

@Service
public class OssService {

    @Value("${app.oss.endpoint}")      // 散落、无校验、类型不安全
    private String endpoint;

    @Value("${app.oss.max-size:1048576}")  // 冒号后面是默认值
    private int maxSize;               // 若配置写成了 "abc",启动直接报 NumberFormatException

    // 若某个 key 忘配且没默认值,启动直接 fail-fast
}

结论@Value
只适合读零散的、单个的配置;凡是「一组有明确含义的配置」(如
app.oss),一律用
@ConfigurationProperties。这是本篇反复强调的规范,也是面试对比题的标准答案。

3.4 生产级
@ConfigurationProperties + @Validated

完整配置类(对象存储 OSS 为例):

package com.example.demo.properties;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

/**
 * 绑定 application.yml  app.oss.* 前缀的配置。
 *  @Validated + JSR-380 注解,启动时校验,配置错了 fail-fast 而不是运行时才炸。
 */
@Data
@Validated
@ConfigurationProperties(prefix = "app.oss")
public class OssProperties {

    /** 对象存储 endpoint,必填 */
    @NotBlank(message = "app.oss.endpoint 不能为空")
    private String endpoint;

    /** accessKey,必填 */
    @NotBlank(message = "app.oss.access-key 不能为空")
    private String accessKey;

    /** secretKey 属于敏感信息,必填但生产从环境变量注入(见 3.6) */
    @NotBlank(message = "app.oss.secret-key 不能为空")
    private String secretKey;

    /** bucket 名,必填 */
    @NotBlank
    private String bucket;

    /** 上传超时(秒),给默认值并限制范围 */
    @Min(1)
    @Max(60)
    private int timeoutSeconds = 10;

    /** 是否启用(功能开关) */
    private boolean enabled = true;

    /** 连接池大小,必填且不小于 1 */
    @NotNull
    @Min(1)
    private Integer poolSize = 8;
}

启用配置类(两种方式,推荐第一种):

package com.example.demo.config;

import com.example.demo.properties.OssProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

/**
 * 方式一:用 @EnableConfigurationProperties 显式注册配置类。
 * 好处:配置类不需要加 @Component,职责更纯粹,也便于测试时选择性启用。
 */
@Configuration
@EnableConfigurationProperties(OssProperties.class)
public class AppConfig {
}
// 方式二(等效):在配置类上加 @ConfigurationPropertiesScan,
// 会自动扫描指定包下所有 @ConfigurationProperties 类
// @Configuration
// @ConfigurationPropertiesScan("com.example.demo.properties")
// public class AppConfig { }

对应的 yml:

app:
  oss:
    endpoint: https://oss-cn-hangzhou.aliyuncs.com
    access-key: ${OSS_ACCESS_KEY}        # 从环境变量读,避免硬编码
    secret-key: ${OSS_SECRET_KEY}        # 敏感信息外部化
    bucket: my-bucket
    timeout-seconds: 20
    pool-size: 16

使用方(构造器注入 + final,系列统一规范):

package com.example.demo.service;

import com.example.demo.properties.OssProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor    // 生成包含 final 字段的构造器,实现构造器注入
@Slf4j
public class OssService {

    private final OssProperties ossProperties;   // 构造器注入,保证依赖不可变

    public String buildUploadUrl() {
        // 使用配置拼出上传地址
        return ossProperties.getEndpoint() + "/" + ossProperties.getBucket();
    }

    public void printConfig() {
        // 日志里绝不打印 secretKey 这类敏感字段
        log.info("OSS 配置:endpoint={}, bucket={}, timeout={}s, enabled={}",
                ossProperties.getEndpoint(),
                ossProperties.getBucket(),
                ossProperties.getTimeoutSeconds(),
                ossProperties.isEnabled());
    }
}

关键点提示(松散绑定):yml 里的
access-keysecret-keytimeout-seconds
会自动绑定到 Java 字段
accessKeysecretKeytimeoutSeconds。这是
@ConfigurationProperties 独有的「松散绑定(Relaxed
Binding)」能力,@Value
没有——@Value("${app.oss.access-key}") 必须逐字一致,而
@ConfigurationProperties
允许中划线、下划线、大小写驼峰自由对应。

关键点提示(元数据提示):想让 IDE 在写 yml
时自动补全
app.oss.*,需引入配置处理器(只参与编译,不打进运行时包):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-configuration-processor</artifactId>
    <optional>true</optional>
</dependency>

校验失败的报错(故意把 endpoint
留空再启动):

Binding to target [Bindable@... type = com.example.demo.properties.OssProperties] failed:
    Property: app.oss.endpoint
    Value: null
    Reason: app.oss.endpoint 不能为空

启动直接失败(fail-fast),而不是上线后用到才炸——这正是
@ConfigurationProperties + 校验的价值。

3.5 多环境 Profile

一套代码,跑开发/测试/生产多个环境,靠 Profile 切换:

# application.yml(所有环境共享的公共配置 + 默认激活哪个环境)
spring:
  profiles:
    active: dev          # 默认 dev;生产用环境变量 SPRING_PROFILES_ACTIVE 覆盖
  application:
    name: demo

server:
  port: 8080
# application-dev.yml(开发环境)
server:
  port: 8080
logging:
  level:
    com.example: DEBUG       # 开发环境业务包打 DEBUG,便于排查
# application-prod.yml(生产环境)
server:
  port: 8080
logging:
  level:
    com.example: INFO        # 生产只打 INFO,减少日志量
app:
  oss:
    endpoint: https://oss-prod.example.com

切换与验证:

# 方式一:yml 里 active(默认 dev)
# 方式二:命令行
java -jar app.jar --spring.profiles.active=prod
# 方式三:环境变量(生产标准做法,运维不改文件)
export SPRING_PROFILES_ACTIVE=prod
java -jar app.jar

合并规则application.yml
是公共底座,application-{profile}.yml
是特定环境的覆盖层,两者合并而非替换,profile
文件里同名的键会覆盖公共文件里的值。

3.6
敏感信息外部化(安全意识贯穿全篇)

铁律:数据库密码、AK/SK、JWT 密钥等敏感信息,绝不硬编码进 yml
提交到 Git 仓库。
一旦进仓库,即使后来删除,Git
历史里依然可查,等于永久泄露。正确做法:

# ❌ 错误示范:硬编码
spring:
  datasource:
    password: "MyP@ssw0rd123"

# ✅ 正确做法一:占位符引用环境变量
spring:
  datasource:
    password: ${DB_PASSWORD}

# ✅ 正确做法二:引用 JVM 系统属性
spring:
  datasource:
    password: ${db.password}

部署时由运维注入:

# 环境变量注入
export DB_PASSWORD='MyP@ssw0rd123'
java -jar app.jar

# 或命令行参数注入(优先级最高,但会出现在进程列表里,需权衡)
java -jar app.jar --spring.datasource.password='MyP@ssw0rd123'

# 或 JVM 系统属性
java -jar -Ddb.password='MyP@ssw0rd123' app.jar

进阶:大型生产会用配置中心(Nacos/Apollo/Vault)统一管理并加密,阶段
10 会讲。现阶段先做到「敏感信息 100% 外部化」,就已超过多数团队。

3.7
进阶:构造器绑定、嵌套配置与 spring.config.import

(1)构造器绑定(Spring Boot 3
推荐的不可变写法)

前面 OssProperties@Data +
setter,这是最通用、兼容性最好的写法。但 Boot 3
更推荐构造器绑定:配置对象一旦创建就不可变(final
字段),且校验注解可直接打在构造参数上。

package com.example.demo.properties;

import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

/**
 * 构造器绑定的不可变配置类。
 * Spring Boot 3.x 中:只要类只有一个构造器,就自动启用构造器绑定,无需 @ConstructorBinding
 */
@Validated
@ConfigurationProperties(prefix = "app.oss")
public class ImmutableOssProperties {

    private final String endpoint;
    private final String accessKey;
    private final String bucket;
    private final int timeoutSeconds;

    // 校验注解直接打在构造参数上,绑定 + 校验一步完成
    public ImmutableOssProperties(@NotBlank String endpoint,
                                  @NotBlank String accessKey,
                                  @NotBlank String bucket,
                                  int timeoutSeconds) {
        this.endpoint = endpoint;
        this.accessKey = accessKey;
        this.bucket = bucket;
        this.timeoutSeconds = timeoutSeconds <= 0 ? 10 : timeoutSeconds; // 缺省兜底
    }

    public String getEndpoint() { return endpoint; }
    public String getAccessKey() { return accessKey; }
    public String getBucket() { return bucket; }
    public int getTimeoutSeconds() { return timeoutSeconds; }
}

何时用哪个?团队协作、字段多、后续要扩展的场景,@Data +
setter
更省事;追求不可变、字段稳定、并发安全的场景,构造器绑定更优。两者都是
@ConfigurationProperties,本文后续仍以 @Data
风格为主(与系列公共约定一致)。

(2)嵌套配置

真实配置常有层级(如 OSS
下面还有重试策略)。@ConfigurationProperties
天然支持嵌套对象:

app:
  oss:
    endpoint: https://oss.example.com
    retry:
      max-attempts: 3
      backoff-ms: 1000
@Data
@ConfigurationProperties(prefix = "app.oss")
@Validated
public class NestedOssProperties {

    @NotBlank
    private String endpoint;

    /** 嵌套对象:必须给默认实例,否则配置缺失时 retry  null */
    @Valid
    private Retry retry = new Retry();

    @Data
    public static class Retry {
        @Min(1)
        @Max(10)
        private int maxAttempts = 3;   // 默认重试 3 次
        @Min(0)
        private long backoffMs = 1000; // 退避 1 秒
    }
}

关键点提示:嵌套对象要加 @Valid
才会触发内层校验;字段要给 = new Retry() 默认实例,否则 yml
里没配 retry 时该字段为 null,用到就 NPE。

(3)松散绑定(Relaxed Binding)完整规则

@ConfigurationProperties
绑定字段时,大小写、中划线、下划线、驼峰写法等价:

配置写法 绑定到 Java 字段 说明
app.thread-pool threadPool 中划线 → 驼峰
app.thread_pool threadPool 下划线 → 驼峰
app.THREAD_POOL threadPool 大写 → 驼峰
app.threadPool threadPool 完全一致

这也是 @Value
做不到的:@Value("${app.thread-pool}") 一旦 key
大小写/分隔符对不上就直接解析失败。

(4)spring.config.import:引入额外配置源

一个 yml 写不下时,或想把敏感配置放在仓库外的文件,用
spring.config.import 引入:

# application.yml
spring:
  config:
    import:
      - "optional:file:/etc/app/oss-secret.yml"   # optional: 文件不存在也不报错
      - "classpath:common.yml"                     # 引入 classpath 下另一个配置文件

/etc/app/oss-secret.yml 内容(不进 Git
仓库,部署时由运维放置):

app:
  oss:
    access-key: AK-XXXX
    secret-key: SK-XXXX

spring.config.import 是 Spring Boot 2.4+
引入的新机制,取代了旧版的
bootstrap.yml
(那个在 Spring Cloud
老版本里用于配置中心,3.x 已废弃)。看到老项目里还有
bootstrap.yml 不要奇怪,但新项目请用
spring.config.import

本章小结:配置管理三件事——yml
写配置、记优先级(命令行 > 系统属性 > 环境变量 > profile >
默认)、用 @ConfigurationProperties +
校验安全地读;敏感信息一律环境变量注入;进阶可掌握构造器绑定、嵌套配置、spring.config.import
引入外部配置源。


第 4 章 日志体系

日志是生产排查的命脉。Spring Boot 默认的日志方案是
SLF4J(门面接口)+ Logback(实现),你代码里只面向
SLF4J 编程,底层实现可替换。

4.1 日志级别与规范

级别从低到高:

TRACE < DEBUG < INFO < WARN < ERROR
  • TRACE/DEBUG:开发排查细节;
  • INFO:关键业务节点(下单成功、支付回调);
  • WARN:可恢复的异常(重试、降级);
  • ERROR:需要人工介入的错误(异常堆栈)。

生产日志铁律

  1. 一律用 Lombok 的 @Slf4j禁止
    System.out.println
    (无级别、无格式、性能差);
  2. 关键操作记入参/出参、耗时、traceId;敏感字段(密码、手机号、身份证)必须脱敏
  3. 异常日志必须带堆栈:log.error("订单处理失败", e),绝不要
    log.error(e.getMessage()) 丢堆栈。

4.2 快速配置(写进 yml 即可)

不写任何 XML 时,Spring Boot 有一套默认日志格式,你也可以在 yml
里做简单定制:

logging:
  level:
    root: INFO                       # 全局默认级别
    com.example.demo: DEBUG          # 自己的包打 DEBUG
    org.springframework: WARN        # 第三方框架降噪,只打 WARN
  file:
    name: logs/demo.log              # 输出到文件(简单场景够用)

但 yml 配置能力有限(不能按大小/日期滚动、不能自定义
pattern),生产必须上
logback-spring.xml

4.3 生产级 logback-spring.xml

文件名必须是 logback-spring.xml(带 -spring
后缀),这样才能用 <springProfile> 按环境切换:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <!-- 定义两个可复用变量:日志目录和文件名 -->
    <property name="LOG_DIR" value="logs"/>
    <property name="APP_NAME" value="demo"/>

    <!-- ① 控制台输出:开发时人眼查看 -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <!-- traceId 从 MDC 取;%X{traceId} 输出链路追踪 id -->
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{50} - %msg%n</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- ② 滚动文件:按日期 + 大小切分,生产回溯靠它 -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_DIR}/${APP_NAME}.log</file>
        <!-- 按时间滚动,一天一个文件,超过 200MB 再切一个 .%i 序号文件 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${LOG_DIR}/${APP_NAME}-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <maxFileSize>200MB</maxFileSize>
            <maxHistory>30</maxHistory>          <!-- 保留 30 天 -->
            <totalSizeCap>10GB</totalSizeCap>    <!-- 总大小上限,防磁盘打满 -->
        </rollingPolicy>
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{50} - %msg%n</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- ③ 错误日志单独归档:方便快速定位 ERROR -->
    <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_DIR}/${APP_NAME}-error.log</file>
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>ERROR</level>
        </filter>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${LOG_DIR}/${APP_NAME}-error-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{50} - %msg%n</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- ④ 按包隔离级别:自己的包 DEBUG,第三方框架降噪 -->
    <logger name="com.example.demo" level="DEBUG"/>
    <logger name="org.springframework" level="WARN"/>
    <logger name="org.apache.tomcat" level="WARN"/>

    <!-- ⑤ 根 logger:默认 INFO,同时挂控制台和文件 -->
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="FILE"/>
        <appender-ref ref="ERROR_FILE"/>
    </root>

    <!-- ⑥ 按环境切换:开发环境控制台打详细,生产环境不打控制台(交给采集器) -->
    <springProfile name="dev">
        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
            <appender-ref ref="FILE"/>
        </root>
    </springProfile>
</configuration>

逐块说明

  • %X{traceId}:从 MDC(Mapped Diagnostic
    Context)取 traceId
    变量,配合下一节的过滤器实现全链路追踪;
  • SizeAndTimeBasedRollingPolicy:同时按时间和大小滚动,日志文件会变成
    demo-2026-08-14.0.log.gz 这样的归档,自动压缩省空间;
  • <springProfile>:这是文件名带
    -spring 后缀才能用的特性,可以在不同环境用不同
    appender,而无需维护多份 XML;
  • 按包隔离:自己的业务包打 DEBUG
    方便排查,org.springframework 等框架包降到
    WARN,避免启动日志刷屏淹没关键信息。

4.4 MDC +
traceId:给每条请求一个「身份证」

ApiResult 里的 traceId 字段,以及日志里的
%X{traceId},都靠一个过滤器把 traceId 放进 MDC。实现:

package com.example.demo.common;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.UUID;

/**
 * 给每个请求生成唯一 traceId 并放入 MDC
 * MDC 是线程本地(ThreadLocal)的,同一请求的所有日志自动带上该 id
 * 从而能把一条请求链路的所有日志串起来。
 */
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)   // 最高优先级,保证最先进入、最后清理
public class TraceIdFilter extends OncePerRequestFilter {

    private static final String TRACE_ID = "traceId";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {
        try {
            // 优先沿用上游传入的 traceId(网关/调用方),没有则自己生成
            String traceId = request.getHeader("X-Trace-Id");
            if (traceId == null || traceId.isBlank()) {
                traceId = UUID.randomUUID().toString().replace("-", "");
            }
            MDC.put(TRACE_ID, traceId);
            // 把 traceId 回写到响应头,方便前端/调用方联调时对应
            response.setHeader("X-Trace-Id", traceId);
            filterChain.doFilter(request, response);
        } finally {
            // 关键:请求结束必须清理,否则线程复用(Tomcat 线程池)会串号
            MDC.remove(TRACE_ID);
        }
    }
}

改造 HelloController,加一条日志验证:

@RestController
@RequestMapping("/api/hello")
@Slf4j
public class HelloController {

    @GetMapping
    public ApiResult<String> hello() {
        log.info("收到 hello 请求");       // 这条日志会自动带上 traceId
        return ApiResult.ok("Hello, Spring Boot!");
    }
}

访问后看日志和响应:

2026-08-14 10:00:01.123 [http-nio-8080-exec-1] [3f2a9c...] INFO  c.e.d.controller.HelloController - 收到 hello 请求

响应 JSON 里的 traceId 也不再是 null:

{"code":0,"message":"success","data":"Hello, Spring Boot!","traceId":"3f2a9c..."}

关键点提示:MDC 底层是
ThreadLocal,所以必须在 finally
MDC.remove()
,否则 Tomcat
线程池复用线程时,下一个请求会串到上一个请求的
traceId,排查时反而制造混乱。同理,异步线程(@Async、自定义线程池)不会自动继承
MDC,需要显式传递,阶段 10 会再讲。

4.5 进阶:日志
Pattern 占位符、异步日志与结构化日志

(1)Pattern 占位符速查

前面 logback-spring.xml 里的 pattern 是
%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{50} - %msg%n,逐段含义:

占位符 含义 示例输出
%d{...} 时间(可自定义格式) 2026-08-14 10:00:01.123
%thread / %t 线程名 http-nio-8080-exec-1
%-5level 日志级别,左对齐占 5 位 INFO / ERROR
%logger{50} Logger 名,最多 50 字符 c.e.d.controller.HelloController
%msg / %m 日志正文 收到 hello 请求
%X{traceId} 从 MDC 取 traceId 3f2a9c...
%n 换行

(2)异步日志:高并发下降低日志写盘对业务的拖累

日志同步写盘在 QPS 很高时可能成为瓶颈。Logback 的
AsyncAppender 用一个队列 + 后台线程异步落盘:

<!-- 在已有 FILE appender 基础上包一层异步 -->
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
    <!-- 队列满时的策略:不丢弃 INFO 以下(discardingThreshold=0 表示不丢弃),避免丢关键日志 -->
    <discardingThreshold>0</discardingThreshold>
    <queueSize>512</queueSize>
    <includeCallerData>false</includeCallerData>
    <!-- 实际落盘仍交给同步的 FILE appender -->
    <appender-ref ref="FILE"/>
</appender>

然后在 <root> 里把 FILE 换成
ASYNC_FILE 即可。

权衡:异步日志提升了吞吐,但代价是——应用崩溃/宕机时,队列里还没落盘的日志会丢失。所以关键审计日志不建议走异步,或接受「极端情况下丢少量日志」的取舍。

(3)结构化(JSON)日志

传统文本日志靠 grep/awk
人工解析,量大时吃力。上了日志采集平台(ELK/Loki)后,更推荐直接输出
JSON,方便机器解析:

<appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>${LOG_DIR}/${APP_NAME}.json.log</file>
    <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
        <fileNamePattern>${LOG_DIR}/${APP_NAME}-%d{yyyy-MM-dd}.%i.json.log.gz</fileNamePattern>
        <maxFileSize>200MB</maxFileSize>
        <maxHistory>7</maxHistory>
    </rollingPolicy>
    <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>

使用 JSON 输出需要额外引入 logstash-logback-encoder
依赖(net.logstash.logback:logstash-logback-encoder:7.4)。没有采集平台的小项目不必上
JSON,普通文本 + traceId 已足够。

本章小结:日志 = @Slf4j 打点 +
logback-spring.xml
落盘(滚动、按包隔离、<springProfile> 按环境切换)+
MDC traceId 串链路;异常必须带堆栈,敏感字段必须脱敏;高并发用
AsyncAppender 降延迟,上采集平台可输出 JSON
结构化日志。


第 5 章 Starter
机制与自定义 Starter

5.1 Starter 是什么

Starter = 「依赖聚合 +
自动配置类」的打包
。它本身不含业务代码,只做两件事:

  1. 聚合依赖spring-boot-starter-web
    把你需要的
    spring-webmvcspring-boot-starter-tomcatjackson
    等十几个依赖一次性引入,且版本全部对齐;
  2. 触发自动配置:把 xxxAutoConfiguration
    类通过 AutoConfiguration.imports 注册进自动配置流程,配合
    @Conditional 判断何时生效。

命名规范(一眼区分官方/第三方):

  • 官方:spring-boot-starter-*(如
    spring-boot-starter-web-data-jpa-data-redis-validation-actuator-test);
  • 第三方/公司内部:*-spring-boot-starter(如
    mybatis-spring-boot-starterid-generator-spring-boot-starter)。

5.2 为什么需要自定义 Starter

当你有一个「跨项目复用的通用能力」时,不该在每个项目里复制粘贴。典型场景:ID
生成器、统一日志切面、第三方 SDK 的封装、内部 RPC
客户端、通用工具
。把这些沉淀成一个 starter,别的项目「引入依赖
+ 配几个属性」即可用,且通过 @ConditionalOnMissingBean
保留被覆盖的余地。

阶段 10 会讲分布式
ID(雪花算法)在分布式场景下的完整实现与时钟回拨处理,本章先做一个可运行的教学版,重点是讲清
starter 的结构与装配流程,不必深入雪花算法细节。

5.3 自定义「ID
生成器」Starter(完整步骤)

第 1 步:新建 Maven 模块
id-generator-spring-boot-starter
,目录结构:

id-generator-spring-boot-starter
├── pom.xml
└── src/main
    ├── java/com/example/idgenerator
    │   ├── IdGenerator.java                 # 对外接口
    │   ├── SnowflakeIdGenerator.java        # 默认实现(雪花算法简化版)
    │   ├── IdGeneratorProperties.java       # 配置属性类
    │   └── IdGeneratorAutoConfiguration.java # 自动配置类(核心)
    └── resources/META-INF/spring
        └── org.springframework.boot.autoconfigure.AutoConfiguration.imports  # 注册入口

第 2 步:写 pom.xml。关键点:只依赖
spring-boot-autoconfigure(轻量),不引入
spring-boot-starter-web
这种重依赖;spring-boot-configuration-processor
生成元数据:

<?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.2.5</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>id-generator-spring-boot-starter</artifactId>
    <version>1.0.0</version>
    <packaging>jar</packaging>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <!-- 自动配置核心:提供 @AutoConfiguration、@ConditionalOnXxx 等注解 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-autoconfigure</artifactId>
        </dependency>

        <!-- 校验注解(@Validated 等),scope provided 表示由使用方决定 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- 生成 yml 自动补全元数据,optional 不打进使用者运行时 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
</project>

第 3 步:写配置属性类

package com.example.idgenerator;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

/**
 * id.generator.* 配置项。
 * 全部给默认值,使用者「零配置」也能跑,想调再覆盖。
 */
@Data
@Validated
@ConfigurationProperties(prefix = "id.generator")
public class IdGeneratorProperties {

    /** 是否启用(功能总开关) */
    private boolean enabled = true;

    /** 雪花算法的工作机器 id(0~1023),多实例部署时每台必须不同 */
    @Min(0)
    @Max(1023)
    private long workerId = 1;

    /** 数据中心 id(0~31) */
    @Min(0)
    @Max(31)
    private long datacenterId = 1;
}

第 4 步:写对外接口 + 默认实现

package com.example.idgenerator;

/** 对外暴露的 ID 生成接口 */
public interface IdGenerator {
    /** 生成全局唯一、趋势递增的 64  ID */
    long nextId();
}
package com.example.idgenerator;

/**
 * 雪花算法(Snowflake)简化教学版。
 * 64 位结构:1 位符号位 + 41 位时间戳 + 5 位数据中心 + 5 位机器 + 12 位序列号。
 * 注意:生产环境的时钟回拨处理、跨机房唯一性,阶段 10 再展开。
 */
public class SnowflakeIdGenerator implements IdGenerator {

    private static final long EPOCH = 1704067200000L;  // 起始时间戳(2024-01-01),可自定义
    private static final long WORKER_ID_BITS = 5L;
    private static final long DATACENTER_ID_BITS = 5L;
    private static final long SEQUENCE_BITS = 12L;

    private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS);       // 31
    private static final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_ID_BITS); // 31
    private static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS);        // 4095

    private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;                                   // 12
    private static final long DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;              // 17
    private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS; // 22

    private final long workerId;
    private final long datacenterId;
    private long sequence = 0L;
    private long lastTimestamp = -1L;

    public SnowflakeIdGenerator(long workerId, long datacenterId) {
        if (workerId > MAX_WORKER_ID || workerId < 0) {
            throw new IllegalArgumentException("workerId 必须在 0~" + MAX_WORKER_ID + " 之间");
        }
        if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0) {
            throw new IllegalArgumentException("datacenterId 必须在 0~" + MAX_DATACENTER_ID + " 之间");
        }
        this.workerId = workerId;
        this.datacenterId = datacenterId;
    }

    @Override
    public synchronized long nextId() {
        long timestamp = System.currentTimeMillis();
        // 时钟回拨保护:若当前时间小于上次时间,直接抛异常(生产需更完善策略,阶段 10 讲)
        if (timestamp < lastTimestamp) {
            throw new IllegalStateException("时钟回拨 " + (lastTimestamp - timestamp) + "ms,拒绝生成 ID");
        }
        if (timestamp == lastTimestamp) {
            // 同一毫秒内序列号自增,超 4095 则自旋等下一毫秒
            sequence = (sequence + 1) & SEQUENCE_MASK;
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }
        lastTimestamp = timestamp;
        // 位移拼装出 64 位 ID
        return ((timestamp - EPOCH) << TIMESTAMP_SHIFT)
                | (datacenterId << DATACENTER_ID_SHIFT)
                | (workerId << WORKER_ID_SHIFT)
                | sequence;
    }

    private long tilNextMillis(long lastTimestamp) {
        long timestamp = System.currentTimeMillis();
        while (timestamp <= lastTimestamp) {
            timestamp = System.currentTimeMillis();
        }
        return timestamp;
    }
}

第 5 步:写自动配置类(核心)

package com.example.idgenerator;

import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;

/**
 * ID 生成器的自动配置类。
 * 三个条件注解层层把关:
 *  - @ConditionalOnProperty:配置开关 id.generator.enabled  true 才生效(默认 true
 *  - @ConditionalOnMissingBean:使用者已自定义 IdGenerator 时让位,绝不覆盖
 */
@AutoConfiguration
@EnableConfigurationProperties(IdGeneratorProperties.class)
@ConditionalOnProperty(prefix = "id.generator", name = "enabled", havingValue = "true", matchIfMissing = true)
public class IdGeneratorAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean(IdGenerator.class)
    public IdGenerator idGenerator(IdGeneratorProperties properties) {
        // 用配置里的 workerId / datacenterId 构造默认雪花实现
        return new SnowflakeIdGenerator(properties.getWorkerId(), properties.getDatacenterId());
    }
}

第 6 步:写注册入口文件(Spring Boot 3.x
的新写法,不是 spring.factories):

文件路径
src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,内容只有一行:

com.example.idgenerator.IdGeneratorAutoConfiguration

第 7 步:安装到本地仓库

cd id-generator-spring-boot-starter
mvn clean install
# 安装到本地 ~/.m2,其他项目即可引用

第 8 步:在业务项目里使用。在 demo
pom.xml 加依赖:

<dependency>
    <groupId>com.example</groupId>
    <artifactId>id-generator-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>

配置(可选,不配就用默认值):

id:
  generator:
    worker-id: 2          # 多实例部署时每台不同
    datacenter-id: 1

使用:

@Service
@RequiredArgsConstructor
@Slf4j
public class OrderService {

    private final IdGenerator idGenerator;   // 构造器注入 starter 提供的 Bean

    public long createOrder() {
        long orderId = idGenerator.nextId();
        log.info("生成订单号 {}", orderId);
        return orderId;
    }
}

验证:用 --debug 启动,在 Positive
matches 里能看到
IdGeneratorAutoConfiguration matched;把配置改成
id.generator.enabled: false 重启,它就进入 Negative
matches。

关键点提示

  • 入口文件名一个字母都不能错META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports。写错(比如还写老的
    spring.factories)会导致自动配置静默不生效,且不报错,极难排查——这也是为什么
    2.4 节强调先看 --debug 报告。
  • @ConditionalOnMissingBean 是自定义 starter
    的「逃生门」
    :使用者想自己实现 IdGenerator
    时,只需注册一个自己的
    Bean,你的默认实现会自动让位,双方都不改代码。
  • starter 依赖要克制:只依赖
    spring-boot-autoconfigure,不要引入一堆重依赖,否则使用方会莫名其妙被传递进一坨没用的
    jar。

5.4
进阶:条件注解的落点与新旧入口对比

(1)条件注解写在「类上」还是「@Bean 方法上」?

这是自定义 starter 时最容易含糊的点。规则很简单:

  • 写在类上:控制整段自动配置(该类里的所有
    @Bean)是否生效;
  • 写在 @Bean 方法上:只控制这一个 Bean
    是否注册,类里其他 Bean 不受影响。
@AutoConfiguration
@ConditionalOnClass(IdGenerator.class)      // 类级条件:classpath 有 IdGenerator 才解析整类
@EnableConfigurationProperties(IdGeneratorProperties.class)
public class IdGeneratorAutoConfiguration {

    // 方法级条件:仅控制"雪花实现"这个 Bean 是否注册
    @Bean
    @ConditionalOnProperty(prefix = "id.generator", name = "impl", havingValue = "snowflake", matchIfMissing = true)
    @ConditionalOnMissingBean(IdGenerator.class)
    public IdGenerator snowflakeIdGenerator(IdGeneratorProperties p) {
        return new SnowflakeIdGenerator(p.getWorkerId(), p.getDatacenterId());
    }

    // 方法级条件:配置成 uuid 时,改用 UUID 实现(另一个 Bean)
    @Bean
    @ConditionalOnProperty(prefix = "id.generator", name = "impl", havingValue = "uuid")
    @ConditionalOnMissingBean(IdGenerator.class)
    public IdGenerator uuidIdGenerator() {
        return () -> UUID.randomUUID().getMostSignificantBits() & Long.MAX_VALUE;
    }
}

配合配置 id.generator.impl: snowflake
uuid,就能让使用方通过配置切换实现,而不用改代码——这是自定义
starter 保持灵活性的标准手法。

(2)新旧入口文件对比(务必记住,别被老博客带偏)

旧写法(Spring Boot 2.7 之前) 新写法(Spring Boot 2.7+ / 3.x)
入口文件 META-INF/spring.factories META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
文件格式 key=value
键值对(org.springframework.boot.autoconfigure.EnableAutoConfiguration=类1,类2
每行一个类名,无 key
示例 org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.example.idgenerator.IdGeneratorAutoConfiguration com.example.idgenerator.IdGeneratorAutoConfiguration

2.7 是过渡版本,两个文件都支持;3.x 只认
AutoConfiguration.imports
。你抄网上老教程时如果还写
spring.factories,starter
静默不生效——这正是「引入 starter
没反应」的高发根因,回到 2.4 节用 --debug 一查便知。

本章小结:Starter = 依赖聚合 +
AutoConfiguration.imports 注册的自动配置类;自定义 starter
六步走(建模块 → 写属性类 → 写接口/实现 → 写
@AutoConfiguration → 写 imports 入口 →
安装使用),@ConditionalOnMissingBean
保证可被覆盖;条件注解可按需落在类级或方法级,3.x 入口文件必须用
AutoConfiguration.imports


第 6 章 打包与部署

6.1
spring-boot-maven-plugin 与可执行 jar

第 3 章 pom.xml 里已经配了
spring-boot-maven-plugin,它是打「可执行 fat
jar」的关键:

mvn clean package

产物在 target/ 下,注意用 .jar
而不是 .jar.original

target/
├── demo-0.0.1-SNAPSHOT.jar          # ✅ 可执行 fat jar(内嵌 Tomcat + 所有依赖)
└── demo-0.0.1-SNAPSHOT.jar.original # ❌ 只是你的字节码,不含依赖,不能直接 java -jar 跑

fat jar 的内部结构(解压可看):

demo-0.0.1-SNAPSHOT.jar
├── BOOT-INF/
│   ├── classes/                      # 你的业务字节码 + application.yml
│   └── lib/                          # 所有第三方依赖 jar
├── META-INF/
│   ├── MANIFEST.MF                   # 声明了 Main-Class 和 Start-Class
│   └── spring/
│       └── org.springframework.boot.autoconfigure.AutoConfiguration.imports  # 各依赖的自动配置入口
└── org/springframework/boot/loader/  # Spring Boot 的类加载器,负责从嵌套 jar 加载类

关键点:MANIFEST.MFMain-Class 是 Spring
Boot 的 JarLauncherStart-Class 才是你的
DemoApplicationJarLauncher 先建好「能从嵌套
jar 加载类」的类加载器,再启动你的应用——这就是「一个 jar
就能跑」的原理。

6.2 运行与多环境切换

# 基础运行
java -jar target/demo-0.0.1-SNAPSHOT.jar

# 指定环境
java -jar target/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod

# 覆盖任意配置(优先级最高)
java -jar target/demo-0.0.1-SNAPSHOT.jar 
     --server.port=9090 
     --spring.datasource.password='xxx'

# 后台运行 + 日志重定向(注意 nohup 与 &)
nohup java -jar target/demo-0.0.1-SNAPSHOT.jar > app.out.log 2>&1 &

6.3 多环境「打包」的两种策略

策略 A(推荐):一个 jar 打天下,运行时用 Profile
。只打一个包,部署时用 SPRING_PROFILES_ACTIVE
或命令行切环境,配置不进 jar:

mvn clean package                         # 只打一个通用包
SPRING_PROFILES_ACTIVE=prod java -jar target/demo-0.0.1-SNAPSHOT.jar

策略 B:按环境打不同的包。用 Maven Profile
在打包时替换资源:

<!-- pom.xml 里定义 Maven profile -->
<profiles>
    <profile>
        <id>prod</id>
        <properties>
            <!-- 打包时激活 prod 环境 -->
            <spring.profiles.active>prod</spring.profiles.active>
        </properties>
    </profile>
</profiles>
mvn clean package -Pprod        # 打出的 jar 内置 prod 配置

生产共识:优先策略
A。让「环境差异」成为部署时的事,而不是打包时的事,避免「打错环境包」这类事故。

6.4 优雅停机

生产发版时直接 kill -9
会导致正在处理的请求被强杀、事务中断。Spring Boot 3.2 内置优雅停机:

# application.yml
server:
  shutdown: graceful                # 开启优雅停机
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s # 最多等 30 秒让存量请求处理完

开启后,收到停机信号(如 kill 默认的
SIGTERM)时,应用会:

  1. 停止接收新请求;
  2. 等待正在处理的请求完成(最多等 30 秒);
  3. 优雅关闭容器与资源(数据源、线程池等)。
# 用 kill 发 SIGTERM(不要用 kill -9,-9 无法优雅停机)
kill <pid>
# 或 systemd 管理时
systemctl stop demo

验证日志里会看到 Commencing graceful shutdown
Graceful shutdown complete

6.5 一个完整的部署检查清单

上线前自检:

# 1. 确认打的包正确(不是 .jar.original)
ls -lh target/*.jar

# 2. 本地用生产配置试跑,观察启动日志无 ERROR
SPRING_PROFILES_ACTIVE=prod java -jar target/demo-0.0.1-SNAPSHOT.jar

# 3. 用 --debug 抽查关键自动配置是否按预期生效/失效
java -jar target/demo-0.0.1-SNAPSHOT.jar --debug 2>&1 | grep -A5 "Positive matches"

# 4. 验证敏感信息来自环境变量而非硬编码
grep -r "password" src/main/resources/   # 应只有 ${...} 占位符

6.6 健康检查与容器化部署

(1)用 Actuator 暴露健康检查,配合优雅停机验证

生产环境(尤其容器编排)需要一个「这个实例还活着吗、能接流量吗」的探针。引入
Actuator:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,info      # 只暴露 health 和 info,不要全开(安全)
  endpoint:
    health:
      show-details: when-authorized  # 详情需授权,默认不泄露内部信息

验证:

# 存活探针:容器编排用它判断要不要重启
curl http://localhost:8080/actuator/health
# {"status":"UP"}

# 就绪探针(组件齐全才 UP,优雅停机时会先变 DOWN,K8s 据此摘流量)
curl http://localhost:8080/actuator/health/readiness
# {"status":"UP"}

# 存活探针
curl http://localhost:8080/actuator/health/liveness
# {"status":"UP"}

与优雅停机的配合server.shutdown=graceful
触发停机时,readiness 会先变成
OUT_OF_SERVICE,容器编排平台(K8s)据此把该 Pod 从 Service
摘除、不再转发新流量,等存量请求处理完再真正退出——这就是生产环境「无损发版」的标准链路。

(2)容器化部署(Dockerfile)

# 用 JRE 而非 JDK,镜像更小、攻击面更小
FROM eclipse-temurin:17-jre

# 创建非 root 用户,避免容器内以 root 运行(安全最佳实践)
RUN groupadd -r app && useradd -r -g app app

WORKDIR /app
# 只 COPY 最终 fat jar
COPY target/order-service-1.0.0.jar app.jar

USER app
# ENTRYPOINT 形式:容器内 PID 1 就是 java 进程,能正确收到 SIGTERM 触发优雅停机
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
# 构建并运行
docker build -t order-service:1.0.0 .
docker run -d -p 8080:8080 
  -e SPRING_PROFILES_ACTIVE=prod 
  -e DB_PASSWORD='xxx' 
  --name order-service order-service:1.0.0

# 优雅停机验证:docker stop 默认发 SIGTERM,会走优雅停机
docker stop order-service

关键点提示:Dockerfile 用 ENTRYPOINT(exec
形式)而不是 CMD + shell 形式
,是为了让 java
进程成为容器 PID 1,从而能正确接收 docker stop 发来的
SIGTERM、触发优雅停机;如果套一层 sh -c,信号会被 shell
吞掉,优雅停机就失效了。

本章小结spring-boot-maven-plugin
打可执行 fat jar(选 .jar 不选
.jar.original);部署用「一个 jar + 运行时
Profile」策略;开启 server.shutdown=graceful
实现优雅停机,发版不用 kill -9;用 Actuator
健康检查配合容器编排实现无损发版。


5.
生产级实战项目:订单服务(order-service)

把本篇 6
章的知识串成一个完整可运行的项目。业务场景:一个极简的订单服务,具备——

  • 统一响应体 ApiResult / 错误码 ErrorCode /
    业务异常 BizException / 全局异常处理;
  • 类型安全配置类 AppProperties + 校验 + Profile 多环境 +
    敏感信息外部化;
  • 生产级 logback-spring.xml + MDC traceId;
  • 使用第 5 章自定义的 id-generator-spring-boot-starter
    生成订单号;
  • 优雅停机。

5.1 目录结构

order-service
├── pom.xml
├── src/main/java/com/example/order
│   ├── OrderApplication.java              # 启动类(根包)
│   ├── controller/OrderController.java
│   ├── service/OrderService.java
│   ├── properties/AppProperties.java      # app.* 配置类
│   ├── config/AppConfig.java              # 启用配置类
│   ├── exception/BizException.java
│   ├── exception/GlobalExceptionHandler.java
│   ├── common/ApiResult.java
│   ├── common/ErrorCode.java
│   └── common/TraceIdFilter.java
└── src/main/resources
    ├── application.yml
    ├── application-dev.yml
    ├── application-prod.yml
    └── logback-spring.xml

5.2 pom.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>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>order-service</artifactId>
    <version>1.0.0</version>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <!-- 自定义 starter:引入即拥有 IdGenerator Bean -->
        <dependency>
            <groupId>com.example</groupId>
            <artifactId>id-generator-spring-boot-starter</artifactId>
            <version>1.0.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

5.3 启动类

package com.example.order;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/** 启动类位于根包 com.example.order,保证默认组件扫描覆盖所有子包 */
@SpringBootApplication
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}

5.4
统一响应体与错误码(系列公共类)

ApiResultErrorCodeBizExceptionGlobalExceptionHandler
与阶段 1
给出的逐字一致,此处引用不重写(跨文章一致性要求)。为让项目自包含,把它们放在
commonexception 包,实现见阶段 1。

5.5 类型安全配置类 + 校验

package com.example.order.properties;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

/** 订单服务的业务配置,绑定 app.* 前缀 */
@Data
@Validated
@ConfigurationProperties(prefix = "app")
public class AppProperties {

    /** 服务名(会出现在日志里,便于多实例区分) */
    @NotBlank
    private String name;

    /** 下单超时时间(秒) */
    @Min(1)
    private int orderTimeoutSeconds = 30;

    /** 订单号生成策略 */
    @NotBlank
    private String idStrategy = "snowflake";
}
package com.example.order.config;

import com.example.order.properties.AppProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

/** 注册配置类:配置类不碰 @Component,职责纯粹,便于测试 */
@Configuration
@EnableConfigurationProperties(AppProperties.class)
public class AppConfig {
}

5.6 业务层(用 starter
生成订单号)

package com.example.order.service;

import com.example.idgenerator.IdGenerator;
import com.example.order.properties.AppProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
@Slf4j
public class OrderService {

    private final IdGenerator idGenerator;      // 来自自定义 starter
    private final AppProperties appProperties;  // 类型安全配置

    /** 创建订单:生成订单号并记录关键日志 */
    public long createOrder(String sku) {
        long orderId = idGenerator.nextId();
        // 记录关键业务节点:谁、买了什么、订单号多少
        log.info("创建订单成功 orderId={}, sku={}, strategy={}",
                orderId, sku, appProperties.getIdStrategy());
        return orderId;
    }
}

5.7 接口层(带参数校验 +
统一响应)

package com.example.order.controller;

import com.example.order.common.ApiResult;
import com.example.order.service.OrderService;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/order")
@RequiredArgsConstructor
@Validated
@Slf4j
public class OrderController {

    private final OrderService orderService;

    @GetMapping("/create")
    public ApiResult<Long> create(@RequestParam @NotBlank(message = "sku 不能为空") String sku) {
        long orderId = orderService.createOrder(sku);
        return ApiResult.ok(orderId);
    }
}

5.8 配置文件

# application.yml(公共底座)
spring:
  application:
    name: order-service
  profiles:
    active: dev
  lifecycle:
    timeout-per-shutdown-phase: 30s   # 优雅停机等待时间

server:
  port: 8080
  shutdown: graceful                  # 开启优雅停机

app:
  name: ${APP_NAME:order-service-01}   # 服务名可从环境变量覆盖,多实例区分
  order-timeout-seconds: 30
  id-strategy: snowflake

id:
  generator:
    worker-id: ${WORKER_ID:1}          # 多实例时每台不同,用环境变量注入
    datacenter-id: 1
# application-dev.yml
logging:
  level:
    com.example.order: DEBUG
app:
  name: order-service-dev
# application-prod.yml
logging:
  level:
    com.example.order: INFO
app:
  name: order-service-prod

5.9 运行与验证

# 1. 先安装自定义 starter(第 5 章)
cd id-generator-spring-boot-starter && mvn clean install && cd ..

# 2. 打包
cd order-service && mvn clean package

# 3. 本地跑(dev)
java -jar target/order-service-1.0.0.jar

# 4. 验证接口
curl http://localhost:8080/api/order/create?sku=1001
# {"code":0,"message":"success","data":1837429500235776,"traceId":"a1b2c3..."}

# 5. 验证参数校验(不传 sku)
curl http://localhost:8080/api/order/create
# {"code":40001,"message":"sku 不能为空","data":null,"traceId":"a1b2c3..."}

# 6. 生产环境跑(注入环境变量)
SPRING_PROFILES_ACTIVE=prod WORKER_ID=2 java -jar target/order-service-1.0.0.jar

# 7. 验证优雅停机
kill <pid>
# 日志出现 Commencing graceful shutdown ... Graceful shutdown complete

6. 常见坑与排错指南

坑 / 现象 原因 解决方案
启动类不在包根目录,访问接口报 No qualifying bean @ComponentScan 默认只扫启动类所在包及子包 启动类上移到根包,或显式
@ComponentScan(basePackages=...)
密码/密钥硬编码在 yml 里提交了 Git 敏感信息进仓库即永久泄露,删掉也留历史 改用 ${ENV_VAR} 占位符 +
环境变量/配置中心注入,并轮换已泄露密钥
改了 yml 配置却不生效 配置被更高优先级来源覆盖(命令行/环境变量) 记住优先级:命令行 > 系统属性 > 环境变量 > profile >
默认;用 --debug 或 actuator 查看实际生效值
@Value 读类型不匹配的配置,启动才报错 @Value 类型不安全、无校验 改用 @ConfigurationProperties +
@Validated,启动时 fail-fast
自动配置「不生效」但没报错 @ConditionalOnClass 缺类 /
@ConditionalOnMissingBean 被自定义 Bean 顶掉
--debug 看 Positive/Negative
matches,按报告对症下药
自定义 starter 引入后毫无反应 AutoConfiguration.imports 文件名写错(如写了老的
spring.factories
检查
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
路径一字不差
日志只打控制台不落文件,线上无法回溯 没配 RollingFileAppender logback-spring.xml 滚动文件 +
maxHistory 归档
System.out.println 打日志 无级别、无格式、无 traceId、性能差 统一 @Slf4j,禁止 System.out
异常只 log.error(e.getMessage()) 丢了堆栈,无法定位出错行 log.error("业务失败", e) 带异常对象
MDC traceId 串号,不同请求同一个 id 请求结束没 MDC.remove(),Tomcat 线程池复用线程 在过滤器的 finally 里清理 MDC
依赖版本不统一,运行时 NoSuchMethodError 没继承 spring-boot-starter-parent 统一版本 继承父 POM,或 dependencyManagement 统一管理
打出的 jar 用 java -jar 跑报「no main manifest」 用了 .jar.original 那个包 spring-boot-maven-plugin 打的 .jar(fat
jar)
发版 kill -9 导致正在处理的请求/事务中断 -9 是强杀,不触发优雅停机 server.shutdown=graceful,用
SIGTERM(kill 不带 -9)停机
部署时把 application-prod.yml 也打进通用包导致误用 把环境差异固化进了 jar 采用「一个 jar + 运行时 Profile」策略,敏感配置外部化

8. 总结与延伸阅读

本篇核心一句话:Spring Boot 的「自动配置」是一套基于
classpath 与已有 Bean
的可预测条件判断机制,而非黑魔法;「配置管理」与「日志」则是把自动配置落地到生产的工程纪律。

你从「跑通第一个接口」出发,拆解了
@SpringBootApplication,吃透了
@EnableAutoConfiguration → AutoConfigurationImportSelector → AutoConfiguration.imports → @Conditional
的完整链路,学会了用 --debug
读「自动配置报告」排查一切「配置不生效」;掌握了
@ConfigurationProperties + 校验、配置优先级、Profile
切换与敏感信息外部化;配置了生产级日志(滚动、按包隔离、traceId);亲手写了一个自定义
starter;最后打包部署并开启优雅停机。这套能力是后续 Web 开发(阶段
3)、数据访问(阶段 4)、分布式组件(阶段 10)的通用底座。

延伸阅读

  1. Spring
    Boot 官方 Reference:Core Features → Externalized Configuration
    ——
    配置优先级与外部化的权威说明;
  2. Spring
    Boot 官方 Reference:Creating Your Own Auto-configuration
    —— 自定义
    starter 官方指南;
  3. Spring
    Boot 官方 Reference:Logging
    —— 日志体系与
    logback-spring.xml 支持;
  4. 《Spring Boot 实战》(Craig Walls 著)—— 经典入门书,案例丰富;
  5. 阶段 10 深度拆解文档 —— 分布式
    ID(雪花算法时钟回拨)与配置中心,是本章自定义 starter
    与敏感信息外部化的进阶。

本文遵循系列统一写作规范:8
段模板、统一代码约定(ApiResult/ErrorCode/BizException/GlobalExceptionHandler)、Spring
Boot 3.2.x / JDK 17。

正文完
 0
评论(没有评论)