Node.js path 与 fs 练习参考答案(25~34)

对应题目:path-fs-exercises.md

答案分册:1~13 题 · 14~24 题

以下示例基于 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;“复制命令成功”不等于备份一定可恢复。