From a8bf0d942b3b142c5347a46895991340608767ae Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 1 Jun 2015 13:22:08 -0700 Subject: [PATCH 01/20] Add a `spring-boot-developer-tools` module Add a new `spring-boot-developer-tools` module which will contain auto-configuration to help during application development. See gh-3082 --- pom.xml | 1 + spring-boot-dependencies/pom.xml | 5 ++ spring-boot-developer-tools/pom.xml | 90 +++++++++++++++++++ .../restart/classloader/Parent.txt | 1 + spring-boot-full-build/pom.xml | 1 + 5 files changed, 98 insertions(+) create mode 100644 spring-boot-developer-tools/pom.xml create mode 100644 spring-boot-developer-tools/src/test/resources/org/springframework/boot/developertools/restart/classloader/Parent.txt diff --git a/pom.xml b/pom.xml index 7e816749732..4a20274893f 100644 --- a/pom.xml +++ b/pom.xml @@ -84,6 +84,7 @@ spring-boot spring-boot-autoconfigure spring-boot-actuator + spring-boot-developer-tools spring-boot-docs spring-boot-starters spring-boot-cli diff --git a/spring-boot-dependencies/pom.xml b/spring-boot-dependencies/pom.xml index 0fa9def79b3..a30530e91cf 100644 --- a/spring-boot-dependencies/pom.xml +++ b/spring-boot-dependencies/pom.xml @@ -184,6 +184,11 @@ spring-boot-configuration-processor 1.3.0.BUILD-SNAPSHOT + + org.springframework.boot + spring-boot-developer-tools + 1.3.0.BUILD-SNAPSHOT + org.springframework.boot spring-boot-loader diff --git a/spring-boot-developer-tools/pom.xml b/spring-boot-developer-tools/pom.xml new file mode 100644 index 00000000000..31acb05aa41 --- /dev/null +++ b/spring-boot-developer-tools/pom.xml @@ -0,0 +1,90 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-parent + 1.3.0.BUILD-SNAPSHOT + ../spring-boot-parent + + spring-boot-developer-tools + Spring Boot Developer Tools + Spring Boot Developer Tools + http://projects.spring.io/spring-boot/ + + Pivotal Software, Inc. + http://www.spring.io + + + ${basedir}/.. + + + + + org.springframework.boot + spring-boot + + + org.springframework.boot + spring-boot-autoconfigure + + + + org.springframework + spring-web + true + + + javax.servlet + javax.servlet-api + true + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework + spring-webmvc + test + + + org.apache.tomcat.embed + tomcat-embed-core + test + + + org.apache.tomcat.embed + tomcat-embed-logging-juli + test + + + org.eclipse.jetty.websocket + websocket-client + ${jetty.version} + test + + + org.springframework.boot + spring-boot-starter-thymeleaf + test + + + + + + org.codehaus.mojo + animal-sniffer-maven-plugin + + + org.springframework.boot.developertools.tunnel.server.RemoteDebugPortProvider + + + + + + diff --git a/spring-boot-developer-tools/src/test/resources/org/springframework/boot/developertools/restart/classloader/Parent.txt b/spring-boot-developer-tools/src/test/resources/org/springframework/boot/developertools/restart/classloader/Parent.txt new file mode 100644 index 00000000000..edfaf68e0b8 --- /dev/null +++ b/spring-boot-developer-tools/src/test/resources/org/springframework/boot/developertools/restart/classloader/Parent.txt @@ -0,0 +1 @@ +fromparent diff --git a/spring-boot-full-build/pom.xml b/spring-boot-full-build/pom.xml index 87cf54a2b03..218ac234d24 100644 --- a/spring-boot-full-build/pom.xml +++ b/spring-boot-full-build/pom.xml @@ -51,6 +51,7 @@ ../spring-boot ../spring-boot-autoconfigure ../spring-boot-actuator + ../spring-boot-developer-tools ../spring-boot-starters ../spring-boot-cli ../spring-boot-samples From 08eaa6629445825fb94c59d6f8fa8b318ee50534 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 1 Jun 2015 13:22:57 -0700 Subject: [PATCH 02/20] Automatically apply development time properties Add auto-configuration to automatically apply properties that make sense during application development. Currently the single property `spring.thymeleaf.cache` is set to `false`. Closes gh-3083 --- ...eveloperPropertyDefaultsPostProcessor.java | 68 +++++++++++++ .../LocalDeveloperToolsAutoConfiguration.java | 37 +++++++ .../autoconfigure/package-info.java | 21 ++++ .../main/resources/META-INF/spring.factories | 3 + ...lDeveloperToolsAutoConfigurationTests.java | 96 +++++++++++++++++++ 5 files changed, 225 insertions(+) create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperPropertyDefaultsPostProcessor.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfiguration.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/package-info.java create mode 100644 spring-boot-developer-tools/src/main/resources/META-INF/spring.factories create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfigurationTests.java diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperPropertyDefaultsPostProcessor.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperPropertyDefaultsPostProcessor.java new file mode 100644 index 00000000000..971be717c37 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperPropertyDefaultsPostProcessor.java @@ -0,0 +1,68 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.autoconfigure; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.context.EnvironmentAware; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.Environment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.PropertySource; + +/** + * {@link BeanFactoryPostProcessor} to add properties that make sense when working + * locally. + * + * @author Phillip Webb + */ +class LocalDeveloperPropertyDefaultsPostProcessor implements BeanFactoryPostProcessor, + EnvironmentAware { + + private static final Map PROPERTIES; + static { + Map properties = new HashMap(); + properties.put("spring.thymeleaf.cache", "false"); + PROPERTIES = Collections.unmodifiableMap(properties); + } + + private Environment environment; + + @Override + public void setEnvironment(Environment environment) { + this.environment = environment; + } + + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) + throws BeansException { + if (this.environment instanceof ConfigurableEnvironment) { + postProcessEnvironment((ConfigurableEnvironment) this.environment); + } + } + + private void postProcessEnvironment(ConfigurableEnvironment environment) { + PropertySource propertySource = new MapPropertySource("refresh", PROPERTIES); + environment.getPropertySources().addFirst(propertySource); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfiguration.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfiguration.java new file mode 100644 index 00000000000..bf74e52b3e0 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfiguration.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.autoconfigure; + +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for local development support. + * + * @author Phillip Webb + * @since 1.3.0 + */ +@Configuration +public class LocalDeveloperToolsAutoConfiguration { + + @Bean + public static LocalDeveloperPropertyDefaultsPostProcessor localDeveloperPropertyDefaultsPostProcessor() { + return new LocalDeveloperPropertyDefaultsPostProcessor(); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/package-info.java new file mode 100644 index 00000000000..cb92f58568f --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Auto-configuration for {@code spring-boot-developer-tools}. + */ +package org.springframework.boot.developertools.autoconfigure; + diff --git a/spring-boot-developer-tools/src/main/resources/META-INF/spring.factories b/spring-boot-developer-tools/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000000..f8bcd39934c --- /dev/null +++ b/spring-boot-developer-tools/src/main/resources/META-INF/spring.factories @@ -0,0 +1,3 @@ +# Auto Configure +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ +org.springframework.boot.developertools.autoconfigure.LocalDeveloperToolsAutoConfiguration diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfigurationTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfigurationTests.java new file mode 100644 index 00000000000..fecd38cb42b --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfigurationTests.java @@ -0,0 +1,96 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.autoconfigure; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.util.SocketUtils; +import org.thymeleaf.templateresolver.TemplateResolver; + +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link LocalDeveloperToolsAutoConfiguration}. + * + * @author Phillip Webb + */ +public class LocalDeveloperToolsAutoConfigurationTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + private int liveReloadPort = SocketUtils.findAvailableTcpPort(); + + private ConfigurableApplicationContext context; + + @After + public void cleanup() { + if (this.context != null) { + this.context.close(); + } + } + + @Test + public void thymeleafCacheIsFalse() throws Exception { + this.context = initializeAndRun(Config.class); + TemplateResolver resolver = this.context.getBean(TemplateResolver.class); + resolver.initialize(); + assertThat(resolver.isCacheable(), equalTo(false)); + } + + private ConfigurableApplicationContext initializeAndRun(Class config) { + return initializeAndRun(config, Collections. emptyMap()); + } + + private ConfigurableApplicationContext initializeAndRun(Class config, + Map properties) { + SpringApplication application = new SpringApplication(config); + application.setDefaultProperties(getDefaultProperties(properties)); + application.setWebEnvironment(false); + ConfigurableApplicationContext context = application.run(); + return context; + } + + private Map getDefaultProperties( + Map specifiedProperties) { + Map properties = new HashMap(); + properties.put("spring.thymeleaf.check-template-location", false); + properties.put("spring.developertools.livereload.port", this.liveReloadPort); + properties.putAll(specifiedProperties); + return properties; + } + + @Configuration + @Import({ LocalDeveloperToolsAutoConfiguration.class, + ThymeleafAutoConfiguration.class }) + public static class Config { + + } + +} From 0862412eb4d32af9e8b9ce05e505f1d67daedf3e Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 1 Jun 2015 13:22:20 -0700 Subject: [PATCH 03/20] Add filesystem watcher support Add a filesystem watcher that can be used to inform listeners of changes (add, delete, modify) to files in specific source folders. The implementation uses a background thread rather than the WatchService API to remain compatible with Java 6 and because WatchService is slow on OSX. See gh-3084 --- .../developertools/filewatch/ChangedFile.java | 128 +++++++++ .../filewatch/ChangedFiles.java | 89 ++++++ .../filewatch/FileChangeListener.java | 36 +++ .../filewatch/FileSnapshot.java | 84 ++++++ .../filewatch/FileSystemWatcher.java | 211 +++++++++++++++ .../filewatch/FolderSnapshot.java | 141 ++++++++++ .../filewatch/package-info.java | 21 ++ .../filewatch/ChangedFileTests.java | 87 ++++++ .../filewatch/FileSnapshotTests.java | 112 ++++++++ .../filewatch/FileSystemWatcherTests.java | 255 ++++++++++++++++++ .../filewatch/FolderSnapshotTests.java | 156 +++++++++++ 11 files changed, 1320 insertions(+) create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFile.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFiles.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileChangeListener.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSnapshot.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSystemWatcher.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FolderSnapshot.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/package-info.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/ChangedFileTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FileSnapshotTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FileSystemWatcherTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FolderSnapshotTests.java diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFile.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFile.java new file mode 100644 index 00000000000..7c7e71e5309 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFile.java @@ -0,0 +1,128 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.filewatch; + +import java.io.File; + +import org.springframework.util.Assert; + +/** + * A single file that has changed. + * + * @author Phillip Webb + * @since 1.3.0 + * @see ChangedFiles + */ +public final class ChangedFile { + + private final File sourceFolder; + + private final File file; + + private final Type type; + + /** + * Create a new {@link ChangedFile} instance. + * @param sourceFolder the source folder + * @param file the file + * @param type the type of change + */ + public ChangedFile(File sourceFolder, File file, Type type) { + Assert.notNull(sourceFolder, "SourceFolder must not be null"); + Assert.notNull(file, "File must not be null"); + Assert.notNull(type, "Type must not be null"); + this.sourceFolder = sourceFolder; + this.file = file; + this.type = type; + } + + /** + * Return the file that was changed. + * @return the file + */ + public File getFile() { + return this.file; + } + + /** + * Return the type of change. + * @return the type of change + */ + public Type getType() { + return this.type; + } + + /** + * Return the name of the file relative to the source folder. + * @return the relative name + */ + public String getRelativeName() { + String folderName = this.sourceFolder.getAbsoluteFile().getPath(); + String fileName = this.file.getAbsoluteFile().getPath(); + Assert.state(fileName.startsWith(folderName), "The file " + fileName + + " is not contained in the source folder " + folderName); + return fileName.substring(folderName.length() + 1); + } + + @Override + public int hashCode() { + return this.file.hashCode() * 31 + this.type.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + if (obj == null) { + return false; + } + if (obj instanceof ChangedFile) { + ChangedFile other = (ChangedFile) obj; + return this.file.equals(other.file) && this.type.equals(other.type); + } + return super.equals(obj); + } + + @Override + public String toString() { + return this.file + " (" + this.type + ")"; + } + + /** + * Change types. + */ + public static enum Type { + + /** + * A new file has been added. + */ + ADD, + + /** + * An existing file has been modified. + */ + MODIFY, + + /** + * An existing file has been deleted. + */ + DELETE + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFiles.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFiles.java new file mode 100644 index 00000000000..87b2dfe442e --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/ChangedFiles.java @@ -0,0 +1,89 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.filewatch; + +import java.io.File; +import java.util.Collections; +import java.util.Iterator; +import java.util.Set; + +/** + * A collections of files from a specific source folder that have changed. + * + * @author Phillip Webb + * @since 1.3.0 + * @see FileChangeListener + * @see ChangedFiles + */ +public final class ChangedFiles implements Iterable { + + private final File sourceFolder; + + private final Set files; + + public ChangedFiles(File sourceFolder, Set files) { + this.sourceFolder = sourceFolder; + this.files = Collections.unmodifiableSet(files); + } + + /** + * The source folder being watched. + * @return the source folder + */ + public File getSourceFolder() { + return this.sourceFolder; + } + + @Override + public Iterator iterator() { + return getFiles().iterator(); + } + + /** + * The files that have been changed. + * @return the changed files + */ + public Set getFiles() { + return this.files; + } + + @Override + public int hashCode() { + return this.files.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (obj == null) { + return false; + } + if (obj == this) { + return true; + } + if (obj instanceof ChangedFiles) { + ChangedFiles other = (ChangedFiles) obj; + return this.sourceFolder.equals(other.sourceFolder) + && this.files.equals(other.files); + } + return super.equals(obj); + } + + @Override + public String toString() { + return this.sourceFolder + " " + this.files; + } +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileChangeListener.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileChangeListener.java new file mode 100644 index 00000000000..11960289470 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileChangeListener.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.filewatch; + +import java.util.Set; + +/** + * Callback interface when file changes are detected. + * + * @author Andy Clement + * @author Phillip Webb + * @since 1.3.0 + */ +public interface FileChangeListener { + + /** + * Called when files have been changed. + * @param changeSet a set of the {@link ChangedFiles} + */ + void onChange(Set changeSet); + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSnapshot.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSnapshot.java new file mode 100644 index 00000000000..567616c3375 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSnapshot.java @@ -0,0 +1,84 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.filewatch; + +import java.io.File; + +import org.springframework.util.Assert; + +/** + * A snapshot of a File at a given point in time. + * + * @author Phillip Webb + */ +class FileSnapshot { + + private final File file; + + private final boolean exists; + + private final long length; + + private final long lastModified; + + public FileSnapshot(File file) { + Assert.notNull(file, "File must not be null"); + Assert.isTrue(file.isFile() || !file.exists(), "File must not be a folder"); + this.file = file; + this.exists = file.exists(); + this.length = file.length(); + this.lastModified = file.lastModified(); + } + + public File getFile() { + return this.file; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (obj instanceof FileSnapshot) { + FileSnapshot other = (FileSnapshot) obj; + boolean equals = this.file.equals(other.file); + equals &= this.exists == other.exists; + equals &= this.length == other.length; + equals &= this.lastModified == other.lastModified; + return equals; + } + return super.equals(obj); + } + + @Override + public int hashCode() { + int hashCode = this.file.hashCode(); + hashCode = 31 * hashCode + (this.exists ? 1231 : 1237); + hashCode = 31 * hashCode + (int) (this.length ^ (this.length >>> 32)); + hashCode = 31 * hashCode + (int) (this.lastModified ^ (this.lastModified >>> 32)); + return hashCode; + } + + @Override + public String toString() { + return this.file.toString(); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSystemWatcher.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSystemWatcher.java new file mode 100644 index 00000000000..4ee5c31cad6 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FileSystemWatcher.java @@ -0,0 +1,211 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.filewatch; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import org.springframework.util.Assert; + +/** + * Watches specific folders for file changes. + * + * @author Andy Clement + * @author Phillip Webb + * @see FileChangeListener + * @since 1.3.0 + */ +public class FileSystemWatcher { + + private static final long DEFAULT_IDLE_TIME = 400; + + private static final long DEFAULT_QUIET_TIME = 200; + + private List listeners = new ArrayList(); + + private final boolean daemon; + + private final long idleTime; + + private final long quietTime; + + private Thread watchThread; + + private AtomicInteger remainingScans = new AtomicInteger(-1); + + private Map folders = new LinkedHashMap(); + + /** + * Create a new {@link FileSystemWatcher} instance. + */ + public FileSystemWatcher() { + this(true, DEFAULT_IDLE_TIME, DEFAULT_QUIET_TIME); + } + + /** + * Create a new {@link FileSystemWatcher} instance. + * @param daemon if a daemon thread used to monitor changes + * @param idleTime the amount of time to wait between checking for changes + * @param quietTime the amount of time required after a change has been detected to + * ensure that updates have completed + */ + public FileSystemWatcher(boolean daemon, long idleTime, long quietTime) { + this.daemon = daemon; + this.idleTime = idleTime; + this.quietTime = quietTime; + } + + /** + * Add listener for file change events. Cannot be called after the watcher has been + * {@link #start() started}. + * @param fileChangeListener the listener to add + */ + public synchronized void addListener(FileChangeListener fileChangeListener) { + Assert.notNull(fileChangeListener, "FileChangeListener must not be null"); + checkNotStarted(); + this.listeners.add(fileChangeListener); + } + + /** + * Add a source folder to monitor. Cannot be called after the watcher has been + * {@link #start() started}. + * @param folder the folder to monitor + */ + public synchronized void addSourceFolder(File folder) { + Assert.notNull(folder, "Folder must not be null"); + Assert.isTrue(folder.isDirectory(), "Folder must not be a file"); + checkNotStarted(); + this.folders.put(folder, null); + } + + private void checkNotStarted() { + Assert.state(this.watchThread == null, "FileSystemWatcher already started"); + } + + /** + * Start monitoring the source folder for changes. + */ + public synchronized void start() { + saveInitalSnapshots(); + if (this.watchThread == null) { + this.watchThread = new Thread() { + @Override + public void run() { + int remainingScans = FileSystemWatcher.this.remainingScans.get(); + while (remainingScans > 0 || remainingScans == -1) { + try { + if (remainingScans > 0) { + FileSystemWatcher.this.remainingScans.decrementAndGet(); + } + scan(); + remainingScans = FileSystemWatcher.this.remainingScans.get(); + } + catch (InterruptedException ex) { + } + } + }; + }; + this.watchThread.setName("File Watcher"); + this.watchThread.setDaemon(this.daemon); + this.remainingScans = new AtomicInteger(-1); + this.watchThread.start(); + } + } + + private void saveInitalSnapshots() { + for (File folder : this.folders.keySet()) { + this.folders.put(folder, new FolderSnapshot(folder)); + } + } + + private void scan() throws InterruptedException { + Thread.sleep(this.idleTime - this.quietTime); + Set previous; + Set current = new HashSet(this.folders.values()); + do { + previous = current; + current = getCurrentSnapshots(); + Thread.sleep(this.quietTime); + } + while (!previous.equals(current)); + updateSnapshots(current); + } + + private Set getCurrentSnapshots() { + Set snapshots = new LinkedHashSet(); + for (File folder : this.folders.keySet()) { + snapshots.add(new FolderSnapshot(folder)); + } + return snapshots; + } + + private void updateSnapshots(Set snapshots) { + Map updated = new LinkedHashMap(); + Set changeSet = new LinkedHashSet(); + for (FolderSnapshot snapshot : snapshots) { + FolderSnapshot previous = this.folders.get(snapshot.getFolder()); + updated.put(snapshot.getFolder(), snapshot); + ChangedFiles changedFiles = previous.getChangedFiles(snapshot); + if (!changedFiles.getFiles().isEmpty()) { + changeSet.add(changedFiles); + } + } + if (!changeSet.isEmpty()) { + fireListeners(Collections.unmodifiableSet(changeSet)); + } + this.folders = updated; + } + + private void fireListeners(Set changeSet) { + for (FileChangeListener listener : this.listeners) { + listener.onChange(changeSet); + } + } + + /** + * Stop monitoring the source folders. + */ + public synchronized void stop() { + stopAfter(0); + } + + /** + * Stop monitoring the source folders. + * @param remainingScans the number of scans remaming + */ + synchronized void stopAfter(int remainingScans) { + Thread thread = this.watchThread; + if (thread != null) { + this.remainingScans.set(remainingScans); + try { + thread.join(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + this.watchThread = null; + } + } +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FolderSnapshot.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FolderSnapshot.java new file mode 100644 index 00000000000..87534b919a4 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/FolderSnapshot.java @@ -0,0 +1,141 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.filewatch; + +import java.io.File; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import org.springframework.boot.developertools.filewatch.ChangedFile.Type; +import org.springframework.util.Assert; + +/** + * A snapshot of a folder at a given point in time. + * + * @author Phillip Webb + */ +class FolderSnapshot { + + private static final Set DOT_FOLDERS = Collections + .unmodifiableSet(new HashSet(Arrays.asList(".", ".."))); + + private final File folder; + + private final Date time; + + private Set files; + + /** + * Create a new {@link FolderSnapshot} for the given folder. + * @param folder the source folder + */ + public FolderSnapshot(File folder) { + Assert.notNull(folder, "Folder must not be null"); + Assert.isTrue(folder.isDirectory(), "Folder must not be a file"); + this.folder = folder; + this.time = new Date(); + Set files = new LinkedHashSet(); + collectFiles(folder, files); + this.files = Collections.unmodifiableSet(files); + } + + private void collectFiles(File source, Set result) { + File[] children = source.listFiles(); + if (children != null) { + for (File child : children) { + if (child.isDirectory() && !DOT_FOLDERS.contains(child.getName())) { + collectFiles(child, result); + } + else if (child.isFile()) { + result.add(new FileSnapshot(child)); + } + } + } + } + + public ChangedFiles getChangedFiles(FolderSnapshot snapshot) { + Assert.notNull(snapshot, "Snapshot must not be null"); + File folder = this.folder; + Assert.isTrue(snapshot.folder.equals(folder), "Snapshot source folder must be '" + + folder + "'"); + Set changes = new LinkedHashSet(); + Map previousFiles = getFilesMap(); + for (FileSnapshot currentFile : snapshot.files) { + FileSnapshot previousFile = previousFiles.remove(currentFile.getFile()); + if (previousFile == null) { + changes.add(new ChangedFile(folder, currentFile.getFile(), Type.ADD)); + } + else if (!previousFile.equals(currentFile)) { + changes.add(new ChangedFile(folder, currentFile.getFile(), Type.MODIFY)); + } + } + for (FileSnapshot previousFile : previousFiles.values()) { + changes.add(new ChangedFile(folder, previousFile.getFile(), Type.DELETE)); + } + return new ChangedFiles(folder, changes); + } + + private Map getFilesMap() { + Map files = new LinkedHashMap(); + for (FileSnapshot file : this.files) { + files.put(file.getFile(), file); + } + return files; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (obj instanceof FolderSnapshot) { + FolderSnapshot other = (FolderSnapshot) obj; + return this.folder.equals(other.folder) && this.files.equals(other.files); + } + return super.equals(obj); + } + + @Override + public int hashCode() { + int hashCode = this.folder.hashCode(); + hashCode = 31 * hashCode + this.files.hashCode(); + return hashCode; + } + + /** + * Return the source folder of this snapshot. + * @return the source folder + */ + public File getFolder() { + return this.folder; + } + + @Override + public String toString() { + return this.folder + " snaphost at " + this.time; + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/package-info.java new file mode 100644 index 00000000000..1bc251d01a4 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/filewatch/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Class to watch the local filesystem for changes. + */ +package org.springframework.boot.developertools.filewatch; + diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/ChangedFileTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/ChangedFileTests.java new file mode 100644 index 00000000000..70d3a732982 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/ChangedFileTests.java @@ -0,0 +1,87 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.filewatch; + +import java.io.File; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.TemporaryFolder; +import org.springframework.boot.developertools.filewatch.ChangedFile.Type; + +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link ChangedFile}. + * + * @author Phillip Webb + */ +public class ChangedFileTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + + @Test + public void sourceFolderMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("SourceFolder must not be null"); + new ChangedFile(null, this.temp.newFile(), Type.ADD); + } + + @Test + public void fileMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("File must not be null"); + new ChangedFile(this.temp.newFolder(), null, Type.ADD); + } + + @Test + public void typeMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Type must not be null"); + new ChangedFile(this.temp.newFile(), this.temp.newFolder(), null); + } + + @Test + public void getFile() throws Exception { + File file = this.temp.newFile(); + ChangedFile changedFile = new ChangedFile(this.temp.newFolder(), file, Type.ADD); + assertThat(changedFile.getFile(), equalTo(file)); + } + + @Test + public void getType() throws Exception { + ChangedFile changedFile = new ChangedFile(this.temp.newFolder(), + this.temp.newFile(), Type.DELETE); + assertThat(changedFile.getType(), equalTo(Type.DELETE)); + } + + @Test + public void getRelativeName() throws Exception { + File folder = this.temp.newFolder(); + File subFolder = new File(folder, "A"); + File file = new File(subFolder, "B.txt"); + ChangedFile changedFile = new ChangedFile(folder, file, Type.ADD); + assertThat(changedFile.getRelativeName(), equalTo("A/B.txt")); + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FileSnapshotTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FileSnapshotTests.java new file mode 100644 index 00000000000..ae47cd5eafa --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FileSnapshotTests.java @@ -0,0 +1,112 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.filewatch; + +import java.io.File; +import java.io.IOException; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.TemporaryFolder; +import org.springframework.util.FileCopyUtils; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link FileSnapshot}. + * + * @author Phillip Webb + */ +public class FileSnapshotTests { + + private static final long TWO_MINS = TimeUnit.MINUTES.toMillis(2); + + private static final long MODIFIED = new Date().getTime() + - TimeUnit.DAYS.toMillis(10); + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void fileMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("File must not be null"); + new FileSnapshot(null); + } + + @Test + public void fileMustNotBeAFolder() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("File must not be a folder"); + new FileSnapshot(this.temporaryFolder.newFolder()); + } + + @Test + public void equalsIfTheSame() throws Exception { + File file = createNewFile("abc", MODIFIED); + File fileCopy = new File(file, "x").getParentFile(); + FileSnapshot snapshot1 = new FileSnapshot(file); + FileSnapshot snapshot2 = new FileSnapshot(fileCopy); + assertThat(snapshot1, equalTo(snapshot2)); + assertThat(snapshot1.hashCode(), equalTo(snapshot2.hashCode())); + } + + @Test + public void notEqualsIfDeleted() throws Exception { + File file = createNewFile("abc", MODIFIED); + FileSnapshot snapshot1 = new FileSnapshot(file); + file.delete(); + assertThat(snapshot1, not(equalTo(new FileSnapshot(file)))); + } + + @Test + public void notEqualsIfLengthChanges() throws Exception { + File file = createNewFile("abc", MODIFIED); + FileSnapshot snapshot1 = new FileSnapshot(file); + setupFile(file, "abcd", MODIFIED); + assertThat(snapshot1, not(equalTo(new FileSnapshot(file)))); + } + + @Test + public void notEqualsIfLastModifiedChanges() throws Exception { + File file = createNewFile("abc", MODIFIED); + FileSnapshot snapshot1 = new FileSnapshot(file); + setupFile(file, "abc", MODIFIED + TWO_MINS); + assertThat(snapshot1, not(equalTo(new FileSnapshot(file)))); + } + + private File createNewFile(String content, long lastModified) throws IOException { + File file = this.temporaryFolder.newFile(); + setupFile(file, content, lastModified); + return file; + } + + private void setupFile(File file, String content, long lastModified) + throws IOException { + FileCopyUtils.copy(content.getBytes(), file); + file.setLastModified(lastModified); + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FileSystemWatcherTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FileSystemWatcherTests.java new file mode 100644 index 00000000000..42b005469ba --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FileSystemWatcherTests.java @@ -0,0 +1,255 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.filewatch; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.TemporaryFolder; +import org.springframework.boot.developertools.filewatch.ChangedFile.Type; +import org.springframework.util.FileCopyUtils; + +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link FileSystemWatcher}. + * + * @author Phillip Webb + */ +public class FileSystemWatcherTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + private FileSystemWatcher watcher; + + private List> changes = new ArrayList>(); + + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + + @Before + public void setup() throws Exception { + setupWatcher(20, 10); + } + + @Test + public void listenerMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("FileChangeListener must not be null"); + this.watcher.addListener(null); + } + + @Test + public void cannotAddListenerToStartedListener() throws Exception { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("FileSystemWatcher already started"); + this.watcher.start(); + this.watcher.addListener(mock(FileChangeListener.class)); + } + + @Test + public void sourceFolderMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Folder must not be null"); + this.watcher.addSourceFolder(null); + } + + @Test + public void cannotAddSourceFolderToStartedListener() throws Exception { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("FileSystemWatcher already started"); + this.watcher.start(); + this.watcher.addSourceFolder(this.temp.newFolder()); + } + + @Test + public void addFile() throws Exception { + File folder = startWithNewFolder(); + File file = touch(new File(folder, "test.txt")); + this.watcher.stopAfter(1); + ChangedFiles changedFiles = getSingleChangedFiles(); + ChangedFile expected = new ChangedFile(folder, file, Type.ADD); + assertThat(changedFiles.getFiles(), contains(expected)); + } + + @Test + public void addNestedFile() throws Exception { + File folder = startWithNewFolder(); + File file = touch(new File(new File(folder, "sub"), "text.txt")); + this.watcher.stopAfter(1); + ChangedFiles changedFiles = getSingleChangedFiles(); + ChangedFile expected = new ChangedFile(folder, file, Type.ADD); + assertThat(changedFiles.getFiles(), contains(expected)); + } + + @Test + public void waitsForIdleTime() throws Exception { + this.changes.clear(); + setupWatcher(100, 0); + File folder = startWithNewFolder(); + touch(new File(folder, "test1.txt")); + Thread.sleep(200); + touch(new File(folder, "test2.txt")); + this.watcher.stopAfter(1); + assertThat(this.changes.size(), equalTo(2)); + } + + @Test + public void waitsForQuietTime() throws Exception { + setupWatcher(300, 200); + File folder = startWithNewFolder(); + for (int i = 0; i < 10; i++) { + touch(new File(folder, i + "test.txt")); + Thread.sleep(100); + } + this.watcher.stopAfter(1); + ChangedFiles changedFiles = getSingleChangedFiles(); + assertThat(changedFiles.getFiles().size(), equalTo(10)); + } + + @Test + public void withExistingFiles() throws Exception { + File folder = this.temp.newFolder(); + touch(new File(folder, "test.txt")); + this.watcher.addSourceFolder(folder); + this.watcher.start(); + File file = touch(new File(folder, "test2.txt")); + this.watcher.stopAfter(1); + ChangedFiles changedFiles = getSingleChangedFiles(); + ChangedFile expected = new ChangedFile(folder, file, Type.ADD); + assertThat(changedFiles.getFiles(), contains(expected)); + } + + @Test + public void multipleSources() throws Exception { + File folder1 = this.temp.newFolder(); + File folder2 = this.temp.newFolder(); + this.watcher.addSourceFolder(folder1); + this.watcher.addSourceFolder(folder2); + this.watcher.start(); + File file1 = touch(new File(folder1, "test.txt")); + File file2 = touch(new File(folder2, "test.txt")); + this.watcher.stopAfter(1); + Set change = getSingleOnChange(); + assertThat(change.size(), equalTo(2)); + for (ChangedFiles changedFiles : change) { + if (changedFiles.getSourceFolder().equals(folder1)) { + ChangedFile file = new ChangedFile(folder1, file1, Type.ADD); + assertEquals(new HashSet(Arrays.asList(file)), + changedFiles.getFiles()); + } + else { + ChangedFile file = new ChangedFile(folder2, file2, Type.ADD); + assertEquals(new HashSet(Arrays.asList(file)), + changedFiles.getFiles()); + } + } + } + + @Test + public void multipleListeners() throws Exception { + File folder = this.temp.newFolder(); + final Set listener2Changes = new LinkedHashSet(); + this.watcher.addSourceFolder(folder); + this.watcher.addListener(new FileChangeListener() { + @Override + public void onChange(Set changeSet) { + listener2Changes.addAll(changeSet); + } + }); + this.watcher.start(); + File file = touch(new File(folder, "test.txt")); + this.watcher.stopAfter(1); + ChangedFiles changedFiles = getSingleChangedFiles(); + ChangedFile expected = new ChangedFile(folder, file, Type.ADD); + assertThat(changedFiles.getFiles(), contains(expected)); + assertEquals(this.changes.get(0), listener2Changes); + } + + @Test + public void modifyDeleteAndAdd() throws Exception { + File folder = this.temp.newFolder(); + File modify = touch(new File(folder, "modify.txt")); + File delete = touch(new File(folder, "delete.txt")); + this.watcher.addSourceFolder(folder); + this.watcher.start(); + FileCopyUtils.copy("abc".getBytes(), modify); + delete.delete(); + File add = touch(new File(folder, "add.txt")); + this.watcher.stopAfter(1); + ChangedFiles changedFiles = getSingleChangedFiles(); + Set actual = changedFiles.getFiles(); + Set expected = new HashSet(); + expected.add(new ChangedFile(folder, modify, Type.MODIFY)); + expected.add(new ChangedFile(folder, delete, Type.DELETE)); + expected.add(new ChangedFile(folder, add, Type.ADD)); + assertEquals(expected, actual); + } + + private void setupWatcher(long idleTime, long quietTime) { + this.watcher = new FileSystemWatcher(false, idleTime, quietTime); + this.watcher.addListener(new FileChangeListener() { + @Override + public void onChange(Set changeSet) { + FileSystemWatcherTests.this.changes.add(changeSet); + } + }); + } + + private File startWithNewFolder() throws IOException { + File folder = this.temp.newFolder(); + this.watcher.addSourceFolder(folder); + this.watcher.start(); + return folder; + } + + private ChangedFiles getSingleChangedFiles() { + Set singleChange = getSingleOnChange(); + assertThat(singleChange.size(), equalTo(1)); + return singleChange.iterator().next(); + } + + private Set getSingleOnChange() { + assertThat(this.changes.size(), equalTo(1)); + return this.changes.get(0); + } + + private File touch(File file) throws FileNotFoundException, IOException { + file.getParentFile().mkdirs(); + FileOutputStream fileOutputStream = new FileOutputStream(file); + fileOutputStream.close(); + return file; + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FolderSnapshotTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FolderSnapshotTests.java new file mode 100644 index 00000000000..2d9875f6beb --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/filewatch/FolderSnapshotTests.java @@ -0,0 +1,156 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.filewatch; + +import java.io.File; +import java.io.IOException; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.TemporaryFolder; +import org.springframework.boot.developertools.filewatch.ChangedFile.Type; +import org.springframework.util.FileCopyUtils; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link FolderSnapshot}. + * + * @author Phillip Webb + */ +public class FolderSnapshotTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private File folder; + + private FolderSnapshot initialSnapshot; + + @Before + public void setup() throws Exception { + this.folder = createTestFolderStructure(); + this.initialSnapshot = new FolderSnapshot(this.folder); + } + + @Test + public void folderMustNotBeNull() { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Folder must not be null"); + new FolderSnapshot(null); + } + + @Test + public void folderMustNotBeFile() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Folder must not be a file"); + new FolderSnapshot(this.temporaryFolder.newFile()); + } + + @Test + public void equalsWhenNothingHasChanged() throws Exception { + FolderSnapshot updatedSnapshot = new FolderSnapshot(this.folder); + assertThat(this.initialSnapshot, equalTo(updatedSnapshot)); + assertThat(this.initialSnapshot.hashCode(), equalTo(updatedSnapshot.hashCode())); + } + + @Test + public void notEqualsWhenAFileIsAdded() throws Exception { + new File(new File(this.folder, "folder1"), "newfile").createNewFile(); + FolderSnapshot updatedSnapshot = new FolderSnapshot(this.folder); + assertThat(this.initialSnapshot, not(equalTo(updatedSnapshot))); + } + + @Test + public void notEqualsWhenAFileIsDeleted() throws Exception { + new File(new File(this.folder, "folder1"), "file1").delete(); + FolderSnapshot updatedSnapshot = new FolderSnapshot(this.folder); + assertThat(this.initialSnapshot, not(equalTo(updatedSnapshot))); + } + + @Test + public void notEqualsWhenAFileIsModified() throws Exception { + File file1 = new File(new File(this.folder, "folder1"), "file1"); + FileCopyUtils.copy("updatedcontent".getBytes(), file1); + FolderSnapshot updatedSnapshot = new FolderSnapshot(this.folder); + assertThat(this.initialSnapshot, not(equalTo(updatedSnapshot))); + } + + @Test + public void getChangedFilesSnapshotMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Snapshot must not be null"); + this.initialSnapshot.getChangedFiles(null); + } + + @Test + public void getChangedFilesSnapshotMustBeTheSameSourceFolder() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Snapshot source folder must be '" + this.folder + "'"); + this.initialSnapshot.getChangedFiles(new FolderSnapshot( + createTestFolderStructure())); + } + + @Test + public void getChangedFilesWhenNothingHasChanged() throws Exception { + FolderSnapshot updatedSnapshot = new FolderSnapshot(this.folder); + this.initialSnapshot.getChangedFiles(updatedSnapshot); + } + + @Test + public void getChangedFilesWhenAFileIsAddedAndDeletedAndChanged() throws Exception { + File folder1 = new File(this.folder, "folder1"); + File file1 = new File(folder1, "file1"); + File file2 = new File(folder1, "file2"); + File newFile = new File(folder1, "newfile"); + FileCopyUtils.copy("updatedcontent".getBytes(), file1); + file2.delete(); + newFile.createNewFile(); + FolderSnapshot updatedSnapshot = new FolderSnapshot(this.folder); + ChangedFiles changedFiles = this.initialSnapshot.getChangedFiles(updatedSnapshot); + assertThat(changedFiles.getSourceFolder(), equalTo(this.folder)); + assertThat(getChangedFile(changedFiles, file1).getType(), equalTo(Type.MODIFY)); + assertThat(getChangedFile(changedFiles, file2).getType(), equalTo(Type.DELETE)); + assertThat(getChangedFile(changedFiles, newFile).getType(), equalTo(Type.ADD)); + } + + private ChangedFile getChangedFile(ChangedFiles changedFiles, File file) { + for (ChangedFile changedFile : changedFiles) { + if (changedFile.getFile().equals(file)) { + return changedFile; + } + } + return null; + } + + private File createTestFolderStructure() throws IOException { + File root = this.temporaryFolder.newFolder(); + File folder1 = new File(root, "folder1"); + folder1.mkdirs(); + FileCopyUtils.copy("abc".getBytes(), new File(folder1, "file1")); + FileCopyUtils.copy("abc".getBytes(), new File(folder1, "file2")); + return root; + } + +} From da51785706693ea0ce08035ab96ef8742dc6aafe Mon Sep 17 00:00:00 2001 From: Andy Clement Date: Mon, 1 Jun 2015 13:22:29 -0700 Subject: [PATCH 04/20] Add a parent last classloader for restart use Add a parent last classloader for use with application restarts. The classloader provides a layer on top of the regular classloader to contain the classes that might change when an application is restarted. See gh-3084 --- .../classloader/RestartClassLoader.java | 111 +++++++++++++ .../restart/classloader/package-info.java | 21 +++ .../classloader/RestartClassLoaderTests.java | 148 ++++++++++++++++++ .../restart/classloader/Sample.java | 26 +++ .../restart/classloader/SampleParent.java | 26 +++ 5 files changed, 332 insertions(+) create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/RestartClassLoader.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/package-info.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/classloader/RestartClassLoaderTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/classloader/Sample.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/classloader/SampleParent.java diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/RestartClassLoader.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/RestartClassLoader.java new file mode 100644 index 00000000000..7be6917d1bc --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/RestartClassLoader.java @@ -0,0 +1,111 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart.classloader; + +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Enumeration; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.core.SmartClassLoader; +import org.springframework.util.Assert; + +/** + * Disposable {@link ClassLoader} used to support application restarting. Provides parent + * last loading for the specified URLs. + * + * @author Andy Clement + * @author Phillip Webb + * @since 1.3.0 + */ +public class RestartClassLoader extends URLClassLoader implements SmartClassLoader { + + private final Log logger; + + /** + * Create a new {@link RestartClassLoader} instance. + * @param parent the parent classloader URLs were created. + * @param urls the urls managed by the classloader + */ + public RestartClassLoader(ClassLoader parent, URL[] urls) { + this(parent, urls, LogFactory.getLog(RestartClassLoader.class)); + } + + /** + * Create a new {@link RestartClassLoader} instance. + * @param parent the parent classloader URLs were created. + * @param urls the urls managed by the classloader + * @param logger the logger used for messages + */ + public RestartClassLoader(ClassLoader parent, URL[] urls, Log logger) { + super(urls, parent); + Assert.notNull(parent, "Parent must not be null"); + Assert.notNull(logger, "Logger must not be null"); + this.logger = logger; + if (logger.isDebugEnabled()) { + logger.debug("Created RestartClassLoader " + toString()); + } + } + + @Override + public Enumeration getResources(String name) throws IOException { + // Use the parent since we're shadowing resource and we don't want duplicates + return getParent().getResources(name); + } + + @Override + public URL getResource(String name) { + URL resource = findResource(name); + if (resource != null) { + return resource; + } + return getParent().getResource(name); + } + + @Override + public Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + Class loadedClass = findLoadedClass(name); + if (loadedClass == null) { + try { + loadedClass = findClass(name); + } + catch (ClassNotFoundException ex) { + loadedClass = getParent().loadClass(name); + } + } + if (resolve) { + resolveClass(loadedClass); + } + return loadedClass; + } + + @Override + protected void finalize() throws Throwable { + if (this.logger.isDebugEnabled()) { + this.logger.debug("Finalized classloader " + toString()); + } + super.finalize(); + } + + @Override + public boolean isClassReloadable(Class classType) { + return (classType.getClassLoader() instanceof RestartClassLoader); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/package-info.java new file mode 100644 index 00000000000..126699d3412 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Classloaders used for reload support + */ +package org.springframework.boot.developertools.restart.classloader; + diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/classloader/RestartClassLoaderTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/classloader/RestartClassLoaderTests.java new file mode 100644 index 00000000000..8f152ea388d --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/classloader/RestartClassLoaderTests.java @@ -0,0 +1,148 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart.classloader; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.jar.JarOutputStream; +import java.util.zip.ZipEntry; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.TemporaryFolder; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.StreamUtils; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.startsWith; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link RestartClassLoader}. + * + * @author Phillip Webb + */ +@SuppressWarnings("resource") +public class RestartClassLoaderTests { + + private static final String PACKAGE = RestartClassLoaderTests.class.getPackage() + .getName(); + + private static final String PACKAGE_PATH = PACKAGE.replace(".", "/"); + + private static final Charset UTF_8 = Charset.forName("UTF-8"); + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + + private File sampleJarFile; + + private URLClassLoader parentClassLoader; + + private RestartClassLoader reloadClassLoader; + + @Before + public void setup() throws Exception { + this.sampleJarFile = createSampleJarFile(); + URL url = this.sampleJarFile.toURI().toURL(); + ClassLoader classLoader = getClass().getClassLoader(); + URL[] urls = new URL[] { url }; + this.parentClassLoader = new URLClassLoader(urls, classLoader); + this.reloadClassLoader = new RestartClassLoader(this.parentClassLoader, urls); + } + + private File createSampleJarFile() throws IOException { + File file = this.temp.newFile("sample.jar"); + JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(file)); + jarOutputStream.putNextEntry(new ZipEntry(PACKAGE_PATH + "/Sample.class")); + StreamUtils.copy(getClass().getResourceAsStream("Sample.class"), jarOutputStream); + jarOutputStream.closeEntry(); + jarOutputStream.putNextEntry(new ZipEntry(PACKAGE_PATH + "/Sample.txt")); + StreamUtils.copy("fromchild", UTF_8, jarOutputStream); + jarOutputStream.closeEntry(); + jarOutputStream.close(); + return file; + } + + @Test + public void parentMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Parent must not be null"); + new RestartClassLoader(null, new URL[] {}); + } + + @Test + public void getResourceFromReloadableUrl() throws Exception { + String content = readString(this.reloadClassLoader + .getResourceAsStream(PACKAGE_PATH + "/Sample.txt")); + assertThat(content, startsWith("fromchild")); + } + + @Test + public void getResourceFromParent() throws Exception { + String content = readString(this.reloadClassLoader + .getResourceAsStream(PACKAGE_PATH + "/Parent.txt")); + assertThat(content, startsWith("fromparent")); + } + + @Test + public void getResourcesFiltersDuplicates() throws Exception { + List resources = toList(this.reloadClassLoader.getResources(PACKAGE_PATH + + "/Sample.txt")); + assertThat(resources.size(), equalTo(1)); + } + + @Test + public void loadClassFromReloadableUrl() throws Exception { + Class loaded = this.reloadClassLoader.loadClass(PACKAGE + ".Sample"); + assertThat(loaded.getClassLoader(), equalTo((ClassLoader) this.reloadClassLoader)); + } + + @Test + public void loadClassFromParent() throws Exception { + Class loaded = this.reloadClassLoader.loadClass(PACKAGE + ".SampleParent"); + assertThat(loaded.getClassLoader(), equalTo(getClass().getClassLoader())); + } + + private String readString(InputStream in) throws IOException { + return new String(FileCopyUtils.copyToByteArray(in)); + } + + private List toList(Enumeration enumeration) { + List list = new ArrayList(); + if (enumeration != null) { + while (enumeration.hasMoreElements()) { + list.add(enumeration.nextElement()); + } + } + return list; + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/classloader/Sample.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/classloader/Sample.java new file mode 100644 index 00000000000..ba80dea846f --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/classloader/Sample.java @@ -0,0 +1,26 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart.classloader; + +/** + * A sample class used to test reloading. + * + * @author Phillip Webb + */ +public class Sample { + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/classloader/SampleParent.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/classloader/SampleParent.java new file mode 100644 index 00000000000..7d532529547 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/classloader/SampleParent.java @@ -0,0 +1,26 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart.classloader; + +/** + * A sample class used to test reloading. + * + * @author Phillip Webb + */ +public class SampleParent { + +} From a5c56ca4820c0fdeac3978b4bcc335a005628276 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 1 Jun 2015 13:22:45 -0700 Subject: [PATCH 05/20] Add support for Restarting applications Add Restarter class that can be used to restart a running application when underlying class files change. The Restarter is automatically initialized via a ApplicationListener and automatically detects classpath URLs that are likely to change (when not running from a fat jar). See gh-3084 --- .../LocalDeveloperToolsAutoConfiguration.java | 2 + .../restart/ChangeableUrls.java | 111 ++++ .../ConditionalOnInitializedRestarter.java | 40 ++ .../restart/DefaultRestartInitializer.java | 94 ++++ .../developertools/restart/MainMethod.java | 84 +++ .../OnInitializedRestarterCondition.java | 55 ++ .../restart/RestartApplicationListener.java | 62 +++ .../restart/RestartInitializer.java | 50 ++ .../restart/RestartLauncher.java | 54 ++ .../developertools/restart/Restarter.java | 519 ++++++++++++++++++ .../restart/SilentExitExceptionHandler.java | 60 ++ .../developertools/restart/package-info.java | 21 + .../main/resources/META-INF/spring.factories | 4 + ...lDeveloperToolsAutoConfigurationTests.java | 7 + .../restart/ChangeableUrlsTests.java | 76 +++ .../DefaultRestartInitializerTests.java | 130 +++++ .../restart/MainMethodTests.java | 155 ++++++ .../restart/MockRestartInitializer.java | 35 ++ .../developertools/restart/MockRestarter.java | 78 +++ .../OnInitializedRestarterConditionTests.java | 112 ++++ .../RestartApplicationListenerTests.java | 87 +++ .../restart/RestarterTests.java | 194 +++++++ .../SilentExitExceptionHandlerTests.java | 84 +++ 23 files changed, 2114 insertions(+) create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/ChangeableUrls.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/ConditionalOnInitializedRestarter.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/DefaultRestartInitializer.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/MainMethod.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/OnInitializedRestarterCondition.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartApplicationListener.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartInitializer.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartLauncher.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/Restarter.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/SilentExitExceptionHandler.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/package-info.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/ChangeableUrlsTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/DefaultRestartInitializerTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MainMethodTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MockRestartInitializer.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MockRestarter.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/OnInitializedRestarterConditionTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/RestartApplicationListenerTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/RestarterTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/SilentExitExceptionHandlerTests.java diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfiguration.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfiguration.java index bf74e52b3e0..3c2943e3346 100644 --- a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfiguration.java +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfiguration.java @@ -17,6 +17,7 @@ package org.springframework.boot.developertools.autoconfigure; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.developertools.restart.ConditionalOnInitializedRestarter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -27,6 +28,7 @@ import org.springframework.context.annotation.Configuration; * @since 1.3.0 */ @Configuration +@ConditionalOnInitializedRestarter public class LocalDeveloperToolsAutoConfiguration { @Bean diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/ChangeableUrls.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/ChangeableUrls.java new file mode 100644 index 00000000000..53ea59c1766 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/ChangeableUrls.java @@ -0,0 +1,111 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart; + +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.regex.Pattern; + +/** + * A filtered collections of URLs which can be change after the application has started. + * + * @author Phillip Webb + */ +class ChangeableUrls implements Iterable { + + private static final String[] SKIPPED_PROJECTS = { "spring-boot", + "spring-boot-developer-tools", "spring-boot-autoconfigure", + "spring-boot-actuator", "spring-boot-starter" }; + + private static final Pattern STARTER_PATTERN = Pattern + .compile("\\/spring-boot-starter-[\\w-]+\\/"); + + private final List urls; + + private ChangeableUrls(URL... urls) { + List reloadableUrls = new ArrayList(urls.length); + for (URL url : urls) { + if (isReloadable(url)) { + reloadableUrls.add(url); + } + } + this.urls = Collections.unmodifiableList(reloadableUrls); + } + + private boolean isReloadable(URL url) { + String urlString = url.toString(); + return isFolderUrl(urlString) && !isSkipped(urlString); + } + + private boolean isFolderUrl(String urlString) { + return urlString.startsWith("file:") && urlString.endsWith("/"); + } + + private boolean isSkipped(String urlString) { + // Skip certain spring-boot projects to allow them to be imported in the same IDE + for (String skipped : SKIPPED_PROJECTS) { + if (urlString.contains("/" + skipped + "/target/classes/")) { + return true; + } + } + // Skip all starter projects + if (STARTER_PATTERN.matcher(urlString).find()) { + return true; + } + return false; + } + + @Override + public Iterator iterator() { + return this.urls.iterator(); + } + + public int size() { + return this.urls.size(); + } + + public URL[] toArray() { + return this.urls.toArray(new URL[this.urls.size()]); + } + + public List toList() { + return Collections.unmodifiableList(this.urls); + } + + @Override + public String toString() { + return this.urls.toString(); + } + + public static ChangeableUrls fromUrlClassLoader(URLClassLoader classLoader) { + return fromUrls(classLoader.getURLs()); + } + + public static ChangeableUrls fromUrls(Collection urls) { + return fromUrls(new ArrayList(urls).toArray(new URL[urls.size()])); + } + + public static ChangeableUrls fromUrls(URL... urls) { + return new ChangeableUrls(urls); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/ConditionalOnInitializedRestarter.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/ConditionalOnInitializedRestarter.java new file mode 100644 index 00000000000..96cfea1e51e --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/ConditionalOnInitializedRestarter.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.annotation.Conditional; + +/** + * {@link Conditional} that only matches when the {@link RestartInitializer} has been + * applied with non {@code null} URLs. + * + * @author Phillip Webb + * @since 1.3.0 + */ +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Conditional(OnInitializedRestarterCondition.class) +public @interface ConditionalOnInitializedRestarter { + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/DefaultRestartInitializer.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/DefaultRestartInitializer.java new file mode 100644 index 00000000000..0bd54b8b6d8 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/DefaultRestartInitializer.java @@ -0,0 +1,94 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart; + +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * Default {@link RestartInitializer} that only enable initial restart when running a + * standard "main" method. Skips initialization when running "fat" jars (included + * exploded) or when running from a test. + * + * @author Phillip Webb + * @since 1.3.0 + */ +public class DefaultRestartInitializer implements RestartInitializer { + + private static final Set SKIPPED_STACK_ELEMENTS; + static { + Set skipped = new LinkedHashSet(); + skipped.add("org.junit.runners."); + skipped.add("org.springframework.boot.test."); + SKIPPED_STACK_ELEMENTS = Collections.unmodifiableSet(skipped); + } + + @Override + public URL[] getInitialUrls(Thread thread) { + if (!isMain(thread)) { + return null; + } + for (StackTraceElement element : thread.getStackTrace()) { + if (isSkippedStackElement(element)) { + return null; + } + } + return getUrls(thread); + } + + /** + * Returns if the thread is for a main invocation. By default checks the name of the + * thread and the context classloader. + * @param thread the thread to check + * @return {@code true} if the thread is a main invocation + */ + protected boolean isMain(Thread thread) { + return thread.getName().equals("main") + && thread.getContextClassLoader().getClass().getName() + .contains("AppClassLoader"); + } + + /** + * Checks if a specific {@link StackTraceElement} should cause the initializer to be + * skipped. + * @param element the stack element to check + * @return {@code true} if the stack element means that the initializer should be + * skipped + */ + protected boolean isSkippedStackElement(StackTraceElement element) { + for (String skipped : SKIPPED_STACK_ELEMENTS) { + if (element.getClassName().startsWith(skipped)) { + return true; + } + } + return false; + } + + /** + * Return the URLs that should be used with initialization. + * @param thread the source thread + * @return the URLs + */ + protected URL[] getUrls(Thread thread) { + return ChangeableUrls.fromUrlClassLoader( + (URLClassLoader) thread.getContextClassLoader()).toArray(); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/MainMethod.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/MainMethod.java new file mode 100644 index 00000000000..825f844a6e2 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/MainMethod.java @@ -0,0 +1,84 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; + +import org.springframework.util.Assert; + +/** + * The "main" method located from a running thread. + * + * @author Phillip Webb + */ +class MainMethod { + + private final Method method; + + public MainMethod() { + this(Thread.currentThread()); + } + + public MainMethod(Thread thread) { + Assert.notNull(thread, "Thread must not be null"); + this.method = getMainMethod(thread); + } + + private Method getMainMethod(Thread thread) { + for (StackTraceElement element : thread.getStackTrace()) { + if ("main".equals(element.getMethodName())) { + Method method = getMainMethod(element); + if (method != null) { + return method; + } + } + } + throw new IllegalStateException("Unable to find main method"); + } + + private Method getMainMethod(StackTraceElement element) { + try { + Class elementClass = Class.forName(element.getClassName()); + Method method = elementClass.getDeclaredMethod("main", String[].class); + if (Modifier.isStatic(method.getModifiers())) { + return method; + } + } + catch (Exception ex) { + // Ignore + } + return null; + } + + /** + * Returns the actual main method. + * @return the main method + */ + public Method getMethod() { + return this.method; + } + + /** + * Return the name of the declaring class. + * @return the declaring class name + */ + public String getDeclaringClassName() { + return this.method.getDeclaringClass().getName(); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/OnInitializedRestarterCondition.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/OnInitializedRestarterCondition.java new file mode 100644 index 00000000000..28eb21bd9e2 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/OnInitializedRestarterCondition.java @@ -0,0 +1,55 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart; + +import org.springframework.boot.autoconfigure.condition.ConditionOutcome; +import org.springframework.boot.autoconfigure.condition.SpringBootCondition; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; + +/** + * {@link Condition} that checks that a {@link Restarter} is available an initialized. + * + * @author Phillip Webb + * @see ConditionalOnInitializedRestarter + */ +class OnInitializedRestarterCondition extends SpringBootCondition { + + @Override + public ConditionOutcome getMatchOutcome(ConditionContext context, + AnnotatedTypeMetadata metadata) { + Restarter restarter = getRestarter(); + if (restarter == null) { + return ConditionOutcome.noMatch("Restarter unavailable"); + } + if (restarter.getInitialUrls() == null) { + return ConditionOutcome.noMatch("Restarter initialized without URLs"); + } + return ConditionOutcome.match("Restarter available and initialized"); + } + + private Restarter getRestarter() { + try { + return Restarter.getInstance(); + } + catch (Exception ex) { + return null; + } + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartApplicationListener.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartApplicationListener.java new file mode 100644 index 00000000000..4bb0ebda399 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartApplicationListener.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart; + +import org.springframework.boot.context.event.ApplicationFailedEvent; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.core.Ordered; + +/** + * {@link ApplicationListener} to initialize the {@link Restarter}. + * + * @author Phillip Webb + * @since 1.3.0 + * @see Restarter + */ +public class RestartApplicationListener implements ApplicationListener, + Ordered { + + private int order = HIGHEST_PRECEDENCE; + + @Override + public void onApplicationEvent(ApplicationEvent event) { + if (event instanceof ApplicationStartedEvent) { + Restarter.initialize(((ApplicationStartedEvent) event).getArgs()); + } + if (event instanceof ApplicationReadyEvent + || event instanceof ApplicationFailedEvent) { + Restarter.getInstance().finish(); + } + } + + @Override + public int getOrder() { + return this.order; + } + + /** + * Set the order of the listener. + * @param order the order of the listener + */ + public void setOrder(int order) { + this.order = order; + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartInitializer.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartInitializer.java new file mode 100644 index 00000000000..36692dadd5c --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartInitializer.java @@ -0,0 +1,50 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart; + +import java.net.URL; + +/** + * Strategy interface used to initialize a {@link Restarter}. + * + * @author Phillip Webb + * @since 1.3.0 + * @see DefaultRestartInitializer + */ +public interface RestartInitializer { + + /** + * {@link RestartInitializer} that doesn't return any URLs. + */ + public static final RestartInitializer NONE = new RestartInitializer() { + + @Override + public URL[] getInitialUrls(Thread thread) { + return null; + } + + }; + + /** + * Return the initial set of URLs for the {@link Restarter} or {@code null} if no + * initial restart is required. + * @param thread the source thread + * @return initial URLs or {@code null} + */ + URL[] getInitialUrls(Thread thread); + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartLauncher.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartLauncher.java new file mode 100644 index 00000000000..7b5a9d7d239 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/RestartLauncher.java @@ -0,0 +1,54 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart; + +import java.lang.reflect.Method; + +/** + * Thread used to launch a restarted application. + * + * @author Phillip Webb + */ +class RestartLauncher extends Thread { + + private final String mainClassName; + + private final String[] args; + + public RestartLauncher(ClassLoader classLoader, String mainClassName, String[] args, + UncaughtExceptionHandler exceptionHandler) { + this.mainClassName = mainClassName; + this.args = args; + setName("restartedMain"); + setUncaughtExceptionHandler(exceptionHandler); + setDaemon(false); + setContextClassLoader(classLoader); + } + + @Override + public void run() { + try { + Class mainClass = getContextClassLoader().loadClass(this.mainClassName); + Method mainMethod = mainClass.getDeclaredMethod("main", String[].class); + mainMethod.invoke(null, new Object[] { this.args }); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/Restarter.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/Restarter.java new file mode 100644 index 00000000000..bdf57ee1725 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/Restarter.java @@ -0,0 +1,519 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart; + +import java.beans.Introspector; +import java.lang.Thread.UncaughtExceptionHandler; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.URL; +import java.util.Arrays; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.BlockingDeque; +import java.util.concurrent.Callable; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.CachedIntrospectionResults; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.developertools.restart.classloader.RestartClassLoader; +import org.springframework.boot.logging.DeferredLog; +import org.springframework.cglib.core.ClassNameReader; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; + +/** + * Allows a running application to be restarted with an updated classpath. The restarter + * works by creating a new application ClassLoader that is split into two parts. The top + * part contains static URLs that don't change (for example 3rd party libraries and Spring + * Boot itself) and the bottom part contains URLs where classes and resources might be + * updated. + *

+ * The Restarter should be {@link #initialize(String[]) initialized} early to ensure that + * classes are loaded multiple times. Mostly the {@link RestartApplicationListener} can be + * relied upon to perform initialization, however, you may need to call + * {@link #initialize(String[])} directly if your SpringApplication arguments are not + * identical to your main method arguments. + *

+ * By default, applications running in an IDE (i.e. those not packaged as "fat jars") will + * automatically detect URLs that can change. It's also possible to manually configure + * URLs or class file updates for remote restart scenarios. + * + * @author Phillip Webb + * @since 1.3.0 + * @see RestartApplicationListener + * @see #initialize(String[]) + * @see #getInstance() + * @see #restart() + */ +public class Restarter { + + private static Restarter instance; + + private Log logger = new DeferredLog(); + + private final boolean forceReferenceCleanup; + + private URL[] urls; + + private final String mainClassName; + + private final ClassLoader applicationClassLoader; + + private final String[] args; + + private final UncaughtExceptionHandler exceptionHandler; + + private final BlockingDeque leakSafeThreads = new LinkedBlockingDeque(); + + private boolean finished = false; + + private Lock stopLock = new ReentrantLock(); + + /** + * Internal constructor to create a new {@link Restarter} instance. + * @param thread the source thread + * @param args the application arguments + * @param forceReferenceCleanup if soft/weak reference cleanup should be forced + * @param initializer + * @see #initialize(String[]) + */ + protected Restarter(Thread thread, String[] args, boolean forceReferenceCleanup, + RestartInitializer initializer) { + Assert.notNull(thread, "Thread must not be null"); + Assert.notNull(args, "Args must not be null"); + Assert.notNull(initializer, "Initializer must not be null"); + this.logger.debug("Creating new Restarter for thread " + thread); + SilentExitExceptionHandler.setup(thread); + this.forceReferenceCleanup = forceReferenceCleanup; + this.urls = initializer.getInitialUrls(thread); + this.mainClassName = getMainClassName(thread); + this.applicationClassLoader = thread.getContextClassLoader(); + this.args = args; + this.exceptionHandler = thread.getUncaughtExceptionHandler(); + this.leakSafeThreads.add(new LeakSafeThread()); + } + + private String getMainClassName(Thread thread) { + try { + return new MainMethod(thread).getDeclaringClassName(); + } + catch (Exception ex) { + return null; + } + } + + protected void initialize(boolean restartOnInitialize) { + preInitializeLeakyClasses(); + if (this.urls != null) { + if (restartOnInitialize) { + this.logger.debug("Immediately restarting application"); + immediateRestart(); + } + } + } + + private void immediateRestart() { + try { + getLeakSafeThread().callAndWait(new Callable() { + + @Override + public Void call() throws Exception { + start(); + return null; + } + + }); + } + catch (Exception ex) { + this.logger.warn("Unable to initialize restarter", ex); + } + SilentExitExceptionHandler.exitCurrentThread(); + } + + /** + * CGLIB has a private exception field which needs to initialized early to ensure that + * the stacktrace doesn't retain a reference to the RestartClassLoader. + */ + private void preInitializeLeakyClasses() { + try { + Class readerClass = ClassNameReader.class; + Field field = readerClass.getDeclaredField("EARLY_EXIT"); + field.setAccessible(true); + ((Throwable) field.get(null)).fillInStackTrace(); + } + catch (Exception ex) { + this.logger.warn("Unable to pre-initialize classes", ex); + } + } + + /** + * Return a {@link ThreadFactory} that can be used to create leak safe threads. + * @return a leak safe thread factory + */ + public ThreadFactory getThreadFactory() { + return new LeakSafeThreadFactory(); + } + + /** + * Restart the running application. + */ + public void restart() { + this.logger.debug("Restarting application"); + getLeakSafeThread().call(new Callable() { + + @Override + public Void call() throws Exception { + Restarter.this.stop(); + Restarter.this.start(); + return null; + } + + }); + } + + /** + * Start the application. + * @throws Exception + */ + protected void start() throws Exception { + Assert.notNull(this.mainClassName, "Unable to find the main class to restart"); + ClassLoader parent = this.applicationClassLoader; + ClassLoader classLoader = new RestartClassLoader(parent, this.urls, this.logger); + if (this.logger.isDebugEnabled()) { + this.logger.debug("Starting application " + this.mainClassName + + " with URLs " + Arrays.asList(this.urls)); + } + relaunch(classLoader); + } + + /** + * Relaunch the application using the specified classloader. + * @param classLoader the classloader to use + * @throws Exception + */ + protected void relaunch(ClassLoader classLoader) throws Exception { + RestartLauncher launcher = new RestartLauncher(classLoader, this.mainClassName, + this.args, this.exceptionHandler); + launcher.start(); + launcher.join(); + } + + /** + * Stop the application. + * @throws Exception + */ + protected void stop() throws Exception { + this.logger.debug("Stopping application"); + this.stopLock.lock(); + try { + triggerShutdownHooks(); + cleanupCaches(); + if (this.forceReferenceCleanup) { + forceReferenceCleanup(); + } + } + finally { + this.stopLock.unlock(); + } + System.gc(); + System.runFinalization(); + } + + @SuppressWarnings("rawtypes") + private void triggerShutdownHooks() throws Exception { + Class hooksClass = Class.forName("java.lang.ApplicationShutdownHooks"); + Method runHooks = hooksClass.getDeclaredMethod("runHooks"); + runHooks.setAccessible(true); + runHooks.invoke(null); + Field field = hooksClass.getDeclaredField("hooks"); + field.setAccessible(true); + field.set(null, new IdentityHashMap()); + } + + private void cleanupCaches() throws Exception { + Introspector.flushCaches(); + cleanupKnownCaches(); + } + + private void cleanupKnownCaches() throws Exception { + // Whilst not strictly necessary it helps to cleanup soft reference caches + // early rather than waiting for memory limits to be reached + clear(ResolvableType.class, "cache"); + clear("org.springframework.core.SerializableTypeWrapper", "cache"); + clear(CachedIntrospectionResults.class, "acceptedClassLoaders"); + clear(CachedIntrospectionResults.class, "strongClassCache"); + clear(CachedIntrospectionResults.class, "softClassCache"); + clear(ReflectionUtils.class, "declaredFieldsCache"); + clear(ReflectionUtils.class, "declaredMethodsCache"); + clear(AnnotationUtils.class, "findAnnotationCache"); + clear(AnnotationUtils.class, "annotatedInterfaceCache"); + clear("com.sun.naming.internal.ResourceManager", "propertiesCache"); + } + + private void clear(String className, String fieldName) { + try { + clear(Class.forName(className), fieldName); + } + catch (Exception ex) { + this.logger.debug("Unable to clear field " + className + " " + fieldName, ex); + } + } + + private void clear(Class type, String fieldName) throws Exception { + Field field = type.getDeclaredField(fieldName); + field.setAccessible(true); + Object instance = field.get(null); + if (instance instanceof Set) { + ((Set) instance).clear(); + } + if (instance instanceof Map) { + Map map = ((Map) instance); + for (Iterator iterator = map.keySet().iterator(); iterator.hasNext();) { + Object value = iterator.next(); + if (value instanceof Class + && ((Class) value).getClassLoader() instanceof RestartClassLoader) { + iterator.remove(); + } + + } + } + } + + /** + * Cleanup any soft/weak references by forcing an {@link OutOfMemoryError} error. + */ + private void forceReferenceCleanup() { + try { + final List memory = new LinkedList(); + while (true) { + memory.add(new long[102400]); + } + } + catch (final OutOfMemoryError ex) { + } + } + + /** + * Called to finish {@link Restarter} initialization when application logging is + * available. + */ + synchronized void finish() { + if (!isFinished()) { + this.logger = DeferredLog.replay(this.logger, LogFactory.getLog(getClass())); + this.finished = true; + } + } + + boolean isFinished() { + return this.finished; + } + + private LeakSafeThread getLeakSafeThread() { + try { + return this.leakSafeThreads.takeFirst(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(ex); + } + } + + /** + * Return the initial set of URLs as configured by the {@link RestartInitializer}. + * @return the initial URLs or {@code null} + */ + public URL[] getInitialUrls() { + return this.urls; + } + + /** + * Initialize restart support. See + * {@link #initialize(String[], boolean, RestartInitializer)} for details. + * @param args main application arguments + * @see #initialize(String[], boolean, RestartInitializer) + */ + public static void initialize(String[] args) { + initialize(args, false, new DefaultRestartInitializer()); + } + + /** + * Initialize restart support. See + * {@link #initialize(String[], boolean, RestartInitializer)} for details. + * @param args main application arguments + * @param initializer the restart initializer + * @see #initialize(String[], boolean, RestartInitializer) + */ + public static void initialize(String[] args, RestartInitializer initializer) { + initialize(args, false, initializer, true); + } + + /** + * Initialize restart support. See + * {@link #initialize(String[], boolean, RestartInitializer)} for details. + * @param args main application arguments + * @param forceReferenceCleanup if forcing of soft/weak reference should happen on + * @see #initialize(String[], boolean, RestartInitializer) + */ + public static void initialize(String[] args, boolean forceReferenceCleanup) { + initialize(args, forceReferenceCleanup, new DefaultRestartInitializer()); + } + + /** + * Initialize restart support. See + * {@link #initialize(String[], boolean, RestartInitializer)} for details. + * @param args main application arguments + * @param forceReferenceCleanup if forcing of soft/weak reference should happen on + * @param initializer the restart initializer + * @see #initialize(String[], boolean, RestartInitializer) + */ + public static void initialize(String[] args, boolean forceReferenceCleanup, + RestartInitializer initializer) { + initialize(args, forceReferenceCleanup, initializer, true); + } + + /** + * Initialize restart support for the current application. Called automatically by + * {@link RestartApplicationListener} but can also be called directly if main + * application arguments are not the same as those passed to the + * {@link SpringApplication}. + * @param args main application arguments + * @param forceReferenceCleanup if forcing of soft/weak reference should happen on + * each restart. This will slow down restarts and is intended primarily for testing + * @param initializer the restart initializer + * @param restartOnInitialize if the restarter should be restarted immediately when + * the {@link RestartInitializer} returns non {@code null} results + */ + public static void initialize(String[] args, boolean forceReferenceCleanup, + RestartInitializer initializer, boolean restartOnInitialize) { + if (instance == null) { + synchronized (Restarter.class) { + instance = new Restarter(Thread.currentThread(), args, + forceReferenceCleanup, initializer); + } + instance.initialize(restartOnInitialize); + } + } + + /** + * Return the active {@link Restarter} instance. Cannot be called before + * {@link #initialize(String[]) initialization}. + * @return the restarter + */ + public synchronized static Restarter getInstance() { + Assert.state(instance != null, "Restarter has not been initialized"); + return instance; + } + + /** + * Set the restarter instance (useful for testing). + * @param instance the instance to set + */ + final static void setInstance(Restarter instance) { + Restarter.instance = instance; + } + + /** + * Clear the instance. Primarily provided for tests and not usually used in + * application code. + */ + public static void clearInstance() { + instance = null; + } + + /** + * Thread that is created early so not to retain the {@link RestartClassLoader}. + */ + private class LeakSafeThread extends Thread { + + private Callable callable; + + private Object result; + + public LeakSafeThread() { + setDaemon(false); + } + + public void call(Callable callable) { + this.callable = callable; + start(); + } + + @SuppressWarnings("unchecked") + public V callAndWait(Callable callable) { + this.callable = callable; + start(); + try { + join(); + return (V) this.result; + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(ex); + } + } + + @Override + public void run() { + // We are safe to refresh the ActionThread (and indirectly call + // AccessController.getContext()) since our stack doesn't include the + // RestartClassLoader + try { + Restarter.this.leakSafeThreads.put(new LeakSafeThread()); + this.result = this.callable.call(); + } + catch (Exception ex) { + ex.printStackTrace(); + System.exit(1); + } + } + + } + + /** + * {@link ThreadFactory} that creates a leak safe thead. + */ + private class LeakSafeThreadFactory implements ThreadFactory { + + @Override + public Thread newThread(final Runnable runnable) { + return getLeakSafeThread().callAndWait(new Callable() { + + @Override + public Thread call() throws Exception { + Thread thread = new Thread(runnable); + thread.setContextClassLoader(Restarter.this.applicationClassLoader); + return thread; + } + + }); + } + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/SilentExitExceptionHandler.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/SilentExitExceptionHandler.java new file mode 100644 index 00000000000..04bf3e70864 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/SilentExitExceptionHandler.java @@ -0,0 +1,60 @@ +/* + * Copyright 2012-2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart; + +import java.lang.Thread.UncaughtExceptionHandler; + +/** + * {@link UncaughtExceptionHandler} decorator that allows a thread to exit silently. + * + * @author Phillip Webb + */ +class SilentExitExceptionHandler implements UncaughtExceptionHandler { + + private final UncaughtExceptionHandler delegate; + + public SilentExitExceptionHandler(UncaughtExceptionHandler delegate) { + this.delegate = delegate; + } + + @Override + public void uncaughtException(Thread thread, Throwable exception) { + if (exception instanceof SilentExitException) { + return; + } + if (this.delegate != null) { + this.delegate.uncaughtException(thread, exception); + } + } + + public static void setup(Thread thread) { + UncaughtExceptionHandler handler = thread.getUncaughtExceptionHandler(); + if (!(handler instanceof SilentExitExceptionHandler)) { + handler = new SilentExitExceptionHandler(handler); + thread.setUncaughtExceptionHandler(handler); + } + } + + public static void exitCurrentThread() { + throw new SilentExitException(); + } + + private static class SilentExitException extends RuntimeException { + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/package-info.java new file mode 100644 index 00000000000..a2ae4bdaaf5 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Application restart support + */ +package org.springframework.boot.developertools.restart; + diff --git a/spring-boot-developer-tools/src/main/resources/META-INF/spring.factories b/spring-boot-developer-tools/src/main/resources/META-INF/spring.factories index f8bcd39934c..5dcd08d49d7 100644 --- a/spring-boot-developer-tools/src/main/resources/META-INF/spring.factories +++ b/spring-boot-developer-tools/src/main/resources/META-INF/spring.factories @@ -1,3 +1,7 @@ +# Application Listeners +org.springframework.context.ApplicationListener=\ +org.springframework.boot.developertools.restart.RestartApplicationListener + # Auto Configure org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ org.springframework.boot.developertools.autoconfigure.LocalDeveloperToolsAutoConfiguration diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfigurationTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfigurationTests.java index fecd38cb42b..a1dd6a68984 100644 --- a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfigurationTests.java +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfigurationTests.java @@ -26,6 +26,9 @@ import org.junit.Test; import org.junit.rules.ExpectedException; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration; +import org.springframework.boot.developertools.restart.MockRestartInitializer; +import org.springframework.boot.developertools.restart.MockRestarter; +import org.springframework.boot.developertools.restart.Restarter; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; @@ -45,6 +48,9 @@ public class LocalDeveloperToolsAutoConfigurationTests { @Rule public ExpectedException thrown = ExpectedException.none(); + @Rule + public MockRestarter mockRestarter = new MockRestarter(); + private int liveReloadPort = SocketUtils.findAvailableTcpPort(); private ConfigurableApplicationContext context; @@ -70,6 +76,7 @@ public class LocalDeveloperToolsAutoConfigurationTests { private ConfigurableApplicationContext initializeAndRun(Class config, Map properties) { + Restarter.initialize(new String[0], false, new MockRestartInitializer(), false); SpringApplication application = new SpringApplication(config); application.setDefaultProperties(getDefaultProperties(properties)); application.setWebEnvironment(false); diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/ChangeableUrlsTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/ChangeableUrlsTests.java new file mode 100644 index 00000000000..c29338e8370 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/ChangeableUrlsTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart; + +import java.io.File; +import java.io.IOException; +import java.net.URL; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link ChangeableUrls}. + * + * @author Phillip Webb + */ +public class ChangeableUrlsTests { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void folderUrl() throws Exception { + URL url = makeUrl("myproject"); + assertThat(ChangeableUrls.fromUrls(url).size(), equalTo(1)); + } + + @Test + public void fileUrl() throws Exception { + URL url = this.temporaryFolder.newFile().toURI().toURL(); + assertThat(ChangeableUrls.fromUrls(url).size(), equalTo(0)); + } + + @Test + public void httpUrl() throws Exception { + URL url = new URL("http://spring.io"); + assertThat(ChangeableUrls.fromUrls(url).size(), equalTo(0)); + } + + @Test + public void skipsUrls() throws Exception { + ChangeableUrls urls = ChangeableUrls + .fromUrls(makeUrl("spring-boot"), makeUrl("spring-boot-autoconfigure"), + makeUrl("spring-boot-actuator"), makeUrl("spring-boot-starter"), + makeUrl("spring-boot-starter-some-thing")); + assertThat(urls.size(), equalTo(0)); + } + + private URL makeUrl(String name) throws IOException { + File file = this.temporaryFolder.newFolder(); + file = new File(file, name); + file = new File(file, "target"); + file = new File(file, "classes"); + file.mkdirs(); + return file.toURI().toURL(); + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/DefaultRestartInitializerTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/DefaultRestartInitializerTests.java new file mode 100644 index 00000000000..c7b899e2c67 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/DefaultRestartInitializerTests.java @@ -0,0 +1,130 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart; + +import java.net.URL; + +import org.junit.Test; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link DefaultRestartInitializer}. + * + * @author Phillip Webb + */ +public class DefaultRestartInitializerTests { + + @Test + public void nullForTests() throws Exception { + MockRestartInitializer initializer = new MockRestartInitializer(true); + assertThat(initializer.getInitialUrls(Thread.currentThread()), nullValue()); + } + + @Test + public void validMainThread() throws Exception { + MockRestartInitializer initializer = new MockRestartInitializer(false); + ClassLoader classLoader = new MockAppClassLoader(getClass().getClassLoader()); + Thread thread = new Thread(); + thread.setName("main"); + thread.setContextClassLoader(classLoader); + assertThat(initializer.isMain(thread), equalTo(true)); + assertThat(initializer.getInitialUrls(thread), not(nullValue())); + } + + @Test + public void threadNotNamedMain() throws Exception { + MockRestartInitializer initializer = new MockRestartInitializer(false); + ClassLoader classLoader = new MockAppClassLoader(getClass().getClassLoader()); + Thread thread = new Thread(); + thread.setName("buscuit"); + thread.setContextClassLoader(classLoader); + assertThat(initializer.isMain(thread), equalTo(false)); + assertThat(initializer.getInitialUrls(thread), nullValue()); + } + + @Test + public void threadNotUsingAppClassLoader() throws Exception { + MockRestartInitializer initializer = new MockRestartInitializer(false); + ClassLoader classLoader = new MockLauncherClassLoader(getClass().getClassLoader()); + Thread thread = new Thread(); + thread.setName("main"); + thread.setContextClassLoader(classLoader); + assertThat(initializer.isMain(thread), equalTo(false)); + assertThat(initializer.getInitialUrls(thread), nullValue()); + } + + @Test + public void skipsDueToJUnitStacks() throws Exception { + testSkipStack("org.junit.runners.Something", true); + } + + @Test + public void skipsDueToSpringTest() throws Exception { + testSkipStack("org.springframework.boot.test.Something", true); + } + + private void testSkipStack(String className, boolean expected) { + MockRestartInitializer initializer = new MockRestartInitializer(true); + StackTraceElement element = new StackTraceElement(className, "someMethod", + "someFile", 123); + assertThat(initializer.isSkippedStackElement(element), equalTo(expected)); + } + + private static class MockAppClassLoader extends ClassLoader { + + public MockAppClassLoader(ClassLoader parent) { + super(parent); + } + + } + + private static class MockLauncherClassLoader extends ClassLoader { + + public MockLauncherClassLoader(ClassLoader parent) { + super(parent); + } + + } + + private static class MockRestartInitializer extends DefaultRestartInitializer { + + private final boolean considerStackElements; + + public MockRestartInitializer(boolean considerStackElements) { + this.considerStackElements = considerStackElements; + } + + @Override + protected boolean isSkippedStackElement(StackTraceElement element) { + if (!this.considerStackElements) { + return false; + } + return true; + } + + @Override + protected URL[] getUrls(Thread thread) { + return new URL[0]; + } + + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MainMethodTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MainMethodTests.java new file mode 100644 index 00000000000..6e203e6a626 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MainMethodTests.java @@ -0,0 +1,155 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart; + +import java.lang.reflect.Method; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.springframework.util.ReflectionUtils; + +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link MainMethod}. + * + * @author Phillip Webb + */ +public class MainMethodTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + private static ThreadLocal mainMethod = new ThreadLocal(); + + private Method actualMain; + + @Before + public void setup() throws Exception { + this.actualMain = Valid.class.getMethod("main", String[].class); + } + + @Test + public void threadMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Thread must not be null"); + new MainMethod(null); + } + + @Test + public void validMainMethod() throws Exception { + MainMethod method = new TestThread(new Runnable() { + @Override + public void run() { + Valid.main(); + } + }).test(); + assertThat(method.getMethod(), equalTo(this.actualMain)); + assertThat(method.getDeclaringClassName(), equalTo(this.actualMain + .getDeclaringClass().getName())); + } + + @Test + public void missingArgsMainMethod() throws Exception { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Unable to find main method"); + new TestThread(new Runnable() { + @Override + public void run() { + MissingArgs.main(); + } + }).test(); + } + + @Test + public void nonStatic() throws Exception { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Unable to find main method"); + new TestThread(new Runnable() { + @Override + public void run() { + new NonStaticMain().main(); + } + }).test(); + } + + private static class TestThread extends Thread { + + private final Runnable runnable; + + private Exception exception; + + private MainMethod mainMethod; + + public TestThread(Runnable runnable) { + this.runnable = runnable; + } + + public MainMethod test() throws InterruptedException { + start(); + join(); + if (this.exception != null) { + ReflectionUtils.rethrowRuntimeException(this.exception); + } + return this.mainMethod; + } + + @Override + public void run() { + try { + this.runnable.run(); + this.mainMethod = MainMethodTests.mainMethod.get(); + } + catch (Exception ex) { + this.exception = ex; + } + } + + } + + public static class Valid { + + public static void main(String... args) { + someOtherMethod(); + } + + private static void someOtherMethod() { + mainMethod.set(new MainMethod()); + } + + } + + public static class MissingArgs { + + public static void main() { + mainMethod.set(new MainMethod()); + } + + } + + private static class NonStaticMain { + + public void main(String... args) { + mainMethod.set(new MainMethod()); + } + + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MockRestartInitializer.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MockRestartInitializer.java new file mode 100644 index 00000000000..38a289804e3 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MockRestartInitializer.java @@ -0,0 +1,35 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart; + +import java.net.URL; + +import org.springframework.boot.developertools.restart.RestartInitializer; + +/** + * Simple mock {@link RestartInitializer} that returns an empty array of URLs. + * + * @author Phillip Webb + */ +public class MockRestartInitializer implements RestartInitializer { + + @Override + public URL[] getInitialUrls(Thread thread) { + return new URL[] {}; + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MockRestarter.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MockRestarter.java new file mode 100644 index 00000000000..b5034595086 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/MockRestarter.java @@ -0,0 +1,78 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart; + +import java.net.URL; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ThreadFactory; + +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Mocked version of {@link Restarter}. + * + * @author Phillip Webb + */ +public class MockRestarter implements TestRule { + + private Map attributes = new HashMap(); + + private Restarter mock = mock(Restarter.class); + + @Override + public Statement apply(final Statement base, Description description) { + return new Statement() { + + @Override + public void evaluate() throws Throwable { + setup(); + base.evaluate(); + cleanup(); + } + + }; + } + + private void setup() { + Restarter.setInstance(this.mock); + given(this.mock.getInitialUrls()).willReturn(new URL[] {}); + given(this.mock.getThreadFactory()).willReturn(new ThreadFactory() { + + @Override + public Thread newThread(Runnable r) { + return new Thread(r); + } + + }); + } + + private void cleanup() { + this.attributes.clear(); + Restarter.clearInstance(); + } + + public Restarter getMock() { + return this.mock; + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/OnInitializedRestarterConditionTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/OnInitializedRestarterConditionTests.java new file mode 100644 index 00000000000..fb3d4f12e9b --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/OnInitializedRestarterConditionTests.java @@ -0,0 +1,112 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart; + +import java.net.URL; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link OnInitializedRestarterCondition}. + * + * @author Phillip Webb + */ +public class OnInitializedRestarterConditionTests { + + private static Object wait = new Object(); + + @Before + @After + public void cleanup() { + Restarter.clearInstance(); + } + + @Test + public void noInstance() throws Exception { + Restarter.clearInstance(); + ConfigurableApplicationContext context = new AnnotationConfigApplicationContext( + Config.class); + assertThat(context.containsBean("bean"), equalTo(false)); + context.close(); + } + + @Test + public void noInitialization() throws Exception { + Restarter.initialize(new String[0], false, RestartInitializer.NONE); + ConfigurableApplicationContext context = new AnnotationConfigApplicationContext( + Config.class); + assertThat(context.containsBean("bean"), equalTo(false)); + context.close(); + } + + @Test + public void initialized() throws Exception { + Thread thread = new Thread() { + + @Override + public void run() { + TestInitialized.main(); + }; + + }; + thread.start(); + synchronized (wait) { + wait.wait(); + } + } + + public static class TestInitialized { + + public static void main(String... args) { + RestartInitializer initializer = mock(RestartInitializer.class); + given(initializer.getInitialUrls((Thread) any())).willReturn(new URL[0]); + Restarter.initialize(new String[0], false, initializer); + ConfigurableApplicationContext context = new AnnotationConfigApplicationContext( + Config.class); + assertThat(context.containsBean("bean"), equalTo(true)); + context.close(); + synchronized (wait) { + wait.notify(); + } + } + + } + + @Configuration + public static class Config { + + @Bean + @ConditionalOnInitializedRestarter + public String bean() { + return "bean"; + } + + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/RestartApplicationListenerTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/RestartApplicationListenerTests.java new file mode 100644 index 00000000000..ab91a120fed --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/RestartApplicationListenerTests.java @@ -0,0 +1,87 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.context.event.ApplicationFailedEvent; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.Ordered; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link RestartApplicationListener}. + * + * @author Phillip Webb + */ +public class RestartApplicationListenerTests { + + @Before + @After + public void cleanup() { + Restarter.clearInstance(); + } + + @Test + public void isHighestPriority() throws Exception { + assertThat(new RestartApplicationListener().getOrder(), + equalTo(Ordered.HIGHEST_PRECEDENCE)); + } + + @Test + public void initializeWithReady() throws Exception { + testInitialize(false); + } + + @Test + public void initializeWithFail() throws Exception { + testInitialize(true); + } + + private void testInitialize(boolean failed) { + Restarter.clearInstance(); + RestartApplicationListener listener = new RestartApplicationListener(); + SpringApplication application = new SpringApplication(); + ConfigurableApplicationContext context = mock(ConfigurableApplicationContext.class); + String[] args = new String[] { "a", "b", "c" }; + listener.onApplicationEvent(new ApplicationStartedEvent(application, args)); + assertThat(Restarter.getInstance(), not(nullValue())); + assertThat(Restarter.getInstance().isFinished(), equalTo(false)); + assertThat(ReflectionTestUtils.getField(Restarter.getInstance(), "args"), + equalTo((Object) args)); + if (failed) { + listener.onApplicationEvent(new ApplicationFailedEvent(application, args, + context, new RuntimeException())); + } + else { + listener.onApplicationEvent(new ApplicationReadyEvent(application, args, + context)); + } + assertThat(Restarter.getInstance().isFinished(), equalTo(true)); + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/RestarterTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/RestarterTests.java new file mode 100644 index 00000000000..a51c69908f0 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/RestarterTests.java @@ -0,0 +1,194 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart; + +import java.net.URL; +import java.net.URLClassLoader; +import java.util.concurrent.ThreadFactory; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.springframework.boot.test.OutputCapture; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.junit.Assert.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link Restarter}. + * + * @author Phillip Webb + */ +public class RestarterTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Rule + public OutputCapture out = new OutputCapture(); + + @Before + public void setup() { + Restarter.setInstance(new TestableRestarter()); + } + + @After + public void cleanup() { + Restarter.clearInstance(); + } + + @Test + public void cantGetInstanceBeforeInitialize() throws Exception { + Restarter.clearInstance(); + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Restarter has not been initialized"); + Restarter.getInstance(); + } + + @Test + public void testRestart() throws Exception { + Restarter.clearInstance(); + Thread thread = new Thread() { + + @Override + public void run() { + SampleApplication.main(); + }; + + }; + thread.start(); + Thread.sleep(1600); + String output = this.out.toString(); + assertThat(StringUtils.countOccurrencesOf(output, "Tick 0"), greaterThan(2)); + assertThat(StringUtils.countOccurrencesOf(output, "Tick 1"), greaterThan(2)); + } + + @Test + public void getThreadFactory() throws Exception { + final ClassLoader parentLoader = Thread.currentThread().getContextClassLoader(); + final ClassLoader contextClassLoader = new URLClassLoader(new URL[0]); + Thread thread = new Thread() { + @Override + public void run() { + Runnable runnable = mock(Runnable.class); + Thread regular = new Thread(); + ThreadFactory factory = Restarter.getInstance().getThreadFactory(); + Thread viaFactory = factory.newThread(runnable); + // Regular threads will inherit the current thread + assertThat(regular.getContextClassLoader(), equalTo(contextClassLoader)); + // Factory threads should should inherit from the initial thread + assertThat(viaFactory.getContextClassLoader(), equalTo(parentLoader)); + }; + }; + thread.setContextClassLoader(contextClassLoader); + thread.start(); + thread.join(); + } + + @Test + public void getInitialUrls() throws Exception { + Restarter.clearInstance(); + RestartInitializer initializer = mock(RestartInitializer.class); + URL[] urls = new URL[] { new URL("file:/proj/module-a.jar!/") }; + given(initializer.getInitialUrls(any(Thread.class))).willReturn(urls); + Restarter.initialize(new String[0], false, initializer, false); + assertThat(Restarter.getInstance().getInitialUrls(), equalTo(urls)); + } + + @Component + @EnableScheduling + public static class SampleApplication { + + private int count = 0; + + private static volatile boolean quit = false; + + @Scheduled(fixedDelay = 100) + public void tickBean() { + System.out.println("Tick " + this.count++ + " " + Thread.currentThread()); + } + + @Scheduled(initialDelay = 350, fixedDelay = 350) + public void restart() { + System.out.println("Restart " + Thread.currentThread()); + if (!SampleApplication.quit) { + Restarter.getInstance().restart(); + } + } + + public static void main(String... args) { + Restarter.initialize(args, false, new MockRestartInitializer()); + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext( + SampleApplication.class); + context.registerShutdownHook(); + System.out.println("Sleep " + Thread.currentThread()); + sleep(); + quit = true; + context.close(); + } + + private static void sleep() { + try { + Thread.sleep(1200); + } + catch (InterruptedException ex) { + } + } + + } + + private static class TestableRestarter extends Restarter { + + public TestableRestarter() { + this(Thread.currentThread(), new String[] {}, false, + new MockRestartInitializer()); + } + + protected TestableRestarter(Thread thread, String[] args, + boolean forceReferenceCleanup, RestartInitializer initializer) { + super(thread, args, forceReferenceCleanup, initializer); + } + + @Override + public void restart() { + try { + stop(); + start(); + } + catch (Exception ex) { + throw new IllegalStateException(ex); + } + } + + @Override + protected void stop() throws Exception { + } + + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/SilentExitExceptionHandlerTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/SilentExitExceptionHandlerTests.java new file mode 100644 index 00000000000..7ee703ab886 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/SilentExitExceptionHandlerTests.java @@ -0,0 +1,84 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.restart; + +import org.junit.Test; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.fail; + +/** + * Tests for {@link SilentExitExceptionHandler}. + * + * @author Phillip Webb + */ +public class SilentExitExceptionHandlerTests { + + @Test + public void setupAndExit() throws Exception { + TestThread testThread = new TestThread() { + @Override + public void run() { + SilentExitExceptionHandler.exitCurrentThread(); + fail("Didn't exit"); + } + }; + SilentExitExceptionHandler.setup(testThread); + testThread.startAndJoin(); + assertThat(testThread.getThrown(), nullValue()); + } + + @Test + public void doesntInterferWithOtherExceptions() throws Exception { + TestThread testThread = new TestThread() { + @Override + public void run() { + throw new IllegalStateException("Expected"); + } + }; + SilentExitExceptionHandler.setup(testThread); + testThread.startAndJoin(); + assertThat(testThread.getThrown().getMessage(), equalTo("Expected")); + } + + private static abstract class TestThread extends Thread { + + private Throwable thrown; + + public TestThread() { + setUncaughtExceptionHandler(new UncaughtExceptionHandler() { + @Override + public void uncaughtException(Thread t, Throwable e) { + TestThread.this.thrown = e; + } + }); + } + + public Throwable getThrown() { + return this.thrown; + } + + public void startAndJoin() throws InterruptedException { + start(); + join(); + } + + } + +} From 3d8db7cddb49ad6ca100ff46c1c5b883485c4342 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 1 Jun 2015 13:23:05 -0700 Subject: [PATCH 06/20] Add Restart auto-configuration Add auto-configuration for application Restarts. Restarts are enabled by default (when not running from a fat jar) and will be triggered when any classpath folder changes. The ClassPathRestartStrategy additional customization of when a full restart is required. By default a PatternClassPathRestartStrategy with patterns loaded from DeveloperToolsProperties. Closes gh-3084 --- .../DeveloperToolsProperties.java | 71 +++++++++ .../LocalDeveloperToolsAutoConfiguration.java | 48 +++++++ .../classpath/ClassPathChangedEvent.java | 68 +++++++++ .../ClassPathFileChangeListener.java | 73 ++++++++++ .../classpath/ClassPathFileSystemWatcher.java | 122 ++++++++++++++++ .../classpath/ClassPathRestartStrategy.java | 39 +++++ .../PatternClassPathRestartStrategy.java | 51 +++++++ .../classpath/package-info.java | 21 +++ ...lDeveloperToolsAutoConfigurationTests.java | 42 ++++++ .../classpath/ClassPathChangedEventTests.java | 68 +++++++++ .../ClassPathFileChangeListenerTests.java | 109 ++++++++++++++ .../ClassPathFileSystemWatcherTests.java | 136 ++++++++++++++++++ .../PatternClassPathRestartStrategyTests.java | 80 +++++++++++ 13 files changed, 928 insertions(+) create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/DeveloperToolsProperties.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathChangedEvent.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathFileChangeListener.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathFileSystemWatcher.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathRestartStrategy.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/PatternClassPathRestartStrategy.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/package-info.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/classpath/ClassPathChangedEventTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/classpath/ClassPathFileChangeListenerTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/classpath/ClassPathFileSystemWatcherTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/classpath/PatternClassPathRestartStrategyTests.java diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/DeveloperToolsProperties.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/DeveloperToolsProperties.java new file mode 100644 index 00000000000..5356bd0685c --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/DeveloperToolsProperties.java @@ -0,0 +1,71 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.autoconfigure; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for developer tools. + * + * @author Phillip Webb + * @since 1.3.0 + */ +@ConfigurationProperties(prefix = "spring.developertools") +public class DeveloperToolsProperties { + + private static final String DEFAULT_RESTART_EXCLUDES = "META-INF/resources/**,resource/**,static/**,public/**,templates/**"; + + private Restart restart = new Restart(); + + public Restart getRestart() { + return this.restart; + } + + /** + * Restart properties + */ + public static class Restart { + + /** + * Enable automatic restart. + */ + private boolean enabled = true; + + /** + * Patterns that should be excluding for triggering a full restart. + */ + private String exclude = DEFAULT_RESTART_EXCLUDES; + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getExclude() { + return this.exclude; + } + + public void setExclude(String exclude) { + this.exclude = exclude; + } + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfiguration.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfiguration.java index 3c2943e3346..f83bf08cb3b 100644 --- a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfiguration.java +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfiguration.java @@ -16,10 +16,22 @@ package org.springframework.boot.developertools.autoconfigure; +import java.net.URL; + +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.developertools.classpath.ClassPathChangedEvent; +import org.springframework.boot.developertools.classpath.ClassPathFileSystemWatcher; +import org.springframework.boot.developertools.classpath.ClassPathRestartStrategy; +import org.springframework.boot.developertools.classpath.PatternClassPathRestartStrategy; import org.springframework.boot.developertools.restart.ConditionalOnInitializedRestarter; +import org.springframework.boot.developertools.restart.Restarter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListener; /** * {@link EnableAutoConfiguration Auto-configuration} for local development support. @@ -29,11 +41,47 @@ import org.springframework.context.annotation.Configuration; */ @Configuration @ConditionalOnInitializedRestarter +@EnableConfigurationProperties(DeveloperToolsProperties.class) public class LocalDeveloperToolsAutoConfiguration { + @Autowired + private DeveloperToolsProperties properties; + @Bean public static LocalDeveloperPropertyDefaultsPostProcessor localDeveloperPropertyDefaultsPostProcessor() { return new LocalDeveloperPropertyDefaultsPostProcessor(); } + /** + * Local Restart Configuration. + */ + @ConditionalOnProperty(prefix = "spring.developertools.restart", name = "enabled", matchIfMissing = true) + static class RestartConfiguration { + + @Autowired + private DeveloperToolsProperties properties; + + @Bean + @ConditionalOnMissingBean + public ClassPathFileSystemWatcher classPathFileSystemWatcher() { + URL[] urls = Restarter.getInstance().getInitialUrls(); + return new ClassPathFileSystemWatcher(classPathRestartStrategy(), urls); + } + + @Bean + @ConditionalOnMissingBean + public ClassPathRestartStrategy classPathRestartStrategy() { + return new PatternClassPathRestartStrategy(this.properties.getRestart() + .getExclude()); + } + + @EventListener + public void onClassPathChanged(ClassPathChangedEvent event) { + if (event.isRestartRequired()) { + Restarter.getInstance().restart(); + } + } + + } + } diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathChangedEvent.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathChangedEvent.java new file mode 100644 index 00000000000..ddd70040920 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathChangedEvent.java @@ -0,0 +1,68 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.classpath; + +import java.util.Set; + +import org.springframework.boot.developertools.filewatch.ChangedFiles; +import org.springframework.context.ApplicationEvent; +import org.springframework.util.Assert; + +/** + * {@link ApplicationEvent} containing details of a classpath change. + * + * @author Phillip Webb + * @since 1.3.0 + * @see ClassPathFileChangeListener + */ +public class ClassPathChangedEvent extends ApplicationEvent { + + private final Set changeSet; + + private final boolean restartRequired; + + /** + * Create a new {@link ClassPathChangedEvent}. + * @param source the source of the event + * @param changeSet the changed files + * @param restartRequired if a restart is required due to the change + */ + public ClassPathChangedEvent(Object source, Set changeSet, + boolean restartRequired) { + super(source); + Assert.notNull(changeSet, "ChangeSet must not be null"); + this.changeSet = changeSet; + this.restartRequired = restartRequired; + } + + /** + * Return details of the files that changed. + * @return the changed files + */ + public Set getChangeSet() { + return this.changeSet; + } + + /** + * Return if an application restart is required due to the change. + * @return if an application restart is required + */ + public boolean isRestartRequired() { + return this.restartRequired; + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathFileChangeListener.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathFileChangeListener.java new file mode 100644 index 00000000000..13a231c47c9 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathFileChangeListener.java @@ -0,0 +1,73 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.classpath; + +import java.util.Set; + +import org.springframework.boot.developertools.filewatch.ChangedFile; +import org.springframework.boot.developertools.filewatch.ChangedFiles; +import org.springframework.boot.developertools.filewatch.FileChangeListener; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.util.Assert; + +/** + * A {@link FileChangeListener} to publish {@link ClassPathChangedEvent + * ClassPathChangedEvents}. + * + * @author Phillip Webb + * @since 1.3.0 + * @see ClassPathFileSystemWatcher + */ +public class ClassPathFileChangeListener implements FileChangeListener { + + private final ApplicationEventPublisher eventPublisher; + + private final ClassPathRestartStrategy restartStrategy; + + /** + * Create a new {@link ClassPathFileChangeListener} instance. + * @param eventPublisher the event publisher used send events + * @param restartStrategy the restart strategy to use + */ + public ClassPathFileChangeListener(ApplicationEventPublisher eventPublisher, + ClassPathRestartStrategy restartStrategy) { + Assert.notNull(eventPublisher, "EventPublisher must not be null"); + Assert.notNull(restartStrategy, "RestartStrategy must not be null"); + this.eventPublisher = eventPublisher; + this.restartStrategy = restartStrategy; + } + + @Override + public void onChange(Set changeSet) { + boolean restart = isRestartRequired(changeSet); + ApplicationEvent event = new ClassPathChangedEvent(this, changeSet, restart); + this.eventPublisher.publishEvent(event); + } + + private boolean isRestartRequired(Set changeSet) { + for (ChangedFiles changedFiles : changeSet) { + for (ChangedFile changedFile : changedFiles) { + if (this.restartStrategy.isRestartRequired(changedFile)) { + return true; + } + } + } + return false; + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathFileSystemWatcher.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathFileSystemWatcher.java new file mode 100644 index 00000000000..826373fa700 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathFileSystemWatcher.java @@ -0,0 +1,122 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.classpath; + +import java.net.URL; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.developertools.filewatch.FileSystemWatcher; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.util.Assert; +import org.springframework.util.ResourceUtils; + +/** + * Encapsulates a {@link FileSystemWatcher} to watch the local classpath folders for + * changes. + * + * @author Phillip Webb + * @since 1.3.0 + * @see ClassPathFileChangeListener + */ +public class ClassPathFileSystemWatcher implements InitializingBean, DisposableBean, + ApplicationContextAware { + + private static final Log logger = LogFactory.getLog(ClassPathFileSystemWatcher.class); + + private final FileSystemWatcher fileSystemWatcher; + + private ClassPathRestartStrategy restartStrategy; + + private ApplicationContext applicationContext; + + /** + * Create a new {@link ClassPathFileSystemWatcher} instance. + * @param urls the classpath URLs to watch + */ + public ClassPathFileSystemWatcher(URL[] urls) { + this(new FileSystemWatcher(), null, urls); + } + + /** + * Create a new {@link ClassPathFileSystemWatcher} instance. + * @param restartStrategy the classpath restart strategy + * @param urls the URLs to watch + */ + public ClassPathFileSystemWatcher(ClassPathRestartStrategy restartStrategy, URL[] urls) { + this(new FileSystemWatcher(), restartStrategy, urls); + } + + /** + * Create a new {@link ClassPathFileSystemWatcher} instance. + * @param fileSystemWatcher the underlying {@link FileSystemWatcher} used to monitor + * the local file system + * @param restartStrategy the classpath restart strategy + * @param urls the URLs to watch + */ + protected ClassPathFileSystemWatcher(FileSystemWatcher fileSystemWatcher, + ClassPathRestartStrategy restartStrategy, URL[] urls) { + Assert.notNull(fileSystemWatcher, "FileSystemWatcher must not be null"); + Assert.notNull(urls, "Urls must not be null"); + this.fileSystemWatcher = new FileSystemWatcher(); + this.restartStrategy = restartStrategy; + addUrls(urls); + } + + private void addUrls(URL[] urls) { + for (URL url : urls) { + addUrl(url); + } + } + + private void addUrl(URL url) { + if (url.getProtocol().equals("file") && url.getPath().endsWith("/")) { + try { + this.fileSystemWatcher.addSourceFolder(ResourceUtils.getFile(url)); + } + catch (Exception ex) { + logger.warn("Unable to watch classpath URL " + url); + logger.trace("Unable to watch classpath URL " + url, ex); + } + } + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) + throws BeansException { + this.applicationContext = applicationContext; + } + + @Override + public void afterPropertiesSet() throws Exception { + if (this.restartStrategy != null) { + this.fileSystemWatcher.addListener(new ClassPathFileChangeListener( + this.applicationContext, this.restartStrategy)); + } + this.fileSystemWatcher.start(); + } + + @Override + public void destroy() throws Exception { + this.fileSystemWatcher.stop(); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathRestartStrategy.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathRestartStrategy.java new file mode 100644 index 00000000000..0e5644f3e81 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/ClassPathRestartStrategy.java @@ -0,0 +1,39 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.classpath; + +import org.springframework.boot.developertools.filewatch.ChangedFile; + +/** + * Strategy interface used to determine when a changed classpath file should trigger a + * full application restart. For example, static web resources might not require a full + * restart where as class files would. + * + * @author Phillip Webb + * @since 1.3.0 + * @see PatternClassPathRestartStrategy + */ +public interface ClassPathRestartStrategy { + + /** + * Return true if a full restart is required. + * @param file the changed file + * @return {@code true} if a full restart is required + */ + boolean isRestartRequired(ChangedFile file); + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/PatternClassPathRestartStrategy.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/PatternClassPathRestartStrategy.java new file mode 100644 index 00000000000..65af3b0f3f1 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/PatternClassPathRestartStrategy.java @@ -0,0 +1,51 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.classpath; + +import org.springframework.boot.developertools.filewatch.ChangedFile; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.StringUtils; + +/** + * Ant style pattern based {@link ClassPathRestartStrategy}. + * + * @author Phillip Webb + * @since 1.3.0 + * @see ClassPathRestartStrategy + */ +public class PatternClassPathRestartStrategy implements ClassPathRestartStrategy { + + private final AntPathMatcher matcher = new AntPathMatcher(); + + private final String[] excludePatterns; + + public PatternClassPathRestartStrategy(String excludePatterns) { + this.excludePatterns = StringUtils + .commaDelimitedListToStringArray(excludePatterns); + } + + @Override + public boolean isRestartRequired(ChangedFile file) { + for (String pattern : this.excludePatterns) { + if (this.matcher.match(pattern, file.getRelativeName())) { + return false; + } + } + return true; + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/package-info.java new file mode 100644 index 00000000000..4ce5e42b61f --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/classpath/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Support for classpath monitoring + */ +package org.springframework.boot.developertools.classpath; + diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfigurationTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfigurationTests.java index a1dd6a68984..86edfbab8eb 100644 --- a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfigurationTests.java +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/autoconfigure/LocalDeveloperToolsAutoConfigurationTests.java @@ -24,8 +24,12 @@ import org.junit.After; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration; +import org.springframework.boot.developertools.classpath.ClassPathChangedEvent; +import org.springframework.boot.developertools.classpath.ClassPathFileSystemWatcher; +import org.springframework.boot.developertools.filewatch.ChangedFiles; import org.springframework.boot.developertools.restart.MockRestartInitializer; import org.springframework.boot.developertools.restart.MockRestarter; import org.springframework.boot.developertools.restart.Restarter; @@ -36,7 +40,10 @@ import org.springframework.util.SocketUtils; import org.thymeleaf.templateresolver.TemplateResolver; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; /** * Tests for {@link LocalDeveloperToolsAutoConfiguration}. @@ -70,6 +77,41 @@ public class LocalDeveloperToolsAutoConfigurationTests { assertThat(resolver.isCacheable(), equalTo(false)); } + @Test + public void restartTriggerdOnClassPathChangeWithRestart() throws Exception { + this.context = initializeAndRun(Config.class); + ClassPathChangedEvent event = new ClassPathChangedEvent(this.context, + Collections. emptySet(), true); + this.context.publishEvent(event); + verify(this.mockRestarter.getMock()).restart(); + } + + @Test + public void restartNotTriggerdOnClassPathChangeWithRestart() throws Exception { + this.context = initializeAndRun(Config.class); + ClassPathChangedEvent event = new ClassPathChangedEvent(this.context, + Collections. emptySet(), false); + this.context.publishEvent(event); + verify(this.mockRestarter.getMock(), never()).restart(); + } + + @Test + public void restartWatchingClassPath() throws Exception { + this.context = initializeAndRun(Config.class); + ClassPathFileSystemWatcher watcher = this.context + .getBean(ClassPathFileSystemWatcher.class); + assertThat(watcher, notNullValue()); + } + + @Test + public void restartDisabled() throws Exception { + Map properties = new HashMap(); + properties.put("spring.developertools.restart.enabled", false); + this.context = initializeAndRun(Config.class, properties); + this.thrown.expect(NoSuchBeanDefinitionException.class); + this.context.getBean(ClassPathFileSystemWatcher.class); + } + private ConfigurableApplicationContext initializeAndRun(Class config) { return initializeAndRun(config, Collections. emptyMap()); } diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/classpath/ClassPathChangedEventTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/classpath/ClassPathChangedEventTests.java new file mode 100644 index 00000000000..6d50383dd50 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/classpath/ClassPathChangedEventTests.java @@ -0,0 +1,68 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.classpath; + +import java.util.LinkedHashSet; +import java.util.Set; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.springframework.boot.developertools.filewatch.ChangedFiles; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.sameInstance; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link ClassPathChangedEvent}. + * + * @author Phillip Webb + */ +public class ClassPathChangedEventTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + private Object source = new Object(); + + @Test + public void changeSetMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("ChangeSet must not be null"); + new ClassPathChangedEvent(this.source, null, false); + } + + @Test + public void getChangeSet() throws Exception { + Set changeSet = new LinkedHashSet(); + ClassPathChangedEvent event = new ClassPathChangedEvent(this.source, changeSet, + false); + assertThat(event.getChangeSet(), sameInstance(changeSet)); + } + + @Test + public void getRestartRequired() throws Exception { + Set changeSet = new LinkedHashSet(); + ClassPathChangedEvent event; + event = new ClassPathChangedEvent(this.source, changeSet, false); + assertThat(event.isRestartRequired(), equalTo(false)); + event = new ClassPathChangedEvent(this.source, changeSet, true); + assertThat(event.isRestartRequired(), equalTo(true)); + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/classpath/ClassPathFileChangeListenerTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/classpath/ClassPathFileChangeListenerTests.java new file mode 100644 index 00000000000..445a7900f66 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/classpath/ClassPathFileChangeListenerTests.java @@ -0,0 +1,109 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.classpath; + +import java.io.File; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.MockitoAnnotations; +import org.springframework.boot.developertools.filewatch.ChangedFile; +import org.springframework.boot.developertools.filewatch.ChangedFiles; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; + +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link ClassPathFileChangeListener}. + * + * @author Phillip Webb + */ +public class ClassPathFileChangeListenerTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Captor + private ArgumentCaptor eventCaptor; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + } + + @Test + public void eventPublisherMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("EventPublisher must not be null"); + new ClassPathFileChangeListener(null, mock(ClassPathRestartStrategy.class)); + } + + @Test + public void restartStrategyMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("RestartStrategy must not be null"); + new ClassPathFileChangeListener(mock(ApplicationEventPublisher.class), null); + } + + @Test + public void sendsEventWithoutRestart() throws Exception { + testSendsEvent(false); + } + + @Test + public void sendsEventWithRestart() throws Exception { + testSendsEvent(true); + } + + private void testSendsEvent(boolean restart) { + ApplicationEventPublisher eventPublisher = mock(ApplicationEventPublisher.class); + ClassPathRestartStrategy restartStrategy = mock(ClassPathRestartStrategy.class); + ClassPathFileChangeListener listener = new ClassPathFileChangeListener( + eventPublisher, restartStrategy); + File folder = new File("s1"); + File file = new File("f1"); + ChangedFile file1 = new ChangedFile(folder, file, ChangedFile.Type.ADD); + ChangedFile file2 = new ChangedFile(folder, file, ChangedFile.Type.ADD); + Set files = new LinkedHashSet(); + files.add(file1); + files.add(file2); + ChangedFiles changedFiles = new ChangedFiles(new File("source"), files); + Set changeSet = Collections.singleton(changedFiles); + if (restart) { + given(restartStrategy.isRestartRequired(file2)).willReturn(true); + } + listener.onChange(changeSet); + verify(eventPublisher).publishEvent(this.eventCaptor.capture()); + ClassPathChangedEvent actualEvent = (ClassPathChangedEvent) this.eventCaptor + .getValue(); + assertThat(actualEvent.getChangeSet(), equalTo(changeSet)); + assertThat(actualEvent.isRestartRequired(), equalTo(restart)); + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/classpath/ClassPathFileSystemWatcherTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/classpath/ClassPathFileSystemWatcherTests.java new file mode 100644 index 00000000000..1c2066563e0 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/classpath/ClassPathFileSystemWatcherTests.java @@ -0,0 +1,136 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.classpath; + +import java.io.File; +import java.net.URL; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.TemporaryFolder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.developertools.filewatch.ChangedFile; +import org.springframework.boot.developertools.filewatch.FileSystemWatcher; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.core.env.MapPropertySource; +import org.springframework.util.FileCopyUtils; + +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link ClassPathFileSystemWatcher}. + * + * @author Phillip Webb + */ +public class ClassPathFileSystemWatcherTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Rule + public TemporaryFolder temp = new TemporaryFolder(); + + @Test + public void urlsMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Urls must not be null"); + URL[] urls = null; + new ClassPathFileSystemWatcher(urls); + } + + @Test + public void configuredWithRestartStrategy() throws Exception { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + Map properties = new HashMap(); + File folder = this.temp.newFolder(); + List urls = new ArrayList(); + urls.add(new URL("http://spring.io")); + urls.add(folder.toURI().toURL()); + properties.put("urls", urls); + MapPropertySource propertySource = new MapPropertySource("test", properties); + context.getEnvironment().getPropertySources().addLast(propertySource); + context.register(Config.class); + context.refresh(); + Thread.sleep(100); + File classFile = new File(folder, "Example.class"); + FileCopyUtils.copy("file".getBytes(), classFile); + Thread.sleep(1100); + List events = context.getBean(Listener.class).getEvents(); + assertThat(events.size(), equalTo(1)); + assertThat(events.get(0).getChangeSet().iterator().next().getFiles().iterator() + .next().getFile(), equalTo(classFile)); + context.close(); + } + + @Configuration + public static class Config { + + @Autowired + public Environment environemnt; + + @Bean + public ClassPathFileSystemWatcher watcher() { + FileSystemWatcher watcher = new FileSystemWatcher(false, 100, 10); + URL[] urls = this.environemnt.getProperty("urls", URL[].class); + return new ClassPathFileSystemWatcher(watcher, restartStrategy(), urls); + } + + @Bean + public ClassPathRestartStrategy restartStrategy() { + return new ClassPathRestartStrategy() { + + @Override + public boolean isRestartRequired(ChangedFile file) { + return false; + } + + }; + } + + @Bean + public Listener listener() { + return new Listener(); + } + + } + + public static class Listener implements ApplicationListener { + + private List events = new ArrayList(); + + @Override + public void onApplicationEvent(ClassPathChangedEvent event) { + this.events.add(event); + } + + public List getEvents() { + return this.events; + } + + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/classpath/PatternClassPathRestartStrategyTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/classpath/PatternClassPathRestartStrategyTests.java new file mode 100644 index 00000000000..53dd7a55400 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/classpath/PatternClassPathRestartStrategyTests.java @@ -0,0 +1,80 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.classpath; + +import java.io.File; + +import org.junit.Test; +import org.springframework.boot.developertools.filewatch.ChangedFile; +import org.springframework.boot.developertools.filewatch.ChangedFile.Type; + +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link PatternClassPathRestartStrategy}. + * + * @author Phillip Webb + */ +public class PatternClassPathRestartStrategyTests { + + @Test + public void nullPattern() throws Exception { + ClassPathRestartStrategy strategy = createStrategy(null); + assertRestartRequired(strategy, "a/b.txt", true); + } + + @Test + public void emptyPattern() throws Exception { + ClassPathRestartStrategy strategy = createStrategy(""); + assertRestartRequired(strategy, "a/b.txt", true); + } + + @Test + public void singlePattern() throws Exception { + ClassPathRestartStrategy strategy = createStrategy("static/**"); + assertRestartRequired(strategy, "static/file.txt", false); + assertRestartRequired(strategy, "static/folder/file.txt", false); + assertRestartRequired(strategy, "public/file.txt", true); + assertRestartRequired(strategy, "public/folder/file.txt", true); + } + + @Test + public void multiplePatterns() throws Exception { + ClassPathRestartStrategy strategy = createStrategy("static/**,public/**"); + assertRestartRequired(strategy, "static/file.txt", false); + assertRestartRequired(strategy, "static/folder/file.txt", false); + assertRestartRequired(strategy, "public/file.txt", false); + assertRestartRequired(strategy, "public/folder/file.txt", false); + assertRestartRequired(strategy, "src/file.txt", true); + assertRestartRequired(strategy, "src/folder/file.txt", true); + } + + private ClassPathRestartStrategy createStrategy(String pattern) { + return new PatternClassPathRestartStrategy(pattern); + } + + private void assertRestartRequired(ClassPathRestartStrategy strategy, + String relativeName, boolean expected) { + assertThat(strategy.isRestartRequired(mockFile(relativeName)), equalTo(expected)); + } + + private ChangedFile mockFile(String relativeName) { + return new ChangedFile(new File("."), new File("./" + relativeName), Type.ADD); + } + +} From f09134180e5ded63ad4cd39a55f9c27b7b662166 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 1 Jun 2015 13:23:11 -0700 Subject: [PATCH 07/20] Add a minimal livereload server implementation Add a minimal server to support livereload.com browser plugins. Includes a partial websocket implementation to save needing a dependency to spring-websocket. See gh-3085 --- .../livereload/Base64Encoder.java | 62 + .../developertools/livereload/Connection.java | 162 +++ .../livereload/ConnectionClosedException.java | 32 + .../livereload/ConnectionInputStream.java | 102 ++ .../livereload/ConnectionOutputStream.java | 59 + .../boot/developertools/livereload/Frame.java | 159 +++ .../livereload/LiveReloadServer.java | 322 +++++ .../livereload/package-info.java | 21 + .../developertools/livereload/livereload.js | 1055 +++++++++++++++++ .../livereload/Base64EncoderTests.java | 53 + .../ConnectionInputStreamTests.java | 103 ++ .../ConnectionOutputStreamTests.java | 73 ++ .../developertools/livereload/FrameTests.java | 188 +++ .../livereload/LiveReloadServerTests.java | 262 ++++ 14 files changed, 2653 insertions(+) create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Base64Encoder.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Connection.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionClosedException.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionInputStream.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionOutputStream.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Frame.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/LiveReloadServer.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/package-info.java create mode 100644 spring-boot-developer-tools/src/main/resources/org/springframework/boot/developertools/livereload/livereload.js create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/livereload/Base64EncoderTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/livereload/ConnectionInputStreamTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/livereload/ConnectionOutputStreamTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/livereload/FrameTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/livereload/LiveReloadServerTests.java diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Base64Encoder.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Base64Encoder.java new file mode 100644 index 00000000000..4453d01352b --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Base64Encoder.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.livereload; + +import java.nio.charset.Charset; + +/** + * Simple Base64 Encoder. + * + * @author Phillip Webb + */ +class Base64Encoder { + + private static final Charset UTF_8 = Charset.forName("UTF-8"); + + private static final String ALPHABET_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "abcdefghijklmnopqrstuvwxyz0123456789+/"; + + static final byte[] ALPHABET = ALPHABET_CHARS.getBytes(UTF_8); + + private static final byte EQUALS_SIGN = '='; + + public static String encode(String string) { + return encode(string.getBytes(UTF_8)); + } + + public static String encode(byte[] bytes) { + byte[] encoded = new byte[bytes.length / 3 * 4 + (bytes.length % 3 == 0 ? 0 : 4)]; + for (int i = 0; i < encoded.length; i += 3) { + encodeBlock(bytes, i, Math.min((bytes.length - i), 3), encoded, i / 3 * 4); + } + return new String(encoded, UTF_8); + } + + private static void encodeBlock(byte[] src, int srcPos, int blockLen, byte[] dest, + int destPos) { + if (blockLen > 0) { + int inBuff = (blockLen > 0 ? ((src[srcPos] << 24) >>> 8) : 0) + | (blockLen > 1 ? ((src[srcPos + 1] << 24) >>> 16) : 0) + | (blockLen > 2 ? ((src[srcPos + 2] << 24) >>> 24) : 0); + for (int i = 0; i < 4; i++) { + dest[destPos + i] = (i > blockLen ? EQUALS_SIGN + : ALPHABET[(inBuff >>> (6 * (3 - i))) & 0x3f]); + } + } + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Connection.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Connection.java new file mode 100644 index 00000000000..04c36df8bdf --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Connection.java @@ -0,0 +1,162 @@ +/* + * Copyright 2012-2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.livereload; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +/** + * A {@link LiveReloadServer} connection. + */ +class Connection { + + private static Log logger = LogFactory.getLog(Connection.class); + + private static final Pattern WEBSOCKET_KEY_PATTERN = Pattern.compile( + "^Sec-WebSocket-Key:(.*)$", Pattern.MULTILINE); + + public final static String WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + + private final Socket socket; + + private final ConnectionInputStream inputStream; + + private final ConnectionOutputStream outputStream; + + private final String header; + + private volatile boolean webSocket; + + private volatile boolean running = true; + + /** + * Create a new {@link Connection} instance. + * @param socket the source socket + * @param inputStream the socket input stream + * @param outputStream the socket output stream + * @throws IOException + */ + public Connection(Socket socket, InputStream inputStream, OutputStream outputStream) + throws IOException { + this.socket = socket; + this.inputStream = new ConnectionInputStream(inputStream); + this.outputStream = new ConnectionOutputStream(outputStream); + this.header = this.inputStream.readHeader(); + logger.debug("Established livereload connection [" + this.header + "]"); + } + + /** + * Run the connection. + * @throws Exception + */ + public void run() throws Exception { + if (this.header.contains("Upgrade: websocket") + && this.header.contains("Sec-WebSocket-Version: 13")) { + runWebSocket(this.header); + } + if (this.header.contains("GET /livereload.js")) { + this.outputStream.writeHttp(getClass().getResourceAsStream("livereload.js"), + "text/javascript"); + } + } + + private void runWebSocket(String header) throws Exception { + String accept = getWebsocketAcceptResponse(); + this.outputStream.writeHeaders("HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", "Connection: Upgrade", "Sec-WebSocket-Accept: " + + accept); + new Frame("{\"command\":\"hello\",\"protocols\":" + + "[\"http://livereload.com/protocols/official-7\"]," + + "\"serverName\":\"spring-boot\"}").write(this.outputStream); + Thread.sleep(100); + this.webSocket = true; + while (this.running) { + readWebSocketFrame(); + } + } + + private void readWebSocketFrame() throws IOException { + try { + Frame frame = Frame.read(this.inputStream); + if (frame.getType() == Frame.Type.PING) { + writeWebSocketFrame(new Frame(Frame.Type.PONG)); + } + else if (frame.getType() == Frame.Type.CLOSE) { + throw new ConnectionClosedException(); + } + else if (frame.getType() == Frame.Type.TEXT) { + logger.debug("Recieved LiveReload text frame " + frame); + } + else { + throw new IOException("Unexpected Frame Type " + frame.getType()); + } + } + catch (SocketTimeoutException ex) { + writeWebSocketFrame(new Frame(Frame.Type.PING)); + Frame frame = Frame.read(this.inputStream); + if (frame.getType() != Frame.Type.PONG) { + throw new IllegalStateException("No Pong"); + } + } + } + + /** + * Trigger livereload for the client using this connection. + * @throws IOException + */ + public void triggerReload() throws IOException { + if (this.webSocket) { + logger.debug("Triggering LiveReload"); + writeWebSocketFrame(new Frame("{\"command\":\"reload\",\"path\":\"/\"}")); + } + } + + private synchronized void writeWebSocketFrame(Frame frame) throws IOException { + frame.write(this.outputStream); + } + + private String getWebsocketAcceptResponse() throws NoSuchAlgorithmException { + Matcher matcher = WEBSOCKET_KEY_PATTERN.matcher(this.header); + if (!matcher.find()) { + throw new IllegalStateException("No Sec-WebSocket-Key"); + } + String response = matcher.group(1).trim() + WEBSOCKET_GUID; + MessageDigest messageDigest = MessageDigest.getInstance("SHA-1"); + messageDigest.update(response.getBytes(), 0, response.length()); + return Base64Encoder.encode(messageDigest.digest()); + } + + /** + * Close the connection. + * @throws IOException + */ + public void close() throws IOException { + this.running = false; + this.socket.close(); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionClosedException.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionClosedException.java new file mode 100644 index 00000000000..0916c3a4faf --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionClosedException.java @@ -0,0 +1,32 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.livereload; + +import java.io.IOException; + +/** + * Exception throw when the client closes the connection. + * + * @author Phillip Webb + */ +class ConnectionClosedException extends IOException { + + public ConnectionClosedException() { + super("Connection closed"); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionInputStream.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionInputStream.java new file mode 100644 index 00000000000..a86c338fa4d --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionInputStream.java @@ -0,0 +1,102 @@ +/* + * Copyright 2012-2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.livereload; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * {@link InputStream} for a server connection. + * + * @author Phillip Webb + */ +class ConnectionInputStream extends FilterInputStream { + + private static final String HEADER_END = "\r\n\r\n"; + + private static final int BUFFER_SIZE = 4096; + + public ConnectionInputStream(InputStream in) { + super(in); + } + + /** + * Read the HTTP header from the {@link InputStream}. Note: This method doesn't expect + * any HTTP content after the header since the initial request is usually just a + * WebSocket upgrade. + * @return the HTTP header + * @throws IOException + */ + public String readHeader() throws IOException { + byte[] buffer = new byte[BUFFER_SIZE]; + StringBuffer content = new StringBuffer(BUFFER_SIZE); + while (content.indexOf(HEADER_END) == -1) { + int amountRead = checkedRead(buffer, 0, BUFFER_SIZE); + content.append(new String(buffer, 0, amountRead)); + } + return content.substring(0, content.indexOf(HEADER_END)).toString(); + } + + /** + * Repeatedly read the underlying {@link InputStream} until the requested number of + * bytes have been loaded. + * @param buffer the destination buffer + * @param offset the buffer offset + * @param length the amount of data to read + * @throws IOException + */ + public void readFully(byte[] buffer, int offset, int length) throws IOException { + while (length > 0) { + int amountRead = checkedRead(buffer, offset, length); + offset += amountRead; + length -= amountRead; + } + } + + /** + * Read a single byte from the stream (checking that the end of the stream hasn't been + * reached. + * @return the content + * @throws IOException + */ + public int checkedRead() throws IOException { + int b = read(); + if (b == -1) { + throw new IOException("End of stream"); + } + return (b & 0xff); + } + + /** + * Read a a number of bytes from the stream (checking that the end of the stream + * hasn't been reached) + * @param buffer the destination buffer + * @param offset the buffer offset + * @param length the length to read + * @return the amount of data read + * @throws IOException + */ + public int checkedRead(byte[] buffer, int offset, int length) throws IOException { + int amountRead = read(buffer, offset, length); + if (amountRead == -1) { + throw new IOException("End of stream"); + } + return amountRead; + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionOutputStream.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionOutputStream.java new file mode 100644 index 00000000000..4a3f14fc82a --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/ConnectionOutputStream.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.livereload; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import org.springframework.util.FileCopyUtils; + +/** + * {@link OutputStream} for a server connection. + * + * @author Phillip Webb + */ +class ConnectionOutputStream extends FilterOutputStream { + + public ConnectionOutputStream(OutputStream out) { + super(out); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + this.out.write(b, off, len); + } + + public void writeHttp(InputStream content, String contentType) throws IOException { + byte[] bytes = FileCopyUtils.copyToByteArray(content); + writeHeaders("HTTP/1.1 200 OK", "Content-Type: " + contentType, + "Content-Length: " + bytes.length, "Connection: close"); + write(bytes); + flush(); + } + + public void writeHeaders(String... headers) throws IOException { + StringBuilder response = new StringBuilder(); + for (String header : headers) { + response.append(header).append("\r\n"); + } + response.append("\r\n"); + write(response.toString().getBytes()); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Frame.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Frame.java new file mode 100644 index 00000000000..138957d8dde --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/Frame.java @@ -0,0 +1,159 @@ +/* + * Copyright 2012-2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.livereload; + +import java.io.IOException; +import java.io.OutputStream; + +import org.springframework.util.Assert; + +/** + * A limited implementation of a WebSocket Frame used to carry LiveReload data. + * + * @author Phillip Webb + */ +class Frame { + + private static final byte[] NO_BYTES = new byte[0]; + + private final Type type; + + private final byte[] payload; + + /** + * Create a new {@link Type#TEXT text} {@link Frame} instance with the specified + * payload. + * @param payload the text payload + */ + public Frame(String payload) { + Assert.notNull(payload, "Payload must not be null"); + this.type = Type.TEXT; + this.payload = payload.getBytes(); + } + + public Frame(Type type) { + Assert.notNull(type, "Type must not be null"); + this.type = type; + this.payload = NO_BYTES; + } + + private Frame(Type type, byte[] payload) { + this.type = type; + this.payload = payload; + } + + public Type getType() { + return this.type; + } + + public byte[] getPayload() { + return this.payload; + } + + @Override + public String toString() { + return new String(this.payload); + } + + public void write(OutputStream outputStream) throws IOException { + outputStream.write(0x80 | this.type.code); + if (this.payload.length < 126) { + outputStream.write(0x00 | (this.payload.length & 0x7F)); + } + else { + outputStream.write(0x7E); + outputStream.write(this.payload.length >> 8 & 0xFF); + outputStream.write(this.payload.length >> 0 & 0xFF); + } + outputStream.write(this.payload); + outputStream.flush(); + } + + public static Frame read(ConnectionInputStream inputStream) throws IOException { + int firstByte = inputStream.checkedRead(); + Assert.state((firstByte & 0x80) != 0, "Fragmented frames are not supported"); + int maskAndLength = inputStream.checkedRead(); + boolean hasMask = (maskAndLength & 0x80) != 0; + int length = (maskAndLength & 0x7F); + Assert.state(length != 127, "Large frames are not supported"); + if (length == 126) { + length = ((inputStream.checkedRead()) << 8 | inputStream.checkedRead()); + } + byte[] mask = new byte[4]; + if (hasMask) { + inputStream.readFully(mask, 0, mask.length); + } + byte[] payload = new byte[length]; + inputStream.readFully(payload, 0, length); + if (hasMask) { + for (int i = 0; i < payload.length; i++) { + payload[i] ^= mask[i % 4]; + } + } + return new Frame(Type.forCode(firstByte & 0x0F), payload); + } + + public static enum Type { + + /** + * Continuation frame. + */ + CONTINUATION(0x00), + + /** + * Text frame. + */ + TEXT(0x01), + + /** + * Binary frame. + */ + BINARY(0x02), + + /** + * Close frame. + */ + CLOSE(0x08), + + /** + * Ping frame. + */ + PING(0x09), + + /** + * Pong frame. + */ + PONG(0x0A); + + private final int code; + + private Type(int code) { + this.code = code; + } + + public static Type forCode(int code) { + for (Type type : values()) { + if (type.code == code) { + return type; + } + } + throw new IllegalStateException("Unknown code " + code); + } + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/LiveReloadServer.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/LiveReloadServer.java new file mode 100644 index 00000000000..49420b607b3 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/LiveReloadServer.java @@ -0,0 +1,322 @@ +/* + * Copyright 2012-2014 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.developertools.livereload; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.util.Assert; + +/** + * A livereload server. + * + * @author Phillip Webb + * @see livereload.com + * @since 1.3.0 + */ +public class LiveReloadServer { + + /** + * The default live reload server port. + */ + public static final int DEFAULT_PORT = 35729; + + private static Log logger = LogFactory.getLog(LiveReloadServer.class); + + private static final int READ_TIMEOUT = (int) TimeUnit.SECONDS.toMillis(4); + + private final int port; + + private final ThreadFactory threadFactory; + + private ServerSocket serverSocket; + + private Thread listenThread; + + private ExecutorService executor = Executors + .newCachedThreadPool(new WorkerThreadFactory()); + + private List connections = new ArrayList(); + + /** + * Create a new {@link LiveReloadServer} listening on the default port. + */ + public LiveReloadServer() { + this(DEFAULT_PORT); + } + + /** + * Create a new {@link LiveReloadServer} listening on the default port with a specific + * {@link ThreadFactory}. + * @param threadFactory the thread factory + */ + public LiveReloadServer(ThreadFactory threadFactory) { + this(DEFAULT_PORT, threadFactory); + } + + /** + * Create a new {@link LiveReloadServer} listening on the specified port. + * @param port the listen port + */ + public LiveReloadServer(int port) { + this(port, new ThreadFactory() { + + @Override + public Thread newThread(Runnable runnable) { + return new Thread(runnable); + } + + }); + } + + /** + * Create a new {@link LiveReloadServer} listening on the specified port with a + * specific {@link ThreadFactory}. + * @param port the listen port + * @param threadFactory the thread factory + */ + public LiveReloadServer(int port, ThreadFactory threadFactory) { + this.port = port; + this.threadFactory = threadFactory; + } + + /** + * Start the livereload server and accept incoming connections. + * @throws IOException + */ + public synchronized void start() throws IOException { + Assert.state(!isStarted(), "Server already started"); + logger.debug("Starting live reload server on port " + this.port); + this.serverSocket = new ServerSocket(this.port); + this.listenThread = this.threadFactory.newThread(new Runnable() { + + @Override + public void run() { + acceptConnections(); + } + + }); + this.listenThread.setDaemon(true); + this.listenThread.setName("Live Reload Server"); + this.listenThread.start(); + } + + /** + * Return if the server has been started. + * @return {@code true} if the server is running + */ + public synchronized boolean isStarted() { + return this.listenThread != null; + } + + /** + * Return the port that the server is listening on + * @return the server port + */ + public int getPort() { + return this.port; + } + + private void acceptConnections() { + do { + try { + Socket socket = this.serverSocket.accept(); + socket.setSoTimeout(READ_TIMEOUT); + this.executor.execute(new ConnectionHandler(socket)); + } + catch (SocketTimeoutException ex) { + // Ignore + } + catch (Exception ex) { + if (logger.isDebugEnabled()) { + logger.debug("LiveReload server error", ex); + } + } + } + while (!this.serverSocket.isClosed()); + } + + /** + * Gracefully stop the livereload server. + * @throws IOException + */ + public synchronized void stop() throws IOException { + if (this.listenThread != null) { + closeAllConnections(); + try { + this.executor.shutdown(); + this.executor.awaitTermination(1, TimeUnit.MINUTES); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + this.serverSocket.close(); + try { + this.listenThread.join(); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + this.listenThread = null; + this.serverSocket = null; + } + } + + private void closeAllConnections() throws IOException { + synchronized (this.connections) { + for (Connection connection : this.connections) { + connection.close(); + } + } + } + + /** + * Trigger livereload of all connected clients. + */ + public void triggerReload() { + synchronized (this.connections) { + for (Connection connection : this.connections) { + try { + connection.triggerReload(); + } + catch (Exception ex) { + logger.debug("Unable to send reload message", ex); + } + } + } + } + + private void addConnection(Connection connection) { + synchronized (this.connections) { + this.connections.add(connection); + } + } + + private void removeConnection(Connection connection) { + synchronized (this.connections) { + this.connections.remove(connection); + } + } + + /** + * Factory method used to create the {@link Connection}. + * @param socket the source socket + * @param inputStream the socket input stream + * @param outputStream the socket output stream + * @return a connection + * @throws IOException + */ + protected Connection createConnection(Socket socket, InputStream inputStream, + OutputStream outputStream) throws IOException { + return new Connection(socket, inputStream, outputStream); + } + + /** + * {@link Runnable} to handle a single connection. + * @see Connection + */ + private class ConnectionHandler implements Runnable { + + private final Socket socket; + + private final InputStream inputStream; + + public ConnectionHandler(Socket socket) throws IOException { + this.socket = socket; + this.inputStream = socket.getInputStream(); + } + + @Override + public void run() { + try { + handle(); + } + catch (ConnectionClosedException ex) { + logger.debug("LiveReload connection closed"); + } + catch (Exception ex) { + if (logger.isDebugEnabled()) { + logger.debug("LiveReload error", ex); + } + } + } + + private void handle() throws Exception { + try { + try { + OutputStream outputStream = this.socket.getOutputStream(); + try { + Connection connection = createConnection(this.socket, + this.inputStream, outputStream); + runConnection(connection); + } + finally { + outputStream.close(); + } + } + finally { + this.inputStream.close(); + } + } + finally { + this.socket.close(); + } + } + + private void runConnection(Connection connection) throws IOException, Exception { + try { + addConnection(connection); + connection.run(); + } + finally { + removeConnection(connection); + } + } + + } + + /** + * {@link ThreadFactory} to create the worker threads, + */ + private static class WorkerThreadFactory implements ThreadFactory { + + private final AtomicInteger threadNumber = new AtomicInteger(1); + + @Override + public Thread newThread(Runnable r) { + Thread thread = new Thread(r); + thread.setDaemon(true); + thread.setName("Live Reload #" + this.threadNumber.getAndIncrement()); + return thread; + } + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/package-info.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/package-info.java new file mode 100644 index 00000000000..64c1937a3fb --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/livereload/package-info.java @@ -0,0 +1,21 @@ +/* + * Copyright 2012-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Support for the livereload protocol. + */ +package org.springframework.boot.developertools.livereload; + diff --git a/spring-boot-developer-tools/src/main/resources/org/springframework/boot/developertools/livereload/livereload.js b/spring-boot-developer-tools/src/main/resources/org/springframework/boot/developertools/livereload/livereload.js new file mode 100644 index 00000000000..edb280265cb --- /dev/null +++ b/spring-boot-developer-tools/src/main/resources/org/springframework/boot/developertools/livereload/livereload.js @@ -0,0 +1,1055 @@ +(function() { +var __customevents = {}, __protocol = {}, __connector = {}, __timer = {}, __options = {}, __reloader = {}, __livereload = {}, __less = {}, __startup = {}; + +// customevents +var CustomEvents; +CustomEvents = { + bind: function(element, eventName, handler) { + if (element.addEventListener) { + return element.addEventListener(eventName, handler, false); + } else if (element.attachEvent) { + element[eventName] = 1; + return element.attachEvent('onpropertychange', function(event) { + if (event.propertyName === eventName) { + return handler(); + } + }); + } else { + throw new Error("Attempt to attach custom event " + eventName + " to something which isn't a DOMElement"); + } + }, + fire: function(element, eventName) { + var event; + if (element.addEventListener) { + event = document.createEvent('HTMLEvents'); + event.initEvent(eventName, true, true); + return document.dispatchEvent(event); + } else if (element.attachEvent) { + if (element[eventName]) { + return element[eventName]++; + } + } else { + throw new Error("Attempt to fire custom event " + eventName + " on something which isn't a DOMElement"); + } + } +}; +__customevents.bind = CustomEvents.bind; +__customevents.fire = CustomEvents.fire; + +// protocol +var PROTOCOL_6, PROTOCOL_7, Parser, ProtocolError; +var __indexOf = Array.prototype.indexOf || function(item) { + for (var i = 0, l = this.length; i < l; i++) { + if (this[i] === item) return i; + } + return -1; +}; +__protocol.PROTOCOL_6 = PROTOCOL_6 = 'http://livereload.com/protocols/official-6'; +__protocol.PROTOCOL_7 = PROTOCOL_7 = 'http://livereload.com/protocols/official-7'; +__protocol.ProtocolError = ProtocolError = (function() { + function ProtocolError(reason, data) { + this.message = "LiveReload protocol error (" + reason + ") after receiving data: \"" + data + "\"."; + } + return ProtocolError; +})(); +__protocol.Parser = Parser = (function() { + function Parser(handlers) { + this.handlers = handlers; + this.reset(); + } + Parser.prototype.reset = function() { + return this.protocol = null; + }; + Parser.prototype.process = function(data) { + var command, message, options, _ref; + try { + if (!(this.protocol != null)) { + if (data.match(/^!!ver:([\d.]+)$/)) { + this.protocol = 6; + } else if (message = this._parseMessage(data, ['hello'])) { + if (!message.protocols.length) { + throw new ProtocolError("no protocols specified in handshake message"); + } else if (__indexOf.call(message.protocols, PROTOCOL_7) >= 0) { + this.protocol = 7; + } else if (__indexOf.call(message.protocols, PROTOCOL_6) >= 0) { + this.protocol = 6; + } else { + throw new ProtocolError("no supported protocols found"); + } + } + return this.handlers.connected(this.protocol); + } else if (this.protocol === 6) { + message = JSON.parse(data); + if (!message.length) { + throw new ProtocolError("protocol 6 messages must be arrays"); + } + command = message[0], options = message[1]; + if (command !== 'refresh') { + throw new ProtocolError("unknown protocol 6 command"); + } + return this.handlers.message({ + command: 'reload', + path: options.path, + liveCSS: (_ref = options.apply_css_live) != null ? _ref : true + }); + } else { + message = this._parseMessage(data, ['reload', 'alert']); + return this.handlers.message(message); + } + } catch (e) { + if (e instanceof ProtocolError) { + return this.handlers.error(e); + } else { + throw e; + } + } + }; + Parser.prototype._parseMessage = function(data, validCommands) { + var message, _ref; + try { + message = JSON.parse(data); + } catch (e) { + throw new ProtocolError('unparsable JSON', data); + } + if (!message.command) { + throw new ProtocolError('missing "command" key', data); + } + if (_ref = message.command, __indexOf.call(validCommands, _ref) < 0) { + throw new ProtocolError("invalid command '" + message.command + "', only valid commands are: " + (validCommands.join(', ')) + ")", data); + } + return message; + }; + return Parser; +})(); + +// connector +// Generated by CoffeeScript 1.3.3 +var Connector, PROTOCOL_6, PROTOCOL_7, Parser, Version, _ref; + +_ref = __protocol, Parser = _ref.Parser, PROTOCOL_6 = _ref.PROTOCOL_6, PROTOCOL_7 = _ref.PROTOCOL_7; + +Version = '2.0.8'; + +__connector.Connector = Connector = (function() { + + function Connector(options, WebSocket, Timer, handlers) { + var _this = this; + this.options = options; + this.WebSocket = WebSocket; + this.Timer = Timer; + this.handlers = handlers; + this._uri = "ws://" + this.options.host + ":" + this.options.port + "/livereload"; + this._nextDelay = this.options.mindelay; + this._connectionDesired = false; + this.protocol = 0; + this.protocolParser = new Parser({ + connected: function(protocol) { + _this.protocol = protocol; + _this._handshakeTimeout.stop(); + _this._nextDelay = _this.options.mindelay; + _this._disconnectionReason = 'broken'; + return _this.handlers.connected(protocol); + }, + error: function(e) { + _this.handlers.error(e); + return _this._closeOnError(); + }, + message: function(message) { + return _this.handlers.message(message); + } + }); + this._handshakeTimeout = new Timer(function() { + if (!_this._isSocketConnected()) { + return; + } + _this._disconnectionReason = 'handshake-timeout'; + return _this.socket.close(); + }); + this._reconnectTimer = new Timer(function() { + if (!_this._connectionDesired) { + return; + } + return _this.connect(); + }); + this.connect(); + } + + Connector.prototype._isSocketConnected = function() { + return this.socket && this.socket.readyState === this.WebSocket.OPEN; + }; + + Connector.prototype.connect = function() { + var _this = this; + this._connectionDesired = true; + if (this._isSocketConnected()) { + return; + } + this._reconnectTimer.stop(); + this._disconnectionReason = 'cannot-connect'; + this.protocolParser.reset(); + this.handlers.connecting(); + this.socket = new this.WebSocket(this._uri); + this.socket.onopen = function(e) { + return _this._onopen(e); + }; + this.socket.onclose = function(e) { + return _this._onclose(e); + }; + this.socket.onmessage = function(e) { + return _this._onmessage(e); + }; + return this.socket.onerror = function(e) { + return _this._onerror(e); + }; + }; + + Connector.prototype.disconnect = function() { + this._connectionDesired = false; + this._reconnectTimer.stop(); + if (!this._isSocketConnected()) { + return; + } + this._disconnectionReason = 'manual'; + return this.socket.close(); + }; + + Connector.prototype._scheduleReconnection = function() { + if (!this._connectionDesired) { + return; + } + if (!this._reconnectTimer.running) { + this._reconnectTimer.start(this._nextDelay); + return this._nextDelay = Math.min(this.options.maxdelay, this._nextDelay * 2); + } + }; + + Connector.prototype.sendCommand = function(command) { + if (this.protocol == null) { + return; + } + return this._sendCommand(command); + }; + + Connector.prototype._sendCommand = function(command) { + return this.socket.send(JSON.stringify(command)); + }; + + Connector.prototype._closeOnError = function() { + this._handshakeTimeout.stop(); + this._disconnectionReason = 'error'; + return this.socket.close(); + }; + + Connector.prototype._onopen = function(e) { + var hello; + this.handlers.socketConnected(); + this._disconnectionReason = 'handshake-failed'; + hello = { + command: 'hello', + protocols: [PROTOCOL_6, PROTOCOL_7] + }; + hello.ver = Version; + if (this.options.ext) { + hello.ext = this.options.ext; + } + if (this.options.extver) { + hello.extver = this.options.extver; + } + if (this.options.snipver) { + hello.snipver = this.options.snipver; + } + this._sendCommand(hello); + return this._handshakeTimeout.start(this.options.handshake_timeout); + }; + + Connector.prototype._onclose = function(e) { + this.protocol = 0; + this.handlers.disconnected(this._disconnectionReason, this._nextDelay); + return this._scheduleReconnection(); + }; + + Connector.prototype._onerror = function(e) {}; + + Connector.prototype._onmessage = function(e) { + return this.protocolParser.process(e.data); + }; + + return Connector; + +})(); + +// timer +var Timer; +var __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; +__timer.Timer = Timer = (function() { + function Timer(func) { + this.func = func; + this.running = false; + this.id = null; + this._handler = __bind(function() { + this.running = false; + this.id = null; + return this.func(); + }, this); + } + Timer.prototype.start = function(timeout) { + if (this.running) { + clearTimeout(this.id); + } + this.id = setTimeout(this._handler, timeout); + return this.running = true; + }; + Timer.prototype.stop = function() { + if (this.running) { + clearTimeout(this.id); + this.running = false; + return this.id = null; + } + }; + return Timer; +})(); +Timer.start = function(timeout, func) { + return setTimeout(func, timeout); +}; + +// options +var Options; +__options.Options = Options = (function() { + function Options() { + this.host = null; + this.port = 35729; + this.snipver = null; + this.ext = null; + this.extver = null; + this.mindelay = 1000; + this.maxdelay = 60000; + this.handshake_timeout = 5000; + } + Options.prototype.set = function(name, value) { + switch (typeof this[name]) { + case 'undefined': + break; + case 'number': + return this[name] = +value; + default: + return this[name] = value; + } + }; + return Options; +})(); +Options.extract = function(document) { + var element, keyAndValue, m, mm, options, pair, src, _i, _j, _len, _len2, _ref, _ref2; + _ref = document.getElementsByTagName('script'); + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + element = _ref[_i]; + if ((src = element.src) && (m = src.match(/^[^:]+:\/\/(.*)\/z?livereload\.js(?:\?(.*))?$/))) { + options = new Options(); + if (mm = m[1].match(/^([^\/:]+)(?::(\d+))?$/)) { + options.host = mm[1]; + if (mm[2]) { + options.port = parseInt(mm[2], 10); + } + } + if (m[2]) { + _ref2 = m[2].split('&'); + for (_j = 0, _len2 = _ref2.length; _j < _len2; _j++) { + pair = _ref2[_j]; + if ((keyAndValue = pair.split('=')).length > 1) { + options.set(keyAndValue[0].replace(/-/g, '_'), keyAndValue.slice(1).join('=')); + } + } + } + return options; + } + } + return null; +}; + +// reloader +// Generated by CoffeeScript 1.3.1 +(function() { + var IMAGE_STYLES, Reloader, numberOfMatchingSegments, pathFromUrl, pathsMatch, pickBestMatch, splitUrl; + + splitUrl = function(url) { + var hash, index, params; + if ((index = url.indexOf('#')) >= 0) { + hash = url.slice(index); + url = url.slice(0, index); + } else { + hash = ''; + } + if ((index = url.indexOf('?')) >= 0) { + params = url.slice(index); + url = url.slice(0, index); + } else { + params = ''; + } + return { + url: url, + params: params, + hash: hash + }; + }; + + pathFromUrl = function(url) { + var path; + url = splitUrl(url).url; + if (url.indexOf('file://') === 0) { + path = url.replace(/^file:\/\/(localhost)?/, ''); + } else { + path = url.replace(/^([^:]+:)?\/\/([^:\/]+)(:\d*)?\//, '/'); + } + return decodeURIComponent(path); + }; + + pickBestMatch = function(path, objects, pathFunc) { + var bestMatch, object, score, _i, _len; + bestMatch = { + score: 0 + }; + for (_i = 0, _len = objects.length; _i < _len; _i++) { + object = objects[_i]; + score = numberOfMatchingSegments(path, pathFunc(object)); + if (score > bestMatch.score) { + bestMatch = { + object: object, + score: score + }; + } + } + if (bestMatch.score > 0) { + return bestMatch; + } else { + return null; + } + }; + + numberOfMatchingSegments = function(path1, path2) { + var comps1, comps2, eqCount, len; + path1 = path1.replace(/^\/+/, '').toLowerCase(); + path2 = path2.replace(/^\/+/, '').toLowerCase(); + if (path1 === path2) { + return 10000; + } + comps1 = path1.split('/').reverse(); + comps2 = path2.split('/').reverse(); + len = Math.min(comps1.length, comps2.length); + eqCount = 0; + while (eqCount < len && comps1[eqCount] === comps2[eqCount]) { + ++eqCount; + } + return eqCount; + }; + + pathsMatch = function(path1, path2) { + return numberOfMatchingSegments(path1, path2) > 0; + }; + + IMAGE_STYLES = [ + { + selector: 'background', + styleNames: ['backgroundImage'] + }, { + selector: 'border', + styleNames: ['borderImage', 'webkitBorderImage', 'MozBorderImage'] + } + ]; + + __reloader.Reloader = Reloader = (function() { + + Reloader.name = 'Reloader'; + + function Reloader(window, console, Timer) { + this.window = window; + this.console = console; + this.Timer = Timer; + this.document = this.window.document; + this.importCacheWaitPeriod = 200; + this.plugins = []; + } + + Reloader.prototype.addPlugin = function(plugin) { + return this.plugins.push(plugin); + }; + + Reloader.prototype.analyze = function(callback) { + return results; + }; + + Reloader.prototype.reload = function(path, options) { + var plugin, _base, _i, _len, _ref; + this.options = options; + if ((_base = this.options).stylesheetReloadTimeout == null) { + _base.stylesheetReloadTimeout = 15000; + } + _ref = this.plugins; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + plugin = _ref[_i]; + if (plugin.reload && plugin.reload(path, options)) { + return; + } + } + if (options.liveCSS) { + if (path.match(/\.css$/i)) { + if (this.reloadStylesheet(path)) { + return; + } + } + } + if (options.liveImg) { + if (path.match(/\.(jpe?g|png|gif)$/i)) { + this.reloadImages(path); + return; + } + } + return this.reloadPage(); + }; + + Reloader.prototype.reloadPage = function() { + return this.window.document.location.reload(); + }; + + Reloader.prototype.reloadImages = function(path) { + var expando, img, selector, styleNames, styleSheet, _i, _j, _k, _l, _len, _len1, _len2, _len3, _ref, _ref1, _ref2, _ref3, _results; + expando = this.generateUniqueString(); + _ref = this.document.images; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + img = _ref[_i]; + if (pathsMatch(path, pathFromUrl(img.src))) { + img.src = this.generateCacheBustUrl(img.src, expando); + } + } + if (this.document.querySelectorAll) { + for (_j = 0, _len1 = IMAGE_STYLES.length; _j < _len1; _j++) { + _ref1 = IMAGE_STYLES[_j], selector = _ref1.selector, styleNames = _ref1.styleNames; + _ref2 = this.document.querySelectorAll("[style*=" + selector + "]"); + for (_k = 0, _len2 = _ref2.length; _k < _len2; _k++) { + img = _ref2[_k]; + this.reloadStyleImages(img.style, styleNames, path, expando); + } + } + } + if (this.document.styleSheets) { + _ref3 = this.document.styleSheets; + _results = []; + for (_l = 0, _len3 = _ref3.length; _l < _len3; _l++) { + styleSheet = _ref3[_l]; + _results.push(this.reloadStylesheetImages(styleSheet, path, expando)); + } + return _results; + } + }; + + Reloader.prototype.reloadStylesheetImages = function(styleSheet, path, expando) { + var rule, rules, styleNames, _i, _j, _len, _len1; + try { + rules = styleSheet != null ? styleSheet.cssRules : void 0; + } catch (e) { + + } + if (!rules) { + return; + } + for (_i = 0, _len = rules.length; _i < _len; _i++) { + rule = rules[_i]; + switch (rule.type) { + case CSSRule.IMPORT_RULE: + this.reloadStylesheetImages(rule.styleSheet, path, expando); + break; + case CSSRule.STYLE_RULE: + for (_j = 0, _len1 = IMAGE_STYLES.length; _j < _len1; _j++) { + styleNames = IMAGE_STYLES[_j].styleNames; + this.reloadStyleImages(rule.style, styleNames, path, expando); + } + break; + case CSSRule.MEDIA_RULE: + this.reloadStylesheetImages(rule, path, expando); + } + } + }; + + Reloader.prototype.reloadStyleImages = function(style, styleNames, path, expando) { + var newValue, styleName, value, _i, _len, + _this = this; + for (_i = 0, _len = styleNames.length; _i < _len; _i++) { + styleName = styleNames[_i]; + value = style[styleName]; + if (typeof value === 'string') { + newValue = value.replace(/\burl\s*\(([^)]*)\)/, function(match, src) { + if (pathsMatch(path, pathFromUrl(src))) { + return "url(" + (_this.generateCacheBustUrl(src, expando)) + ")"; + } else { + return match; + } + }); + if (newValue !== value) { + style[styleName] = newValue; + } + } + } + }; + + Reloader.prototype.reloadStylesheet = function(path) { + var imported, link, links, match, style, _i, _j, _k, _l, _len, _len1, _len2, _len3, _ref, _ref1, + _this = this; + links = (function() { + var _i, _len, _ref, _results; + _ref = this.document.getElementsByTagName('link'); + _results = []; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + link = _ref[_i]; + if (link.rel === 'stylesheet' && !link.__LiveReload_pendingRemoval) { + _results.push(link); + } + } + return _results; + }).call(this); + imported = []; + _ref = this.document.getElementsByTagName('style'); + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + style = _ref[_i]; + if (style.sheet) { + this.collectImportedStylesheets(style, style.sheet, imported); + } + } + for (_j = 0, _len1 = links.length; _j < _len1; _j++) { + link = links[_j]; + this.collectImportedStylesheets(link, link.sheet, imported); + } + if (this.window.StyleFix && this.document.querySelectorAll) { + _ref1 = this.document.querySelectorAll('style[data-href]'); + for (_k = 0, _len2 = _ref1.length; _k < _len2; _k++) { + style = _ref1[_k]; + links.push(style); + } + } + this.console.log("LiveReload found " + links.length + " LINKed stylesheets, " + imported.length + " @imported stylesheets"); + match = pickBestMatch(path, links.concat(imported), function(l) { + return pathFromUrl(_this.linkHref(l)); + }); + if (match) { + if (match.object.rule) { + this.console.log("LiveReload is reloading imported stylesheet: " + match.object.href); + this.reattachImportedRule(match.object); + } else { + this.console.log("LiveReload is reloading stylesheet: " + (this.linkHref(match.object))); + this.reattachStylesheetLink(match.object); + } + } else { + this.console.log("LiveReload will reload all stylesheets because path '" + path + "' did not match any specific one"); + for (_l = 0, _len3 = links.length; _l < _len3; _l++) { + link = links[_l]; + this.reattachStylesheetLink(link); + } + } + return true; + }; + + Reloader.prototype.collectImportedStylesheets = function(link, styleSheet, result) { + var index, rule, rules, _i, _len; + try { + rules = styleSheet != null ? styleSheet.cssRules : void 0; + } catch (e) { + + } + if (rules && rules.length) { + for (index = _i = 0, _len = rules.length; _i < _len; index = ++_i) { + rule = rules[index]; + switch (rule.type) { + case CSSRule.CHARSET_RULE: + continue; + case CSSRule.IMPORT_RULE: + result.push({ + link: link, + rule: rule, + index: index, + href: rule.href + }); + this.collectImportedStylesheets(link, rule.styleSheet, result); + break; + default: + break; + } + } + } + }; + + Reloader.prototype.waitUntilCssLoads = function(clone, func) { + var callbackExecuted, executeCallback, poll, + _this = this; + callbackExecuted = false; + executeCallback = function() { + if (callbackExecuted) { + return; + } + callbackExecuted = true; + return func(); + }; + clone.onload = function() { + console.log("onload!"); + _this.knownToSupportCssOnLoad = true; + return executeCallback(); + }; + if (!this.knownToSupportCssOnLoad) { + (poll = function() { + if (clone.sheet) { + console.log("polling!"); + return executeCallback(); + } else { + return _this.Timer.start(50, poll); + } + })(); + } + return this.Timer.start(this.options.stylesheetReloadTimeout, executeCallback); + }; + + Reloader.prototype.linkHref = function(link) { + return link.href || link.getAttribute('data-href'); + }; + + Reloader.prototype.reattachStylesheetLink = function(link) { + var clone, parent, + _this = this; + if (link.__LiveReload_pendingRemoval) { + return; + } + link.__LiveReload_pendingRemoval = true; + if (link.tagName === 'STYLE') { + clone = this.document.createElement('link'); + clone.rel = 'stylesheet'; + clone.media = link.media; + clone.disabled = link.disabled; + } else { + clone = link.cloneNode(false); + } + clone.href = this.generateCacheBustUrl(this.linkHref(link)); + parent = link.parentNode; + if (parent.lastChild === link) { + parent.appendChild(clone); + } else { + parent.insertBefore(clone, link.nextSibling); + } + return this.waitUntilCssLoads(clone, function() { + var additionalWaitingTime; + if (/AppleWebKit/.test(navigator.userAgent)) { + additionalWaitingTime = 5; + } else { + additionalWaitingTime = 200; + } + return _this.Timer.start(additionalWaitingTime, function() { + var _ref; + if (!link.parentNode) { + return; + } + link.parentNode.removeChild(link); + clone.onreadystatechange = null; + return (_ref = _this.window.StyleFix) != null ? _ref.link(clone) : void 0; + }); + }); + }; + + Reloader.prototype.reattachImportedRule = function(_arg) { + var href, index, link, media, newRule, parent, rule, tempLink, + _this = this; + rule = _arg.rule, index = _arg.index, link = _arg.link; + parent = rule.parentStyleSheet; + href = this.generateCacheBustUrl(rule.href); + media = rule.media.length ? [].join.call(rule.media, ', ') : ''; + newRule = "@import url(\"" + href + "\") " + media + ";"; + rule.__LiveReload_newHref = href; + tempLink = this.document.createElement("link"); + tempLink.rel = 'stylesheet'; + tempLink.href = href; + tempLink.__LiveReload_pendingRemoval = true; + if (link.parentNode) { + link.parentNode.insertBefore(tempLink, link); + } + return this.Timer.start(this.importCacheWaitPeriod, function() { + if (tempLink.parentNode) { + tempLink.parentNode.removeChild(tempLink); + } + if (rule.__LiveReload_newHref !== href) { + return; + } + parent.insertRule(newRule, index); + parent.deleteRule(index + 1); + rule = parent.cssRules[index]; + rule.__LiveReload_newHref = href; + return _this.Timer.start(_this.importCacheWaitPeriod, function() { + if (rule.__LiveReload_newHref !== href) { + return; + } + parent.insertRule(newRule, index); + return parent.deleteRule(index + 1); + }); + }); + }; + + Reloader.prototype.generateUniqueString = function() { + return 'livereload=' + Date.now(); + }; + + Reloader.prototype.generateCacheBustUrl = function(url, expando) { + var hash, oldParams, params, _ref; + if (expando == null) { + expando = this.generateUniqueString(); + } + _ref = splitUrl(url), url = _ref.url, hash = _ref.hash, oldParams = _ref.params; + if (this.options.overrideURL) { + if (url.indexOf(this.options.serverURL) < 0) { + url = this.options.serverURL + this.options.overrideURL + "?url=" + encodeURIComponent(url); + } + } + params = oldParams.replace(/(\?|&)livereload=(\d+)/, function(match, sep) { + return "" + sep + expando; + }); + if (params === oldParams) { + if (oldParams.length === 0) { + params = "?" + expando; + } else { + params = "" + oldParams + "&" + expando; + } + } + return url + params + hash; + }; + + return Reloader; + + })(); + +}).call(this); + +// livereload +var Connector, LiveReload, Options, Reloader, Timer; + +Connector = __connector.Connector; + +Timer = __timer.Timer; + +Options = __options.Options; + +Reloader = __reloader.Reloader; + +__livereload.LiveReload = LiveReload = (function() { + + function LiveReload(window) { + var _this = this; + this.window = window; + this.listeners = {}; + this.plugins = []; + this.pluginIdentifiers = {}; + this.console = this.window.location.href.match(/LR-verbose/) && this.window.console && this.window.console.log && this.window.console.error ? this.window.console : { + log: function() {}, + error: function() {} + }; + if (!(this.WebSocket = this.window.WebSocket || this.window.MozWebSocket)) { + console.error("LiveReload disabled because the browser does not seem to support web sockets"); + return; + } + if (!(this.options = Options.extract(this.window.document))) { + console.error("LiveReload disabled because it could not find its own