大文件上传下载处理方案-断点续传,秒传,分片,合并
1.断点续传介绍
通常一些文件如视频文件体积都比较大,对于这些文件的上传需求要满足大文件的上传要求。http协议本身对上传文件大小没有限制,但是客户的网络环境质量、电脑硬件环境等参差不齐,如果一个大文件快上传完了网断了没有上传完成,需要客户重新上传,用户体验非常差,所以对于大文件上传的要求最基本的是断点续传。
什么是断点续传:
引用百度百科:断点续传指的是在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为的划分为几个部分,每一个部分采用一个线程进行上传或下载,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传下载未完成的部分,而没有必要从头开始上传下载,断点续传可以提高节省操作时间,提高用户体验性。
流程如下:
1、前端上传前先把文件分成块
2、一块一块的上传,上传中断后重新上传,已上传的分块则不用再上传
3、各分块上传完成最后在服务端合并文件
2.MinIO大文件分块合并思路
2.1 整体思路
1、前端对文件进行分块。
2、前端上传分块文件前请求媒资服务检查文件是否存在,如果已经存在则不再上传。
3、如果分块文件不存在则前端开始上传
4、前端请求媒资服务上传分块。
5、媒资服务将分块上传至MinIO。
6、前端将分块上传完毕请求媒资服务合并分块。
7、媒资服务判断分块上传完成则请求MinIO合并文件。
8、合并完成校验合并后的文件是否完整,如果完整则上传完成,否则删除文件
为了更好的理解文件分块上传的原理,下边用java代码测试文件的分块与合并。
2.2 文件分块
文件分块的流程如下:
1、获取源文件长度
2、根据设定的分块文件的大小计算出块数
3、从源文件读数据依次向每一个块文件写数据。
本地将大文件分片:
//本地大文件切片
@Test
public void splitLocalFile() throws Exception {
//源大文件
File sourceFile=new File("D:\\dOWN\\ttt.zip");
//计算分块数量
long fileTotalSize=sourceFile.length();
long chunkSize=1024*1024*5; //每块5MB
long chunkNum= (long) Math.ceil(fileTotalSize*1.0/chunkSize);
try (RandomAccessFile rafRead = new RandomAccessFile(sourceFile, "r")) {
// 1MB缓冲区(平衡IO次数和内存占用)
byte[] buffer = new byte[1024 * 1024];
// 遍历生成每个分块
for (int i = 0; i < chunkNum; i++) {
// 分块文件命名规则:数字(保证排序有序)
File chunkFile = new File("F:\\外接项目\\E-Learn项目重构\\demo\\backend\\E-Learn-Backend\\doc\\testBigFile\\" + i);
// 创建空分块文件
boolean createSuccess = chunkFile.createNewFile();
if (!createSuccess) {
throw new IOException("分块文件创建失败:" + chunkFile.getAbsolutePath());
}
// 写入分块数据(try-with-resources自动关闭写流)
try (RandomAccessFile rafWrite = new RandomAccessFile(chunkFile, "rw")) {
int readLen; // 单次读取的字节数
// 循环读取源文件数据,写入分块文件
while ((readLen = rafRead.read(buffer)) != -1) {
rafWrite.write(buffer, 0, readLen);
// 分块文件大小达到阈值则停止写入(避免单个分块超过5MB)
if (chunkFile.length() >= chunkSize) {
break;
}
}
}
}
}
}
本地分片上传到minio:
//将本地分片上传到minio
@Test
public void test() throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
minioClient = MinioClient.builder()
.endpoint("http://127.0.0.1:9000")
.credentials("c7bbXkpNOJU5wBLw", "6TcSQfRHOPv8ZqaYN9TLMZfsNfqEbrsm")
.build();
// 1. 校验本地分块目录有效性
File chunkFolder=new File("F:\\外接项目\\E-Learn项目重构\\demo\\backend\\E-Learn-Backend\\doc\\testBigFile\\");
File[] chunkFiles=chunkFolder.listFiles();
//2.分块文件排序(按文件数字升序排序,保证上传顺序)
List<File> chunkFileList=Arrays.asList(chunkFiles);
chunkFileList.sort((f1, f2) -> {
int num1 = Integer.parseInt(f1.getName());
int num2 = Integer.parseInt(f2.getName());
return Integer.compare(num1, num2);
});
//3.逐个上传分块到minio
for (File chunkFile : chunkFileList) {
// MinIO中分块对象名:前缀+分块编号(如chunk/0)
String chunkObjectName = "/chunk/" + chunkFile.getName();
// 构建上传参数
UploadObjectArgs uploadArgs = UploadObjectArgs.builder()
.bucket("test") // 目标存储桶
.object(chunkObjectName) // 分块对象名
.filename(chunkFile.getAbsolutePath()) // 本地分块路径
.build();
// 执行上传(异常直接抛出)
minioClient.uploadObject(uploadArgs);
System.out.println("分块[" + chunkFile.getName() + "]上传成功,MinIO路径:" + chunkObjectName);
}
System.out.println("===== 所有分块上传完成 =====");
}
2.3 分片合并
合并minio中的文件分片并校验其合法性:
//minio中合并分片
@Test
public void mergeChunksInMinio() throws Exception {
minioClient = MinioClient.builder()
.endpoint("http://127.0.0.1:9000")
.credentials("c7bbXkpNOJU5wBLw", "6TcSQfRHOPv8ZqaYN9TLMZfsNfqEbrsm")
.build();
// 1. 查询MinIO中的分块数量(避免硬编码分块数)
ListObjectsArgs listArgs = ListObjectsArgs.builder()
.bucket("test")
.prefix("/chunk/") // 只查询分块前缀下的对象
.build();
Iterable<Result<io.minio.messages.Item>> chunkItems = minioClient.listObjects(listArgs);
long chunkNum = 0;
for (Result<Item> ignored : chunkItems) {
chunkNum++;
}
if (chunkNum == 0) {
throw new Exception("MinIO中未查询到分块文件,无法合并");
}
System.out.println("===== 合并参数 =====");
System.out.println("待合并分块数:" + chunkNum);
// 2. 构建分块源列表(按顺序关联每个分块)
List<ComposeSource> composeSources = Stream.iterate(0, i -> i + 1)
.limit(chunkNum) // 限制生成数量为分块总数
.map(i -> ComposeSource.builder()
.bucket("test") // 分块所在桶
.object("/chunk/" + i) // 分块对象名
.build())
.collect(Collectors.toList());
// 3. 构建合并参数并执行合并
ComposeObjectArgs composeArgs = ComposeObjectArgs.builder()
.bucket("test") // 合并后文件所在桶
.object("/merge/mergedFile") // 合并后文件名
.sources(composeSources) // 分块源列表
.build();
minioClient.composeObject(composeArgs);
System.out.println("===== MinIO分块合并完成 =====");
// 4. 校验合并文件完整性(对比源文件MD5)
validateMergeFile();
}
/**
* 辅助方法:校验合并后的文件完整性
* 逻辑:对比本地源文件和MinIO合并文件的MD5值
* 异常:直接抛出
*/
private void validateMergeFile() throws IOException, ServerException, InsufficientDataException, ErrorResponseException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
System.out.println("===== 开始校验合并文件 =====");
// 1. 下载MinIO合并后的文件到本地临时文件
File mergeTempFile = new File("F:\\外接项目\\E-Learn项目重构\\demo\\backend\\E-Learn-Backend\\doc\\merres");
if (mergeTempFile.exists()) {
mergeTempFile.delete();
}
DownloadObjectArgs downloadArgs = DownloadObjectArgs.builder()
.bucket("test")
.object("/merge/mergedFile")
.filename(mergeTempFile.getAbsolutePath())
.build();
minioClient.downloadObject(downloadArgs);
// 2. 计算源文件MD5
File sourceFile = new File("D:\\dOWN\\ttt.zip");
String sourceMd5;
try (FileInputStream sourceIs = new FileInputStream(sourceFile)) {
sourceMd5 = DigestUtils.md5Hex(sourceIs);
}
// 3. 计算合并文件MD5
String mergeMd5;
try (FileInputStream mergeIs = new FileInputStream(mergeTempFile)) {
mergeMd5 = DigestUtils.md5Hex(mergeIs);
}
// 4. 对比MD5
if (sourceMd5.equals(mergeMd5)) {
System.out.println("文件校验成功!MD5值:" + sourceMd5);
// 删除临时文件
mergeTempFile.delete();
} else {
throw new IOException("文件校验失败!源文件MD5:" + sourceMd5 + ",合并文件MD5:" + mergeMd5);
}
System.out.println("===== 校验完成 =====");
}
清理MinIO中的文件分片(合并后执行):
//清理Minio中的文件分片(合并后执行)
@Test
public void cleanChunksInMinio() throws Exception {
minioClient = MinioClient.builder()
.endpoint("http://127.0.0.1:9000")
.credentials("c7bbXkpNOJU5wBLw", "6TcSQfRHOPv8ZqaYN9TLMZfsNfqEbrsm")
.build();
// 1. 查询待删除的分块列表
ListObjectsArgs listArgs = ListObjectsArgs.builder()
.bucket("test")
.prefix("/chunk/")
.build();
Iterable<Result<Item>> chunkItems = minioClient.listObjects(listArgs);
// 2. 构建删除对象列表
List<DeleteObject> deleteObjects = new ArrayList<>();
for (Result<Item> itemResult : chunkItems) {
Item item = itemResult.get();
deleteObjects.add(new DeleteObject(item.objectName()));
}
if (deleteObjects.isEmpty()) {
System.out.println("MinIO中无分块文件需要清理");
return;
}
// 3. 批量删除分块
System.out.println("===== 开始清理分块 =====");
RemoveObjectsArgs removeArgs = RemoveObjectsArgs.builder()
.bucket("test")
.objects(deleteObjects) // 待删除的分块列表
.build();
Iterable<Result<DeleteError>> deleteResults = minioClient.removeObjects(removeArgs);
// 4. 遍历删除结果(捕获单个分块删除失败的异常)
for (Result<DeleteError> deleteResult : deleteResults) {
DeleteError error = deleteResult.get();
if (error != null) {
throw new IOException("分块[" + error.objectName() + "]删除失败:" + error.message());
}
}
System.out.println("===== 分块清理完成 =====");
System.out.println("共删除分块数:" + deleteObjects.size());
}
3.大文件上传案例演示
3.1 整体思路实现
大文件分片上传方案基于 “前端分片 + 后端合并 + 断点续传 + 秒传” 核心设计,完整流程分为 前端预处理 → 校验阶段 → 分片上传 → 后端合并 → 播放 / 下载 五个核心阶段,前后端分工明确:
- 前端:负责文件切割、MD5 计算、分片上传(并发 + 断点)、进度回调;
- 后端:负责分片接收、MinIO 存储、分块校验、合并分块、MD5 验真、在线播放 / 下载支持。
3.2 后端接口设计
3.2.1 整体设计
文件上传:
文件合并:
3.2.2 接口设计
Controller:
import com.lgh.common.result.Result;
import com.lgh.web.service.VedioService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
/**
* 视频接口测试
* @Author GuihaoLv
*/
@RestController
@RequestMapping("/web/vedio")
@Tag(name="视频接口测试",description = "视频接口测试")
@Slf4j
public class VedioController {
@Autowired
private VedioService vedioService;
/**
* 文件上传前检查
* @param fileMd5
* @return
*/
@PostMapping("/upload/checkFile")
@Operation(summary = "文件上传前检查")
public Result<Boolean> checkFile(@RequestParam("fileMd5") String fileMd5) {
Boolean exists = vedioService.checkFileExists(fileMd5);
return Result.success(exists);
}
/**
* 分块上传前检查
* @param fileMd5
* @param chunk
* @return
*/
@PostMapping("/upload/checkChunk")
@Operation(summary = "文件上传前检查")
public Result<Boolean> checkChunk(@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunk")int chunk) {
Boolean exists = vedioService.checkChunkExists(fileMd5,chunk);
return Result.success(exists);
}
/**
* 上传分块文件
* @param fileMd5
* @param chunk
* @return
*/
@PostMapping("/upload/uploadChunk")
@Operation(summary = "传分块文件")
public Result<Boolean> uploadChunk(@RequestParam("file") MultipartFile file,
@RequestParam("fileMd5") String fileMd5,
@RequestParam("chunk")int chunk) {
Boolean success = vedioService.uploadChunk(file, fileMd5, chunk);
return Result.success(success);
}
/**
* 合并分块文件
* @param fileMd5
* @param chunkTotal
* @return
*/
@PostMapping("/upload/mergeChunk")
@Operation(summary = "合并分块文件")
public Result<Boolean> mergeChunk(@RequestParam("fileMd5") String fileMd5,
@RequestParam("fileName") String fileName,
@RequestParam("chunkTotal") int chunkTotal) {
Boolean success = vedioService.mergeChunk(fileMd5, fileName, chunkTotal);
return Result.success(success);
}
}
Service:
import org.springframework.web.multipart.MultipartFile;
public interface VedioService {
/**
* 文件上传前检查
* @param fileMd5
* @return
*/
Boolean checkFileExists(String fileMd5);
/**
* 检查分块是否存在
* @param fileMd5
* @param chunk
* @return
*/
Boolean checkChunkExists(String fileMd5, int chunk);
/**
* 检查分块是否存在
* @param file
* @param fileMd5
* @param chunk
* @return
*/
Boolean uploadChunk(MultipartFile file, String fileMd5, int chunk);
/**
* 合并分块
* @param fileMd5
* @param fileName
* @return
*/
Boolean mergeChunk(String fileMd5, String fileName, int chunkTotal);
}
import com.lgh.common.properties.MinIoProperties;
import com.lgh.web.service.VedioService;
import io.minio.*;
import io.minio.errors.MinioException;
import io.minio.messages.DeleteError;
import io.minio.messages.DeleteObject;
import io.minio.messages.Item;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.compress.utils.IOUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.util.List;
import java.util.ArrayList;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* 视频服务实现类
* @Author GuihaoLv
*/
@Service
@Slf4j
public class VedioServiceImpl implements VedioService {
@Autowired
private MinioClient minioClient;
@Autowired
private MinIoProperties minIoProperties;
/**
* 文件上传前检查
* @param fileMd5
* @return
*/
public Boolean checkFileExists(String fileMd5) {
// 1. 构建完整文件在MinIO中的存储路径(与分块目录规则一致)
String fullFilePath = getFullFilePath(fileMd5);
try {
// 2. 检查MinIO中是否存在该对象
StatObjectArgs statArgs = StatObjectArgs.builder()
.bucket(minIoProperties.getBucketName())
.object(fullFilePath)
.build();
minioClient.statObject(statArgs);
log.info("文件已存在,MD5:{},MinIO路径:{}", fileMd5, fullFilePath);
return true;
} catch (MinioException e) {
// MinIO返回对象不存在异常,说明文件未上传
if (e.getMessage().equals("NoSuchKey")) {
log.info("文件不存在,MD5:{}", fileMd5);
return false;
}
// 其他异常打印日志,返回false
log.error("检查文件存在性失败,MD5:{}", fileMd5, e);
return false;
} catch (Exception e) {
log.error("检查文件存在性异常,MD5:{}", fileMd5, e);
return false;
}
}
/**
* 检查分块是否已存在
* 逻辑:拼接分块文件路径,检查MinIO中是否存在该分块对象
*/
public Boolean checkChunkExists(String fileMd5, int chunk) {
// 1. 构建分块文件在MinIO中的路径
String chunkFilePath = getChunkFileFolderPath(fileMd5) + chunk;
try {
// 2. 检查分块对象是否存在
StatObjectArgs statArgs = StatObjectArgs.builder()
.bucket(minIoProperties.getBucketName())
.object(chunkFilePath)
.build();
minioClient.statObject(statArgs);
log.info("分块已存在,MD5:{},分块索引:{}", fileMd5, chunk);
return true;
} catch (MinioException e) {
if (e.getMessage().equals("NoSuchKey")) {
log.info("分块不存在,MD5:{},分块索引:{}", fileMd5, chunk);
return false;
}
log.error("检查分块存在性失败,MD5:{},分块索引:{}", fileMd5, chunk, e);
return false;
} catch (Exception e) {
log.error("检查分块存在性异常,MD5:{},分块索引:{}", fileMd5, chunk, e);
return false;
}
}
/**
* 上传分块文件到MinIO
* 逻辑:MultipartFile转InputStream,上传到分块指定路径
*/
public Boolean uploadChunk(MultipartFile file, String fileMd5, int chunk) {
// 1. 校验参数
if (file.isEmpty()) {
log.error("上传分块失败,文件为空,MD5:{},分块索引:{}", fileMd5, chunk);
return false;
}
// 2. 构建分块存储路径
String chunkFilePath = getChunkFileFolderPath(fileMd5) + chunk;
try (InputStream inputStream = file.getInputStream()) {
// 3. 上传分块到MinIO
PutObjectArgs putArgs = PutObjectArgs.builder()
.bucket(minIoProperties.getBucketName())
.object(chunkFilePath)
.stream(inputStream, file.getSize(), -1) // -1表示自动检测文件大小
.contentType(file.getContentType())
.build();
minioClient.putObject(putArgs);
log.info("分块上传成功,MD5:{},分块索引:{},路径:{}", fileMd5, chunk, chunkFilePath);
return true;
} catch (Exception e) {
log.error("分块上传失败,MD5:{},分块索引:{}", fileMd5, chunk, e);
return false;
}
}

编辑



编辑

编辑



编辑





