Vue + Springboot 大文件分片上傳

Vue + Springboot 大文件分片上傳

思路來自于「劉悅的技術(shù)博客」https://v3u.cn/a_id_175
上面這位大神使用的是element-ui的組件腥沽,后端為python FastAPI
我使用的是 Ant Design Vue,后端為Springboot 2.x
其實(shí)思路一致鸠蚪,只是做法稍有不同

本文所有代碼 gitee
前端
后端


前端部分

還是常規(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;
    }
測試上傳時(shí)

測試合并后
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市舞箍,隨后出現(xiàn)的幾起案子女责,更是在濱河造成了極大的恐慌,老刑警劉巖创译,帶你破解...
    沈念sama閱讀 206,214評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異墙基,居然都是意外死亡软族,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,307評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門残制,熙熙樓的掌柜王于貴愁眉苦臉地迎上來立砸,“玉大人,你說我怎么就攤上這事初茶】抛#” “怎么了?”我有些...
    開封第一講書人閱讀 152,543評(píng)論 0 341
  • 文/不壞的土叔 我叫張陵恼布,是天一觀的道長螺戳。 經(jīng)常有香客問我,道長折汞,這世上最難降的妖魔是什么倔幼? 我笑而不...
    開封第一講書人閱讀 55,221評(píng)論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮爽待,結(jié)果婚禮上损同,老公的妹妹穿的比我還像新娘。我一直安慰自己鸟款,他們只是感情好膏燃,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,224評(píng)論 5 371
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著何什,像睡著了一般组哩。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,007評(píng)論 1 284
  • 那天禁炒,我揣著相機(jī)與錄音而咆,去河邊找鬼。 笑死幕袱,一個(gè)胖子當(dāng)著我的面吹牛暴备,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播们豌,決...
    沈念sama閱讀 38,313評(píng)論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼涯捻,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了望迎?” 一聲冷哼從身側(cè)響起障癌,我...
    開封第一講書人閱讀 36,956評(píng)論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎辩尊,沒想到半個(gè)月后涛浙,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,441評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡摄欲,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,925評(píng)論 2 323
  • 正文 我和宋清朗相戀三年轿亮,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片胸墙。...
    茶點(diǎn)故事閱讀 38,018評(píng)論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡我注,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出迟隅,到底是詐尸還是另有隱情但骨,我是刑警寧澤,帶...
    沈念sama閱讀 33,685評(píng)論 4 322
  • 正文 年R本政府宣布智袭,位于F島的核電站奔缠,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏补履。R本人自食惡果不足惜添坊,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,234評(píng)論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望箫锤。 院中可真熱鬧贬蛙,春花似錦、人聲如沸谚攒。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,240評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽馏臭。三九已至野蝇,卻和暖如春讼稚,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背绕沈。 一陣腳步聲響...
    開封第一講書人閱讀 31,464評(píng)論 1 261
  • 我被黑心中介騙來泰國打工锐想, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人乍狐。 一個(gè)月前我還...
    沈念sama閱讀 45,467評(píng)論 2 352
  • 正文 我出身青樓赠摇,卻偏偏與公主長得像,于是被迫代替她去往敵國和親浅蚪。 傳聞我的和親對象是個(gè)殘疾皇子藕帜,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,762評(píng)論 2 345