文件上传之前碰到过很多次,碰到大文件,一方面是限制其上传大小,另一方面是增加NGINX的post容量。这种情形基本就能解决问题了。如果有更大的文件需要上次,则需要引用第三方存储,如oss的前端sdk直接上传。
但是显然这两种方案都不太令人满意。刚好我前段时间有此需求,于是自己实现了一个。
我们首先来拆解下需求,要实现一个完整的上传模块,我们需要实现以下需求:
- 秒传(非必须)
- 分片传输
- 断点续传
- 进度展示
- 暂停/开始(非必须)
接下来,开始具体实现
秒传
通俗的说,你把要上传的东西上传,服务器会先做摘要(如md5)校验,如果服务器上有一样的东西,就不用上传了,直接返回地址即可,根据实际需要,这个地址可能是一个,也可能是多个,但他们都是指向了服务器的同一个文件。
前端获取文件md5可以直接用spark-md5
const FileDigest=(file:File,fn:(digest:string)=>void)=>{
const blobSlice = File.prototype.slice,
chunkSize = 2097152, // Read in chunks of 2MB
chunks = Math.ceil(file.size / chunkSize),
spark = new SparkMD5.ArrayBuffer(),
fileReader = new FileReader()
let currentChunk = 0
fileReader.onload = function (e) {
console.log('read chunk nr', currentChunk + 1, 'of', chunks)
if (e && e.target && e.target.readyState === FileReader.DONE) {
spark.append(e.target.result as ArrayBuffer) // Append array buffer
currentChunk++
if (currentChunk < chunks) {
loadNext()
} else {
console.log('finished loading')
const hexHash = spark.end()
fn(hexHash)
}
}
}
fileReader.onerror = function () {
console.warn('oops, something went wrong.')
}
const loadNext = () => {
const start = currentChunk * chunkSize,
end = start + chunkSize >= file.size ? file.size : start + chunkSize
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end))
}
loadNext()
}
断点续传
断点续传是在下载或上传时,将下载或上传任务(一个文件或一个压缩包)人为的划分为几个部分,如果碰到网络故障,可以从已经上传或下载的部分开始继续上传或者下载未完成的部分,而没有必要从头开始上传或者下载。本文的断点续传主要是针对断点上传场景。
要实现断点续传,要做到以下两步
- 在开始上传前,从服务端获取需要上传的分片。
- 每个分片的摘要在服务端存储一份,等上传完成merge的时候,再验证下每个分片的完整性。
分片传输
分片上传,就是将所要上传的文件,按照一定的大小,将整个文件分隔成多个数据块(我们称之为Part)来进行分别上传,上传完之后再由服务端对所有上传的文件进行汇总整合成原始的文件。
那如何分割文件呢,我们可以先算出总片数totalChunks = Math.ceil(totalSize / chunkSize)
,然后使用文件对象File的方法File.prototype.slice
切片读取。
并发控制
分片上传是最重要的地方,也是容易失败的地方,假设有100个分片,那我们若是直接发100个请求的话,很容易达到浏览器的瓶颈,所以需要对请求进行并发处理。
要实现并发控制,关键在于控制执行者的数量,为此我们很容易想到订阅模型,我们只要控制consumer的数量即可。
网上有现成的包async-pool来实现并发控制。但是看了它的基本逻辑,感觉并不算复杂,所以没必要额外再引入一个包,我自己写了一个对应的实现。
我们用一个Array
来实现队列。我们将切片编号放进Array
.然后再通过Promise.all
来启动指定数量的promise去执行任务的获取和上传。对于失败的任务,可以重新push到Array
重新上传。
代码如下
Promise.all(
Array.from({length: this.maxConcurrency}, (_, index) => {
return new Promise(resolve => {
const proc = () => {
const task = this.tasks.shift()
if (task === undefined) {
resolve(index)
return
}
const start = task * this.chunksize
const end = start + this.chunksize >= this.totalSize ? this.totalSize : start + this.chunksize
const chunk = Blob.prototype.slice.call(this.file, start, end)
const chunkReader = new FileReader()
chunkReader.onload = (e: ProgressEvent<FileReader>) => {
if (e && e.target && e.target.readyState === FileReader.DONE) {
this.upload(d.upload_id, index, task, e.target.result as ArrayBuffer, proc)
}
}
chunkReader.onerror = (e: ProgressEvent<FileReader>) => {
console.log(e)
}
chunkReader.onerror = (e: ProgressEvent<FileReader>) => {
console.log(e)
}
chunkReader.readAsArrayBuffer(chunk)
}
proc()
})
}),
).then((values: any) => {
console.log(values)
this.merge(d.upload_id)
})
服务端实现
touchUrl (post)
初始化上传动作,返回upload_id,会携带文件md5和mime信息,
如果文件已经存在,则直接返回status=2,url不为空。
如果之前上传过,status=1,chunks会返回已经上传的chunk的index和hash
ur参数
属性 | 类型 | 说明 |
---|---|---|
size | String |
文件的 md5 值 |
chunk_size | String |
文件名 |
digest | String |
文件的 md5 值 |
body
文件的前200个字节(为了获取文件的mime
)
返回参数
属性 | 类型 | 说明 |
---|---|---|
url | String |
已上传时返回线上文件路径 |
upload_id | String |
上传凭证,如果已完成,该字段为空字符串 |
chunks | Array<{index:number,etag:string}> |
未完全上传时,返回已上传的分块序号 |
status | int |
0:待上传,1:上传中,2:已完成,3:已失败 |
uploadUrl (post)
分片传输
ur参数
属性 | 类型 | 说明 |
---|---|---|
index | String |
分片序号,从0开始 |
upload_id | String |
传输凭证 |
digest | String |
文件的 md5 值 |
body
传输的分片内容
mergeUrl
合并传输分片
ur参数
属性 | 类型 | 说明 |
---|---|---|
upload_id | String |
传输凭证 |
digest | String |
文件的 md5 值 |
body
json格式,所有分片的编号和摘要,Array<{index:number,etag:string}>
微信小程序支持
小程序的用法与h5基本一致,不同的是无法直接获取到File
实例,但是可以用对应的api来获取文件信息。
- FileSystemManager.getFileInfo 可以获取文件的size和digest(文档没写,但是实际是可以获取的)
- FileSystemManager.readFile可以根据range获取每个分片的文件内容。