vue-simple-uploader结合Spring boot实现文件分块上传
vue-simple-uploader中文APi地址
前端搭建
引入依赖
npm install vue-simple-uploader --save
在main.js中全局引用
import uploader from 'vue-simple-uploader'
Vue.use(uploader)
页面代码
测试时可以只替换里面的url
<template>
<div class="hello">
<uploader :key="uploader_key" :options="options" class="uploader-example"
@file-success="onFileSuccess">
<uploader-unsupport></uploader-unsupport>
<uploader-drop>
<uploader-btn :single="true">选择文件</uploader-btn>
</uploader-drop>
<uploader-list></uploader-list>
</uploader>
</div>
</template>
<script>
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)
}
}
}
</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 (Exception e) {
e.printStackTrace();
System.out.println("上传文件失败");
response.setStatus(415);
}
}
Service 实现层
中间的代码最初从网上借鉴的,最后发现很多逻辑都是错误的,而且异常太多。最后自己修改了逻辑了,替换了很多关键代码。
@Override
public String chunkUploadByMappedByteBuffer(MultipartFileParam param, String filePath) throws IOException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
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){
renameFile(tempFile,fileName);
}
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();
}
}