Fix remote DevTools' support for adding and removing classes

Previously, remote DevTools only correctly supported modifying
existing classes. New classes that were added would be missed, and
deleted classes could cause a failure as they would be found by
component scanning but hidden by RestartClassLoader.

This commit introduces a DevTools-specific ResourcePatternResolver
that is installed as the application context's resource loader. This
custom resolver is aware of the files that have been added and
deleted and modifies the result returned from getResource and
getResources accordingly.

New intergration tests have been introduced to verify DevTools'
behaviour. The tests cover four scenarios:

- Adding a new controller
- Removing an existing controller
- Adding a request mapping to a controller
- Removing a request mapping from a controller

These four scenarios are tested with:

- DevTools updating a local application
- DevTools updating a remote application packaged in a jar file
- DevTools updating a remote application that's been exploded

Closes gh-7379
This commit is contained in:
Andy Wilkinson 2016-11-17 18:38:26 +00:00
parent b3e0a37197
commit 918e122ddc
17 changed files with 917 additions and 2 deletions

View File

@ -0,0 +1,165 @@
/*
* Copyright 2012-2016 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.devtools.restart;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map.Entry;
import java.util.Set;
import org.springframework.boot.devtools.restart.classloader.ClassLoaderFile;
import org.springframework.boot.devtools.restart.classloader.ClassLoaderFile.Kind;
import org.springframework.boot.devtools.restart.classloader.ClassLoaderFileURLStreamHandler;
import org.springframework.boot.devtools.restart.classloader.ClassLoaderFiles;
import org.springframework.boot.devtools.restart.classloader.ClassLoaderFiles.SourceFolder;
import org.springframework.core.io.AbstractResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.util.AntPathMatcher;
/**
* A {@code ResourcePatternResolver} that considers {@link ClassLoaderFiles} when
* resolving resources.
*
* @author Andy Wilkinson
*/
final class ClassLoaderFilesResourcePatternResolver implements ResourcePatternResolver {
private static final Set<String> LOCATION_PATTERN_PREFIXES = Collections
.unmodifiableSet(new HashSet<String>(
Arrays.asList(CLASSPATH_ALL_URL_PREFIX, CLASSPATH_URL_PREFIX)));
private final ResourcePatternResolver delegate = new PathMatchingResourcePatternResolver();
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
private final ClassLoaderFiles classLoaderFiles;
ClassLoaderFilesResourcePatternResolver(ClassLoaderFiles classLoaderFiles) {
this.classLoaderFiles = classLoaderFiles;
}
@Override
public Resource getResource(String location) {
Resource candidate = this.delegate.getResource(location);
if (isExcludedResource(candidate)) {
return new DeletedClassLoaderFileResource(location);
}
return candidate;
}
@Override
public ClassLoader getClassLoader() {
return this.delegate.getClassLoader();
}
@Override
public Resource[] getResources(String locationPattern) throws IOException {
List<Resource> resources = new ArrayList<Resource>();
Resource[] candidates = this.delegate.getResources(locationPattern);
for (Resource candidate : candidates) {
if (!isExcludedResource(candidate)) {
resources.add(candidate);
}
}
resources.addAll(getAdditionalResources(locationPattern));
return resources.toArray(new Resource[resources.size()]);
}
private String trimLocationPattern(String locationPattern) {
for (String prefix : LOCATION_PATTERN_PREFIXES) {
if (locationPattern.startsWith(prefix)) {
return locationPattern.substring(prefix.length());
}
}
return locationPattern;
}
private List<Resource> getAdditionalResources(String locationPattern)
throws MalformedURLException {
List<Resource> additionalResources = new ArrayList<Resource>();
String trimmedLocationPattern = trimLocationPattern(locationPattern);
for (SourceFolder sourceFolder : this.classLoaderFiles.getSourceFolders()) {
for (Entry<String, ClassLoaderFile> entry : sourceFolder.getFilesEntrySet()) {
if (entry.getValue().getKind() == Kind.ADDED && this.antPathMatcher
.match(trimmedLocationPattern, entry.getKey())) {
additionalResources.add(new UrlResource(new URL("reloaded", null, -1,
"/" + entry.getKey(),
new ClassLoaderFileURLStreamHandler(entry.getValue()))));
}
}
}
return additionalResources;
}
private boolean isExcludedResource(Resource resource) {
for (SourceFolder sourceFolder : this.classLoaderFiles.getSourceFolders()) {
for (Entry<String, ClassLoaderFile> entry : sourceFolder.getFilesEntrySet()) {
try {
if (entry.getValue().getKind() == Kind.DELETED && resource.exists()
&& resource.getURI().toString().endsWith(entry.getKey())) {
return true;
}
}
catch (IOException ex) {
throw new IllegalStateException(
"Failed to retrieve URI from '" + resource + "'", ex);
}
}
}
return false;
}
/**
* A {@link Resource} that represents a {@link ClassLoaderFile} that has been
* {@link Kind#DELETED deleted}.
*
* @author Andy Wilkinson
*/
private final class DeletedClassLoaderFileResource extends AbstractResource {
private final String name;
private DeletedClassLoaderFileResource(String name) {
this.name = name;
}
@Override
public boolean exists() {
return false;
}
@Override
public String getDescription() {
return "Deleted: " + this.name;
}
@Override
public InputStream getInputStream() throws IOException {
throw new IOException(this.name + " has been deleted");
}
}
}

View File

@ -48,6 +48,7 @@ import org.springframework.boot.devtools.restart.classloader.RestartClassLoader;
import org.springframework.boot.logging.DeferredLog;
import org.springframework.cglib.core.ClassNameReader;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.core.ResolvableType;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.util.Assert;
@ -418,6 +419,10 @@ public class Restarter {
if (applicationContext != null && applicationContext.getParent() != null) {
return;
}
if (applicationContext instanceof GenericApplicationContext) {
((GenericApplicationContext) applicationContext).setResourceLoader(
new ClassLoaderFilesResourcePatternResolver(this.classLoaderFiles));
}
this.rootContext = applicationContext;
}

View File

@ -28,11 +28,11 @@ import java.net.URLStreamHandler;
*
* @author Phillip Webb
*/
class ClassLoaderFileURLStreamHandler extends URLStreamHandler {
public class ClassLoaderFileURLStreamHandler extends URLStreamHandler {
private ClassLoaderFile file;
ClassLoaderFileURLStreamHandler(ClassLoaderFile file) {
public ClassLoaderFileURLStreamHandler(ClassLoaderFile file) {
this.file = file;
}

View File

@ -21,6 +21,7 @@
<java.version>1.8</java.version>
</properties>
<modules>
<module>spring-boot-devtools-tests</module>
<module>spring-boot-gradle-tests</module>
<module>spring-boot-launch-script-tests</module>
<module>spring-boot-security-tests</module>

View File

@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-integration-tests</artifactId>
<version>1.4.3.BUILD-SNAPSHOT</version>
</parent>
<artifactId>spring-boot-devtools-tests</artifactId>
<name>Spring Boot DevTools Tests</name>
<description>${project.name}</description>
<url>http://projects.spring.io/spring-boot/</url>
<organization>
<name>Pivotal Software, Inc.</name>
<url>http://www.spring.io</url>
</organization>
<properties>
<main.basedir>${basedir}/../..</main.basedir>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy-dependencies</id>
<phase>process-test-resources</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<includeScope>runtime</includeScope>
<outputDirectory>${project.build.directory}/dependencies</outputDirectory>
<overWriteSnapshots>true</overWriteSnapshots>
<overWriteIfNewer>true</overWriteIfNewer>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.eclipse.m2e</groupId>
<artifactId>lifecycle-mapping</artifactId>
<version>1.0.0</version>
<configuration>
<lifecycleMappingMetadata>
<pluginExecutions>
<pluginExecution>
<pluginExecutionFilter>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<versionRange>[2.10,)</versionRange>
<goals>
<goal>copy-dependencies</goal>
</goals>
</pluginExecutionFilter>
<action>
<ignore></ignore>
</action>
</pluginExecution>
</pluginExecutions>
</lifecycleMappingMetadata>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>

View File

@ -0,0 +1,30 @@
/*
* Copyright 2012-2016 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 com.example;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ControllerOne {
@RequestMapping("/one")
public String one() {
return "one";
}
}

View File

@ -0,0 +1,32 @@
/*
* Copyright 2012-2016 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 com.example;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.system.EmbeddedServerPortFileWriter;
@SpringBootApplication
public class DevToolsTestApplication {
public static void main(String[] args) {
new SpringApplicationBuilder(DevToolsTestApplication.class)
.listeners(new EmbeddedServerPortFileWriter("target/server.port"))
.run(args);
}
}

View File

@ -0,0 +1,28 @@
/*
* Copyright 2012-2016 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.devtools.tests;
/**
* Launches an application with DevTools.
*
* @author Andy Wilkinson
*/
public interface ApplicationLauncher {
LaunchedApplication launchApplication(JavaLauncher javaLauncher) throws Exception;
}

View File

@ -0,0 +1,191 @@
/*
* Copyright 2012-2016 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.devtools.tests;
import java.io.File;
import java.io.FileReader;
import java.util.ArrayList;
import java.util.List;
import net.bytebuddy.ByteBuddy;
import net.bytebuddy.description.annotation.AnnotationDescription;
import net.bytebuddy.description.modifier.Visibility;
import net.bytebuddy.dynamic.DynamicType.Builder;
import net.bytebuddy.implementation.FixedValue;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameters;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.util.FileCopyUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for DevTools.
*
* @author Andy Wilkinson
*/
@RunWith(Parameterized.class)
public class DevToolsIntegrationTests {
private LaunchedApplication launchedApplication;
private final File serverPortFile = new File("target/server.port");
private final ApplicationLauncher applicationLauncher;
@Rule
public JavaLauncher javaLauncher = new JavaLauncher();
@Parameters(name = "{0}")
public static Object[] parameters() {
return new Object[] { new Object[] { new LocalApplicationLauncher() },
new Object[] { new ExplodedRemoteApplicationLauncher() },
new Object[] { new JarFileRemoteApplicationLauncher() } };
}
public DevToolsIntegrationTests(ApplicationLauncher applicationLauncher) {
this.applicationLauncher = applicationLauncher;
}
@Before
public void launchApplication() throws Exception {
this.serverPortFile.delete();
this.launchedApplication = this.applicationLauncher
.launchApplication(this.javaLauncher);
}
@After
public void stopApplication() {
this.launchedApplication.stop();
}
@Test
public void addARequestMappingToAnExistingController() throws Exception {
TestRestTemplate template = new TestRestTemplate();
String urlBase = "http://localhost:" + awaitServerPort() + "/";
assertThat(template.getForObject(urlBase + "/one", String.class))
.isEqualTo("one");
assertThat(template.getForEntity(urlBase + "/two", String.class).getStatusCode())
.isEqualTo(HttpStatus.NOT_FOUND);
controller("com.example.ControllerOne").withRequestMapping("one")
.withRequestMapping("two").build();
assertThat(template.getForObject(urlBase + "/one", String.class))
.isEqualTo("one");
assertThat(template.getForObject("http://localhost:" + awaitServerPort() + "/two",
String.class)).isEqualTo("two");
}
@Test
public void removeARequestMappingFromAnExistingController() throws Exception {
TestRestTemplate template = new TestRestTemplate();
assertThat(template.getForObject("http://localhost:" + awaitServerPort() + "/one",
String.class)).isEqualTo("one");
controller("com.example.ControllerOne").build();
assertThat(template.getForEntity("http://localhost:" + awaitServerPort() + "/one",
String.class).getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
public void createAController() throws Exception {
TestRestTemplate template = new TestRestTemplate();
String urlBase = "http://localhost:" + awaitServerPort() + "/";
assertThat(template.getForObject(urlBase + "/one", String.class))
.isEqualTo("one");
assertThat(template.getForEntity(urlBase + "/two", String.class).getStatusCode())
.isEqualTo(HttpStatus.NOT_FOUND);
controller("com.example.ControllerTwo").withRequestMapping("two").build();
assertThat(template.getForObject(urlBase + "/one", String.class))
.isEqualTo("one");
assertThat(template.getForObject("http://localhost:" + awaitServerPort() + "/two",
String.class)).isEqualTo("two");
}
@Test
public void deleteAController() throws Exception {
TestRestTemplate template = new TestRestTemplate();
assertThat(template.getForObject("http://localhost:" + awaitServerPort() + "/one",
String.class)).isEqualTo("one");
assertThat(new File(this.launchedApplication.getClassesDirectory(),
"com/example/ControllerOne.class").delete()).isTrue();
assertThat(template.getForEntity("http://localhost:" + awaitServerPort() + "/one",
String.class).getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
private int awaitServerPort() throws Exception {
long end = System.currentTimeMillis() + 20000;
while (!this.serverPortFile.exists()) {
if (System.currentTimeMillis() > end) {
throw new IllegalStateException(
"server.port file was not written within 20 seconds");
}
Thread.sleep(100);
}
int port = Integer
.valueOf(FileCopyUtils.copyToString(new FileReader(this.serverPortFile)));
this.serverPortFile.delete();
return port;
}
private ControllerBuilder controller(String name) {
return new ControllerBuilder(name,
this.launchedApplication.getClassesDirectory());
}
private static final class ControllerBuilder {
private final List<String> mappings = new ArrayList<String>();
private final String name;
private final File classesDirectory;
private ControllerBuilder(String name, File classesDirectory) {
this.name = name;
this.classesDirectory = classesDirectory;
}
public ControllerBuilder withRequestMapping(String mapping) {
this.mappings.add(mapping);
return this;
}
public void build() throws Exception {
Builder<Object> builder = new ByteBuddy().subclass(Object.class)
.name(this.name).annotateType(AnnotationDescription.Builder
.ofType(RestController.class).build());
for (String mapping : this.mappings) {
builder = builder.defineMethod(mapping, String.class, Visibility.PUBLIC)
.intercept(FixedValue.value(mapping)).annotateMethod(
AnnotationDescription.Builder.ofType(RequestMapping.class)
.defineArray("value", mapping).build());
}
builder.make().saveIn(this.classesDirectory);
}
}
}

View File

@ -0,0 +1,54 @@
/*
* Copyright 2012-2016 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.devtools.tests;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import org.springframework.util.FileSystemUtils;
import org.springframework.util.StringUtils;
/**
* {@link ApplicationLauncher} that launches a remote application with its classes
* available directly on the file system.
*
* @author Andy Wilkinson
*/
public class ExplodedRemoteApplicationLauncher extends RemoteApplicationLauncher {
@Override
protected String createApplicationClassPath() throws Exception {
File appDirectory = new File("target/app");
FileSystemUtils.deleteRecursively(appDirectory);
appDirectory.mkdirs();
FileSystemUtils.copyRecursively(new File("target/test-classes/com"),
new File("target/app/com"));
List<String> entries = new ArrayList<String>();
entries.add("target/app");
for (File jar : new File("target/dependencies").listFiles()) {
entries.add(jar.getAbsolutePath());
}
return StringUtils.collectionToDelimitedString(entries, File.pathSeparator);
}
@Override
public String toString() {
return "exploded remote";
}
}

View File

@ -0,0 +1,86 @@
/*
* Copyright 2012-2016 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.devtools.tests;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.jar.Attributes;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import java.util.zip.ZipEntry;
import org.springframework.util.FileSystemUtils;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
/**
* {@link ApplicationLauncher} that launches a remote application with its classes in a
* jar file.
*
* @author Andy Wilkinson
*/
public class JarFileRemoteApplicationLauncher extends RemoteApplicationLauncher {
@Override
protected String createApplicationClassPath() throws Exception {
File appDirectory = new File("target/app");
FileSystemUtils.deleteRecursively(appDirectory);
appDirectory.mkdirs();
Manifest manifest = new Manifest();
manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0");
JarOutputStream output = new JarOutputStream(
new FileOutputStream(new File(appDirectory, "app.jar")), manifest);
FileSystemUtils.copyRecursively(new File("target/test-classes/com"),
new File("target/app/com"));
addToJar(output, new File("target/app/"), new File("target/app/"));
output.close();
List<String> entries = new ArrayList<String>();
entries.add("target/app/app.jar");
for (File jar : new File("target/dependencies").listFiles()) {
entries.add(jar.getAbsolutePath());
}
String classpath = StringUtils.collectionToDelimitedString(entries,
File.pathSeparator);
return classpath;
}
private void addToJar(JarOutputStream output, File root, File current)
throws IOException {
for (File file : current.listFiles()) {
if (file.isDirectory()) {
addToJar(output, root, file);
}
output.putNextEntry(new ZipEntry(
file.getAbsolutePath().substring(root.getAbsolutePath().length() + 1)
+ (file.isDirectory() ? "/" : "")));
if (file.isFile()) {
StreamUtils.copy(new FileInputStream(file), output);
}
output.closeEntry();
}
}
@Override
public String toString() {
return "jar file remote";
}
}

View File

@ -0,0 +1,53 @@
/*
* Copyright 2012-2016 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.devtools.tests;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
/**
* @author awilkinson
*/
public class JavaLauncher implements TestRule {
private File outputDirectory;
@Override
public Statement apply(Statement base, Description description) {
this.outputDirectory = new File("target/output/" + "/"
+ description.getMethodName().replaceAll("[^A-Za-z]+", ""));
this.outputDirectory.mkdirs();
return base;
}
Process launch(String name, String classpath, String... args) throws IOException {
List<String> command = new ArrayList<String>(Arrays
.asList(System.getProperty("java.home") + "/bin/java", "-cp", classpath));
command.addAll(Arrays.asList(args));
return new ProcessBuilder(command.toArray(new String[command.size()]))
.redirectError(new File(this.outputDirectory, name + ".err"))
.redirectOutput(new File(this.outputDirectory, name + ".out")).start();
}
}

View File

@ -0,0 +1,47 @@
/*
* Copyright 2012-2016 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.devtools.tests;
import java.io.File;
/**
* An application launched by {@link ApplicationLauncher}.
*
* @author Andy Wilkinson
*/
class LaunchedApplication {
private final File classesDirectory;
private final Process[] processes;
LaunchedApplication(File classesDirectory, Process... processes) {
this.classesDirectory = classesDirectory;
this.processes = processes;
}
void stop() {
for (Process process : this.processes) {
process.destroy();
}
}
File getClassesDirectory() {
return this.classesDirectory;
}
}

View File

@ -0,0 +1,60 @@
/*
* Copyright 2012-2016 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.devtools.tests;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import org.springframework.util.FileSystemUtils;
import org.springframework.util.StringUtils;
/**
* {@link ApplicationLauncher} that launches a local application with DevTools enabled.
*
* @author Andy Wilkinson
*/
public class LocalApplicationLauncher implements ApplicationLauncher {
@Override
public LaunchedApplication launchApplication(JavaLauncher javaLauncher)
throws Exception {
Process process = javaLauncher.launch("local", createApplicationClassPath(),
"com.example.DevToolsTestApplication", "--server.port=0");
return new LaunchedApplication(new File("target/app"), process);
}
protected String createApplicationClassPath() throws Exception {
File appDirectory = new File("target/app");
FileSystemUtils.deleteRecursively(appDirectory);
appDirectory.mkdirs();
FileSystemUtils.copyRecursively(new File("target/test-classes/com"),
new File("target/app/com"));
List<String> entries = new ArrayList<String>();
entries.add("target/app");
for (File jar : new File("target/dependencies").listFiles()) {
entries.add(jar.getAbsolutePath());
}
return StringUtils.collectionToDelimitedString(entries, File.pathSeparator);
}
@Override
public String toString() {
return "local";
}
}

View File

@ -0,0 +1,67 @@
/*
* Copyright 2012-2016 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.devtools.tests;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.devtools.RemoteSpringApplication;
import org.springframework.util.FileSystemUtils;
import org.springframework.util.SocketUtils;
import org.springframework.util.StringUtils;
/**
* Base class for {@link ApplicationLauncher} implementations that use
* {@link RemoteSpringApplication}.
*
* @author Andy Wilkinson
*/
abstract class RemoteApplicationLauncher implements ApplicationLauncher {
@Override
public LaunchedApplication launchApplication(JavaLauncher javaLauncher)
throws Exception {
int port = SocketUtils.findAvailableTcpPort();
Process application = javaLauncher.launch("app", createApplicationClassPath(),
"com.example.DevToolsTestApplication", "--server.port=" + port,
"--spring.devtools.remote.secret=secret");
Process remoteSpringApplication = javaLauncher.launch("remote-spring-application",
createRemoteSpringApplicationClassPath(),
RemoteSpringApplication.class.getName(),
"--spring.devtools.remote.secret=secret", "http://localhost:" + port);
return new LaunchedApplication(new File("target/remote"), application,
remoteSpringApplication);
}
protected abstract String createApplicationClassPath() throws Exception;
private String createRemoteSpringApplicationClassPath() throws Exception {
File remoteDirectory = new File("target/remote");
FileSystemUtils.deleteRecursively(remoteDirectory);
remoteDirectory.mkdirs();
FileSystemUtils.copyRecursively(new File("target/test-classes/com"),
new File("target/remote/com"));
List<String> entries = new ArrayList<String>();
entries.add("target/remote");
for (File jar : new File("target/dependencies").listFiles()) {
entries.add(jar.getAbsolutePath());
}
return StringUtils.collectionToDelimitedString(entries, File.pathSeparator);
}
}

View File

@ -77,6 +77,11 @@
<artifactId>jline</artifactId>
<version>2.11</version>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.5.4</version>
</dependency>
<dependency>
<groupId>net.sf.jopt-simple</groupId>
<artifactId>jopt-simple</artifactId>

View File

@ -6,6 +6,7 @@
<suppress files="SpringApplicationTests\.java" checks="FinalClass" />
<suppress files=".+Configuration\.java" checks="HideUtilityClassConstructor" />
<suppress files="LaunchScriptTestApplication\.java" checks="HideUtilityClassConstructor" />
<suppress files="DevToolsTestApplication\.java" checks="HideUtilityClassConstructor" />
<suppress files="SignalUtils\.java" checks="IllegalImport" />
<suppress files="[\\/]src[\\/]test[\\/]java[\\/]cli[\\/]command[\\/]" checks="ImportControl" />
<suppress files="[\\/]src[\\/]main[\\/]java[\\/]sample[\\/]" checks="ImportControl" />