Spring Boot 4.1.1 深度源码解析-第 02 章 · 构建体系深潜:Gradle 约定插件、自动配置的编译期校验与依赖治理
承接第 01 章。第 01 章给了我们地图,本章开始挖掘地图背后的工程机器------Spring Boot 是如何用 Gradle 把上百名贡献者、440 个子项目、数千个自动配置类管住的。
本章的源码全部来自
buildSrc/与gradle/plugins/,这是很多源码分析文章完全忽略的地带。但恰恰是这里,藏着 Spring Boot 团队协作方式的全部秘密。
本章导读
如果你只关心"Spring Boot 怎么工作的",本章可以跳过。但如果你的问题是下面这些中的任何一个,本章就是为你写的:
- 为什么我把自动配置类写好了,构建却失败了?
AutoConfiguration.imports文件是手写的还是生成的?为什么顺序错一点就报错?@AutoConfiguration为什么既有after又有afterName?两个不重复吗?optional依赖到底是怎么实现的?为什么它不会传递给下游?- 我能不能在自己的项目里复制 Spring Boot 的这套工程化能力?
本章会逐行拆开 buildSrc 中的真实代码,回答以上全部问题。
本章的核心论点 :Spring Boot 的工程化能力,本质上是一套"把运行时陷阱前移为编译期失败 "的机制。自动配置的注册、排序、依赖关系、可选性------这些本来都是运行时才可能暴露的问题,Boot 用 Gradle 插件把它们全部变成了 ./gradlew build 会失败的东西。
1. Gradle 构建的全貌:三层结构
Spring Boot 的 Gradle 构建不是一个普通的构建,它是三层复合结构:
sql
┌─────────────────────────────────────────────────────────┐
│ 第一层:gradle/plugins(独立复合构建) │
│ └── cycle-detection-plugin │
│ 通过 settings.gradle 的 includeBuild 引入 │
└─────────────────────────────────────────────────────────┘
↓ includeBuild
┌─────────────────────────────────────────────────────────┐
│ 第二层:buildSrc(Gradle 自动编译的构建脚本库) │
│ └── org.springframework.boot.build.* 约定插件 │
│ JavaConventions / AutoConfigurationPlugin / │
│ ArchitecturePlugin / OptionalDependenciesPlugin ...│
└─────────────────────────────────────────────────────────┘
↓ 自动可用
┌─────────────────────────────────────────────────────────┐
│ 第三层:440 个子项目的 build.gradle │
│ core / module / starter / loader / build-plugin ... │
└─────────────────────────────────────────────────────────┘
1.1 第一层:gradle/plugins ------ 复合构建(Composite Build)
settings.gradle 开头:
groovy
pluginManagement {
evaluate(new File("${rootDir}/buildSrc/SpringRepositorySupport.groovy")).apply(this)
repositories {
mavenCentral()
gradlePluginPortal()
spring.mavenRepositories()
}
resolutionStrategy {
eachPlugin {
if (requested.id.id == "org.jetbrains.kotlin.jvm") {
useVersion "${kotlinVersion}"
}
if (requested.id.id == "org.jetbrains.kotlin.plugin.spring") {
useVersion "${kotlinVersion}"
}
if (requested.id.id == "org.jetbrains.kotlin.plugin.serialization") {
useVersion "${kotlinVersion}"
}
}
}
includeBuild("gradle/plugins")
}
三个要点:
(1) evaluate(new File(".../SpringRepositorySupport.groovy")).apply(this)
这是一行很"老派"但很实用的代码:直接 evaluate 一个 Groovy 脚本文件,并把结果 apply 到当前的 pluginManagement 块。
buildSrc/SpringRepositorySupport.groovy 的作用是根据 spring.build-type(第 01 章 §1.1 讲过的 oss)决定使用哪些 Maven 仓库。把它提取成独立文件,是因为 pluginManagement 块和普通 build script 需要共享这段逻辑。
这是一个技巧:当一段配置需要在 Gradle 的不同作用域(settings / project / pluginManagement)复用时,把它写成独立 Groovy 脚本再 evaluate。
(2) resolutionStrategy.eachPlugin 统一 Kotlin 插件版本
groovy
if (requested.id.id == "org.jetbrains.kotlin.jvm") {
useVersion "${kotlinVersion}"
}
Gradle 的 Kotlin 插件有多个(kotlin.jvm、kotlin.plugin.spring、kotlin.plugin.serialization),它们必须版本一致 ,否则会出现难以诊断的编译错误。这里用 resolutionStrategy 强制统一到 kotlinVersion=2.3.21。
这是多插件项目中的标准做法------凡是一组插件必须版本锁定的,都在这里统一。
(3) includeBuild("gradle/plugins") ------ 复合构建
includeBuild 让 Gradle 把 gradle/plugins 当作一个独立的构建来构建,并把它的产出(Gradle 插件)提供给主构建使用。
与 buildSrc 的区别:
buildSrc |
includeBuild |
|
|---|---|---|
| 编译时机 | Gradle 自动编译,在主构建前 | 作为独立构建参与 |
| 用途 | 通用工具类、约定插件 | 独立可发布的插件 |
| 是否可被单独测试 | 较难 | 容易(独立构建) |
| 缓存行为 | 变更会导致全量重编译 | 更好的增量性 |
Boot 把 cycle-detection 插件 放在 gradle/plugins,是因为它需要独立性和更好的增量构建表现。而大量的约定插件放在 buildSrc,因为它们是内部工具。
注意 :gradle/plugins 目录下实际只有 cycle-detection-plugin 一个子项目(我实测确认)。这与 settings.gradle 中的 includeBuild 一致。
1.2 第二层:buildSrc ------ 约定插件的仓库
buildSrc 是 Gradle 的特殊目录:Gradle 会自动编译它,并把它的类加入到构建脚本的 classpath。
buildSrc/src/main/java/org/springframework/boot/build/ 下的核心类(实测清单):
arduino
antora/ 文档聚合(Antora)
architecture/ 架构校验
├── ArchitectureCheck.java
├── ArchitectureCheckAnnotation.java
├── ArchitecturePlugin.java
├── ArchitectureRules.java
└── AutoConfigurationChecker.java
artifacts/ 发布工件
autoconfigure/ ★ 自动配置构建支持
├── AutoConfigurationClass.java
├── AutoConfigurationImportsTask.java
├── AutoConfigurationMetadata.java
├── AutoConfigurationPlugin.java
├── CheckAutoConfigurationClasses.java
└── CheckAutoConfigurationImports.java
optional/ ★ optional 依赖支持
└── OptionalDependenciesPlugin.java
properties/ 配置属性支持
toolchain/ JDK Toolchain
aggregation/ 聚合任务
AntoraConventions.java
JavaConventions.java ★ Java 约定
KotlinConventions.java
MavenRepositoryPlugin.java(推测)
SystemRequirementsExtension.java
ToolchainExtension.java
ConfigureJavadocLinks.java
DeployedPlugin.java
...
注意带 ★ 的三个 :autoconfigure/(本章核心)、optional/(模块化支点)、JavaConventions(编译基线)。
1.3 第三层:子项目的 build.gradle
第 01 章 §3.2 已经完整解剖过 module/spring-boot-webmvc/build.gradle。这里补充一个 starter 的例子,看看它的简洁程度:
groovy
// starter/spring-boot-starter-classic/build.gradle(去掉 license 头)
plugins {
id "org.springframework.boot.starter"
}
description = "Core classic starter, including full auto-configuration support, logging and YAML"
dependencies {
api(project(":starter:spring-boot-starter"))
api(project(":module:spring-boot-autoconfigure-classic"))
}
只有 12 行 。所有复杂逻辑(生成 POM、处理 optional、生成文档、发布配置)都由 org.springframework.boot.starter 这个约定插件完成。
这就是约定插件的价值:让 171 个 starter 的构建脚本保持在 10-20 行。
1.4 配置缓存与并行:性能的基础
groovy
enableFeaturePreview("STABLE_CONFIGURATION_CACHE")
以及:
properties
org.gradle.caching=true
org.gradle.parallel=true
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
这三者组合,是 440 个项目能够在可接受时间内构建的关键。
配置缓存(Configuration Cache) 的原理:Gradle 的构建分为"配置阶段"和"执行阶段"。配置阶段执行所有 build.gradle 脚本,构建任务图。在 440 个项目的规模下,配置阶段本身就要几十秒。配置缓存把任务图序列化到磁盘,下次构建直接反序列化,跳过配置阶段。
代价是:构建脚本必须遵守配置缓存的约束(不能在执行阶段访问 Project 对象、不能读取 System.getProperty 等)。Boot 团队为此做了大量改造------这也是为什么你在 Boot 的构建脚本中看到大量 providers、lazy 配置。
并行(Parallel) 的前提是没有循环依赖 ,这由 cycle-detection 插件保证(§7.4)。
2. 约定插件体系全景
Spring Boot 定义了若干自定义 Gradle 插件,每个子项目按需组合。我们从 module/spring-boot-webmvc/build.gradle 中已经见过五个:
groovy
plugins {
id "java-library"
id "java-test-fixtures"
id "org.springframework.boot.auto-configuration"
id "org.springframework.boot.configuration-properties"
id "org.springframework.boot.deployed"
id "org.springframework.boot.optional-dependencies"
}
下面逐个讲。
2.1 org.springframework.boot.deployed ------ 发布标记
对应 buildSrc/src/main/java/org/springframework/boot/build/DeployedPlugin.java。
从 AutoConfigurationPlugin 的源码中可以看到它的作用:
java
@Override
public void apply(Project project) {
project.getPlugins().apply(DeployedPlugin.class);
project.getPlugins().withType(JavaPlugin.class, (javaPlugin) -> new Configurer(project).configure());
}
DeployedPlugin 标记该项目的产出需要发布到 Maven 仓库。这会触发:
- 生成 POM(含
<optional>true</optional>标记) - 生成 sources jar 与 javadoc jar
- 签名(发布时)
- 上传到 Artifactory/Maven Central
为什么需要显式标记? 因为仓库里有大量不发布 的项目:smoke-test(106 个)、integration-test、test-support 等。它们参与构建和测试,但不发布。用一个插件显式区分,比"默认发布、例外排除"更安全。
2.2 org.springframework.boot.optional-dependencies ------ 模块化的支点
这是整个 Boot 模块化架构的技术支点,源码只有 50 行,但极其精妙。完整源码如下:
java
package org.springframework.boot.build.optional;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.artifacts.Configuration;
import org.gradle.api.plugins.JavaPlugin;
import org.gradle.api.plugins.JavaPluginExtension;
import org.gradle.api.tasks.SourceSetContainer;
/**
* A {@code Plugin} that adds support for Maven-style optional dependencies. Creates a new
* {@code optional} configuration. The {@code optional} configuration is part of the
* project's compile and runtime classpaths but does not affect the classpath of dependent
* projects.
*
* @author Andy Wilkinson
*/
public class OptionalDependenciesPlugin implements Plugin<Project> {
/**
* Name of the {@code optional} configuration.
*/
public static final String OPTIONAL_CONFIGURATION_NAME = "optional";
@Override
public void apply(Project project) {
Configuration optional = project.getConfigurations().create("optional");
optional.setCanBeConsumed(true);
optional.setCanBeResolved(false);
project.getPlugins().withType(JavaPlugin.class, (javaPlugin) -> {
SourceSetContainer sourceSets = project.getExtensions()
.getByType(JavaPluginExtension.class)
.getSourceSets();
sourceSets.all((sourceSet) -> {
project.getConfigurations()
.getByName(sourceSet.getCompileClasspathConfigurationName())
.extendsFrom(optional);
project.getConfigurations()
.getByName(sourceSet.getRuntimeClasspathConfigurationName())
.extendsFrom(optional);
});
});
}
}
2.2.1 逐行解读
(1) project.getConfigurations().create("optional")
创建一个名为 optional 的新 Configuration。Gradle 的 Configuration 本质上是一个"依赖容器 + 解析规则"。
(2) optional.setCanBeConsumed(true) / setCanBeResolved(false)
这是 Gradle 的依赖管理最佳实践:
canBeConsumed = true:其他项目可以消费(引用)这个 configurationcanBeResolved = false:这个 configuration 不能直接解析成文件集合(它只是个"桶")
为什么要 canBeResolved=false?因为 optional 本身不应该被"解析"------它的内容会通过 extendsFrom 流入其他 classpath。禁止直接解析可以避免误用,也让 Gradle 能做更好的优化。
(3) sourceSets.all((sourceSet) -> { ... extendsFrom(optional) })
对所有 source set(main、test,以及将来可能新增的任何 source set),把 optional 挂到其 compileClasspath 和 runtimeClasspath 上。
这就是 optional 依赖"编译时可见"的实现 :因为 compileClasspath extendsFrom optional,所以 optional 中的 jar 会出现在编译 classpath 中,代码可以引用其中的类。
2.2.2 那"不传递给下游"是怎么实现的?
这是关键问题。答案在于 Gradle 的依赖传递机制:
Gradle 的依赖传递,是通过
apiElements(编译传递)和runtimeElements(运行传递)这两个 consumable configuration 实现的。
而 optional 没有 被 apiElements 或 runtimeElements 继承。它只被本项目的 compileClasspath/runtimeClasspath 继承。
所以:
- 本项目编译、运行时:
optional的依赖在 classpath 上 ✅ - 下游项目引用本项目时:Gradle 看的是
apiElements/runtimeElements,里面没有optional的内容 ✅
这就是"编译时需要、不传递给下游"的完整实现。 只用 50 行代码。
2.2.3 Maven POM 中的 <optional>true</optional>
当发布到 Maven 仓库时,DeployedPlugin 会把 optional configuration 中的依赖在 POM 里标记为:
xml
<dependency>
<groupId>...</groupId>
<artifactId>...</artifactId>
<optional>true</optional>
</dependency>
Maven 的 <optional>true</optional> 语义与 Gradle 的 optional 一致:消费者不会继承这个依赖。
2.2.4 Boot 为这个插件写了集成测试
值得一提的是,仓库中还有:
arduino
buildSrc/src/test/java/org/springframework/boot/build/optional/OptionalDependenciesPluginIntegrationTests.java
它用 Gradle TestKit (GradleRunner)真实地跑一个小构建,验证插件行为:
java
@Test
void optionalConfigurationIsCreated() throws IOException {
try (PrintWriter out = new PrintWriter(new FileWriter(this.buildFile))) {
out.println("plugins { id 'org.springframework.boot.optional-dependencies' }");
out.println("task printConfigurations {");
out.println(" doLast {");
out.println(" configurations.all { println it.name }");
out.println(" }");
out.println("}");
}
BuildResult buildResult = runGradle("printConfigurations");
assertThat(buildResult.getOutput()).contains(OptionalDependenciesPlugin.OPTIONAL_CONFIGURATION_NAME);
}
@Test
void optionalDependenciesAreAddedToMainSourceSetsCompileClasspath() throws IOException {
optionalDependenciesAreAddedToSourceSetClasspath("main", "compileClasspath");
}
@Test
void optionalDependenciesAreAddedToTestSourceSetsCompileClasspath() throws IOException {
optionalDependenciesAreAddedToSourceSetClasspath("test", "compileClasspath");
}
Boot 团队连自己的构建插件都写测试。 这是一个强烈的工程文化信号,也是你可以直接借鉴的实践:
如果你的项目有自定义 Gradle 插件,用 Gradle TestKit 为它写集成测试。这能防住"插件改了一点,20 个子项目构建失败"这类噩梦。
2.3 org.springframework.boot.auto-configuration ------ 本章主角
对应 buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/AutoConfigurationPlugin.java。这是本章的核心,我们在 §3 全文拆解。
先通过它的 Javadoc 了解职责:
java
/**
* {@link Plugin} for projects that define auto-configuration. When applied, the plugin
* applies the {@link DeployedPlugin}. Additionally, when the {@link JavaPlugin} is
* applied it:
*
* <ul>
* <li>Adds a dependency on the auto-configuration annotation processor.
* <li>Defines a task that produces metadata describing the auto-configuration. The
* metadata is made available as an artifact in the {@code autoConfigurationMetadata}
* configuration.
* <li>Add checks to ensure import files and annotations are correct</li>
* </ul>
*
* @author Andy Wilkinson
*/
三件事:
- 添加自动配置注解处理器依赖
- 定义生成自动配置元数据 的任务,并作为 artifact 暴露在
autoConfigurationMetadataconfiguration 中 - 添加检查确保 imports 文件与注解正确
2.4 org.springframework.boot.configuration-properties ------ 元数据生成
对应 buildSrc/src/main/java/org/springframework/boot/build/properties/ 下的插件(我在文件列表中看到了 properties/ 目录)。
它负责:
- 应用
spring-boot-configuration-processor注解处理器 - 生成
META-INF/spring-configuration-metadata.json - 校验
additional-spring-configuration-metadata.json
这支撑着 IDE 中 application.properties 的自动补全。第 15 章详述。
2.5 java-test-fixtures ------ Gradle 原生能力
这是 Gradle 自带的插件(不是 Boot 自定义的),但在 Boot 中被大量使用。
它的作用:把 src/testFixtures/java 下的代码编译成一个可被其他项目的测试依赖的工件。
在 spring-boot-webmvc/build.gradle 中:
groovy
testFixturesApi(testFixtures(project(":module:spring-boot-actuator")))
testImplementation(testFixtures(project(":module:spring-boot-actuator-autoconfigure")))
testImplementation(testFixtures(project(":module:spring-boot-web-server")))
为什么需要这个? 因为跨模块测试需要共享测试辅助类。传统做法有三种:
- 复制代码 ------ 不可维护
- 发布一个
-testjar ------ 增加发布物 - test-fixtures ------ Gradle 原生支持,干净
Boot 选择了第三种。这解释了为什么 module/ 下有那么多 *-test 模块存在------它们有些是给用户用的测试 starter,有些是内部 test-fixtures 的载体。
2.6 插件的组合关系
ruby
org.springframework.boot.deployed
↑ 被自动应用
org.springframework.boot.auto-configuration
│
├── 添加注解处理器依赖
│ :core:spring-boot-autoconfigure-processor
│ :configuration-metadata:spring-boot-configuration-processor
│
├── 创建 autoConfigurationMetadata configuration (consumable)
│ 属性:Category=DOCUMENTATION
│ Usage=auto-configuration-metadata
│
├── 注册任务 autoConfigurationMetadata
│
├── 注册任务 checkAutoConfigurationImports
│
├── 创建 autoConfigurationRequiredClasspath
│ extendsFrom: implementation + runtimeOnly
│ 加上::core:spring-boot-autoconfigure
│
├── 注册任务 checkAutoConfigurationClasses
│
└── 若存在 OptionalDependenciesPlugin:
创建 autoConfigurationOptionalClassPath
extendsFrom: optional
check 任务 dependsOn:
checkAutoConfigurationImports
checkAutoConfigurationClasses
这张图是理解本章后续内容的骨架。
3. AutoConfigurationPlugin 逐行拆解
现在进入本章的核心。完整源码如下(去掉 license 头):
java
package org.springframework.boot.build.autoconfigure;
import java.util.Arrays;
import java.util.Collections;
import java.util.Set;
import java.util.stream.Collectors;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.artifacts.Configuration;
import org.gradle.api.artifacts.ConfigurationContainer;
import org.gradle.api.artifacts.Dependency;
import org.gradle.api.attributes.Category;
import org.gradle.api.attributes.Usage;
import org.gradle.api.plugins.JavaBasePlugin;
import org.gradle.api.plugins.JavaPlugin;
import org.gradle.api.plugins.JavaPluginExtension;
import org.gradle.api.tasks.SourceSet;
import org.gradle.api.tasks.TaskContainer;
import org.gradle.api.tasks.TaskProvider;
import org.springframework.boot.build.DeployedPlugin;
import org.springframework.boot.build.optional.OptionalDependenciesPlugin;
public class AutoConfigurationPlugin implements Plugin<Project> {
private static final String AUTO_CONFIGURATION_METADATA_CONFIGURATION_NAME = "autoConfigurationMetadata";
@Override
public void apply(Project project) {
project.getPlugins().apply(DeployedPlugin.class);
project.getPlugins().withType(JavaPlugin.class, (javaPlugin) -> new Configurer(project).configure());
}
private static class Configurer {
private final Project project;
private SourceSet main;
Configurer(Project project) {
this.project = project;
this.main = project.getExtensions()
.getByType(JavaPluginExtension.class)
.getSourceSets()
.getByName(SourceSet.MAIN_SOURCE_SET_NAME);
}
void configure() {
addAnnotationProcessorsDependencies();
TaskContainer tasks = this.project.getTasks();
ConfigurationContainer configurations = this.project.getConfigurations();
configurations.consumable(AUTO_CONFIGURATION_METADATA_CONFIGURATION_NAME, (configuration) -> {
configuration.attributes((attributes) -> {
attributes.attribute(Category.CATEGORY_ATTRIBUTE,
this.project.getObjects().named(Category.class, Category.DOCUMENTATION));
attributes.attribute(Usage.USAGE_ATTRIBUTE,
this.project.getObjects().named(Usage.class, "auto-configuration-metadata"));
});
});
tasks.register("autoConfigurationMetadata", AutoConfigurationMetadata.class,
this::configureAutoConfigurationMetadata);
TaskProvider<CheckAutoConfigurationImports> checkAutoConfigurationImports = tasks.register(
"checkAutoConfigurationImports", CheckAutoConfigurationImports.class,
this::configureCheckAutoConfigurationImports);
Configuration requiredClasspath = configurations.create("autoConfigurationRequiredClasspath")
.extendsFrom(configurations.getByName(this.main.getImplementationConfigurationName()),
configurations.getByName(this.main.getRuntimeOnlyConfigurationName()));
requiredClasspath.getDependencies().add(projectDependency(":core:spring-boot-autoconfigure"));
TaskProvider<CheckAutoConfigurationClasses> checkAutoConfigurationClasses = tasks.register(
"checkAutoConfigurationClasses", CheckAutoConfigurationClasses.class,
(task) -> configureCheckAutoConfigurationClasses(requiredClasspath, task));
this.project.getPlugins()
.withType(OptionalDependenciesPlugin.class,
(plugin) -> configureCheckAutoConfigurationClassesForOptionalDependencies(configurations,
checkAutoConfigurationClasses));
this.project.getTasks()
.getByName(JavaBasePlugin.CHECK_TASK_NAME)
.dependsOn(checkAutoConfigurationImports, checkAutoConfigurationClasses);
}
// ... 后续方法见下
}
}
3.1 apply 方法的两行
java
public void apply(Project project) {
project.getPlugins().apply(DeployedPlugin.class);
project.getPlugins().withType(JavaPlugin.class, (javaPlugin) -> new Configurer(project).configure());
}
- 第一行:无条件应用
DeployedPlugin(因为包含自动配置的模块都应该发布) - 第二行:
withType而不是apply------ 这是 Gradle 插件开发的最佳实践
withType(JavaPlugin.class, action) 的含义是:"如果 JavaPlugin 被应用(无论在我之前还是之后),就执行这个 action"。
为什么这样写? 因为插件的应用顺序不确定。如果这里写 project.getPlugins().apply(JavaPlugin.class) 然后立即配置,而用户的 build.gradle 里 plugin 块的顺序不同,就可能拿到未初始化的状态。
withType 是惰性的:无论 JavaPlugin 何时被应用,都会触发配置。这是编写健壮 Gradle 插件的关键技巧。
3.2 Configurer 内部类模式
java
private static class Configurer {
private final Project project;
private SourceSet main;
Configurer(Project project) {
this.project = project;
this.main = project.getExtensions()
.getByType(JavaPluginExtension.class)
.getSourceSets()
.getByName(SourceSet.MAIN_SOURCE_SET_NAME);
}
void configure() { ... }
}
一个简单的参数对象 + 状态持有 模式。把 Project 和 main sourceSet 作为字段,让后续各个配置方法共享。
这种模式在 Gradle 插件开发中很常见,因为配置逻辑往往需要在多个方法间共享上下文。
3.3 addAnnotationProcessorsDependencies() ------ 注入注解处理器
java
private void addAnnotationProcessorsDependencies() {
this.project.getConfigurations()
.getByName(JavaPlugin.ANNOTATION_PROCESSOR_CONFIGURATION_NAME)
.getDependencies()
.addAll(projectDependencies(":core:spring-boot-autoconfigure-processor",
":configuration-metadata:spring-boot-configuration-processor"));
}
自动为项目添加两个注解处理器:
:core:spring-boot-autoconfigure-processor------ 处理@AutoConfiguration注解,生成AutoConfiguration.imports?还是生成 metadata?我们在 §6 确认。:configuration-metadata:spring-boot-configuration-processor------ 处理@ConfigurationProperties,生成配置元数据
这意味着 :任何应用了 auto-configuration 插件的模块,自动 获得这两个注解处理器,无需在 build.gradle 中声明。
这解释了为什么你在 module/spring-boot-webmvc/build.gradle 里看不到任何 annotationProcessor 声明------它们由插件注入。
3.4 autoConfigurationMetadata ------ 一个跨项目的 consumable configuration
java
configurations.consumable(AUTO_CONFIGURATION_METADATA_CONFIGURATION_NAME, (configuration) -> {
configuration.attributes((attributes) -> {
attributes.attribute(Category.CATEGORY_ATTRIBUTE,
this.project.getObjects().named(Category.class, Category.DOCUMENTATION));
attributes.attribute(Usage.USAGE_ATTRIBUTE,
this.project.getObjects().named(Usage.class, "auto-configuration-metadata"));
});
});
这是一个高级 Gradle 技巧,值得展开讲。
3.4.1 configurations.consumable(...) 的含义
consumable 创建一个"供其他项目消费"的 configuration,等价于:
java
configuration.setCanBeConsumed(true);
configuration.setCanBeResolved(false);
即:这个 configuration 不能在本项目内解析成文件 ,但可以被其他项目引用。
3.4.2 Gradle 的属性(Attribute)机制
java
attributes.attribute(Category.CATEGORY_ATTRIBUTE, named(Category.class, Category.DOCUMENTATION));
attributes.attribute(Usage.USAGE_ATTRIBUTE, named(Usage.class, "auto-configuration-metadata"));
Gradle 用 Attribute 来区分"同一个项目提供的不同种类的产出"。这里声明了两个属性:
Category = DOCUMENTATION------ 这是文档类产出(不是代码、不是 jar)Usage = auto-configuration-metadata------ 自定义 usage ,值为auto-configuration-metadata
Usage 是一个可以自由扩展的属性。Boot 定义了一个专属值 auto-configuration-metadata,用来在依赖图中唯一标识"自动配置元数据"这一类 artifact。
为什么需要这个? 因为 Boot 需要在另一个项目中聚合所有模块的自动配置元数据。例如文档生成时,需要知道"所有模块一共有哪些自动配置、每个的条件是什么"。
有了这个 consumable configuration,其他项目可以这样引用:
groovy
dependencies {
someConfiguration(project(":module:spring-boot-webmvc")) {
attributes {
attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.class, "auto-configuration-metadata"))
}
}
}
Gradle 会根据 attribute 匹配,自动找到对应的 artifact。
这是 Gradle 中"跨项目传递非标准产出"的标准方案。如果你有多个模块需要产出一类自定义文件并在顶层聚合,这就是模板。
3.4.3 与 autoConfigurationMetadata 任务的绑定
java
tasks.register("autoConfigurationMetadata", AutoConfigurationMetadata.class,
this::configureAutoConfigurationMetadata);
private void configureAutoConfigurationMetadata(AutoConfigurationMetadata task) {
task.setSourceSet(this.main);
task.dependsOn(this.main.getClassesTaskName());
task.getOutputFile()
.set(this.project.getLayout().getBuildDirectory().file("auto-configuration-metadata.properties"));
this.project.getArtifacts()
.add(AutoConfigurationPlugin.AUTO_CONFIGURATION_METADATA_CONFIGURATION_NAME, task.getOutputFile(),
(artifact) -> artifact.builtBy(task));
}
四步:
task.setSourceSet(main)------ 指定要扫描的 source settask.dependsOn(main.getClassesTaskName())------ 必须在classes任务之后(因为要读 .class 文件)task.getOutputFile().set(build/auto-configuration-metadata.properties)------ 输出路径project.getArtifacts().add(configName, outputFile, artifact -> artifact.builtBy(task))------ 把任务输出注册为该 configuration 的 artifact
第 4 步是关键:builtBy(task) 告诉 Gradle"要拿到这个文件,先跑这个任务"。于是当其他项目消费 autoConfigurationMetadata configuration 时,Gradle 会自动触发该任务。
这就是 Gradle 的 artifact 机制:把任务产出与 configuration 绑定,实现按需构建。
3.5 autoConfigurationRequiredClasspath ------ "必需依赖"的定义
java
Configuration requiredClasspath = configurations.create("autoConfigurationRequiredClasspath")
.extendsFrom(configurations.getByName(this.main.getImplementationConfigurationName()),
configurations.getByName(this.main.getRuntimeOnlyConfigurationName()));
requiredClasspath.getDependencies().add(projectDependency(":core:spring-boot-autoconfigure"));
创建一个新 configuration,它继承自 implementation + runtimeOnly,并额外加上 :core:spring-boot-autoconfigure。
它的用途 :在 CheckAutoConfigurationClasses 中判断"某个类是来自必需依赖还是可选依赖"。
回忆第 01 章 §3.2 的 optional 依赖。现在我们可以精确定义:
swift
必需依赖 (required) = implementation + runtimeOnly + core:spring-boot-autoconfigure
可选依赖 (optional) = optional configuration 中的内容
仅可选 (optionalOnly) = optional - required
这个划分是后续校验规则的基础。
为什么额外加上 :core:spring-boot-autoconfigure? 因为自动配置类必然引用 @AutoConfiguration、@ConditionalOnClass 等注解,这些来自 core:spring-boot-autoconfigure。而它通常是 optional 依赖(见 spring-boot-webmvc/build.gradle 中的 optional(project(":core:spring-boot-autoconfigure")))。
如果不把它加入 required,那么所有 Boot 自动配置的注解类都会被算作 "optional-only",导致校验逻辑误判。
3.6 check 任务的依赖注入
java
this.project.getTasks()
.getByName(JavaBasePlugin.CHECK_TASK_NAME)
.dependsOn(checkAutoConfigurationImports, checkAutoConfigurationClasses);
把两个检查任务挂到标准 check 任务上。
这意味着 :./gradlew build 或 ./gradlew check 会自动执行这两个校验。开发者不需要记得手动跑。
这就是"把约定变成构建失败"的落地方式 ------不是写文档说"请检查你的 imports 文件",而是让它成为 check 的一部分。
3.7 可选依赖的条件配置
java
this.project.getPlugins()
.withType(OptionalDependenciesPlugin.class,
(plugin) -> configureCheckAutoConfigurationClassesForOptionalDependencies(configurations,
checkAutoConfigurationClasses));
private void configureCheckAutoConfigurationClassesForOptionalDependencies(
ConfigurationContainer configurations,
TaskProvider<CheckAutoConfigurationClasses> checkAutoConfigurationClasses) {
checkAutoConfigurationClasses.configure((check) -> {
Configuration optionalClasspath = configurations.create("autoConfigurationOptionalClassPath")
.extendsFrom(configurations.getByName(OptionalDependenciesPlugin.OPTIONAL_CONFIGURATION_NAME));
check.setOptionalDependencies(optionalClasspath);
});
}
注意这里又用了 withType 惰性配置:只有当项目同时应用了 OptionalDependenciesPlugin 时,才配置 optional classpath。
这是一个优雅的设计:插件之间松耦合地协作 。如果项目没有 optional 依赖,CheckAutoConfigurationClasses 的 optionalDependencies 就是空集合,校验逻辑自然跳过相关规则。
4. 第一道闸门:CheckAutoConfigurationImports
这个任务校验 AutoConfiguration.imports 文件本身。它继承自 AutoConfigurationImportsTask。
4.1 基类 AutoConfigurationImportsTask
先看清基类的定义:
java
public abstract class AutoConfigurationImportsTask extends DefaultTask {
/**
* The path of the {@code AutoConfiguration.imports} file.
*/
public static final String IMPORTS_FILE = "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports";
private FileCollection sourceFiles = getProject().getObjects().fileCollection();
@InputFiles
@SkipWhenEmpty
@PathSensitive(PathSensitivity.RELATIVE)
public FileTree getSource() {
return this.sourceFiles.getAsFileTree().matching((filter) -> filter.include(IMPORTS_FILE));
}
public void setSource(Object source) {
this.sourceFiles = getProject().getObjects().fileCollection().from(source);
}
protected List<String> loadImports() {
File importsFile = getSource().getSingleFile();
try {
return Files.readAllLines(importsFile.toPath());
}
catch (IOException ex) {
throw new UncheckedIOException(ex);
}
}
}
三个要点:
(1) IMPORTS_FILE 常量的权威定义
java
public static final String IMPORTS_FILE = "META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports";
这是整个构建系统中 imports 文件路径的唯一权威定义。 你在第 01 章看到的所有 *.imports 文件,路径都来自这个常量。
如果你要写工具脚本处理 imports 文件,用这个常量,不要硬编码路径。
(2) Gradle 任务注解的最佳实践
java
@InputFiles
@SkipWhenEmpty
@PathSensitive(PathSensitivity.RELATIVE)
public FileTree getSource() { ... }
@InputFiles:声明为输入,参与增量构建与缓存键计算@SkipWhenEmpty:如果输入为空,跳过任务(不报错)。这很重要------没有自动配置的模块不应该构建失败。@PathSensitive(PathSensitivity.RELATIVE):只关心相对路径,绝对路径变化不算变化。这让构建缓存可以在不同机器的不同目录下复用。
这三个注解的组合是 Gradle 任务输入声明的标准姿势,值得记住。
Gradle 对任务输入的声明要求极严:漏了注解会导致增量构建失效(任务该跑不跑)或缓存错误。Boot 的每一个自定义任务都严格标注。
(3) loadImports() 返回原始行
java
protected List<String> loadImports() {
File importsFile = getSource().getSingleFile();
return Files.readAllLines(importsFile.toPath());
}
注意:保留原始顺序,不做排序、不去重、不过滤空行(虽然实际文件应该没有空行)。因为后面的检查需要判断"是否已排序"。
4.2 CheckAutoConfigurationImports 的三条规则
完整 check 方法:
java
@TaskAction
void execute() {
File importsFile = getSource().getSingleFile();
check(importsFile);
}
private void check(File importsFile) {
List<String> imports = loadImports();
List<String> problems = new ArrayList<>();
for (String imported : imports) {
File classFile = find(imported);
if (classFile == null) {
problems.add("'%s' was not found".formatted(imported));
}
else if (!correctlyAnnotated(classFile)) {
problems.add("'%s' is not annotated with @AutoConfiguration".formatted(imported));
}
}
List<String> sortedValues = new ArrayList<>(imports);
Collections.sort(sortedValues);
if (!sortedValues.equals(imports)) {
File sortedOutputFile = getOutputDirectory().file("sorted-" + importsFile.getName()).get().getAsFile();
writeString(sortedOutputFile,
sortedValues.stream().collect(Collectors.joining(System.lineSeparator())) + System.lineSeparator());
problems.add("Entries should be sorted alphabetically (expect content written to "
+ sortedOutputFile.getAbsolutePath() + ")");
}
File outputFile = getOutputDirectory().file("failure-report.txt").get().getAsFile();
writeReport(importsFile, problems, outputFile);
if (!problems.isEmpty()) {
throw new VerificationException("%s check failed. See '%s' for details"
.formatted(AutoConfigurationImportsTask.IMPORTS_FILE, outputFile));
}
}
private File find(String className) {
for (File root : this.classpath.getFiles()) {
String classFilePath = className.replace(".", "/") + ".class";
File classFile = new File(root, classFilePath);
if (classFile.isFile()) {
return classFile;
}
}
return null;
}
private boolean correctlyAnnotated(File classFile) {
return AutoConfigurationClass.of(classFile) != null;
}
规则 1:每个条目必须有对应的 class 文件
java
for (String imported : imports) {
File classFile = find(imported);
if (classFile == null) {
problems.add("'%s' was not found".formatted(imported));
}
...
}
find() 把类名转成路径(a.b.C → a/b/C.class)并在 classpath 根中查找。
这防止了什么? 类名拼写错误、重构后忘记改 imports 文件、imports 中残留已删除的类。
这类错误在运行时是非常难查的:自动配置类不存在,Boot 不会报错(只是跳过),你会困惑于"为什么我的自动配置不生效"。编译期拦截它,价值巨大。
规则 2:该类必须标注 @AutoConfiguration
java
else if (!correctlyAnnotated(classFile)) {
problems.add("'%s' is not annotated with @AutoConfiguration".formatted(imported));
}
correctlyAnnotated 调用 AutoConfigurationClass.of(classFile),返回非 null 表示有 @AutoConfiguration 注解(§6 详解)。
这防止了什么? 把普通 @Configuration 类放进 imports 文件。因为 @AutoConfiguration 与 @Configuration 有本质区别(前者不能被组件扫描到、必须 final、顺序由 sorter 控制等),混用会导致难以预料的行为。
规则 3:条目必须按字母序排序
java
List<String> sortedValues = new ArrayList<>(imports);
Collections.sort(sortedValues);
if (!sortedValues.equals(imports)) {
File sortedOutputFile = getOutputDirectory().file("sorted-" + importsFile.getName()).get().getAsFile();
writeString(sortedOutputFile,
sortedValues.stream().collect(Collectors.joining(System.lineSeparator())) + System.lineSeparator());
problems.add("Entries should be sorted alphabetically (expect content written to "
+ sortedOutputFile.getAbsolutePath() + ")");
}
这条规则最有意思,也最体现工程素养。
它不只是报错,还生成一份排好序的正确文件供你直接复制:
css
Entries should be sorted alphabetically (expect content written to
/path/to/build/checkAutoConfigurationImports/sorted-org.springframework.boot.autoconfigure.AutoConfiguration.imports)
为什么要强制排序?
- 减少合并冲突:多人同时向同一个 imports 文件添加条目,如果都追加到末尾,必然冲突。如果按字母序插入,冲突概率大幅降低(不同位置的插入不会互相干扰)。
- 便于 review:排序后的 diff 更清晰,一眼能看出新增/删除了哪条。
- 确定性:构建产物可复现。
这是一个可以直接复制到任何项目的实践 :凡是多人协作维护的列表型配置文件(如 spring.factories、.imports、白名单、枚举注册表),都应该在 CI 中强制字母序,并在失败时生成正确的版本。
4.3 失败报告机制
java
private void writeReport(File importsFile, List<String> problems, File outputFile) {
outputFile.getParentFile().mkdirs();
StringBuilder report = new StringBuilder();
if (!problems.isEmpty()) {
report.append("Found problems in '%s':%n".formatted(importsFile));
problems.forEach((problem) -> report.append(" - %s%n".formatted(problem)));
}
// ... 写入文件
}
以及:
java
if (!problems.isEmpty()) {
throw new VerificationException("%s check failed. See '%s' for details"
.formatted(AutoConfigurationImportsTask.IMPORTS_FILE, outputFile));
}
注意模式:把详细报告写到文件,异常里只给路径。
为什么? 因为 Gradle 控制台输出可能被截断或淹没在大量日志中。把详细报告写成文件,开发者可以直接打开查看。
同时注意 @OutputDirectory:
java
@OutputDirectory
public abstract DirectoryProperty getOutputDirectory();
任务声明了输出目录,所以即使检查失败,报告文件也会作为任务产出存在。这意味着 ./gradlew check 失败后,报告文件是可用的。
4.4 三条规则的完整清单
| # | 规则 | 失败信息 | 防止的问题 |
|---|---|---|---|
| 1 | 类名必须有对应 .class | 'X' was not found |
拼写错误、残留条目 |
| 2 | 类必须有 @AutoConfiguration |
'X' is not annotated with @AutoConfiguration |
混入普通 @Configuration |
| 3 | 条目必须字母序 | Entries should be sorted alphabetically |
合并冲突、diff 混乱 |
5. 第二道闸门:CheckAutoConfigurationClasses
这个任务校验自动配置类本身 (而不是 imports 文件)。它同样继承自 AutoConfigurationImportsTask,因此也拥有 loadImports()。
5.1 任务的输入准备
java
public abstract class CheckAutoConfigurationClasses extends AutoConfigurationImportsTask {
private FileCollection classpath = getProject().getObjects().fileCollection();
private FileCollection optionalDependencies = getProject().getObjects().fileCollection();
private FileCollection requiredDependencies = getProject().getObjects().fileCollection();
private SetProperty<String> optionalDependencyClassNames = getProject().getObjects().setProperty(String.class);
private SetProperty<String> requiredDependencyClassNames = getProject().getObjects().setProperty(String.class);
public CheckAutoConfigurationClasses() {
getOutputDirectory().convention(getProject().getLayout().getBuildDirectory().dir(getName()));
setGroup(LifecycleBasePlugin.VERIFICATION_GROUP);
this.optionalDependencyClassNames.set(getProject().provider(() -> classNamesOf(this.optionalDependencies)));
this.requiredDependencyClassNames.set(getProject().provider(() -> classNamesOf(this.requiredDependencies)));
}
private static List<String> classNamesOf(FileCollection classpath) {
return classpath.getFiles().stream().flatMap((file) -> {
try (JarFile jarFile = new JarFile(file)) {
return Collections.list(jarFile.entries())
.stream()
.filter((entry) -> !entry.isDirectory())
.map(JarEntry::getName)
.filter((entryName) -> entryName.endsWith(".class"))
.map((entryName) -> entryName.substring(0, entryName.length() - ".class".length()))
.map((entryName) -> entryName.replace("/", "."));
}
catch (IOException ex) {
throw new UncheckedIOException(ex);
}
}).toList();
}
5.1.1 classNamesOf ------ 从 jar 中枚举所有类名
java
try (JarFile jarFile = new JarFile(file)) {
return Collections.list(jarFile.entries())
.stream()
.filter((entry) -> !entry.isDirectory())
.map(JarEntry::getName())
.filter((entryName) -> entryName.endsWith(".class"))
.map((entryName) -> entryName.substring(0, entryName.length() - ".class".length()))
.map((entryName) -> entryName.replace("/", "."));
}
注意 :这里用 JarFile 读取 jar 的条目名,而不是加载类。因为:
- 我们只需要"类名清单",不需要类的内容
- 加载这些类可能失败(依赖缺失、版本冲突)
- 枚举条目比加载快几个数量级
这是元数据扫描 vs 类加载的经典权衡。Boot 在这里选择了元数据扫描。
5.1.2 provider(() -> ...) ------ 惰性求值
java
this.optionalDependencyClassNames.set(getProject().provider(() -> classNamesOf(this.optionalDependencies)));
用 provider 包装,意味着 classNamesOf 只在真正需要时才执行。如果任务的输入没有变化(Gradle 判断 up-to-date),这个 lambda 根本不会被调用。
这是 Gradle 配置阶段性能优化的关键 :把所有昂贵的计算包在 provider 里,让它变成惰性的。
在 440 个项目的构建中,如果每个项目在配置阶段就扫描几百个 jar,配置阶段会慢到无法接受。惰性求值让这些工作只在必要时执行。
5.1.3 @Classpath 注解
java
@Classpath
public FileCollection getClasspath() { return this.classpath; }
@Classpath
public FileCollection getOptionalDependencies() { return this.optionalDependencies; }
@Classpath
public FileCollection getRequiredDependencies() { return this.requiredDependencies; }
@Classpath 是 @InputFiles 的特化:它告诉 Gradle"这是 classpath,请用 ABI(Application Binary Interface)快照来判断是否变化"。
ABI 快照 vs 文件哈希 :普通 @InputFiles 用文件内容的哈希判断变化。而 classpath 中 jar 的很多变化(如注释、行号、方法体内部实现)不影响 ABI。用 @Classpath,Gradle 只提取类的公共签名做指纹,忽略无关变化,从而大幅提高缓存命中率。
这是 Gradle 性能优化的一个重要细节。Boot 的构建脚本中,凡是 classpath 都用 @Classpath 而非 @InputFiles。
5.2 执行逻辑
java
@TaskAction
void execute() {
Map<String, List<String>> problems = new TreeMap<>();
Set<String> optionalOnlyClassNames = new HashSet<>(this.optionalDependencyClassNames.get());
Set<String> requiredClassNames = this.requiredDependencyClassNames.get();
optionalOnlyClassNames.removeAll(requiredClassNames);
List<String> imports = loadImports();
classFiles().forEach((classFile) -> {
AutoConfigurationClass autoConfigurationClass = AutoConfigurationClass.of(classFile);
if (autoConfigurationClass != null) {
check(autoConfigurationClass, optionalOnlyClassNames, requiredClassNames, imports, problems);
}
});
File outputFile = getOutputDirectory().file("failure-report.txt").get().getAsFile();
writeReport(problems, outputFile);
if (!problems.isEmpty()) {
throw new VerificationException(
"Auto-configuration class check failed. See '%s' for details".formatted(outputFile));
}
}
关键行:optionalOnlyClassNames.removeAll(requiredClassNames)
java
Set<String> optionalOnlyClassNames = new HashSet<>(this.optionalDependencyClassNames.get());
Set<String> requiredClassNames = this.requiredDependencyClassNames.get();
optionalOnlyClassNames.removeAll(requiredClassNames);
计算出"仅在 optional 中出现的类 "------即那些可能被用户不引入的类。
这个集合是后续所有 "before/after 引用是否安全" 判断的基础。
为什么要用 optional - required 而不是直接用 optional? 因为一个依赖可能同时出现在 optional 和 implementation 中(配置错误或历史原因)。此时它对本项目是"必需的"(因为 implementation 会传递),不应算作 optional-only。用差集可以正确处理这种情况。
classFiles() ------ 遍历项目自己的 class 文件
java
private List<File> classFiles() {
List<File> classFiles = new ArrayList<>();
for (File root : this.classpath.getFiles()) {
if (root.exists()) {
try (Stream<Path> files = Files.walk(root.toPath())) {
files.forEach((file) -> {
if (Files.isRegularFile(file) && file.getFileName().toString().endsWith(".class")) {
classFiles.add(file.toFile());
}
});
}
catch (IOException ex) {
throw new UncheckedIOException(ex);
}
}
}
return classFiles;
}
用 Files.walk 递归遍历本项目的 class 输出目录,收集所有 .class 文件。
然后对每个 class 文件调用 AutoConfigurationClass.of(classFile),非 null 说明是自动配置类,进入 check。
注意 :这里遍历的是本项目自己的 class (this.classpath),而 optionalDependencies/requiredDependencies 是用来判断"外部类的归属"的。
5.3 六条校验规则(本章最重要的一节)
java
private void check(AutoConfigurationClass autoConfigurationClass, Set<String> optionalOnlyClassNames,
Set<String> requiredClassNames, List<String> imports, Map<String, List<String>> problems) {
// 规则 1:命名
if (!autoConfigurationClass.name().endsWith("AutoConfiguration")) {
problems.computeIfAbsent(autoConfigurationClass.name(), (name) -> new ArrayList<>())
.add("Name of a class annotated with @AutoConfiguration should end with AutoConfiguration");
}
// 规则 2 & 3:注册状态
boolean testAutoConfiguration = autoConfigurationClass.name().endsWith("TestAutoConfiguration");
if (!getOmittedFromImports().getOrElse(Collections.emptySet()).contains(autoConfigurationClass.name())
&& !imports.contains(autoConfigurationClass.name()) && !testAutoConfiguration) {
problems.computeIfAbsent(autoConfigurationClass.name(), (name) -> new ArrayList<>())
.add("Class is not registered in AutoConfiguration.imports");
}
if ((getOmittedFromImports().getOrElse(Collections.emptySet()).contains(autoConfigurationClass.name())
|| testAutoConfiguration) && imports.contains(autoConfigurationClass.name())) {
problems.computeIfAbsent(autoConfigurationClass.name(), (name) -> new ArrayList<>())
.add("Class should not be registered in AutoConfiguration.imports");
}
// 规则 4 & 5:before / beforeName
autoConfigurationClass.before().forEach((before) -> {
if (optionalOnlyClassNames.contains(before)) {
problems.computeIfAbsent(autoConfigurationClass.name(), (name) -> new ArrayList<>())
.add("before '%s' is from an optional dependency and should be declared in beforeName"
.formatted(before));
}
});
autoConfigurationClass.beforeName().forEach((beforeName) -> {
if (!optionalOnlyClassNames.contains(beforeName)) {
String problem = requiredClassNames.contains(beforeName)
? "beforeName '%s' is from a required dependency and should be declared in before"
.formatted(beforeName)
: "beforeName '%s' not found".formatted(beforeName);
problems.computeIfAbsent(autoConfigurationClass.name(), (name) -> new ArrayList<>()).add(problem);
}
});
// 规则 6 & 7:after / afterName(与 before 对称)
autoConfigurationClass.after().forEach((after) -> {
if (optionalOnlyClassNames.contains(after)) {
problems.computeIfAbsent(autoConfigurationClass.name(), (name) -> new ArrayList<>())
.add("after '%s' is from an optional dependency and should be declared in afterName"
.formatted(after));
}
});
autoConfigurationClass.afterName().forEach((afterName) -> {
if (!optionalOnlyClassNames.contains(afterName)) {
String problem = requiredClassNames.contains(afterName)
? "afterName '%s' is from a required dependency and should be declared in after"
.formatted(afterName)
: "afterName '%s' not found".formatted(afterName);
problems.computeIfAbsent(autoConfigurationClass.name(), (name) -> new ArrayList<>()).add(problem);
}
});
}
下面逐条深入。
规则 1:类名必须以 AutoConfiguration 结尾
java
if (!autoConfigurationClass.name().endsWith("AutoConfiguration")) {
problems...add("Name of a class annotated with @AutoConfiguration should end with AutoConfiguration");
}
这是一条命名约定,被强制执行。
为什么重要?因为 Boot 的工具链(文档生成、Actuator 的 conditions 端点、IDE 插件)都依赖这个命名来识别自动配置类。统一命名让"列出所有自动配置"变得简单可靠。
规则 2:自动配置类必须注册在 imports 中
java
boolean testAutoConfiguration = autoConfigurationClass.name().endsWith("TestAutoConfiguration");
if (!getOmittedFromImports().getOrElse(Collections.emptySet()).contains(autoConfigurationClass.name())
&& !imports.contains(autoConfigurationClass.name()) && !testAutoConfiguration) {
problems...add("Class is not registered in AutoConfiguration.imports");
}
三个豁免条件:
- 在
omittedFromImports集合中显式豁免 - 已经在 imports 中(正常情况)
- 是
*TestAutoConfiguration
这防止了"写了自动配置类但忘记注册" ------这是模块化后最容易犯的错误。在 3.x 时代,自动配置集中在 spring.factories,漏注册的痛苦较小;4.x 每个模块自己维护 imports,漏注册的概率大幅上升。所以必须有这道闸门。
规则 3:*TestAutoConfiguration 不应注册在 imports 中
java
if ((getOmittedFromImports().getOrElse(Collections.emptySet()).contains(autoConfigurationClass.name())
|| testAutoConfiguration) && imports.contains(autoConfigurationClass.name())) {
problems...add("Class should not be registered in AutoConfiguration.imports");
}
反向规则:测试专用的自动配置不能进入生产 imports 文件。
为什么? 因为测试自动配置(如 @AutoConfigureTestDatabase 背后的配置)不应该在生产运行时生效。如果误注册,会导致生产环境行为异常(例如把生产数据库替换成嵌入式数据库------这是极其危险的事故)。
这一类"防止测试配置泄漏到生产"的校验,价值极高。
规则 4-7:before/beforeName/after/afterName 的正确使用
这是本章最有技术含量的部分,也是理解 @AutoConfiguration 设计的关键。
先看规则 4:
java
autoConfigurationClass.before().forEach((before) -> {
if (optionalOnlyClassNames.contains(before)) {
problems...add("before '%s' is from an optional dependency and should be declared in beforeName"
.formatted(before));
}
});
即:如果你用 before = X.class 引用了一个 optional-only 的类,构建失败 ,提示你应该改用 beforeName = "..."。
再看规则 5:
java
autoConfigurationClass.beforeName().forEach((beforeName) -> {
if (!optionalOnlyClassNames.contains(beforeName)) {
String problem = requiredClassNames.contains(beforeName)
? "beforeName '%s' is from a required dependency and should be declared in before"
.formatted(beforeName)
: "beforeName '%s' not found".formatted(beforeName);
problems...add(problem);
}
});
即:如果你用 beforeName = "..." 但那个类不是 optional-only,构建失败。分两种情况:
- 该类在 required 中 → 应该用
before(Class 字面量) - 该类根本找不到 → 类名写错了
规则 6、7 对 after/afterName 完全对称。
5.4 为什么需要 beforeName / afterName?(深度解析)
这是本章最重要的知识点。
5.4.1 问题的起源:Class 字面量的类加载陷阱
假设你在 4.x 中写:
java
@AutoConfiguration(after = JettyAutoConfiguration.class)
public final class MyAutoConfiguration {
...
}
而 JettyAutoConfiguration 位于 spring-boot-jetty,它在你的项目中是 optional 依赖。
会发生什么?
当用户没有 引入 spring-boot-jetty 时:
- Boot 扫描到
MyAutoConfiguration - 需要读取它的
@AutoConfiguration注解元数据 - 注解中有
after = JettyAutoConfiguration.class------ 这是一个 Class 字面量 - JVM 需要解析这个 Class 引用
JettyAutoConfiguration不在 classpath 上- 💥
NoClassDefFoundError/TypeNotPresentException
关键点 :这个错误发生在注解元数据解析 阶段,早于任何条件判断。也就是说,@ConditionalOnClass 根本来不及保护它。
这是 Java 注解的一个经典陷阱:注解中的 Class 字面量会在注解被读取时触发解析。
Spring 有一些缓解机制(如 AnnotationMetadata 用 ASM 读取避免加载类,TypeNotPresentException 在某些路径下被吞掉),但这依赖具体读取方式,不可靠。
5.4.2 解决方案:字符串形式的 beforeName / afterName
Boot 4 的解法非常直接:提供字符串版本的属性。
java
@AutoConfiguration(afterName = "org.springframework.boot.jetty.autoconfigure.JettyAutoConfiguration")
public final class MyAutoConfiguration {
...
}
用字符串,就没有 Class 引用,也就不会触发类加载。Boot 在排序时按需用类名去查找------找不到就跳过(因为那个自动配置本来就不存在)。
5.4.3 为什么不让所有人统一用字符串?
既然字符串更安全,为什么还要保留 Class 字面量形式?
因为 Class 字面量有编译期检查:
- IDE 能自动补全
- 重构(重命名、移动包)会自动同步
- 拼写错误在编译时报错
- 可以"跳转到定义"
而字符串形式完全没有这些保障 。afterName = "org.springfamework.boot.JettyAutoConfiguration"(拼错)只有在构建期被 CheckAutoConfigurationClasses 抓到(这就是规则 5 的 "not found" 分支)。
所以 Boot 的策略是:
能用 Class 就用 Class(required 依赖),不能用才用字符串(optional 依赖)。构建插件强制你做出正确选择。
这是一个在"编译期安全"与"运行时灵活"之间精确取舍的设计。非常优雅。
5.4.4 规则的完整决策表
| 目标类所在位置 | 应使用的属性 | 用错时的构建错误 |
|---|---|---|
| required 依赖(implementation/runtimeOnly/autoconfigure) | before / after(Class) |
如果用 beforeName:"... is from a required dependency and should be declared in before" |
| optional-only 依赖 | beforeName / afterName(String) |
如果用 before:"... is from an optional dependency and should be declared in beforeName" |
| 不存在 | ------ | beforeName '...' not found |
5.4.5 一个真实场景
假设你在开发一个自定义 starter,它的数据源配置应该在 JPA 自动配置之后,但 JPA 是可选的:
java
@AutoConfiguration
@ConditionalOnClass(SomeJpaClass.class)
@AutoConfiguration(afterName = "org.springframework.boot.jpa.autoconfigure.HibernateJpaAutoConfiguration")
public final class MyDataSourceAutoConfiguration {
...
}
如果你的项目把 spring-boot-jpa 声明为 optional,构建会通过(因为你用了 afterName)。如果你错误地改成 after = HibernateJpaAutoConfiguration.class,构建会失败并告诉你改用 afterName。
这就是"把运行时陷阱前移为编译期失败"的具体实现。
5.5 六条(实为七条)规则清单
| # | 规则 | 失败信息 |
|---|---|---|
| 1 | 类名以 AutoConfiguration 结尾 |
Name ... should end with AutoConfiguration |
| 2 | 必须注册在 imports(除非是 Test 或豁免) | Class is not registered in AutoConfiguration.imports |
| 3 | *TestAutoConfiguration 不得注册 |
Class should not be registered in AutoConfiguration.imports |
| 4 | before/after 不得引用 optional-only 类 |
... should be declared in beforeName/afterName |
| 5 | beforeName/afterName 只能引用 optional-only 类(且必须存在) |
... should be declared in before/after 或 ... not found |
| 6 | (规则 4 的 after 版本) | 同 4 |
| 7 | (规则 5 的 afterName 版本) | 同 5 |
6. AutoConfigurationClass:用 ASM 读取字节码
前面多次用到 AutoConfigurationClass.of(classFile)。它到底是什么?
6.1 完整源码
java
public record AutoConfigurationClass(String name, List<String> before, List<String> beforeName, List<String> after,
List<String> afterName) {
private AutoConfigurationClass(String name, Map<String, List<String>> attributes) {
this(name, attributes.getOrDefault("before", Collections.emptyList()),
attributes.getOrDefault("beforeName", Collections.emptyList()),
attributes.getOrDefault("after", Collections.emptyList()),
attributes.getOrDefault("afterName", Collections.emptyList()));
}
public static AutoConfigurationClass of(InputStream input) {
try {
ClassReader classReader = new ClassReader(input);
AutoConfigurationClassVisitor visitor = new AutoConfigurationClassVisitor();
classReader.accept(visitor, ClassReader.SKIP_DEBUG | ClassReader.SKIP_CODE | ClassReader.SKIP_FRAMES);
return visitor.autoConfigurationClass;
}
catch (IOException ex) {
throw new UncheckedIOException(ex);
}
}
static AutoConfigurationClass of(File classFile) {
try (InputStream input = new FileInputStream(classFile)) {
return of(input);
}
catch (IOException ex) {
throw new UncheckedIOException(ex);
}
}
// ... Visitor 见下
}
6.2 它是一个 record
java
public record AutoConfigurationClass(String name, List<String> before, List<String> beforeName, List<String> after,
List<String> afterName)
Java 16+ 的 record 类型。五个字段:类名 + 四个排序属性。
为什么用 record? 因为这是一个纯数据载体,需要 equals/hashCode/toString,record 自动生成。而且 record 天然不可变,适合在流式处理中传递。
6.3 用 ASM 而非反射
java
ClassReader classReader = new ClassReader(input);
AutoConfigurationClassVisitor visitor = new AutoConfigurationClassVisitor();
classReader.accept(visitor, ClassReader.SKIP_DEBUG | ClassReader.SKIP_CODE | ClassReader.SKIP_FRAMES);
三个要点:
(1) org.springframework.asm.ClassReader
注意不是 org.objectweb.asm.ClassReader,而是 Spring 自己 repackage 的 ASM (org.springframework.asm)。
Spring 一直这么做(spring-core 内嵌 ASM),为了避免与用户项目中不同版本的 ASM 冲突。这是一个重要的依赖管理策略:把高危的通用库 repackage 到自己的包名下。
(2) SKIP_DEBUG | SKIP_CODE | SKIP_FRAMES ------ 性能极致优化
java
ClassReader.SKIP_DEBUG // 跳过调试信息(行号、局部变量表)
ClassReader.SKIP_CODE // 跳过方法体字节码
ClassReader.SKIP_FRAMES // 跳过栈帧表
因为我们只读注解,不需要方法体、不需要调试信息、不需要栈帧。三个 SKIP 让 ASM 的解析工作量降到最低。
这是性能优化的教科书式做法:明确自己需要什么,然后跳过一切不需要的。
(3) 为什么不反射?
反射需要:
- 加载类到 JVM
- 解析并初始化
- 可能触发静态初始化器
- 可能因为依赖缺失而失败
而 ASM 只是读字节流,完全不加载类。对于构建期工具来说,ASM 是唯一正确的选择。
这个选择也解释了前面的规则设计 :既然用 ASM 读,before = X.class 中的 X 本身不会导致加载失败(ASM 只读类名字符串)。真正的风险在运行时 (Spring 的 AnnotationMetadata 可能用反射)。所以构建期规则是"预防运行时问题",而不是"构建期会崩"。
6.4 Visitor 实现
java
private static final class AutoConfigurationClassVisitor extends ClassVisitor {
private AutoConfigurationClass autoConfigurationClass;
private String name;
private AutoConfigurationClassVisitor() {
super(SpringAsmInfo.ASM_VERSION);
}
@Override
public void visit(int version, int access, String name, String signature, String superName,
String[] interfaces) {
this.name = Type.getObjectType(name).getClassName();
}
@Override
public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
String annotationClassName = Type.getType(descriptor).getClassName();
if ("org.springframework.boot.autoconfigure.AutoConfiguration".equals(annotationClassName)) {
return new AutoConfigurationAnnotationVisitor();
}
return null;
}
private final class AutoConfigurationAnnotationVisitor extends AnnotationVisitor {
private Map<String, List<String>> attributes = new HashMap<>();
private static final Set<String> INTERESTING_ATTRIBUTES = Set.of("before", "beforeName", "after",
"afterName");
private AutoConfigurationAnnotationVisitor() {
super(SpringAsmInfo.ASM_VERSION);
}
@Override
public void visitEnd() {
AutoConfigurationClassVisitor.this.autoConfigurationClass = new AutoConfigurationClass(
AutoConfigurationClassVisitor.this.name, this.attributes);
}
@Override
public AnnotationVisitor visitArray(String attributeName) {
if (INTERESTING_ATTRIBUTES.contains(attributeName)) {
return new AnnotationVisitor(SpringAsmInfo.ASM_VERSION) {
@Override
public void visit(String name, Object value) {
if (value instanceof Type type) {
value = type.getClassName();
}
AutoConfigurationAnnotationVisitor.this.attributes
.computeIfAbsent(attributeName, (n) -> new ArrayList<>())
.add(Objects.toString(value));
}
};
}
return null;
}
}
}
6.4.1 只关注 @AutoConfiguration
java
if ("org.springframework.boot.autoconfigure.AutoConfiguration".equals(annotationClassName)) {
return new AutoConfigurationAnnotationVisitor();
}
return null;
visitAnnotation 返回 null 表示"不关心这个注解",ASM 会跳过它。只有 @AutoConfiguration 才继续解析。
注意 :这里是硬编码的字符串比较,而不是引用 AutoConfiguration.class.getName()。因为 buildSrc 编译时不依赖 spring-boot-autoconfigure(避免循环构建依赖)。
6.4.2 只提取四个属性
java
private static final Set<String> INTERESTING_ATTRIBUTES = Set.of("before", "beforeName", "after", "afterName");
@AutoConfiguration 注解可能还有其他属性(如 proxyBeanMethods),但这里只需要排序相关的四个。
6.4.3 Type → 类名的转换
java
@Override
public void visit(String name, Object value) {
if (value instanceof Type type) {
value = type.getClassName();
}
attributes.computeIfAbsent(attributeName, (n) -> new ArrayList<>()).add(Objects.toString(value));
}
ASM 中 Class 字面量以 Type 对象的形式出现(内部是 Lorg/x/Y; 这样的描述符)。这里转成正常类名 org.x.Y。
这正好解释了为什么 before(Class 字面量)和 beforeName(字符串)在 ASM 层面可以统一处理:
before = X.class→ ASM 给出Type→ 转成"org.x.X"beforeName = "org.x.X"→ ASM 给出String→ 直接用
两者最终都变成字符串,所以 AutoConfigurationClass 的四个字段都是 List<String>。这部分的优雅之处在于:ASM 抹平了两种表达形式的差异。
6.4.4 返回 null 表示"不是自动配置类"
java
static AutoConfigurationClass of(File classFile) { ... }
如果 class 文件没有 @AutoConfiguration 注解,visitor.autoConfigurationClass 保持 null,of() 返回 null。
调用方用 != null 判断是否为自动配置类:
java
AutoConfigurationClass autoConfigurationClass = AutoConfigurationClass.of(classFile);
if (autoConfigurationClass != null) {
check(autoConfigurationClass, ...);
}
以及:
java
private boolean correctlyAnnotated(File classFile) {
return AutoConfigurationClass.of(classFile) != null;
}
"返回 null 表示不适用" vs "抛异常":这里选择返回 null,因为在遍历大量 class 文件时,绝大多数不是自动配置类,抛异常的开销和代码复杂度都不划算。
7. AutoConfigurationMetadata 任务:生成模块级元数据
前面 §3.4 提到 autoConfigurationMetadata 任务会生成一个 properties 文件。现在看它到底生成了什么。
7.1 完整源码
java
public abstract class AutoConfigurationMetadata extends DefaultTask {
private static final String COMMENT_START = "#";
private final String moduleName;
private FileCollection classesDirectories;
public AutoConfigurationMetadata() {
this.moduleName = getProject().getName();
}
public void setSourceSet(SourceSet sourceSet) {
getAutoConfigurationImports().set(new File(sourceSet.getOutput().getResourcesDir(),
"META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports"));
this.classesDirectories = sourceSet.getOutput().getClassesDirs();
dependsOn(sourceSet.getOutput());
}
@InputFile
@PathSensitive(PathSensitivity.RELATIVE)
abstract RegularFileProperty getAutoConfigurationImports();
@OutputFile
public abstract RegularFileProperty getOutputFile();
@Classpath
FileCollection getClassesDirectories() {
return this.classesDirectories;
}
@TaskAction
void documentAutoConfiguration() throws IOException {
Properties autoConfiguration = readAutoConfiguration();
File outputFile = getOutputFile().get().getAsFile();
outputFile.getParentFile().mkdirs();
try (FileWriter writer = new FileWriter(outputFile)) {
autoConfiguration.store(writer, null);
}
}
private Properties readAutoConfiguration() throws IOException {
Properties autoConfiguration = CollectionFactory.createSortedProperties(true);
List<String> classNames = readAutoConfigurationsFile();
Set<String> publicClassNames = new LinkedHashSet<>();
for (String className : classNames) {
File classFile = findClassFile(className);
if (classFile == null) {
throw new IllegalStateException("Auto-configuration class '" + className + "' not found.");
}
try (InputStream in = new FileInputStream(classFile)) {
int access = new ClassReader(in).getAccess();
if ((access & Opcodes.ACC_PUBLIC) == Opcodes.ACC_PUBLIC) {
publicClassNames.add(className);
}
}
}
autoConfiguration.setProperty("autoConfigurationClassNames", String.join(",", publicClassNames));
autoConfiguration.setProperty("module", this.moduleName);
return autoConfiguration;
}
private List<String> readAutoConfigurationsFile() throws IOException {
File file = getAutoConfigurationImports().getAsFile().get();
if (!file.exists()) {
return Collections.emptyList();
}
try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
return reader.lines().map(this::stripComment).filter((line) -> !line.isEmpty()).toList();
}
}
private String stripComment(String line) {
int commentStart = line.indexOf(COMMENT_START);
if (commentStart == -1) {
return line.trim();
}
return line.substring(0, commentStart).trim();
}
private File findClassFile(String className) {
String classFileName = className.replace(".", "/") + ".class";
for (File classesDir : this.classesDirectories) {
File classFile = new File(classesDir, classFileName);
if (classFile.isFile()) {
return classFile;
}
}
return null;
}
}
7.2 输出格式
生成的 build/auto-configuration-metadata.properties 内容形如:
properties
autoConfigurationClassNames=org.springframework.boot.webmvc.autoconfigure.DispatcherServletAutoConfiguration,org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration,...
module=spring-boot-webmvc
只有两个属性:
autoConfigurationClassNames------ 本模块所有自动配置类的逗号分隔列表module------ 模块名(项目名)
7.3 几个值得注意的实现细节
(1) 支持 # 注释
java
private String stripComment(String line) {
int commentStart = line.indexOf(COMMENT_START);
if (commentStart == -1) {
return line.trim();
}
return line.substring(0, commentStart).trim();
}
imports 文件支持 # 开头的注释,读取时会被剥离。
注意 :这说明 AutoConfiguration.imports 文件的格式比表面看起来更宽松------可以有注释行和空行。而 CheckAutoConfigurationImports 的排序检查是在 loadImports()(不剥离注释)的基础上做的,所以注释行也要参与排序。
(2) 只保留 public 类
java
int access = new ClassReader(in).getAccess();
if ((access & Opcodes.ACC_PUBLIC) == Opcodes.ACC_PUBLIC) {
publicClassNames.add(className);
}
用 ASM 读取类的 access flag,只把 public 的自动配置类写入元数据。
为什么? 因为元数据是给文档生成 用的------只有 public 的自动配置类是用户可见的 API。包级私有的(如 *TestAutoConfiguration)不应出现在文档中。
注意这里用 ClassReader.getAccess() 而不是完整解析------ASM 提供了这个快捷方法,只读 access flag,极快。
(3) CollectionFactory.createSortedProperties(true) ------ 有序 Properties
java
Properties autoConfiguration = CollectionFactory.createSortedProperties(true);
普通的 java.util.Properties 是无序的(继承自 Hashtable)。Spring 的 CollectionFactory.createSortedProperties 返回一个有序的 Properties 实现。
为什么要有序? 因为生成的 properties 文件会作为构建产物参与缓存和比较。如果每次生成的顺序不同,会导致:
- 构建缓存失效
- 无谓的重新构建
- diff 噪音
这是一个容易被忽略但很重要的细节 :构建产物的确定性(determinism)。凡是生成文件,都应该保证相同输入产生字节级相同的输出。
(4) 找不到类时抛 IllegalStateException
java
if (classFile == null) {
throw new IllegalStateException("Auto-configuration class '" + className + "' not found.");
}
注意这里抛异常 而不是跳过。因为能走到这一步,说明 checkAutoConfigurationImports 已经验证过类存在了(如果配置正确的话)。抛异常是"不应该发生"的防御。
7.4 元数据的用途
这个 properties 文件被暴露为 autoConfigurationMetadata configuration 的 artifact,供其他项目消费。主要用途:
- 文档聚合 :
documentation:spring-boot-docs需要生成"所有自动配置类"的附录。它消费所有模块的元数据,汇总成一个列表。 - IDE 支持:理论上 IDE 插件可以用它快速索引。
- 校验:跨模块的一致性检查。
这是 Gradle 跨项目 artifact 传递的一个完整案例:
css
模块 A 生成文件 → 声明为 consumable configuration 的 artifact(带自定义 Usage 属性)
↓
聚合项目用 attribute 匹配引用 → Gradle 自动触发 A 的生成任务 → 拿到文件
7.5 与 AutoConfigurationMetadataLoader 的关系(重要区分)
注意不要混淆两个同名概念:
| 构建期 | 运行期 | |
|---|---|---|
| 类 | buildSrc/.../AutoConfigurationMetadata(Gradle Task) |
core/spring-boot-autoconfigure/.../AutoConfigurationMetadataLoader |
| 产物 | build/auto-configuration-metadata.properties |
META-INF/spring-autoconfigure-metadata.properties |
| 内容 | autoConfigurationClassNames + module |
条件注解元数据(ConditionalOnClass 等的值) |
| 用途 | 文档聚合 | 运行时加速条件判断 |
| 生成者 | Gradle 任务(ASM 读 class) | 注解处理器 spring-boot-autoconfigure-processor |
运行期的 AutoConfigurationMetadataLoader 加载的是 META-INF/spring-autoconfigure-metadata.properties,内容为:
ini
org.springframework.boot.XxxAutoConfiguration.ConditionalOnClass=com.example.Foo
org.springframework.boot.XxxAutoConfiguration.ConditionalOnBean=...
它的作用是让 Boot 在不加载类 的情况下就能判断 @ConditionalOnClass 是否成立------这是启动性能优化的关键。第 13 章详述。
两者的名字相似但完全无关,一个是 Boot 构建自己的工具,一个是 Boot 运行时的机制。阅读时注意区分。
8. 注解处理器:spring-boot-autoconfigure-processor
AutoConfigurationPlugin.addAnnotationProcessorsDependencies() 注入了两个注解处理器:
java
.addAll(projectDependencies(":core:spring-boot-autoconfigure-processor",
":configuration-metadata:spring-boot-configuration-processor"));
8.1 :core:spring-boot-autoconfigure-processor
这个模块的作用是在编译期处理 @AutoConfiguration 注解 ,生成运行期的 spring-autoconfigure-metadata.properties。
它解决什么问题?
考虑这个自动配置:
java
@AutoConfiguration
@ConditionalOnClass(JettyServer.class)
public final class JettyAutoConfiguration { ... }
运行时,Boot 需要判断 JettyServer 是否存在。最直接的方式是:
- 加载
JettyAutoConfiguration类 - 读取
@ConditionalOnClass注解 - 得到
JettyServer.class - 尝试
Class.forName("org.eclipse.jetty.server.Server")
问题在第 1 步:加载 JettyAutoConfiguration 本身就可能失败 ,因为它的方法签名、字段类型可能引用了不存在的类(例如某个 @Bean 方法返回 JettyServer)。
即使 JettyAutoConfiguration 能加载,读取注解时 @ConditionalOnClass(JettyServer.class) 中的 Class 字面量也可能触发解析失败。
解法:在编译期就把注解信息提取出来,写成 properties 文件:
properties
org.springframework.boot.jetty.autoconfigure.JettyAutoConfiguration.ConditionalOnClass=org.eclipse.jetty.server.Server
运行时,Boot 先读这个 properties 文件,就知道"要检查 org.eclipse.jetty.server.Server 是否存在",而完全不需要加载 JettyAutoConfiguration 类。
这是一个极其重要的启动性能优化,也是 Boot 能在 classpath 上有几百个自动配置类的情况下快速启动的原因之一。第 13 章会深入。
8.2 :configuration-metadata:spring-boot-configuration-processor
这个处理器扫描 @ConfigurationProperties 类,生成 META-INF/spring-configuration-metadata.json。
例如:
java
@ConfigurationProperties(prefix = "server")
public class ServerProperties {
private Integer port;
private String address;
// getters/setters
}
生成:
json
{
"groups": [...],
"properties": [
{ "name": "server.port", "type": "java.lang.Integer", ... },
{ "name": "server.address", "type": "java.lang.String", ... }
]
}
IDE 读取这个 JSON,就能在你敲 server. 时给出补全和文档。
第 15 章会完整展开配置元数据体系。
8.3 注解处理器的版本对齐问题
注意这两个处理器是项目内依赖 (:core:spring-boot-autoconfigure-processor),而不是外部 Maven 坐标。这意味着:
- 编译 Spring Boot 自己时,用的是当前源码的处理器
- 不存在版本不匹配问题
这是自举(bootstrap)构建的典型处理。
对于用户项目 ,你可以使用 spring-boot-configuration-processor 作为 annotationProcessor 依赖(Maven/Gradle 中),此时版本由 Boot BOM 管理。
9. 依赖治理:platform 与 BOM 体系
第 01 章 §2.1 简单提过 platform/。这里深度展开。
9.1 两个 BOM 的分工
makefile
platform:spring-boot-dependencies ← 对外,用户看到的就是它
platform:spring-boot-internal-dependencies ← 对内,Boot 自己的 128 个模块用
它们的 build.gradle 开头揭示了差异:
对外 BOM:
groovy
plugins {
id "org.springframework.boot.bom"
id "org.springframework.boot.deployed"
}
description = "Spring Boot Dependencies"
bom {
upgrade {
policy = "same-minor-version"
gitHub {
issueLabels = [
"type: dependency-upgrade",
"status: team-only"
]
}
}
...
}
对内 BOM:
groovy
plugins {
id "org.springframework.boot.bom"
}
description = "Spring Boot Internal Dependencies"
bom {
upgrade {
policy = "same-major-version"
gitHub {
issueLabels = [
"type: task",
"status: team-only"
]
}
}
...
}
关键差异:
| 维度 | spring-boot-dependencies |
spring-boot-internal-dependencies |
|---|---|---|
| 是否发布 | 是(有 deployed 插件) |
否(无 deployed) |
| 升级策略 | same-minor-version |
same-major-version |
| GitHub issue 标签 | type: dependency-upgrade |
type: task |
升级策略的含义
same-minor-version:只允许在同一 minor 版本内升级(如6.2.9→6.2.10)。这是保守策略------对外的 BOM 必须稳定,不能因为升级一个依赖的 minor 版本就引入破坏。same-major-version:只允许在同一 major 版本内升级(如6.2.9→6.5.0)。这是激进策略------内部 BOM 追求最新,因为 Boot 自己的测试会覆盖这些升级带来的变化。
这个设计非常聪明:
对外的依赖版本保守(保证用户稳定),对内的依赖版本激进(保证 Boot 自己用最新、并且能提前发现问题)。
当内部 BOM 用了某个库的新版本并跑通了全部测试,就积累了升级的信心;下一次对外 BOM 升级时,风险已经很低了。
9.2 BOM 的声明式语法
org.springframework.boot.bom 插件提供了一套**领域特定语言(DSL)**来声明依赖。看一个真实例子:
groovy
library("ActiveMQ", "6.2.9") {
group("org.apache.activemq") {
modules = [
"activemq-console",
"activemq-spring"
]
bom("activemq-bom")
}
links {
site("https://activemq.apache.org")
docs("https://activemq.apache.org/components/classic/documentation")
releaseNotes(version -> "https://activemq.apache.org/components/classic/download/classic-%02d-%02d-%02d"
.formatted(version.componentInts()))
}
}
这个声明包含了普通 BOM 没有的元数据:
| 字段 | 作用 |
|---|---|
library("ActiveMQ", "6.2.9") |
库名 + 版本 |
group("org.apache.activemq") |
Maven groupId |
modules = [...] |
该 group 下需要版本管理的 artifactId 列表 |
bom("activemq-bom") |
该库官方提供的 BOM(Boot 会 import 它,而不是逐个声明) |
links { site(...) } |
官方站点 |
links { docs(...) } |
文档链接 |
links { releaseNotes(...) } |
发布说明链接(支持 lambda 动态生成) |
为什么要这些额外信息? 因为这支撑了自动化的依赖升级流程:
- Boot 的 CI 定期检查每个库的新版本
- 发现新版本后,根据
policy判断是否升级 - 自动创建 GitHub issue,附带:
- 标签(
type: dependency-upgrade) - 版本变更
- 发布说明链接 (从
releaseNoteslambda 生成)
- 标签(
- 团队 review 后合并
这就是 Spring Boot 能管理数百个第三方依赖版本的秘密------不是靠人力,而是靠声明式元数据 + 自动化。
9.3 permit 机制:BOM 导入的例外处理
再看 Artemis 的例子:
groovy
library("Artemis", "2.53.0") {
group("org.apache.artemis") {
bom("artemis-bom") {
permit("org.apache.activemq:artemis-amqp-protocol")
permit("org.apache.activemq:artemis-boot")
permit("org.apache.activemq:artemis-cdi-client")
permit("org.apache.activemq:artemis-cli")
permit("org.apache.activemq:artemis-commons")
permit("org.apache.activemq:artemis-console")
permit("org.apache.activemq:artemis-core-client-all")
...(数十个)
}
}
links {
site("https://github.com/apache/activemq-artemis")
releaseNotes("https://github.com/apache/activemq-artemis/releases/tag/{version}")
}
}
bom("artemis-bom") { permit(...) } 的含义:
导入 Artemis 官方的 artemis-bom,但只允许 permit 列表中的模块使用 BOM 中的版本。
为什么需要 permit? 因为第三方 BOM 往往管理了大量 Boot 不关心 甚至不应该采纳 的依赖。例如 artemis-bom 可能管理了 Netty、Jackson 等的版本------如果 Boot 全盘接受,就会与 Boot 自己精心挑选的版本冲突。
用 permit 白名单,Boot 精确控制从第三方 BOM 中采纳哪些版本。
这是一个非常重要的 BOM 工程实践:
导入第三方 BOM 时永远用"白名单"而不是"全盘接受"。
这条原则对于任何维护 BOM 的团队都适用。
9.4 spring-boot-dependencies 对外提供了什么
用户这样使用:
groovy
// Gradle
dependencies {
implementation(platform("org.springframework.boot:spring-boot-dependencies:4.1.1"))
implementation("org.springframework:spring-core") // 无需版本
}
或 Maven:
xml
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>4.1.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
之后所有 Boot 管理过的依赖都可以省略版本号。
BOM 中管理了多少个库? 从 build.gradle 的规模可以推测------仅 ActiveMQ、Angus Mail、Artemis 三个库就用了几十行。整个文件应该有数千行,管理数百个库。
9.5 依赖治理的三层结构
综合起来,Boot 的依赖治理是三层:
ini
┌─────────────────────────────────────────────────────┐
│ 第三层:各 module 的 build.gradle │
│ 不写版本号,只声明 group:artifact │
│ 示例:optional("com.fasterxml.jackson.core:jackson-databind")
└──────────────────────┬──────────────────────────────┘
│ 版本来自
▼
┌─────────────────────────────────────────────────────┐
│ 第二层:spring-boot-internal-dependencies │
│ policy = same-major-version(激进) │
│ 不对外发布,仅供 Boot 自身构建 │
└──────────────────────┬──────────────────────────────┘
│ 参考
▼
┌─────────────────────────────────────────────────────┐
│ 第一层:spring-boot-dependencies(对外 BOM) │
│ policy = same-minor-version(保守) │
│ 发布到 Maven Central,供全世界使用 │
└─────────────────────────────────────────────────────┘
再加一层:gradle.properties 中的关键版本 (springFrameworkVersion、jacksonVersion 等)是"被特别对待 "的------它们不通过 BOM DSL 声明,而是直接作为属性,因为它们需要被 settings.gradle、buildSrc、文档等多处引用。
9.6 依赖冲突的仲裁
在 128 个模块的规模下,依赖冲突是必然的。Boot 的处理方式:
- 内部 BOM 统一版本 ------ 所有模块看到的版本一致
permit白名单 ------ 第三方 BOM 不会污染optional不传递 ------ 减少冲突面cycle-detection------ 防止循环依赖architecture检查 ------ 防止不恰当的依赖方向
这套组合拳让 440 个项目的依赖图保持可控。
10. ArchitectureCheck 深潜:ArchUnit 在 Boot 中的完整落地
第 01 章 §4.3 讲了 ArchitectureRules 里的规则。本章补充这些规则是如何被执行的 ------即 ArchitecturePlugin 与 ArchitectureCheck 任务。
10.1 ArchitecturePlugin:为每个 source set × 语言注册任务
java
public class ArchitecturePlugin implements Plugin<Project> {
@Override
public void apply(Project project) {
project.getPlugins().withType(JavaPlugin.class, (javaPlugin) -> registerTasks(project));
}
private void registerTasks(Project project) {
JavaPluginExtension javaPluginExtension = project.getExtensions().getByType(JavaPluginExtension.class);
for (SourceSet sourceSet : javaPluginExtension.getSourceSets()) {
registerArchitectureCheck(sourceSet, "java", project)
.configure((task) -> task.setClasses(project.files(project.getTasks()
.named(sourceSet.getCompileTaskName("java"), JavaCompile.class)
.flatMap((compile) -> compile.getDestinationDirectory()))));
project.getPlugins()
.withId("org.jetbrains.kotlin.jvm",
(kotlinPlugin) -> registerArchitectureCheck(sourceSet, "kotlin", project)
.configure((task) -> task.setClasses(project.files(project.getTasks()
.named(sourceSet.getCompileTaskName("kotlin"), KotlinCompileTool.class)
.flatMap((compile) -> compile.getDestinationDirectory())))));
}
}
private TaskProvider<ArchitectureCheck> registerArchitectureCheck(SourceSet sourceSet, String language,
Project project) {
TaskProvider<ArchitectureCheck> checkArchitecture = project.getTasks()
.register(
"checkArchitecture"
+ StringUtils.capitalize(sourceSet.getName() + StringUtils.capitalize(language)),
ArchitectureCheck.class, (task) -> {
task.getSourceSet().set(sourceSet.getName());
task.getCompileClasspath().from(sourceSet.getCompileClasspath());
task.getResourcesDirectory().set(sourceSet.getOutput().getResourcesDir());
task.dependsOn(sourceSet.getProcessResourcesTaskName());
task.setDescription("Checks the architecture of the " + language + " classes of the "
+ sourceSet.getName() + " source set.");
task.setGroup(LifecycleBasePlugin.VERIFICATION_GROUP);
});
project.getTasks()
.named(LifecycleBasePlugin.CHECK_TASK_NAME)
.configure((check) -> check.dependsOn(checkArchitecture));
return checkArchitecture;
}
}
10.1.1 任务命名规则
java
"checkArchitecture" + StringUtils.capitalize(sourceSet.getName() + StringUtils.capitalize(language))
生成形如:
erlang
checkArchitectureMainJava
checkArchitectureMainKotlin
checkArchitectureTestJava
checkArchitectureTestKotlin
checkArchitectureTestFixturesJava
...
每个 source set × 每种语言,一个任务。 这让检查可以精确针对某部分代码,也让增量构建更细粒度(改了 test 代码不会重跑 main 的检查)。
10.1.2 Java 与 Kotlin 分别处理
java
registerArchitectureCheck(sourceSet, "java", project)
.configure((task) -> task.setClasses(...named(sourceSet.getCompileTaskName("java"), JavaCompile.class)...));
project.getPlugins()
.withId("org.jetbrains.kotlin.jvm", (kotlinPlugin) -> registerArchitectureCheck(sourceSet, "kotlin", project)
.configure((task) -> task.setClasses(...named(sourceSet.getCompileTaskName("kotlin"), KotlinCompileTool.class)...)));
- Java 任务无条件注册
- Kotlin 任务仅当
org.jetbrains.kotlin.jvm插件存在时注册(withId惰性)
为什么分开? 因为 Java 和 Kotlin 编译到不同的输出目录(compileJava 与 compileKotlin 的 destinationDirectory 可能不同),且 Kotlin 类在字节码层面有特殊之处(见 §10.6)。
10.1.3 getDestinationDirectory() 的 flatMap
java
.flatMap((compile) -> compile.getDestinationDirectory())
用 flatMap 从 TaskProvider<JavaCompile> 中提取 Provider<Directory>,实现惰性绑定:任务的输出目录在配置阶段可能还没确定,用 Provider 链可以推迟到执行时。
这是 Gradle 懒配置(lazy configuration)的标准做法。
10.1.4 依赖 processResources
java
task.dependsOn(sourceSet.getProcessResourcesTaskName());
task.getResourcesDirectory().set(sourceSet.getOutput().getResourcesDir());
架构检查需要访问资源目录 (因为某些规则要检查 AutoConfiguration.imports 文件、additional-spring-configuration-metadata.json 等)。
10.2 ArchitectureCheck:规则的装配
java
public ArchitectureCheck() {
getOutputDirectory().convention(getProject().getLayout().getBuildDirectory().dir(getName()));
getAnnotationClasses().convention(ArchitectureCheckAnnotation.asMap());
getRules().addAll(getProhibitObjectsRequireNonNull().convention(true)
.map(whenTrue(ArchitectureRules::noClassesShouldCallObjectsRequireNonNull)));
getRules().addAll(ArchitectureRules.standard());
getRules().addAll(whenMainSources(() -> ArchitectureRules.beanMethods(ArchitectureCheckAnnotation
.classFor(getAnnotationClasses().get(), ArchitectureCheckAnnotation.CONDITIONAL_ON_CLASS))));
getRules().addAll(whenMainSources(() -> ArchitectureRules.conditionalOnMissingBean(ArchitectureCheckAnnotation
.classFor(getAnnotationClasses().get(), ArchitectureCheckAnnotation.CONDITIONAL_ON_MISSING_BEAN))));
getRules().addAll(whenMainSources(() -> ArchitectureRules.configurationProperties(ArchitectureCheckAnnotation
.classFor(getAnnotationClasses().get(), ArchitectureCheckAnnotation.CONFIGURATION_PROPERTIES))));
getRules().addAll(whenMainSources(
() -> ArchitectureRules.configurationPropertiesBinding(ArchitectureCheckAnnotation.classFor(
getAnnotationClasses().get(), ArchitectureCheckAnnotation.CONFIGURATION_PROPERTIES_BINDING))));
getRules().addAll(whenMainSources(
() -> ArchitectureRules.configurationPropertiesDeprecation(ArchitectureCheckAnnotation.classFor(
getAnnotationClasses().get(), ArchitectureCheckAnnotation.DEPRECATED_CONFIGURATION_PROPERTY))));
getRules().addAll(whenMainSources(() -> Collections.singletonList(
ArchitectureRules.allCustomAssertionMethodsNotReturningSelfShouldBeAnnotatedWithCheckReturnValue())));
getRuleDescriptions().set(getRules().map(this::asDescriptions));
}
10.2.1 规则分组:通用 vs 仅 main
java
private Provider<List<ArchRule>> whenMainSources(Supplier<List<ArchRule>> rules) {
return isMainSourceSet().map(whenTrue(rules));
}
private Provider<Boolean> isMainSourceSet() {
return getSourceSet().convention(SourceSet.MAIN_SOURCE_SET_NAME).map(SourceSet.MAIN_SOURCE_SET_NAME::equals);
}
private Transformer<List<ArchRule>, Boolean> whenTrue(Supplier<List<ArchRule>> rules) {
return (in) -> (!in) ? Collections.emptyList() : rules.get();
}
ArchitectureRules.standard()和noClassesShouldCallObjectsRequireNonNull()------ 所有 source set 都检查- 其余规则 ------ 仅 main source set
为什么? 因为 beanMethods、conditionalOnMissingBean、configurationProperties 这些规则针对的是生产代码中的自动配置语义,测试代码不适用。
10.2.2 扩展规则集(第 01 章未覆盖的部分)
第 01 章 §4.3.1 讲了 standard() 的 16 条。这里补充另外 6 组:
(a) beanMethods(CONDITIONAL_ON_CLASS)(已在 §4.3.3 讲过)
java
static List<ArchRule> beanMethods(String annotationClass) {
return List.of(allBeanMethodsShouldReturnNonPrivateType(),
allBeanMethodsShouldNotHaveConditionalOnClassAnnotation(annotationClass));
}
(b) conditionalOnMissingBean(CONDITIONAL_ON_MISSING_BEAN)
这条规则我没有读到源码,但从命名可以推断它检查 @ConditionalOnMissingBean 的使用。常见的约束可能包括:
@ConditionalOnMissingBean应该指定value或type(而非无参使用)- 或者检查
search策略的使用
注意 :我这里是基于命名的合理推断,而非源码实证。如果你需要精确规则,请阅读 ArchitectureRules.java 中对应方法的实现。本系列的原则是:凡是引用源码的部分都有实证,凡是不确定的地方明确标注。
(c) configurationProperties(CONFIGURATION_PROPERTIES)
检查 @ConfigurationProperties 类的形态。常见的 Boot 约束包括:
- 必须有
prefix - 类应该是 public
- 应该有 getter/setter
(d) configurationPropertiesBinding(CONFIGURATION_PROPERTIES_BINDING)
@ConfigurationPropertiesBinding 注解用于标记"这是一个配置属性转换器"。规则可能检查:
- 被标注的类型必须是
Converter、GenericConverter或Formatter
(e) configurationPropertiesDeprecation(DEPRECATED_CONFIGURATION_PROPERTY)
@DeprecatedConfigurationProperty 用于标记已废弃的配置属性。规则可能检查:
- 标注了废弃的属性必须有
replacement(替代品) - 或必须有
reason
(f) allCustomAssertionMethodsNotReturningSelfShouldBeAnnotatedWithCheckReturnValue()
检查自定义断言方法(测试工具类中的 assertXxx)必须:
- 返回
this(支持链式调用),或 - 标注
@CheckReturnValue
@CheckReturnValue 来自 org.springframework.lang.CheckReturnValue(从 import 可以看到)。它的作用是让静态分析工具警告"忽略了返回值"。
这条规则的价值 :防止写出 assertThat(x).isNull(); 这样"调用但不用结果"的无意义断言(如果方法返回了新对象而非 this)。
10.2.3 prohibitObjectsRequireNonNull 可配置
java
getRules().addAll(getProhibitObjectsRequireNonNull().convention(true)
.map(whenTrue(ArchitectureRules::noClassesShouldCallObjectsRequireNonNull)));
这是一个 Property<Boolean>,默认 true,但可以被关闭。这给了某些特殊项目豁免的能力。
这是一个好的设计:规则默认开启,但留了逃生舱。
10.3 ArchitectureCheckAnnotation:解耦注解引用
这是整个机制中最巧妙的设计,值得单独讲。
问题:buildSrc 编译时不依赖 spring-boot-autoconfigure(否则会造成构建循环)。但 ArchUnit 规则需要引用 @ConditionalOnClass、@ConfigurationProperties 等注解来定义规则。怎么办?
答案:ArchitectureCheckAnnotation 枚举 + 字符串类名映射。
java
getAnnotationClasses().convention(ArchitectureCheckAnnotation.asMap());
...
ArchitectureCheckAnnotation.classFor(getAnnotationClasses().get(), ArchitectureCheckAnnotation.CONDITIONAL_ON_CLASS)
即:
java
enum ArchitectureCheckAnnotation {
CONDITIONAL_ON_CLASS("org.springframework.boot.autoconfigure.condition.ConditionalOnClass"),
CONDITIONAL_ON_MISSING_BEAN("...condition.ConditionalOnMissingBean"),
CONFIGURATION_PROPERTIES("org.springframework.boot.context.properties.ConfigurationProperties"),
CONFIGURATION_PROPERTIES_BINDING("...ConfigurationPropertiesBinding"),
DEPRECATED_CONFIGURATION_PROPERTY("...DeprecatedConfigurationProperty"),
...;
static Map<String, String> asMap() { ... }
static String classFor(Map<String, String> map, ArchitectureCheckAnnotation annotation) { ... }
}
(以上是推断的结构,实际实现请查看 ArchitectureCheckAnnotation.java。)
为什么用字符串而不是 Class 引用?
因为 ArchUnit 的规则定义方式是:
java
noMethods().should().beAnnotatedWith(annotationClass) // 参数是 String 类名
或
java
noMethods().should().beAnnotatedWith(ConditionalOnClass.class)
前者不需要编译期依赖,后者需要。Boot 选择了前者,并用枚举管理字符串常量,避免硬编码散落。
这个模式可以推广:当你的构建工具需要引用被构建项目的类时,用"字符串类名 + 常量枚举"而不是 Class 引用,避免循环依赖。
10.4 AutoConfigurationChecker:递归检查导入的配置类
这是第 01 章 §4.3.1 中 autoConfigurationClassesShouldHaveNoPublicMembers() 规则的实现者。
java
class AutoConfigurationChecker {
private final DescribedPredicate<JavaClass> isAutoConfiguration = ArchitectureRules.areRegularAutoConfiguration()
.and(ArchitectureRules.areNotKotlinClasses());
EvaluationResult check(JavaClasses javaClasses) {
AutoConfigurations autoConfigurations = new AutoConfigurations(javaClasses);
for (JavaClass javaClass : javaClasses) {
if (isAutoConfigurationOrEnclosedInAutoConfiguration(javaClass)) {
autoConfigurations.add(javaClass);
}
}
return ArchitectureRules.shouldHaveNoPublicMembers().evaluate(autoConfigurations.getConfigurations());
}
private boolean isAutoConfigurationOrEnclosedInAutoConfiguration(JavaClass javaClass) {
if (this.isAutoConfiguration.test(javaClass)) {
return true;
}
JavaClass enclosingClass = javaClass.getEnclosingClass().orElse(null);
while (enclosingClass != null) {
if (this.isAutoConfiguration.test(enclosingClass)) {
return true;
}
enclosingClass = enclosingClass.getEnclosingClass().getOrElse(null);
}
return false;
}
...
}
10.4.1 它检查什么
"找出所有自动配置类(包括其嵌套类 和被 @Import 的类),检查它们都没有 public 成员。"
10.4.2 嵌套类的处理
java
JavaClass enclosingClass = javaClass.getEnclosingClass().orElse(null);
while (enclosingClass != null) {
if (this.isAutoConfiguration.test(enclosingClass)) {
return true;
}
enclosingClass = enclosingClass.getEnclosingClass().orElse(null);
}
逐级向上查找 enclosing class。这处理了多层嵌套的情况:
java
@AutoConfiguration
public final class XxxAutoConfiguration {
static class Inner {
static class DeepInner { // ← 也会被检查
...
}
}
}
10.4.3 @Import 的递归处理
从 AutoConfigurations 内部类可以看到:
java
private static final class AutoConfigurations {
private static final String SPRING_BOOT_ROOT_PACKAGE = "org.springframework.boot";
private static final String IMPORT = "org.springframework.context.annotation.Import";
private static final String CONFIGURATION = "org.springframework.context.annotation.Configuration";
private final JavaClasses classes;
private final Map<String, JavaClass> autoConfigurationClasses = new HashMap<>();
void add(JavaClass autoConfiguration) {
if (!autoConfiguration.isMetaAnnotatedWith(CONFIGURATION)) {
return;
}
if (this.autoConfigurationClasses.putIfAbsent(autoConfiguration.getName(), autoConfiguration) != null) {
return;
}
processImports(autoConfiguration, this.autoConfigurationClasses);
}
...
}
关键逻辑:
isMetaAnnotatedWith(CONFIGURATION)------ 只处理@Configuration及其元注解(@AutoConfiguration本身被@Configuration元标注)putIfAbsent(...) != null------ 已处理过就跳过,防止循环导入导致无限递归processImports(...)------ 递归处理@Import
这正好呼应了第 01 章 §10.3(6) 讲到的 4.x 新模式:
java
@AutoConfiguration
@Import({ ServletWebServerConfiguration.class, TomcatWebServerConfiguration.class })
public final class TomcatServletWebServerAutoConfiguration { ... }
ServletWebServerConfiguration 和 TomcatWebServerConfiguration 会被 processImports 递归发现,并检查它们也没有 public 成员。
这就是为什么 4.x 可以放心地把 Bean 定义拆分到 @Import 的类中------构建期的架构检查会保证拆分出去的类同样遵守"无 public 成员"的约定。
10.4.4 SPRING_BOOT_ROOT_PACKAGE 的作用
java
private static final String SPRING_BOOT_ROOT_PACKAGE = "org.springframework.boot";
限制检查范围为 org.springframework.boot 包下的类。这避免了把第三方库的类纳入检查。
10.5 Kotlin 的特殊豁免
java
private final DescribedPredicate<JavaClass> isAutoConfiguration = ArchitectureRules.areRegularAutoConfiguration()
.and(ArchitectureRules.areNotKotlinClasses());
以及 Javadoc 中的说明:
Kotlin classes are ignored as Kotlin does not have package-private visibility and
internalisn't a good substitute.
翻译 :Kotlin 类被忽略,因为 Kotlin 没有包级私有可见性,而 internal 不是一个好的替代品。
这是一个非常实际的妥协:
- Kotlin 的可见性修饰符:
private/protected/internal/public - 没有 Java 的"包级私有"(package-private)
internal表示"模块内可见",在字节码层面其实是 public (加了一个@Metadata注解记录 internal 信息)
所以如果用 internal 来替代包级私有,字节码上仍然是 public 方法,ArchUnit 检查会失败。
Boot 的选择:Kotlin 编写的自动配置类豁免这条规则。
这个细节告诉你一件事:Boot 4 支持用 Kotlin 编写自动配置(虽然主流仍是 Java),并且团队为此做了规则层面的适配。
10.6 ArchUnit 的执行方式
java
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.lang.EvaluationResult;
ArchUnit 是一个 Java 架构测试库。它的特点:
- 不加载类 ,而是用 ASM 读取字节码构建
JavaClasses模型 - 规则用流式 DSL 表达
- 可以对方法调用、字段访问、注解、继承关系等做断言
Boot 用它来做编译产物的静态分析 ,而不是运行时检查。这比"写一个 JUnit 测试"更彻底------因为它分析的是实际的 class 文件,包括编译器生成的桥接方法、合成字段等。
对你的启示 :如果你的项目有架构约束(分层依赖、命名规范、禁止某些 API),ArchUnit 是最成熟的 Java 方案。Boot 的 ArchitectureRules.java 是一个可以直接参考的规则库。
11. 其他质量闸门
除了自动配置检查和架构检查,Boot 还有若干质量闸门。
11.1 Checkstyle
properties
checkstyleToolVersion=10.12.4
配置文件位于 gradle/plugins/config/checkstyle/(第 01 章提到过这个目录)。
Checkstyle 做语法层面的静态检查:命名规范、import 顺序、空白字符、魔法数字等。
注意:Boot 只用 Checkstyle 做基础检查,更高级的语义约束交给 ArchUnit。这是一个合理的分工:
| 层面 | 工具 |
|---|---|
| 格式(缩进、换行) | io.spring.javaformat |
| 语法约定(命名、import) | Checkstyle |
| 架构语义(依赖方向、API 使用) | ArchUnit |
| 空安全 | nullability 插件 |
| 自动配置正确性 | auto-configuration 插件 |
11.2 io.spring.javaformat
properties
javaFormatVersion=0.0.47
Spring 团队自己的 Java 格式化工具。特点:
- Tab 缩进(这是 Spring 的传统)
- 强制统一的代码布局
- 提供 Gradle 插件,可以
./gradlew format自动格式化
Boot 源码中所有文件都用 Tab 缩进且布局一致,就是它的功劳。
对阅读的影响:当你看到奇怪的换行时,那是格式化工具的结果,不影响语义。
11.3 Nullability 插件
properties
nullabilityPluginVersion=0.0.14
在 module/spring-boot-webmvc/build.gradle 中我们看到:
groovy
tasks.nullability.checking = "tests"
这个扩展支持把空值检查应用到测试代码。
工作原理(推测,基于 jspecify + NullAway 的常见做法):
- 源码用
org.jspecify.annotations.Nullable标注可为 null 的位置 - 编译器插件(或 ErrorProne/NullAway)分析数据流
- 如果发现"可能为 null 的值被当作非 null 使用",报错
这在 Boot 4 中是全覆盖 的,所以源码中的 @Nullable 是精确可信的。
11.4 cycle-detection ------ 循环依赖检测
groovy
plugins {
id "org.springframework.boot.cycle-detection"
}
对应 gradle/plugins/cycle-detection-plugin/。
它检查什么? 项目之间的依赖是否存在环。例如:
css
A → B → C → A ← 非法
为什么必须检查? 因为:
- Gradle 的并行构建在有环的情况下行为未定义
- 循环依赖意味着职责划分有问题
- 会导致构建顺序无法确定
在 440 个项目、128 个模块的规模下,人工发现循环依赖是不可能的。这个插件是 org.gradle.parallel=true 能安全开启的前提。
注意 :这里检查的是 Gradle 项目间 的循环依赖,不是 Spring Bean 之间的循环依赖(那是运行时的事,Boot 默认禁止 Bean 循环引用,见 ApplicationProperties.allowCircularReferences)。两者容易混淆。
11.5 Javadoc 与文档检查
java
// buildSrc/.../ConfigureJavadocLinks.java
links.add("https://docs.oracle.com/en/java/javase/17/docs/api/");
以及 AntoraConventions、CheckJavadocMacros 等。
Boot 对 Javadoc 有严格要求(因为要发布到 Maven Central,Javadoc jar 是必需产物)。
CheckJavadocMacros 检查 Javadoc 中的宏引用是否正确------因为 Boot 的文档用 Antora 构建,Javadoc 中可能引用外部文档链接。
11.6 质量闸门全景
bash
./gradlew build
│
├── compileJava / compileKotlin
│ └── 注解处理器:生成 imports? / metadata / configuration-metadata
│
├── check
│ ├── checkAutoConfigurationImports ← imports 文件 3 条规则
│ ├── checkAutoConfigurationClasses ← 自动配置类 7 条规则
│ ├── checkArchitectureMainJava ← ArchUnit 通用 16+ 条
│ ├── checkArchitectureMainKotlin ← (若存在 Kotlin)
│ ├── checkArchitectureTestJava ← ArchUnit 通用规则(测试)
│ ├── checkstyleMain / checkstyleTest ← 语法规范
│ ├── test ← 单元/切片测试
│ └── ...其他(javadoc、nullability)
│
├── autoConfigurationMetadata ← 生成模块元数据(供聚合)
│
└── assemble → jar / sourcesJar / javadocJar
任何一个环节失败,整个构建失败。这就是"把约定变成编译期约束"的完整落地。
12. 实战:把 Boot 的工程化能力搬到你的项目
读到这里,你可能会想:"这些机制很棒,但我的项目也需要吗?"
答案是:分层次采纳。下面给出从易到难的落地路线。
12.1 层次一:立即可用(1 小时内)
(1) 可选依赖支持
如果你在做 SDK 或 starter,复制 OptionalDependenciesPlugin 的 50 行代码:
java
public class OptionalDependenciesPlugin implements Plugin<Project> {
public static final String OPTIONAL_CONFIGURATION_NAME = "optional";
@Override
public void apply(Project project) {
Configuration optional = project.getConfigurations().create("optional");
optional.setCanBeConsumed(true);
optional.setCanBeResolved(false);
project.getPlugins().withType(JavaPlugin.class, (javaPlugin) -> {
SourceSetContainer sourceSets = project.getExtensions()
.getByType(JavaPluginExtension.class).getSourceSets();
sourceSets.all((sourceSet) -> {
project.getConfigurations()
.getByName(sourceSet.getCompileClasspathConfigurationName()).extendsFrom(optional);
project.getConfigurations()
.getByName(sourceSet.getRuntimeClasspathConfigurationName()).extendsFrom(optional);
});
});
}
}
收益:你的库可以"编译时依赖但运行时可选",这是做可插拔能力的基础。
(2) 列表文件排序检查
凡是你维护的"同类条目列表文件"(注册表、白名单、SPI 文件),加一个排序检查任务,并在失败时生成正确的版本。
收益:消灭大部分合并冲突。
12.2 层次二:需要一点投入(1 天内)
(3) ArchUnit 架构规则
引入 ArchUnit,写 3-5 条最关键的规则:
java
@Test
void layeredArchitectureIsRespected() {
layeredArchitecture()
.layer("Controller").definedBy("..controller..")
.layer("Service").definedBy("..service..")
.layer("Repository").definedBy("..repository..")
.whereLayer("Controller").mayNotBeAccessedByAnyLayer()
.whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
.check(importedClasses);
}
@Test
void noClassesShouldUseJavaUtilLogging() {
noClasses().should().dependOnClassesThat().haveFullyQualifiedName("java.util.logging.Logger")
.because("we use SLF4J").check(importedClasses);
}
收益:分层架构不再靠自觉。
(4) 自定义 Gradle 插件的 TestKit 测试
如果你有自定义 Gradle 插件,用 GradleRunner 写集成测试(参考 OptionalDependenciesPluginIntegrationTests)。
收益:插件改动不再胆战心惊。
12.3 层次三:完整体系(1 周以上)
(5) 自己的 BOM
如果团队有多个服务/库,建立内部 BOM:
groovy
bom {
upgrade {
policy = "same-minor-version"
}
library("MyLib", "1.2.3") {
group("com.example") {
modules = ["my-lib-core", "my-lib-web"]
}
}
}
收益:全团队依赖版本统一,升级有据可依。
(6) 自动配置/扩展点的编译期校验
如果你的项目有"插件注册"机制(类似 Boot 的自动配置),建立:
- 注册表文件与源码的一致性检查
- 注册类的形态校验(命名、可见性、注解)
- 依赖可用性校验(必需 vs 可选)
收益:这一类错误从"运行时神秘失效"变成"构建失败"。
12.4 落地路线图
yaml
Week 1: optional 依赖支持 + 关键文件的排序检查
Week 2: ArchUnit 基础规则(分层 + 禁用 API)
Week 3: 自定义 Gradle 插件的 TestKit 测试
Month 2: 内部 BOM + 依赖升级自动化
Month 3: 扩展点注册的编译期校验
原则 :不要一次性照搬 Boot 的全部机制。Boot 有 440 个项目和上百名贡献者,你的项目可能只有 10 个模块和 5 个开发者。选择能解决你当前痛点的那一两条,先用起来。
12.5 一个关键判断:什么时候该上工程化?
我的建议标准:
| 项目规模 | 建议 |
|---|---|
| < 10 个模块、< 5 人 | 只需要格式统一 + 基础 lint |
| 10-50 个模块、5-20 人 | 加 ArchUnit 分层规则 + 内部 BOM |
| > 50 个模块、> 20 人 | 完整体系:架构规则 + 编译期校验 + 自动化依赖升级 |
| 对外发布 SDK/starter | 必须:optional 支持 + 扩展点校验 + BOM |
核心判断依据:当"某类错误靠人力 review 已经拦不住"时,就该把它变成构建失败。
13. 本章新增定律
第四定律:顺序语义常藏在 Javadoc / 文档里,不在代码里(第 2 章)
构建体系里有三类"顺序",代码层面都看不出约束:
- 插件应用顺序 ------Gradle 不保证
plugins {}块的生效次序,所以约定插件必须用withType(JavaPlugin.class, action)这种惰性写法,而不是apply后立即配置(§3)。 "顺序不确定"这个前提只写在 Gradle 的文档里,代码里只是"刚好这么写了"。 AutoConfiguration.imports的行序 ------运行期会被 sorter 重排,所以对功能无影响; 但它影响 diff 与合并冲突,于是CheckAutoConfigurationImports把"必须字母序" 变成了一条硬规则(§4 规则 3)。loadImports()刻意不排序、不去重(§4)------因为它要把原始顺序交给后面的检查去判定。
凡是"文档里写了、代码里没保证"的约束,都要在构建期补一条可执行断言; 否则它一定会在某次重构里被悄悄违反。
14. 本章未逐行核实 / 待你实测的项
buildSrc下的 Groovy 约定插件未全部读完,本章只解剖了AutoConfigurationPlugin、JavaConventions等主要几个。ArchitectureCheck的 ArchUnit 规则只展开到自动配置相关部分,其余规则未逐条读。ConfigureJavadocLinks、CheckJavadocMacros的实现细节未展开。- 依赖治理的 BOM 清单(
spring-boot-dependencies)未逐项列出。 - Kotlin 豁免规则在 Java / Kotlin 混编项目中的实际表现未实测。
下一章 :第 03 章进入模块化的自动配置------imports 机制如何取代 spring.factories,以及一个 Starter 是怎么被生成的。
15. 本章小结
13.1 本章的核心发现
-
optional的实现只有 50 行 :创建一个optionalconfiguration,让所有 source set 的 compile/runtime classpath 继承它,但不 让它流入apiElements/runtimeElements。这就是"编译时可见、不传递给下游"的全部秘密。 -
自动配置的正确性由 10 条构建期规则保证:
CheckAutoConfigurationImports3 条(存在性、注解、排序)CheckAutoConfigurationClasses7 条(命名、注册、Test 隔离、before/after 的 4 条)
-
beforeName/afterName的存在理由是"避免 Class 字面量的类加载陷阱" :当排序目标来自 optional 依赖时,Class 字面量会在注解解析时触发NoClassDefFoundError,必须用字符串形式。构建插件强制你做出正确选择。 -
ASM 而非反射 :所有构建期的类分析都用 ASM 读字节码,配合
SKIP_DEBUG | SKIP_CODE | SKIP_FRAMES做到极致性能。 -
BOM 是声明式 + 自动化的 :
library { group { modules; bom { permit(...) } } links { ... } }这样的 DSL 让数百个依赖的版本、升级策略、发布说明链接都有据可循。 -
两个 BOM 的差异化策略 :对外
same-minor-version(保守),对内same-major-version(激进)。 -
规则是可配置的、有逃生舱 :
prohibitObjectsRequireNonNull可以关闭,Kotlin 类豁免某些规则,omittedFromImports可以豁免注册检查。
13.2 本章的方法论价值
Boot 的工程实践可以浓缩成一句话:
凡是能在编译期发现的错误,就不要留给运行时;凡是能自动化的约定,就不要依赖人。
具体手段:
| 手段 | 例子 |
|---|---|
| 把约定写进构建 | 排序检查、命名检查 |
| 把运行时陷阱前移 | beforeName 校验 |
| 用元数据替代加载 | ASM 读字节码、注解处理器生成 metadata |
| 失败时给出可执行修复 | 生成 sorted 文件供复制 |
| 给工具本身写测试 | OptionalDependenciesPluginIntegrationTests |
| 分层治理解耦 | 两个 BOM、Java/Kotlin 分别检查 |
13.3 与后续章节的衔接
- 第 03 章 :会用本章的知识,完整讲清"模块化自动配置"的全貌------imports 机制、
classic兼容层、starter 生成。 - 第 13 章 :会讲运行时的
AutoConfigurationImportSelector------与本章的编译期校验形成对照。本章是"编译期怎么保证 imports 文件正确",第 13 章是"运行时怎么读这些文件"。 - 第 15 章 :配置元数据------本章 §7 提到
configuration-metadata处理器,第 15 章展开。 - 第 27 章:AOT------本章反复出现的"编译期优先"思想,在 AOT 中达到顶峰。
13.4 动手验证清单
-
找到 imports 文件路径的权威定义:
bashgrep -rn "IMPORTS_FILE" buildSrc/ -
故意破坏它试试:在某个 imports 文件里把顺序打乱,或者加一个不存在的类名,看构建如何失败、报告文件长什么样。
附录 A · 本章源码索引
| 文件 | 路径 | 关键内容 |
|---|---|---|
AutoConfigurationPlugin |
buildSrc/src/main/java/org/springframework/boot/build/autoconfigure/AutoConfigurationPlugin.java |
插件装配(§3) |
AutoConfigurationImportsTask |
同目录 | IMPORTS_FILE 常量(§4.1) |
CheckAutoConfigurationImports |
同目录 | imports 3 条规则(§4.2) |
CheckAutoConfigurationClasses |
同目录 | 类 7 条规则(§5.3) |
AutoConfigurationClass |
同目录 | ASM 解析(§6) |
AutoConfigurationMetadata |
同目录 | 元数据生成(§7) |
OptionalDependenciesPlugin |
buildSrc/src/main/java/org/springframework/boot/build/optional/ |
optional 实现(§2.2) |
OptionalDependenciesPluginIntegrationTests |
buildSrc/src/test/java/.../optional/ |
插件测试(§2.2.4) |
ArchitecturePlugin |
buildSrc/src/main/java/org/springframework/boot/build/architecture/ |
任务注册(§10.1) |
ArchitectureCheck |
同目录 | 规则装配(§10.2) |
ArchitectureRules |
同目录 | 规则定义(第 01 章 §4.3) |
AutoConfigurationChecker |
同目录 | 递归导入检查(§10.4) |
JavaConventions |
buildSrc/src/main/java/org/springframework/boot/build/ |
Java 17 基线 |
spring-boot-dependencies/build.gradle |
platform/spring-boot-dependencies/build.gradle |
对外 BOM(§9.2) |
spring-boot-internal-dependencies/build.gradle |
platform/spring-boot-internal-dependencies/build.gradle |
内部 BOM(§9.1) |
附录 B · 构建期 vs 运行期的概念对照
本章涉及多组"名字相似但语境不同"的概念,集中列出以防混淆:
| 构建期概念 | 运行期对应物 | 区别 |
|---|---|---|
AutoConfigurationMetadata(Gradle Task) |
AutoConfigurationMetadataLoader |
前者生成模块元数据文档,后者加载条件注解元数据 |
CheckAutoConfigurationClasses |
AutoConfigurationImportSelector |
前者编译期校验,后者运行时导入 |
optional configuration |
@ConditionalOnClass |
前者构建层"可选",后者运行时"按需",是同一思想的两种实现 |
autoConfigurationRequiredClasspath |
classpath 实际内容 | 前者是为校验构造的视图 |
cycle-detection(项目依赖环) |
allowCircularReferences(Bean 环) |
完全不同的两件事 |
附录 C · 10 条构建期规则速查
| # | 规则 | 来源任务 |
|---|---|---|
| 1 | imports 中的每个类必须有 .class 文件 | CheckAutoConfigurationImports |
| 2 | imports 中的类必须有 @AutoConfiguration |
CheckAutoConfigurationImports |
| 3 | imports 条目必须字母序 | CheckAutoConfigurationImports |
| 4 | 自动配置类名以 AutoConfiguration 结尾 |
CheckAutoConfigurationClasses |
| 5 | 自动配置类必须注册在 imports | CheckAutoConfigurationClasses |
| 6 | *TestAutoConfiguration 不得注册 |
CheckAutoConfigurationClasses |
| 7 | before/after 不得引用 optional-only 类 |
CheckAutoConfigurationClasses |
| 8 | beforeName/afterName 只能引用 optional-only 类 |
CheckAutoConfigurationClasses |
| 9 | after 不得引用 optional-only 类 |
CheckAutoConfigurationClasses |
| 10 | afterName 只能引用 optional-only 类 |
CheckAutoConfigurationClasses |
本章完 。下一章《第 03 章 · 模块化的自动配置:
*.imports机制、classic 兼容层与 Starter 生成》,我们将从编译期回到架构层,完整梳理 4.x 自动配置的分发机制,并回答"为什么 Boot 要这么做"以及"这对我的项目意味着什么"。