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

@@ -26,9 +26,12 @@ import java.util.Map;
import org.springframework.core.MethodParameter;
import org.springframework.core.ResolvableType;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpRange;
import org.springframework.http.HttpRangeResource;
import org.springframework.http.HttpStatus;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
@@ -194,6 +197,19 @@ public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodPro
return;
}
}
if(inputMessage.getHeaders().containsKey(HttpHeaders.RANGE) &&
Resource.class.isAssignableFrom(body.getClass())) {
try {
List<HttpRange> httpRanges = inputMessage.getHeaders().getRange();
Resource bodyResource = (Resource) body;
body = new HttpRangeResource(httpRanges, bodyResource);
outputMessage.setStatusCode(HttpStatus.PARTIAL_CONTENT);
} catch (IllegalArgumentException exc) {
outputMessage.setStatusCode(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
outputMessage.flush();
return;
}
}
// Try even with null body. ResponseBodyAdvice could get involved.
writeWithMessageConverters(body, returnType, inputMessage, outputMessage);

View File

@@ -24,10 +24,16 @@ import javax.servlet.http.HttpServletRequest;
import org.springframework.core.Conventions;
import org.springframework.core.MethodParameter;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpRange;
import org.springframework.http.HttpRangeResource;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.validation.BindingResult;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
@@ -164,9 +170,25 @@ public class RequestResponseBodyMethodProcessor extends AbstractMessageConverter
throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
mavContainer.setRequestHandled(true);
ServletServerHttpRequest inputMessage = createInputMessage(webRequest);
ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);
if(inputMessage.getHeaders().containsKey(HttpHeaders.RANGE) &&
Resource.class.isAssignableFrom(returnValue.getClass())) {
try {
List<HttpRange> httpRanges = inputMessage.getHeaders().getRange();
Resource bodyResource = (Resource) returnValue;
returnValue = new HttpRangeResource(httpRanges, bodyResource);
outputMessage.setStatusCode(HttpStatus.PARTIAL_CONTENT);
} catch (IllegalArgumentException exc) {
outputMessage.setStatusCode(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE);
outputMessage.flush();
return;
}
}
// Try even with null return value. ResponseBodyAdvice could get involved.
writeWithMessageConverters(returnValue, returnType, webRequest);
writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage);
}
}

View File

@@ -16,15 +16,12 @@
package org.springframework.web.servlet.resource;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.List;
import javax.servlet.ServletException;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@@ -36,15 +33,15 @@ import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpRange;
import org.springframework.http.HttpRangeResource;
import org.springframework.http.MediaType;
import org.springframework.http.converter.ResourceHttpMessageConverter;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.util.MimeTypeUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.ResourceUtils;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.HttpRequestHandler;
@@ -94,16 +91,14 @@ public class ResourceHttpRequestHandler extends WebContentGenerator
private static final Log logger = LogFactory.getLog(ResourceHttpRequestHandler.class);
private static final boolean jafPresent = ClassUtils.isPresent(
"javax.activation.FileTypeMap", ResourceHttpRequestHandler.class.getClassLoader());
private final List<Resource> locations = new ArrayList<Resource>(4);
private final List<ResourceResolver> resourceResolvers = new ArrayList<ResourceResolver>(4);
private final List<ResourceTransformer> resourceTransformers = new ArrayList<ResourceTransformer>(4);
private ResourceHttpMessageConverter resourceHttpMessageConverter;
private ContentNegotiationManager contentNegotiationManager;
private CorsConfiguration corsConfiguration;
@@ -165,6 +160,20 @@ public class ResourceHttpRequestHandler extends WebContentGenerator
return this.resourceTransformers;
}
/**
* Configure the {@link ResourceHttpMessageConverter} to use.
* <p>By default a {@link ResourceHttpMessageConverter} will be configured.
* @since 4.3.0
*/
public void setResourceHttpMessageConverter(ResourceHttpMessageConverter resourceHttpMessageConverter) {
this.resourceHttpMessageConverter = resourceHttpMessageConverter;
}
public ResourceHttpMessageConverter getResourceHttpMessageConverter() {
return resourceHttpMessageConverter;
}
/**
* Configure a {@code ContentNegotiationManager} to determine the media types
* for resources being served. If the manager contains a path
@@ -177,7 +186,7 @@ public class ResourceHttpRequestHandler extends WebContentGenerator
* settings is used to create the manager. See the Javadoc of
* {@code ContentNegotiationManagerFactoryBean} for details
* @param contentNegotiationManager the manager to use
* @since 4.3
* @since 4.3.0
*/
public void setContentNegotiationManager(ContentNegotiationManager contentNegotiationManager) {
this.contentNegotiationManager = contentNegotiationManager;
@@ -215,6 +224,9 @@ public class ResourceHttpRequestHandler extends WebContentGenerator
if (this.contentNegotiationManager == null) {
this.contentNegotiationManager = initContentNegotiationManager();
}
if( this.resourceHttpMessageConverter == null) {
this.resourceHttpMessageConverter = new ResourceHttpMessageConverter();
}
}
/**
@@ -226,7 +238,7 @@ public class ResourceHttpRequestHandler extends WebContentGenerator
if (CollectionUtils.isEmpty(this.locations)) {
return;
}
for (int i = getResourceResolvers().size()-1; i >= 0; i--) {
for (int i = getResourceResolvers().size() - 1; i >= 0; i--) {
if (getResourceResolvers().get(i) instanceof PathResourceResolver) {
PathResourceResolver pathResolver = (PathResourceResolver) getResourceResolvers().get(i);
if (ObjectUtils.isEmpty(pathResolver.getAllowedLocations())) {
@@ -310,12 +322,26 @@ public class ResourceHttpRequestHandler extends WebContentGenerator
return;
}
ServletServerHttpResponse outputMessage = new ServletServerHttpResponse(response);
if (request.getHeader(HttpHeaders.RANGE) == null) {
setHeaders(response, resource, mediaType);
writeContent(response, resource);
this.resourceHttpMessageConverter.write(resource, mediaType, outputMessage);
}
else {
writePartialContent(request, response, resource, mediaType);
ServletServerHttpRequest inputMessage = new ServletServerHttpRequest(request);
try {
List<HttpRange> httpRanges = inputMessage.getHeaders().getRange();
HttpRangeResource rangeResource = new HttpRangeResource(httpRanges, resource);
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
this.resourceHttpMessageConverter.write(rangeResource, mediaType, outputMessage);
}
catch (IllegalArgumentException ex) {
Long contentLength = resource.contentLength();
if (contentLength != null) {
response.addHeader("Content-Range", "bytes */" + resource.contentLength());
}
response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
}
}
}
@@ -507,140 +533,6 @@ public class ResourceHttpRequestHandler extends WebContentGenerator
response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes");
}
/**
* Write the actual content out to the given servlet response,
* streaming the resource's content.
* @param response current servlet response
* @param resource the identified resource (never {@code null})
* @throws IOException in case of errors while writing the content
*/
protected void writeContent(HttpServletResponse response, Resource resource) throws IOException {
try {
InputStream in = resource.getInputStream();
try {
StreamUtils.copy(in, response.getOutputStream());
}
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 request current servlet request
* @param response current servlet response
* @param resource the identified resource (never {@code null})
* @param contentType the content type
* @throws IOException in case of errors while writing the content
*/
protected void writePartialContent(HttpServletRequest request, HttpServletResponse response,
Resource resource, MediaType contentType) throws IOException {
long length = resource.contentLength();
List<HttpRange> ranges;
try {
HttpHeaders headers = new ServletServerHttpRequest(request).getHeaders();
ranges = headers.getRange();
}
catch (IllegalArgumentException ex) {
response.addHeader("Content-Range", "bytes */" + length);
response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
return;
}
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
if (ranges.size() == 1) {
HttpRange range = ranges.get(0);
long start = range.getRangeStart(length);
long end = range.getRangeEnd(length);
long rangeLength = end - start + 1;
setHeaders(response, resource, contentType);
response.addHeader("Content-Range", "bytes " + start + "-" + end + "/" + length);
response.setContentLength((int) rangeLength);
InputStream in = resource.getInputStream();
try {
copyRange(in, response.getOutputStream(), start, end);
}
finally {
try {
in.close();
}
catch (IOException ex) {
// ignore
}
}
}
else {
String boundaryString = MimeTypeUtils.generateMultipartBoundaryString();
response.setContentType("multipart/byteranges; boundary=" + boundaryString);
ServletOutputStream out = response.getOutputStream();
for (HttpRange range : ranges) {
long start = range.getRangeStart(length);
long end = range.getRangeEnd(length);
InputStream in = resource.getInputStream();
// Writing MIME header.
out.println();
out.println("--" + boundaryString);
if (contentType != null) {
out.println("Content-Type: " + contentType);
}
out.println("Content-Range: bytes " + start + "-" + end + "/" + length);
out.println();
// Printing content
copyRange(in, out, start, end);
}
out.println();
out.print("--" + boundaryString + "--");
}
}
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;
}
}
}
@Override
public String toString() {
return "ResourceHttpRequestHandler [locations=" + getLocations() + ", resolvers=" + getResourceResolvers() + "]";