大文件分片上传:完整前后端代码
前端代码
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>大文件上传</title>
</head>
<body>
<input type="file" id="fileInput" />
<button onclick="upload()">上传</button>
<div id="progress"></div>
<script>
const CHUNK_SIZE = 5 * 1024 * 1024; // 5M 一片
async function upload() {
const file = document.getElementById('fileInput').files[0];
if (!file) return;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const fileId = Date.now() + '-' + Math.random().toString(36).substring(2);
// 计算文件的 MD5(秒传/校验用,大文件建议用 SparkMD5 增量计算)
// 这里省略 MD5 计算,实际可以加上
// 逐片上传说到底就是 for 循环
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('fileId', fileId);
formData.append('chunkIndex', i);
formData.append('totalChunks', totalChunks);
formData.append('fileName', file.name);
// 上传这一片
const resp = await fetch('/upload/chunk', {
method: 'POST',
body: formData
});
const result = await resp.json();
if (!result.success) {
document.getElementById('progress').textContent = '上传失败,分片 ' + i;
return;
}
// 更新进度
const pct = Math.round(((i + 1) / totalChunks) * 100);
document.getElementById('progress').textContent = pct + '%';
}
// 所有分片上传完成,通知后端合并
const resp = await fetch('/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileId, fileName: file.name, totalChunks })
});
const result = await resp.json();
alert('上传完成:' + result.filePath);
}
</script>
</body>
</html>
带并发控制的分片上传
如果一个个上传太慢,可以并发上传,但要注意控制并发数,不然浏览器会把连接占满。
html
<script>
async function uploadWithConcurrency(file, maxConcurrent = 3) {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const fileId = Date.now() + '-' + Math.random().toString(36).substring(2);
// 把所有分片信息准备好
const tasks = [];
for (let i = 0; i < totalChunks; i++) {
tasks.push(i);
}
let completed = 0;
// 控制并发:一次只跑 maxConcurrent 个
async function worker() {
while (tasks.length > 0) {
const i = tasks.shift();
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('fileId', fileId);
formData.append('chunkIndex', i);
formData.append('totalChunks', totalChunks);
formData.append('fileName', file.name);
const resp = await fetch('/upload/chunk', { method: 'POST', body: formData });
const result = await resp.json();
if (!result.success) throw new Error('分片 ' + i + ' 上传失败');
completed++;
const pct = Math.round((completed / totalChunks) * 100);
document.getElementById('progress').textContent = pct + '%';
}
}
// 启动 maxConcurrent 个 worker
const workers = [];
for (let i = 0; i < maxConcurrent; i++) {
workers.push(worker());
}
await Promise.all(workers);
// 合并
const resp = await fetch('/upload/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileId, fileName: file.name, totalChunks })
});
return await resp.json();
}
</script>
后端代码
Controller 层
java
@RestController
@RequestMapping("/upload")
public class UploadController {
@Autowired
private ChunkUploadService chunkUploadService;
/**
* 接收一个分片
*/
@PostMapping("/chunk")
public Result uploadChunk(@RequestParam("chunk") MultipartFile chunk,
@RequestParam("fileId") String fileId,
@RequestParam("chunkIndex") int chunkIndex,
@RequestParam("totalChunks") int totalChunks,
@RequestParam("fileName") String fileName) throws IOException {
chunkUploadService.saveChunk(fileId, chunkIndex, chunk);
return Result.success();
}
/**
* 合并所有分片
*/
@PostMapping("/merge")
public Result mergeChunks(@RequestBody MergeRequest request) throws IOException {
String filePath = chunkUploadService.merge(request.getFileId(),
request.getFileName(),
request.getTotalChunks());
return Result.success(filePath);
}
}
分片上传服务
java
@Service
public class ChunkUploadService {
/** 临时分片存放目录 */
private static final String CHUNK_DIR = "/data/uploads/chunks/";
/** 合并后的文件存放目录 */
private static final String DEST_DIR = "/data/uploads/files/";
/**
* 保存一个分片到临时目录
*/
public void saveChunk(String fileId, int chunkIndex, MultipartFile chunk) throws IOException {
// 每个文件一个文件夹,存放它的所有分片
Path chunkDir = Paths.get(CHUNK_DIR, fileId);
Files.createDirectories(chunkDir);
// 分片文件命名:0、1、2、3...
Path chunkFile = chunkDir.resolve(String.valueOf(chunkIndex));
try (FileChannel out = FileChannel.open(chunkFile,
StandardOpenOption.WRITE,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
FileChannel in = (FileChannel) chunk.getInputStream().getChannel()) {
long transferred = 0;
long fileSize = in.size();
while (transferred < fileSize) {
transferred += in.transferTo(transferred, fileSize - transferred, out);
}
}
}
/**
* 合并所有分片
*/
public String merge(String fileId, String fileName, int totalChunks) throws IOException {
Path chunkDir = Paths.get(CHUNK_DIR, fileId);
Path destFile = Paths.get(DEST_DIR, System.currentTimeMillis() + "_" + fileName);
Files.createDirectories(destFile.getParent());
try (FileChannel out = FileChannel.open(destFile,
StandardOpenOption.WRITE,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING)) {
// 按照分片顺序,一个一个写进去
for (int i = 0; i < totalChunks; i++) {
Path chunkFile = chunkDir.resolve(String.valueOf(i));
if (!Files.exists(chunkFile)) {
throw new IOException("分片丢失:" + i);
}
try (FileChannel in = FileChannel.open(chunkFile, StandardOpenOption.READ)) {
long transferred = 0;
long fileSize = in.size();
while (transferred < fileSize) {
transferred += in.transferTo(transferred, fileSize - transferred, out);
}
}
}
}
// 合并完删除临时分片目录
deleteChunkDir(chunkDir);
return destFile.toString();
}
private void deleteChunkDir(Path chunkDir) throws IOException {
try (Stream<Path> files = Files.list(chunkDir)) {
files.forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException ignored) {}
});
}
Files.deleteIfExists(chunkDir);
}
}
DTO
java
@Data
public class MergeRequest {
private String fileId;
private String fileName;
private int totalChunks;
}
@Data
public class Result {
private boolean success = true;
private String filePath;
public static Result success() {
return new Result();
}
public static Result success(String filePath) {
Result r = new Result();
r.filePath = filePath;
return r;
}
}
用到的关键点
前端
file.slice(start, end)--- 切分文件,不占额外内存FormData--- 传二进制分片,不需要 Base64 编码- 并发控制 --- 用 worker 模式限制并发数,不要一次性全发出去
后端
FileChannel.transferTo--- 零拷贝写分片文件和合并- 临时分片以
fileId/分片序号组织,天然有序 - 合并完清理临时目录
容错
实际生产还需要补充:
- 断点续传 --- 上传前先请求
/upload/check?fileId=xxx,后端返回已收到的分片列表,前端跳过这些分片 - MD5 校验 --- 前端计算文件 MD5,合并后后端校验是否一致
- 超时清理 --- 定时任务清理超过一定时间未合并的临时分片目录
- 分片大小自适应 --- 根据网络情况动态调整分片大小(但这个一般不需要,固定 5M 就挺好)