Spring Framework 7.0.9 深度剖析-02-模块化与构建体系

3 阅读35分钟

Spring Framework 7.0.9 深度剖析-02-模块化与构建体系

一、本章导读

读到本章之前,你打开 spring-core、spring-beans 的源码目录,已经可以跟着类名跳转;但只要你把这个仓库 clone 下来、敲下 ./gradlew build,立刻会遇到另一组完全不同的问题:为什么根目录没有 build.gradle 之外的工程文件?为什么每个模块目录里的构建脚本叫 spring-core.gradle 而不是默认的 build.gradle?为什么 spring-core.gradle 里出现了 optional(...) 这种 Gradle DSL 里根本不存在的配置方法?为什么全仓库搜不到一个 module-info.java,但官方宣称 Spring 对 Jigsaw 模块化是"友好"的?为什么依赖图里 spring-beans 只依赖 spring-core、spring-webmvc 却一口气 api 六个模块?

本章要解决的就是这些问题。Spring Framework 7.0.9 不是一个 Maven 多模块工程,而是一个Gradle 9.7 多模块工程(gradle/wrapper/gradle-wrapper.properties 中 distributionUrl=...gradle-9.7.0-bin.zip),它的构建体系由四层构成:

  1. settings 层:settings.gradle,决定"这个构建包含哪些工程";
  2. 根脚本层:根 build.gradle,决定"所有工程共享什么约定";
  3. buildSrc 约定插件层:buildSrc/ 下用 Java 编写的 7 个 Gradle 插件,把"Java 工具链、编译器参数、Checkstyle、测试、仓库、版本约束"全部固化成代码,各模块脚本一行配置都不用写;
  4. 模块脚本层:spring-core/spring-core.gradle 这类脚本,只声明"本模块独有的东西"——自身依赖、特殊打包、AOT 等。

本章涉及的关键文件全部可以在仓库中逐一指认:settings.gradle、gradle.properties、根 build.gradle、buildSrc/src/main/java/org/springframework/build/ 下的全部约定插件、gradle/spring-module.gradle、gradle/publications.gradle、gradle/ide.gradle、以及 22 个 spring-*.gradle 与 4 个 framework-*.gradle。

前置知识:你需要会写基本的 Gradle 构建脚本(知道 api/implementation/compileOnly 的区别,知道 java-library 插件做了什么),知道 Maven BOM 与 Gradle platform()/enforcedPlatform() 的语义,对 JPMS(Java Platform Module System)的 automatic module 与 named module 概念不陌生。读完本章,你应当能够:在本机源码上复现官方构建的每一个关键决策(JDK 25 工具链编译、-release 17 字节码基线、Multi-Release JAR),画出 11 个核心模块的依赖方向图并指出每一条依赖的设计理由,说清 7.0.9 相对旧版本在模块化与日志桥接上的两处重大变化,并且能看懂 spring-framework-bom 这个发布产物是如何从单一版本源自动生成的。

需要特别预告两个会贯穿全章的"反直觉"事实,它们都和很多旧资料/旧博客的描述相反,本章会逐处给出仓库证据:

  • 7.0.9 全仓库没有任何一个 module-info.java(笔者对整仓递归搜索 module-info.java 的结果为空)。Spring 7 放弃了 6.x 时代在部分模块中放置 module 描述符的路线,所有 jar 都以 automatic module 形式存在于模块路径上,模块名完全由 jar manifest 里的 Automatic-Module-Name 属性决定。
  • 7.0.9 没有 spring-jcl 模块。settings.gradle 的 27 个 include 中不存在 spring-jcl,源码树里也找不到 org.apache.commons.logging.LogFactory 的任何桥接实现类;相反,spring-core.gradle 第 78 行直接 api("commons-logging:commons-logging"),由 framework-platform 约束到 commons-logging:commons-logging:1.3.5——日志门面从"内嵌桥接"变成了"真实第三方依赖"。

二、核心概念

本章不重复 Gradle 用户手册的定义,只结合本仓库的实际使用方式,把后面源码剖析要反复用到的抽象一次讲清。

约定插件(Convention Plugin)。Gradle 官方在 2017 年前后提出"avoid the script Dsl",大型工程应当把可复用的构建逻辑写成 buildSrc 里的 Plugin<Project> 实现类,而不是堆在根脚本里 subprojects { ... } 闭包中。本仓库 buildSrc/src/main/java/org/springframework/build/ConventionsPlugin.java 第 41-48 行就是本仓库的"总约定入口":

@Override
public void apply(Project project) {
	project.getExtensions().create("springFramework", SpringFrameworkExtension.class);
	new ArchitecturePlugin().apply(project);
	new CheckstyleConventions().apply(project);
	new JavaConventions().apply(project);
	new KotlinConventions().apply(project);
	new TestConventions().apply(project);
}

它自己不做事,只是把五个子约定(架构检查、Checkstyle、Java 编译、Kotlin 编译、测试)按顺序 apply 到目标工程。这种"总插件+子约定"的切分方式,决定了你在任何 spring-*.gradle 里都看不到 sourceCompatibility = ...、test.useJUnitPlatform() 这类配置——它们全部来自这里。

api 与 implementation 的传递性差异。这是理解本仓库依赖图的钥匙:api(...) 声明的依赖会进入编译 API classpath,从而对下游模块"可见"(下游编译时能用到它的类);implementation(...) 只在当前模块自己的编译与运行时 classpath 上,下游编译时看不到。本仓库几乎不使用裸 implementation 来声明模块间依赖,模块间依赖一律 api(project(":spring-xxx"))——这意味着 spring-context api 了 spring-beans,那么任何用 spring-context 的工程,编译期自动拿到 spring-beans,这正是"spring-context 一站式聚合"的依赖传播基础。

optional 配置(本仓库私有约定)。buildSrc/.../optional/OptionalDependenciesPlugin.java 创建了一个名为 optional 的配置,它 canBeConsumed=false、canBeResolved=false,并且只 extendsFrom 到当前工程自己的 compile/runtime classpath,不向下游传递。语义上等价于 Maven 的 <optional>true</optional>:编译期我能用,运行时 classpath 上有,但我的下游不强制继承。你会在每个 spring-*.gradle 里看到几十个 optional(...)——这正是 Spring"可选集成"(类加载时探测某个库是否存在,存在才启用对应功能)的构建侧表达。

java-platform / BOM。Gradle 的 java-platform 插件把一个工程变成"只声明依赖约束、不产出字节码"的平台。framework-platform 与 framework-bom 两个工程都用它:前者是内部强制平台(enforcedPlatform,约束版本但不发布),后者是对外发布的 BOM(发布为 Maven 坐标 org.springframework:spring-framework-bom)。二者区别后面会展开。

Toolchain 与 -release。Gradle 的 toolchain 机制允许"用某个 JDK 运行编译",而 javac 的 --release N 参数决定"产出的 class 文件能在哪个版本的 JVM 上运行"。本仓库把二者解耦得非常干净:toolchain 用 JDK 25(JavaConventions.DEFAULT_LANGUAGE_VERSION = JavaLanguageVersion.of(25)),-release 默认 17(DEFAULT_RELEASE_VERSION = JavaLanguageVersion.of(17))。这解释了为什么你需要装 JDK 25 才能构建、但产物宣称兼容 JDK 17+。

Automatic Module Name。当一个 jar 没有 module-info.class、却被放到 JDK 9+ 的模块路径上时,模块系统会根据 jar 文件名推导一个"自动模块名"(把 - 之后的一切都丢掉、把 - 换成 .),这种推导脆弱且不可控;jar manifest 里的 Automatic-Module-Name 属性可以显式指定这个名字。本仓库所有 jar 统一由 gradle/spring-module.gradle 第 45 行写入该属性,这就是 Spring 在没有 module-info 的情况下依然"JPMS 友好"的全部秘密。

Multi-Release JAR(MR-JAR)。一种 jar 打包格式:META-INF/versions/21/、META-INF/versions/24/ 目录下存放针对高版本 JDK 的替代类,运行时 JVM 会优先加载自己能识别的最高版本目录里的类,其余用根目录类。本仓库 spring-core 额外维护了 src/main/java21/ 与 src/main/java24/ 两套源码,就是 MR-JAR 的源码组织形式。

三、源码剖析(一):settings.gradle 逐段解读

settings.gradle 是 Gradle 构建的入口中的入口:Gradle 启动时第一件事就是执行它,它决定了"settings 阶段"要把哪些目录识别为工程(Project)。本仓库的 settings.gradle 全文只有 53 行,但每一行都有讲究。先把全文按段拆开。

3.1 插件解析:Develocity 约定插件

plugins {
	id "io.spring.develocity.conventions" version "0.0.25"
}

settings.gradle 的 plugins {} 块是 settings 插件的声明位置,作用对象是 Settings 对象本身,而不是某个 Project。这里声明的 io.spring.develocity.conventions 是 Spring 自己发布在 Gradle Plugin Portal 上的内部插件(版本 0.0.25),它封装了 Gradle Enterprise / Develocity Build Scan 的接入约定:把构建上传到 Spring 自托管的 Develocity 服务、自动标注 CI build、缓存构建数据等。你在任何模块脚本里都看不到 develocity 配置,因为全在这个 settings 插件里。

这个插件的存在解释了同文件第 38-52 行那段看似奇怪的钩子:

settings.gradle.projectsLoaded {
	develocity {
		buildScan {
			File buildDir = settings.gradle.rootProject
					.getLayout().getBuildDirectory().getAsFile().get()
			buildDir.mkdirs()
			new File(buildDir, "build-scan-uri.txt").text = "(build scan not generated)"
			buildScanPublished { scan ->
				if (buildDir.exists()) {
					new File(buildDir, "build-scan-uri.txt").text = "${scan.buildScanUri}\n"
				}
			}
		}
	}
}

逐段讲:settings.gradle.projectsLoaded 是 settings 阶段的回调,工程图加载完成后触发。此时通过 settings.gradle.rootProject 拿到根工程的 build 目录(build/),先写一个占位文件 build-scan-uri.txt,内容是 (build scan not generated)——这是给本地开发者看的:你这次构建没有产生 build scan 也没关系,构建不会因为找不到这个文件而失败。真正有意思的是 buildScanPublished { scan -> ... } 回调:当 Develocity 插件成功发布了一次 build scan 后,它会把生成的 scan URL 回写到同一个 build-scan-uri.txt。CI 脚本随后读这个文件、把 URL 打印到日志或作为 artifact 上传。这是一个典型的"异步产物回调落盘"模式——build scan 的发布发生在构建后期,而文件路径必须在构建早期就确定。

3.2 27 个 include:工程图的全部成员

include "spring-aop"
include "spring-aspects"
include "spring-beans"
include "spring-context"
include "spring-context-indexer"
include "spring-context-support"
include "spring-core"
include "spring-core-test"
include "spring-expression"
include "spring-instrument"
include "spring-jdbc"
include "spring-jms"
include "spring-messaging"
include "spring-orm"
include "spring-oxm"
include "spring-r2dbc"
include "spring-test"
include "spring-tx"
include "spring-web"
include "spring-webflux"
include "spring-webmvc"
include "spring-websocket"
include "framework-api"
include "framework-bom"
include "framework-docs"
include "framework-platform"
include "integration-tests"

数一下:22 个 spring-* 业务模块 + framework-api/framework-bom/framework-docs/framework-platform 四个非代码模块 + integration-tests,共 27 个。三个层次一眼可辨:

  • 业务模块(spring- 前缀):最终发布到 Maven Central 的 jar,也是 root build.gradle 第 13 行 moduleProjects = subprojects.findAll { it.name.startsWith("spring-") } 圈定的集合。
  • framework-* 模块:不发布业务 jar 的"工具型"工程,名字以 framework- 开头,被第 14 行 javaProjects = subprojects.findAll { !it.name.startsWith("framework-") } 明确排除在"应用 java 插件"的范围之外——这就是为什么 framework-platform、framework-bom 用 java-platform 而不是 java-library。
  • integration-tests:名字既不以 spring- 也不以 framework- 开头,它落在 javaProjects 集合里(会被应用 java 插件与测试约定),但不在 moduleProjects 里(不发布 jar、不套用 spring-module.gradle)。

这里要特别指出一个陷阱:很多旧资料会告诉你"Spring 的日志桥接在 spring-jcl 模块里"。在本仓库的 27 个 include 中不存在 spring-jcl。这不是漏写,而是 7.x 的有意为之——本章第 3.8 节会专门回到这个话题。

3.3 rootProject.name 与 buildFileName 映射

rootProject.name = "spring"
rootProject.children.each {project ->
	project.buildFileName = "${project.name}.gradle"
}

第一行把根工程的名字设为 spring。Gradle 默认根工程名取目录名(clone 下来目录可能叫 spring-framework-7.0.9 或别的),显式写死保证无论 clone 到什么目录名、无论 CI checkout 路径如何,根工程坐标永远是 spring。

第二行是本仓库最具辨识度的约定:rootProject.children.each 遍历所有子工程,把每个子工程的构建脚本文件名改成 ${project.name}.gradle。Gradle 默认会找 <工程目录>/build.gradle,这里改成找 <工程目录>/spring-core.gradle、<工程目录>/spring-beans.gradle……这就是为什么你在仓库里看到的是 spring-core/spring-core.gradle 而不是 spring-core/build.gradle。

这个设计有三个实际收益:

  1. 在 IDE 文件树里一眼认出构建脚本。模块目录下如果有 build.gradle,你得点开目录才知道这是哪个模块的脚本;叫 spring-core.gradle 后,文件名本身就带模块名,搜索 spring-core.gradle 直接命中。
  2. 避免与未来可能的 build.gradle 混淆。Gradle 生态里"根脚本叫 build.gradle、模块脚本也叫 build.gradle"是默认约定,全仓库十几个同名文件,grep 时极易误中。改名后每个构建脚本文件名全局唯一。
  3. 与 rootProject.children.each 的动态性配合。注意这段代码不是写死的 27 行映射,而是对"所有 include 进来的工程"统一生效——你以后临时 include 一个实验模块,它自动找 <模块名>.gradle,无需改这段逻辑。

代价是:新贡献者第一次 clone 仓库会困惑"为什么没有 build.gradle"。仓库根目录的 import-into-eclipse.md、import-into-intellij-idea.md 就是为了降低这个上手成本。

3.4 settings 阶段做了什么、没做什么

把 3.1-3.3 串起来看:settings.gradle 一共做了四件事——接 Develocity、圈定 27 个工程、固定根工程名、重命名各工程构建脚本。它没有声明任何仓库依赖(repositories {} 块不在 settings 里,而在 buildSrc 的 RepositoriesPlugin 里),没有配置任何模块依赖关系(那是各模块 .gradle 的事),没有设置版本号(版本号在 gradle.properties)。这符合 Gradle 的分层哲学:settings 只管"工程图本身",工程图建好之后,才轮到根 build.gradle 对每个 Project 施加约定。

四、源码剖析(二):gradle.properties 与根 build.gradle

4.1 gradle.properties 逐行解读

本仓库的 gradle.properties 短得反常——全文只有 11 行:

version=7.0.9

org.gradle.caching=true
org.gradle.jvmargs=-Xmx2048m
org.gradle.parallel=true

kotlinVersion=2.2.21
byteBuddyVersion=1.17.6

kotlin.jvm.target.validation.mode=ignore
kotlin.stdlib.default.dependency=false

逐行拆:

version=7.0.9——全仓库唯一版本源。 Gradle 把 gradle.properties 里的属性自动注入为根 project 的 project property,因此所有 27 个工程共享同一个 project.version。这一行同时是 Maven 坐标 org.springframework:spring-core:7.0.9 的来源、spring-framework-bom 的版本来源、javadoc 链接里 https://docs.spring.io/spring-framework/docs/7.0.9/kdoc-api/ 的版本来源(见 buildSrc/.../KotlinConventions.java 第 71-74 行用 project.getVersion() 拼 URL)。发布 7.0.10 时只需要改这一行,这就是本章后面要讲的"单一版本源"机制的全部支点。

org.gradle.caching=true——开启构建缓存。 Gradle 会把每个 task 的输入输出哈希后存到 ~/.gradle/caches/build-cache-1,任何机器上、任何工作区里,只要 task 的输入哈希相同就直接复用上次结果。对 Spring 这种"27 个模块、几万个测试类"的工程,二次构建(尤其是切分支回来)速度收益巨大。配合根 build.gradle 第 23-28 行 configurations.all { resolutionStrategy { cacheChangingModulesFor 0, "seconds"; cacheDynamicVersionsFor 0, "seconds" } }——注意这两行是针对依赖解析缓存的,与构建缓存正交:它告诉 Gradle,对于 changing module 和动态版本号(如 1.0.+),不要缓存解析结果,每次都重新查远程仓库,避免开发期拿到过期快照。

org.gradle.jvmargs=-Xmx2048m——Gradle daemon 堆内存。 这是运行 Gradle 本身的 JVM 的堆设置,不是编译或测试 JVM 的。Spring 的构建图巨大(27 工程、每个工程几十个 task),Gradle 在配置阶段要把所有脚本 evaluate 一遍,2G 堆是经验值;太小会在 configure 阶段 OOM,太大则挤占测试 JVM 的内存。

org.gradle.parallel=true——并行构建不相关工程。 Gradle 根据模块依赖图自动划分并行槽位:spring-expression 与 spring-webmvc 编译互不阻塞时可以同时跑。这要求所有模块构建脚本是"配置阶段自包含"的(不能在配置期跨模块读状态),本仓库把约定都收敛进 buildSrc 插件,客观上保证了这个前提。

kotlinVersion=2.2.21、byteBuddyVersion=1.17.6——两个被脚本显式引用的版本变量。 前者在根 build.gradle 第 4 行被插值:id 'org.jetbrains.kotlin.plugin.serialization' version "${kotlinVersion}"——注意 Gradle 的 plugins {} 块在 settings/根脚本里求值时,kotlinVersion 必须已经存在于 gradle.properties,这就是为什么它要放在这里而不是某个 ext 块里。后者被 buildSrc/.../TestConventions.java 第 84-92 行读取,用于给所有测试 JVM 挂 -javaagent:...byte-buddy-agent:1.17.6(Mockito 等库在测试期需要 ByteBuddy 做类重定义)。

kotlin.jvm.target.validation.mode=ignore——这是 Kotlin Gradle 插件的一个开关。Kotlin 编译器默认会校验"你设置的 JVM target 与 toolchain 是否一致",不一致直接报错。本仓库 toolchain 是 JDK 25 而 Kotlin 代码 -Xjdk-release=17(见 KotlinConventions.java 第 56 行),二者天然不一致;显式 ignore 告诉 Kotlin 插件不要拦。

kotlin.stdlib.default.dependency=false——关掉 Kotlin 插件的"自动添加 stdlib"行为。Kotlin Gradle 插件默认给每个 Kotlin 工程偷偷加上 kotlin-stdlib 依赖;本仓库要么显式 optional("org.jetbrains.kotlin:kotlin-stdlib")(作为可选集成),要么根本不需要 stdlib,自动注入会污染依赖图、破坏"可选依赖不传递"的精心设计,因此显式关闭。

4.2 根 build.gradle:插件清单与工程分类

根 build.gradle 全文 83 行,是整个构建的"总指挥"。逐段看。

plugins {
	id 'io.freefair.aspectj' version '8.13.1' apply false
	// kotlinVersion is managed in gradle.properties
	id 'org.jetbrains.kotlin.plugin.serialization' version "${kotlinVersion}" apply false
	id 'org.jetbrains.dokka'
	id 'com.github.bjornvester.xjc' version '1.8.2' apply false
	id 'com.gradleup.shadow' version "9.2.2" apply false
	id 'me.champeau.jmh' version '0.7.2' apply false
	id 'io.spring.nullability' version '0.0.14' apply false
}

根脚本的 plugins {} 块有两个作用:声明插件版本,以及(不带 apply false 的)把插件应用到根工程本身。注意这里的版本号全部"对齐到仓库":aspectj 8.13.1、xjc 1.8.2、shadow 9.2.2、jmh 0.7.2、nullability 0.0.14——只有 serialization 插件用 ${kotlinVersion} 占位,与 gradle.properties 联动。apply false 的意思是"声明版本但先不应用",留给子模块在自己的脚本里 apply plugin: '...' 时使用——比如 spring-module.gradle 第 7 行 apply plugin: 'me.champeau.jmh' 用的就是这里声明的 0.7.2。唯一一个没写 apply false 的是 org.jetbrains.dokka:根工程自己要应用它(其实是通过 KotlinConventions 里对 :framework-api 的 dokka 任务聚合间接使用)。

紧接着是两行 ext,把工程分类固化下来:

ext {
	moduleProjects = subprojects.findAll { it.name.startsWith("spring-") }
	javaProjects = subprojects.findAll { !it.name.startsWith("framework-") }
}

moduleProjects 是 22 个要发布 jar 的业务模块;javaProjects 是"除四个 framework-* 之外的所有工程",即 22 个业务模块 + integration-tests。这两个集合后面被反复引用,是根脚本的两个"切片"。

4.3 configure(allprojects):所有工程的公共底座

configure(allprojects) { project ->
	apply plugin: "org.springframework.build.localdev"
    apply plugin: "org.springframework.build.repositories"
	group = "org.springframework"
	configurations.all {
		resolutionStrategy {
			cacheChangingModulesFor 0, "seconds"
			cacheDynamicVersionsFor 0, "seconds"
		}
	}
}

allprojects 包括根工程自己。对每一个工程(连 framework-platform、framework-docs 都不例外)做三件事:

  1. 应用 org.springframework.build.localdev——即 LocalDevelopmentPlugin,作用是如果开发者加了 -PskipDocs 参数,就把所有 group 为 documentation 或 distribution 的 task 全部 setEnabled(false),本地快速构建时跳过 javadoc、打包。
  2. 应用 org.springframework.build.repositories——即 RepositoriesPlugin,配置 Maven 仓库(详见 4.5 节)。
  3. 统一 group = "org.springframework",并把"依赖解析缓存 0 秒"的策略套到所有 configuration 上。

4.4 dependencyManagement 配置与 enforcedPlatform

configure(allprojects - project(":framework-platform")) {
	configurations {
		dependencyManagement {
			canBeConsumed = false
			canBeResolved = false
			visible = false
		}
		matching { it.name.endsWith("Classpath") }.all { it.extendsFrom(dependencyManagement) }
	}
	dependencies {
		dependencyManagement(enforcedPlatform(dependencies.project(path: ":framework-platform")))
	}
}

这是整个版本管理体系的枢纽,逐行拆:

  • 新建一个名为 dependencyManagement 的 configuration,三个属性 canBeConsumed=false、canBeResolved=false、visible=false——它既不被外部消费、也不自己参与解析、对脚本不可见,纯粹是一个"载体"。
  • 所有名字以 Classpath 结尾的 configuration(compileClasspath、runtimeClasspath、testCompileClasspath……)都 extendsFrom(dependencyManagement)——这样这个载体里的内容自动进入所有 classpath。
  • 往这个载体里放一个 enforcedPlatform(project(":framework-platform"))。enforcedPlatform 与普通 platform() 的区别在于"强制":它不仅提供版本约束,还会覆盖传递依赖里出现的任何冲突版本。

合起来的语义是:除了 :framework-platform 自己以外,全仓库每个工程的每一条 classpath,都被 :framework-platform 的版本清单强制约束。这就是为什么各模块脚本里写 api("commons-logging:commons-logging") 不写版本号——版本号统一在 framework-platform 里管。这也解释了为什么 exclude :framework-platform 本身:它自己不能被自己强制,否则循环。

4.5 configure([rootProject] + javaProjects):Java 工程约定与公共测试依赖

configure([rootProject] + javaProjects) { project ->
	apply plugin: "java"
	apply plugin: "java-test-fixtures"
	apply plugin: 'org.springframework.build.conventions'
	apply from: "${rootDir}/gradle/ide.gradle"

	dependencies {
		testImplementation("org.junit.jupiter:junit-jupiter")
		testImplementation("org.junit.platform:junit-platform-suite")
		testImplementation("org.mockito:mockito-core")
		testImplementation("org.mockito:mockito-junit-jupiter")
		testImplementation("io.mockk:mockk") {
			exclude group: 'junit', module: 'junit'
		}
		testImplementation("org.assertj:assertj-core")
		testRuntimeOnly("org.junit.platform:junit-platform-launcher")
		testRuntimeOnly("org.apache.logging.log4j:log4j-core")
	}
	...
}

对根工程 + 所有非 framework-* 工程:应用 java 插件、java-test-fixtures 插件(Gradle 官方的"测试夹具"机制,允许 testImplementation(testFixtures(project(":spring-core"))) 这种跨工程复用测试工具类)、org.springframework.build.conventions(buildSrc 里那个总约定插件)、以及 ide.gradle(Eclipse 工程文件定制)。

测试依赖在这里统一配置:JUnit Jupiter、JUnit Platform Suite、Mockito 全家桶、MockK(注意排除了传递进来的 JUnit 4)、AssertJ、JUnit Platform Launcher(运行时需要,因为 Gradle 默认不带)、Log4j Core(测试期日志实现)。所有模块共享这一套,模块脚本里只需写自己特有的测试依赖(比如 spring-web 的 Jetty、spring-test 的 Selenium)。

末尾的 ext.javadocLinks 是一个外部 javadoc URL 数组(Java 17、Hibernate ORM 7.2、Quartz、HttpClient5、Reactor、Reactive Streams、JUnit 4/6、R2DBC、JSpecify),供各模块 javadoc task 用 -link 交叉引用外部 API 文档。

最后一段:

configure(moduleProjects) { project ->
	apply from: "${rootDir}/gradle/spring-module.gradle"
}

22 个 spring-* 业务模块统一套用 gradle/spring-module.gradle——这是模块脚本层的"公共母版",下一节讲。

五、源码剖析(三):buildSrc——把约定写成 Java 代码

5.1 buildSrc 自身的构建

buildSrc 是 Gradle 的"特殊目录":构建启动时,Gradle 会先单独编译这个目录,把产物加到构建脚本的 classpath 上,于是根脚本和各模块脚本里就能 apply plugin: "org.springframework.build.conventions" 这样引用其中的类。

buildSrc/build.gradle 自己只做三件事:

plugins {
	id 'java-gradle-plugin'
	id 'checkstyle'
}
repositories {
	mavenCentral()
	gradlePluginPortal()
}

java-gradle-plugin 插件提供了 gradlePlugin { plugins { ... } } DSL,用于把内部类注册成 Gradle plugin marker。随后的 dependencies 声明了 buildSrc 自己需要的工具:kotlin-gradle-plugin(版本号动态从父工程 gradle.properties 读 kotlinVersion,见脚本第 11-18 行——buildSrc 与主构建共享 Kotlin 版本,避免两套 Kotlin 打架)、dokka-gradle-plugin:2.1.0、archunit:1.4.1、test-retry-gradle-plugin:1.6.2、spring-javaformat-gradle-plugin:0.0.43、nohttp-gradle:0.0.11。

gradlePlugin { plugins { ... } } 块把七个实现类注册为七个插件 marker:

marker id实现类
org.springframework.architectureArchitecturePlugin
org.springframework.build.conventionsConventionsPlugin
org.springframework.build.localdevLocalDevelopmentPlugin
org.springframework.build.multiReleaseJarMultiReleaseJarPlugin
org.springframework.build.repositoriesRepositoriesPlugin
org.springframework.build.optional-dependenciesOptionalDependenciesPlugin
org.springframework.build.runtimehints-agentRuntimeHintsAgentPlugin

注意最后一行 jar.dependsOn check:buildSrc 自己的 jar 在打包前必须通过 checkstyle——构建工具本身也要遵守代码规范,这是一种"自举"纪律。

5.2 JavaConventions:JDK 25 编译、字节码 17

这是全章最关键的构建类。buildSrc/src/main/java/org/springframework/build/JavaConventions.java 第 48-54 行定义了两个版本常量:

/**
 * The Java version we should use as the JVM baseline for building the project.
 */
private static final JavaLanguageVersion DEFAULT_LANGUAGE_VERSION = JavaLanguageVersion.of(25);

/**
 * The Java version we should use as the baseline for the compiled bytecode
 * (the "-release" compiler argument).
 */
private static final JavaLanguageVersion DEFAULT_RELEASE_VERSION = JavaLanguageVersion.of(17);

这就是 7.0.9 的 Java 基线,以仓库为准:

  • 构建用 JDK 25。.sdkmanrc 里 java=25-librca 与之呼应。Gradle toolchain 会自动探测本机是否装了 JDK 25,没装就按 toolchain 下载规范提示你。
  • 字节码基线 Java 17。即 -release 17,意味着产物 class 文件主版本号 61,能在 JDK 17 及以上运行。这也解释了 gradle/ide.gradle 第 8-9 行为什么 sourceCompatibility = 17; targetCompatibility = 17——Eclipse IDE 里的编译配置对齐到运行时基线。

编译器参数清单(同文件第 56-73 行)值得逐条看:

List<String> commonCompilerArgs = List.of(
		"-Xlint:serial", "-Xlint:cast", "-Xlint:classfile", "-Xlint:dep-ann",
		"-Xlint:divzero", "-Xlint:empty", "-Xlint:finally", "-Xlint:overrides",
		"-Xlint:path", "-Xlint:processing", "-Xlint:static", "-Xlint:try", "-Xlint:-options",
		"-parameters"
);
COMPILER_ARGS = new ArrayList<>();
COMPILER_ARGS.addAll(commonCompilerArgs);
COMPILER_ARGS.addAll(List.of(
		"-Xlint:varargs", "-Xlint:fallthrough", "-Xlint:rawtypes", "-Xlint:deprecation",
		"-Xlint:unchecked", "-Werror"
));

主源码编译打开了一长串 -Xlint,并且 -Werror——任何一个警告都让构建失败。-parameters 是个关键开关:它把方法参数名保留进 class 文件,Spring 的 @PathVariable、@RequestParam 注解依赖参数名做绑定,这个开关必须在框架自己编译时就打开。测试源码则单独减载(TEST_COMPILER_ARGS 把 varargs/fallthrough/rawtypes/deprecation/unchecked 几个 lint 关掉),因为测试代码里允许有未检查转换等。

setJavaRelease 方法(第 122-133 行)是 MR-JAR 的编译侧关键:

private void setJavaRelease(JavaCompile task) {
	int defaultVersion = DEFAULT_RELEASE_VERSION.asInt();
	int releaseVersion = defaultVersion;
	int compilerVersion = task.getJavaCompiler().get().getMetadata().getLanguageVersion().asInt();
	for (int version = defaultVersion ; version <= compilerVersion ; version++) {
		if (task.getName().contains("Java" + version)) {
			releaseVersion = version;
			break;
		}
	}
	task.getOptions().getRelease().set(releaseVersion);
}

默认 -release 17;但如果 task 名字里包含 Java21、Java24(Gradle 为 src/main/java21、src/main/java24 自动生成的编译 task 名分别是 compileJava21Java、compileJava24Java),就把 release 提升到 21 / 24。这样 spring-core/src/main/java21/org/springframework/core/task/VirtualThreadDelegate.java 可以合法使用虚拟线程 API(JDK 21+),编译产物落到 META-INF/versions/21/,运行在 JDK 17 的用户根本碰不到这个类。

5.3 TestConventions:测试 JVM 的统一配置

TestConventions.java 把所有测试 task 统一成这样(第 60-81 行):

test.useJUnitPlatform(...);
test.include("**/*Tests.class", "**/*Test.class");
test.setSystemProperties(Map.of(
		"java.awt.headless", "true",
		"io.netty.leakDetection.level", "paranoid",
		"junit.platform.discovery.issue.severity.critical", "INFO"
));
if (project.hasProperty("testGroups")) {
	test.systemProperty("testGroups", project.findProperty("testGroups"));
}
test.jvmArgs(
		"--add-opens=java.base/java.lang=ALL-UNNAMED",
		"--add-opens=java.base/java.util=ALL-UNNAMED",
		"-Xshare:off"
);

要点:统一用 JUnit Platform;只跑类名以 Tests/Test 结尾的类;headless AWT;Netty 泄漏检测开到最严的 paranoid;--add-opens 打开 java.lang/java.util 给未命名模块反射(Spring 测试要大量操作私有字段);-Xshare:off 关闭类数据共享,避免某些测试 agent 与 CDS 冲突。

ByteBuddy agent(第 83-95 行):当工程有 byteBuddyVersion 属性时,创建一个 byteBuddyAgentConfig 配置(transitive=false),把 net.bytebuddy:byte-buddy-agent:1.17.6 挂到每个测试 JVM 的 -javaagent 上——Mockito 在测试期要重定义类,需要这个 agent。

测试重试(第 97-103 行):maxRetries 在 CI 环境变量为 true 时设为 3,本地为 0。也就是说 CI 上的 flaky test 会自动重试 3 次,且 failOnPassedAfterRetry=true——重试后通过也算失败(防止把 flaky 静默掉)。

5.4 CheckstyleConventions 与 nohttp

CheckstyleConventions.java 第 50-58 行:应用 Checkstyle 插件,工具版本 13.10.0,配置目录指向根工程 src/checkstyle,并把 io.spring.javaformat:spring-javaformat-checkstyle 作为 checkstyle 依赖——Spring 自己的 Java 格式规范同时跑在 Checkstyle 框架上。每个 checkstyle task 堆 1G 堆内存。

根工程额外应用 NoHttpPlugin(第 62-77 行):扫描所有源码里的 http:// 链接,必须在 src/nohttp/allowlist.lines 白名单里才放行,防止文档/注释里出现未加密链接。注意第 67 行的排除项里赫然写着 "**/spring-jcl/**"——这是历史遗迹,说明以前确实存在过 spring-jcl 目录,7.0.9 里已经没有了,但 nohttp 的排除规则还留着它,恰好佐证了我们前面发现的"spring-jcl 已移除"。

5.5 RepositoriesPlugin 与 LocalDevelopmentPlugin

RepositoriesPlugin.java 的仓库策略是"按版本号形态决定仓库集合"(第 36-50 行):永远加 mavenCentral();如果版本号里含 -(如 7.0.9-RC1),加 https://repo.spring.io/milestone/;如果版本号以 -SNAPSHOT 结尾,再加 https://repo.spring.io/snapshot/。发布版 7.0.9 不含 -,所以普通构建只需要 Maven Central。商业仓库(COMMERCIAL_RELEASE_REPO_URL 等环境变量)和 release train 仓库则完全由 CI 环境变量注入,本地开发者无感。

LocalDevelopmentPlugin.java 前面已经讲过:-PskipDocs 一把关掉所有 documentation/distribution 组的 task。这是本地日常 compileTestJava 循环时的提速开关。

六、源码剖析(四):gradle/ 下的三个脚本母版

6.1 spring-module.gradle:22 个业务模块的公共母版

根 build.gradle 第 80-82 行把 gradle/spring-module.gradle apply 到全部 22 个 spring-* 模块。它是模块脚本层的"九段配置"。

第一段:插件套装(第 1-9 行)

apply plugin: 'java-library'
apply plugin: 'org.springframework.build.conventions'
apply plugin: 'org.springframework.build.optional-dependencies'
apply plugin: 'me.champeau.jmh'
apply from: "$rootDir/gradle/publications.gradle"
apply plugin: "io.spring.nullability"

java-library 提供 api/implementation 配置;optional-dependencies 注册前一章说的 optional 配置;jmh 开放 JMH 微基准测试;publications.gradle 挂载 Maven 发布;io.spring.nullability 是 Spring 的 JSpecify nullability 插件(要求显式标注)。

第二段:jar manifest——Automatic-Module-Name 的唯一出处(第 42-55 行)

jar {
	manifest.attributes["Implementation-Title"] = project.name
	manifest.attributes["Implementation-Version"] = project.version
	manifest.attributes["Automatic-Module-Name"] = project.name.replace('-', '.')  // for Jigsaw
	manifest.attributes["Created-By"] =
			"${System.getProperty("java.version")} (${System.getProperty("java.specification.vendor")})"

	from("${rootDir}/framework-docs/src/docs/dist") {
		include "license.txt"
		include "notice.txt"
		into "META-INF"
		expand(copyright: new Date().format("yyyy"), version: project.version)
	}
}

第 45 行是全仓库唯一的 Automatic-Module-Name 写入点(笔者用 Grep 全仓 .gradle 文件验证,仅此一处)。project.name.replace('-', '.'):spring-core → spring.core,spring-webmvc → spring.webmvc。也就是说,7.0.9 发布的 jar 在模块路径上的自动模块名是 spring.core、spring.beans、spring.context 这种短名,而不是 org.springframework.core。这是一个必须点出的版本差异:Spring 6.x 时代 spring-core 里有真实的 module-info.java,模块名是 org.springframework.core;7.0.9 删掉了 module-info,退化为 automatic module,模块名变成 manifest 里的 spring.core。下游在 module-info.java 里写 requires spring.core; 才能引用——如果有人按旧资料写 requires org.springframework.core;,在 7.0.9 上会直接编译失败。

第三段:javadoc 与源码 jar(第 63-108 行)。模块级 javadoc 只检查语法、不检查引用完整性(addBooleanOption("Xdoclint:syntax,-reference", true)),并且 Werror=false——注释写得明白:Java 25 的 javadoc 工具上 -reference 开关不再生效,跨模块的 @see/@link 引用完整性交给 framework-api 的全局 javadoc task 去把关(那里开了 Xdoclint:syntax,reference 且 Werror=true)。sourcesJar、javadocJar 两个 task 单独注册,用于发布 sources 与文档 jar。

第四段:发布与 testFixtures 裁剪(第 110-122 行)

publishing {
	publications {
		mavenJava(MavenPublication) {
			from components.java
			artifact sourcesJar
			artifact javadocJar
		}
	}
}
components.java.withVariantsFromConfiguration(configurations.testFixturesApiElements) { skip() }
components.java.withVariantsFromConfiguration(configurations.testFixturesRuntimeElements) { skip() }

from components.java 让 Gradle 根据 api/implementation 依赖自动生成 POM;再附加 sources 与 javadoc 两个 artifact。最后两行跳过 testFixtures 的 variant 发布——testFixtures 是给本仓库内部模块互相测试用的,绝不能泄露到 POM 里污染下游。

6.2 publications.gradle:POM 模板

gradle/publications.gradle 第 1 行 apply plugin: "maven-publish",随后给每个 MavenPublication 固定一套 POM 元数据:name/description 取 project.description(即各模块脚本里 description = "Spring Core" 那一行)、GitHub SCM、Apache-2.0 license、开发者 jhoeller / Juergen Hoeller / juergen.hoeller@broadcom.com、GitHub issue 管理。

最关键的是 versionMapping 块(第 40-47 行):

versionMapping {
	usage('java-api') {
		fromResolutionResult()
	}
	usage('java-runtime') {
		fromResolutionResult()
	}
}

fromResolutionResult() 的意思是:POM 里写的依赖版本号,不取 build script 里写的字面量,而取依赖解析后实际选中的版本。由于 4.4 节那个 enforcedPlatform,所有传递依赖的版本都被 framework-platform 强制过,因此 POM 里自然落上 platform 约束后的版本——发布出去的 POM 与本地构建的解析结果完全一致,不会出现"本地是 1.3.5、POM 里写 latest"这种漂移。

configureDeploymentRepository 函数(第 54-64 行):只有当命令行带 -PdeploymentRepository=... 时,才把发布仓库设到指定 URL——本地 publish 默认无处可去,CI 通过参数注入仓库地址。

6.3 ide.gradle:Eclipse 工程文件定制

gradle/ide.gradle 第 1 行就 import org.gradle.plugins.ide.eclipse.model.*,整文件 105 行几乎全是 Eclipse classpath 的修补:

  • eclipse.jdt { sourceCompatibility = 17; targetCompatibility = 17 }——Eclipse 编译器按 17 跑,与运行时基线一致;
  • 第 14-36 行把 classpath 里"指向别的模块 build/ 输出目录"的条目替换成 ProjectDependency(这是 Gradle GRADLE-1116 这个老问题的 workaround);
  • 第 74-76 行剔除 src/main/java21、src/main/java24 两个源码目录——注释明说:Eclipse 用 JDK 17 跑,MR-JAR 的高版本源码在 Eclipse 里编译不了,直接从 classpath 拿掉。

这段脚本基本是"历史包袱收纳柜",现代开发者用 IntelliJ IDEA 时几乎不感知它,但它证明了一件事:Spring 的构建体系对 IDE 导入体验有长期承诺,每一个非标准目录都要在 ide.gradle 里显式交代。

七、源码剖析(五):模块依赖方向——逐模块 dependencies 与依赖图

这一节是本章的硬核部分。我们把 11 个核心模块的 dependencies 块逐条读出来,只关心 api(project(...)) 这种模块间传递依赖,第三方依赖暂不展开。

7.1 逐模块依赖事实

spring-core(spring-core/spring-core.gradle 第 73-94 行):没有 api(project(...))——它是依赖图的根,不依赖任何 Spring 模块。它对外暴露的 api 第三方依赖是 commons-logging:commons-logging(无版本号,由 platform 约束到 1.3.5)与 org.jspecify:jspecify。此外通过 shadow 重打包内嵌了两个第三方库:com.palantir.javapoet:javapoet:0.10.0 被 relocate 成 org.springframework.javapoet,org.objenesis:objenesis:3.5 被 relocate 成 org.springframework.objenesis,最终打进 spring-core jar(第 119-131 行把两个 repack jar 的内容 from 进主 jar)。这就是 spring-core 里 org.springframework.javapoet、org.springframework.objenesis 两个包的由来——它们不是手写源码,是构建期重打包进来的。

spring-beans(spring-beans/spring-beans.gradle 第 5-12 行):

dependencies {
	api(project(":spring-core"))
	optional("jakarta.inject:jakarta.inject-api")
	optional("org.apache.groovy:groovy-xml")
	optional("org.jetbrains.kotlin:kotlin-reflect")
	optional("org.jetbrains.kotlin:kotlin-stdlib")
	optional("org.reactivestreams:reactive-streams")
	optional("org.yaml:snakeyaml")
	...
}

唯一的 api 模块依赖是 :spring-core。注意 snakeyaml 是 optional——YAML 解析只在用户 @PropertySource("x.yml") 时才需要,放 optional 让不需要的用户不被迫带上 snakeyaml。

spring-aop(spring-aop/spring-aop.gradle 第 5-8 行):

api(project(":spring-beans"))
api(project(":spring-core"))

spring-expression(spring-expression/spring-expression.gradle 第 5-7 行):

api(project(":spring-core"))

spring-context(spring-context/spring-context.gradle 第 9-14 行):

api(project(":spring-aop"))
api(project(":spring-beans"))
api(project(":spring-core"))
api(project(":spring-expression"))
api("io.micrometer:micrometer-observation")

一次 api 四个模块。

spring-tx(spring-tx/spring-tx.gradle 第 5-9 行):

api(project(":spring-beans"))
api(project(":spring-core"))
optional(project(":spring-aop"))
optional(project(":spring-context"))  // for JCA, @EnableTransactionManagement

关键设计:spring-tx 不把 spring-aop、spring-context 做成 api,而是 optional。注释解释了原因:@EnableTransactionManagement 需要上下文注解配置、JCA 需要 context 的资源加载,但纯事务核心(PlatformTransactionManager、TransactionDefinition)本身不需要 AOP/context。下游如果只用 @Transactional 声明式事务,Spring 会在运行时探测 aop/context 是否存在。

spring-jdbc(spring-jdbc/spring-jdbc.gradle 第 5-9 行):

api(project(":spring-beans"))
api(project(":spring-core"))
api(project(":spring-tx"))
optional(project(":spring-context"))  // for JndiDataSourceLookup

spring-web(spring-web/spring-web.gradle 第 7-9 行):

api(project(":spring-beans"))
api(project(":spring-core"))
api("io.micrometer:micrometer-observation")

spring-web 对 spring-aop、spring-context 都是 optional(第 12-13 行)——它的核心是 RestTemplate/WebClient/HttpMessageConverter,不需要 IoC 容器就能跑。

spring-webmvc(spring-webmvc/spring-webmvc.gradle 第 7-12 行):

api(project(":spring-aop"))
api(project(":spring-beans"))
api(project(":spring-context"))
api(project(":spring-core"))
api(project(":spring-expression"))
api(project(":spring-web"))

一口气 api 六个模块——WebMvc 是"全家桶"模块,DispatcherServlet 要操作容器、要 SpEL 视图解析、要 AOP 拦截器,确实都需要编译期可见。

spring-webflux(spring-webflux/spring-webflux.gradle 第 7-10 行):

api(project(":spring-beans"))
api(project(":spring-core"))
api(project(":spring-web"))
api("io.projectreactor:reactor-core")

注意 webflux 不 api spring-context(第 12 行 optional)——响应式编程模型刻意保持轻量,HandlerResultHandler 等核心抽象不依赖 IoC 容器。

spring-test(spring-test/spring-test.gradle 第 7-18 行):

api(project(":spring-core"))
optional(project(":spring-aop"))
optional(project(":spring-beans"))
optional(project(":spring-context"))
optional(project(":spring-jdbc"))
optional(project(":spring-orm"))
optional(project(":spring-tx"))
optional(project(":spring-web"))
optional(project(":spring-webflux"))
optional(project(":spring-webmvc"))
optional(project(":spring-websocket"))

spring-test 的依赖面是全仓库最宽的,但全部 optional(除 spring-core)。这是测试模块的特殊定位:它要能为任意组合的模块提供测试支持(只测 spring-jdbc?只测 webflux?),因此每个领域模块都"可用可不用",用户引入哪个模块,spring-test 的对应支持类才在 classpath 上被激活。

7.2 完整模块依赖 ASCII 图

把上面的 api(project(...)) 关系统一成有向图(箭头方向:依赖者 → 被依赖者):

                        ┌─────────────┐
                        │ spring-core │◀───────────────────────────────────┐
                        └──────┬──────┘                                    │
                               │                                           │
              ┌────────────────┼──────────────────┐                        │
              ▼                ▼                  ▼                        │
        ┌───────────┐  ┌──────────────┐  ┌──────────────┐                 │
        │spring-beans│  │spring-expression│  │ spring-test │ (api core 仅此)│
        └─────┬─────┘  └──────┬───────┘  └──────┬───────┘                 │
              │               │                │                         │
              ▼               │                │                         │
        ┌───────────┐          │                │                         │
        │ spring-aop │◀────────┤                │                         │
        └─────┬─────┘          │                │                         │
              │                │                │                         │
              ▼                ▼                │                         │
        ┌────────────────────────┐              │                         │
        │      spring-context    │──────────────┼─── api aop+beans+core+expr │
        └──────┬────────┬────────┘              │                         │
               │        │                        │                         │
   optional aop/context │              optional beans/jdbc/orm/tx/web/    │
               ▼        ▼                   webflux/webmvc/websocket ...  │
        ┌──────────┐ ┌───────────┐                                          │
        │ spring-tx│ │spring-web │                                          │
        └─────┬────┘ └─────┬─────┘                                          │
              │            │                                                │
              ▼            ▼                                                │
        ┌───────────┐  ┌──────────────┐  ┌──────────────┐                  │
        │spring-jdbc│  │spring-webmvc│  │spring-webflux│───────────────────┘
        └───────────┘  └──────────────┘  └──────────────┘

7.3 依赖规则的合理性讨论

为什么 spring-beans 必须 api spring-core? BeanDefinition 抽象大量使用 org.springframework.core 下的 ResolvableType、Ordered、AttributeAccessor、NativesDetector 等工具;BeanWrapperImpl 直接操作 core 的 PropertyDescriptor。这些类在 beans 的公开 API 签名里出现(比如 BeanFactory.getBean(Class) 内部要解析泛型),下游用 spring-beans 时编译期就需要 core 的类——必须 api 而不是 implementation。

为什么 spring-webmvc 对 spring-web 是 api? DispatcherServlet、HandlerMapping、ControllerAdvice 这些类的公开签名里大量直接使用 spring-web 的 ServerHttpRequest、HttpMethod、MediaType——这是 webmvc 与 web 的"共享协议层"。反过来,spring-web 对 spring-context 是 optional,因为 web 的 RestTemplate 不需要容器;webmvc 对 context 是 api,因为 DispatcherServlet 初始化时要 WebApplicationContext。这个不对称是精心设计的:把"是否需要 IoC 容器"作为分层边界。

为什么 spring-test 的依赖面这么宽却全是 optional? 测试框架的价值在于"跟随用户的栈"。用户的工程可能只用 spring-jdbc 做数据访问测试、也可能用 spring-webmvc 做 MockMvc 测试。如果 spring-test 把这些模块做成 api,那只做数据访问测试的用户会被迫把整个 webmvc 拉进 classpath;做成 optional 后,spring-test 的类会在运行时探测这些模块是否存在,存在才启用对应支持(比如 MockMvc 只在 spring-webmvc 存在时可用)。这是"测试支持库"的经典做法。

为什么没有循环依赖? 注意 spring-tx 对 spring-context 是 optional、spring-web 对 spring-context 是 optional、spring-webflux 对 spring-context 是 optional——但凡会形成环的地方,都降级成了 optional。buildSrc/.../architecture/ArchitectureRules.java 第 30-33 行甚至用 ArchUnit 显式检查"所有包切片无环":

static ArchRule allPackagesShouldBeFreeOfTangles() {
	return SlicesRuleDefinition.slices()
			.assignedFrom(new SpringSlices()).should().beFreeOfCycles();
}

构建时 ArchUnit 会把每个包当作切片跑依赖环检测,一旦有人改出循环依赖,check 任务直接失败。依赖方向不只是文档约束,而是 CI 门禁。

八、源码剖析(六):JDK 模块化与日志桥接的 7.0.9 真相

8.1 全仓库没有 module-info.java

这是本章最需要"以仓库为准"的一节。笔者在仓库根目录递归搜索 module-info.java:

Get-ChildItem -Path "spring-framework-7.0.9" -Recurse -Filter "module-info.java" -File
# 输出为空

结果为空。27 个工程、几十万个 Java 文件,没有一个 module 描述符。这与很多技术资料的描述不同——Spring Framework 5.x 后期到 6.x,官方曾尝试在 spring-core 等模块中提供 module-info.java(你至今能在网上搜到 module org.springframework.core { exports org.springframework.core; ... } 的旧博客)。7.0.9 已经整体撤回了这条路线。

取而代之的是 6.1 节已经讲过的:所有 jar 通过 manifest 的 Automatic-Module-Name 成为自动模块。我们来核对一下实际产物上的模块名是什么样。gradle/spring-module.gradle 第 45 行:

manifest.attributes["Automatic-Module-Name"] = project.name.replace('-', '.')  // for Jigsaw

project.name 是 Gradle 工程名,也就是 spring-core、spring-beans、spring-webmvc 这种连字符形式;replace('-', '.') 后变成:

工程名Automatic-Module-Name
spring-corespring.core
spring-beansspring.beans
spring-contextspring.context
spring-aopspring.aop
spring-expressionspring.expression
spring-txspring.tx
spring-jdbcspring.jdbc
spring-webspring.web
spring-webmvcspring.webmvc
spring-webfluxspring.webflux
spring-testspring.test

注意:不是 org.springframework.core,而是 spring.core。 这意味着如果你在自己的模块化应用里写 module my.app { requires spring.core; },编译能过;写 requires org.springframework.core;(按 6.x 旧 module-info 的名字)会报"模块未找到"。这个差异在升级到 7.0 时极容易踩坑,后面"常见问题"一节还会展开。

为什么撤回 module-info?仓库里没有注释明说,但从构建证据可以推断两个原因:其一,写 module-info 就要精确声明每个 exports 与 requires,而 Spring 的模块之间通过 reflection、spring.factories、spring.components 做了大量非编译期耦合,精确表达这些关系维护成本极高;其二,7.0 大力推 AOT/native-image,运行时类加载模式与静态模块图假设越来越远,automatic module + 显式 Automatic-Module-Name 已经足够满足"在模块路径上不报错"的最低要求。

8.2 spring-jcl 的消失:日志桥接变成真实第三方依赖

第二个反直觉事实。先做两个仓库内验证:

  1. settings.gradle 的 27 个 include 里没有 spring-jcl(见 3.2 节清单,逐个数过)。
  2. 全仓库 Grep package org.apache.commons.logging,0 命中。org.springframework.jcl 包同样 0 命中。

那么全代码库到处 import org.apache.commons.logging.LogFactory;(笔者 Grep 结果超过 1500 处,遍布 spring-web、spring-oxm、spring-context 等每个模块)用的是哪个 jar 的类?答案写在 spring-core/spring-core.gradle 第 78 行:

api("commons-logging:commons-logging")

没有写版本号——版本由 framework-platform/framework-platform.gradle 第 48 行约束:

api("commons-logging:commons-logging:1.3.5")

也就是说,7.0.9 的 spring-core 直接依赖 Apache 官方的 commons-logging:1.3.5。这与 6.x 及更早版本的架构完全不同:

  • 旧架构(≤6.x):Spring 把 commons-logging 的桥接类(LogFactory、Log)源码内嵌在 spring-jcl 模块里,包名仍叫 org.apache.commons.logging。用户 classpath 上不需要真实 commons-logging,Spring 自己的桥接会自动探测 classpath 上有没有 Log4j/SLF4J/Jul,有就桥接过去,没有就退化到 JDK 1.4 logging。
  • 新架构(7.0.9):桥接类没了,直接依赖真实 commons-logging 1.3.5。commons-logging 1.3.x 本身已经是一个"瘦桥接"——它在运行时探测 SLF4J/Log4j/JUL。

对用户的实际影响:引入 spring-core:7.0.9 现在会传递引入 commons-logging:commons-logging:1.3.5。如果你用 Logback/SLF4J,桥接依然能工作(commons-logging 1.3.x 自动发现 slf4j);如果你以前靠"spring 自带 jcl、自己排除掉 commons-logging",7.0.9 里这条排除会变成编译错误,必须让 commons-logging 留在 classpath 上。

旁证:buildSrc/.../CheckstyleConventions.java 第 67 行 nohttp 排除规则里还残留 "**/spring-jcl/**"——说明这个目录在历史上确实存在过,只是 7.0.9 删了,构建脚本里的排除项忘了清理。

8.3 Multi-Release JAR:java21 与 java24 源码目录

spring-core 的源码目录不是常规的 src/main/java 一份。spring-core.gradle 第 13-15 行:

multiRelease {
	releaseVersions 21, 24
}

这个 multiRelease 扩展由 buildSrc/.../multirelease/MultiReleaseJarPlugin.java 创建(第 61-66 行)。它告诉 Gradle:除了常规 src/main/java,还要把 src/main/java21、src/main/java24 当作额外 source set,编译后分别放进 jar 的 META-INF/versions/21/、META-INF/versions/24/。

实际内容(笔者 Glob 结果):

  • spring-core/src/main/java21/org/springframework/core/task/VirtualThreadDelegate.java——JDK 21 虚拟线程的委托实现;
  • spring-core/src/main/java24/org/springframework/core/type/classreading/ 下 6 个类(ClassFileAnnotationVisitor、ClassFileMetadataReader 等)——针对 JDK 24 class 文件格式升级的元数据读取器。

这就是 5.2 节 setJavaRelease 方法存在的意义:根目录的类用 -release 17 编译,能在 JDK 17 跑;java21 目录的类用 -release 21 编译,只有 JDK 21+ 运行时才会加载它们;JDK 17 的用户拿到 jar 后根本看不到这些类文件。同一份 jar,在不同 JDK 上自动选择最优实现——这是 MR-JAR 的标准玩法。

MultiReleaseJarPlugin 第 68-74 行还注册了一个 validateMultiReleaseJar task,挂到 check 上:用 JDK 25 launcher 打开打出来的 jar,验证 META-INF/versions/ 结构合法。这是一个"发布前自校验"任务。

九、源码剖析(七):特殊模块群

9.1 framework-platform:内部版本约束中心

framework-platform/framework-platform.gradle 全文 146 行,只做两件事。

plugins {
	id 'java-platform'
}
javaPlatform {
	allowDependencies()
}

java-platform 插件默认禁止平台工程声明普通依赖(只允许 constraints),allowDependencies() 放开这个限制,因为 Spring 想在平台里直接 api(platform(...)) 引入别的 BOM。

第 10-24 行一口气引入 16 个外部 BOM:jackson-bom 2.20.2、micrometer-bom 1.16.7、netty-bom 4.2.17、reactor-bom 2025.0.7、rsocket-bom 1.1.5、groovy-bom 5.0.8、log4j-bom 2.26.1、assertj-bom 3.27.7、jetty-bom 12.1.12、kotlinx-coroutines-bom 1.10.2、kotlinx-serialization-bom 1.9.0、junit-bom 6.0.3、mockito-bom 5.23.0、tools.jackson 3.0.4……这些 BOM 各自管理自己生态的版本,framework-platform 不重复造轮子,只做"二次聚合"。

第 26-145 行是约 120 条 constraints { api("...:x.y.z") },覆盖所有"不在任何外部 BOM 里、但 Spring 构建要用"的库:commons-logging 1.3.5、snakeyaml 2.6、aspectjweaver 1.9.25、hibernate-core 7.2.24、tomcat-embed-core 11.0.24、jakarta.servlet-api 6.1.0、h2 2.4.240……每个模块脚本里写 optional("org.yaml:snakeyaml") 不写版本号,版本就来自这里。

记住 4.4 节:全仓库每个工程都通过 enforcedPlatform(project(":framework-platform")) 被这个文件强制约束。它不发布到 Maven Central——它是内部工具,只在 Spring 自己的构建图里存在。

9.2 framework-bom:对外发布的 spring-framework-bom

framework-bom/framework-bom.gradle 只有 22 行:

description = "Spring Framework (Bill of Materials)"
apply plugin: 'java-platform'
apply from: "$rootDir/gradle/publications.gradle"
group = "org.springframework"

dependencies {
	constraints {
		parent.moduleProjects.sort { "$it.name" }.each {
			api project(it.path)
		}
	}
}

publishing {
	publications {
		mavenJava(MavenPublication) {
			artifactId = 'spring-framework-bom'
			from components.javaPlatform
		}
	}
}

逐行讲:

  • parent.moduleProjects 就是根脚本里那个"所有 spring-* 工程"集合;.sort { "$it.name" } 按名字排序,保证每次构建生成的 BOM 顺序稳定(版本管理工具 diff 友好)。
  • 对每个模块 api project(it.path)——即给每个 org.springframework:spring-xxx 加一条 constraint,版本是当前 project.version(7.0.9)。
  • artifactId = 'spring-framework-bom'——发布坐标是 org.springframework:spring-framework-bom:7.0.9。
  • from components.javaPlatform——发布 java-platform 组件(即一个 pom,packaging=pom)。

framework-platform 与 framework-bom 的区别一句话讲清:

framework-platformframework-bom
管什么第三方库版本(jackson、netty、jakarta.* 等)Spring 自己的 22 个模块版本
对内/对外内部,不发布对外,发布到 Maven Central
用户怎么用不用管,Spring 构建自己用用户 implementation platform("org.springframework:spring-framework-bom:7.0.9")
机制enforcedPlatform(强制覆盖)platform(普通约束,可被覆盖)

9.3 framework-api:无源码的聚合 javadoc 工程

framework-api 目录下除了 framework-api.gradle 没有任何源码文件(笔者 Glob 整个目录确认)。它的全部职责在脚本里:

plugins {
	id 'java-platform'
	id 'io.freefair.aggregate-javadoc' version '8.13.1'
	id 'org.jetbrains.dokka'
}
dependencies {
	rootProject.ext.moduleProjects.each { moduleProject ->
		javadoc project(moduleProject.path)
	}
}

它把 22 个模块的 javadoc 全部聚合成一个统一文档站点,用 JDK 25 的 javadoc 工具(第 25-27 行 languageVersion = JavaLanguageVersion.of(25)),并且这次打开 Xdoclint:syntax,reference 与 Werror(第 42、47 行)——跨模块的 @see/@link 完整性在这里被严格检查。

两个分发 task:docsZip 把 javadoc-api + kdoc-api + changelog 打成 spring-framework-7.0.9-docs.zip;schemaZip 扫描每个模块 META-INF/spring.schemas 里注册的 XSD,按 schema 名字分目录打成 spring-framework-7.0.9-schema.zip——后者就是 https://springframework.org/schema/ 网站的内容源。

9.4 framework-docs 与 integration-tests

framework-docs/framework-docs.gradle 用 Antora 构建参考文档(id 'org.antora' version '1.0.0'),Node 版本固定 24.15.0(第 23-25 行),jar { enabled = false }、javadoc { enabled = false }——它不产出 jar,只产出文档网站。它把 10 个业务模块全部 implementation 进来(第 62-72 行),因为参考文档里的代码示例需要真实类编译通过。

integration-tests/integration-tests.gradle 更简单:对 aop/beans/context/core/core-test/expression/jdbc/orm/test/tx/web 做 testImplementation,再加 aspectjweaver、hsqldb、hibernate-core——专门跑跨模块的端到端集成场景(XML 命名空间、AOP 自动代理、JTA 等)。它不在 moduleProjects 集合里,因此不发布。

9.5 spring-context-indexer:候选组件索引处理器(已废弃)

这是一个注解处理器模块。它的 src/main/resources/META-INF/services/javax.annotation.processing.Processor 文件只有一行:

org.springframework.context.index.processor.CandidateComponentsIndexer

编译器在编译用户代码时会自动发现这个处理器。主类 CandidateComponentsIndexer.java 第 48-49 行有一个极其重要的注解:

@Deprecated(since = "6.1", forRemoval = true)
public class CandidateComponentsIndexer implements Processor {

forRemoval = true——这个模块在 7.0.9 已被官方标记为"将在下个大版本删除"。 注释(第 45-47 行)解释:被 AOT 引擎取代,未来由 AOT 生成组件索引。

它的工作原理仍值得讲清,因为运行时机制还在。process 方法(第 84-91 行):

public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
	this.metadataCollector.processing(roundEnv);
	roundEnv.getRootElements().forEach(this::processElement);
	if (roundEnv.processingOver()) {
		writeMetaData();
	}
	return false;
}

每一轮编译,遍历所有 root element,交给三个 StereotypesProvider:IndexedStereotypesProvider(识别 @Indexed 元注解)、StandardStereotypesProvider(识别 jakarta.* 注解)、PackageInfoStereotypesProvider(识别 package-info)。收集到"哪些类带哪些刻板印象"后,最后一轮把结果写到 META-INF/spring.components 资源文件里。

运行时在 spring-context 侧:CandidateComponentsIndexLoader.java 第 49 行定义资源位置:

public static final String COMPONENTS_RESOURCE_LOCATION = "META-INF/spring.components";

doLoadIndex(第 92-118 行)用 ClassLoader.getResources("META-INF/spring.components") 把所有 jar 里的索引文件读进来,合并成一个 CandidateComponentsIndex。这样 ClassPathScanningCandidateComponentProvider 在启动时不用扫描 classpath 字节码,直接查索引就能拿到"所有 @Component 类",启动速度大幅提升。

@Indexed 注解(spring-context/src/main/java/org/springframework/stereotype/Indexed.java)是用户侧的开关:它被元注解在 @Component 上,任何被 @Component 标注的类都会被索引;用户自定义组合注解上打 @Indexed 也能纳入索引。7.0 新增的 addIndex(ClassLoader, CandidateComponentsIndex)(第 132-134 行)允许 AOT 阶段把预生成的索引直接塞进去——这是处理器废弃后,运行时机制为 AOT 留的桥。

9.6 spring-core-test:测试工具与 RuntimeHints agent

spring-core-test/spring-core-test.gradle 只有 19 行,但做两件特殊的事:

dependencies {
	api(project(":spring-core"))
	optional("org.assertj:assertj-core")
	optional("org.junit.jupiter:junit-jupiter-api")
	compileOnly("org.junit.jupiter:junit-jupiter-params")
	compileOnly("org.junit.platform:junit-platform-launcher")
	implementation("com.thoughtworks.qdox:qdox")
}

jar {
	manifest {
		attributes(
			'Premain-Class': 'org.springframework.aot.agent.RuntimeHintsAgent',
			'Can-Redefine-Classes': 'true'
		)
	}
}

第一,它是一个Java agent。manifest 里声明 Premain-Class: org.springframework.aot.agent.RuntimeHintsAgent——用户可以用 -javaagent:spring-core-test.jar 挂这个 agent,它会在测试运行时记录"哪些反射调用发生在没有 RuntimeHints 声明的地方",用于 GraalVM native 适配。第二,它提供 core.test.tools 包:TestCompiler、DynamicClassLoader、SourceFile 等一组动态编译工具,让测试可以在运行时把字符串源码编译成类再加载——spring-test 的很多行为测试靠它。

至于 TestGroup,7.0.9 里它已经不在 spring-core-test 模块,而是挪到了 spring-core 的 testFixtures 里(spring-core/src/testFixtures/java/org/springframework/core/testfixture/TestGroup.java),是一个只有 LONG_RUNNING 一个枚举值的枚举,通过 -DtestGroups=LONG_RUNNING 系统属性控制是否跑长测试(TestConventions 第 73-75 行把这个属性透传给测试 JVM)。

十、运行机制:一次 ./gradlew build 的完整生命周期

前面九节我们按文件逐个解剖,这一节把它们串成一次真实的构建过程。假设你在干净环境下执行 ./gradlew build -PskipDocs,按时间顺序发生的事如下。

阶段 0:Wrapper 引导。 gradle/wrapper/gradle-wrapper.properties 指定 distributionUrl 为 Gradle 9.7.0。本机没有这个版本时,wrapper 先下载 gradle-9.7.0-bin.zip 到 ~/.gradle/wrapper/dists,解压后用它启动 daemon。org.gradle.jvmargs=-Xmx2048m 决定这个 daemon 堆 2G;org.gradle.parallel=true 允许 daemon 并行调度;org.gradle.caching=true 让 task 输出可跨工作区复用。

阶段 1:buildSrc 编译。 Gradle 发现 buildSrc/ 目录,先把它当作一个嵌套构建单独编译:执行 buildSrc/build.gradle,从 Maven Central 拉 kotlin-gradle-plugin、archunit 等依赖,编译 org.springframework.build.* 下的 Java 类,打成 jar 挂到主构建的 classpath。注意 buildSrc/build.gradle 第 72 行 jar.dependsOn check——这一步会跑 buildSrc 自己的 checkstyle。

阶段 2:settings 阶段。 执行根目录 settings.gradle:应用 develocity 插件,include 27 个工程,把每个工程的 buildFileName 改成 <工程名>.gradle,注册 build-scan-uri.txt 回调。此时 Gradle 知道了全部工程的位置。

阶段 3:配置阶段。 Gradle 按拓扑顺序 evaluate 每个工程的构建脚本。对每个工程:

  1. configure(allprojects) 先跑——apply localdev、repositories,设 group,设依赖解析缓存策略。
  2. 对除 framework-platform 外的工程,建立 dependencyManagement 配置并塞入 enforcedPlatform(:framework-platform)。此时 framework-platform.gradle 也被 evaluate,16 个外部 BOM 与 120 条 constraints 全部登记。
  3. 对根工程 + javaProjects,apply java、java-test-fixtures、conventions、ide.gradle。ConventionsPlugin.apply 触发 ArchitecturePlugin、CheckstyleConventions、JavaConventions、KotlinConventions、TestConventions——Java toolchain 设为 25,所有 JavaCompile task 挂上一长串 -Xlint 与 -Werror,Test task 挂上 JUnit Platform 与 ByteBuddy agent。
  4. 对 22 个 spring-* 模块,apply spring-module.gradle——java-library、optional-dependencies、jmh、publications;jar manifest 写入 Automatic-Module-Name。
  5. 各模块自己的 <模块名>.gradle 被 evaluate,登记模块依赖与特殊 task(spring-core 的 shadow repack、spring-context 的 runtimehints-agent 等)。

阶段 4:任务图执行。 Gradle 根据依赖关系把任务拓扑排序。由于 org.gradle.parallel=true,不相关模块的 compileJava 并行。典型的一条执行链:

:spring-core:compileJava (JDK25 toolchain, -release 17)
  → :spring-core:compileJava21 (-release 21)
  → :spring-core:compileJava24 (-release 24)
  → :spring-core:jar (把三个输出打入 jar,写入 Automatic-Module-Name: spring.core,
                      并入 javapoet/objenesis repack 内容,附带 license/notice)
:spring-beans:compileJava (依赖 :spring-core:jar)
:spring-aop:compileJava (依赖 beans+core)
:spring-context:compileJava (依赖 aop+beans+core+expression)
...
每个模块的 :check 串着 checkstyle、test(JUnit Platform,重试 3 次 if CI)、archunit 包环检查

由于带了 -PskipDocs,LocalDevelopmentPlugin 在 afterEvaluate 阶段把所有 documentation/distribution task 设为 disabled,javadoc、docsZip、schemaZip 全部跳过。

阶段 5:产物。 build/libs/ 下每个模块产出 <模块名>-7.0.9.jar、<模块名>-7.0.9-sources.jar、<模块名>-7.0.9-javadoc.jar;framework-bom 产出一个 pom(packaging=pom);framework-api(如果没 skipDocs)产出 docs 与 schema 两个 zip。

整条链的设计哲学可以总结为三句话:约定在 buildSrc 里写成代码,模块脚本只写差异;版本在一个文件里(gradle.properties)+ 一个平台里(framework-platform);发布在 publications.gradle 里统一模板。


十一、扩展用法与最佳实践

11.1 作为下游用户,如何正确依赖 Spring 7.0.9

正确姿势是用 BOM:

dependencies {
	implementation platform("org.springframework:spring-framework-bom:7.0.9")
	implementation "org.springframework:spring-context"
	implementation "org.springframework:spring-webmvc"
}

不要写版本号——BOM 会把 22 个模块统一约束到 7.0.9。注意 webmvc 会 api 传递 context、aop、expression、web,你不需要再显式声明它们。如果只要 RestTemplate,用 spring-web 就够,不要为它拉 webmvc。

11.2 模块化应用里的 requires 语句

因为 7.0.9 退化为 automatic module,你的 module-info.java 应当写:

module com.example.myapp {
	requires spring.core;
	requires spring.beans;
	requires spring.context;
	requires spring.web;
}

模块名是 spring.core 而不是 org.springframework.core。同时,由于 automatic module 不做精确导出,它会把 jar 里所有包都开放给 requires 它的模块,你不需要也无法控制 exports。

11.3 本地构建提速

  • 日常改代码跑测试:./gradlew test -PskipDocs -DtestGroups=(不跑 LONG_RUNNING)。
  • 只编译某几个模块:./gradlew :spring-context:compileJava :spring-beans:compileTestJava。
  • 由于 org.gradle.caching=true,第一次全量构建后,切分支回来二次构建通常只需重编译受影响模块。
  • ByteBuddy agent 挂在每个测试 JVM 上,如果你不需要 Mockito 的 inline mock maker,可以在命令行 -PbyteBuddyVersion= 覆盖(TestConventions 第 84 行判断 hasProperty 才挂)。

11.4 可选依赖的正确用法

读源码时会频繁遇到 optional(...)。它的语义是"编译期可见、运行期存在、但不传递"。作为下游用户,如果你要用 Spring 的某个可选集成(比如把 snakeyaml 加到 classpath 上以支持 YAML @PropertySource),必须自己显式声明 snakeyaml——Spring 的 optional 不会帮你传递。反过来,如果你不需要某个可选集成,不引入它也不会编译失败,相关类只会在运行时被跳过(Spring 大量使用 ClassUtils.isPresent 做类路径探测)。

十二、常见问题与坑

12.1 为什么我的模块化应用升级到 7.0.9 后报 "module not found: org.springframework.core"

源码级解释:6.x 时代 spring-core 有真实 module-info.java,模块名是 org.springframework.core;7.0.9 删掉了 module-info(本章 8.1 节已用递归搜索验证),jar 变成 automatic module,名字由 gradle/spring-module.gradle 第 45 行的 Automatic-Module-Name = project.name.replace('-', '.') 决定,即 spring.core。你旧 module-info.java 里写的 requires org.springframework.core; 在 7.0.9 上找不到这个模块名。修复:改成 requires spring.core;,并把所有 requires org.springframework.beans/context/web... 同步改成短名形式。

12.2 为什么 classpath 上突然多了 commons-logging:1.3.5

源码级解释:7.0.9 删除了内嵌的 spring-jcl 桥接(本章 8.2 节:settings.gradle 无 spring-jcl、源码树无 org.apache.commons.logging 包),改为 spring-core.gradle 第 78 行 api("commons-logging:commons-logging") 直接依赖真实 Apache 构件,版本由 framework-platform 第 48 行约束到 1.3.5。如果你以前在自己的工程里写 exclude group: 'commons-logging', module: 'commons-logging' 想"去掉 Spring 的日志桥接",7.0.9 上这个排除会导致 spring-core 编译失败——LogFactory 类从哪来?修复:让 commons-logging 留在 classpath,它会自动桥接你的 SLF4J/Logback。

12.3 为什么必须装 JDK 25 才能构建,但文档说兼容 JDK 17

源码级解释:JavaConventions.java 第 48 行 toolchain 锁死 JDK 25(.sdkmanrc 也写 java=25-librca),但第 54 行 -release 默认 17。toolchain 是"用哪个 JDK 跑编译器",-release 17 是"产出的 class 文件面向哪个 JVM"。JDK 25 的 javac 知道如何生成 17 的字节码(通过 --release 交叉编译),且能编译 21/24 目录里的高版本源码。你本机装 JDK 25 构建,产物在 JDK 17 上能跑;反过来,你本机只有 JDK 17,Gradle toolchain 会拒绝或自动下载 JDK 25。

12.4 为什么本地构建很慢,比 CI 慢很多

两个常见原因:其一,你没加 -PskipDocs,javadoc 与 docsZip 在跑;其二,CI 上 testRetry.maxRetries=3、本地为 0(TestConventions.java 第 101 行 isCi() ? 3 : 0),本地测试失败即停,反而更快。真正拖慢本地的是 checkstyle(每个 task 1G 堆)和 archunit 全量包环扫描——这两个在 CI 必跑,本地若只想快速验证编译,用 ./gradlew compileTestJava 绕开它们。

12.5 为什么我在 spring-xxx.gradle 里写 implementation("com.fasterxml.jackson:jackson-databind") 不报错,但下游编译期拿不到 Jackson

源码级解释:implementation 配置的依赖只对当前模块自己的编译 classpath 可见,不进 API classpath。Spring 模块脚本里凡是希望下游感知的依赖一律 api(...)(比如 spring-context 第 14 行 api("io.micrometer:micrometer-observation")),希望"可选集成"的一律 optional(...)(OptionalDependenciesPlugin 只 extendsFrom 到本工程 classpath,不传递)。你写 implementation 等于把 Jackson 变成"本模块内部细节",下游 import com.fasterxml.jackson.databind.* 会在自己的编译期报 class not found。这不是 bug,是 Gradle java-library 插件的设计。

12.6 为什么 spring-context-indexer 还在仓库里,但官方说要删

源码级解释:CandidateComponentsIndexer.java 第 48 行 @Deprecated(since = "6.1", forRemoval = true)。6.1 起官方推荐用 AOT 引擎预生成组件索引,注解处理器这条老路线进入 deprecated 状态,但运行时 CandidateComponentsIndexLoader 仍在 spring-context 里工作(本章 9.5 节),且 7.0 新增了 addIndex 方法供 AOT 注入预生成索引。短期不要在新项目里引入 spring-context-indexer 作为注解处理器——它会在下个大版本被移除,AOT 是替代方向。


十三、本章小结

本章从 53 行的 settings.gradle 出发,逐段解剖了 Spring Framework 7.0.9 的整个构建体系:

  1. 工程图:27 个工程由 settings.gradle 圈定,22 个 spring-* 业务模块 + 4 个 framework-* 工具模块 + integration-tests;buildFileName 映射让每个模块脚本以模块名命名。
  2. 版本与性能:gradle.properties 11 行搞定单版本源(7.0.9)、并行构建、2G 堆、Kotlin 2.2.21/ByteBuddy 1.17.6。
  3. 约定下沉:buildSrc 用 Java 写了 7 个插件,把 JDK 25 toolchain / -release 17 / -Werror / Checkstyle 13.10.0 / JUnit Platform / ByteBuddy agent / 仓库策略全部固化,模块脚本几乎零样板。
  4. 依赖方向:core 是根,beans→core、aop→beans+core、expression→core、context→aop+beans+core+expression、tx→beans+core(aop/context optional)、webmvc→全家桶、webflux→beans+core+web(context optional)、test→core(其余全 optional)。ArchUnit 包环检查兜底。
  5. 模块化真相:7.0.9 无任何 module-info.java,全部 jar 靠 Automatic-Module-Name manifest 属性成为 automatic module,模块名是 spring.core 这种短名;日志桥接从内嵌 spring-jcl 退化为真实 commons-logging:1.3.5 依赖。
  6. 特殊模块:framework-platform 管第三方版本(不发布)、framework-bom 管自家模块版本(发布为 spring-framework-bom)、framework-api 聚合 javadoc、framework-docs 跑 Antora、spring-context-indexer 已 deprecated、spring-core-test 是 RuntimeHints agent。
  7. 发布与门禁:publications.gradle 统一 POM 模板与 fromResolutionResult 版本映射;CI 上 checkstyle + archunit + nohttp + 测试重试构成质量门禁。

本章关键文件清单

文件/类仓库相对路径职责一句话
settings.gradlesettings.gradle圈定 27 工程、根名 spring、buildFileName 映射
gradle.propertiesgradle.properties单版本源 7.0.9、构建性能开关、Kotlin/ByteBuddy 版本
根 build.gradlebuild.gradle工程切片、enforcedPlatform、公共测试依赖
JavaConventionsbuildSrc/src/main/java/org/springframework/build/JavaConventions.javaJDK 25 toolchain、-release 17、-Werror 编译器参数
TestConventionsbuildSrc/src/main/java/org/springframework/build/TestConventions.javaJUnit Platform、ByteBuddy agent、CI 重试 3 次
OptionalDependenciesPluginbuildSrc/src/main/java/org/springframework/build/optional/OptionalDependenciesPlugin.java注册 optional 配置,等价 Maven optional
spring-module.gradlegradle/spring-module.gradle22 模块公共母版、Automatic-Module-Name 唯一写入点
publications.gradlegradle/publications.gradlePOM 模板、fromResolutionResult 版本映射
framework-platform.gradleframework-platform/framework-platform.gradle16 外部 BOM + 120 条第三方版本约束
framework-bom.gradleframework-bom/framework-bom.gradle发布 spring-framework-bom,约束自家 22 模块
framework-api.gradleframework-api/framework-api.gradle聚合 javadoc/kdoc/docsZip/schemaZip
CandidateComponentsIndexerspring-context-indexer/src/main/java/org/springframework/context/index/processor/CandidateComponentsIndexer.java编译期组件索引处理器(已 deprecated forRemoval)
CandidateComponentsIndexLoaderspring-context/src/main/java/org/springframework/context/index/CandidateComponentsIndexLoader.java运行期加载 META-INF/spring.components
TestGroupspring-core/src/testFixtures/java/org/springframework/core/testfixture/TestGroup.javaLONG_RUNNING 测试分组开关
RuntimeHintsAgentspring-core-test/src/main/java/org/springframework/aot/agent/RuntimeHintsAgent.javaspring-core-test manifest 里的 Premain-Class

十四、源码剖析(八):其余模块依赖全解与 spring-core 重打包机制

7.1 节只精读了 11 个核心模块,这一节把剩余 11 个 spring-* 模块的依赖关系补齐,否则依赖图是残缺的。

14.1 spring-aspects:唯一用 AspectJ 编译器的模块

spring-aspects/spring-aspects.gradle 第 3 行 apply plugin: "io.freefair.aspectj"——这是全仓库唯一直接编译 .aj AspectJ 源文件的模块。第 5-18 行显式把 compileAspectj/compileTestAspectj 的 sourceCompatibility 锁到 17,并给 ajc 加 -parameters:

compileAspectj {
	sourceCompatibility = "17"
	targetCompatibility = "17"
	ajcOptions {
		compilerArgs += "-parameters"
	}
}

为什么这里要单独锁 17 而不是继承 JavaConventions 的 -release 17?因为 AspectJ 编译器 ajc 走的是自己的管道,不经过 Gradle 的 JavaCompile task,JavaConventions 那套 -Xlint/-release 约定对它无效,必须在脚本里手动对齐。依赖关系上,spring-aspects 对 aop/beans/context/context-support/orm/tx 全部 optional(第 23-28 行),注释逐条写明用途:"for @Async support"、"for @Configurable support"、"for JPA exception translation"——它是一组 AspectJ 切面(AspectJTransactionManagementConfiguration、AspectJCacheConfiguration 等),只在用户主动织入时才需要对应的模块在 classpath 上。第 41-44 行还给 Eclipse 加了 AJDT nature——在 Eclipse 里这个工程需要 AspectJ 开发工具支持。

14.2 spring-instrument:一个纯 Java agent

spring-instrument/spring-instrument.gradle 全文 15 行,没有任何 dependencies 块——它不依赖任何 Spring 模块。全部内容是 jar manifest:

jar {
	manifest.attributes["Premain-Class"] =
			"org.springframework.instrument.InstrumentationSavingAgent"
	manifest.attributes["Agent-Class"] =
			"org.springframework.instrument.InstrumentationSavingAgent"
	manifest.attributes["Can-Redefine-Classes"] = "true"
	manifest.attributes["Can-Retransform-Classes"] = "true"
	manifest.attributes["Can-Set-Native-Method-Prefix"] = "false"
}

这是一个"极简 agent":premain 把 JVM 传入的 java.lang.instrument.Instrumentation 静态存起来,供 Hibernate 等需要类重转换的库在运行时取回。Can-Retransform-Classes=true 声明它支持 retransform。它和 spring-core-test 的 RuntimeHints agent 是仓库里两个独立的 agent 模块,用途完全不同。

14.3 spring-messaging、spring-orm、spring-jms、spring-r2dbc、spring-websocket、spring-context-support

把这六个模块的 api(project(...)) 汇总成一张表(其余 optional 依赖从略):

模块api 的 Spring 模块api 的第三方
spring-messagingbeans, core—
spring-ormbeans, core, spring-jdbc, spring-tx—
spring-jmsbeans, core, spring-messaging, spring-txmicrometer-observation
spring-r2dbcbeans, core, spring-txreactor-core, r2dbc-spi
spring-websocketspring-context, core, spring-web—
spring-context-supportbeans, spring-context, core—

几个值得点出的设计:

  • spring-orm 必须 api spring-jdbc。ORM 模块(Hibernate/JPA 支持)大量复用 DataSourceUtils、JdbcTemplate 做异常翻译,这些类在 spring-jdbc 里;下游用 spring-orm 时编译期要直接用 jdbc 的类,所以 api 而不是 optional。
  • spring-r2dbc 不 api spring-jdbc。响应式数据访问是另一套抽象,与 JDBC 互斥;但它 api spring-tx,因为响应式事务与声明式事务共享 TransactionDefinition 抽象。
  • spring-websocket api spring-context。与 spring-webflux 刻意不 api context 的选择相反,WebSocket(STOMP 子协议)天然依赖消息代理、订阅等容器化组件,必须把 context 暴露给下游。
  • spring-oxm 是 api beans+core 的最薄模块,它的 optional 全是 XML 库(xstream、jaxb、xml-apis),第 31-37 行还演示了一个 Gradle 技巧:XJC 插件默认把生成代码加到 main source set,但测试不需要把生成的类打进发布 jar,于是手动把 main 的 srcDirs 重置、再把 XJC 输出挂到 test source set——这是"构建期代码生成但不发布"的标准做法。

14.4 spring-core 的重打包机制:javapoet 与 objenesis 是怎么进来的

spring-core.gradle 第 30-57 行注册了两个 ShadowJar task:

tasks.register('javapoetRepackJar', ShadowJar) {
	archiveBaseName = 'spring-javapoet-repack'
	archiveVersion = javapoetVersion
	configurations = [javaPoetConfig]
	relocate('com.palantir.javapoet', 'org.springframework.javapoet')
}
tasks.register('objenesisRepackJar', ShadowJar) {
	archiveBaseName = 'spring-objenesis-repack'
	configurations = [objenesisConfig]
	relocate('org.objenesis', 'org.springframework.objenesis')
}

com.gradleup.shadow 插件做两件事:把第三方 jar 的全部类解压、再把包名 relocate(重命名)后重新打包。为什么要重打包?两个原因:其一,避免用户 classpath 上的 javapoet/objenesis 版本与 Spring 内置的版本冲突(Spring 用了 javapoet 的内部 API,版本必须锁定);其二,这些库是 Spring 的内部实现细节,不应当成为用户可调用的公共 API,relocate 到 org.springframework.javapoet 后语义上就归 Spring 所有。

随后 jar task(第 119-131 行)把这两个 repack jar 的内容合并进主 jar:

jar {
	manifest.attributes["Dependencies"] = "jdk.unsupported,org.jboss.vfs"
	dependsOn javapoetRepackJar
	from(zipTree(javapoetRepackJar.archiveFile)) {
		include "org/springframework/javapoet/**"
	}
	dependsOn objenesisRepackJar
	from(zipTree(objenesisRepackJar.archiveFile)) {
		include "org/springframework/objenesis/**"
	}
}

manifest 那行 Dependencies: jdk.unsupported,org.jboss.vfs 是 OSGi 声明(虽然 7.0 已不主打 OSGi,但保留兼容 WildFly 的类加载提示——注释写明"for WildFly (Objenesis and VfsUtils)")。最终 spring-core jar 里实际包含:手写的 org.springframework.core/util/aot/... + 重打包的 org.springframework.javapoet + 重打包的 org.springframework.objenesis。这就是为什么你在 IDE 里能 import org.springframework.objenesis.Objenesis; 却找不到它的源码——它是构建期从 objenesis 3.5 解压改名进来的。

14.5 spring-core 的 META-INF:AOT 与 native 提示

spring-core/src/main/resources/META-INF/ 下三个关键文件:

  • services/reactor.blockhound.integration.BlockHoundIntegration——SPI 文件,告诉 BlockHound"spring-core 里哪些阻塞调用是允许的";
  • spring/aot.factories——AOT 处理器注册;
  • native-image/org.springframework.native-image.properties——GraalVM native-image 构建期参数提示。

这三个资源文件不参与编译,但会被打进 jar 的 META-INF,分别在 BlockHound 测试、Spring AOT 处理、GraalVM native 编译三个场景被框架或外部工具读取。它们是"构建产物附带的元数据",与 jar manifest 里的 Automatic-Module-Name、Dependencies 一起构成 spring-core 的完整自描述。

十五、源码剖析(九):buildSrc 剩余插件与质量门禁

15.1 RuntimeHintsAgentPlugin:给测试挂 AOT agent

spring-context.gradle 第 1-3 行与 integration-tests.gradle 第 1-4 行都 id 'org.springframework.build.runtimehints-agent'。这个插件(buildSrc/.../hint/RuntimeHintsAgentPlugin.java)做的事在第 56-67 行:

TaskProvider<Test> agentTest = project.getTasks().register(RUNTIMEHINTS_TEST_TASK, Test.class, test -> {
	test.useJUnitPlatform(options -> {
		options.includeTags("RuntimeHintsTests");
	});
	test.include("**/*Tests.class", "**/*Test.class");
	test.systemProperty("org.graalvm.nativeimage.imagecode", "runtime");
	test.setTestClassesDirs(jvmTestSuite.getSources().getOutput().getClassesDirs());
	test.setClasspath(jvmTestSuite.getSources().getRuntimeClasspath());
	test.getJvmArgumentProviders().add(createRuntimeHintsAgentArgumentProvider(project, agentExtension));
});
project.getTasks().named("check", task -> task.dependsOn(agentTest));

它注册了一个新 task runtimeHintsTest,只跑打了 @Tag("RuntimeHintsTests") 的测试,并且把 org.graalvm.nativeimage.imagecode=runtime 这个系统属性打开——模拟 native-image 运行环境,然后把 spring-core-test 里的 RuntimeHintsAgent 通过 -javaagent 挂上去。这个 agent 会记录"测试里哪些反射调用发生在没有 RuntimeHints 声明的位置",一旦发现,测试失败。这就是 Spring 保证自己的代码在 GraalVM native 下可用的 CI 机制:check 任务依赖 runtimeHintsTest,每改一个反射调用,都要同步补 RuntimeHints 声明。

第 88-100 行的 configuration 配置写得非常细——显式设置 Bundling=EXTERNAL、Category=LIBRARY、LibraryElements=JAR、TargetJvmVersion=当前 JDK、Usage=JAVA_RUNTIME——这是 Gradle 现代 variant-aware 依赖解析的标准写法,目的是从 spring-core-test 的发布变体里精确挑出那个带 Premain-Class manifest 的 agent jar,而不是普通 jar。

15.2 ArchitecturePlugin 与 ArchUnit 规则

ArchitecturePlugin.java 第 42-66 行:对每个非 test source set 注册一个 checkArchitectureMain task,把它挂到 check 上。规则内容在 ArchitectureRules.java,我们已经看过包环检测(allPackagesShouldBeFreeOfTangles),还有几条更细的:

static ArchRule noClassesShouldCallStringToLowerCaseWithoutLocale() {
	return ArchRuleDefinition.noClasses()
			.should()
			.callMethod(String.class, "toLowerCase")
			.because("String.toLowerCase(Locale.ROOT) should be used instead");
}

这条规则禁止全仓库调用 String.toLowerCase()(无参版本),理由是无参版本用默认 Locale,在土耳其语环境会出 I→ı 的经典 bug,必须用 toLowerCase(Locale.ROOT)。String.toUpperCase 同理。

static ArchRule classesShouldNotImportForbiddenTypes() {
	return ArchRuleDefinition.noClasses()
			.should().dependOnClassesThat().haveFullyQualifiedName("reactor.core.support.Assert")
			.orShould().dependOnClassesThat().haveFullyQualifiedName("org.slf4j.LoggerFactory")
			.orShould().dependOnClassesThat().haveFullyQualifiedName("org.springframework.lang.NonNull")
			.orShould().dependOnClassesThat().haveFullyQualifiedName("org.springframework.lang.Nullable");
}

四条禁令:不许直接依赖 Reactor 内部 Assert、不许直接绑定 SLF4J(日志门面必须走 commons-logging)、不许用旧的 org.springframework.lang.NonNull/Nullable(已迁移到 JSpecify org.jspecify.annotations.Nullable)。这些规则把"框架内部代码规范"变成了构建期强制检查——PR 里有人 import 了 org.slf4j.LoggerFactory,ArchUnit 直接红。

15.3 SpringFrameworkExtension:preview 特性开关

SpringFrameworkExtension.java 全文 53 行,核心是一个 Property<Boolean> enableJavaPreviewFeatures:

private CommandLineArgumentProvider asArgumentProvider() {
	return () -> {
		if (getEnableJavaPreviewFeatures().getOrElse(false)) {
			return List.of("--enable-preview");
		}
		return Collections.emptyList();
	};
}

任何 JavaCompile task 与 Test task 都被挂上这个 argument provider:如果某个模块脚本里写 springFramework { enableJavaPreviewFeatures = true },编译器和测试 JVM 就加 --enable-preview。目前 7.0.9 没有模块开启它,但这个扩展的存在意味着"用 JDK 25 跑、需要用 JDK 25 preview API"的实验性模块可以随时打开而不用改 buildSrc。

15.4 KotlinConventions 的 dokka 聚合

5.5 节讲了 Kotlin 编译参数,这里补 dokka 那段(KotlinConventions.java 第 37-43 行):

if (project.getLayout().getProjectDirectory().dir("src/main/kotlin").getAsFile().exists()) {
	project.getPlugins().apply(DokkaPlugin.class);
	project.getExtensions().configure(DokkaExtension.class, dokka -> configure(project, dokka));
	project.project(":framework-api").getDependencies().add("dokka", project);
}

如果某个模块存在 src/main/kotlin 目录,就给它应用 Dokka,并把它加到 :framework-api 的 dokka configuration——这就是 framework-api 能聚合出全框架 kdoc 文档的接线:每个含 Kotlin 源码的模块自动把自己的 dokka output 贡献给 framework-api。第 71-74 行还把外部文档链接里的 spring-framework URL 用 project.getVersion() 拼出来——https://docs.spring.io/spring-framework/docs/7.0.9/kdoc-api/element-list,版本号再次从单一来源流入。

15.5 .sdkmanrc 与开发者环境对齐

根目录 .sdkmanrc 全文:

# Enable auto-env through the sdkman_auto_env config
# Add key=value pairs of SDKs to use below
java=25-librca

SDKMAN!是 Linux/macOS 的多 JDK 版本管理器,auto_env 开启后进入目录自动切换 JDK。这一行告诉贡献者:本仓库要用 25-librca( Liberica OpenJDK 25)。它与 buildSrc 的 DEFAULT_LANGUAGE_VERSION = JavaLanguageVersion.of(25) 是双保险——前者管开发者本机装什么,后者管 Gradle toolchain 用什么。Windows 贡献者没有 SDKMAN,靠 Gradle toolchain 自动探测或下载。

15.6 CI 质量门禁全景

.github/workflows/build-pull-request.yml 很薄,只做一件事:checkout 代码后调用 ./.github/actions/build 这个 composite action。真正的门禁组合在那个 action 里,但从我们读过的构建脚本可以反推出 PR 必须过的关卡:

  1. 编译:所有模块 compileJava/compileTestJava,-Werror 即失败。
  2. checkstyle:每个模块 :checkstyleMain+:checkstyleTest,1G 堆,spring-javaformat 规范。
  3. ArchUnit:每个模块 :checkArchitectureMain,包环、Locales 字符串、SLF4J 禁令。
  4. nohttp:根工程扫描 http:// 链接。
  5. 测试:每个模块 :test,CI 环境重试 3 次仍失败才算挂;runtimeHintsTest 单独跑 native 模拟。
  6. MR-JAR 校验:spring-core 的 :validateMultiReleaseJar 用 JDK 25 launcher 验 jar 结构。
  7. javadoc 完整性:framework-api 的全局 javadoc task(Werror=true),只在发布或全量构建时跑。

任何一关挂了,PR 不能合并。这就是为什么模块脚本里不敢随便加一行 import org.slf4j.LoggerFactory——构建会直接红。

十六、版本管理与发布流水线

16.1 单一版本源如何流到每个产物

回到一个看似简单的问题:发布 7.0.9 时,这个数字从哪里出现、又出现在哪些产物上?

源头只有一个:gradle.properties 第 1 行 version=7.0.9。Gradle 自动把它注入为根 project 的 version,而 Gradle 的 project 模型是父子继承的——所有子工程不另设 version,就自动继承根工程的 version。于是:

  • 每个模块的 jar 文件名是 spring-core-7.0.9.jar(Gradle java 插件默认命名规则);
  • spring-module.gradle 第 44 行 Implementation-Version manifest 属性 = 7.0.9;
  • framework-bom.gradle 第 10-12 行 api project(it.path) 自动带上 7.0.9,生成 spring-framework-bom-7.0.9.pom;
  • publications.gradle 的 POM 里 <version>7.0.9</version>;
  • KotlinConventions 第 71 行拼 dokka 外部链接时用 project.getVersion() = 7.0.9;
  • framework-docs.gradle 第 28 行把 BUILD_VERSION 环境变量传给 Antora,文档里的版本号也是 7.0.9;
  • framework-api.gradle 第 29 行 title = "${rootProject.description} ${version} API" = "Spring Framework 7.0.9 API"。

发布新版本时,维护者只改 gradle.properties 一行(比如 7.0.9 → 7.0.10),打 tag v7.0.10,其余全部自动跟随。这就是"单一版本源"的全部含义——它不是什么特殊机制,就是 Gradle project 属性继承 + 各处脚本统一读 project.version 的结果。

16.2 发布流水线:从 tag 到 Maven Central

.github/workflows/publish-release.yml 第 3-5 行触发条件:push tag v7.0.[0-9]+。也就是说只修小版本(patch)走这个工作流;minor/major 版本走另一条 release-train-* 系列(由 Spring Release Train BOM 统一调度)。

这个工作流只有两个 job:

  1. trigger-docs-build:从 git ref 名剥掉 v 前缀拿到版本号,然后 gh workflow run deploy-docs.yml 触发文档仓库的部署。
  2. create-github-release:调用 ./.github/actions/create-github-release composite action,生成 GitHub Release,带 changelog。

真正"把 jar 发到 Maven 仓库"的动作不在这里——它在被 tag 触发的另一条 build-and-deploy-snapshot.yml 或 release 专用 workflow 里,通过 -PdeploymentRepository=... 把 publications.gradle 第 57-62 行那个 deployment repository 激活。发布仓库地址不写在代码里,全靠 CI 变量注入,这是开源发布的标准做法。

16.3 发布前验证:spring-framework-release-verification

.github/workflows/verify.yml 第 43 行 checkout 的是另一个仓库:spring-projects/spring-framework-release-verification 的 v0.0.3 tag。它做什么?第 75 行 ./gradlew spring-framework-release-verification-tests:test——在一个独立工程里,把刚发布的 spring-framework jar 当作依赖拉下来,跑一组"真实用户视角"的集成测试。

第 54-55 行设置 JDK 是 17(liberica),不是 25。这一点很关键:我们前面讲了构建用 JDK 25、-release 17。发布后验证阶段用 JDK 17 跑,就是为了证明"产物真的能在最低支持版本上运行"。如果 MR-JAR、Automatic-Module-Name、commons-logging 桥接任何一环在 JDK 17 上有问题,这个验证会挂。第 65 行还显式 org.gradle.daemon=false——发布验证环境不用 daemon,避免跨次测试状态污染。

这是一道"发布后门禁":jar 已经传到 staging 仓库了,但如果 verification 挂了,就不会 promote 到 Maven Central。

16.4 升级第三方依赖的流程

第三方版本全在 framework-platform.gradle 一个文件里。升级 jackson 到 2.20.3,就改第 10 行 jackson-bom:2.20.3;升级 snakeyaml,就改第 144 行 org.yaml:snakeyaml:2.6 → 新版本。因为 enforcedPlatform 是强制约束,改完后全仓库所有模块下次构建自动用新版本,不需要改任何模块脚本。

两个例外要注意:

  • kotlinVersion、byteBuddyVersion 在 gradle.properties,不在 framework-platform——因为它们是构建脚本本身要用的(Kotlin Gradle 插件版本、buildSrc 依赖),不是运行时依赖,平台管不着。
  • buildSrc 自己的依赖(archunit 1.4.1、dokka 2.1.0、test-retry 1.6.2、nohttp 0.0.11、spring-javaformat 0.0.43)写在 buildSrc/build.gradle,这是"构建工具链的版本",与业务运行时依赖分层管理。

16.5 为什么没有 apiDiff 任务

任务清单里提到"如 spring-api-diff? 以实际存在为准"。笔者在仓库里 Grep apiDiff、binary-compatibility、revapi 均无命中,buildSrc 的插件列表(7 个)里也没有任何 API 兼容性检查插件。7.0.9 的发布门禁里没有自动的二进制兼容性检查——版本号语义化(7.0.9 是 patch)与维护者自律承担了这个责任。这是一个可以客观指出的"仓库现状",不要凭旧资料写"有 apiDiff 任务"。


十七、写在最后:构建体系的设计哲学

把全章压成三句话:

第一,约定下沉到 buildSrc,模块脚本只写差异。22 个业务模块的 .gradle 平均只有几十行,绝大部分配置(toolchain、编译参数、测试、Checkstyle、javadoc、发布)都在 buildSrc 的 Java 插件里。新增一个模块,只需要 include "spring-xxx"、建目录写 spring-xxx.gradle 声明依赖,其他全免费继承。这是 Gradle 官方"conventions over configuration"理念在大型框架工程上的教科书级实现。

第二,版本与依赖关系是显式的、可审计的、CI 强制的。版本号一个文件、第三方版本一个文件、模块依赖关系每个模块脚本里逐行列出;ArchUnit 查包环、checkstyle 查格式、nohttp 查链接、runtimeHintsTest 查 GraalVM 兼容、MR-JAR validate 查打包结构。没有"约定俗成"的依赖方向,每一条模块间 api 都是写出来、被构建门禁兜底的。

第三,对 JPMS 的务实退让。7.0.9 没有 module-info.java,靠 Automatic-Module-Name 成为 automatic module;没有内嵌 spring-jcl,直接依赖真实 commons-logging:1.3.5。这两个决定都让 Spring 在"模块化纯净度"上做了让步,换来了维护成本的下降与 native 时代的灵活性。对使用者而言,记住两个新事实即可:模块名是 spring.core 短名;commons-logging 现在是真实传递依赖。

十八、源码剖析(十):测试约定与特殊 task 深挖

18.1 spring-test 的多引擎测试与 xml-apis 排除

spring-test/spring-test.gradle 末尾的 test 块(第 107-121 行)是全仓库最特殊的测试配置:

test {
	description = "Runs JUnit 4, JUnit Jupiter, and TestNG tests."
	useJUnitPlatform {
		includeEngines "junit-vintage", "junit-jupiter", "testng"
	}
	filter.excludeTestsMatching("*TestCase")
	systemProperty("junit.vintage.discovery.issue.reporting.enabled", "false")
}

spring-test 框架本身要同时支持 JUnit 4、JUnit 5(Jupiter)、TestNG 三种用户编写的测试,所以它自己的测试也必须在三种引擎下跑。includeEngines 显式声明三个引擎:junit-jupiter(JUnit 5)、junit-vintage(兼容 JUnit 4)、testng(TestNG,通过 org.junit.support:testng-engine)。filter.excludeTestsMatching("*TestCase") 排除抽象测试基类——这些类是给其他测试继承用的,不能直接跑。

第 103-105 行还有一段配置:

configurations.optional {
	exclude group: "xml-apis", module: "xml-apis"
}

注释解释:xml-apis 这个老 jar 与 JDK 自带的 java.xml 模块存在 split package(同一个 org.w3c.dom 包两边都有),在 Eclipse 模块路径下会报 split package 错误。直接在 optional 配置里把它全局排除,让 JDK 的 XML API 生效。这是一个典型的"为 IDE 兼容打的补丁"。

18.2 spring-core 的测试 classpath 与 BlockHound JVM 参数

spring-core.gradle 第 133-143 行:

test {
	// Make sure the classes dir is used on the test classpath (required by ResourceTests).
	classpath = sourceSets.main.output.classesDirs + files(sourceSets.main.output.resourcesDir) + classpath - files(jar.archiveFile)

	jvmArgs += [
		"-XX:+AllowRedefinitionToAddDeleteMethods"
	]
}

第一行把测试 classpath 改成"直接用 classes 目录而不是打出来的 jar"——注释说这是 ResourceTests 的要求(测试要直接读 build/classes 里的文件,走 jar 会路径不对)。第二行加了一个 JDK 实验性 flag -XX:+AllowRedefinitionToAddDeleteMethods,注释指向 BlockHound issue 33:BlockHound 在 JDK 13+ 上做类重定义时需要这个 flag,否则某些 redefine 操作会失败。这种"为特定测试库打 JVM flag"的细节,正是构建脚本存在的意义。

18.3 spring-webflux 的并行 fork

spring-webflux.gradle 末尾:

test {
	maxParallelForks = 4
}

WebFlux 测试大量涉及 Reactor 异步流与端口绑定,单 fork 串行跑太慢,显式开到 4 个并行 JVM。其他模块没设这个,用 Gradle 默认的 1——这是按模块测试特征调优,不是统一约定。

18.4 MultiReleaseJarValidateTask 的真实实现

8.3 节提到这个 task,现在看它到底怎么验(MultiReleaseJarValidateTask.java 第 35-38 行):

public MultiReleaseJarValidateTask() {
	getMainModule().set("jdk.jartool");
	getArgumentProviders().add(() -> List.of("--validate", "--file", getJar().get().getAsFile().getAbsolutePath()));
}

它是一个 JavaExec task,运行 jdk.jartool 模块的 jar 工具,带 --validate --file <jar路径> 参数。JDK 9+ 自带的 jar --validate 会检查 jar 是否符合规范,对 MR-JAR 尤其会校验 META-INF/versions/ 下的类版本号是否单调递增、是否有重复条目。@CacheableTask 注解让这个 task 的输入(jar 文件)可以被构建缓存复用。它挂在 check 上(MultiReleaseJarPlugin.java 第 73 行 task.dependsOn(validateJarTask)),意味着每次 ./gradlew check 都会自动校验打出的 jar 合法。

18.5 buildSrc/README 自文档化

buildSrc/README.md 不是摆设,它是构建体系的"用户手册"。第 19-23 行给模块作者示范怎么用 preview 特性扩展:

springFramework {
	enableJavaPreviewFeatures = true
}

第 42-50 行示范怎么用 multiRelease 插件:

plugins {
    id 'org.springframework.build.multiReleaseJar'
}
multiRelease {
	releaseVersions 21, 24
}

第 68-83 行示范 RuntimeHints agent 的 DSL:

runtimeHintsAgent {
	includedPackages = ["org.springframework", "io.spring"]
	excludedPackages = ["org.example"]
}

也就是说,buildSrc 不只是"内部实现",它把自己当成一个独立的小 Gradle 插件生态对外(对本仓库的模块作者)提供 API。这种"构建代码也要写文档"的纪律,是大型开源工程区别于小作坊的标志。

十九、源码剖析(十一):commons-logging 1.3.5 桥接机制与 testFixtures 机制

19.1 日志门面在 7.0.9 到底怎么工作

8.2 节给出了构建侧的事实:spring-core api("commons-logging:commons-logging"),版本被 framework-platform 约束到 1.3.5。这一节把运行机制讲透。

全代码库 1500+ 处 import org.apache.commons.logging.LogFactory;,最终拿到的是 Apache Commons Logging 1.3.5 这个第三方 jar 里的 LogFactory。它的工作方式与老 spring-jcl 几乎一致——都是"运行时探测 classpath 上有什么日志实现,就桥接过去":

  • classpath 上有 SLF4j(org.slf4j.impl.StaticLoggerBinder 或 slf4j 1.7+ 的 Provider)→ 桥接到 SLF4J,最终走 Logback/Log4j2;
  • 有 Log4j 2 → 桥接;
  • 什么都没有 → 退化到 java.util.logging。

commons-logging 1.3.x 相比 1.2.x 的重要改进是它不再依赖类加载顺序猜,而是用显式的 API 探测,避免了著名的"commons-logging 内存泄漏/类加载死锁"老问题。Spring 7 选择直接依赖它而不是内嵌桥接,等于把这个"已被 Apache 官方修好的瘦桥接"拿来即用,不再自己维护一份桥接代码。

对 Spring 用户的实际影响链:

  1. 你引入 spring-boot-starter-web(它传递 spring-webmvc、spring-web、spring-context、spring-beans、spring-core);
  2. 传递依赖树里出现 commons-logging:commons-logging:1.3.5;
  3. Spring Boot 的 spring-boot-starter-logging 会用 spring-jcl 模块(注意:那是 Boot 自己的模块,不是 Framework 的)排除这个传递依赖、换成 Boot 的 jcl-slf4j 桥——这是 Boot 生态的事,与 Framework 7.0.9 本身无关;
  4. 如果你不用 Spring Boot,直接用 spring-framework-bom,那 commons-logging 1.3.5 就在 classpath 上,你自己引入 Logback 即可自动桥接。

这个区别要讲清:Framework 7 把日志桥接"外包"给 Apache Commons Logging 1.3.5;Spring Boot 仍然用自己的 spring-jcl 把它再桥接到 SLF4J。两个层次不要混淆。

19.2 java-test-fixtures:跨模块测试工具复用

根 build.gradle 第 47 行 apply plugin: 'java-test-fixtures' 给每个 javaProject 都开了 test fixtures。这个 Gradle 官方插件的语义是:每个模块可以建一个 src/testFixtures/java 目录,里面的类既给本模块的 test 用,也可以被其他模块通过 testImplementation(testFixtures(project(":spring-core"))) 引用。

仓库里最典型的例子就是 TestGroup:它放在 spring-core/src/testFixtures/java/org/springframework/core/testfixture/TestGroup.java,于是 spring-beans、spring-aop、spring-tx、spring-web 等模块的测试都能写 testImplementation(testFixtures(project(":spring-core"))) 来用 @EnabledForTestGroups。注意 6.1 节 spring-module.gradle 第 121-122 行把 testFixtures 的发布 variant 全部 skip——testFixtures 是给仓库内部测试用的,绝不发布到 Maven Central,下游用户拿不到。

testFixturesApi 与 testFixturesImplementation 的区别和普通 api/implementation 一样:前者对"用这个 fixtures 的其他模块"可见,后者只对本模块 test 可见。spring-context.gradle 第 35 行 testFixturesApi("org.junit.jupiter:junit-jupiter-api") 就是把 JUnit API 暴露给所有用它 fixtures 的下游模块。

19.3 dependencyManagement 配置:为什么要 canBeResolved=false

回头看根 build.gradle 第 32-39 行那个 dependencyManagement 配置,三个属性 canBeConsumed=false、canBeResolved=false、visible=false 的设计意图值得再讲一层:

  • canBeResolved=false:Gradle 不会试图去解析这个配置(不会真的去下载 enforcedPlatform 里约束的所有 jar)。如果允许解析,每个工程启动时都要把 framework-platform 里 120 个约束全部解析一遍,配置阶段爆炸。
  • canBeConsumed=false:不对外发布这个配置的内容。它只是内部载体。
  • visible=false:从 configurations.dependencyManagement 这种脚本视角不可见,避免误用。

它唯一的作用是被所有 *Classpath 配置 extendsFrom——通过继承,enforcedPlatform 的版本约束效果渗透到 compileClasspath/runtimeClasspath,但本身不触发解析。这是 Gradle 实现"内部 BOM 注入"的标准模式,比每个模块脚本手写 dependencies { constraints { ... } } 干净得多。

19.4 spring-core 的 optional 列表:可选集成全景

spring-core.gradle 第 83-94 行一口气列了 11 个 optional 依赖:

optional("io.micrometer:context-propagation")
optional("io.netty:netty-buffer")
optional("io.projectreactor:reactor-core")
optional("io.reactivex.rxjava3:rxjava")
optional("io.smallrye.reactive:mutiny")
optional("net.sf.jopt-simple:jopt-simple")
optional("org.aspectj:aspectjweaver")
optional("org.eclipse.jetty:jetty-io")
optional("org.jetbrains.kotlin:kotlin-reflect")
optional("org.jetbrains.kotlin:kotlin-stdlib")
optional("org.jetbrains.kotlinx:kotlinx-coroutines-core")
optional("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")

为什么 spring-core 这个最基础的模块要 optional 这么多?因为它提供 ReactiveAdapterRegistry、GenericConversionService 里的 Kotlin 支持、ObjectUtils 里的 JOpt 命令行解析等扩展点。这些扩展点只在用户 classpath 上真的有对应库时才激活——Spring 代码里大量 ClassUtils.isPresent("io.netty.buffer.ByteBuf", classLoader) 判断,存在才调用对应的 API。把这些库做成 optional,意味着:

  • 不需要响应式的用户,classpath 上没有 reactor-core,spring-core 照常工作,相关类不加载;
  • 需要的用户,自己显式 implementation "io.projectreactor:reactor-core",Spring 的扩展自动激活。

这就是"可选依赖"模式的全部价值——核心模块保持精简,扩展点按需挂载。

二十、源码剖析(十二):双 Jackson 生态、buildSrc 版本自管理与发布 POM 生成

20.1 spring-web 里的两套 Jackson:com.fasterxml 与 tools.jackson

读 spring-web.gradle 第 15-19 行与第 59-64 行,会发现 Jackson 依赖写了两遍:

optional("com.fasterxml.jackson.core:jackson-databind")
optional("com.fasterxml.jackson.dataformat:jackson-dataformat-cbor")
optional("com.fasterxml.jackson.dataformat:jackson-dataformat-smile")
optional("com.fasterxml.jackson.dataformat:jackson-dataformat-xml")
optional("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml")
...
optional("tools.jackson.core:jackson-databind")
optional("tools.jackson.dataformat:jackson-dataformat-smile")
optional("tools.jackson.dataformat:jackson-dataformat-cbor")
optional("tools.jackson.dataformat:jackson-dataformat-smile")
optional("tools.jackson.dataformat:jackson-dataformat-xml")
optional("tools.jackson.dataformat:jackson-dataformat-yaml")

com.fasterxml.jackson 是 Jackson 2.x 的 Maven groupId;tools.jackson 是 Jackson 3.x 的新 groupId(Jackson 3 把包名从 com.fasterxml.jackson 迁移到 tools.jackson,Maven 坐标同步换 groupId)。framework-platform 第 24 行同时 import 了两个 BOM:com.fasterxml.jackson:jackson-bom:2.20.2 与 tools.jackson:jackson-bom:3.0.4。Spring 7.0 在同一份代码里同时兼容 Jackson 2 与 Jackson 3——运行时探测 classpath 上是哪套,就用对应的消息转换器。这就是为什么 web、webmvc、webflux、messaging、test 等模块的 optional 列表里都成对出现两个 groupId。对下游用户的意义:你的工程用 Jackson 2 还是 3 都行,Spring 两边都支持。

20.2 buildSrc 自己的版本号:一套独立的小生态

buildSrc/gradle.properties 全文:

org.gradle.caching=true
assertjVersion=3.27.3
javaFormatVersion=0.0.43
junitVersion=5.12.2

注意 junitVersion=5.12.2 与主 gradle.properties 里没有 junit 版本(主构建里 junit 版本由 framework-platform 的 org.junit:junit-bom:6.0.3 约束)。buildSrc 是独立的嵌套构建,它有自己的依赖解析——主构建的 enforcedPlatform 管不到 buildSrc。所以 buildSrc 自己用 AssertJ 3.27.3、JUnit 5.12.2、spring-javaformat 0.0.43,与主构建用 JUnit BOM 6.0.3、Assertj BOM 3.27.7 并不对齐,也不需要对齐:buildSrc 的测试是"测构建脚本本身",与框架产品测试是两回事。

这个分层很重要:你在主工程里看到 org.junit.jupiter:junit-jupiter 不写版本号,是因为被 framework-platform 的 junit-bom 6.0.3 约束;但 buildSrc 里写 org.junit.jupiter:junit-jupiter 不写版本号时,是被 buildSrc/build.gradle 第 30 行 testImplementation(platform("org.junit:junit-bom:${junitVersion}")) 的 5.12.2 约束。两套 JUnit BOM 各管各的构建阶段。

20.3 fromResolutionResult 如何生成最终 POM

publications.gradle 第 40-47 行的 versionMapping { usage('java-api') { fromResolutionResult() } } 值得再展开一层。假设 spring-webmvc 的脚本里写了 api("com.fasterxml.jackson.core:jackson-databind") 不写版本号。Gradle 在发布时生成 POM,有两种策略填版本:

  1. fromPublishedDependencies(默认):取脚本里声明的字面量——这里是空的,POM 里会写 [latest,) 或报错;
  2. fromResolutionResult:取这次构建实际解析出的版本——经过 enforcedPlatform 强制后,实际选中的是 2.20.2(或 3.0.4),POM 里就写死 2.20.2。

Spring 选了第二种。结果是发布出去的 POM 里每一行依赖版本都是"这次构建真实解析到的版本",与 CI 验证过的完全一致。下游用户引入 spring-webmvc 后,Maven 会按 POM 里写死的 2.20.2 拉 Jackson,不会出现"Spring 没写版本、Maven 自己挑一个 latest 导致版本漂移"的问题。

20.4 framework-bom 是怎么自动覆盖 22 个模块的

framework-bom.gradle 第 10-12 行:

parent.moduleProjects.sort { "$it.name" }.each {
	api project(it.path)
}

parent 是 framework-bom 工程的父工程(根工程);parent.moduleProjects 是根脚本 ext 里那个"所有 spring-* 工程"列表。.sort { "$it.name" } 按工程名字母序排,保证每次生成的 BOM 顺序稳定。.each { api project(it.path) } 等价于手写:

dependencies {
	constraints {
		api "org.springframework:spring-aop:7.0.9"
		api "org.springframework:spring-aspects:7.0.9"
		api "org.springframework:spring-beans:7.0.9"
		...
	}
}

但它是动态生成的——以后新增 spring-xxx 模块,只要 include 进来,framework-bom 自动把它纳入约束,不用改脚本。api project(it.path) 里的 project(it.path) 是 Gradle 跨工程引用,版本自动取当前 project.version(7.0.9)。最终发布的 spring-framework-bom-7.0.9.pom 里就是这 22 条 constraint,下游 implementation platform("org.springframework:spring-framework-bom:7.0.9") 后,引入任何 org.springframework:spring-xxx 都不用写版本号。

20.5 为什么 spring-instrument 不依赖任何 Spring 模块

回头看 spring-instrument.gradle,它连 dependencies 块都没有。这不是遗漏——它的全部职责是"premain 时把 Instrumentation 存起来",只需要 JDK 自带的 java.lang.instrument API,不需要任何 Spring 类。它甚至连 spring-core 都不依赖。这种"零依赖模块"在 Spring 生态里很特殊:它必须能在任何应用服务器(WebLogic、WildFly)的 agent 加载早期就工作,此时 Spring 的类还没加载,依赖任何 Spring 模块都会造成类加载顺序问题。

20.6 把依赖方向规则再压实一遍

7.3 节讨论了依赖合理性,这里再用仓库里的 ArchUnit 规则做一次交叉验证。ArchitectureRules.java 第 49-59 行禁止依赖 org.springframework.lang.NonNull/Nullable(已迁移到 JSpecify);第 61-74 行禁止 Java 类 import Kotlin 注解。这些规则在 checkArchitectureMain task 里跑,挂在 check 上。也就是说,构建期门禁不仅管"包之间有没有环",还管"包内部用了什么 API"。依赖图不只是 build.gradle 里的 api(project(...)) 声明,更是被 ArchUnit 持续校验的事实约束。

二十一、源码剖析(十三):framework-docs 与 integration-tests 的角色

21.1 framework-docs:Antora 文档站

framework-docs/framework-docs.gradle 第 4-8 行用两个插件:io.spring.antora.generate-antora-yml 0.0.1 与 org.antora 1.0.0。Antora 是现代文档站点生成器,读 AsciiDoc 源文件产出静态 HTML。

jar { enabled = false }、javadoc { enabled = false }——这个工程不产 jar、不产 javadoc,纯粹是文档构建。第 23-25 行 node { version = '24.15.0' } 固定 Node.js 版本,Antora 本身是 Node 工具。

第 62-72 行把 10 个业务模块全部 implementation 进来,因为参考文档里的代码片段要被真实编译验证(Antora 有 antora-test 机制)。这也是为什么 framework-docs 不在 moduleProjects 集合里却能引用模块——它在 javaProjects 集合里,被 apply 了 java 插件。

framework-docs/antora-playbook.yml 与 antora.yml 是 Antora 的配置文件,定义文档站点的结构、导航、UI。modules/ROOT/nav.adoc 是左侧导航树,modules/ROOT/assets/images/ 下存放架构图(aop-proxy-call.png、container-magic.png 等——你在 Spring 官方参考文档里看到的那些图就在这里)。

21.2 integration-tests:跨模块端到端测试

integration-tests/integration-tests.gradle 第 9-23 行把 aop/beans/context/core/core-test/expression/jdbc/orm/test/tx/web 全部 testImplementation——注意它是 testImplementation 而不是 api,它不发布,只是测试。

它测什么?看它的资源目录:src/test/resources/META-INF/spring.handlers、spring.schemas,以及 org/springframework/aop/config/AopNamespaceHandler...、org/springframework/beans/factory/xml/component.xsd——这些是 XML 命名空间的集成测试。单元测试在各模块自己的 src/test 里跑,跨模块的场景(比如"用 XML 配置声明事务 + AOP 自动代理 + JDBC 模板"这种端到端组合)在 integration-tests 里跑。它不被 include 进任何发布物,只在 CI 全量构建时执行。

21.3 27 个工程的分类总表

把全章分析的 27 个工程汇总成一张表,作为本章收尾:

工程类型发布关键特征
spring-core业务jar根模块,内嵌 javapoet/objenesis repack,MR-JAR 21/24
spring-beans业务jarapi core,snakeyaml optional
spring-aop业务jarapi beans+core
spring-expression业务jarapi core,SpEL
spring-context业务jarapi aop+beans+core+expression,挂 runtimeHintsAgent
spring-context-support业务jarapi beans+context+core,Quartz/FreeMarker optional
spring-context-indexer业务jar注解处理器,已 deprecated forRemoval
spring-core-test业务jarRuntimeHints agent,TestCompiler 工具
spring-tx业务jarapi beans+core,aop/context optional
spring-jdbc业务jarapi beans+core+tx
spring-orm业务jarapi beans+core+jdbc+tx
spring-oxm业务jarapi beans+core,XJC 测试代码生成
spring-jms业务jarapi beans+core+messaging+tx
spring-messaging业务jarapi beans+core
spring-r2dbc业务jarapi beans+core+tx,reactor
spring-web业务jarapi beans+core,双 Jackson
spring-webmvc业务jarapi 全家桶
spring-webflux业务jarapi beans+core+web,reactor
spring-websocket业务jarapi context+core+web
spring-aspects业务jar唯一用 ajc 编译
spring-instrument业务jar纯 Java agent,零 Spring 依赖
framework-platform工具否java-platform,内部版本约束中心
framework-bom工具pom对外 spring-framework-bom
framework-api工具docs/schema zip聚合 javadoc/kdoc,无源码
framework-docs工具否Antora 文档站
integration-tests测试否跨模块端到端测试

这张表就是本章依赖分析与模块剖析的最终收敛。

二十二、本章关键数字速查

为方便读者快速对位,把全章出现的所有版本号与路径汇总在这里,全部来自仓库实际文件:

工具链与运行时基线

  • Gradle wrapper:gradle/wrapper/gradle-wrapper.properties → Gradle 9.7.0
  • 构建 JDK:buildSrc/.../JavaConventions.java → toolchain 25;.sdkmanrc → java=25-librca
  • 字节码基线:JavaConventions.java → -release 17
  • MR-JAR 变体:spring-core multiRelease { releaseVersions 21, 24 }

第三方关键版本(全部在 framework-platform.gradle)

  • commons-logging:1.3.5
  • jakarta.servlet-api:6.1.0
  • reactor-bom:2025.0.7
  • jackson 2.x bom:2.20.2;jackson 3.x (tools.jackson) bom:3.0.4
  • hibernate-core:7.2.24.Final
  • aspectjweaver:1.9.25
  • snakeyaml:2.6
  • junit-bom:6.0.3

构建工具版本

  • Kotlin:gradle.properties → 2.2.21
  • ByteBuddy agent:gradle.properties → 1.17.6
  • Checkstyle:CheckstyleConventions.java → 13.10.0
  • spring-javaformat:buildSrc/gradle.properties → 0.0.43
  • ArchUnit:buildSrc/build.gradle → 1.4.1
  • Dokka:buildSrc/build.gradle → 2.1.0
  • Shadow:根 build.gradle → 9.2.2
  • JMH:根 build.gradle → 0.7.2

两个 7.0.9 专属差异(本章反复强调、务必记住)

  1. 全仓库无 module-info.java,jar 模块名由 gradle/spring-module.gradle 第 45 行 Automatic-Module-Name = project.name.replace('-', '.') 决定,即 spring.core 短名。
  2. 无 spring-jcl 模块,spring-core 直接 api("commons-logging:commons-logging"),版本 1.3.5。

这两个差异与网上绝大多数旧博客、旧教程描述的"Spring 模块化"都不同,升级时务必以本章的仓库证据为准。

二十三、给源码读者的实战提示

读 Spring Framework 7.0.9 源码时,构建体系知识会直接帮你定位问题。三个实战场景:

场景一:你改了一行 spring-beans 的代码,想只重跑 beans 的测试。 命令是 ./gradlew :spring-beans:test -PskipDocs。由于 org.gradle.parallel=true 与构建缓存,已编译的 spring-core 不会重编。如果想跳过长测试,再加 -DtestGroups=(空字符串)。

场景二:你的应用在 JDK 17 上启动报 "module not found: spring.core"。 说明你的工程在模块路径上跑,但 jar 没在 classpath 上正确加载。检查你的 module-info.java,模块名是不是写对了短名;或者干脆把这些 jar 放到 classpath(不写 module-info.java),Spring 7 的 automatic module 在 classpath 模式下完全不需要 requires 语句。

场景三:你调试时发现 Objenesis 类找不到。 这是因为 spring-core 把 objenesis 3.5 重打包成了 org.springframework.objenesis,不是原生的 org.objenesis。正确 import 是 org.springframework.objenesis.Objenesis。同理 com.palantir.javapoet 在 spring-core 里叫 org.springframework.javapoet。

场景四:你想知道某个 optional 依赖到底什么时候被激活。 去对应模块的源码里 Grep ClassUtils.isPresent——每个 optional 库在代码里都有对应的探测点,探测通过才加载对应类。这是"可选依赖"模式在运行时的完整闭环。

掌握了本章的构建体系,你就掌握了 Spring Framework 7.0.9 从源码到发布 jar 的完整流水线:27 个工程在 Gradle 9.7 下用 JDK 25 编译、产出 JDK 17 字节码,版本由 gradle.properties 单点控制,第三方依赖由 framework-platform 强制约束,模块方向由 build.gradle 声明 + ArchUnit 门禁双向保障,最终以 spring-framework-bom + 22 个 jar 的形式发布到 Maven Central。后续章节讨论的任何一个类,你都能在这张构建地图上找到它所在的模块、它的依赖者、它被打进哪个 jar、以及它在模块路径上叫什么名字。

二十四、结语

本章没有追求面面俱到地罗列每个模块的每一行依赖,而是抓住三条主线:版本如何单点控制、依赖如何声明并被门禁保障、模块化与日志桥接在 7.0.9 的真实状态。构建文件本身不长,但每一段配置背后都有明确的工程动机——为什么 toolchain 是 25 而 release 是 17、为什么 optional 不传递、为什么 spring-core 要重打包 javapoet、为什么 framework-platform 不发布而 framework-bom 发布。把这些"为什么"讲透,比背熟版本号列表更有价值。后续章节讨论的每一个核心类,都可以在本章建立的模块地图里找到坐标。请记住:构建体系不是源码的附属品,它本身就是框架工程能力的一部分——Spring 能维护二十年、覆盖几十个模块、同时兼容 JDK 17 到 25、同时支持 Jackson 2 与 3,靠的正是本章这套被 buildSrc 固化、被 CI 门禁兜底的构建体系本身。