Spring-Boot implements HTTP large file breakpoint resumable download in fragments

How does the server segment a large video file and respond to the client in segments so that the browser can play it progressively.

Spring Boot implements HTTP fragmented download breakpoint resuming, thereby solving the problem of large video playback on H5 pages, and achieving progressive playback. Only the content that needs to be played is played each time, and there is no need to load the entire file into memory.

File resuming at breakpoints, multi-threaded concurrent file downloading (this is how Thunder works), etc.

<dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-bom</artifactId>
        <version>5.8.18</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

  <dependencies>
    <dependency>
      <groupId>cn.hutool</groupId>
      <artifactId>hutool-core</artifactId>
    </dependency>

    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <optional>true</optional>
    </dependency>
  </dependencies>

Code
ResourceController

package com.example.insurance.controller;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;

import com.example.insurance.common.ContentRange;
import com.example.insurance.common.MediaContentUtil;
import com.example.insurance.common.NioUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpRange;
import org.springframework.http.HttpStatus;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StopWatch;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * Content resource controller
 */
@SuppressWarnings("unused")
@Slf4j
@RestController("resourceController")
@RequestMapping(path = "/resource")
public class ResourceController {<!-- -->

    /**
     * Get file content
     *
     * @param fileName content file name
     * @param response response object
     */
    @GetMapping("/media/{fileName}")
    public void getMedia(@PathVariable String fileName, HttpServletRequest request, HttpServletResponse response,
                         @RequestHeader HttpHeaders headers) {<!-- -->
// printRequestInfo(fileName, request, headers);

        String filePath = MediaContentUtil.filePath();
        try {<!-- -->
            this.download(fileName, filePath, request, response, headers);
        } catch (Exception e) {<!-- -->
            log.error("getMedia error, fileName={}", fileName, e);
        }
    }

    /**
     * Get cover content
     *
     * @param fileName content cover name
     * @param response response object
     */
    @GetMapping("/cover/{fileName}")
    public void getCover(@PathVariable String fileName, HttpServletRequest request, HttpServletResponse response,
                         @RequestHeader HttpHeaders headers) {<!-- -->
// printRequestInfo(fileName, request, headers);

        String filePath = MediaContentUtil.filePath();
        try {<!-- -->
            this.download(fileName, filePath, request, response, headers);
        } catch (Exception e) {<!-- -->
            log.error("getCover error, fileName={}", fileName, e);
        }
    }


    // ======= internal =======

    private static void printRequestInfo(String fileName, HttpServletRequest request, HttpHeaders headers) {<!-- -->
        String requestUri = request.getRequestURI();
        String queryString = request.getQueryString();
        log.debug("file={}, url={}?{}", fileName, requestUri, queryString);
        log.info("headers={}", headers);
    }

    /**
     * Set request response status, header information, content type and length, etc.
     * <pre>
     * <a href="https://www.rfc-editor.org/rfc/rfc7233">
     * HTTP/1.1 Range Requests</a>
     * 2. Range Units
     * 4. Responses to a Range Request
     *
     * <a href="https://www.rfc-editor.org/rfc/rfc2616.html">
     * HTTP/1.1</a>
     * 10.2.7 206 Partial Content
     * 14.5 Accept-Ranges
     * 14.13 Content-Length
     * 14.16 Content-Range
     * 14.17 Content-Type
     * 19.5.1 Content-Disposition
     * 15.5 Content-Disposition Issues
     *
     * <a href="https://www.rfc-editor.org/rfc/rfc2183">
     * Content-Disposition</a>
     * 2. The Content-Disposition Header Field
     * 2.1 The Inline Disposition Type
     * 2.3 The Filename Parameter
     * 

*
* @param response request response object
* @param fileName requested file name
* @param contentType content type
* @param contentRange content range object
*/
private static void setResponse(
HttpServletResponse response, String fileName, String contentType,
ContentRange contentRange) {
// The http status code should be 206: indicating that part of the content is obtained
response.setStatus(HttpStatus.PARTIAL_CONTENT.value());
//Support breakpoint resumption and obtain partial byte content
// Accept-Ranges: bytes, indicating support for Range requests
response.setHeader(HttpHeaders.ACCEPT_RANGES, ContentRange.BYTES_STRING);
// inline indicates direct use by the browser, attachment indicates downloading, and fileName indicates the downloaded file name.
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
“inline;filename=” + MediaContentUtil.encode(fileName));
// Content-Range, the format is: [Start position to download]-[End position]/[Total file size]
// Content-Range: bytes 0-10/3103, the format is bytes start-end/all
response.setHeader(HttpHeaders.CONTENT_RANGE, contentRange.toContentRange());

response.setContentType(contentType);
// Content-Length: 11, the size of this content
response.setContentLengthLong(contentRange.applyAsContentLength());
}

/**
*
* Spring Boot handles HTTP Headers

*/
private void download(
String fileName, String path, HttpServletRequest request, HttpServletResponse response,
HttpHeaders headers)
throws IOException {
Path filePath = Paths.get(path + fileName);
if (!Files.exists(filePath)) {
log.warn(“file not exist, filePath={}”, filePath);
return;
}
long fileLength = Files.size(filePath);
// long fileLength2 = filePath.toFile().length() – 1;
// // fileLength=1184856, fileLength2=1184855
// log.info(“fileLength={}, fileLength2={}”, fileLength, fileLength2);

// content range
ContentRange contentRange = applyAsContentRange(headers, fileLength, request);

//The length to be downloaded
long contentLength = contentRange.applyAsContentLength();
log.debug(“contentRange={}, contentLength={}”, contentRange, contentLength);

// file type
String contentType = request.getServletContext().getMimeType(fileName);
// mimeType=video/mp4, CONTENT_TYPE=null
log.debug(“mimeType={}, CONTENT_TYPE={}”, contentType, request.getContentType());

setResponse(response, fileName, contentType, contentRange);

// Time-consuming indicator statistics
StopWatch stopWatch = new StopWatch(“downloadFile”);
stopWatch.start(fileName);
try {
// case-1. Refer to other people’s implementations on the Internet
// if (fileLength >= Integer.MAX_VALUE) {
// NioUtils.copy(filePath, response, contentRange);
// } else {
// NioUtils.copyByChannelAndBuffer(filePath, response, contentRange);
// }

// case-2. Use ready-made API
NioUtils.copyByBio(filePath, response, contentRange);
// NioUtils.copyByNio(filePath, response, contentRange);

// case-3. Video segmented progressive playback
// if (contentType.startsWith(“video”)) {
// NioUtils.copyForBufferSize(filePath, response, contentRange);
// } else {
// // Pictures, PDF and other files
// NioUtils.copyByBio(filePath, response, contentRange);
// }
} finally {
stopWatch.stop();
log.info(“download file, fileName={}, time={} ms”, fileName, stopWatch.getTotalTimeMillis());
}
}

private static ContentRange applyAsContentRange(
HttpHeaders headers, long fileLength, HttpServletRequest request) {
/*
* 3.1. Range – HTTP/1.1 Range Requests
* https://www.rfc-editor.org/rfc/rfc7233#section-3.1
* Range: “bytes” “=” first-byte-pos “-” [ last-byte-pos ]
*
* For example:
*bytes=0-
* bytes=0-499
*/
// Range: Inform the server that the client wants to download the file from the specified location.
List httpRanges = headers.getRange();

String range = request.getHeader(HttpHeaders.RANGE);
// httpRanges=[], range=null
// httpRanges=[448135688-], range=bytes=448135688-
log.debug(“httpRanges={}, range={}”, httpRanges, range);

//Start download location
long firstBytePos;
//End download location
long lastBytePos;
if (CollectionUtils.isEmpty(httpRanges)) {
firstBytePos = 0;
lastBytePos = fileLength – 1;
} else {
HttpRange httpRange = httpRanges.get(0);
firstBytePos = httpRange.getRangeStart(fileLength);
lastBytePos = httpRange.getRangeEnd(fileLength);
}
return new ContentRange(firstBytePos, lastBytePos, fileLength);
}
}

NioUtils

package com.example.insurance.common;

import javax.servlet.http.HttpServletResponse;
import java.io.BufferedOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;

import cn.hutool.core.io.IORuntimeException;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.io.NioUtil;
import cn.hutool.core.io.StreamProgress;
import cn.hutool.core.io.unit.DataSize;
import lombok.extern.slf4j.Slf4j;
import org.apache.catalina.connector.ClientAbortException;

/**
 * NIO related tool encapsulation, mainly for Channel reading, writing, copying, etc.
 */
@Slf4j
public final class NioUtils {<!-- -->

    /**
     * Buffer size 16KB
     *
     * @see NioUtil#DEFAULT_BUFFER_SIZE
     * @see NioUtil#DEFAULT_LARGE_BUFFER_SIZE
     */
// private static final int BUFFER_SIZE = NioUtil.DEFAULT_MIDDLE_BUFFER_SIZE;
    private static final int BUFFER_SIZE = (int) DataSize.ofKilobytes(16L).toBytes();

    /**
     * <pre>
     * <a href="https://blog.csdn.net/qq_32099833/article/details/109703883">
     * Java backend implements video segmented progressive playback</a>
     * How does the server segment a large video file and respond to the client in segments so that the browser can play it progressively.
     * Resumable file downloads, multi-threaded concurrent file downloads (this is how Thunder works), etc.
     *
     * <a href="https://blog.csdn.net/qq_32099833/article/details/109630499">
     * Front-end and back-end implementation of multi-part upload of large files</a>
     * 

*/
public static void copyForBufferSize(
Path filePath, HttpServletResponse response, ContentRange contentRange) {
String fileName = filePath.getFileName().toString();

RandomAccessFile randomAccessFile = null;
OutputStream outputStream = null;
try {
// Randomly read files
randomAccessFile = new RandomAccessFile(filePath.toFile(), “r”);
//Move the access pointer to the specified location
randomAccessFile.seek(contentRange.getStart());

// Note: The buffer size is 2MB, and the video loads normally; when it is 1MB, some videos fail to load.
int bufferSize = BUFFER_SIZE;

//Get the response output stream
outputStream = new BufferedOutputStream(response.getOutputStream(), bufferSize);

// Only 1MB video stream is returned per request
byte[] buffer = new byte[bufferSize];
int len = randomAccessFile.read(buffer);
//Set the data length returned this time
response.setContentLength(len);
//Respond this 1MB video stream to the client
outputStream.write(buffer, 0, len);

log.info(“file download complete, fileName={}, contentRange={}”,
fileName, contentRange.toContentRange());
} catch (ClientAbortException | IORuntimeException e) {
// Catching this exception means that the user stopped downloading
log.warn(“client stop file download, fileName={}”, fileName);
} catch (Exception e) {
log.error(“file download error, fileName={}”, fileName, e);
} finally {
IoUtil.close(outputStream);
IoUtil.close(randomAccessFile);
}
}

/**
* Copy the stream and close the stream after copying.
*
* @param filePath source file path
* @param response request response
* @param contentRange content range
*/
public static void copyByBio(
Path filePath, HttpServletResponse response, ContentRange contentRange) {
String fileName = filePath.getFileName().toString();

InputStream inputStream = null;
OutputStream outputStream = null;
try {
RandomAccessFile randomAccessFile = new RandomAccessFile(filePath.toFile(), “r”);
randomAccessFile.seek(contentRange.getStart());

inputStream = Channels.newInputStream(randomAccessFile.getChannel());
outputStream = new BufferedOutputStream(response.getOutputStream(), BUFFER_SIZE);

StreamProgress streamProgress = new StreamProgressImpl(fileName);

long transmitted = IoUtil.copy(inputStream, outputStream, BUFFER_SIZE, streamProgress);
log.info(“file download complete, fileName={}, transmitted={}”, fileName, transmitted);
} catch (ClientAbortException | IORuntimeException e) {
// Catching this exception means that the user stopped downloading
log.warn(“client stop file download, fileName={}”, fileName);
} catch (Exception e) {
log.error(“file download error, fileName={}”, fileName, e);
} finally {
IoUtil.close(outputStream);
IoUtil.close(inputStream);
}
}

/**
* Copy the stream and close the stream after copying.
*

     * <a href="https://www.cnblogs.com/czwbig/p/10035631.html">
     * Java NIO study notes (1)----overview, Channel/Buffer</a>
     * 

*
* @param filePath source file path
* @param response request response
* @param contentRange content range
*/
public static void copyByNio(
Path filePath, HttpServletResponse response, ContentRange contentRange) {
String fileName = filePath.getFileName().toString();

InputStream inputStream = null;
OutputStream outputStream = null;
try {
RandomAccessFile randomAccessFile = new RandomAccessFile(filePath.toFile(), “r”);
randomAccessFile.seek(contentRange.getStart());

inputStream = Channels.newInputStream(randomAccessFile.getChannel());
outputStream = new BufferedOutputStream(response.getOutputStream(), BUFFER_SIZE);

StreamProgress streamProgress = new StreamProgressImpl(fileName);

long transmitted = NioUtil.copyByNIO(inputStream, outputStream,
BUFFER_SIZE, streamProgress);
log.info(“file download complete, fileName={}, transmitted={}”, fileName, transmitted);
} catch (ClientAbortException | IORuntimeException e) {
// Catching this exception means that the user stopped downloading
log.warn(“client stop file download, fileName={}”, fileName);
} catch (Exception e) {
log.error(“file download error, fileName={}”, fileName, e);
} finally {
IoUtil.close(outputStream);
IoUtil.close(inputStream);
}
}

/**
*

     * <a href="https://blog.csdn.net/lovequanquqn/article/details/104562945">
     * SpringBoot Java implements Http fragmented downloading and breakpoint resume + implements progressive playback of H5 large videos</a>
     * SpringBoot implements Http fragmented download breakpoint resuming, thereby solving the problem of large video playback on H5 pages, and achieving progressive playback. Only the content that needs to be played is played each time, and there is no need to load the entire file into memory.
     * 2. Implementation of Http fragmented download breakpoint resume download
     * 4. Scheduled cache file deletion tasks
     * 

*/
public static void copy(Path filePath, HttpServletResponse response, ContentRange contentRange) {
String fileName = filePath.getFileName().toString();
//The length to be downloaded
long contentLength = contentRange.applyAsContentLength();

BufferedOutputStream outputStream = null;
RandomAccessFile randomAccessFile = null;
//Size of data transferred
long transmitted = 0;
try {
randomAccessFile = new RandomAccessFile(filePath.toFile(), “r”);
randomAccessFile.seek(contentRange.getStart());
outputStream = new BufferedOutputStream(response.getOutputStream(), BUFFER_SIZE);
//Read data into the buffer
byte[] buffer = new byte[BUFFER_SIZE];

int len = BUFFER_SIZE;
//warning: The logic ((transmitted + len) <= contentLength) to determine whether the end is less than 4096 (buffer length) bytes ((transmitted + len) <= contentLength) should be placed first //Otherwise, randomAccessFile will be read first, causing an error in the subsequent reading position; while ((transmitted + len) <= contentLength & amp; & amp; (len = randomAccessFile.read(buffer)) != -1) {
outputStream.write(buffer, 0, len);
transmitted + = len;

log.info(“fileName={}, transmitted={}”, fileName, transmitted);
}
//Process the insufficient buffer.length part
if (transmitted < contentLength) {
len = randomAccessFile.read(buffer, 0, (int) (contentLength – transmitted));
outputStream.write(buffer, 0, len);
transmitted + = len;

log.info(“fileName={}, transmitted={}”, fileName, transmitted);
}

log.info(“file download complete, fileName={}, transmitted={}”, fileName, transmitted);
} catch (ClientAbortException e) {
// Catching this exception means that the user stopped downloading
log.warn(“client stop file download, fileName={}, transmitted={}”, fileName, transmitted);
} catch (Exception e) {
log.error(“file download error, fileName={}, transmitted={}”, fileName, transmitted, e);
} finally {
IoUtil.close(outputStream);
IoUtil.close(randomAccessFile);
}
}

/**
* Read file data through data transmission channels and buffers.
*

     * When the file length exceeds {@link Integer#MAX_VALUE},
     * Use {@link FileChannel#map(FileChannel.MapMode, long, long)} to report the following exception.
     * java.lang.IllegalArgumentException: Size exceeds Integer.MAX_VALUE
     * at sun.nio.ch.FileChannelImpl.map(FileChannelImpl.java:863)
     * at com.example.insurance.controller.ResourceController.download(ResourceController.java:200)
     * 

*
* @param filePath source file path
* @param response request response
* @param contentRange content range
*/
public static void copyByChannelAndBuffer(
Path filePath, HttpServletResponse response, ContentRange contentRange) {
String fileName = filePath.getFileName().toString();
//The length to be downloaded
long contentLength = contentRange.applyAsContentLength();

BufferedOutputStream outputStream = null;
FileChannel inChannel = null;
//Size of data transferred
long transmitted = 0;
long firstBytePos = contentRange.getStart();
long fileLength = contentRange.getLength();
try {
inChannel = FileChannel.open(filePath, StandardOpenOption.READ, StandardOpenOption.WRITE);
//Create a direct buffer
MappedByteBuffer inMap = inChannel.map(FileChannel.MapMode.READ_ONLY, firstBytePos, fileLength);
outputStream = new BufferedOutputStream(response.getOutputStream(), BUFFER_SIZE);
//Read data into the buffer
byte[] buffer = new byte[BUFFER_SIZE];

int len = BUFFER_SIZE;
// warning: The logic ((transmitted + len) <= contentLength) to determine whether the end is less than 4096 (buffer length) bytes ((transmitted + len) <= contentLength) should be placed first // Otherwise, the file will be read first, causing an error in the subsequent reading position. while ((transmitted + len) <= contentLength) {
inMap.get(buffer);
outputStream.write(buffer, 0, len);
transmitted + = len;

log.info(“fileName={}, transmitted={}”, fileName, transmitted);
}
// Process the insufficient buffer.length part
if (transmitted < contentLength) {
len = (int) (contentLength – transmitted);
buffer = new byte[len];
inMap.get(buffer);
outputStream.write(buffer, 0, len);
transmitted + = len;

log.info(“fileName={}, transmitted={}”, fileName, transmitted);
}

log.info(“file download complete, fileName={}, transmitted={}”, fileName, transmitted);
} catch (ClientAbortException e) {
// Catching this exception means that the user stopped downloading
log.warn(“client stop file download, fileName={}, transmitted={}”, fileName, transmitted);
} catch (Exception e) {
log.error(“file download error, fileName={}, transmitted={}”, fileName, transmitted, e);
} finally {
IoUtil.close(outputStream);
IoUtil.close(inChannel);
}
}

}

ContentRange

package com.example.insurance.common;

import lombok.AllArgsConstructor;
import lombok.Getter;

/**
 * Content range object
 * <pre>
 * <a href="https://www.rfc-editor.org/rfc/rfc7233#section-4.2">
 * 4.2. Content-Range - HTTP/1.1 Range Requests</a>
 * Content-Range: "bytes" first-byte-pos "-" last-byte-pos "/" complete-length
 *
 * For example:
 * Content-Range: bytes 0-499/1234
 * 

*
* @see org.apache.catalina.servlets.DefaultServlet.Range
*/
@Getter
@AllArgsConstructor
public class ContentRange {

/**
* The position of the first byte
*/
private final long start;
/**
* The position of the last byte
*/
private long end;
/**
* Complete content length/total length
*/
private final long length;

public static final String BYTES_STRING = “bytes”;

/**
* Assemble the response headers for the content range.
*

     * <a href="https://www.rfc-editor.org/rfc/rfc7233#section-4.2">
     * 4.2. Content-Range - HTTP/1.1 Range Requests</a>
     * Content-Range: "bytes" first-byte-pos "-" last-byte-pos "/" complete-length
     *
     * For example:
     * Content-Range: bytes 0-499/1234
     * 

*
* @return response header of content range
*/
public String toContentRange() {
return BYTES_STRING + ‘ ‘ + start + ‘-‘ + end + ‘/’ + length;
// return “bytes ” + start + “-” + end + “/” + length;
}

/**
* Calculate the complete length/total length of the content.
*
* @return the complete length/total length of the content
*/
public long applyAsContentLength() {
return end – start + 1;
}

/**
* Validate range.
*
* @return true if the range is valid, otherwise false
*/
public boolean validate() {
if (end >= length) {
end = length – 1;
}
return (start >= 0) & amp; & amp; (end >= 0) & amp; & amp; (start <= end) & amp; & amp; (length > 0);
}

@Override
public String toString() {
return “firstBytePos=” + start +
“, lastBytePos=” + end +
“, fileLength=” + length;
}
}

StreamProgressImpl

package com.example.insurance.common;

import cn.hutool.core.io.StreamProgress;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;

/**
 * Data flow progress bar
 */
@Slf4j
@AllArgsConstructor
public class StreamProgressImpl implements StreamProgress {<!-- -->

    private final String fileName;

    @Override
    public void start() {<!-- -->
        log.info("start progress {}", fileName);
    }

    @Override
    public void progress(long total, long progressSize) {<!-- -->
        log.debug("progress {}, total={}, progressSize={}", fileName, total, progressSize);
    }

    @Override
    public void finish() {<!-- -->
        log.info("finish progress {}", fileName);
    }
}

MediaContentUtil

package com.example.insurance.common;

import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

/**
 * File content auxiliary method set
 */
public final class MediaContentUtil {<!-- -->

    public static String filePath() {<!-- -->
        String osName = System.getProperty("os.name");
        String filePath = "/data/files/";
        if (osName.startsWith("Windows")) {<!-- -->
            filePath = "D:" + filePath;
        }
// else if (osName.startsWith("Linux")) {<!-- -->
// filePath = MediaContentConstant.FILE_PATH;
// }
        else if (osName.startsWith("Mac") || osName.startsWith("Linux")) {<!-- -->
            filePath = "/home/admin" + filePath;
        }
        return filePath;
    }

    public static String encode(String fileName) {<!-- -->
        return URLEncoder.encode(fileName, StandardCharsets.UTF_8);
    }

    public static String decode(String fileName) {<!-- -->
        return URLDecoder.decode(fileName, StandardCharsets.UTF_8);
    }
}

Reference article: http://blog.ncmem.com/wordpress/2023/10/24/spring-boot implements http large file breakpoint resume and fragmented download/
Welcome to join the group to discuss