实现大文件上传技术(spring boot + vue-simple-uploader )
文件上传功能在开发中是经常遇到的一个需求,普通的文件上传方案,如果文件太大,甚至在几百兆时,就会出现上传速度很慢,请求超时等问题。如果遇到网络问题中途断开后,用户需要重新上传文件,体验十分不好。因此面对这种情况,采用大文件分片上传技术,利用断传,秒传功能,解决上述难题。
一、前端实现
前端要引入vue-simple-uploader和MD5
//安装
npm install vue-simple-uploader --save
npm install spark-md5 --save
//main.js 全局引用
import uploader from 'vue-simple-uploader'
Vue.use(uploader)
vue-simple-uploader有template插槽,支持自定义样式;
<div style="margin-left: 30px;">
<uploader
ref="uploader"
:options="initOptions"
:autoStart="false"
@file-added="onFileAdded"
@file-success="onFileSuccess"
@file-error="onFileError"
@file-progress="onFileProgress"
>
<uploader-btn type="primary" ref="uploadBtn">选择文件<i class="el-icon-upload el-icon-right"></i></uploader-btn>
<el-progress style="width: 150px;margin-top: 8px" :percentage="percentage" v-if="(progressShow&&statuShow)"/>
<el-progress status="exception" style="width: 150px;margin-top: 8px" :percentage="percentage" v-if="(progressShow&&(!statuShow))"/>
</uploader>
</div>
initOptions相关配置
percentage: 0,
progressShow: false,
statuShow: true,
initOptions: {
target: '/upload', // 上传的服务器地址
chunkSize: 1024 * 1024 * 5, // 文件分块的大小
fileParameterName: 'file', // 后端参数的名字
maxChunkRetries: 3, //最大重试次数
testChunks: true, // 是否开启服务器分片校验,如果要实现断传和秒传功能,必须为true
headers: {
'token': getToken() // 自定义请求头部信息
},
successStatus: [201,202], //成功状态,由于后端一般统一返回状态码,所以该配置可以忽略
// 服务器分片校验函数,判断是否需要跳过该分片
checkChunkUploadedByResponse: function (chunk, message) {
let skip = false
try {
let objMessage = JSON.parse(message)
if(objMessage.code != 200) {
this.statuShow = false
// 暂停上传
return true
}
if (objMessage.data.skipUpload) {
skip = true
} else {
skip = (objMessage.data.uploaded || []).indexOf(chunk.offset + 1) >= 0
}
} catch (e) {}
return skip
},
}
点击上传动作后,首先执行onFileAdded
函数,一般可以在这步校验文件等操作;这里我们先初始化进度条的状态和计算文件的md5
,md5
的作用是保证文件的唯一性和不被更改,假如文件的内容改变了,它的值也随着改变。
onFileAdded(file) {
// 初始化进度条
this.progressShow = true
this.percentage = 0
this.statuShow = true
// 计算MD5
computeMD5(file).then((result) => this.startUpload(result))
}
md5计算完毕后,开始发送请求到服务器
startUpload({md5, file}) {
file.uniqueIdentifier = md5
file.resume()
},
注意:要支持文件断传和秒传功能,前端必须设置testChunk
为true。simple-uploader
会先发一次检测请求,用于检测文件块有哪些已经成功上传,或者文件是否已经上传成功,达到断传和秒传的功能。
这时checkChunkUploadedByResponse
被调用,如果返回的结果code不为200发生异常,或者objMessage.data.skipUpload
为true,文件已经上传过了并成功,则直接跳过。否则,根据返回的uploaded集合判断该chunk位移是否在集合中,在则跳过,不在后续发送该chunk上传请求。
该代码在initOption中
checkChunkUploadedByResponse: function (chunk, message) {
let skip = false
try {
let objMessage = JSON.parse(message)
if(objMessage.code != 200) {
this.statuShow = false
// 暂停上传
return true
}
if (objMessage.data.skipUpload) {
skip = true
} else {
skip = (objMessage.data.uploaded || []).indexOf(chunk.offset + 1) >= 0
}
} catch (e) {}
return skip
},
文件上传成功执行:
onFileSuccess(rootFile, file, response, chunk) {
let res = JSON.parse(response)
// 服务端自定义的错误(即http状态码为200,但是是错误的情况),这种错误是Uploader无法拦截的
if (!res || res.code != 200 || (res.data.status != 'success')) {
this.error()
return
}
this.percentage = 100
},
文件上传错误执行:
onFileError(rootFile, file, response, chunk) {
this.error()
},
error() {
this.percentage = 100
this.statuShow = false
}
分块上传请求监控:
onFileProgress(rootFile, file, chunk) {
var res = chunk.xhr.response
if(res) {
var jsonRes = JSON.parse(chunk.xhr.response)
if(jsonRes.code == 200) {
this.percentage = jsonRes.data.percentage
}
}
}
md5工具封装
import SparkMD5 from 'spark-md5'
/**
* 计算md5值,以实现断点续传及秒传
* @param file
* @returns Promise
*/
export function computeMD5(file) {
let fileReader = new FileReader()
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
let currentChunk = 0
const chunkSize = 1024 * 1024
let chunks = Math.ceil(file.size / chunkSize)
let spark = new SparkMD5.ArrayBuffer()
// 文件状态设为"计算MD5"
// this.statusSet(file.id, 'md5')
file.pause()
const loadNext = () => {
let start = currentChunk * chunkSize
let end = start + chunkSize >= file.size ? file.size : start + chunkSize
fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end))
}
loadNext()
return new Promise((resolve, reject) => {
fileReader.onload = (e) => {
spark.append(e.target.result)
if (currentChunk < chunks) {
currentChunk++
loadNext()
} else {
let md5 = spark.end()
// md5计算完毕
resolve({md5, file})
console.log("计算md5完毕:" + md5)
}
}
fileReader.onerror = function () {
file.cancel()
reject()
}
})
}
后端实现
完整后端代码
无论是第一次检测还是后续分块上传,请求都到同一个接口,代码如下
// Post请求会导致第一次侦探请求失败!!!
@RequestMapping("/upload")
public Result uploadFile(Chunk chunk) {
String path = parent + File.separator + chunk.getIdentifier();
FileChunkResult chunkResult = FileUtil.chunkUpload(chunk, path);
if (FileUploadConstant.FAILURE.equals(chunkResult.getStatus())) {
return Result.error(ResultEnum.FILE_UPLOAD_ERROR);
}
return Result.success(chunkResult);
}
首先要区分处理检测请求和分块上传请求,第一个检测请求的file为空且chunkNumber
为1.
在前端配置正确的情况下,总是会在检测请求响应完毕后才会发送后续的上传分块。因此可以在检测请求收到后执行对应的初始化工作,如创建文件和检查文件是否已经上传过等,并将结果直接返回。
分块上传的请求,是已经根据检测请求校验过该分块尚未上传或者上传失败的,因此可以直接执行写入文件动作,并实时更新此时的文件进度。这里采用的是随机访问文件,支持多线程同时写入。还有一种方案是每个分块生成一个临时文件,都上传成功后再在前端的FileSuccess
处理里发送合并请求。
那我们是如何知道上传的进度?这里采用的是多写一个配置文件,文件内容长度为文件分块的总数量,如果分块上传成功,则同时往配置文件写入Byte.MAX_VALUE
。
public static FileChunkResult chunkUpload(Chunk chunk, String path) {
FileChunkResult fileChunkResult = new FileChunkResult();
fileChunkResult.setStatus(FileUploadConstant.LOADING);
try {
if (chunk.getChunkNumber() == 1 && Objects.isNull(chunk.getFile())) {
initUpload(chunk, path, fileChunkResult);
return fileChunkResult;
}
// 新的文件块,执行写入操作
chunkWrite(path, chunk, fileChunkResult);
checkoutProgress(path, chunk, fileChunkResult);
} catch (IOException e) {
logger.error("上传文件块:{}-{}失败,原因:{}", chunk.getFilename(), chunk.getChunkNumber(), e.getMessage());
fileChunkResult.setStatus(FileUploadConstant.FAILURE);
} finally {
return fileChunkResult;
}
}
初始化代码,如果文件没有创建则新建文件,confFile
是用于记录文件块上传状态:
private static FileChunkResult initUpload(Chunk chunk, String path, FileChunkResult fileChunkResult) throws IOException {
File tmpDir = new File(path);
// 该文件用每个字节记录对应位置上传的状态,127代表上传成功
File confFile = new File(path, chunk.getFilename() + ".conf");
File tmpFile = new File(path, chunk.getFilename());
// 目录不存在 新建目录
if (!tmpDir.exists()) {
tmpDir.mkdirs();
}
// 文件不存在 创建文件
if (!confFile.exists()) {
confFile.createNewFile();
tmpFile.createNewFile();
}
// 第一次探测,初始化
fileChunkResult.setStatus(FileUploadConstant.INIT);
checkoutProgress(path, chunk, fileChunkResult);
return fileChunkResult;
}
写入文件代码,RandomAccessFile
可以多个文件块同时写入:
private static void chunkWrite(String tmpDir, Chunk chunk, FileChunkResult fileChunkResult) throws IOException {
File tmpFile = new File(tmpDir, chunk.getFilename());
File tmpConf = new File(tmpDir, chunk.getFilename() + ".conf");
try (
//将块文件写入文件中
InputStream fos = chunk.getFile().getInputStream();
RandomAccessFile raf = new RandomAccessFile(tmpFile, "rw");
RandomAccessFile ramConf = new RandomAccessFile(tmpConf, "rw")
) {
// 写入临时文件
int len = -1;
byte[] buffer = new byte[1024];
raf.seek((chunk.getChunkNumber() - 1) * 1024 * 1024 * 5);
while ((len = fos.read(buffer)) != -1) {
raf.write(buffer, 0, len);
}
// 文件块写入成功,记录缓存文件
ramConf.setLength(chunk.getTotalChunks());
ramConf.seek(chunk.getChunkNumber() - 1);
ramConf.write(Byte.MAX_VALUE);
} catch (IOException e) {
throw new IOException(e);
}
}
检查进度条:
private static void checkoutProgress(String path, Chunk chunk, FileChunkResult fileChunkResult) throws IOException {
File confFile = new File(path, chunk.getFilename() + ".conf");
List<Integer> uploaded = new ArrayList();
byte[] completeList = FileUtils.readFileToByteArray(confFile);
for (int i = 0; i < completeList.length; i++) {
if (Byte.MAX_VALUE == completeList[i]) {
uploaded.add(i + 1);
}
}
fileChunkResult.setUploaded(uploaded);
fileChunkResult.setPercentage(fileChunkResult.getUploaded().size() * 100 / chunk.getTotalChunks());
if (uploaded.size() == chunk.getTotalChunks()) {
fileChunkResult.setNeedMerge(true);
fileChunkResult.setStatus(FileUploadConstant.SUCCESS);
fileChunkResult.setPath(path + File.separator + chunk.getFilename());
}
}
分块请求实体:
@Data
public class Chunk implements Serializable {
/**
* 当前文件块,从1开始
*/
private Integer chunkNumber;
/**
* 分块大小
*/
private Long chunkSize;
/**
* 当前分块大小
*/
private Long currentChunkSize;
/**
* 总大小
*/
private Long totalSize;
/**
* 文件标识
*/
private String identifier;
/**
* 文件名
*/
private String filename;
/**
* 相对路径
*/
private String relativePath;
/**
* 总块数
*/
private Integer totalChunks;
/**
* 二进制文件
*/
private MultipartFile file;
}
结果封装
@Data
public class FileChunkResult implements Serializable {
/**
* 分片是否需要跳过
*/
private Boolean skipUpload = false;
/**
* 是否需要合并
*/
private Boolean needMerge;
/**
* 已经上传的分片集合
*/
private List<Integer> uploaded;
/**
* 文件上传成功后路径
*/
private String path;
/**
* 目前文件上传状态
*/
private String status;
/**
* 已上传的百分比
*/
private int percentage;
}
文件上传状态常量
public class FileUploadConstant {
public static final String INIT = "init";
public static final String LOADING = "loading";
public static final String SUCCESS = "success";
public static final String FAILURE = "fail";
}