Node.js pathfs 练习参考答案(1~13)

对应题目:path-fs-exercises.md。示例面向 Node.js 22+、TypeScript 和 CommonJS 编译输出。为突出题目重点,各段代码可独立整理进模块;生产代码还应加入日志、授权、资源上限和测试。

答案分册:14~24 题 · 25~34 题

通用辅助类型

function isErrno(error: unknown): error is NodeJS.ErrnoException {
  return error instanceof Error && "code" in error;
}

第一部分:path 基础

1. 拼接上传文件路径

import * as path from "node:path";

function getUploadPath(fileName: string): string {
  return path.join(process.cwd(), "uploads", fileName);
}

Windows 结果类似 D:\app\uploads\avatar.png,Linux 类似 /srv/app/uploads/avatar.png。这题只演示拼接;真实用户文件名还要按第 19~22 题处理。

2. 获取文件基本信息

const input = "/var/app/uploads/2026/avatar.png";
const parsed = path.posix.parse(input);

const result = {
  fileName: path.posix.basename(input),       // avatar.png
  name: path.posix.basename(input, path.posix.extname(input)), // avatar
  extension: path.posix.extname(input),      // .png
  directory: path.posix.dirname(input),      // /var/app/uploads/2026
  root: parsed.root,                         // /
};

输入明确是 POSIX 路径,所以使用 path.posix,避免运行平台改变解析结果。

3. 规范化路径

function normalizeInput(input: string): string {
  return path.normalize(input);
}

结果通常为 uploads/temp/avatar.png(分隔符取决于平台)。normalize() 只做语法折叠;它不限制根目录、不处理授权,也不能防止符号链接把路径带出允许目录。

4. 相对路径与绝对路径

const project = "/home/node/app";
const file = "/home/node/app/uploads/avatar.png";

path.posix.relative(project, file); // uploads/avatar.png
path.posix.resolve(project, "uploads/avatar.png"); // /home/node/app/uploads/avatar.png

join() 拼接并规范化全部片段;resolve() 从右向左解析,遇到绝对片段后丢弃其左侧片段,并总是返回绝对路径:

path.posix.join("/app", "/tmp/a.txt");    // /app/tmp/a.txt
path.posix.resolve("/app", "/tmp/a.txt"); // /tmp/a.txt

5. 初步判断路径风格

type PathStyle = "windows" | "posix" | "unknown";

function detectPathStyle(input: string): PathStyle {
  if (/^[A-Za-z]:[\\/]/.test(input) || /^\\\\[^\\]+\\[^\\]+/.test(input)) {
    return "windows";
  }
  if (input.startsWith("/")) return "posix";
  return "unknown";
}

C:\...C:/... 和 UNC 路径属于 Windows;/home/... 属于 POSIX。src/index.tsREADME.md 都是相对路径,无法仅凭字符串确定来源平台。

6. win32posix

path.posix.parse("/var/log/my-app/error.log");
// { root: '/', dir: '/var/log/my-app', base: 'error.log', ext: '.log', name: 'error' }

path.win32.parse("/var/log/my-app/error.log");
// 反斜杠才是分隔符,因此解析语义不同

path.win32.parse("C:\\app\\logs\\error.log");
path.posix.parse("C:\\app\\logs\\error.log"); // POSIX 会把反斜杠当普通字符

处理“数据所属平台”的路径应显式选 win32/posix,不能总依赖程序当前操作系统。

第二部分:文件与目录

7. 递归创建日志目录

import { mkdir } from "node:fs/promises";

async function ensureLogDirectory(): Promise<string> {
  const directory = path.join(process.cwd(), "logs", "2026", "07");
  await mkdir(directory, { recursive: true });
  return directory;
}

直接 mkdir(..., {recursive:true}) 可避免 access() 与创建之间的竞态。

8. 追加启动日志

import { appendFile, mkdir } from "node:fs/promises";

async function writeStartupLog(): Promise<void> {
  const directory = path.join(process.cwd(), "logs");
  await mkdir(directory, { recursive: true });
  await appendFile(
    path.join(directory, "app.log"),
    `${new Date().toISOString()} server started\n`,
    { encoding: "utf8" },
  );
}

多进程高频日志不应自行这样管理,应交给结构化日志库和日志轮转系统。

9. 读取并验证 JSON 配置

import { readFile } from "node:fs/promises";

interface AppConfig { port: number; host: string }

function isAppConfig(value: unknown): value is AppConfig {
  if (typeof value !== "object" || value === null) return false;
  const item = value as Record<string, unknown>;
  return Number.isInteger(item.port)
    && typeof item.port === "number"
    && item.port > 0 && item.port <= 65_535
    && typeof item.host === "string";
}

async function readConfig(): Promise<AppConfig> {
  const text = await readFile(path.join(process.cwd(), "config.json"), "utf8");
  let value: unknown;
  try { value = JSON.parse(text) as unknown; }
  catch (error: unknown) { throw new Error("config.json 不是合法 JSON", { cause: error }); }
  if (!isAppConfig(value)) throw new Error("config.json 结构不符合 AppConfig");
  return value;
}

readFile 错误自然向上传播;SyntaxError 被包装为格式错误。TypeScript 接口只在编译期存在,必须运行时校验外部数据。

10. 判断文件、目录或不存在

import { stat } from "node:fs/promises";
type PathKind = "file" | "directory" | "other" | "not-found";

async function getPathKind(target: string): Promise<PathKind> {
  try {
    const info = await stat(target);
    if (info.isFile()) return "file";
    if (info.isDirectory()) return "directory";
    return "other";
  } catch (error: unknown) {
    if (isErrno(error) && error.code === "ENOENT") return "not-found";
    throw error;
  }
}

若要判断符号链接本身,应改用 lstat()stat() 跟随链接。

11. 安全删除临时文件

import { unlink } from "node:fs/promises";

async function removeTempFile(target: string): Promise<void> {
  try { await unlink(target); }
  catch (error: unknown) {
    if (isErrno(error) && error.code === "ENOENT") return;
    throw error;
  }
}

unlink() 对目录会失败;不要使用递归删除把类型错误隐藏掉。

12. 复制头像并拒绝覆盖

import { constants } from "node:fs";
import { copyFile, mkdir, stat } from "node:fs/promises";

async function copyAvatar(): Promise<void> {
  const source = path.resolve("temp/avatar.png");
  const target = path.resolve("uploads/users/1001/avatar.png");
  await mkdir(path.dirname(target), { recursive: true });
  await copyFile(source, target, constants.COPYFILE_EXCL);
  if (!(await stat(target)).isFile()) throw new Error("复制结果不是普通文件");
}

这里选择拒绝覆盖,冲突返回 EEXIST。如果业务允许替换,应先明确原子发布和失败恢复,而不是静默覆盖。

13. 跨文件系统移动

import { copyFile, mkdir, rename, unlink } from "node:fs/promises";

interface MoveResult { moved: boolean; sourceCleanupPending: boolean }

async function moveFile(source: string, target: string): Promise<MoveResult> {
  await mkdir(path.dirname(target), { recursive: true });
  try {
    await rename(source, target);
    return { moved: true, sourceCleanupPending: false };
  } catch (error: unknown) {
    if (!isErrno(error) || error.code !== "EXDEV") throw error;
  }

  await copyFile(source, target, constants.COPYFILE_EXCL);
  try {
    await unlink(source);
    return { moved: true, sourceCleanupPending: false };
  } catch (error: unknown) {
    return { moved: true, sourceCleanupPending: true };
  }
}

复制成功、删除失败意味着目标可用但源仍存在,不能简单报告“完全失败”并盲目重试复制;应记录待清理状态。高可靠实现还应校验大小/Hash,并处理部分目标文件。