Recently I needed to implement HTTP byte-range requests in Spring MVC for serving some media files. In my case those were not really static files though.
This is actually like implementing Tomcat's DefaultServlet (http://tomcat.apache.org/tomcat-6.0-doc/default-servlet.html) in order to serve static files. So first thing that comes to mind is, is there any way to simply re-use implementation of org.apache.catalina.servlets.DefaultServlet by sub-classing it or by simply invoking some methods from the class. But after looking at the DefaultServlet source code, I decided against it as it looked difficult to retrofit that in Spring MVC paradigm. After little more googling, I found this excellent blog-post by Bauke Scholtz - http://balusc.blogspot.in/2009/02/fileservlet-supporting-resume-and.html. The blog-post talks about supporting byte-range or part-range requests as received from the client browser or any HTTP-based client who wishes to send such requests.
I refactored this code for a Spring View. Here is the code -
Next, you just need to define the above view class to be a Spring bean and use it in Spring MVC's ModelAndView object.
This is actually like implementing Tomcat's DefaultServlet (http://tomcat.apache.org/tomcat-6.0-doc/default-servlet.html) in order to serve static files. So first thing that comes to mind is, is there any way to simply re-use implementation of org.apache.catalina.servlets.DefaultServlet by sub-classing it or by simply invoking some methods from the class. But after looking at the DefaultServlet source code, I decided against it as it looked difficult to retrofit that in Spring MVC paradigm. After little more googling, I found this excellent blog-post by Bauke Scholtz - http://balusc.blogspot.in/2009/02/fileservlet-supporting-resume-and.html. The blog-post talks about supporting byte-range or part-range requests as received from the client browser or any HTTP-based client who wishes to send such requests.
I refactored this code for a Spring View. Here is the code -
/** * net/balusc/webapp/FileServlet.java * * Copyright (C) 2009 BalusC * * This program is free software: you can redistribute it and/or modify it under the terms of the * GNU Lesser General Public License as published by the Free Software Foundation, either version 3 * of the License, or (at your option) any later version. * * This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License along with this library. * If not, see . */ import org.springframework.web.servlet.view.AbstractView; import javax.servlet.ServletOutputStream; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.*; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Map; /** * LGPL code from http://balusc.blogspot.in/2009/02/fileservlet-supporting-resume-and.html adopted * as Spring View for streaming binary files. * * @author Atul M Dambalkar (Adapted for Spring View) */ public class StreamingViewRenderer extends AbstractView { // Constants ---------------------------------------------------------------------------------- private static final int DEFAULT_BUFFER_SIZE = 20480; // ..bytes = 20KB. private static final long DEFAULT_EXPIRE_TIME = 604800000L; // ..ms = 1 week. private static final String MULTIPART_BOUNDARY = "MULTIPART_BYTERANGES"; @Override protected void renderMergedOutputModel(Map objectMap, HttpServletRequest request, HttpServletResponse response) throws Exception { InputStream dataStream = (InputStream) objectMap.get(DownloadConstants.INPUT_STREAM); if (dataStream == null) { response.sendError(HttpServletResponse.SC_NOT_FOUND); return; } long length = (Long) objectMap.get(DownloadConstants.CONTENT_LENGTH); String fileName = (String) objectMap.get(DownloadConstants.FILENAME); Date lastModifiedObj = (Date)objectMap.get(DownloadConstants.LAST_MODIFIED); if (StringUtils.isEmpty(fileName) || lastModifiedObj == null) { response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); return; } long lastModified = lastModifiedObj.getTime(); String contentType = (String) objectMap.get(DownloadConstants.CONTENT_TYPE); // Validate request headers for caching --------------------------------------------------- // If-None-Match header should contain "*" or ETag. If so, then return 304. String ifNoneMatch = request.getHeader("If-None-Match"); if (ifNoneMatch != null && HttpUtils.matches(ifNoneMatch, fileName)) { response.setHeader("ETag", fileName); // Required in 304. response.sendError(HttpServletResponse.SC_NOT_MODIFIED); return; } // If-Modified-Since header should be greater than LastModified. If so, then return 304. // This header is ignored if any If-None-Match header is specified. long ifModifiedSince = request.getDateHeader("If-Modified-Since"); if (ifNoneMatch == null && ifModifiedSince != -1 && ifModifiedSince + 1000 > lastModified) { response.setHeader("ETag", fileName); // Required in 304. response.sendError(HttpServletResponse.SC_NOT_MODIFIED); return; } // Validate request headers for resume ---------------------------------------------------- // If-Match header should contain "*" or ETag. If not, then return 412. String ifMatch = request.getHeader("If-Match"); if (ifMatch != null && !HttpUtils.matches(ifMatch, fileName)) { response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); return; } // If-Unmodified-Since header should be greater than LastModified. If not, then return 412. long ifUnmodifiedSince = request.getDateHeader("If-Unmodified-Since"); if (ifUnmodifiedSince != -1 && ifUnmodifiedSince + 1000 <= lastModified) { response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); return; } // Validate and process range ------------------------------------------------------------- // Prepare some variables. The full Range represents the complete file. Range full = new Range(0, length - 1, length); List ranges = new ArrayList(); // Validate and process Range and If-Range headers. String range = request.getHeader("Range"); if (range != null) { // Range header should match format "bytes=n-n,n-n,n-n...". If not, then return 416. if (!range.matches("^bytes=\\d*-\\d*(,\\d*-\\d*)*$")) { response.setHeader("Content-Range", "bytes */" + length); // Required in 416. response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); return; } String ifRange = request.getHeader("If-Range"); if (ifRange != null && !ifRange.equals(fileName)) { try { long ifRangeTime = request.getDateHeader("If-Range"); // Throws IAE if invalid. if (ifRangeTime != -1) { ranges.add(full); } } catch (IllegalArgumentException ignore) { ranges.add(full); } } // If any valid If-Range header, then process each part of byte range. if (ranges.isEmpty()) { for (String part : range.substring(6).split(",")) { // Assuming a file with length of 100, the following examples returns bytes at: // 50-80 (50 to 80), 40- (40 to length=100), -20 (length-20=80 to length=100). long start = StringUtils.sublong(part, 0, part.indexOf("-")); long end = StringUtils.sublong(part, part.indexOf("-") + 1, part.length()); if (start == -1) { start = length - end; end = length - 1; } else if (end == -1 || end > length - 1) { end = length - 1; } // Check if Range is syntactically valid. If not, then return 416. if (start > end) { response.setHeader("Content-Range", "bytes */" + length); // Required in 416. response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); return; } // Add range. ranges.add(new Range(start, end, length)); } } } // Prepare and initialize response -------------------------------------------------------- // Get content type by file name and set content disposition. String disposition = "inline"; // If content type is unknown, then set the default value. // For all content types, see: http://www.w3schools.com/media/media_mimeref.asp // To add new content types, add new mime-mapping entry in web.xml. if (contentType == null) { contentType = "application/octet-stream"; } else if (!contentType.startsWith("image")) { // Else, expect for images, determine content disposition. If content type is supported by // the browser, then set to inline, else attachment which will pop a 'save as' dialogue. String accept = request.getHeader("Accept"); disposition = accept != null && HttpUtils.accepts(accept, contentType) ? "inline" : "attachment"; } // Initialize response. response.reset(); response.setBufferSize(DEFAULT_BUFFER_SIZE); response.setHeader("Content-Disposition", disposition + ";filename=\"" + fileName + "\""); response.setHeader("Accept-Ranges", "bytes"); response.setHeader("ETag", fileName); response.setDateHeader("Last-Modified", lastModified); response.setDateHeader("Expires", System.currentTimeMillis() + DEFAULT_EXPIRE_TIME); // Send requested file (part(s)) to client ------------------------------------------------ // Prepare streams. InputStream input = null; OutputStream output = null; try { // Open streams. input = new BufferedInputStream(dataStream); output = response.getOutputStream(); if (ranges.isEmpty() || ranges.get(0) == full) { // Return full file. Range r = full; response.setContentType(contentType); response.setHeader("Content-Range", "bytes " + r.start + "-" + r.end + "/" + r.total); response.setHeader("Content-Length", String.valueOf(r.length)); copy(input, output, length, r.start, r.length); } else if (ranges.size() == 1) { // Return single part of file. Range r = ranges.get(0); response.setContentType(contentType); response.setHeader("Content-Range", "bytes " + r.start + "-" + r.end + "/" + r.total); response.setHeader("Content-Length", String.valueOf(r.length)); response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // 206. // Copy single part range. copy(input, output, length, r.start, r.length); } else { // Return multiple parts of file. response.setContentType("multipart/byteranges; boundary=" + MULTIPART_BOUNDARY); response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // 206. // Cast back to ServletOutputStream to get the easy println methods. ServletOutputStream sos = (ServletOutputStream) output; // Copy multi part range. for (Range r : ranges) { // Add multipart boundary and header fields for every range. sos.println(); sos.println("--" + MULTIPART_BOUNDARY); sos.println("Content-Type: " + contentType); sos.println("Content-Range: bytes " + r.start + "-" + r.end + "/" + r.total); // Copy single part range of multi part range. copy(input, output, length, r.start, r.length); } // End with multipart boundary. sos.println(); sos.println("--" + MULTIPART_BOUNDARY + "--"); } } finally { // Gently close streams. close(output); close(input); close(dataStream); } } /** * Close the given resource. * @param resource The resource to be closed. */ private static void close(Closeable resource) { if (resource != null) { try { resource.close(); } catch (IOException ignore) { // Ignore IOException. If you want to handle this anyway, it might be useful to know // that this will generally only be thrown when the client aborted the request. } } } /** * Copy the given byte range of the given input to the given output. * @param input The input to copy the given range to the given output for. * @param output The output to copy the given range from the given input for. * @param start Start of the byte range. * @param length Length of the byte range. * @throws IOException If something fails at I/O level. */ private static void copy(InputStream input, OutputStream output, long inputSize, long start, long length) throws IOException { byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; int read; if (inputSize == length) { // Write full range. while ((read = input.read(buffer)) > 0) { output.write(buffer, 0, read); output.flush(); } } else { input.skip(start); long toRead = length; while ((read = input.read(buffer)) > 0) { if ((toRead -= read) > 0) { output.write(buffer, 0, read); output.flush(); } else { output.write(buffer, 0, (int) toRead + read); output.flush(); break; } } } } // Inner classes ------------------------------------------------------------------------------ /** * This class represents a byte range. */ protected class Range { long start; long end; long length; long total; /** * Construct a byte range. * @param start Start of the byte range. * @param end End of the byte range. * @param total Total length of the byte source. */ public Range(long start, long end, long total) { this.start = start; this.end = end; this.length = end - start + 1; this.total = total; } } } public class DownloadConstants { public static final String INPUT_STREAM = "inputStream"; public static final String CONTENT_TYPE = "contentType"; public static final String CONTENT_LENGTH = "contentLength"; public static final String FILENAME = "filename"; public static final String LAST_MODIFIED = "lastModified"; } public class HttpUtils { /** * Returns true if the given accept header accepts the given value. * @param acceptHeader The accept header. * @param toAccept The value to be accepted. * @return True if the given accept header accepts the given value. */ public static boolean accepts(String acceptHeader, String toAccept) { String[] acceptValues = acceptHeader.split("\\s*(,|;)\\s*"); Arrays.sort(acceptValues); return Arrays.binarySearch(acceptValues, toAccept) > -1 || Arrays.binarySearch(acceptValues, toAccept.replaceAll("/.*$", "/*")) > -1 || Arrays.binarySearch(acceptValues, "*/*") > -1; } /** * Returns true if the given match header matches the given value. * @param matchHeader The match header. * @param toMatch The value to be matched. * @return True if the given match header matches the given value. */ public static boolean matches(String matchHeader, String toMatch) { String[] matchValues = matchHeader.split("\\s*,\\s*"); Arrays.sort(matchValues); return Arrays.binarySearch(matchValues, toMatch) > -1 || Arrays.binarySearch(matchValues, "*") > -1; } } public class StringUtils { /** * Returns a substring of the given string value from the given begin index to the given end * index as a long. If the substring is empty, then -1 will be returned * @param value The string value to return a substring as long for. * @param beginIndex The begin index of the substring to be returned as long. * @param endIndex The end index of the substring to be returned as long. * @return A substring of the given string value as long or -1 if substring is empty. */ public static long sublong(String value, int beginIndex, int endIndex) { String substring = value.substring(beginIndex, endIndex); return (substring.length() > 0) ? Long.parseLong(substring) : -1; } }
Next, you just need to define the above view class to be a Spring bean and use it in Spring MVC's ModelAndView object.
This is an outstanding post! You saved me hours, even days with this. It worked like magic. Now we can stream our podcast. Thanks!!!!!!!!
ReplyDeleteThanks Eugene! Glad that it helped you.
ReplyDeleteAmazing stuff!
ReplyDeleteI was really sad when I understood that Spring MVC has no partial content management ...
Thanks a lot! Do you mind if I share this with the StackOverflow community (I will quote you of course)
hi Gardaş, how are you ?
ReplyDeleteHi,
ReplyDeleteThis is Amazin stuff, Thank you. I'm developing a project. This project is split into pages loaded PDFs. users see the files in the PDF viewer. When users move down the pdf viewer scrollbar, I want to pdf pages withdraw from the server. How do I do this using Byte Range Request. (I am using Spring framework and vaadin)
Hi,
ReplyDeleteAnyone can tell me how to implement this code in my controller using this View? I'm not sure if I have to call renderMergedOutputModel method or how to build that map as parameter.
Thanks! great job!
I've translated it to use it without Spring MVC (even without spring at all), if you are interested, check it out at https://gist.github.com/davinkevin/b97e39d7ce89198774b4
ReplyDeleteThat helps me to use it inside a Spring application using mainly @RestController
List ranges = new ArrayList(); => List ranges = new ArrayList<>(); will address the compilation error.
ReplyDeleteList ranges = new ArrayList<>();
DeleteThis comment has been removed by the author.
Delete