大文件上传

609 阅读4分钟

大文件上传

这个仓库实现了三个功能

文件的分片上传,附上文件的哈希 ,最后上传完实现合并的功能

文件的秒传功能

前端方面

对于前端我们采用的是简单的html的形式实现的一个简单的文件上传

文件分片

这里我们采用一个简单的while循环 和slice做一个简单的分串

//分片文件
const createChunks = (file) => {
    const chunks = []
    let start = 0
    let index = 0
    while(start < file.size){
        let curChunk = file.slice(start,start+chunkSize)
        chunks.push({
            file:curChunk,
            uploaded:false,
            fileHash: fileHash,
            chunkIndex:index
        })
        index++
        start += chunkSize
    }
    return chunks
}

文件上传

这里我们使用upload接口上传分片,

// 单个文件上传
const uploadHandler = (chunk) => {
    return new Promise(async (resolve, reject) => {
        try {
            let fd = new FormData();
            fd.append('file', chunk.file);
            fd.append('fileHash', chunk.fileHash);
            fd.append('chunkIndex', chunk.chunkIndex);
            let result = await fetch('http://localhost:3000/upload', {
                method: 'POST',
                body: fd
            }).then(res => res.json());
            chunk.uploaded = true;
            resolve(result)
        } catch (err) {
            reject(err)
        }
    })
}

使用promise.all发送多个请求,通过将分片将文件分成一个分片数组,然后使用参数控制最大请求数 然后就是通过最大请求数 将分片分组 然后是使用分组发送请求

// 批量上传切片
const uploadChunks = (chunks, maxRequest = 6) => {
    return new Promise((resolve, reject) => {
        if (chunks.length == 0) {
            resolve([]);
        }
        let requestSliceArr = []
        let start = 0;
        while (start < chunks.length) {
            requestSliceArr.push(chunks.slice(start, start + maxRequest))
            start += maxRequest;
        }
        let index = 0;
        let requestReaults = [];
        let requestErrReaults = [];
​
        const request = async () => {
            if (index > requestSliceArr.length - 1) {
                resolve(requestReaults)
                return;
            }
            let sliceChunks = requestSliceArr[index];
            Promise.all(
                sliceChunks.map(chunk => uploadHandler(chunk))
            ).then((res) => {
                requestReaults.push(...(Array.isArray(res) ? res : []))
                index++;
                request()
            }).catch((err) => {
                requestErrReaults.push(...(Array.isArray(err) ? err : []))
                reject(requestErrReaults)
            })
        }
        request()
    })
}

这里的分片哈希我们使用spark-md5来计算,这里因为是简单的展示,所以计算的哈希都是文件的哈希,所以每一个分片的哈希也是文件的哈希

const getHash = (file) => {
    return new Promise((resolve, reject) => {
        if (!(file instanceof File)) {
            reject(new Error('Invalid file object'));
            return;
        }
​
        const fileReader = new FileReader();
        fileReader.onload = function (e) {
            try {
                let fileMd5 = SparkMD5.ArrayBuffer.hash(e.target.result);
                resolve(fileMd5);
            } catch (error) {
                reject(error);
            }
        };
        fileReader.onerror = function (e) {
            reject(new Error('File reading error'));
        };
​
        fileReader.readAsArrayBuffer(file);
    });
}

文件合并

这里使用的是直接在文件分片上传完之后直接调用后台让后台直接合并分片

文件秒传

在这里我们其实是每次文件上传之前先计算一下文件的哈希,发送一个预请求 如果这个哈希在后台已经存在了,就返回文件上传成功了

//文件上传
const uploadFile = async (file) => {
    fileName = file.name
    fileHash = await getHash(file)
    //校验文件是否已经上传
    let { exitFile } = await verify(fileHash,fileName)
    if(exitFile){
        console.log("文件已上传(秒传)")
        return {
            msg:"文件已上传(秒传)",
            success:true
        }
    }
    //获取切片
    chunks = createChunks(file)
    try{
        await uploadChunks(chunks)
        const res = await mereRequest(fileHash,fileName)
        console.log(res)
    }catch(err){
        return {
            msg:"上传失败",
            err:err,
            success:false
        }
    }
}

后端方面

对于后台我们采用的node实现的后台

后台方面我们主要实现文件上传,文件合并,检测目标文件是否存在

文件上传

这里我们首先在在一个我们的文件哈希做一个临时文件里存储我们的分片文件,并且用分片的index做文件名 将我们的分片存储在临时文件夹里面

const upload = multer({ dest: 'static/uploads' }); // 指定存储路径// 预定义上传文件的基础目录
const uploadFilesDir = path.join(__dirname, 'uploadFiles');
fse.ensureDirSync(uploadFilesDir); // 确保基础目录存在
​
app.post('/upload', upload.single('file'), (req, res) => {
    // 参数验证
    const { fileHash, chunkIndex } = req.body;
    console.log(req.body)
    if (!fileHash || !chunkIndex) {
        return res.status(400).send({ msg: '参数不完整', success: false });
    }
​
    // 临时文件夹路径
    const tempFileDir = path.join(uploadFilesDir, fileHash);
    const tempChunkPath = path.join(tempFileDir, chunkIndex.toString());
​
    try {
        // 确保临时文件夹存在
        fse.ensureDirSync(tempFileDir);
​
        // 检查并移动分片文件
        if (!fse.existsSync(tempChunkPath)) {
            fse.moveSync(req.file.path, tempChunkPath);
        } else {
            fse.removeSync(req.file.path);
        }
​
        res.send({ msg: '上传成功', success: true });
    } catch (error) {
        console.error('上传失败:', error);
        res.status(500).send({ msg: '上传失败', success: false, error: error.message });
    }
});

文件合并

这里我们通过传过来的哈希确定临时文件夹,然后读取里面的切片追加到文件中,然后将临时文件夹和切片文件删除掉

从而将文件保存到文件哈希中

app.get('/merge', async (req, res) => {
    const { fileHash, fileName } = req.query;
    if (!fileHash || !fileName) {
        return res.status(400).send({ msg: "缺少必要的参数", success: false });
    }
​
    const filePath = path.join('uploadFiles', fileHash + path.extname(fileName));
    const tempFileDir = path.join('uploadFiles', fileHash);
​
    try {
        // 确保临时文件夹存在
        const uploadDir = path.resolve(tempFileDir);
        if (!await fse.pathExists(uploadDir)) {
            throw new Error(`目录不存在: ${uploadDir}`);
        }
​
        // 读取临时文件夹,获取所有切片
        const chunkPaths = await fse.promises.readdir(uploadDir);
​
        if (chunkPaths.length === 0) {
            throw new Error("没有找到文件分片");
        }
​
        console.log('chunkPaths:', chunkPaths);
​
        // 将切片追加到文件中
        const mergeTasks = chunkPaths.map(chunkFileName => {
            const chunkPath = path.join(uploadDir, chunkFileName);
            return fse.promises.appendFile(filePath, fse.createReadStream(chunkPath));
        });
​
        // 等待所有切片追加完成
        await Promise.all(mergeTasks);
​
        // 删除每个分片
        for (const chunkPath of chunkPaths.map(chunkFileName => path.join(uploadDir, chunkFileName))) {
            fse.unlinkSync(chunkPath);
        }
​
        // 删除临时文件夹
        fse.rmdirSync(uploadDir);
​
        res.send({
            msg: "合并成功",
            success: true,
            mergedFilePath: filePath // 返回合并后的文件路径
        });
    } catch (error) {
        console.error('合并文件失败:', error);
        res.status(500).send({ msg: "合并文件失败", success: false, error: error.message });
    }
});

检测目标文件是否存在

这里我们直接将文件哈希拼出最终的哈希然后直接判断这个文件是否存在即可

//大文件秒传
//检测目标文件是否存在
app.get('/verify',(req,res)=>{
    const {fileHash,fileName} = req.query
    const filePath = path.resolve('uploadFiles', fileHash + path.extname(fileName))
    const exitFile = fse.pathExistsSync(filePath)
    res.send({
        exitFile
    })
})

至此一个简单的大文件上传完成

待优化点

计算文件哈希可以优化 (Webwork) 然后直接将文件分片上传

并发请求这个可以维护一个队列 提高效率

断点回复

中断上传

回复上传

文件丢失

参考资料 juejin.cn/post/732388…