最近在做公司的视频业务,涉及到大视频的上传。
之前的图片、Excel等上传做的很简单,直接表单提交后端用MultipartFile接收保存到磁盘就行了。
但是针对大文件的上传,需要做额外的处理,否则可能会遇到如下问题:
- 文件过大,超出服务端的请求大小限制(如SpringMVC,默认文件上传最大1MB)。
- 请求的时间过长,请求超时。
- 客户端网络不好的话,容易传输中断,必须整个文件重传。
为了解决这些问题,笔者研究了一下,发现可以用分片上传的方式来解决。
前端处理
大文件分片上传,是需要前端和后端配合操作的。
整体流程是:前端将大文件进行分片,例如一个50MB的文件,分成10片,每个片5MB。然后发10个HTTP请求,将这10个分片数据发送给后端,后端根据分片的下标和Size来往磁盘文件的不同位置写入分片数据,10个分片全部写完后即得到一个完整的文件。
前端的处理流程如下:
前端实战代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
<script src="https://cdn.bootcss.com/blueimp-md5/2.10.0/js/md5.js"></script>
</head>
<body>
<input type="file" name="file" id="file">
<button id="upload" onClick="upload()">upload</button>
</body>
</html>
<script>
const sliceSize = 5 * 1024 * 1024; // 每个文件切片大小定为5MB
//发送请求
function upload() {
const blob = document.getElementById("file").files[0];
const fileSize = blob.size;// 文件大小
const fileName = blob.name;// 文件名
//计算文件切片总数
const totalSlice = Math.ceil(fileSize / sliceSize);
// 循环上传
for (let i = 1; i <= totalSlice; i++) {
let chunk;
if (i == totalSlice) {
// 最后一片
chunk = blob.slice((i - 1) * sliceSize, fileSize - 1);//切割文件
} else {
chunk = blob.slice((i - 1) * sliceSize, i * sliceSize);
}
const formData = new FormData();
formData.append("file", chunk);
formData.append("md5", md5(blob));
formData.append("name", fileName);
formData.append("size", fileSize);
formData.append("chunks", totalSlice);
formData.append("chunk", i);
$.ajax({
url: 'http://localhost:8080/chunk/upload',
type: 'POST',
cache: false,
data: formData,
processData: false,
contentType: false,
async: false
});
}
}
</script>
笔者这里写了一个比较粗糙的前端例子,市面上有很多优秀的分片上传插件,例如:webuploader。
后端处理
后端接收到分片数据后,要根据分片的下标和分片的大小来往文件的指定位置写入分片数据。
例如:分片大小为1MB,第一个分片就要往文件的第0个字节开始,写入1048576字节的数据。第二个分片就要往文件的第1048576个字节开始,写入1048576字节的数据,以此类推。待所有的分片数据全部写入完成后,即得到一个完整的文件。
后端处理流程如下:
RandomAccessFile
分片数据的写入,需要对文件进行定位,移动访问指针。
JDK提供了java.io.RandomAccessFile
类,支持对文件进行随机的读写操作。
在Linux平台上,所有打开的文件都有一个文件描述符(FD),文件描述符自身维护了一个文件偏移量(current file offset),简称cfo,通过lseek
函数可以移动文件的读写指针,RandomAccessFile的seek()
方法就是调用了Linux的lseek
系统函数来实现的。
通过RandomAccessFile.seek()移动访问指针,然后写入分片数据。
后端处理代码如下:
@RestController
public class FileController {
// 存放文件的临时目录
private static final String DATA_DIR = System.getProperty("user.dir") + "/temp/";
// 文件MD5的缓存容器
private static final ConcurrentMap<String, File> MD5_CACHE = new ConcurrentHashMap<>();
/**
* 大文件分片上传
* @param name 文件名
* @param md5 文件MD5值
* @param size 文件大小
* @param chunks 总的分片数
* @param chunk 当前分片数
* @param multipartFile 分片流
* @throws IOException
*/
@PostMapping("/chunk/upload")
public void chunkUpload(String name,
String md5,
Long size,
Integer chunks,
Integer chunk,
@RequestParam("file") MultipartFile multipartFile) throws IOException {
// 是否生成了文件???
File targetFile = MD5_CACHE.get(md5);
if (targetFile == null) {
// 没有生成的话就生成一个新的文件,没有做并发控制,多线程上传会出问题
targetFile = new File(DATA_DIR, UUID.randomUUID().toString(true) + "." + FileNameUtil.extName(name));
targetFile.getParentFile().mkdirs();
MD5_CACHE.put(md5, targetFile);
}
// 可以对文件的任意位置进行读写
RandomAccessFile accessFile = new RandomAccessFile(targetFile, "rw");
boolean finished = chunk == chunks;//是否最后一片
if (finished) {
// 移动指针到指定位置
accessFile.seek(size - multipartFile.getSize());
}else {
accessFile.seek((chunk - 1) * multipartFile.getSize());
}
// 写入分片的数据
accessFile.write(multipartFile.getBytes());
accessFile.close();
if (finished) {
System.out.println("success.");
// 上传成功
MD5_CACHE.remove(md5);
}
}
}
文件分片上传的所有请求中,必须有一个唯一标识可以将所有的分片数据关联起来,笔者这里用的是文件的MD5值。
你可能感兴趣的文章: