根据项目需要,在很多地方都需要将超大文件上传到服务器,特别是将视频文件上传到云平台的OSS服务器上,这种需求在项目中已经是十分普遍的需求了。在网上收集了很多资料,基本上都只有JAVA到OSS服务器,或都VUE前端服务端到Spring Boot后端服务器。当前前后端开发已经十分普及了,大文件一般都保存到OSS服务器,不会保存到自己的业务服务器,OSS大文件服务器+tomcat为业务服务器+VUE/Ract/小程序前端服务器的三层后台架构是当前最主流的架构设计,因此必须解决这个问题。
通过几天的反复研究和实践,结合网上找到的资料,需要将VUE服务端到Spring Boot服务器和JAVA到OSS服务器两部分结合起来,形成整体的解决方案。
在这个整体中有两上难点:
1、VUE前端服务器的JS程序是异步的,而大文件分片传输需要大量分次请求,请求执行完所顺序是不一致的;
2、由于是三层请求,涉及到多用户,Spring Boot服务器需要将分原来整体功能,撤分为三个,存在中间缓存信息持久化保存与多用户隔离问题
一、解决方案框架
由于这个功能还是非常复杂,为了解决这个问题,还是绘制了功能框架图,并且在具体编程时,对功能框架图进行了多次修正,最终框架如下图所示。
1、 框架整体上是前端判断是否大文件,超过10M,如果向Spring Boot服务器,请求大文件传输,Spring Boot向OSS服务器申请创新大文件传递的uploadID.
2、前端对文件进行分片,并产生分片MD5码,根据第1得到的id发起分片传输,Spring Boot收到分片后,上传分片
3、Spring boot在redis中缓存存文件ID,分片信息,以备文件合成用
4、前端检索返回MD5,如果有错,重新传递分片
5、前端检查分片是否全部正确传输完成,如果传递完成,发起文件合并请求
6、Spring boot将缓存信息组合,向OSS提出文件合并请求
7、OSS服务器收到相关信息,检验后合成文件并返回文件访问地址
8、Spring boot将访问地址及相关信息保存到服务器,并清除缓存
9、前端收到信息,显示进展,请除缓存存
10、根据地址使用已经上传的文件,如播放或预览。
二、Spring Boot后台程序
后台程序分为Controller和utils两部分,controller与前端交互,utils与OSS交互。
2.1、前端与用户交互的Controller
由于前后端分离,需要将大文件上传分为三个部分,即大文件上传初始化、上传大文件块、合并已上传文件,分别与VUE前端进行交互。
/**
* <p>
* 前端控制器
* </p>
*
* @author wu jize
* @since 2020-07-05
*/
@RestController
@RequestMapping("/map/scenic/file")
public class MapBigFileController extends BaseController {
@Autowired
private IMapScenicFileService iMapScenicFileService;
@Autowired
IMapScenicService iMapScenicService;
@Autowired
private TokenService tokenService;
@Autowired
private BigFileUploadUtils bigFileUploadUtils;
/**
* 大文件上传初始化
* 返回文件uploadId
*/
@ApiOperation("大文件上传初始化")
@PreAuthorize("@ss.hasPermi('data:scenicfile:edit')")
@Log(title = "大文件上传初始化", businessType = BusinessType.UPDATE)
@PostMapping("/initUpload")
public AjaxResult initBigFileUpload(@RequestParam(name = "fileName", required = true) String fileName,
@RequestParam(name = "scenicId", required = false) Long scenicId) {
//格式化文件路径,按县、景区ID、用户名组织文件
String adcode = iMapScenicService.selectScenicById(scenicId).getAdcode();
if (StringUtils.isEmpty(adcode)) {
throw new CustomException("景区行政区域编码为空,不能添加景区多媒体文件!");
}
LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
String author = loginUser.getUsername();
String objectName = "scenicfile" + "/" + adcode + "/" + scenicId.toString() + "/" + author + '/' + fileName;
String uploadId = bigFileUploadUtils.initUpload(objectName);
AjaxResult ajax = AjaxResult.success();
ajax.put("uploadId", uploadId);
return ajax;
}
/**
* 上传大文件的Chunk
* 返回chunk的MD5
*/
@ApiOperation("上传大文件的Chunk")
@PreAuthorize("@ss.hasPermi('data:scenicfile:edit')")
@Log(title = "上传大文件的Chunk", businessType = BusinessType.UPDATE)
@PostMapping("/uploadChunk")
public AjaxResult uploadChunk(@RequestParam("chunkFile") MultipartFile chunkFile,
@RequestParam(name = "uploadId", required = true) String uploadId,
@RequestParam(name = "chunkId", required = true) Integer chunkId,
@RequestParam(name = "total", required = true) Integer total) throws IOException {
String md5Str = bigFileUploadUtils.uploadChunk(uploadId, chunkId, chunkFile);
AjaxResult ajax = AjaxResult.success();
ajax.put("md5Str", md5Str);
return ajax;
}
/**
* 大文件上传完成后合并
* 返回文件访问的URL
*/
@ApiOperation("大文件上传完成后合并")
@PreAuthorize("@ss.hasPermi('data:scenicfile:edit')")
@Log(title = "大文件上传完成后合并", businessType = BusinessType.UPDATE)
@PostMapping("/mergeFile")
public AjaxResult mergeFile(@RequestParam(name = "uploadId", required = true) String uploadId) {
AjaxResult ajax = AjaxResult.success();
String url = bigFileUploadUtils.completeFile(uploadId);
ajax.put("url", url);
return ajax;
}
}
2.2、util包,负责与OSS交互。
在后端与OSS交互中,有一个难点是解决大文件前后端分离后,异步数据上传中该文件相关信息的保存。由于是异步请求,方法分为三段执行,不同请求对于Spring Boot来说就是不同的线程,试了以下几种方法,最后用redis缓存存:
1、用包类静态全局变量,由是不同请求会产生不同线程,此方法行不通
2、用线程变量,静态全局变量不行就考虑过用线程变量,功能实现后现还是不行,进一步研究,发现前端不同请求在后端仍然在不同线程,经如初始化大文件上传的uploadId和objectId,在后面请求访问不到。
3、用session变量,研究了一下,发现更加复杂,还不一定能解决得好并好用户,同一用户不同窗口上传问题
4、反馈给前端,uploadId和objectId还行,但是分片的partETag信息太多,处理起来会十分繁琐
5、用数据库临时表进行持久化,也挺复杂,并且还要引入JPA,如用mybatis工作也不少
6、经过多方比较,还是用redis,简单并且速度快,唯一缺点时redis服务器重启后,没有上传完的片断会变成数据垃圾,还没搞清楚阿里OSS怎么处理,不知道要不要占自己的存储容量。Redis根据uploadId保存信息,不同用户,即使同一用户同时上传不同文件其uploadId是唯一的,这样能做好多用户相互隔离问题。
/**
* 用于超过10M的大文件分片上传
*
* @author Wu Jize
* @version 1.0
* @date 2020/7/5 18:28
*/
@Component
public class BigFileUploadUtils {
protected static final Logger log = LoggerFactory.getLogger(OSSFileUtils.class);
//将OSS访问参数保存到配置文件,并忽略上传GIT,避免有权限的数据泄漏
@Value("${aliyun.oss.endpoint}")
private String endpoint;
@Value("${aliyun.oss.accessKeyId}")
private String accessKeyId;
@Value("${aliyun.oss.accessKeySecret}")
private String accessKeySecret;
@Value("${aliyun.oss.dir.prefix}")
private String prefix;
@Value("${aliyun.oss.bucketName}")
private String bucketName;
@Autowired
private RedisCache redisCache;
/**
* 创建OSSClient全局变量。
*
*/
private static OSS ossClient = null;
/**
* 上初始化大文件上传环境,返回uploadId
*
* @param objectName 文件
* @return 返回uploadId
*/
public String initUpload(String objectName) {
ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
// 创建InitiateMultipartUploadRequest对象。
InitiateMultipartUploadRequest request = new InitiateMultipartUploadRequest(bucketName, objectName);
// 如果需要在初始化分片时设置文件存储类型,请参考以下示例代码。
// ObjectMetadata metadata = new ObjectMetadata();
// metadata.setHeader(OSSHeaders.OSS_STORAGE_CLASS, StorageClass.Standard.toString());
// request.setObjectMetadata(metadata);
// 初始化分片。
InitiateMultipartUploadResult upresult = ossClient.initiateMultipartUpload(request);
// 返回uploadId,它是分片上传事件的唯一标识,您可以根据这个uploadId发起相关的操作,如取消分片上传、查询分片上传等。
String uploadId = upresult.getUploadId();
//将uploadId缓存到redis,2个小时有效
redisCache.setCacheObject("uploadId:"+uploadId, objectName, 120*60*1000, TimeUnit.MINUTES);
return uploadId;
}
/**
* 上传指定的文件片断,返回uploadId
*
* @param uploadId 文件ID
* @return chunk的MD5
*/
public String uploadChunk(String uploadId,
Integer chunkId,
MultipartFile file) throws IOException {
ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
InputStream instream = file.getInputStream();
long curPartSize = file.getSize();
String objectName = redisCache.getCacheObject("uploadId:"+uploadId);
UploadPartRequest uploadPartRequest = new UploadPartRequest();
uploadPartRequest.setBucketName(bucketName);
uploadPartRequest.setKey(objectName);
uploadPartRequest.setUploadId(uploadId);
uploadPartRequest.setInputStream(instream);
// 设置分片大小。除了最后一个分片没有大小限制,其他的分片最小为100 KB。
uploadPartRequest.setPartSize(curPartSize);
// 设置分片号。每一个上传的分片都有一个分片号,取值范围是1~10000,如果超出这个范围,OSS将返回InvalidArgument的错误码。
uploadPartRequest.setPartNumber(chunkId);
System.out.println(uploadPartRequest);
System.out.println(ossClient);
// 每个分片不需要按顺序上传,甚至可以在不同客户端上传,OSS会按照分片号排序组成完整的文件。
UploadPartResult uploadPartResult = ossClient.uploadPart(uploadPartRequest);
// 每次上传分片之后,OSS的返回结果包含PartETag。PartETag将被保存在partETags中。
PartETag partETag = uploadPartResult.getPartETag();
//需要将PartETag缓存存起来,如果是第一个块,需要先将创建list,以便add
List<PartETag> partETagList = redisCache.getCacheObject("partETags:"+uploadId);
if (partETagList == null){
partETagList = new ArrayList<PartETag>();
}
partETagList.add(partETag);
redisCache.setCacheObject("partETags:"+uploadId, partETagList,120*60*1000, TimeUnit.MINUTES);
System.out.println(partETag);
String md5Str = partETag.getETag();
return md5Str;
}
/**
* 合并文件,返回文件URL
*
* @param uploadId 文件
* @return 返回文件返回URL
*/
public String completeFile(String uploadId) {
ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
//获取该uploadId缓存的信息和各块的信息,
String objectName = redisCache.getCacheObject("uploadId:"+uploadId);
List<PartETag> partETagList = redisCache.getCacheObject("partETags:"+uploadId);
// 创建CompleteMultipartUploadRequest对象。
// 在执行完成分片上传操作时,需要提供所有有效的partETags。OSS收到提交的partETags后,会逐一验证每个分片的有效性。当所有的数据分片验证通过后,OSS将把这些分片组合成一个完整的文件。
CompleteMultipartUploadRequest completeMultipartUploadRequest =
new CompleteMultipartUploadRequest(bucketName, objectName, uploadId, partETagList);
// 如果需要在完成文件上传的同时设置文件访问权限,请参考以下示例代码。
// completeMultipartUploadRequest.setObjectACL(CannedAccessControlList.PublicRead);
// 完成上传。
CompleteMultipartUploadResult completeMultipartUploadResult = ossClient.completeMultipartUpload(completeMultipartUploadRequest);
System.out.println(completeMultipartUploadResult);
// 关闭OSSClient,删除redis缓存的内容。
ossClient.shutdown();
redisCache.deleteObject("uploadId:"+uploadId);
redisCache.deleteObject("partETags:"+uploadId);
return completeMultipartUploadResult.getLocation();
}
}
三、VUE前端方案
前端功能主要是组件化实现,文件选取,大文件分块,分块传输,进度条,上传文件验证等,效果图如下。
3.1、文件选取组件
这个组件与upload区别不大
3.2、文件进度及提示

添加了进度条,文件上传信息
3.3、前端代码
<template>
<div class="fileuploadbase">
<div class="fileuploadgroup">
<input type="file" name="file" id="fileinput" @change="fileUploadChanged" :multiple='false'
style="display: none;"/>
<el-button v-loading="loading" @click="fileButtonClick()"
:disabled="!isUploadClick"
icon="el-icon-upload" size="mini" plain style='width"96px;height:38px;font-size: 14px;'>
{{fileButtonText}}
</el-button>
<div class="currentfilename" v-if="!isAutoUpload">{{ waitUploadFile }}</div>
<el-button @click="fileUploadStartup" v-show="isFileUploadStartupBottonShow" v-if="!isAutoUpload" size="mini"
plain>点击上传
</el-button>
</div>
<div v-show="currentFileName.length > 0">
<div class="fileuploadgroup" v-for="(item, index) in currentFileName" :key="index">
<div class="currentfilename" @click="handleFileDownloadClick(item)">{{ item.name }}</div>
<el-progress :text-inside="true" :stroke-width="18" v-if="percentage" :percentage="percentage"
class="elprogress"
v-show="isProgressVis && index==(currentFileName.length-1)"></el-progress>
<i class="el-icon-delete" title="删除文件" @click="handleFileDelClick(item)"
v-show="isDeleteIconShow && (!isProgressVis || index!=(currentFileName.length-1))"
:style="{cursor: 'pointer'}"></i>
</div>
</div>
</div>
</template>
<script>
import { initUpload, uploadChunk, mergeFile } from '@/api/data/bigfileupload'
export default {
name: 'bigFileUpload',
data() {
return {
percentage: 0,
filesdata: [],
// 遮罩层
loading: false,
isDeleteIconShow: false,
isFileUploadStartupBottonShow: false,
isProgressVis: false,
currentFileName: [],
waitUploadFile: '',
isUploadClick: true,
//大文件上传相关信息
uploadId: '',
md5Str: '',
//上传成功后文件地址
fileUrl: '',
//上传文件块号
chunkId: 0,
md5Str: []
}
},
props: {
// 上传方式:true - 自动上传;false - 手动上传
isAutoUpload: {
type: Boolean,
default: false
},
// 单/多文件:true - 多文件;false - 单文件
isMultiple: {
type: Boolean,
default: false
},
// 景区代码
scenicId: {
type: Number,
default: null
}
},
computed: {
fileButtonText() {
if (this.currentFileName.length == 0) {
return '上传文件'
} else {
return '继续添加'
}
}
},
created() {
if (!this.histFileArr) {
this.isDeleteIconShow = false
this.currentFileName = []
return
}
this.isDeleteIconShow = true
this.currentFileName = this.histFileArr
},
methods: {
fileUploadChanged() {
this.filesdata = this.$el.getElementsByTagName('input')[0].files//this.$el.getElementsByTagName方法可返回带有指定标签名的对象的集合。
if (this.filesdata.length == 0) return
this.isDeleteIconShow = true
this.isFileUploadStartupBottonShow = true
this.isProgressVis = false
this.percentage = 0
this.waitUploadFile = this.filesdata[0].name
if (this.isAutoUpload) {
this.fileUploadStartup()
}
},
fileUploadStartup() {
this.loading = true
this.isUploadClick = false
this.isDeleteIconShow = false
this.isFileUploadStartupBottonShow = false
//初始化参数
this._name = this.filesdata[0].name //文件名
this._size = this.filesdata[0].size //总大小
this._shardSize = 2 * 1024 * 1024 //以2MB为一个分片
this._shardCount = Math.ceil(this._size / this._shardSize) //总片数
this.uploaded = 0
if (!this.isMultiple) {
this.fileDelOper(this.currentFileName[0])// 进行删除操作
this.currentFileName = []
}
this.isProgressVis = true
this.currentFileName.push({ name: this.filesdata[0].name })
//初始化大文件上传,传入景区代码文文件名,返回uploadId
let self = this
let formData = new FormData()
formData.append('scenicId', this.scenicId)
formData.append('fileName', this.filesdata[0].name)
initUpload(formData).then(response => {
console.log(response)
if (response.code === 200) {
self.msgSuccess('大文件上传初始化成功')
//从第0块开始上传
self.uploadId = response.uploadId
self.uploadByChunk(self.chunkId)
} else {
self.msgError(response.msg)
}
})
},
//分片上传大文件
uploadByChunk(index) {
this._start = index * this._shardSize
this._end = Math.min(this._size, this._start + this._shardSize)//结束时总大小,和 开始的大小+之前的大小比较
let self = this
let fileData = this.filesdata[0].slice(this._start, this._end)
//获取文件块MD5
let reader = new FileReader()
reader.readAsBinaryString(fileData)
// 读取成功后的回调
reader.onloadend = function() {
//用hex_md5生成md5值,与OSS的算法对应,并转换为大写,不是直接md5-js的算法
self.md5Str[index] = self.hex_md5(this.result).toLocaleUpperCase()
console.log(self.md5Str[index])
}
let form1 = new FormData()//new一个form的实例,可以进行键值对的添加,
form1.append('chunkFile', fileData) //slice方法用于切出文件的一部分
form1.append('uploadId', this.uploadId)
form1.append('chunkId', (index + 1).toString())
form1.append('total', this._shardCount.toString()) //是否最后一片
uploadChunk(form1).then(response => {
console.log(response.md5Str)
if (response.code === 200) {
//判断返回的MD5值是否一致,一致继续传下一块,否则重传本块
if (self.md5Str[index] === response.md5Str) {
this.msgSuccess('第' + (index + 1).toString() + '块文件上传成功')
self.uploaded++
let percent = Math.floor((self.uploaded / self._shardCount) * 100)
self.percentageSend(percent)
//如果没上传完成,继续上传一下块
index++
if (index < self._shardCount) {
this.uploadByChunk(index)
}
} else {
//不一致,重新传本块
this.msgSuccess('第' + (index + 1).toString() + '块文件上传不成功,重新上传')
this.uploadByChunk(index)
}
} else {
//出错,跳出循环显示错误
this.msgError(response.msg)
}
})
},
//接收上传的百分值回调
percentageSend(perNum) {
this.percentage = perNum
//如果上传完成,合并文件
if (perNum === 100) {
let form2 = new FormData()//new一个form的实例,可以进行键值对的添加,
form2.append('uploadId', this.uploadId)
mergeFile(form2).then(response => {
console.log(response)
if (response.code === 200) {
this.msgSuccess('大文件合并成功')
this.fileUrl = response.url
this.successUpload(true, this.filesdata[0], response.url)
} else {
this.msgError(response.msg)
}
})
this.loading = false
}
},
successUpload(success, field, value) {
if (success) {
if (this.percentage < 100) return
this.isDeleteIconShow = true
this.isFileUploadStartupBottonShow = false
this.isProgressVis = false
this.isUploadClick = true
document.getElementById('fileinput').value = ''
this.waitUploadFile = ''
this.successvalue = value
console.log(this.successvalue)
this.$emit('successvalue', this.successvalue)
this.$message({
message: '文件上传成功',
type: 'success'
})
}
},
// 点击文件后面的删除按钮
handleFileDelClick(item) {
this.filesdata = []
document.getElementById('fileinput').value = ''
for (let i = 0; i < this.currentFileName.length; i++) {
if (this.currentFileName[i].name == item.name) {
this.fileDelOper(item)// 进行删除操作
this.currentFileName.splice(i, 1) //删除下标为i的元素
return this.currentFileName
}
}
this.isDeleteIconShow = false
this.isFileUploadStartupBottonShow = false
this.isProgressVis = false
},
// 点击选取文件
fileButtonClick() {
document.getElementById('fileinput').click()
},
// 删除操作
fileDelOper(val) {
this.$emit('fileDelOper', val)
},
// 点击文件下载
handleFileDownloadClick(val) {
this.$emit('fileDownload', val)
}
}
}
</script>
<style scoped>
a {
color: #65b978;
}
</style>
前端有几个关键点:
1、分块代码上传
最先是先计算好分块数量,用for循环逐块上传,发现有一个问题,js前端是异步的,for一下就循环结束了,一下全部交给后端,各块上传结束时间不一样,后面的块很快就会超时传输不成功。
先试了一下前端循环等待,CPU直接飙升,风扇加速,受不了。
试用使用sleep方案,这个timeout也是异就的,起不到等待作用,且前端会仍阻塞,CPU占用缓慢飙升。
后面想到了用request异步嵌套实现,即上传成功返回后,再调下一块上传请求。
//如果没上传完成,继续上传一下块
index++
if (index < self._shardCount) {
this.uploadByChunk(index)
}
2、MD5比较
上传前先生成MD5,然后与阿里OSS返回的比较,注意阿里用的是hex_md5函数方法,开始下载了md5-js组件,用md5函数,生成的不一样,后从网上找了一个包含hex_md5函数的JS,自己将其加到utils然后在main.js中引入。
3、上传完成判断
刚开始是放在for循环中判断和Spring boot中同时判断,哪个发现了就先发走文件流关闭,发现有问题,系统是异步提效的,不论是Spring boot还是for循环的最后一个块都有可能不是最后完成的块,提前关闭会导致文件出错。
最后选择的方法是在前端块上传结束后,如果正确,并判断上成功上传块数量。即增加上传完成的块数this.uploaded变量,这个变量触发进度条更新,在进度条更新到100后,再触发文件合并关闭请求。同时,在Spring boot中不再判断。
四、小结
总的来说,VUE通过Spring boot上传超大文件到OSS过程还是比较复杂,花了不少时间,才完全将此问题解决。网上有几篇相关文章,虽然不全,但对学习起了很大作用并引用了部分代码,一并表示感谢。