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:
UploadService uploadService; ("/file/upload")public JSONObject uploadFile(String filename, ("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:
"/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(); } }}