背景介绍
在现代Web应用开发中,文件上传是常见的功能需求,尤其是在处理图片、视频、文档等资源时。随着用户对多媒体内容需求的增加,上传文件的体积也越来越大,传统的单次上传方式在处理大文件时暴露出诸多问题。
直接上代码
后端代码
UploadController.java
java
package com.shenyun.lyguide.web;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
@RestController
@RequestMapping("upload")
public class UploadController {
// 文件存储的基本路径
private static final String UPLOAD_DIR = "uploads/";
// 定义分块大小(默认10MB)
private static final long CHUNK_SIZE = 1024 * 1024 * 5;
/**
* 分块上传文件接口
*
* @param file 文件分块
* @param chunkNumber 当前分块序号
* @param totalChunks 总分块数
* @param fileName 文件名
* @return 上传结果
*/
@PostMapping("/chunk")
public Map<String, Object> uploadChunk(@RequestParam("file") MultipartFile file,
@RequestParam("chunkNumber") Integer chunkNumber,
@RequestParam("totalChunks") Integer totalChunks,
@RequestParam("fileName") String fileName) {
Map<String, Object> result = new HashMap<>();
try {
// 创建上传目录
Path uploadPath = Paths.get(UPLOAD_DIR);
if (!Files.exists(uploadPath)) {
Files.createDirectories(uploadPath);
}
// 创建临时目录存储分块文件
Path tempDir = uploadPath.resolve("temp_" + fileName);
if (!Files.exists(tempDir)) {
Files.createDirectories(tempDir);
}
// 保存分块文件
Path chunkPath = tempDir.resolve("chunk_" + chunkNumber);
Files.write(chunkPath, file.getBytes());
result.put("success", true);
result.put("message", "分块上传成功");
} catch (Exception e) {
result.put("success", false);
result.put("message", "分块上传失败: " + e.getMessage());
}
return result;
}
/**
* 合并分块文件
*
* @param fileName 文件名
* @param totalChunks 总分块数
* @return 合并结果
*/
@PostMapping("/merge")
public Map<String, Object> mergeChunks(@RequestParam("fileName") String fileName,
@RequestParam("totalChunks") Integer totalChunks) {
Map<String, Object> result = new HashMap<>();
try {
Path uploadPath = Paths.get(UPLOAD_DIR);
Path tempDir = uploadPath.resolve("temp_" + fileName);
Path targetFile = uploadPath.resolve(fileName);
// 创建目标文件
try (OutputStream out = Files.newOutputStream(targetFile)) {
// 按顺序合并分块文件
for (int i = 0; i < totalChunks; i++) {
Path chunkPath = tempDir.resolve("chunk_" + i);
if (Files.exists(chunkPath)) {
Files.copy(chunkPath, out);
} else {
throw new RuntimeException("缺少分块文件: " + i);
}
}
}
// 删除临时目录
deleteDirectory(tempDir);
result.put("success", true);
result.put("message", "文件合并成功");
result.put("filePath", targetFile.toString());
} catch (Exception e) {
result.put("success", false);
result.put("message", "文件合并失败: " + e.getMessage());
}
return result;
}
/**
* 检查分块是否已上传
*
* @param fileName 文件名
* @param chunkNumber 分块序号
* @return 检查结果
*/
@GetMapping("/chunk/check")
public Map<String, Object> checkChunk(@RequestParam("fileName") String fileName,
@RequestParam("chunkNumber") Integer chunkNumber) {
Map<String, Object> result = new HashMap<>();
try {
Path chunkPath = Paths.get(UPLOAD_DIR).resolve("temp_" + fileName).resolve("chunk_" + chunkNumber);
boolean exists = Files.exists(chunkPath);
result.put("exists", exists);
result.put("success", true);
} catch (Exception e) {
result.put("success", false);
result.put("message", "检查分块失败: " + e.getMessage());
}
return result;
}
/**
* 删除目录及其内容
*
* @param directoryPath 目录路径
* @throws IOException IO异常
*/
private void deleteDirectory(Path directoryPath) throws IOException {
if (Files.exists(directoryPath)) {
Files.walk(directoryPath)
.sorted(Comparator.reverseOrder())
.map(Path::toFile)
.forEach(File::delete);
}
}
}
前端代码
resource/static/upload.html
html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>大文件分块上传</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.upload-container {
border: 2px dashed #ccc;
border-radius: 10px;
padding: 20px;
text-align: center;
margin-bottom: 20px;
}
.upload-container.dragover {
border-color: #007bff;
background-color: #f8f9fa;
}
.file-input {
margin: 20px 0;
}
.progress-container {
margin: 20px 0;
}
.progress-bar {
width: 100%;
height: 20px;
background-color: #f0f0f0;
border-radius: 10px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: #007bff;
width: 0%;
transition: width 0.3s ease;
}
.chunk-info {
margin: 10px 0;
font-size: 14px;
}
.btn {
background-color: #007bff;
color: white;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
}
.btn:hover {
background-color: #0056b3;
}
.btn:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.status {
margin: 10px 0;
padding: 10px;
border-radius: 5px;
}
.status.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
</style>
</head>
<body>
<h1>大文件分块上传示例</h1>
<div class="upload-container" id="dropZone">
<p>拖拽文件到此处或点击选择文件</p>
<input type="file" id="fileInput" class="file-input" style="display: none;">
<button class="btn" onclick="document.getElementById('fileInput').click()">选择文件</button>
<div class="file-info" id="fileInfo"></div>
</div>
<div class="chunk-settings">
<label for="chunkSize">分块大小 (MB):</label>
<input type="number" id="chunkSize" value="5" min="1" max="100">
</div>
<button class="btn" id="uploadBtn" onclick="startUpload()" disabled>开始上传</button>
<div class="progress-container">
<div class="chunk-info" id="chunkInfo"></div>
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="chunk-info" id="progressText">0%</div>
</div>
<div class="status" id="statusMessage" style="display: none;"></div>
<script>
let selectedFile = null;
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const fileInfo = document.getElementById('fileInfo');
const uploadBtn = document.getElementById('uploadBtn');
const chunkInfo = document.getElementById('chunkInfo');
const progressFill = document.getElementById('progressFill');
const progressText = document.getElementById('progressText');
const statusMessage = document.getElementById('statusMessage');
// 文件选择事件
fileInput.addEventListener('change', function(e) {
if (e.target.files.length > 0) {
selectedFile = e.target.files[0];
showFileInfo();
uploadBtn.disabled = false;
}
});
// 拖拽上传事件
dropZone.addEventListener('dragover', function(e) {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', function() {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', function(e) {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
selectedFile = e.dataTransfer.files[0];
showFileInfo();
uploadBtn.disabled = false;
}
});
// 显示文件信息
function showFileInfo() {
const chunkSize = document.getElementById('chunkSize').value * 1024 * 1024;
const totalChunks = Math.ceil(selectedFile.size / chunkSize);
fileInfo.innerHTML = `
<p><strong>文件名:</strong> ${selectedFile.name}</p>
<p><strong>文件大小:</strong> ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB</p>
<p><strong>分块数量:</strong> ${totalChunks}</p>
`;
}
// 开始上传
async function startUpload() {
if (!selectedFile) {
showMessage('请先选择文件', 'error');
return;
}
const chunkSize = document.getElementById('chunkSize').value * 1024 * 1024;
const totalChunks = Math.ceil(selectedFile.size / chunkSize);
const fileName = selectedFile.name;
uploadBtn.disabled = true;
showMessage('开始上传...', 'success');
let uploadedChunks = 0;
// 分块上传文件
for (let i = 0; i < totalChunks; i++) {
// 检查分块是否已存在
const checkResponse = await checkChunk(fileName, i);
if (checkResponse.exists) {
// 分块已存在,跳过上传
uploadedChunks++;
updateProgress(uploadedChunks, totalChunks);
continue;
}
// 计算分块的起始和结束位置
const start = i * chunkSize;
const end = Math.min(start + chunkSize, selectedFile.size);
const chunk = selectedFile.slice(start, end);
// 创建FormData
const formData = new FormData();
formData.append('file', chunk, `${fileName}_chunk_${i}`);
formData.append('chunkNumber', i);
formData.append('totalChunks', totalChunks);
formData.append('fileName', fileName);
try {
// 上传分块
const response = await fetch('/upload/chunk', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
uploadedChunks++;
updateProgress(uploadedChunks, totalChunks);
} else {
throw new Error(result.message);
}
} catch (error) {
showMessage(`上传分块 ${i} 失败: ${error.message}`, 'error');
uploadBtn.disabled = false;
return;
}
}
// 所有分块上传完成,开始合并
showMessage('所有分块上传完成,正在合并文件...', 'success');
try {
const response = await fetch('/upload/merge', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: `fileName=${encodeURIComponent(fileName)}&totalChunks=${totalChunks}`
});
const result = await response.json();
if (result.success) {
showMessage(`文件上传成功!文件路径: ${result.filePath}`, 'success');
} else {
throw new Error(result.message);
}
} catch (error) {
showMessage(`合并文件失败: ${error.message}`, 'error');
}
uploadBtn.disabled = false;
}
// 检查分块是否存在
async function checkChunk(fileName, chunkNumber) {
const response = await fetch(`/upload/chunk/check?fileName=${encodeURIComponent(fileName)}&chunkNumber=${chunkNumber}`);
return await response.json();
}
// 更新进度条
function updateProgress(uploaded, total) {
const percent = (uploaded / total) * 100;
progressFill.style.width = percent + '%';
progressText.textContent = percent.toFixed(2) + '%';
chunkInfo.textContent = `已上传: ${uploaded}/${total} 个分块`;
}
// 显示状态消息
function showMessage(message, type) {
statusMessage.textContent = message;
statusMessage.className = 'status ' + type;
statusMessage.style.display = 'block';
// 3秒后自动隐藏成功消息
if (type === 'success') {
setTimeout(() => {
statusMessage.style.display = 'none';
}, 3000);
}
}
</script>
</body>
</html>
注意点
如果分块的大小超过10MB,需要配置spring boot上传文件大小限制
html
# 文件上传相关配置
spring.servlet.multipart.max-file-size=20MB
spring.servlet.multipart.max-request-size=20MB
原理解释
接口功能解释
1、分块上传接口 (/upload/chunk):
接收文件分块、分块序号、总分块数和文件名
将每个分块保存到临时目录中
2、分块检查接口 (/upload/chunk/check):
检查指定分块是否已经上传,用于断点续传功能
3、合并分块接口 (/upload/merge):
将所有分块按顺序合并成完整文件
合并完成后删除临时分块文件
功能特性:
支持大文件分块上传
支持断点续传(通过检查分块接口)
自动合并分块文件
清理临时文件