SpringBoot:SpringBoot+Minio+Vue实现分片上传、断点续传(分片下载)功能
前言
开发项目的时候遇到了稍大一点的文件,做上传下载的时候有点慢,就网上冲浪下借鉴各位大佬的代码实现了分片上传、断点续传功能,在此做个记录文章。
框架环境说明

Minio使用docker部署的,版本:2024.7.4
分片上传功能
Vue实现
<template>
<div class="container">
<el-upload
class="upload-demo"
drag
action="/xml/fileUpload"
multiple
:on-change="handleChange"
:auto-upload="false">
<i class="el-icon-upload"></i>
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
</el-upload>
<el-button style="margin-left: 10px;" size="small" type="success" @click="submitUpload">上传到服务器</el-button>
</div>
</template>
<script>
export default {
name: 'App',
data() {
return {
file: '',
fileList: [],
CHUNK_SIZE: 1024 * 1024 * 100//100MB
}
},
watch: {},
created() {
},
methods: {
async submitUpload() {
//获取上传的文件信息
const file = this.fileList[0].raw
//分片
const totalChunks = Math.ceil(file.size / this.CHUNK_SIZE);
for (let i = 0; i < totalChunks; i++) {
const start = i * this.CHUNK_SIZE;
const end = Math.min(start + this.CHUNK_SIZE, file.size);
//将文件切片
const chunk = file.slice(start, end);
//组装参数
const formData = new FormData();
formData.append('file', chunk);
formData.append('fileName', file.name);
formData.append('index', i);
//异步上传
await fetch('/system/sys-file/upload/bigFile', {
method: 'POST',
body: formData
});
}
//调用合并分片请求
await fetch('/system/sys-file/merge/bigFile', {
method: 'POST',
body: JSON.stringify({fileName: file.name}),
headers: {'Content-Type': 'application/json'}
});
},
handleChange(file, fileList) {
this.fileList = fileList
}
}
}
</script>
<style>
.container {
display: flex;
}
</style>
Springboot实现
Controller文件
@Tag(name = "系统公共文件管理")
@RequiredArgsConstructor
@RestController
@RequestMapping("/system/sys-file")
public class SysFileController {
private final SysFileService sysFileService;
/**
* 文件分片-上传分片接口
* @param file 文件
* @param fileName 文件名称 例:5c7e4b370488494986c2f9284ea6eab9-A001.ppt.chunk.0
* @param type 存储到Minio的文件夹目录 例:/temp/ppt/
* @param relativePath 相对Minio桶的路径,第一次分片调用时不需要传递,第二次分片调用时将第一次结果中的相对路径传递过来
* @return
*/
@Operation(summary = "文件分片-上传分片接口")
@RequestMapping(value = "/upload/bigFile", method = RequestMethod.POST)
private R bigFileUpload(@RequestPart("file") MultipartFile file,
@RequestParam("fileName") String fileName,
@RequestParam("index") Integer index,
@RequestParam("type") String type,
@RequestParam("relativePath") String relativePath) {
if (file.isEmpty()) {
return R.fail("上传失败!");
}
// 文件临时存储路径
Map<String, Object> map = sysFileService.uploadMinioChunksFile(file, (fileName + Constants.fileChunk + index),
type, relativePath);
if (map != null && !map.isEmpty()) {
return R.ok(map);
}
return R.fail();
}
/**
* 文件分片-合并分片接口
* @param request fileName 将分片合并后的名称(这里需要取分片文件名称的一部分作为合并后的名称) 例:5c7e4b370488494986c2f9284ea6eab9-A001.ppt
* @param request relativePath 最后一次分片结果的相对路径传递过来
* @param request size 文件的总大小,用于存储文件记录信息(看业务情况,不需要的话可以删掉)
* @return
*/
@Operation(summary = "文件分片-合并分片接口")
@PostMapping("/merge/bigFile")
public R mergeChunks(@RequestBody Map<String, String> request) {
String filename = request.get("fileName").toString();
String relativePath = request.get("relativePath").toString();
long size = Long.valueOf(request.get("size").toString());
Map<String, Object> map = sysFileService.mergeMinioChunksFile(filename, relativePath, size);
if (map != null && map.get("id") != null) {
return R.ok(map);
}
return R.fail();
}
}
SysFileServiceImpl文件
@Service
@RequiredArgsConstructor
public class SysFileServiceImpl extends ServiceImpl<CommonFileMapper, SysFile> implements SysFileService {
private final MinioService minioService;
/**
* 上传文件
* @param file 文件
* @param fileName 文件名称 例:5c7e4b370488494986c2f9284ea6eab9-A001.ppt.chunk.0
* @param type 存储到Minio的文件夹目录 /temp/ppt/
* @param relativePath 相对Minio桶的路径,第一次分片调用时不需要传递,第二次分片调用时将第一次的相对路径结果传递过来
* @return {@link String}
*/
@Override
public Map<String, Object> uploadMinioChunksFile(MultipartFile file, String fileName, String type, String relativePath) {
Map<String, Object> map = new HashMap<>();
// 验证文件保存的目录是否合法,这步可以根据大家的业务情况来改变
type = type.lastIndexOf("/") == -1 ? type : type.substring(type.lastIndexOf("/") - 1);
type = FILE_TYPE_UPLOAD.getType(type);
// 相对路径不为空,进行下一步
if(StringUtils.isNotEmpty(type)) {
try {
// 将路径拆分数组
String[] split = fileName.split("/");
// 获取文件名称中的UUID
String uuid = split[split.length - 1].split("-")[0];
// 去掉路径第一个 / 然后拼接UUID
// 这里代码目的为:分片文件存储在以文件名称中的UUID为名称的目录中,方便后续找到这个UUID目录做分片文件合并
String prefix = FileUploadUtils.minioPrefixDir(type)+uuid+"/";
// 相对路径的文件名
String filename = StringUtils.isNotEmpty(relativePath) ? relativePath + fileName
: prefix + fileName;
// minio 开始上传文件
minioService.uploadFile(filename, file.getInputStream());
map.put("filename", filename);
map.put("name", fileName);
map.put("type", type);
map.put("relativePath", StringUtils.isNotEmpty(relativePath) ? relativePath : prefix);
} catch (Exception e) {
e.printStackTrace();
AdminResponseEnum.SYS_UPLOAD_FILE_ERROR.assertTrue(true);
}
}
return map;
}
/**
* 合并文件
* @param fileName 合并后的文件名称 例:5c7e4b370488494986c2f9284ea6eab9-A001.ppt
* @param relativePath 最后一次分片调用结果的相对路径传递过来 例:/temp/ppt/5c7e4b370488494986c2f9284ea6eab9/
* @param size 文件总大小
* @return {@link String}
*/
@Override
public Map<String, Object> mergeMinioChunksFile(String fileName, String relativePath, Long size) {
Map<String, Object> map = new HashMap<>();
// 验证文件保存的目录是否合法,这步可以根据大家的业务情况来改变
List<String> fileTypes = FILE_TYPE_UPLOAD.getFileTypes();
String type = null;
// 将相对路径拆分为集合
List<String> split = Arrays.asList(relativePath.split("/"));
for(String fileType : fileTypes){
// 如果相对路径中包含 type 信息
if(split.contains(fileType)){
type = fileType;
break;
}
}
// 相对路径不为空且文件目录合法,进行下一步
if(StringUtils.isNotEmpty(relativePath) && StringUtils.isNotEmpty(type)){
// 取文件名称中的UUID, 5c7e4b370488494986c2f9284ea6eab9
String name = fileName.replace(fileName.split("-")[0]+"-", "");
// 将相对路径中的文件UUID路径剔除,/temp/ppt/5c7e4b370488494986c2f9284ea6eab9/ -> /temp/ppt/
// 继续拼接 合并后的文件名称 5c7e4b370488494986c2f9284ea6eab9-A001.ppt -> /temp/ppt/5c7e4b370488494986c2f9284ea6eab9-A001.ppt
// 这个路径就是要存储的相对路径文件名 /temp/ppt/5c7e4b370488494986c2f9284ea6eab9-A001.ppt
String filename = relativePath.replaceAll(split.get(split.size()-1)+"/", "") + fileName;
// 合并分片文件
minioService.mergeFile(filename);
// 获取上传文件url
String fileUrl = minioService.getPublicObjectUrl(filename);
map.put("filename", filename);
map.put("name", name);
map.put("type", type);
map.put("url", fileUrl);
String sub = fileUrl.substring(fileUrl.lastIndexOf(":") + 1);
int start = sub.indexOf("/");
map.put("urlNet", minioProperties.getHttpConf() + sub.substring(start + 1));
SysFile sysFile = fileLog(size, map);
map.put("id", sysFile.getId());
// 获取当前的LocalDateTime实例
LocalDateTime now = LocalDateTime.now();
// 创建一个DateTimeFormatter实例,指定想要的格式
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// 使用formatter格式化LocalDateTime对象
String time = now.format(formatter);
map.put("createTime", time);
}
return map;
}
}
MinioService文件
@Service
public class MinioService {
@Resource
private MinioClient minioClient;
// Minio的存储桶名称
@Resource
private String bucketName;
/**
* 上传文件流
* @param objectName 对象名称 /temp/ppt/5c7e4b370488494986c2f9284ea6eab9/5c7e4b370488494986c2f9284ea6eab9-A001.ppt.chunk.3
* @param inputStream 文件流
* @return ObjectWriteResponse
*/
@SneakyThrows
public ObjectWriteResponse uploadFile(String objectName, InputStream inputStream) {
return minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.stream(inputStream, inputStream.available(), -1)
.build());
}
/**
* 合并分片文件
* @param objectName 对象名 例:/temp/ppt/5c7e4b370488494986c2f9284ea6eab9-A001.ppt
* @return ObjectWriteResponse
*/
@SneakyThrows
public void mergeFile(String objectName) {
// 将相对路径文件名拆分成数组
String[] split = objectName.split("/");
// 将相对路径文件名去除文件UUID名称 和 路径第一个字符 / -> temp/ppt/
String parentPath = objectName.replaceAll(split[split.length - 1], "").substring(1);
// 获取文件名称中的UUID -> 5c7e4b370488494986c2f9284ea6eab9
String uuid = split[split.length - 1].split("-")[0];
// 拼接分片文件存储的相对路径 -> temp/ppt/5c7e4b370488494986c2f9284ea6eab9/
String prefix = parentPath + uuid + "/";
// 获取所有分片对象
Iterable<Result<Item>> parts = minioClient.listObjects(
ListObjectsArgs.builder()
.bucket(bucketName)
.prefix(prefix) // 将分片文件存储的相对路径下的所有文件查出来
.recursive(false)
.build());
// 将所有分片对象存储在一个列表中以便后续操作
List<Item> partList = new ArrayList<>();
for (Result<Item> result : parts) {
Item item = result.get();
if (!item.isDir()) {
partList.add(item);
}
}
// 按分片编号排序
Collections.sort(partList, Comparator.comparingInt(o -> extractPartNumber(o.objectName())));
//获取需要合并的分片组装成ComposeSource
List<ComposeSource> sourceObjectList = new ArrayList<>(partList.size());
for (Item object : partList) {
// 每个分片的相对路径文件名称
String partName = object.objectName();
sourceObjectList.add(
ComposeSource.builder()
.bucket(bucketName)
.object(partName)
.build()
);
}
//合并分片
try {
ComposeObjectArgs composeObjectArgs = ComposeObjectArgs.builder()
.bucket(bucketName)
//合并后的相对路径文件名称
.object(objectName)
//指定源文件
.sources(sourceObjectList)
.build();
// 开始合并
minioClient.composeObject(composeObjectArgs);
} catch (Exception e) {
System.err.println("Error merge object " + objectName + ": " + e.getMessage());
throw e;
}
// 删除所有分片对象,保存分片的临时UUID目录也会因为分片文件删除后自动清除
for (Item part : partList) {
String partName = part.objectName();
try {
// 删除所有临时存储的分片文件
minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(partName).build());
} catch (Exception e) {
System.err.println("Error removing object " + partName + ": " + e.getMessage());
throw e;
}
}
}
/**
* 获得永不失效的文件外链
* @param objectName 对象名称
* @return url
*/
@SneakyThrows
public String getPublicObjectUrl(String objectName) {
GetPresignedObjectUrlArgs args = GetPresignedObjectUrlArgs.builder()
.bucket(bucketName)
.object(objectName)
.method(Method.GET)
.build();
URI uri = new URI(minioClient.getPresignedObjectUrl(args));
String path = uri.getPath().replaceAll("//", "/");
return new URI(uri.getScheme(), uri.getAuthority(), path, null, null).toString();
}
}
断点续传功能
Vue实现
<template>
<div class="main">
<div class="fileList">
<div class="title">
文件列表
<!-- <hr> -->
</div>
<el-table :data="fileList" border style="width: 360px">
<el-table-column prop="name" label="文件名" width="150">
</el-table-column>
<el-table-column prop="size" label="文件大小" width="110">
<template #default="scope">
{{ formatSize(scope.row) }}
</template>
</el-table-column>
<el-table-column prop="" label="操作" width="100">
<template #default="scope">
<el-button
size="small"
type="primary"
@click="downloadFile(scope.row)"
>下载</el-button
>
</template>
</el-table-column>
</el-table>
</div>
<div class="downloadList">
<el-divider content-position="left">下载列表</el-divider>
<div v-for="file in downloadingFileList">
<div class="downloading">
<span class="fileName">{{ file.name }}</span>
<span class="fileSize">{{ formatSize(file) }}</span>
<span class="downloadSpeed">{{ file.downloadSpeed }}</span>
<div class="progress">
<span>下载进度:</span>
<el-progress
:text-inside="true"
:stroke-width="16"
:percentage="file.downloadPersentage"
>
</el-progress>
<el-button circle link @click="changeDownloadStop(file)">
<el-icon size="20" v-if="file.downloadingStop == false"
><VideoPause
/></el-icon>
<el-icon size="20" v-else><VideoPlay /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import axios from "axios";
import { ref, reactive, getCurrentInstance } from "vue";
import emitter from "../utils/eventBus.js";
import { VideoPause, VideoPlay } from "@element-plus/icons-vue";
const { appContext } = getCurrentInstance();
const request = appContext.config.globalProperties.request;
var fileList = reactive([]);
var downloadingFileList = ref([]);
//上传文件之后,重新加载文件列表
emitter.on("reloadFileList", () => {
load();
});
function load() {
fileList.length = 0;
request({
url: "/fileList",
method: "get",
}).then((res) => {
// console.log("res", res.data.data);
fileList.push(...res.data.data);
});
}
load();
//换算文件的大小单位
function formatSize(file) {
//console.log("size",file.size);
var size = file.size;
var unit;
var units = [" B", " K", " M", " G"];
var pointLength = 2;
while ((unit = units.shift()) && size > 1024) {
size = size / 1024;
}
return (
(unit === "B"
? size
: size.toFixed(pointLength === undefined ? 2 : pointLength)) + unit
);
}
//点击暂停下载
function changeDownloadStop(file) {
file.downloadingStop = !file.downloadingStop;
if (!file.downloadingStop) {
console.log("开始。。");
downloadChunk(1, file);
}
}
//点击下载文件
function downloadFile(file) {
// console.log("下载", file);
file.downloadingStop = false;
file.downloadSpeed = "0 M/s";
file.downloadPersentage = 0;
file.blobList = [];
file.chunkList = [];
downloadingFileList.value.push(file);
downloadChunk(1, file);
}
//点击下载文件分片
function downloadChunk(index, file) {
// 每次分片下载的文件大小
var chunkSize = 1024 * 1024 * 5;
// 文件下载总次数
var chunkTotal = Math.ceil(file.size / chunkSize);
if (index <= chunkTotal) {
// console.log("下载进度",index);
var exit = file.chunkList.includes(index);
console.log("存在", exit);
if (!exit) {
if (!file.downloadingStop) {
var formData = new FormData();
formData.append("fileId", file.id);
formData.append("chunkSize", chunkSize);
formData.append("index", index);
formData.append("chunkTotal", chunkTotal);
if (index * chunkSize >= file.size) {
chunkSize = file.size - (index - 1) * chunkSize;
formData.set("chunkSize", chunkSize);
}
var startTime = new Date().valueOf();
axios({
url: "http://localhost:8080/system/sys-file/export/file/bigFile",
method: "post",
data: formData,
responseType: "blob",
timeout: 50000,
}).then((res) => {
file.chunkList.push(index);
var endTime = new Date().valueOf();
var timeDif = (endTime - startTime) / 1000;
file.downloadSpeed = (5 / timeDif).toFixed(1) + " M/s";
//todo
file.downloadPersentage = parseInt((index / chunkTotal) * 100);
// var chunk = res.data.data.chunk
// const blob = new Blob([res.data]);
const blob = res.data;
file.blobList.push(blob);
// console.log("res", blobList);
if (index == chunkTotal) {
var resBlob = new Blob(file.blobList, {
type: "application/octet-stream",
});
// console.log("resb", resBlob);
let url = window.URL.createObjectURL(resBlob); // 将获取的文件转化为blob格式
let a = document.createElement("a"); // 此处向下是打开一个储存位置
a.style.display = "none";
a.href = url;
// 下面两行是自己项目需要的处理,总之就是得到下载的文件名(加后缀)即可
var fileName = file.name;
a.setAttribute("download", fileName);
document.body.appendChild(a);
a.click(); //点击下载
document.body.removeChild(a); // 下载完成移除元素
window.URL.revokeObjectURL(url); // 释放掉blob对象
}
downloadChunk(index + 1, file);
});
}
} else {
file.downloadPersentage = parseInt((index / chunkTotal) * 100);
downloadChunk(index + 1, file);
}
}
}
</script>
<style scoped>
.main {
display: flex;
}
.fileList {
width: 400px;
}
.downloadList {
width: 450px;
}
.title {
margin-top: 5px;
margin-bottom: 5px;
}
.downloading {
margin-top: 10px;
}
.downloading .fileName {
margin-left: 76px;
margin-right: 30px;
}
.downloading .fileSize {
/* margin-left: 70px; */
margin-right: 30px;
}
.downloading .progress {
display: flex;
}
.progress .el-progress {
/* font-size: 18px; */
width: 310px;
}
</style>
Springboot实现
Controller文件
@Tag(name = "系统公共文件管理")
@RequiredArgsConstructor
@RestController
@RequestMapping("/system/sys-file")
public class SysFileController {
private final SysFileService sysFileService;
/**
* 大文件分片下载
* @param fileId 文件ID
* @param sliceSize 分片大小(字节)
* @param sliceNum 分片总次数
* @param index 当前次数
* @param response
*/
@Operation(summary = "大文件分片下载")
@GetMapping("/export/file/bigFile")
@SaCheckPermission("dataset.download.export.file.big")
public void exportBigFile(@RequestParam("fileId") Long fileId,
@RequestParam("chunkSize") Integer sliceSize,
@RequestParam("chunkTotal") Integer sliceNum,
@RequestParam("index") Integer index,
HttpServletResponse response) {
sysFileService.fileDownload(response, fileId, sliceSize, sliceNum, index);
}
}
SysFileServiceImpl文件
@Service
@RequiredArgsConstructor
public class SysFileServiceImpl extends ServiceImpl<CommonFileMapper, SysFile> implements SysFileService {
private final MinioService minioService;
/**
* 大文件分片下载
* @param response
* @param fileId 文件ID
* @param sliceSize 分片大小(字节)
* @param sliceNum 分片次数
* @param index 当前次数
*/
@Override
public void fileDownload(HttpServletResponse response, Long fileId,
Integer sliceSize, Integer sliceNum, Integer index) {
SysFileVo file = sysFileControllerFeign.getInfoById(fileId);
if(file == null) return;
// 计算起始字节位置
long offset = (long) sliceSize * (index - 1);
// 如果当前次数为最后一次
if(Objects.equals(index, sliceNum)){
offset = Long.valueOf(file.getSize()) - sliceSize;
}
InputStream fileInputStream = null;
BufferedInputStream bis = null;
ServletOutputStream outputStream = null;
try {
// 指定起始结束字节获取Minio的文件数据
fileInputStream = minioService.getObject(file.getFilename(), offset, sliceSize);
ContentDisposition disposition = ContentDisposition.builder("attachment")
.filename(new String(URLEncoder.encode("bytes " + offset + "-" + (offset+sliceSize) + "/" + file.getSize(), "UTF-8")
.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1)).build();
response.addHeader("Content-Disposition", disposition.toString());
response.setContentType("application/force-download");
response.setCharacterEncoding("UTF-8");
outputStream = response.getOutputStream();
bis = new BufferedInputStream(fileInputStream);
byte[] buff = new byte[1024];
int readLength = 0;
while ((readLength = bis.read(buff)) != -1) {
outputStream.write(buff, 0, readLength);
}
outputStream.flush();
} catch (Exception e) {
log.error("下载失败");
} finally {
try {
if (bis != null) {
bis.close();
}
if (fileInputStream != null) {
fileInputStream.close();
}
if (outputStream != null) {
outputStream.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
MinioService文件
@Service
public class MinioService {
@Resource
private MinioClient minioClient;
// Minio的存储桶名称
@Resource
private String bucketName;
/**
* 断点下载
* @param objectName 文件名称
* @param offset 起始字节的位置
* @param length 要读取的长度
* @return 二进制流
*/
@SneakyThrows
public InputStream getObject(String objectName, long offset, long length) {
return minioClient.getObject(
GetObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.offset(offset)
.length(length)
.build());
}
}
文章整合至:SpringBoot+Vue实现大文件上传(分片上传)、java实现大文件的分片上传与下载(springboot+vue3)
-----------------------------------
作者:怒吼的萝卜
链接:http://www.cnblogs.com/nhdlb/
-----------------------------------

浙公网安备 33010602011771号