使用WebSocket实现文件上传

3,863 阅读4分钟

需求

  • 解决手机和PC的文件传输问题
  • 手机不想安装任何APP, 用浏览器能解决的问题, 为什么要多装一个APP?
  • 很闲...😅

技术准备

  • Vue
    • 莫名奇妙地觉得Vue太笨重了, 对组件封装不好, 撸了一遍Google Lit和微软的Fast框架, 结果是...我是真的闲...这玩意儿学了, 最后还是免不了和Vue搭配使用, 何必呢🤔
  • FastApi
    • Python语法简单, 可以快速打造原型, 然而...我写了整整两个礼拜...🙃(其实写UI是大头)
  • HTML5
    • WebSocket API
    • 使用js选择文件
    • CSS... 想的总和看到的不一样...总会发出灵魂问题: **为什么对不齐!!!**😵‍💫

实现细节

  1. 传输前, 客户端post上传请求
    • 表单内容: 文件名FilenName+文件大小FileSize+重传标志Reupload(可选)
async function request_upload(item:UploadItem){
    const reqUrl='/api/files/upload'
    const body={
        file_name:item.fileName,
        file_size:item.fileSize,
        reupload:item.reupload??false,
    }
    // console.log(body)
    const res= await fetch(reqUrl,{
        method:'post',
        headers:{
            'Content-Type':'application/json;charset=utf-8'
        },
        body:JSON.stringify(body)
    })

    if (!res.ok){
        item.status=UploadStatus.Error
        // console.log( `Request uploading Failed[${res.status}]: ${item.fileName}`)
        item.detail="HTTP Error:"+res.status
    }
    const raw_json=await res.json()
    return JSON.parse(raw_json) as UploadRecord

}
  1. 服务器检索保存目录upload_dir下有没有同名, 同大小的文件
    • 文件存在, 判断重传标志
      • 重传标志为false, 返回上传记录UploadRecord, 设置status=Completed
      • 重传标志为true, 删除文件和临时文件, 当作文件不存在处理
    • 文件不存在, 根据fid检索临时文件tempFile(upload_dir/fid.temp)是否存在
      • 存在临时文件, 读取临时文件大小, 返回上传记录UploadRecord, 告诉客户端继续上传的位置ofsset
      • 不存在临时文件, 返回上传记录, 设offset=0
    • 服务器使用dcit<fid,UploadRecord>缓存UploadRecord
    # UploadRecord class
    def make_record(file_name: str, file_size: int,overwrite=False) -> UploadRecord:
        file_name = file_name.strip()
        dest_path=upload_dir/file_name
        fid = UploadRecord.make_fid(file_name, file_size)
        if os.path.exists(dest_path):
            if overwrite:
                os.remove(dest_path)
                temp_path=upload_dir/(str(fid)+'.temp')
                if os.path.exists(temp_path):
                    os.remove(temp_path)
            else:
                return UploadRecord(file_name,-1,-1,FileState.Completed)
        if fid in UploadRecord._cache:
            return UploadRecord._cache[fid]
        temp_path = upload_dir/(str(fid)+'.temp')
        if not temp_path.is_file():
            offset = 0
            state=FileState.New
        else:
            offset = temp_path.stat().st_size
            state=FileState.Broken

        record = UploadRecord(file_name,fid, offset,state)
        UploadRecord._cache[fid]=record
        return record
  1. 客户端收到UploadRecord
    1. 判断status

      • Completed, 询问用户是否重传
        • true, 回到第1步, 设置Reupload=true
        • false, 上传结束
      • 其他, 继续
    2. 读取要上传文件大小

      • 小文件, 使用fetch+post上传
      async function uploadSmallFile(item:UploadItem){
          item.status=UploadStatus.Uploading
          const api='/api/files/upload/small'
          const formData= new FormData()
          formData.append('file',item.file)
          const res=await fetch(api,{
              method:'post',
              body:formData,
          })
          const result:SmallFileUploadResult=await res.json()
          if (res.ok) {
              item.status=UploadStatus.Completed
              // item.detail=''+result.file_path
          }else{
              // console.log(`Upload failed[${res.status}]:${item.fileName}`)
              item.status=UploadStatus.Error
              item.detail='Error:'+result.error??'unknown error'
          }
      }
      
      • 大文件, 进入下一步
    3. 创建文件块Chunk, 每次从UploadRecord.offset读取块大小ChunkSize的二进制数据

      • 文件数据块加上ChunkHead(fid,offset,length), 给服务器验证用
      • 使用异步迭代器, 方便操作
    async function* buildChunkGenerator(file:File,uploadRecord:UploadRecord,chunkSize:number=1*MB){
        let header=new ArrayBuffer(12)
        let header_view= new Int32Array(header)
        header_view[0]=uploadRecord.fid
        const fileLength=file.size
        // console.log('FileSize, Offset:',fileLength,uploadRecord.offset)
        while(uploadRecord.offset<fileLength){
            header_view[1]=uploadRecord.offset
            let length=Math.min(chunkSize,fileLength-uploadRecord.offset)
            header_view[2]=length
            let buffer=new ArrayBuffer(12+length)
            let buffer_view=new Uint8Array(buffer)
            const data= await (file.slice(uploadRecord.offset,uploadRecord.offset+length).arrayBuffer())
            buffer_view.set(new Uint8Array(header))
            buffer_view.set(new Uint8Array(data),12)
            // console.log('Generat chunk:',{fid:uploadRecord.fid,offset:uploadRecord.offset, length})
            yield buffer
            uploadRecord.offset+=length               
        }
        header_view[1]=0
        header_view[2]=0
        // console.log('Generate End chunk:',uploadRecord.fid)
        yield header
    }
    
    1. 通过WebSocket发送第一个chunk
        const ws=new WebSocket(api)
        const chunkGenerator=buildChunkGenerator(item.file,fileState)
        ws.onopen=async ()=>{
            // console.log('Upload WS opened...')
            let chunk=await chunkGenerator.next()
            ws.send(chunk.value as ArrayBuffer)
            // console.log('Send first Chunk')
        }
    
  2. 服务器收到Chunk
    1. 验证chunk
      • 使用chunk.fid读取UploadRecord, 如果不存在, 告诉客户端先post上传请求
      • 验证chunk.offset==tempFile.length, 如果不匹配, 告诉客户端传输错误
    2. 保存chunk
      • 使用'ab'模式打开tempFile, 把chunk掐掉头部追加上去
      • 返回接收结果ChunkRecord, 设置state=Accepted
async def receive_chunk(websocket: WebSocket) -> bool:
    try:
        buffer = await websocket.receive_bytes()
        chunk = BufferChunk(buffer)
        record = UploadRecord.load(chunk.fid)
        res = ChunkRecord(fid=chunk.fid, offset=chunk.offset)
        if not record:
            res.state = ChunkState.Rejected
            res.message = 'Chunk not allowed, post file information first!'
            print('Chunk Rejectd:',chunk.fid)
            await websocket.send_json(res.json())
            return False
        if chunk.ended:
            print('End chunk:', res.json())
            record.complete()
            res.state = ChunkState.Ended
            res.message = record.file_name
            await websocket.send_json(res.json())
            return False

        await record.save_chunk(chunk)
        res.state = ChunkState.Accepted
        print('Chunk Saved:', res.json())
        await websocket.send_json(res.json())
        return True
    except Exception as e:
        res.state = ChunkState.Failed
        res.message = str(e)
        print('Save Chunk Failed:', res.json())
        await websocket.send_json(res.json())
        return False
  1. 客户端收到ChunkRecord
    • 如果state=Accepted, 继续传输下一个chunk
    • 如果state=other, 结束传输
    • 如果文件读取到了末尾, 发送特殊的EndChunk(length=0)
    ws.onmessage=async function(event){
        if (!can_upload(item)) {// 暂停功能
            ws.close()
            return
        }
        let chunkState=(JSON.parse(event.data))
        chunkState=JSON.parse(chunkState) as ChunkRecord
        // console.log('Accept ws message:',event.data)
        // console.log('Parsed chunk result:',chunkState)
        if (chunkState.state==ChunkState.Ended) {
            // console.log('Complete chunk upload:',fileState.fid)
            // ws.close()
            item.status=UploadStatus.Completed
            ws.close()
            return
        }
        if (chunkState.state==ChunkState.Accepted){            
            item.progress=fileState.offset/item.fileSize
            // console.log('Upload chunk ok:',fileState.fid,'progress:',item.progress)
            let chunk=await chunkGenerator.next()
            ws.send(chunk.value as ArrayBuffer)
        }
        else{
            // console.log('Error chunk upload:',chunkState)
            item.status=UploadStatus.Error
            item.detail=chunkState.message
            ws.close()
        }
    }
  1. 服务器收到EndChunk, 将临时文件重名为正式的FileName
    • 如果有同名不同大小的文件存在, 进行重命名操作
    def complete(self):
        dest_path=upload_dir/self.file_name
        os.rename(self.temp_path,dest_path)
        del UploadRecord._cache[self.fid]
        print('Complete Uploading:',self.fid,self.file_name)

项目演示

动画.gif

改进方向

  • 加快传输速度
    • 根据实际网络情况, 修改ChunkSize, 增加单次传输的量
    • 或者使用并行发送文件块, 实现有点难,特别是服务器端需要保存的信息比较多, 个人使用凑合就行
  • 界面美化
    • 一开始是想用赛博朋克画风... 结果做成了"阴间"朋克🤣
  • 支持添加文件夹
    • 暂时没有找到解决方案

致命缺点

  • 安卓浏览器(Edge)息屏后, 传输会暂停
    • 尝试使用WakeLock API,不支持
    • 尝试使用NoSleep.js, 无效
  • 所以, 忙活了两个礼拜, 一切白干😒

项目地址