Средства поддержки AOT-оптимизации в Spring предназначены для просмотра ApplicationContext во время сборки, а также применения решений и логики обнаружения, которые обычно имеют место во время выполнения. Это позволяет создать более простую схему запуска приложения, ориентированную на фиксированный набор функций, основанных в основном на classpath и Environment.

Применение такой оптимизации на ранних стадиях подразумевает следующие ограничения:

  • Classpath фиксирован и полностью определен во время сборки.

  • Бины, определенные в приложении, нельзя изменять во время выполнения, а это значит, что:

    • @Profile, в частности, конфигурацию, характерная для конкретного профиля, необходимо выбрать во время сборки.

    • Свойства окружения, влияющие на наличие бина (аннотация @Conditional), учитываются только во время сборки.

Если эти ограничения были введены, становится возможным выполнять обработку перед выполнением во время сборки и генерировать дополнительные ресурсы. Обрабатываемое перед выполнением приложение Spring обычно генерирует:

  • • Исходный Java-код

  • • Байт-код (обычно для динамических прокси)

  • RuntimeHints для использования отражения, загрузки ресурсов, сериализации и прокси JDK.

На данный момент принцип AOT ориентирован на то, чтобы обеспечить возможность развёртки приложений Spring в виде нативных образов через GraalVM. Мы намерены обеспечить поддержку большего количества сценариев использования на базе JVM в будущих поколениях.

Обзор AOT-движка

Точкой входа AOT-движка для обработки компоновки ApplicationContext является ApplicationContextAotGenerator. Он выполняет следующие шаги, основываясь на GenericApplicationContext, который представляет оптимизируемое приложение, и GenerationContext:

  • Обновление ApplicationContext для AOT-обработки. В отличие от традиционного обновления, эта версия создает только определения бинов, а не их экземпляры.

  • Вызов доступных реализаций BeanFactoryInitializationAotProcessor и применение результатов их работы к GenerationContext. Например, основная реализация осуществляет перебор всех определений бинов-кандидатов и генерирует необходимый код для восстановления состояния BeanFactory.

После завершения этого процесса GenerationContext будет обновлен сгенерированным кодом, ресурсами и классами, необходимыми для запуска приложения. Экземпляр RuntimeHints также можно использовать для генерации соответствующих конфигурационных файлов нативного образа GraalVM.

ApplicationContextAotGenerator#processAheadOfTime возвращает имя класса точки входа ApplicationContextInitializer, которая позволяет запустить контекст с AOT-оптимизацией.

Более подробно эти шаги описаны в следующих разделах.

Обновление для обработки AOT

Обновление для обработки AOT поддерживается во всех реализациях GenericApplicationContext. Контекст приложения создается с помощью любого количества точек входа, обычно в виде классов с добавлением аннотации @Configuration.

Давайте рассмотрим базовый пример:

@Configuration(proxyBeanMethods=false)
@ComponentScan
@Import({DataSourceConfiguration.class, ContainerConfiguration.class})
public class MyApplication {
}

Запуск этого приложения с помощью обычной среды выполнения предусматривает ряд этапов, среди которых в том числе сканирование classpath, парсинг конфигурационного класса, создание экземпляров бинов и обработка обратных вызовов жизненного цикла. В процессе обновления при AOT-обработке применяется только часть всех процессов, которые имеют место при обычном обновление. AOT-обработку можно запустить следующим образом:

RuntimeHints hints = new RuntimeHints();
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
context.register(MyApplication.class);
context.refreshForAotProcessing(hints);

В этом режиме реализации BeanFactoryPostProcessor вызываются в обычном порядке. Этот процесс предусматривает парсинг конфигурационных классов, использование селекторов импорта, сканирование classpath и т.д. Такие шаги позволяют убедиться, что BeanRegistry содержит соответствующие определения бинов для приложения. Если определения бинов защищены условиями (например, аннотацией @Profile), они на данном этапе игнорируются.

Поскольку в этом режиме экземпляры бинов фактически не создаются, реализации BeanPostProcessor не будут вызываться, за исключением специфических вариантов, имеющих отношение к AOT-обработке. К ним относятся:

  • Реализации MergedBeanDefinitionPostProcessor, которые осуществляют постобработку определений бинов для извлечения дополнительных параметров, таких как методы init и destroy.

  • Реализации SmartInstantiationAwareBeanPostProcessor, которые при необходимости определяют более точный тип бина. Это позволяет создавать все необходимые во время выполнения прокси.

После завершения этой части BeanFactory будет содержать определения бинов, которые необходимы для работы приложения. Эта фабрика бинов не запускает создание экземпляров бинов, но позволяет AOT-движку просматривать бины, которые будут созданы во время выполнения.

Инициализация фабрики бинов для AOT-вкладов

Компоненты, которые будут принимать участие в работе на этом этапе, могут реализовать интерфейс BeanFactoryInitializationAotProcessor. Каждая реализация может возвращать AOT-вклад на основании состояния фабрики бинов.

АOT-вклад – это компонент, который вносит сгенерированный код, воспроизводящий определенную логику работы. Он также может вносить RuntimeHints, чтобы обозначить необходимость в отражении, загрузке ресурсов, сериализации или создании прокси JDK.

Реализация BeanFactoryInitializationAotProcessor может быть зарегистрирована в META-INF/spring/aot.factories с ключом, эквивалентным полному имени интерфейса.

BeanFactoryInitializationAotProcessor также может быть реализован непосредственно самим бином. В этом режиме бин обеспечивает AOT-вклад, эквивалентный тому, который он обеспечивает в обычной среде выполнения. Следовательно, такой бин автоматически исключается из оптимизируемого по принципу AOT контекста.

Если бин реализует интерфейс BeanFactoryInitializationAotProcessor, то бин и все его зависимости будут инициализированы во время AOT-обработки. Обычно мы рекомендуем, чтобы этот интерфейс реализовывался только инфраструктурными бинами, такими как BeanFactoryPostProcessor, которые имеют ограниченные зависимости и уже инициализированы на ранних стадиях жизненного цикла фабрики бинов. Если такой бин регистрируется с помощью фабричного метода с аннотацией @Bean, убедитесь, что этот метод является статичным, чтобы не пришлось инициализировать его вложенный класс с аннотацией @Configuration.

Регистрация бинов для АОТ-вкладов

Основная реализация BeanFactoryInitializationAotProcessor отвечает за сбор необходимых вкладов для каждого кандидата BeanDefinition. Для этого используется специализированный BeanRegistrationAotProcessor.

Этот интерфейс используется следующим образом:

  • Реализуется бином BeanPostProcessor, чтобы заменить его логику работы во время выполнения. Например, AutowiredAnnotationBeanPostProcessor реализует этот интерфейс для генерации кода, который внедряет члены, помеченные аннотацией @Autowired.

  • Реализуется типом, зарегистрированным в META-INF/spring/aot.factories с ключом, эквивалентным полному имени интерфейса. Обычно используется, если определение бина необходимо подстроить под характерные особенности основного фреймворка.

Если бин реализует интерфейс BeanRegistrationAotProcessor, то бин и все его зависимости будут инициализированы во время AOT-обработки. Обычно мы рекомендуем, чтобы этот интерфейс реализовывался только инфраструктурными бинами, такими как BeanFactoryPostProcessor, которые имеют ограниченные зависимости и уже инициализированы на ранних стадиях жизненного цикла фабрики бинов. Если такой бин регистрируется с помощью фабричного метода с аннотацией @Bean, убедитесь, что этот метод является статичным, чтобы не пришлось инициализировать его вложенный класс с аннотацией @Configuration.

Если ни один BeanRegistrationAotProcessor не обрабатывает конкретный зарегистрированный бин, его обрабатывает реализация по умолчанию. Это является логикой работы по умолчанию, поскольку настройка сгенерированного кода для определения бина должна быть ограничена тупиковыми ситуациями.

Взяв наш предыдущий пример, предположим, что DataSourceConfiguration выглядит следующим образом:

Java
@Configuration(proxyBeanMethods = false)
public class DataSourceConfiguration {
    @Bean
    public SimpleDataSource dataSource() {
        return new SimpleDataSource();
    }
}

Поскольку к этому классу не применяется никаких особых условий, dataSourceConfiguration и dataSource определяются как кандидаты. AOT-движок преобразует приведенный выше конфигурационный класс в код, подобный следующему:

Java
/**
 * Определения бинов для {@link DataSourceConfiguration}
 */
public class DataSourceConfiguration__BeanDefinitions {
    /**
     * Получаем определение бина для 'dataSourceConfiguration'.
     */
    public static BeanDefinition getDataSourceConfigurationBeanDefinition() {
        Class<?> beanType = DataSourceConfiguration.class;
        RootBeanDefinition beanDefinition = new RootBeanDefinition(beanType);
        beanDefinition.setInstanceSupplier(DataSourceConfiguration::new);
        return beanDefinition;
    }
    /**
     * Получаем экземпляр бина поставщика для "dataSource".
     */
    private static BeanInstanceSupplier<SimpleDataSource> getDataSourceInstanceSupplier() {
        return BeanInstanceSupplier.<SimpleDataSource>forFactoryMethod(DataSourceConfiguration.class, "dataSource")
                .withGenerator((registeredBean) -> registeredBean.getBeanFactory().getBean(DataSourceConfiguration.class).dataSource());
    }
    /**
     * Получаем определение бина для "dataSource".
     */
    public static BeanDefinition getDataSourceBeanDefinition() {
        Class<?> beanType = SimpleDataSource.class;
        RootBeanDefinition beanDefinition = new RootBeanDefinition(beanType);
        beanDefinition.setInstanceSupplier(getDataSourceInstanceSupplier());
        return beanDefinition;
    }
}
Точный сгенерированный код может отличаться в зависимости от конкретной природы определений бинов.

Сгенерированный выше код создает определения бинов, эквивалентные классу с аннотацией @Configuration, но прямым способом и без использования отражения, если это вообще возможно. Будет существовать определение бина для dataSourceConfiguration и одно для dataSourceBean. Если потребуется экземпляр datasource, будет вызван BeanInstanceSupplier. Этот поставщик вызывает метод dataSource() в бинах dataSourceConfiguration.

Подсказки во время выполнения

Запуск приложения в виде нативного образа требует дополнительной информации в сравнении с обычным выполнением на JVM. Например, GraalVM необходимо заранее знать, использует ли компонент отражение. Аналогично, ресурсы classpath не поставляются в нативном образе, если это не задано явным образом. Следовательно, если приложению необходимо загрузить ресурс, на него нужно сослаться из соответствующего конфигурационного файла нативного образа GraalVM.

RuntimeHints API собирает информацию о необходимости отражения, загрузки ресурсов, сериализации и создания прокси JDK во время выполнения. В следующем примере показано, что config/app.properties гарантированно будет загружен из classpath во время выполнения в рамках нативного образа:

Java
runtimeHints.resources().registerPattern("config/app.properties");

Ряд контрактов обрабатывается автоматически во время AOT-обработки. Например, проверяется возвращаемый тип метода с аннотацией @Controller, а также добавляются соответствующие подсказки отражения, если Spring обнаруживает, что тип необходимо сериализовать (обычно в JSON).

В случаях, если основной контейнер не может выполнить вывод, можно зарегистрировать такие подсказки программно. Также предусмотрен ряд вспомогательных аннотаций для распространенных сценариев использования.

Аннотация @ImportRuntimeHints

Реализации RuntimeHintsRegistrar позволяют получить обратный вызов экземпляра RuntimeHints, управляемого AOT-движком. Реализации этого интерфейса могут быть зарегистрированы через снабжение аннотацией @ImportRuntimeHints любого бина Spring или фабрики бинов, помеченной аннотацией @Bean. Реализации RuntimeHintsRegistrar обнаруживаются и вызываются во время сборки.

import java.util.Locale;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.hint.RuntimeHintsRegistrar;
import org.springframework.context.annotation.ImportRuntimeHints;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
@Component
@ImportRuntimeHints(SpellCheckService.SpellCheckServiceRuntimeHints.class)
public class SpellCheckService {
    public void loadDictionary(Locale locale) {
        ClassPathResource resource = new ClassPathResource("dicts/" + locale.getLanguage() + ".txt");
        //...
    }
    static class SpellCheckServiceRuntimeHints implements RuntimeHintsRegistrar {
        @Override
        public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
            hints.resources().registerPattern("dicts/*");
        }
    }
}

Если это вообще возможно, аннотацию @ImportRuntimeHints следует использовать как можно ближе к компоненту, которому требуются подсказки. Таким образом, если компонент не будет внесен в BeanFactory, подсказки также не будут внесены.

Также можно зарегистрировать реализацию статически, добавив запись в META-INF/spring/aot.factories с ключом, эквивалентным полному имени интерфейса RuntimeHintsRegistrar.

Аннотация @Reflective

Аннотация @Reflective позволяет идиоматическим способом помечать необходимость отражения для аннотированного элемента. Например, аннотация @EventListener мета-аннотируется аннотацией @Reflective, поскольку базовая реализация вызывает аннотированный метод через отражение.

По умолчанию учитываются только бины Spring, а для аннотированного элемента регистрируется подсказка вызова. Эту логику работы можно тонко настроить, задав кастомную реализацию ReflectiveProcessor через аннотацию @Reflective.

Авторы библиотек могут повторно использовать эту аннотацию в своих целях. Если необходимо обработать компоненты, отличные от бинов Spring, BeanFactoryInitializationAotProcessor может определить соответствующие типы и использовать ReflectiveRuntimeHintsRegistrar для их обработки.

Аннотация @RegisterReflectionForBinding

Аннотация @RegisterReflectionForBinding – это конкретизация аннотации @Reflective, которая регистрирует необходимость сериализации произвольных типов. Типичным случаем является использование DTO, которые контейнер не может вывести, например, при использовании веб-клиента в теле метода.

Аннотацию @RegisterReflectionForBinding можно применить к любому бину Spring на уровне класса, но также её можно применить и непосредственно к методу, полю или конструктору, чтобы лучше обозначить, где именно будут необходимы подсказки. В следующем примере регистрируется Account для сериализации.

Java
@Component
public class OrderService {
    @RegisterReflectionForBinding(Account.class)
    public void process(Order order) {
        // ...
    }
}

Подсказки для тестирования во время выполнения

В составе Spring Core также поставляется RuntimeHintsPredicates, утилита для проверки соответствия существующих подсказок определенному сценарию использования. Её можно использовать в собственных тестах, чтобы валидировать, что RuntimeHintsRegistrar содержит ожидаемые результаты. Мы можем написать тест для нашего SpellCheckService и убедиться, что сможем загрузить словарь во время выполнения:

@Test
void shouldRegisterResourceHints() {
    RuntimeHints hints = new RuntimeHints();
    new SpellCheckServiceRuntimeHints().registerHints(hints, getClass().getClassLoader());
    assertThat(RuntimeHintsPredicates.resource().forResource("dicts/en.txt"))
            .accepts(hints);
}

При помощи RuntimeHintsPredicates можно проверять наличие подсказок, сигнализирующих о необходимости отражения, заргрузки ресурсов, сериализации или генерации прокси. Этот подход отлично подходит для модульных тестов, но подразумевает, что логика работы компонента во время выполнения должна быть хорошо изучена.

Можно узнать больше о глобальной логике работы приложения во время выполнения, запустив его тестовый комплект (или само приложение) с использованием агента трассировки GraalVM. Этот агент будет регистрировать все соответствующие вызовы, требующие вывода подсказок из GraalVM во время выполнения, и записывать их в виде конфигурационных файлов в формате JSON.

Для более целенаправленного обнаружения и тестирования в составе Spring Framework поставляется специальный модуль, содержащий основные утилиты для AOT-тестирования, "org.springframework:spring-core-test". Этот модуль содержит RuntimeHints Agent, Java-агент, который регистрирует все вызовы методов, связанные с подсказками среды выполнения, и помогает добавить утверждение о том, что данный экземпляр RuntimeHints охватывает все зарегистрированные вызовы. Рассмотрим часть инфраструктуры, для которой мы хотели бы протестировать подсказки, вносимые на этапе AOT-обработки.

import java.lang.reflect.Method;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.util.ClassUtils;
public class SampleReflection {
    private final Log logger = LogFactory.getLog(SampleReflection.class);
    public void performReflection() {
        try {
            Class<?> springVersion = ClassUtils.forName("org.springframework.core.SpringVersion", null);
            Method getVersion = ClassUtils.getMethod(springVersion, "getVersion");
            String version = (String) getVersion.invoke(null);
            logger.info("Spring version:" + version);
        }
        catch (Exception exc) {
            logger.error("reflection failed", exc);
        }
    }
}

Затем можно написать модульный тест (не требующий компиляции), который проверит внесенные нами подсказки:

import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.aot.hint.ExecutableMode;
import org.springframework.aot.hint.RuntimeHints;
import org.springframework.aot.test.agent.EnabledIfRuntimeHintsAgent;
import org.springframework.aot.test.agent.RuntimeHintsInvocations;
import org.springframework.aot.test.agent.RuntimeHintsRecorder;
import org.springframework.core.SpringVersion;
import static org.assertj.core.api.Assertions.assertThat;
// Аннотация @EnabledIfRuntimeHintsAgent сообщает о том, что аннотированный тестовый класс или тестовый
// метод должны активироваться только в том случае, если RuntimeHintsAgent был загружен в текущей JVM.
// Эта аннотация также помечает тесты JUnit-тегом "RuntimeHints".
@EnabledIfRuntimeHintsAgent
class SampleReflectionRuntimeHintsTests {
    @Test
    void shouldRegisterReflectionHints() {
        RuntimeHints runtimeHints = new RuntimeHints();
        // Вызываем RuntimeHintsRegistrar, который вносит подсказки типа:
        runtimeHints.reflection().registerType(SpringVersion.class, typeHint ->
                typeHint.withMethod("getVersion", List.of(), ExecutableMode.INVOKE));
        // Обращаемся к соответствующему фрагменту кода, который необходимо протестировать, в рамках записи лямбда-выражения
        RuntimeHintsInvocations invocations = RuntimeHintsRecorder.record(() -> {
            SampleReflection sample = new SampleReflection();
            sample.performReflection();
        });
        // добавляем утверждение, что на зарегистрированные вызовы распространяются внесенные подсказки
        assertThat(invocations).match(runtimeHints);
    }
}

Если вы забыли внести подсказку, тест завершится с ошибкой и выдаст некоторые сведения о вызове:

org.springframework.docs.core.aot.hints.testing.SampleReflection performReflection
INFO: Spring version:6.0.0-SNAPSHOT
Missing <"ReflectionHints"> for invocation <java.lang.Class#forName>
with arguments ["org.springframework.core.SpringVersion",
    false,
    jdk.internal.loader.ClassLoaders$AppClassLoader@251a69d7].
Stacktrace:
<"org.springframework.util.ClassUtils#forName, Line 284
io.spring.runtimehintstesting.SampleReflection#performReflection, Line 19
io.spring.runtimehintstesting.SampleReflectionRuntimeHintsTests#lambda$shouldRegisterReflectionHints$0, Line 25

Существуют различные способы конфигурирования этого Java-агента в вашей сборке, поэтому, пожалуйста, обратитесь к документации для вашего средства сборки и плагина выполнения тестов. Сам агент можно сконфигурировать на инструментирование определенных пакетов (по умолчанию инструментирование осуществляется только для org.springframework). Более подробную информацию вы найдете в README-файле к buildSrc для Spring Framework .