【Springboot文件上传】前后端双开,大文件秒传、断点续传的解决方案和优雅实现

发布时间 2023-10-25 13:37:55作者: Xproer-松鼠

思路和解决方案探讨
秒传
这里指的 “秒传”,是指:当用户选择上传一个文件时,服务端检测该文件之前是否已经被上传过,如果服务器已经存有该文件(完全一样),就立马返回前端 “文件已上传成功”。前端随即将进度条更新至100%。 这样给用户的感觉就是 “秒传” 的感觉。

对于每一个上传到服务器的资源,我们都需要往数据库的 db_file 表插入一条记录,每条记录都包含文件的MD5值、已上传的字节数等等,当然还包含其他文件信息字段。表的结构在后面会完全给出。

要实现秒传,我们需要考虑两个问题:

1、如何唯一标识一个文件?

计算整个文件的MD5值。这里,我找到了一个计算文件MD5值的在线工具:http://www.metools.info/other/o21.html。

2、如何计算整个文件的MD5值?

(1)首先计算整个文件的MD5值这个工作必须是由前端来完成,因为要由服务端计算文件MD5值,就必须先把整个文件上传到服务端。这不就违背了 “秒传” 的想法了吗?

(2)计算MD5值,我们需要借助一个js插件 spark-md5.js 。Github地址:https://github.com/satazor/js-spark-md5

上面的在线工具应该也是借助了该js插件。如何使用这个插件呢?Github的 README.md 里面提供了 demo,它的demo通过分块可计算大文件(数G)文件的MD5值。我试过,如果不分块,没办法计算大文件的MD5值。因此我们只需要把它的demo拷贝过来,改一下就行了。

断点续传(断点上传)
断点续传是什么样的效果呢? 用户正在上传某个大文件,中途点击了 “取消” 。下次再次上传该文件时,能够从上次中断的地方继续上传,而不会从头开始上传。这个有点复杂,实现逻辑涉及到了前后端。

1、一般的文件上传的实现流程

一般的文件上传,服务端用MultipartFile来接收,而前端用ajax异步上传文件。假如文件很大,比如达到了数G,首先服务端肯定要设置最大的上传大小。文件上传无疑是个费时操作,这表示前端的文件发送是个费时操作,与此同时服务端的MultipartFile接收也是个费时操作。服务端在接收过程中,会产生一个临时文件,默认会在web应用服务器的一个临时目录,当然也可以指定。当文件上传完成后或者接收过程中发生错误,临时文件会被自动删除。你甚至可以自己验证,来观察到这个现象,这里我就不多说了。

2、如何取消文件上传呢?

用 ajax 异步上传文件,要想达到终止上传的效果,只能调用 XMLHttpRequest 的的 abort() 方法。这个方法会直接中断客户端和服务端的连接,导致服务端的流读取异常(SpringMVC抛出)。异常抛出之后,controller层以及后续的逻辑都不会执行。接收到一半生成的临时文件也会被自动删除。这也就意味着:上传进度没办法保存到数据库!

3、取消文件后如何保存文件上传进度?

如果文件上传被终止,无论是通过XMLHttpRequest 的的 abort() 方法 还是 网页突然关闭 、断网等等,都是前端单方面断开连接,服务端会抛出异常,导致临时文件被删除,无法保存上传进度。为了解决这个问题,我们可以使用分块上传的解决方案。

在前端,通过js将整个大文件的未上传的部分划分为 等大小的 n 块,每块的大小定义为 chunkSize(例如:2 MB) 。如果最后一块不足 chunkSize,则最后两块合并为一块。每一块的上传都发起一次 ajax 请求,每一块成功上传之后,服务端会通过NIO将这一块追加到自己创建的文件末尾,同时更新数据库中该文件的 “已上传字节数”。

如果某次ajax请求被突然中断,也是仅仅导致这个分块的上传失败而已,不影响前面已经成功上传的分块。那么下次再次上传时,前端接收服务端返回的文件 “已上传的字节数”。然后前端js,将可以据此定位到文件未上传的部分,然后将未上传的部分重新分块上传。

后端接口说明
数据返回格式封装

 

 

接口介绍 请求方式 请求路径 请求参数说明 请求参数备注 成功时返回的data
检查服务器中是否已有文件资源 post /file/check
fileMd5:整个文件的MD5值

totalBytes:整个文件的总字节数

suffix:文件的后缀

3个请求参数都是必须的
FileCheckRspVo {

uploadToken:该接口签发的token(Jwt),上传时需要

uploadedBytes:该文件已上传的字节数

}

分块上传 post /file/upload
file:需要上传的分块

uploadToken:上传需要携带的token

2个参数都是必须的 已上传的字节数
为什么 /file/check 接口(下面简称 check接口)返回的data包含一个 uploadToken ?请求 /file/upload 接口(下面简称 upload接口)为什么要携带 /file/check 签发的 uploadToken?

upload 接口不能被随便请求,必须要在请求 check 接口之后!为了确保这个先后次序,所以请求 upload 接口必须携带 check 接口签发的令牌(jwt,可自行百度)。这个jwt令牌是没有办法伪造的,携带错误的或者过期的令牌访问upload接口,都会被检验出来!!!

关键代码
下面贴出关键代码和注释,只需要关注实现的具体逻辑的即可。

前端代码
upload.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<!-- 不设置的话,手机端不会进行响应式布局 -->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>大文件断点续传</title>

<!-- 引入Bootstrap核心样式文件(必须) -->
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css">

<!-- 你自己的样式或其他文件 -->
<link rel="stylesheet" href="/css/upload.css">

<!--站点图标-->
<!-- ... -->
</head>
<body>
<div class="container">
<div class="progress progress-top">
<div id="progressbar" class="progress-bar progress-bar-success" role="progressbar"
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="min-width: 2em;">
0%
</div>
</div>
<div class="form-group">
<div class="input-group">
<input id='location' class="form-control" onclick="$('#i-file').click();">
<div class="input-group-btn">
<input type="button" id="i-check" value="选择" class="btn btn-default" onclick="$('#i-file').click();">
<input type="button" value="上传" onClick="upload()" class="btn btn-default" >
<input type="button" value="取消" onClick="cancel()" class="btn btn-default" >
<input type="button" value="下载二维码" onClick="downloadQRCode()" class="btn btn-default" >
</div>
</div>
<input type="file" name="file" id='i-file' onchange="$('#location').val($('#i-file').val());" style="display: none">
<p class="help-block proccess-msg" id="proccess-msg"></p>
</div>
<img id="downloadQRcode" src="" />
</div>

<script src="/lib/jquery/jquery.min.js"></script>
<!-- 引入所有的Bootstrap的JS插件 -->
<script src="/lib/bootstrap/js/bootstrap.min.js"></script>
<script src="/lib/spark-md5.min.js"></script>
<script src="/js/upload.js"></script>
</body>
</html>

upload.js


// 真正上传文件的ajax请求
var uploadAjax = null;
var fileMd5; // 文件md5值

function downloadQRCode() {
if (fileMd5 != null) {
var url = '/file/qrcode/generate?fileMd5=' + fileMd5 + '&seconds=900';
$('#downloadQRcode').attr('src', url);
}
}

function upload() {
// 文件限制检查
var file = $('#i-file')[0].files[0];
if (file == null) {
return;
}

var suffix = file.name.substr(file.name.lastIndexOf('.'));
var type = file.type;
var totalBytes = file.size;
var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
console.log(suffix, type, totalBytes);

// 开始。通过回调函数,进行链式调用
calculteFileMd5();

// 计算文件的MD5值,分块计算,支持大文件
function calculteFileMd5() {
var chunkSize = 2097152, // Read in chunks of 2MB 。每一块的大小
chunks = Math.ceil(file.size / chunkSize), // 整个文件可分为多少块,向下取整
currentChunk = 0, // 当前加载的块。初始化为0
spark = new SparkMD5.ArrayBuffer(),
fileReader = new FileReader();

// fileReader加载文件数据到内存之后会执行此回调函数
fileReader.onload = function (e) {
refreshMsg('read chunk nr ' + (currentChunk + 1) + ' of ' + chunks);
spark.append(e.target.result); // Append array buffer
currentChunk++;

if (currentChunk < chunks) {
loadNext();
} else {
refreshMsg('finished loading');
// 计算出文件的md5值
fileMd5 = spark.end();
refreshMsg('computed hash: ' + fileMd5); // Compute hash

// 服务器检查文件是否存在
requestCheckFile();
}
};

// 开始计算
loadNext();

function loadNext() {
var start = currentChunk * chunkSize,
end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;

fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
}
}

// 请求服务器验证文件
function requestCheckFile() {
$.ajax({
url: '/file/check', // 提交到controller的url路径
type: "POST", // 提交方式
dataType: "json",
data: {
fileMd5: fileMd5,
totalBytes: totalBytes,
suffix: suffix
},
success: function (res) {
console.log(res);
if (res.code === 2000) {
var percentage = parseFloat(res.data.uploadedBytes) / totalBytes * 100;
refreshStatus(percentage);
if (res.data.uploadedBytes < totalBytes) {
requestRealUpload(res.data);
}
}
}
});
}

// 分块上传
function requestRealUpload(params) {
var chunkSize = 2097152; // 每一块的大小。2 M
//var chunks = Math.ceil((totalBytes - params.uploadedBytes) / chunkSize); // 尚未上传的部分可分为几块,取下整
//var currentChunk = 0; // 当前加载的块。初始化为0

uploadChunk(params.uploadedBytes);

// 请求服务端,上传一块
function uploadChunk(uploadedBytes) {
var formData = new FormData();
var start = uploadedBytes;
var end = Math.min(start + chunkSize, totalBytes);
console.log(start, end);
formData.append('file', blobSlice.call(file, start, end)); // [start, end)
formData.append('uploadToken', params.uploadToken); // 携带token
var preLoaded = 0; // 当前块的上一次加载的字节数,用于计算速度
var preTime = new Date().getTime(); // 上一次回调进度的时间
uploadAjax = $.ajax({
url: '/file/upload',
type: "POST",
data: formData,
cache: false,
contentType: false, // 必须 不设置内容类型
processData: false, // 必须 不处理数据
xhr: function() {
//获取原生的xhr对象
var xhr = $.ajaxSettings.xhr();
if (xhr.upload) {
//添加 progress 事件监听
//console.log(xhr.upload);
xhr.upload.onprogress = function(e) {
// e.loaded 应该是指当前块,已经加载到内存的字节数
// 这里的上传进度是整个文件的上传进度,并不是指当前这一块
var percentage = (start + e.loaded) / totalBytes * 100;
refreshStatus(percentage); // 更新百分比

// 计算速度
var now = new Date().getTime();
var duration = now - preTime; // 毫秒
var speed = ((e.loaded - preLoaded) / duration).toFixed(2); // KB/s
preLoaded = e.loaded;
preTime = now;
//if (duration > 1000) {
// 隔1秒才更新速度
refreshMsg('正在上传:' + speed + ' KB/s');
//}
};
xhr.upload.onabort = function() {
refreshMsg('已取消上传,服务端已保存上传完成的分块,下次重传可续传');
};
}
return xhr;
},
success: function(res) {
//成功回调
console.log(res);
if (res.code === 2000) {
if (res.data < totalBytes) {
uploadChunk(res.data); // 上传下一块
} else {
refreshMsg('上传完成!'); //所有块上传完成
}
} else {
refreshMsg(res.msg); // 当前块上传失败,提示错误,后续块停止上传
}
}
});
}
}

// 刷新进度条
function refreshStatus(percentage) {
var per = (percentage).toFixed(2);
console.log(per);
$('#progressbar').text(per + '%');
$('#progressbar').css({
width: per + '%'
});
}
// 更新提示信息
function refreshMsg(msg) {
$('#proccess-msg').text(msg);
}
}

// 直接终端上传的ajax请求,后端会抛出异常
function cancel() {
if (uploadAjax != null) {
console.log(uploadAjax);
uploadAjax.abort();
}
}

服务端代码
FileController.java

package net.ysq.easymall.controller;

import java.io.IOException;

import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;

import com.auth0.jwt.interfaces.DecodedJWT;

import net.ysq.easymall.common.JwtUtils;
import net.ysq.easymall.common.ResultModel;
import net.ysq.easymall.common.StatusCode;
import net.ysq.easymall.po.DbFile;
import net.ysq.easymall.po.User;
import net.ysq.easymall.service.FileService;
import net.ysq.easymall.vo.FileCheckRspVo;

/**
* @author passerbyYSQ
* @date 2020-11-13 17:42:40
*/
@Controller
@RequestMapping("/file")
public class FileController {

@Autowired
private FileService fileService;

@GetMapping("/upload")
public String uploadPage() {
return "upload";
}

@PostMapping("/check")
@ResponseBody
public ResultModel<FileCheckRspVo> checkFileExist(String fileMd5, long totalBytes,
String suffix, HttpSession session) {
// 简单的参数检查,之后再全局处理优化
if (StringUtils.isEmpty(fileMd5) || totalBytes <= 0
|| StringUtils.isEmpty(suffix)) {
return ResultModel.error(StatusCode.PARAM_IS_INVALID);
}

/*
// 检查大小
DataSize size = DataSize.of(totalBytes, DataUnit.BYTES);
// 限制100 M
DataSize limit = DataSize.of(100, DataUnit.MEGABYTES);
if (size.compareTo(limit) > 0) {
String msg = String.format("当前文件大小为 %d MB,最大允许大小为 %d MB",
size.toMegabytes(), limit.toMegabytes());
return ResultModel.error(StatusCode.FILE_SIZE_EXCEEDED.getCode(), msg);
}
*/
User user = (User) session.getAttribute("user");

// 根据md5去数据库查询是否已存在文件
DbFile dbFile = fileService.checkFileExist(fileMd5);
// 如果不存在,则创建文件,并插入记录。如果已存在,返回结果
if (ObjectUtils.isEmpty(dbFile)) {
dbFile = fileService.createFile(fileMd5, totalBytes, suffix, user);
}

FileCheckRspVo fileCheckRspVo = new FileCheckRspVo();
fileCheckRspVo.setUploadedBytes(dbFile.getUploadedBytes());
if (dbFile.getUploadedBytes() < dbFile.getTotalBytes()) { // 未上传完,返回token
String uploadToken = fileService.generateUploadToken(user.getEmail(), dbFile);
fileCheckRspVo.setUploadToken(uploadToken);
}

return ResultModel.success(fileCheckRspVo);
}

@PostMapping("/upload")
@ResponseBody
public ResultModel<Long> uploadFile(MultipartFile file, String uploadToken) {

// 解析过程可能会抛出异常,全局进行捕获
DecodedJWT decodedJWT = JwtUtils.verifyJwt(uploadToken);
String fileMd5 = decodedJWT.getClaim("fileMd5").asString();
// 如果token验证通过(没有异常抛出),则肯定能找得到
DbFile dbFile = fileService.checkFileExist(fileMd5);

// 上传文件
long uploadedBytes = fileService.transfer(file, dbFile);
System.out.println("已上传:" + uploadedBytes);
//System.out.println("总大小:" + dbFile.getTotalBytes());

return ResultModel.success(uploadedBytes);
}

@GetMapping("/qrcode/generate")
public void downloadByQrcode(String fileMd5, long seconds,
HttpServletResponse response) throws IOException, Exception {
if (ObjectUtils.isEmpty(fileMd5)) {
throw new Exception("fileMd5为空");
}
if (ObjectUtils.isEmpty(seconds) || seconds <= 0) {
seconds = 60 * 15; // 15分钟
}
DbFile dbFile = fileService.checkFileExist(fileMd5);
if (ObjectUtils.isEmpty(dbFile)) {
throw new Exception("fileMd5错误");
}

fileService.generateDownloadQRCode(seconds, dbFile, response.getOutputStream());
}

@GetMapping("/qrcode/download")
public void downloadByQrcode(String downloadToken, HttpSession session,
HttpServletResponse response) {
System.out.println("download!!");

DecodedJWT decodedJWT = JwtUtils.verifyJwt(downloadToken);
String fileMd5 = decodedJWT.getClaim("fileMd5").asString();
DbFile dbFile = fileService.checkFileExist(fileMd5);

// 设置响应头
response.setHeader("Content-Type", "application/x-msdownload");
response.setHeader("Content-Disposition", "attachment; filename=" + dbFile.getRandName());

fileService.download(dbFile, response);
}
}

FileService.java

package net.ysq.easymall.service;

import java.io.OutputStream;

import javax.servlet.http.HttpServletResponse;

import org.springframework.web.multipart.MultipartFile;

import net.ysq.easymall.po.DbFile;
import net.ysq.easymall.po.User;

/**
* @author passerbyYSQ
* @date 2020-11-13 17:54:06
*/
public interface FileService {
// 下载
void download(DbFile dbFile, HttpServletResponse response);

// 生成下载的token
void generateDownloadQRCode(long seconds, DbFile dbFile, OutputStream outStream) throws Exception;

// 根据id查找
DbFile findById(Integer fileId);

// 根据fileMd5检查文件是否已存在
DbFile checkFileExist(String fileMd5);

// 在磁盘上创建文件,并将记录插入数据库
DbFile createFile(String fileMd5, long totalBytes, String suffix, User user);

// 生成上传文件的token
String generateUploadToken(String email, DbFile dbFile);

// 复制到目标目录
long transfer(MultipartFile file, DbFile dbFile);
}

FileServiceImpl.java

package net.ysq.easymall.service.impl;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.ResourceUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import net.ysq.easymall.common.CloseUtils;
import net.ysq.easymall.common.JwtUtils;
import net.ysq.easymall.common.QRCodeUtils;
import net.ysq.easymall.dao.DbFileMapper;
import net.ysq.easymall.po.DbFile;
import net.ysq.easymall.po.User;
import net.ysq.easymall.service.FileService;

/**
* @author passerbyYSQ
* @date 2020-11-13 17:55:09
*/
@Service
public class FileServiceImpl implements FileService {

@Autowired
private DbFileMapper dbFileMapper;

@Override
public DbFile findById(Integer fileId) {
DbFile record = new DbFile();
record.setId(fileId);
return dbFileMapper.selectOne(record);
}

@Override
public DbFile checkFileExist(String fileMd5) {
DbFile record = new DbFile();
// 设置查询条件
record.setFileMd5(fileMd5);
// 找不到返回null
DbFile dbFile = dbFileMapper.selectOne(record);
//System.out.println(dbFile);
return dbFile;
}

@Override
public DbFile createFile(String fileMd5, long totalBytes, String suffix, User user) {
try {
// 创建目标目录
File classpath = ResourceUtils.getFile("classpath:");
File destDir = new File(classpath, "upload/" + user.getEmail());
if (!destDir.exists()) {
destDir.mkdirs(); // 递归创建创建多级
System.out.println("创建目录成功:" + destDir.getAbsolutePath());
}
// 利用UUID生成随机文件名
String randName = UUID.randomUUID().toString().replace("-", "") + suffix;
File destFile = new File(destDir, randName);
// 创建目标
destFile.createNewFile();

String path = user.getEmail() + "/" + randName;
DbFile dbFile = new DbFile();
dbFile.setFileMd5(fileMd5);
dbFile.setRandName(randName);
dbFile.setPath(path);
dbFile.setTotalBytes(totalBytes);
dbFile.setUploadedBytes(0L);
dbFile.setCreatorId(user.getId());
int count = dbFileMapper.insertSelective(dbFile);

return count == 1 ? dbFile : null;
} catch (IOException e) {
e.printStackTrace();
}
return null;
}

@Override
public String generateUploadToken(String email, DbFile dbFile) {
Map<String, String> claims = new HashMap<>();
claims.put("fileMd5", dbFile.getFileMd5());
// 5分钟后过期
String jwt = JwtUtils.generateJwt(claims, 60 * 1000 * 5);
return jwt;
}

@Override
public void generateDownloadQRCode(long seconds, DbFile dbFile, OutputStream outStream) throws Exception {
Map<String, String> claims = new HashMap<>();
claims.put("fileMd5", dbFile.getFileMd5());
long millis = Duration.ofSeconds(seconds).toMillis();
String downloadToken = JwtUtils.generateJwt(claims, millis);
String downloadUrl = ServletUriComponentsBuilder
.fromCurrentContextPath()
.path("/file/qrcode/download")
.queryParam("downloadToken", downloadToken)
.toUriString();

QRCodeUtils.encode(downloadUrl, outStream);
//QRCodeUtil.generateWithStr(downloadUrl, outStream);
}

@Override
public long transfer(MultipartFile file, DbFile dbFile) {

InputStream inStream = null;
ReadableByteChannel inChannel = null;
FileOutputStream outStream = null;
FileChannel outChannel = null;
try {
inStream = file.getInputStream();
inChannel = Channels.newChannel(inStream);

File classpath = ResourceUtils.getFile("classpath:");
File destFile = new File(classpath, "upload/" + dbFile.getPath());
outStream = new FileOutputStream(destFile, true); // 注意,第二个参数为true,否则无法追加
outChannel = outStream.getChannel();

long count = outChannel.transferFrom(inChannel, outChannel.size(), file.getSize());
//long count = inChannel.transferTo(dbFile.getUploadedBytes(), inChannel.size(), outChannel);

DbFile record = new DbFile();
record.setId(dbFile.getId());
record.setUploadedBytes(dbFile.getUploadedBytes() + count);
// 更新已上传的字节数到数据库
dbFileMapper.updateByPrimaryKeySelective(record);

return record.getUploadedBytes();
} catch (IOException e) {
e.printStackTrace();
} finally {
CloseUtils.close(inChannel, inStream, outChannel, outStream);
}
return dbFile.getUploadedBytes();
}

@Override
public void download(DbFile dbFile, HttpServletResponse response) {
FileInputStream inStream = null;
FileChannel inChannel = null;
OutputStream outStream = null;
WritableByteChannel outChannel = null;
try {
File classpath = ResourceUtils.getFile("classpath:");
File destFile = new File(classpath, "upload/" + dbFile.getPath());
inStream = new FileInputStream(destFile);
inChannel = inStream.getChannel();
outStream = response.getOutputStream();
outChannel = Channels.newChannel(outStream);
inChannel.transferTo(0, inChannel.size(), outChannel);
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
CloseUtils.close(outChannel, outStream, inChannel, inStream);
}
}

}

 

参考文章:http://blog.ncmem.com/wordpress/2023/10/25/%e3%80%90springboot%e6%96%87%e4%bb%b6%e4%b8%8a%e4%bc%a0%e3%80%91%e5%89%8d%e5%90%8e%e7%ab%af%e5%8f%8c%e5%bc%80%ef%bc%8c%e5%a4%a7%e6%96%87%e4%bb%b6%e7%a7%92%e4%bc%a0%e3%80%81%e6%96%ad%e7%82%b9%e7%bb%ad/

欢迎入群一起讨论