Skip to content

文件与路径处理

运维脚本最频繁的操作:读配置、写报告、扫描日志目录、生成结果文件。Node.js 的文件操作主要用 node:fs/promises(异步 Promise 版),路径操作用 node:path

Promise 版 fs:新代码的默认选择

Node.js 有三套 fs API:同步版(fs.readFileSync)、回调版(fs.readFile)、Promise 版(fs/promises.readFile)。新代码直接用 Promise 版——异步操作在 async/await 下读起来像同步代码,不阻塞事件循环,也避免了回调嵌套。

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

const text = await readFile("config.json", "utf8");
// 不指定 utf8 的话返回的是 Buffer,不能当字符串用
console.log(text);

Buffer 是 Node.js 里表示二进制数据的类型。读文件不指定编码时返回 Buffer,要手动 .toString() 才能得到字符串:

js
const data = await readFile("config.json");    // Buffer
console.log(data.toString("utf8"));             // 手动转

写入文件

writeFile 默认覆盖文件(文件不存在则创建):

js
import { writeFile } from "node:fs/promises";

const report = ["service\tstatus", "nginx\tactive", "sshd\tactive"].join("\n") + "\n";
await writeFile("report.tsv", report, "utf8");

追加内容用 appendFile——适合日志流水和增量结果:

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

const line = `${new Date().toISOString()}\tnginx\tactive\n`;
await appendFile("check.log", line, "utf8");

写 JSON 结果时加 null, 2 缩进:

js
const result = {
  checkedAt: new Date().toISOString(),
  services: [{ name: "nginx", active: true }]
};

await writeFile("result.json", JSON.stringify(result, null, 2), "utf8");

JSON.stringify 的三个参数:第一个是数据对象;第二个是 replacer(对键值做二次处理,设为 null 表示不过滤);第三个是缩进空格数。不加第三个参数,输出是一长行——人读不了,grep 也不方便。

目录操作

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

// recursive: true——父目录不存在时一路创建,不需要先判 /data 有没有再判 /data/logs
await mkdir("logs/app", { recursive: true });

// 读目录
const names = await readdir(".");
console.log(names);   // ['index.js', 'src', 'package.json']

带类型信息的目录读取可以减少 stat 调用:

js
const entries = await readdir(".", { withFileTypes: true });
for (const entry of entries) {
  // isDirectory() 不额外调 stat,比先读文件名再挨个 stat 效率高
  console.log(entry.name, entry.isDirectory() ? "dir" : "file");
}

路径:用 path.join,不手写拼接

js
import path from "node:path";

// path.join 自动处理分隔符——Linux 用 /,Windows 用 \
const file = path.join("/data", "app", "config.json");
console.log(file);   // /data/app/config.json

几个常用方法:

js
const file = "/data/app/config.json";

path.basename(file);   // "config.json"——文件名
path.dirname(file);    // "/data/app"——目录路径
path.extname(file);    // ".json"——扩展名(含点)
path.resolve("config.json");  // 基于当前 process.cwd() 转绝对路径

path.joinpath.resolve 的区别:

  • path.join 只是拼路径片段,用当前系统分隔符
  • path.resolve 会把相对路径基于 process.cwd() 解析成绝对路径
js
// 假设 process.cwd() === "/home/user"
path.join("data", "config.json");     // data/config.json(相对路径)
path.resolve("data", "config.json");  // /home/user/data/config.json(绝对路径)

路径拼接不手写字符串。"/data/" + dir + "/" + file 在 Linux 上能跑,换到 Windows 上分隔符变成 \,路径变成混合斜杠,后续 fs 操作可能失败。

脚本自身路径与工作目录

这是最容易混淆的一对概念:

含义什么时候用
process.cwd()当前工作目录——执行命令时所在目录读取用户传入的文件
脚本目录.js 文件本身所在目录读随脚本发布的模板、默认配置

ESM 里没有 CommonJS 的 __dirname,需要手动构造:

js
import { fileURLToPath } from "node:url";
import path from "node:path";

const currentFile = fileURLToPath(import.meta.url);
// import.meta.url 是当前文件的 file:// URL,fileURLToPath 转成系统路径
const currentDir = path.dirname(currentFile);

// 读取和脚本放在一起的文件
const template = path.join(currentDir, "templates", "nginx.conf");

systemd、crontab、CI 里 process.cwd() 经常不是脚本所在目录。如果用相对路径读配置但依赖了 cwd,换一种执行方式就会报文件找不到——用脚本目录拼接模板路径,用 cwd 处理调用者传入的参数。

判断文件是否存在

js
import { access } from "node:fs/promises";

async function exists(file) {
  try {
    await access(file);   // access 检查文件是否可访问(存在且有权限)
    return true;
  } catch {
    return false;
  }
}

但先判存在再读文件有竞态窗口——判的时候还在,读的时候文件已经没了。巡检脚本里影响不大;长期运行的服务里,更安全的做法是直接读,失败再处理:

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

async function readOptional(file) {
  try {
    return await readFile(file, "utf8");
  } catch (err) {
    if (err.code === "ENOENT") return null;  // 文件不存在是预期情况
    throw err;  // 权限错误、磁盘错误则继续向上抛
  }
}

err.code 是 Node.js 文件系统错误码,来自操作系统的 errno。常见的有:

code含义
ENOENT文件或目录不存在
EACCES权限不足
EISDIR目标是目录,但操作用了文件方式
ENOTDIR目标是文件,但路径里某段当目录用了

递归遍历目录

js
import { readdir } from "node:fs/promises";
import path from "node:path";

async function walk(dir) {
  const entries = await readdir(dir, { withFileTypes: true });
  const files = [];

  for (const entry of entries) {
    const fullPath = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      files.push(...await walk(fullPath));     // 递归子目录
    } else {
      files.push(fullPath);
    }
  }

  return files;
}

大目录(日志目录、备份目录、容器 overlay2)走递归遍历要小心。文件数爆炸时,全部塞进一个数组可能撑爆内存——对这种场景,限制扫描深度和文件数上限比事后优化更直接。

文件权限

js
import { writeFile, chmod } from "node:fs/promises";

await writeFile("run.sh", "#!/bin/bash\necho ok\n", "utf8");
await chmod("run.sh", 0o755);    // 八进制权限——0o 前缀代表八进制
八进制权限谁该用
0o644rw-r--r--普通配置文件
0o600rw-------含密钥的文件
0o755rwxr-xr-x可执行脚本

含密钥的文件权限在写的时候就应该收紧到 0o600——chmodwriteFile 之后立即调用,避免短暂的时间窗口里文件以默认权限(通常是 0o644)暴露在磁盘上。

js
await writeFile(".token", secretValue, "utf8");
await chmod(".token", 0o600);