后端实现方式请查看另一篇文章:Minio直传方案之后端实现:Spring Boot 整合 Minio 实现直传功能。
前言
一般的 minio 使用中,我们基本都是前端上传到服务器,再由服务器上传至 minio 平台。在上传小文件时并没有什么问题,但在上传大文件时就显得不稳定及上传效率低,因为要经过后台服务器中转。
我在想如果不经过后台服务中转,由前端直接上传至 minio,但前端有需要存储密钥等连接信息,很不安全,那还有没有其他办法呢?
在 SpringBoot通过Minio实现大文件分片上传 看到相关解决方案。
由于 Java 版本的 Minio SDK 默认不允许单独调用分片的相关方法,我们需要自定义 Client 并继承 MinioClient
才可以使用分片方法。
流程:
- 前端获取文件MD5,发送至后台判断是否有该文件,有则直接转存;
- 前端调用初始化接口(分多少片协商好),后端调用 minio 初始化,返回分片上传地址和 uploadId;
- 前端上传分片文件;
- 上传完成后,前端发送请求至后台服务,后台服务调用 minio 合并文件;
流程图:
效果图
实现
项目中使用到的类库:spark-md5
、axios
、element-ui
;
spark-md5
主要用来计算文件MD5,安装命令:
npm install spark-md5 --save
- 页面代码
<template>
<div class="container">
<h2>Minio 上传示例</h2>
<el-upload
class="upload-demo"
ref="upload"
action="https://jsonplaceholder.typicode.com/posts/"
:on-remove="handleRemove"
:on-change="handleFileChange"
:file-list="uploadFileList"
:show-file-list="false"
:auto-upload="false">
<el-button slot="trigger" type="primary" plain>选择文件</el-button>
<el-button style="margin-left: 10px;" type="success" @click="handleUpload" plain>上传</el-button>
<el-button type="danger" @click="clearFileHandler" plain>清空</el-button>
</el-upload>
<!-- 文件列表 -->
<div class="file-list-wrapper">
<el-collapse>
<el-collapse-item v-for="(item, index) in uploadFileList" :key="index">
<template slot="title">
<div class="upload-file-item">
<div class="file-info-item file-name">文件名:{{ item.name }}</div>
<div class="file-info-item file-size">文件大小:{{ item.size | transformByte }}</div>
<div class="file-info-item file-progress">
<span class="file-progress-label">文件进度:</span>
<el-progress :percentage="item.uploadProgress" class="file-progress-value" />
</div>
<div class="file-info-item file-size"><span>状态:</span>
<el-tag v-if="item.status === '等待上传'" size="medium" type="info">等待上传</el-tag>
<el-tag v-else-if="item.status === '校验MD5'" size="medium" type="warning">校验MD5</el-tag>
<el-tag v-else-if="item.status === '正在上传'" size="medium">正在上传</el-tag>
<el-tag v-else-if="item.status === '上传成功'" size="medium" type="success">上传完成</el-tag>
<el-tag v-else size="medium" type="danger">上传错误</el-tag>
</div>
</div>
</template>
<div class="file-chunk-list-wrapper">
<!-- 分片列表 -->
<el-table
:data="item.chunkList"
max-height="400"
style="width: 100%">
<el-table-column
prop="chunkNumber"
label="分片序号"
width="180">
</el-table-column>
<el-table-column
prop="progress"
label="上传进度">
<template v-slot="{ row }">
<el-progress v-if="!row.status || row.progressStatus === 'normal'" :percentage="row.progress" />
<el-progress v-else :percentage="row.progress" :status="row.progressStatus" :text-inside="true" :stroke-width="16" />
</template>
</el-table-column>
<el-table-column
prop="status"
label="状态"
width="180">
</el-table-column>
</el-table>
</div>
</el-collapse-item>
</el-collapse>
</div>
</div>
</template>
- 计算文件MD5
import SparkMD5 from 'spark-md5'
方法:
getFileMd5(file, callback) {
const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
const fileReader = new FileReader()
// 计算分片数
const totalChunks = Math.ceil(file.size / chunkSize)
let currentChunk = 0
const spark = new SparkMD5.ArrayBuffer()
loadNext()
fileReader.onload = function (e) {
try {
spark.append(e.target.result)
} catch (error) {
console.log('获取Md5错误:' + currentChunk)
}
if (currentChunk < totalChunks) {
currentChunk++
loadNext()
} else {
callback(spark.end())
}
}
fileReader.onerror = function () {
console.warn('读取Md5失败,文件读取错误')
}
function loadNext () {
const start = currentChunk * chunkSize
const end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize
// 注意这里的 fileRaw
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end))
}
}
- 文件分片
createFileChunk(file, size = chunkSize) {
const fileChunkList = []
let count = 0
while(count < file.size) {
fileChunkList.push({
file: file.slice(count, count + size),
})
count += size
}
return fileChunkList
}
- 上传文件分片,这里的代码参考了 一个多文件断点续传、分片上传、秒传、重试机制的组件;
uploadChunkBase(chunkList) {
const self = this
let successCount = 0
let totalChunks = chunkList.length
return new Promise((resolve, reject) => {
const handler = () => {
if (chunkList.length) {
const chunkItem = chunkList.shift()
// 直接上传二进制,不需要构造 FormData,否则上传后文件损坏
axios.put(chunkItem.uploadUrl, chunkItem.chunk.file, {
// 上传进度处理
onUploadProgress: self.checkChunkUploadProgress(chunkItem),
headers: {
'Content-Type': 'application/octet-stream'
}
}).then(response => {
if (response.status === 200) {
console.log('分片:' + chunkItem.chunkNumber + ' 上传成功')
successCount++
// 继续上传下一个分片
handler()
} else {
// 注意:这里没有针对失败做处理,请根据自己需求修改
console.log('上传失败:' + response.status + ',' + response.statusText)
}
}).catch(error => {
// 更新状态
console.log('分片:' + chunkItem.chunkNumber + ' 上传失败,' + error)
// 重新添加到队列中
chunkList.push(chunkItem)
handler()
})
}
if (successCount >= totalChunks) {
resolve()
}
}
// 并发
for (let i = 0; i < this.simultaneousUploads; i++) {
handler()
}
})
}
上传的进度处理在 axios 请求中配置 onUploadProgress
指定 checkChunkUploadProgress
方法处理:
checkChunkUploadProgress(item) {
return p => {
item.progress = parseInt(String((p.loaded / p.total) * 100))
this.updateChunkUploadStatus(item)
}
},
updateChunkUploadStatus(item) {
let status = FileStatus.uploading
let progressStatus = 'normal'
if (item.progress >= 100) {
status = FileStatus.success
progressStatus = 'success'
}
let chunkIndex = item.chunkNumber - 1
let currentChunk = this.uploadFileList[currentFileIndex].chunkList[chunkIndex]
// 修改状态
currentChunk.status = status
currentChunk.progressStatus = progressStatus
// 更新状态
this.$set(this.uploadFileList[currentFileIndex].chunkList, chunkIndex, currentChunk)
// 获取文件上传进度
this.getCurrentFileProgress()
},
getCurrentFileProgress() {
const currentFile = this.uploadFileList[currentFileIndex]
if (!currentFile || !currentFile.chunkList) {
return
}
const chunkList = currentFile.chunkList
const uploadedSize = chunkList.map((item) => item.chunk.file.size * item.progress).reduce((acc, cur) => acc + cur)
// 计算方式:已上传大小 / 文件总大小
let progress = parseInt((uploadedSize / currentFile.size).toFixed(2))
currentFile.uploadProgress = progress
this.$set(this.uploadFileList, currentFileIndex, currentFile)
}
- 上传方法(整体流程)
handleUpload() {
const self = this
const files = this.uploadFileList
if (files.length === 0) {
this.$message.error('请先选择文件')
return
}
// 当前操作文件
const currentFile = files[currentFileIndex]
currentFile.status = FileStatus.getMd5
// 1. 计算MD5
this.getFileMd5(currentFile.raw, async (md5) => {
// 2. 检查是否已上传
const checkResult = await self.checkFileUploadedByMd5(md5)
// 已上传
if (checkResult.data.status === 1) {
self.$message.success(`上传成功,文件地址:${checkResult.data.url}`)
console.log('文件访问地址:' + checkResult.data.url)
currentFile.status = FileStatus.success
currentFile.uploadProgress = 100
return
} else if (checkResult.data.status === 2) { // "上传中" 状态
// 获取已上传分片列表
let chunkUploadedList = checkResult.data.chunkUploadedList
currentFile.chunkUploadedList = chunkUploadedList
} else { // 未上传
console.log('未上传')
}
console.log('文件MD5:' + md5)
// 3. 正在创建分片
let fileChunks = self.createFileChunk(currentFile.raw, chunkSize)
let param = {
fileName: currentFile.name,
fileSize: currentFile.size,
chunkSize: chunkSize,
fileMd5: md5,
contentType: 'application/octet-stream'
}
// 4. 获取上传url
let uploadIdInfoResult = await self.getFileUploadUrls(param)
self.uploadIdInfo = uploadIdInfoResult.data.data
self.saveFileUploadId(uploadIdInfoResult.data.data)
let uploadUrls = uploadIdInfoResult.data.data.uploadUrls
if (fileChunks.length !== uploadUrls.length) {
self.$message.error('文件分片上传地址获取错误')
return
}
self.$set(currentFile, 'chunkList', [])
fileChunks.map((chunkItem, index) => {
currentFile.chunkList.push({
chunkNumber: index + 1,
chunk: chunkItem,
uploadUrl: uploadUrls[index],
progress: 0,
status: '—'
})
})
let tempFileChunks = []
currentFile.chunkList.forEach((item) => {
tempFileChunks.push(item)
})
currentFile.status = FileStatus.uploading
// 处理分片列表,删除已上传的分片
tempFileChunks = self.processUploadChunkList(tempFileChunks)
// 5. 上传
await self.uploadChunkBase(tempFileChunks)
console.log('上传完成')
// 6. 合并文件
const mergeResult = await self.mergeFile({
uploadId: self.uploadIdInfo.uploadId,
fileName: currentFile.name,
md5: md5
})
if (!mergeResult.success) {
currentFile.status = FileStatus.error
self.$message.error(mergeResult.error)
} else {
currentFile.status = FileStatus.success
console.log('文件访问地址:' + mergeResult.data.url)
self.$message.success(`上传成功,文件地址:${mergeResult.data.url}`)
}
})
}
总结
本文仅介绍上传流程的简单实现,很多功能未完善,如多文件上传、上传暂停、停止等功能。
项目代码地址:https://github.com/lanweihong/vue-minio-upload-sample
参考文献
[1]. 多文件断点续传、分片上传、秒传、重试机制