文件上传下载攻略,断点续传等等那些事儿

4,002 阅读4分钟

前言

一开始在知乎发文章,后来感觉知乎平台不太合适我,于是就来掘金啦。

文件上传基本流程

前端通过input获取到用户选择的文件,放入FormData中,设置 content-typemultipart/form-data发送给服务端。服务端通过cookie/token/...等等信息,再对文件/数据库进行处理操作。

好像没啥讲的,直接上代码吧。

<input type="file" onChange={handleFileChange} ref={inputRef} multiple />

const files = inputRef.current?.files;
// 获取到file数组,对数组处理,然后得到file

const formData = new FormData();
formData.append('file', file);
formData.append('xx', xx);
// 可以带上一些信息

const { code } = await uploadFiles(formData);

服务端我用的是node express,然后使用multer去处理multipart/form-data类型的表单数据。multer传送门
import multer from 'multer';

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, './dist')
  },
  filename: (req, file, cb) => {
    cb(null, `${(+ new Date())}${file.originalname}`)
  }
});

const upload = multer({ storage });
app.use(upload.single('file'));
// 我这边是单文件处理,对应这req.file;如果要多文件,那就是array,对应req.files

router.post('/upload', async (req, res) => {
    const { file } = req;
    // to do sth.
    // 目录转移的话,可以用fs.renameSync/fs.rename
});

拖拽上传

拖拽上传就是利用onDroponDragOver,阻止浏览器默认事件,然后就得到对应文件辽。

<div onDrop={onDrop} onDragOver={onDragOver} ></div>

const onDragOver = (e: DragEvent | React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
};
const onDrop = (e: DragEvent | React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    handleFileChange(e.dataTransfer?.files);
};

大文件上传

大文件上传,可能会出现,上传时间过长,接口限制了文件大小。所以,大文件直接上传,也很不友好,一般采用分片上传的方式去上传。而blob提供了slice方法, file继承了blob自然也能使用slice去进行分片处理。

处理流程:

  • 前端对大文件进行分片,分片名采用文件hash后续会讲下标
  • 为了防止完全占用tcp资源,我这里是采用4个4个上传
  • 在最后,再发送一个合并请求
  • 服务端根据请求,对前面的分片内容进行合并成一个整体文件,然后删除分片
const handleUpload = () => {
    chunks = [];

    const file: File = files.current[dataIndex];
    // 获取对应文件file
    let start = 0, end = BIG_FILE_SIZE;
    
    // const BIG_FILE_SIZE = 1024 * 1024 * 2;
    while(true) {
        const part: Blob = file.slice(start, end);
        if (part.size) {
            chunks.push(part);
        } else {
            break;
        }
        start += BIG_FILE_SIZE;
        end += BIG_FILE_SIZE;
    };
    
    // worker!.postMessage({ chunkList: chunks });
    // 利用webworker获取hash,后面会讲
    return;
};

const partUpload = async (start: number) => {
    const uploadArr = [];
    let restReq = MAX_FILE_TCP;
    // MAX_FILE_TCP = 4;同时发起4个连接
    let index = start;
    while (restReq) {
        if (index >= chunkCount) {
        // chunkCount是chunk的length,即多少个片段
            break;
        };
        
        // const blobPart = `${hash}-${index}`;
        // if (hashData[hash] && hashData[hash][blobPart]) 
        // {
        //     index++;
        //     continue;
        // };
        // 注释部分是,断点续传部分代码,可跳过
        
        const formData = new FormData();
        formData.append('file', chunks[index]);
        formData.append('xx', xx);
        
        const uploadFunc = () => {
            return new Promise(async (res) => {
                const { code } = await uploadPart(formData);
                res(code);
            });
        };
        
        uploadArr.push(uploadFunc);
        index++;
        restReq--;
    };
    
    const result = await Promise.all(uploadArr.map(v => v()));
    
    result.map((v) => {
      if (v === 0) {
        console.log('上传片段成功');
      } else {
        throw new Error('上传失败');
      }
      return null;
    });
    
    if (index < chunkCount) {
      partUpload(index);
    } else {
        const params = {
            // sth.
        };
        const {code} = await partMerge(params);
        // 发送合并请求
        // todo code sth.
    }
};

服务端的话,我这边是把文件根据对应的hash和下标进行命名,即static/hash/hash-i。利用fs.rename去修改文件&路径, 通过pipe合并文件。

router.post('/upload_part', (req, res) => {
    try {
        const { hash, index } = req.body;
        const { file } = req;
        const sourcePath = path.join(__dirname, file.path);
        const destPath = path.join(__dirname, `xxxx/${hash}/${hash}-${index}`);
        fs.renameSync(sourcePath, destPath);
        return res.json({code: 0});
    } catch(e) {
        return res.json({code: 1, msg: e});
    }
});

router.post('/merge_part', (req, res) => {
    try {
        const destPath = 'xxx/yyy/zzz/a.png';
        const writePath = fs.createWriteStream(destPath);
        // 最终合并结果存储在哪
        const fileMerge = (i: number) => {
            const blobPath = `xxx/part/${hash}/${hash}-${i}`;
            const blobFile = fs.createReadStream(blobPath);
            blobFile.on("end", async (err) => {
                if (err) {
                    return res.json({code: 1, msg: err});
                };
                fs.unlink(blobPath);
                // 删除片段
                if (i + 1 < chunkCount) {
                    fileMerge(i + 1);
                } else {
                    fs.rmdirSync(`xxx/part/${hash}`);
                    // 删除文件夹
                    // 数据库操作 todo
                    return res.json({ code: 0 });
                }
            });
            blobFile.pipe(writeStream, { end: false });
        };
        fileMerge(0);
    } catch(e) {
        return res.json({code: 1, msg: e});
    }
});
// 省略了不必要内容

断点续传

大文件分片上传,如果客户端发生异常中断了上传过程,那么下次重新上传的时候,比较友好的做法应该是跳过那些已经上传的片段。 那么问题也就是,怎么跳过那些文件?刚才前面的代码,也显示了,其实就是通过${hash}-${i}设置文件名,然后保存已经上传成功的片段数据,在文件分片上传的时候,跳过已经上传过的片段,就是断点续传辽。 对于这类数据存储,一般都是两个方法:

  • 前端存储
  • 服务端存储

前端存储的话,一般就是用localStorage,不太推荐使用。因为用户如果清了缓存,或者换了设备登陆,就无法生效。

服务端的话,就返回对应的已成功上传的片段名。因为对应userId下的文件hash-i,是唯一的。node这里就采用readdirSync/readdir去读取文件名辽。

前端这里就是前面提到过的部分。

const blobPart = `${hash}-${index}`;
if (hashData[hash] && hashData[hash][blobPart]) 
{
    // hashData是服务端传回来的数据,判断片段是否存在,存在就跳过
    // 具体可以看看前面
    index++;
    continue;
};

WebWoker获取hash

那么就剩下一个问题辽,去获取文件的hash。推荐使用spark-md5去生成文件的hash。因为生成hash过程是比较耗时间的,我这边采用了webworker去计算hash

webwoker的基础知识传送门

webworker我这边是直接是将webworker的代码写到html上面,然后利用Blob & URL.createObjectURL去本地创建webwoker

<script id="worker" type="app/worker">
self.importScripts("https://cdn.bootcss.com/spark-md5/3.0.0/spark-md5.min.js");

self.onmessage = function(e) {
    var spark = new self.SparkMD5.ArrayBuffer();
    var chunkList = e.data.chunkList;
    var count = 0;
    var next = function(index) {
        var fileReader = new FileReader();
        fileReader.readAsArrayBuffer(chunkList[index]);
        fileReader.onload = function(e) {
            count ++;
            spark.append(e.target.result);
            if (count === chunkList.length) {
              self.postMessage({
                hash: spark.end()
              });
            } else {
              loadNext(count);
            }  
        };
    };
    next(0);
};
</script>

然后在hook函数里调用createObjectURL去生成DOMString,作为Worker的参数。

const newWebWorker = () => {
  const textContent = document.getElementById('worker')!.textContent;
  const blob = new Blob([textContent as BlobPart]);
  createURL = window.URL.createObjectURL(blob);
  worker = new Worker(createURL);
  worker.onmessage = (e) => {
    hash = e.data.hash;
    // 这里获取到hash
    // todo sth.
    partUpload(0);
    // 进行文件片段上传操作
  };
};

这里利用createObjectURL生成URL后,使用完,需要使用revokeObjectURL去释放

文件下载

文件下载方法还是挺多的,form表单呀,open呀,直接a标签呀,blob方式呀等等。我这边采用的是利用blob去下载文件,主要考虑到,可以进行鉴权操作&可以下载各种类型文件等等。

过程就是

  • 服务端返回Blob
  • 前端这里通过createObjectURL生成DOMString
  • 设置a标签的href然后执行它的点击事件
{
    url
      ? <a ref={aRef} style={{ display: 'none' }} href={url} download={data.file_name} />
      : null
}
// download就是你文件下载下来的文件名

const onDownload = async () => {
    const blob = await getFileBlob({ id: data.id, type: data.type });
    const url = window.URL.createObjectURL(blob);
    setUrl(url);
};

useEffect(() => {
    if (url) {
        aRef.current?.click();
        window.URL.revokeObjectURL(url);
        setUrl('');
    }
}, [url]);

export const getFileBlob = async (params: GetFileBlob) => {
  const { data } = await api.get<Data>('/get_file_blob', {
    params,
    responseType: 'blob'
    // responseType需要设置blob
  });
  return data;
};

node这里就用createReadStream读取文件,需要设置Content-Type和Content-Disposition

router.get('/get_file_blob', async (req, res) => {
    // todo sth.
    const destPath = 'xxx/xxx';
    const fileStream = fs.createReadStream(destPath);
    res.setHeader('Content-Type', 'application/octet-stream');
    res.setHeader('Content-Disposition', `attachment;filename=whatareudoing`);
    // 前面a标签已经设置了download,这里的filename不会有影响
    fileStream.on("data", (chunk) => res.write(chunk, "binary"));
    fileStream.on("end", () => res.end());
});

进度条

进度条就是ProgressEvent,如果是断点续传,就先对片段进行遍历判断是否有已经上传过的,然后就得到一个初始进度。

const onUploadProgress = (event: ProgressEvent) => {
    const progress = Math.floor((event.loaded / event.total) * 100);
    // todo sth.
};

export const uploadFiles = async ({ formData, onUploadProgress }: UploadFiles) => {
  const { data } = await api.post<Data>('/upload', formData, { onUploadProgress });
  //...
};

获取视频文件缩略图

如果是视频类文件,那么很多情况下都需要获取视频文件缩略图。有两个办法,一个是在前端获取,用canvas可获取;另一个则是服务端自己获取。

我这边用的是node利用ffmpeg去获取视频的第一帧作为缩略图保存。

const getVideoSceenshots = (videoPath: string, outPutPath: string, outPutName: string) => {
  const process = new ffmpeg(videoPath);
  process.then((video) => {
    video.fnExtractFrameToJPG(outPutPath, {
      frame_rate: 1,
      number: 1,
      file_name: outPutName
    }, error => {
      if (error) {
        console.log('error: ' + error)
      }
    })
  }, err => {
    console.log('Error: ' + err)
  })
};

// videoPath视频文件路径,outPutPath缩略图输出路径,outPutName输出名