如何使用Hono与Bun流式传输并消费两个二进制文件?
解决方案
核心问题
直接把两个文件的二进制流拼在一起发送,消费端根本没法区分文件边界——就像把两本书的页粘成一本,没人分得清哪页属于哪本书。所以必须在服务端的流里加明确的“分隔标记”,这里推荐用「先传文件元数据(文件名+大小),再传文件内容」的方式,比随便找个特殊符号当分隔符靠谱多了(毕竟二进制文件里啥奇怪内容都可能有,万一刚好撞上分隔符就会拆分错误)。
1. 修改服务端:给每个文件添加元数据前缀
服务端要在每个文件流的前面,先把该文件的文件名、大小转成二进制发送,而且得先告诉消费端“接下来多少字节是元数据”,不然消费端不知道该读多少字节来解析元数据。
import { Hono } from "hono"; import { stream } from "hono/streaming"; const app = new Hono(); // 将文件元数据转为带长度前缀的二进制,方便消费端识别边界 async function sendMetadata(stream: any, meta: { filename: string; size: number }) { const metaJson = JSON.stringify(meta); const metaBytes = new TextEncoder().encode(metaJson); // 用4字节无符号整数存储元数据长度(大端序,保证跨平台兼容) const lengthBuf = new ArrayBuffer(4); new DataView(lengthBuf).setUint32(0, metaBytes.length, false); // 先发送长度前缀,再发送元数据内容 await stream.write(new Uint8Array(lengthBuf)); await stream.write(metaBytes); } app.get("/", (c) => { return stream(c, async (stream) => { stream.onAbort(() => { console.log("传输被中止!"); }); // 传输第一个文件:视频 const videoFile = Bun.file("./data/worker/video.mp4"); console.log("开始传输视频文件..."); await sendMetadata(stream, { filename: "video.mp4", size: videoFile.size }); await stream.pipe(videoFile.stream()); // 传输第二个文件:音频 const audioFile = Bun.file("./data/worker/audio.mp4"); console.log("开始传输音频文件..."); await sendMetadata(stream, { filename: "audio.mp4", size: audioFile.size }); await stream.pipe(audioFile.stream()); console.log("两个文件传输完成!"); }); }); console.log("服务端运行在端口3001"); export default { port: 3001, fetch: app.fetch, };
2. 修改消费端:按元数据拆分流并保存文件
消费端要先读取4字节的元数据长度,再读取对应长度的字节解析出文件名和大小,然后精准读取对应大小的文件内容,写完一个文件再处理下一个,直到流结束。
import { Hono } from "hono"; const app = new Hono(); // 从流中精准读取指定长度的字节 async function readFixedLength(reader: ReadableStreamDefaultReader<Uint8Array>, length: number): Promise<Uint8Array> { const result = new Uint8Array(length); let offset = 0; while (offset < length) { const { done, value } = await reader.read(); if (done) throw new Error("流提前中断"); result.set(value, offset); offset += value.length; } return result; } // 读取并解析文件元数据 async function getFileMetadata(reader: ReadableStreamDefaultReader<Uint8Array>) { // 先读取4字节的元数据长度 const lengthBuf = await readFixedLength(reader, 4); const metaLength = new DataView(lengthBuf.buffer).getUint32(0, false); // 读取元数据内容并解析 const metaBytes = await readFixedLength(reader, metaLength); const metaJson = new TextDecoder().decode(metaBytes); return JSON.parse(metaJson) as { filename: string; size: number }; } app.get("/", async (c) => { console.log("开始从服务端拉取文件流..."); const response = await fetch("http://localhost:3001/"); if (!response.ok) { return c.json({ error: "拉取文件流失败" }, 500); } const reader = response.body!.getReader(); const outputDir = "./data/output/"; // 确保输出目录存在,Bun.write到目录会自动创建目录 await Bun.write(outputDir, ""); try { while (true) { // 获取当前文件的元数据 const meta = await getFileMetadata(reader); console.log(`开始接收文件:${meta.filename},大小${meta.size}字节`); // 读取对应大小的文件内容 const fileData = await readFixedLength(reader, meta.size); // 写入本地文件 await Bun.write(`${outputDir}${meta.filename}`, fileData); console.log(`${meta.filename}保存完成`); } } catch (err) { if ((err as Error).message === "流提前中断") { console.log("所有文件接收完成"); } else { console.error("接收文件出错:", err); return c.json({ error: "接收文件失败" }, 500); } } return c.json({ message: "所有文件流式传输并保存成功" }); }); export default app;
方案优势
- 可靠性高:用文件大小拆分流,不会出现特殊分隔符和文件内容冲突的问题;
- 扩展性强:元数据携带文件名,后续新增文件无需修改消费端逻辑;
- 跨平台兼容:用标准的大端序存储长度,避免不同系统的字节序差异问题。
内容的提问来源于stack exchange,提问作者empz
相关产品推荐
相关产品推荐

