文章目录
(1)的问题:
- 我们建立临时文件夹选择的是使用文件名,这显然不是最优解,因为我们的文件夹名称必须保证唯一性,使用一串唯一标识符号显然是更好的解决方法。分片的校验即完整文件的md5+编号实现分片唯一校验;完整文件的校验即直接md5实现。
- 我们秒传中只能解决完整文件的秒传,并且并发数设置为1导致的传输效率大打折扣。应提出能够解决高并发传输(利用http的并发允许)并且能够重传非有序分片的方案。这里提出的是后端检验文件的时候,把各分片编号放入数组返回给前端,前端遍历数据寻找未传输的分片进行传输。实现完整文件秒传+高并发+分片秒传+分片重发。
优化一:md5校验
md5,一种对信息的加密,无论多长的内容都能加工成一定长度的字符串。如果文件的字节相同,也就是内容相同,则md5值一定相同。而只要有一个字符不同,md5值都可能有很大差异(一定不同)。
要实现文件校验,包括分片校验和文件校验
- 分片校验,因为我们的临时文件夹命名为文件的md5值,所以分片会自动分配到对应的文件夹,也就实现了分片校验。
- 完整文件校验,对于合并文件的时候,对合并好的文件在后端进行一次md5编码,与临时文件夹的文件名进行比较,如果相同则校验成功,如果不同则说明文件有损坏或者出错,直接丢弃并返回信息要求重传所有文件。
- 续传过程需要优化,因为分片不一定有序,当我们需要续传的时候,返回的应该是已经有的分片编号的数组,而不能默认有序直接返回最新的编号。
1.1 前端
绑定钩子函数
<uploader :options="this.options"
@file-added="this.fileAdded"
@file-success="this.fileSuccess">
<uploader-unsupport></uploader-unsupport>
<uploader-btn class="uploader-btn">
点击上传
</uploader-btn>
</uploader>
file是官方组件定义的一个文件对象
- 官方提供一个钩子函数fileAdded,即文件被加入到上传队列的之后,我们可以对该文件进行md5的计算
- 计算md5首先是需要一个文件流,读取文件的内容进行md5计算
- 先暂停file.pause()文件上传的流程,计算完后覆盖请求参数中的identifier参数后file.resume()继续上传
- 下面官方是通过异步对文件进行一个文件IO与md5值的计算,通过回调函数去合并md5值结果。也就是先对文件进行分片(单片大小为那个chunkSize参数),一共分为chunks个片进行异步计算,异步能够提高计算md5的速度。也是因为是异步计算md5值,所以需要使用Promise对象,用来表示异步操作的结果。
下面代码官方有提供
GlobalUploader.vue - shady-xia/vue-uploader-solutions - GitHub1s
methods: {
fileSuccess() {
//fileSuccess钩子是在所有分片上传成功的时候激活
//我们请求一次检索当前文件夹内容的接口去更新当前的展示
//使用this.$emit是因为当前uploader被封装成一个子组件,需要emit与父组件通信
//父组件提供一个额外的方法给子组件访问,用于更新父组件的数据。【emit是事件通信,详细看我父子组件通信的博客】
this.$http.post("/file/showAllFiles", {
curUrl: this.$store.state.file.curUrl,
}).then((res) => {
this.$emit("uploadSuccess",res.data.list)
});
},
fileAdded(file) {
// 添加文件进行MD5校验,并覆盖identify参数
this.computeMD5(file)
},
computeMD5(file) {
//建立Reader流,通过file内容建立md5的内容
let fileReader = new FileReader()
let time = new Date().getTime()
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
let currentChunk = 0
const chunkSize = 10 * 1024 * 1000
let chunks = Math.ceil(file.size / chunkSize)
let spark = new SparkMD5.ArrayBuffer()
//暂停文件上传
file.pause()
loadNext()
//返回值是一个Promise对象
//Promise对象用于表示一个异步操作的最终完成(或失败)及其结果值,是js中的一个对象
return new Promise((resolve, reject) => {
fileReader.onload = (e) => {
spark.append(e.target.result)
if (currentChunk < chunks) {
currentChunk++
loadNext()
} else {
let md5 = spark.end()
// md5计算完毕
this.startUpload({md5,file})
}
}
//出现error异常的时候取消上传
fileReader.onerror = function () {
this.error(`文件${file.name}读取出错,请检查该文件`)
file.cancel()
reject()
}
})
//方法内定义一个方法,便于区分模块
function loadNext() {
let start = currentChunk * chunkSize
let end = start + chunkSize >= file.size ? file.size : start + chunkSize
fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end))
}
},
// md5计算完毕,开始上传
startUpload({md5,file}) {
//覆盖文件原来的文件标识
file.uniqueIdentifier = md5
//resume是表示文件继续上传
file.resume()
}
}
1.2 后端
1.2.1 Controller
- 其他不变,这里使用identifier命名文件夹
/**
* @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("identifier") String identifier,
@RequestParam("filename") String filename,
@RequestParam("curUrl") String curUrl,
HttpServletRequest request) {
User user = (User)request.getAttribute("user");
int uid = user.getUid();
String[] strs = filename.split("\\.");
//其他不变,这里使用identifier命名文件夹
String localUrl = store + uid + curUrl + "\\" + identifier;
//不是最后一个分片,不需要合并
fileService.uploadFile(file, localUrl, strs[1]+chunkNumber, false);
if(chunkNumber == totalChunks) {
//否则发起合并服务,merge合并然后校验md5
fileService.uploadFile(file, localUrl, filename, true);
}
}
1.2.2 Service
- md5编码,与前端的校验
@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);
//校验完整文件,localUrl是xxx/xxx/md5值,完整文件在xxx/xxx/filename
String target = localUrl.substring(0, localUrl.lastIndexOf("\\")+1)+filename;
//文件夹名就是前端上传的文件的md5
String oriMd5 = localUrl.substring(localUrl.lastIndexOf("\\")+1);
String md5 = "";
//通过spring的工具类获取合并后文件的md5
try (FileInputStream inputStream = new FileInputStream(target)) {
md5 = DigestUtils.md5DigestAsHex(inputStream);
} catch (IOException e) {
e.printStackTrace();
log.error("文件md5值计算出错!path:{}; err: {}", target, e.getMessage());
}
System.out.println("前端发送的md5:"+oriMd5);
System.out.println("后端校验的md5:"+md5);
if(!oriMd5.equals(md5)) {
//如果不相等,重传
return -1;
}
//合并并且也校验后删除tmp文件夹
try {
MultipartFileUtil.deleteDirByNio(localUrl);
} catch (Exception e) {
e.printStackTrace();
}
}
return 1;
}
1.3 演示
1.3.1 上传
1.3.2 自动创建临时文件夹,以md5命名
1.3.3 合并文件完成并校验md5值
1.3.4 校验成功后自动删除文件夹
小插曲:大文件容易出现损坏【已解决】
但是!我发现在上传小文件的时候,前后端的md5是能够对上的
而上传大文件的时候,md5值就开始对不上了
【测试图片,pdf,rar压缩文件等都可以传输】
经过测试,并不是文件格式问题,而是文件的大小问题。
【40M一下均没问题,90M以上就开始存在合并问题】
问题描述!
× 猜测一:合并时机不对。已经推翻,因为通过前端决定合并时机也会出现合并错误
猜测是分片有序性的问题,分片少的时候有序性容易保证,而分片多的时候有序性难以保证,因此(1)中的并发数为1的时候一定有序是错误的。网络带宽是不稳定的时候各个分片到达的时间也不是有序的。如果仅仅凭借最后一个分片到达就认为允许合并,这是不可行的。
√ 猜测二:后端合并代码出现问题,即分片数超过两位数的时候会出现问题,是字典序默认排序导致,即1后面是10而不是2
后端合并文件的方法中自定义比较器
保证合并的file[]有序
Arrays.sort(files,
(o1,o2) ->
Integer.parseInt(o1.getName()) -
Integer.parseInt(o2.getName()));
已解决:确定是字典序问题
优化二:提高并发与优化合并时机
- 前端的test请求发送过来,后端检验文件的时候,把各分片编号放入数组返回给前端,前端遍历数据寻找未传输的分片进行传输。实现完整文件秒传+高并发+分片秒传+分片重发。
- 合并时机应该由前端决定,因为前端能通过响应码来知道是否所有分片都上传成功
- 做完上述后,把前端并发设置为3试试结果是否会被影响
2.1 优化合并时机
2.1.1 前端
uploader绑定fileSuccess事件
<uploader :options="this.options" @file-added="this.fileAdded"
@file-error="this.fileError" @file-success="this.fileSuccess" >
在fileSuccess事件方法中发起请求,对于参数有不懂的,console.log打印出来就知道有什么内容了
这个事件是在所有分片都成功上传【判断标准是后端返回200状态码】后触发。
fileSuccess(rootFile, file, chunk) {
//这是所有分片都上传成功的钩子,发送合并请求
//通过输出,得到chunk为最后一个分片实例
//file为完整文件实例,和rootFile好像
//打印file去找变量
this.$http.post("/file/mergeFile", {
//传送我们需要的参数即可
curUrl: this.$store.state.file.curUrl,
identifier: file.uniqueIdentifier,
filename: file.name,
}).then((res) => {
//更新数据
this.$emit("uploadSuccess", res.data.list)
});
},
2.1.2 后端
增加一个Controller层的接口就好,service不需要改变。同时其他两个相关接口的代码逻辑有一点改变
- 删除后端遇到最后一个分片编号的时候进行合并的代码,uploadFile接口不进行合并
- 前端发起mergeFile请求后,进行合并,同时返回列表文件,省去一次对showAllFiles的访问
/**
* @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("identifier") String identifier,
@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 + "\\" + identifier +"\\";
long count = fileService.findShards(localUrl);
map.put("position", count);
}
return map;
}
/**
* @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("identifier") String identifier,
@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 + "\\" + identifier;
//不是最后一个分片,不需要合并
fileService.uploadFile(file, localUrl, ""+chunkNumber, false);
}
/**
* @Author Nineee
* @Date 2022/9/24 21:45
* @Description : 合并文件分片的请求
* @param params:
* @param request:
* @return Map
*/
@PostMapping("/mergeFile")
@ResponseBody
public Map mergeFile(@RequestBody Map<String, String> params,
HttpServletRequest request) {
String curUrl = params.get("curUrl");
String identifier = params.get("identifier");
String filename = params.get("filename");
User user = (User)request.getAttribute("user");
int uid = user.getUid();
//对于localUrl,如果不是末尾分片,我们应该加上一个tmp文件夹避免文件混乱。
//只有发起合并请求的时候再合并到源路径后删除tmp文件夹。
//注意,.需要转义
String[] strs = filename.split("\\.");
String localUrl = store + uid + curUrl + "\\" + identifier;
int res = fileService.uploadFile(null, localUrl, filename, true);
List<FileInfo> list = fileService.showAllFiles(store + uid + curUrl);
Map map = new HashMap();
map.put("list", list);
map.put("user", user);
return map;
}
2.1.3 效果
前端决定合并时机
后端文件合并正常
2.2 提高并发
2.2.1 直接在上传器中设置
//options中设置
simultaneousUploads: 3,
2.2.2 效果
一次性三个进行,可以看到还没有状态码。
然后最后文件也是正常进行合并
优化三:续传优化
只需要优化test接口即可。
- test接口去检查所有分片的编号并放入一个int[]里返回
3.1 前端
- 每次传送分片都会进入该函数,因为test接口返回的skipUpload是false
- 如果uploaded数组里能找到当前分片的编号【+1是因为编号是从1开始的】,说明不需要传了,也就是return true。
- 如果在test请求返回的uploaded已上传数组中找不到当前分片编号,则返回false,即需要传送当前分片到后端。
checkChunkUploadedByResponse: function (chunk, message) {
let objMessage = JSON.parse(message);
if (objMessage.skipUpload) {
return true;
}
return (objMessage.uploaded || []).indexOf(chunk.offset + 1) >= 0
},
3.2 后端
Controller
/**
* @Author Nineee
* @Date 2022/9/22 13:07
* @Description : 用于test快传 秒传 和 续传 的接口
* @param filename: 文件名
* @param curUrl: 当前位置
* @return Map
*/
@GetMapping("/uploadFile")
@ResponseBody
public Map uploadFile( @RequestParam("identifier") String identifier,
@RequestParam("filename") String filename,
@RequestParam("curUrl") String curUrl,
HttpServletRequest request) {
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 + "\\" + identifier +"\\";
//多了这里,获取分片数组
int[] uploaded = fileService.findShards(localUrl);
map.put("uploaded", uploaded);
}
return map;
}
Service
- 如果连文件夹都还需要创建,说明还没开始上传
- 如果有文件夹了,就获取list的所有文件名,即编号
/**
* @Author Nineee
* @Date 2022/9/22 16:41
* @Description : 找到已有的分片编号
* @param localUrl: 临时文件夹位置
* @return int[]
*/
@Override
public int[] findShards(String localUrl) {
File tempDir = new File(localUrl);
if (!tempDir.exists()) {
tempDir.mkdirs();
return new int[]{};
}
//应该检查目前到第几个分片,默认分片是有序的
int[] uploaded = new int[]{};
try {
File[] files = tempDir.listFiles();
uploaded = new int[files.length];
for(int i = 0; i < files.length; ++i) {
uploaded[i] = Integer.parseInt(files[i].getName());
}
} catch (Exception e) {
e.printStackTrace();
}
return uploaded;
}
3.3 演示
对于没传送过的文件
对于有部分分片的文件
-
只有7个无序分片【通过刷新网页中断上传】
-
获取到7个分片编号
-
成功合并
-
再看这个位置,重传的时候,16个分片,只进行了9次请求,说明之前7个分片没有重新上传,而是实现了续传。
总结与预告
已优化:
- md5唯一标识文件,校验分片所属。并且对合并好的文件重新进行md5编码与校验,确定文件完整。
- 合并请求时机由前端决定【使用回调函数】,保证分片全部上传成功再告知后端合并。
- 并发数可以提高为3或者自定义,传输过程不需要保证有序性
- 解决了后端合并文件的时候分片有序性,其依赖对File[]数组的重排序,避免字典序排序【即1后面是10而不是2】
- 优化了test接口,test接口返回是否存在完整文件与已存在的分片编号,前端根据后端返回的uploaded数组判断当前分片是否需要上传,实现可靠和高效的续传。减少重复分片的上传。
未实现:
- 保存用户的断点(暂停,或者网络问题导致的断点)
- 实现文件上传列表展示,上传进度展示与上传控制(暂停与开始)