需求
- 解决手机和PC的大文件传输问题
- 手机不想安装任何APP, 用浏览器能解决的问题, 为什么要多装一个APP?
- 很闲...😅
技术准备
- Vue
- 莫名奇妙地觉得Vue太笨重了, 对组件封装不好, 撸了一遍Google Lit和微软的Fast框架, 结果是...我是真的闲...这玩意儿学了, 最后还是免不了和Vue搭配使用, 何必呢🤔
- FastApi
- Python语法简单, 可以快速打造原型, 然而...我写了整整两个礼拜...🙃(其实写UI是大头)
- HTML5
- WebSocket API
- 使用
js
选择文件 - CSS... 想的总和看到的不一样...总会发出灵魂问题: **为什么对不齐!!!**😵💫
实现细节
- 传输前, 客户端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
}
- 服务器检索保存目录
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
- 客户端收到
UploadRecord
-
判断
status
- 为
Completed
, 询问用户是否重传true
, 回到第1步, 设置Reupload=true
false
, 上传结束
- 其他, 继续
- 为
-
读取要上传文件大小
- 小文件, 使用
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' } }
- 大文件, 进入下一步
- 小文件, 使用
-
创建文件块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 }
- 通过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') }
-
- 服务器收到
Chunk
- 验证
chunk
- 使用
chunk.fid
读取UploadRecord
, 如果不存在, 告诉客户端先post
上传请求 - 验证
chunk.offset==tempFile.length
, 如果不匹配, 告诉客户端传输错误
- 使用
- 保存
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
- 客户端收到
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()
}
}
- 服务器收到
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)
项目演示
改进方向
- 加快传输速度
- 根据实际网络情况, 修改ChunkSize, 增加单次传输的量
- 或者使用并行发送文件块, 实现有点难,特别是服务器端需要保存的信息比较多, 个人使用凑合就行
- 界面美化
- 一开始是想用赛博朋克画风... 结果做成了"阴间"朋克🤣
- 支持添加文件夹
- 暂时没有找到解决方案
致命缺点
- 安卓浏览器(Edge)息屏后, 传输会暂停
- 尝试使用
WakeLock API
,不支持 - 尝试使用
NoSleep.js
, 无效
- 尝试使用
- 所以, 忙活了两个礼拜, 一切白干😒
项目地址
- 刚申请的公开, 还没有审核完
- 文件上传是其中一个模块, 其他模块还是
idea
RemoteMonitor: 在同一个局域网内,通过任意设备访问服务端的功能及资源 1、大屏时钟显示,定点报时 2、系统资源使用率监控 3、文件上传下载 4、系统控制:关机、音量调节、多媒体控制 (gitee.com)