Read OSS

Ahead-of-Time コンパイル:Spring がネイティブイメージ向けにコードを生成する仕組み

上級

前提知識

  • 第2回:BeanFactory と BeanDefinition の概念
  • 第3回:ConfigurationClassPostProcessor と refresh() のシーケンス
  • GraalVM ネイティブイメージの制約についての基本的な理解

Ahead-of-Time コンパイル:Spring がネイティブイメージ向けにコードを生成する仕組み

このシリーズを通じて、Spring がいかに実行時の動的処理に依存しているかを見てきました。第2回のリフレクションを使った Bean 生成、第3回のアノテーションスキャン、第4回の動的プロキシ生成がその代表例です。しかしこれらはいずれも、GraalVM ネイティブイメージとは相性が悪い技術です。ネイティブイメージは「クローズドワールド仮定」を前提としており、実行時に使われるものすべてがビルド時に判明している必要があります。Spring Framework の AOT エンジンは、動的な処理を静的な代替手段に置き換える Java ソースコードを生成することで、この溝を埋めます。本記事では、そのパイプライン全体を追っていきます。

問題の本質:なぜネイティブイメージには AOT が必要か

GraalVM のネイティブイメージコンパイラは、積極的にデッドコードを除去し、クラスをビルド時に事前初期化します。そのためには、以下の情報をあらかじめ把握しておく必要があります。

  • リフレクションで参照されるクラス
  • クラスパスから読み込まれるリソース
  • 動的に生成されるプロキシ
  • 使用されるシリアライゼーション形式

第3回で解説した従来の Spring 起動シーケンスは、これらすべての前提を破っています。ConfigurationClassPostProcessor は実行時にクラスパスをスキャンし、AbstractAutowireCapableBeanFactory はリフレクションで Bean を生成し、DefaultAopProxyFactory は CGLIB プロキシを動的に生成します。

AotDetector は、これら2つの世界を切り替えるスイッチです。

spring-core/src/main/java/org/springframework/aot/AotDetector.java#L31-L54

public static boolean useGeneratedArtifacts() {
    return (inNativeImage || SpringProperties.getFlag(AOT_ENABLED));
}
flowchart TD
    Start["Application Starts"]
    
    Start --> Check{"AotDetector<br/>.useGeneratedArtifacts()?"}
    
    Check -->|"false (JVM mode)"| Traditional["Traditional startup:<br/>classpath scanning,<br/>reflection-based bean creation,<br/>dynamic proxy generation"]
    
    Check -->|"true (native or AOT mode)"| AOT["AOT startup:<br/>load generated code,<br/>pre-computed bean definitions,<br/>pre-built proxy classes"]
    
    Traditional --> Runtime["Runtime: Full Spring flexibility"]
    AOT --> Native["Runtime: Faster startup,<br/>lower memory, no reflection"]

検出の仕組みはシンプルです。NativeDetector が GraalVM ネイティブイメージ内での実行を検出するか、システムプロパティ spring.aot.enabled が設定されているかのどちらかです。AOT モードが有効な場合、生成されたコードが動的な設定処理パイプライン全体を置き換えます。

RuntimeHints:ネイティブイメージが必要とするものを宣言する

リフレクションやリソースアクセスをすべて排除できるわけではありません。フレームワークやライブラリによっては、実行時にそれらが正当に必要な場合もあります。RuntimeHints は、そうした要件を Spring が一元管理するレジストリです。

spring-core/src/main/java/org/springframework/aot/hint/RuntimeHints.java#L34-L80

classDiagram
    class RuntimeHints {
        +reflection() ReflectionHints
        +resources() ResourceHints
        +proxies() ProxyHints
        +jni() ReflectionHints
    }
    
    class ReflectionHints {
        +registerType(Class, MemberCategory...)
        +registerConstructor(Constructor, ExecutableMode)
        +registerMethod(Method, ExecutableMode)
    }
    
    class ResourceHints {
        +registerPattern(String)
        +registerResourceBundle(String)
    }
    
    class ProxyHints {
        +registerJdkProxy(Class... interfaces)
    }
    
    RuntimeHints --> ReflectionHints
    RuntimeHints --> ResourceHints
    RuntimeHints --> ProxyHints

コンポーネントがヒントを登録する方法はいくつかあります。

  1. RuntimeHintsRegistrar の実装 — AOT 処理中に呼び出されるコールバックインターフェース
  2. @Reflective@RegisterReflectionForBinding — リフレクションの要件を宣言するアノテーション
  3. BeanRegistrationAotProcessor による自動登録 — Bean 定義を解析してヒントを登録するプロセッサ

ビルドシステムには検証の仕組みも備わっています。buildSrc 内の RuntimeHintsAgentPlugin は、テスト実行時にヒントの完全性を検証する Java エージェントを設定します。

buildSrc/src/main/java/org/springframework/build/hint/RuntimeHintsAgentPlugin.java#L42-L60

RuntimeHintsTests タグが付いたテストはこのエージェントと共に実行され、エージェントはリフレクションの呼び出しを傍受して、対応するヒントが登録されているかを検証します。ヒントでカバーされていないリフレクション呼び出しがあると、テストが失敗します。これにより、ネイティブイメージの互換性は「うまくいくといいな」という願望から、テスト可能で CI が保証する確かな品質へと変わります。

AOT 処理パイプライン:Bean 定義から生成コードへ

AOT パイプラインはビルド時に動作し、通常は実行時に使われる Bean 定義と同じものを処理します。その中心となるインターフェースが BeanRegistrationAotProcessor です。

spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanRegistrationAotProcessor.java#L50-L90

sequenceDiagram
    participant Build as Build Tool
    participant AOT as AOT Engine
    participant BRAP as BeanRegistrationAotProcessor
    participant Contrib as BeanRegistrationAotContribution
    participant GenCtx as GenerationContext
    participant ISCG as InstanceSupplierCodeGenerator
    participant JP as JavaPoet (vendored)

    Build->>AOT: Process application context
    AOT->>AOT: Simulate refresh() up to step 5
    Note right of AOT: Discovers all bean definitions<br/>without instantiating singletons
    
    loop For each RegisteredBean
        AOT->>BRAP: processAheadOfTime(registeredBean)
        BRAP-->>AOT: BeanRegistrationAotContribution
    end
    
    loop For each Contribution
        AOT->>Contrib: applyTo(generationContext, code)
        Contrib->>GenCtx: getGeneratedClasses()
        Contrib->>ISCG: generateCode(generatedMethods)
        ISCG->>JP: Build Java source with CodeBlock
        JP-->>ISCG: MethodSpec
        ISCG-->>Contrib: MethodReference
    end
    
    AOT->>GenCtx: Write all generated .java files
    Build->>Build: Compile generated sources

処理は3つのフェーズで構成されます。

フェーズ1 — 探索:AOT エンジンはアプリケーションの起動をシミュレートし、ConfigurationClassPostProcessor などの BeanFactoryPostProcessor Bean を実行してすべての Bean 定義を探索します。これは第3回で解説した refresh() のステップ1〜5と同じ処理です。

フェーズ2 — コントリビューション生成:探索された Bean ごとに、エンジンが BeanRegistrationAotProcessor.processAheadOfTime() を呼び出します。デフォルトのプロセッサは Bean 定義のコンストラクタ、ファクトリメソッド、プロパティ値を調べ、登録コードの生成方法を知っている BeanRegistrationAotContribution を生成します。

フェーズ3 — コード出力:各コントリビューションは AOT コード生成の中心インターフェースである GenerationContext を使います。

spring-core/src/main/java/org/springframework/aot/generate/GenerationContext.java#L43-L57

GenerationContext は3つのものを提供します。

  1. GeneratedClasses — 新しい Java クラスを生成するための名前空間
  2. GeneratedFiles — リソースファイル、バイトコード、ソースコードの書き出し
  3. RuntimeHints — リフレクション/リソース/プロキシのヒント登録

実際の Bean インスタンス化コードを生成する主役が InstanceSupplierCodeGenerator です。

spring-beans/src/main/java/org/springframework/beans/factory/aot/InstanceSupplierCodeGenerator.java#L17-L59

このクラスは、第1回のシャドウ/リパックのセクションで触れた vendored JavaPoet ライブラリ(org.springframework.javapoet 名前空間)を使って Java ソースコードを出力します。たとえば次のように定義された Bean に対して:

@Bean
public MyService myService(MyRepository repo) {
    return new MyService(repo);
}

ジェネレータは次のようなコードを生成します。

public static MyService getMyServiceInstance(RegisteredBean registeredBean) {
    return new MyService(registeredBean.getBeanFactory().getBean(MyRepository.class));
}

リフレクションも Method.invoke() もありません。GraalVM が静的に解析できる、シンプルな直接コンストラクタ呼び出しだけです。

BeanRegistrationsAotContribution は一括登録を担います。JVM の制限を回避するため、個々の Bean 登録をファイルあたり5,000件、メソッドあたり1,000件で分割しながら生成クラスにまとめます。

spring-beans/src/main/java/org/springframework/beans/factory/aot/BeanRegistrationsAotContribution.java#L51-L58

ヒント: アプリケーションが生成した AOT コードを確認するには、./gradlew processAot(Maven の場合は同等のコマンド)を実行し、build/generated/aotSources を参照してください。生成されたクラスは普通の Java ファイルです。読み解けますし、ブレークポイントも設定でき、AOT の問題をデバッグする際にも役立ちます。

ConfigurationClassPostProcessor の AOT 統合

ConfigurationClassPostProcessor は実行時の設定処理だけを担うクラスではありません。AOT コードの生成にも貢献します。インポート宣言を見ると、その二面性がよくわかります。

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassPostProcessor.java#L44-L75

インポートには GeneratedClassGeneratedMethodGenerationContextMethodReferenceRuntimeHintsReflectionHints と、AOT ツールキット一式が含まれています。このクラスは BeanRegistrationAotProcessorBeanFactoryInitializationAotProcessor の両方を実装しており、Bean 単位とファクトリ全体の両方のレベルでコードを生成します。

@Bean メソッドを持つ @Configuration クラスに対して、このポストプロセッサはファクトリメソッドを直接呼び出すコードを生成します。従来の @Configuration クラス処理を支えていた CGLIB エンハンスメントとリフレクションによるメソッド呼び出しが、これに置き換わります。

BeanRegistrar と JSpecify:コンパイル時の安全性を設計する

第3回で紹介した BeanRegistrar API は、AOT を最重要の関心事として設計されています。

spring-beans/src/main/java/org/springframework/beans/factory/BeanRegistrar.java#L22-L41

flowchart LR
    subgraph "Traditional @Bean"
        T1["@Bean method"] --> T2["Reflection to discover"]
        T2 --> T3["Reflection to invoke"]
        T3 --> T4["AOT: Generate code<br/>to replace reflection"]
    end
    
    subgraph "BeanRegistrar"
        B1["register(registry, env)"] --> B2["Direct API calls"]
        B2 --> B3["supplier lambda"]
        B3 --> B4["AOT: Already<br/>reflection-free"]
    end

BeanRegistrar はアノテーション付きメソッドではなく、明示的な API 呼び出しとサプライヤーラムダを使うため、AOT エンジンはほぼ変換なしに処理できます。supplier(context -> new Bar(context.bean(Foo.class))) というラムダはすでに直接コンストラクタ呼び出しであり、置き換えるべきリフレクションはありません。

BeanRegistrar と並行して、Spring 7.x では null 許容アノテーションを org.springframework.lang.@Nullable から JSpecify の org.jspecify.annotations.@Nullable へ移行しています。この移行を支援するのが Nullness ユーティリティクラスです。

spring-core/src/main/java/org/springframework/core/Nullness.java#L43-L60

この移行は、IDE サポートの向上だけが目的ではありません。JSpecify のアノテーションは静的解析ツールやコンパイラと連携するよう設計されており、null 安全性の違反をコンパイル時に検出できます。AOT の観点では、コードジェネレータが実行時チェックなしに null 許容性についてより強い仮定を置けるようになります。第1回で紹介した ArchUnit ルールは、org.springframework.lang の古い @Nullable@NonNull のインポートを禁止することで、この移行を強制しています。

flowchart TD
    subgraph "Spring's Compile-Time Safety Strategy"
        JSpecify["JSpecify @Nullable / @NullMarked"]
        BR["BeanRegistrar (no reflection)"]
        AOT["AOT Code Generation"]
        ArchUnit["ArchUnit enforcement"]
        
        JSpecify --> |"Enables"| StaticAnalysis["Compile-time null checks"]
        BR --> |"Enables"| NoReflect["Reflection-free bean creation"]
        AOT --> |"Produces"| Generated["Generated Java source"]
        ArchUnit --> |"Prevents"| OldAPI["Use of deprecated annotations"]
        
        StaticAnalysis --> Native["GraalVM Native Image"]
        NoReflect --> Native
        Generated --> Native
    end

全体像:動的から静的へ

このシリーズ全体を振り返ると、AOT の物語はすべての主要コンポーネントとつながっていることがわかります。

  1. 第1回(ビルドシステム):spring-core に vendored された JavaPoet ライブラリがコード生成エンジンです。RuntimeHintsAgentPlugin は CI でヒントの完全性を検証します。

  2. 第2回(IoC コンテナ)createBeanInstance() とリフレクションを使った Bean 生成パイプラインが、コンストラクタを直接呼び出す生成済み InstanceSupplier コードに置き換わります。

  3. 第3回(refresh() + 設定)ConfigurationClassPostProcessor の実行時クラスパススキャンが、生成コード内の事前計算済み Bean 定義に置き換わります。BeanRegistrar は本質的に AOT フレンドリーな代替手段を提供します。

  4. 第4回(AOP プロキシ):CGLIB プロキシクラスは実行時に動的に生成されるのではなく、ビルド時に生成されてネイティブイメージに組み込まれます。

  5. 第5回(Web ディスパッチ):WebFlux の関数型 RouterFunction モデルは、ルートがアノテーションスキャンで発見されるのではなくコードで定義されるため、本質的に AOT フレンドリーです。

AOT エンジンは単なる最適化ではありません。Spring の豊かな動的プログラミングモデルを、動的処理を禁じた環境でも動作させる、根本的なアーキテクチャの転換です。生成されたコードは、開発者体験(アノテーション、スキャン、プロキシ)と実行時効率(静的ディスパッチ、リフレクションなし、即時起動)の橋渡し役です。

ヒント: AOT の問題をデバッグする際、最も多い原因は RuntimeHints の登録漏れです。実行時にリフレクションが必要な Bean(たとえば Hibernate が解析する JPA エンティティ)がある場合は、そのタイプに対して RuntimeHintsRegistrar が登録されているか確認してください。@ImportRuntimeHints アノテーションを使えば、これを宣言的に行えます。

シリーズのまとめ

6本の記事を通じて、Spring Framework 全体をくまなく見てきました。22モジュール構成の Gradle モノレポとビルド規約から始まり、IoC コンテナのインターフェース階層と Bean 生成パイプライン、ブートストラップシーケンスと設定処理、AOP プロキシ基盤、2つの Web ディスパッチアーキテクチャ、そして AOT コンパイルエンジンまで。各レイヤーはその下のレイヤーの上に成り立っており、各アーキテクチャ上の決断には、全体像を把握して初めて明らかになる理由があります。

Spring Framework が長年にわたって支持され続ける理由は、この内部的な一貫性にあります。同じパターン(Template Method、Strategy、インターフェース分離)がすべてのレベルで繰り返し登場し、150万行というコードベースでも道に迷わず読み進められます。Javadoc で丁寧にドキュメント化されたソースコードが最良のリファレンスです。そして今、あなたはどこを見ればいいかを知っています。