Spring Boot 4.1.1 深度源码解析-第 02 章 · 构建体系深潜:Gradle 约定插件、自动配置的编译期校验与依赖治理

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:其他项目可以消费(引用)这个 configuration
  • canBeResolved = 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
 */

三件事:

  1. 添加自动配置注解处理器依赖
  2. 定义生成自动配置元数据 的任务,并作为 artifact 暴露在 autoConfigurationMetadata configuration 中
  3. 添加检查确保 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")))

为什么需要这个? 因为跨模块测试需要共享测试辅助类。传统做法有三种:

  1. 复制代码 ------ 不可维护
  2. 发布一个 -test jar ------ 增加发布物
  3. 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"));
}

自动为项目添加两个注解处理器:

  1. :core:spring-boot-autoconfigure-processor ------ 处理 @AutoConfiguration 注解,生成 AutoConfiguration.imports?还是生成 metadata?我们在 §6 确认。
  2. :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));
}

四步:

  1. task.setSourceSet(main) ------ 指定要扫描的 source set
  2. task.dependsOn(main.getClassesTaskName()) ------ 必须在 classes 任务之后(因为要读 .class 文件)
  3. task.getOutputFile().set(build/auto-configuration-metadata.properties) ------ 输出路径
  4. 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)

为什么要强制排序?

  1. 减少合并冲突:多人同时向同一个 imports 文件添加条目,如果都追加到末尾,必然冲突。如果按字母序插入,冲突概率大幅降低(不同位置的插入不会互相干扰)。
  2. 便于 review:排序后的 diff 更清晰,一眼能看出新增/删除了哪条。
  3. 确定性:构建产物可复现。

这是一个可以直接复制到任何项目的实践 :凡是多人协作维护的列表型配置文件(如 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 的条目名,而不是加载类。因为:

  1. 我们只需要"类名清单",不需要类的内容
  2. 加载这些类可能失败(依赖缺失、版本冲突)
  3. 枚举条目比加载快几个数量级

这是元数据扫描 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");
}

三个豁免条件:

  1. 在 omittedFromImports 集合中显式豁免
  2. 已经在 imports 中(正常情况)
  3. 是 *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 时:

  1. Boot 扫描到 MyAutoConfiguration
  2. 需要读取它的 @AutoConfiguration 注解元数据
  3. 注解中有 after = JettyAutoConfiguration.class ------ 这是一个 Class 字面量
  4. JVM 需要解析这个 Class 引用
  5. JettyAutoConfiguration 不在 classpath 上
  6. 💥 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) 为什么不反射?

反射需要:

  1. 加载类到 JVM
  2. 解析并初始化
  3. 可能触发静态初始化器
  4. 可能因为依赖缺失而失败

而 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,供其他项目消费。主要用途:

  1. 文档聚合 :documentation:spring-boot-docs 需要生成"所有自动配置类"的附录。它消费所有模块的元数据,汇总成一个列表。
  2. IDE 支持:理论上 IDE 插件可以用它快速索引。
  3. 校验:跨模块的一致性检查。

这是 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 是否存在。最直接的方式是:

  1. 加载 JettyAutoConfiguration 类
  2. 读取 @ConditionalOnClass 注解
  3. 得到 JettyServer.class
  4. 尝试 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 动态生成)

为什么要这些额外信息? 因为这支撑了自动化的依赖升级流程:

  1. Boot 的 CI 定期检查每个库的新版本
  2. 发现新版本后,根据 policy 判断是否升级
  3. 自动创建 GitHub issue,附带:
    • 标签(type: dependency-upgrade)
    • 版本变更
    • 发布说明链接 (从 releaseNotes lambda 生成)
  4. 团队 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 的处理方式:

  1. 内部 BOM 统一版本 ------ 所有模块看到的版本一致
  2. permit 白名单 ------ 第三方 BOM 不会污染
  3. optional 不传递 ------ 减少冲突面
  4. cycle-detection ------ 防止循环依赖
  5. 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);
    }
    ...
}

关键逻辑:

  1. isMetaAnnotatedWith(CONFIGURATION) ------ 只处理 @Configuration 及其元注解(@AutoConfiguration 本身被 @Configuration 元标注)
  2. putIfAbsent(...) != null ------ 已处理过就跳过,防止循环导入导致无限递归
  3. 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 internal isn'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 的常见做法):

  1. 源码用 org.jspecify.annotations.Nullable 标注可为 null 的位置
  2. 编译器插件(或 ErrorProne/NullAway)分析数据流
  3. 如果发现"可能为 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    ← 非法

为什么必须检查? 因为:

  1. Gradle 的并行构建在有环的情况下行为未定义
  2. 循环依赖意味着职责划分有问题
  3. 会导致构建顺序无法确定

在 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 章)

构建体系里有三类"顺序",代码层面都看不出约束:

  1. 插件应用顺序 ------Gradle 不保证 plugins {} 块的生效次序,所以约定插件必须用 withType(JavaPlugin.class, action) 这种惰性写法,而不是 apply 后立即配置(§3)。 "顺序不确定"这个前提只写在 Gradle 的文档里,代码里只是"刚好这么写了"。
  2. AutoConfiguration.imports 的行序 ------运行期会被 sorter 重排,所以对功能无影响; 但它影响 diff 与合并冲突,于是 CheckAutoConfigurationImports 把"必须字母序" 变成了一条硬规则(§4 规则 3)。
  3. loadImports() 刻意不排序、不去重(§4)------因为它要把原始顺序交给后面的检查去判定。

凡是"文档里写了、代码里没保证"的约束,都要在构建期补一条可执行断言; 否则它一定会在某次重构里被悄悄违反。


14. 本章未逐行核实 / 待你实测的项

  1. buildSrc 下的 Groovy 约定插件未全部读完,本章只解剖了 AutoConfigurationPlugin、JavaConventions 等主要几个。
  2. ArchitectureCheck 的 ArchUnit 规则只展开到自动配置相关部分,其余规则未逐条读。
  3. ConfigureJavadocLinks、CheckJavadocMacros 的实现细节未展开。
  4. 依赖治理的 BOM 清单(spring-boot-dependencies)未逐项列出。
  5. Kotlin 豁免规则在 Java / Kotlin 混编项目中的实际表现未实测。

下一章 :第 03 章进入模块化的自动配置------imports 机制如何取代 spring.factories,以及一个 Starter 是怎么被生成的。


15. 本章小结

13.1 本章的核心发现

  1. optional 的实现只有 50 行 :创建一个 optional configuration,让所有 source set 的 compile/runtime classpath 继承它,但不 让它流入 apiElements/runtimeElements。这就是"编译时可见、不传递给下游"的全部秘密。

  2. 自动配置的正确性由 10 条构建期规则保证:

    • CheckAutoConfigurationImports 3 条(存在性、注解、排序)
    • CheckAutoConfigurationClasses 7 条(命名、注册、Test 隔离、before/after 的 4 条)
  3. beforeName/afterName 的存在理由是"避免 Class 字面量的类加载陷阱" :当排序目标来自 optional 依赖时,Class 字面量会在注解解析时触发 NoClassDefFoundError,必须用字符串形式。构建插件强制你做出正确选择。

  4. ASM 而非反射 :所有构建期的类分析都用 ASM 读字节码,配合 SKIP_DEBUG | SKIP_CODE | SKIP_FRAMES 做到极致性能。

  5. BOM 是声明式 + 自动化的 :library { group { modules; bom { permit(...) } } links { ... } } 这样的 DSL 让数百个依赖的版本、升级策略、发布说明链接都有据可循。

  6. 两个 BOM 的差异化策略 :对外 same-minor-version(保守),对内 same-major-version(激进)。

  7. 规则是可配置的、有逃生舱 :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 动手验证清单

  1. 找到 imports 文件路径的权威定义:

    bash 复制代码
    grep -rn "IMPORTS_FILE" buildSrc/
  2. 故意破坏它试试:在某个 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 要这么做"以及"这对我的项目意味着什么"。

相关推荐
geats人山人海1 小时前
java 8 字符串
java·开发语言
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 04 章 · `SpringApplication` 全景:`run()` 的十二步状态机
java·后端·开源
鱼宵1 小时前
支付网关收官:Spring Boot 里,策略、模板方法、观察者怎么一起干活
spring boot·后端·面试
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 01 章 · Spring Boot 4 的坐标系:版本矩阵、模块拓扑与源码阅读方法论
java·后端·开源
云和数据.ChenGuang1 小时前
Golang AI 工程师路线图:从入门到实战
开发语言·人工智能·后端·golang
zyseo81 小时前
谷歌SEO 内链地图脚本:Python 画出权重分发,揪出孤岛页
java·前端·python
Sayai1 小时前
Java 进程 Unable to create new native thread 排查实战:Linux 线程数五层限制与 JVM -Xss 调优
java·linux·运维·jvm·性能优化
wuminyu1 小时前
Continuation::freeze与thaw的x86_64汇编Stack Chunk拆解源码分析
java·linux·c语言·汇编·jvm·c++
高频因子挖掘机1 小时前
分钟线跨午休后时间轴不连续,是数据错了还是 A 股交易时段本来如此?
后端·github·api