Node.js path 与 fs 练习参考答案(14~24)
对应题目:path-fs-exercises.md。
以下示例基于 Node.js 22+、TypeScript 和 CommonJS 编译输出。
共用导入
import * as path from "node:path";
import { randomUUID } from "node:crypto";
import { mkdir, readdir, stat } from "node:fs/promises";
function isErrno(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error;
}
14. 列出目录中的图片
const imageExtensions = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]);
async function listImages(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
return entries
.filter((entry) => {
return entry.isFile()
&& imageExtensions.has(path.extname(entry.name).toLowerCase());
})
.map((entry) => entry.name)
.sort((left, right) => left.localeCompare(right));
}
withFileTypes 返回 Dirent,能够在多数情况下避免为每个条目额外 stat。符号链接不会被当作普通文件。
15. 递归列出所有文件
interface WalkFailure {
directory: string;
error: unknown;
}
interface WalkResult {
files: string[];
failures: WalkFailure[];
}
async function listFilesRecursively(rootInput: string): Promise<WalkResult> {
const root = path.resolve(rootInput);
const files: string[] = [];
const failures: WalkFailure[] = [];
async function visit(directory: string): Promise<void> {
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
} catch (error: unknown) {
failures.push({ directory, error });
return;
}
for (const entry of entries) {
const absolutePath = path.join(directory, entry.name);
if (entry.isDirectory()) {
await visit(absolutePath);
} else if (entry.isFile()) {
files.push(path.relative(root, absolutePath));
}
}
}
await visit(root);
files.sort((left, right) => left.localeCompare(right));
return { files, failures };
}
这里选择跳过无法读取的子目录并明确记录失败。若业务要求结果必须完整,应在出现 failures 时让整个任务失败。默认不跟随符号链接,避免循环和目录越界。
16. 查找最近修改的日志
interface LatestLog {
filePath: string;
modifiedAt: Date;
modifiedAtMs: number;
}
async function findLatestLog(directory: string): Promise<LatestLog | undefined> {
const entries = await readdir(directory, { withFileTypes: true });
let latest: LatestLog | undefined;
for (const entry of entries) {
if (!entry.isFile() || path.extname(entry.name).toLowerCase() !== ".log") {
continue;
}
const filePath = path.join(directory, entry.name);
const information = await stat(filePath);
if (latest === undefined || information.mtimeMs > latest.modifiedAtMs) {
latest = {
filePath,
modifiedAt: information.mtime,
modifiedAtMs: information.mtimeMs,
};
}
}
return latest;
}
目录为空或没有 log 文件时自然返回 undefined。
17. 统计目录大小
async function calculateDirectorySize(directory: string): Promise<number> {
let totalBytes = 0;
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) {
totalBytes += await calculateDirectorySize(target);
} else if (entry.isFile()) {
totalBytes += (await stat(target)).size;
}
}
return totalBytes;
}
返回单位是字节。答案不跟随符号链接;跟随链接可能重复统计、越出根目录或形成循环。超大目录还应使用第 24 题的有限并发或迭代遍历。
18. 查找超过 30 天的日志
async function findExpiredLogs(
rootInput: string,
inactiveDays = 30,
): Promise<string[]> {
const root = path.resolve(rootInput);
const threshold = Date.now() - inactiveDays * 24 * 60 * 60 * 1000;
const { files, failures } = await listFilesRecursively(root);
if (failures.length > 0) {
throw new AggregateError(
failures.map((item) => item.error),
"部分目录无法读取,不能保证过期日志清单完整",
);
}
const expired: string[] = [];
for (const relativePath of files) {
if (path.extname(relativePath).toLowerCase() !== ".log") continue;
const absolutePath = path.join(root, relativePath);
if ((await stat(absolutePath)).mtimeMs < threshold) {
expired.push(absolutePath);
}
}
return expired;
}
查找和删除必须分离。mtime 是内容修改时间,并非可靠的业务创建时间;真正清理前还要排除活跃任务。
19. 防止目录穿越
function isInsideDirectory(root: string, candidate: string): boolean {
const relativePath = path.relative(root, candidate);
return relativePath !== ""
&& relativePath !== ".."
&& !relativePath.startsWith(".." + path.sep)
&& !path.isAbsolute(relativePath);
}
function resolveDownloadPath(userInput: string): string {
const root = path.resolve(process.cwd(), "public", "downloads");
// 在 POSIX 上也把客户端传来的反斜杠视为分隔符。
const portableInput = userInput.replaceAll("\\", "/");
const candidate = path.resolve(root, portableInput);
if (!isInsideDirectory(root, candidate)) {
throw new Error("请求路径越出下载目录");
}
return candidate;
}
normalize 只折叠语法,不建立允许目录边界。以上仍不能完全防范符号链接和“检查后被替换”的 TOCTOU;生产接口优先使用文件 ID 到服务端路径的映射,并在打开文件时继续控制链接策略。
20. 生成安全上传文件名
const windowsReservedName =
/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
interface UploadNames {
storedName: string;
displayName: string;
}
function createUploadNames(originalName: string): UploadNames {
// 连续使用两种 basename,处理两种分隔符。
const baseName = path.win32.basename(path.posix.basename(originalName));
const cleaned = baseName
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
.replace(/[. ]+$/g, "")
.slice(0, 150);
const displayName =
cleaned.length === 0 || windowsReservedName.test(cleaned)
? "file"
: cleaned;
const extension = path.extname(displayName)
.toLowerCase()
.replace(/[^.a-z0-9]/g, "");
return {
storedName: randomUUID() + extension,
displayName,
};
}
磁盘名由服务端生成,可避免冲突、保留名和危险字符。displayName 作为元数据保存,下载时再由 Content-Disposition 的安全编码逻辑使用。扩展名不是内容类型证明。
21. 限制可读取文件类型
const allowedReadableExtensions = new Set([".txt", ".json"]);
function resolveReadableFile(rootInput: string, userInput: string): string {
const root = path.resolve(rootInput);
const candidate = path.resolve(
root,
userInput.replaceAll("\\", "/"),
);
if (!isInsideDirectory(root, candidate)) {
throw new Error("路径越界");
}
const extension = path.extname(candidate).toLowerCase();
if (!allowedReadableExtensions.has(extension)) {
throw new Error("文件类型不允许");
}
return candidate;
}
report.json.exe 的最终扩展名是 exe;包含 …/ 的输入要先解析最终路径。endsWith 仍不能替代目录边界、普通文件检查、授权和内容校验。
22. 创建用户专属目录
function validateUserId(userId: string): string {
if (!/^[1-9]\d{0,18}$/.test(userId)) {
throw new Error("用户 ID 必须是 1~19 位正整数");
}
return userId;
}
function getUserDirectory(userId: string): string {
const root = path.resolve("uploads", "users");
const target = path.join(root, validateUserId(userId));
if (!isInsideDirectory(root, target)) {
throw new Error("用户目录越界");
}
return target;
}
async function ensureUserDirectory(userId: string): Promise<string> {
const directory = getUserDirectory(userId);
await mkdir(directory, { recursive: true });
return directory;
}
验证、路径生成和实际创建被拆成独立职责,便于单元测试。
23. 三种并发读取方式
import { readFile } from "node:fs/promises";
const configFiles = [
"config/app.json",
"config/database.json",
"config/logger.json",
];
async function readSerially(): Promise<string[]> {
const result: string[] = [];
for (const file of configFiles) {
result.push(await readFile(file, "utf8"));
}
return result;
}
function readConcurrently(): Promise<string[]> {
return Promise.all(
configFiles.map((file) => readFile(file, "utf8")),
);
}
function readAndCollectAll(): Promise<PromiseSettledResult<string>[]> {
return Promise.allSettled(
configFiles.map((file) => readFile(file, "utf8")),
);
}
- 串行:一个失败后,后面的读取尚未启动。
- Promise.all:全部立即启动,一个失败就让聚合 Promise 拒绝,但其他读取不会自动取消。
- allSettled:等待全部结束,逐项返回 fulfilled 或 rejected。
配置缺一不可时通常使用 Promise.all;诊断或批处理需要完整结果时用 allSettled。
24. 最多同时处理五个文件
interface FileProcessFailure {
file: string;
error: unknown;
}
async function processWithLimit(
files: string[],
concurrency = 5,
): Promise<FileProcessFailure[]> {
if (!Number.isInteger(concurrency) || concurrency < 1) {
throw new Error("concurrency 必须是正整数");
}
let cursor = 0;
const failures: FileProcessFailure[] = [];
async function worker(): Promise<void> {
while (true) {
const index = cursor;
cursor += 1;
if (index >= files.length) return;
const file = files[index]!;
try {
await processFile(file);
} catch (error: unknown) {
failures.push({ file, error });
}
}
}
const workerCount = Math.min(concurrency, files.length);
await Promise.all(
Array.from({ length: workerCount }, () => worker()),
);
return failures;
}
答案选择“记录失败后继续”。JavaScript 同步地领取 cursor,不会在领取过程之间发生 await 交错。无限 Promise.all 可能耗尽文件描述符、内存和磁盘 I/O,并拖慢同机其他服务。