22 Markdown ZIP 转换工程设计
1. 先设计状态机
created
→ uploading
→ validating
→ extracting
→ rendering
→ archiving
→ ready
→ downloading
→ expired/deleted
任意处理中状态 → failed
状态应保存在数据库或持久任务系统,而不是只存在 Express 请求局部变量中。短接口也至少要在日志中拥有 taskId。
2. 建议目录
TEMP_DIR/jobs/<taskId>/
├─input/source.zip
├─extract/
├─rendered/
└─build/result.zip.tmp
STORAGE_DIR/exports/<taskId>/result.zip
TEMP_DIR 可随 TTL 清理,STORAGE_DIR 保存用户可下载结果。二者不要放在项目源码目录,也不要让 Nginx 直接公开整个根目录。
3. 上传阶段
Multer 负责 multipart 解析和压缩包大小初步限制。Controller 只提取经过白名单的数据:
interface CreateMarkdownExportInput {
uploadedPath: string;
originalName: string;
}
不要相信:
originalname
mimetype
扩展名
客户端提供的相对路径
这些只用于初筛或展示。真实格式要通过 ZIP Reader 打开验证。
4. ZIP Entry 验证
处理每个 Entry 前至少检查:
原始路径没有 NUL
使用 ZIP POSIX 路径语义
不是绝对路径
没有 . 或 .. 路径段
不是符号链接
路径总长度和段长度受限
文件数量受限
Header 声明大小受限
累计实际解压字节受限
目标路径位于 extractRoot
目标没有与已有 Entry 冲突
不要这样处理:
const safeName = normalizeFileName(entry.fileName);
entry.fileName 包含目录。如果函数只保留文件名,会把:
guide/a/index.md
guide/b/index.md
都写到根目录,造成覆盖。应先验证完整 POSIX 路径,再逐段构造本地路径。展示用文件名规范化与归档路径验证是两个不同职责。
5. yauzl 的事件和 Entry Stream
概念流程:
打开 ZipFile
→ entry
→ 验证元数据
→ openReadStream(entry)
→ pipeline(entryStream, fileWriteStream)
→ 请求下一个 entry
→ end
使用 lazy entries 时,只有当前 Entry 完整处理后才调用 readEntry(),自然限制同时打开的 Entry 数量。
需要管理三层错误:
ZipFile error
Entry ReadStream error
目标 WriteStream error
Entry 复制应使用:
await pipeline(entryReadStream, targetWriteStream);
发生错误时关闭 ZipFile、销毁当前 Stream,并让上层统一删除任务目录。不要只监听 writeStream.finish。
6. 实际解压字节计数
不能只相信 Entry Header。可以在中间放计数 Transform:
const limiter = new Transform({
transform(chunk: Buffer, encoding, callback) {
entryBytes += chunk.length;
totalBytes += chunk.length;
if (
entryBytes > maxEntryBytes ||
totalBytes > maxTotalBytes
) {
callback(new Error("解压大小超过限制"));
return;
}
callback(null, chunk);
},
});
然后:
await pipeline(entryStream, limiter, outputStream);
7. 渲染阶段
遍历 extract/ 下经过验证的 .md 文件,计算相对路径:
extract/guide/start.md
→ relative = guide/start.md
→ rendered/guide/start.html
每个 Markdown 有独立大小上限。渲染步骤:
readFile(受限 Markdown)
→ markdown-it.render()
→ sanitize-html
→ 写临时 HTML
→ rename 到目标
语法高亮必须由 markdown-it 的 highlight 配置调用 highlight.js,才能生成带 hljs-* class 的 span。只给 <code> 设置整体颜色不会产生 Token 级着色。
图片等静态资源适合 Pipeline 复制。链接重写要以 Markdown 文件所在目录为基准,不能全部相对于 ZIP 根目录。
8. Archiver 阶段
推荐把要打包的目录作为目录树加入:
archive.directory(renderedDirectory, false);
false 的含义是“不额外包一层顶级目录”,不是压平子目录。若最终层级消失,应检查更早的 rendered 目录、archive.file() 的 name、Entry 名称生成和是否只传了 basename。
Archiver 与文件 WriteStream 是两个不同完成面:
archive.finalize()
表示不再添加 Entry,开始完成归档
输出 WriteStream finish/close
表示 ZIP 字节写入目标完成/资源关闭
应在调用 finalize() 前设置错误监听,并使用 Promise/finished(output) 等方式等待输出完成。Archiver 自身 warning 要区分可恢复警告和致命错误。
9. 下载阶段
只有结果 ZIP 已完整生成并移动到 finalPath 后才能设置下载响应。推荐:
res.attachment(downloadName);
await pipeline(createReadStream(finalPath), res);
客户端断开时 Pipeline reject。若结果设计为可重复下载,不要因为一次中断立即删除 finalPath;若是一次性临时响应,则按业务策略清理。
10. 一个上层骨架
export async function createMarkdownExport(
input: CreateMarkdownExportInput,
signal?: AbortSignal,
): Promise<MarkdownExportResult> {
const context = await createTaskContext(input);
try {
await validateUpload(context);
await extractZip(context, signal);
await renderMarkdownFiles(context, signal);
await archiveRenderedDirectory(context, signal);
return await publishResult(context);
} catch (error: unknown) {
await markTaskFailed(context, error);
throw error;
} finally {
await cleanupIntermediateFiles(context);
}
}
它只展示阶段和职责,不是核心实现答案。每层都接受 task context 和取消信号,避免依赖全局变量。
11. 错误响应边界
转换完成前尚未发送响应头
→ 可以返回 JSON 业务错误
开始 ZIP 下载后
→ 不能再切换成 JSON
→ 中断 Stream、记日志、清理/保留结果
把“生成结果”和“发送结果”分成两步,比边生成边下载更容易为初学者建立正确错误边界。
练习题
- 为每个阶段列出输入、输出、句柄和失败清理动作。
- 设计拒绝大小写折叠冲突 Entry 的数据结构。
- 用实际字节计数防止 Header 大小不可信。
- 验证
archive.directory(dir, false)是否保留子目录。 - 客户端在 50% 下载时断开,定义任务和结果文件状态。