Browse Source
Provide auto-configuration for remote application update and restart. Local classpath changes are now monitored via RemoteSpringApplication and pushed to the remote server. See gh-3086pull/3077/merge
8 changed files with 624 additions and 1 deletions
@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
|
||||
/* |
||||
* 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.ByteArrayOutputStream; |
||||
import java.io.IOException; |
||||
import java.io.ObjectOutputStream; |
||||
import java.net.MalformedURLException; |
||||
import java.net.URI; |
||||
import java.net.URISyntaxException; |
||||
import java.net.URL; |
||||
import java.util.Collections; |
||||
import java.util.HashMap; |
||||
import java.util.Map; |
||||
|
||||
import org.apache.commons.logging.Log; |
||||
import org.apache.commons.logging.LogFactory; |
||||
import org.springframework.boot.developertools.classpath.ClassPathChangedEvent; |
||||
import org.springframework.boot.developertools.filewatch.ChangedFile; |
||||
import org.springframework.boot.developertools.filewatch.ChangedFiles; |
||||
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.context.ApplicationListener; |
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.http.HttpMethod; |
||||
import org.springframework.http.HttpStatus; |
||||
import org.springframework.http.MediaType; |
||||
import org.springframework.http.client.ClientHttpRequest; |
||||
import org.springframework.http.client.ClientHttpRequestFactory; |
||||
import org.springframework.http.client.ClientHttpResponse; |
||||
import org.springframework.util.Assert; |
||||
import org.springframework.util.FileCopyUtils; |
||||
|
||||
/** |
||||
* Listens and pushes any classpath updates to a remote endpoint. |
||||
* |
||||
* @author Phillip Webb |
||||
* @since 1.3.0 |
||||
*/ |
||||
public class ClassPathChangeUploader implements |
||||
ApplicationListener<ClassPathChangedEvent> { |
||||
|
||||
private static final Map<ChangedFile.Type, ClassLoaderFile.Kind> TYPE_MAPPINGS; |
||||
static { |
||||
Map<ChangedFile.Type, ClassLoaderFile.Kind> map = new HashMap<ChangedFile.Type, ClassLoaderFile.Kind>(); |
||||
map.put(ChangedFile.Type.ADD, ClassLoaderFile.Kind.ADDED); |
||||
map.put(ChangedFile.Type.DELETE, ClassLoaderFile.Kind.DELETED); |
||||
map.put(ChangedFile.Type.MODIFY, ClassLoaderFile.Kind.MODIFIED); |
||||
TYPE_MAPPINGS = Collections.unmodifiableMap(map); |
||||
} |
||||
|
||||
private static final Log logger = LogFactory.getLog(ClassPathChangeUploader.class); |
||||
|
||||
private final URI uri; |
||||
|
||||
private final ClientHttpRequestFactory requestFactory; |
||||
|
||||
public ClassPathChangeUploader(String url, ClientHttpRequestFactory requestFactory) { |
||||
Assert.hasLength(url, "URL must not be empty"); |
||||
Assert.notNull(requestFactory, "RequestFactory must not be null"); |
||||
try { |
||||
this.uri = new URL(url).toURI(); |
||||
} |
||||
catch (URISyntaxException ex) { |
||||
throw new IllegalArgumentException("Malformed URL '" + url + "'"); |
||||
} |
||||
catch (MalformedURLException ex) { |
||||
throw new IllegalArgumentException("Malformed URL '" + url + "'"); |
||||
} |
||||
this.requestFactory = requestFactory; |
||||
} |
||||
|
||||
@Override |
||||
public void onApplicationEvent(ClassPathChangedEvent event) { |
||||
try { |
||||
ClassLoaderFiles classLoaderFiles = getClassLoaderFiles(event); |
||||
ClientHttpRequest request = this.requestFactory.createRequest(this.uri, |
||||
HttpMethod.POST); |
||||
byte[] bytes = serialize(classLoaderFiles); |
||||
HttpHeaders headers = request.getHeaders(); |
||||
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); |
||||
headers.setContentLength(bytes.length); |
||||
FileCopyUtils.copy(bytes, request.getBody()); |
||||
logUpload(classLoaderFiles); |
||||
ClientHttpResponse response = request.execute(); |
||||
Assert.state(response.getStatusCode() == HttpStatus.OK, "Unexpected " |
||||
+ response.getStatusCode() + " response uploading class files"); |
||||
} |
||||
catch (IOException ex) { |
||||
throw new IllegalStateException(ex); |
||||
} |
||||
} |
||||
|
||||
private void logUpload(ClassLoaderFiles classLoaderFiles) { |
||||
int size = classLoaderFiles.size(); |
||||
logger.info("Uploaded " + size + " class " |
||||
+ (size == 1 ? "resource" : "resources")); |
||||
} |
||||
|
||||
private byte[] serialize(ClassLoaderFiles classLoaderFiles) throws IOException { |
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); |
||||
ObjectOutputStream objectOutputStream = new ObjectOutputStream(outputStream); |
||||
objectOutputStream.writeObject(classLoaderFiles); |
||||
objectOutputStream.close(); |
||||
return outputStream.toByteArray(); |
||||
} |
||||
|
||||
private ClassLoaderFiles getClassLoaderFiles(ClassPathChangedEvent event) |
||||
throws IOException { |
||||
ClassLoaderFiles files = new ClassLoaderFiles(); |
||||
for (ChangedFiles changedFiles : event.getChangeSet()) { |
||||
String sourceFolder = changedFiles.getSourceFolder().getAbsolutePath(); |
||||
for (ChangedFile changedFile : changedFiles) { |
||||
files.addFile(sourceFolder, changedFile.getRelativeName(), |
||||
asClassLoaderFile(changedFile)); |
||||
} |
||||
} |
||||
return files; |
||||
} |
||||
|
||||
private ClassLoaderFile asClassLoaderFile(ChangedFile changedFile) throws IOException { |
||||
ClassLoaderFile.Kind kind = TYPE_MAPPINGS.get(changedFile.getType()); |
||||
byte[] bytes = (kind == Kind.DELETED ? null : FileCopyUtils |
||||
.copyToByteArray(changedFile.getFile())); |
||||
long lastModified = (kind == Kind.DELETED ? System.currentTimeMillis() |
||||
: changedFile.getFile().lastModified()); |
||||
return new ClassLoaderFile(kind, lastModified, bytes); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,148 @@
@@ -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.remote.client; |
||||
|
||||
import java.io.ByteArrayInputStream; |
||||
import java.io.File; |
||||
import java.io.IOException; |
||||
import java.io.ObjectInputStream; |
||||
import java.util.Collection; |
||||
import java.util.Iterator; |
||||
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.junit.rules.TemporaryFolder; |
||||
import org.springframework.boot.developertools.classpath.ClassPathChangedEvent; |
||||
import org.springframework.boot.developertools.filewatch.ChangedFile; |
||||
import org.springframework.boot.developertools.filewatch.ChangedFile.Type; |
||||
import org.springframework.boot.developertools.filewatch.ChangedFiles; |
||||
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.developertools.restart.classloader.ClassLoaderFiles.SourceFolder; |
||||
import org.springframework.boot.developertools.test.MockClientHttpRequestFactory; |
||||
import org.springframework.http.HttpStatus; |
||||
import org.springframework.mock.http.client.MockClientHttpRequest; |
||||
import org.springframework.util.FileCopyUtils; |
||||
|
||||
import static org.hamcrest.Matchers.equalTo; |
||||
import static org.junit.Assert.assertThat; |
||||
|
||||
/** |
||||
* Tests for {@link ClassPathChangeUploader}. |
||||
* |
||||
* @author Phillip Webb |
||||
*/ |
||||
public class ClassPathChangeUploaderTests { |
||||
|
||||
@Rule |
||||
public ExpectedException thrown = ExpectedException.none(); |
||||
|
||||
@Rule |
||||
public TemporaryFolder temp = new TemporaryFolder(); |
||||
|
||||
private MockClientHttpRequestFactory requestFactory; |
||||
|
||||
private ClassPathChangeUploader uploader; |
||||
|
||||
@Before |
||||
public void setup() { |
||||
this.requestFactory = new MockClientHttpRequestFactory(); |
||||
this.uploader = new ClassPathChangeUploader("http://localhost/upload", |
||||
this.requestFactory); |
||||
} |
||||
|
||||
@Test |
||||
public void urlMustNotBeNull() throws Exception { |
||||
this.thrown.expect(IllegalArgumentException.class); |
||||
this.thrown.expectMessage("URL must not be empty"); |
||||
new ClassPathChangeUploader(null, this.requestFactory); |
||||
} |
||||
|
||||
@Test |
||||
public void urlMustNotBeEmpty() throws Exception { |
||||
this.thrown.expect(IllegalArgumentException.class); |
||||
this.thrown.expectMessage("URL must not be empty"); |
||||
new ClassPathChangeUploader("", this.requestFactory); |
||||
} |
||||
|
||||
@Test |
||||
public void requestFactoryMustNotBeNull() throws Exception { |
||||
this.thrown.expect(IllegalArgumentException.class); |
||||
this.thrown.expectMessage("RequestFactory must not be null"); |
||||
new ClassPathChangeUploader("http://localhost:8080", null); |
||||
} |
||||
|
||||
@Test |
||||
public void urlMustNotBeMalformed() throws Exception { |
||||
this.thrown.expect(IllegalArgumentException.class); |
||||
this.thrown.expectMessage("Malformed URL 'htttttp:///ttest'"); |
||||
new ClassPathChangeUploader("htttttp:///ttest", this.requestFactory); |
||||
} |
||||
|
||||
@Test |
||||
public void sendsClassLoaderFiles() throws Exception { |
||||
File sourceFolder = this.temp.newFolder(); |
||||
Set<ChangedFile> files = new LinkedHashSet<ChangedFile>(); |
||||
File file1 = createFile(sourceFolder, "File1"); |
||||
File file2 = createFile(sourceFolder, "File2"); |
||||
File file3 = createFile(sourceFolder, "File3"); |
||||
files.add(new ChangedFile(sourceFolder, file1, Type.ADD)); |
||||
files.add(new ChangedFile(sourceFolder, file2, Type.MODIFY)); |
||||
files.add(new ChangedFile(sourceFolder, file3, Type.DELETE)); |
||||
Set<ChangedFiles> changeSet = new LinkedHashSet<ChangedFiles>(); |
||||
changeSet.add(new ChangedFiles(sourceFolder, files)); |
||||
ClassPathChangedEvent event = new ClassPathChangedEvent(this, changeSet, false); |
||||
this.requestFactory.willRespond(HttpStatus.OK); |
||||
this.uploader.onApplicationEvent(event); |
||||
MockClientHttpRequest request = this.requestFactory.getExecutedRequests().get(0); |
||||
ClassLoaderFiles classLoaderFiles = deserialize(request.getBodyAsBytes()); |
||||
Collection<SourceFolder> sourceFolders = classLoaderFiles.getSourceFolders(); |
||||
assertThat(sourceFolders.size(), equalTo(1)); |
||||
SourceFolder classSourceFolder = sourceFolders.iterator().next(); |
||||
assertThat(classSourceFolder.getName(), equalTo(sourceFolder.getAbsolutePath())); |
||||
Iterator<ClassLoaderFile> classFiles = classSourceFolder.getFiles().iterator(); |
||||
assertClassFile(classFiles.next(), "File1", ClassLoaderFile.Kind.ADDED); |
||||
assertClassFile(classFiles.next(), "File2", ClassLoaderFile.Kind.MODIFIED); |
||||
assertClassFile(classFiles.next(), null, ClassLoaderFile.Kind.DELETED); |
||||
assertThat(classFiles.hasNext(), equalTo(false)); |
||||
} |
||||
|
||||
private void assertClassFile(ClassLoaderFile file, String content, Kind kind) { |
||||
assertThat(file.getContents(), |
||||
equalTo(content == null ? null : content.getBytes())); |
||||
assertThat(file.getKind(), equalTo(kind)); |
||||
} |
||||
|
||||
private File createFile(File sourceFolder, String name) throws IOException { |
||||
File file = new File(sourceFolder, name); |
||||
FileCopyUtils.copy(name.getBytes(), file); |
||||
return file; |
||||
} |
||||
|
||||
private ClassLoaderFiles deserialize(byte[] bytes) throws IOException, |
||||
ClassNotFoundException { |
||||
ObjectInputStream objectInputStream = new ObjectInputStream( |
||||
new ByteArrayInputStream(bytes)); |
||||
return (ClassLoaderFiles) objectInputStream.readObject(); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,133 @@
@@ -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.test; |
||||
|
||||
import java.io.IOException; |
||||
import java.net.URI; |
||||
import java.util.ArrayDeque; |
||||
import java.util.ArrayList; |
||||
import java.util.Deque; |
||||
import java.util.List; |
||||
import java.util.concurrent.atomic.AtomicLong; |
||||
|
||||
import org.springframework.http.HttpMethod; |
||||
import org.springframework.http.HttpStatus; |
||||
import org.springframework.http.MediaType; |
||||
import org.springframework.http.client.ClientHttpRequest; |
||||
import org.springframework.http.client.ClientHttpRequestFactory; |
||||
import org.springframework.http.client.ClientHttpResponse; |
||||
import org.springframework.mock.http.client.MockClientHttpRequest; |
||||
import org.springframework.mock.http.client.MockClientHttpResponse; |
||||
|
||||
/** |
||||
* Mock {@link ClientHttpRequestFactory}. |
||||
* |
||||
* @author Phillip Webb |
||||
*/ |
||||
public class MockClientHttpRequestFactory implements ClientHttpRequestFactory { |
||||
|
||||
private AtomicLong seq = new AtomicLong(); |
||||
|
||||
private Deque<Response> responses = new ArrayDeque<Response>(); |
||||
|
||||
private List<MockClientHttpRequest> executedRequests = new ArrayList<MockClientHttpRequest>(); |
||||
|
||||
@Override |
||||
public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) |
||||
throws IOException { |
||||
return new MockRequest(uri, httpMethod); |
||||
} |
||||
|
||||
public void willRespond(HttpStatus... response) { |
||||
for (HttpStatus status : response) { |
||||
this.responses.add(new Response(0, null, status)); |
||||
} |
||||
} |
||||
|
||||
public void willRespond(String... response) { |
||||
for (String payload : response) { |
||||
this.responses.add(new Response(0, payload.getBytes(), HttpStatus.OK)); |
||||
} |
||||
} |
||||
|
||||
public void willRespondAfterDelay(int delay, HttpStatus status) { |
||||
this.responses.add(new Response(delay, null, status)); |
||||
} |
||||
|
||||
public List<MockClientHttpRequest> getExecutedRequests() { |
||||
return this.executedRequests; |
||||
} |
||||
|
||||
private class MockRequest extends MockClientHttpRequest { |
||||
|
||||
public MockRequest(URI uri, HttpMethod httpMethod) { |
||||
super(httpMethod, uri); |
||||
} |
||||
|
||||
@Override |
||||
protected ClientHttpResponse executeInternal() throws IOException { |
||||
MockClientHttpRequestFactory.this.executedRequests.add(this); |
||||
Response response = MockClientHttpRequestFactory.this.responses.pollFirst(); |
||||
if (response == null) { |
||||
response = new Response(0, null, HttpStatus.GONE); |
||||
} |
||||
return response.asHttpResponse(MockClientHttpRequestFactory.this.seq); |
||||
} |
||||
|
||||
} |
||||
|
||||
static class Response { |
||||
|
||||
private final int delay; |
||||
|
||||
private final byte[] payload; |
||||
|
||||
private final HttpStatus status; |
||||
|
||||
public Response(int delay, byte[] payload, HttpStatus status) { |
||||
this.delay = delay; |
||||
this.payload = payload; |
||||
this.status = status; |
||||
} |
||||
|
||||
public ClientHttpResponse asHttpResponse(AtomicLong seq) { |
||||
MockClientHttpResponse httpResponse = new MockClientHttpResponse( |
||||
this.payload, this.status); |
||||
waitForDelay(); |
||||
if (this.payload != null) { |
||||
httpResponse.getHeaders().setContentLength(this.payload.length); |
||||
httpResponse.getHeaders().setContentType( |
||||
MediaType.APPLICATION_OCTET_STREAM); |
||||
httpResponse.getHeaders().add("x-seq", |
||||
Long.toString(seq.incrementAndGet())); |
||||
} |
||||
return httpResponse; |
||||
} |
||||
|
||||
private void waitForDelay() { |
||||
if (this.delay > 0) { |
||||
try { |
||||
Thread.sleep(this.delay); |
||||
} |
||||
catch (InterruptedException ex) { |
||||
} |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue