Node.js path 与 fs 练习参考答案(25~34)
对应题目:path-fs-exercises.md。
以下示例基于 Node.js 22+、TypeScript 和 CommonJS 编译输出。
共用导入
import * as path from "node:path";
import { randomUUID } from "node:crypto";
import {
constants,
createReadStream,
createWriteStream,
watch,
watchFile,
unwatchFile,
} from "node:fs";
import {
copyFile,
cp,
mkdir,
readFile,
readdir,
rename,
stat,
unlink,
writeFile,
} from "node:fs/promises";
import { pipeline } from "node:stream/promises";
function isErrno(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error;
}
25. 避免读取—修改—写回竞态
interface CounterFile {
count: number;
}
function isCounterFile(value: unknown): value is CounterFile {
return typeof value === "object"
&& value !== null
&& typeof (value as Record<string, unknown>).count === "number";
}
let counterQueue: Promise<void> = Promise.resolve();
function incrementCounter(): Promise<void> {
const operation = counterQueue.then(async () => {
const filePath = path.resolve("data", "counter.json");
const value = JSON.parse(await readFile(filePath, "utf8")) as unknown;
if (!isCounterFile(value)) {
throw new Error("counter.json 结构无效");
}
await writeAtomicJson(filePath, { count: value.count + 1 });
});
// 上一个任务失败后,队列仍应允许后续任务运行。
counterQueue = operation.catch(() => undefined);
return operation;
}
单线程只代表同一时刻执行一段同步 JavaScript。两个请求可以在 readFile 的 await 处交错,都读到 count=10,再分别写回 11,丢失一次更新。该队列只保护一个进程;PM2 Cluster、多服务器或其他语言进程需要数据库原子 UPDATE、事务或分布式锁。
26. 原子写入配置
async function writeAtomicJson(
targetInput: string,
value: unknown,
): Promise<void> {
const target = path.resolve(targetInput);
const temporary = path.join(
path.dirname(target),
"." + path.basename(target) + "." + randomUUID() + ".tmp",
);
try {
await writeFile(
temporary,
JSON.stringify(value, null, 2) + "\n",
{ encoding: "utf8", flag: "wx" },
);
await rename(temporary, target);
} catch (error: unknown) {
try {
await unlink(temporary);
} catch (cleanupError: unknown) {
if (!isErrno(cleanupError) || cleanupError.code !== "ENOENT") {
// 生产代码记录 cleanupError,但仍抛出原始错误。
}
}
throw error;
}
}
临时文件必须与目标处于同一目录,才能确保同一文件系统内 rename。flag=wx 避免意外覆盖临时文件。POSIX 上替换同文件系统目标通常是原子的;Windows 在目标存在、被占用或受安全软件影响时可能失败,必须在部署平台测试。严格持久性还需要 FileHandle.sync 和目录同步等更深入设计。
27. 流式统计 2 GB 日志行数
async function countLines(filePath: string): Promise<number> {
const input = createReadStream(filePath);
let newlineCount = 0;
let hasData = false;
let finalByteIsLf = false;
for await (const chunk of input) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (buffer.length > 0) {
hasData = true;
finalByteIsLf = buffer[buffer.length - 1] === 0x0a;
}
for (const byte of buffer) {
if (byte === 0x0a) newlineCount += 1;
}
}
return newlineCount + (hasData && !finalByteIsLf ? 1 : 0);
}
按 LF 字节计数天然支持换行符跨 Chunk;CRLF 也只有一个 LF。for-await-of 会把 Stream 错误抛给调用者。readFile 会尝试整体分配和保存 2 GB 数据,带来巨大内存压力。
28. pipeline 流式复制
async function copyLargeFile(
source: string,
target: string,
): Promise<void> {
await pipeline(
createReadStream(source),
createWriteStream(target, { flags: "wx" }),
);
}
pipeline 等待整条链路结束,传播源读取、目标写入和关闭错误,并在失败时销毁相关 Stream。背压会在写入方跟不上时暂停读取。失败可能留下部分目标文件,调用层应根据业务决定删除、保留诊断还是稍后恢复。
29. 简单静态文件服务
import { createServer, type ServerResponse } from "node:http";
const publicRoot = path.resolve("public");
const contentTypes: Record<string, string> = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
};
function isInside(root: string, target: string): boolean {
const relative = path.relative(root, target);
return relative === ""
|| (
relative !== ".."
&& !relative.startsWith(".." + path.sep)
&& !path.isAbsolute(relative)
);
}
function sendText(
response: ServerResponse,
status: number,
text: string,
): void {
response.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" });
response.end(text);
}
const server = createServer(async (request, response) => {
try {
const url = new URL(request.url ?? "/", "http://localhost");
let pathname: string;
try {
pathname = decodeURIComponent(url.pathname).replaceAll("\\", "/");
} catch {
sendText(response, 400, "Bad Request");
return;
}
let target = path.resolve(publicRoot, "." + pathname);
if (!isInside(publicRoot, target)) {
sendText(response, 403, "Forbidden");
return;
}
let information = await stat(target);
if (information.isDirectory()) {
target = path.join(target, "index.html");
information = await stat(target);
}
if (!information.isFile()) {
sendText(response, 404, "Not Found");
return;
}
response.writeHead(200, {
"Content-Type": contentTypes[path.extname(target).toLowerCase()]
?? "application/octet-stream",
"Content-Length": information.size,
});
await pipeline(createReadStream(target), response);
} catch (error: unknown) {
if (response.headersSent) {
response.destroy(error instanceof Error ? error : undefined);
return;
}
if (isErrno(error) && error.code === "ENOENT") {
sendText(response, 404, "Not Found");
return;
}
sendText(response, 500, "Internal Server Error");
}
});
server.listen(8080);
这是教学实现。生产还需处理 HEAD、Range、缓存、符号链接、权限、压缩和安全 Header,通常使用 Nginx 或成熟静态文件中间件。
30. 上传临时文件清理器
interface DeleteFailure {
filePath: string;
error: unknown;
}
interface CleanupResult {
successCount: number;
failureCount: number;
failures: DeleteFailure[];
}
async function scanExpiredTempFiles(
directory: string,
cutoffMs: number,
): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const result: string[] = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
const filePath = path.join(directory, entry.name);
if ((await stat(filePath)).mtimeMs < cutoffMs) result.push(filePath);
}
return result;
}
async function deleteCandidates(files: string[]): Promise<CleanupResult> {
const failures: DeleteFailure[] = [];
let successCount = 0;
for (const filePath of files) {
try {
await unlink(filePath);
successCount += 1;
} catch (error: unknown) {
if (isErrno(error) && error.code === "ENOENT") continue;
failures.push({ filePath, error });
}
}
return {
successCount,
failureCount: failures.length,
failures,
};
}
async function cleanupUploads(): Promise<CleanupResult> {
const root = path.resolve("temp", "uploads");
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
return deleteCandidates(await scanExpiredTempFiles(root, cutoff));
}
生产删除前应再次验证路径位于 root,并排除活跃任务。扫描和删除分离后可以单独测试和提供 dry-run。
31. 日志轮转
let rotationQueue: Promise<void> = Promise.resolve();
function rotateAppLog(): Promise<void> {
const operation = rotationQueue.then(async () => {
const logDirectory = path.resolve("logs");
const current = path.join(logDirectory, "app.log");
let information;
try {
information = await stat(current);
} catch (error: unknown) {
if (isErrno(error) && error.code === "ENOENT") return;
throw error;
}
if (information.size <= 10 * 1024 * 1024) return;
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, "-");
const archived = path.join(
logDirectory,
"app-" + timestamp + ".log",
);
await rename(current, archived);
await writeFile(current, "", { flag: "wx" });
});
rotationQueue = operation.catch(() => undefined);
return operation;
}
队列只解决单进程竞态;应用可能仍持有旧文件描述符。真实生产通常让 Pino transport、logrotate、journald 或日志平台负责轮转。
32. 文件缓存读取器
interface CacheEntry {
content?: string;
mtimeMs?: number;
pending?: Promise<string>;
}
const textCache = new Map<string, CacheEntry>();
async function readCachedText(input: string): Promise<string> {
const key = path.resolve(input);
const information = await stat(key);
const existing = textCache.get(key);
if (
existing?.content !== undefined
&& existing.mtimeMs === information.mtimeMs
) {
return existing.content;
}
if (existing?.pending !== undefined) return existing.pending;
const pending = readFile(key, "utf8")
.then((content) => {
textCache.set(key, {
content,
mtimeMs: information.mtimeMs,
});
return content;
})
.catch((error: unknown) => {
textCache.delete(key);
throw error;
});
textCache.set(key, { pending });
return pending;
}
pending 合并并发首次读取。严格实现应在读取后再次 stat,防止读取期间文件变化,并限制条目数、内容总字节数和淘汰策略。
33. 文件变化监听
function watchConfiguration(
filePath: string,
reload: () => Promise<void>,
): () => void {
let timer: NodeJS.Timeout | undefined;
const schedule = (): void => {
if (timer !== undefined) clearTimeout(timer);
timer = setTimeout(() => {
void reload().catch((error: unknown) => {
console.error("重新加载配置失败", error);
});
}, 200);
};
const watcher = watch(path.dirname(filePath), (eventType, fileName) => {
if (fileName?.toString() === path.basename(filePath)) schedule();
});
return () => {
if (timer !== undefined) clearTimeout(timer);
watcher.close();
};
}
function watchConfigurationByPolling(
filePath: string,
reload: () => Promise<void>,
): () => void {
watchFile(filePath, { interval: 1000 }, (current, previous) => {
if (current.mtimeMs !== previous.mtimeMs) void reload();
});
return () => unwatchFile(filePath);
}
fs.watch 使用系统事件,效率高但事件可能合并、重复,直接监听文件还可能因编辑器原子替换而失效,所以示例监听父目录。watchFile 轮询 stat,开销更大但行为较稳定。重新加载失败应保留旧配置;生产一致性不能只依赖文件事件。
34. 备份指定目录
function assertBackupOutsideSource(
sourceInput: string,
targetInput: string,
): { source: string; target: string } {
const source = path.resolve(sourceInput);
const target = path.resolve(targetInput);
const relative = path.relative(source, target);
if (
relative === ""
|| (
relative !== ".."
&& !relative.startsWith(".." + path.sep)
&& !path.isAbsolute(relative)
)
) {
throw new Error("备份目标不能位于源目录内部");
}
return { source, target };
}
async function backupDataDirectory(): Promise<string> {
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, "-");
const { source, target } = assertBackupOutsideSource(
"data",
path.join("backups", "data-" + timestamp),
);
await cp(source, target, {
recursive: true,
errorOnExist: true,
force: false,
filter(sourcePath) {
return ![".tmp", ".part"].includes(
path.extname(sourcePath).toLowerCase(),
);
},
});
return target;
}
Node.js 22+ 的 fs.cp 适合整体复制、保留层级且代码简洁,但失败时不提供完整逐文件报告。若必须记录每个成功和失败文件,应使用第二册第 15 题的递归清单、第二册第 24 题的有限并发和 copyFile,逐项收集结果。备份完成还应校验文件数量、大小或 Hash;“复制命令成功”不等于备份一定可恢复。