一、简介
随着互联网的快速发展,大文件的传输成为了互联网应用的重要组成部分。然而,由于网络不稳定等因素的影响,大文件的传输经常会出现中断的情况,这时需要重新传输,导致传输效率低下。
为了解决这个问题,可以实现大文件的断点续传功能。断点续传功能可以在传输中断后继续传输,而不需要从头开始传输。这样可以大大提高传输的效率。
Spring Boot是一个快速开发的Java Web开发框架,可以帮助我们快速搭建一个Web应用程序。在Spring Boot中,我们可以很容易地实现大文件的断点续传功能。
本文将介绍如何使用Spring Boot实现大文件的断点续传功能。
二、Spring Boot实现大文件断点续传的原理
实现大文件的断点续传功能,需要在客户端和服务端都进行相应的实现。
实现示例1
服务端如何将一个大视频文件做切分,分段响应给客户端,让浏览器可以渐进式地播放。
Spring Boot实现HTTP分片下载断点续传,从而实现H5页面的大视频播放问题,实现渐进式播放,每次只播放需要播放的内容就可以了,不需要加载整个文件到内存中。
文件的断点续传、文件多线程并发下载(迅雷就是这么玩的)等。
<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>
代码实现
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;
/**
* 内容资源控制器
*/
@SuppressWarnings("unused")
@Slf4j
@RestController("resourceController")
@RequestMapping(path = "/resource")
public class ResourceController {
/**
* 获取文件内容
*
* @param fileName 内容文件名称
* @param response 响应对象
*/
@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);
}
}
/**
* 获取封面内容
*
* @param fileName 内容封面名称
* @param response 响应对象
*/
@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);
}
/**
* 设置请求响应状态、头信息、内容类型与长度 等。
* <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
* </pre>
*
* @param response 请求响应对象
* @param fileName 请求的文件名称
* @param contentType 内容类型
* @param contentRange 内容范围对象
*/
private static void setResponse(
HttpServletResponse response, String fileName, String contentType,
ContentRange contentRange) {
// http状态码要为206:表示获取部分内容
response.setStatus(HttpStatus.PARTIAL_CONTENT.value());
// 支持断点续传,获取部分字节内容
// Accept-Ranges:bytes,表示支持Range请求
response.setHeader(HttpHeaders.ACCEPT_RANGES, ContentRange.BYTES_STRING);
// inline表示浏览器直接使用,attachment表示下载,fileName表示下载的文件名
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
"inline;filename=" + MediaContentUtil.encode(fileName));
// Content-Range,格式为:[要下载的开始位置]-[结束位置]/[文件总大小]
// Content-Range: bytes 0-10/3103,格式为bytes 开始-结束/全部
response.setHeader(HttpHeaders.CONTENT_RANGE, contentRange.toContentRange());
response.setContentType(contentType);
// Content-Length: 11,本次内容的大小
response.setContentLengthLong(contentRange.applyAsContentLength());
}
/**
* <a href="https://www.jianshu.com/p/08db5ba3bc95">
* Spring Boot 处理 HTTP Headers</a>
*/
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);
// 内容范围
ContentRange contentRange = applyAsContentRange(headers, fileLength, request);
// 要下载的长度
long contentLength = contentRange.applyAsContentLength();
log.debug("contentRange={}, contentLength={}", contentRange, contentLength);
// 文件类型
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);
// 耗时指标统计
StopWatch stopWatch = new StopWatch("downloadFile");
stopWatch.start(fileName);
try {
// case-1.参考网上他人的实现
// if (fileLength >= Integer.MAX_VALUE) {
// NioUtils.copy(filePath, response, contentRange);
// } else {
// NioUtils.copyByChannelAndBuffer(filePath, response, contentRange);
// }
// case-2.使用现成API
NioUtils.copyByBio(filePath, response, contentRange);
// NioUtils.copyByNio(filePath, response, contentRange);
// case-3.视频分段渐进式播放
// if (contentType.startsWith("video")) {
// NioUtils.copyForBufferSize(filePath, response, contentRange);
// } else {
// // 图片、PDF等文件
// 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:告知服务端,客户端下载该文件想要从指定的位置开始下载
List<HttpRange> httpRanges = headers.getRange();
String range = request.getHeader(HttpHeaders.RANGE);
// httpRanges=[], range=null
// httpRanges=[448135688-], range=bytes=448135688-
log.debug("httpRanges={}, range={}", httpRanges, range);
// 开始下载位置
long firstBytePos;
// 结束下载位置
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相关工具封装,主要针对Channel读写、拷贝等封装
*/
@Slf4j
public final class NioUtils {
/**
* 缓冲区大小 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后端实现视频分段渐进式播放</a>
* 服务端如何将一个大的视频文件做切分,分段响应给客户端,让浏览器可以渐进式地播放。
* 文件的断点续传、文件多线程并发下载(迅雷就是这么玩的)等。
*
* <a href="https://blog.csdn.net/qq_32099833/article/details/109630499">
* 大文件分片上传前后端实现</a>
* </pre>
*/
public static void copyForBufferSize(
Path filePath, HttpServletResponse response, ContentRange contentRange) {
String fileName = filePath.getFileName().toString();
RandomAccessFile randomAccessFile = null;
OutputStream outputStream = null;
try {
// 随机读文件
randomAccessFile = new RandomAccessFile(filePath.toFile(), "r");
// 移动访问指针到指定位置
randomAccessFile.seek(contentRange.getStart());
// 注意:缓冲区大小 2MB,视频加载正常;1MB时有部分视频加载失败
int bufferSize = BUFFER_SIZE;
//获取响应的输出流
outputStream = new BufferedOutputStream(response.getOutputStream(), bufferSize);
// 每次请求只返回1MB的视频流
byte[] buffer = new byte[bufferSize];
int len = randomAccessFile.read(buffer);
//设置此次相应返回的数据长度
response.setContentLength(len);
// 将这1MB的视频流响应给客户端
outputStream.write(buffer, 0, len);
log.info("file download complete, fileName={}, contentRange={}",
fileName, contentRange.toContentRange());
} catch (ClientAbortException | IORuntimeException e) {
// 捕获此异常表示用户停止下载
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);
}
}
/**
* 拷贝流,拷贝后关闭流。
*
* @param filePath 源文件路径
* @param response 请求响应
* @param contentRange 内容范围
*/
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) {
// 捕获此异常表示用户停止下载
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);
}
}
/**
* 拷贝流,拷贝后关闭流。
* <pre>
* <a href="https://www.cnblogs.com/czwbig/p/10035631.html">
* Java NIO 学习笔记(一)----概述,Channel/Buffer</a>
* </pre>
*
* @param filePath 源文件路径
* @param response 请求响应
* @param contentRange 内容范围
*/
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) {
// 捕获此异常表示用户停止下载
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);
}
}
/**
* <pre>
* <a href="https://blog.csdn.net/lovequanquqn/article/details/104562945">
* SpringBoot Java实现Http方式分片下载断点续传+实现H5大视频渐进式播放</a>
* SpringBoot 实现Http分片下载断点续传,从而实现H5页面的大视频播放问题,实现渐进式播放,每次只播放需要播放的内容就可以了,不需要加载整个文件到内存中。
* 二、Http分片下载断点续传实现
* 四、缓存文件定时删除任务
* </pre>
*/
public static void copy(Path filePath, HttpServletResponse response, ContentRange contentRange) {
String fileName = filePath.getFileName().toString();
// 要下载的长度
long contentLength = contentRange.applyAsContentLength();
BufferedOutputStream outputStream = null;
RandomAccessFile randomAccessFile = null;
// 已传送数据大小
long transmitted = 0;
try {
randomAccessFile = new RandomAccessFile(filePath.toFile(), "r");
randomAccessFile.seek(contentRange.getStart());
outputStream = new BufferedOutputStream(response.getOutputStream(), BUFFER_SIZE);
// 把数据读取到缓冲区中
byte[] buffer = new byte[BUFFER_SIZE];
int len = BUFFER_SIZE;
//warning:判断是否到了最后不足4096(buffer的length)个byte这个逻辑((transmitted + len) <= contentLength)要放前面
//不然会会先读取randomAccessFile,造成后面读取位置出错;
while ((transmitted + len) <= contentLength && (len = randomAccessFile.read(buffer)) != -1) {
outputStream.write(buffer, 0, len);
transmitted += len;
log.info("fileName={}, transmitted={}", fileName, transmitted);
}
//处理不足buffer.length部分
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) {
// 捕获此异常表示用户停止下载
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);
}
}
/**
* 通过数据传输通道和缓冲区读取文件数据。
* <pre>
* 当文件长度超过{@link Integer#MAX_VALUE}时,
* 使用{@link FileChannel#map(FileChannel.MapMode, long, long)}报如下异常。
* 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)
* </pre>
*
* @param filePath 源文件路径
* @param response 请求响应
* @param contentRange 内容范围
*/
public static void copyByChannelAndBuffer(
Path filePath, HttpServletResponse response, ContentRange contentRange) {
String fileName = filePath.getFileName().toString();
// 要下载的长度
long contentLength = contentRange.applyAsContentLength();
BufferedOutputStream outputStream = null;
FileChannel inChannel = null;
// 已传送数据大小
long transmitted = 0;
long firstBytePos = contentRange.getStart();
long fileLength = contentRange.getLength();
try {
inChannel = FileChannel.open(filePath, StandardOpenOption.READ, StandardOpenOption.WRITE);
// 建立直接缓冲区
MappedByteBuffer inMap = inChannel.map(FileChannel.MapMode.READ_ONLY, firstBytePos, fileLength);
outputStream = new BufferedOutputStream(response.getOutputStream(), BUFFER_SIZE);
// 把数据读取到缓冲区中
byte[] buffer = new byte[BUFFER_SIZE];
int len = BUFFER_SIZE;
// warning:判断是否到了最后不足4096(buffer的length)个byte这个逻辑((transmitted + len) <= contentLength)要放前面
// 不然会会先读取file,造成后面读取位置出错
while ((transmitted + len) <= contentLength) {
inMap.get(buffer);
outputStream.write(buffer, 0, len);
transmitted += len;
log.info("fileName={}, transmitted={}", fileName, transmitted);
}
// 处理不足buffer.length部分
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) {
// 捕获此异常表示用户停止下载
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;
/**
* 内容范围对象
* <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
* </pre>
*
* @see org.apache.catalina.servlets.DefaultServlet.Range
*/
@Getter
@AllArgsConstructor
public class ContentRange {
/**
* 第一个字节的位置
*/
private final long start;
/**
* 最后一个字节的位置
*/
private long end;
/**
* 内容完整的长度/总长度
*/
private final long length;
public static final String BYTES_STRING = "bytes";
/**
* 组装内容范围的响应头。
* <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
* </pre>
*
* @return 内容范围的响应头
*/
public String toContentRange() {
return BYTES_STRING + ' ' + start + '-' + end + '/' + length;
// return "bytes " + start + "-" + end + "/" + length;
}
/**
* 计算内容完整的长度/总长度。
*
* @return 内容完整的长度/总长度
*/
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) && (end >= 0) && (start <= end) && (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;
/**
* 数据流进度条
*/
@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;
/**
* 文件内容辅助方法集
*/
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);
}
}
实现示例2
代码实现
(1)客户端需要实现以下功能:
- 建立连接:客户端需要连接服务端,并建立连接。
- 分块传输文件:客户端需要将文件分成若干块,并逐块传输。在传输中,每个块传输完成后,需要将已传输的位置发送给服务端,以便服务端记录传输位置。
- 计算MD5值:在传输完成后,客户端需要计算文件的MD5值,以确保传输的完整性。
- 与服务端比较MD5值:在计算出MD5值后,客户端需要将MD5值发送给服务端,并与服务端返回的MD5值比较,以确保传输的完整性。
(2)服务端需要实现以下功能:
- 建立连接:服务端需要等待客户端连接,并建立连接。
- 接收文件:服务端需要接收客户端传输的文件。在接收文件时,需要记录传输的位置,并在传输中断后继续接收文件。
- 计算MD5值:在接收完成后,服务端需要计算文件的MD5值,以确保传输的完整性。
- 返回MD5值:在计算出MD5值后,服务端需要将MD5值返回给客户端。
1.编写客户端代码
在客户端中,我们需要实现以下功能:
- 建立连接:使用Java的Socket类建立与服务端的连接。
- 分块传输文件:将文件分成若干块,并逐块传输。在传输中,每个块传输完成后,需要将已传输的位置发送给服务端,以便服务端记录传输位置。
- 计算MD5值:在传输完成后,计算文件的MD5值,以确保传输的完整性。
- 与服务端比较MD5值:将MD5值发送给服务端,并与服务端返回的MD5值比较,以确保传输的完整性。
以下是客户端代码的实现:
@RestController
@RequestMapping("/file")
public class FileController {
@PostMapping("/upload")
public ResponseEntity<?> uploadFile(
@RequestParam("file") MultipartFile file,
@RequestParam("fileName") String fileName,
@RequestParam("startPosition") long startPosition) {
try { // 建立连接
Socket socket = new Socket("localhost", 8080);
OutputStream outputStream = socket.getOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(outputStream);
// 分块传输文件
FileInputStream fileInputStream = (FileInputStream) file.getInputStream();
fileInputStream.skip(startPosition);
byte[] buffer = new byte[1024];
int len;
while ((len = fileInputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, len);
}
// 计算MD5值
fileInputStream.getChannel().position(0);
String md5 = DigestUtils.md5Hex(fileInputStream);
// 与服务端比较MD5值
InputStream inputStream = socket.getInputStream();
ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
String serverMd5 = (String) objectInputStream.readObject();
if (!md5.equals(serverMd5)) {
throw new RuntimeException("MD5值不匹配");
}
// 关闭连接
objectOutputStream.close();
outputStream.close();
socket.close();
} catch (Exception e) {
e.printStackTrace();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage());
}
return ResponseEntity.ok().build();
}
}
2.编写服务端代码
在服务端中,我们需要实现以下功能:
- 建立连接:使用Java的ServerSocket类等待客户端连接,并建立连接。
- 接收文件:接收客户端传输的文件。在接收文件时,需要记录传输的位置,并在传输中断后继续接收文件。
- 计算MD5值:在接收完成后,计算文件的MD5值,以确保传输的完整性。
- 返回MD5值:将MD5值返回给客户端。
以下是服务端代码的实现:
@RestController
@RequestMapping("/file")
public class FileController {
private final String FILE_PATH = "/tmp/upload/";
@PostMapping("/upload")
public ResponseEntity<?> uploadFile(HttpServletRequest request, @RequestParam("fileName") String fileName) {
try {
// 建立连接
ServerSocket serverSocket = new ServerSocket(8080);
Socket socket = serverSocket.accept();
InputStream inputStream = socket.getInputStream();
ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
// 接收文件
String filePath = FILE_PATH + fileName;
RandomAccessFile randomAccessFile = new RandomAccessFile(filePath, "rw");
long startPosition = randomAccessFile.length();
randomAccessFile.seek(startPosition);
byte[] buffer = new byte[1024];
int len;
while ((len = inputStream.read(buffer)) != -1) {
randomAccessFile.write(buffer, 0, len);
} // 计算MD5值
FileInputStream fileInputStream = new FileInputStream(filePath);
String md5 = DigestUtils.md5Hex(fileInputStream);
// 返回MD5值
OutputStream outputStream = socket.getOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(outputStream);
objectOutputStream.writeObject(md5); // 关闭连
objectInputStream.close();
inputStream.close();
randomAccessFile.close();
socket.close();
serverSocket.close();
} catch (Exception e) {
e.printStackTrace();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(e.getMessage());
}
return ResponseEntity.ok().build();
}
}
3. 编写前端代码
在前端中,我们需要实现以下功能:
- 选择文件:提供一个文件选择框,让用户选择要上传的文件。
- 分块上传:将文件分块上传到服务器。在上传过程中,需要记录上传的位置,并在上传中断后继续上传。
以下是前端代码的实现:
<html>
<head>
<meta charset="UTF-8">
<title>Spring Boot File Upload</title>
<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script>
</head>
<body><input type="file" id="file">
<button onclick="upload()">Upload</button>
<script> var file;
var startPosition = 0;
$('#file').on('change', function () {
file = this.files[0];
});
function upload() {
if (!file) {
alert('Please select a file!');
return;
}
var formData = new FormData();
formData.append('file', file);
formData.append('fileName', file.name);
formData.append('startPosition', startPosition);
$.ajax({
url: '/file/upload',
type: 'post',
data: formData,
cache: false,
processData: false,
contentType: false,
success: function () {
alert('Upload completed!');
},
error: function (xhr) {
alert(xhr.responseText);
},
xhr: function () {
var xhr = $.ajaxSettings.xhr();
xhr.upload.onprogress = function (e) {
if (e.lengthComputable) {
var percent = e.loaded / e.total * 100;
console.log('Upload percent: ' + percent.toFixed(2) + '%');
}
};
return xhr;
}
});
}</script>
</body>
</html>
总结
本文介绍了如何使用Spring Boot实现大文件断点续传。在实现中,我们使用了Java的RandomAccessFile类来实现文件的分块上传和断点续传,使用了Spring Boot的RestController注解来实现Web服务的开发,使用了jQuery的Ajax函数来实现前端页面的开发。
在实际开发中,需要注意以下几点:
- 上传文件的大小和分块的大小需要根据实际情况进行设置,以确保上传速度和服务器的稳定性。
- 在上传过程中,需要对异常情况进行处理,以确保程序的健壮性。
- 在上传完成后,需要对上传的文件进行校验,以确保传输的完整性。