概要
在日常开发中上传文件是常见的功能,像使用 SpringBoot 作为服务端接收上传的文件是很方便的,但是默认情况下 SpringBoot 为我限定了单次上传文件的大小,默认是1MB,当我们单次上传的大小超过1MB的时候就会报错,这时候我们可以通过修改上传大小限制来解决这个问题,主要是这两个属性:
- spring.servlet.multipart.max-file-size
- spring.servlet.multipart.max-request-size
但是这对于小型项目或者能确定每次上传的文件大小大概范围的可以,对于大型项目和不确定文件大小的项目这样的操作显然不适用,试想一下,如果你设置为了100MB,那假如有100个人同时上传文件,项目的内存不就GG了吗?因此通用稳妥的解决方法是分片上传,例如上传一个100MB的文件我们可以将这个文件先分割为100分1MB的文件,然后分别上传这100份就可以了,当然这里分片的大小要根据你的服务器配置自行调节,并不是越小越好,这样既解决了文件限制大小,而且可以利用多线程更快的进行上传。
实现过程
在实现前我们首先要考虑好,如何让服务端知道多次请求上传的文件是同一个文件的分片?你可能会想让前端在发送时生成一个uid,然后每个分片上传时都携带这个uid,然后服务端在进行文件合并的时候通过这个来判断,这个思路是正确的,那这个uid怎么生成呢?这里我们可以使用 spark-md5.js 来为文件生成一个唯一的hash值,这样既可以区分分片文件也可以在后续进行文件下载时进行文件的校验,因为一个文件的hash值是固定的,只要文件被修改那么hash值就会发生改变。
但是计算文件的hash值对于小文件我们可以直接计算,但是对于大文件,我们就要分片计算然后合并,这个过程也不用担心,spark-md5.js 里有现成的方法。因此,现在大概的流程就是:选择文件,然后将文件进行分片,在分片的同时计算每一个分片的hash值,然后分片完成后合并hash获取到完整文件的hash,最后遍历分片构造上传FormData进行上传请求。
文件的分片可以使用 JavaScript 的 slice(file, start, end) 方法实现,这里再说一下 start 和 end 参数的确定,我们先定义几个变量:
- chunks:表示分片的数量,这个是文件大小除以分片大小向下取整,可以通过Math.ceil()方法得到。
- currentChunk:当前chunk索引,初始值设为0
- chunkSize:分片的大小,本文中设置为了1MB
start 的值是 currentChunk*chunkSize 确定 end 参数时有两种情况:
- start+chunkSize >= file.size 时 end 为 file.size
- start+chunkSize < file.size 时 end 为 start + chunkSize
前端完整代码
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>文件分片上传/下载测试</title>
<script src="./js/jquery.min.js"></script>
<script src="./js/spark-md5.min.js"></script>
<style>
html,
body {
width: 100%;
height: 100vh;
margin: 0;
}
header {
height: 30px;
display: flex;
align-items: center;
margin: 0;
padding: 10px;
background-color: #333;
color: white;
}
header>h1 {
font-size: 18px;
}
main {
height: calc(100% - 100px);
}
.uploadBox {
height: 100%;
display: flex;
padding: 10px;
font-size: 14px;
}
</style>
</head>
<body>
<header>
<h1>文件分片上传测试页面</h1>
</header>
<main>
<div class="uploadBox" id="upload-box">
<div class="chooseFile">
<label for="file">选择文件:</label>
<button id="selectFile">选择文件</button>
<div class="fileInfo">
<p id="fileName">文件名称:</p>
<p id="fileSize">文件大小:</p>
<label for="chunkSize">分片大小:</label><input value="1024" id="chunkSize" type="number" placeholder="分片大小"><span>
kb</span>
</div>
<input hidden type="file" id="file">
<button style="width: 100%; margin-top:10px;">开始上传</button>
<p id="uploadInfo"></p>
</div>
</div>
</main>
<script>
$(document).ready(function () {
$('#selectFile').click(() => {
$('#file').click();
});
$('#file').change(async (e) => {
var chunkSize = $('#chunkSize').val() * 1024;
if (chunkSize == 0) {
alert('分片大小不可以为0')
return;
}
var file = e.target.files[0]
$('#fileSize').append(Math.ceil(file.size / 1024) + ' kb')
$('#fileName').append(file.name)
//进行文件分割并上传
sliceFileAndUpload(e.target.files, chunkSize)
})
});
function sliceFileAndUpload(files, chunkSize) {
var filePartList = [];
var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
file = files[0],
chunks = Math.ceil(file.size / chunkSize),
currentChunk = 0,
spark = new SparkMD5.ArrayBuffer(),
fileReader = new FileReader();
fileReader.onload = e => {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
var uid = spark.end();
filePartList.forEach(item => {
let formData = new FormData();
formData.append('file', item.filePart);
formData.append('uid', uid);
formData.append('currentIndex', item.currentIndex);
formData.append('fileName', file.name);
$.ajax({
url: '/file/uploadBySlice',
method: 'POST',
data: formData,
processData: false,
contentType: false,
success: function (e) {
console.log(e)
$('#uploadInfo').append(e + "<br/>")
}
})
})
alert('上传完成,请到测试文件夹验证文件是否正确')
}
}
fileReader.onerror = () => {
console.log('文件读取处理发生错误')
}
function loadNext() {
var temp = {}
var start = currentChunk * chunkSize,
end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
let filePart = blobSlice.call(file, start, end);
temp.currentIndex = start;
temp.filePart = filePart;
filePartList.push(temp);
fileReader.readAsArrayBuffer(filePart);
}
loadNext();
}
</script>
</body>
</html>
由于是测试项目,前端的样式没有过多的美化,见谅。
服务端的代码就比较简单了,由于要进行文件的拼接,因此需要使用 RandomAccessFile 来进行文件的操作,因为它可以在指定的位置插入内容,它的 seek 就是这个作用,你可以把它看为一个指针,每次在插入文内容前先将这个指针拨到我们想要的位置,然后进行插入。
服务端代码
java
package vip.huhailong.slicefile.controller;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
@RestController
@RequestMapping("/file")
public class FileController {
@Value("${upload.dir}")
private String uploadDir;
@PostMapping("/uploadBySlice")
public String uploadBySlice(MultipartFile file, String fileName, String uid, long currentIndex) throws IOException {
mkdirUploadDir();
String filePath = uploadDir + uid+"-"+fileName;
try (RandomAccessFile accessFile = new RandomAccessFile(filePath, "rw")) {
accessFile.seek(currentIndex);
System.out.println("Thread name:"+Thread.currentThread().getName()+";currentIndex:" + currentIndex + "; fileSize:" + file.getBytes().length);
accessFile.write(file.getBytes());
return "Thread name:"+Thread.currentThread().getName()+";currentIndex:" + currentIndex + "; fileSize:" + file.getBytes().length;
}
}
private void mkdirUploadDir() {
File uploadDirFile = new File(uploadDir);
if (!uploadDirFile.exists()) {
boolean mkdirs = uploadDirFile.mkdirs();
if (!mkdirs) {
throw new RuntimeException("创建上传目录失败");
}
}
}
}
测试截图
测试截图
以上就是分片上传文件的简单实现,可以在此基础上增加更多的附加功能,本文中的代码对应的代码仓库地址: