文件上传

1、文件上传两套方案

1.1 基于文件流(form-data)

1.1.1 经典的form和input上传

设置 form 的 ​​aciton​​ 属性为请求的后端地址,​​enctype=“multipart/form-data”​​ 为编码格式,​​type=‘post’ ​​为请求类型。

<form action='uploadFile.php' enctype="multipart/form-data" type='post'>
  <input type='file'>
  <input type='hidden' name='userid'>
  <input type='hidden' name='signature'>
  <button>提交</button>
</form>


使用input选择文件,设置好其他input的值,点击提交,将文件数据及签名等认证信息发送到form设置的action对应的页面,浏览器也会跳转到该页面。

触发form表单提交数据的方式有2种,一种是在页面上点击button按钮或按钮触发,第二种是在 js 中执行 form.submit() 方法。

注意:

  • 默认情况下,form 表单的 enctype 的值是 application/x-www-form-urlencoded
  • application/x-www-form-urlencoded,只能上传文本格式的文件
  • multipart/form-data 是将文件以二进制的形式上传,对字符不进行编码,这样可以实现多种类型的文件上传
  • 优点:

1)使用简单方便,兼容性好,基本所有浏览器都支持。

  • 缺点:

1)提交数据后页面会跳转(下面会讲如何禁止页面跳转)。

2)因为是浏览器发起的请求,不是一个 ajax,所以前端无法知道什么时候上传结束,无法使用回调函数。

3)form表单里发送除文件外的数据,一般是新建一个​​type=hidden的input,value=‘需要传的数据’​​,每发送一个数据就需要一个input,一旦多了就会使得dom看起来比较冗余。

1.1.2 用使用 js 封装 FormData

创建 FormData 对象,封装参数,file 传入的是 blob 类型(二进制数据)。

<input type='file'>
var formData = new FormData();
formData.append("userid", userid);
formData.append("signature", signature);
formData.append("file", file);

// 再用ajax发送formData到服务器即可,注意一定要是post方式上传


第一种方法提到了创建多个 ​​type=‘hidden’​​ 的 input 来发送签名数据,这儿可以用 ​​formData.append​​ 方法来代替该操作,避免了 dom 中有多个 input 的情况出现。最后将 file 数据也 append 到 formData 发送到服务器即可完成上传,上传方式要用 POST 请求方式。

  • 优点:

1)由于这种方式是ajax上传,可以准确知道什么时候上传完成,也可以方便地接收到回调数据。

  • 缺点:

1)兼容性差,最低只兼容IE10。

1.2 基于 Base64

客户端需要将文件编码成 Base64 格式。

var fr = new FileReader();
fr.readAsDataURL(file); // 将文件转化为Base64
fr.onload = event=> {
var data= event.target.result; //此处获得的data是base64格式的数据
img.src = data;
ajax(url,{data} ,function(){})
}


上面获得的data可以用来实现图片上传前的本地预览,也可以用来发送base64数据给后端然后返回该数据块对应的地址。

  • 优点:

1)由于这种方式是 ajax 上传,可以准确知道什么时候上传完成,也可以方便地接收到回调数据。

  • 缺点:

1)一次性发送大量的 base64 数据会导致浏览器卡顿,服务器端接收这样的数据可能也会出现问题。

2)HTML5 的新 api,兼容性也不是特别好,只兼容到了IE10。

2、大文件上传

前端上传文件时如果文件很大,上传时会出现各种问题,比如连接超时了,网断了,都会导致上传失败。

为了避免上传大文件时上传超时,就需要用到切片上传,工作原理是:我们将大文件切割为小文件,然后将切割的若干小文件上传到服务器端,服务器端接收到被切割的小文件,然后按照一定的顺序将小文件拼接合并成一个大文件。

2.1 前端提供切片支持

  • html 上传组件

使用了 element-ui 的上传组件。

<!-- 上传组件 -->
<el-upload
drag
action
:auto-upload="false"
:show-file-list="false"
:on-change="changeFile"
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">点击上传</div>
</el-upload>

<!-- 进度条 -->
<div class="progress">
<span>上传进度:{{total|totalText}}%</span>
<!-- 暂停\继续上传文件 -->
<el-link type="primary" v-if="total>0 && total<100" @click="handleBtn">{{btn|btnText}}</el-link>
</div>


​action​​ 不设置值,我们手动去准备参数,请求服务器。

当选择了一个文件后,会触发 ​​on-change​​ 事件,执行我们定义的 ​​changeFile​​ 方法。

进度条中的进度是通过 ​​total​​ 的值来显示的,我们每次上传完一个切片,​​total​​ 值加一,即进度加一。

​handleBtn​​ 文件上传中途可以停止或者继续上传文件,实现断点续传的功能。

下面我们重点分步分析 ​​changeFile​​ 方法。

  • 首先我们可以先对文件的格式、大小进行校验。
async changeFile(file) {
// 这个才是真正的文件Blob对象
file = file.raw;
// 格式校验
let { type, size } = file;
if (!/(png|gif|jpeg|jpg)/i.test(type)) {
this.$message("文件合适不正确~~");
return;
}
// 大小校验
if (size > 200 * 1024 * 1024) {
this.$message("文件过大,请上传小于200MB的文件~~");
return;
}
// ...
}


  • 对文件内容进行加密,生成文件的唯一hash标识,相同文件内容的hash标识一样。
// 工具类,将文件转换成 base64 或者 buffer 二进制流的格式
export function fileParse(file, type = "base64") {
return new Promise(resolve => {
let fileRead = new FileReader();
if (type === "base64") {
fileRead.readAsDataURL(file);
} else if (type === "buffer") {
fileRead.readAsArrayBuffer(file);
}
fileRead.onload = (ev) => {
resolve(ev.target.result);
};
});
};
async changeFile(file) {
// ...

// 将文件转成二进制流,对文件的内容进行加密(相同文件的加密结果一样,可以判断是否传了相同文件)
let buffer = await fileParse(file, "buffer");
let spark = new SparkMD5.ArrayBuffer();
spark.append(buffer);
let hash= spark.end();

// 提取出文件的后缀名
let suffix = /\.([0-9a-zA-Z]+)$/i.exec(file.name)[1];

// ...
}


  • 将大文件进行切片,分割成一个个小的切片文件,定义分片数组,将切片的数据都存入数组中,准备向发送上传请求。
async changeFile(file) {    // ...        // 创建分片数组,每个数组的元素都包含了 1)chunk:分片后的文件 2)filename:每个分片文件的名称    let partList = [];    // 每一个分片大小应该是: 文件总大小 / 100    let partsize = file.size / 100;    // 当前的分片    let cur = 0;        // 循环100次,即把文件分成100个切片    for (let i = 0; i < 100; i++) {        let item = {            // Blob.slice() 方法用于创建一个包含源 Blob 的指定字节范围内的数据的新 Blob 对象,对文件进行分割。            chunk: file.slice(cur, cur + partsize),            // 定义每个分片文件的名称            filename: `${hash}_${i}.${suffix}`,        };        // 当前分片号+1        cur += partsize;        // 放到分片数组中        partList.push(item);    } // 将这个方法中生成的局部变量放到页面的Vue对象中    this.partList = partList;    this.hash = hash;    this.suffix = suffix;    // 执行外部的发送请求方法    this.sendRequest();}


  • 定义请求数组,存放请求的函数,请求函数的参数根据上面定义的切片信息封装。
async sendRequest() {    // 定义了数组存放请求,存放请求的方法    let requestList = [];    // 将上面定义的切片信息进行遍历    this.partList.forEach((item, index) => {        // 每一个函数都是发送一个切片的请求        let fn = () => {            // FormData 封装参数            let formData = new FormData();            formData.append("chunk", item.chunk);            formData.append("filename", item.filename);            // ajax发送请求            return axios                .post("/single3", formData, {                headers: { "Content-Type": "multipart/form-data" },            })                .then((result) => {                result = result.data;                if (result.code == 200) {                    // 每当有一个分片上传成功了,成功总数+1。并且将成功的切片从切面信息集合中去除。(为了断点续传)                    this.total += 1;                    this.partList.splice(index, 1);                }            });        };        // 将一个个请求都放入数组中        requestList.push(fn);    });    // ...}


  • 发送切片文件上传的请求,切片全部上传完后发送合并所有切片的请求。
async sendRequest() {    // ...    let i = 0;    // 定义发送的递归方法,循环请求数组,每次执行请求,当i等于数组的长度,证明所有请求都发送完了。    let send = async () => {        // 当我们点击“暂停上传”后,abort设置为 true,就不再发送请求了,提前结束递归。        if (this.abort) return;        if (i >= requestList.length) {            // 所有请求都发送完了,执行合并方法,退出递归。            complete();            return;        }        await requestList[i]();        i++;        send();    }; // 定义文件合并方法,请求服务器。    let complete = async () => {        let result = await axios.get("/merge", {            params: {                hash: this.hash,                suffix: this.suffix            },        });        result = result.data;        if (result.code == 200) {            this.video = result.path;        }    }; // 开始发送请求    send();}


  • 断点续传。
handleBtn() {  if (this.btn) {    // 断点续传,点击“继续”按钮后,abort标识改为false,重新执行sendRequest方法,重新封装请求数组。    this.abort = false;    this.btn = false;    this.sendRequest();    return;  }  // 暂停上传,abort标识改为true,sendRequest方法中的 send 方法就会停止继续向服务器发送。  this.btn = true;  this.abort = true;},


2.2 后端对切片进行上传和合并

2.2.1 切片文件的上传

对应了前端 requestList 数组中一个个请求的参数,chunk为传过来的文件,filename为文件的名字。

  • controller:
@Autowiredprivate UploadService uploadService;@PostMapping("/file/upload")public JSONObject uploadFile(String filename, @RequestParam("chunk") MultipartFile chunk) {    JSONObject response = new JSONObject();    try {        uploadService.uploadFiles(filename,chunk);    } catch (Exception e) {        log.error("文件上传失败,{}", ex.getMessage());        response.put("code", 500);        response.put("message", ex.getMessage());        return response;    }    response.put("code", 200);    response.put("message", "文件分片上传成功!!");    return response;}


  • service:
public void uploadFiles(String filename, MultipartFile file) throws IOException {    // 上传切片的绝对路径    String chunkFilePath = "D:/upload/" + filename;    File chunkFile = new File(chunkFilePath);    // 分片文件存在,并且切片文件的大小等于上传过来的切片文件大小,就不上传切片了,实现了秒传的效果    if (uploadFile.exists() && uploadFile.length() == file.getSize()) {        return;    }    // 文件上传    BufferedOutputStream bos = null;    FileOutputStream os = null;    try {        os = new FileOutputStream(chunkFile);        bos = new BufferedOutputStream(os);        byte[] bytes = new byte[1024 * 1024];        int length = -1;        while ((length = file.getInputStream().read(bytes)) != -1) {            bos.write(bytes, 0, length);        }    } finally {        // 一般先打开的后关闭,后打开的先关闭        if (bos != null) {            bos.close();        }        if (os != null) {            os.close();        }    }}


2.2.2 切片文件的合并

  • controller:
@PostMapping("/file/mergeFile")public JSONObject mergeFile(String hash, String suffix) {    JSONObject response = new JSONObject();    ZipCaseFileResource zipCaseFileResource = null;    try {        phoneEvidenceService.mergeFile(hash,suffix);    }catch (IOException e){        log.error("文件合并失败", e);        response.setCode(500);        response.setMessage(e.getMessage());        return response;    }    response.setCode(200);    response.setMessage("文件分片合并成功!!");    return response;}


  • service:
public void mergeFile(String hash, String suffix) throws IOException {    //上传路径    String path = "D:/upload/";    // 合并文件的名字    String mergeFilePath = path + hash + suffix ;  // 定义合并文件    File mergeFile = new File(uploadPath);    // 开始准备合并切片    FileOutputStream fileOutputStream = null;    BufferedOutputStream os = null;    try {        fileOutputStream = new FileOutputStream(mergeFile);        os = new BufferedOutputStream(fileOutputStream);        // 100个切片文件        for (int i = 0; i < 100; i++) {            // 切片文件的路径            File chunkFile = new File(path, hash + "_" + i + "." + suffix);     // 将切片文件的数据读到内存中,并写入输出流,写入合并文件中            byte[] bytes = FileUtils.readFileToByteArray(chunkFile);            os.write(bytes);            os.flush();            // 切片文件全部写入完了,就删除这个切片文件            chunkFile.delete();        }    }finally {        if (os != null) {            os.close();        }        if (fileOutputStream != null){            fileOutputStream.close();        }    }}