From 09bd531fe5a2126c6c63521e4e16bc8b36b2d014 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 29 Aug 2022 21:23:39 -0700 Subject: [PATCH] Add Maven plugin support for processing test contexts Ahead-of-time Refactor and update the Spring Boot Maven Plugin so that it can be used to perform AOT processing of test classes. Closes gh-32191 --- .../build/mavenplugin/MavenPluginPlugin.java | 2 + .../spring-boot-starter-parent/build.gradle | 21 +- .../src/docs/asciidoc/aot.adoc | 28 ++- .../src/docs/maven/aot-test/pom.xml | 25 +++ .../{AotGenerateTests.java => AotTests.java} | 16 +- .../src/intTest/projects/aot-test/pom.xml | 66 ++++++ .../main/java/org/test/SampleApplication.java | 29 +++ .../java/org/test/SampleApplicationTests.java | 53 +++++ .../boot/maven/AbstractAotMojo.java | 190 ++++++++++++++++++ .../maven/AbstractDependencyFilterMojo.java | 25 ++- .../boot/maven/AbstractRunMojo.java | 2 +- .../boot/maven/ProcessAotMojo.java | 160 +-------------- .../boot/maven/ProcessTestAotMojo.java | 180 +++++++++++++++++ 13 files changed, 639 insertions(+), 158 deletions(-) create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/aot-test/pom.xml rename spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/{AotGenerateTests.java => AotTests.java} (85%) create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/aot-test/pom.xml create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/aot-test/src/main/java/org/test/SampleApplication.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/aot-test/src/test/java/org/test/SampleApplicationTests.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractAotMojo.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ProcessTestAotMojo.java diff --git a/buildSrc/src/main/java/org/springframework/boot/build/mavenplugin/MavenPluginPlugin.java b/buildSrc/src/main/java/org/springframework/boot/build/mavenplugin/MavenPluginPlugin.java index fd5eb5467e3..c182feb2e10 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/mavenplugin/MavenPluginPlugin.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/mavenplugin/MavenPluginPlugin.java @@ -446,6 +446,8 @@ public class MavenPluginPlugin implements Plugin { effectiveBom.property("spring-framework.version", versions::setProperty); effectiveBom.property("jakarta-servlet.version", versions::setProperty); effectiveBom.property("kotlin.version", versions::setProperty); + effectiveBom.property("assertj.version", versions::setProperty); + effectiveBom.property("junit-jupiter.version", versions::setProperty); return versions; } diff --git a/spring-boot-project/spring-boot-starters/spring-boot-starter-parent/build.gradle b/spring-boot-project/spring-boot-starters/spring-boot-starter-parent/build.gradle index 7581ab641b8..1434d63c940 100644 --- a/spring-boot-project/spring-boot-starters/spring-boot-starter-parent/build.gradle +++ b/spring-boot-project/spring-boot-starters/spring-boot-starter-parent/build.gradle @@ -132,6 +132,13 @@ publishing.publications.withType(MavenPublication) { delegate.useDefaultDelimiters('false') } } + plugin { + delegate.groupId('org.graalvm.buildtools') + delegate.artifactId('native-maven-plugin') + configuration { + delegate.extensions('true') + } + } plugin { delegate.groupId('io.github.git-commit-id') delegate.artifactId('git-commit-id-maven-plugin') @@ -226,6 +233,13 @@ publishing.publications.withType(MavenPublication) { profiles { profile { delegate.id("native") + delegate.dependencies { + dependency { + delegate.groupId('org.junit.platform') + delegate.artifactId('junit-platform-launcher') + delegate.scope('test') + } + } build { plugins { plugin { @@ -238,7 +252,12 @@ publishing.publications.withType(MavenPublication) { delegate.goal('process-aot') } } - } + execution { + delegate.id('process-test-aot') + goals { + delegate.goal('process-test-aot') + } + } } } plugin { delegate.groupId('org.graalvm.buildtools') diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/aot.adoc b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/aot.adoc index 88431f516fc..e569bded9e7 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/aot.adoc +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/asciidoc/aot.adoc @@ -1,7 +1,13 @@ [[aot]] -= Optimizing Your Application at Build-Time += Ahead-of-Time Processing +Spring AOT is a process that analyzes your code at build-time in order to generate an optimized version of it. +It is most often used to help generate GraalVM native images. -Spring AOT inspects an application at build-time and generates an optimized version of it. +The Spring Boot Maven plugin offers goals that can be used to perform AOT processing on both application and test code. + + + +== Processing Applications Based on your `@SpringBootApplication`-annotated main class, the AOT engine generates a persistent view of the beans that are going to be contributed at runtime in a way that bean instantiation is as straightforward as possible. Additional post-processing of the factory is possible using callbacks. For instance, these are used to generate the necessary reflection configuration that GraalVM needs to initialize the context in a native image. @@ -18,5 +24,21 @@ This has an important difference compared to what a regular Spring Boot applicat For instance, if you want to opt-in or opt-out for certain features, you need to configure the environment used at build time to do so. The `process-aot` goal shares a number of properties with the <> for that reason. - include::goals/process-aot.adoc[leveloffset=+1] + + + +== Processing Tests +The AOT engine can be applied to JUnit 5 tests that use Spring's Test Context Framework. +Suitable tests are processed by the AOT engine in order to generate `ApplicationContextInitialzer` code. + +To configure your application to use this feature, add an execution for the `process-test-aot` goal, as shown in the following example: + +[source,xml,indent=0,subs="verbatim,attributes",tabsize=4] +---- +include::../maven/aot-test/pom.xml[tags=aot] +---- + +As with application AOT processing, the `BeanFactory` is fully prepared at build-time. + +include::goals/process-test-aot.adoc[leveloffset=+1] diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/aot-test/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/aot-test/pom.xml new file mode 100644 index 00000000000..b20f0d0dcc8 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/docs/maven/aot-test/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + aot + + + + + org.springframework.boot + spring-boot-maven-plugin + + + process-test-aot + + process-test-aot + + + + + + + + + + diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/AotGenerateTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/AotTests.java similarity index 85% rename from spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/AotGenerateTests.java rename to spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/AotTests.java index a82ddf3cd4b..fc40812f28c 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/AotGenerateTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/java/org/springframework/boot/maven/AotTests.java @@ -33,7 +33,7 @@ import static org.assertj.core.api.Assertions.assertThat; * @author Andy Wilkinson */ @ExtendWith(MavenBuildExtension.class) -public class AotGenerateTests { +public class AotTests { @TestTemplate void whenAotRunsSourcesAreGenerated(MavenBuild mavenBuild) { @@ -113,6 +113,20 @@ public class AotGenerateTests { }); } + @TestTemplate + void whenAotTestRunsSourcesAndResourcesAreGenerated(MavenBuild mavenBuild) { + mavenBuild.project("aot-test").goals("test").execute((project) -> { + Path aotDirectory = project.toPath().resolve("target/spring-aot/test"); + assertThat(collectRelativePaths(aotDirectory.resolve("sources"))).contains(Path.of("org", "test", + "SampleApplicationTests__TestContext001_ApplicationContextInitializer.java")); + Path testClassesDirectory = project.toPath().resolve("target/test-classes"); + assertThat(collectRelativePaths(testClassesDirectory)).contains(Path.of("META-INF", "native-image", + "org.springframework.boot.maven.it", "aot-test", "reflect-config.json")); + assertThat(collectRelativePaths(testClassesDirectory)).contains(Path.of("org", "test", + "SampleApplicationTests__TestContext001_ApplicationContextInitializer.class")); + }); + } + Stream collectRelativePaths(Path sourceDirectory) { try { return Files.walk(sourceDirectory).filter(Files::isRegularFile) diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/aot-test/pom.xml b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/aot-test/pom.xml new file mode 100644 index 00000000000..88fb089d9f1 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/aot-test/pom.xml @@ -0,0 +1,66 @@ + + + 4.0.0 + org.springframework.boot.maven.it + aot-test + 0.0.1.BUILD-SNAPSHOT + + UTF-8 + @java.version@ + @java.version@ + + + + + @project.groupId@ + @project.artifactId@ + @project.version@ + + + + process-test-aot + + + + + + + + + org.springframework.boot + spring-boot + @project.version@ + + + jakarta.servlet + jakarta.servlet-api + @jakarta-servlet.version@ + provided + + + org.springframework + spring-test + @spring-framework.version@ + test + + + org.springframework.boot + spring-boot-test + @project.version@ + test + + + org.assertj + assertj-core + @assertj.version@ + test + + + org.junit.jupiter + junit-jupiter + @junit-jupiter.version@ + test + + + diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/aot-test/src/main/java/org/test/SampleApplication.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/aot-test/src/main/java/org/test/SampleApplication.java new file mode 100644 index 00000000000..f74b538faa3 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/aot-test/src/main/java/org/test/SampleApplication.java @@ -0,0 +1,29 @@ +/* + * Copyright 2012-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.test; + +import org.springframework.boot.SpringApplication; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +public class SampleApplication { + + public static void main(String[] args) { + SpringApplication.run(SampleApplication.class, args); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/aot-test/src/test/java/org/test/SampleApplicationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/aot-test/src/test/java/org/test/SampleApplicationTests.java new file mode 100644 index 00000000000..f47293f23b4 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/intTest/projects/aot-test/src/test/java/org/test/SampleApplicationTests.java @@ -0,0 +1,53 @@ +/* + * Copyright 2012-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.test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +@SpringJUnitConfig +class SampleApplicationTests { + + @Autowired + private MyBean myBean; + + @Test + void contextLoads() { + assertThat(this.myBean).isNotNull(); + } + + @Configuration + static class MyConfig { + + @Bean + MyBean myBean() { + return new MyBean(); + } + + } + + static class MyBean { + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractAotMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractAotMojo.java new file mode 100644 index 00000000000..35f33eabb0f --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractAotMojo.java @@ -0,0 +1,190 @@ +/* + * Copyright 2012-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.boot.maven; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import javax.tools.Diagnostic; +import javax.tools.DiagnosticListener; +import javax.tools.JavaCompiler; +import javax.tools.JavaCompiler.CompilationTask; +import javax.tools.JavaFileObject; +import javax.tools.StandardJavaFileManager; +import javax.tools.ToolProvider; + +import org.apache.maven.execution.MavenSession; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.MojoFailureException; +import org.apache.maven.plugins.annotations.Component; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.shared.artifact.filter.collection.ArtifactsFilter; +import org.apache.maven.toolchain.ToolchainManager; + +import org.springframework.boot.maven.CommandLineBuilder.ClasspathBuilder; + +/** + * Abstract base class for AOT processing MOJOs. + * + * @author Phillip Webb + * @since 3.0.0 + */ +public abstract class AbstractAotMojo extends AbstractDependencyFilterMojo { + + /** + * The current Maven session. This is used for toolchain manager API calls. + */ + @Parameter(defaultValue = "${session}", readonly = true) + private MavenSession session; + + /** + * The toolchain manager to use to locate a custom JDK. + */ + @Component + private ToolchainManager toolchainManager; + + /** + * Skip the execution. + */ + @Parameter(property = "spring-boot.aot.skip", defaultValue = "false") + private boolean skip; + + /** + * List of JVM system properties to pass to the AOT process. + */ + @Parameter + private Map systemPropertyVariables; + + /** + * JVM arguments that should be associated with the AOT process. On command line, make + * sure to wrap multiple values between quotes. + */ + @Parameter(property = "spring-boot.aot.jvmArguments") + private String jvmArguments; + + @Override + public void execute() throws MojoExecutionException, MojoFailureException { + if (this.skip) { + getLog().debug("Skipping AOT execution as per configuration"); + return; + } + try { + executeAot(); + } + catch (Exception ex) { + throw new MojoExecutionException(ex.getMessage(), ex); + } + } + + protected abstract void executeAot() throws Exception; + + protected void generateAotAssets(URL[] classPath, String processorClassName, String... arguments) throws Exception { + List command = CommandLineBuilder.forMainClass(processorClassName) + .withSystemProperties(this.systemPropertyVariables) + .withJvmArguments(new RunArguments(this.jvmArguments).asArray()).withClasspath(classPath) + .withArguments(arguments).build(); + if (getLog().isDebugEnabled()) { + getLog().debug("Generating AOT assets using command: " + command); + } + JavaProcessExecutor processExecutor = new JavaProcessExecutor(this.session, this.toolchainManager); + processExecutor.run(this.project.getBasedir(), command, Collections.emptyMap()); + } + + protected final void compileSourceFiles(URL[] classPath, File sourcesDirectory, File outputDirectory) + throws Exception { + List sourceFiles = Files.walk(sourcesDirectory.toPath()).filter(Files::isRegularFile).toList(); + if (sourceFiles.isEmpty()) { + return; + } + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + try (StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null)) { + List options = new ArrayList<>(); + options.add("-cp"); + options.add(ClasspathBuilder.build(Arrays.asList(classPath))); + options.add("-d"); + options.add(outputDirectory.toPath().toAbsolutePath().toString()); + Iterable compilationUnits = fileManager.getJavaFileObjectsFromPaths(sourceFiles); + Errors errors = new Errors(); + CompilationTask task = compiler.getTask(null, fileManager, errors, options, null, compilationUnits); + boolean result = task.call(); + if (!result || errors.hasReportedErrors()) { + throw new IllegalStateException("Unable to compile generated source" + errors); + } + } + } + + protected final URL[] getClassPath(File classesDirectory, ArtifactsFilter... artifactFilters) + throws MojoExecutionException { + List urls = new ArrayList<>(); + urls.add(toURL(classesDirectory)); + urls.addAll(getDependencyURLs(artifactFilters)); + return urls.toArray(URL[]::new); + } + + protected final void copyAll(Path from, Path to) throws IOException { + List files = (Files.exists(from)) ? Files.walk(from).filter(Files::isRegularFile).toList() + : Collections.emptyList(); + for (Path file : files) { + String relativeFileName = file.subpath(from.getNameCount(), file.getNameCount()).toString(); + getLog().debug("Copying '" + relativeFileName + "' to " + to); + Path target = to.resolve(relativeFileName); + Files.createDirectories(target.getParent()); + Files.copy(file, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + /** + * {@link DiagnosticListener} used to collect errors. + */ + protected static class Errors implements DiagnosticListener { + + private final StringBuilder message = new StringBuilder(); + + @Override + public void report(Diagnostic diagnostic) { + if (diagnostic.getKind() == Diagnostic.Kind.ERROR) { + this.message.append("\n"); + this.message.append(diagnostic.getMessage(Locale.getDefault())); + this.message.append(" "); + this.message.append(diagnostic.getSource().getName()); + this.message.append(" "); + this.message.append(diagnostic.getLineNumber()).append(":").append(diagnostic.getColumnNumber()); + } + } + + boolean hasReportedErrors() { + return this.message.length() > 0; + } + + @Override + public String toString() { + return this.message.toString(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractDependencyFilterMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractDependencyFilterMojo.java index 9de8ca7577f..5f534b913c7 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractDependencyFilterMojo.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractDependencyFilterMojo.java @@ -20,12 +20,14 @@ import java.io.File; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; +import java.util.Collection; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.StringTokenizer; import org.apache.maven.artifact.Artifact; +import org.apache.maven.artifact.resolver.filter.ArtifactFilter; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecutionException; import org.apache.maven.plugins.annotations.Parameter; @@ -157,9 +159,12 @@ public abstract class AbstractDependencyFilterMojo extends AbstractMojo { return cleaned.toString(); } - protected static class TestScopeArtifactFilter extends AbstractArtifactFeatureFilter { + /** + * {@link ArtifactFilter} to exclude test scope dependencies. + */ + protected static class ExcludeTestScopeArtifactFilter extends AbstractArtifactFeatureFilter { - TestScopeArtifactFilter() { + ExcludeTestScopeArtifactFilter() { super("", Artifact.SCOPE_TEST); } @@ -170,4 +175,20 @@ public abstract class AbstractDependencyFilterMojo extends AbstractMojo { } + /** + * {@link ArtifactFilter} that only include runtime scopes. + */ + protected static class RuntimeArtifactFilter implements ArtifactFilter { + + private static final Collection SCOPES = List.of(Artifact.SCOPE_COMPILE, + Artifact.SCOPE_COMPILE_PLUS_RUNTIME, Artifact.SCOPE_RUNTIME); + + @Override + public boolean include(Artifact artifact) { + String scope = artifact.getScope(); + return !artifact.isOptional() && (scope == null || SCOPES.contains(scope)); + } + + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractRunMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractRunMojo.java index 2e588d3b3e2..06647169f43 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractRunMojo.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/AbstractRunMojo.java @@ -371,7 +371,7 @@ public abstract class AbstractRunMojo extends AbstractDependencyFilterMojo { private void addDependencies(List urls) throws MalformedURLException, MojoExecutionException { Set artifacts = (this.useTestClasspath) ? filterDependencies(this.project.getArtifacts()) - : filterDependencies(this.project.getArtifacts(), new TestScopeArtifactFilter()); + : filterDependencies(this.project.getArtifacts(), new ExcludeTestScopeArtifactFilter()); for (Artifact artifact : artifacts) { if (artifact.getFile() != null) { urls.add(artifact.getFile().toURI().toURL()); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ProcessAotMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ProcessAotMojo.java index f14ca3b0bf7..cf5d124b304 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ProcessAotMojo.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ProcessAotMojo.java @@ -17,37 +17,15 @@ package org.springframework.boot.maven; import java.io.File; -import java.io.IOException; import java.net.URL; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; import java.util.List; -import java.util.Locale; -import java.util.Map; -import javax.tools.Diagnostic; -import javax.tools.DiagnosticListener; -import javax.tools.JavaCompiler; -import javax.tools.JavaCompiler.CompilationTask; -import javax.tools.JavaFileObject; -import javax.tools.StandardJavaFileManager; -import javax.tools.ToolProvider; - -import org.apache.maven.execution.MavenSession; -import org.apache.maven.plugin.MojoExecutionException; -import org.apache.maven.plugin.MojoFailureException; -import org.apache.maven.plugins.annotations.Component; import org.apache.maven.plugins.annotations.LifecyclePhase; import org.apache.maven.plugins.annotations.Mojo; import org.apache.maven.plugins.annotations.Parameter; import org.apache.maven.plugins.annotations.ResolutionScope; -import org.apache.maven.toolchain.ToolchainManager; -import org.springframework.boot.maven.CommandLineBuilder.ClasspathBuilder; import org.springframework.util.ObjectUtils; /** @@ -60,22 +38,10 @@ import org.springframework.util.ObjectUtils; @Mojo(name = "process-aot", defaultPhase = LifecyclePhase.PREPARE_PACKAGE, threadSafe = true, requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME, requiresDependencyCollection = ResolutionScope.COMPILE_PLUS_RUNTIME) -public class ProcessAotMojo extends AbstractDependencyFilterMojo { +public class ProcessAotMojo extends AbstractAotMojo { private static final String AOT_PROCESSOR_CLASS_NAME = "org.springframework.boot.AotProcessor"; - /** - * The current Maven session. This is used for toolchain manager API calls. - */ - @Parameter(defaultValue = "${session}", readonly = true) - private MavenSession session; - - /** - * The toolchain manager to use to locate a custom JDK. - */ - @Component - private ToolchainManager toolchainManager; - /** * Directory containing the classes and resource files that should be packaged into * the archive. @@ -83,12 +49,6 @@ public class ProcessAotMojo extends AbstractDependencyFilterMojo { @Parameter(defaultValue = "${project.build.outputDirectory}", required = true) private File classesDirectory; - /** - * Skip the execution. - */ - @Parameter(property = "spring-boot.aot.skip", defaultValue = "false") - private boolean skip; - /** * Directory containing the generated sources. */ @@ -107,19 +67,6 @@ public class ProcessAotMojo extends AbstractDependencyFilterMojo { @Parameter(defaultValue = "${project.build.directory}/spring-aot/main/classes", required = true) private File generatedClasses; - /** - * List of JVM system properties to pass to the AOT process. - */ - @Parameter - private Map systemPropertyVariables; - - /** - * JVM arguments that should be associated with the AOT process. On command line, make - * sure to wrap multiple values between quotes. - */ - @Parameter(property = "spring-boot.aot.jvmArguments") - private String jvmArguments; - /** * Name of the main class to use as the source for the AOT process. If not specified * the first compiled class found that contains a 'main' method will be used. @@ -134,35 +81,15 @@ public class ProcessAotMojo extends AbstractDependencyFilterMojo { private String[] profiles; @Override - public void execute() throws MojoExecutionException, MojoFailureException { - if (this.skip) { - getLog().debug("skipping execution as per configuration."); - return; - } - try { - generateAotAssets(); - compileSourceFiles(); - copyAll(this.generatedResources.toPath().resolve("META-INF/native-image"), - this.classesDirectory.toPath().resolve("META-INF/native-image")); - copyAll(this.generatedClasses.toPath(), this.classesDirectory.toPath()); - } - catch (Exception ex) { - throw new MojoExecutionException(ex.getMessage(), ex); - } - } - - private void generateAotAssets() throws MojoExecutionException { + protected void executeAot() throws Exception { String applicationClass = (this.mainClass != null) ? this.mainClass : SpringBootApplicationClassFinder.findSingleClass(this.classesDirectory); - List command = CommandLineBuilder.forMainClass(AOT_PROCESSOR_CLASS_NAME) - .withSystemProperties(this.systemPropertyVariables) - .withJvmArguments(new RunArguments(this.jvmArguments).asArray()).withClasspath(getClassPathUrls()) - .withArguments(getAotArguments(applicationClass)).build(); - if (getLog().isDebugEnabled()) { - getLog().debug("Generating AOT assets using command: " + command); - } - JavaProcessExecutor processExecutor = new JavaProcessExecutor(this.session, this.toolchainManager); - processExecutor.run(this.project.getBasedir(), command, Collections.emptyMap()); + URL[] classPath = getClassPath(); + generateAotAssets(classPath, AOT_PROCESSOR_CLASS_NAME, getAotArguments(applicationClass)); + compileSourceFiles(classPath, this.generatedSources, this.classesDirectory); + copyAll(this.generatedResources.toPath().resolve("META-INF/native-image"), + this.classesDirectory.toPath().resolve("META-INF/native-image")); + copyAll(this.generatedClasses.toPath(), this.classesDirectory.toPath()); } private String[] getAotArguments(String applicationClass) { @@ -179,75 +106,8 @@ public class ProcessAotMojo extends AbstractDependencyFilterMojo { return aotArguments.toArray(String[]::new); } - private URL[] getClassPathUrls() throws MojoExecutionException { - List urls = new ArrayList<>(); - urls.add(toURL(this.classesDirectory)); - urls.addAll(getDependencyURLs(new TestScopeArtifactFilter())); - return urls.toArray(URL[]::new); - } - - private void compileSourceFiles() throws IOException, MojoExecutionException { - List sourceFiles = Files.walk(this.generatedSources.toPath()).filter(Files::isRegularFile).toList(); - if (sourceFiles.isEmpty()) { - return; - } - JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); - try (StandardJavaFileManager fm = compiler.getStandardFileManager(null, null, null)) { - List options = new ArrayList<>(); - options.add("-cp"); - options.add(ClasspathBuilder.build(Arrays.asList(getClassPathUrls()))); - options.add("-d"); - options.add(this.classesDirectory.toPath().toAbsolutePath().toString()); - Iterable compilationUnits = fm.getJavaFileObjectsFromPaths(sourceFiles); - Errors errors = new Errors(); - CompilationTask task = compiler.getTask(null, fm, errors, options, null, compilationUnits); - boolean result = task.call(); - if (!result || errors.hasReportedErrors()) { - throw new IllegalStateException("Unable to compile generated source" + errors); - } - } - } - - private void copyAll(Path from, Path to) throws IOException { - List files = (Files.exists(from)) ? Files.walk(from).filter(Files::isRegularFile).toList() - : Collections.emptyList(); - for (Path file : files) { - String relativeFileName = file.subpath(from.getNameCount(), file.getNameCount()).toString(); - getLog().debug("Copying '" + relativeFileName + "' to " + to); - Path target = to.resolve(relativeFileName); - Files.createDirectories(target.getParent()); - Files.copy(file, target, StandardCopyOption.REPLACE_EXISTING); - } - } - - /** - * {@link DiagnosticListener} used to collect errors. - */ - static class Errors implements DiagnosticListener { - - private final StringBuilder message = new StringBuilder(); - - @Override - public void report(Diagnostic diagnostic) { - if (diagnostic.getKind() == Diagnostic.Kind.ERROR) { - this.message.append("\n"); - this.message.append(diagnostic.getMessage(Locale.getDefault())); - this.message.append(" "); - this.message.append(diagnostic.getSource().getName()); - this.message.append(" "); - this.message.append(diagnostic.getLineNumber()).append(":").append(diagnostic.getColumnNumber()); - } - } - - boolean hasReportedErrors() { - return this.message.length() > 0; - } - - @Override - public String toString() { - return this.message.toString(); - } - + protected URL[] getClassPath() throws Exception { + return getClassPath(this.classesDirectory, new ExcludeTestScopeArtifactFilter()); } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ProcessTestAotMojo.java b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ProcessTestAotMojo.java new file mode 100644 index 00000000000..a7d6c5fde54 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-maven-plugin/src/main/java/org/springframework/boot/maven/ProcessTestAotMojo.java @@ -0,0 +1,180 @@ +/* + * Copyright 2012-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.boot.maven; + +import java.io.File; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.apache.maven.artifact.Artifact; +import org.apache.maven.artifact.DefaultArtifact; +import org.apache.maven.artifact.handler.DefaultArtifactHandler; +import org.apache.maven.artifact.repository.ArtifactRepository; +import org.apache.maven.artifact.resolver.ArtifactResolutionRequest; +import org.apache.maven.artifact.resolver.ArtifactResolutionResult; +import org.apache.maven.artifact.resolver.ResolutionErrorHandler; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugins.annotations.Component; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.maven.repository.RepositorySystem; + +/** + * Invoke the AOT engine on tests. + * + * @author Phillip Webb + * @since 3.0.0 + */ +@Mojo(name = "process-test-aot", defaultPhase = LifecyclePhase.PROCESS_TEST_CLASSES, threadSafe = true, + requiresDependencyResolution = ResolutionScope.TEST, requiresDependencyCollection = ResolutionScope.TEST) +public class ProcessTestAotMojo extends AbstractAotMojo { + + private static final String JUNIT_PLATFORM_GROUP_ID = "org.junit.platform"; + + private static final String JUNIT_PLATFORM_COMMONS_ARTIFACT_ID = "junit-platform-commons"; + + private static final String JUNIT_PLATFORM_LAUNCHER_ARTIFACT_ID = "junit-platform-launcher"; + + private static final String AOT_PROCESSOR_CLASS_NAME = "org.springframework.test.context.aot.TestAotProcessor"; + + /** + * Directory containing the classes and resource files that should be packaged into + * the archive. + */ + @Parameter(defaultValue = "${project.build.testOutputDirectory}", required = true) + private File classesDirectory; + + /** + * Directory containing the generated sources. + */ + @Parameter(defaultValue = "${project.build.directory}/spring-aot/test/sources", required = true) + private File generatedSources; + + /** + * Directory containing the generated resources. + */ + @Parameter(defaultValue = "${project.build.directory}/spring-aot/test/resources", required = true) + private File generatedResources; + + /** + * Directory containing the generated classes. + */ + @Parameter(defaultValue = "${project.build.directory}/spring-aot/test/classes", required = true) + private File generatedClasses; + + /** + * Local artifact repository used to resolve JUnit platform launcher jars. + */ + @Parameter(defaultValue = "${localRepository}", required = true, readonly = true) + private ArtifactRepository localRepository; + + /** + * Remove artifact repositories used to resolve JUnit platform launcher jars. + */ + @Parameter(defaultValue = "${project.remoteArtifactRepositories}", required = true, readonly = true) + private List remoteRepositories; + + @Component + private RepositorySystem repositorySystem; + + @Component + private ResolutionErrorHandler resolutionErrorHandler; + + @Override + protected void executeAot() throws Exception { + if (Boolean.getBoolean("skipTests") || Boolean.getBoolean("maven.test.skip")) { + getLog().info("Skipping AOT test processing since tests are skipped"); + return; + } + Path testOutputDirectory = Paths.get(this.project.getBuild().getTestOutputDirectory()); + if (Files.notExists(testOutputDirectory)) { + getLog().info("Skipping AOT test processing since no tests have been detected"); + return; + } + generateAotAssets(getClassPath(true), AOT_PROCESSOR_CLASS_NAME, getAotArguments()); + compileSourceFiles(getClassPath(false), this.generatedSources, this.classesDirectory); + copyAll(this.generatedResources.toPath().resolve("META-INF/native-image"), + this.classesDirectory.toPath().resolve("META-INF/native-image")); + copyAll(this.generatedClasses.toPath(), this.classesDirectory.toPath()); + } + + private String[] getAotArguments() { + List aotArguments = new ArrayList<>(); + aotArguments.add(this.classesDirectory.toPath().toAbsolutePath().normalize().toString()); + 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()); + return aotArguments.toArray(String[]::new); + } + + protected URL[] getClassPath(boolean includeJUnitPlatformLauncher) throws Exception { + URL[] classPath = getClassPath(this.classesDirectory); + if (!includeJUnitPlatformLauncher || this.project.getArtifactMap() + .containsKey(JUNIT_PLATFORM_GROUP_ID + ":" + JUNIT_PLATFORM_LAUNCHER_ARTIFACT_ID)) { + return classPath; + } + return addJUnitPlatformLauncher(classPath); + } + + private URL[] addJUnitPlatformLauncher(URL[] classPath) throws Exception { + String version = getJUnitPlatformVersion(); + DefaultArtifactHandler handler = new DefaultArtifactHandler("jar"); + handler.setIncludesDependencies(true); + ArtifactResolutionResult resolutionResult = resolveArtifact(new DefaultArtifact(JUNIT_PLATFORM_GROUP_ID, + JUNIT_PLATFORM_LAUNCHER_ARTIFACT_ID, version, null, "jar", null, handler)); + Set fullClassPath = new LinkedHashSet<>(Arrays.asList(classPath)); + for (Artifact artifact : resolutionResult.getArtifacts()) { + fullClassPath.add(artifact.getFile().toURI().toURL()); + } + return fullClassPath.toArray(URL[]::new); + } + + private String getJUnitPlatformVersion() throws MojoExecutionException { + String id = JUNIT_PLATFORM_GROUP_ID + ":" + JUNIT_PLATFORM_COMMONS_ARTIFACT_ID; + Artifact platformCommonsArtifact = this.project.getArtifactMap().get(id); + String version = (platformCommonsArtifact != null) ? platformCommonsArtifact.getBaseVersion() : null; + if (version == null) { + throw new MojoExecutionException( + "Unable to find '%s' dependnecy. Please ensure JUnit is correctly configured.".formatted(id)); + } + return version; + } + + private ArtifactResolutionResult resolveArtifact(Artifact artifact) throws Exception { + ArtifactResolutionRequest request = new ArtifactResolutionRequest(); + request.setArtifact(artifact); + request.setLocalRepository(this.localRepository); + request.setResolveTransitively(true); + request.setCollectionFilter(new RuntimeArtifactFilter()); + request.setRemoteRepositories(this.remoteRepositories); + ArtifactResolutionResult result = this.repositorySystem.resolve(request); + this.resolutionErrorHandler.throwErrors(request, result); + return result; + } + +}