文件上传功能是我们在做开发时经常会遇到的。Spring boot默认上传文件非常小,好像最大可以修改配置文件支持50M。如果太大的文件直接上传,占用内存太严重,很容易造成后台崩溃。这里我在使用前端使用vue-simple-uploader,后台使用Spring boot实现文件分块(分片上传)。同时了为了保障文件的完整性,加入了md5校验。
vue-simple-uploader
基于Vue的前端上传插件,支持分块和断点上传、自动重传,自带进度条,界面十分友好。在没做分块上传之前,我一直觉得这块很难做,用了这个插件发现,前端几乎不用写代码,后端对接好这个插件的参数就可以了。
前端搭建
- 引入依赖
npm install vue-simple-uploader --save
npm install spark-md5 --save
- 在main.js中全局引用
import uploader from 'vue-simple-uploader'
Vue.use(uploader)
- 页面代码
测试时可以只替换里面的url
<template>
<div class="hello">
<uploader :key="uploader_key" :options="options"
:autoStart="false"
class="uploader-example"
@file-success="onFileSuccess" @file-added="filesAdded">
<uploader-unsupport></uploader-unsupport>
<uploader-drop>
<uploader-btn :single="true" >选择文件</uploader-btn>
</uploader-drop>
<uploader-list></uploader-list>
</uploader>
</div>
</template>
<script>
import SparkMD5 from 'spark-md5'
export default {
name: 'HelloWorld',
data(){
return{
uploader_key: new Date().getTime(),//这个用来刷新组件--解决不刷新页面连续上传的缓存上传数据(注:每次上传时,强制这个值进行更改---根据自己的实际情况重新赋值)
options: {
target: 'http://localhost:18002/ossserver/api/v1/material/chunkUpload',//SpringBoot后台接收文件夹数据的接口
testChunks: false,//是否测试分片
}
}
},
props: {
msg: String
},
methods:{
onFileSuccess: function (rootFile, file, response, chunk) {
console.log(rootFile)
console.log(file)
console.log(response)
console.log(chunk)
},
/**
* 计算md5,实现断点续传及秒传
* @param file
*/
computeMD5(file) {
//大文件的md5计算时间比较长,显示个进度条
const loading = this.$loading({
lock: true,
text: '正在计算MD5',
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.7)'
});
let fileReader = new FileReader();
let time = new Date().getTime();
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
let currentChunk = 0;
const chunkSize = 10 * 1024 * 1000;
let chunks = Math.ceil(file.size / chunkSize);
let spark = new SparkMD5.ArrayBuffer();
file.pause();
loadNext();
fileReader.onload = (e => {
spark.append(e.target.result);
if (currentChunk < chunks) {
currentChunk++;
loadNext();
// 实时展示MD5的计算进度
this.$nextTick(() => {
console.log('校验MD5 '+ ((currentChunk/chunks)*100).toFixed(0)+'%')
})
} else {
let md5 = spark.end();
loading.close();
this.computeMD5Success(md5, file);
console.log(`MD5计算完毕:${file.name} \nMD5:${md5} \n分片:${chunks} 大小:${file.size} 用时:${new Date().getTime() - time} ms`);
}
});
fileReader.onerror = function () {
this.error(`文件${file.name}读取出错,请检查该文件`);
loading.close();
file.cancel();
};
function loadNext() {
let start = currentChunk * chunkSize;
let end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
fileReader.readAsArrayBuffer(blobSlice.call(file.file, start, end));
}
},
computeMD5Success(md5, file) {
file.uniqueIdentifier = md5;//把md5值作为文件的识别码
file.resume();//开始上传
},
/**
* 添加文件后触发
* @param file
* @param event
*/
filesAdded(file, event){
this.computeMD5(file)
}
}
}
</script>
<style>
.uploader-example {
width: 90%;
padding: 15px;
margin: 40px auto 0;
font-size: 12px;
box-shadow: 0 0 10px rgba(0, 0, 0, .4);
}
.uploader-example .uploader-btn {
margin-right: 4px;
}
.uploader-example .uploader-list {
max-height: 440px;
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
}
</style>
后端代码
- 首先是Param 实体类,专门对接了vue-simple-uploader的参数
package com.grandtech.oss.domain;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import org.springframework.web.multipart.MultipartFile;
@ApiModel("大文件分片入参实体")
public class MultipartFileParam {
@ApiModelProperty("文件传输任务ID")
private String taskId;
@ApiModelProperty("当前为第几分片")
private int chunkNumber;
@ApiModelProperty("每个分块的大小")
private long chunkSize;
@ApiModelProperty("分片总数")
private int totalChunks;
@ApiModelProperty("文件唯一标识")
private String identifier;
@ApiModelProperty("分块文件传输对象")
private MultipartFile file;
public String getTaskId() {
return taskId;
}
public void setTaskId(String taskId) {
this.taskId = taskId;
}
public int getChunkNumber() {
return chunkNumber;
}
public void setChunkNumber(int chunkNumber) {
this.chunkNumber = chunkNumber;
}
public long getChunkSize() {
return chunkSize;
}
public void setChunkSize(long chunkSize) {
this.chunkSize = chunkSize;
}
public int getTotalChunks() {
return totalChunks;
}
public void setTotalChunks(int totalChunks) {
this.totalChunks = totalChunks;
}
public MultipartFile getFile() {
return file;
}
public void setFile(MultipartFile file) {
this.file = file;
}
public String getIdentifier() {
return identifier;
}
public void setIdentifier(String identifier) {
this.identifier = identifier;
}
}
- Controller 接口
注意返回的状态码,充分利用前端插件的自动重传
@ApiOperation("大文件分片上传")
@PostMapping("/chunkUpload")
public void fileChunkUpload(MultipartFileParam param, HttpServletRequest request,HttpServletResponse response){
//自己的业务获取存储路径,可以换成自己的
OSSInformation ossInformation = ossInformationService.queryOne();
String root = ossInformation.getRoot();
//验证文件夹规则,不能包含特殊字符
File file = new File(root);
createDirectoryQuietly(file);
String path=file.getAbsolutePath();
response.setContentType("text/html;charset=UTF-8");
// response.setStatus对接前端插件
// 200, 201, 202: 当前块上传成功,不需要重传。
// 404, 415. 500, 501: 当前块上传失败,会取消整个文件上传。
// 其他状态码: 出错了,但是会自动重试上传。
try {
/**
* 判断前端Form表单格式是否支持文件上传
*/
boolean isMultipart = ServletFileUpload.isMultipartContent(request);
if(!isMultipart){
//这里是我向前端发送数据的代码,可理解为 return 数据; 具体的就不贴了
System.out.println("不支持的表单格式");
response.setStatus(404);
response.getOutputStream().write("不支持的表单格式".getBytes());
}else {
param.setTaskId(param.getIdentifier());
materialService.chunkUploadByMappedByteBuffer(param,path);//service层
response.setStatus(200);
response.getWriter().print("上传成功");
}
response.getWriter().flush();
}
catch (NotSameFileExpection e){
response.setStatus(501);
}
catch (Exception e) {
e.printStackTrace();
System.out.println("上传文件失败");
response.setStatus(415);
}
}
- Service 实现层
中间的代码最初从网上借鉴的,最后发现很多逻辑都是错误的,而且异常太多。最后自己修改了逻辑了,替换了很多关键代码。
@Override
public String chunkUploadByMappedByteBuffer(MultipartFileParam param, String filePath) throws IOException, NoSuchMethodException, InvocationTargetException, IllegalAccessException,NotSameFileExpection {
if(param.getTaskId() == null || "".equals(param.getTaskId())){
param.setTaskId(UUID.randomUUID().toString());
}
/**
*
* 1:创建临时文件,和源文件一个路径
* 2:如果文件路径不存在重新创建
*/
String fileName = param.getFile().getOriginalFilename();
String tempFileName = param.getTaskId() + fileName.substring(fileName.lastIndexOf(".")) + "_tmp";
File fileDir = new File(filePath);
if(!fileDir.exists()){
fileDir.mkdirs();
}
File tempFile = new File(filePath,tempFileName);
//第一步
RandomAccessFile raf = new RandomAccessFile(tempFile,"rw");
//第二步
FileChannel fileChannel = raf.getChannel();
//第三步 计算偏移量
long position = (param.getChunkNumber()-1) * param.getChunkSize();
//第四步
byte[] fileData = param.getFile().getBytes();
//第五步
long end=position+fileData.length-1;
fileChannel.position(position);
fileChannel.write(ByteBuffer.wrap(fileData));
//使用 fileChannel.map的方式速度更快,但是容易产生IO操作,无建议使用
// MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE,position,fileData.length);
// //第六步
// mappedByteBuffer.put(fileData);
//第七步
// freedMappedByteBuffer(mappedByteBuffer);
// Method method = FileChannelImpl.class.getDeclaredMethod("unmap", MappedByteBuffer.class);
// method.setAccessible(true);
// method.invoke(FileChannelImpl.class, mappedByteBuffer);
fileChannel.force(true);
fileChannel.close();
raf.close();
//第八步
boolean isComplete = checkUploadStatus(param,fileName,filePath);
if(isComplete){
//重命名文件,然后校验MD5文件是否一致
String md5= DigestUtils.md5Hex(new FileInputStream(tempFile.getPath()));
renameFile(tempFile,fileName);
if(StringUtils.isNotBlank(md5) && !md5.equals(param.getIdentifier())){
//不是同一文件抛出异常
throw new NotSameFileExpection();
}
}
return param.getTaskId();
}
/**
* 文件重命名
* @param toBeRenamed 将要修改名字的文件
* @param toFileNewName 新的名字
* @return
*/
public void renameFile(File toBeRenamed, String toFileNewName) {
//检查要重命名的文件是否存在,是否是文件
if (!toBeRenamed.exists() || toBeRenamed.isDirectory()) {
System.out.println("文件不存在");
return;
}
String p = toBeRenamed.getParent();
File newFile = new File(p + File.separatorChar + toFileNewName);
//修改文件名
toBeRenamed.renameTo(newFile);
}
/**
* 检查文件上传进度
* @return
*/
public boolean checkUploadStatus(MultipartFileParam param,String fileName,String filePath) throws IOException {
File confFile = new File(filePath,fileName+".conf");
RandomAccessFile confAccessFile = new RandomAccessFile(confFile,"rw");
//设置文件长度
confAccessFile.setLength(param.getTotalChunks());
//设置起始偏移量
confAccessFile.seek(param.getChunkNumber()-1);
//将指定的一个字节写入文件中 127,
confAccessFile.write(Byte.MAX_VALUE);
byte[] completeStatusList = FileUtils.readFileToByteArray(confFile);
confAccessFile.close();//不关闭会造成无法占用
//这一段逻辑有点复杂,看的时候思考了好久,创建conf文件文件长度为总分片数,每上传一个分块即向conf文件中写入一个127,那么没上传的位置就是默认的0,已上传的就是Byte.MAX_VALUE 127
for(int i = 0; i<completeStatusList.length; i++){
if(completeStatusList[i]!=Byte.MAX_VALUE){
return false;
}
}
//如果全部文件上传完成,删除conf文件
confFile.delete();
return true;
}
/**
* 在MappedByteBuffer释放后再对它进行读操作的话就会引发jvm crash,在并发情况下很容易发生
* 正在释放时另一个线程正开始读取,于是crash就发生了。所以为了系统稳定性释放前一般需要检 查是否还有线程在读或写
* @param mappedByteBuffer
*/
public static void freedMappedByteBuffer(final MappedByteBuffer mappedByteBuffer) {
try {
if (mappedByteBuffer == null) {
return;
}
mappedByteBuffer.force();
AccessController.doPrivileged(new PrivilegedAction<Object>() {
@Override
public Object run() {
try {
Method getCleanerMethod = mappedByteBuffer.getClass().getMethod("cleaner", new Class[0]);
//可以访问private的权限
getCleanerMethod.setAccessible(true);
//在具有指定参数的 方法对象上调用此 方法对象表示的底层方法
sun.misc.Cleaner cleaner = (sun.misc.Cleaner) getCleanerMethod.invoke(mappedByteBuffer,
new Object[0]);
cleaner.clean();
} catch (Exception e) {
e.printStackTrace();
System.out.println("清理缓存出错!!!"+e.getMessage());
}
System.out.println("缓存清理完毕!!!");
return null;
}
});
} catch (Exception e) {
e.printStackTrace();
}
}