Isolate class loading for launched CLI apps

Rework classloading for launched applications so that CLI classes and
dependencies are not visible. This change allows many of the previous
hacks and workarounds to be removed.

With the exception of the 'org.springframework.boot.groovy' package
and 'groovy-all' all user required depndencies are now pulled in
via @Grab annotations.

The updated classloading algorithm has enabled the following changes:

- AetherGrapeEngine is now back in the cli project and the
  spring-boot-cli-grape project has been removed. The AetherGrapeEngine
  has also been simplified.

- The TestCommand now launches a TestRunner (similar in design to the
  SpringApplicationRunner) and report test failures directly using
  the junit TextListener. Adding custom 'testers' source to the users
  project is no longer required. The previous 'double compile' for
  tests has also been removed.

- Utility classes have been removed in favor of using versions from
  spring-core.

- The CLI jar is now packaged using the 'boot-loader' rather than using
  the maven shade plugin.

This commit also applied minor polish refactoring to a number of
classes.
This commit is contained in:
Phillip Webb
2013-11-04 22:35:23 -08:00
parent 58300deee4
commit b19f6bb238
62 changed files with 1481 additions and 1681 deletions

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2012-2013 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 org.junit.Rule;
import org.junit.Test;
import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertThat;
/**
* Tests for CLI Classloader issues.
*
* @author Phillip Webb
*/
public class ClassLoaderIntegrationTests {
@Rule
public CliTester cli = new CliTester();
@Test
public void runWithIsolatedClassLoader() throws Exception {
// CLI classes or dependencies should not be exposed to the app
String output = this.cli.run("src/test/resources/classloader-test-app.groovy",
SpringCli.class.getName());
assertThat(output, containsString("HasClasses-false-true-false"));
}
}

View File

@@ -27,8 +27,10 @@ import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
import org.springframework.boot.OutputCapture;
import org.springframework.boot.cli.command.AbstractCommand;
import org.springframework.boot.cli.command.CleanCommand;
import org.springframework.boot.cli.command.RunCommand;
import org.springframework.boot.cli.command.TestCommand;
/**
* {@link TestRule} that can be used to invoke CLI commands.
@@ -41,7 +43,7 @@ public class CliTester implements TestRule {
private long timeout = TimeUnit.MINUTES.toMillis(6);
private List<RunCommand> commands = new ArrayList<RunCommand>();
private List<AbstractCommand> commands = new ArrayList<AbstractCommand>();
public void setTimeout(long timeout) {
this.timeout = timeout;
@@ -61,6 +63,20 @@ public class CliTester implements TestRule {
return getOutput();
}
public String test(final String... args) throws Exception {
Future<TestCommand> future = Executors.newSingleThreadExecutor().submit(
new Callable<TestCommand>() {
@Override
public TestCommand call() throws Exception {
TestCommand command = new TestCommand();
command.run(args);
return command;
}
});
this.commands.add(future.get(this.timeout, TimeUnit.MILLISECONDS));
return getOutput();
}
public String getOutput() {
return this.outputCapture.toString();
}
@@ -87,9 +103,9 @@ public class CliTester implements TestRule {
this.base.evaluate();
}
finally {
for (RunCommand command : CliTester.this.commands) {
if (command != null) {
command.stop();
for (AbstractCommand command : CliTester.this.commands) {
if (command != null && command instanceof RunCommand) {
((RunCommand) command).stop();
}
}
System.clearProperty("disableSpringSnapshotRepos");

View File

@@ -16,14 +16,17 @@
package org.springframework.boot.cli;
import java.io.BufferedReader;
import java.io.File;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URI;
import org.codehaus.plexus.util.FileUtils;
import org.junit.BeforeClass;
import org.junit.Ignore;
import org.junit.Rule;
import org.junit.Test;
import org.springframework.boot.cli.util.FileUtils;
import org.springframework.boot.cli.util.IoUtils;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
@@ -38,14 +41,14 @@ import static org.junit.Assert.assertTrue;
*/
public class SampleIntegrationTests {
@Rule
public CliTester cli = new CliTester();
@BeforeClass
public static void cleanGrapes() throws Exception {
GrapesCleaner.cleanIfNecessary();
}
@Rule
public CliTester cli = new CliTester();
@Test
public void appSample() throws Exception {
String output = this.cli.run("samples/app.groovy");
@@ -61,7 +64,6 @@ public class SampleIntegrationTests {
@Test
public void jobSample() throws Exception {
String output = this.cli.run("samples/job.groovy", "foo=bar");
System.out.println(output);
assertTrue("Wrong output: " + output,
output.contains("completed with the following parameters"));
}
@@ -83,30 +85,30 @@ public class SampleIntegrationTests {
"foo=bar");
assertTrue("Wrong output: " + output,
output.contains("completed with the following parameters"));
String result = IoUtils.readEntirely("http://localhost:8080");
String result = readEntirely("http://localhost:8080");
assertEquals("World!", result);
}
@Test
public void webSample() throws Exception {
this.cli.run("samples/web.groovy");
String result = IoUtils.readEntirely("http://localhost:8080");
String result = readEntirely("http://localhost:8080");
assertEquals("World!", result);
}
@Test
public void uiSample() throws Exception {
this.cli.run("samples/ui.groovy", "--classpath=.:src/test/resources");
String result = IoUtils.readEntirely("http://localhost:8080");
String result = readEntirely("http://localhost:8080");
assertTrue("Wrong output: " + result, result.contains("Hello World"));
result = IoUtils.readEntirely("http://localhost:8080/css/bootstrap.min.css");
result = readEntirely("http://localhost:8080/css/bootstrap.min.css");
assertTrue("Wrong output: " + result, result.contains("container"));
}
@Test
public void actuatorSample() throws Exception {
this.cli.run("samples/actuator.groovy");
String result = IoUtils.readEntirely("http://localhost:8080");
String result = readEntirely("http://localhost:8080");
assertEquals("{\"message\":\"Hello World!\"}", result);
}
@@ -139,7 +141,7 @@ public class SampleIntegrationTests {
String output = this.cli.run("samples/jms.groovy");
assertTrue("Wrong output: " + output,
output.contains("Received Greetings from Spring Boot via ActiveMQ"));
FileUtils.recursiveDelete(new File("activemq-data")); // cleanup ActiveMQ cruft
FileUtils.deleteDirectory(new File("activemq-data"));// cleanup ActiveMQ cruft
}
@Test
@@ -154,8 +156,24 @@ public class SampleIntegrationTests {
@Test
public void deviceSample() throws Exception {
this.cli.run("samples/device.groovy");
String result = IoUtils.readEntirely("http://localhost:8080");
String result = readEntirely("http://localhost:8080");
assertEquals("Hello Normal Device!", result);
}
private static String readEntirely(String uri) {
try {
InputStream stream = URI.create(uri).toURL().openStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(stream));
String line;
StringBuilder result = new StringBuilder();
while ((line = reader.readLine()) != null) {
result.append(line);
}
return result.toString();
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
}

View File

@@ -19,22 +19,29 @@ package org.springframework.boot.cli;
import org.junit.After;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.springframework.boot.cli.command.CleanCommand;
import org.springframework.boot.cli.command.TestCommand;
import org.springframework.boot.cli.command.tester.TestResults;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertThat;
/**
* Integration tests to exercise the CLI's test command.
*
* @author Greg Turnquist
* @author Phillip Webb
*/
public class TestCommandIntegrationTests {
@Rule
public ExpectedException thrown = ExpectedException.none();
@Rule
public CliTester cli = new CliTester();
@BeforeClass
public static void cleanGrapes() throws Exception {
GrapesCleaner.cleanIfNecessary();
@@ -53,84 +60,53 @@ public class TestCommandIntegrationTests {
@Test
public void noTests() throws Throwable {
TestCommand command = new TestCommand();
command.run("test-samples/book.groovy");
TestResults results = command.getResults();
assertEquals(0, results.getRunCount());
assertEquals(0, results.getFailureCount());
assertTrue(results.wasSuccessful());
String output = this.cli.test("test-samples/book.groovy");
assertThat(output, containsString("No tests found"));
}
@Test
public void empty() throws Exception {
TestCommand command = new TestCommand();
command.run("test-samples/empty.groovy");
TestResults results = command.getResults();
assertEquals(0, results.getRunCount());
assertEquals(0, results.getFailureCount());
assertTrue(results.wasSuccessful());
String output = this.cli.test("test-samples/empty.groovy");
assertThat(output, containsString("No tests found"));
}
@Test(expected = RuntimeException.class)
@Test
public void noFile() throws Exception {
try {
TestCommand command = new TestCommand();
command.run("test-samples/nothing.groovy");
}
catch (RuntimeException ex) {
assertEquals("Can't find test-samples/nothing.groovy", ex.getMessage());
throw ex;
}
TestCommand command = new TestCommand();
this.thrown.expect(RuntimeException.class);
this.thrown.expectMessage("Can't find test-samples/nothing.groovy");
command.run("test-samples/nothing.groovy");
}
@Test
public void appAndTestsInOneFile() throws Exception {
TestCommand command = new TestCommand();
command.run("test-samples/book_and_tests.groovy");
TestResults results = command.getResults();
assertEquals(1, results.getRunCount());
assertEquals(0, results.getFailureCount());
assertTrue(results.wasSuccessful());
String output = this.cli.test("test-samples/book_and_tests.groovy");
assertThat(output, containsString("OK (1 test)"));
}
@Test
public void appInOneFileTestsInAnotherFile() throws Exception {
TestCommand command = new TestCommand();
command.run("test-samples/book.groovy", "test-samples/test.groovy");
TestResults results = command.getResults();
assertEquals(1, results.getRunCount());
assertEquals(0, results.getFailureCount());
assertTrue(results.wasSuccessful());
String output = this.cli.test("test-samples/book.groovy",
"test-samples/test.groovy");
assertThat(output, containsString("OK (1 test)"));
}
@Test
public void spockTester() throws Exception {
TestCommand command = new TestCommand();
command.run("test-samples/spock.groovy");
TestResults results = command.getResults();
assertEquals(1, results.getRunCount());
assertEquals(0, results.getFailureCount());
assertTrue(results.wasSuccessful());
String output = this.cli.test("test-samples/spock.groovy");
assertThat(output, containsString("OK (1 test)"));
}
@Test
public void spockAndJunitTester() throws Exception {
TestCommand command = new TestCommand();
command.run("test-samples/spock.groovy", "test-samples/book_and_tests.groovy");
TestResults results = command.getResults();
assertEquals(2, results.getRunCount());
assertEquals(0, results.getFailureCount());
assertTrue(results.wasSuccessful());
String output = this.cli.test("test-samples/spock.groovy",
"test-samples/book_and_tests.groovy");
assertThat(output, containsString("OK (2 tests)"));
}
@Test
public void verifyFailures() throws Exception {
TestCommand command = new TestCommand();
command.run("test-samples/failures.groovy");
TestResults results = command.getResults();
assertEquals(5, results.getRunCount());
assertEquals(3, results.getFailureCount());
assertFalse(results.wasSuccessful());
String output = this.cli.test("test-samples/failures.groovy");
assertThat(output, containsString("Tests run: 5, Failures: 3"));
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright 2012-2013 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 org.junit.Test;
import org.junit.internal.TextListener;
import org.junit.runner.JUnitCore;
import static org.junit.Assert.fail;
/**
* @author pwebb
*/
public class TestTest {
@Test
public void testName() throws Exception {
fail("Arse");
}
public static void main(String[] args) {
JUnitCore core = new JUnitCore();
core.addListener(new TextListener(System.out));
core.run(TestTest.class);
}
}

View File

@@ -1,129 +0,0 @@
/*
* Copyright 2012-2013 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;
import groovy.lang.GroovyObjectSupport;
import groovy.lang.Script;
import org.junit.BeforeClass;
import org.junit.Test;
import org.springframework.boot.cli.GrapesCleaner;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertTrue;
/**
* Tests for {@link ScriptCommand}.
*
* @author Dave Syer
*/
public class ScriptCommandTests {
public static boolean executed = false;
@BeforeClass
public static void cleanGrapes() throws Exception {
GrapesCleaner.cleanIfNecessary();
}
@Test(expected = IllegalStateException.class)
public void testMissing() throws Exception {
ScriptCommand command = new ScriptCommand("missing");
command.run("World");
}
@Test
public void testScript() throws Exception {
ScriptCommand command = new ScriptCommand("script");
command.run("World");
assertEquals("World",
((String[]) ((Script) command.getMain()).getProperty("args"))[0]);
}
@Test
public void testLocateFile() throws Exception {
ScriptCommand command = new ScriptCommand(
"src/test/resources/commands/script.groovy");
command.setPaths(new String[] { "." });
command.run("World");
assertEquals("World",
((String[]) ((Script) command.getMain()).getProperty("args"))[0]);
}
@Test
public void testRunnable() throws Exception {
ScriptCommand command = new ScriptCommand("runnable");
command.run("World");
assertTrue(executed);
}
@Test
public void testClosure() throws Exception {
ScriptCommand command = new ScriptCommand("closure");
command.run("World");
assertTrue(executed);
}
@Test
public void testCommand() throws Exception {
ScriptCommand command = new ScriptCommand("command");
assertEquals("My script command", command.getUsageHelp());
command.run("World");
assertTrue(executed);
}
@Test
public void testDuplicateClassName() throws Exception {
ScriptCommand command1 = new ScriptCommand("handler");
ScriptCommand command2 = new ScriptCommand("command");
assertNotSame(command1.getMain().getClass(), command2.getMain().getClass());
assertEquals(command1.getMain().getClass().getName(), command2.getMain()
.getClass().getName());
}
@Test
public void testOptions() throws Exception {
ScriptCommand command = new ScriptCommand("handler");
String out = ((OptionHandler) command.getMain()).getHelp();
assertTrue("Wrong output: " + out, out.contains("--foo"));
command.run("World", "--foo");
assertTrue(executed);
}
@Test
public void testMixin() throws Exception {
ScriptCommand command = new ScriptCommand("mixin");
GroovyObjectSupport object = (GroovyObjectSupport) command.getMain();
String out = (String) object.getProperty("help");
assertTrue("Wrong output: " + out, out.contains("--foo"));
command.run("World", "--foo");
assertTrue(executed);
}
@Test
public void testMixinWithBlock() throws Exception {
ScriptCommand command = new ScriptCommand("test");
GroovyObjectSupport object = (GroovyObjectSupport) command.getMain();
String out = (String) object.getProperty("help");
System.err.println(out);
assertTrue("Wrong output: " + out, out.contains("--foo"));
command.run("World", "--foo", "--bar=2");
assertTrue(executed);
}
}

View File

@@ -0,0 +1,68 @@
/*
* Copyright 2012-2013 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.compiler;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import static org.hamcrest.Matchers.sameInstance;
import static org.junit.Assert.assertThat;
/**
* Tests for {@link ExtendedGroovyClassLoader}.
*
* @author Phillip Webb
*/
public class ExtendedGroovyClassLoaderTests {
@Rule
public ExpectedException thrown = ExpectedException.none();
private ClassLoader contextClassLoader;
private ExtendedGroovyClassLoader defaultScopeGroovyClassLoader;
@Before
public void setup() {
this.contextClassLoader = Thread.currentThread().getContextClassLoader();
this.defaultScopeGroovyClassLoader = new ExtendedGroovyClassLoader(
GroovyCompilerScope.DEFAULT);
}
@Test
public void loadsGroovyFromSameClassLoader() throws Exception {
Class<?> c1 = this.contextClassLoader.loadClass("groovy.lang.Script");
Class<?> c2 = this.defaultScopeGroovyClassLoader.loadClass("groovy.lang.Script");
assertThat(c1.getClassLoader(), sameInstance(c2.getClassLoader()));
}
@Test
public void filteresNonGroovy() throws Exception {
this.contextClassLoader.loadClass("org.springframework.util.StringUtils");
this.thrown.expect(ClassNotFoundException.class);
this.defaultScopeGroovyClassLoader
.loadClass("org.springframework.util.StringUtils");
}
@Test
public void loadsJavaTypes() throws Exception {
this.defaultScopeGroovyClassLoader.loadClass("java.lang.Boolean");
}
}

View File

@@ -0,0 +1,103 @@
/*
* Copyright 2012-2013 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.compiler.grape;
import groovy.lang.GroovyClassLoader;
import java.util.HashMap;
import java.util.Map;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
/**
* Tests for {@link AetherGrapeEngine}.
*
* @author Andy Wilkinson
*/
public class AetherGrapeEngineTests {
private final GroovyClassLoader groovyClassLoader = new GroovyClassLoader();
private final AetherGrapeEngine grapeEngine = new AetherGrapeEngine(
this.groovyClassLoader);
@Test
public void dependencyResolution() {
Map<String, Object> args = new HashMap<String, Object>();
this.grapeEngine.grab(args,
createDependency("org.springframework", "spring-jdbc", "3.2.4.RELEASE"));
assertEquals(6, this.groovyClassLoader.getURLs().length);
}
@Test
public void nonTransitiveDependencyResolution() {
Map<String, Object> args = new HashMap<String, Object>();
this.grapeEngine.grab(
args,
createDependency("org.springframework", "spring-jdbc", "3.2.4.RELEASE",
false));
assertEquals(1, this.groovyClassLoader.getURLs().length);
}
@Test
public void dependencyResolutionWithCustomClassLoader() {
Map<String, Object> args = new HashMap<String, Object>();
GroovyClassLoader customClassLoader = new GroovyClassLoader();
args.put("classLoader", customClassLoader);
this.grapeEngine.grab(args,
createDependency("org.springframework", "spring-jdbc", "3.2.4.RELEASE"));
assertEquals(0, this.groovyClassLoader.getURLs().length);
assertEquals(6, customClassLoader.getURLs().length);
}
@Test(expected = DependencyResolutionFailedException.class)
public void resolutionWithSnapshotRepositoriesDisabled() {
Map<String, Object> args = new HashMap<String, Object>();
System.setProperty("disableSpringSnapshotRepos", "true");
try {
new AetherGrapeEngine(this.groovyClassLoader).grab(args,
createDependency("org.springframework", "spring-jdbc", "3.2.0.M1"));
}
finally {
System.clearProperty("disableSpringSnapshotRepos");
}
}
private Map<String, Object> createDependency(String group, String module,
String version) {
Map<String, Object> dependency = new HashMap<String, Object>();
dependency.put("group", group);
dependency.put("module", module);
dependency.put("version", version);
return dependency;
}
private Map<String, Object> createDependency(String group, String module,
String version, boolean transitive) {
Map<String, Object> dependency = createDependency(group, module, version);
dependency.put("transitive", transitive);
return dependency;
}
}