文件上传总结

文件上传

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:
@Autowired
private 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();
        }
    }

}
posted @ 2021-09-03 18:07  Baby丿太依赖  阅读(237)  评论(0)    收藏  举报