•
阅读时长 5 分钟

大文件切割后,完整性传输确认

目录

上传文件时,某一段失败,如果每次都从头上传,文件越大,重来的成本越高。分片上传把文件分成小块,每块可以独立发送和重试,最后再合并。

分片不只是前端 slice 一下。服务端还要知道这些块属于哪个文件、顺序是什么、有没有缺块,以及最后拼出来的内容是否和原文件一致。

服务端保存分片并校验完整性

下面做一个本机小例子,文件上限 8MB。分片存在内存中,重启就丢失,不用它承担正式文件存储。

创建 upload.mjs:

import { randomBytes, createHash } from "node:crypto";
export const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
export function createUploads() {
  const uploads = new Map();
  return {
    begin({ size, total, hash }) {
      if (
        !Number.isInteger(size) ||
        size < 1 ||
        size > 8 * 1024 * 1024 ||
        !Number.isInteger(total) ||
        total < 1 ||
        total > 128 ||
        !/^[a-f0-9]{64}$/.test(hash)
      )
        throw new Error("invalid metadata");
      const id = randomBytes(16).toString("hex");
      uploads.set(id, { size, total, hash, chunks: new Map() });
      return id;
    },
    put(id, index, bytes) {
      const item = uploads.get(id);
      if (
        !item ||
        !Number.isInteger(index) ||
        index < 0 ||
        index >= item.total ||
        bytes.length > 2 * 1024 * 1024
      )
        throw new Error("invalid chunk");
      const previous = item.chunks.get(index);
      if (previous && !previous.equals(bytes)) throw new Error("conflicting retry");
      if (
        !previous &&
        [...item.chunks.values()].reduce((sum, b) => sum + b.length, bytes.length) >
          item.size
      )
        throw new Error("size exceeded");
      item.chunks.set(index, bytes);
    },
    status(id) {
      const item = uploads.get(id);
      if (!item) throw new Error("unknown upload");
      return [...item.chunks.keys()].sort((a, b) => a - b);
    },
    finish(id) {
      const item = uploads.get(id);
      if (!item || item.chunks.size !== item.total) throw new Error("missing chunks");
      const result = Buffer.concat(
        Array.from({ length: item.total }, (_, i) => item.chunks.get(i)),
      );
      if (result.length !== item.size || sha256(result) !== item.hash)
        throw new Error("integrity mismatch");
      return result;
    },
  };
}

初始化时记录文件大小、分片数量和完整文件的 SHA-256。相同编号重复上传同样的内容允许成功,重复上传不同内容则拒绝,避免重试悄悄覆盖已经收下的块。

finish 先检查块数,再按编号合并,检查总长度和完整文件哈希。块数够了不代表文件就对了,顺序错误或者内容被替换都要被发现。

创建 server.mjs,把这些方法接到 HTTP 接口:

import http from "node:http";
import { readFile } from "node:fs/promises";
import { createUploads } from "./upload.mjs";
const uploads = createUploads();
const files = new Map([
  ["/", ["index.html", "text/html"]],
  ["/dist/main.js", ["dist/main.js", "text/javascript"]],
]);
const json = (res, status, value) => {
  res.writeHead(status, {
    "Content-Type": "application/json",
    "Cache-Control": "no-store",
  });
  res.end(JSON.stringify(value));
};
async function body(req) {
  const parts = [];
  let length = 0;
  for await (const part of req) {
    length += part.length;
    if (length > 2 * 1024 * 1024) throw new Error("body too large");
    parts.push(part);
  }
  return Buffer.concat(parts);
}
http
  .createServer(async (req, res) => {
    const url = new URL(req.url, "http://localhost");
    try {
      if (url.pathname === "/api/upload" && req.method === "POST")
        return json(res, 201, { id: uploads.begin(JSON.parse(await body(req))) });
      const match = url.pathname.match(
        /^\/api\/upload\/([a-f0-9]+)(?:\/(\d+|finish))?$/,
      );
      if (match) {
        const [, id, action] = match;
        if (action === "finish" && req.method === "POST")
          return json(res, 200, { size: uploads.finish(id).length });
        if (action && req.method === "PUT") {
          uploads.put(id, Number(action), await body(req));
          return json(res, 200, { ok: true });
        }
        if (!action && req.method === "GET")
          return json(res, 200, { received: uploads.status(id) });
      }
      const file = files.get(url.pathname);
      if (file && req.method === "GET") {
        res.setHeader("Content-Type", file[1] + "; charset=utf-8");
        return res.end(await readFile(new URL(file[0], import.meta.url)));
      }
      json(res, 404, { error: "not found" });
    } catch (error) {
      json(res, 400, { error: error.message });
    }
  })
  .listen(3000, "127.0.0.1", () => console.log("http://127.0.0.1:3000"));

请求体有单次大小限制,静态文件也只开放两个明确路径。所有示例文件放在同一个目录里,服务通过模块位置读取文件,不依赖在哪个目录启动。

React 负责切片、进度和有限重试

创建一个空目录,安装 React 和打包工具。示例不用 JSX,省去 Babel 配置,组件逻辑和 JSX 的写法是一样的。

npm init -y
npm install --save-exact react@18.3.1 react-dom@18.3.1
npm install --save-dev --save-exact webpack@5.99.9

创建 build.cjs:

const webpack = require("webpack");
const path = require("path");
webpack(
  {
    mode: "development",
    entry: path.resolve("main.js"),
    output: { path: path.resolve("dist"), filename: "main.js" },
    devtool: "source-map",
  },
  (error, stats) => {
    if (error || stats.hasErrors()) {
      console.error(error || stats.toString("errors-only"));
      process.exitCode = 1;
    }
  },
);

创建 index.html:

<!doctype html>
<html lang="zh-CN">
  <meta charset="utf-8" /><meta name="viewport" content="width=device-width" />
  <title>React 实验</title>
  <div id="root"></div>
  <script src="./dist/main.js"></script>
</html>

把下面的代码存为 main.js,运行 node build.cjs,然后用浏览器打开 index.html。这里用开发构建和根节点 StrictMode,观察次数时要把这两个条件算进去。

import React, {
  useState,
  useRef,
  useEffect,
  useMemo,
  useContext,
  memo,
  StrictMode,
} from "react";
import { createRoot } from "react-dom/client";
const e = React.createElement;
window.lab = { effects: [], contexts: { stable: 0, unstable: 0 }, measurements: [] };
function Upload() {
  const [status, setStatus] = useState("等待文件");
  const controller = useRef(null);
  useEffect(() => () => controller.current?.abort(), []);
  async function send(file) {
    controller.current?.abort();
    const current = new AbortController();
    controller.current = current;
    const options = { signal: current.signal };
    try {
      const bytes = await file.arrayBuffer();
      const digest = await crypto.subtle.digest("SHA-256", bytes);
      const hash = Array.from(new Uint8Array(digest), (v) =>
        v.toString(16).padStart(2, "0"),
      ).join("");
      const chunkSize = 64 * 1024;
      const total = Math.ceil(file.size / chunkSize);
      const begin = await fetch("/api/upload", {
        ...options,
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ size: file.size, total, hash }),
      });
      if (!begin.ok) throw new Error("初始化失败");
      const { id } = await begin.json();
      for (let index = 0; index < total; index++) {
        let success = false;
        for (let attempt = 0; attempt < 3; attempt++) {
          const response = await fetch("/api/upload/" + id + "/" + index, {
            ...options,
            method: "PUT",
            body: file.slice(index * chunkSize, (index + 1) * chunkSize),
          });
          if (response.ok) {
            success = true;
            break;
          }
          if (response.status < 500) throw new Error("分片不合法");
        }
        if (!success) throw new Error("重试失败");
        if (controller.current === current)
          setStatus("已发送 " + (index + 1) + "/" + total);
      }
      const end = await fetch("/api/upload/" + id + "/finish", {
        ...options,
        method: "POST",
      });
      if (!end.ok) throw new Error("合并失败");
      const merged = await end.json();
      if (controller.current === current) setStatus("完成 " + merged.size + " 字节");
    } catch (error) {
      if (controller.current === current)
        setStatus(current.signal.aborted ? "取消" : error.message);
    }
  }
  return e(
    "section",
    null,
    e("h2", null, "分片上传(本机内存存储)"),
    e("input", {
      id: "upload",
      type: "file",
      onChange: (event) => event.target.files[0] && send(event.target.files[0]),
    }),
    e("button", { onClick: () => controller.current?.abort() }, "取消上传"),
    e("p", { id: "upload-status" }, status),
  );
}

createRoot(document.querySelector("#root")).render(e(StrictMode, null, e(Upload)));

这次不要直接打开 index.html,运行 node server.mjs,访问 http://127.0.0.1:3000。前端和接口同源,fetch 使用相对地址即可。

选文件后计算完整哈希,每块 64KB,按顺序上传。某块收到 5xx 时最多尝试三次;4xx 直接失败。当前代码没有自动重试网络断连,fetch 抛错会进入失败提示,不能把它说成任意网络错误都能恢复。

换文件会取消上一轮请求。写状态时还检查 controller 是否仍属于当前任务,避免旧任务的提示覆盖新任务。取消请求不会删除服务端已经收到的分片。

怎么判断真的传对了

正常文件上传后显示完成和字节数。可以在浏览器请求拦截工具里让第一块首次返回 503,下一次放行,确认同一编号重试后仍能完成;持续返回 503,应在第三次之后停止。

服务端还可以直接调用 begin、put、finish 检查乱序上传、缺块和错误哈希。内容正确时顺序上传和乱序上传应得到相同字节,缺块和哈希不一致都应拒绝。

这个实现没有持久化、上传配额和过期清理,也没有刷新页面后的续传。虽然接口能查询已收编号,完整续传仍需要保存任务 id、验证任务归属和文件一致性。全文件 arrayBuffer 也会占内存,所以不能把 8MB 的做法直接扩大到几个 GB。