Support HTTP range requests in Controllers

Prior to this commit, HTTP Range requests were only supported by the
ResourceHttpRequestHandler when serving static resources.

This commit improves the ResourceHttpMessageConverter that
now supports partial writes of Resources.
For this, the `HttpEntityMethodProcessor` and
`RequestResponseBodyMethodProcessor` now wrap resources with HTTP
range information in a `HttpRangeResource`, if necessary. The
message converter handle those types and knows how to handle partial
writes.

Controller methods can now handle Range requests for
return types that extend Resource or HttpEntity:

    @RequestMapping("/example/video.mp4")
    public Resource handler() { }

    @RequestMapping("/example/video.mp4")
    public HttpEntity<Resource> handler() { }

Issue: SPR-13834
This commit is contained in:
Brian Clozel
2016-03-22 19:06:36 +01:00
parent 15fe8279e6
commit c7bd3b8440
10 changed files with 700 additions and 327 deletions

View File

@@ -0,0 +1,119 @@
/*
* Copyright 2002-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.http;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.util.List;
import org.springframework.core.io.Resource;
import org.springframework.util.Assert;
/**
* Holder that combines a {@link Resource} descriptor with
* {@link HttpRange} information to be used for reading
* selected parts of the resource.
*
* <p>Used as an argument for partial conversion operations in
* {@link org.springframework.http.converter.ResourceHttpMessageConverter}.
*
* @author Brian Clozel
* @since 4.3.0
*/
public class HttpRangeResource implements Resource {
private final List<HttpRange> httpRanges;
private final Resource resource;
public HttpRangeResource(List<HttpRange> httpRanges, Resource resource) {
Assert.notEmpty(httpRanges, "list of HTTP Ranges should not be empty");
this.httpRanges = httpRanges;
this.resource = resource;
}
/**
* Return the list of HTTP (byte) ranges describing the requested
* parts of the Resource, as provided by the HTTP Range request.
*/
public final List<HttpRange> getHttpRanges() {
return httpRanges;
}
@Override
public boolean exists() {
return resource.exists();
}
@Override
public boolean isReadable() {
return resource.isReadable();
}
@Override
public boolean isOpen() {
return resource.isOpen();
}
@Override
public URL getURL() throws IOException {
return resource.getURL();
}
@Override
public URI getURI() throws IOException {
return resource.getURI();
}
@Override
public File getFile() throws IOException {
return resource.getFile();
}
@Override
public long contentLength() throws IOException {
return resource.contentLength();
}
@Override
public long lastModified() throws IOException {
return resource.lastModified();
}
@Override
public Resource createRelative(String relativePath) throws IOException {
return resource.createRelative(relativePath);
}
@Override
public String getFilename() {
return resource.getFilename();
}
@Override
public String getDescription() {
return resource.getDescription();
}
@Override
public InputStream getInputStream() throws IOException {
return resource.getInputStream();
}
}

View File

@@ -16,8 +16,12 @@
package org.springframework.http.converter;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.List;
import javax.activation.FileTypeMap;
import javax.activation.MimetypesFileTypeMap;
@@ -25,23 +29,33 @@ import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.HttpRange;
import org.springframework.http.HttpRangeResource;
import org.springframework.http.MediaType;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.MimeTypeUtils;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
/**
* Implementation of {@link HttpMessageConverter} that can read and write {@link Resource Resources}.
* Implementation of {@link HttpMessageConverter} that can read and write {@link Resource Resources}
* and supports byte range requests.
*
* <p>By default, this converter can read all media types. The Java Activation Framework (JAF) -
* if available - is used to determine the {@code Content-Type} of written resources.
* If JAF is not available, {@code application/octet-stream} is used.
*
* <p>This converter supports HTTP byte range requests and can write partial content, when provided
* with an {@link HttpRangeResource} instance containing the required Range information.
*
* @author Arjen Poutsma
* @author Juergen Hoeller
* @author Kazuki Shimizu
* @author Brian Clozel
* @since 3.0.2
*/
public class ResourceHttpMessageConverter extends AbstractHttpMessageConverter<Resource> {
@@ -64,7 +78,7 @@ public class ResourceHttpMessageConverter extends AbstractHttpMessageConverter<R
protected Resource readInternal(Class<? extends Resource> clazz, HttpInputMessage inputMessage)
throws IOException, HttpMessageNotReadableException {
if (InputStreamResource.class == clazz){
if (InputStreamResource.class == clazz) {
return new InputStreamResource(inputMessage.getBody());
}
else if (clazz.isAssignableFrom(ByteArrayResource.class)) {
@@ -94,6 +108,9 @@ public class ResourceHttpMessageConverter extends AbstractHttpMessageConverter<R
return null;
}
long contentLength = resource.contentLength();
if (contentLength > Integer.MAX_VALUE) {
throw new IOException("Resource content too long (beyond Integer.MAX_VALUE): " + resource);
}
return (contentLength < 0 ? null : contentLength);
}
@@ -101,18 +118,140 @@ public class ResourceHttpMessageConverter extends AbstractHttpMessageConverter<R
protected void writeInternal(Resource resource, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
InputStream in = resource.getInputStream();
outputMessage.getHeaders().add(HttpHeaders.ACCEPT_RANGES, "bytes");
if (resource instanceof HttpRangeResource) {
writePartialContent((HttpRangeResource) resource, outputMessage);
}
else {
writeContent(resource, outputMessage);
}
}
protected void writeContent(Resource resource, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
try {
StreamUtils.copy(in, outputMessage.getBody());
}
finally {
InputStream in = resource.getInputStream();
try {
in.close();
StreamUtils.copy(in, outputMessage.getBody());
}
catch (IOException ex) {
catch (NullPointerException ex) {
// ignore, see SPR-13620
}
finally {
try {
in.close();
}
catch (Throwable ex) {
// ignore, see SPR-12999
}
}
}
catch (FileNotFoundException ex) {
// ignore, see SPR-12999
}
}
/**
* Write parts of the resource as indicated by the request {@code Range} header.
* @param resource the identified resource (never {@code null})
* @param outputMessage current servlet response
* @throws IOException in case of errors while writing the content
*/
protected void writePartialContent(HttpRangeResource resource, HttpOutputMessage outputMessage) throws IOException {
Assert.notNull(resource, "Resource should not be null");
List<HttpRange> ranges = resource.getHttpRanges();
HttpHeaders responseHeaders = outputMessage.getHeaders();
MediaType contentType = responseHeaders.getContentType();
Long length = getContentLength(resource, contentType);
if (ranges.size() == 1) {
HttpRange range = ranges.get(0);
long start = range.getRangeStart(length);
long end = range.getRangeEnd(length);
long rangeLength = end - start + 1;
responseHeaders.add("Content-Range", "bytes " + start + "-" + end + "/" + length);
responseHeaders.setContentLength((int) rangeLength);
InputStream in = resource.getInputStream();
try {
copyRange(in, outputMessage.getBody(), start, end);
}
finally {
try {
in.close();
}
catch (IOException ex) {
// ignore
}
}
}
else {
String boundaryString = MimeTypeUtils.generateMultipartBoundaryString();
responseHeaders.set(HttpHeaders.CONTENT_TYPE, "multipart/byteranges; boundary=" + boundaryString);
OutputStream out = outputMessage.getBody();
for (HttpRange range : ranges) {
long start = range.getRangeStart(length);
long end = range.getRangeEnd(length);
InputStream in = resource.getInputStream();
// Writing MIME header.
println(out);
print(out, "--" + boundaryString);
println(out);
if (contentType != null) {
print(out, "Content-Type: " + contentType.toString());
println(out);
}
print(out, "Content-Range: bytes " + start + "-" + end + "/" + length);
println(out);
println(out);
// Printing content
copyRange(in, out, start, end);
}
println(out);
print(out, "--" + boundaryString + "--");
}
}
private static void println(OutputStream os) throws IOException {
os.write('\r');
os.write('\n');
}
private static void print(OutputStream os, String buf) throws IOException {
os.write(buf.getBytes("US-ASCII"));
}
private void copyRange(InputStream in, OutputStream out, long start, long end) throws IOException {
long skipped = in.skip(start);
if (skipped < start) {
throw new IOException("Skipped only " + skipped + " bytes out of " + start + " required.");
}
long bytesToCopy = end - start + 1;
byte buffer[] = new byte[StreamUtils.BUFFER_SIZE];
while (bytesToCopy > 0) {
int bytesRead = in.read(buffer);
if (bytesRead <= bytesToCopy) {
out.write(buffer, 0, bytesRead);
bytesToCopy -= bytesRead;
}
else {
out.write(buffer, 0, (int) bytesToCopy);
bytesToCopy = 0;
}
if (bytesRead == -1) {
break;
}
}
outputMessage.getBody().flush();
}