最近在做公司的视频业务,涉及到大视频的上传。
之前的图片、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="/jquery/2.0.0/jquery.min.js"></script><script src="/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()移动访问指针,然后写入分片数据。
后端处理代码如下:
@RestControllerpublic 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值。
你可能感兴趣的文章:
AQS源码导读摊牌了,我要手写一个RPCJava锁的膨胀过程以及一致性哈希对锁膨胀的影响ThreadLocal源码解析CMS与三色标记算法大白话理解可达性分析算法