Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

文件上传 #70

Open
peng-yin opened this issue Jul 9, 2021 · 0 comments
Open

文件上传 #70

peng-yin opened this issue Jul 9, 2021 · 0 comments

Comments

@peng-yin
Copy link
Owner

peng-yin commented Jul 9, 2021

基本流程

前端通过input获取到用户选择的文件,放入FormData中,设置 content-type 为 multipart/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
});

大文件上传

大文件上传,可能会出现,上传时间过长,接口限制了文件大小。所以,大文件直接上传,也很不友好,一般采用分片上传的方式去上传。而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});
    }
});
// 省略了不必要内容

分片上传的思路如下:

第一步:先对文件进行MD5的加密, 这样有两个好处, 即可以对文件进行唯一的标识, 为秒传做准备, 也可以为后台进行文件完整性的校验进行比对

第二步:拿到MD5值以后, 要查询一下, 这个文件是否已经上传过了, 如果上传过了, 就不用再次重复上传, 也就是能够秒传, 网盘里的秒传, 原理也是一样的

第三步:对文件进行切片, 假如文件是500M, 一个切片大小我们定义为50M, 那么整个文件就为分为100次上传

第四步:向后台请求一个接口, 接口里面的数据是该文件已经上传过的文件块, 为什么要有这个请求呢? 我们经常用网盘, 网盘里面有续传的功能, 一个文件传到一半, 由于各种原因, 不想再传了, 那么再次上传的时候, 服务器应该保留我之前上传过的文件块, 跳过这些已经上传过的块, 再次上传其他文件块, 当然续传方案有很多, 目前来看, 单独发一次请求, 这样效率最高

第五步:开始对未上传过的块进行POST上传

第六步:当上传成功后, 通知服务器进行文件的合并, 至此, 上传完成!

断点续传实现原理

  • 断点续传就是在上传一个文件的时候可以暂停掉上传中的文件,然后恢复上传时不需要重新上传整个文件。

  • 该功能实现流程是先把上传的文件进行切割,然后把切割之后的文件块发送到服务端,发送完毕之后通知服务端组合文件块。

  • 其中暂停上传功能就是前端取消掉文件块的上传请求,恢复上传则是把未上传的文件块重新上传。需要前后端配合完成。

前端主要分为:切割文件、获取文件MD5值、上传切割后的文件块、合并文件、暂停和恢复上传等功能。

切割文件:这个功能点在整个断点续传中属于比较重要的一环,这里仔细说明下。我们用ajax上传一个大文件用的时间会比较长,在上传途中如果取消掉请求,那在下一次上传时又要重新上传整个文件。而通过把大文件分解成若干个文件块去上传,这样在上传中取消请求,已经上传的文件块会保存到服务端,下一次上传就只需要上传其他没上传成功的文件块(不用传整个文件)。

这里把文件块放入一个fileChunkList数组,方便后面去获取文件的MD5值、上传文件块等。

// 使用HTML5的file.slice对文件进行切割,file.slice方法返回Blob对象
let start = 0;
while (start < file.size) {
  fileChunkList.push({ file: file.slice(start, start + CHUNK_SIZE) });
  start += CHUNK_SIZE;
}

获取文件MD5值:我们不能通过文件名来判断服务端是否存在上传的文件,因为用户上传的文件很可能会有重名的情况。所以应该通过文件内容来区分,这样就需要获取文件的MD5值。

使用spark-md5模块获取文件的MD5值。模块详情点击这里

// 部分代码展示
let spark = new SparkMD5.ArrayBuffer();
let fileReader = new FileReader();
fileReader.onload = e => {
        if (e.target && e.target.result) {
                count++;
                spark.append(e.target.result as ArrayBuffer);
        }
        if (count < totalCount) {
                loadNext();
        } else {
                resolve(spark.end());
        }
};
function loadNext() {
        fileReader.readAsArrayBuffer(fileChunkList[count].file);
}
loadNext();

上传切割后的文件块:根据前面的fileChunkList数组,使用FormData上传文件块。

// 部分代码展示
Axios.post(uploadChunkPath, formData, {
  headers: { 'Content-Type': 'multipart/form-data' },
  cancelToken: source.token,
}).then(()=>{
  // ...
})

合并文件:就是等所有文件块上传成功后发送ajax通知服务端,让服务端把文件块进行合并。

// 部分代码展示
Axios.get(mergeChunkPath, {
  params: {
          fileHash: targetFile,
          fileName,
  },
})

暂停功能:把上传文件块的请求放到一个数组里,请求完成的则从数组中删除;点击暂停的时候把数组里所有的请求暂停。

/* 文件块请求放入数组 */
const source = CancelToken.source();
// ...
axiosList.push(source);

/* 暂停请求 */
axiosList.forEach((item) => item.cancel('abort'));
axiosList.length = 0;
message.error('上传暂停');
  • 恢复上传:去服务端查询已经上传的文件块有哪些,然后上传没有上传成功的文件块。
// 部分代码展示
let uploadedFileInfo = await getFileChunks(this.fileName, this.fileMd5Value);
if (this.handleUploaded(uploadedFileInfo.fileExist) && uploadedFileInfo.chunkList) {
  this.uploadChunks(this.chunkListInfo, uploadedFileInfo.chunkList, this.fileName);
}

断点续传

大文件分片上传,如果客户端发生异常中断了上传过程,那么下次重新上传的时候,比较友好的做法应该是跳过那些已经上传的片段。
那么问题也就是,怎么跳过那些文件?刚才前面的代码,也显示了,其实就是通过${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。
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>

拖拽上传

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

<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);
};

文件下载

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

过程就是

  • 服务端返回Blob
  • 前端这里通过createObjectURL生成DOMString
  • 设置a标签的href然后执行它的点击事件

进度条

进度条就是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 });
  //...
};
@peng-yin peng-yin pinned this issue Jul 9, 2021
@peng-yin peng-yin unpinned this issue Jul 9, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant