Spring Boot 4.1.1 深度源码解析-第 28 章 AOT 与 GraalVM Native:SpringApplicationAotProcessor、RuntimeHints 与序列化模型
0. 从一条命令出发:process-aot 到底做了什么
先给出一个能落地的起点。一个标准 Spring Boot 应用要做 native 编译,Maven 侧至少会经过这两步:
bash
mvn -Pnative spring-boot:process-aot
mvn -Pnative native:compile
第一条命令的产物不在 target/classes,而在 target/spring-aot/:
bash
target/spring-aot/main/sources # 生成的 .java
target/spring-aot/main/resources # 生成的资源(META-INF/spring/...)
target/spring-aot/main/classes # 上面那些 .java 编译出的 .class
Gradle 侧是等价的,由 ProcessAot 任务完成,默认输出到 build/generated/aotSources、build/generated/aotResources、build/generated/aotClasses(具体路径由 JavaPluginExtension 的 source set 注册决定,本章 §8 会给出任务源码)。
这里有一个必须先纠正的直觉 :AOT 处理不是静态分析,也不是字节码改写 。它是一次"真实的启动"------真的 new SpringApplication(...)、真的刷新 ApplicationContext、真的实例化自动配置类、真的做条件求值------只不过这次启动发生在构建机上,并且在 refresh 之前被人为掐断,把"已经算出来的结论"写成 Java 源码与资源文件。运行期再把这份结论读回来,从而跳过扫描、反射、条件求值、XML 解析这些在 native image 里既慢又无法静态追踪的动作。
这个"掐断"的机制,是本章 §2 的核心。SpringApplicationAotProcessor 的做法相当激进:它注册一个 SpringApplicationRunListener,在 contextLoaded 回调里抛异常 ,用异常把已经构建好的 ApplicationContext 从正常的启动流程里"带出来"。而 SpringApplication 的失败处理逻辑里为这个异常留了一个专门的分支,让它不被当作启动失败。
本章会沿着下面这条线走完:
- 三条时间线(构建期 AOT、运行期 AOT 模式、native image 运行期)与三个布尔开关(§1)
SpringApplicationAotProcessor如何在不启动应用的前提下拿到上下文(§2)- 生成的三类产物,以及运行期怎么把它们接管回来(§3)
META-INF/spring/aot.factories:AOT 的 SPI,以及 Spring Boot 自己给它写的构建期校验器(§4)RuntimeHints的注册方式谱系:从一行常量到全量绑定反射(§5)BeanRegistrationAotProcessor:直接改写某个 bean 的实例化代码(§6)- 第 24 章埋下的第四十一定律("AOT 下序列化解析结果而非原始输入")的完整实现:
META-INF/spring/logback-model(§7) - 构建插件侧 Maven 与 Gradle 的对称与差异(§8)
- 测试的 AOT:
SpringBootTestAotProcessor(§9) - native image 构建阶段:
NativeImageArgFile与可达性元数据排除(§10,承接第 27 章 §8.6 的伏笔)
本章所有结论都来自本仓库源码。凡是源码没有逐行追到、或依赖 Spring Framework(spring-core / spring-context)内部实现的部分,我会在 §13 明确标出并给出自测方法,不会拿推断当证据。
1. 三条时间线与三个开关
理解 AOT 的第一步,是分清"我现在在哪条时间线上"。整个机制里有三个独立的布尔开关,它们的取值组合决定了代码走哪条分支。
1.1 三个开关
| 开关 | 来源 | 含义 |
|---|---|---|
AotDetector.useGeneratedArtifacts() |
org.springframework.aot.AotDetector(spring-core),由系统属性 AotDetector.AOT_ENABLED 控制 |
运行期是否应该使用构建期生成的产物 |
NativeDetector.inNativeImage() |
org.springframework.core.NativeDetector(spring-core) |
当前 JVM 是不是一个 native image 进程 |
AbstractAotProcessor.AOT_PROCESSING |
org.springframework.context.aot.AbstractAotProcessor(spring-context)的常量,通过 Boolean.getBoolean(...) 读取 |
当前进程是不是正在做 AOT 处理 |
第三个开关在本仓库里的唯一使用点是 SpringBootJoranConfigurator:
java
// core/spring-boot/.../logging/logback/SpringBootJoranConfigurator.java
private boolean isAotProcessingInProgress() {
return Boolean.getBoolean(AbstractAotProcessor.AOT_PROCESSING);
}
注意它读的是 AbstractAotProcessor.AOT_PROCESSING 这个常量本身作为属性名,也就是说属性名是 spring-context 定义的字符串,本仓库不重复定义。这一点很关键:Spring Boot 不自己发明第二个"AOT 处理中"标志,而是复用 Spring Framework 的,避免出现两套不一致的状态。
待核实 :
AbstractAotProcessor.AOT_PROCESSING的字面值(推测为"spring.aot.processing"之类)定义在spring-context中,本仓库不含该类源码。可自测:在 AOT 进程里System.getProperty全量 dump,或反查spring-contextjar。
1.2 四个场景下的取值表
| 场景 | useGeneratedArtifacts() |
inNativeImage() |
AOT_PROCESSING |
|---|---|---|---|
java -jar app.jar(常规 fat jar) |
false |
false |
false |
mvn spring-boot:process-aot 起的那个 Java 进程 |
false |
false |
true |
AOT 产物已生成、但用普通 JVM 跑(java -Dspring.aot.enabled=true -jar) |
true |
false |
false |
| native image 可执行文件 | true |
true |
false |
第二行值得单独说:AOT 处理进程自己并不开启 useGeneratedArtifacts() 。它是在"常规模式"下跑一次完整启动,然后靠 AOT_PROCESSING 这个标志让各个组件知道"我现在在录制"。这是一个很干净的设计------录制者不需要消费录制结果,否则会自举。
第三行是很多人忽略的组合:AOT 产物在普通 JVM 上同样可用,这也是本地验证 AOT 是否正确的手段(不需要装 GraalVM)。StartupInfoLogger 在这种模式下会打印 AOT-processed:
java
// core/spring-boot/.../StartupInfoLogger.java
private void appendAotMode(StringBuilder message) {
append(message, "", () -> AotDetector.useGeneratedArtifacts() ? "AOT-processed" : null);
}
append(...) 的第三个参数是 Supplier<String>,返回 null 时这段就不拼进日志。也就是说常规启动下这一项完全不出现,不产生噪音。
1.3 运行期可见的三处痕迹
AotDetector.useGeneratedArtifacts() 在本仓库 main 源码里只有四处调用(不含测试),每一处都对应一个"AOT 下不需要再做"的动作:
第一处:DefaultApplicationContextFactory------上下文类型直接换掉
java
// core/spring-boot/.../DefaultApplicationContextFactory.java
private ConfigurableApplicationContext createDefaultApplicationContext() {
if (!AotDetector.useGeneratedArtifacts()) {
return new AnnotationConfigApplicationContext();
}
return new GenericApplicationContext();
}
这是本章最易被忽略、影响面最大的一行。常规模式用 AnnotationConfigApplicationContext(带注解扫描),AOT 模式用 GenericApplicationContext(不带)。原因直白:AOT 模式下所有 bean definition 都由生成的 __ApplicationContextInitializer 直接 registerBeanDefinition,不再需要 ClassPathBeanDefinitionScanner 扫 @Component;既然不需要扫描器,就不必背负 AnnotatedBeanDefinitionReader 与 ClassPathScanningCandidateComponentProvider------native image 里这些类的可达性分析成本不低。
顺带解释了 §2 会遇到的强制约束:AOT 处理要求上下文必须是 GenericApplicationContext,因为只有它暴露 registerBeanDefinition 这种"不经过扫描直接登记"的能力。
第二处:SpringApplication.prepareContext------不加载 sources
java
// core/spring-boot/.../SpringApplication.java
if (!AotDetector.useGeneratedArtifacts()) {
// Load the sources
Set<Object> sources = getAllSources();
Assert.state(!ObjectUtils.isEmpty(sources), "No sources defined");
load(context, sources.toArray(new Object[0]));
}
listeners.contextLoaded(context);
注意这段在 listeners.contextLoaded(context) 之前 。contextLoaded 正是 §2 里 AOT hook 抛异常的位置------也就是说,AOT 模式下这个分支被跳过,然后立刻抛异常,后面的 refreshContext 根本不会执行。两个动作在时间线上是紧挨着的。
第三处:EnvironmentPostProcessorApplicationListener------插入生成的 EnvironmentPostProcessor
java
// core/spring-boot/.../support/EnvironmentPostProcessorApplicationListener.java
private void addAotGeneratedEnvironmentPostProcessorIfNecessary(List<EnvironmentPostProcessor> postProcessors,
SpringApplication springApplication) {
if (AotDetector.useGeneratedArtifacts()) {
ClassLoader classLoader = (springApplication.getResourceLoader() != null)
? springApplication.getResourceLoader().getClassLoader() : null;
Class<?> mainApplicationClass = springApplication.getMainApplicationClass();
Assert.state(mainApplicationClass != null, "mainApplicationClass not found");
String postProcessorClassName = mainApplicationClass.getName() + "__" + AOT_FEATURE_NAME;
if (ClassUtils.isPresent(postProcessorClassName, classLoader)) {
postProcessors.add(0, instantiateEnvironmentPostProcessor(postProcessorClassName, classLoader));
}
}
}
AOT_FEATURE_NAME 是同类里的常量 static final String AOT_FEATURE_NAME = "EnvironmentPostProcessor";,于是生成类的全限定名是 <MainClass>__EnvironmentPostProcessor。这里用 ClassUtils.isPresent 做存在性判断 而非强制加载------找不到就静默跳过。这与 §3 里 ApplicationContextInitializer 的处理(找不到抛 AotInitializerNotFoundException)形成鲜明对比,§3.4 会解释这个不对称为什么合理。
第四处:LogbackLoggingSystem------这是 §7 的主角,那里展开。
1.4 JDK 25 的硬门槛,以及它为什么只在 native 下校验
java
// core/spring-boot/.../SpringApplication.java
/**
* Exception which is thrown if GraalVM's native-image requirements aren't met.
*/
static final class NativeImageRequirementsException extends RuntimeException {
private static final JavaVersion MINIMUM_REQUIRED_JAVA_VERSION = JavaVersion.TWENTY_FIVE;
private static final JavaVersion CURRENT_JAVA_VERSION = JavaVersion.getJavaVersion();
NativeImageRequirementsException(String message) {
super(message);
}
static void throwIfNotMet() {
if (CURRENT_JAVA_VERSION.isOlderThan(MINIMUM_REQUIRED_JAVA_VERSION)) {
throw new NativeImageRequirementsException("Native Image requirements not met. "
+ "Native Image must support at least Java %s but Java %s was detected"
.formatted(MINIMUM_REQUIRED_JAVA_VERSION, CURRENT_JAVA_VERSION));
}
}
}
三个细节。MINIMUM_REQUIRED_JAVA_VERSION = JavaVersion.TWENTY_FIVE ------Spring Boot 4.1.x 的 native 路径要求 JDK 25 起步,这是本章能写下的最硬的一个数字。CURRENT_JAVA_VERSION 是 static final ,类加载时求值一次(JavaVersion.getJavaVersion() 内部解析 java.version 系统属性,缓存成静态字段避免每次启动重复解析);代价是有人在同一 JVM 里反射改掉 java.version 时断言不跟着变------可接受的取舍。调用点极其克制:
java
// SpringApplication.addAotGeneratedInitializerIfNecessary(...) 末尾
if (NativeDetector.inNativeImage()) {
NativeImageRequirementsException.throwIfNotMet();
}
只在 inNativeImage() 时才校验 。这意味着:你可以在 JDK 17 上跑 mvn spring-boot:process-aot 生成产物(AOT 处理进程不是 native,不校验),但产物拿去编译时会被要求 JDK 25。反过来说,在 JDK 21 上用 -Dspring.aot.enabled=true 跑普通 JVM 也不会被拦------因为那不是 native image。
这个克制是有道理的:AOT 生成阶段用哪个 JDK 编译,取决于你配置的 toolchain(AbstractAotMojo 里有 ToolchainManager,§8 会讲),跟 native 运行要求不是一回事。把校验放在"真的在 native 里"这个唯一会出问题的地方,能避免误伤。
2. SpringApplicationAotProcessor:不启动应用,却要拿到上下文
现在进入本章的核心机制。全部代码在 core/spring-boot/src/main/java/org/springframework/boot/SpringApplicationAotProcessor.java,141 行。
2.1 类签名与它继承的东西
java
public class SpringApplicationAotProcessor extends ContextAotProcessor {
private final String[] applicationArgs;
public SpringApplicationAotProcessor(Class<?> application, Settings settings, String[] applicationArgs) {
super(application, settings);
this.applicationArgs = applicationArgs;
}
...
}
ContextAotProcessor 来自 org.springframework.context.aot(spring-context)。它负责的是"AOT 处理的通用骨架":准备 GenerationContext、驱动 BeanFactoryInitializationAotProcessor、驱动 BeanRegistrationAotProcessor、最后把生成的代码与资源写到磁盘。Spring Boot 要覆写的只有一件事------怎么拿到那个 GenericApplicationContext。
于是整个类只覆写了一个方法:prepareApplicationContext(Class<?>)。
2.2 六个位置参数
java
public static void main(String[] args) throws Exception {
int requiredArgs = 6;
Assert.state(args.length >= requiredArgs, () -> "Usage: " + SpringApplicationAotProcessor.class.getName()
+ " <applicationMainClass> <sourceOutput> <resourceOutput> <classOutput> <groupId> <artifactId> <originalArgs...>");
Class<?> application = Class.forName(args[0]);
Settings settings = Settings.builder()
.sourceOutput(Paths.get(args[1]))
.resourceOutput(Paths.get(args[2]))
.classOutput(Paths.get(args[3]))
.groupId((StringUtils.hasText(args[4])) ? args[4] : "unspecified")
.artifactId(args[5])
.build();
String[] applicationArgs = (args.length > requiredArgs) ? Arrays.copyOfRange(args, requiredArgs, args.length)
: new String[0];
new SpringApplicationAotProcessor(application, settings, applicationArgs).process();
}
六个参数按位置绑定,没有 --key=value:
| 位置 | 参数 | 说明 |
|---|---|---|
| 0 | applicationMainClass |
用 Class.forName(args[0]) 加载,不含 main 方法签名信息 |
| 1 | sourceOutput |
生成的 .java 输出目录 |
| 2 | resourceOutput |
生成的资源输出目录 |
| 3 | classOutput |
生成的 .class 输出目录(编译产物) |
| 4 | groupId |
空字符串会被替换成 "unspecified" |
| 5 | artifactId |
无默认值 |
| 6+ | originalArgs... |
透传给应用 main 方法的命令行参数,可为空 |
groupId 的 (StringUtils.hasText(args[4])) ? args[4] : "unspecified" 是一条防御:Maven 的 ${project.groupId} 理论上不会为空,但如果有人手工构造命令行、或在某个没有 groupId 的聚合构建里运行,Settings.builder() 可能会拒绝空值。这里兜底成字符串 "unspecified",保证 AOT 总能跑完。
第 6 个之后的参数是 Arrays.copyOfRange(args, requiredArgs, args.length)------这些参数会原样传给应用的 main(String[])。这一点有实际价值:如果应用在 main 里根据参数做了分支(比如 --spring.profiles.active=prod),AOT 处理时应该传同样的参数,否则录制出来的结论和真实运行不一致。Maven 插件为此专门提供了 <arguments> 与 <profiles> 配置(§8.1)。
2.3 prepareApplicationContext:用异常做控制流
java
@Override
protected GenericApplicationContext prepareApplicationContext(Class<?> application) {
return new AotProcessorHook(application).run(() -> {
Method mainMethod = getMainMethod(application);
mainMethod.setAccessible(true);
if (mainMethod.getParameterCount() == 0) {
ReflectionUtils.invokeMethod(mainMethod, null);
}
else {
ReflectionUtils.invokeMethod(mainMethod, null, new Object[] { this.applicationArgs });
}
return Void.class;
});
}
这段是整个 AOT 入口最反直觉的部分。run(...) 的 lambda 返回值是 Void.class(不是 null,是一个 Class 对象),但 run 的签名是 <T> GenericApplicationContext run(ThrowingSupplier<T> action)------lambda 的返回值根本不被使用 。prepareApplicationContext 真正想要的 GenericApplicationContext,是从异常里捞出来的。
完整链路:
java
private static final class AotProcessorHook implements SpringApplicationHook {
private final Class<?> application;
AotProcessorHook(Class<?> application) {
this.application = application;
}
@Override
public SpringApplicationRunListener getRunListener(SpringApplication application) {
return new SpringApplicationRunListener() {
@Override
public void contextLoaded(ConfigurableApplicationContext context) {
throw new AbandonedRunException(context);
}
};
}
private <T> GenericApplicationContext run(ThrowingSupplier<T> action) {
try {
SpringApplication.withHook(this, action);
}
catch (AbandonedRunException ex) {
ApplicationContext context = ex.getApplicationContext();
Assert.state(context instanceof GenericApplicationContext,
() -> "AOT processing requires a GenericApplicationContext but got a "
+ ((context != null) ? context.getClass().getName() : "null"));
return (GenericApplicationContext) context;
}
throw new IllegalStateException(
"No application context available after calling main method of '%s'. Does it run a SpringApplication?"
.formatted(this.application.getName()));
}
}
把它翻译成人话:
- 注册一个
SpringApplicationHook,它的getRunListener返回一个只实现了一个方法 的SpringApplicationRunListener------只关心contextLoaded,其它生命周期方法用默认空实现(Java 8 的 default method 让这成为可能)。 contextLoaded在prepareContext末尾被调用(见 §1.3 第二处),此时上下文已经建好、环境已就绪、bean 定义已登记,但还没 refresh。这正是 AOT 需要的时刻:所有"静态可判定"的信息都在,而所有"需要真实运行"的动作还没发生。- 在这一刻抛出异常,把
context塞进异常对象。 run捕获它,校验类型,返回。
为什么用异常而不是返回值?因为控制流要穿过 main 方法。AOT 处理调用的是用户写的 main ,main 内部会 new SpringApplication(...).run(args)。这个 run 是 SpringApplication 的方法,没有为 AOT 预留"提前返回上下文"的出口。异常是唯一能从任意深度的调用栈里直接把对象带出来的机制------而且不需要改 SpringApplication.run 的签名。
代价是这条路径必须被"合法化",否则 run 的失败处理逻辑会把 AOT 处理当成一次启动失败。这就是下一节。
2.4 AbandonedRunException:一个为控制流而生的公开异常
java
// SpringApplication.java 的 public static 嵌套类
/**
* Exception that can be thrown to silently exit a running {@link SpringApplication}
* without handling run failures.
*
* @since 3.0.0
*/
public static class AbandonedRunException extends RuntimeException {
private final @Nullable ConfigurableApplicationContext applicationContext;
public AbandonedRunException() {
this(null);
}
public AbandonedRunException(@Nullable ConfigurableApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
public @Nullable ConfigurableApplicationContext getApplicationContext() {
return this.applicationContext;
}
}
三点值得注意:
- 它是
public static的,且是SpringApplication的嵌套类。这是刻意为之:第三方(包括 Spring Boot 自己的 AOT 处理器)需要在别处抛它。Javadoc 第一句 "silently exit a running SpringApplication without handling run failures" 说清了它的定位------它是一个控制信号,不是错误。 - 无参构造器存在,且
applicationContext可为null。这个分支对应"上下文还没建好就 abandon"的场景(比如某个ApplicationContextFactory返回 null)。 - 它没有
serialVersionUID,也没有填充栈轨迹的优化(super(message)之外没有writableStackTrace参数)。对一个每进程只抛一次的控制流异常,这些都不重要。
2.5 handleRunFailure 里的特判
java
// SpringApplication.java
private RuntimeException handleRunFailure(@Nullable ConfigurableApplicationContext context, Throwable exception,
@Nullable SpringApplicationRunListeners listeners) {
if (exception instanceof AbandonedRunException abandonedRunException) {
return abandonedRunException;
}
try {
try {
handleExitCode(context, exception);
...
第一行就返回。这意味着 AbandonedRunException 不会 触发:退出码计算、listeners.failed(...) 广播、SpringBootExceptionReporter 错误分析、失败分析报告(FailureAnalyzer)、应用上下文关闭。
这是必须的------否则一次成功的 AOT 处理会打印一大堆"启动失败"的诊断信息。注意它用的是 instanceof 的模式匹配(instanceof AbandonedRunException abandonedRunException),JDK 16+ 语法。
同时返回类型是 RuntimeException,返回的就是那个异常本身,调用方(run 方法)会把它继续往外抛,最终被 AotProcessorHook.run 的 catch 接住。整个链路闭合。
待核实 :
handleRunFailure的调用方拿到返回值后是否会再次抛出、还是吞掉,取决于SpringApplication.run的catch块结构。本仓库已确认handleRunFailure首行返回abandonedRunException;调用方行为需结合run(String...)的完整 try/catch 阅读。可自测:在 AOT 处理时开 DEBUG 日志观察是否出现 "Application run failed" 字样------按设计不应该出现。
2.6 getMainMethod:main(String[]) 优先于 main()
java
private static Method getMainMethod(Class<?> application) throws Exception {
try {
return application.getDeclaredMethod("main", String[].class);
}
catch (NoSuchMethodException ex) {
return application.getDeclaredMethod("main");
}
}
顺序是先找 main(String[]) ,找不到再找无参 main()。这对应 JEP 445(隐式声明类的简化 main)------Spring Boot 3.x 起支持没有 String[] 参数的 main。
两个细节:
- 用的是
getDeclaredMethod而非getMethod,所以private static void main(String[])也能被找到(继承的main反而找不到)。随后mainMethod.setAccessible(true)打开访问。 - 如果两种都不存在,第二个
getDeclaredMethod("main")抛NoSuchMethodException。这个异常从prepareApplicationContext直接冒泡,没有包装------错误信息会是原始的NoSuchMethodException,指向应用类。这里可以理解为"配置错误不需要友好提示"。
调用时按参数个数分派:
java
if (mainMethod.getParameterCount() == 0) {
ReflectionUtils.invokeMethod(mainMethod, null);
}
else {
ReflectionUtils.invokeMethod(mainMethod, null, new Object[] { this.applicationArgs });
}
ReflectionUtils.invokeMethod(method, target, args) 内部会 makeAccessible 并把 InvocationTargetException 拆开重抛真实异常------所以如果用户的 main 里抛了异常,AOT 处理看到的是原始异常而不是反射包装。
2.7 SpringApplicationHook 与 withHook:ThreadLocal 的作用域
SpringApplication.withHook(hook, action) 用一个 ThreadLocal 挂载 hook,在 action 执行期间生效、结束后清理。SingleUseSpringApplicationHook(private static final class,内含 private final AtomicBoolean used)把 hook 包一层,保证只被消费一次------getRunListener 每次创建 SpringApplication 都会被调用,而 AOT 只希望第一次生效(应用内部若又创建了 SpringApplication,不该再次抛 AbandonedRunException)。
待核实 :ThreadLocal 字段命名与
SingleUseSpringApplicationHook.getRunListener的完整实现(第二次是否返回 no-op listener)未逐行读取。可自测:写一个main里连续创建两个SpringApplication的样例跑 AOT。
2.8 为什么必须是 GenericApplicationContext
java
Assert.state(context instanceof GenericApplicationContext,
() -> "AOT processing requires a GenericApplicationContext but got a "
+ ((context != null) ? context.getClass().getName() : "null"));
结合 §1.3 第一处:DefaultApplicationContextFactory 在 AOT 模式下本来就返回 GenericApplicationContext,正常情况这个断言不会触发。它真正拦截的是自定义 ApplicationContextFactory ------若通过 spring.factories 注册了自己的工厂并返回 AnnotationConfigApplicationContext,AOT 就在这里失败并打印实际类型。
断言消息里对 null 单独处理,避免 NullPointerException 掩盖真正的诊断信息。这类"错误消息里不能抛出第二个异常"的写法,第 27 章 NestedLocation 那里也见过,是同一条纪律。
2.9 错误分支:应用根本没跑 SpringApplication
java
throw new IllegalStateException(
"No application context available after calling main method of '%s'. Does it run a SpringApplication?"
.formatted(this.application.getName()));
如果 main 正常返回(没有抛 AbandonedRunException),说明这个 main 方法没有启动 Spring 应用------可能是工具类、可能是空实现、可能用了 SpringApplicationBuilder 之外的方式。这里的提示语 "Does it run a SpringApplication?" 直接指向原因。
这也解释了为什么 AotProcessorHook.run 的 try 块没有 catch 其它异常 :如果 main 抛了业务异常,SpringApplication.run 会走 handleRunFailure,而 handleRunFailure 只对 AbandonedRunException 特判,其它异常会走正常失败流程------最终抛出的是原始业务异常。AOT 处理因此失败,错误信息就是应用自己的错误。这是正确的行为:录制一个跑不起来的应用没有意义。
3. 生成的三类产物,与运行期的接管
AOT 处理结束后,target/spring-aot/main/ 下会有三类东西。它们各自有独立的"运行期发现机制",互不依赖。
3.1 第一类:<MainClass>__ApplicationContextInitializer
这是主产物,承载所有 bean 定义的硬编码。命名规则在 SpringApplication 里写死:
java
private void addAotGeneratedInitializerIfNecessary(List<ApplicationContextInitializer<?>> initializers) {
if (AotDetector.useGeneratedArtifacts()) {
List<ApplicationContextInitializer<?>> aotInitializers = new ArrayList<>(
initializers.stream().filter(AotApplicationContextInitializer.class::isInstance).toList());
if (aotInitializers.isEmpty()) {
Assert.state(this.mainApplicationClass != null, "No application main class found");
String initializerClassName = this.mainApplicationClass.getName() + "__ApplicationContextInitializer";
if (!ClassUtils.isPresent(initializerClassName, getClassLoader())) {
throw new AotInitializerNotFoundException(this.mainApplicationClass, initializerClassName);
}
aotInitializers.add(AotApplicationContextInitializer.forInitializerClasses(initializerClassName));
}
initializers.removeAll(aotInitializers);
initializers.addAll(0, aotInitializers);
}
if (NativeDetector.inNativeImage()) {
NativeImageRequirementsException.throwIfNotMet();
}
}
逐段拆。第一段:先找有没有现成的 ------initializers.stream().filter(AotApplicationContextInitializer.class::isInstance) 扫已有列表,看是否已有一个 AOT 初始化器(用户通过 addInitializers(...) 显式加的场景,测试里常见,§9 的 SpringBootTestAotProcessor 走的正这条路)。有就不再按名字猜。第二段:按约定名加载 ------mainApplicationClass.getName() + "__ApplicationContextInitializer",取的是全限定名 (点号形式)而非二进制名,所以 com.example.MyApp → com.example.MyApp__ApplicationContextInitializer。第三段:找不到就抛 AotInitializerNotFoundException------运行期缺产物是致命配置错误 ,必须给可操作信息而非 ClassNotFoundException。
第四段:removeAll 再 addAll(0, ...) 是顺序保证。 ApplicationContextInitializer 的执行顺序就是列表顺序,AOT 初始化器必须在最前------它负责登记所有 bean 定义,其它初始化器可能要访问这些 bean,顺序反了就看到空工厂。当第一段已找到现成实例时,这两行的作用就是"把它挪到队首"。一个 removeAll + addAll(0, ...) 同时覆盖两种语义,写法紧凑。第五段是 native 版本校验,见 §1.4。
3.2 AotInitializerNotFoundException 的错误信息长什么样
本仓库 core/spring-boot/src/main/java/org/springframework/boot/AotInitializerNotFoundException.java。它对应的测试断言是:
java
// SpringApplicationTests.java
assertThatExceptionOfType(AotInitializerNotFoundException.class).isThrownBy(application::run)
.withMessageMatching("^.+AOT initializer .+ could not be found$");
所以消息格式大致是 ...AOT initializer <类名> could not be found。测试用正则而非精确匹配,说明消息前缀可能包含引导建议(比如提示"你是否忘了运行 process-aot")。
待核实 :
AotInitializerNotFoundException的完整构造与消息拼装未在本轮逐行读取。可自测:在 AOT 模式下运行一个没有生成产物的应用,读异常消息全文。
3.3 第二类:<MainClass>__EnvironmentPostProcessor
这是本章 §1.3 第三处提到的产物。它比第一类小得多,而且是可选的(找不到就跳过)。它的生成逻辑是一个极好的"最小完整 AOT contribution"样例,值得全文看一遍:
java
// support/EnvironmentPostProcessorApplicationListener.java
/**
* Contribute a {@code <Application>__EnvironmentPostProcessor} class that stores AOT
* optimizations.
*/
static class EnvironmentBeanFactoryInitializationAotProcessor implements BeanFactoryInitializationAotProcessor {
@Override
public @Nullable BeanFactoryInitializationAotContribution processAheadOfTime(
ConfigurableListableBeanFactory beanFactory) {
Environment environment = beanFactory.getBean(ConfigurableApplicationContext.ENVIRONMENT_BEAN_NAME,
Environment.class);
String[] activeProfiles = environment.getActiveProfiles();
String[] defaultProfiles = environment.getDefaultProfiles();
if (!ObjectUtils.isEmpty(activeProfiles) && !Arrays.equals(activeProfiles, defaultProfiles)) {
return new EnvironmentAotContribution(activeProfiles);
}
return null;
}
}
它解决什么问题 :如果构建时通过 --spring.profiles.active=prod 指定了 profile,那么运行期其实不需要再做 profile 解析------直接把"active profiles 是这些"写死进代码即可。processAheadOfTime 先取 active 与 default,只有当两者不相等 (即确实显式指定了)时才产出 contribution。没指定就返回 null------不生成无用代码。
这是一个通用的 AOT 编写纪律:processAheadOfTime 返回 null 表示"我没什么可优化的" ,框架会跳过。所有 Bean*InitializationAotProcessor 都遵循这个约定。
生成代码的部分:
java
private static final class EnvironmentAotContribution implements BeanFactoryInitializationAotContribution {
private static final String ENVIRONMENT_VARIABLE = "environment";
private final String[] activeProfiles;
@Override
public void applyTo(GenerationContext generationContext,
BeanFactoryInitializationCode beanFactoryInitializationCode) {
GeneratedClass generatedClass = generationContext.getGeneratedClasses()
.addForFeature(AOT_FEATURE_NAME, (type) -> {
type.addModifiers(Modifier.PUBLIC);
type.addJavadoc("Configure the environment with AOT optimizations.");
type.addSuperinterface(EnvironmentPostProcessor.class);
});
generatedClass.getMethods().add("postProcessEnvironment", (method) -> {
method.addModifiers(Modifier.PUBLIC);
method.addAnnotation(Override.class);
method.addParameter(ConfigurableEnvironment.class, ENVIRONMENT_VARIABLE);
method.addParameter(SpringApplication.class, "application");
method.addCode(generateActiveProfilesInitializeCode());
});
generationContext.getRuntimeHints()
.reflection()
.registerType(GeneratedTypeReference.of(generatedClass.getName()),
MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS);
}
private CodeBlock generateActiveProfilesInitializeCode() {
CodeBlock.Builder code = CodeBlock.builder();
for (String activeProfile : this.activeProfiles) {
code.addStatement("$L.addActiveProfile($S)", ENVIRONMENT_VARIABLE, activeProfile);
}
return code.build();
}
}
几个关键点。addForFeature(AOT_FEATURE_NAME, ...) 是生成类的入口,featureName 参与最终类名构造------这正是 <MainClass>__<featureName> 命名规则的来源:传 "EnvironmentPostProcessor" 得到 <MainClass>__EnvironmentPostProcessor,传 "ApplicationContextInitializer" 得到 <MainClass>__ApplicationContextInitializer。同一个机制,两个产物。 代码生成用 JavaPoet (org.springframework.javapoet,Spring 的 fork):$L 字面量替换、$S 字符串字面量替换(自动加引号转义),code.addStatement("$L.addActiveProfile($S)", "environment", "prod") 生成 environment.addActiveProfile("prod");。addSuperinterface(EnvironmentPostProcessor.class) + addAnnotation(Override.class) 让生成的类是合法的 EnvironmentPostProcessor;方法显式带两个参数(ConfigurableEnvironment 与 SpringApplication),这是接口签名要求。
最后三行最该记住 :生成完类之后必须自己注册反射提示------GeneratedTypeReference.of(generatedClass.getName()) + MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS。运行期要用 BeanUtils.instantiateClass 反射实例化它(§1.3 第三处),native image 必须知道它有 public 构造器。"生成代码"和"让生成的代码可达"是两件事,缺一不可 ------忘了的表现是 JVM 上正常、native 下 ClassNotFoundException 或 no such constructor。
3.4 为什么两个产物的"找不到"处理不对称
| 产物 | 找不到时 |
|---|---|
__ApplicationContextInitializer |
抛 AotInitializerNotFoundException,启动失败 |
__EnvironmentPostProcessor |
ClassUtils.isPresent 返回 false,静默跳过 |
理由很清楚:__ApplicationContextInitializer 是 AOT 模式的全部意义所在------没有它,AOT 模式下上下文里一个 bean 都没有,应用必然不可用。静默跳过只会产生一个"启动成功但什么都没干"的诡异状态,不如直接报错。
而 __EnvironmentPostProcessor 只是一次优化 (省掉 profile 解析)。它不存在是正常的------只要构建时没指定 profile,就不会生成(§3.3 的 return null)。静默跳过完全正确。
这是 AOT 设计里一条值得记住的判断标准:产物是"必需品"还是"加速器",决定了它的缺失应该报错还是跳过。
3.5 第三类:资源文件
写到 resourceOutput 目录的东西,会被构建插件 copyAll 到 target/classes(Maven)或并入 resources source set(Gradle),最终进 jar 的 classpath 根。本仓库 main 源码里只有两处用 Kind.RESOURCE 写文件,都在 SpringBootJoranConfigurator(§7):
META-INF/spring/logback-modelMETA-INF/spring/logback-pattern-rules
它们同时会被注册为资源提示(hints.resources().registerPattern(...)),否则 native image 不会把它们打进可执行文件------因为在 native 构建视角下,没有任何 Java 代码"引用"这两个文件(它们是被 getResourceAsStream 按名读的)。
顺带解决了一个容易困惑的问题:为什么这些文件放在 META-INF/spring/ 而不是别的目录? 因为这是一个统一的命名空间,与 META-INF/spring.factories、META-INF/spring/aot.factories、META-INF/spring-configuration-metadata.json 同族。第 27 章讲 loader 时提到 META-INF/native-image/argfile(那是 GraalVM 自己的约定,§10),两者不要混。
4. META-INF/spring/aot.factories:AOT 的 SPI
4.1 四个键
META-INF/spring/aot.factories 是 AOT 阶段的 spring.factories。本仓库一共 14 个模块有这个文件(另有 1 个在集成测试项目里),用到的键共四个:
| 键 | 作用 | 生命周期 |
|---|---|---|
org.springframework.aot.hint.RuntimeHintsRegistrar |
声明式补充运行时提示(反射/资源/序列化/代理) | AOT 处理期 |
org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor |
在整个 bean 工厂层面做贡献 | AOT 处理期 |
org.springframework.beans.factory.aot.BeanRegistrationAotProcessor |
针对单个 bean 定义做贡献 | AOT 处理期 |
org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter |
把某些 bean 排除在 AOT 代码生成之外 | AOT 处理期 |
注意这四个键全部来自 org.springframework.* ,没有一个 Spring Boot 自己的接口。AOT 是 Spring Framework 6 的能力,Spring Boot 只是最大的使用方。这也意味着:任何 Spring 应用(非 Boot)都可以写 aot.factories。
第四个键 BeanRegistrationExcludeFilter 是唯一"反向"的,本仓库两处使用:
ini
# core/spring-boot-autoconfigure
org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter=\
org.springframework.boot.autoconfigure.SharedMetadataReaderFactoryContextInitializer
# core/spring-boot-testcontainers
org.springframework.beans.factory.aot.BeanRegistrationExcludeFilter=\
org.springframework.boot.testcontainers.service.connection.ConnectionDetailsRegistrar$ServiceConnectionBeanRegistrationExcludeFilter
SharedMetadataReaderFactoryContextInitializer 被排除是典型的:它注册的是一个 BeanFactoryPostProcessor/初始化器性质的 bean,在 AOT 模式下它的存在意义(共享 MetadataReaderFactory 缓存以加速扫描)已经消失------因为根本没有扫描了。为它生成代码只会产生无用字节。
4.2 14 个模块的实际内容
把本仓库所有 aot.factories 摊开看,能直接读出 AOT 覆盖面:
ruby
core/spring-boot(三个键都有)
RuntimeHintsRegistrar: ApplicationProperties$...RuntimeHints, SpringApplicationBannerPrinter$...RuntimeHints,
WebApplicationType$...RuntimeHints, ConfigDataLocationRuntimeHints, ConfigDataPropertiesRuntimeHints,
PropertySourceRuntimeHints, JavaLoggingSystemRuntimeHints, Log4J2RuntimeHints, LogbackRuntimeHints,
ElasticCommonSchemaProperties$...RuntimeHints, GraylogExtendedLogFormatProperties$...RuntimeHints,
StructuredLoggingJsonProperties$...RuntimeHints, JavaVersion$Hints
BeanFactoryInitializationAotProcessor: ConfigurationPropertiesBeanFactoryInitializationAotProcessor,
StructuredLoggingJsonPropertiesBeanFactoryInitializationAotProcessor,
EnvironmentPostProcessorApplicationListener$EnvironmentBeanFactoryInitializationAotProcessor
BeanRegistrationAotProcessor: ConfigurationPropertiesBeanRegistrationAotProcessor
core/spring-boot-autoconfigure
BeanFactoryInitializationAotProcessor: ConditionEvaluationReportLoggingProcessor
BeanRegistrationExcludeFilter: SharedMetadataReaderFactoryContextInitializer
RuntimeHintsRegistrar: TemplateRuntimeHints
module/spring-boot-jackson
RuntimeHintsRegistrar: JacksonAutoConfiguration$JacksonAutoConfigurationRuntimeHints
BeanFactoryInitializationAotProcessor: JacksonComponentModule$JacksonComponentBeanFactoryInitializationAotProcessor
BeanRegistrationAotProcessor: JacksonMixinModuleEntriesBeanRegistrationAotProcessor
module/spring-boot-flyway BeanRegistrationAotProcessor: ResourceProviderCustomizerBeanRegistrationAotProcessor
module/spring-boot-http-client RuntimeHintsRegistrar: ClientHttpRequestFactoryRuntimeHints
module/spring-boot-webmvc RuntimeHintsRegistrar: WebMvcWebApplicationTypeDeducer$...RuntimeHints
module/spring-boot-micrometer-metrics RuntimeHintsRegistrar: ServiceLevelObjectiveBoundary$...Hints
其余(freemarker / groovy-templates / jdbc / webflux / testcontainers)都是一到两条 RuntimeHintsRegistrar 或 BeanRegistrationExcludeFilter。
显著规律:大量 registrar 写成宿主类的静态嵌套类 ,命名统一是 <宿主><后缀>(如 WebApplicationType$WebApplicationTypeRuntimeHints、ServiceLevelObjectiveBoundary$ServiceLevelObjectiveBoundaryHints)。好处是 registrar 与所服务的类同文件,改宿主类的反射需求能立刻看到旁边的 hint,且可用宿主类的 private 成员与包级常量。
4.3 构建期校验:CheckAotFactories
这是本仓库最值得学习的基础设施。写错类名要到 AOT 处理运行时才炸------Spring Boot 把检查提前到了 gradle check。
java
// buildSrc/.../springframework/CheckAotFactories.java
public abstract class CheckAotFactories extends CheckFactoriesFile {
public CheckAotFactories() {
super("META-INF/spring/aot.factories");
}
}
注册点在 JavaConventions.configureFactoriesFilesChecks:task.setSource(main.getResources()) + task.setClasspath(main.getOutput().getClassesDirs()) + check.dependsOn(checkAotFactories)。只挂 main source set ,但 check 依赖它------每次 gradle build 都会跑。
4.4 校验器的三条规则
CheckFactoriesFile.execute → check(File):
java
private void check(File factoriesFile) {
Properties properties = load(factoriesFile);
Map<String, List<String>> problems = new LinkedHashMap<>();
for (String name : properties.stringPropertyNames()) {
String value = properties.getProperty(name);
List<String> classNames = Arrays.asList(StringUtils.commaDelimitedListToStringArray(value));
collectProblems(problems, name, classNames);
List<String> sortedValues = new ArrayList<>(classNames);
Collections.sort(sortedValues);
if (!sortedValues.equals(classNames)) {
List<String> problemsForClassName = problems.computeIfAbsent(name, (k) -> new ArrayList<>());
problemsForClassName.add("Entries should be sorted alphabetically");
}
}
File outputFile = getOutputDirectory().file("failure-report.txt").get().getAsFile();
writeReport(factoriesFile, problems, outputFile);
if (!problems.isEmpty()) {
throw new VerificationException("%s check failed. See '%s' for details".formatted(this.path, outputFile));
}
}
规则一:类必须存在。 collectProblems → find(className):
java
private boolean find(String className) {
for (File root : this.classpath.getFiles()) {
String classFilePath = className.replace(".", "/") + ".class";
if (new File(root, classFilePath).isFile()) {
return true;
}
}
return false;
}
用 classpath 里找 .class 文件 的方式验证,而不是 Class.forName。这避免了加载类带来的副作用(静态初始化、依赖缺失),也避免了把构建器的 classpath 污染进来。
规则二:嵌套类必须用二进制名(Outer$Inner)。
java
private void addNoFoundProblem(String className, List<String> problemsForClassName) {
String binaryName = binaryNameOf(className);
boolean foundBinaryForm = find(binaryName);
problemsForClassName.add(!foundBinaryForm ? "'%s' was not found".formatted(className)
: "'%s' should be listed using its binary name '%s'".formatted(className, binaryName));
}
private String binaryNameOf(String className) {
int lastDotIndex = className.lastIndexOf('.');
return className.substring(0, lastDotIndex) + "$" + className.substring(lastDotIndex + 1);
}
binaryNameOf 把最后一个点 换成 $------对应 §4.2 的命名现象:嵌套类在 factories 里必须写 Outer$Inner,不能写 Outer.Inner(后者 Class.forName 也失败)。错误消息设计得好:先试二进制名,能找到就说"你应该用二进制名 X";两个都找不到才说"没找到"------区分"写错形式"与"根本不存在",诊断效率完全不同。
规则三:值必须按字母序 (sortedValues.equals(classNames))。§4.2 里每个键下的类列表是排好序的,不是巧合、是 CI 强制。有了这条,多人改同一文件时不会产生无意义 diff 冲突。
报告输出 :writeReport 写 failure-report.txt 再抛 VerificationException 指向该文件。错误信息给报告路径而非内联所有问题------问题可能很多,内联会让 Gradle 输出不可读。
5. RuntimeHints 的注册方式谱系
本仓库 main 源码里有 72 个 RuntimeHintsRegistrar 实现(不含测试),49 处 @ImportRuntimeHints 注解。数量本身说明了一件事:在 native 世界里,"告诉 GraalVM 什么需要保留"是应用框架的主要工作量之一。
RuntimeHintsRegistrar 接口只有两个方法参数:
java
void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader);
hints 有四个子空间:hints.reflection()、hints.resources()、hints.serialization()、hints.proxies()。classLoader 可为 null ------这是刻意的,因为很多 registrar 需要判断"某个可选依赖在不在",而判断需要 classloader。所有实现都必须处理 null。
按实现手法,72 个可以分成五类。下面每类给一个真实源码。
5.1 第一类:常量型------注册一个静态已知的资源
最简单的形态,一行:
java
// SpringApplicationBannerPrinter.java
static class SpringApplicationBannerPrinterRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
hints.resources().registerPattern(DEFAULT_BANNER_LOCATION);
}
}
DEFAULT_BANNER_LOCATION 就是 banner.txt。native image 默认只保留编译期能被静态追踪到的资源;banner.txt 是靠 getResourceAsStream("banner.txt") 读的,字符串常量追踪不到,必须显式注册。
注意它是 static class(包级可见,非 public),且是 SpringApplicationBannerPrinter 的嵌套类。这也解释了 §4.2 的命名规律。
5.2 第二类:条件型------可选依赖存在才注册
Logback 是典型(Logback 是可选依赖,可能不存在):
java
// logging/logback/LogbackRuntimeHints.java
class LogbackRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
if (!ClassUtils.isPresent("ch.qos.logback.classic.LoggerContext", classLoader)) {
return;
}
ReflectionHints reflection = hints.reflection();
registerHintsForLogbackLoggingSystemTypeChecks(reflection, classLoader);
registerHintsForBuiltInLogbackConverters(reflection);
registerHintsForSpringBootConverters(reflection);
}
private void registerHintsForLogbackLoggingSystemTypeChecks(ReflectionHints reflection,
@Nullable ClassLoader classLoader) {
reflection.registerType(LoggerContext.class);
reflection.registerTypeIfPresent(classLoader, "org.slf4j.bridge.SLF4JBridgeHandler", (typeHint) -> {
});
}
private void registerHintsForBuiltInLogbackConverters(ReflectionHints reflection) {
registerForPublicConstructorInvocation(reflection, DateTokenConverter.class, IntegerTokenConverter.class,
SyslogStartConverter.class);
}
private void registerHintsForSpringBootConverters(ReflectionHints reflection) {
registerForPublicConstructorInvocation(reflection, ColorConverter.class,
EnclosedInSquareBracketsConverter.class, ExtendedWhitespaceThrowableProxyConverter.class,
WhitespaceThrowableProxyConverter.class, CorrelationIdConverter.class);
}
private void registerForPublicConstructorInvocation(ReflectionHints reflection, Class<?>... classes) {
reflection.registerTypes(TypeReference.listOf(classes),
(hint) -> hint.withMembers(MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS));
}
}
四个细节。守卫用字符串类名 "ch.qos.logback.classic.LoggerContext" 而非 LoggerContext.class:本文件已 import LoggerContext.class,若直接用它判断,一旦 Logback 不在 classpath,加载 LogbackRuntimeHints 本身就 NoClassDefFoundError(import 会被解析)。字符串 + ClassUtils.isPresent 规避了这点------可选依赖场景的标准写法 。registerTypeIfPresent(classLoader, "...SLF4JBridgeHandler", (typeHint) -> { }) 的 lambda 体是空的,意即"这个类存在就注册进来(默认成员类别)",空 lambda 是此 API 的惯用法。注册了 Spring Boot 自己的 5 个 Converter (ColorConverter、EnclosedInSquareBracketsConverter、ExtendedWhitespaceThrowableProxyConverter、WhitespaceThrowableProxyConverter、CorrelationIdConverter)------它们在 logback.xml 里通过 <conversionRule> 按类名配置,Logback 会反射 newInstance()。MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS 是最小权限 选择:这些 Converter 只需被 new。成员类别越窄,native image 保留的元数据越少、体积越小、启动越快------对比 §7 的 INVOKE_PUBLIC_METHODS + INVOKE_PUBLIC_CONSTRUCTORS 双注册(那里需要 setter 注入)。
5.3 第三类:绑定型------把配置属性的绑定需求翻译成反射提示
@ConfigurationProperties 是最容易被 native 打断的功能,因为它靠反射调 setter 或构造器。Spring Boot 的对策是一个专门的 BeanFactoryInitializationAotProcessor:
java
// context/properties/ConfigurationPropertiesBeanFactoryInitializationAotProcessor.java
class ConfigurationPropertiesBeanFactoryInitializationAotProcessor implements BeanFactoryInitializationAotProcessor {
@Override
public @Nullable ConfigurationPropertiesReflectionHintsContribution processAheadOfTime(
ConfigurableListableBeanFactory beanFactory) {
String[] beanNames = beanFactory.getBeanNamesForAnnotation(ConfigurationProperties.class);
List<Bindable<?>> bindables = new ArrayList<>();
for (String beanName : beanNames) {
Class<?> beanType = beanFactory.getType(beanName, false);
if (beanType != null) {
BindMethod bindMethod = beanFactory.containsBeanDefinition(beanName)
? (BindMethod) beanFactory.getBeanDefinition(beanName).getAttribute(BindMethod.class.getName())
: null;
bindables.add(Bindable.of(ClassUtils.getUserClass(beanType))
.withBindMethod((bindMethod != null) ? bindMethod : BindMethod.JAVA_BEAN));
}
}
return (!bindables.isEmpty()) ? new ConfigurationPropertiesReflectionHintsContribution(bindables) : null;
}
}
逐步拆。getBeanNamesForAnnotation(ConfigurationProperties.class) 拿所有带注解的 bean 名------依赖 bean 定义已登记完毕,所以此处理器必须在工厂准备好后运行。getType(beanName, false) 第二个参数 false 表示允许 FactoryBean 本身而非其产品类型 ;返回 null 就跳过。BindMethod 从 bean definition 的 attribute 取 :getAttribute(BindMethod.class.getName())------BindMethod(JAVA_BEAN / VALUE_OBJECT)在绑定阶段已算好并存进 attribute,AOT 直接复用,不必再算一遍 ,也保证与运行期判定一致;缺省 JAVA_BEAN 兜底。ClassUtils.getUserClass(beanType) 去掉 CGLIB 代理前缀(Foo$$SpringCGLIB$$0 → Foo)------注册代理类的话 native image 里根本没有这个类,漏了就直接崩。最后返回 null 表示无可贡献(§3.3 的纪律)。
贡献本身:
java
static final class ConfigurationPropertiesReflectionHintsContribution
implements BeanFactoryInitializationAotContribution {
private final List<Bindable<?>> bindables;
@Override
public void applyTo(GenerationContext generationContext,
BeanFactoryInitializationCode beanFactoryInitializationCode) {
BindableRuntimeHintsRegistrar.forBindables(this.bindables)
.registerHints(generationContext.getRuntimeHints());
}
Iterable<Bindable<?>> getBindables() {
return this.bindables;
}
}
真正干脏活的是 BindableRuntimeHintsRegistrar(org.springframework.boot.context.properties.bind):递归遍历 Bindable 的类型图,为每个属性类型注册 setter/构造器/getter。getBindables() 是包级访问器,供测试断言用------本仓库反复出现的模式(第 25 章 Actuator 见过同款)。
待核实 :
BindableRuntimeHintsRegistrar的递归规则(泛型、集合元素、嵌套类、接口如何处理)本轮未逐行读取。可自测:写一个带嵌套@ConfigurationProperties与List<Foo>属性的类,跑 AOT 后检查reflect-config.json里是否出现Foo。
5.4 第四类:手工反射型------注册具体方法
有时需要的不是"整个类的成员",而是"某一特定方法"。ConfigDataPropertiesRuntimeHints:
java
class ConfigDataPropertiesRuntimeHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
BindableRuntimeHintsRegistrar.forTypes(ConfigDataProperties.class).registerHints(hints);
Method method = ReflectionUtils.findMethod(ConfigDataLocation.class, "of", String.class);
Assert.state(method != null, "'method' must not be null");
hints.reflection().registerMethod(method, ExecutableMode.INVOKE);
}
}
第一行走 §5.3 那条路,但用 forTypes(Class) 而非 forBindables(...)(不需 Bindable 包装)。重点是后三行:ConfigDataLocation.of(String) 是静态工厂方法 ,运行期通过反射调用;ReflectionUtils.findMethod 返回 @Nullable,故用 Assert.state 把"找不到"变成构建期失败------方法被重命名时 AOT 处理立刻失败,而不是等 native 运行才崩 ;ExecutableMode.INVOKE 表示"要能调用"。
这体现了 AOT 的隐藏价值:把运行期的反射失败提前到构建期 。Assert.state 在 AOT 处理时执行,构建机上就会红。
5.5 第五类:组合型------用现成的 registrar 拼出复杂规则
ConfigDataLocationRuntimeHints 是最复杂的一个,它要回答"application-*.yml / .properties 可能在哪些路径、叫什么名字、什么后缀":
java
class ConfigDataLocationRuntimeHints implements RuntimeHintsRegistrar {
private static final Log logger = LogFactory.getLog(ConfigDataLocationRuntimeHints.class);
@Override
public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
List<String> fileNames = getFileNames(classLoader);
List<String> locations = getLocations(classLoader);
List<String> extensions = getExtensions(classLoader);
if (logger.isDebugEnabled()) {
logger.debug("Registering application configuration hints for " + fileNames + "(" + extensions + ") at "
+ locations);
}
FilePatternResourceHintsRegistrar.forClassPathLocations(locations)
.withFilePrefixes(fileNames)
.withFileExtensions(extensions)
.registerHints(hints.resources(), classLoader);
}
三个维度各自独立计算,最后交给 FilePatternResourceHintsRegistrar 做笛卡尔积展开成资源模式。
文件名 直接复用常量:return Arrays.asList(StandardConfigDataLocationResolver.DEFAULT_CONFIG_NAMES);------DEFAULT_CONFIG_NAMES 就是 application。这里没有硬编码 "application" 字符串,引用的是解析器的常量,保证 AOT 注册的名字与真实解析用的名字永远一致。与第 24 章日志那边的纪律同一条:常量单点定义。
位置 来自 ConfigDataEnvironment.DEFAULT_SEARCH_LOCATIONS,但要过滤:
java
protected List<String> getLocations(@Nullable ClassLoader classLoader) {
List<String> classpathLocations = new ArrayList<>();
for (ConfigDataLocation candidate : ConfigDataEnvironment.DEFAULT_SEARCH_LOCATIONS) {
for (ConfigDataLocation configDataLocation : candidate.split()) {
String location = configDataLocation.getValue();
if (location.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) {
classpathLocations.add(location);
}
}
}
return classpathLocations;
}
双重循环:外层遍历搜索位置,内层 candidate.split() 展开(optional:classpath:/;optional:file:./ 这类多位置会被拆开)。然后只保留 classpath: 前缀的------FilePatternResourceHintsRegistrar.forClassPathLocations 只处理 classpath 位置,file: 的配置文件本来就该在外部,不进 native image。
扩展名来自 SPI:
java
protected List<String> getExtensions(@Nullable ClassLoader classLoader) {
List<String> extensions = new ArrayList<>();
List<PropertySourceLoader> propertySourceLoaders = getSpringFactoriesLoader(classLoader)
.load(PropertySourceLoader.class);
for (PropertySourceLoader propertySourceLoader : propertySourceLoaders) {
for (String fileExtension : propertySourceLoader.getFileExtensions()) {
String candidate = "." + fileExtension;
if (!extensions.contains(candidate)) {
extensions.add(candidate);
}
}
}
return extensions;
}
protected SpringFactoriesLoader getSpringFactoriesLoader(@Nullable ClassLoader classLoader) {
return SpringFactoriesLoader.forDefaultResourceLocation(classLoader);
}
这是本节最值得学的一招 :扩展名不写死 yml/yaml/properties,而是遍历 PropertySourceLoader SPI 问每个实现"你支持什么后缀"。于是只要你引入了一个自定义的 PropertySourceLoader(比如支持 TOML 的),AOT 注册的资源模式自动 包含 .toml,不需要改任何 hint 代码。
getSpringFactoriesLoader 被单独抽成 protected 方法,是为了让子类(测试里常见)能替换成固定的 loader 列表------这是本仓库为可测试性留的缝,与 §5.3 的 getBindables() 是同一种手法。
注意 extensions.contains(candidate) 的去重是 O(n²),但 n 通常是个位数,无所谓。
5.6 第六类:存在性过滤型------只注册真的存在的资源
WebResourcesRuntimeHints(唯一一个 public 的 registrar,因为要被 WebMvc/WebFlux 两侧通过 @ImportRuntimeHints 引用):
java
public class WebResourcesRuntimeHints implements RuntimeHintsRegistrar {
private static final List<String> DEFAULT_LOCATIONS = List.of("META-INF/resources/", "resources/", "static/",
"public/");
@Override
public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
ClassLoader classLoaderToUse = (classLoader != null) ? classLoader : getClass().getClassLoader();
String[] locations = DEFAULT_LOCATIONS.stream()
.filter((candidate) -> classLoaderToUse.getResource(candidate) != null)
.map((location) -> location + "**")
.toArray(String[]::new);
if (locations.length > 0) {
hints.resources().registerPattern((hint) -> hint.includes(locations));
}
}
}
三点:
classLoader的 null 兜底 :(classLoader != null) ? classLoader : getClass().getClassLoader()。这是所有 registrar 处理 nullable classloader 的标准姿势。getResource(candidate) != null过滤 :只有目录真实存在才注册。Spring Boot 应用通常只有static/,那就只注册static/**。别小看这一步------资源模式越多,native image 构建时扫描 classpath 的成本越高,而且**模式会让 GraalVM 保留更多文件。if (locations.length > 0):一个都不存在就不注册。空数组传给includes(...)可能产生一个匹配空集的模式,不如干脆不注册。hint.includes(locations)用 lambda 形式 的registerPattern(Consumer<ResourceHints.Builder>)------因为要一次注册多个模式。单模式时用registerPattern(String)。
这四个静态位置 META-INF/resources/、resources/、static/、public/ 就是 Spring Boot 默认的静态资源目录,与第 20/21 章讲的 WebFlux/WebMvc 资源处理是同一份常量来源。
5.7 谱系小结
| 类型 | 代表 | 关键手法 |
|---|---|---|
| 常量型 | SpringApplicationBannerPrinterRuntimeHints |
一行 registerPattern(常量) |
| 条件型 | LogbackRuntimeHints |
字符串 isPresent 守卫 + registerTypeIfPresent |
| 绑定型 | ConfigurationPropertiesBeanFactoryInitializationAotProcessor |
Bindable + BindableRuntimeHintsRegistrar |
| 手工反射型 | ConfigDataPropertiesRuntimeHints |
ReflectionUtils.findMethod + Assert.state + ExecutableMode.INVOKE |
| 组合型 | ConfigDataLocationRuntimeHints |
FilePatternResourceHintsRegistrar + SPI 枚举扩展名 |
| 存在性过滤型 | WebResourcesRuntimeHints |
getResource(...) != null 过滤后再注册 |
贯穿这六类的三条纪律:可选依赖用字符串判断 、常量从真实使用方引用而非重写 、能不注册就不注册。
6. BeanRegistrationAotProcessor:直接改写某个 bean 的实例化代码
6.1 两类 AOT 处理器的分工
BeanFactoryInitializationAotProcessor |
BeanRegistrationAotProcessor |
|
|---|---|---|
| 输入 | ConfigurableListableBeanFactory(整个工厂) |
RegisteredBean(单个 bean) |
| 输出 | BeanFactoryInitializationAotContribution |
BeanRegistrationAotContribution |
| 典型用途 | 注册 hints、写资源文件、生成环境后处理器 | 改写这个 bean 的实例化代码 |
| 本仓库数量 | 5 | 4 |
BeanFactoryInitializationAotProcessor 是"横切"的:看全局,适合做 hints 汇总(§5.3 扫全工厂找 @ConfigurationProperties)。BeanRegistrationAotProcessor 是"定点"的:框架对每个 bean 定义都调一次 processAheadOfTime(RegisteredBean),返回 null 表示"这个 bean 不用特殊处理"。
Spring Boot 应用通常有几百个 bean 定义,processAheadOfTime 会被调几百次。这解释了一个设计细节:它必须能尽快返回 null 。本仓库全部四个实现的开头都是一个 equals 或 isXxx 判断(if (!isImmutableConfigurationPropertiesBeanDefinition(...)) return null; / if (registeredBean.getBeanClass().equals(Xxx.class)) {...} return null;),没有一个是先做重活再判断的。
6.2 withCustomCodeFragments:装饰器模式
四个实现里三个用同一手法:BeanRegistrationAotContribution.withCustomCodeFragments((codeFragments) -> new XxxCodeFragments(codeFragments, registeredBean))。它接受 Function<BeanRegistrationCodeFragments, BeanRegistrationCodeFragments>:传入框架生成的默认代码片段 ,返回包了一层的装饰器;装饰器继承 BeanRegistrationCodeFragmentsDecorator,只覆写需要改的方法,其余委托 super。
好处是 AOT 代码生成本身很复杂(作用域、依赖注入、生命周期回调、代理判断......),实现者不必重写这套逻辑,只替换"生成实例供应商"这一小块。被覆写得最多的正是 generateInstanceSupplierCode------三个实现都覆写了它。
6.3 ConfigurationPropertiesBeanRegistrationAotProcessor:不可变绑定
java
// context/properties/ConfigurationPropertiesBeanRegistrationAotProcessor.java
/**
* {@link BeanRegistrationAotProcessor} for immutable configuration properties.
*/
class ConfigurationPropertiesBeanRegistrationAotProcessor implements BeanRegistrationAotProcessor {
@Override
public @Nullable BeanRegistrationAotContribution processAheadOfTime(RegisteredBean registeredBean) {
if (!isImmutableConfigurationPropertiesBeanDefinition(registeredBean.getMergedBeanDefinition())) {
return null;
}
return BeanRegistrationAotContribution.withCustomCodeFragments(
(codeFragments) -> new ConfigurationPropertiesBeanRegistrationCodeFragments(codeFragments,
registeredBean));
}
private boolean isImmutableConfigurationPropertiesBeanDefinition(BeanDefinition beanDefinition) {
return BindMethod.VALUE_OBJECT.equals(BindMethodAttribute.get(beanDefinition));
}
注意它读的是 registeredBean.getMergedBeanDefinition()(合并后的定义,包含父定义继承来的 attribute),而 §5.3 的工厂级处理器读的是 beanFactory.getBeanDefinition(beanName)。两者语义不同但指向同一个 attribute:BindMethod。
判定条件是 BindMethod.VALUE_OBJECT------只有构造器绑定(@ConstructorBinding)的不可变配置属性才需要这个处理器。JAVA_BEAN 方式的属性靠 §5.3 注册的 setter 反射就够了,不需要改写实例化代码。
装饰器覆写了三个方法:
java
@Override
public CodeBlock generateSetBeanDefinitionPropertiesCode(GenerationContext generationContext,
BeanRegistrationCode beanRegistrationCode, RootBeanDefinition beanDefinition,
Predicate<String> attributeFilter) {
return super.generateSetBeanDefinitionPropertiesCode(generationContext, beanRegistrationCode,
beanDefinition, attributeFilter.or(BindMethodAttribute.NAME::equals));
}
@Override
public ClassName getTarget(RegisteredBean registeredBean) {
return ClassName.get(this.registeredBean.getBeanClass());
}
@Override
public CodeBlock generateInstanceSupplierCode(GenerationContext generationContext,
BeanRegistrationCode beanRegistrationCode, boolean allowDirectSupplierShortcut) {
GeneratedMethod generatedMethod = beanRegistrationCode.getMethods().add("getInstance", (method) -> {
Class<?> beanClass = this.registeredBean.getBeanClass();
method.addJavadoc("Get the bean instance for '$L'.", this.registeredBean.getBeanName())
.addModifiers(Modifier.PRIVATE, Modifier.STATIC)
.returns(beanClass)
.addParameter(RegisteredBean.class, REGISTERED_BEAN_PARAMETER_NAME)
.addStatement("$T beanFactory = registeredBean.getBeanFactory()", BeanFactory.class)
.addStatement("$T beanName = registeredBean.getBeanName()", String.class)
.addStatement("$T<?> beanClass = registeredBean.getBeanClass()", Class.class)
.addStatement("return ($T) $T.from(beanFactory, beanName, beanClass)", beanClass,
ConstructorBound.class);
});
return CodeBlock.of("$T.of($T::$L)", InstanceSupplier.class, beanRegistrationCode.getClassName(),
generatedMethod.getName());
}
第一个覆写最微妙。 generateSetBeanDefinitionPropertiesCode 负责把 bean definition 的 attribute 写进生成代码。它调用 super 时用 attributeFilter.or(BindMethodAttribute.NAME::equals) 把过滤条件扩了一下------效果是把 BindMethod 这个 attribute 也写进生成的 bean definition 。因为运行期(AOT 模式)拿到这个 bean 定义后仍需要知道它是 VALUE_OBJECT 绑定的;不写就得重新跑一遍类型分析(构造器数量、参数名可获取性等),那正是 AOT 想省掉的工作。用 Predicate.or(...) 而非替换,是因为框架传进来的 attributeFilter 本身也有要保留的属性(scope、lazy-init 等),不能覆盖。
第二个覆写 getTarget 返回 ClassName.get(this.registeredBean.getBeanClass())。这里用的是字段捕获的 registeredBean ,不是方法参数 registeredBean(两者同名,靠 this. 区分),容易读错。
第三个覆写 生成 private static <BeanType> getInstance(RegisteredBean),方法体是 BeanFactory beanFactory = registeredBean.getBeanFactory(); ... return (MyProps) ConstructorBound.from(beanFactory, beanName, beanClass);,然后返回 InstanceSupplier.of(Generated::getInstance)。
关键点:生成的代码不是 new MyProps(...),而是调用 ConstructorBound.from(...) 。构造器绑定需要 Environment、参数名、转换服务------这些是运行期才完整存在的东西;若生成 new MyProps(name, port),值就被硬编码、配置"死"在构建期了。ConstructorBound.from(...) 复用完整的构造器绑定逻辑,只跳过"判定用哪种绑定方式"。
这是一条重要的 AOT 原则 :生成代码的目标是省掉"决策",不是省掉"执行"。配置值仍在运行时读取,只是"怎么读"被提前定死。
6.4 JacksonMixinModuleEntriesBeanRegistrationAotProcessor:把扫描结果写成字面量
Jackson 的 @JsonMixin 靠扫描声明 mixin,native 下不可行(classpath 静态、GraalVM 不保留未引用资源)。对策见类 Javadoc:"replaces any JacksonMixinModuleEntries with a hard-coded equivalent. This has the effect of disabling scanning at runtime"。
java
@Override
public CodeBlock generateInstanceSupplierCode(GenerationContext generationContext,
BeanRegistrationCode beanRegistrationCode, boolean allowDirectSupplierShortcut) {
JacksonMixinModuleEntries entries = this.registeredBean.getBeanFactory()
.getBean(this.registeredBean.getBeanName(), JacksonMixinModuleEntries.class);
contributeHints(generationContext.getRuntimeHints(), entries);
GeneratedMethod generatedMethod = beanRegistrationCode.getMethods().add("getInstance", (method) -> {
method.addJavadoc("Get the bean instance for '$L'.", this.registeredBean.getBeanName());
method.addModifiers(Modifier.PRIVATE, Modifier.STATIC);
method.returns(BEAN_TYPE);
CodeBlock.Builder code = CodeBlock.builder();
code.add("return $T.create(", JacksonMixinModuleEntries.class).beginControlFlow("(mixins) ->");
entries.doWithEntry(this.classLoader, (type, mixin) -> addEntryCode(code, type, mixin));
code.endControlFlow(")");
method.addCode(code.build());
});
return generatedMethod.toMethodReference().toCodeBlock();
}
第一行就调用了 getBean(...)------在 AOT 处理进程里真的实例化了这个 bean。这与 §2"录制一次真实启动"完全一致:要生成硬编码的 mixin 列表,就得先把真实列表算出来,而唯一可靠方式是真的跑一遍。生成的代码形态:
java
return JacksonMixinModuleEntries.create((mixins) -> {
mixins.and(com.example.Foo.class, com.example.FooMixin.class);
mixins.and(com.example.Bar.class, com.example.BarMixin.class);
});
addEntryCode 有两种写法:
java
private void addEntryCode(CodeBlock.Builder code, Class<?> type, Class<?> mixin) {
AccessControl accessForTypes = AccessControl.lowest(AccessControl.forClass(type),
AccessControl.forClass(mixin));
if (accessForTypes.isPublic()) {
code.addStatement("$L.and($T.class, $T.class)", "mixins", type, mixin);
}
else {
code.addStatement("$L.and($S, $S)", "mixins", type.getName(), mixin.getName());
}
}
AccessControl.lowest(a, b) 取两者中可见性更低 的那个。都 public 就生成 mixins.and(Foo.class, FooMixin.class)------直接用 Class 字面量,编译期可查类型;任一非 public 则生成 mixins.and("com.example.Foo", "com.example.FooMixin")------用字符串类名。
为什么非 public 要用字符串?生成的 AOT 类在另一个包里,没有权限引用 包级私有类,写 Foo.class 会导致生成代码编译不过;字符串绕过编译期访问检查,运行期由 Class.forName 解析。这是 JavaPoet 代码生成的通用陷阱:生成代码的"编译环境"与手写代码不同,访问权限必须显式计算 。本仓库用 AccessControl 抽象把这事标准化了。
hint 的贡献:
java
private void contributeHints(RuntimeHints runtimeHints, JacksonMixinModuleEntries entries) {
Set<Class<?>> mixins = new LinkedHashSet<>();
entries.doWithEntry(this.classLoader, (type, mixin) -> mixins.add(mixin));
new BindingReflectionHintsRegistrar().registerReflectionHints(runtimeHints.reflection(),
mixins.toArray(Class<?>[]::new));
}
它只注册 mixin 类 (mixins.add(mixin)),不注册被 mixin 的目标类------Jackson 只需反射访问 mixin 的注解与方法签名,目标类的反射需求由业务代码的 hint 负责。用 LinkedHashSet 保证顺序确定(reflect-config.json 顺序稳定,diff 友好)。classLoader 在构造器里缓存成字段,因为 generateInstanceSupplierCode 在稍后的生成阶段才被调用。
6.5 ResourceProviderCustomizerBeanRegistrationAotProcessor:整类替换
Flyway 的迁移脚本发现机制依赖 classpath 扫描,native 下完全失效。Spring Boot 的对策最激进------把 bean 换成一个完全不同的实现:
java
// module/spring-boot-flyway/.../ResourceProviderCustomizerBeanRegistrationAotProcessor.java
/**
* Replaces the {@link ResourceProviderCustomizer} bean with a
* {@link NativeImageResourceProviderCustomizer} bean.
*/
class ResourceProviderCustomizerBeanRegistrationAotProcessor implements BeanRegistrationAotProcessor {
@Override
public @Nullable BeanRegistrationAotContribution processAheadOfTime(RegisteredBean registeredBean) {
if (registeredBean.getBeanClass().equals(ResourceProviderCustomizer.class)) {
return BeanRegistrationAotContribution
.withCustomCodeFragments((codeFragments) -> new AotContribution(codeFragments, registeredBean));
}
return null;
}
private static class AotContribution extends BeanRegistrationCodeFragmentsDecorator {
...
@Override
public CodeBlock generateInstanceSupplierCode(GenerationContext generationContext,
BeanRegistrationCode beanRegistrationCode, boolean allowDirectSupplierShortcut) {
GeneratedMethod generatedMethod = beanRegistrationCode.getMethods().add("getInstance", (method) -> {
method.addJavadoc("Get the bean instance for '$L'.", this.registeredBean.getBeanName());
method.addModifiers(Modifier.PRIVATE, Modifier.STATIC);
method.returns(NativeImageResourceProviderCustomizer.class);
CodeBlock.Builder code = CodeBlock.builder();
code.addStatement("return new $T()", NativeImageResourceProviderCustomizer.class);
method.addCode(code.build());
});
return generatedMethod.toMethodReference().toCodeBlock();
}
}
}
生成的返回类型写的是 NativeImageResourceProviderCustomizer(子类),方法体就是 return new NativeImageResourceProviderCustomizer();。没有参数、没有依赖、没有反射 ------最彻底的 AOT 形态:运行期零成本。这里没覆写 getTarget(前两个实现覆写了),推测原因是返回值类型的协变已足够,不需额外指定目标类型。
待核实 :
getTarget默认行为与覆写后对生成代码的具体影响未逐行确认。可自测:对 Flyway 样例跑process-aot,读target/spring-aot/main/sources里生成的初始化器源码。
替换后的实现:
java
class NativeImageResourceProviderCustomizer extends ResourceProviderCustomizer {
@Override
public void customize(FluentConfiguration configuration) {
if (configuration.getResourceProvider() == null) {
Scanner<JavaMigration> scanner = new Scanner<>(JavaMigration.class, configuration,
configuration.getLocations());
NativeImageResourceProvider resourceProvider = new NativeImageResourceProvider(scanner,
configuration.getClassLoader(), Arrays.asList(configuration.getLocations()),
configuration.getEncoding(), configuration.isFailOnMissingLocations());
configuration.resourceProvider(resourceProvider);
}
}
}
if (configuration.getResourceProvider() == null) 是防御:用户自己设了就不覆盖。
6.6 NativeImageResourceProvider:native 与非 native 的双路径
java
@Override
public @Nullable LoadableResource getResource(String name) {
if (!NativeDetector.inNativeImage()) {
return this.scanner.getResource(name);
}
LoadableResource resource = this.scanner.getResource(name);
if (resource != null) {
return resource;
}
if (this.classLoader.getResource(name) == null) {
return null;
}
return new ClassPathResource(null, name, this.classLoader, this.encoding);
}
这里用的是 NativeDetector.inNativeImage() 而不是 AotDetector.useGeneratedArtifacts(),区别很重要:AOT 模式 + 普通 JVM 时 inNativeImage() 为 false,走 Flyway 自己的 scanner(普通 JVM 上扫描可行,不必降级);native 下为 true,先试扫描器、失败再用 classLoader.getResource(name) 兜底。
判断依据不是"AOT 开没开",而是"当前 JVM 到底是不是 native"------因为能力差异来自后者,不是前者。
批量路径 getResources(prefix, suffixes) 同样是双分支,native 分支先 ensureInitialized()(ReentrantLock + boolean initialized 的一次性懒初始化),再把 scanner 结果与预存 locatedResources 合并。即 native 下第一次批量查询是慢的,之后走缓存------与第 27 章 loader 的缓存思路一致。
待核实 :
initialize()如何枚举 native image 里的资源(是否读某个生成的索引文件)本轮未读取。可自测:native 下跑 Flyway 迁移,开 DEBUG 观察初始化耗时与locatedResources规模。
6.7 四个实现对照
| 实现 | 触发条件 | 生成什么 | 省掉什么 |
|---|---|---|---|
ConfigurationPropertiesBeanRegistrationAotProcessor |
BindMethod.VALUE_OBJECT |
ConstructorBound.from(...) |
绑定方式判定 |
JacksonMixinModuleEntriesBeanRegistrationAotProcessor |
bean 类是 JacksonMixinModuleEntries |
硬编码 mixins.and(...) |
mixin 扫描 |
JsonMixinModuleEntriesBeanRegistrationAotProcessor(jackson2) |
同类 | 同类 | 同类 |
ResourceProviderCustomizerBeanRegistrationAotProcessor |
bean 类是 ResourceProviderCustomizer |
new NativeImage...Customizer() |
整类替换 |
7. 第四十一定律的完整实现:META-INF/spring/logback-model
第 24 章讲日志时立下过一条定律:"AOT 下序列化解析结果而非原始输入" 。这一节给出它的完整实现。全部代码在 core/spring-boot/src/main/java/org/springframework/boot/logging/logback/SpringBootJoranConfigurator.java,约 420 行。
7.1 问题:为什么 logback.xml 在 native 下特别难
Logback 的配置是 XML,由 Joran 解析器解析成一棵 Model 树 (ch.qos.logback.core.model.Model),再由 DefaultProcessor 把这棵树"解释"成真实的 appender、encoder、logger。native 下有三重障碍:XML 解析本身(SAX/DOM 需要大量反射 hint 且慢);Joran 的 Action 机制(每个 XML 元素对应一个 Action 类,按 RuleStore 规则表动态实例化,典型反射驱动);<springProfile> / <springProperty> 这两个 Spring Boot 自定义元素更麻烦------解析时要访问 Environment,而录制阶段与运行阶段的 Environment 未必一致。
Spring Boot 的选择是:AOT 处理时就把 XML 解析成 Model 树,用 Java 序列化写到 META-INF/spring/logback-model;运行期反序列化后直接交给 DefaultProcessor,跳过整个解析阶段。 Model 本身实现了 Serializable(Logback 的设计),这条路零改造成本。
7.2 类的骨架
java
class SpringBootJoranConfigurator extends JoranConfigurator {
private final LoggingInitializationContext initializationContext;
SpringBootJoranConfigurator(LoggingInitializationContext initializationContext) {
this.initializationContext = initializationContext;
}
它继承 Logback 的 JoranConfigurator,额外持有 LoggingInitializationContext(里面有 Environment,供 <springProfile> 用)。覆写五个方法:sanityCheck(Model)(加 Spring Boot 自检规则)、addModelHandlerAssociations(DefaultProcessor)(注册 springProperty / springProfile 的 handler)、addElementSelectorAndActionAssociations(RuleStore)(注册两个自定义元素的规则)、buildModelInterpretationContext()、processModel(Model)(AOT 挂载点)。前四个是"Spring Boot 定制 Logback"的常规操作。
buildModelInterpretationContext 值得单独看:它在 super 之后调 this.modelInterpretationContext.setConfiguratorSupplier(() -> new SpringBootJoranConfigurator(this.initializationContext) ...)------告诉 Joran 遇到 <include> 需要嵌套解析时用这个 supplier 创建新配置器。不设的话 Logback 会 new JoranConfigurator(),嵌套 XML 里就没有 <springProfile> 支持了。这是典型的"框架留的扩展点,不设就静默降级"的坑。
7.3 processModel:AOT 挂载点
java
@Override
public void processModel(Model model) {
super.processModel(model);
if (!NativeDetector.inNativeImage() && isAotProcessingInProgress()) {
getContext().putObject(BeanFactoryInitializationAotContribution.class.getName(),
new LogbackConfigurationAotContribution(model, getModelInterpretationContext(), getContext()));
}
}
private boolean isAotProcessingInProgress() {
return Boolean.getBoolean(AbstractAotProcessor.AOT_PROCESSING);
}
第一个条件 !NativeDetector.inNativeImage() :native 里跑的时候是要消费 model 的,不能再往 context 里塞 contribution(那时也没有 GenerationContext)。第二个条件 isAotProcessingInProgress() :见 §1.1。组合起来:只有在 AOT 处理进程里、且不是 native 时,才录制。
getContext().putObject(key, value) 是 Logback Context 的通用对象袋,key 用的是 BeanFactoryInitializationAotContribution.class.getName()------一个跨框架的字符串契约 :Logback 的 context 里放一个 Spring 类型的对象,稍后由 Spring 侧按同样的 key 取出来。用类名字符串而非 Class 对象,Logback 就不需要 import Spring 类型。这与第 27 章 ClearCachesApplicationListener 反射按名调用 clearCache() 是同一条纪律:跨模块边界用字符串契约,不用类型引用(§12 第七十一、七十二定律会再总结)。
7.4 LogbackConfigurationAotContribution
java
static final class LogbackConfigurationAotContribution implements BeanFactoryInitializationAotContribution {
private final ModelWriter modelWriter;
private final PatternRules patternRules;
private LogbackConfigurationAotContribution(Model model, ModelInterpretationContext interpretationContext,
Context context) {
this.modelWriter = new ModelWriter(model, interpretationContext);
this.patternRules = new PatternRules(context);
}
@Override
public void applyTo(GenerationContext generationContext,
BeanFactoryInitializationCode beanFactoryInitializationCode) {
this.modelWriter.writeTo(generationContext);
this.patternRules.save(generationContext);
}
}
一个 contribution 写两样东西:model 本体 + pattern rules。applyTo 的第二个参数 beanFactoryInitializationCode 完全没用------它不生成 Java 代码,只写资源,这也是合法的。
7.5 ModelWriter.writeTo:序列化 + 四类 hint
java
private void writeTo(GenerationContext generationContext) {
byte[] serializedModel = serializeModel();
generationContext.getGeneratedFiles()
.handleFile(Kind.RESOURCE, MODEL_RESOURCE_LOCATION,
new RequireNewOrMatchingContentFileHandler(serializedModel));
generationContext.getRuntimeHints().resources().registerPattern(MODEL_RESOURCE_LOCATION);
ReflectionHints reflectionHints = generationContext.getRuntimeHints().reflection();
serializationTypes(this.model).forEach(reflectionHints::registerJavaSerialization);
reflectionTypes(this.model).forEach((type) -> generationContext.getRuntimeHints()
.reflection()
.registerType(TypeReference.of(type), MemberCategory.INVOKE_PUBLIC_METHODS,
MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS));
}
四步:序列化 (ObjectOutputStream 写 Model 树得 byte[]);写文件 (generationContext.getGeneratedFiles().handleFile(Kind.RESOURCE, location, handler));注册资源 hint (hints.resources().registerPattern(...),没有这步文件不会进 native image);注册两类反射 hint ------registerJavaSerialization 给所有参与序列化的类型,registerType(..., INVOKE_PUBLIC_METHODS, INVOKE_PUBLIC_CONSTRUCTORS) 给所有 model 里出现的组件类型。
第 4 步最容易被低估:光有 model 的字节不够,还得让 GraalVM 知道反序列化时会碰到哪些类,以及解释这棵树时会 new 哪些类、调哪些 setter。
MODEL_RESOURCE_LOCATION = "META-INF/spring/logback-model"。序列化本身很朴素------try (ObjectOutputStream output = new ObjectOutputStream(bytes)) { output.writeObject(this.model); },失败包成 RuntimeException。
用标准 Java 序列化 而非 JSON/ProtoBuf,理由很实际:Model 及其子类型本就实现了 Serializable,零改造。代价是格式不透明、有版本耦合------Logback 改字段布局就读不了旧文件。但 model 文件是构建期生成、与可执行文件一起打包的,两者永远同版本,风险实际不存在。
7.6 serializationTypes:沿类层级与字段收集
java
@SuppressWarnings("unchecked")
private Set<Class<? extends Serializable>> serializationTypes(Model model) {
Set<Class<? extends Serializable>> modelClasses = new HashSet<>();
Class<?> candidate = model.getClass();
while (Model.class.isAssignableFrom(candidate)) {
if (modelClasses.add((Class<? extends Model>) candidate)) {
ReflectionUtils.doWithFields(candidate, (field) -> {
if (Modifier.isStatic(field.getModifiers())) {
return;
}
ReflectionUtils.makeAccessible(field);
Object value = field.get(model);
if (value != null) {
Class<?> fieldType = value.getClass();
if (Serializable.class.isAssignableFrom(fieldType)) {
modelClasses.add((Class<? extends Serializable>) fieldType);
}
}
});
candidate = candidate.getSuperclass();
}
}
for (Model submodel : model.getSubModels()) {
modelClasses.addAll(serializationTypes(submodel));
}
return modelClasses;
}
三层遍历。外层沿类层级向上 (while (Model.class.isAssignableFrom(candidate)) + getSuperclass()):Java 序列化会写所有非 transient 字段,包括父类的 ,所以父类字段也要覆盖。modelClasses.add(...) 的返回值(Set.add 是否已存在)顺带做了去重与防重复处理。中层遍历当前类声明的字段 (含 private):跳过 static(不参与序列化)、makeAccessible 后取值,关键是用 value.getClass() 而不是 field.getType() ------声明类型可能是 Object 或 List,序列化写的却是运行时实际类型;只收 Serializable 的(其它情况序列化会抛异常,那是 Logback 的问题)。内层递归子 model。
待核实 :
ReflectionUtils.doWithFields是否遍历父类字段未逐行确认(推测只遍历当前类声明的,外层循环正是为此存在)。可自测:构造深层继承的 Model 子类,检查生成的serialization-config.json。
7.7 reflectionTypes 与 determineType:三级类型推断
比序列化类型更难的是"解释这棵树时会用到哪些组件类"------AppenderModel 对应哪个 appender 类?<encoder> 没有 class 属性时又是什么?
java
private Set<Class<?>> reflectionTypes(Model model) {
return reflectionTypes(model, () -> null);
}
private Set<Class<?>> reflectionTypes(Model model, Supplier<Object> parent) {
Set<Class<?>> reflectionTypes = new HashSet<>();
Class<?> componentType = determineType(model, parent);
if (componentType != null) {
processComponent(componentType, reflectionTypes);
}
Supplier<Object> componentSupplier = SingletonSupplier.of(() -> instantiate(componentType));
for (Model submodel : model.getSubModels()) {
reflectionTypes.addAll(reflectionTypes(submodel, componentSupplier));
}
return reflectionTypes;
}
递归时把父组件的实例 传给子 model------子组件类型可能需要从父组件的 setter 推断(第三级)。SingletonSupplier.of(...) 保证 instantiate 最多调一次且结果缓存。
determineType 的三级推断:
java
private @Nullable Class<?> determineType(Model model, Supplier<Object> parentSupplier) {
String className = (model instanceof ComponentModel componentModel) ? componentModel.getClassName() : null;
if (className != null) {
return loadImportType(className);
}
String tag = model.getTag();
if (tag != null) {
className = this.modelInterpretationContext.getDefaultNestedComponentRegistry()
.findDefaultComponentTypeByTag(tag);
if (className != null) {
return loadImportType(className);
}
return inferTypeFromParent(parentSupplier, tag);
}
return null;
}
第一级:显式 class 属性。 <appender class="ch.qos.logback.core.ConsoleAppender"> → ComponentModel.getClassName() 直接给出,走 loadImportType:
java
private Class<?> loadImportType(String className) {
return loadComponentType(this.modelInterpretationContext.getImport(className));
}
private Class<?> loadComponentType(String componentType) {
try {
return ClassUtils.forName(this.modelInterpretationContext.subst(componentType),
getClass().getClassLoader());
}
catch (Throwable ex) {
throw new RuntimeException("Failed to load component type '" + componentType + "'", ex);
}
}
getImport(className) 解析 Joran 的 <import> 别名;subst(...) 做 ${...} 变量替换------也就是说 AOT 录制用的是构建机上的变量值 。若 logback.xml 用 ${} 拼类名而构建机与生产机取值不同,录出来的组件类型就错了。这是 AOT 的固有局限,§7.12 会讨论。
第二级:默认嵌套组件注册表。 <appender><encoder>...</encoder></appender> 里 <encoder> 没有 class 属性------Logback 有"默认嵌套组件"表(DefaultNestedComponentRegistry),按标签名给出默认实现,findDefaultComponentTypeByTag(tag) 查这张表。
第三级:从父组件推断。 前两级都失败时走 inferTypeFromParent:Logback 的 PropertySetter.getClassNameViaImplicitRules(tag, AggregationType.AS_COMPLEX_PROPERTY, registry) 有"隐式规则"------给一个父对象和标签名,能根据父对象的 setter 签名推出子元素类型(父有 setEncoder(Encoder e),标签 encoder 就对应 Encoder)。这里真的把父对象 new 出来再问 Logback。
catch (Exception ex) { return null; }------推断失败就放弃,不阻断 AOT。这是正确的取舍:推断不出来只是少注册一个 hint,最多 native 下该类缺失;让 AOT 失败会阻断整个构建。 宁可少注册,不可阻断。
7.8 processComponent 与 parameterTypesNames:只注册真正需要的类型
java
private void processComponent(Class<?> componentType, Set<Class<?>> reflectionTypes) {
BeanDescription beanDescription = this.modelInterpretationContext.getBeanDescriptionCache()
.getBeanDescription(componentType);
reflectionTypes.addAll(parameterTypesNames(beanDescription.getPropertyNameToAdder().values()));
reflectionTypes.addAll(parameterTypesNames(beanDescription.getPropertyNameToSetter().values()));
reflectionTypes.add(componentType);
}
private Collection<Class<?>> parameterTypesNames(Collection<Method> methods) {
return methods.stream()
.filter((method) -> !method.getDeclaringClass().equals(ContextAware.class)
&& !method.getDeclaringClass().equals(ContextAwareBase.class))
.map(Method::getParameterTypes)
.flatMap(Stream::of)
.filter((type) -> !type.isPrimitive() && !type.equals(String.class))
.map((type) -> type.isArray() ? type.getComponentType() : type)
.toList();
}
BeanDescription 是 Logback 缓存的类元数据,给出所有 adder(对应 <child> 嵌套)与 setter(对应属性)。parameterTypesNames 四条过滤规则:排除 ContextAware / ContextAwareBase 声明的方法(这两个基类有大量 setContext、addInfo 之类与配置无关的 setter,注册进来只污染 hint);排除基本类型;排除 String;数组取元素类型(setFoo(Foo[]) 需要的是 Foo)。最后把组件自身加进去。
注册成员类别是 INVOKE_PUBLIC_METHODS + INVOKE_PUBLIC_CONSTRUCTORS(§7.5)------既要 new 又要 set。对比 §5.2 Logback Converter 只用 INVOKE_PUBLIC_CONSTRUCTORS,能看出成员类别是按实际需求精确定制的。
7.9 PatternRules:第二个资源文件
Pattern rule 是 Logback 的 <conversionRule conversionWord="foo" converterClass="..."/> 机制(用户自定义 %foo 占位符),规则存在 Logback Context 的 PATTERN_RULE_REGISTRY 里,native 下同样需要持久化。RESOURCE_LOCATION = "META-INF/spring/logback-pattern-rules"。
load() 用 ClassPathResource 判断存在性,不存在返回 false------这是一个哨兵 :configureUsingAotGeneratedArtifacts() 第一步就是 if (!new PatternRules(getContext()).load()) return false;,即"没有 pattern rules 文件说明 AOT 没跑过,整个走常规路径"。
save(GenerationContext) 三步:写文件(handleFile(Kind.RESOURCE, RESOURCE_LOCATION, new RequireNewOrMatchingContentFileHandler(rules)));注册资源 hint;对每个 rule 类名注册 INVOKE_PUBLIC_CONSTRUCTORS(用字符串 TypeReference.of(ruleClassName),因为规则类可能不在 classpath 上------那是 XML 里写的字符串)。序列化格式用 Properties 而非 Java 序列化:
java
private byte[] asBytes(Map<String, String> patternRuleRegistry) {
Properties properties = CollectionFactory.createSortedProperties(true);
patternRuleRegistry.forEach(properties::setProperty);
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try {
properties.store(bytes, "");
}
catch (IOException ex) {
throw new RuntimeException(ex);
}
return bytes.toByteArray();
}
CollectionFactory.createSortedProperties(true) 创建排序的 Properties,properties.store() 输出键顺序确定、两次构建产出同样字节------这是 §7.10 内容比对的前提。
7.10 RequireNewOrMatchingContentFileHandler:强制内容一致
这是本节最精妙的一段:
java
private static final class RequireNewOrMatchingContentFileHandler implements ThrowingConsumer<FileHandler> {
private final byte[] newContent;
@Override
public void acceptWithException(FileHandler file) throws Exception {
if (file.exists()) {
InputStreamSource content = file.getContent();
Assert.state(content != null, "Unable to get file content");
byte[] existingContent = content.getInputStream().readAllBytes();
if (!Arrays.equals(this.newContent, existingContent)) {
throw new IllegalStateException(
"Logging configuration differs from the configuration that has already been written. "
+ "Update your logging configuration so that it is the same for each context");
}
}
else {
file.create(new ByteArrayResource(this.newContent));
}
}
}
它解决什么问题?同一个 AOT 处理进程里可能出现多个上下文 (§9 的测试 AOT,或应用里嵌套创建 ApplicationContext)。每个上下文都会触发一次 Logback 配置录制,都试图写 META-INF/spring/logback-model。若两次配置不同(第二个上下文用了不同 profile,<springProfile> 走不同分支),"该写哪一份"就无解------运行期只有一个文件。
Spring Boot 的选择是拒绝 :第二次写入内容不一致就直接抛 IllegalStateException,消息明确说"请让你的日志配置在每个上下文下都一样"。三个好处:失败在构建期而非运行期 (运行期只会有一份文件,读到哪份都是"错但看起来正常"------最难查的 bug);错误消息可操作 (不是"文件冲突",而是"改配置让它一致");实现极简(不需要版本号、不需要合并策略,就是字节比较)。
呼应 §7.9 的 createSortedProperties(true):正因为输出确定性,两次相同配置会产生完全相同的字节 ,Arrays.equals 才通过。若 properties 顺序随机,同样的配置第二次写也会失败------这两个设计是配套的。
7.11 ModelReader:反序列化后的两个修正
java
private static final class ModelReader {
private Model read() {
try (InputStream modelInput = getClass().getClassLoader()
.getResourceAsStream(ModelWriter.MODEL_RESOURCE_LOCATION)) {
try (ObjectInputStream input = new ObjectInputStream(modelInput)) {
Model model = (Model) input.readObject();
ModelUtil.resetForReuse(model);
markIncludesAsHandled(model);
return model;
}
}
catch (Exception ex) {
throw new RuntimeException("Failed to load model from '" + ModelWriter.MODEL_RESOURCE_LOCATION + "'", ex);
}
}
private void markIncludesAsHandled(Model model) {
if (model instanceof IncludeModel) { model.markAsHandled(); }
for (Model submodel : model.getSubModels()) { markIncludesAsHandled(submodel); }
}
}
反序列化后要做两件方向相反的事。ModelUtil.resetForReuse(model) :Model 在解释中被打"已处理"标记,序列化会把状态位一起写进去;不 reset 则 DefaultProcessor 跳过它,配置不生效。markIncludesAsHandled(model) :反过来 IncludeModel 必须标为已处理------include 在录制时已展开,子 model 已在树里;不标记则运行期再 include 一次,重复且可能失败。
一个 reset、一个 mark,方向相反,目标一致:让反序列化出来的树处于"该处理的都处理、不该处理的都跳过"的正确初始状态。 消费端:
java
boolean configureUsingAotGeneratedArtifacts() {
if (!new PatternRules(getContext()).load()) {
return false;
}
Model model = new ModelReader().read();
processModel(model);
registerSafeConfiguration(model);
return true;
}
以及 LogbackLoggingSystem.initialize:if (!initializeFromAotGeneratedArtifactsIfPossible(initializationContext, logFile)) { super.initialize(...); }。
这是优雅的降级 :AOT 产物存在就用,不存在走常规路径。所以 -Dspring.aot.enabled=true 但没跑过 process-aot 的应用照样能启动(只是没优化)。initializeFromAotGeneratedArtifactsIfPossible 第一步同样是 if (!AotDetector.useGeneratedArtifacts()) return false;------与 §1.3 那四处同一套守卫。
7.12 这条定律的边界
"序列化解析结果"要走通,四个条件缺一不可:中间产物可序列化 (Model 实现了 Serializable);与运行时环境无关 (Model 里不含 Environment 引用------<springProfile> 的判定结果已固化在树的结构里);反序列化后能恢复到正确初始状态 (resetForReuse + markIncludesAsHandled);同一进程内多次录制必须一致 (RequireNewOrMatchingContentFileHandler)。
第 2 条最易不满足。回想 §7.7 的 subst(componentType):若 logback.xml 用了 ${} 变量且构建机与生产机取值不同,录制结果就是错的。这正是 ProcessAotMojo 提供 <arguments> 与 <profiles> 的原因(§8.1)------让你把生产环境的参数告诉构建机。
第 24 章把这条写成"定律",是因为它不只是 Logback 的技巧:任何"AOT 下跳过昂贵解析"的场景都可套用同样四步------解析、序列化、写资源、注册 hint。
8. 构建插件侧:Maven 与 Gradle 的对称与差异
AOT 处理必须在一个独立的 JVM 进程 里跑(因为它要真实启动应用,会污染类加载器、会注册 MBean、会起线程)。两个构建插件的做法都是"fork 一个 Java 进程跑 SpringApplicationAotProcessor.main",但细节不同。
8.1 Maven:ProcessAotMojo
java
@Mojo(name = "process-aot", defaultPhase = LifecyclePhase.PREPARE_PACKAGE, threadSafe = true,
requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME,
requiresDependencyCollection = ResolutionScope.COMPILE_PLUS_RUNTIME)
public class ProcessAotMojo extends AbstractAotMojo {
private static final String AOT_PROCESSOR_CLASS_NAME = "org.springframework.boot.SpringApplicationAotProcessor";
四个注解参数都有意义:
defaultPhase = PREPARE_PACKAGE------在打包之前,这样生成的资源能进 jar。requiresDependencyResolution = COMPILE_PLUS_RUNTIME------AOT 需要看到 runtime 依赖(自动配置都在 runtime scope)。少了这个,AOT 处理时类找不到。threadSafe = true------可并行构建。
三个关键目录参数(@Parameter(defaultValue = ...)):
| 参数 | 默认值 |
|---|---|
classesDirectory |
${project.build.outputDirectory} |
generatedSources |
${project.build.directory}/spring-aot/main/sources |
generatedResources |
${project.build.directory}/spring-aot/main/resources |
generatedClasses |
${project.build.directory}/spring-aot/main/classes |
主流程:
java
@Override
protected void executeAot() throws Exception {
if (this.project.getPackaging().equals("pom")) {
getLog().debug("process-aot goal could not be applied to pom project.");
return;
}
String applicationClass = (this.mainClass != null) ? this.mainClass
: SpringBootApplicationClassFinder.findSingleClass(this.classesDirectory);
URL[] classPath = getClassPath();
generateAotAssets(classPath, AOT_PROCESSOR_CLASS_NAME, getAotArguments(applicationClass));
compileSourceFiles(classPath, this.generatedSources, this.generatedClasses);
copyAll(this.generatedResources.toPath(), this.classesDirectory.toPath());
copyAll(this.generatedClasses.toPath(), this.classesDirectory.toPath());
}
四步缺一不可:找主类 ------mainClass 参数优先,否则 SpringBootApplicationClassFinder.findSingleClass(classesDirectory) 在编译输出目录里找唯一 含 main 方法的类(多个 main 类会报错);生成 ------generateAotAssets fork 新进程;编译 ------compileSourceFiles;回拷 ------两次 copyAll 把生成的资源与 class 拷进 classesDirectory(即 target/classes),从而进入最终 jar。
第 4 步解释了 §0 的目录结构:target/spring-aot/ 只是中转站 ,最终产物都在 target/classes。copyAll 用 Files.walk + REPLACE_EXISTING,源不存在时直接返回。
参数拼装:
java
private String[] getAotArguments(String applicationClass) {
List<String> aotArguments = new ArrayList<>();
aotArguments.add(applicationClass);
aotArguments.add(this.generatedSources.toString());
aotArguments.add(this.generatedResources.toString());
aotArguments.add(this.generatedClasses.toString());
aotArguments.add(this.project.getGroupId());
aotArguments.add(this.project.getArtifactId());
aotArguments.addAll(resolveArguments().getArgs());
return aotArguments.toArray(String[]::new);
}
private RunArguments resolveArguments() {
RunArguments runArguments = new RunArguments(this.arguments);
if (!ObjectUtils.isEmpty(this.profiles)) {
runArguments.getArgs().addFirst("--spring.profiles.active=" + String.join(",", this.profiles));
}
return runArguments;
}
正好对应 §2.2 的六个位置参数 + 透传参数。注意 profiles 是用 addFirst 放到最前面 的,这样命令行上显式写的 --spring.profiles.active 会覆盖它(后出现的参数优先级更高)。这正是 §7.12 说的"把生产环境参数告诉构建机"的入口。
classpath 构造值得单独看:
java
private URL[] getClassPath() throws Exception {
File[] directories = new File[] { this.classesDirectory, this.generatedClasses };
return getClassPath(directories, new ExcludeTestScopeArtifactFilter(), DEVTOOLS_EXCLUDE_FILTER);
}
两个过滤器:排除 test scope 依赖 、排除 devtools。后者很关键------devtools 的重启类加载器会干扰 AOT 处理。
8.2 AbstractAotMojo:进程外编译
生成的是 .java,必须编译。Maven 插件的做法是用 JDK 自带的 JavaCompiler API 就地编译 ,而不是调用外部 javac:
java
protected final void compileSourceFiles(URL[] classPath, File sourcesDirectory, File outputDirectory)
throws Exception {
List<Path> sourceFiles;
try (Stream<Path> pathStream = Files.walk(sourcesDirectory.toPath())) {
sourceFiles = pathStream.filter(Files::isRegularFile).toList();
}
if (sourceFiles.isEmpty()) {
return;
}
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null)) {
JavaCompilerPluginConfiguration compilerConfiguration = new JavaCompilerPluginConfiguration(this.project);
List<String> args = new ArrayList<>();
args.addAll(ClassPath.of(classPath).args(false));
args.add("-d");
args.add(outputDirectory.toPath().toAbsolutePath().toString());
String releaseVersion = compilerConfiguration.getReleaseVersion();
if (releaseVersion != null) {
args.add("--release");
args.add(releaseVersion);
}
else {
String source = compilerConfiguration.getSourceMajorVersion();
...
}
args.add("-parameters");
args.addAll(new RunArguments(this.compilerArguments).getArgs());
Iterable<? extends JavaFileObject> compilationUnits = fileManager.getJavaFileObjectsFromPaths(sourceFiles);
Errors errors = new Errors();
CompilationTask task = compiler.getTask(null, fileManager, errors, args, null, compilationUnits);
boolean result = task.call();
if (!result || errors.hasReportedErrors()) {
throw new IllegalStateException("Unable to compile generated source" + errors);
}
}
}
四个要点:if (sourceFiles.isEmpty()) return;------没有生成源码就跳过,AOT 零产出时不能因"编译零个文件"报错;-parameters 是硬加的 (不在用户可配置范围)------Spring 依赖注入靠参数名,生成代码丢了参数名构造器绑定就废了;--release 优先于 --source/--target(现代 JDK 推荐,--release 同时约束 API 级别);Errors 是 DiagnosticListener<JavaFileObject>,只收集 Diagnostic.Kind.ERROR,把消息、源文件名、行列号拼成字符串,task.call() 返回 false 或 hasReportedErrors() 时抛异常带上全部错误。
为什么自己编译而不交给 maven-compiler-plugin?生成的源码不在标准 source 目录,交给编译插件要改一堆配置;而且 AOT 生成源码编译失败应立刻归因于 AOT,不该混在常规编译输出里。
AbstractAotMojo 还暴露了四个调优开关:skip(spring-boot.aot.skip)、systemPropertyVariables(传给 AOT 进程的 JVM 系统属性)、jvmArguments(spring-boot.aot.jvmArguments)、compilerArguments(spring-boot.aot.compilerArguments)。以及 ToolchainManager------支持用与构建 JDK 不同的 JDK 跑 AOT(呼应 §1.4)。
generateAotAssets 用 CommandLineBuilder.forMainClass(processorClassName).withSystemProperties(...).withJvmArguments(...).withClasspath(...).withArguments(...).build() 拼命令行,再由 JavaProcessExecutor 执行------后者也处理 toolchain 切换。
8.3 Gradle:AbstractAot / ProcessAot / ProcessTestAot
Gradle 侧选择了一条不同的路:把 AOT 处理包装成 JavaExec 任务,让 Gradle 自己管 fork 与 classpath。
java
@DisableCachingByDefault(because = "Cacheability can only be determined by a concrete implementation")
public abstract class AbstractAot extends JavaExec {
List<String> processorArgs() {
List<String> args = new ArrayList<>();
args.add(getSourcesOutput().getAsFile().get().getAbsolutePath());
args.add(getResourcesOutput().getAsFile().get().getAbsolutePath());
args.add(getClassesOutput().getAsFile().get().getAbsolutePath());
args.add(getGroupId().get());
args.add(getArtifactId().get());
args.addAll(super.getArgs());
return args;
}
}
(字段声明:三个 DirectoryProperty ------ sourcesDir / resourcesDir / classesDir,两个 Property<String> ------ groupId / artifactId,各自带 @OutputDirectory 或 @Input。)
processorArgs() 只拼后五个参数(对应 §2.2 的位置 1--5),位置 0(主类)留给子类------ProcessAot 传应用主类,ProcessTestAot 传 classpath roots,两者不同。分层很干净。
基类标 @DisableCachingByDefault,理由是"能否缓存只有具体实现知道";两个子类都标 @CacheableTask(输入是 classpath + 主类,输出是三个目录,确实可缓存)。基类不替子类做缓存决策------Gradle 任务设计的好纪律。
java
@CacheableTask
public abstract class ProcessAot extends AbstractAot {
public ProcessAot() {
getMainClass().set("org.springframework.boot.SpringApplicationAotProcessor");
}
@Input
public abstract Property<String> getApplicationMainClass();
@Override
@TaskAction
public void exec() {
List<String> args = new ArrayList<>();
args.add(getApplicationMainClass().get());
args.addAll(processorArgs());
setArgs(args);
super.exec();
}
}
mainClass 与 Maven 的 AOT_PROCESSOR_CLASS_NAME 是同一个字符串------两侧硬编码同一份 ,靠集成测试保证不漂移。输出目录标 @OutputDirectory 而非 @OutputFiles,因为生成的文件集合运行前未知。
8.4 两侧差异小结
| 维度 | Maven | Gradle |
|---|---|---|
| 进程 fork | 自己拼命令行 + JavaProcessExecutor |
复用 Gradle 的 JavaExec |
| 编译生成源码 | 插件内 ToolProvider.getSystemJavaCompiler() |
交给 Gradle 的 compileJava / 独立 source set |
| toolchain | 显式 ToolchainManager |
Gradle Java toolchain |
| 缓存 | 无(每次重跑) | @CacheableTask |
| 产物回拷 | copyAll 到 target/classes |
注册进 source set,自然参与打包 |
Gradle 的"交给构建系统"更简洁,Maven 的"自己干"更可控。两边都没有把 AOT 处理塞进构建 JVM------这一点是一致的,也必须是。
9. 测试的 AOT:SpringBootTestAotProcessor
测试也要 AOT:native 下的 @SpringBootTest 同样需要避免扫描。入口在 core/spring-boot-test:
java
public class SpringBootTestAotProcessor extends TestAotProcessor {
public SpringBootTestAotProcessor(Set<Path> classpathRoots, Settings settings) {
super(classpathRoots, settings);
}
public static void main(String[] args) {
int requiredArgs = 6;
Assert.isTrue(args.length >= requiredArgs,
() -> "Usage: %s <classpathRoots> <sourceOutput> <resourceOutput> <classOutput> <groupId> <artifactId>"
.formatted(TestAotProcessor.class.getName()));
Set<Path> classpathRoots = Arrays.stream(args[0].split(File.pathSeparator))
.map(Paths::get)
.collect(Collectors.toSet());
Settings settings = Settings.builder()
.sourceOutput(Paths.get(args[1]))
.resourceOutput(Paths.get(args[2]))
.classOutput(Paths.get(args[3]))
.groupId(args[4])
.artifactId(args[5])
.build();
new SpringBootTestAotProcessor(classpathRoots, settings).process();
}
}
继承的是 org.springframework.test.context.aot.TestAotProcessor(spring-test)而非 ContextAotProcessor。差异在于:测试 AOT 没有"一个 main 方法"可调用 ------它接收一组 classpath 根 ,自己扫描里面的测试类,对每个测试类的 ApplicationContext 分别做 AOT 处理。同样是六个参数,位置 0 换成 classpathRoots(File.pathSeparator 分隔:Windows ;,Unix :)。
Gradle 侧对应 ProcessTestAot,mainClass 设为 org.springframework.boot.test.context.SpringBootTestAotProcessor,exec() 里把 classpathRoots.getFiles() 过滤 File::exists 后用 File.pathSeparator join 成 args0。三个注解值得注意:@SkipWhenEmpty(没有测试类就不跑)、@IgnoreEmptyDirectories(空目录不算输入)、@PathSensitive(PathSensitivity.RELATIVE)(只关心相对路径 ,换机器构建缓存仍命中------绝对路径会因机器不同而失效)。.filter(File::exists) 排掉尚未生成的目录。
另外回忆 §7.10:测试 AOT 是最容易触发"多个上下文日志配置不一致"的场景------每个 @SpringBootTest 类一个上下文,配置稍有差异就 IllegalStateException。
10. Native Image 构建:NativeImageArgFile
第 27 章 §8.6 埋的伏笔在这里收口。承接对象是 Packager.writeNativeImageArgFile:
java
private void writeNativeImageArgFile(AbstractJarWriter writer, JarFile sourceJar,
Map<String, Library> writtenLibraries) throws IOException {
Set<String> excludes = new LinkedHashSet<>();
for (Map.Entry<String, Library> entry : writtenLibraries.entrySet()) {
LibraryCoordinates coordinates = entry.getValue().getCoordinates();
ZipEntry zipEntry = (coordinates != null)
? sourceJar.getEntry(ReachabilityMetadataProperties.getLocation(coordinates)) : null;
if (zipEntry != null) {
try (InputStream inputStream = sourceJar.getInputStream(zipEntry)) {
ReachabilityMetadataProperties properties = ReachabilityMetadataProperties
.fromInputStream(inputStream);
if (properties.isOverridden()) {
excludes.add(entry.getKey());
}
}
}
}
NativeImageArgFile argFile = new NativeImageArgFile(excludes);
argFile.writeIfNecessary((lines) -> {
String contents = String.join("\n", lines) + "\n";
writer.writeEntry(NativeImageArgFile.LOCATION,
new ByteArrayInputStream(contents.getBytes(StandardCharsets.UTF_8)));
});
}
它解决的问题是:依赖 jar 自带的 GraalVM 可达性元数据可能与 Spring Boot 提供的冲突 。GraalVM 的 META-INF/native-image/<group>/<artifact>/<version>/reachability-metadata.properties 里可以写 override=true 表示"用我的元数据覆盖外部的"。Spring Boot 在 repackage 时逐个检查每个库,凡是标了 override 的,就把它加进排除列表。
ReachabilityMetadataProperties 只有两个能力:
java
public static final String REACHABILITY_METADATA_PROPERTIES_LOCATION_TEMPLATE =
"META-INF/native-image/%s/%s/%s/reachability-metadata.properties";
public boolean isOverridden() {
return Boolean.parseBoolean(this.properties.getProperty("override"));
}
public static String getLocation(LibraryCoordinates coordinates) {
return REACHABILITY_METADATA_PROPERTIES_LOCATION_TEMPLATE.formatted(coordinates.getGroupId(),
coordinates.getArtifactId(), coordinates.getVersion());
}
excludes 用 LinkedHashSet 保证顺序确定(argfile 内容稳定、可 diff)。coordinates == null 的库(比如本地文件依赖)直接跳过。
生成的 argfile:
java
public final class NativeImageArgFile {
public static final String LOCATION = "META-INF/native-image/argfile";
private final List<String> excludes;
public void writeIfNecessary(ThrowingConsumer<List<String>> writer) {
if (this.excludes.isEmpty()) {
return;
}
List<String> lines = new ArrayList<>();
for (String exclude : this.excludes) {
int lastSlash = exclude.lastIndexOf('/');
String jar = (lastSlash != -1) ? exclude.substring(lastSlash + 1) : exclude;
lines.add("--exclude-config");
lines.add(Pattern.quote(jar));
lines.add("^/META-INF/native-image/.*");
}
writer.accept(lines);
}
}
每个被排除的库产出三行:
arduino
--exclude-config
\Qfoo-1.2.3.jar\E
^/META-INF/native-image/.*
三点:
Pattern.quote(jar)------ 把 jar 名包成\Q...\E(正则字面量)。因为 jar 名里的.、+、(在正则里有含义,foo-1.2.3.jar若不 quote 会匹配到foo-1x2x3xjar。这是第 27 章writeNativeImageArgFile里同一条纪律:用户输入不能直接当正则用。^/META-INF/native-image/.*------ 排除范围限定在 native-image 元数据目录,不是整个 jar。writeIfNecessary的早退 :if (this.excludes.isEmpty()) return;------没有排除项时不写这个文件。空 argfile 会让 GraalVM 命令行多一个无意义参数,也可能让某些版本报错。
LOCATION 是 GraalVM 自己的约定(不是 Spring 的),所以放在 META-INF/native-image/ 而不是 META-INF/spring/(§3.5 的那两个是 Spring 自己的)。这条边界在排查"我的元数据没生效"时很关键。
Gradle 侧对应 BootZipCopyAction.writeNativeImageArgFileIfNecessary(),且用 ReachabilityMetadataProperties.REACHABILITY_METADATA_PROPERTIES_LOCATION_TEMPLATE.formatted(".*", ".*", ".*") 编译出一个匹配所有坐标的正则------同一套逻辑,两套构建工具的独立实现。
11. 决策速查表
| 你要做的事 | 看哪个类 / 文件 | 关键约束 |
|---|---|---|
| 给自己的可选依赖注册反射 hint | 写 RuntimeHintsRegistrar + META-INF/spring/aot.factories |
用字符串 ClassUtils.isPresent 守卫;嵌套类写 Outer$Inner;值按字母序 |
| 给单个 bean 改写实例化代码 | BeanRegistrationAotProcessor |
processAheadOfTime 开头尽快返回 null;用 withCustomCodeFragments 装饰器 |
| 在整个工厂层面做贡献 | BeanFactoryInitializationAotProcessor |
返回 null 表示无贡献 |
| 把某个 bean 排除出 AOT | BeanRegistrationExcludeFilter |
同在 aot.factories |
| 生成额外资源文件 | GenerationContext.getGeneratedFiles().handleFile(Kind.RESOURCE, ...) |
必须同时 hints.resources().registerPattern(...) |
| 生成额外 Java 类 | getGeneratedClasses().addForFeature(name, ...) |
必须同时注册 INVOKE_PUBLIC_CONSTRUCTORS |
| 调试"AOT 产物没生效" | 看启动日志有没有 AOT-processed |
没有说明 spring.aot.enabled 没开 |
| 调试"native 下类找不到" | 生成的 reflect-config.json |
检查 registrar 是否覆盖到该类 |
| 日志配置在 native 下不对 | META-INF/spring/logback-model |
多上下文配置必须一致,否则构建期 IllegalStateException |
| profile 相关行为不一致 | ProcessAotMojo 的 <profiles> / <arguments> |
构建时就要把生产参数传给 AOT |
排查顺序建议:先确认开关 (useGeneratedArtifacts() / inNativeImage() / AOT_PROCESSING 三个值),再确认产物存在 (<MainClass>__ApplicationContextInitializer 在不在 classpath),最后看 hint (生成的 reflect-config.json / resource-config.json)。绝大多数 AOT 问题出在第一或第三步。
12. 从本章提炼的定律
第七十一:跨框架边界用字符串契约,不用类型引用。 Logback 的 Context 里放 Spring 的 contribution,key 用 BeanFactoryInitializationAotContribution.class.getName()(§7.3);core 反射按名调 loader 的 clearCache()(第 27 章第六十六)。弱依赖方不必 import 强依赖方类型,代价是失去编译期检查------所以必须配套构建期校验(§4.3)。
第七十二:录制者不消费录制结果。 AOT 处理进程自己不设 spring.aot.enabled,改用独立的 AOT_PROCESSING 标志(§1.1)。否则自举:一边生成产物一边又去读它。
第七十三:判断能力差异要看执行环境,不看模式开关。 Flyway 的 NativeImageResourceProvider 判 NativeDetector.inNativeImage() 而非 AotDetector.useGeneratedArtifacts()(§6.6)------扫描能不能用取决于 JVM 是不是 native,不是 AOT 开没开。
第七十四:AOT 生成代码的目标是省掉"决策",不是省掉"执行"。 生成的是 ConstructorBound.from(...) 而不是 new MyProps("a", 1)(§6.3)------配置值仍在运行期读,只是绑定方式提前定死。
第七十五:processAheadOfTime 必须能快速返回 null。 每个 bean 定义都调一次,几百次很正常;所有实现都在开头做一次 equals 判断(§6.1)。
第七十六:生成代码之后必须自己注册可达性 hint。 生成了 <MainClass>__EnvironmentPostProcessor 就要 registerType(..., INVOKE_PUBLIC_CONSTRUCTORS)(§3.3);写了 META-INF/spring/logback-model 就要 registerPattern(...)(§7.5)。生成与可达是两件事,缺一不可------漏了的表现是 JVM 上正常、native 下崩。
第七十七:可选依赖的守卫必须用字符串类名。 ClassUtils.isPresent("ch.qos.logback.classic.LoggerContext", classLoader) 而非 LoggerContext.class(§5.2)------import 会被解析,用 Class 字面量会让 registrar 类本身在依赖缺失时加载失败。
第七十八:序列化解析结果前,先确认能与运行时环境解耦。 logback.xml 里的 ${} 在构建机求值(§7.7 subst),构建机与生产机取值不同就录错。所以构建插件必须提供传入生产参数的入口(§8.1)。
第七十九:同一份产物被多次写入时,要么内容一致,要么直接失败。 RequireNewOrMatchingContentFileHandler 比较字节,不一致抛异常(§7.10);配套要求是输出确定性------CollectionFactory.createSortedProperties(true)(§7.9)。
第八十:产物是"必需品"还是"加速器",决定缺失时报错还是跳过。 __ApplicationContextInitializer 缺失抛 AotInitializerNotFoundException,__EnvironmentPostProcessor 缺失静默跳过(§3.4)。判断标准是:缺了它应用是否还有意义。
13. 本章未逐行核实 / 待你实测的项
AbstractAotProcessor.AOT_PROCESSING的字面值 (§1.1)在spring-context中,本仓库无源码。自测:AOT 进程里 dump 全部系统属性。AotDetector.useGeneratedArtifacts()的判定依据 (§1.1)------推测是读AotDetector.AOT_ENABLED系统属性(测试里这么用),但spring-core源码不在本仓库。自测:System.setProperty(AotDetector.AOT_ENABLED, "true")后跑一次普通启动。handleRunFailure调用方拿到返回值后的行为 (§2.5)------本轮只确认了方法首行返回abandonedRunException。自测:AOT 处理时开 DEBUG,看是否出现 "Application run failed"。withHook的 ThreadLocal 字段与SingleUseSpringApplicationHook.getRunListener的二次调用行为 (§2.7)。自测:写一个main里连续创建两个SpringApplication的样例跑 AOT。AotInitializerNotFoundException的完整消息拼装(§3.2)。自测:AOT 模式下运行未生成产物的应用,读异常全文。BindableRuntimeHintsRegistrar的递归规则(§5.3)------泛型、集合元素、嵌套类、接口如何处理。getTarget默认行为与覆写后的差异(§6.5)------为何 Flyway 那个实现不覆写。NativeImageResourceProvider.initialize()如何枚举 native 资源(§6.6)。ReflectionUtils.doWithFields是否遍历父类字段 (§7.6)------若否,则外层while循环正是为此存在。EnvironmentPostProcessorApplicationListener里"按 key 取出 contribution"的消费方是谁(§7.3)------推测在 spring-context 的 AOT 驱动代码里,本仓库看不到。- Gradle
ProcessAot三个输出目录的实际路径 (§0)------由 source set 注册决定,未读SpringBootPlugin源码确认。 ReachabilityMetadataProperties的override属性由谁写入(§10)------是第三方库自己的元数据,本仓库只消费不生产。
14. 小结与下一章
本章把 AOT 这条链从头走到尾:入口 ------SpringApplicationAotProcessor 覆写一个方法,用 AbandonedRunException 在 contextLoaded 时把上下文从启动流程里"带出来"(§2),这是全章最反直觉、也最能说明"AOT 就是录制一次真实启动"的设计;三个开关 (§1)------useGeneratedArtifacts() / inNativeImage() / AOT_PROCESSING,取值表是理解所有分支的前提;产物 (§3)------__ApplicationContextInitializer(必需)、__EnvironmentPostProcessor(可选)、META-INF/spring/*(资源),addForFeature 是统一命名机制;SPI (§4)------aot.factories 四个键、14 个文件、CheckAotFactories 三条构建期规则;hints (§5)------72 个 registrar 归六类,共同纪律是"字符串守卫可选依赖、常量从真实使用方引用、能不注册就不注册";改写 bean (§6)------三个 BeanRegistrationAotProcessor 展示从"改写一段代码"到"整类替换"的不同力度;序列化解析结果 (§7)------META-INF/spring/logback-model 是第四十一定律的完整实现,四个必要条件缺一不可;构建侧 (§8--§9)------Maven 自己 fork + 自己编译,Gradle 交给 JavaExec,测试 AOT 用 classpath roots 而非 main 类;native 构建 (§10)------NativeImageArgFile 把"哪些库要排除可达性元数据"写进 fat jar,与第 27 章 §8.6 呼应闭合。
下一章(第 29 章)进入 DevTools 与开发期体验 。本章 §6 的"整类替换"手法(用 NativeImageResourceProviderCustomizer 换掉 ResourceProviderCustomizer)在 DevTools 里会再次出现------只不过换的目的是热重启而非 native 兼容。届时会读到 spring-boot-devtools 的 RestartClassLoader、两个类加载器的资源隔离边界、spring.devtools.restart.additional-paths 的触发逻辑,以及 LiveReload 服务端如何与浏览器插件协作。