【独行长路】基于Vue3、Python、WebSocket的大文件传输demo

171 阅读3分钟

前言

本篇内容不涉及md5、断点续传等常规大文件上传要素,只是赶工出来的粗糙毛坯,留作备忘。前端Vue3,后端Python。 先引用 @哒布溜 的常规要素:

  1. 先对文件进行md5加密。使用md5加密的优点是:可以对文件进行唯一标识,同样可以为后台进行文件完整性校验进行比对。
  2. 拿到md5值以后,服务器端查询下该文件是否已经上传过,如果已经上传过的话,就不用重新再上传。
  3. 对大文件进行分片。比如一个100M的文件,我们一个分片是5M的话,那么这个文件可以分20次上传。
  4. 向后台请求接口,接口里的数据就是我们已经上传过的文件块。(注意:为什么要发这个请求?就是为了能续传,比如我们当一个文件传到一半的时候,突然想下班不想上传了,那么服务器就应该记住我之前上传过的文件块,当我打开电脑重新上传的时候,那么它应该跳过我之前已经上传的文件块。再上传后续的块)。
  5. 开始对未上传过的文件块进行上传。(这个是第二个请求,会把所有的分片合并,然后上传请求)。
  6. 上传成功后,服务器会进行文件合并。

正文

获取文件的File对象

它应该长这样:

File {
    lastModified: 1710472518000,
    lastModifiedDate: Fri Mar 15 2024 11:15:18 GMT+0800 (中国标准时间),
    name: "test.pdf",
    size: 32285991,
    type: "application/pdf", // 我的文件是pdf,所以是“application/pdf”
    webkitRelativePath: "",
}   

这是二进制的,你看不到里面的东西。

文件切片

const size = file.size;
const shardSize = 1024 * 1024; // 假设以1MB为一个分片
const shardCount = Math.ceil(size / shardSize); // 总片数
const start = i * shardSize; // 切片的起始位置
const end = Math.min(size, start + shardSize); // 结束位置
const fileClip = file.slice(start, end); // File类型继承自Blob,原型链上有slice()方法

切出来的东西长这样:

fileClip: Blob {size1048576type''}

这东西经过JSON.stringify()出来的是一个{},所以要么直接ws.send(fileClip),倘若send里是一个对象,就需要对其转码。

切片转码

需要用到FileReader

const reader = new FileReader();
reader.onload = async (e) => {
  const blob = reader.result;
  console.log(blob);
  // 'data:application/octet-stream;base64,ZmURO…U9GCg=='
  // 这串东西就可以转string了
};
reader.readAsDataURL(fileClip); // 读为base64
// 你还可以
// reader.readAsArrayBuffer();
// reader.readAsText()
// 按需选择

传输到后端

这部分随你发挥,我用循环:

const sendMessage = () => {
  const file = fileList.value[0].file;
  const size = file.size;
  const shardSize = 1024 * 1024; // 以1MB为一个分片
  const shardCount = Math.ceil(size / shardSize); // 总片数

  for (let i = 0; i < shardCount; i++) {
    const start = i * shardSize;
    const end = Math.min(size, start + shardSize);
    const fileClip = file.slice(start, end);
    
    const reader = new FileReader();
    reader.onload = (e) => {
      const blob = reader.result;
      ws.value.send(
        JSON.stringify({
          fileName: file.name,
          currentSlice: i + 1,
          totalSlice: shardCount,
          file: blob,
        })
      );
    };
    reader.readAsDataURL(fileClip);
  }
};

后端接收与组装

我问GPT的。思路是做个临时存储,里面以分片下标做键,计数达到totalSlice时开始组装。 组装文件的部分写成一个类了:

class PdfAssembler:
    def __init__(self, file_name, total_slices):
        self.file_name = file_name
        self.total_slices = total_slices
        self.received_slices = {} # 分片存储的地方
        self.received_data = b''  # 用于存储所有分片数据
    
    def add_slice(self, current_slice, file_data):
        self.received_slices[current_slice] = file_data
    
    def is_complete(self):
        return len(self.received_slices) == self.total_slices

    # 组装文件
    def assemble_pdf(self):
        if not self.is_complete():
            return None
        
        # Sort received slices by current slice number
        sorted_slices = sorted(self.received_slices.items(), key=lambda x: x[0])
        
        for _, slice_data in sorted_slices:
            b64 = slice_data.split(",", 1) # 拿掉base64的头,只要后面的编码本体
            self.received_data += base64.b64decode(b64[1])
        
        output_path = self.file_name
        with open(output_path, "wb") as output_file:
            output_file.write(self.received_data)
        
        return output_path

python接收websocket的部分省略了,处理消息的部分如下:

async def on_message(self, message):
    print("===== get_message =====")

    data = tornado.escape.json_decode(message)
    file_name = data.get('fileName')
    current_slice = data.get('currentSlice')
    total_slice = data.get('totalSlice')
    file_data = data.get('file')

    if file_name not in self.assemblers:
        self.assemblers[file_name] = PdfAssembler(file_name, total_slice)

    assembler = self.assemblers[file_name]
    assembler.add_slice(current_slice, file_data) // 存储分片

    if assembler.is_complete():
        assembled_pdf_path = assembler.assemble_pdf()
        if assembled_pdf_path:
            print(f"PDF assembled: {assembled_pdf_path}")
            # 可以在这里执行任何其他操作,例如将文件发送给客户端
            # 然后清理已完成的 assembler
            del self.assemblers[file_name]

    custom_data = {"data": "your response"}
    self.write_message(custom_data)

完结。