diff --git a/spring-boot-cli/README.md b/spring-boot-cli/README.md index 3a750c14d5d..f2fdb525cd7 100644 --- a/spring-boot-cli/README.md +++ b/spring-boot-cli/README.md @@ -198,3 +198,18 @@ $ . ~/.gvm/springboot/current/bash_completion.d/spring $ spring clean -d debug help run test version ``` + +## Packaging Your Application + +You can use the `jar` command to package your application into a +self-contained executable jar file. For example: + +``` +$ spring jar my-app.jar *.groovy +``` + +The resulting jar will containe the classes produced by compiling +the application and all of the application's dependencies such that +it can then be run using `java -jar`. The jar file will also contain +entries from the application's classpath. See the output of +`spring help jar` for more information. \ No newline at end of file diff --git a/spring-boot-cli/pom.xml b/spring-boot-cli/pom.xml index e3e129416b6..43c2f6a8904 100644 --- a/spring-boot-cli/pom.xml +++ b/spring-boot-cli/pom.xml @@ -33,6 +33,11 @@ spring-boot-dependency-tools ${project.version} + + ${project.groupId} + spring-boot-loader-tools + ${project.version} + jline jline @@ -98,6 +103,12 @@ aether-util + + ${project.groupId} + spring-boot-loader + ${project.version} + provided + org.codehaus.groovy groovy-templates diff --git a/spring-boot-cli/src/it/java/org/springframework/boot/cli/CommandLineIT.java b/spring-boot-cli/src/it/java/org/springframework/boot/cli/CommandLineIT.java index a03985500c0..73522541ae6 100644 --- a/spring-boot-cli/src/it/java/org/springframework/boot/cli/CommandLineIT.java +++ b/spring-boot-cli/src/it/java/org/springframework/boot/cli/CommandLineIT.java @@ -43,7 +43,7 @@ public class CommandLineIT { Invocation cli = this.cli.invoke("hint"); assertThat(cli.await(), equalTo(0)); assertThat(cli.getErrorOutput().length(), equalTo(0)); - assertThat(cli.getStandardOutputLines().size(), equalTo(6)); + assertThat(cli.getStandardOutputLines().size(), equalTo(7)); } @Test diff --git a/spring-boot-cli/src/it/java/org/springframework/boot/cli/JarCommandIT.java b/spring-boot-cli/src/it/java/org/springframework/boot/cli/JarCommandIT.java new file mode 100644 index 00000000000..75b794ca96a --- /dev/null +++ b/spring-boot-cli/src/it/java/org/springframework/boot/cli/JarCommandIT.java @@ -0,0 +1,75 @@ +/* + * Copyright 2012-2014 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.boot.cli; + +import java.io.File; + +import org.junit.Test; +import org.springframework.boot.cli.infrastructure.CommandLineInvoker; +import org.springframework.boot.cli.infrastructure.CommandLineInvoker.Invocation; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * @author Andy Wilkinson + */ +public class JarCommandIT { + + private final CommandLineInvoker cli = new CommandLineInvoker(new File( + "src/it/resources/jar-command")); + + @Test + public void noArguments() throws Exception { + Invocation invocation = this.cli.invoke("jar"); + invocation.await(); + assertEquals(0, invocation.getStandardOutput().length()); + assertEquals( + "The name of the resulting jar and at least one source file must be specified", + invocation.getErrorOutput().trim()); + } + + @Test + public void noSources() throws Exception { + Invocation invocation = this.cli.invoke("jar", "test-app.jar"); + invocation.await(); + assertEquals(0, invocation.getStandardOutput().length()); + assertEquals( + "The name of the resulting jar and at least one source file must be specified", + invocation.getErrorOutput().trim()); + } + + @Test + public void jarCreation() throws Exception { + File jar = new File("target/test-app.jar"); + Invocation invocation = this.cli.invoke("jar", jar.getAbsolutePath(), + "jar.groovy"); + invocation.await(); + assertEquals(0, invocation.getErrorOutput().length()); + assertTrue(jar.exists()); + + ProcessBuilder builder = new ProcessBuilder(System.getProperty("java.home") + + "/bin/java", "-jar", jar.getAbsolutePath()); + Process process = builder.start(); + Invocation appInvocation = new Invocation(process); + appInvocation.await(); + + assertEquals(0, appInvocation.getErrorOutput().length()); + assertTrue(appInvocation.getStandardOutput().contains("Hello World!")); + assertTrue(appInvocation.getStandardOutput().contains("/static/test.txt")); + } +} diff --git a/spring-boot-cli/src/it/java/org/springframework/boot/cli/infrastructure/CommandLineInvoker.java b/spring-boot-cli/src/it/java/org/springframework/boot/cli/infrastructure/CommandLineInvoker.java index a434adfa3c3..0659f6c37d1 100644 --- a/spring-boot-cli/src/it/java/org/springframework/boot/cli/infrastructure/CommandLineInvoker.java +++ b/spring-boot-cli/src/it/java/org/springframework/boot/cli/infrastructure/CommandLineInvoker.java @@ -37,6 +37,16 @@ import org.springframework.util.Assert; */ public final class CommandLineInvoker { + private final File workingDirectory; + + public CommandLineInvoker() { + this(new File(".")); + } + + public CommandLineInvoker(File workingDirectory) { + this.workingDirectory = workingDirectory; + } + public Invocation invoke(String... args) throws IOException { return new Invocation(runCliProcess(args)); } @@ -45,7 +55,7 @@ public final class CommandLineInvoker { List command = new ArrayList(); command.add(findLaunchScript().getAbsolutePath()); command.addAll(Arrays.asList(args)); - return new ProcessBuilder(command).start(); + return new ProcessBuilder(command).directory(this.workingDirectory).start(); } private File findLaunchScript() { @@ -70,9 +80,9 @@ public final class CommandLineInvoker { } /** - * An ongoing CLI invocation. + * An ongoing Process invocation. */ - public final class Invocation { + public static final class Invocation { private final StringBuffer err = new StringBuffer(); @@ -80,7 +90,7 @@ public final class CommandLineInvoker { private final Process process; - Invocation(Process process) { + public Invocation(Process process) { this.process = process; new Thread(new StreamReadingRunnable(this.process.getErrorStream(), this.err)) .start(); diff --git a/spring-boot-cli/src/it/resources/jar-command/jar.groovy b/spring-boot-cli/src/it/resources/jar-command/jar.groovy new file mode 100644 index 00000000000..e022c572a37 --- /dev/null +++ b/spring-boot-cli/src/it/resources/jar-command/jar.groovy @@ -0,0 +1,21 @@ +package org.test + +@Component +class Example implements CommandLineRunner { + + @Autowired + private MyService myService + + void run(String... args) { + println "Hello ${this.myService.sayWorld()}" + println getClass().getResource('/static/test.txt') + } +} + +@Service +class MyService { + + String sayWorld() { + return 'World!' + } +} \ No newline at end of file diff --git a/spring-boot-cli/src/it/resources/jar-command/static/test.txt b/spring-boot-cli/src/it/resources/jar-command/static/test.txt new file mode 100644 index 00000000000..e69de29bb2d diff --git a/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/DefaultCommandFactory.java b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/DefaultCommandFactory.java index dfe53fd1022..26d549ded5f 100644 --- a/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/DefaultCommandFactory.java +++ b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/DefaultCommandFactory.java @@ -22,6 +22,7 @@ import java.util.List; import org.springframework.boot.cli.command.core.VersionCommand; import org.springframework.boot.cli.command.grab.GrabCommand; +import org.springframework.boot.cli.command.jar.JarCommand; import org.springframework.boot.cli.command.run.RunCommand; import org.springframework.boot.cli.command.test.TestCommand; @@ -33,7 +34,8 @@ import org.springframework.boot.cli.command.test.TestCommand; public class DefaultCommandFactory implements CommandFactory { private static final List DEFAULT_COMMANDS = Arrays. asList( - new VersionCommand(), new RunCommand(), new TestCommand(), new GrabCommand()); + new VersionCommand(), new RunCommand(), new TestCommand(), new GrabCommand(), + new JarCommand()); @Override public Collection getCommands() { diff --git a/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/SourceOptions.java b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/SourceOptions.java index 496c3b99f03..c953af2dc7c 100644 --- a/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/SourceOptions.java +++ b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/SourceOptions.java @@ -31,6 +31,7 @@ import org.springframework.util.Assert; * @author Phillip Webb * @author Dave Syer * @author Greg Turnquist + * @author Andy Wilkinson */ public class SourceOptions { @@ -46,6 +47,14 @@ public class SourceOptions { this(options, null); } + /** + * Create a new {@link SourceOptions} instance. + * @param arguments the source arguments + */ + public SourceOptions(List arguments) { + this(arguments, null); + } + /** * Create a new {@link SourceOptions} instance. If it is an error to pass options that * specify non-existent sources, but the default paths are allowed not to exist (the @@ -57,7 +66,10 @@ public class SourceOptions { * found in the local filesystem */ public SourceOptions(OptionSet optionSet, ClassLoader classLoader) { - List nonOptionArguments = optionSet.nonOptionArguments(); + this(optionSet.nonOptionArguments(), classLoader); + } + + private SourceOptions(List nonOptionArguments, ClassLoader classLoader) { List sources = new ArrayList(); int sourceArgCount = 0; for (Object option : nonOptionArguments) { @@ -84,7 +96,7 @@ public class SourceOptions { } this.args = Collections.unmodifiableList(nonOptionArguments.subList( sourceArgCount, nonOptionArguments.size())); - Assert.isTrue(sources.size() > 0, "Please specify at least one file to run"); + Assert.isTrue(sources.size() > 0, "Please specify at least one file"); this.sources = Collections.unmodifiableList(sources); } diff --git a/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/jar/JarCommand.java b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/jar/JarCommand.java new file mode 100644 index 00000000000..c149774ca4a --- /dev/null +++ b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/jar/JarCommand.java @@ -0,0 +1,269 @@ +/* + * Copyright 2012-2014 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.boot.cli.command.jar; + +import groovy.lang.Grab; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.Manifest; + +import joptsimple.OptionSet; +import joptsimple.OptionSpec; + +import org.codehaus.groovy.ast.ASTNode; +import org.codehaus.groovy.ast.AnnotationNode; +import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.ModuleNode; +import org.codehaus.groovy.ast.expr.ConstantExpression; +import org.codehaus.groovy.control.SourceUnit; +import org.codehaus.groovy.transform.ASTTransformation; +import org.springframework.boot.cli.command.Command; +import org.springframework.boot.cli.command.CompilerOptionHandler; +import org.springframework.boot.cli.command.OptionParsingCommand; +import org.springframework.boot.cli.command.SourceOptions; +import org.springframework.boot.cli.command.jar.ResourceMatcher.MatchedResource; +import org.springframework.boot.cli.compiler.GroovyCompiler; +import org.springframework.boot.cli.compiler.GroovyCompiler.CompilationCallback; +import org.springframework.boot.cli.compiler.GroovyCompilerConfiguration; +import org.springframework.boot.cli.compiler.GroovyCompilerConfigurationAdapter; +import org.springframework.boot.cli.compiler.RepositoryConfigurationFactory; +import org.springframework.boot.cli.compiler.grape.RepositoryConfiguration; +import org.springframework.boot.loader.ArchiveResolver; +import org.springframework.boot.loader.tools.JarWriter; +import org.springframework.util.StringUtils; + +/** + * {@link Command} to create a self-contained executable jar file from a CLI application + * + * @author Andy Wilkinson + */ +public class JarCommand extends OptionParsingCommand { + + public JarCommand() { + super( + "jar", + "Create a self-contained executable jar file from a Spring Groovy script", + new JarOptionHandler()); + } + + @Override + public String getUsageHelp() { + return "[options] "; + } + + private static final class JarOptionHandler extends CompilerOptionHandler { + + private OptionSpec includeOption; + + private OptionSpec excludeOption; + + @Override + protected void doOptions() { + this.includeOption = option( + "include", + "Pattern applied to directories on the classpath to find files to include in the resulting jar") + .withRequiredArg().defaultsTo("public/**", "static/**", + "resources/**", "META-INF/**", "*"); + this.excludeOption = option( + "exclude", + "Pattern applied to directories on the claspath to find files to exclude from the resulting jar") + .withRequiredArg().defaultsTo(".*", "repository/**", "build/**", + "target/**"); + } + + @Override + protected void run(OptionSet options) throws Exception { + List nonOptionArguments = new ArrayList( + options.nonOptionArguments()); + if (nonOptionArguments.size() < 2) { + throw new IllegalStateException( + "The name of the resulting jar and at least one source file must be specified"); + } + + File output = new File((String) nonOptionArguments.remove(0)); + if (output.exists() && !output.delete()) { + throw new IllegalStateException( + "Failed to delete existing application jar file " + + output.getPath()); + } + + GroovyCompiler groovyCompiler = createCompiler(options); + + List classpathUrls = Arrays.asList(groovyCompiler.getLoader().getURLs()); + List classpathEntries = findClasspathEntries(classpathUrls, + options); + + final Map compiledClasses = new HashMap(); + groovyCompiler.compile(new CompilationCallback() { + + @Override + public void byteCodeGenerated(byte[] byteCode, ClassNode classNode) + throws IOException { + String className = classNode.getName(); + compiledClasses.put(className, byteCode); + } + + }, new SourceOptions(nonOptionArguments).getSourcesArray()); + + List dependencyUrls = new ArrayList(Arrays.asList(groovyCompiler + .getLoader().getURLs())); + dependencyUrls.removeAll(classpathUrls); + + JarWriter jarWriter = new JarWriter(output); + + try { + jarWriter.writeManifest(createManifest(compiledClasses)); + addDependencies(jarWriter, dependencyUrls); + addClasspathEntries(jarWriter, classpathEntries); + addApplicationClasses(jarWriter, compiledClasses); + jarWriter.writeLoaderClasses(); + addJarRunner(jarWriter); + } + finally { + jarWriter.close(); + } + } + + private GroovyCompiler createCompiler(OptionSet options) { + List repositoryConfiguration = RepositoryConfigurationFactory + .createDefaultRepositoryConfiguration(); + + GroovyCompilerConfiguration configuration = new GroovyCompilerConfigurationAdapter( + options, this, repositoryConfiguration); + + GroovyCompiler groovyCompiler = new GroovyCompiler(configuration); + groovyCompiler.getAstTransformations().add(0, new ASTTransformation() { + + @Override + public void visit(ASTNode[] nodes, SourceUnit source) { + for (ASTNode node : nodes) { + if (node instanceof ModuleNode) { + ModuleNode module = (ModuleNode) node; + for (ClassNode classNode : module.getClasses()) { + AnnotationNode annotation = new AnnotationNode( + new ClassNode(Grab.class)); + annotation.addMember("value", new ConstantExpression( + "groovy")); + classNode.addAnnotation(annotation); + } + } + } + } + }); + return groovyCompiler; + } + + private List findClasspathEntries(List classpath, + OptionSet options) throws IOException { + ResourceMatcher resourceCollector = new ResourceMatcher( + options.valuesOf(this.includeOption), + options.valuesOf(this.excludeOption)); + + List roots = new ArrayList(); + + for (URL classpathEntry : classpath) { + roots.add(new File(URI.create(classpathEntry.toString()))); + } + + return resourceCollector.matchResources(roots); + } + + private Manifest createManifest(final Map compiledClasses) { + Manifest manifest = new Manifest(); + manifest.getMainAttributes().putValue("Manifest-Version", "1.0"); + manifest.getMainAttributes() + .putValue( + "Application-Classes", + StringUtils.collectionToCommaDelimitedString(compiledClasses + .keySet())); + + manifest.getMainAttributes() + .putValue("Main-Class", JarRunner.class.getName()); + return manifest; + } + + private void addDependencies(JarWriter jarWriter, List urls) + throws IOException, URISyntaxException, FileNotFoundException { + for (URL url : urls) { + addDependency(jarWriter, new File(url.toURI())); + } + } + + private void addDependency(JarWriter jarWriter, File dependency) + throws FileNotFoundException, IOException { + if (dependency.isFile()) { + jarWriter.writeNestedLibrary("lib/", dependency); + } + } + + private void addClasspathEntries(JarWriter jarWriter, + List classpathEntries) throws IOException { + for (MatchedResource classpathEntry : classpathEntries) { + if (classpathEntry.isRoot()) { + addDependency(jarWriter, classpathEntry.getFile()); + } + else { + jarWriter.writeEntry(classpathEntry.getPath(), new FileInputStream( + classpathEntry.getFile())); + } + } + } + + private void addApplicationClasses(JarWriter jarWriter, + final Map compiledClasses) throws IOException { + + for (Entry entry : compiledClasses.entrySet()) { + String className = entry.getKey().replace(".", "/") + ".class"; + jarWriter.writeEntry(className, + new ByteArrayInputStream(entry.getValue())); + } + } + + private void addJarRunner(JarWriter jar) throws IOException, URISyntaxException { + JarFile jarFile = getJarFile(); + String namePrefix = JarRunner.class.getName().replace(".", "/"); + Enumeration entries = jarFile.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + if (entry.getName().startsWith(namePrefix)) { + jar.writeEntry(entry.getName(), jarFile.getInputStream(entry)); + } + } + } + + private JarFile getJarFile() throws URISyntaxException, IOException { + return new JarFile( + new ArchiveResolver().resolveArchiveLocation(JarCommand.class)); + } + } +} diff --git a/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/jar/JarRunner.java b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/jar/JarRunner.java new file mode 100644 index 00000000000..6b5024bad38 --- /dev/null +++ b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/jar/JarRunner.java @@ -0,0 +1,98 @@ +/* + * Copyright 2012-2014 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.boot.cli.command.jar; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.boot.loader.ArchiveResolver; +import org.springframework.boot.loader.AsciiBytes; +import org.springframework.boot.loader.LaunchedURLClassLoader; +import org.springframework.boot.loader.archive.Archive; +import org.springframework.boot.loader.archive.Archive.Entry; +import org.springframework.boot.loader.archive.Archive.EntryFilter; + +/** + * A runner for a CLI application that has been compiled and packaged as a jar file + * + * @author Andy Wilkinson + */ +public class JarRunner { + + private static final AsciiBytes LIB = new AsciiBytes("lib/"); + + public static void main(String[] args) throws URISyntaxException, IOException, + ClassNotFoundException, SecurityException, NoSuchMethodException, + IllegalArgumentException, IllegalAccessException, InvocationTargetException { + + Archive archive = new ArchiveResolver().resolveArchive(JarRunner.class); + + ClassLoader classLoader = createClassLoader(archive); + Class[] classes = loadApplicationClasses(archive, classLoader); + + Thread.currentThread().setContextClassLoader(classLoader); + + // Use reflection to load and call Spring + Class application = classLoader + .loadClass("org.springframework.boot.SpringApplication"); + Method method = application.getMethod("run", Object[].class, String[].class); + method.invoke(null, classes, args); + + } + + private static ClassLoader createClassLoader(Archive archive) throws IOException, + MalformedURLException { + List nestedArchives = archive.getNestedArchives(new EntryFilter() { + + @Override + public boolean matches(Entry entry) { + return entry.getName().startsWith(LIB); + } + + }); + + List urls = new ArrayList(); + urls.add(archive.getUrl()); + for (Archive nestedArchive : nestedArchives) { + urls.add(nestedArchive.getUrl()); + } + + ClassLoader classLoader = new LaunchedURLClassLoader(urls.toArray(new URL[urls + .size()]), JarRunner.class.getClassLoader()); + return classLoader; + } + + private static Class[] loadApplicationClasses(Archive archive, + ClassLoader classLoader) throws ClassNotFoundException, IOException { + String[] classNames = archive.getManifest().getMainAttributes() + .getValue("Application-Classes").split(","); + + Class[] classes = new Class[classNames.length]; + + for (int i = 0; i < classNames.length; i++) { + Class applicationClass = classLoader.loadClass(classNames[i]); + classes[i] = applicationClass; + } + return classes; + } +} diff --git a/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/jar/ResourceMatcher.java b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/jar/ResourceMatcher.java new file mode 100644 index 00000000000..7c4754a4aef --- /dev/null +++ b/spring-boot-cli/src/main/java/org/springframework/boot/cli/command/jar/ResourceMatcher.java @@ -0,0 +1,165 @@ +/* + * Copyright 2012-2014 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.boot.cli.command.jar; + +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; + +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.util.AntPathMatcher; + +/** + * Used to match resources for inclusion in a CLI application's jar file + * + * @author Andy Wilkinson + */ +final class ResourceMatcher { + + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + private final List includes; + + private final List excludes; + + ResourceMatcher(List includes, List excludes) { + this.includes = includes; + this.excludes = excludes; + } + + List matchResources(List roots) throws IOException { + List matchedResources = new ArrayList(); + + for (File root : roots) { + if (root.isFile()) { + matchedResources.add(new MatchedResource(root)); + } + else { + matchedResources.addAll(matchResources(root)); + } + } + return matchedResources; + } + + private List matchResources(File root) throws IOException { + List resources = new ArrayList(); + + PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver( + new ResourceCollectionResourceLoader(root)); + + for (String include : this.includes) { + Resource[] candidates = resolver.getResources(include); + for (Resource candidate : candidates) { + File file = candidate.getFile(); + if (file.isFile()) { + MatchedResource matchedResource = new MatchedResource(root, file); + if (!isExcluded(matchedResource)) { + resources.add(matchedResource); + } + } + } + } + + return resources; + } + + private boolean isExcluded(MatchedResource matchedResource) { + for (String exclude : this.excludes) { + if (this.pathMatcher.match(exclude, matchedResource.getPath())) { + return true; + } + } + + return false; + } + + private static final class ResourceCollectionResourceLoader extends + DefaultResourceLoader { + + private final File root; + + ResourceCollectionResourceLoader(File root) throws MalformedURLException { + super(new URLClassLoader(new URL[] { root.toURI().toURL() }) { + @Override + public Enumeration getResources(String name) throws IOException { + return findResources(name); + } + + @Override + public URL getResource(String name) { + return findResource(name); + } + }); + this.root = root; + } + + @Override + protected Resource getResourceByPath(String path) { + return new FileSystemResource(new File(this.root, path)); + } + } + + static final class MatchedResource { + + private final File file; + + private final String path; + + private final boolean root; + + private MatchedResource(File resourceFile) { + this(resourceFile, resourceFile.getName(), true); + } + + private MatchedResource(File root, File resourceFile) { + this(resourceFile, resourceFile.getAbsolutePath().substring( + root.getAbsolutePath().length() + 1), false); + } + + private MatchedResource(File resourceFile, String path, boolean root) { + this.file = resourceFile; + this.path = path; + this.root = root; + } + + File getFile() { + return this.file; + } + + String getPath() { + return this.path; + } + + boolean isRoot() { + return this.root; + } + + @Override + public String toString() { + return this.file.getAbsolutePath(); + } + } + +} diff --git a/spring-boot-cli/src/main/java/org/springframework/boot/cli/compiler/GroovyCompiler.java b/spring-boot-cli/src/main/java/org/springframework/boot/cli/compiler/GroovyCompiler.java index 37664215801..b0998ab6cc2 100644 --- a/spring-boot-cli/src/main/java/org/springframework/boot/cli/compiler/GroovyCompiler.java +++ b/spring-boot-cli/src/main/java/org/springframework/boot/cli/compiler/GroovyCompiler.java @@ -34,6 +34,7 @@ import org.codehaus.groovy.ast.ClassNode; import org.codehaus.groovy.classgen.GeneratorContext; import org.codehaus.groovy.control.CompilationFailedException; import org.codehaus.groovy.control.CompilationUnit; +import org.codehaus.groovy.control.CompilationUnit.ClassgenCallback; import org.codehaus.groovy.control.CompilePhase; import org.codehaus.groovy.control.CompilerConfiguration; import org.codehaus.groovy.control.Phases; @@ -42,6 +43,8 @@ import org.codehaus.groovy.control.customizers.CompilationCustomizer; import org.codehaus.groovy.control.customizers.ImportCustomizer; import org.codehaus.groovy.transform.ASTTransformation; import org.codehaus.groovy.transform.ASTTransformationVisitor; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.ClassWriter; import org.springframework.boot.cli.compiler.grape.AetherGrapeEngine; import org.springframework.boot.cli.compiler.grape.AetherGrapeEngineFactory; import org.springframework.boot.cli.compiler.grape.GrapeEngineInstaller; @@ -117,6 +120,10 @@ public class GroovyCompiler { } } + public List getAstTransformations() { + return this.transformations; + } + public ExtendedGroovyClassLoader getLoader() { return this.loader; } @@ -203,6 +210,42 @@ public class GroovyCompiler { return classes.toArray(new Class[classes.size()]); } + public void compile(final CompilationCallback callback, String... sources) + throws CompilationFailedException, IOException { + this.loader.clearCache(); + + CompilerConfiguration configuration = this.loader.getConfiguration(); + + final CompilationUnit compilationUnit = new CompilationUnit(configuration, null, + this.loader); + ClassgenCallback classgenCallback = new ClassgenCallback() { + + @Override + public void call(ClassVisitor writer, ClassNode node) + throws CompilationFailedException { + try { + callback.byteCodeGenerated(((ClassWriter) writer).toByteArray(), node); + } + catch (IOException ioe) { + throw new CompilationFailedException(Phases.CLASS_GENERATION, + compilationUnit); + } + } + }; + compilationUnit.setClassgenCallback(classgenCallback); + + for (String source : sources) { + List paths = ResourceUtils.getUrls(source, this.loader); + for (String path : paths) { + compilationUnit.addSource(new URL(path)); + } + } + + addAstTransformations(compilationUnit); + + compilationUnit.compile(Phases.CLASS_GENERATION); + } + @SuppressWarnings("rawtypes") private void addAstTransformations(CompilationUnit compilationUnit) { LinkedList[] phaseOperations = getPhaseOperations(compilationUnit); @@ -286,4 +329,10 @@ public class GroovyCompiler { } + public static interface CompilationCallback { + + public void byteCodeGenerated(byte[] byteCode, ClassNode classNode) + throws IOException; + } + } diff --git a/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/jar/ResourceMatcherTests.java b/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/jar/ResourceMatcherTests.java new file mode 100644 index 00000000000..23431d1e094 --- /dev/null +++ b/spring-boot-cli/src/test/java/org/springframework/boot/cli/command/jar/ResourceMatcherTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-2014 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.boot.cli.command.jar; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.Test; +import org.springframework.boot.cli.command.jar.ResourceMatcher.MatchedResource; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * @author awilkinson + */ +public class ResourceMatcherTests { + + private final ResourceMatcher resourceMatcher = new ResourceMatcher(Arrays.asList( + "alpha/**", "bravo/*", "*"), Arrays.asList(".*", "alpha/**/excluded")); + + @Test + public void nonExistentRoot() throws IOException { + List matchedResources = this.resourceMatcher + .matchResources(Arrays.asList(new File("does-not-exist"))); + assertEquals(0, matchedResources.size()); + } + + @Test + public void resourceMatching() throws IOException { + List matchedResources = this.resourceMatcher + .matchResources(Arrays.asList(new File( + "src/test/resources/resource-matcher/one"), new File( + "src/test/resources/resource-matcher/two"), new File( + "src/test/resources/resource-matcher/three"))); + System.out.println(matchedResources); + List paths = new ArrayList(); + for (MatchedResource resource : matchedResources) { + paths.add(resource.getPath()); + } + + assertEquals(6, paths.size()); + assertTrue(paths.containsAll(Arrays.asList("alpha/nested/fileA", "bravo/fileC", + "fileD", "bravo/fileE", "fileF", "three"))); + } +} diff --git a/spring-boot-cli/src/test/resources/resource-matcher/one/alpha/nested/excluded b/spring-boot-cli/src/test/resources/resource-matcher/one/alpha/nested/excluded new file mode 100644 index 00000000000..e69de29bb2d diff --git a/spring-boot-cli/src/test/resources/resource-matcher/one/alpha/nested/fileA b/spring-boot-cli/src/test/resources/resource-matcher/one/alpha/nested/fileA new file mode 100644 index 00000000000..e69de29bb2d diff --git a/spring-boot-cli/src/test/resources/resource-matcher/one/bravo/fileC b/spring-boot-cli/src/test/resources/resource-matcher/one/bravo/fileC new file mode 100644 index 00000000000..e69de29bb2d diff --git a/spring-boot-cli/src/test/resources/resource-matcher/one/bravo/nested/fileB b/spring-boot-cli/src/test/resources/resource-matcher/one/bravo/nested/fileB new file mode 100644 index 00000000000..e69de29bb2d diff --git a/spring-boot-cli/src/test/resources/resource-matcher/one/fileD b/spring-boot-cli/src/test/resources/resource-matcher/one/fileD new file mode 100644 index 00000000000..e69de29bb2d diff --git a/spring-boot-cli/src/test/resources/resource-matcher/three b/spring-boot-cli/src/test/resources/resource-matcher/three new file mode 100644 index 00000000000..e69de29bb2d diff --git a/spring-boot-cli/src/test/resources/resource-matcher/two/.file b/spring-boot-cli/src/test/resources/resource-matcher/two/.file new file mode 100644 index 00000000000..e69de29bb2d diff --git a/spring-boot-cli/src/test/resources/resource-matcher/two/bravo/fileE b/spring-boot-cli/src/test/resources/resource-matcher/two/bravo/fileE new file mode 100644 index 00000000000..e69de29bb2d diff --git a/spring-boot-cli/src/test/resources/resource-matcher/two/fileF b/spring-boot-cli/src/test/resources/resource-matcher/two/fileF new file mode 100644 index 00000000000..e69de29bb2d diff --git a/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/JarWriter.java b/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/JarWriter.java index 8af05720627..9bee0d2b1cd 100644 --- a/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/JarWriter.java +++ b/spring-boot-tools/spring-boot-loader-tools/src/main/java/org/springframework/boot/loader/tools/JarWriter.java @@ -45,7 +45,7 @@ import java.util.zip.ZipEntry; * * @author Phillip Webb */ -class JarWriter { +public class JarWriter { private static final String NESTED_LOADER_JAR = "META-INF/loader/spring-boot-loader.jar"; @@ -107,6 +107,18 @@ class JarWriter { } } + /** + * Writes an entry. The {@code inputStream} is closed once the entry has been written + * + * @param entryName The name of the entry + * @param inputStream The stream from which the entry's data can be read + * @throws IOException if the write fails + */ + public void writeEntry(String entryName, InputStream inputStream) throws IOException { + JarEntry entry = new JarEntry(entryName); + writeEntry(entry, new InputStreamEntryWriter(inputStream, true)); + } + /** * Write a nested library. * @param destination the destination of the library diff --git a/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ArchiveResolver.java b/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ArchiveResolver.java new file mode 100644 index 00000000000..5d1d48db187 --- /dev/null +++ b/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ArchiveResolver.java @@ -0,0 +1,83 @@ +/* + * Copyright 2012-2014 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.boot.loader; + +import java.io.File; +import java.io.IOException; +import java.net.JarURLConnection; +import java.net.URL; +import java.net.URLConnection; +import java.security.CodeSource; +import java.security.ProtectionDomain; + +import org.springframework.boot.loader.archive.Archive; +import org.springframework.boot.loader.archive.ExplodedArchive; +import org.springframework.boot.loader.archive.JarFileArchive; + +/** + * Resolves the {@link Archive} from which a {@link Class} was loaded. + * + * @author Andy Wilkinson + * @author Phillip Webb + */ +public class ArchiveResolver { + + /** + * Resolves the {@link Archive} that contains the given {@code clazz}. + * @param clazz The class whose containing archive is to be resolved + * + * @return The class's containing archive + * @throws IOException if an error occurs when resolving the containing archive + */ + public Archive resolveArchive(Class clazz) throws IOException { + File root = resolveArchiveLocation(clazz); + return (root.isDirectory() ? new ExplodedArchive(root) : new JarFileArchive(root)); + } + + /** + * Resolves the location of the archive that contains the given {@code clazz}. + * @param clazz The class for which the location of the containing archive is to be + * resolved + * + * @return The location of the class's containing archive + * @throws IOException if an error occurs when resolving the containing archive's + * location + */ + public File resolveArchiveLocation(Class clazz) throws IOException { + ProtectionDomain protectionDomain = getClass().getProtectionDomain(); + CodeSource codeSource = protectionDomain.getCodeSource(); + + if (codeSource != null) { + File root; + URL location = codeSource.getLocation(); + URLConnection connection = location.openConnection(); + if (connection instanceof JarURLConnection) { + root = new File(((JarURLConnection) connection).getJarFile().getName()); + } + else { + root = new File(location.getPath()); + } + + if (!root.exists()) { + throw new IllegalStateException( + "Unable to determine code source archive from " + root); + } + return root; + } + throw new IllegalStateException("Unable to determine code source archive"); + } +} diff --git a/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java b/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java index 1a4dbb0c7f6..05df0f5ae81 100644 --- a/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java +++ b/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/ExecutableArchiveLauncher.java @@ -16,10 +16,6 @@ package org.springframework.boot.loader; -import java.io.File; -import java.net.URI; -import java.security.CodeSource; -import java.security.ProtectionDomain; import java.util.ArrayList; import java.util.List; import java.util.jar.JarEntry; @@ -27,8 +23,6 @@ import java.util.jar.JarEntry; import org.springframework.boot.loader.archive.Archive; import org.springframework.boot.loader.archive.Archive.Entry; import org.springframework.boot.loader.archive.Archive.EntryFilter; -import org.springframework.boot.loader.archive.ExplodedArchive; -import org.springframework.boot.loader.archive.JarFileArchive; /** * Base class for executable archive {@link Launcher}s. @@ -49,19 +43,7 @@ public abstract class ExecutableArchiveLauncher extends Launcher { } private Archive createArchive() throws Exception { - ProtectionDomain protectionDomain = getClass().getProtectionDomain(); - CodeSource codeSource = protectionDomain.getCodeSource(); - URI location = (codeSource == null ? null : codeSource.getLocation().toURI()); - String path = (location == null ? null : location.getPath()); - if (path == null) { - throw new IllegalStateException("Unable to determine code source archive"); - } - File root = new File(path); - if (!root.exists()) { - throw new IllegalStateException( - "Unable to determine code source archive from " + root); - } - return (root.isDirectory() ? new ExplodedArchive(root) : new JarFileArchive(root)); + return new ArchiveResolver().resolveArchive(getClass()); } protected final Archive getArchive() { diff --git a/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/PropertiesLauncher.java b/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/PropertiesLauncher.java index 508427732c7..f166d743dfa 100644 --- a/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/PropertiesLauncher.java +++ b/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader/PropertiesLauncher.java @@ -21,13 +21,10 @@ import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; -import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.net.URLClassLoader; import java.net.URLConnection; -import java.security.CodeSource; -import java.security.ProtectionDomain; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -497,19 +494,7 @@ public class PropertiesLauncher extends Launcher { } private Archive createArchive() throws Exception { - ProtectionDomain protectionDomain = getClass().getProtectionDomain(); - CodeSource codeSource = protectionDomain.getCodeSource(); - URI location = (codeSource == null ? null : codeSource.getLocation().toURI()); - String path = (location == null ? null : location.getPath()); - if (path == null) { - throw new IllegalStateException("Unable to determine code source archive"); - } - File root = new File(path); - if (!root.exists()) { - throw new IllegalStateException( - "Unable to determine code source archive from " + root); - } - return (root.isDirectory() ? new ExplodedArchive(root) : new JarFileArchive(root)); + return new ArchiveResolver().resolveArchive(getClass()); } private void addParentClassLoaderEntries(List lib) throws IOException,