GH-613 - Add SPI to support external ApplicationModuleSource contributions.

We now expose ApplicationModuleSourceFactory as Spring Factories-based SPI interface to further contribute ApplicationModuleSource instances either from a provided root package subject for module detection through a (potentially customized) ApplicationModuleDetectionStrategy or by explicitly listing particular module base packages.
This commit is contained in:
Oliver Drotbohm
2024-09-04 08:51:19 +02:00
parent b370d5a3f2
commit 1062f53bfa
19 changed files with 697 additions and 78 deletions

View File

@@ -102,7 +102,7 @@ public class ApplicationModule implements Comparable<ApplicationModule> {
Assert.notNull(source, "Base package must not be null!");
Assert.notNull(exclusions, "Exclusions must not be null!");
JavaPackage basePackage = source.moduleBasePackage();
JavaPackage basePackage = source.getModuleBasePackage();
this.source = source;
this.basePackage = basePackage;
@@ -144,7 +144,7 @@ public class ApplicationModule implements Comparable<ApplicationModule> {
* @return will never be {@literal null} or empty.
*/
public String getName() {
return source.moduleName();
return source.getModuleName();
}
/**

View File

@@ -15,6 +15,7 @@
*/
package org.springframework.modulith.core;
import java.util.Objects;
import java.util.stream.Stream;
import org.springframework.util.Assert;
@@ -29,9 +30,25 @@ import org.springframework.util.Assert;
* @author Oliver Drotbohm
* @since 1.3
*/
record ApplicationModuleSource(
JavaPackage moduleBasePackage,
String moduleName) {
public class ApplicationModuleSource {
private final JavaPackage moduleBasePackage;
private final String moduleName;
/**
* Creates a new {@link ApplicationModuleSource} for the given module base package and module name.
*
* @param moduleBasePackage must not be {@literal null}.
* @param moduleName must not be {@literal null} or empty.
*/
private ApplicationModuleSource(JavaPackage moduleBasePackage, String moduleName) {
Assert.notNull(moduleBasePackage, "JavaPackage must not be null!");
Assert.hasText(moduleName, "Module name must not be null or empty!");
this.moduleBasePackage = moduleBasePackage;
this.moduleName = moduleName;
}
/**
* Returns a {@link Stream} of {@link ApplicationModuleSource}s by applying the given
@@ -66,4 +83,46 @@ record ApplicationModuleSource(
return new ApplicationModuleSource(pkg, name);
}
/**
* @return will never be {@literal null}.
*/
public JavaPackage getModuleBasePackage() {
return moduleBasePackage;
}
/**
* @return will never be {@literal null} or empty.
*/
public String getModuleName() {
return moduleName;
}
/*
* (non-Javadoc)
* @see java.lang.Object#equals(java.lang.Object)
*/
@Override
public boolean equals(Object obj) {
if (obj == this) {
return true;
}
if (!(obj instanceof ApplicationModuleSource that)) {
return false;
}
return Objects.equals(this.moduleName, that.moduleName)
&& Objects.equals(this.moduleBasePackage, that.moduleBasePackage);
}
/*
* (non-Javadoc)
* @see java.lang.Object#hashCode()
*/
@Override
public int hashCode() {
return Objects.hash(moduleName, moduleBasePackage);
}
}

View File

@@ -0,0 +1,115 @@
/*
* Copyright 2024 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.modulith.core;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Stream;
import org.springframework.core.io.support.SpringFactoriesLoader;
import com.tngtech.archunit.core.domain.JavaClasses;
/**
* Lookup of external {@link ApplicationModuleSource} contributions via {@link ApplicationModuleSourceFactory}
* implementations.
*
* @author Oliver Drotbohm
* @since 1.3
*/
class ApplicationModuleSourceContributions {
static String LOCATION = SpringFactoriesLoader.FACTORIES_RESOURCE_LOCATION;
private final List<String> rootPackages;
private final List<ApplicationModuleSource> sources;
/**
* Creates a new {@link ApplicationModuleSourceContributions} for the given importer function, default
* {@link ApplicationModuleDetectionStrategy} and whether to use fully-qualified module names.
*
* @param importer must not be {@literal null}.
* @param defaultStrategy must not be {@literal null}.
* @param useFullyQualifiedModuleNames whether to use fully-qualified module names.
*/
public static ApplicationModuleSourceContributions of(Function<Collection<String>, JavaClasses> importer,
ApplicationModuleDetectionStrategy defaultStrategy, boolean useFullyQualifiedModuleNames) {
var loader = SpringFactoriesLoader.forResourceLocation(LOCATION, ApplicationModules.class.getClassLoader());
return new ApplicationModuleSourceContributions(loader.load(ApplicationModuleSourceFactory.class), importer,
defaultStrategy, useFullyQualifiedModuleNames);
}
/**
* Creates a new {@link ApplicationModuleSourceContributions} for the given {@link ApplicationModuleSourceFactory}s,
* importer function, default {@link ApplicationModuleDetectionStrategy} and whether to use fully-qualified module
* names.
*
* @param factories must not be {@literal null}.
* @param importer must not be {@literal null}.
* @param defaultStrategy must not be {@literal null}.
* @param useFullyQualifiedModuleNames whether to use fully-qualified module names.
*/
ApplicationModuleSourceContributions(List<? extends ApplicationModuleSourceFactory> factories,
Function<Collection<String>, JavaClasses> importer,
ApplicationModuleDetectionStrategy defaultStrategy, boolean useFullyQualifiedModuleNames) {
this.rootPackages = new ArrayList<>();
this.sources = new ArrayList<>();
factories.forEach(factory -> {
var contributedPackages = factory.getRootPackages();
var factoryStrategy = factory.getApplicationModuleDetectionStrategy();
var classes = importer.apply(contributedPackages);
var strategy = factoryStrategy == null ? defaultStrategy : factoryStrategy;
// Add discovered ApplicationModuleSources
rootPackages.addAll(contributedPackages);
contributedPackages.stream()
.map(it -> JavaPackage.of(Classes.of(classes), it))
.flatMap(it -> factory.getApplicationModuleSources(it, strategy, useFullyQualifiedModuleNames))
.forEach(this.sources::add);
// Add enumerated ApplicationModuleSources
Function<String, JavaPackage> packageRegistrar = it -> {
return JavaPackage.of(Classes.of(importer.apply(List.of(it))), it);
};
factory.getApplicationModuleSources(packageRegistrar, useFullyQualifiedModuleNames).forEach(this.sources::add);
});
}
/**
* @return will never be {@literal null}.
*/
public Stream<String> getRootPackages() {
return rootPackages.stream();
}
/**
* @return will never be {@literal null}.
*/
public Stream<ApplicationModuleSource> getSources() {
return sources.stream();
}
}

View File

@@ -0,0 +1,137 @@
/*
* Copyright 2024 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.modulith.core;
import java.util.Collections;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Stream;
import org.springframework.lang.Nullable;
/**
* SPI to allow build units contribute additional {@link ApplicationModuleSource}s in the form of either declaring them
* directly via {@link #getModuleBasePackages()} and {@link #getApplicationModuleSources(Function, boolean)} or via
* provided {@link #getRootPackages()} and subsequent resolution via
* {@link #getApplicationModuleSources(JavaPackage, ApplicationModuleDetectionStrategy, boolean)} for each of the
* packages provided. <br>
* The following snippet would register {@link ApplicationModuleSource}s for {@code com.acme.foo} and
* {@code com.acme.bar} directly:
*
* <pre>
* {@code
* class MyCustomFactory implements ApplicationModuleSourceFactory {
*
* &#64;Override
* public List<String> getModuleBasePackages() {
* return List.of("com.acme.foo", "com.acme.bar");
* }
* }
* }
* </pre>
*
* The following snippet would register all modules located underneath {@code com.acme} found via the
* {@link ApplicationModuleDetectionStrategy#explicitlyAnnotated()} strategy:
*
* <pre>
* {@code
* class MyCustomFactory implements ApplicationModuleSourceFactory {
*
* &#64;Override
* public List<String> getRootPackages() {
* return List.of("com.acme");
* }
*
* &#64;Override
* ApplicationModuleDetectionStrategy getApplicationModuleDetectionStrategy() {
* return ApplicationModuleDetectionStrategy.explicitlyAnnotated();
* }
* }
* }
* </pre>
*
* @author Oliver Drotbohm
* @since 1.3
*/
public interface ApplicationModuleSourceFactory {
/**
* Returns the additional root packages to be considered. The ones returned from this method will be scanned for
* {@link ApplicationModuleSource}s via
* {@link #getApplicationModuleSources(JavaPackage, ApplicationModuleDetectionStrategy, boolean)} using the
* {@link ApplicationModuleDetectionStrategy} returned from {@link #getApplicationModuleDetectionStrategy()}. If the
* latter is {@literal null}, the default {@link ApplicationModuleDetectionStrategy} is used.
*
* @return must not be {@literal null}.
*/
default List<String> getRootPackages() {
return Collections.emptyList();
}
/**
* Returns additional module base packages to create {@link ApplicationModuleSource}s from. Subsequently handled by
* {@link #getApplicationModuleSources(Function, boolean)}.
*
* @return must not be {@literal null}.
*/
default List<String> getModuleBasePackages() {
return Collections.emptyList();
}
/**
* Returns the {@link ApplicationModuleDetectionStrategy} to be used to detect {@link ApplicationModuleSource}s from
* the packages returned by {@link #getRootPackages()}. If {@literal null} is returned, the default
* {@link ApplicationModuleDetectionStrategy} will be used.
*
* @return can be {@literal null}.
*/
@Nullable
default ApplicationModuleDetectionStrategy getApplicationModuleDetectionStrategy() {
return null;
}
/**
* Creates all {@link ApplicationModuleSource}s using the given base package and
* {@link ApplicationModuleDetectionStrategy}.
*
* @param rootPackage will never be {@literal null}.
* @param strategy will never be {@literal null}.
* @param useFullyQualifiedModuleNames whether to use fully-qualified names for application modules.
* @return must not be {@literal null}.
* @see ApplicationModuleSource#from(JavaPackage, ApplicationModuleDetectionStrategy, boolean)
*/
default Stream<ApplicationModuleSource> getApplicationModuleSources(JavaPackage rootPackage,
ApplicationModuleDetectionStrategy strategy, boolean useFullyQualifiedModuleNames) {
return ApplicationModuleSource.from(rootPackage, strategy, useFullyQualifiedModuleNames);
}
/**
* Creates {@link ApplicationModuleSource} for individually, manually described application modules.
*
* @param packages will never be {@literal null}.
* @param useFullyQualifiedModuleNames whether to use fully-qualified names for application modules.
* @return must not be {@literal null}.
* @see ApplicationModuleSource#from(JavaPackage, String)
*/
default Stream<ApplicationModuleSource> getApplicationModuleSources(Function<String, JavaPackage> packages,
boolean useFullyQualifiedModuleNames) {
return getModuleBasePackages().stream()
.map(packages)
.map(it -> ApplicationModuleSource.from(it, useFullyQualifiedModuleNames ? it.getName() : it.getLocalName()));
}
}

View File

@@ -96,12 +96,11 @@ public class ApplicationModules implements Iterable<ApplicationModule> {
*
* @param metadata must not be {@literal null}.
* @param ignored must not be {@literal null}.
* @param useFullyQualifiedModuleNames can be {@literal null}.
* @param option must not be {@literal null}.
*/
protected ApplicationModules(ModulithMetadata metadata,
DescribedPredicate<? super JavaClass> ignored, boolean useFullyQualifiedModuleNames, ImportOption option) {
this(metadata, metadata.getBasePackages(), ignored, useFullyQualifiedModuleNames, option);
DescribedPredicate<? super JavaClass> ignored, ImportOption option) {
this(metadata, metadata.getBasePackages(), ignored, metadata.useFullyQualifiedModuleNames(), option);
}
/**
@@ -113,8 +112,7 @@ public class ApplicationModules implements Iterable<ApplicationModule> {
* @param useFullyQualifiedModuleNames can be {@literal null}.
* @param option must not be {@literal null}.
* @deprecated since 1.2, for removal in 1.3. Use {@link ApplicationModules(ModulithMetadata, DescribedPredicate,
* boolean, ImportOption)} instead and set up {@link ModulithMetadata} to contain the packages you want to
* use.
* ImportOption)} instead and set up {@link ModulithMetadata} to contain the packages you want to use.
*/
@Deprecated(forRemoval = true)
protected ApplicationModules(ModulithMetadata metadata, Collection<String> packages,
@@ -128,8 +126,9 @@ public class ApplicationModules implements Iterable<ApplicationModule> {
DescribedPredicate<? super JavaClass> excluded = DescribedPredicate.or(ignored, IS_AOT_TYPE, IS_SPRING_CGLIB_PROXY);
this.metadata = metadata;
this.allClasses = new ClassFileImporter() //
.withImportOption(option) //
var importer = new ClassFileImporter() //
.withImportOption(option);
this.allClasses = importer //
.importPackages(packages) //
.that(not(excluded));
@@ -138,9 +137,14 @@ public class ApplicationModules implements Iterable<ApplicationModule> {
Classes classes = Classes.of(allClasses);
var strategy = ApplicationModuleDetectionStrategyLookup.getStrategy();
var sources = packages.stream() //
var contributions = ApplicationModuleSourceContributions.of(
pkgs -> importer.importPackages(pkgs).that(not(excluded)), strategy, useFullyQualifiedModuleNames);
var directSources = packages.stream() //
.map(it -> JavaPackage.of(classes, it))
.flatMap(it -> ApplicationModuleSource.from(it, strategy, useFullyQualifiedModuleNames))
.flatMap(it -> ApplicationModuleSource.from(it, strategy, useFullyQualifiedModuleNames));
var sources = Stream.concat(directSources, contributions.getSources())
.distinct()
.collect(Collectors.toUnmodifiableSet());
@@ -148,12 +152,12 @@ public class ApplicationModules implements Iterable<ApplicationModule> {
.map(it -> {
return new ApplicationModule(it,
JavaPackages.onlySubPackagesOf(it.moduleBasePackage(),
sources.stream().map(ApplicationModuleSource::moduleBasePackage).toList())); //
JavaPackages.onlySubPackagesOf(it.getModuleBasePackage(),
sources.stream().map(ApplicationModuleSource::getModuleBasePackage).toList())); //
})
.collect(toMap(ApplicationModule::getName, Function.identity()));
this.rootPackages = packages.stream() //
this.rootPackages = Stream.concat(packages.stream(), contributions.getRootPackages()) //
.map(it -> JavaPackage.of(classes, it).toSingle()) //
.toList();
@@ -643,8 +647,7 @@ public class ApplicationModules implements Iterable<ApplicationModule> {
return CACHE.computeIfAbsent(cacheKey, key -> {
var metadata = key.getMetadata();
var modules = new ApplicationModules(metadata, key.getIgnored(),
metadata.useFullyQualifiedModuleNames(), key.getOptions());
var modules = new ApplicationModules(metadata, key.getIgnored(), key.getOptions());
var sharedModules = metadata.getSharedModuleNames() //
.map(modules::getRequiredModule) //

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2024 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 contributed;
import java.util.List;
import org.springframework.modulith.core.ApplicationModuleDetectionStrategy;
import org.springframework.modulith.core.ApplicationModuleSourceFactory;
/**
* Example {@link ApplicationModuleSourceFactory}.
*
* @author Oliver Drotbohm
*/
public class ApplicationModuleSourceContribution implements ApplicationModuleSourceFactory {
/*
* (non-Javadoc)
* @see org.springframework.modulith.core.ApplicationModuleSourceFactory#getRootPackages()
*/
@Override
public List<String> getRootPackages() {
return List.of("contributed");
}
/*
* (non-Javadoc)
* @see org.springframework.modulith.core.ApplicationModuleSourceFactory#getApplicationModuleDetectionStrategy()
*/
@Override
public ApplicationModuleDetectionStrategy getApplicationModuleDetectionStrategy() {
return ApplicationModuleDetectionStrategy.explicitlyAnnotated();
}
/*
* (non-Javadoc)
* @see org.springframework.modulith.core.ApplicationModuleSourceFactory#getModuleBasePackages()
*/
@Override
public List<String> getModuleBasePackages() {
return List.of("contributed.enumerated");
}
}

View File

@@ -0,0 +1,2 @@
@org.springframework.modulith.ApplicationModule
package contributed.detected;

View File

@@ -0,0 +1 @@
package contributed.enumerated;

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2024 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.modulith.core;
import static org.assertj.core.api.Assertions.*;
import contributed.ApplicationModuleSourceContribution;
import java.util.List;
import org.junit.jupiter.api.Test;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
/**
* Unit tests for {@link ApplicationModuleSourceContributions}.
*
* @author Oliver Drotbohm
* @since 1.3
*/
class ApplicationModuleSourceContributionsUnitTests {
@Test // GH-613
void detectsContributions() {
var factories = List.of(new ApplicationModuleSourceContribution());
var importer = new ClassFileImporter().withImportOption(new ImportOption.OnlyIncludeTests());
var strategy = ApplicationModuleDetectionStrategy.directSubPackage();
var contributions = new ApplicationModuleSourceContributions(factories, importer::importPackages, strategy, false);
assertThat(contributions.getRootPackages()).contains("contributed");
assertThat(contributions.getSources())
.extracting(ApplicationModuleSource::getModuleBasePackage)
.extracting(JavaPackage::getName)
.containsExactlyInAnyOrder("contributed.detected", "contributed.enumerated");
}
}

View File

@@ -110,6 +110,6 @@ public class TestUtils {
}
private static ApplicationModules of(ModulithMetadata metadata, DescribedPredicate<JavaClass> ignores) {
return new ApplicationModules(metadata, ignores, false, new ImportOption.OnlyIncludeTests()) {};
return new ApplicationModules(metadata, ignores, new ImportOption.OnlyIncludeTests()) {};
}
}