Vue + Springboot 大文件分片上傳
思路來自于「劉悅的技術(shù)博客」https://v3u.cn/a_id_175
上面這位大神使用的是element-ui的組件腥沽,后端為python FastAPI
我使用的是 Ant Design Vue,后端為Springboot 2.x
其實(shí)思路一致鸠蚪,只是做法稍有不同
前端部分
還是常規(guī)的通過cli創(chuàng)建vue項(xiàng)目
vue create antd-vue-upload
也可以通過
vue ui
vuecli會(huì)開啟一個(gè)可視化界面供我們?nèi)コ跏蓟粋€(gè)vue項(xiàng)目
配置方面我們選擇yarn作為包管理工具
然后是配置按需加載 ant-design-vue
官網(wǎng)并沒有很明顯的指明配置方式今阳,沒有使用過babel-plugin-import的同學(xué)們可能會(huì)碰到很多頭痛的問題
這里記錄一下讓大家少走彎路
1.首先是引入依賴 babel-plugin-import,因?yàn)锳nt Design Vue使用的是less茅信,所以還有l(wèi)ess和less-loader
yarn add babel-plugin-import -D
yarn add less -D
yarn add less-loader -D
2.與package.json文件同級(jí)目錄下新建文件 babel.config.js
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
],
plugins: [
[
"import",
{ libraryName: "ant-design-vue", libraryDirectory: "es", style: true }
]
]
}
3.配置less-loader
由于安裝less和less-loader沒有指定版本盾舌,所有都是比較新的less 3.x 以及l(fā)ess-loader 7.x
會(huì)出現(xiàn)
.bezierEasingMixin();
^
Inline JavaScript is not enabled. Is it set in your options?
這是因?yàn)榘姹靖吡耍覀冃枰谂cpackage.json文件同級(jí)目錄下新建文件 vue.config.js
module.exports = {
lintOnSave: true,
css: {
loaderOptions: { // 向 CSS 相關(guān)的 loader 傳遞選項(xiàng)
less: {
lessOptions: { javascriptEnabled: true }
}
}
}
}
配置好javascriptEnabled: true蘸鲸,此時(shí)按需加載已經(jīng)完成
4.上傳
(引用的上文思路)
前端通過Ant Design Vue上傳時(shí)妖谴,通過分片大小的閾值對文件進(jìn)行切割,并且記錄每一片文件的切割順序(chunk)酌摇,在這個(gè)過程中膝舅,通過SparkMD5來計(jì)算文件的唯一標(biāo)識(shí)(防止多個(gè)文件同時(shí)上傳的覆蓋問題identifier),在每一次分片文件的上傳中窑多,會(huì)將分片文件實(shí)體仍稀,切割順序(chunk)以及唯一標(biāo)識(shí)(identifier),還有總的分片大星右痢(totalChunks)琳轿,異步發(fā)送到后端接口,后端將chunk和identifier結(jié)合在一起作為臨時(shí)文件寫入服務(wù)器磁盤中耿芹,當(dāng)前端將所有的分片文件都發(fā)送完畢后崭篡,最后請求一次后端另外一個(gè)接口,后端將所有文件合并吧秕。
vue文件
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<a-upload name="file" :data="uploadData" :customRequest="chunkUpload" :action="uploadUrl" :remove="beforeRemove">
<a-button>
<a-icon type="upload" /> Click to Upload
</a-button>
</a-upload>
</div>
</template>
<script>
import { Button, Upload, Icon } from 'ant-design-vue'
import chunkUpload from '../utils/chunkupload'
export default {
name: 'HelloWorld',
components: {
AButton: Button,
AUpload: Upload,
AIcon: Icon,
},
props: {
msg: String,
},
data() {
return {
uploadData: {
//這里面放額外攜帶的參數(shù)
},
//文件上傳的路徑
uploadUrl: 'http://localhost:8000/uploadfile/', //文件上傳的路徑
chunkUpload: chunkUpload, // 分片上傳自定義方法,在頭部引入了
}
},
methods: {
onError(err, file, fileList) {
this.$alert('文件上傳失敗砸彬,請重試', '錯(cuò)誤', {
confirmButtonText: '確定',
})
},
beforeRemove(file) {
},
},
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>
引用的文件上傳工具類 chunkupload.js
import SparkMD5 from 'spark-md5'
import Axios from 'axios'
import QS from 'qs'
//錯(cuò)誤信息
function getError (action, option, xhr) {
let msg
if (xhr.response) {
msg = `${xhr.response.error || xhr.response}`
} else if (xhr.responseText) {
msg = `${xhr.responseText}`
} else {
msg = `fail to post ${action} ${xhr.status}`
}
const err = new Error(msg)
err.status = xhr.status
err.method = 'post'
err.url = action
return err
}
// 上傳成功完成合并之后颠毙,獲取服務(wù)器返回的json
function getBody (xhr) {
const text = xhr.responseText || xhr.response
if (!text) {
return text
}
try {
return JSON.parse(text)
} catch (e) {
return text
}
}
// 分片上傳的自定義請求,以下請求會(huì)覆蓋element的默認(rèn)上傳行為
export default function upload (option) {
if (typeof XMLHttpRequest === 'undefined') {
return
}
const spark = new SparkMD5.ArrayBuffer()// md5的ArrayBuffer加密類
const fileReader = new FileReader()// 文件讀取類
const action = option.action // 文件上傳上傳路徑
const chunkSize = 1024 * 1024 * 1 // 單個(gè)分片大小砂碉,這里測試用1m
let md5 = ''// 文件的唯一標(biāo)識(shí)
const optionFile = option.file // 需要分片的文件
let fileChunkedList = [] // 文件分片完成之后的數(shù)組
const percentage = [] // 文件上傳進(jìn)度的數(shù)組蛀蜜,單項(xiàng)就是一個(gè)分片的進(jìn)度
// 文件開始分片,push到fileChunkedList數(shù)組中增蹭, 并用第一個(gè)分片去計(jì)算文件的md5
for (let i = 0; i < optionFile.size; i = i + chunkSize) {
const tmp = optionFile.slice(i, Math.min((i + chunkSize), optionFile.size))
if (i === 0) {
fileReader.readAsArrayBuffer(tmp)
}
fileChunkedList.push(tmp)
}
// 在文件讀取完畢之后滴某,開始計(jì)算文件md5,作為文件唯一標(biāo)識(shí)
fileReader.onload = async (e) => {
spark.append(e.target.result)
md5 = spark.end() + new Date().getTime()
console.log('文件唯一標(biāo)識(shí)--------', md5)
// 將fileChunkedList轉(zhuǎn)成FormData對象,并加入上傳時(shí)需要的數(shù)據(jù)
fileChunkedList = fileChunkedList.map((item, index) => {
const formData = new FormData()
if (option.data) {
// 額外加入外面?zhèn)魅氲膁ata數(shù)據(jù)
Object.keys(option.data).forEach(key => {
formData.append(key, option.data[ key ])
})
// 這些字段看后端需要哪些霎奢,就傳哪些户誓,也可以自己追加額外參數(shù)
formData.append(option.filename, item, option.file.name)// 文件
formData.append('chunkNumber', index + 1)// 當(dāng)前文件塊
formData.append('chunkSize', chunkSize)// 單個(gè)分塊大小
formData.append('currentChunkSize', item.size)// 當(dāng)前分塊大小
formData.append('totalSize', optionFile.size)// 文件總大小
formData.append('identifier', md5)// 文件標(biāo)識(shí)
formData.append('filename', option.file.name)// 文件名
formData.append('totalChunks', fileChunkedList.length)// 總塊數(shù)
}
return { formData: formData, index: index }
})
// 更新上傳進(jìn)度條百分比的方法
const updataPercentage = (e) => {
let loaded = 0// 當(dāng)前已經(jīng)上傳文件的總大小
percentage.forEach(item => {
loaded += item
})
e.percent = loaded / optionFile.size * 100
option.onProgress(e)
}
// 創(chuàng)建隊(duì)列上傳任務(wù),limit是上傳并發(fā)數(shù)幕侠,默認(rèn)會(huì)用兩個(gè)并發(fā)
function sendRequest (chunks, limit = 2) {
return new Promise((resolve, reject) => {
const len = chunks.length
let counter = 0
let isStop = false
const start = async () => {
if (isStop) {
return
}
const item = chunks.shift()
console.log()
if (item) {
const xhr = new XMLHttpRequest()
const index = item.index
// 分片上傳失敗回調(diào)
xhr.onerror = function error (e) {
isStop = true
reject(e)
}
// 分片上傳成功回調(diào)
xhr.onload = function onload () {
if (xhr.status < 200 || xhr.status >= 300) {
isStop = true
reject(getError(action, option, xhr))
}
if (counter === len - 1) {
// 最后一個(gè)上傳完成
resolve()
} else {
counter++
start()
}
}
// 分片上傳中回調(diào)
if (xhr.upload) {
xhr.upload.onprogress = function progress (e) {
if (e.total > 0) {
e.percent = e.loaded / e.total * 100
}
percentage[ index ] = e.loaded
console.log(index)
updataPercentage(e)
}
}
xhr.open('post', action, true)
if (option.withCredentials && 'withCredentials' in xhr) {
xhr.withCredentials = true
}
const headers = option.headers || {}
for (const item in headers) {
if (headers.hasOwnProperty(item) && headers[ item ] !== null) {
xhr.setRequestHeader(item, headers[ item ])
}
}
// 文件開始上傳
xhr.send(item.formData)
}
}
while (limit > 0) {
setTimeout(() => {
start()
}, Math.random() * 1000)
limit -= 1
}
})
}
try {
let totalChunks = fileChunkedList.length
// 調(diào)用上傳隊(duì)列方法 等待所有文件上傳完成
await sendRequest(fileChunkedList, 2)
// 這里的參數(shù)根據(jù)自己實(shí)際情況寫
const data = {
identifier: md5,
filename: option.file.name,
totalSize: optionFile.size,
totalChunks: totalChunks
}
console.log(data)
// 給后端發(fā)送文件合并請求
const fileInfo = await Axios({
method: 'post',
url: 'http://localhost:8000/mergefile',
data: QS.stringify(data)
}, {
headers: {
"Content-Type": "multipart/form-data"
}
}).catch(error => {
console.log("ERRRR:: ", error.response.data)
})
console.log(fileInfo)
if (fileInfo.data.code === 200) {
const success = getBody(fileInfo.request)
option.onSuccess(success)
return
}
} catch (error) {
option.onError(error)
}
}
}
這里定義的后端上傳接口是:http://localhost:8000/uploadfile/ 合并文件接口是:http://localhost:8000/mergefile/
本地調(diào)試時(shí)可以在vue.config.js中配置devServer代理請求
devServer: {
proxy: {
'/uploadfile': {
target: 'http://localhost:8000/uploadfile'
},
'/mergefile': {
target: 'http://localhost:8000/mergefile'
}
}
}
5.啟動(dòng)
yarn serve
后端部分
1.創(chuàng)建boot項(xiàng)目帝美,并引入依賴 web,lombok以及commons-lang3
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
2.編寫Controller
@RestController
@CrossOrigin
@Slf4j
public class FileUploadController {
@RequestMapping("uploadfile")
public UploadResult uploadfile(MultipartFile file, UploadParam params) {
String task = params.getIdentifier(); // 獲取文件唯一標(biāo)識(shí)符
int chunk = params.getChunkNumber(); // 獲取該分片在所有分片中的序號(hào)
String filename = String.format("%s%d", task, chunk); // 構(gòu)成該分片唯一標(biāo)識(shí)符
try {
file.transferTo(new File(String.format("d:/test-upload/%s", filename)));
} catch (IllegalStateException | IOException e) {
log.error("%s", e);
}
return UploadResult.builder().filename(filename).build();
}
@RequestMapping("mergefile")
public UploadResult mergefile(String identifier/* 文件的唯一標(biāo)識(shí)符 */, String filename/* 上傳文件的文件名 */,
int totalChunks/* 總分片數(shù) */) {
String basePath = "d:/test-upload/";
// 組裝所有文件的路徑
String[] paths = new String[totalChunks];// 存放所有路徑
for (int chunk = 1 /* 分片開始序號(hào) */; chunk <= totalChunks; chunk++) {
paths[chunk - 1] = basePath + identifier + chunk;
}
mergeFiles(paths, basePath + filename);
return UploadResult.builder().code(200).build();
}
}
實(shí)體類UploadParam.java
@Data
public class UploadParam {
private int chunkNumber;// 當(dāng)前文件塊
private float chunkSize;// 單個(gè)分塊大小
private float currentChunkSize;// 當(dāng)前分塊大小
private float totalSize;// 文件總大小
private String identifier;// 文件標(biāo)識(shí)
private String filename;// 文件名
private int totalChunks;// 總塊數(shù)
}
合并文件時(shí)晤硕,使用的是java nio的FileChannel合并多個(gè)文件
利用Java nio庫中FileChannel類的transferTo方法進(jìn)行合并悼潭。此方法可以利用很多操作系統(tǒng)直接從文件緩存?zhèn)鬏斪止?jié)的能力來優(yōu)化傳輸速度
/**
* TODO 利用nio FileChannel合并多個(gè)文件
*
* @param fpaths
* @param resultPath
* @return
*/
public static boolean mergeFiles(String[] fpaths, String resultPath) {
if (fpaths == null || fpaths.length < 1 || StringUtils.isBlank(resultPath)) {
return false;
}
if (fpaths.length == 1) {
return new File(fpaths[0]).renameTo(new File(resultPath));
}
File[] files = new File[fpaths.length];
for (int i = 0; i < fpaths.length; i++) {
files[i] = new File(fpaths[i]);
if (StringUtils.isBlank(fpaths[i]) || !files[i].exists() || !files[i].isFile()) {
return false;
}
}
File resultFile = new File(resultPath);
try {
@Cleanup
FileOutputStream fout = new FileOutputStream(resultFile, true);
@Cleanup
FileChannel resultFileChannel = fout.getChannel();
for (int i = 0; i < fpaths.length; i++) {
@Cleanup
FileInputStream fin = new FileInputStream(files[i]);
@Cleanup
FileChannel blk = fin.getChannel();
resultFileChannel.transferFrom(blk, resultFileChannel.size(), blk.size());
}
} catch (FileNotFoundException e) {
log.error("%s", e);
return false;
} catch (IOException e) {
log.error("%s", e);
return false;
}
for (int i = 0; i < fpaths.length; i++) {
files[i].delete();
}
return true;
}