Spring Boot 4.1.1 深度源码解析-第 28 章 AOT 与 GraalVM Native与序列化模型

10 阅读28分钟

Spring Boot 4.1.1 深度源码解析-第 28 章 AOT 与 GraalVM Native:SpringApplicationAotProcessor、RuntimeHints 与序列化模型


0. 从一条命令出发:process-aot 到底做了什么

先给出一个能落地的起点。一个标准 Spring Boot 应用要做 native 编译,Maven 侧至少会经过这两步:

mvn -Pnative spring-boot:process-aot
mvn -Pnative native:compile

第一条命令的产物不在 target/classes,而在 target/spring-aot/:

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 的失败处理逻辑里为这个异常留了一个专门的分支,让它不被当作启动失败。

本章会沿着下面这条线走完:

  1. 三条时间线(构建期 AOT、运行期 AOT 模式、native image 运行期)与三个布尔开关(§1)
  2. SpringApplicationAotProcessor 如何在不启动应用的前提下拿到上下文(§2)
  3. 生成的三类产物,以及运行期怎么把它们接管回来(§3)
  4. META-INF/spring/aot.factories:AOT 的 SPI,以及 Spring Boot 自己给它写的构建期校验器(§4)
  5. RuntimeHints 的注册方式谱系:从一行常量到全量绑定反射(§5)
  6. BeanRegistrationAotProcessor:直接改写某个 bean 的实例化代码(§6)
  7. 第 24 章埋下的第四十一定律("AOT 下序列化解析结果而非原始输入")的完整实现:META-INF/spring/logback-model(§7)
  8. 构建插件侧 Maven 与 Gradle 的对称与差异(§8)
  9. 测试的 AOT:SpringBootTestAotProcessor(§9)
  10. 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_PROCESSINGorg.springframework.context.aot.AbstractAotProcessor(spring-context)的常量,通过 Boolean.getBoolean(...) 读取当前进程是不是正在做 AOT 处理

第三个开关在本仓库里的唯一使用点是 SpringBootJoranConfigurator:

// 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-context jar。

1.2 四个场景下的取值表

场景useGeneratedArtifacts()inNativeImage()AOT_PROCESSING
java -jar app.jar(常规 fat jar)falsefalsefalse
mvn spring-boot:process-aot 起的那个 Java 进程falsefalsetrue
AOT 产物已生成、但用普通 JVM 跑(java -Dspring.aot.enabled=true -jar)truefalsefalse
native image 可执行文件truetruefalse

第二行值得单独说:AOT 处理进程自己并不开启 useGeneratedArtifacts()。它是在"常规模式"下跑一次完整启动,然后靠 AOT_PROCESSING 这个标志让各个组件知道"我现在在录制"。这是一个很干净的设计——录制者不需要消费录制结果,否则会自举。

第三行是很多人忽略的组合:AOT 产物在普通 JVM 上同样可用,这也是本地验证 AOT 是否正确的手段(不需要装 GraalVM)。StartupInfoLogger 在这种模式下会打印 AOT-processed:

// 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——上下文类型直接换掉

// 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

// 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

// 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 下校验

// 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 时断言不跟着变——可接受的取舍。调用点极其克制:

// 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 类签名与它继承的东西

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 六个位置参数

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:

位置参数说明
0applicationMainClass用 Class.forName(args[0]) 加载,不含 main 方法签名信息
1sourceOutput生成的 .java 输出目录
2resourceOutput生成的资源输出目录
3classOutput生成的 .class 输出目录(编译产物)
4groupId空字符串会被替换成 "unspecified"
5artifactId无默认值
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:用异常做控制流

@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,是从异常里捞出来的。

完整链路:

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()));
    }
}

把它翻译成人话:

  1. 注册一个 SpringApplicationHook,它的 getRunListener 返回一个只实现了一个方法的 SpringApplicationRunListener——只关心 contextLoaded,其它生命周期方法用默认空实现(Java 8 的 default method 让这成为可能)。
  2. contextLoaded 在 prepareContext 末尾被调用(见 §1.3 第二处),此时上下文已经建好、环境已就绪、bean 定义已登记,但还没 refresh。这正是 AOT 需要的时刻:所有"静态可判定"的信息都在,而所有"需要真实运行"的动作还没发生。
  3. 在这一刻抛出异常,把 context 塞进异常对象。
  4. run 捕获它,校验类型,返回。

为什么用异常而不是返回值?因为控制流要穿过 main 方法。AOT 处理调用的是用户写的 main,main 内部会 new SpringApplication(...).run(args)。这个 run 是 SpringApplication 的方法,没有为 AOT 预留"提前返回上下文"的出口。异常是唯一能从任意深度的调用栈里直接把对象带出来的机制——而且不需要改 SpringApplication.run 的签名。

代价是这条路径必须被"合法化",否则 run 的失败处理逻辑会把 AOT 处理当成一次启动失败。这就是下一节。

2.4 AbandonedRunException:一个为控制流而生的公开异常

// 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 里的特判

// 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()

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,指向应用类。这里可以理解为"配置错误不需要友好提示"。

调用时按参数个数分派:

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

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

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 里写死:

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。它对应的测试断言是:

// 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"样例,值得全文看一遍:

// 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 都遵循这个约定。

生成代码的部分:

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,启动失败
__EnvironmentPostProcessorClassUtils.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-model
  • META-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 是唯一"反向"的,本仓库两处使用:

# 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 覆盖面:

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。

// 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):

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):

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)。

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 接口只有两个方法参数:

void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader);

hints 有四个子空间:hints.reflection()、hints.resources()、hints.serialization()、hints.proxies()。classLoader 可为 null——这是刻意的,因为很多 registrar 需要判断"某个可选依赖在不在",而判断需要 classloader。所有实现都必须处理 null。

按实现手法,72 个可以分成五类。下面每类给一个真实源码。

5.1 第一类:常量型——注册一个静态已知的资源

最简单的形态,一行:

// 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 是可选依赖,可能不存在):

// 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:

// 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 的纪律)。

贡献本身:

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:

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 可能在哪些路径、叫什么名字、什么后缀":

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,但要过滤:

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:

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 引用):

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));
        }
    }
}

三点:

  1. classLoader 的 null 兜底:(classLoader != null) ? classLoader : getClass().getClassLoader()。这是所有 registrar 处理 nullable classloader 的标准姿势。
  2. getResource(candidate) != null 过滤:只有目录真实存在才注册。Spring Boot 应用通常只有 static/,那就只注册 static/**。别小看这一步——资源模式越多,native image 构建时扫描 classpath 的成本越高,而且 ** 模式会让 GraalVM 保留更多文件。
  3. if (locations.length > 0):一个都不存在就不注册。空数组传给 includes(...) 可能产生一个匹配空集的模式,不如干脆不注册。
  4. 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
绑定型ConfigurationPropertiesBeanFactoryInitializationAotProcessorBindable + BindableRuntimeHintsRegistrar
手工反射型ConfigDataPropertiesRuntimeHintsReflectionUtils.findMethod + Assert.state + ExecutableMode.INVOKE
组合型ConfigDataLocationRuntimeHintsFilePatternResourceHintsRegistrar + SPI 枚举扩展名
存在性过滤型WebResourcesRuntimeHintsgetResource(...) != null 过滤后再注册

贯穿这六类的三条纪律:可选依赖用字符串判断、常量从真实使用方引用而非重写、能不注册就不注册。


6. BeanRegistrationAotProcessor:直接改写某个 bean 的实例化代码

6.1 两类 AOT 处理器的分工

BeanFactoryInitializationAotProcessorBeanRegistrationAotProcessor
输入ConfigurableListableBeanFactory(整个工厂)RegisteredBean(单个 bean)
输出BeanFactoryInitializationAotContributionBeanRegistrationAotContribution
典型用途注册 hints、写资源文件、生成环境后处理器改写这个 bean 的实例化代码
本仓库数量54

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:不可变绑定

// 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 反射就够了,不需要改写实例化代码。

装饰器覆写了三个方法:

@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"。

@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 列表,就得先把真实列表算出来,而唯一可靠方式是真的跑一遍。生成的代码形态:

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 有两种写法:

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 的贡献:

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 换成一个完全不同的实现:

// 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 里生成的初始化器源码。

替换后的实现:

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 的双路径

@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 四个实现对照

实现触发条件生成什么省掉什么
ConfigurationPropertiesBeanRegistrationAotProcessorBindMethod.VALUE_OBJECTConstructorBound.from(...)绑定方式判定
JacksonMixinModuleEntriesBeanRegistrationAotProcessorbean 类是 JacksonMixinModuleEntries硬编码 mixins.and(...)mixin 扫描
JsonMixinModuleEntriesBeanRegistrationAotProcessor(jackson2)同类同类同类
ResourceProviderCustomizerBeanRegistrationAotProcessorbean 类是 ResourceProviderCustomizernew 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 类的骨架

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 挂载点

@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

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

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:沿类层级与字段收集

@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 属性时又是什么?

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 的三级推断:

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:

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:只注册真正需要的类型

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 序列化:

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:强制内容一致

这是本节最精妙的一段:

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:反序列化后的两个修正

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,方向相反,目标一致:让反序列化出来的树处于"该处理的都处理、不该处理的都跳过"的正确初始状态。 消费端:

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

@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

主流程:

@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,源不存在时直接返回。

参数拼装:

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 构造值得单独看:

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:

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。

@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 任务设计的好纪律。

@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 两侧差异小结

维度MavenGradle
进程 fork自己拼命令行 + JavaProcessExecutor复用 Gradle 的 JavaExec
编译生成源码插件内 ToolProvider.getSystemJavaCompiler()交给 Gradle 的 compileJava / 独立 source set
toolchain显式 ToolchainManagerGradle Java toolchain
缓存无(每次重跑)@CacheableTask
产物回拷copyAll 到 target/classes注册进 source set,自然参与打包

Gradle 的"交给构建系统"更简洁,Maven 的"自己干"更可控。两边都没有把 AOT 处理塞进构建 JVM——这一点是一致的,也必须是。


9. 测试的 AOT:SpringBootTestAotProcessor

测试也要 AOT:native 下的 @SpringBootTest 同样需要避免扫描。入口在 core/spring-boot-test:

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 成 args[0]。三个注解值得注意:@SkipWhenEmpty(没有测试类就不跑)、@IgnoreEmptyDirectories(空目录不算输入)、@PathSensitive(PathSensitivity.RELATIVE)(只关心相对路径,换机器构建缓存仍命中——绝对路径会因机器不同而失效)。.filter(File::exists) 排掉尚未生成的目录。

另外回忆 §7.10:测试 AOT 是最容易触发"多个上下文日志配置不一致"的场景——每个 @SpringBootTest 类一个上下文,配置稍有差异就 IllegalStateException。


10. Native Image 构建:NativeImageArgFile

第 27 章 §8.6 埋的伏笔在这里收口。承接对象是 Packager.writeNativeImageArgFile:

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 只有两个能力:

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:

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);
    }
}

每个被排除的库产出三行:

--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 改写实例化代码BeanRegistrationAotProcessorprocessAheadOfTime 开头尽快返回 null;用 withCustomCodeFragments 装饰器
在整个工厂层面做贡献BeanFactoryInitializationAotProcessor返回 null 表示无贡献
把某个 bean 排除出 AOTBeanRegistrationExcludeFilter同在 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. 本章未逐行核实 / 待你实测的项

  1. AbstractAotProcessor.AOT_PROCESSING 的字面值(§1.1)在 spring-context 中,本仓库无源码。自测:AOT 进程里 dump 全部系统属性。
  2. AotDetector.useGeneratedArtifacts() 的判定依据(§1.1)——推测是读 AotDetector.AOT_ENABLED 系统属性(测试里这么用),但 spring-core 源码不在本仓库。自测:System.setProperty(AotDetector.AOT_ENABLED, "true") 后跑一次普通启动。
  3. handleRunFailure 调用方拿到返回值后的行为(§2.5)——本轮只确认了方法首行返回 abandonedRunException。自测:AOT 处理时开 DEBUG,看是否出现 "Application run failed"。
  4. withHook 的 ThreadLocal 字段与 SingleUseSpringApplicationHook.getRunListener 的二次调用行为(§2.7)。自测:写一个 main 里连续创建两个 SpringApplication 的样例跑 AOT。
  5. AotInitializerNotFoundException 的完整消息拼装(§3.2)。自测:AOT 模式下运行未生成产物的应用,读异常全文。
  6. BindableRuntimeHintsRegistrar 的递归规则(§5.3)——泛型、集合元素、嵌套类、接口如何处理。
  7. getTarget 默认行为与覆写后的差异(§6.5)——为何 Flyway 那个实现不覆写。
  8. NativeImageResourceProvider.initialize() 如何枚举 native 资源(§6.6)。
  9. ReflectionUtils.doWithFields 是否遍历父类字段(§7.6)——若否,则外层 while 循环正是为此存在。
  10. EnvironmentPostProcessorApplicationListener 里"按 key 取出 contribution"的消费方是谁(§7.3)——推测在 spring-context 的 AOT 驱动代码里,本仓库看不到。
  11. Gradle ProcessAot 三个输出目录的实际路径(§0)——由 source set 注册决定,未读 SpringBootPlugin 源码确认。
  12. 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 服务端如何与浏览器插件协作。