本文由 简悦 SimpRead 转码, 原文地址 zhuanlan.zhihu.com

本文代码已上传 Gitee带只拖鞋去流浪 / 基于 express 大文件上传 dev 分支

优化代码:git checkout dev2 git pull origin dev2

思路:假如你要搬一个组装式的大桌子,桌子太大只能拆解成一块块搬过去再组装起来。

  1. 创建 express 服务
  2. 前端
  3. 服务端
  4. 切片优化
  5. 并发量优化
  6. 断点续传

一、创建 express 服务

I. 初始化

mkdir test && cd test
npm init -y
yarn add express
yarn add nodemon      # node 热更新 或者使用 supervisor (yarn add supervisor)
yarn add cli-color    # 输出命令行颜色

II. 创建服务

const express = require('express');
const clc = require('cli-color');

const server = express();

server.get('/', (req, res) => {
    res.send('hi,express.');
});

server.listen(3000, _ => {
    console.log(clc.bold.blue.underline('http://localhost:3000/'));
    console.log(clc.bold.blue.underline('http://127.0.0.1:3000/'));
});

III. 配置启动

a. 如果使用 nodemon

// package.json
{
  "scripts": {
    "serve": "nodemon ./src/index.js"
  }
}

b. 如果使用 supervisor

将上面的 nodemon 改成 supervisor 就可以了。

IV. 测试

二、前端

I. HTML

<div id="app">
    <form action="">
        <input type="file" >
        <button id="uploadBtn">上传</button>
    </form>
</div>

II. 绑定事件

// 文件被更改
function handleFileChange(event) { ... }

// 大文件上传
async function handleFileUpload(event) { ... }

document.getElementById('uploadInput').addEventListener('change', handleFileChange);
document.getElementById('uploadBtn').addEventListener('click', handleFileUpload);

III. 文件被更改 handleFileChange

var file = null;

// 文件被更改
function handleFileChange(event) {
    const file = event.target.files[0];
    if (!file) return;
    window.file = file;
}

IV. 大文件上传

// 大文件上传
async function handleFileUpload(event) {
    event.preventDefault();

    const file = window.file;
    if (!file) return;

    // 创建切片
    const createFileChunks = function (file, size = SIZE) { ... }

    // 上传切片
    const uploadFileChunks = async function (fileChunks, filename) { ... }

    // 合并切片
    const mergeFileChunks = async function (filename) { ... }

    const fileChunks = createFileChunks(file);
    await uploadFileChunks(fileChunks, file.name);
    await mergeFileChunks(file.name);

    console.log('上传完成');
}

V. 创建切片

const SIZE = 1024 * 1024 * 10; // 10MB 切片大小
 
// 创建切片
const createFileChunks = function (file, size = SIZE) {
    let fileChunks = [];
    for(let cur = 0; cur < file.size; cur += size){
        fileChunks.push(file.slice(cur, cur + size));
    }
    return fileChunks;
}

VI. 上传切片

a. 引入 axios(CDN)

<script src="https://unpkg.com/axios/dist/axios.min.js"></script>

b. 配置 axios.defaults.baseURL

axios.defaults.baseURL = `http://localhost:3000`;

c. uploadFileChunks

// 上传切片
const uploadFileChunks = async function (fileChunks, filename) {

    const chunksList = fileChunks.map((chunk, index) => {
        let formData = new FormData();
        formData.append('filename', filename);
        formData.append('hash', index);
        formData.append('chunk', chunk);
        return {
            formData
        };
    });

    const uploadList = chunksList.map(({
        formData
    }) => axios({
        method: 'post',
        url: '/upload',
        data: formData
    }));

    await Promise.all(uploadList);
}

VII. 合并切片

// 合并切片
const mergeFileChunks = async function (filename) {
    await axios({
        method: 'get',
        url: '/merge',
        params: {
            filename
        }
    });
}

三、服务端

I. 切片上传 server:post('/upload')

a. multiparty

b. events 事件触发器 | Node.js API 文档

c. fs 文件系统 | Node.js API 文档

d. stream 流 | Node.js API 文档

const multiparty = require('multiparty');
const EventEmitter = require('events');
const fs = require('fs');
const path = require('path');

const STATIC_TEMPORARY = path.resolve(__dirname, '../static/temporary');

server.post('/upload', (req, res) => {

    const multipart = new multiparty.Form();
    const myEmitter = new EventEmitter();

    const formData = {
        filename: undefined,
        hash: undefined,
        chunk: undefined,
    }

    let isFieldOk = false, isFileOk = false;

    multipart.parse(req, function (err, fields, files) {
        formData.filename = fields['filename'][0];
        formData.hash = fields['hash'][0];

        isFieldOk = true;
        myEmitter.emit('start');
    });

    multipart.on('file', function (name, file) {
        formData.chunk = file;
        isFileOk = true;
        myEmitter.emit('start');
    });

    myEmitter.on('start', function () {
        if (isFieldOk && isFileOk) {
            const { filename, hash, chunk } = formData;
            const dir = `${STATIC_TEMPORARY}/${filename}`;

            try {
                if (!fs.existsSync(dir)) fs.mkdirSync(dir);

                const buffer = fs.readFileSync(chunk.path);
                const ws = fs.createWriteStream(`${dir}/${hash}`);
                ws.write(buffer);
                ws.close();

                res.send(`${filename}-${hash} 切片上传成功`)
            } catch (error) {
                console.error(error);
            }

            isFieldOk = false;
            isFileOk = false;

        }
    });
});

II. 合并切片

const { Buffer } = require('buffer');
const STATIC_FILES = path.resolve(__dirname, '../static/files');

server.get('/merge', async (req, res) => {
    const { filename } = req.query;

    try {
        let len = 0;
        const bufferList = fs.readdirSync(`${STATIC_TEMPORARY}/${filename}`).map(hash => {
            const buffer = fs.readFileSync(`${STATIC_TEMPORARY}/${filename}/${hash}`);
            len += buffer.length;
            return buffer;
        });

        const buffer = Buffer.concat(bufferList, len);
        const ws = fs.createWriteStream(`${STATIC_FILES}/${filename}`);
        ws.write(buffer);
        ws.close();

        res.send(`切片合并完成`);
    } catch (error) {
        console.error(error);
    }
})

最后,切片合并完成之后需要删除 temporary 临时存放的切片,删除整个文件夹

function deleteFolder(filepath) {
    if (fs.existsSync(filepath)) {
        fs.readdirSync(filepath).forEach(filename => {
            const fp = `${filepath}/${filename}`;
            if (fs.statSync(fp).isDirectory()) deleteFolder(fp);
            else fs.unlinkSync(fp);
        });
        fs.rmdirSync(filepath);
    }
}

四、切片优化

分治思想,递归切片

// 递归切片(切出来不全都是 SIZE)
function createFileChunks(file, size = SIZE) {
-   // const fileChunks = [];
-   // for (let cur = 0; cur < file.size; cur += size)
-   //    fileChunks.push(file.slice(cur, cur + size));
-   // return fileChunks;
    
+   const count = file.size;
+   if(count <= size) return [file];
+   const mid = count / 2;
+   const leftChunk = fileChunk.slice(0, mid);
+   const rightChunk = fileChunk.slice(mid);
+   return [...createFileChunks(leftChunk), ...createFileChunks(rightChunk)];
}

五、并发量优化

每次并发请求数量控制在 10 次以内

// 上传切片
const uploadFileChunks = async function (fileChunks, filename) {

+   for (let i = 0, len = axiosList.length; i < len; i += 10) {
+       const list = i + 10 < len ? axiosList.slice(i, i + 10) : axiosList.slice(i);
+       await Promise.all(list);
+   }

-   // await Promise.all(axiosList);
}

六、断点续传

递归续传

let failUploadDict = {};

// 上传切片
const uploadFileChunks = async function (formDataList, failUploadDict = {}) {
    if (!formDataList.length) return;

    const axiosList = formDataList.map(({
        formData
    }) => {
        const index = formData.get('index');
        return axios({
            method: 'post',
            url: `/upload`,
            data: formData
        }).then(res => {
            console.log(res.data);
            delete failUploadDict[index];
        }).catch(err => {
            console.error(err);
            failUploadDict[index] = formData;
        })
    });

    for (let i = 0, len = axiosList.length; i < len; i += 10) {
        const list = i + 10 < len ? axiosList.slice(i, i + 10) : axiosList.slice(i);
        await Promise.all(list);
    }

    // await Promise.all(axiosList);

    formDataList = Object.values(failUploadDict), failUploadDict = {};
    return uploadFileChunks(formDataList);
}

带只拖鞋去流浪的个人空间_哔哩哔哩_Bilibili带只拖鞋去流浪的博客_CSDN 博客 - 渗透测试, java 基础, leetcode 领域博主带只拖鞋去流浪 - 简书​www.jianshu.com/u/45339cbb7573

警告

商业转载请联系本人,非商业转载请注明出处。