From d9c4f949c0ecde12bc0a66dd42f4f9c134618a22 Mon Sep 17 00:00:00 2001 From: Marius Bogoevici Date: Sat, 5 Sep 2015 16:09:22 -0400 Subject: [PATCH] Add support for including/excluding dependencies when launching a module - adding includes/excludes properties for modules - includes and their transitive deps are mandatory, excludes can be pattern-based - supports both individual module launch and aggregation Allow exclusions to apply to all artifacts Fixing classloading issues when LaunchedUrlClassloader is used with JARs from the Maven repository Javadoc enhancements --- .../module/launcher/ModuleLauncher.java | 162 ++++++++++++++---- .../module/launcher/ModuleLauncherRunner.java | 8 +- .../module/launcher/MultiArchiveLauncher.java | 101 +++++++++++ .../module/resolver/AetherModuleResolver.java | 122 ++++++++++--- .../stream/module/resolver/Coordinates.java | 71 ++++++++ .../InclusionExclusionDependencyFilter.java | 70 ++++++++ .../module/resolver/ModuleResolver.java | 23 ++- .../stream/module/utils/ClassloaderUtils.java | 1 - .../resolver/AetherModuleResolverTests.java | 12 +- .../aggregate/AggregateApplication.java | 2 +- 10 files changed, 494 insertions(+), 78 deletions(-) create mode 100644 spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/launcher/MultiArchiveLauncher.java create mode 100644 spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/resolver/Coordinates.java create mode 100644 spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/resolver/InclusionExclusionDependencyFilter.java diff --git a/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/launcher/ModuleLauncher.java b/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/launcher/ModuleLauncher.java index 221fbec77..e171ee408 100644 --- a/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/launcher/ModuleLauncher.java +++ b/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/launcher/ModuleLauncher.java @@ -19,8 +19,10 @@ package org.springframework.cloud.stream.module.launcher; import java.io.IOException; import java.lang.reflect.Method; import java.net.URL; +import java.net.URLClassLoader; import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.regex.Matcher; @@ -31,6 +33,7 @@ import org.apache.commons.logging.LogFactory; import org.springframework.boot.loader.archive.Archive; import org.springframework.boot.loader.archive.JarFileArchive; +import org.springframework.cloud.stream.module.resolver.Coordinates; import org.springframework.cloud.stream.module.resolver.ModuleResolver; import org.springframework.cloud.stream.module.utils.ClassloaderUtils; import org.springframework.core.io.Resource; @@ -60,11 +63,15 @@ public class ModuleLauncher { private static final String DEFAULT_EXTENSION = "jar"; - private static final String DEFAULT_CLASSIFIER = "exec"; + private static final String DEFAULT_MODULE_CLASSIFIER = "exec"; private static final Pattern COORDINATES_PATTERN = Pattern.compile("([^: ]+):([^: ]+)(:([^: ]*)(:([^: ]+))?)?:([^: ]+)"); + private static final String INCLUDE_DEPENDENCIES_ARG = "includes"; + + private static final String EXCLUDE_DEPENDENCIES_ARG = "excludes"; + private final ModuleResolver moduleResolver; /** @@ -87,20 +94,21 @@ public class ModuleLauncher { * @param aggregate whether the modules should be aggregated at launch * @param parentArgs a list of arguments for the whole aggregate */ + @SuppressWarnings("unchecked") public void launch(List moduleLaunchRequests, boolean aggregate, Map parentArgs) { List reversed = new ArrayList<>(moduleLaunchRequests); Collections.reverse(reversed); if (moduleLaunchRequests.size() == 1 || !aggregate) { - launchIndividualModules(moduleLaunchRequests); + launchIndividualModules(reversed); } else { - launchAggregatedModules(moduleLaunchRequests, toArgArray(parentArgs)); + launchAggregatedModules(moduleLaunchRequests, parentArgs != null ? parentArgs : Collections.EMPTY_MAP); } } @SuppressWarnings("unchecked") public void launch(List moduleLaunchRequests, boolean aggregate) { - this.launch(moduleLaunchRequests, aggregate, Collections.EMPTY_MAP); + this.launch(moduleLaunchRequests, aggregate, Collections.emptyMap()); } public void launch(List moduleLaunchRequests) { @@ -125,53 +133,106 @@ public class ModuleLauncher { } } - public void launchAggregatedModules(List moduleLaunchRequests, final String[] parentArgs) { + public void launchAggregatedModules(List moduleLaunchRequests, Map parentArgs) { try { List mainClassNames = new ArrayList<>(); - List jarURLs = new ArrayList<>(); + LinkedHashSet jarURLs = new LinkedHashSet<>(); List seenArchives = new ArrayList<>(); final List arguments = new ArrayList<>(); - // aggregate jars from all modules and extract their main Classes - for (ModuleLaunchRequest moduleLaunchRequest : moduleLaunchRequests) { - Resource resource = resolveModule(moduleLaunchRequest.getModule()); - JarFileArchive jarFileArchive = new JarFileArchive(resource.getFile()); - jarURLs.add(jarFileArchive.getUrl()); - for (Archive archive : jarFileArchive.getNestedArchives(ArchiveMatchingEntryFilter.FILTER)) { - // avoid duplication based on unique JAR names - // TODO - read the metadata from the JARs, do proper version resolution on merge - String urlAsString = archive.getUrl().toString(); - String jarNameWithExtension = urlAsString.substring(0, urlAsString.lastIndexOf("!/")); - String jarNameWithoutExtension = jarNameWithExtension.substring(jarNameWithExtension.lastIndexOf("/") + 1); - if (!seenArchives.contains(jarNameWithoutExtension)) { - seenArchives.add(jarNameWithoutExtension); - jarURLs.add(archive.getUrl()); + final ClassLoader classLoader; + if (!(parentArgs.containsKey(EXCLUDE_DEPENDENCIES_ARG) || + parentArgs.containsKey(INCLUDE_DEPENDENCIES_ARG))) { + for (ModuleLaunchRequest moduleLaunchRequest : moduleLaunchRequests) { + Resource resource = resolveModule(moduleLaunchRequest.getModule()); + JarFileArchive jarFileArchive = new JarFileArchive(resource.getFile()); + jarURLs.add(jarFileArchive.getUrl()); + for (Archive archive : jarFileArchive.getNestedArchives(ArchiveMatchingEntryFilter.FILTER)) { + // avoid duplication based on unique JAR names + // TODO - read the metadata from the JARs, do proper version resolution on merge + String urlAsString = archive.getUrl().toString(); + String jarNameWithExtension = urlAsString.substring(0, urlAsString.lastIndexOf("!/")); + String jarNameWithoutExtension = + jarNameWithExtension.substring(jarNameWithExtension.lastIndexOf("/") + 1); + if (!seenArchives.contains(jarNameWithoutExtension)) { + seenArchives.add(jarNameWithoutExtension); + jarURLs.add(archive.getUrl()); + } } + mainClassNames.add(jarFileArchive.getMainClass()); + arguments.add(toArgArray(moduleLaunchRequest.getArguments())); } - mainClassNames.add(jarFileArchive.getMainClass()); - arguments.add(toArgArray(moduleLaunchRequest.getArguments())); + classLoader = ClassloaderUtils.createModuleClassloader(jarURLs.toArray(new URL[jarURLs.size()])); + } else { + // First, resolve modules and extract main classes - while slightly less efficient than just + // doing the same processing after resolution, this ensures that module artifacts are processed + // correctly for extracting their main class names. It is not possible in the general case to + // identify, after resolution, whether a resource represents a module artifact which was part of the + // original request or not. We will include the first module as root and the next as direct dependencies + Coordinates root = null; + ArrayList includeCoordinates = new ArrayList<>(); + for (ModuleLaunchRequest moduleLaunchRequest : moduleLaunchRequests) { + Coordinates moduleCoordinates + = toCoordinates(moduleLaunchRequest.getModule(), DEFAULT_MODULE_CLASSIFIER); + if (root == null) { + root = moduleCoordinates; + } + else { + includeCoordinates.add(toCoordinates(moduleLaunchRequest.getModule(), + DEFAULT_MODULE_CLASSIFIER)); + } + Resource moduleResource = resolveModule(moduleLaunchRequest.getModule()); + JarFileArchive moduleArchive = new JarFileArchive(moduleResource.getFile()); + mainClassNames.add(moduleArchive.getMainClass()); + arguments.add(toArgArray(moduleLaunchRequest.getArguments())); + } + for (String include : + StringUtils.commaDelimitedListToStringArray(parentArgs.get(INCLUDE_DEPENDENCIES_ARG))) { + includeCoordinates.add(toCoordinates(include, "")); + } + // Resolve all artifacts - since modules have been specified as direct dependencies, they will take + // precedence in the resolution order, ensuring that the already resolved artifacts will be returned as + // part of the response. + Resource[] libraries = moduleResolver.resolve(root, + includeCoordinates.toArray(new Coordinates[includeCoordinates.size()]), + StringUtils.commaDelimitedListToStringArray(parentArgs.get(EXCLUDE_DEPENDENCIES_ARG))); + for (Resource library : libraries) { + jarURLs.add(library.getURL()); + } + classLoader = new URLClassLoader(jarURLs.toArray(new URL[jarURLs.size()])); } - final ClassLoader classLoader = ClassloaderUtils - .createModuleClassloader(jarURLs.toArray(new URL[jarURLs.size()])); + final List> mainClasses = new ArrayList<>(); for (String mainClass : mainClassNames) { mainClasses.add(ClassUtils.forName(mainClass, classLoader)); } - Runnable moduleAggregatorRunner = new ModuleAggregatorRunner(classLoader, mainClasses, parentArgs, arguments); - + Runnable moduleAggregatorRunner = new ModuleAggregatorRunner(classLoader, mainClasses, + toArgArray(parentArgs), arguments); Thread moduleAggregatorRunnerThread = new Thread(moduleAggregatorRunner); moduleAggregatorRunnerThread.setContextClassLoader(classLoader); moduleAggregatorRunnerThread.setName(MODULE_AGGREGATOR_RUNNER_THREAD_NAME); moduleAggregatorRunnerThread.start(); } catch (Exception e) { - throw new RuntimeException("failed to start aggregated modules: " + StringUtils.collectionToCommaDelimitedString(moduleLaunchRequests), e); + throw new RuntimeException("failed to start aggregated modules: " + + StringUtils.collectionToCommaDelimitedString(moduleLaunchRequests), e); } } - public void launchIndividualModules(List reversed) { + public void launchIndividualModules(List moduleLaunchRequests) { + List reversed = new ArrayList<>(moduleLaunchRequests); + Collections.reverse(reversed); for (ModuleLaunchRequest moduleLaunchRequest : reversed) { String module = moduleLaunchRequest.getModule(); moduleLaunchRequest.addArgument("spring.jmx.default-domain", module.replace("/", ".").replace(":", ".")); - launchModule(module, toArgArray(moduleLaunchRequest.getArguments())); + Map arguments = moduleLaunchRequest.getArguments(); + if (arguments.containsKey(INCLUDE_DEPENDENCIES_ARG) || arguments.containsKey(EXCLUDE_DEPENDENCIES_ARG)) { + String includes = arguments.get(INCLUDE_DEPENDENCIES_ARG); + String excludes = arguments.get(EXCLUDE_DEPENDENCIES_ARG); + launchModuleWithDependencies(module, toArgArray(arguments), + StringUtils.commaDelimitedListToStringArray(includes), + StringUtils.commaDelimitedListToStringArray(excludes)); + } else { + launchModule(module, toArgArray(arguments)); + } } } @@ -187,20 +248,49 @@ public class ModuleLauncher { } } - private Resource resolveModule(String coordinates) { + private void launchModuleWithDependencies(String module, String[] args, String[] includes, String[] excludes) { + try { + Resource[] libraries = this.moduleResolver.resolve(toCoordinates(module, DEFAULT_MODULE_CLASSIFIER), + toCoordinateArray(includes), excludes); + List archives = new ArrayList<>(); + for (Resource library : libraries) { + archives.add(new JarFileArchive(library.getFile())); + } + MultiArchiveLauncher jarLauncher = new MultiArchiveLauncher(archives); + jarLauncher.launch(args); + } + catch (IOException e) { + throw new RuntimeException("failed to launch module: " + module, e); + } + } + + private Resource resolveModule(String moduleCoordinates) { + Coordinates coordinates = toCoordinates(moduleCoordinates, DEFAULT_MODULE_CLASSIFIER); + return this.moduleResolver.resolve(coordinates); + } + + private Coordinates toCoordinates(String coordinates, String defaultClassifier) { Matcher matcher = COORDINATES_PATTERN.matcher(coordinates); Assert.isTrue(matcher.matches(), "Bad artifact coordinates " + coordinates + ", expected format is :[:[:]]:"); String groupId = matcher.group(1); String artifactId = matcher.group(2); String extension = StringUtils.hasLength(matcher.group(4)) ? matcher.group(4) : DEFAULT_EXTENSION; - String classifier = StringUtils.hasLength(matcher.group(6)) ? matcher.group(6) : DEFAULT_CLASSIFIER; + String classifier = StringUtils.hasLength(matcher.group(6)) ? matcher.group(6) : defaultClassifier; String version = matcher.group(7); - return this.moduleResolver.resolve(groupId, artifactId, extension, classifier, version); + return new Coordinates(groupId, artifactId, extension, classifier, version); + } + + private Coordinates[] toCoordinateArray(String[] coordinateList) { + List result = new ArrayList<>(); + for (String coordinates : coordinateList) { + result.add(toCoordinates(coordinates, "")); + } + return result.toArray(new Coordinates[result.size()]); } private class ModuleAggregatorRunner implements Runnable { - + private final ClassLoader classLoader; private final String[] parentArgs; @@ -209,7 +299,8 @@ public class ModuleLauncher { private final List arguments; - public ModuleAggregatorRunner(ClassLoader classLoader, List> mainClasses, String[] parentArgs, List moduleArguments) { + public ModuleAggregatorRunner(ClassLoader classLoader, List> mainClasses, String[] parentArgs, + List moduleArguments) { this.classLoader = classLoader; this.parentArgs = parentArgs; this.mainClasses = mainClasses; @@ -227,7 +318,8 @@ public class ModuleLauncher { mainClasses.toArray(new Class[mainClasses.size()]), parentArgs, arguments.toArray(new String[][] {})); } catch (Exception e) { - log.error("failed to launch aggregated modules :" + StringUtils.collectionToCommaDelimitedString(mainClasses), e); + log.error("failed to launch aggregated modules :" + + StringUtils.collectionToCommaDelimitedString(mainClasses), e); throw new RuntimeException(e); } } diff --git a/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/launcher/ModuleLauncherRunner.java b/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/launcher/ModuleLauncherRunner.java index 83272e01c..362a160b7 100644 --- a/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/launcher/ModuleLauncherRunner.java +++ b/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/launcher/ModuleLauncherRunner.java @@ -17,6 +17,8 @@ package org.springframework.cloud.stream.module.launcher; import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -65,7 +67,9 @@ public class ModuleLauncherRunner implements CommandLineRunner { } this.moduleLauncher.launch(launchRequests, moduleLauncherProperties.isAggregate(), - moduleLauncherProperties.isAggregate() ? moduleLauncherProperties.getArgs().get(AGGREGATE_ARGS_KEY) : null); + moduleLauncherProperties.isAggregate() ? + moduleLauncherProperties.getArgs().get(AGGREGATE_ARGS_KEY) : + Collections.emptyMap()); } private List generateModuleLaunchRequests() { @@ -92,6 +96,4 @@ public class ModuleLauncherRunner implements CommandLineRunner { } return requests; } - - } diff --git a/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/launcher/MultiArchiveLauncher.java b/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/launcher/MultiArchiveLauncher.java new file mode 100644 index 000000000..98699a3fc --- /dev/null +++ b/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/launcher/MultiArchiveLauncher.java @@ -0,0 +1,101 @@ +/* + * Copyright 2015 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 + * + * http://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.cloud.stream.module.launcher; + +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.List; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.boot.loader.Launcher; +import org.springframework.boot.loader.archive.Archive; +import org.springframework.cloud.stream.module.utils.ClassloaderUtils; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.ReflectionUtils; + +/** + * A {@link Launcher} for multiple independent JAR archives (which aren't nested in an uberjar). This class + * supports module aggregation and direct binding. + * + * @author Marius Bogoevici + * @author Eric Bottard + */ +public class MultiArchiveLauncher extends Launcher { + + private static Log log = LogFactory.getLog(MultiArchiveLauncher.class); + + private final List archives; + + /** + * A list of archives, the first of which is expected to be a Spring boot uberJar + * + * @param archives + */ + public MultiArchiveLauncher(List archives) { + Assert.notEmpty(archives, "A list of archives must be provided"); + this.archives = archives; + } + + @Override + protected String getMainClass() throws Exception { + return archives.get(0).getManifest().getMainAttributes().getValue("Start-Class"); + } + + @Override + protected List getClassPathArchives() throws Exception { + return archives; + } + + @Override + // TODO: this method is protected in Spring Boot but we need it to be public here + public void launch(String[] args) { + super.launch(args); + } + + @Override + protected void launch(String[] args, String mainClass, ClassLoader classLoader) + throws Exception { + if (log.isDebugEnabled()) { + for (Archive archive : archives) { + log.debug("Launching with: " + archive.getUrl().toString()); + } + } + if (ClassUtils.isPresent( + "org.apache.catalina.webresources.TomcatURLStreamHandlerFactory", + classLoader)) { + // Ensure the method is invoked on a class that is loaded by the provided + // class loader (not the current context class loader): + Method method = ReflectionUtils + .findMethod( + classLoader + .loadClass("org.apache.catalina.webresources.TomcatURLStreamHandlerFactory"), + "disable"); + ReflectionUtils.invokeMethod(method, null); + } + super.launch(args, mainClass, classLoader); + } + + @Override + protected ClassLoader createClassLoader(URL[] urls) throws Exception { + return new URLClassLoader(urls); + } + +} diff --git a/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/resolver/AetherModuleResolver.java b/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/resolver/AetherModuleResolver.java index 361f9c413..df8e6d911 100644 --- a/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/resolver/AetherModuleResolver.java +++ b/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/resolver/AetherModuleResolver.java @@ -17,6 +17,7 @@ package org.springframework.cloud.stream.module.resolver; import java.io.File; +import java.util.ArrayList; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -29,22 +30,29 @@ import org.eclipse.aether.RepositorySystem; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.artifact.Artifact; import org.eclipse.aether.artifact.DefaultArtifact; +import org.eclipse.aether.collection.CollectRequest; import org.eclipse.aether.connector.basic.BasicRepositoryConnectorFactory; +import org.eclipse.aether.graph.Dependency; import org.eclipse.aether.impl.DefaultServiceLocator; import org.eclipse.aether.repository.LocalRepository; import org.eclipse.aether.repository.RemoteRepository; import org.eclipse.aether.resolution.ArtifactRequest; import org.eclipse.aether.resolution.ArtifactResolutionException; import org.eclipse.aether.resolution.ArtifactResult; +import org.eclipse.aether.resolution.DependencyRequest; +import org.eclipse.aether.resolution.DependencyResolutionException; +import org.eclipse.aether.resolution.DependencyResult; import org.eclipse.aether.spi.connector.RepositoryConnectorFactory; import org.eclipse.aether.spi.connector.transport.TransporterFactory; import org.eclipse.aether.transport.file.FileTransporterFactory; import org.eclipse.aether.transport.http.HttpTransporterFactory; +import org.eclipse.aether.util.artifact.JavaScopes; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; +import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; /** @@ -108,36 +116,13 @@ public class AetherModuleResolver implements ModuleResolver { /** * Resolve an artifact and return its location in the local repository. Aether performs the normal * Maven resolution process ensuring that the latest update is cached to the local repository. - * @param groupId the groupId - * @param artifactId the artifactId - * @param extension the file extension - * @param classifier classifier can be null if none - * @param version the version + * @param coordinates the Maven coordinates of the artifact * @return a {@link FileSystemResource} representing the resolved artifact in the local repository * @throws RuntimeException if the artifact does not exist or the resolution fails */ @Override - public Resource resolve(String groupId, String artifactId, String extension, String classifier, String version) { - Assert.hasText(groupId, "'groupId' cannot be blank."); - Assert.hasText(artifactId, "'artifactId' cannot be blank."); - Assert.hasText(extension, "'extension' cannot be blank."); - if (classifier == null) { - classifier = ""; - } - Assert.hasText(version, "'version' cannot be blank."); - - Artifact artifact = new DefaultArtifact(groupId, artifactId, classifier, extension, version); - RepositorySystemSession session = newRepositorySystemSession(repositorySystem, - localRepository.getAbsolutePath()); - ArtifactResult result; - try { - result = repositorySystem.resolveArtifact(session, - new ArtifactRequest(artifact, remoteRepositories, "runtime")); - } - catch (ArtifactResolutionException e) { - throw new RuntimeException(e); - } - return new FileSystemResource(result.getArtifact().getFile()); + public Resource resolve(Coordinates coordinates) { + return this.resolve(coordinates, null, null)[0]; } /* @@ -169,4 +154,89 @@ public class AetherModuleResolver implements ModuleResolver { }); return locator.getService(RepositorySystem.class); } + + + /** + * Resolve a set of artifacts based on their coordinates, including their dependencies, and return the locations of + * the transitive set in the local repository. Aether performs the normal Maven resolution process ensuring that the + * latest update is cached to the local repository. A number of additional includes and excludes can be specified, + * allowing to override the transitive dependencies of the original set. Includes and their transitive dependencies + * will always + * + * @param root the Maven coordinates of the artifacts + * @return a {@link FileSystemResource} representing the resolved artifact in the local repository + * @throws RuntimeException if the artifact does not exist or the resolution fails + */ + @Override + public Resource[] resolve(Coordinates root, Coordinates[] includes, String[] excludePatterns) { + Assert.notNull(root, "Root cannot be null"); + validateCoordinates(root); + if (!ObjectUtils.isEmpty(includes)) { + for (Coordinates include : includes) { + Assert.notNull(include, "Includes cannot be null"); + validateCoordinates(include); + } + } + List result = new ArrayList<>(); + Artifact rootArtifact = toArtifact(root); + RepositorySystemSession session = newRepositorySystemSession(repositorySystem, + localRepository.getAbsolutePath()); + if (ObjectUtils.isEmpty(includes) && ObjectUtils.isEmpty(excludePatterns)) { + ArtifactResult resolvedArtifact; + try { + resolvedArtifact = repositorySystem.resolveArtifact(session, + new ArtifactRequest(rootArtifact, remoteRepositories, JavaScopes.RUNTIME)); + } + catch (ArtifactResolutionException e) { + throw new RuntimeException(e); + } + result.add(toResource(resolvedArtifact)); + } + else { + try { + CollectRequest collectRequest = new CollectRequest(); + collectRequest.setRepositories(remoteRepositories); + collectRequest.setRoot(new Dependency(rootArtifact, JavaScopes.RUNTIME)); + Artifact[] includeArtifacts = new Artifact[!ObjectUtils.isEmpty(includes) ? includes.length : 0]; + int i = 0; + for (Coordinates include : includes) { + Artifact includedArtifact = toArtifact(include); + collectRequest.addDependency(new Dependency(includedArtifact, JavaScopes.RUNTIME)); + includeArtifacts[i++] = includedArtifact; + } + DependencyResult dependencyResult = + repositorySystem.resolveDependencies(session, + new DependencyRequest(collectRequest, + new InclusionExclusionDependencyFilter(includeArtifacts, excludePatterns))); + for (ArtifactResult artifactResult : dependencyResult.getArtifactResults()) { + // we are only interested in the jars + if ("jar".equalsIgnoreCase(artifactResult.getArtifact().getExtension())) { + result.add(toResource(artifactResult)); + } + } + } catch (DependencyResolutionException e) { + throw new RuntimeException(e); + } + } + return result.toArray(new Resource[result.size()]); + } + + private void validateCoordinates(Coordinates coordinates) { + Assert.hasText(coordinates.getGroupId(), "'groupId' cannot be blank."); + Assert.hasText(coordinates.getArtifactId(), "'artifactId' cannot be blank."); + Assert.hasText(coordinates.getExtension(), "'extension' cannot be blank."); + Assert.hasText(coordinates.getVersion(), "'version' cannot be blank."); + } + + public FileSystemResource toResource(ArtifactResult resolvedArtifact) { + return new FileSystemResource(resolvedArtifact.getArtifact().getFile()); + } + + private Artifact toArtifact(Coordinates root) { + return new DefaultArtifact(root.getGroupId(), + root.getArtifactId(), + root.getClassifier() != null ? root.getClassifier() : "", + root.getExtension(), + root.getVersion()); + } } diff --git a/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/resolver/Coordinates.java b/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/resolver/Coordinates.java new file mode 100644 index 000000000..e3fdc234f --- /dev/null +++ b/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/resolver/Coordinates.java @@ -0,0 +1,71 @@ +/* + * Copyright 2015 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 + * + * http://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.cloud.stream.module.resolver; + +/** + * Encapsulates Maven coordinates. + * + * @author Marius Bogoevici + * @author Eric Bottard + */ +public class Coordinates { + + private final String groupId; + + private final String artifactId; + + private final String extension; + + private String classifier; + + private final String version; + + /** + * @param groupId the groupId + * @param artifactId the artifactId + * @param extension the file extension + * @param classifier classifier + * @param version the version + */ + public Coordinates(String groupId, String artifactId, String extension, String classifier, String version) { + this.groupId = groupId; + this.artifactId = artifactId; + this.extension = extension; + this.classifier = classifier; + this.version = version; + } + + public String getGroupId() { + return groupId; + } + + public String getArtifactId() { + return artifactId; + } + + public String getExtension() { + return extension; + } + + public String getClassifier() { + return classifier; + } + + public String getVersion() { + return version; + } +} diff --git a/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/resolver/InclusionExclusionDependencyFilter.java b/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/resolver/InclusionExclusionDependencyFilter.java new file mode 100644 index 000000000..141dcb3c3 --- /dev/null +++ b/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/resolver/InclusionExclusionDependencyFilter.java @@ -0,0 +1,70 @@ +/* + * Copyright 2015 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 + * + * http://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.cloud.stream.module.resolver; + +import java.util.List; + +import org.eclipse.aether.artifact.Artifact; +import org.eclipse.aether.graph.DependencyFilter; +import org.eclipse.aether.graph.DependencyNode; +import org.eclipse.aether.util.filter.PatternExclusionsDependencyFilter; + +import org.springframework.util.ObjectUtils; + +/** + * A {@link DependencyFilter} that uses a list of explicit includes and pattern-based excludes. Any items that match + * the exclusion pattern will be rejected, unless they are accepted explicitly + * + * @author Marius Bogoevici + */ +public class InclusionExclusionDependencyFilter implements DependencyFilter { + + private final PatternExclusionsDependencyFilter patternExclusionsDependencyFilter; + + private final Artifact[] includes; + + public InclusionExclusionDependencyFilter(Artifact[] includes, String... excludes) { + this.patternExclusionsDependencyFilter = new PatternExclusionsDependencyFilter(excludes); + this.includes = includes != null ? includes : new Artifact[0]; + } + + @Override + public boolean accept(DependencyNode node, List parents) { + // nodes included explicitly are always accepted + return isIncludedDirectly(node) || patternExclusionsDependencyFilter.accept(node, parents); + } + + private boolean isIncludedDirectly(DependencyNode node) { + if (node.getArtifact() != null) { + for (Artifact include : includes) { + Artifact nodeArtifact = node.getArtifact(); + // we check if this was a specifically included artifact by checking its group, artifactId, extension and + // classifier. The version is left out in the case when resolution produces a different artifact version + // (there cannot be two artifacts with the same group, artifactId, extension and classifier but different + // version in the resolved group) + if (ObjectUtils.nullSafeEquals(include.getGroupId(), nodeArtifact.getGroupId()) + && ObjectUtils.nullSafeEquals(include.getArtifactId(), nodeArtifact.getArtifactId()) + && ObjectUtils.nullSafeEquals(include.getClassifier(), nodeArtifact.getClassifier()) + && ObjectUtils.nullSafeEquals(include.getExtension(), nodeArtifact.getExtension())) { + return true; + } + } + return false; + } + return true; + } +} diff --git a/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/resolver/ModuleResolver.java b/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/resolver/ModuleResolver.java index b9bc712b8..d175b2762 100644 --- a/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/resolver/ModuleResolver.java +++ b/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/resolver/ModuleResolver.java @@ -23,19 +23,30 @@ import org.springframework.core.io.Resource; * uber-jar based on its Maven coordinates. * * @author David Turanski + * @author Marius Bogoevici + * @author Eric Bottard */ public interface ModuleResolver { /** * Retrieve a resource given its coordinates. * - * @param groupId the groupId - * @param artifactId the artifactId - * @param extension the file extension - * @param classifier classifier - * @param version the version + * @param coordinates the coordinates of a resource * @return the resource */ - Resource resolve(String groupId, String artifactId, String extension, String classifier, String version); + Resource resolve(Coordinates coordinates); + + /** + * Retrieve a set of resources given their coordinates, along with additional dependencies. + * Exclusion rules patterns (conforming to {@link org.eclipse.aether.util.filter.PatternExclusionsDependencyFilter} + * can be provided as well. + * + * @param root the coordinates of the main resource + * @param includes a list of coordinates to include along the main resource + * @param excludePatterns a list of exclusion patterns + * @see org.eclipse.aether.util.filter.PatternExclusionsDependencyFilter + * @return the main resource and the additional dependencies + */ + Resource[] resolve(Coordinates root, Coordinates[] includes, String[] excludePatterns); } diff --git a/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/utils/ClassloaderUtils.java b/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/utils/ClassloaderUtils.java index 5dc92ec53..bbf123a8e 100644 --- a/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/utils/ClassloaderUtils.java +++ b/spring-cloud-stream-module-launcher/src/main/java/org/springframework/cloud/stream/module/utils/ClassloaderUtils.java @@ -20,7 +20,6 @@ import java.net.URL; import java.net.URLClassLoader; import org.springframework.boot.loader.LaunchedURLClassLoader; -import org.springframework.util.Assert; /** * @author Marius Bogoevici diff --git a/spring-cloud-stream-module-launcher/src/test/java/org/springframework/cloud/stream/module/resolver/AetherModuleResolverTests.java b/spring-cloud-stream-module-launcher/src/test/java/org/springframework/cloud/stream/module/resolver/AetherModuleResolverTests.java index 6725e4d79..122a58aba 100644 --- a/spring-cloud-stream-module-launcher/src/test/java/org/springframework/cloud/stream/module/resolver/AetherModuleResolverTests.java +++ b/spring-cloud-stream-module-launcher/src/test/java/org/springframework/cloud/stream/module/resolver/AetherModuleResolverTests.java @@ -61,7 +61,7 @@ public class AetherModuleResolverTests { ClassPathResource cpr = new ClassPathResource("local-repo"); File localRepository = cpr.getFile(); AetherModuleResolver defaultModuleResolver = new AetherModuleResolver(localRepository, null); - Resource resource = defaultModuleResolver.resolve("foo.bar", "foo-bar", "jar", "", "1.0.0"); + Resource resource = defaultModuleResolver.resolve(new Coordinates("foo.bar", "foo-bar", "jar", "", "1.0.0")); assertTrue(resource.exists()); assertEquals(resource.getFile().getName(), "foo-bar-1.0.0.jar"); } @@ -71,7 +71,7 @@ public class AetherModuleResolverTests { ClassPathResource cpr = new ClassPathResource("local-repo"); File localRepository = cpr.getFile(); AetherModuleResolver defaultModuleResolver = new AetherModuleResolver(localRepository, null); - defaultModuleResolver.resolve("niente", "nada", "jar", "", "zilch"); + defaultModuleResolver.resolve(new Coordinates("niente", "nada", "jar", "", "zilch")); } @Test @@ -82,8 +82,8 @@ public class AetherModuleResolverTests { Map remoteRepos = new HashMap<>(); remoteRepos.put("modules", "http://repo.spring.io/libs-snapshot"); AetherModuleResolver defaultModuleResolver = new AetherModuleResolver(localRepository, remoteRepos); - Resource resource = defaultModuleResolver.resolve("org.springframework.cloud.stream.module", "time-source", - "jar", "exec", "1.0.0.BUILD-SNAPSHOT"); + Resource resource = defaultModuleResolver.resolve( + new Coordinates("org.springframework.cloud.stream.module", "time-source", "jar", "exec", "1.0.0.BUILD-SNAPSHOT")); assertTrue(resource.exists()); assertEquals(resource.getFile().getName(), "time-source-1.0.0.BUILD-SNAPSHOT-exec.jar"); } @@ -102,7 +102,7 @@ public class AetherModuleResolverTests { .withStatus(200) .withBodyFile(stubFileName))); AetherModuleResolver defaultModuleResolver = new AetherModuleResolver(localRepository, remoteRepos); - Resource resource = defaultModuleResolver.resolve("org.bar", "foo", "jar", "", "1.0.0"); + Resource resource = defaultModuleResolver.resolve(new Coordinates("org.bar", "foo", "jar", "", "1.0.0")); assertTrue(resource.exists()); assertEquals(resource.getFile().getName(), "foo-1.0.0.jar"); } @@ -123,7 +123,7 @@ public class AetherModuleResolverTests { .withBodyFile(stubFileName))); AetherModuleResolver defaultModuleResolver = new AetherModuleResolver(localRepository, remoteRepos); defaultModuleResolver.setOffline(true); - defaultModuleResolver.resolve("org.bar", "foo", "jar", "", "1.0.0"); + defaultModuleResolver.resolve(new Coordinates("org.bar", "foo", "jar", "", "1.0.0")); } catch (RuntimeException e) { // remote resolution fails because the resolver is operating offline assertThat(e.getCause(), instanceOf(ArtifactResolutionException.class)); diff --git a/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/aggregate/AggregateApplication.java b/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/aggregate/AggregateApplication.java index 60d0c322b..2d232489e 100644 --- a/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/aggregate/AggregateApplication.java +++ b/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/aggregate/AggregateApplication.java @@ -20,10 +20,10 @@ import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.cloud.stream.annotation.EnableBinding; +import org.springframework.cloud.stream.binding.BindableProxyFactory; import org.springframework.cloud.stream.messaging.Processor; import org.springframework.cloud.stream.messaging.Sink; import org.springframework.cloud.stream.messaging.Source; -import org.springframework.cloud.stream.binding.BindableProxyFactory; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.integration.channel.DirectChannel;