NodeJs 全栈创建多文件断点续传

NodeJs 全栈创建多文件断点续传

文件上传,算是项目开发中比较常见的需求,本文将展示如何构建一个多文件断点续传组件,可以同时处理多个文件,并可以在出现异常或者网络中断的情况下恢复上传,可以手动暂停和恢复文件的上传。文章内容涉及前端和后端,算是一个小型的全栈项目,项目将使用 NodeJsExpressBusboyXMLHttpRequest,并使用自己开发的脚手架 generator-norm 来构建项目。

NodeJs 全栈创建多文件断点续传效果图

文章涉及的源代码在GitHub上,查看源代码

项目安装

这是一个 Node 项目,可以使用常规的方式进行设置,如果已经有一个项目,则可以继续执行该项目,完全没有问题。如果是全新开始,请执行以下操作:

  1. 下载并安装 Node,它会全局上安装 npm;
  2. 安装 Yeoman ,npm install -g yo ,并全局安装脚手架 npm install -g generator-norm
  3. 创建项目文件所在的文件夹;
  4. 打开终端并使用 cd 命令导航到项目目录,例如 cd multifile-uploader
  5. 运行命令 npm init -y 初始化 npm 项目,生成简单的 package.json
  6. 运行命令 yo norm 初始化项目基础依赖;
  7. 使用 npm install express --save 命令安装 express 模块。
  8. 运行 yarn start ,即可打开预览

工作原理

现在来了解这个系统是如何工作的,此应用程序有 2 个流程需要服务器和客户端之间的严格协调。

  • 上传流程:获取新文件,发送有关文件信息到服务器,服务器返回一个密钥(id),在发送文件块时需要使用该密钥(id),允许它跟踪文件并能够在以后发生中断时恢复它上传。

  • 恢复上传流程:向服务器查询提供的名称和密钥(id)的文件的状态,以便服务器可以响应上传停止的块大小,以便上传可以从该点继续。

还有第四个端点,用于获取所有待处理的文件密钥以恢复上传,以防上传停止并且想在几天后恢复。对于本教程,一旦上传并获取 ID,会将其保留在客户端以恢复它们,但是如果关闭浏览器选项卡,ID 将丢失,并且将无法恢复。

客户端

这里的客户端主要是WEB应用端。这个项目的HTML非常简单,修改文件 app/index.html ,下面是核心的代码。

<div class="row marketing">
    <div class="col-lg-12">
        <label class="upload-btn">
            上传文件
            <input
                type="file"
                multiple
                accept="video/*"
                id="file-upload-input"
                style="display: none"
            />
        </label>
    </div>
</div>

这里的重要细节是 input 属性必须具有 multiple 属性以允许用户选择多个文件,还可以选择使用 accept 属性标识允许上传的文件类型。

对于上传文件,通过 inputid 属性获取元素对象,并为其绑定事件 change 来监听用户文件的选择。

const elemFileInput = document.getElementById("file-upload-input");

elemFileInput.addEventListener("change", (e) => {
    // handle file here
});

下面代码定义了 uploadFiles 方法:

const uploadFiles = (() => {
    const URL = `http://localhost:3008/`;
    const ENDPOINTS = {
        UPLOAD: `${URL}upload`,
        UPLOAD_STATUS: `${URL}/upload-status`,
        UPLOAD_REQUEST: `${URL}/upload-request`,
    };
    const defaultOptions = {
        url: ENDPOINTS.UPLOAD,
        startingByte: 0,
        fileId: "",
        onAbort() {},
        onProgress() {},
        onError() {},
        onComplete() {},
    };
    return (files, options = defaultOptions) => {
        // handle file objects here
    };
})();

上面的代码返回一个函数,它接受一个文件列表和一个可选的选项对象来处理上传。涉及的三个 API 端点如下:

  • upload:传输文件块
  • upload-status:查询文件上传状态,如果开始上传和停止或有什么东西中断了,迫使选择一个文件再次上传,这将返回关于文件停止的位置、在中断之前上传了多少块的信息。
  • upload-request:通知服务器要上传的内容,以便服务器设置密钥并在上传开始时跟踪文件。

选项对上传的各个阶段或状态进行回调(中止、进度、错误和完成),起始字节是从要发起上传的文件流的哪个位置开始,文件 id 是标识文件的一种方法。

文件上传UI/UX处理

在服务器上设置上传端点之前,最好在客户机上处理它,因为这将帮助服务器端更有意义。

对于这一部分,需要一些处理UI的东西。这部分可以很容易地用任何UI库或框架来完成,比如Vue和Angular。

类似于 uploadFiles 函数,需要与 uploadFiles 交互并更新UI的 uploadAndTrackFilesuploadAndTrackFiles 是一个函数,它获取一个文件列表并调用uploadFiles,然后通过将页面体上的每个文件元素进度指示器附加到 progressBox 容器中来设置视图。

在内部,它还有所有的回调函数来跟踪和响应每个文件状态。

const uploadAndTrackFiles = (() => {
    const progressBox = document.createElement("div");
    let uploader = null;
    const setFileElement = (file) => {
        // create file element here
    };
    const onProgress = (e, file) => {};
    const onError = (e, file) => {};
    const onAbort = (e, file) => {};
    const onComplete = (e, file) => {};
    return (uploadedFiles) => {
        [...uploadedFiles].forEach(setFileElement);

        document.body.appendChild(progressBox);
        uploader = uploadFiles(uploadedFiles, {
            onProgress,
            onError,
            onAbort,
            onComplete,
        });
    };
})();

change 事件侦听器中调用 uploadAndTrackFiles 传递文件列表。

elemFileInput.addEventListener("change", (e) => {
    uploadAndTrackFiles(e.currentTarget.files);
    e.currentTarget.value = "";
});

现在要做的一件事是清除之后的 input 的值,这样浏览器就就不会阻止用户添加更多的文件。到目前为止,可以回到 uploadFiles 函数中处理上传。

文件上传逻辑

回到 uploadFiles 函数,循环遍历文件列表,并调用处理请求初始化的 uploadFile。发起请求后,将返回一个对象,返回的对象为公开函数,当使用文件调用该函数集。

const uploadFiles = (() => {
    const URL = `http://localhost:3008/`;
    const ENDPOINTS = {
        UPLOAD: `${URL}upload`,
        UPLOAD_STATUS: `${URL}/upload-status`,
        UPLOAD_REQUEST: `${URL}/upload-request`,
    };
    const defaultOptions = {
        url: ENDPOINTS.UPLOAD,
        startingByte: 0,
        fileId: "",
        onAbort() {},
        onProgress() {},
        onError() {},
        onComplete() {},
    };
    const fileRequests = new WeakMap();
    const uploadFile = (file, options) => {};
    const abortFileUpload = (file) => {};
    const retryFileUpload = (file) => {};
    const clearFileUpload = (file) => {};
    const resumeFileUpload = (file) => {};
    return (files, options = defaultOptions) => {
        [...files].forEach((file) => {
            uploadFile(file, { ...defaultOptions, ...options });
        });

        return {
            abortFileUpload,
            retryFileUpload,
            clearFileUpload,
            resumeFileUpload,
        };
    };
})();

因为可以同时上传多个文件,将定义 WeakMap (与Map相比,键名是弱引用,键值可以是任意的,键名所指向的对象可以被垃圾回收)类型的变量 fileRequests 来跟踪所有文件。

uploadFile 函数只是让服务器知道它应该期待一个文件很快被上传,并提供文件名。

const uploadFiles = (() => {
    const URL = `http://localhost:3008/`;
    const ENDPOINTS = {
        UPLOAD: `${URL}upload`,
        UPLOAD_STATUS: `${URL}/upload-status`,
        UPLOAD_REQUEST: `${URL}/upload-request`,
    };
    const defaultOptions = {
        url: ENDPOINTS.UPLOAD,
        startingByte: 0,
        fileId: "",
        onAbort() {},
        onProgress() {},
        onError() {},
        onComplete() {},
    };
    const fileRequests = new WeakMap();
    const uploadFile = (file, options) => {};
    const abortFileUpload = (file) => {};
    const retryFileUpload = (file) => {};
    const clearFileUpload = (file) => {};
    const resumeFileUpload = (file) => {};

    const uploadFileChunks = (file, options) => {};
    const uploadFile = (file, options) => {
        return fetch(ENDPOINTS.UPLOAD_REQUEST, {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify({
                fileName: file.name,
            }),
        })
            .then((res) => res.json())
            .then((res) => {
                options = { ...options, ...res };
                fileRequests.set(file, { request: null, options });

                uploadFileChunks(file, options);
            })
            .catch((e) => {
                options.onError({ ...e, file });
            });
    };

    return (files, options = defaultOptions) => {
        [...files].forEach((file) => {
            uploadFile(file, { ...defaultOptions, ...options });
        });

        return {
            abortFileUpload,
            retryFileUpload,
            clearFileUpload,
            resumeFileUpload,
        };
    };
})();

一旦服务器用文件 id 进行响应,它就会更新函数,并使用请求选项创建一个文件请求空记录,以备后续重试。一旦这些都设置好了,它就会调用带有文件和更新选项的uploadFileChunks,这些选项将实际处理上传。

在这里,首先初始化表单数据和请求,然后从起始字节点开始对文件进行切片。

文件是一个 Blob 类型,它让用 TypedArray 来切片文件字节,这就是如何跟踪从哪一点开始上传文件服务器会负责把文件一块一块地放在一起。

const uploadFileChunks = (file, options) => {
    const formData = new FormData();
    const req = new XMLHttpRequest();
    const chunk = file.slice(options.startingByte);

    formData.append("chunk", chunk, file.name);
    formData.append("fileId", options.fileId);

    req.open("POST", options.url, true);
    req.setRequestHeader(
        "Content-Range",
        `bytes=${options.startingByte}-${options.startingByte + chunk.size}/${
            file.size
        }`
    );
    req.setRequestHeader("X-File-Id", options.fileId);

    req.onload = (e) => {
        //当请求状态不是200时被调用
        //这将只把 200 视为成功,其他的都是失败
        if (req.status === 200) {
            options.onComplete(e, file);
        } else {
            options.onError(e, file);
        }
    };

    req.upload.onprogress = (e) => {
        const loaded = options.startingByte + e.loaded;
        options.onProgress(
            {
                ...e,
                loaded,
                total: file.size,
                percentage: (loaded * 100) / file.size,
            },
            file
        );
    };
    req.ontimeout = (e) => options.onError(e, file);
    req.onabort = (e) => options.onAbort(e, file);
    req.onerror = (e) => options.onError(e, file);
    fileRequests.get(file).request = req;
    req.send(formData);
};

然后,通过提供块的名称(即文件名)和文件id与服务器通信,并通过在将要发送的表单数据中设置这些属性。

当使用 XMLHttpRequest 时,需要打开一个请求,在这里发起一个POST请求,设置一些头信息。

  • Content-Range :这是与服务器通信的方式发送的文件字节的范围。该信息对服务器很重要,以便知道如何将文件放回一起并验证请求。
  • X-File-Id:传递文件id 的另一种方式。

一旦设置好请求,就用传递事件和文件Blob的选项回调来映射上传进度事件。

  • 一旦上传完成,就会触发 load 事件。
  • 当请求接收到更多数据时,就会触发 progress 事件,从那里可以提取到目前为止上传的总字节数,并计算上传进度百分比。
  • timeouterror 将被视为失败;
  • 当触发中止以暂停上传时,中止 将被触发。

接下来要做的就是使用 WeakMap 中的实际请求更新文件请求并发送表单数据。

文件恢复上传逻辑

恢复上传的逻辑要简单得多,这里,所要做的就是使用提供的文件从 WeakMap 中获取文件请求,并将文件id和名称作为查询参数传递到服务器上查询该文件的上传状态。

有了状态(之前上传的总字节)后,调用 uploadFileChunks 函数,提供开始上传的字节。

const resumeFileUpload = (file) => {
    const fileReq = fileRequests.get(file);

    if (fileReq) {
        return fetch(
            `${ENDPOINTS.UPLOAD_STATUS}?fileName=${file.name}&fileId=${fileReq.options.fileId}`
        )
            .then((res) => res.json())
            .then((res) => {
                uploadFileChunks(file, {
                    ...fileReq.options,
                    startingByte: Number(res.totalChunkUploaded),
                });
            })
            .catch((e) => {
                fileReq.options.onError({ ...e, file });
            });
    }
};

文件重试上传逻辑

文件上传过程中可能会出错,也许网络失去了连接,服务器崩溃了等等。

其逻辑与恢复上传逻辑几乎相同,唯一的区别是在 catch 块上,重新启动上传,以防文件从未开始上传,并且服务器不知道它。

const retryFileUpload = (file) => {
    const fileReq = fileRequests.get(file);

    if (fileReq) {
        // try to get the status just in case it failed mid upload
        return fetch(
            `${ENDPOINTS.UPLOAD_STATUS}?fileName=${file.name}&fileId=${fileReq.options.fileId}`
        )
            .then((res) => res.json())
            .then((res) => {
                // if uploaded we continue
                uploadFileChunks(file, {
                    ...fileReq.options,
                    startingByte: Number(res.totalChunkUploaded),
                });
            })
            .catch(() => {
                // if never uploaded we start
                uploadFileChunks(file, fileReq.options);
            });
    }
};

文件暂停上传逻辑

要暂停当前上传的文件,需要中止请求。除了抓取文件上传进度,中止请求的能力是使用 XMLHttpRequest 的第二个原因。

当使用文件调用这个函数时,从 WeakMap 中获取请求,并使用请求调用 abort 函数来停止上传。

const abortFileUpload = async (file) => {
    const fileReq = fileRequests.get(file);

    if (fileReq && fileReq.request) {
        fileReq.request.abort();
        return true;
    }

    return false;
};

这将简单地停止向服务器发送块,然后可以通过调用 resumeFileUpload 函数继续上传。

文件清除逻辑

在上传完成时或之后清除或取消上传,它只会中止请求并清除它,执行清除操作后将无法继续恢复上传。

const clearFileUpload = async (file) => {
    const fileReq = fileRequests.get(file);

    if (fileReq) {
        await abortFileUpload(file);
        fileRequests.delete(file);

        return true;
    }

    return false;
};

上传进度

uploadAndTrackFiles 函数,可以定义进度框的内部HTML,它将显示当前有多少文件正在上传,并提供一个上传进度恢复表,告诉有多少文件失败了、成功了、暂停了等等。

还有一个最大化按钮,当点击将展开或折叠上传程序。紧接着,有一个总的文件上传进度条和一个容器,将放置每个文件指示元素和单独的状态和控件。

const uploadAndTrackFiles = (() => {
    const progressBox = document.createElement("div");
    let uploader = null;
    progressBox.className = "upload-progress-tracker";
    progressBox.innerHTML = `
	<h3>Uploading 0 Files</h3>
	<p class="upload-progress">
	    <span class="uploads-percentage">0%</span>
	    <span class="success-count">0</span>
	    <span class="failed-count">0</span>
	    <span class="paused-count">0</span>
	</p>
	<button type="button" 
                class="maximize-btn">Maximize</button>
	<div class="uploads-progress-bar" style="width: 0;"></div>
	<div class="file-progress-wrapper"></div>
    `;

    progressBox
        .querySelector(".maximize-btn")
        .addEventListener("click", (e) => {
            e.currentTarget.classList.toggle("expanded");
            progressBox.classList.toggle("expanded");
        });
    const setFileElement = (file) => {
        // create file element here
    };
    const onProgress = (e, file) => {};
    const onError = (e, file) => {};
    const onAbort = (e, file) => {};
    const onComplete = (e, file) => {};
    return (uploadedFiles) => {
        [...uploadedFiles].forEach(setFileElement);

        document.body.appendChild(progressBox);
        uploader = uploadFiles(uploadedFiles, {
            onProgress,
            onError,
            onAbort,
            onComplete,
        });
    };
})();

一旦接收到文件Blobs,就可以使用它来创建文件元素,方法是调用 setFileElement,它创建一个包含文件名、进度条、百分比以及用于处理文件上传的控制按钮的div

还需要跟踪各个文件元素,以便稍后可以使用文件Blob引用它们。需要跟踪它的大小、状态、百分比和上传的块大小。所有这些细节将用于呈现元素进度细节并相应地更新视图。

const files = new Map();
const FILE_STATUS = {
    PENDING: "pending",
    UPLOADING: "uploading",
    PAUSED: "paused",
    COMPLETED: "completed",
    FAILED: "failed",
};
const setFileElement = (file) => {
    const extIndex = file.name.lastIndexOf(".");
    const fileElement = document.createElement("div");
    fileElement.className = "file-progress";
    fileElement.innerHTML = `
	<div class="file-details" style="position: relative">
	    <p>
		<span class="status">pending</span>
		<span class="file-name">
                    ${file.name.substring(0, extIndex)}
                </span>
		<span class="file-ext">
                    ${file.name.substring(extIndex)}
                </span>
	    </p>
            <div class="progress-bar" style="width: 0;"></div>
	</div>
	<div class="file-actions">
	     <button type="button" class="retry-btn" style="display: none">Retry</button>
	     <button type="button" class="cancel-btn" style="display: none">Pause</button>
	     <button type="button" class="resume-btn" style="display: none">Resume</button>
	     <button type="button" class="clear-btn" style="display: none">Clear</button>
	</div>
     `;

    files.set(file, {
        element: fileElement,
        size: file.size,
        status: FILE_STATUS.PENDING,
        percentage: 0,
        uploadedChunkSize: 0,
    });

    const [
        _,
        {
            children: [retryBtn, pauseBtn, resumeBtn, clearBtn],
        },
    ] = fileElement.children;

    clearBtn.addEventListener("click", () => {
        uploader.clearFileUpload(file);
        files.delete(file);
        fileElement.remove();
        updateProgressBox();
    });

    retryBtn.addEventListener("click", () => {
        uploader.retryFileUpload(file);
    });

    pauseBtn.addEventListener("click", () => {
        uploader.abortFileUpload(file);
    });

    resumeBtn.addEventListener("click", () => {
        uploader.resumeFileUpload(file);
    });

    progressBox
        .querySelector(".file-progress-wrapper")
        .appendChild(fileElement);
};

一旦使用了这些元素,就获取了它的控制按钮的引用,并附加了从上传器调用公开方法的单击事件,以控制传递文件的内容。

当清除元素时,需要通过调用 updateProgressBox 函数来更新进度框元素。这是更新所有上传文件的整体细节的函数。比如所有失败、成功、暂停和上传文件。

const updateProgressBox = () => {
    const [title, uploadProgress, expandBtn, progressBar] =
        progressBox.children;

    if (files.size > 0) {
        let totalUploadedFiles = 0;
        let totalUploadingFiles = 0;
        let totalFailedFiles = 0;
        let totalPausedFiles = 0;
        let totalChunkSize = 0;
        let totalUploadedChunkSize = 0;
        const [uploadedPerc, successCount, failedCount, pausedCount] =
            uploadProgress.children;

        files.forEach((fileObj) => {
            if (fileObj.status === FILE_STATUS.FAILED) {
                totalFailedFiles += 1;
            } else {
                if (fileObj.status === FILE_STATUS.COMPLETED) {
                    totalUploadedFiles += 1;
                } else if (fileObj.status === FILE_STATUS.PAUSED) {
                    totalPausedFiles += 1;
                } else {
                    totalUploadingFiles += 1;
                }

                totalChunkSize += fileObj.size;
                totalUploadedChunkSize += fileObj.uploadedChunkSize;
            }
        });

        const percentage =
            totalChunkSize > 0
                ? Math.min(
                      100,
                      Math.round(
                          (totalUploadedChunkSize * 100) / totalChunkSize
                      )
                  )
                : 0;

        title.textContent =
            percentage === 100
                ? `Uploaded ${totalUploadedFiles} File${
                      totalUploadedFiles !== 1 ? "s" : ""
                  }`
                : `Uploading ${totalUploadingFiles}/${files.size} File${
                      files.size !== 1 ? "s" : ""
                  }`;

        uploadedPerc.textContent = `${percentage}%`;
        successCount.textContent = totalUploadedFiles;
        failedCount.textContent = totalFailedFiles;
        pausedCount.textContent = totalPausedFiles;
        progressBar.style.width = `${percentage}%`;
        progressBox.style.backgroundSize = `${percentage}%`;
        expandBtn.style.display = "inline-block";
        uploadProgress.style.display = "block";
        progressBar.style.display = "block";
    } else {
        title.textContent = "No Upload in Progress";
        expandBtn.style.display = "none";
        uploadProgress.style.display = "none";
        progressBar.style.display = "none";
    }
};

还需要根据文件上传进度事件更新各个文件元素,它只是更新进度条百分比和状态文本消息,并根据文件上传状态切换可见或不可见的按钮。


const updateFileElement = (fileObject) => {
    const [
        {
            children: [
                {
                    children: [status],
                },
                progressBar,
            ],
        },
        // .file-details
        {
            children: [retryBtn, pauseBtn, resumeBtn, clearBtn],
        },
    ] = fileObject.element.children;

    requestAnimationFrame(() => {
        status.textContent =
            fileObject.status === FILE_STATUS.COMPLETED
                ? fileObject.status
                : `${Math.round(fileObject.percentage)}%`;
        status.className = `status ${fileObject.status}`;

        progressBar.style.width = fileObject.percentage + "%";
        progressBar.style.background =
            fileObject.status === FILE_STATUS.COMPLETED
                ? "green"
                : fileObject.status === FILE_STATUS.FAILED
                ? "red"
                : "#222";

        pauseBtn.style.display =
            fileObject.status === FILE_STATUS.UPLOADING
                ? "inline-block"
                : "none";

        retryBtn.style.display =
            fileObject.status === FILE_STATUS.FAILED ? "inline-block" : "none";

        resumeBtn.style.display =
            fileObject.status === FILE_STATUS.PAUSED ? "inline-block" : "none";

        clearBtn.style.display =
            fileObject.status === FILE_STATUS.COMPLETED ||
            fileObject.status === FILE_STATUS.PAUSED
                ? "inline-block"
                : "none";

        updateProgressBox();
    });
};

现在有了基于上传事件更新元素的函数,需要相应地处理所有文件上传事件。

因此,每当进程事件调用 onProgress 回调函数时,将使用该文件从 setFileElement 函数中设置的文件中获取渲染的文件对象,并使用事件细节更新其状态、百分比和块大小,然后调用 updateFileElement 函数。

const onProgress = (e, file) => {
    const fileObj = files.get(file);

    fileObj.status = FILE_STATUS.UPLOADING;
    fileObj.percentage = e.percentage;
    fileObj.uploadedChunkSize = e.loaded;

    updateFileElement(fileObj);
};

当上传完成时,对文件对象做几乎相同的事情并再次调用 updateFileElement 函数。

const onComplete = (e, file) => {
    const fileObj = files.get(file);

    fileObj.status = FILE_STATUS.COMPLETED;
    fileObj.percentage = 100;

    updateFileElement(fileObj);
};

对于中止事件,状态更新为“暂停”,更新文件元素。

const onAbort = (e, file) => {
    const fileObj = files.get(file);

    fileObj.status = FILE_STATUS.PAUSED;

    updateFileElement(fileObj);
};

最后,如果出现问题,将百分比设置为100,这样进度条就会完整呈现,并将状态设置为失败,这样进度条就会变成红色。

const onError = (e, file) => {
    const fileObj = files.get(file);

    fileObj.status = FILE_STATUS.FAILED;
    fileObj.percentage = 100;

    updateFileElement(fileObj);
};

到这里就完成了客户端文件上传模块的所有逻辑。

服务端

这是一个简单全栈项目,将需要服务端来处理上传请求,需要在项目目录中创建一个server.js 文件,代码如下:

const express = require("express");
const app = express();
app.listen(3008, () => {
    console.log("服务运行在端口:3008");
});

这是一个非常简单的服务器,仅用于处理上传,不提供应用程序网站文件。

端点预先设置

要设置端点,需要做的事情很少,将首先安装该cors模块,以便可以允许来自不同域的请求顺利通过。接下来是允许请求通过添加 express.json 中间件来处理 JSON 请求体。

const express = require("express");
const cors = require("cors");

const PORT = 3008;

const app = express();
app.use(express.json());
app.use(cors());

app.listen(PORT, () => {
    console.log(`服务运行在端口:${PORT}`);
});

由于服务端涉及文件处理,需要导入 fs 模块,还需要增加创建唯一ID生成器的函数。还将把文件放在上传目录中,所以需要一个实用程序函数来组合该文件路径与 id 和文件名。

const uniqueAlphaNumericId = (() => {
    const heyStack = "0123456789abcdefghijklmnopqrstuvwxyz";
    const randomInt = () =>
        Math.floor(Math.random() * Math.floor(heyStack.length));

    return (length = 24) =>
        Array.from({ length }, () => heyStack[randomInt()]).join("");
})();

const getFilePath = (fileName, fileId) =>
    `./uploads/file-${fileId}-${fileName}`;

上传请求端点

上传请求端点非常简单,它需要文件名,如果没有提供它应该抛出一个错误。当有文件名时,生成一个 id 并使用 idname 创建一个空文件,然后用文件 id 响应。

app.post("/upload-request", (req, res) => {
    if (!req.body || !req.body.fileName) {
        res.status(400).json({ message: "fileName 内容不能为空" });
    } else {
        const fileId = uniqueAlphaNumericId();
        fs.createWriteStream(getFilePath(req.body.fileName, fileId), {
            flags: "w",
        });
        res.status(200).json({ fileId });
    }
});

上传状态端点

上传状态也很简单,第一个设置是承诺 fs.stat 方法以使其更易于使用。查询文件并获取其状态以读取到目前为止上传的块的总大小并返回它,否则,以 400 状态和错误响应。

const getFileDetails = promisify(fs.stat);
app.get("/upload-status", (req, res) => {
    if (req.query && req.query.fileName && req.query.fileId) {
        getFileDetails(getFilePath(req.query.fileName, req.query.fileId))
            .then((stats) => {
                res.status(200).json({ totalChunkUploaded: stats.size });
            })
            .catch((err) => {
                console.error("文件读取错误", err);

                res.status(400).json({
                    message: "没有相应的文件",
                    credentials: req.query,
                });
            });
    }
});

上传端点

要处理文件块,需要安装 busboy 模块

npm install busboy --save

对于这个处理程序的前半部分,只需要通过验证文件 id 和内容范围的请求标头来确保拥有继续所需的一切。

app.post("/upload", (req, res) => {
    const contentRange = req.headers["content-range"];
    const fileId = req.headers["x-file-id"];

    if (!contentRange) {
        console.log("Missing Content-Range");
        return res
            .status(400)
            .json({ message: 'Missing "Content-Range" header' });
    }

    if (!fileId) {
        console.log("Missing File Id");
        return res.status(400).json({ message: 'Missing "X-File-Id" header' });
    }

    const match = contentRange.match(/bytes=(\d+)-(\d+)\/(\d+)/);

    if (!match) {
        console.log("Invalid Content-Range Format");
        return res
            .status(400)
            .json({ message: 'Invalid "Content-Range" Format' });
    }

    const rangeStart = Number(match[1]);
    const rangeEnd = Number(match[2]);
    const fileSize = Number(match[3]);

    if (
        rangeStart >= fileSize ||
        rangeStart >= rangeEnd ||
        rangeEnd > fileSize
    ) {
        return res
            .status(400)
            .json({ message: 'Invalid "Content-Range" provided' });
    }

    const busboy = new Busboy({ headers: req.headers });

    busboy.on("file", (_, file, fileName) => {
        const filePath = getFilePath(fileName, fileId);

        if (!fileId) {
            req.pause();
        }

        getFileDetails(filePath)
            .then((stats) => {
                if (stats.size !== rangeStart) {
                    return res
                        .status(400)
                        .json({ message: 'Bad "chunk" provided' });
                }

                file.pipe(fs.createWriteStream(filePath, { flags: "a" })).on(
                    "error",
                    (e) => {
                        console.error("failed upload", e);
                        res.sendStatus(500);
                    }
                );
            })
            .catch((err) => {
                console.log("No File Match", err);
                res.status(400).json({
                    message: "No file with such credentials",
                    credentials: req.query,
                });
            });
    });

    busboy.on("error", (e) => {
        console.error("failed upload", e);
        res.sendStatus(500);
    });

    busboy.on("finish", () => {
        res.sendStatus(200);
    });

    req.pipe(busboy);
});

busboy 模块获取请求头,可以将请求通过管道传递给它。在 busboy 文件事件中,确保字节从内存中的块开始,然后开始写块并把文件放在一起。

到此,服务器端的逻辑也基本完成,文章涉及的源代码在GitHub上,查看源代码