坚持周总结系列第四周(文件上传)

本文深入探讨了文件上传过程中的关键技术,包括文件类型判断、文件切片、hash计算及优化、秒传与断点续传的实现。通过前端与后端的协同工作,确保文件的安全与高效处理。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

坚持周总结系列第四周(2020.5.5)

文件上传

文件类型判断

  • 通过文件二进制流来判断文件类型,防止篡改文件后缀,影响判断
blobToString(blob){
    return new Promise(resolve=>{
        const reader = new FileReader()
        reader.onload = function(){
            // 将文件流转成16进制
            const ret = reader.result
            	.split('')
            	.map(v=>v.charCodeAt())
            	.map(v=>v.toString(16).toUppseCase())
            	.join(' ')
            resolve(ret)
        }
        reader.readAsBinaryString(blob)
    })
},
// 判断gif文件
// 前面6个16进制是 47 49 46 38 39 61 或 47 49 46 38 37 61
const ret = await this.blobToString(file.slice(0,6))
const isGif = (ret == '47 49 46 38 39 61') || (ret == '47 49 46 38 37 61')
// 判断png文件
// 前面8个16进制是 89 50 4E 47 0D 0A 1A 0A
const ret = await this.bolbToString(file.slice(0, 8))
const isPng = (ret == '89 50 4E 47 0D 0A 1A 0A')
// 判断jpg文件
// 前面2个16进制是 FF D8,后面2个16进制是 FF D9
const len = file.size
const start = await this.bolbToString(file.slice(0, 2))
const end = await this.bolbToString(file.slice(-2, len))
const isJpg = (start == 'FF D8') && (end == 'FF D9')

文件切片

const CHUNK_SIZE = 1 * 1024 * 1024 // 每M切片
// 文件切片
createFileChunk(size=CHUNK_SIZE){
    const chunks=[]
    let cur=0
    while(cur<this.file.size){
        chunks.push({index:cur,file:this.file.slice(cur,cur+size)})
        cur+=size
    }
    return chuks
}

文件hash计算

  • 重新开启浏览器子进程计算hash
async calculateHashWorker(){
    return new Promise(resolve=>{
        // 使用hash.js文件启动子进程
        this.worker=new Worker('/hash.js')
        this.worker.postMessage({chunks:this.chunks})
        this.worker.onmessage=e=>{
            const {progress,hash}=e.data
            this.hashProgress=Number(progress.toFixed(2))
            if(hash){
                resolve(hash)
            }
        }
    })
}
// 在static公共资源目录下创建 hash.js
// 引入spark-MD5
self.importScripts('spark-md5.min.js')

self.onmessage=e=>{
    // 接收主线程数据
    const {chunks}=e.data
    const spark=new self.SparkMD5.ArrayBuffer()
    let progress=0
    let count=0
    const loadNext=index=>{
        const reader=new FileReader()
        reader.readAsArrayBuffer(chunks[index].file)
        reader.onload=e=>{
            count++
            spark.append(e.target.result)
            if(count==chunks.length){
                self.postMessage({
                    progress:100,
                    hash:spark.end()
                })
            }else{
                progress+=100/chunks.length
                self.postMessage({
                    progress
                })
                loadNext()
            }
        }
    }
    loadNext(0)
}
  • 利用浏览器空闲时间计算hash
async calculateHashIdle(){
    const chunks=this.chunks
    return new Promise(resolve=>{
        const spark=new sparkMD5.ArrayBuffer()
        let count=0
        const appendToSpark=async file=>{
            return new Promise(resolve=>{
                const reader=new FileReader()
                reader.readAsArrayBuffer(file)
                reader.onload=e=>{
                    spark.append(e.target.result)
                    resolve()
                }
            })
        }
        const workLoop=async deadLine=>{
            while(count<chunks.length && deadLine.timeRemaining()>1){
                // 空闲时间且有任务
                await appendToSpark(chunks[count].file)
                count++
                if(count>chunks.length){
                    this.hashProgress=Number(((100*count)/chunks.length).toFixed(2))
                }else{
                    this.hashProgress=100
                    resolve(spark.end())
                }
            }
        }
    })
}
  • 抽样计算hash,牺牲部分精度,换取时间效率
// 抽样hash一样,文件不一定一样;但是抽样hash不一样,文件肯定不一样
async calculateHashSmple(){
    return new Promise(resolve=>{
        const spark=new sparkMD5()
        const reader=new FileReader()
        const file=this.file
        const size=file.size
        const offset=2*1024*1024 // 每2M抽样
        // 第一个2M和最后一个2M数据全要
        // 中间的,取前中后各2个字节
        let chunks=[file.slice(0,offset)] // 第一个区块
        let cur=offset
        while(cur<size){
            if(cur+offset>=size){
                // 最后一个区块
                chunks.push(file.slice(cur,cur+offset))
            }else{
                // 中间区块
                const mid=cur+offset/2
                const end=cur+offset
                chunks.push(file.slice(cur,cur+2))
                chunks.push(file.slice(mid,mid+2))
                chunks.push(file.slice(end-2,end))
            }
            cur+=offset
        }
        reader.readAsArrayBuffer(new Blob(chunks))
        reader.onload=e=>{
            spark.append(e.target.result)
            this.hashProgress=100
            resolve(spark.end())
        }
    })
}

文件秒传和断点续传

  • 前台逻辑
const chunks = this.createFileChunk()
this.hash = await this.calculateHashSample()
// 询问后端,文件是否上传过,如果没有,是否存在文件切片
const {data:{uploaded,uploadedList}}=await this.$http.post('/checkfile',{
    hash:this.hash,
    ext:this.file.name.split('.').pop()
})
if(uploaded){
    // 秒传
    return this.$message.success('文件秒传成功!')
}
this.chunks=chunks.map((chunk,index)=>{
    // 切片名字 hash+index
    const name=this.hash+'-'+index
    return {
        hash:this.hash,name,index,chunk:chunk.file,
        // 设置进度条,已经上传的,设为100
        progress:uploadList.indexOf(name)>-1 ? 100 : 0
    }
})
await this.uploadChunks(uploadList)
// 切片上传
async uploadChunks(uploadList=[]){
    const requests=this.chunks
    	// 过滤已经上传的
    	.filter(chunk=>uploadList.indexOf(chunk.name)===-1)
    	.map((chunk,index)=>{
            // 转成promise
            const form=new FormData()
            form.append('chunk',chunk.chunk)
            form.append('hash',chunk.hash)
            form.append('name',chunk.name)
           	return {
                form,index:chunk.index,error:0
            }
        })
    // 并发量控制 上传切片
    await this.sendRequest(requests)
    // 合成切片
    await this.mergeRequest()
}
  • 后台处理逻辑
async checkfile(){
    const {ctx}=this
    const {ext,hash}=ctx.request.body
    const filePath=path.resolve(this.config.UPLOAD_DIR,`${hash}.${ext}`)
    let uploaded=false
    let uploadedList=[]
    if(fse.existsSync(filePath)){
        // 文件存在
        uploaded=true
    }else{
        uploadList=await this.getUploadedList(path.resolve(this.config.UPLOAD_DIR,hash))
    }
    this.success({
        uploaded,
        uploadedList
    })
}
async getUploadedList(dirpath){
    return fse.existsSync(dirpath) ?
        // 过滤.开头文件名
        (await fse.readdir(dirpath)).filter(name=>name[0]!=='.') :
    	[]
}

上传并发量控制

  • 前台控制逻辑
async sendRequest(chunks,limit=3){
    // limit为并发数量
    // 实现本质:一个数组,数组长度为limit
    return new Promise((resolve,reject)=>{
        const len=chunks.length
        let count=0
        let isStop=false
        const start=async ()=>{
            if(isStop){
                return
            }
            // 每次从前面取一个切片
            const task=chunks.shift()
            if(task){
                const {form,index}=task
                try{
                    await this.$http.post('/uploadFile',form,{
                        onUploadProgress:progress=>{
                            this.chunks[index].progress = 
                                Number(((progress.loaded / progress.total) * 100).toFixed(2))
                        }
                    })
                    if(count==len-1){
                        // 最后一个任务
                        resolve()
                    }else{
                        count++
                        // 启动下一个任务
                        start()
                    }
                }catch(err){
                    this.chunks[index].progress=-1
                    if(task.error<3){
                        // 错误重试逻辑,错误超过3次,直接结束
                        task.error++
                        chunks.unshift(task)
                        start()
                    }esle{
                        // 结束
                        isStop=true
                        reject()
                    }
                }
            }
        }
        while(limit>0){
            // 启动limit个任务,模拟延迟
            setTimeout(()=>{
                start()
            },Math.random()*2000)
            limit-=1
        }
    })
}
  • 后台代码实现
async uploadFile(){
    const {ctx}=this
    const file=ctx.request.files[0]
    const {hash,name}=ctx.request.body
    const chunkPath=path.resolve(this.config.UPLOAD_DIR, hash)
    if(!fse.exists(chunkPath)){
        await fse.mkdir(chunkPath)
    }
    await fse.move(file.filepath,chunkPath+'/'+name)
    this.message('切片上传成功!')
}

切片合成

  • 前台请求逻辑
async mergeRequest () {
    this.$http.post('/merge', {
        ext: this.file.name.split('.').pop(),
        size: CHUNK_SIZE,
        hash: this.hash
    })
}
  • 后台实现
async merge() {
    const {ext,size,hash} = this.ctx.request.body
    const filePath = path.resolve(this.config.UPLOAD_DIR, `${hash}.${ext}`)
    await this.ctx.service.tools.mergeFile(filePath, hash, size)
    this.success({
        url: `/public/${hash}.${ext}`,
    })
}
// service/tool.js
async mergeFile(filePath,fileHash,size){
    const chunkDir=path.resolve(this.config.UPLOAD_DIR,fileHash)
    let chunks=await fse.readdir(chunkDir)
    // 按照chunk的index大小排序
    chunks.sort((a,b)=> a.split('-')[1]-b.split('-')[1])
    // 拼接chunk路径
    chunks=chunks.map(cp=>path.resolve(chunkDir,cp))
    await this.mergeChunks(chunks,filePath,size,chunkDir)
}
async mergeChunks(files,dest,size,chunkDir){
    const pipeStream=(filePath,writeStream)=> new Promise(resolve=>{
        const readStream=fse.createReadStream(filePath)
        readStream.on('end',()=>{
            fse.unlinkSync(filePath)
            resolve()
        })
        readStream.pipe(writeStream)
    })
    await Promise.all(
        // 文件读写流
    	files.map((file,index)=>{
            pipeStream(file,fse.createWriteStream(dest,{
                start:index*size,
                end:(index+1)*size
            }))
        })
    )
    // 删除切片文件
    fse.rmdirSync(chunkDir)
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值