From c27b63b3544540c50397688a698cfde9f51dcdb1 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 1 Jun 2015 13:23:47 -0700 Subject: [PATCH] Trigger livereload on remote updates Add livereload support to RemoteClientConfiguration which is triggered whenever updates are pushed to the remote application. Closes gh-3086 --- .../client/DelayedLiveReloadTrigger.java | 116 +++++++++++++++ .../client/RemoteClientConfiguration.java | 55 ++++++++ .../client/DelayedLiveReloadTriggerTests.java | 133 ++++++++++++++++++ .../RemoteClientConfigurationTests.java | 35 +++++ 4 files changed, 339 insertions(+) create mode 100644 spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/DelayedLiveReloadTrigger.java create mode 100644 spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/remote/client/DelayedLiveReloadTriggerTests.java diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/DelayedLiveReloadTrigger.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/DelayedLiveReloadTrigger.java new file mode 100644 index 0000000000..594140dc40 --- /dev/null +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/DelayedLiveReloadTrigger.java @@ -0,0 +1,116 @@ +/* + * 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.remote.client; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.boot.developertools.autoconfigure.OptionalLiveReloadServer; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.client.ClientHttpRequest; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.util.Assert; + +/** + * {@link Runnable} that waits to triggers live reload until the remote server has + * restarted. + * + * @author Phillip Webb + */ +class DelayedLiveReloadTrigger implements Runnable { + + private static final long SHUTDOWN_TIME = 1000; + + private static final long SLEEP_TIME = 500; + + private static final long TIMEOUT = 30000; + + private static final Log logger = LogFactory.getLog(DelayedLiveReloadTrigger.class); + + private final OptionalLiveReloadServer liveReloadServer; + + private final ClientHttpRequestFactory requestFactory; + + private final URI uri; + + private long shutdownTime = SHUTDOWN_TIME; + + private long sleepTime = SLEEP_TIME; + + private long timeout = TIMEOUT; + + public DelayedLiveReloadTrigger(OptionalLiveReloadServer liveReloadServer, + ClientHttpRequestFactory requestFactory, String url) { + Assert.notNull(liveReloadServer, "LiveReloadServer must not be null"); + Assert.notNull(requestFactory, "RequestFactory must not be null"); + Assert.hasLength(url, "URL must not be empty"); + this.liveReloadServer = liveReloadServer; + this.requestFactory = requestFactory; + try { + this.uri = new URI(url); + } + catch (URISyntaxException ex) { + throw new IllegalArgumentException(ex); + } + } + + protected void setTimings(long shutdown, long sleep, long timeout) { + this.shutdownTime = shutdown; + this.sleepTime = sleep; + this.timeout = timeout; + } + + @Override + public void run() { + try { + Thread.sleep(this.shutdownTime); + long start = System.currentTimeMillis(); + while (!isUp()) { + long runTime = System.currentTimeMillis() - start; + if (runTime > this.timeout) { + return; + } + Thread.sleep(this.sleepTime); + } + logger.info("Remote server has changed, triggering LiveReload"); + this.liveReloadServer.triggerReload(); + } + catch (InterruptedException ex) { + } + } + + private boolean isUp() { + try { + ClientHttpRequest request = createRequest(); + ClientHttpResponse response = request.execute(); + return response.getStatusCode() == HttpStatus.OK; + } + catch (Exception ex) { + return false; + } + } + + private ClientHttpRequest createRequest() throws IOException { + return this.requestFactory.createRequest(this.uri, HttpMethod.GET); + } + +} diff --git a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/RemoteClientConfiguration.java b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/RemoteClientConfiguration.java index 461888cb48..10f8d15d28 100644 --- a/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/RemoteClientConfiguration.java +++ b/spring-boot-developer-tools/src/main/java/org/springframework/boot/developertools/remote/client/RemoteClientConfiguration.java @@ -17,6 +17,8 @@ package org.springframework.boot.developertools.remote.client; import java.net.URL; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import javax.annotation.PostConstruct; @@ -24,16 +26,23 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +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.autoconfigure.DeveloperToolsProperties; +import org.springframework.boot.developertools.autoconfigure.OptionalLiveReloadServer; import org.springframework.boot.developertools.autoconfigure.RemoteDeveloperToolsProperties; +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.livereload.LiveReloadServer; import org.springframework.boot.developertools.restart.DefaultRestartInitializer; +import org.springframework.boot.developertools.restart.RestartScope; +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; import org.springframework.context.support.PropertySourcesPlaceholderConfigurer; import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.SimpleClientHttpRequestFactory; @@ -75,6 +84,52 @@ public class RemoteClientConfiguration { } } + /** + * LiveReload configuration. + */ + @ConditionalOnProperty(prefix = "spring.developertools.livereload", name = "enabled", matchIfMissing = true) + static class LiveReloadConfiguration { + + @Autowired + private DeveloperToolsProperties properties; + + @Autowired(required = false) + private LiveReloadServer liveReloadServer; + + @Autowired + private ClientHttpRequestFactory clientHttpRequestFactory; + + @Value("${remoteUrl}") + private String remoteUrl; + + private ExecutorService executor = Executors.newSingleThreadExecutor(); + + @Bean + @RestartScope + @ConditionalOnMissingBean + public LiveReloadServer liveReloadServer() { + return new LiveReloadServer(this.properties.getLivereload().getPort(), + Restarter.getInstance().getThreadFactory()); + } + + @EventListener + public void onClassPathChanged(ClassPathChangedEvent event) { + String url = this.remoteUrl + this.properties.getRemote().getContextPath(); + this.executor.execute(new DelayedLiveReloadTrigger( + optionalLiveReloadServer(), this.clientHttpRequestFactory, url)); + } + + @Bean + public OptionalLiveReloadServer optionalLiveReloadServer() { + return new OptionalLiveReloadServer(this.liveReloadServer); + } + + final ExecutorService getExecutor() { + return this.executor; + } + + } + /** * Client configuration for remote update and restarts. */ diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/remote/client/DelayedLiveReloadTriggerTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/remote/client/DelayedLiveReloadTriggerTests.java new file mode 100644 index 0000000000..c0378e5180 --- /dev/null +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/remote/client/DelayedLiveReloadTriggerTests.java @@ -0,0 +1,133 @@ +/* + * 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.remote.client; + +import java.io.IOException; +import java.net.URI; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.boot.developertools.autoconfigure.OptionalLiveReloadServer; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.client.ClientHttpRequest; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpResponse; + +import static org.hamcrest.Matchers.greaterThan; +import static org.junit.Assert.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link DelayedLiveReloadTrigger}. + * + * @author Phillip Webb + */ +public class DelayedLiveReloadTriggerTests { + + private static final String URL = "http://localhost:8080"; + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Mock + private OptionalLiveReloadServer liveReloadServer; + + @Mock + private ClientHttpRequestFactory requestFactory; + + @Mock + private ClientHttpRequest errorRequest; + + @Mock + private ClientHttpRequest okRequest; + + @Mock + private ClientHttpResponse errorResponse; + + @Mock + private ClientHttpResponse okResponse; + + private DelayedLiveReloadTrigger trigger; + + @Before + public void setup() throws IOException { + MockitoAnnotations.initMocks(this); + given(this.errorRequest.execute()).willReturn(this.errorResponse); + given(this.okRequest.execute()).willReturn(this.okResponse); + given(this.errorResponse.getStatusCode()).willReturn( + HttpStatus.INTERNAL_SERVER_ERROR); + given(this.okResponse.getStatusCode()).willReturn(HttpStatus.OK); + this.trigger = new DelayedLiveReloadTrigger(this.liveReloadServer, + this.requestFactory, URL); + } + + @Test + public void liveReloadServerMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("LiveReloadServer must not be null"); + new DelayedLiveReloadTrigger(null, this.requestFactory, URL); + } + + @Test + public void requestFactoryMustNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("RequestFactory must not be null"); + new DelayedLiveReloadTrigger(this.liveReloadServer, null, URL); + } + + @Test + public void urlMostNotBeNull() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("URL must not be empty"); + new DelayedLiveReloadTrigger(this.liveReloadServer, this.requestFactory, null); + } + + @Test + public void urlMustNotBeEmpty() throws Exception { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("URL must not be empty"); + new DelayedLiveReloadTrigger(this.liveReloadServer, this.requestFactory, ""); + } + + @Test + public void triggerReloadOnStatus() throws Exception { + given(this.requestFactory.createRequest(new URI(URL), HttpMethod.GET)).willThrow( + new IOException()).willReturn(this.errorRequest, this.okRequest); + long startTime = System.currentTimeMillis(); + this.trigger.setTimings(10, 200, 30000); + this.trigger.run(); + assertThat(System.currentTimeMillis() - startTime, greaterThan(300L)); + verify(this.liveReloadServer).triggerReload(); + } + + @Test + public void timeout() throws Exception { + given(this.requestFactory.createRequest(new URI(URL), HttpMethod.GET)).willThrow( + new IOException()); + this.trigger.setTimings(10, 0, 10); + this.trigger.run(); + verify(this.liveReloadServer, never()).triggerReload(); + } + +} diff --git a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/remote/client/RemoteClientConfigurationTests.java b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/remote/client/RemoteClientConfigurationTests.java index 539c8f8f63..efaade66d9 100644 --- a/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/remote/client/RemoteClientConfigurationTests.java +++ b/spring-boot-developer-tools/src/test/java/org/springframework/boot/developertools/remote/client/RemoteClientConfigurationTests.java @@ -17,6 +17,9 @@ package org.springframework.boot.developertools.remote.client; import java.io.IOException; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; import org.junit.After; import org.junit.Rule; @@ -25,7 +28,12 @@ import org.junit.rules.ExpectedException; import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext; import org.springframework.boot.context.embedded.tomcat.TomcatEmbeddedServletContainerFactory; +import org.springframework.boot.developertools.autoconfigure.OptionalLiveReloadServer; +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.livereload.LiveReloadServer; +import org.springframework.boot.developertools.remote.client.RemoteClientConfiguration.LiveReloadConfiguration; import org.springframework.boot.developertools.remote.server.Dispatcher; import org.springframework.boot.developertools.remote.server.DispatcherFilter; import org.springframework.boot.developertools.restart.MockRestarter; @@ -44,6 +52,7 @@ import static org.junit.Assert.assertThat; import static org.mockito.BDDMockito.given; import static org.mockito.Matchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; /** * Tests for {@link RemoteClientConfiguration}. @@ -85,6 +94,27 @@ public class RemoteClientConfigurationTests { assertThat(this.output.toString(), not(containsString("is insecure"))); } + @Test + public void liveReloadOnClassPathChanged() throws Exception { + configure(); + Set changeSet = new HashSet(); + ClassPathChangedEvent event = new ClassPathChangedEvent(this, changeSet, false); + this.context.publishEvent(event); + LiveReloadConfiguration configuration = this.context + .getBean(LiveReloadConfiguration.class); + configuration.getExecutor().shutdown(); + configuration.getExecutor().awaitTermination(2, TimeUnit.SECONDS); + LiveReloadServer server = this.context.getBean(LiveReloadServer.class); + verify(server).triggerReload(); + } + + @Test + public void liveReloadDisabled() throws Exception { + configure("spring.developertools.livereload.enabled:false"); + this.thrown.expect(NoSuchBeanDefinitionException.class); + this.context.getBean(OptionalLiveReloadServer.class); + } + @Test public void remoteRestartDisabled() throws Exception { configure("spring.developertools.remote.restart.enabled:false"); @@ -115,6 +145,11 @@ public class RemoteClientConfigurationTests { return new TomcatEmbeddedServletContainerFactory(remotePort); } + @Bean + public LiveReloadServer liveReloadServer() { + return mock(LiveReloadServer.class); + } + @Bean public DispatcherFilter dispatcherFilter() throws IOException { return new DispatcherFilter(dispatcher());