springboot后端实现断点续传(分片下载)

发布时间 2023-11-16 14:01:53作者: Xproer-松鼠

简介:
大家应该都听说过分片上传(断点上传),那么断点下载又是什么呢?其实完全可以按照上传的理解

来理解断点续传、分片下载。下载文件的时候将一个大文件分成N个部分进行下载,然后前端再进行组合。

最终得到一个完整的文件。

但是呢,下载跟上传,后端的实现方式还是有区别的,上传需要把接口分成4个接口;但是下载不需要,

一个接口搞定;主要依赖http的Range(关于range,网上资料应该不少)头来进行处理(其实个人还考虑过

另外一种方式,未验证不知道是否可行;方式就是后端将文件进行切割,然后提供一个接口告诉前端某个文

件有多少个分片,前端分别调用接口获取各个分片,然后将分片文件进行合并,此方式是参考到分片上传的

假想)。此方法同样支持普通下载,不传入Range头就可进行普通下载;也可一次只下载一段(传入一个

range:bytes=0-10240);也可下载多段(传入多个range:bytes=0-10240,10241-20480);也可一次下载完文件

(range范围为整个文件即可:bytes=0-102400);前端怎样配合实现完全不知道,如果有哪位大佬知道的话,

真心求教!下面开始进行代码的编码

实现:
1. 下载接口实现:
/**
* 文件下载
* @author kevin
* @param response :
* @param range :
* @param filePath :
* @date 2021/1/17
*/
@ApiOperation(value = "文件下载", notes = "downloadFile")
@GetMapping(value = "/downloadFile")
public void downloadFile(@RequestParam("fileId") String fileId, @RequestParam(name = "filePath",
required = false) String filePath, HttpServletResponse response,
@RequestHeader(name = "Range", required = false) String range) {

List<FileInfo> fileInfo= fileMapper.getFileById(fileId);
if(null == fileInfo){
throw new RuntimeException("下载失败,未找到需要下载的文件");
}
filePath = StringUtils.isNotBlank(filePath) ? filePath : fileInfo.getFilePath();

File file = new File(filePath);
String filename = file.getName();
long length = file.length();
Range full = new Range(0, length - 1, length);
List<Range> ranges = new ArrayList<>();
//处理Range
try {
if (!file.exists()) {
String msg = "需要下载的文件不存在:" + file.getAbsolutePath();
log.error(msg);
throw new RuntimeException(msg);
}

if (file.isDirectory()) {
String msg = "需要下载的文件的路径对应的是一个文件夹:" + file.getAbsolutePath();
log.error(msg);
throw new RuntimeException(ResponseState.REQUEST_ERROR.getCode(), msg);
}
dealRanges(full, range, ranges, response, length);
}catch (IOException e){
e.printStackTrace();
throw new RuntimeException("文件下载异常:" + e.getMessage());
}
// 如果浏览器支持内容类型,则设置为“内联”,否则将弹出“另存为”对话框. attachment inline
String disposition = "attachment";

// 将需要下载的文件段发送到客服端,准备流.
try (RandomAccessFile input = new RandomAccessFile(file, "r");
ServletOutputStream output = response.getOutputStream()) {
//最后修改时间
FileTime lastModifiedObj = Files.getLastModifiedTime(file.toPath());
long lastModified = LocalDateTime.ofInstant(lastModifiedObj.toInstant(),
ZoneId.of(ZoneId.systemDefault().getId())).toEpochSecond(ZoneOffset.UTC);
//初始化response.
response.reset();
response.setBufferSize(20480);
response.setHeader("Content-type", "application/octet-stream;charset=UTF-8");
response.setHeader("Content-Disposition", disposition + ";filename=" +
URLEncoder.encode(filename, StandardCharsets.UTF_8.name()));
response.setHeader("Accept-Ranges", "bytes");
response.setHeader("ETag", URLEncoder.encode(filename, StandardCharsets.UTF_8.name()));
response.setDateHeader("Last-Modified", lastModified);
response.setDateHeader("Expires", System.currentTimeMillis() + 604800000L);
//输出Range到response
outputRange(response, ranges, input, output, full, length);
output.flush();
response.flushBuffer();
}catch (Exception e){
e.printStackTrace();
throw new RuntimeException("文件下载异常:" + e.getMessage());
}
}

/**
* 处理请求中的Range(多个range或者一个range,每个range范围)
* @author kevin
* @param range :
* @param ranges :
* @param response :
* @param length :
* @date 2021/1/17
*/
private void dealRanges(Range full, String range, List<Range> ranges, HttpServletResponse response,
long length) throws IOException {
if (range != null) {
// Range 头的格式必须为 "bytes=n-n,n-n,n-n...". 如果不是此格式, 返回 416.
if (!range.matches("^bytes=\\d*-\\d*(,\\d*-\\d*)*$")) {
response.setHeader("Content-Range", "bytes */" + length);
response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
return;
}

// 处理传入的range的每一段.
for (String part : range.substring(6).split(",")) {
part = part.split("/")[0];
// 对于长度为100的文件,以下示例返回:
// 50-80 (50 to 80), 40- (40 to length=100), -20 (length-20=80 to length=100).
int delimiterIndex = part.indexOf("-");
long start = Range.sublong(part, 0, delimiterIndex);
long end = Range.sublong(part, delimiterIndex + 1, part.length());

//如果未设置起始点,则计算的是最后的 end 个字节;设置起始点为 length-end,结束点为length-1
//如果未设置结束点,或者结束点设置的比总长度大,则设置结束点为length-1
if (start == -1) {
start = length - end;
end = length - 1;
} else if (end == -1 || end > length - 1) {
end = length - 1;
}

// 检查Range范围是否有效。如果无效,则返回416.
if (start > end) {
response.setHeader("Content-Range", "bytes */" + length);
response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
return;
}
// 添加Range范围.
ranges.add(new Range(start, end, end - start + 1));
}
}else{
//如果未传入Range,默认下载整个文件
ranges.add(full);
}
}



/**
* output写流输出到response
* @author kevin
* @param response :
* @param ranges :
* @param input :
* @param output :
* @param full :
* @param length :
* @date 2021/1/17
*/
private void outputRange(HttpServletResponse response, List<Range> ranges, RandomAccessFile input,
ServletOutputStream output, Range full, long length) throws IOException {
if (ranges.isEmpty() || ranges.get(0) == full) {
// 返回整个文件.
response.setContentType("application/octet-stream;charset=UTF-8");
response.setHeader("Content-Range", "bytes " + full.start + "-" + full.end + "/" + full.total);
response.setHeader("Content-length", String.valueOf(full.length));
response.setStatus(HttpServletResponse.SC_OK); // 200.
Range.copy(input, output, length, full.start, full.length);
} else if (ranges.size() == 1) {
// 返回文件的一个分段.
Range r = ranges.get(0);
response.setContentType("application/octet-stream;charset=UTF-8");
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.
// 复制单个文件分段.
Range.copy(input, output, length, r.start, r.length);
} else {
// 返回文件的多个分段.
response.setContentType("multipart/byteranges; boundary=MULTIPART_BYTERANGES");
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // 206.

// 复制多个文件分段.
for (Range r : ranges) {
//为每个Range添加MULTIPART边界和标题字段
output.println();
output.println("--MULTIPART_BYTERANGES");
output.println("Content-Type: application/octet-stream;charset=UTF-8");
output.println("Content-length: " + r.length);
output.println("Content-Range: bytes " + r.start + "-" + r.end + "/" + r.total);
// 复制多个需要复制的文件分段当中的一个分段.
Range.copy(input, output, length, r.start, r.length);
}

// 以MULTIPART文件的边界结束.
output.println();
output.println("--MULTIPART_BYTERANGES--");
}
}

参考文章:http://blog.ncmem.com/wordpress/2023/11/16/springboot%e5%90%8e%e7%ab%af%e5%ae%9e%e7%8e%b0%e6%96%ad%e7%82%b9%e7%bb%ad%e4%bc%a0%e5%88%86%e7%89%87%e4%b8%8b%e8%bd%bd/

欢迎入群一起讨论