From b537b5a62879187aa1c56ea17b08fa093bfa3f6c Mon Sep 17 00:00:00 2001 From: Dmytro Nosan Date: Tue, 15 Apr 2025 02:09:35 +0300 Subject: [PATCH 1/2] Use CompileClasspathClassResolver for ArchitectureCheck Gradle Task Prior to this commit, certain rules, such as `BeanPostProcessor`, did not work with external classes. This commit ensures that `ArchRules` are executed using the `CompileClasspathClassResolver`, which resolves any missing classes. This helps build a Class Graph for external classes. Signed-off-by: Dmytro Nosan --- .../build/architecture/ArchitectureCheck.java | 50 ++-- .../architecture/ArchitecturePlugin.java | 3 +- .../CompileClasspathClassResolver.java | 91 ++++++ .../architecture/ArchitectureCheckTests.java | 269 ++++++++++-------- .../FilterOrderingIntegrationTests.java | 2 +- .../HttpEncodingAutoConfigurationTests.java | 4 +- .../servlet/WebMvcAutoConfigurationTests.java | 2 +- ...ebSocketServletAutoConfigurationTests.java | 4 +- 8 files changed, 276 insertions(+), 149 deletions(-) create mode 100644 buildSrc/src/main/java/org/springframework/boot/build/architecture/CompileClasspathClassResolver.java diff --git a/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java b/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java index 82642af49b05..e47e05d97f0c 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java @@ -26,6 +26,7 @@ import java.util.function.Supplier; import java.util.stream.Stream; +import com.tngtech.archunit.ArchConfiguration; import com.tngtech.archunit.core.domain.JavaClasses; import com.tngtech.archunit.core.importer.ClassFileImporter; import com.tngtech.archunit.lang.ArchRule; @@ -33,11 +34,13 @@ import org.gradle.api.DefaultTask; import org.gradle.api.Task; import org.gradle.api.Transformer; +import org.gradle.api.file.ConfigurableFileCollection; import org.gradle.api.file.DirectoryProperty; import org.gradle.api.file.FileCollection; import org.gradle.api.file.FileTree; import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.Property; +import org.gradle.api.tasks.Classpath; import org.gradle.api.tasks.IgnoreEmptyDirectories; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.InputFiles; @@ -58,6 +61,7 @@ * @author Scott Frederick * @author Ivan Malutin * @author Phillip Webb + * @author Dmytro Nosan */ public abstract class ArchitectureCheck extends DefaultTask { @@ -80,14 +84,18 @@ private List asDescriptions(List rules) { } @TaskAction - void checkArchitecture() throws IOException { - JavaClasses javaClasses = new ClassFileImporter().importPaths(classFilesPaths()); - List violations = evaluate(javaClasses).filter(EvaluationResult::hasViolation).toList(); - File outputFile = getOutputDirectory().file("failure-report.txt").get().getAsFile(); - writeViolationReport(violations, outputFile); - if (!violations.isEmpty()) { - throw new VerificationException("Architecture check failed. See '" + outputFile + "' for details."); - } + void checkArchitecture() { + ArchConfiguration.withThreadLocalScope((configuration) -> { + configuration.setClassResolver(CompileClasspathClassResolver.class); + configuration.setProperty(CompileClasspathClassResolver.PROPERTY_NAME, getCompileClasspath().getAsPath()); + JavaClasses javaClasses = new ClassFileImporter().importPaths(classFilesPaths()); + List violations = evaluate(javaClasses).filter(EvaluationResult::hasViolation).toList(); + File outputFile = getOutputDirectory().file("failure-report.txt").get().getAsFile(); + writeViolationReport(violations, outputFile); + if (!violations.isEmpty()) { + throw new VerificationException("Architecture check failed. See '" + outputFile + "' for details."); + } + }); } private List classFilesPaths() { @@ -98,15 +106,21 @@ private Stream evaluate(JavaClasses javaClasses) { return getRules().get().stream().map((rule) -> rule.evaluate(javaClasses)); } - private void writeViolationReport(List violations, File outputFile) throws IOException { - outputFile.getParentFile().mkdirs(); - StringBuilder report = new StringBuilder(); - for (EvaluationResult violation : violations) { - report.append(violation.getFailureReport()); - report.append(String.format("%n")); + private void writeViolationReport(List violations, File outputFile) { + try { + Files.createDirectories(outputFile.getParentFile().toPath()); + StringBuilder report = new StringBuilder(); + for (EvaluationResult violation : violations) { + report.append(violation.getFailureReport()); + report.append(String.format("%n")); + } + Files.writeString(outputFile.toPath(), report.toString(), StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING); + } + catch (IOException ex) { + throw new VerificationException( + "Failed to write violation report to '" + outputFile + "' " + ex.getMessage()); } - Files.writeString(outputFile.toPath(), report.toString(), StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING); } public void setClasses(FileCollection classes) { @@ -126,6 +140,10 @@ final FileTree getInputClasses() { return this.classes.getAsFileTree(); } + @InputFiles + @Classpath + public abstract ConfigurableFileCollection getCompileClasspath(); + @Optional @InputFiles @PathSensitive(PathSensitivity.RELATIVE) diff --git a/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitecturePlugin.java b/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitecturePlugin.java index 44bf79a32d46..8998a6660bfa 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitecturePlugin.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitecturePlugin.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 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. @@ -49,6 +49,7 @@ private void registerTasks(Project project) { TaskProvider checkPackageTangles = project.getTasks() .register("checkArchitecture" + StringUtils.capitalize(sourceSet.getName()), ArchitectureCheck.class, (task) -> { + task.getCompileClasspath().from(sourceSet.getCompileClasspath()); task.setClasses(sourceSet.getOutput().getClassesDirs()); task.getResourcesDirectory().set(sourceSet.getOutput().getResourcesDir()); task.dependsOn(sourceSet.getProcessResourcesTaskName()); diff --git a/buildSrc/src/main/java/org/springframework/boot/build/architecture/CompileClasspathClassResolver.java b/buildSrc/src/main/java/org/springframework/boot/build/architecture/CompileClasspathClassResolver.java new file mode 100644 index 000000000000..a41ed37f4436 --- /dev/null +++ b/buildSrc/src/main/java/org/springframework/boot/build/architecture/CompileClasspathClassResolver.java @@ -0,0 +1,91 @@ +/* + * Copyright 2012-2025 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.build.architecture; + +import java.io.File; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Arrays; +import java.util.Optional; + +import com.tngtech.archunit.ArchConfiguration; +import com.tngtech.archunit.base.ArchUnitException; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.core.importer.resolvers.ClassResolver; + +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * A {@link ClassResolver} that resolves Java classes from a provided compile classpath. + * + * @author Dmytro Nosan + */ +class CompileClasspathClassResolver implements ClassResolver { + + static final String PROPERTY_NAME = CompileClasspathClassResolver.class.getName(); + + private ClassUriImporter classUriImporter; + + private final URLClassLoader classLoader; + + CompileClasspathClassResolver() { + this.classLoader = new URLClassLoader(getUrls(), getClass().getClassLoader()); + } + + @Override + public void setClassUriImporter(ClassUriImporter classUriImporter) { + this.classUriImporter = classUriImporter; + } + + @Override + public Optional tryResolve(String typeName) { + String fileName = typeName.replace(".", "/") + ".class"; + URL url = this.classLoader.getResource(fileName); + if (url == null) { + return Optional.empty(); + } + try { + return this.classUriImporter.tryImport(url.toURI()); + } + catch (URISyntaxException ex) { + throw new ArchUnitException.LocationException(ex); + } + } + + private static URL[] getUrls() { + ArchConfiguration configuration = ArchConfiguration.get(); + String classpath = configuration.getProperty(PROPERTY_NAME); + Assert.state(classpath != null, () -> PROPERTY_NAME + " property is not set"); + return Arrays.stream(StringUtils.tokenizeToStringArray(classpath, File.pathSeparator)) + .map(File::new) + .map(CompileClasspathClassResolver::toURL) + .toArray(URL[]::new); + } + + private static URL toURL(File file) { + try { + return file.toURI().toURL(); + } + catch (MalformedURLException ex) { + throw new ArchUnitException.LocationException(ex); + } + } + +} diff --git a/buildSrc/src/test/java/org/springframework/boot/build/architecture/ArchitectureCheckTests.java b/buildSrc/src/test/java/org/springframework/boot/build/architecture/ArchitectureCheckTests.java index 79a8b649523f..3736c3470760 100644 --- a/buildSrc/src/test/java/org/springframework/boot/build/architecture/ArchitectureCheckTests.java +++ b/buildSrc/src/test/java/org/springframework/boot/build/architecture/ArchitectureCheckTests.java @@ -16,22 +16,20 @@ package org.springframework.boot.build.architecture; -import java.io.File; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.function.Consumer; -import org.gradle.api.GradleException; -import org.gradle.api.Project; -import org.gradle.testfixtures.ProjectBuilder; +import org.gradle.testkit.runner.GradleRunner; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; -import org.springframework.core.io.Resource; -import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.ClassPathResource; import org.springframework.util.FileSystemUtils; -import org.springframework.util.function.ThrowingConsumer; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; /** * Tests for {@link ArchitectureCheck}. @@ -39,188 +37,207 @@ * @author Andy Wilkinson * @author Scott Frederick * @author Ivan Malutin + * @author Dmytro Nosan */ class ArchitectureCheckTests { - @TempDir - File temp; + private Path projectDir; - @Test - void whenPackagesAreTangledTaskFailsAndWritesAReport() throws Exception { - prepareTask("tangled", (architectureCheck) -> { - assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture); - assertThat(failureReport(architectureCheck)).isNotEmpty(); - }); + private Path buildFile; + + @BeforeEach + void setup(@TempDir Path projectDir) { + this.projectDir = projectDir; + this.buildFile = projectDir.resolve("build.gradle"); } @Test - void whenPackagesAreNotTangledTaskSucceedsAndWritesAnEmptyReport() throws Exception { - prepareTask("untangled", (architectureCheck) -> { - architectureCheck.checkArchitecture(); - assertThat(failureReport(architectureCheck)).isEmpty(); - }); + void whenPackagesAreTangledTaskFailsAndWritesAReport() throws IOException { + runGradleWithCompiledClasses("tangled", + shouldHaveFailureReportWithMessage("slices matching '(**)' should be free of cycles")); } - File failureReport(ArchitectureCheck architectureCheck) { - return architectureCheck.getProject() - .getLayout() - .getBuildDirectory() - .file("checkArchitecture/failure-report.txt") - .get() - .getAsFile(); + @Test + void whenPackagesAreNotTangledTaskSucceedsAndWritesAnEmptyReport() throws IOException { + runGradleWithCompiledClasses("untangled", shouldHaveEmptyFailureReport()); } @Test - void whenBeanPostProcessorBeanMethodIsNotStaticTaskFailsAndWritesAReport() throws Exception { - prepareTask("bpp/nonstatic", (architectureCheck) -> { - assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture); - assertThat(failureReport(architectureCheck)).isNotEmpty(); - }); + void whenBeanPostProcessorBeanMethodIsNotStaticTaskFailsAndWritesAReport() throws IOException { + runGradleWithCompiledClasses("bpp/nonstatic", + shouldHaveFailureReportWithMessage( + "methods that are annotated with @Bean and have raw return type assignable " + + "to org.springframework.beans.factory.config.BeanPostProcessor")); } @Test - void whenBeanPostProcessorBeanMethodIsStaticAndHasUnsafeParametersTaskFailsAndWritesAReport() throws Exception { - prepareTask("bpp/unsafeparameters", (architectureCheck) -> { - assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture); - assertThat(failureReport(architectureCheck)).isNotEmpty(); - }); + void whenBeanPostProcessorBeanMethodIsStaticAndHasUnsafeParametersTaskFailsAndWritesAReport() throws IOException { + runGradleWithCompiledClasses("bpp/unsafeparameters", + shouldHaveFailureReportWithMessage( + "methods that are annotated with @Bean and have raw return type assignable " + + "to org.springframework.beans.factory.config.BeanPostProcessor")); } @Test void whenBeanPostProcessorBeanMethodIsStaticAndHasSafeParametersTaskSucceedsAndWritesAnEmptyReport() - throws Exception { - prepareTask("bpp/safeparameters", (architectureCheck) -> { - architectureCheck.checkArchitecture(); - assertThat(failureReport(architectureCheck)).isEmpty(); - }); + throws IOException { + runGradleWithCompiledClasses("bpp/safeparameters", shouldHaveEmptyFailureReport()); } @Test void whenBeanPostProcessorBeanMethodIsStaticAndHasNoParametersTaskSucceedsAndWritesAnEmptyReport() - throws Exception { - prepareTask("bpp/noparameters", (architectureCheck) -> { - architectureCheck.checkArchitecture(); - assertThat(failureReport(architectureCheck)).isEmpty(); - }); + throws IOException { + runGradleWithCompiledClasses("bpp/noparameters", shouldHaveEmptyFailureReport()); } @Test - void whenBeanFactoryPostProcessorBeanMethodIsNotStaticTaskFailsAndWritesAReport() throws Exception { - prepareTask("bfpp/nonstatic", (architectureCheck) -> { - assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture); - assertThat(failureReport(architectureCheck)).isNotEmpty(); - }); + void whenBeanFactoryPostProcessorBeanMethodIsNotStaticTaskFailsAndWritesAReport() throws IOException { + runGradleWithCompiledClasses("bfpp/nonstatic", + shouldHaveFailureReportWithMessage("methods that are annotated with @Bean and have raw return " + + "type assignable to org.springframework.beans.factory.config.BeanFactoryPostProcessor")); } @Test - void whenBeanFactoryPostProcessorBeanMethodIsStaticAndHasParametersTaskFailsAndWritesAReport() throws Exception { - prepareTask("bfpp/parameters", (architectureCheck) -> { - assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture); - assertThat(failureReport(architectureCheck)).isNotEmpty(); - }); + void whenBeanFactoryPostProcessorBeanMethodIsStaticAndHasParametersTaskFailsAndWritesAReport() throws IOException { + runGradleWithCompiledClasses("bfpp/parameters", + shouldHaveFailureReportWithMessage("methods that are annotated with @Bean and have raw return " + + "type assignable to org.springframework.beans.factory.config.BeanFactoryPostProcessor")); } @Test void whenBeanFactoryPostProcessorBeanMethodIsStaticAndHasNoParametersTaskSucceedsAndWritesAnEmptyReport() - throws Exception { - prepareTask("bfpp/noparameters", (architectureCheck) -> { - architectureCheck.checkArchitecture(); - assertThat(failureReport(architectureCheck)).isEmpty(); - }); + throws IOException { + runGradleWithCompiledClasses("bfpp/noparameters", shouldHaveEmptyFailureReport()); + } + + @Test + void whenClassLoadsResourceUsingResourceUtilsTaskFailsAndWritesReport() throws IOException { + runGradleWithCompiledClasses("resources/loads", shouldHaveFailureReportWithMessage( + "no classes should call method where target owner type org.springframework.util.ResourceUtils and target name 'getURL'")); } @Test - void whenClassLoadsResourceUsingResourceUtilsTaskFailsAndWritesReport() throws Exception { - prepareTask("resources/loads", (architectureCheck) -> { - assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture); - assertThat(failureReport(architectureCheck)).isNotEmpty(); - }); + void whenClassUsesResourceUtilsWithoutLoadingResourcesTaskSucceedsAndWritesAnEmptyReport() throws IOException { + runGradleWithCompiledClasses("resources/noloads", shouldHaveEmptyFailureReport()); } @Test - void whenClassUsesResourceUtilsWithoutLoadingResourcesTaskSucceedsAndWritesAnEmptyReport() throws Exception { - prepareTask("resources/noloads", (architectureCheck) -> { - architectureCheck.checkArchitecture(); - assertThat(failureReport(architectureCheck)).isEmpty(); - }); + void whenClassDoesNotCallObjectsRequireNonNullTaskSucceedsAndWritesAnEmptyReport() throws IOException { + runGradleWithCompiledClasses("objects/noRequireNonNull", shouldHaveEmptyFailureReport()); } @Test - void whenClassDoesNotCallObjectsRequireNonNullTaskSucceedsAndWritesAnEmptyReport() throws Exception { - prepareTask("objects/noRequireNonNull", (architectureCheck) -> { - architectureCheck.checkArchitecture(); - assertThat(failureReport(architectureCheck)).isEmpty(); - }); + void whenClassCallsObjectsRequireNonNullWithMessageTaskFailsAndWritesReport() throws IOException { + runGradleWithCompiledClasses("objects/requireNonNullWithString", shouldHaveFailureReportWithMessage( + "no classes should call method Objects.requireNonNull(Object, String)")); } @Test - void whenClassCallsObjectsRequireNonNullWithMessageTaskFailsAndWritesReport() throws Exception { - prepareTask("objects/requireNonNullWithString", (architectureCheck) -> { - assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture); - assertThat(failureReport(architectureCheck)).isNotEmpty(); - }); + void whenClassCallsObjectsRequireNonNullWithSupplierTaskFailsAndWritesReport() throws IOException { + runGradleWithCompiledClasses("objects/requireNonNullWithSupplier", shouldHaveFailureReportWithMessage( + "no classes should call method Objects.requireNonNull(Object, Supplier)")); } @Test - void whenClassCallsObjectsRequireNonNullWithSupplierTaskFailsAndWritesReport() throws Exception { - prepareTask("objects/requireNonNullWithSupplier", (architectureCheck) -> { - assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture); - assertThat(failureReport(architectureCheck)).isNotEmpty(); - }); + void whenClassCallsStringToUpperCaseWithoutLocaleFailsAndWritesReport() throws IOException { + runGradleWithCompiledClasses("string/toUpperCase", + shouldHaveFailureReportWithMessage("because String.toUpperCase(Locale.ROOT) should be used instead")); } @Test - void whenClassCallsStringToUpperCaseWithoutLocaleFailsAndWritesReport() throws Exception { - prepareTask("string/toUpperCase", (architectureCheck) -> { - assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture); - assertThat(failureReport(architectureCheck)).isNotEmpty() - .content() - .contains("because String.toUpperCase(Locale.ROOT) should be used instead"); - }); + void whenClassCallsStringToLowerCaseWithoutLocaleFailsAndWritesReport() throws IOException { + runGradleWithCompiledClasses("string/toLowerCase", + shouldHaveFailureReportWithMessage("because String.toLowerCase(Locale.ROOT) should be used instead")); } @Test - void whenClassCallsStringToLowerCaseWithoutLocaleFailsAndWritesReport() throws Exception { - prepareTask("string/toLowerCase", (architectureCheck) -> { - assertThatExceptionOfType(GradleException.class).isThrownBy(architectureCheck::checkArchitecture); - assertThat(failureReport(architectureCheck)).isNotEmpty() - .content() - .contains("because String.toLowerCase(Locale.ROOT) should be used instead"); - }); + void whenClassCallsStringToLowerCaseWithLocaleShouldNotFail() throws IOException { + runGradleWithCompiledClasses("string/toLowerCaseWithLocale", shouldHaveEmptyFailureReport()); } @Test - void whenClassCallsStringToLowerCaseWithLocaleShouldNotFail() throws Exception { - prepareTask("string/toLowerCaseWithLocale", (architectureCheck) -> { - architectureCheck.checkArchitecture(); - assertThat(failureReport(architectureCheck)).isEmpty(); - }); + void whenClassCallsStringToUpperCaseWithLocaleShouldNotFail() throws IOException { + runGradleWithCompiledClasses("string/toUpperCaseWithLocale", shouldHaveEmptyFailureReport()); } @Test - void whenClassCallsStringToUpperCaseWithLocaleShouldNotFail() throws Exception { - prepareTask("string/toUpperCaseWithLocale", (architectureCheck) -> { - architectureCheck.checkArchitecture(); - assertThat(failureReport(architectureCheck)).isEmpty(); - }); + void whenBeanPostProcessorBeanMethodIsNotStaticWithExternalClass() throws IOException { + Files.writeString(this.buildFile, """ + plugins { + id 'java' + id 'org.springframework.boot.architecture' + } + repositories { + mavenCentral() + } + java { + sourceCompatibility = 17 + } + dependencies { + implementation("org.springframework.integration:spring-integration-jmx:6.3.9") + } + """); + Path testClass = this.projectDir.resolve("src/main/java/boot/architecture/bpp/external/TestClass.java"); + Files.createDirectories(testClass.getParent()); + Files.writeString(testClass, """ + package org.springframework.boot.build.architecture.bpp.external; + import org.springframework.context.annotation.Bean; + import org.springframework.integration.monitor.IntegrationMBeanExporter; + public class TestClass { + @Bean + IntegrationMBeanExporter integrationMBeanExporter() { + return new IntegrationMBeanExporter(); + } + } + """); + runGradle(shouldHaveFailureReportWithMessage("methods that are annotated with @Bean and have raw return " + + "type assignable to org.springframework.beans.factory.config.BeanPostProcessor ")); } - private void prepareTask(String classes, ThrowingConsumer callback) throws Exception { - File projectDir = new File(this.temp, "project"); - projectDir.mkdirs(); - copyClasses(classes, projectDir); - Project project = ProjectBuilder.builder().withProjectDir(projectDir).build(); - project.getTasks().register("checkArchitecture", ArchitectureCheck.class, (task) -> { - task.setClasses(project.files("classes")); - callback.accept(task); - }); + private Consumer shouldHaveEmptyFailureReport() { + return (gradleRunner) -> { + assertThat(gradleRunner.build().getOutput()).contains("BUILD SUCCESSFUL") + .contains("Task :checkArchitectureMain"); + assertThat(failureReport()).isEmptyFile(); + }; + } + + private Consumer shouldHaveFailureReportWithMessage(String message) { + return (gradleRunner) -> { + assertThat(gradleRunner.buildAndFail().getOutput()).contains("BUILD FAILED") + .contains("Task :checkArchitectureMain FAILED"); + assertThat(failureReport()).content().contains(message); + }; + } + + private void runGradleWithCompiledClasses(String path, Consumer callback) throws IOException { + ClassPathResource classPathResource = new ClassPathResource(path, getClass()); + FileSystemUtils.copyRecursively(classPathResource.getFile().toPath(), + this.projectDir.resolve("classes").resolve(classPathResource.getPath())); + Files.writeString(this.buildFile, """ + plugins { + id 'java' + id 'org.springframework.boot.architecture' + } + sourceSets { + main { + output.classesDirs.setFrom(file("classes")) + } + } + """); + runGradle(callback); + } + + private void runGradle(Consumer callback) { + callback.accept(GradleRunner.create() + .withProjectDir(this.projectDir.toFile()) + // .withArguments("checkArchitectureMain", "-Dorg.gradle.debug=true") + .withArguments("checkArchitectureMain") + .withPluginClasspath()); } - private void copyClasses(String name, File projectDir) throws IOException { - PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); - Resource root = resolver.getResource("classpath:org/springframework/boot/build/architecture/" + name); - FileSystemUtils.copyRecursively(root.getFile(), - new File(projectDir, "classes/org/springframework/boot/build/architecture/" + name)); + private Path failureReport() { + return this.projectDir.resolve("build/checkArchitectureMain/failure-report.txt"); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/FilterOrderingIntegrationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/FilterOrderingIntegrationTests.java index e567ba366005..af2267b28de4 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/FilterOrderingIntegrationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/FilterOrderingIntegrationTests.java @@ -103,7 +103,7 @@ MockServletWebServerFactory webServerFactory() { } @Bean - WebServerFactoryCustomizerBeanPostProcessor ServletWebServerCustomizerBeanPostProcessor() { + static WebServerFactoryCustomizerBeanPostProcessor servletWebServerCustomizerBeanPostProcessor() { return new WebServerFactoryCustomizerBeanPostProcessor(); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/HttpEncodingAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/HttpEncodingAutoConfigurationTests.java index 4cf0d35ca6cb..fe2e998b97a4 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/HttpEncodingAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/HttpEncodingAutoConfigurationTests.java @@ -211,12 +211,12 @@ OrderedFormContentFilter formContentFilter() { static class MinimalWebAutoConfiguration { @Bean - MockServletWebServerFactory MockServletWebServerFactory() { + MockServletWebServerFactory mockServletWebServerFactory() { return new MockServletWebServerFactory(); } @Bean - WebServerFactoryCustomizerBeanPostProcessor ServletWebServerCustomizerBeanPostProcessor() { + static WebServerFactoryCustomizerBeanPostProcessor servletWebServerCustomizerBeanPostProcessor() { return new WebServerFactoryCustomizerBeanPostProcessor(); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java index 80d76d5d86c3..5be2b42ab7f1 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfigurationTests.java @@ -1098,7 +1098,7 @@ ServletWebServerFactory webServerFactory() { } @Bean - WebServerFactoryCustomizerBeanPostProcessor ServletWebServerCustomizerBeanPostProcessor() { + static WebServerFactoryCustomizerBeanPostProcessor servletWebServerCustomizerBeanPostProcessor() { return new WebServerFactoryCustomizerBeanPostProcessor(); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketServletAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketServletAutoConfigurationTests.java index bd4d9213ba1d..9d6ef0b784fd 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketServletAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/websocket/servlet/WebSocketServletAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2025 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. @@ -183,7 +183,7 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha } @Bean - WebServerFactoryCustomizerBeanPostProcessor ServletWebServerCustomizerBeanPostProcessor() { + static WebServerFactoryCustomizerBeanPostProcessor servletWebServerCustomizerBeanPostProcessor() { return new WebServerFactoryCustomizerBeanPostProcessor(); } From 5baa6d9fc62df1bb39c478640ffbe15d2d3cd38f Mon Sep 17 00:00:00 2001 From: Dmytro Nosan Date: Wed, 16 Apr 2025 19:02:27 +0300 Subject: [PATCH 2/2] Use ClassLoader with ArchitectureCheck Prior to this commit, certain rules, like BeanPostProcessor, did not work with external classes. This commit ensures that ArchRules are executed within a context ClassLoader that includes all classes from the compile classpath. Signed-off-by: Dmytro Nosan --- .../build/architecture/ArchitectureCheck.java | 44 +++++---- .../CompileClasspathClassResolver.java | 91 ------------------- 2 files changed, 28 insertions(+), 107 deletions(-) delete mode 100644 buildSrc/src/main/java/org/springframework/boot/build/architecture/CompileClasspathClassResolver.java diff --git a/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java b/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java index e47e05d97f0c..732dbb8a42e4 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java @@ -18,15 +18,18 @@ import java.io.File; import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.concurrent.Callable; import java.util.function.Supplier; import java.util.stream.Stream; -import com.tngtech.archunit.ArchConfiguration; import com.tngtech.archunit.core.domain.JavaClasses; import com.tngtech.archunit.core.importer.ClassFileImporter; import com.tngtech.archunit.lang.ArchRule; @@ -84,10 +87,8 @@ private List asDescriptions(List rules) { } @TaskAction - void checkArchitecture() { - ArchConfiguration.withThreadLocalScope((configuration) -> { - configuration.setClassResolver(CompileClasspathClassResolver.class); - configuration.setProperty(CompileClasspathClassResolver.PROPERTY_NAME, getCompileClasspath().getAsPath()); + void checkArchitecture() throws Exception { + withCompileClasspath(() -> { JavaClasses javaClasses = new ClassFileImporter().importPaths(classFilesPaths()); List violations = evaluate(javaClasses).filter(EvaluationResult::hasViolation).toList(); File outputFile = getOutputDirectory().file("failure-report.txt").get().getAsFile(); @@ -95,6 +96,7 @@ void checkArchitecture() { if (!violations.isEmpty()) { throw new VerificationException("Architecture check failed. See '" + outputFile + "' for details."); } + return null; }); } @@ -106,23 +108,33 @@ private Stream evaluate(JavaClasses javaClasses) { return getRules().get().stream().map((rule) -> rule.evaluate(javaClasses)); } - private void writeViolationReport(List violations, File outputFile) { + private void withCompileClasspath(Callable callable) throws Exception { + ClassLoader previous = Thread.currentThread().getContextClassLoader(); try { - Files.createDirectories(outputFile.getParentFile().toPath()); - StringBuilder report = new StringBuilder(); - for (EvaluationResult violation : violations) { - report.append(violation.getFailureReport()); - report.append(String.format("%n")); + List urls = new ArrayList<>(); + for (File file : getCompileClasspath().getFiles()) { + urls.add(file.toURI().toURL()); } - Files.writeString(outputFile.toPath(), report.toString(), StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING); + ClassLoader classLoader = new URLClassLoader(urls.toArray(new URL[0]), getClass().getClassLoader()); + Thread.currentThread().setContextClassLoader(classLoader); + callable.call(); } - catch (IOException ex) { - throw new VerificationException( - "Failed to write violation report to '" + outputFile + "' " + ex.getMessage()); + finally { + Thread.currentThread().setContextClassLoader(previous); } } + private void writeViolationReport(List violations, File outputFile) throws IOException { + Files.createDirectories(outputFile.getParentFile().toPath()); + StringBuilder report = new StringBuilder(); + for (EvaluationResult violation : violations) { + report.append(violation.getFailureReport()); + report.append(String.format("%n")); + } + Files.writeString(outputFile.toPath(), report.toString(), StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING); + } + public void setClasses(FileCollection classes) { this.classes = classes; } diff --git a/buildSrc/src/main/java/org/springframework/boot/build/architecture/CompileClasspathClassResolver.java b/buildSrc/src/main/java/org/springframework/boot/build/architecture/CompileClasspathClassResolver.java deleted file mode 100644 index a41ed37f4436..000000000000 --- a/buildSrc/src/main/java/org/springframework/boot/build/architecture/CompileClasspathClassResolver.java +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright 2012-2025 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.build.architecture; - -import java.io.File; -import java.net.MalformedURLException; -import java.net.URISyntaxException; -import java.net.URL; -import java.net.URLClassLoader; -import java.util.Arrays; -import java.util.Optional; - -import com.tngtech.archunit.ArchConfiguration; -import com.tngtech.archunit.base.ArchUnitException; -import com.tngtech.archunit.core.domain.JavaClass; -import com.tngtech.archunit.core.importer.resolvers.ClassResolver; - -import org.springframework.util.Assert; -import org.springframework.util.StringUtils; - -/** - * A {@link ClassResolver} that resolves Java classes from a provided compile classpath. - * - * @author Dmytro Nosan - */ -class CompileClasspathClassResolver implements ClassResolver { - - static final String PROPERTY_NAME = CompileClasspathClassResolver.class.getName(); - - private ClassUriImporter classUriImporter; - - private final URLClassLoader classLoader; - - CompileClasspathClassResolver() { - this.classLoader = new URLClassLoader(getUrls(), getClass().getClassLoader()); - } - - @Override - public void setClassUriImporter(ClassUriImporter classUriImporter) { - this.classUriImporter = classUriImporter; - } - - @Override - public Optional tryResolve(String typeName) { - String fileName = typeName.replace(".", "/") + ".class"; - URL url = this.classLoader.getResource(fileName); - if (url == null) { - return Optional.empty(); - } - try { - return this.classUriImporter.tryImport(url.toURI()); - } - catch (URISyntaxException ex) { - throw new ArchUnitException.LocationException(ex); - } - } - - private static URL[] getUrls() { - ArchConfiguration configuration = ArchConfiguration.get(); - String classpath = configuration.getProperty(PROPERTY_NAME); - Assert.state(classpath != null, () -> PROPERTY_NAME + " property is not set"); - return Arrays.stream(StringUtils.tokenizeToStringArray(classpath, File.pathSeparator)) - .map(File::new) - .map(CompileClasspathClassResolver::toURL) - .toArray(URL[]::new); - } - - private static URL toURL(File file) { - try { - return file.toURI().toURL(); - } - catch (MalformedURLException ex) { - throw new ArchUnitException.LocationException(ex); - } - } - -}