diff --git a/spring-context/src/main/java/org/springframework/context/aot/AotProcessor.java b/spring-context/src/main/java/org/springframework/context/aot/AotProcessor.java new file mode 100644 index 0000000000..a28ee2dd42 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/context/aot/AotProcessor.java @@ -0,0 +1,215 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.context.aot; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.aot.generate.ClassNameGenerator; +import org.springframework.aot.generate.DefaultGenerationContext; +import org.springframework.aot.generate.FileSystemGeneratedFiles; +import org.springframework.aot.generate.GeneratedFiles.Kind; +import org.springframework.aot.hint.ExecutableMode; +import org.springframework.aot.hint.ReflectionHints; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.TypeReference; +import org.springframework.aot.nativex.FileNativeConfigurationWriter; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.javapoet.ClassName; +import org.springframework.util.CollectionUtils; +import org.springframework.util.FileSystemUtils; + +/** + * Filesystem-based ahead-of-time processing base implementation. Typically + * used to kick off the optimizations of an application in a build tool. + * + * @author Stephane Nicoll + * @author Andy Wilkinson + * @author Phillip Webb + * @since 6.0 + */ +public abstract class AotProcessor { + + private final Class application; + + private final Path sourceOutput; + + private final Path resourceOutput; + + private final Path classOutput; + + private final String groupId; + + private final String artifactId; + + /** + * Create a new instance. + * + * @param application the application entry point + * @param sourceOutput the location of generated sources + * @param resourceOutput the location of generated resources + * @param classOutput the location of generated classes + * @param groupId the group ID of the application, used to locate + * {@code native-image.properties} + * @param artifactId the artifact ID of the application, used to locate + * {@code native-image.properties} + */ + protected AotProcessor(Class application, Path sourceOutput, Path resourceOutput, + Path classOutput, String groupId, String artifactId) { + this.application = application; + this.sourceOutput = sourceOutput; + this.resourceOutput = resourceOutput; + this.classOutput = classOutput; + this.groupId = groupId; + this.artifactId = artifactId; + } + + /** + * Prepare the {@link GenericApplicationContext} for the specified + * application to be used against an {@link ApplicationContextAotGenerator}. + * @return a non-refreshed {@link GenericApplicationContext} + */ + protected abstract GenericApplicationContext prepareApplicationContext(Class application); + + + /** + * Invoke the processing by clearing target outputs first, followed by + * {@link #performAotProcessing(GenericApplicationContext)}. + * @return the {@code ClassName} of the {@code ApplicationContextInitializer} + * entry point + */ + public ClassName process() { + deleteExistingOutput(); + GenericApplicationContext applicationContext = prepareApplicationContext(this.application); + return performAotProcessing(applicationContext); + } + + /** + * Delete the source, resource and class outputs. + */ + protected void deleteExistingOutput() { + deleteExistingOutput(this.sourceOutput, this.resourceOutput, this.classOutput); + } + + /** + * Perform ahead-of-time processing on the specified context. Code, + * resources and generated classes are stored in the configured outputs. In + * particular, additional hints are registered for the application and its + * entry point. + * @param applicationContext the context to process. + */ + protected ClassName performAotProcessing(GenericApplicationContext applicationContext) { + FileSystemGeneratedFiles generatedFiles = new FileSystemGeneratedFiles(this::getRoot); + DefaultGenerationContext generationContext = new DefaultGenerationContext( + createClassNameGenerator(), generatedFiles); + ApplicationContextAotGenerator generator = new ApplicationContextAotGenerator(); + ClassName generatedInitializerClassName = generator.processAheadOfTime(applicationContext, generationContext); + registerEntryPointHint(generationContext, generatedInitializerClassName); + generationContext.writeGeneratedContent(); + writeHints(generationContext.getRuntimeHints()); + writeNativeImageProperties(getDefaultNativeImageArguments(this.application.getName())); + return generatedInitializerClassName; + } + + /** + * Callback to customize the {@link ClassNameGenerator}. By default, a + * standard {@link ClassNameGenerator} using the configured application + * as the default target is used. + * @return the class name generator + */ + protected ClassNameGenerator createClassNameGenerator() { + return new ClassNameGenerator(ClassName.get(this.application)); + } + + /** + * Return the native image arguments to use. By default, the main + * class to use, as well as standard application flags are added. + *

If the returned list is empty, no {@code native-image.properties} is + * contributed. + * @param application the application entry point + * @return the native image options to contribute + */ + protected List getDefaultNativeImageArguments(String application) { + List args = new ArrayList<>(); + args.add("-H:Class=" + application); + args.add("--report-unsupported-elements-at-runtime"); + args.add("--no-fallback"); + args.add("--install-exit-handlers"); + return args; + } + + private void registerEntryPointHint(DefaultGenerationContext generationContext, + ClassName generatedInitializerClassName) { + TypeReference generatedType = TypeReference.of(generatedInitializerClassName.canonicalName()); + TypeReference applicationType = TypeReference.of(this.application); + ReflectionHints reflection = generationContext.getRuntimeHints().reflection(); + reflection.registerType(applicationType); + reflection.registerType(generatedType, typeHint -> typeHint.onReachableType(applicationType) + .withConstructor(Collections.emptyList(), ExecutableMode.INVOKE)); + } + + private void writeHints(RuntimeHints hints) { + FileNativeConfigurationWriter writer = + new FileNativeConfigurationWriter(this.resourceOutput, this.groupId, this.artifactId); + writer.write(hints); + } + + private void writeNativeImageProperties(List args) { + if (CollectionUtils.isEmpty(args)) { + return; + } + StringBuilder sb = new StringBuilder(); + sb.append("Args = "); + sb.append(String.join(String.format(" \\%n"), args)); + Path file = this.resourceOutput + .resolve("META-INF/native-image/" + this.groupId + "/" + this.artifactId + "/native-image.properties"); + try { + if (!Files.exists(file)) { + Files.createDirectories(file.getParent()); + Files.createFile(file); + } + Files.writeString(file, sb.toString()); + } + catch (IOException ex) { + throw new IllegalStateException("Failed to write native-image properties", ex); + } + } + + private void deleteExistingOutput(Path... paths) { + for (Path path : paths) { + try { + FileSystemUtils.deleteRecursively(path); + } + catch (IOException ex) { + throw new RuntimeException("Failed to delete existing output in '" + path + "'"); + } + } + } + + private Path getRoot(Kind kind) { + return switch (kind) { + case SOURCE -> this.sourceOutput; + case RESOURCE -> this.resourceOutput; + case CLASS -> this.classOutput; + }; + } + +} diff --git a/spring-context/src/test/java/org/springframework/context/aot/AotProcessorTests.java b/spring-context/src/test/java/org/springframework/context/aot/AotProcessorTests.java new file mode 100644 index 0000000000..e5a6a957b6 --- /dev/null +++ b/spring-context/src/test/java/org/springframework/context/aot/AotProcessorTests.java @@ -0,0 +1,148 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.context.aot; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.javapoet.ClassName; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link AotProcessor}. + * + * @author Stephane Nicoll + */ +class AotProcessorTests { + + @Test + void processGeneratesAssets(@TempDir Path directory) { + GenericApplicationContext context = new AnnotationConfigApplicationContext(); + context.registerBean(SampleApplication.class); + AotProcessor processor = new TestAotProcessor(SampleApplication.class, directory); + ClassName className = processor.process(); + assertThat(className).isEqualTo(ClassName.get(SampleApplication.class.getPackageName(), + "AotProcessorTests_SampleApplication__ApplicationContextInitializer")); + assertThat(directory).satisfies(hasGeneratedAssetsForSampleApplication()); + } + + @Test + void processingDeletesExistingOutput(@TempDir Path directory) throws IOException { + Path sourceOutput = directory.resolve("source"); + Path resourceOutput = directory.resolve("resource"); + Path classOutput = directory.resolve("class"); + Path existingSourceOutput = createExisting(sourceOutput); + Path existingResourceOutput = createExisting(resourceOutput); + Path existingClassOutput = createExisting(classOutput); + AotProcessor processor = new TestAotProcessor(SampleApplication.class, + sourceOutput, resourceOutput, classOutput); + processor.process(); + assertThat(existingSourceOutput).doesNotExist(); + assertThat(existingResourceOutput).doesNotExist(); + assertThat(existingClassOutput).doesNotExist(); + } + + @Test + void processWithEmptyNativeImageArgumentsDoesNotCreateNativeImageProperties(@TempDir Path directory) { + GenericApplicationContext context = new AnnotationConfigApplicationContext(); + context.registerBean(SampleApplication.class); + AotProcessor processor = new TestAotProcessor(SampleApplication.class, directory) { + @Override + protected List getDefaultNativeImageArguments(String application) { + return Collections.emptyList(); + } + }; + processor.process(); + assertThat(directory.resolve("resource/META-INF/native-image/com.example/example/native-image.properties")) + .doesNotExist(); + } + + private Path createExisting(Path directory) throws IOException { + Path existing = directory.resolve("existing"); + Files.createDirectories(directory); + Files.createFile(existing); + return existing; + } + + private Consumer hasGeneratedAssetsForSampleApplication() { + return directory -> { + assertThat(directory.resolve( + "source/org/springframework/context/aot/AotProcessorTests_SampleApplication__ApplicationContextInitializer.java")) + .exists().isRegularFile(); + assertThat(directory.resolve("source/org/springframework/context/aot/AotProcessorTests__BeanDefinitions.java")) + .exists().isRegularFile(); + assertThat(directory.resolve( + "source/org/springframework/context/aot/AotProcessorTests_SampleApplication__BeanFactoryRegistrations.java")) + .exists().isRegularFile(); + assertThat(directory.resolve("resource/META-INF/native-image/com.example/example/reflect-config.json")) + .exists().isRegularFile(); + Path nativeImagePropertiesFile = directory + .resolve("resource/META-INF/native-image/com.example/example/native-image.properties"); + assertThat(nativeImagePropertiesFile).exists().isRegularFile().hasContent(""" + Args = -H:Class=org.springframework.context.aot.AotProcessorTests$SampleApplication \\ + --report-unsupported-elements-at-runtime \\ + --no-fallback \\ + --install-exit-handlers + """); + }; + } + + + private static class TestAotProcessor extends AotProcessor { + + public TestAotProcessor(Class application, + Path sourceOutput, Path resourceOutput, Path classOutput) { + super(application, sourceOutput, resourceOutput, classOutput, "com.example", "example"); + } + + public TestAotProcessor(Class application, Path rootPath) { + super(application, rootPath.resolve("source"), rootPath.resolve("resource"), + rootPath.resolve("class"), "com.example", "example"); + } + + @Override + protected GenericApplicationContext prepareApplicationContext(Class application) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register(application); + return context; + } + + } + + @Configuration(proxyBeanMethods = false) + static class SampleApplication { + + @Bean + public String testBean() { + return "Hello"; + } + + } + +}