From bd945c7b39e39441f6367a94cf40b45f8c776182 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 1 Jun 2015 13:22:35 -0700 Subject: [PATCH] Update Restarter to support for ClassLoaderFiles Update the `Restarter` so that class files and resources can change independently of the underlying source folders. This change will allow updates to be pushed to a remotely running application, without requiring the application to run in an exploded form. See gh-3086 --- .../developertools/restart/Restarter.java | 41 +++- .../restart/classloader/ClassLoaderFile.java | 112 ++++++++++ .../ClassLoaderFileRepository.java | 50 +++++ .../ClassLoaderFileURLStreamHandler.java | 67 ++++++ .../restart/classloader/ClassLoaderFiles.java | 202 ++++++++++++++++++ .../classloader/RestartClassLoader.java | 130 ++++++++++- .../restart/RestarterTests.java | 55 +++++ .../classloader/ClassLoaderFileTests.java | 90 ++++++++ .../classloader/ClassLoaderFilesTests.java | 184 ++++++++++++++++ .../classloader/RestartClassLoaderTests.java | 81 ++++++- 10 files changed, 1000 insertions(+), 12 deletions(-) create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFile.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFileRepository.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFileURLStreamHandler.java create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFiles.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFileTests.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFilesTests.java 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 index a0017f705e..90fc2a6690 100644 --- 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 @@ -22,9 +22,11 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; import java.net.URL; import java.util.Arrays; +import java.util.Collection; import java.util.HashMap; import java.util.IdentityHashMap; import java.util.Iterator; +import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -41,6 +43,7 @@ import org.apache.commons.logging.LogFactory; import org.springframework.beans.CachedIntrospectionResults; import org.springframework.beans.factory.ObjectFactory; import org.springframework.boot.SpringApplication; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFiles; import org.springframework.boot.developertools.restart.classloader.RestartClassLoader; import org.springframework.boot.logging.DeferredLog; import org.springframework.cglib.core.ClassNameReader; @@ -81,7 +84,7 @@ public class Restarter { private final boolean forceReferenceCleanup; - private URL[] urls; + private URL[] initialUrls; private final String mainClassName; @@ -91,6 +94,10 @@ public class Restarter { private final UncaughtExceptionHandler exceptionHandler; + private final Set urls = new LinkedHashSet(); + + private final ClassLoaderFiles classLoaderFiles = new ClassLoaderFiles(); + private final Map attributes = new HashMap(); private final BlockingDeque leakSafeThreads = new LinkedBlockingDeque(); @@ -115,7 +122,7 @@ public class Restarter { this.logger.debug("Creating new Restarter for thread " + thread); SilentExitExceptionHandler.setup(thread); this.forceReferenceCleanup = forceReferenceCleanup; - this.urls = initializer.getInitialUrls(thread); + this.initialUrls = initializer.getInitialUrls(thread); this.mainClassName = getMainClassName(thread); this.applicationClassLoader = thread.getContextClassLoader(); this.args = args; @@ -134,7 +141,8 @@ public class Restarter { protected void initialize(boolean restartOnInitialize) { preInitializeLeakyClasses(); - if (this.urls != null) { + if (this.initialUrls != null) { + this.urls.addAll(Arrays.asList(this.initialUrls)); if (restartOnInitialize) { this.logger.debug("Immediately restarting application"); immediateRestart(); @@ -176,6 +184,24 @@ public class Restarter { } } + /** + * Add additional URLs to be includes in the next restart. + * @param urls the urls to add + */ + public void addUrls(Collection urls) { + Assert.notNull(urls, "Urls must not be null"); + this.urls.addAll(ChangeableUrls.fromUrls(urls).toList()); + } + + /** + * Add additional {@link ClassLoaderFiles} to be included in the next restart. + * @param classLoaderFiles the files to add + */ + public void addClassLoaderFiles(ClassLoaderFiles classLoaderFiles) { + Assert.notNull(classLoaderFiles, "ClassLoaderFiles must not be null"); + this.classLoaderFiles.addAll(classLoaderFiles); + } + /** * Return a {@link ThreadFactory} that can be used to create leak safe threads. * @return a leak safe thread factory @@ -208,10 +234,13 @@ public class Restarter { 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); + URL[] urls = this.urls.toArray(new URL[this.urls.size()]); + ClassLoaderFiles updatedFiles = new ClassLoaderFiles(this.classLoaderFiles); + ClassLoader classLoader = new RestartClassLoader(parent, urls, updatedFiles, + this.logger); if (this.logger.isDebugEnabled()) { this.logger.debug("Starting application " + this.mainClassName - + " with URLs " + Arrays.asList(this.urls)); + + " with URLs " + Arrays.asList(urls)); } relaunch(classLoader); } @@ -369,7 +398,7 @@ public class Restarter { * @return the initial URLs or {@code null} */ public URL[] getInitialUrls() { - return this.urls; + return this.initialUrls; } /** diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFile.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFile.java new file mode 100644 index 0000000000..9a5c2c8d1e --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFile.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.classloader; + +import java.io.Serializable; + +import org.springframework.util.Assert; + +/** + * A single file that may be served from a {@link ClassLoader}. Can be used to represent + * files that have been added, modified or deleted since the original JAR was created. + * + * @author Phillip Webb + * @see ClassLoaderFileRepository + * @since 1.3.0 + */ +public class ClassLoaderFile implements Serializable { + + private static final long serialVersionUID = 1; + + private final Kind kind; + + private final byte[] contents; + + private final long lastModified; + + /** + * Create a new {@link ClassLoaderFile} instance. + * @param kind the kind of file + * @param contents the file contents + */ + public ClassLoaderFile(Kind kind, byte[] contents) { + this(kind, System.currentTimeMillis(), contents); + } + + /** + * Create a new {@link ClassLoaderFile} instance. + * @param kind the kind of file + * @param lastModified the last modified time + * @param contents the file contents + */ + public ClassLoaderFile(Kind kind, long lastModified, byte[] contents) { + Assert.notNull(kind, "Kind must not be null"); + Assert.isTrue(kind == Kind.DELETED ? contents == null : contents != null, + "Contents must " + (kind == Kind.DELETED ? "" : "not ") + "be null"); + this.kind = kind; + this.lastModified = lastModified; + this.contents = contents; + } + + /** + * Return the file {@link Kind} (added, modified, deleted). + * @return the kind + */ + public Kind getKind() { + return this.kind; + } + + /** + * Return the time that the file was last modified. + * @return the last modified time + */ + public long getLastModified() { + return this.lastModified; + } + + /** + * Return the contents of the file as a byte array or {@code null} if + * {@link #getKind()} is {@link Kind#DELETED}. + * @return the contents or {@code null} + */ + public byte[] getContents() { + return this.contents; + } + + /** + * The kinds of class load files. + */ + public static enum Kind { + + /** + * The file has been added since the original JAR was created. + */ + ADDED, + + /** + * The file has been modified since the original JAR was created. + */ + MODIFIED, + + /** + * The file has been deleted since the original JAR was created. + */ + DELETED + + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFileRepository.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFileRepository.java new file mode 100644 index 0000000000..e2c08e88b8 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFileRepository.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.classloader; + +/** + * A container for files that may be served from a {@link ClassLoader}. Can be used to + * represent files that have been added, modified or deleted since the original JAR was + * created. + * + * @author Phillip Webb + * @since 1.3.0 + * @see ClassLoaderFile + */ +public interface ClassLoaderFileRepository { + + /** + * Empty {@link ClassLoaderFileRepository} implementation. + */ + public static final ClassLoaderFileRepository NONE = new ClassLoaderFileRepository() { + + @Override + public ClassLoaderFile getFile(String name) { + return null; + } + + }; + + /** + * Return a {@link ClassLoaderFile} for the given name or {@code null} if no file is + * contained in this collection. + * @param name the name of the file + * @return a {@link ClassLoaderFile} or {@code null} + */ + ClassLoaderFile getFile(String name); + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFileURLStreamHandler.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFileURLStreamHandler.java new file mode 100644 index 0000000000..2b7fbacc37 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFileURLStreamHandler.java @@ -0,0 +1,67 @@ +/* + * 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.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; + +/** + * {@link URLStreamHandler} for the contents of a {@link ClassLoaderFile}. + * + * @author Phillip Webb + */ +class ClassLoaderFileURLStreamHandler extends URLStreamHandler { + + private ClassLoaderFile file; + + public ClassLoaderFileURLStreamHandler(ClassLoaderFile file) { + this.file = file; + } + + @Override + protected URLConnection openConnection(URL url) throws IOException { + return new Connection(url); + } + + private class Connection extends URLConnection { + + public Connection(URL url) { + super(url); + } + + @Override + public void connect() throws IOException { + } + + @Override + public InputStream getInputStream() throws IOException { + return new ByteArrayInputStream( + ClassLoaderFileURLStreamHandler.this.file.getContents()); + } + + @Override + public long getLastModified() { + return ClassLoaderFileURLStreamHandler.this.file.getLastModified(); + + } + + } +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFiles.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFiles.java new file mode 100644 index 0000000000..c4c89e86b7 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFiles.java @@ -0,0 +1,202 @@ +/* + * 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.Serializable; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import javax.management.loading.ClassLoaderRepository; + +import org.springframework.util.Assert; + +/** + * {@link ClassLoaderFileRepository} that maintains a collection of + * {@link ClassLoaderFile} items grouped by source folders. + * + * @author Phillip Webb + * @since 1.3.0 + * @see ClassLoaderFile + * @see ClassLoaderRepository + */ +public class ClassLoaderFiles implements ClassLoaderFileRepository, Serializable { + + private static final long serialVersionUID = 1; + + private final Map sourceFolders; + + /** + * Create a new {@link ClassLoaderFiles} instance. + */ + public ClassLoaderFiles() { + this.sourceFolders = new LinkedHashMap(); + } + + /** + * Create a new {@link ClassLoaderFiles} instance. + * @param classLoaderFiles the source classloader files. + */ + public ClassLoaderFiles(ClassLoaderFiles classLoaderFiles) { + Assert.notNull(classLoaderFiles, "ClassLoaderFiles must not be null"); + this.sourceFolders = new LinkedHashMap( + classLoaderFiles.sourceFolders); + } + + /** + * Add all elements items from the specified {@link ClassLoaderFiles} to this + * instance. + * @param files the files to add + */ + public void addAll(ClassLoaderFiles files) { + Assert.notNull(files, "Files must not be null"); + for (SourceFolder folder : files.getSourceFolders()) { + for (Map.Entry entry : folder.getFilesEntrySet()) { + addFile(folder.getName(), entry.getKey(), entry.getValue()); + } + } + } + + /** + * Add a single {@link ClassLoaderFile} to the collection. + * @param name the name of the file + * @param file the file to add + */ + public void addFile(String name, ClassLoaderFile file) { + addFile("", name, file); + } + + /** + * Add a single {@link ClassLoaderFile} to the collection. + * @param sourceFolder the source folder of the file + * @param name the name of the file + * @param file the file to add + */ + public void addFile(String sourceFolder, String name, ClassLoaderFile file) { + Assert.notNull(sourceFolder, "SourceFolder must not be null"); + Assert.notNull(name, "Name must not be null"); + Assert.notNull(file, "File must not be null"); + removeAll(name); + getOrCreateSourceFolder(sourceFolder).add(name, file); + } + + private void removeAll(String name) { + for (SourceFolder sourceFolder : this.sourceFolders.values()) { + sourceFolder.remove(name); + } + } + + /** + * Get or create a {@link SourceFolder} with the given name. + * @param name the name of the folder + * @return an existing or newly added {@link SourceFolder} + */ + protected final SourceFolder getOrCreateSourceFolder(String name) { + SourceFolder sourceFolder = this.sourceFolders.get(name); + if (sourceFolder == null) { + sourceFolder = new SourceFolder(name); + this.sourceFolders.put(name, sourceFolder); + } + return sourceFolder; + } + + /** + * Return all {@link SourceFolder SourceFolders} that have been added to the + * collection. + * @return a collection of {@link SourceFolder} items + */ + public Collection getSourceFolders() { + return Collections.unmodifiableCollection(this.sourceFolders.values()); + } + + /** + * Return the size of the collection. + * @return the size of the collection + */ + public int size() { + int size = 0; + for (SourceFolder sourceFolder : this.sourceFolders.values()) { + size += sourceFolder.getFiles().size(); + } + return size; + } + + @Override + public ClassLoaderFile getFile(String name) { + for (SourceFolder sourceFolder : this.sourceFolders.values()) { + ClassLoaderFile file = sourceFolder.get(name); + if (file != null) { + return file; + } + } + return null; + } + + /** + * An individual source folder that is being managed by the collection. + */ + public static class SourceFolder implements Serializable { + + private static final long serialVersionUID = 1; + + private final String name; + + private final Map files = new LinkedHashMap(); + + SourceFolder(String name) { + this.name = name; + } + + public Set> getFilesEntrySet() { + return this.files.entrySet(); + } + + protected final void add(String name, ClassLoaderFile file) { + this.files.put(name, file); + } + + protected final void remove(String name) { + this.files.remove(name); + } + + protected final ClassLoaderFile get(String name) { + return this.files.get(name); + } + + /** + * Return the name of the source folder. + * @return the name of the source folder + */ + public String getName() { + return this.name; + } + + /** + * Return all {@link ClassLoaderFile ClassLoaderFiles} in the collection that are + * contained in this source folder. + * @return the files contained in the source folder + */ + public Collection getFiles() { + return Collections.unmodifiableCollection(this.files.values()); + } + + } + +} 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 index 7be6917d1b..fe35d27051 100644 --- 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 @@ -17,12 +17,16 @@ package org.springframework.boot.developertools.restart.classloader; import java.io.IOException; +import java.net.MalformedURLException; import java.net.URL; import java.net.URLClassLoader; +import java.security.AccessController; +import java.security.PrivilegedAction; import java.util.Enumeration; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFile.Kind; import org.springframework.core.SmartClassLoader; import org.springframework.util.Assert; @@ -38,25 +42,44 @@ public class RestartClassLoader extends URLClassLoader implements SmartClassLoad private final Log logger; + private final ClassLoaderFileRepository updatedFiles; + /** * Create a new {@link RestartClassLoader} instance. - * @param parent the parent classloader URLs were created. + * @param parent the parent classloader * @param urls the urls managed by the classloader */ public RestartClassLoader(ClassLoader parent, URL[] urls) { - this(parent, urls, LogFactory.getLog(RestartClassLoader.class)); + this(parent, urls, ClassLoaderFileRepository.NONE); } /** * Create a new {@link RestartClassLoader} instance. - * @param parent the parent classloader URLs were created. + * @param parent the parent classloader + * @param updatedFiles any files that have been updated since the JARs referenced in + * URLs were created. + * @param urls the urls managed by the classloader + */ + public RestartClassLoader(ClassLoader parent, URL[] urls, + ClassLoaderFileRepository updatedFiles) { + this(parent, urls, updatedFiles, LogFactory.getLog(RestartClassLoader.class)); + } + + /** + * Create a new {@link RestartClassLoader} instance. + * @param parent the parent classloader + * @param updatedFiles any files that have been updated since the JARs referenced in + * 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) { + public RestartClassLoader(ClassLoader parent, URL[] urls, + ClassLoaderFileRepository updatedFiles, Log logger) { super(urls, parent); Assert.notNull(parent, "Parent must not be null"); + Assert.notNull(updatedFiles, "UpdatedFiles must not be null"); Assert.notNull(logger, "Logger must not be null"); + this.updatedFiles = updatedFiles; this.logger = logger; if (logger.isDebugEnabled()) { logger.debug("Created RestartClassLoader " + toString()); @@ -66,11 +89,26 @@ public class RestartClassLoader extends URLClassLoader implements SmartClassLoad @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); + Enumeration resources = getParent().getResources(name); + ClassLoaderFile file = this.updatedFiles.getFile(name); + if (file != null) { + // Assume that we're replacing just the first item + if (resources.hasMoreElements()) { + resources.nextElement(); + } + if (file.getKind() != Kind.DELETED) { + return new CompoundEnumeration(createFileUrl(name, file), resources); + } + } + return resources; } @Override public URL getResource(String name) { + ClassLoaderFile file = this.updatedFiles.getFile(name); + if (file != null && file.getKind() == Kind.DELETED) { + return null; + } URL resource = findResource(name); if (resource != null) { return resource; @@ -78,8 +116,30 @@ public class RestartClassLoader extends URLClassLoader implements SmartClassLoad return getParent().getResource(name); } + @Override + public URL findResource(final String name) { + final ClassLoaderFile file = this.updatedFiles.getFile(name); + if (file == null) { + return super.findResource(name); + } + if (file.getKind() == Kind.DELETED) { + return null; + } + return AccessController.doPrivileged(new PrivilegedAction() { + @Override + public URL run() { + return createFileUrl(name, file); + } + }); + } + @Override public Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + String path = name.replace('.', '/').concat(".class"); + ClassLoaderFile file = this.updatedFiles.getFile(path); + if (file != null && file.getKind() == Kind.DELETED) { + throw new ClassNotFoundException(name); + } Class loadedClass = findLoadedClass(name); if (loadedClass == null) { try { @@ -95,6 +155,35 @@ public class RestartClassLoader extends URLClassLoader implements SmartClassLoad return loadedClass; } + @Override + protected Class findClass(final String name) throws ClassNotFoundException { + String path = name.replace('.', '/').concat(".class"); + final ClassLoaderFile file = this.updatedFiles.getFile(path); + if (file == null) { + return super.findClass(name); + } + if (file.getKind() == Kind.DELETED) { + throw new ClassNotFoundException(name); + } + return AccessController.doPrivileged(new PrivilegedAction>() { + @Override + public Class run() { + byte[] bytes = file.getContents(); + return defineClass(name, bytes, 0, bytes.length); + } + }); + } + + private URL createFileUrl(String name, ClassLoaderFile file) { + try { + return new URL("reloaded", null, -1, "/" + name, + new ClassLoaderFileURLStreamHandler(file)); + } + catch (MalformedURLException ex) { + throw new IllegalStateException(ex); + } + } + @Override protected void finalize() throws Throwable { if (this.logger.isDebugEnabled()) { @@ -108,4 +197,35 @@ public class RestartClassLoader extends URLClassLoader implements SmartClassLoad return (classType.getClassLoader() instanceof RestartClassLoader); } + /** + * Compound {@link Enumeration} that adds an additional item to the front. + */ + private static class CompoundEnumeration implements Enumeration { + + private E firstElement; + + private final Enumeration enumeration; + + public CompoundEnumeration(E firstElement, Enumeration enumeration) { + this.firstElement = firstElement; + this.enumeration = enumeration; + } + + @Override + public boolean hasMoreElements() { + return (this.firstElement != null || this.enumeration.hasMoreElements()); + } + + @Override + public E nextElement() { + if (this.firstElement == null) { + return this.enumeration.nextElement(); + } + E element = this.firstElement; + this.firstElement = null; + return element; + } + + } + } 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 index b0cac5be79..63bf0fa3e8 100644 --- 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 @@ -18,6 +18,8 @@ package org.springframework.boot.developertools.restart; import java.net.URL; import java.net.URLClassLoader; +import java.util.Collection; +import java.util.Collections; import java.util.concurrent.ThreadFactory; import org.junit.After; @@ -27,11 +29,15 @@ import org.junit.Test; import org.junit.rules.ExpectedException; import org.springframework.beans.BeansException; import org.springframework.beans.factory.ObjectFactory; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFile; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFile.Kind; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFiles; 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.FileCopyUtils; import org.springframework.util.StringUtils; import static org.hamcrest.Matchers.equalTo; @@ -100,6 +106,44 @@ public class RestarterTests { assertThat(attribute, equalTo((Object) "abc")); } + public void addUrlsMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Urls must not be null"); + Restarter.getInstance().addUrls(null); + } + + @Test + public void addUrls() throws Exception { + URL url = new URL("file:/proj/module-a.jar!/"); + Collection urls = Collections.singleton(url); + Restarter restarter = Restarter.getInstance(); + restarter.addUrls(urls); + restarter.restart(); + ClassLoader classLoader = ((TestableRestarter) restarter) + .getRelaunchClassLoader(); + assertThat(((URLClassLoader) classLoader).getURLs()[0], equalTo(url)); + } + + @Test + public void addClassLoaderFilesMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("ClassLoaderFiles must not be null"); + Restarter.getInstance().addClassLoaderFiles(null); + } + + @Test + public void addClassLoaderFiles() throws Exception { + ClassLoaderFiles classLoaderFiles = new ClassLoaderFiles(); + classLoaderFiles.addFile("f", new ClassLoaderFile(Kind.ADDED, "abc".getBytes())); + Restarter restarter = Restarter.getInstance(); + restarter.addClassLoaderFiles(classLoaderFiles); + restarter.restart(); + ClassLoader classLoader = ((TestableRestarter) restarter) + .getRelaunchClassLoader(); + assertThat(FileCopyUtils.copyToByteArray(classLoader.getResourceAsStream("f")), + equalTo("abc".getBytes())); + } + @Test @SuppressWarnings("rawtypes") public void getOrAddAttributeWithExistingAttribute() throws Exception { @@ -191,6 +235,8 @@ public class RestarterTests { private static class TestableRestarter extends Restarter { + private ClassLoader relaunchClassLoader; + public TestableRestarter() { this(Thread.currentThread(), new String[] {}, false, new MockRestartInitializer()); @@ -212,10 +258,19 @@ public class RestarterTests { } } + @Override + protected void relaunch(ClassLoader classLoader) throws Exception { + this.relaunchClassLoader = classLoader; + } + @Override protected void stop() throws Exception { } + public ClassLoader getRelaunchClassLoader() { + return this.relaunchClassLoader; + } + } } diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFileTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFileTests.java new file mode 100644 index 0000000000..0e3e772bbd --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFileTests.java @@ -0,0 +1,90 @@ +/* + * 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 org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFile; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFile.Kind; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; + +/** + * Tests for {@link ClassLoaderFile}. + * + * @author Phillip Webb + */ +public class ClassLoaderFileTests { + + public static final byte[] BYTES = "ABC".getBytes(); + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Test + public void kindMustNotBeNull() { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Kind must not be null"); + new ClassLoaderFile(null, null); + } + + @Test + public void addedContentsMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Contents must not be null"); + new ClassLoaderFile(Kind.ADDED, null); + } + + @Test + public void modifiedContentsMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Contents must not be null"); + new ClassLoaderFile(Kind.MODIFIED, null); + } + + @Test + public void deletedContentsMustBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Contents must be null"); + new ClassLoaderFile(Kind.DELETED, new byte[10]); + } + + @Test + public void added() throws Exception { + ClassLoaderFile file = new ClassLoaderFile(Kind.ADDED, BYTES); + assertThat(file.getKind(), equalTo(ClassLoaderFile.Kind.ADDED)); + assertThat(file.getContents(), equalTo(BYTES)); + } + + @Test + public void modified() throws Exception { + ClassLoaderFile file = new ClassLoaderFile(Kind.MODIFIED, BYTES); + assertThat(file.getKind(), equalTo(ClassLoaderFile.Kind.MODIFIED)); + assertThat(file.getContents(), equalTo(BYTES)); + } + + @Test + public void deleted() throws Exception { + ClassLoaderFile file = new ClassLoaderFile(Kind.DELETED, null); + assertThat(file.getKind(), equalTo(ClassLoaderFile.Kind.DELETED)); + assertThat(file.getContents(), nullValue()); + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFilesTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFilesTests.java new file mode 100644 index 0000000000..952731945d --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/restart/classloader/ClassLoaderFilesTests.java @@ -0,0 +1,184 @@ +/* + * 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.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFile.Kind; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFiles.SourceFolder; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link ClassLoaderFiles}. + * + * @author Phillip Webb + */ +public class ClassLoaderFilesTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + private ClassLoaderFiles files = new ClassLoaderFiles(); + + @Test + public void addFileNameMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Name must not be null"); + this.files.addFile(null, mock(ClassLoaderFile.class)); + } + + @Test + public void addFileFileMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("File must not be null"); + this.files.addFile("test", null); + } + + @Test + public void getFileWithNullName() throws Exception { + assertThat(this.files.getFile(null), nullValue()); + } + + @Test + public void addAndGet() throws Exception { + ClassLoaderFile file = new ClassLoaderFile(Kind.ADDED, new byte[10]); + this.files.addFile("myfile", file); + assertThat(this.files.getFile("myfile"), equalTo(file)); + } + + @Test + public void getMissing() throws Exception { + assertThat(this.files.getFile("missing"), nullValue()); + } + + @Test + public void addTwice() throws Exception { + ClassLoaderFile file1 = new ClassLoaderFile(Kind.ADDED, new byte[10]); + ClassLoaderFile file2 = new ClassLoaderFile(Kind.MODIFIED, new byte[10]); + this.files.addFile("myfile", file1); + this.files.addFile("myfile", file2); + assertThat(this.files.getFile("myfile"), equalTo(file2)); + } + + @Test + public void addTwiceInDifferentSourceFolders() throws Exception { + ClassLoaderFile file1 = new ClassLoaderFile(Kind.ADDED, new byte[10]); + ClassLoaderFile file2 = new ClassLoaderFile(Kind.MODIFIED, new byte[10]); + this.files.addFile("a", "myfile", file1); + this.files.addFile("b", "myfile", file2); + assertThat(this.files.getFile("myfile"), equalTo(file2)); + assertThat(this.files.getOrCreateSourceFolder("a").getFiles().size(), equalTo(0)); + assertThat(this.files.getOrCreateSourceFolder("b").getFiles().size(), equalTo(1)); + } + + @Test + public void getSourceFolders() throws Exception { + ClassLoaderFile file1 = new ClassLoaderFile(Kind.ADDED, new byte[10]); + ClassLoaderFile file2 = new ClassLoaderFile(Kind.MODIFIED, new byte[10]); + ClassLoaderFile file3 = new ClassLoaderFile(Kind.MODIFIED, new byte[10]); + ClassLoaderFile file4 = new ClassLoaderFile(Kind.MODIFIED, new byte[10]); + this.files.addFile("a", "myfile1", file1); + this.files.addFile("a", "myfile2", file2); + this.files.addFile("b", "myfile3", file3); + this.files.addFile("b", "myfile4", file4); + Iterator sourceFolders = this.files.getSourceFolders().iterator(); + SourceFolder sourceFolder1 = sourceFolders.next(); + SourceFolder sourceFolder2 = sourceFolders.next(); + assertThat(sourceFolders.hasNext(), equalTo(false)); + assertThat(sourceFolder1.getName(), equalTo("a")); + assertThat(sourceFolder2.getName(), equalTo("b")); + assertThat(new ArrayList(sourceFolder1.getFiles()), + equalTo(Arrays.asList(file1, file2))); + assertThat(new ArrayList(sourceFolder2.getFiles()), + equalTo(Arrays.asList(file3, file4))); + } + + @Test + public void serialzie() throws Exception { + ClassLoaderFile file = new ClassLoaderFile(Kind.ADDED, new byte[10]); + this.files.addFile("myfile", file); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + oos.writeObject(this.files); + oos.close(); + ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream( + bos.toByteArray())); + ClassLoaderFiles readObject = (ClassLoaderFiles) ois.readObject(); + assertThat(readObject.getFile("myfile"), notNullValue()); + } + + @Test + public void addAll() throws Exception { + ClassLoaderFile file1 = new ClassLoaderFile(Kind.ADDED, new byte[10]); + this.files.addFile("a", "myfile1", file1); + ClassLoaderFiles toAdd = new ClassLoaderFiles(); + ClassLoaderFile file2 = new ClassLoaderFile(Kind.MODIFIED, new byte[10]); + ClassLoaderFile file3 = new ClassLoaderFile(Kind.MODIFIED, new byte[10]); + toAdd.addFile("a", "myfile2", file2); + toAdd.addFile("b", "myfile3", file3); + this.files.addAll(toAdd); + Iterator sourceFolders = this.files.getSourceFolders().iterator(); + SourceFolder sourceFolder1 = sourceFolders.next(); + SourceFolder sourceFolder2 = sourceFolders.next(); + assertThat(sourceFolders.hasNext(), equalTo(false)); + assertThat(sourceFolder1.getName(), equalTo("a")); + assertThat(sourceFolder2.getName(), equalTo("b")); + assertThat(new ArrayList(sourceFolder1.getFiles()), + equalTo(Arrays.asList(file1, file2))); + } + + @Test + public void getSize() throws Exception { + this.files.addFile("s1", "n1", mock(ClassLoaderFile.class)); + this.files.addFile("s1", "n2", mock(ClassLoaderFile.class)); + this.files.addFile("s2", "n3", mock(ClassLoaderFile.class)); + this.files.addFile("s2", "n1", mock(ClassLoaderFile.class)); + assertThat(this.files.size(), equalTo(3)); + } + + @Test + public void classLoaderFilesMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("ClassLoaderFiles must not be null"); + new ClassLoaderFiles(null); + } + + @Test + public void constructFromExistingSet() throws Exception { + this.files.addFile("s1", "n1", mock(ClassLoaderFile.class)); + this.files.addFile("s1", "n2", mock(ClassLoaderFile.class)); + ClassLoaderFiles copy = new ClassLoaderFiles(this.files); + this.files.addFile("s2", "n3", mock(ClassLoaderFile.class)); + assertThat(this.files.size(), equalTo(3)); + assertThat(copy.size(), equalTo(2)); + } + +} 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 index 8f152ea388..ff09265364 100644 --- 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 @@ -34,6 +34,7 @@ import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; import org.junit.rules.TemporaryFolder; +import org.springframework.boot.developertools.restart.classloader.ClassLoaderFile.Kind; import org.springframework.util.FileCopyUtils; import org.springframework.util.StreamUtils; @@ -66,6 +67,8 @@ public class RestartClassLoaderTests { private URLClassLoader parentClassLoader; + private ClassLoaderFiles updatedFiles; + private RestartClassLoader reloadClassLoader; @Before @@ -75,7 +78,9 @@ public class RestartClassLoaderTests { ClassLoader classLoader = getClass().getClassLoader(); URL[] urls = new URL[] { url }; this.parentClassLoader = new URLClassLoader(urls, classLoader); - this.reloadClassLoader = new RestartClassLoader(this.parentClassLoader, urls); + this.updatedFiles = new ClassLoaderFiles(); + this.reloadClassLoader = new RestartClassLoader(this.parentClassLoader, urls, + this.updatedFiles); } private File createSampleJarFile() throws IOException { @@ -98,6 +103,13 @@ public class RestartClassLoaderTests { new RestartClassLoader(null, new URL[] {}); } + @Test + public void updatedFilesMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("UpdatedFiles must not be null"); + new RestartClassLoader(this.parentClassLoader, new URL[] {}, null); + } + @Test public void getResourceFromReloadableUrl() throws Exception { String content = readString(this.reloadClassLoader @@ -131,6 +143,73 @@ public class RestartClassLoaderTests { assertThat(loaded.getClassLoader(), equalTo(getClass().getClassLoader())); } + @Test + public void getDeletedResource() throws Exception { + String name = PACKAGE_PATH + "/Sample.txt"; + this.updatedFiles.addFile(name, new ClassLoaderFile(Kind.DELETED, null)); + assertThat(this.reloadClassLoader.getResource(name), equalTo(null)); + } + + @Test + public void getDeletedResourceAsStream() throws Exception { + String name = PACKAGE_PATH + "/Sample.txt"; + this.updatedFiles.addFile(name, new ClassLoaderFile(Kind.DELETED, null)); + assertThat(this.reloadClassLoader.getResourceAsStream(name), equalTo(null)); + } + + @Test + public void getUpdatedResource() throws Exception { + String name = PACKAGE_PATH + "/Sample.txt"; + byte[] bytes = "abc".getBytes(); + this.updatedFiles.addFile(name, new ClassLoaderFile(Kind.MODIFIED, bytes)); + URL resource = this.reloadClassLoader.getResource(name); + assertThat(FileCopyUtils.copyToByteArray(resource.openStream()), equalTo(bytes)); + } + + @Test + public void getResourcesWithDeleted() throws Exception { + String name = PACKAGE_PATH + "/Sample.txt"; + this.updatedFiles.addFile(name, new ClassLoaderFile(Kind.DELETED, null)); + List resources = toList(this.reloadClassLoader.getResources(name)); + assertThat(resources.size(), equalTo(0)); + } + + @Test + public void getResourcesWithUpdated() throws Exception { + String name = PACKAGE_PATH + "/Sample.txt"; + byte[] bytes = "abc".getBytes(); + this.updatedFiles.addFile(name, new ClassLoaderFile(Kind.MODIFIED, bytes)); + List resources = toList(this.reloadClassLoader.getResources(name)); + assertThat(FileCopyUtils.copyToByteArray(resources.get(0).openStream()), + equalTo(bytes)); + } + + @Test + public void getDeletedClass() throws Exception { + String name = PACKAGE_PATH + "/Sample.class"; + this.updatedFiles.addFile(name, new ClassLoaderFile(Kind.DELETED, null)); + this.thrown.expect(ClassNotFoundException.class); + this.reloadClassLoader.loadClass(PACKAGE + ".Sample"); + } + + @Test + public void getUpdatedClass() throws Exception { + String name = PACKAGE_PATH + "/Sample.class"; + this.updatedFiles.addFile(name, new ClassLoaderFile(Kind.MODIFIED, new byte[10])); + this.thrown.expect(ClassFormatError.class); + this.reloadClassLoader.loadClass(PACKAGE + ".Sample"); + } + + @Test + public void getAddedClass() throws Exception { + String name = PACKAGE_PATH + "/SampleParent.class"; + byte[] bytes = FileCopyUtils.copyToByteArray(getClass().getResourceAsStream( + "SampleParent.class")); + this.updatedFiles.addFile(name, new ClassLoaderFile(Kind.ADDED, bytes)); + Class loaded = this.reloadClassLoader.loadClass(PACKAGE + ".SampleParent"); + assertThat(loaded.getClassLoader(), equalTo((ClassLoader) this.reloadClassLoader)); + } + private String readString(InputStream in) throws IOException { return new String(FileCopyUtils.copyToByteArray(in)); }