使用 Spring Boot + MinIO 实现文件的分片上传、秒传、续传功能开发
在当今的互联网应用中,文件上传是一个常见的功能需求。然而,传统的文件上传方式在面对大文件或不稳定的网络环境时,可能会出现性能瓶颈和上传失败的问题。为了解决这些问题,分片上传、秒传和续传技术应运而生。本文将详细介绍如何使用 Spring Boot 结合 MinIO 来实现这些高级的文件上传功能。
技术选型
-
Spring Boot:一个快速开发框架,简化了 Spring 应用的搭建和配置。
-
MinIO:一个高性能的对象存储服务器,支持 S3 协议。
分片上传、秒传和续传原理说明
分片上传:
-
原理:将大文件分割成多个较小的片段(称为分片),然后分别上传这些分片。这样可以避免一次性传输大文件导致的超时、网络不稳定等问题。每个分片可以独立上传,并且在服务器端可以根据一定的规则重新组合成完整的文件。
-
优点:提高上传的成功率和稳定性,尤其在网络状况不佳的情况下。可以并行上传多个分片,提高上传速度。
秒传:
-
原理:在上传文件之前,先计算文件的唯一标识,通常是通过计算文件的哈希值(如 MD5)。服务器端会检查是否已经存在具有相同哈希值的文件。如果存在,则直接认为文件已上传成功,无需再次传输实际的文件内容。
-
优点:节省上传时间和带宽,对于重复的文件无需再次上传。
续传:
-
原理:在上传过程中断后,记录已经上传的分片信息。当下次继续上传时,客户端告知服务器已经上传的部分,服务器根据这些信息从上次中断的位置继续接收分片,而不是重新开始上传。
-
优点:在网络不稳定或其他原因导致上传中断时,无需从头开始上传,提高上传效率。
项目创建及依赖配置(pom.xml)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.0.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>file-upload-with-minio</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>File Upload with MinIO</name>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.4.4</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
配置文件(application.yml)
minio:
endpoint: http://localhost:9000
accessKey: accessKey
secretKey: secretKey
bucketName: your-bucket-name
MinIO 服务类
package com.example.service;
import io.minio.MinioClient;
import io.minio.Result;
import io.minio.errors.*;
import io.minio.messages.Item;
import java.io.IOException;
import java.io.InputStream;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;
public class MinIOService {
private final MinioClient minioClient;
private final String bucketName;
public MinIOService(String endpoint, String accessKey, String secretKey, String bucketName) {
this.minioClient = MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
this.bucketName = bucketName;
}
// 检查桶是否存在,如果不存在则创建
public void ensureBucketExists() throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException {
boolean found = minioClient.bucketExists(bucketName);
if (!found) {
minioClient.makeBucket(bucketName);
}
}
// 分片上传
public void uploadChunk(String objectName, int chunkNumber, InputStream inputStream) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException {
minioClient.putObject(bucketName, objectName + "_chunk_" + chunkNumber, inputStream, inputStream.available(), "application/octet-stream");
}
// 合并分片
public void mergeChunks(String objectName, int totalChunks) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException {
try (OutputStream outputStream = new ByteArrayOutputStream()) {
for (int i = 1; i <= totalChunks; i++) {
InputStream chunkInputStream = minioClient.getObject(bucketName, objectName + "_chunk_" + i);
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = chunkInputStream.read(buffer))!= -1) {
outputStream.write(buffer, 0, bytesRead);
}
chunkInputStream.close();
}
minioClient.putObject(bucketName, objectName, new ByteArrayInputStream(((ByteArrayOutputStream) outputStream).toByteArray()), ((ByteArrayOutputStream) outputStream).size(), "application/octet-stream");
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("合并分片失败", e);
}
}
// 秒传判断
public boolean isInstantUpload(String objectName, long fileSize, String md5Hash) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException {
try {
// 检查文件是否已存在
boolean exists = minioClient.statObject(bucketName, objectName).isPresent();
if (exists) {
// 获取已存在文件的大小和 MD5 哈希值
StatObjectResponse statResponse = minioClient.statObject(bucketName, objectName);
long existingFileSize = statResponse.size();
String existingMd5Hash = statResponse.etag();
// 比较大小和 MD5 哈希值
if (existingFileSize == fileSize && existingMd5Hash.equals(md5Hash)) {
return true;
}
}
return false;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
// 续传判断
public boolean isResumeUpload(String objectName, int uploadedChunks) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException {
try {
// 获取已上传的分片数量
ListObjectsArgs listArgs = ListObjectsArgs.builder()
.bucket(bucketName)
.prefix(objectName + "_chunk_")
.build();
Iterable<Result<Item>> results = minioClient.listObjects(listArgs);
int existingChunks = 0;
for (Result<Item> result : results) {
String name = result.get().objectName();
if (name.matches(objectName + "_chunk_\\d+")) {
existingChunks++;
}
}
// 比较已上传的分片数量
return existingChunks == uploadedChunks;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
}
}
控制器类
package com.example.controller;
import com.example.service.MinIOService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
public class FileUploadController {
@Autowired
private MinIOService minIOService;
@PostMapping("/uploadChunk")
public String uploadChunk(@RequestParam("fileName") String fileName,
@RequestParam("chunkNumber") int chunkNumber,
@RequestParam("file") MultipartFile file) {
try {
minIOService.uploadChunk(fileName, chunkNumber, file.getInputStream());
return "Chunk uploaded successfully";
} catch (Exception e) {
e.printStackTrace();
return "Upload failed";
}
}
@PostMapping("/mergeChunks")
public String mergeChunks(@RequestParam("fileName") String fileName,
@RequestParam("totalChunks") int totalChunks) {
try {
minIOService.mergeChunks(fileName, totalChunks);
return "Chunks merged successfully";
} catch (Exception e) {
e.printStackTrace();
return "Merge failed";
}
}
@PostMapping("/checkInstantUpload")
public boolean checkInstantUpload(@RequestParam("fileName") String fileName,
@RequestParam("fileSize") long fileSize,
@RequestParam("md5Hash") String md5Hash) {
try {
return minIOService.isInstantUpload(fileName, fileSize, md5Hash);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
@PostMapping("/checkResumeUpload")
public boolean checkResumeUpload(@RequestParam("fileName") String fileName,
@RequestParam("uploadedChunks") int uploadedChunks) {
try {
return minIOService.isResumeUpload(fileName, uploadedChunks);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
}
前端页面(HTML + JavaScript)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文件上传</title>
</head>
<body>
<input type="file" id="fileInput">
<button onclick="startUpload()">开始上传</button>
<script>
function startUpload() {
// 获取文件
let file = document.getElementById('fileInput').files[0];
// 计算文件 MD5
let reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.onload = function (e) {
let arrayBuffer = e.target.result;
let sparkMD5 = new SparkMD5.ArrayBuffer();
sparkMD5.append(arrayBuffer);
let md5Hash = sparkMD5.end();
// 分片大小
let chunkSize = 1024 * 1024; // 1MB
// 总片数
let totalChunks = Math.ceil(file.size / chunkSize);
// 分片上传逻辑
for (let i = 0; i < totalChunks; i++) {
let start = i * chunkSize;
let end = Math.min((i + 1) * chunkSize, file.size);
let chunk = file.slice(start, end);
let formData = new FormData();
formData.append('fileName', file.name);
formData.append('chunkNumber', i + 1);
formData.append('file', chunk);
fetch('/uploadChunk', {
method: 'POST',
body: formData
})
.then(response => response.text())
.then(result => console.log(result))
.catch(error => console.error(error));
}
};
}
</script>
</body>
</html>
总结
通过以上的代码实现,我们成功地利用 Spring Boot 和 MinIO 搭建了一个支持分片上传、秒传和续传的文件上传系统。分片上传能够有效地解决大文件上传的问题,提高上传的稳定性和效率;秒传通过文件的唯一标识(如 MD5 哈希值)来判断文件是否已存在,避免重复上传;续传则能够在上传中断后继续从上次的位置进行上传。
在实际应用中,还需要根据具体的需求对代码进行优化和完善,例如处理并发上传、错误处理、文件的权限管理等。希望本文能够为大家在实现类似的文件上传功能时提供有价值的参考和帮助。