文章目录
了解概念:
分片:文件分多次传输,每次传输一小部分,最后再合并所有文件
秒传:先发送一个简易请求让后端判断是否存在,如果存在则直接不发送,如果不存在再进行第二次请求,第二次请求包含file流。避免重复的时候大文件在传输中浪费带宽。
续传:在分片上传有序的基础上,我们只会进行一次的检验,后端找到最新的一个分片(编号最大),返回分片编号即可,前端接收分片编号并把上传器的索引设置为分片编号+1后继续重传即可。
此文章先简单实现分片上传与普通秒传,http并发数设置为1保证按序传送分片,至于分片校验,完整文件校验留在下一篇文章。
找到的比较优秀的demo博客:
https://www.cnblogs.com/xiahj/p/vue-simple-uploader.html#file%E7%9A%84%E5%8A%A8%E6%80%81params%E7%BB%91%E5%AE%9A
1.前端
组件需要的参数和事件的介绍,去看我整理的vue-simple-uploader文档,是github找的一个轮子。
【uploader】表格化自整理vue-simple-uploader的文档(超详细)_玖等了的博客-CSDN博客
先来理清流程:
1.点击上传器,选择文件后,我们使用自动上传,即选择好就上传
2.判断+发送。
上传器把选择到的文件先进行test,也就是尝试发送,其不包含file文件流,只包含一些普通的值,会涉及组件参数里的testMethod,testChunks和checkChunkUploadedByResponse,可以稍微注意一下。也就是一个文件会发送两次请求,第一次是不包含文件的发送让后端判断文件或者分片是否存在,如果后端返回true表示文件已经存在,则不会发送文件,否则会发送第二次请求中包含文件流。
我用一个会发生错误的请求演示:
可以看到,我们只有3个分片,但是发送了6个请求,并且我们可以看到这个红色的请求是不包含file字段的
而这个发送成功的请求是包含file字段的,说明了上传器默认是先发送一个简易的请求用来判断文件是否已经存在,从而实现上传重复文件的秒传,避免重复的时候文件在网络中占用带宽。
3.分片上传的按序上传
我们前端设置了并发数为1,并且不会存在中间的分片丢失,因为上传失败的分片会重传,如果重传失败会停止,不会说跳过。
而如果设置高并发数,会出现后面的分片先到达的情况。
高并发数我们下一个文章再尝试
4.不应该对每个分片都检验
在第3点,有序的基础上,我们的检验应该是enableOnceCheck,也就是只会进行一次的检验,后端找到最新的一个分片(编号最大),返回分片编号即可,前端接收分片编号并把上传器的索引设置为分片编号+1后继续重传即可。
1.1 uploader组件
- 把uploader当做一个上传器整体,uploader-btn就是我们看见的按钮,点击按钮就可以触发上传器。
<uploader
:options="this.options"
@file-added="this.fileAdded"
style="margin-right: 30px;float: left;">
<uploader-unsupport></uploader-unsupport>
<uploader-btn class="uploader-btn">
点击上传
</uploader-btn>
</uploader>
- 其中我们绑定了options,也就是uploader的配置参数,有什么参数具体看文档。
data() {
return {
options: {
target: "/file/uploadFile", //目标位置,后端的位置
//query是额外的参数,属于query式参数,后端要用@RequestParam接收
query: {
curUrl: this.$store.state.file.curUrl },
//testMethod和uploadMethod的请求方法设置为不同,方便后端接口编写
testMethod: "GET", //这里表示的是秒传的优先判断的请求方式
uploadMethod: "POST", //这里表示的是发送文件流的请求方式
//单个分片的大小,这里表示5Mb
chunkSize: 5 * 1024 * 1024,
forceChunkSize: true, //强制每个分片都小于chunkSize,否则可能出现单个分片过大的情况
testChunks: true,//是否开启服务器分片校验,开启就是可以实现秒传
//一个回调函数,也就是第一次不带文件的请求的一个回调,一般后端对应接口需要返回skipUpload【true or false】
//js中一个函数是可以作为值的
checkChunkUploadedByResponse:
function (chunk, res) {
// 服务器分片校验函数,秒传及断点续传基础
//需后台给出对应的查询分片的接口进行分片文件验证
//这里是可以自定义的,但最好用官方的参数,如果后端判断文件存在,return一个skipUpload = true就行
let objMessage = JSON.parse(res);
if (objMessage.skipUpload) {
return true;
}
//如果当前分片比已经上传的最后一个分片要大,则允许上传
//如果小于等于,因为已经有序存在于服务器,为false不需要重传
return chunk.offset+1 > res.position;
},
simultaneousUploads: 1, //最大并发数,可以保证分片到达后端是有序的
},
}
},
1.2 测试分片上传与秒传
- 在搞懂了上面的配置后,只要把后端写好,就可以实现分片上传和秒传,如图:
先test,如果是false说明没有完整文件,检查分片位置,从position+1开始
明确了要开始的分片数
- 然后我们再上传相同的文件,如图:
对于已经存在的文件,发送一个尝试请求,后端返回skipUpload = true的话,会子哦东跳过,不再发送文件上传请求
但是要注意,秒传只能针对完整文件,对于分片是不作用的。断点续传才是针对分片的,要注意定义。
2.后端
2.1 test接口与正式接口
区别:test我们设置为get接口,并且是非multipart文件接口;而正式接口是用来接收文件,对应上面前端设置的两个请求。
2.1.1 test接口
- 拼接url,判断完整文件是否已经存在
- 如果存在,返回一个skipUpload = true的json串给前端,跳过传输,实现秒传
- 如果不存在,返回一个skipUpload = false 与最新分片位置 position = xx 的json串给前端,开始传输
/** * @Author Nineee * @Date 2022/9/22 13:07 * @Description : 用于test快传 秒传 和 续传 的接口 * @param chunkNumber: 分片编号 * @param totalChunks: 总分片数 * @param totalSize: 总大小 * @param filename: 文件名 * @param curUrl: 当前位置 * @return Map */
@GetMapping("/uploadFile")
@ResponseBody
public Map uploadFile( @RequestParam("chunkNumber") String chunkNumber,
@RequestParam("totalChunks") String totalChunks,
@RequestParam("totalSize") String totalSize,
@RequestParam("filename") String filename,
@RequestParam("curUrl") String curUrl,
HttpServletRequest request, HttpServletResponse response) {
User user = (User)request.getAttribute("user");
int uid = user.getUid();
Map map = new HashMap();
boolean isTotalFileExist = Files.exists(Paths.get(store + uid + curUrl + "\\" + filename));
if(isTotalFileExist) {
//存在文件,秒传文件
map.put("skipUpload", true);
}else {
//未存在完整文件
map.put("skipUpload", false);
//应该检查目前到第几个分片,默认分片是有序的
String[] strs = filename.split("\\.");
String localUrl = store + uid + curUrl + "\\" + "tmp_"+strs[1]+"_"+strs[0]+"\\";
long count = fileService.findNewShard(localUrl);
map.put("position", count);
}
return map;
}
2.1.2 正式接口
- 拼接一个临时文件夹用来存放分片
- 如果chunkNumber还没等于totalChunks,说明还没传送完(因为我们并发为1,一定按序传送),则传入false给服务,直接IO把分片写入到临时文件夹中
- 如果chunkNumber等于totalChunks说明这是最后一个分片,在把最后一个分片也传入到临时文件后,传入true进行合并服务
/** * @Author Nineee * @Date 2022/8/16 23:47 * @Description : 上传文件 * @param file: 需要上传的文件 * @param request: 请求体获取当前对象 * @return void */
@PostMapping("/uploadFile")
@ResponseBody
public void uploadFile( @RequestParam("file") MultipartFile file,
@RequestParam("chunkNumber") Integer chunkNumber,
@RequestParam("totalChunks") Integer totalChunks,
@RequestParam("totalSize") String totalSize,
@RequestParam("filename") String filename,
@RequestParam("curUrl") String curUrl,
HttpServletRequest request) {
User user = (User)request.getAttribute("user");
int uid = user.getUid();
//对于localUrl,如果不是末尾分片,我们应该加上一个tmp文件夹避免文件混乱。
//只有发起合并请求的时候再合并到源路径后删除tmp文件夹。
//注意,.需要转义
String[] strs = filename.split("\\.");
String localUrl = store + uid + curUrl + "\\" + "tmp_"+strs[1]+"_"+strs[0];
//不是最后一个分片,不需要合并
fileService.uploadFile(file, localUrl, ""+chunkNumber, false);
if(chunkNumber == totalChunks) {
//否则发起合并服务,merge合并
fileService.uploadFile(file, localUrl, filename, true);
}
}
2.2 Service层
- merge参数表示是否要合并
- 如果不用合并,直接使用工具类的IO写入到临时文件夹curUrl
- 如果要合并,则使用工具类进行合并
- 合并完后要递归删除临时文件夹
@Override
/** * @Author Nineee * @Date 2022/8/16 23:07 * @Description : 上传文件 * @param file: multipart二进制文件流,也就是目标文件 * @param curUrl: 上传的目标地址 * @return Integer 1表示成功,0表示失败 */
public Integer uploadFile(MultipartFile file, String localUrl, String filename, boolean merge) {
if(!merge) {
MultipartFileUtil.addFile(file, localUrl, filename);
}else {
MultipartFileUtil.mergeFileByRandomAccessFile(localUrl, filename);
//合并后删除tmp文件夹
try {
MultipartFileUtil.deleteDirByNio(localUrl);
} catch (Exception e) {
e.printStackTrace();
}
}
return 1;
}
/** * @Author Nineee * @Date 2022/9/22 16:41 * @Description : 找到最新的分片编号 * @param localUrl: 临时文件夹位置 * @return Long */
@Override
public Long findNewShard(String localUrl) {
File tempDir = new File(localUrl);
//如果连临时文件夹都没有,说明一定还没开始上传,不需要续传,直接0
if (!tempDir.exists()) {
tempDir.mkdirs();
return 0L;
}
//应该检查目前到第几个分片,默认分片是有序的
long count = 0;
try {
count = Files.list(Paths.get(localUrl)).count();
} catch (IOException e) {
e.printStackTrace();
}
return count;
}
2.3 工具类介绍
2.3.1 addFile直接IO写入分片
- 因为设置的一个分片为5M,比较小,所以使用传统BIO也不会很影响性能,比较简单,后续可以改为NIO
/** * @Author Nineee * @Date 2022/8/16 23:32 * @Description : 把multipartFile流文件写入到本地url中 * @param file: 文件流 * @param url: 本地路径 * @return void */
public static void addFile(MultipartFile file, String url, String filename){
OutputStream outputStream = null;
InputStream inputStream = null;
try {
inputStream = file.getInputStream();
//log.info("fileName="+fileName);
} catch (IOException e) {
e.printStackTrace();
}
try {
// 2、保存到临时文件
// 1K的数据缓冲
byte[] bs = new byte[1024];
// 读取到的数据长度
int len;
// 输出的文件流保存到本地文件
File tempFile = new File(url);
if (!tempFile.exists()) {
tempFile.mkdirs();
}
//跨平台写法,windows和linux都适用
outputStream = new FileOutputStream(tempFile.getPath()+"\\"+filename);
// 开始读取和写入os
while ((len = inputStream.read(bs)) != -1) {
outputStream.write(bs, 0, len);
}
} catch (IOException e) {
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
} finally {
// 完毕,关闭所有链接
try {
outputStream.close();
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
2.3.2 mergeFileByRandomAccessFile合并文件
- RandomAccessFile是NIO的一个随机访问IO的工具,速度要比流式IO的FileChannel快一点点
- 随机访问即维护两个指针,一个头指针,一个limit指针,把一个File数组传入循环写入到单个输出url即可
/** * @Author Nineee * @Date 2022/9/22 13:53 * @Description : 通过随机访问IO即RandomAccessFile合并某文件夹里面的所有文件为一个文件 * @param fromUrl: 文件夹里装有所有的分片 * @param filename: 新的文件名 * @return void */
public static void mergeFileByRandomAccessFile(String fromUrl, String filename) {
String toUrl = fromUrl.substring(0, fromUrl.lastIndexOf("\\")+1)+"\\"+filename;
RandomAccessFile in = null;
RandomAccessFile out = null;
System.out.println(fromUrl);
File[] files = new File(fromUrl).listFiles();
//必须保证有序,名字根据编号排。避免默认的字典序,即1后面是10编号而不是2
Arrays.sort(files, (o1,o2) -> Integer.parseInt(o1.getName())-Integer.parseInt(o2.getName()));
try {
out = new RandomAccessFile(toUrl, "rw");
for (File file : files) {
in = new RandomAccessFile(file, "r");
int len = 0;
byte[] bt = new byte[BUF_SIZE];
while (-1 != (len = in.read(bt))) {
out.write(bt, 0, len);
}
in.close();
}
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (in != null) {
in.close();
}
if (out != null) {
out.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
2.3.3 deleteDirByNio删除文件夹
- 如果直接使用Files的delete,可能会出现文件不存在或者文件夹不为空的异常,因此需要递归删除
- Files类中的walkFileTree方法可以获取到文件树,方便递归访问,文件树会遵循先文件后文件夹的访问顺序
/** * @Author Nineee * @Date 2022/9/22 14:29 * @Description : 递归删除一个含有文件或者子文件夹的文件夹 * @param url: 文件夹地址 * @return void */
public static void deleteDirByNio(String url) throws Exception {
Path path = Paths.get(url);
Files.walkFileTree(path,
new SimpleFileVisitor<Path>() {
// 先去遍历删除文件
@Override
public FileVisitResult visitFile(Path file,
BasicFileAttributes attrs) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
// 再去遍历删除目录
@Override
public FileVisitResult postVisitDirectory(Path dir,
IOException exc) throws IOException {
Files.delete(dir);
return FileVisitResult.CONTINUE;
}
}
);
}
2.4 演示
2.4.1 前端选择文件夹
2.4.2 临时文件夹
2.4.3 文件分片按序存储
2.4.4 合并与自动删除
3.个人封装的uploader.vue
已实现:分片,秒传
<template>
<div>
<uploader :options="this.options" @file-added="this.fileAdded" style="margin-right: 30px;float: left;">
<uploader-unsupport></uploader-unsupport>
<uploader-btn class="uploader-btn">
点击上传
</uploader-btn>
</uploader>
</div>
</template>
<script> export default {
data() {
return {
options: {
target: "/file/uploadFile", query: {
curUrl: this.$store.state.file.curUrl }, testMethod: "GET", //这里表示的是秒传的优先判断的请求方式 uploadMethod: "POST", //这里表示的是发送文件流的请求方式 method: "multipart", fileParameterName: "file", chunkSize: 5 * 1024 * 1024, forceChunkSize: true, //强制每个分片都小于chunkSize simultaneousUploads: 1, //最大并发数 testChunks: true,//是否开启服务器分片校验 checkChunkUploadedByResponse: function (chunk, res) {
// 服务器分片校验函数,秒传及断点续传基础 //需后台给出对应的查询分片的接口进行分片文件验证 let objMessage = JSON.parse(res);//skipUpload、uploaded 需自己跟后台商量好参数名称 if (objMessage.skipUpload) {
return true; } return (objMessage.uploaded || []).indexOf(chunk.offset + 1) >= 0 }, maxChunkRetries: 2, //最大自动失败重试上传次数 }, } }, methods:{
} } </script>
<style scoped> </style>
4.后端的MutipartFileUtil
package com.nw.nwcloud.util;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class MultipartFileUtil {
public static final int BUF_SIZE = 1024 * 1024;
/** * @Author Nineee * @Date 2022/8/16 23:32 * @Description : 把multipartFile流文件写入到本地url中 * @param file: 文件流 * @param url: 本地路径 * @return void */
public static void addFile(MultipartFile file, String url, String filename){
OutputStream outputStream = null;
InputStream inputStream = null;
try {
inputStream = file.getInputStream();
//log.info("fileName="+fileName);
} catch (IOException e) {
e.printStackTrace();
}
try {
// 2、保存到临时文件
// 1K的数据缓冲
byte[] bs = new byte[1024];
// 读取到的数据长度
int len;
// 输出的文件流保存到本地文件
File tempFile = new File(url);
if (!tempFile.exists()) {
tempFile.mkdirs();
}
//跨平台写法,windows和linux都适用
outputStream = new FileOutputStream(tempFile.getPath()+"\\"+filename);
// 开始读取和写入os
while ((len = inputStream.read(bs)) != -1) {
outputStream.write(bs, 0, len);
}
} catch (IOException e) {
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
} finally {
// 完毕,关闭所有链接
try {
outputStream.close();
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
/** * @Author Nineee * @Date 2022/9/22 13:53 * @Description : 通过随机访问IO即RandomAccessFile合并某文件夹里面的所有文件为一个文件 * @param fromUrl: 文件夹里装有所有的分片 * @param filename: 新的文件名 * @return void */
public static void mergeFileByRandomAccessFile(String fromUrl, String filename) {
String toUrl = fromUrl.substring(0, fromUrl.lastIndexOf("\\")+1)+"\\"+filename;
RandomAccessFile in = null;
RandomAccessFile out = null;
System.out.println(fromUrl);
File[] files = new File(fromUrl).listFiles();
//必须保证有序,名字根据编号排。避免默认的字典序,即1后面是10编号而不是2
Arrays.sort(files, (o1,o2) -> Integer.parseInt(o1.getName())-Integer.parseInt(o2.getName()));
for(File f : files) {
System.out.println(f.getPath());
}
try {
out = new RandomAccessFile(toUrl, "rw");
for (File file : files) {
in = new RandomAccessFile(file, "r");
int len = 0;
byte[] bt = new byte[BUF_SIZE];
while (-1 != (len = in.read(bt))) {
out.write(bt, 0, len);
}
in.close();
}
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
if (in != null) {
in.close();
}
if (out != null) {
out.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
/** * @Author Nineee * @Date 2022/9/22 13:56 * @Description : 通过流式NIO即FileChannel合并某文件夹里面的所有文件为一个文件 * @param fromUrl: 文件夹里装有所有的分片 * @param filename: 新的文件名 * @return void */
public static void mergeFileByFileChannel(String fromUrl, String filename) {
String toUrl = fromUrl.substring(0, fromUrl.lastIndexOf("\\")+1)+"\\"+filename;
FileChannel outChannel = null;
FileChannel inChannel = null;
File[] files = new File(fromUrl).listFiles();
try {
outChannel = new FileOutputStream(toUrl).getChannel();
for (File file : files) {
inChannel = new FileInputStream(file).getChannel();
ByteBuffer bb = ByteBuffer.allocate(BUF_SIZE);
while (inChannel.read(bb) != -1) {
bb.flip();
outChannel.write(bb);
bb.clear();
}
inChannel.close();
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (outChannel != null) {
outChannel.close();
}
if (inChannel != null) {
inChannel.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
/** * @Author Nineee * @Date 2022/9/22 14:29 * @Description : 递归删除一个含有文件或者子文件夹的文件夹 * @param url: 文件夹地址 * @return void */
public static void deleteDirByNio(String url) throws Exception {
Path path = Paths.get(url);
Files.walkFileTree(path,
new SimpleFileVisitor<Path>() {
// 先去遍历删除文件
@Override
public FileVisitResult visitFile(Path file,
BasicFileAttributes attrs) throws IOException {
Files.delete(file);
return FileVisitResult.CONTINUE;
}
// 再去遍历删除目录
@Override
public FileVisitResult postVisitDirectory(Path dir,
IOException exc) throws IOException {
Files.delete(dir);
return FileVisitResult.CONTINUE;
}
}
);
}
}
5.总结与预告
已实现:
- 分片上传
- 建立临时文件夹存放分片
- 基于并发数为1实现分片按序,遇到最后一个分片即合并标记
- 通过RandomAccessFile实现了分片文件按序合并,因为分片文件是按编号命名,编号有序
- 合并后实现递归删除分片文件夹
- 前端通过test请求与后端对应接口实现了秒传完整文件
- 实现了单次test请求获取当前文件分片位置,实现续传
未实现:
-
前端并发上传
-
分片校验
-
完整文件校验
今天的文章upload上传多个文件_大文件分片上传分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/83229.html