小文件上传
后端:SpringBoot+JDK17
前端:JavaScript+spark+md5.min.js
一、依赖
<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.1.2</version><relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>uploadDemo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>uploadDemo</name>
<description>uploadDemo</description>
<properties><java.version>17</java.version>
</properties>
<dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins>
</build>
二、业务代码
@RestController
public class UploadController{//上传路径public static final String UPLOAD_PATH = "D:\\upload";@RequestMapping("/upload")public ResponseEntity<Map<String,String>> upload(@RequestParam MultipartFile file) throws IOException {File dstFile = new File(UPLOAD_PATH,String.format("%s.%s", UUID.randomUUID(), StringUtils.getFilename(file.getOriginalFilename())));file.transferTo(dstFile);return ResponseEntity.ok(Map.of("path",dstFile.getAbsolutePath()));}
}
三、前端显示
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>upload</title>
</head>
<body>
upload<form enctype="multipart/form-data"><input type="file" name="fileInput" id="fileInput"><input type="button" value="上传" onclick="uploadFile()">
</form>上传结果
<span id="uploadResult"></span><script>var uploadResult=document.getElementById("uploadResult")function uploadFile() {var fileInput = document.getElementById('fileInput');var file = fileInput.files[0];if (!file) return; // 没有选择文件var xhr = new XMLHttpRequest();// 处理上传进度xhr.upload.onprogress = function(event) {var percent = 100 * event.loaded / event.total;uploadResult.innerHTML='上传进度:' + percent + '%';};// 当上传完成时调用xhr.onload = function() {if (xhr.status === 200) {uploadResult.innerHTML='上传成功'+ xhr.responseText;}}xhr.onerror = function() {uploadResult.innerHTML='上传失败';}// 发送请求xhr.open('POST', '/upload', true);var formData = new FormData();formData.append('file', file);xhr.send(formData);}
</script></body>
</html>
【注意事项】
在上传过程会报文件大小限制错误,主要有三个参数需要设置:
org.apache.tomcat.util.http.fileupload.impl.SizeLimitExceededException: the request was rejected because its size (46302921) exceeds the configured maximum (10485760)
需在springboot的application.properties 或者application.yml中添加:
max-file-size
max-request-size
默认大小分别是1M和10M,因此需要重新设定
spring.servlet.multipart.max-file-size=1024MB
spring.servlet.multipart.max-request-size=1024MB
如果使用nginx报 413状态码413 Request Entity Too Large,Nginx默认最大上传1MB文件,需要在nginx.conf配置文件中的 http{ }添加配置项:client_max_body_size 1024m
大文件上传
一、前端分片
计算文件MD5值用了spark-md5这个库
因为文件在传输写入过程中可能会出现错误,导致最终合成的文件可能和原文件不一样,所以要对比一下前端计算的MD5和后端计算的MD5是不是一样,保证上传数据的一致性
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>分片上传</title><script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>
</head>
<body>
分片上传<form enctype="multipart/form-data"><input type="file" name="fileInput" id="fileInput"><input type="button" value="计算文件MD5" onclick="calculateFileMD5()"><input type="button" value="上传" onclick="uploadFile()"><input type="button" value="检测文件完整性" onclick="checkFile()">
</form><p>文件MD5:<span id="fileMd5"></span>
</p>
<p>上传结果:<span id="uploadResult"></span>
</p>
<p>检测文件完整性:<span id="checkFileRes"></span>
</p><script>//每片的大小var chunkSize = 1 * 1024 * 1024;var uploadResult = document.getElementById("uploadResult")var fileMd5Span = document.getElementById("fileMd5")var checkFileRes = document.getElementById("checkFileRes")var fileMd5;function calculateFileMD5(){var fileInput = document.getElementById('fileInput');var file = fileInput.files[0];getFileMd5(file).then((md5) => {console.info(md5)fileMd5=md5;fileMd5Span.innerHTML=md5;})}function uploadFile() {var fileInput = document.getElementById('fileInput');var file = fileInput.files[0];if (!file) return;if (!fileMd5) return;//获取到文件let fileArr = this.sliceFile(file);//保存文件名称let fileName = file.name;fileArr.forEach((e, i) => {//创建formdata对象let data = new FormData();data.append("totalNumber", fileArr.length)data.append("chunkSize", chunkSize)data.append("chunkNumber", i)data.append("md5", fileMd5)data.append("file", new File([e],fileName));upload(data);})}/*** 计算文件md5值*/function getFileMd5(file) {return new Promise((resolve, reject) => {let fileReader = new FileReader()fileReader.onload = function (event) {let fileMd5 = SparkMD5.ArrayBuffer.hash(event.target.result)resolve(fileMd5)}fileReader.readAsArrayBuffer(file)})}function upload(data) {var xhr = new XMLHttpRequest();// 当上传完成时调用xhr.onload = function () {if (xhr.status === 200) {uploadResult.append( '上传成功分片:' +data.get("chunkNumber")+'\t' ) ;}}xhr.onerror = function () {uploadResult.innerHTML = '上传失败';}// 发送请求xhr.open('POST', '/uploadBig', true);xhr.send(data);}function checkFile() {var xhr = new XMLHttpRequest();// 当上传完成时调用xhr.onload = function () {if (xhr.status === 200) {checkFileRes.innerHTML = '检测文件完整性成功:' + xhr.responseText;}}xhr.onerror = function () {checkFileRes.innerHTML = '检测文件完整性失败';}// 发送请求xhr.open('POST', '/checkFile', true);let data = new FormData();data.append("md5", fileMd5)xhr.send(data);}function sliceFile(file) {const chunks = [];let start = 0;let end;while (start < file.size) {end = Math.min(start + chunkSize, file.size);chunks.push(file.slice(start, end));start = end;}return chunks;}</script></body>
</html>
二、后端
两个接口/uploadBig用于每一片文件的上传和/checkFile检测文件的MD5
FileChannel fileChannel = randomAccessFile.getChannel();
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, chunkNumber * chunkSize, fileData.length);
mappedByteBuffer.put(fileData);
@RestController
public class UploadController {public static final String UPLOAD_PATH = "D:\\upload\\";/*** @param chunkSize 每个分片大小* @param chunkNumber 当前分片* @param md5 文件总MD5* @param file 当前分片文件数据* @return* @throws IOException*/@RequestMapping("/uploadBig")public ResponseEntity<Map<String, String>> uploadBig(@RequestParam Long chunkSize, @RequestParam Integer totalNumber, @RequestParam Long chunkNumber, @RequestParam String md5, @RequestParam MultipartFile file) throws IOException {//文件存放位置String dstFile = String.format("%s\\%s\\%s.%s", UPLOAD_PATH, md5, md5, StringUtils.getFilenameExtension(file.getOriginalFilename()));//上传分片信息存放位置String confFile = String.format("%s\\%s\\%s.conf", UPLOAD_PATH, md5, md5);//第一次创建分片记录文件//创建目录File dir = new File(dstFile).getParentFile();if (!dir.exists()) {dir.mkdir();//所有分片状态设置为0byte[] bytes = new byte[totalNumber];Files.write(Path.of(confFile), bytes);}//随机分片写入文件try (RandomAccessFile randomAccessFile = new RandomAccessFile(dstFile, "rw");RandomAccessFile randomAccessConfFile = new RandomAccessFile(confFile, "rw");InputStream inputStream = file.getInputStream()) {//定位到该分片的偏移量(可以将光标移到文件指定位置开始写数据,每一个文件每将上传分片编号chunkNumber都是不一样的,所以各自写自己文件块,多线程写同一个文件不会出现线程安全问题)randomAccessFile.seek(chunkNumber * chunkSize);//写入该分片数据大文件写入时用RandomAccessFile可能比较慢,可以使用MappedByteBuffer内存映射来加速大文件写入,不过使用MappedByteBuffer如果要删除文件可能会存在删除不掉,因为删除了磁盘上的文件,内存的文件还是存在的randomAccessFile.write(inputStream.readAllBytes());//定位到当前分片状态位置randomAccessConfFile.seek(chunkNumber);//设置当前分片上传状态为1randomAccessConfFile.write(1);}return ResponseEntity.ok(Map.of("path", dstFile));}/*** 获取文件分片状态,检测文件MD5合法性** @param md5* @return* @throws Exception*/@RequestMapping("/checkFile")public ResponseEntity<Map<String, String>> uploadBig(@RequestParam String md5) throws Exception {String uploadPath = String.format("%s\\%s\\%s.conf", UPLOAD_PATH, md5, md5);Path path = Path.of(uploadPath);//MD5目录不存在文件从未上传过if (!Files.exists(path.getParent())) {return ResponseEntity.ok(Map.of("msg", "文件未上传"));}//判断文件是否上传成功StringBuilder stringBuilder = new StringBuilder();byte[] bytes = Files.readAllBytes(path);for (byte b : bytes) {stringBuilder.append(String.valueOf(b));}//所有分片上传完成计算文件MD5if (!stringBuilder.toString().contains("0")) {File file = new File(String.format("%s\\%s\\", UPLOAD_PATH, md5));File[] files = file.listFiles();String filePath = "";for (File f : files) {//计算文件MD5是否相等if (!f.getName().contains("conf")) {filePath = f.getAbsolutePath();try (InputStream inputStream = new FileInputStream(f)) {String md5pwd = DigestUtils.md5DigestAsHex(inputStream);if (!md5pwd.equalsIgnoreCase(md5)) {return ResponseEntity.ok(Map.of("msg", "文件上传失败"));}}}}return ResponseEntity.ok(Map.of("path", filePath));} else {//文件未上传完成,反回每个分片状态,前端将未上传的分片继续上传return ResponseEntity.ok(Map.of("chucks", stringBuilder.toString()));}}}
断点续传
用/checkFile接口,文件里如果有未完成上传的分片,接口返回chunks字段对就的位置值为0,前端将未上传的分片继续上传,完成后再调用/checkFile就完成了断点续传
秒传
只要修改前端代码流程就好了,比如张三上传了一个文件,然后李四又上传了同样内容的文件,同一文件的MD5值可以认为是一样的(虽然会存在不同文件的MD5一样,不过概率很小,可以认为MD5一样文件就是一样)李四调用/checkFile接口后,后端直接返回了李四上传的文件路径,李四就完成了秒传。大部分云盘秒传的思路应该也是这样,只不过计算文件HASH算法更为复杂,返回给用户文件路径也更为安全,要防止被别人算出文件路径了