From 918e122ddccae7cb1bf245269e49b40822114c42 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Thu, 17 Nov 2016 18:38:26 +0000 Subject: [PATCH] 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 --- ...assLoaderFilesResourcePatternResolver.java | 165 +++++++++++++++ .../boot/devtools/restart/Restarter.java | 5 + .../ClassLoaderFileURLStreamHandler.java | 4 +- spring-boot-integration-tests/pom.xml | 1 + .../spring-boot-devtools-tests/pom.xml | 90 +++++++++ .../test/java/com/example/ControllerOne.java | 30 +++ .../com/example/DevToolsTestApplication.java | 32 +++ .../devtools/tests/ApplicationLauncher.java | 28 +++ .../tests/DevToolsIntegrationTests.java | 191 ++++++++++++++++++ .../ExplodedRemoteApplicationLauncher.java | 54 +++++ .../JarFileRemoteApplicationLauncher.java | 86 ++++++++ .../boot/devtools/tests/JavaLauncher.java | 53 +++++ .../devtools/tests/LaunchedApplication.java | 47 +++++ .../tests/LocalApplicationLauncher.java | 60 ++++++ .../tests/RemoteApplicationLauncher.java | 67 ++++++ spring-boot-parent/pom.xml | 5 + .../checkstyle/checkstyle-suppressions.xml | 1 + 17 files changed, 917 insertions(+), 2 deletions(-) create mode 100644 spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/ClassLoaderFilesResourcePatternResolver.java create mode 100644 spring-boot-integration-tests/spring-boot-devtools-tests/pom.xml create mode 100644 spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/com/example/ControllerOne.java create mode 100644 spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/com/example/DevToolsTestApplication.java create mode 100644 spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/ApplicationLauncher.java create mode 100644 spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/DevToolsIntegrationTests.java create mode 100644 spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/ExplodedRemoteApplicationLauncher.java create mode 100644 spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/JarFileRemoteApplicationLauncher.java create mode 100644 spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/JavaLauncher.java create mode 100644 spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/LaunchedApplication.java create mode 100644 spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/LocalApplicationLauncher.java create mode 100644 spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/RemoteApplicationLauncher.java diff --git a/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/ClassLoaderFilesResourcePatternResolver.java b/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/ClassLoaderFilesResourcePatternResolver.java new file mode 100644 index 00000000000..7cc768e7cbd --- /dev/null +++ b/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/ClassLoaderFilesResourcePatternResolver.java @@ -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 LOCATION_PATTERN_PREFIXES = Collections + .unmodifiableSet(new HashSet( + 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 resources = new ArrayList(); + 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 getAdditionalResources(String locationPattern) + throws MalformedURLException { + List additionalResources = new ArrayList(); + String trimmedLocationPattern = trimLocationPattern(locationPattern); + for (SourceFolder sourceFolder : this.classLoaderFiles.getSourceFolders()) { + for (Entry 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 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"); + } + } +} \ No newline at end of file diff --git a/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/Restarter.java b/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/Restarter.java index 1c94bc55061..6137a29a538 100644 --- a/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/Restarter.java +++ b/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/Restarter.java @@ -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; } diff --git a/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFileURLStreamHandler.java b/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFileURLStreamHandler.java index c754eeb4693..cd4da3c642a 100644 --- a/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFileURLStreamHandler.java +++ b/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/restart/classloader/ClassLoaderFileURLStreamHandler.java @@ -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; } diff --git a/spring-boot-integration-tests/pom.xml b/spring-boot-integration-tests/pom.xml index 2beb79b59a9..30b7135d5ef 100644 --- a/spring-boot-integration-tests/pom.xml +++ b/spring-boot-integration-tests/pom.xml @@ -21,6 +21,7 @@ 1.8 + spring-boot-devtools-tests spring-boot-gradle-tests spring-boot-launch-script-tests spring-boot-security-tests diff --git a/spring-boot-integration-tests/spring-boot-devtools-tests/pom.xml b/spring-boot-integration-tests/spring-boot-devtools-tests/pom.xml new file mode 100644 index 00000000000..4e9338936b2 --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-devtools-tests/pom.xml @@ -0,0 +1,90 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-integration-tests + 1.4.3.BUILD-SNAPSHOT + + spring-boot-devtools-tests + Spring Boot DevTools Tests + ${project.name} + http://projects.spring.io/spring-boot/ + + Pivotal Software, Inc. + http://www.spring.io + + + ${basedir}/../.. + + + + org.springframework.boot + spring-boot-devtools + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-test + test + + + net.bytebuddy + byte-buddy + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-dependencies + process-test-resources + + copy-dependencies + + + runtime + ${project.build.directory}/dependencies + true + true + + + + + + + + + org.eclipse.m2e + lifecycle-mapping + 1.0.0 + + + + + + org.apache.maven.plugins + maven-dependency-plugin + [2.10,) + + copy-dependencies + + + + + + + + + + + + + + diff --git a/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/com/example/ControllerOne.java b/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/com/example/ControllerOne.java new file mode 100644 index 00000000000..874073ab944 --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/com/example/ControllerOne.java @@ -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"; + } + +} diff --git a/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/com/example/DevToolsTestApplication.java b/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/com/example/DevToolsTestApplication.java new file mode 100644 index 00000000000..0f121c58c92 --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/com/example/DevToolsTestApplication.java @@ -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); + } + +} diff --git a/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/ApplicationLauncher.java b/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/ApplicationLauncher.java new file mode 100644 index 00000000000..7b9c05b0b84 --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/ApplicationLauncher.java @@ -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; + +} diff --git a/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/DevToolsIntegrationTests.java b/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/DevToolsIntegrationTests.java new file mode 100644 index 00000000000..e970b0aade6 --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/DevToolsIntegrationTests.java @@ -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 mappings = new ArrayList(); + + 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 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); + } + } +} diff --git a/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/ExplodedRemoteApplicationLauncher.java b/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/ExplodedRemoteApplicationLauncher.java new file mode 100644 index 00000000000..a9ceb950844 --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/ExplodedRemoteApplicationLauncher.java @@ -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 entries = new ArrayList(); + 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"; + } + +} diff --git a/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/JarFileRemoteApplicationLauncher.java b/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/JarFileRemoteApplicationLauncher.java new file mode 100644 index 00000000000..9bdda3e207c --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/JarFileRemoteApplicationLauncher.java @@ -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 entries = new ArrayList(); + 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"; + } + +} diff --git a/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/JavaLauncher.java b/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/JavaLauncher.java new file mode 100644 index 00000000000..114cf4bf18f --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/JavaLauncher.java @@ -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 command = new ArrayList(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(); + } + +} diff --git a/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/LaunchedApplication.java b/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/LaunchedApplication.java new file mode 100644 index 00000000000..3848fb2a05c --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/LaunchedApplication.java @@ -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; + } + +} diff --git a/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/LocalApplicationLauncher.java b/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/LocalApplicationLauncher.java new file mode 100644 index 00000000000..debb41dbb6f --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/LocalApplicationLauncher.java @@ -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 entries = new ArrayList(); + 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"; + } + +} diff --git a/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/RemoteApplicationLauncher.java b/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/RemoteApplicationLauncher.java new file mode 100644 index 00000000000..c5ff5e54426 --- /dev/null +++ b/spring-boot-integration-tests/spring-boot-devtools-tests/src/test/java/org/springframework/boot/devtools/tests/RemoteApplicationLauncher.java @@ -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 entries = new ArrayList(); + entries.add("target/remote"); + for (File jar : new File("target/dependencies").listFiles()) { + entries.add(jar.getAbsolutePath()); + } + return StringUtils.collectionToDelimitedString(entries, File.pathSeparator); + } + +} diff --git a/spring-boot-parent/pom.xml b/spring-boot-parent/pom.xml index 1b417894c35..34d00bf22aa 100644 --- a/spring-boot-parent/pom.xml +++ b/spring-boot-parent/pom.xml @@ -77,6 +77,11 @@ jline 2.11 + + net.bytebuddy + byte-buddy + 1.5.4 + net.sf.jopt-simple jopt-simple diff --git a/spring-boot-parent/src/checkstyle/checkstyle-suppressions.xml b/spring-boot-parent/src/checkstyle/checkstyle-suppressions.xml index 0e6ca91c655..df411c70570 100644 --- a/spring-boot-parent/src/checkstyle/checkstyle-suppressions.xml +++ b/spring-boot-parent/src/checkstyle/checkstyle-suppressions.xml @@ -6,6 +6,7 @@ +