Skip to content

文件切片简易版

写的不好,后续可以看看别人是如何实现的。

1.后端实现

文件切片上传,总体思路: 1.把收到的某个切片文件存放在一个切片文件夹中(该文件夹唯一标识一个文件) 2.将目标切片文件夹中的所有切片合并成一个文件,删除所有切片和对应切片文件夹即可 后端实现需要接触到许多 fs 模块的内容,许多 API 都是一知半解,能用就行那种程度

文件切片上传

文件切片上传,通过文件名称创建切片文件夹,将后续请求所有的切片文件都写入到该文件夹中。 约定好 form-data 中的字段名为(实际情况按照开发需求定,下列情况中,文件名称和解析方式都具有特殊性): fileName:文件名称,不包括扩展名,用来创建切片文件夹名称(文件名不能出现如:.或_,会影响解析和创建文件) chunkName:切片的名称,形如 xxx.png_0,xxx 为文件名称,png 为文件扩展名,_0 为文件切片时的索引,后端需要通过索引号按顺序合并成文件,才能正确的访问文件 chunkFile:切片文件,用来保存到切片文件夹中。 需要解析请求体里面的 form-data 中的各个对象,我们使用@koa/multer 来解析 form-data 中的数据:

ts
router.post(
  "/file-chunk",
  // 解析请求体中的字段(文件字段必须声明,非文件字段可声明可不声明)
  upload.fields([
    {
      // 分片名(分片名称的格式 xxx.png_0)
      name: "chunk-name",
    },
    {
      // 分片文件
      name: "chunk",
      maxCount: 1,
    },
    {
      // 文件名称
      name: "file-name",
    },
  ]),
  () => {
    // 中间件部分
  }
);
router.post(
  "/file-chunk",
  // 解析请求体中的字段(文件字段必须声明,非文件字段可声明可不声明)
  upload.fields([
    {
      // 分片名(分片名称的格式 xxx.png_0)
      name: "chunk-name",
    },
    {
      // 分片文件
      name: "chunk",
      maxCount: 1,
    },
    {
      // 文件名称
      name: "file-name",
    },
  ]),
  () => {
    // 中间件部分
  }
);

通过中间件我们获取到解析成功后的请求体,用 fileName 来创建文件夹,创建之前需要先检查文件夹是否存在,若存在则直接将切片文件保存到文件夹中,没有需要创建后,再存放到切片文件夹中。 fs.mkdir 创建文件夹 fs.existsSync 查询文件夹是否存在 fs.writeFile 来将切片文件保存到对应切片文件夹中 中间件实现:

ts
// 文件分片上传
// 使用@koa/multer可以自动配置上传form-data时的字段名,从而进行解析
// 非文件字段会被保存到ctx.request.body中
// 文件字段会被保存到ctx.request.files中
router.post(
  "/file-chunk",
  // 解析请求体中的字段(文件字段必须声明,非文件字段可声明可不声明)
  upload.fields([
    {
      // 分片名(分片名称的格式 xxx.png_0)
      name: "chunk-name",
    },
    {
      // 分片文件
      name: "chunk",
      maxCount: 1,
    },
    {
      // 文件名称
      name: "file-name",
    },
  ]),
  (ctx) => {
    // 非文件的form-data字段会被解析到body里
    // console.log(ctx.request.body);
    // 文件会被保存到ctx.request.files中
    // console.log(ctx.request.files);

    // 1.解析form-data中的数据
    // @ts-ignore 获取文件列表
    const fileList: any = ctx.request.files;
    // 解析分片文件
    const file = fileList["chunk"][0];
    // 解析出分片文件的文件名称
    const chunkName = ctx.request.body["chunk-name"];
    // 解析出文件的名称(以文件的名称来创建文件夹)
    const fileName = ctx.request.body["file-name"];

    // 2.创建文件夹,用于存放分片文件(根据文件名称来创建文件夹,将所有分片文件保存到文件夹中)
    // 文件夹路径
    const chunkDirPath = path.resolve("./static/chunk", `./${fileName}`);
    if (!fs.existsSync(chunkDirPath)) {
      // 文件不存在 创建文件夹
      fs.mkdirSync(chunkDirPath);
    }
    // 3.将当前分片文件保存在分片文件夹中
    const chunkFilePath = path.resolve(chunkDirPath, `./${chunkName}`);
    // 将文件保存到分片文件夹中
    try {
      fs.writeFileSync(chunkFilePath, file.buffer);
      ctx.body = {
        msg: "save ok",
        fileName,
        chunkName,
      };
    } catch (error) {
      console.log(error);
      ctx.body = {
        msg: "save fail",
        fileName,
        chunkName,
      };
    }
  }
);
// 文件分片上传
// 使用@koa/multer可以自动配置上传form-data时的字段名,从而进行解析
// 非文件字段会被保存到ctx.request.body中
// 文件字段会被保存到ctx.request.files中
router.post(
  "/file-chunk",
  // 解析请求体中的字段(文件字段必须声明,非文件字段可声明可不声明)
  upload.fields([
    {
      // 分片名(分片名称的格式 xxx.png_0)
      name: "chunk-name",
    },
    {
      // 分片文件
      name: "chunk",
      maxCount: 1,
    },
    {
      // 文件名称
      name: "file-name",
    },
  ]),
  (ctx) => {
    // 非文件的form-data字段会被解析到body里
    // console.log(ctx.request.body);
    // 文件会被保存到ctx.request.files中
    // console.log(ctx.request.files);

    // 1.解析form-data中的数据
    // @ts-ignore 获取文件列表
    const fileList: any = ctx.request.files;
    // 解析分片文件
    const file = fileList["chunk"][0];
    // 解析出分片文件的文件名称
    const chunkName = ctx.request.body["chunk-name"];
    // 解析出文件的名称(以文件的名称来创建文件夹)
    const fileName = ctx.request.body["file-name"];

    // 2.创建文件夹,用于存放分片文件(根据文件名称来创建文件夹,将所有分片文件保存到文件夹中)
    // 文件夹路径
    const chunkDirPath = path.resolve("./static/chunk", `./${fileName}`);
    if (!fs.existsSync(chunkDirPath)) {
      // 文件不存在 创建文件夹
      fs.mkdirSync(chunkDirPath);
    }
    // 3.将当前分片文件保存在分片文件夹中
    const chunkFilePath = path.resolve(chunkDirPath, `./${chunkName}`);
    // 将文件保存到分片文件夹中
    try {
      fs.writeFileSync(chunkFilePath, file.buffer);
      ctx.body = {
        msg: "save ok",
        fileName,
        chunkName,
      };
    } catch (error) {
      console.log(error);
      ctx.body = {
        msg: "save fail",
        fileName,
        chunkName,
      };
    }
  }
);

将切片文件合并

切片合并,约定:需要传入合并的文件夹名称与切片时每一份数据的大小,再读取切片文件夹,遍历所有切片文件,将切片文件写入到合并文件中,最后删除切片文件。

查询参数: fileName:通过 fileName 来找到需要合并的切片文件夹(本地中切片文件夹目录必须存在该文件夹) size:解析每份切片文件的大小,在合并文件时需要按照字节顺序依次写入内容。

先通过 fileName 到切片文件目录中查询是否存在该文件夹,存在则拼接出路径,读取该文件夹中所有的切片文件名称,并通过索引顺序来排序。 在对应目录下通过文件流的方式创建合并文件,遍历排序切片文件名称数组,将切片名称拼接成路径通过文件流的方式读取文件,按照字节顺序写入到合并文件中。 fs.readdirSync 读取文件夹中的所有文件名称 fs.createReadStream 创建读文件流 fs.createWriteStream 创建写文件流,可以指定从那个字节大小创建可写文件流 streamIns01.pipe(streamIns02)将实例 01 的文件流写入到实例 02 文件流中

ts
// 合并切片的文件文件
router.get("/merge-file", async (ctx) => {
  // 解析需要解析的文件名称
  if (ctx.query.fileName === undefined || ctx.query.size === undefined) {
    return (ctx.body = "fileName or size is query need!");
  }
  // 文件名称
  const fileName = ctx.query.fileName as string;
  // 分片大小为多少?
  const size = +ctx.query.size;

  const res = await resolveMerge(fileName, size);

  if (res === 0) {
    ctx.body = "file not exist!";
  } else {
    ctx.body = res;
  }
});

async function resolveMerge(fileName: string, size: number) {
  // 切片文件夹的路径(一个文件夹代表一个文件)
  const chunkDirPath = path.resolve("./static/chunk", `./${fileName}`);
  // 1.查询文件是否存在
  if (!fs.existsSync(chunkDirPath)) {
    // 文件不存在
    return Promise.resolve(0);
  }
  // 文件存在
  // 2.读取该切片文件夹中的所有文件名称
  const fileNameList = fs.readdirSync(chunkDirPath);
  // @ts-ignore 切片名称为 xxx.png_0 需要按照索引顺序进行排序,避免文件被混乱的合并
  fileNameList.sort((a, b) => a.split("_")[1] - b.split("_")[1]);

  // 3.遍历所有切片合并文件,并删除切片文件夹
  const res = fileNameList.map((chunkName, index) => {
    // 当前切片的路径
    const chunkFilePath = path.resolve(chunkDirPath, `./${chunkName}`);
    // 需要合并的文件路径(合并后文件的路径)
    const filePath = path.resolve(
      "./static/file",
      `${chunkName.split("_")[0]}`
    );

    // 根据当前切片的路径,访问该切片,将该切片写入到目标文件中
    return pipeStream(
      chunkFilePath,
      // 根据size的指定位置创建可写流
      fs.createWriteStream(filePath, {
        start: index * size,
      })
    );
  });
  // 等待全部切片写入完成
  await Promise.all(res);
  // 全部切片写入完成后,就删除该切片文件夹
  fs.rmdirSync(chunkDirPath);

  return Promise.resolve({
    fileName,
    filePath:
      "http://127.0.0.1:3000/file" + "/" + fileNameList[0].split("_")[0],
  });
}

// 写入文件流
function pipeStream(chunkPath: string, writeStream: fs.WriteStream) {
  return new Promise<void>((r) => {
    // 读取切片流
    const readStream = fs.createReadStream(chunkPath);
    // 读取完成就删除该切片
    readStream.on("end", () => {
      fs.unlinkSync(chunkPath);
      r();
    });
    // 将切片流写入到目标文件流中
    readStream.pipe(writeStream);
  });
}
// 合并切片的文件文件
router.get("/merge-file", async (ctx) => {
  // 解析需要解析的文件名称
  if (ctx.query.fileName === undefined || ctx.query.size === undefined) {
    return (ctx.body = "fileName or size is query need!");
  }
  // 文件名称
  const fileName = ctx.query.fileName as string;
  // 分片大小为多少?
  const size = +ctx.query.size;

  const res = await resolveMerge(fileName, size);

  if (res === 0) {
    ctx.body = "file not exist!";
  } else {
    ctx.body = res;
  }
});

async function resolveMerge(fileName: string, size: number) {
  // 切片文件夹的路径(一个文件夹代表一个文件)
  const chunkDirPath = path.resolve("./static/chunk", `./${fileName}`);
  // 1.查询文件是否存在
  if (!fs.existsSync(chunkDirPath)) {
    // 文件不存在
    return Promise.resolve(0);
  }
  // 文件存在
  // 2.读取该切片文件夹中的所有文件名称
  const fileNameList = fs.readdirSync(chunkDirPath);
  // @ts-ignore 切片名称为 xxx.png_0 需要按照索引顺序进行排序,避免文件被混乱的合并
  fileNameList.sort((a, b) => a.split("_")[1] - b.split("_")[1]);

  // 3.遍历所有切片合并文件,并删除切片文件夹
  const res = fileNameList.map((chunkName, index) => {
    // 当前切片的路径
    const chunkFilePath = path.resolve(chunkDirPath, `./${chunkName}`);
    // 需要合并的文件路径(合并后文件的路径)
    const filePath = path.resolve(
      "./static/file",
      `${chunkName.split("_")[0]}`
    );

    // 根据当前切片的路径,访问该切片,将该切片写入到目标文件中
    return pipeStream(
      chunkFilePath,
      // 根据size的指定位置创建可写流
      fs.createWriteStream(filePath, {
        start: index * size,
      })
    );
  });
  // 等待全部切片写入完成
  await Promise.all(res);
  // 全部切片写入完成后,就删除该切片文件夹
  fs.rmdirSync(chunkDirPath);

  return Promise.resolve({
    fileName,
    filePath:
      "http://127.0.0.1:3000/file" + "/" + fileNameList[0].split("_")[0],
  });
}

// 写入文件流
function pipeStream(chunkPath: string, writeStream: fs.WriteStream) {
  return new Promise<void>((r) => {
    // 读取切片流
    const readStream = fs.createReadStream(chunkPath);
    // 读取完成就删除该切片
    readStream.on("end", () => {
      fs.unlinkSync(chunkPath);
      r();
    });
    // 将切片流写入到目标文件流中
    readStream.pipe(writeStream);
  });
}

上述只是实现了切片文件并合并的一种方式,写得很特殊,没法考虑到解析时文件名和扩展名的问题,只是提供一种思路。 不论是 createReadStream 和 createWriteStream 都可以指定从那个字节开始读取/写入数据,也可以指定 end,表示读取/写入到对应字节结束。