Vue + Springboot 大文件分片上传

Vue + Springboot 大文件分片上传

思路来自于「刘悦的技术博客」https://v3u.cn/a_id_175
上面这位大神使用的是element-ui的组件,后端为python FastAPI
我使用的是 Ant Design Vue,后端为Springboot 2.x
其实思路一致,只是做法稍有不同

本文所有代码 gitee
前端
后端


前端部分

还是常规的通过cli创建vue项目

vue create antd-vue-upload

也可以通过

vue ui

vuecli会开启一个可视化界面供我们去初始化一个vue项目

配置方面我们选择yarn作为包管理工具

文件路径

然后是配置按需加载 ant-design-vue

官网并没有很明显的指明配置方式,没有使用过babel-plugin-import的同学们可能会碰到很多头痛的问题
这里记录一下让大家少走弯路

1.首先是引入依赖 babel-plugin-import,因为Ant Design Vue使用的是less,所以还有less和less-loader

yarn add babel-plugin-import -D
yarn add less -D
yarn add less-loader -D

2.与package.json文件同级目录下新建文件 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 以及less-loader 7.x
会出现

.bezierEasingMixin();
^
Inline JavaScript is not enabled. Is it set in your options?

这是因为版本高了,我们需要在与package.json文件同级目录下新建文件 vue.config.js

module.exports = {
    lintOnSave: true,
    css: {
        loaderOptions: { // 向 CSS 相关的 loader 传递选项
            less: {
                lessOptions: { javascriptEnabled: true }
            }
        }
    }
}

配置好javascriptEnabled: true,此时按需加载已经完成

4.上传
(引用的上文思路)

前端通过Ant Design Vue上传时,通过分片大小的阈值对文件进行切割,并且记录每一片文件的切割顺序(chunk),在这个过程中,通过SparkMD5来计算文件的唯一标识(防止多个文件同时上传的覆盖问题identifier),在每一次分片文件的上传中,会将分片文件实体,切割顺序(chunk)以及唯一标识(identifier),还有总的分片大小(totalChunks),异步发送到后端接口,后端将chunk和identifier结合在一起作为临时文件写入服务器磁盘中,当前端将所有的分片文件都发送完毕后,最后请求一次后端另外一个接口,后端将所有文件合并。

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: {
        //这里面放额外携带的参数
      },
      //文件上传的路径
      uploadUrl: 'http://localhost:8000/uploadfile/', //文件上传的路径
      chunkUpload: chunkUpload, // 分片上传自定义方法,在头部引入了
    }
  },
  methods: {
    onError(err, file, fileList) {
      this.$alert('文件上传失败,请重试', '错误', {
        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'

//错误信息
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
}
// 上传成功完成合并之后,获取服务器返回的json
function getBody (xhr) {
    const text = xhr.responseText || xhr.response
    if (!text) {
        return text
    }
    try {
        return JSON.parse(text)
    } catch (e) {
        return text
    }
}

// 分片上传的自定义请求,以下请求会覆盖element的默认上传行为
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 // 单个分片大小,这里测试用1m
    let md5 = ''// 文件的唯一标识
    const optionFile = option.file // 需要分片的文件
    let fileChunkedList = [] // 文件分片完成之后的数组
    const percentage = [] // 文件上传进度的数组,单项就是一个分片的进度

    // 文件开始分片,push到fileChunkedList数组中, 并用第一个分片去计算文件的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)
    }

    // 在文件读取完毕之后,开始计算文件md5,作为文件唯一标识
    fileReader.onload = async (e) => {
        spark.append(e.target.result)
        md5 = spark.end() + new Date().getTime()
        console.log('文件唯一标识--------', md5)
        // 将fileChunkedList转成FormData对象,并加入上传时需要的数据
        fileChunkedList = fileChunkedList.map((item, index) => {
            const formData = new FormData()
            if (option.data) {
                // 额外加入外面传入的data数据
                Object.keys(option.data).forEach(key => {
                    formData.append(key, option.data[ key ])
                })
                // 这些字段看后端需要哪些,就传哪些,也可以自己追加额外参数
                formData.append(option.filename, item, option.file.name)// 文件
                formData.append('chunkNumber', index + 1)// 当前文件块
                formData.append('chunkSize', chunkSize)// 单个分块大小
                formData.append('currentChunkSize', item.size)// 当前分块大小
                formData.append('totalSize', optionFile.size)// 文件总大小
                formData.append('identifier', md5)// 文件标识
                formData.append('filename', option.file.name)// 文件名
                formData.append('totalChunks', fileChunkedList.length)// 总块数
            }
            return { formData: formData, index: index }
        })

        // 更新上传进度条百分比的方法
        const updataPercentage = (e) => {
            let loaded = 0// 当前已经上传文件的总大小
            percentage.forEach(item => {
                loaded += item
            })
            e.percent = loaded / optionFile.size * 100
            option.onProgress(e)
        }

        // 创建队列上传任务,limit是上传并发数,默认会用两个并发
        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
                        // 分片上传失败回调
                        xhr.onerror = function error (e) {
                            isStop = true
                            reject(e)
                        }
                        // 分片上传成功回调
                        xhr.onload = function onload () {
                            if (xhr.status < 200 || xhr.status >= 300) {
                                isStop = true
                                reject(getError(action, option, xhr))
                            }
                            if (counter === len - 1) {
                                // 最后一个上传完成
                                resolve()
                            } else {
                                counter++
                                start()
                            }
                        }
                        // 分片上传中回调
                        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
            // 调用上传队列方法 等待所有文件上传完成
            await sendRequest(fileChunkedList, 2)
            // 这里的参数根据自己实际情况写
            const data = {
                identifier: md5,
                filename: option.file.name,
                totalSize: optionFile.size,
                totalChunks: totalChunks
            }
            console.log(data)
            // 给后端发送文件合并请求
            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/
本地调试时可以在vue.config.js中配置devServer代理请求

devServer: {
        proxy: {
            '/uploadfile': {
                target: 'http://localhost:8000/uploadfile'
            },
            '/mergefile': {
                target: 'http://localhost:8000/mergefile'
            }
        }
    }

5.启动

yarn serve
效果图

后端部分

1.创建boot项目,并引入依赖 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(); // 获取文件唯一标识符
        int chunk = params.getChunkNumber(); // 获取该分片在所有分片中的序号
        String filename = String.format("%s%d", task, chunk); // 构成该分片唯一标识符

        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/* 文件的唯一标识符 */, String filename/* 上传文件的文件名 */,
            int totalChunks/* 总分片数 */) {

        String basePath = "d:/test-upload/";

        // 组装所有文件的路径
        String[] paths = new String[totalChunks];// 存放所有路径
        for (int chunk = 1 /* 分片开始序号 */; chunk <= totalChunks; chunk++) {
            paths[chunk - 1] = basePath + identifier + chunk;
        }

        mergeFiles(paths, basePath + filename);

        return UploadResult.builder().code(200).build();
    }
}

实体类UploadParam.java

@Data
public class UploadParam {
    private int chunkNumber;// 当前文件块
    private float chunkSize;// 单个分块大小
    private float currentChunkSize;// 当前分块大小
    private float totalSize;// 文件总大小
    private String identifier;// 文件标识
    private String filename;// 文件名
    private int totalChunks;// 总块数
}

合并文件时,使用的是java nio的FileChannel合并多个文件
利用Java nio库中FileChannel类的transferTo方法进行合并。此方法可以利用很多操作系统直接从文件缓存传输字节的能力来优化传输速度

/**
     * TODO 利用nio FileChannel合并多个文件
     * 
     * @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;
    }
测试上传时

测试合并后

推荐阅读更多精彩内容