Skip to main content

File System

Источник.

1. Концепции, паттерны и соглашения, используемые в ФС

В данном разделе предполагаются следующие импорты:

import * as fs from "node:fs";
import * as fsPromises from "node:fs/promises";

1.2. Стиль функций

ФС предоставляет 3 стиля функций:

  • синхронный стиль с обычными функциями:
    • fs.readFileSync(path, options?): string | Buffer;
  • 2 асинхронных стиля:
    • с функциями обратного вызова:
      • fs.readFile(path, options?, callback): void;
    • с функциями, возвращающими промисы:
      • fsPromises.readFile(path, options?): Promise<string | Buffer>.

1.1.1. Синхронные функции

Синхронные функции являются самыми простыми - они сразу возвращают значения и выбрасывают ошибки в виде исключений:

import * as fs from "node:fs";

try {
const result = fs.readFileSync("/etc/passwd", { encoding: "utf-8" });

console.log(result);
} catch (err) {
console.error(err);
}

В статье, в основном, используется данный стиль.

1.1.2. Функции, основанные на промисах

Такие функции возвращают промисы, которые разрешаются результатами и отклоняются с ошибками:

import * as fsPromises from "node:fs/promises";

try {
const result = await fsPromises.readFile(
"/etc/passwd", { encoding: "utf-8" });

console.log(result);
} catch (err) {
console.error(err);
}

Обратите внимание: промисифицированный (promisified) ФС импортируется из другого модуля.

1.1.3. Функции, основанные на колбэках

Такие функции передают результат и ошибки колбэку, передаваемому им в качестве последнего аргумента:

import * as fs from "node:fs";

fs.readFile("/etc/passwd", { encoding: "utf-8" },
(err, result) => {
if (err) {
console.error(err);
return;
}

console.log(result);
}
);

Данный стиль в статье не используется (он считается устаревшим).

1.2. Доступ к файлам

  • все содержимое файла можно читать и записывать в виде строки;
  • потоки для чтения и записи позволяют обрабатывать файлы небольшими частями (чанками/chunks), по одной за раз. Потоки разрешают только последовательный доступ;
  • для последовательного и произвольного доступа могут использоваться файловые дескрипторы или FileHandles, отдаленно напоминающие потоки:
    • файловые дескрипторы - это целые числа, представляющие файлы. Они управляются с помощью следующих функций (у каждой синхронной версии имеется колбэк-эквивалент - fs.open() и т.п.):
      • fs.openSync(path, flags?, mode?): открывает новый дескриптор файла по указанному пути и возвращает его;
      • fs.closeSync(fd): закрывает дескриптор;
      • fs.fchmodSync(fd, mode);
      • fs.fchownSync(fd, uid, gid);
      • fs.fdatasyncSync(fd);
      • fs.fstatSync(fd, options?);
      • fs.fsyncSync(fd);
      • fs.ftruncateSync(fd, len?);
      • fs.futimesSync(fd, atime, mtime);
    • файловые дескрипторы могут использоваться синхронным и колбэк-ФС. Промис-ФС предоставляет абстракцию - класс FileHandle, основанный на дескрипторах. Экземпляры создаются с помощью fsPromises.open(). Операции выполняются с помощью таких методов (не функций), как:
    • fileHandle.close();
    • fileHandle.chmod(mode);
    • fileHandle.chown(uid, gid);
    • и др.

FileHandles в этой статье не рассматриваются.

1.3. Префиксы названий функций

1.3.1. Префикс "l": символические ссылки

Функции, названия которых начинаются с буквы l, как правило, оперируют символическими ссылками:

  • fs.lchmodSync(), fs.lchmod(), fsPromises.lchmod();
  • fs.lchownSync(), fs.lchown(), fsPromises.lchown();
  • fs.lutimesSync(), fs.lutimes(), fsPromises.lutimes();
  • и др.

1.3.2. Префикс "f": дескрипторы файлов

Функции, названия которых начинаются с буквы f, как правило, управляют файловыми дескрипторами:

  • fs.fchmodSync(), fs.fchmod();
  • fs.fchownSync(), fs.fchown();
  • fs.fstatSync(), fs.fstat();
  • и др.

1.4. Важные классы

1.4.1. URL: альтернатива строковым путям к файловой системе

Функции, принимающие строковые пути (1), как правило, также принимают экземпляры URL (2):

import * as fs from "node:fs";

assert.equal(
fs.readFileSync(
"/tmp/data.txt", { encoding: "utf-8" }), // (1)
"Текст"
);

assert.equal(
fs.readFileSync(
new URL("file:///tmp/data.txt"), { encoding: "utf-8" }), // (2)
"Текст"
);

Ручное преобразование путей в file: кажется простым, но необходимо учитывать большое количество нюансов: процентное кодирование и декодирование, управляющие символы, буквы дисков Windows и т.д. Поэтому лучше применять следующие функции:

URL будет рассмотрен в одной из следующих статей.

1.4.2. Буферы

Класс Buffer представляет последовательность байтов фиксированного размера. Он является подклассом Uint8Array (TypedArray - типизированного массива). Буферы используются, в основном, для работы с файлами, содержащими бинарные данные.

Функции, принимающие Buffer, также принимают Uint8Array. Поскольку Uint8Arrays являются кроссплатформенными, а Buffers нет, предпочтение следует отдавать первым.

Преимущество буферов состоит в возможности кодирования и декодирования текста в разные кодировки. Для кодирования или декодирования UTF-8 в Uint8Array можно использовать TextEncoder или TextDecoder. Эти классы доступны на большинстве JavaScript-платформ:

> new TextEncoder().encode("café")
Uint8Array.of(99, 97, 102, 195, 169)

> new TextDecoder().decode(Uint8Array.of(99, 97, 102, 195, 169))
"café"

1.4.3. Потоки

Некоторые функции принимают или возвращают нативные потоки данных (native streams):

  • stream.Readable: класс для создания потоков для чтения. Модуль node:fs использует fs.ReadStream, который является подклассом stream.Readable;
  • stream.Writable: класс для создания потоков для записи. Модуль node:fs использует fs.WriteStream, который является подклассом stream.Writable.

Вместо нативных потоков можно использовать кроссплатформенные веб-потоки (web streams), о которых рассказывалось в одной из предыдущих статей.

2. Чтение и запись файлов

2.1. Синхронное чтение файла в строку (опционально: разбиение по строкам)

fs.readFileSync(path, options?) читает файл по указанному пути в строку (результат чтения файла возвращается в виде единой строки):

import * as fs from "node:fs";

assert.equal(
fs.readFileSync("data.txt", { encoding: "utf-8" }),
"несколько\r\nстрок\nтекста"
);

Плюсы и минусы данного подхода (по сравнению с использованием потока):

  • +: файл читается синхронно, делается это легко. Подходит для многих случаев;
  • -: плохой выбор для больших файлов - обработке файла предшествует чтение всего содержимого файла.

2.1.1. Разбиение текста без включения разделителей строк

Следующий код разбивает текст построчно и удаляет разделители строк (line terminators):

const RE_EOL = /\r?\n/;

const splitLines = (str) => str.split(RE_EOL);

assert.deepEqual(
splitLines("несколько\r\nстрок\nтекста"),
["несколько", "строк", "текста"]
);

"EOL" расшифровывается как "end of line" (конец строки).

2.1.2. Разбиение текста с включением разделителей строк

const RE_EOL = /(?<=\r?\n)/; // (1)

const splitLinesWithEols = (str) => str.split(RE_EOL);

assert.deepEqual(
splitLinesWithEols("несколько\r\nстрок\nтекста"),
["несколько\r\n", "строк\n", "текста"]
);
assert.deepEqual(
splitLinesWithEols("первый\n\nтретий"),
["первый\n", "\n", "третий"]
);
assert.deepEqual(
splitLinesWithEols("EOL в конце\n"),
["EOL в конце\n"]
);
assert.deepEqual(
splitLinesWithEols(""),
[""]
);

На строке 1 у нас имеется регулярное выражение с ретроспективной проверкой (lookbehind assertion). Оно сопоставляется с тем, что предшествует \r?\n, но ничего не захватывает. Поэтому разделители сохраняются.

На платформах, не поддерживающих ретроспективные проверки, можно использовать такую функцию:

function splitLinesWithEols(str) {
if (str.length === 0) return [""];

const lines = [];

let prevEnd = 0;

while (prevEnd < str.length) {
// Поиск "\n" также означает поиск "\r\n"
const newlineIndex = str.indexOf("\n", prevEnd);

// Перевод на новую строку включается в строку
const end = newlineIndex < 0 ? str.length : newlineIndex + 1;

lines.push(str.slice(prevEnd, end));

prevEnd = end;
}

return lines;
}

2.2. Построчное чтение файла с помощью потока

import * as fs from "node:fs";
import { Readable } from "node:stream";

const nodeReadable = fs.createReadStream(
"text-file.txt",
{ encoding: "utf-8" }
);

const webReadableStream = Readable.toWeb(nodeReadable);

const lineStream = webReadableStream.pipeThrough(new ChunksToLinesStream());

for await (const line of lineStream) {
console.log(line);
}
/**
* несколько\r\n
* строк\n
* текста
*/

Вот, что здесь используется:

  • fs.createReadStream(path, options?): создает поток (экземпляр stream.Readable);
  • stream.Readable.toWeb(nodeReadable): преобразует доступный для чтения поток Node.js в веб-поток (экземпляр ReadableStream);
  • класс ChunksToLinesStream представляет поток для преобразования. Чанки - это небольшие части данных, производимые потоками. Если у нас есть поток, чанки которого представляют строки произвольной длины, и мы пропускает эти чанки через ChunksToLinesStream, то получаем поток с построчными чанками.

Веб-потоки являются асинхронно итерируемыми, что позволяет использовать цикл for-await-of для их перебора.

Плюсы и минусы данного подхода (по сравнению с чтением в строку):

  • +: хорошо подходит для больших файлов - данные могут обрабатываться инкрементально, не нужно ждать завершения чтения всего содержимого файла;
  • -: данные читаются асинхронно, код сложнее и его больше.

2.3. Синхронная запись строки в файл

fs.writeFileSync(path, str, options?) записывает строку в файл по указанному пути. Существующий файл перезаписывается:

import * as fs from "node:fs";

fs.writeFileSync(
"data.txt",
"Первая строка\nВторая строка\n",
{ encoding: "utf-8" }
);

Плюсы и минусы (по сравнению с потоком):

  • +: файл записывается синхронно, делается это легко. Подходит для многих случаев;
  • -: плохой выбор для больших файлов.

2.4. Синхронное добавление строки в файл

import * as fs from "node:fs";

fs.writeFileSync(
"data.txt",
"Новая строка\n",
{ encoding: "utf-8", flag: "a" }
);

Настройка flag со значением a означает, что мы добавляем данные. Другие возможные значения этой настройки.

Обратите внимание: в одних функциях настройка называется flag, в других - flags.

2.5. Запись нескольких строк в файл с помощью потока

import * as fs from "node:fs";
import { Writable } from "node:stream";

const nodeWritable = fs.createWriteStream(
"data.txt",
{ encoding: "utf-8" }
);

const webWritableStream = Writable.toWeb(nodeWritable);

const writer = webWritableStream.getWriter();

try {
await writer.write("Первая строка\n");
await writer.write("Вторая строка\n");
await writer.close();
} finally {
writer.releaseLock()
}

Вот, что здесь используется:

Плюсы и минусы:

  • +: хорошо подходит для больших файлов;
  • -: запись выполняется асинхронно, код сложнее и его больше.

2.6. Добавление нескольких строк в файл с помощью потока

import * as fs from "node:fs";
import { Writable } from "node:stream";

const nodeWritable = fs.createWriteStream(
"data.txt",
// !
{ encoding: "utf-8", flags: "a" }
);

const webWritableStream = Writable.toWeb(nodeWritable);

const writer = webWritableStream.getWriter();

try {
await writer.write("Первая добавленная строка\n");
await writer.write("Вторая добавленная строка\n");
await writer.close();
} finally {
writer.releaseLock()
}

3. Кроссплатформенная обработка разделителей строк

На разных платформах используются разные разделители строк, отмечающие конец строки:

  • на Windows - это \r\n;
  • на Unix - \n.

Для кроссплатформенной обработки EOL можно использовать несколько стратегий.

3.1. Чтение разделителей строк

При обработке строк с EOL иногда бывает полезным их удалять:

const RE_EOL_REMOVE = /\r?\n$/;

function removeEol(line) {
const match = RE_EOL_REMOVE.exec(line);

if (!match) return line;

return line.slice(0, match.index);
}

assert.equal(
removeEol("Windows EOL\r\n"),
"Windows EOL"
);
assert.equal(
removeEol("Unix EOL\n"),
"Unix EOL"
);
assert.equal(
removeEol("Без EOL"),
"Без EOL"
);

3.2. Запись разделителей строк

Для записи разделителей строк в нашем распоряжении имеется 2 варианта:

  • константа EOL из модуля node:os содержит EOL текущей платформы;
  • можно регистрировать формат EOL входного файла и использовать этот формат при дальнейшей модификации данного файла.

4. Обход и создание директорий

4.1. Обход директории

Следующая функция обходит (traverse) директорию и возвращает список всех ее потомков (ее дочерних элементов, потомков дочерних элементов и т.д.):

import * as path from "node:path";
import * as fs from "node:fs";

function* traverseDir(dirPath) {
const dirEntries = fs.readdirSync(dirPath, {withFileTypes: true});

// Сортируем сущности для обеспечения большей предсказуемости
dirEntries.sort(
(a, b) => a.name.localeCompare(b.name, "en")
);

for (const dirEntry of dirEntries) {
const fileName = dirEntry.name;
const pathName = path.join(dirPath, fileName);
yield pathName;

if (dirEntry.isDirectory()) {
yield* traverseDir(pathName);
}
}
}

Здесь:

  • fs.readdirSync(path, options?) возвращает потомков директории по указанному пути:
    • если настройка withFileTypes имеет значение true, функция возвращает записи каталога (directory entries), экземпляры fs.Dirent. Записи каталога содержат такие свойства, как:
      • dirent.name;
      • dirent.isDirectory();
      • dirent.isFile();
      • dirent.isSymbolicLink();
    • если настройка withFileTypes имеет значение true или не указана, функция возвращает список названий файлов.

Пример использования функции traverseDir:

for (const filePath of traverseDir("dir")) {
console.log(filePath);
}
/**
* dir/dir-file.txt
* dir/subdir
* dir/subdir/subdir-file1.txt
* dir/subdir/subdir-file2.csv
*/

4.2. Создание директории (mkdir, mkdir -p)

Для создания директорий можно использовать функцию fs.mkdirSync(path, options?).

options.recursive определяет, как создается директория по указанному пути:

  • если recursive имеет значение false или отсутствует, mkdirSync() возвращает undefined. Если директория (или файл) уже существует или отсутствует родительская директория, выбрасывается исключение;
  • если recursive имеет значение true, mkdirSync() возвращает путь первой созданной директории. Если директория (или файл) уже существует, ничего не происходит. Если отсутствует родительская директория, она создается.

Пример использования mkdirSync():

import * as fs from "node:fs";

assert.deepEqual(
Array.from(traverseDir(".")),
[
"dir",
]
);

fs.mkdirSync("dir/sub/subsub", { recursive: true });

assert.deepEqual(
Array.from(traverseDir(".")),
[
"dir",
"dir/sub",
"dir/sub/subsub",
]
);

4.3. Определение наличия директории

При создании вложенных директорий и файлов мы не всегда может быть уверены в существовании родительской директории. Следующая функция может в этом помочь:

import * as path from "node:path";
import * as fs from "node:fs";

function ensureParentDirectory(filePath) {
const parentDir = path.dirname(filePath);

if (!fs.existsSync(parentDir)) {
fs.mkdirSync(parentDir, { recursive: true });
}
}

Пример использования:

assert.deepEqual(
Array.from(traverseDir(".")),
[
"dir",
]
);

const filePath = "dir/sub/subsub/new-file.txt";

ensureParentDirectory(filePath);

fs.writeFileSync(filePath, "content", { encoding: "utf-8" });

assert.deepEqual(
Array.from(traverseDir(".")),
[
"dir",
"dir/sub",
"dir/sub/subsub",
"dir/sub/subsub/new-file.txt",
]
);

4.4. Создание временной директории

fs.mkdtemp(pathPrefix, options?) создает временную директорию: она добавляет 6 произвольных символов к pathPrefix, создает директорию и возвращает путь.

Обратите внимание: pathPrefix не должен оканчиваться на заглавную X, поскольку некоторые платформы заменяют X произвольными символами.

Для создания временной директории внутри специфичной для операционной системы глобальной временной директории можно использовать функцию os.tmpdir:

import * as os from "node:os";
import * as path from "node:path";
import * as fs from "node:fs";

const pathPrefix = path.resolve(os.tmpdir(), "my-app");
// например, "/var/folders/ph/sz0384m11vxf/T/my-app"

const tmpPath = fs.mkdtempSync(pathPrefix);
// например, "/var/folders/ph/sz0384m11vxf/T/my-app1QXOXP"

Созданные таким способом директории автоматически не удаляются.

5. Копирование, переименование, перемещение файлов или директорий

5.1. Копирование файлов или директорий

fs.cpSync(srcPath, destPath, options?) копирует файл или директорию из srcPath в destPath. Полезные настройки:

  • recursive (false по умолчанию): директории (включая пустые) копируются только когда данная настройка имеет значение true;
  • force (true): если имеет значение true, существующие файлы перезаписываются:
    • если имеет значение false и настройка errorOnExist установлена в true, при наличии файла выбрасывается исключение;
  • filter: функция, позволяющая управлять тем, какие файлы копируются;
  • preserveTimestamps (false): если имеет значение true, копии в destPath получат отметки времени оригиналов (время создания, последней модификации и т.п.).

Пример использования:

import * as fs from "node:fs";

assert.deepEqual(
Array.from(traverseDir(".")),
[
"dir-orig",
"dir-orig/some-file.txt",
]
);

fs.cpSync("dir-orig", "dir-copy", { recursive: true });

assert.deepEqual(
Array.from(traverseDir(".")),
[
"dir-copy",
"dir-copy/some-file.txt",
"dir-orig",
"dir-orig/some-file.txt",
]
);

5.2. Переименование или перемещение файлов или директорий

fs.renameSync(oldPath, newPath) переименовывает или перемещает файл или директорию из oldPath в newPath.

Пример использования данной функции для переименования директории:

import * as fs from "node:fs";

assert.deepEqual(
Array.from(traverseDir(".")),
[
"old-dir-name",
"old-dir-name/some-file.txt",
]
);

fs.renameSync("old-dir-name", "new-dir-name");

assert.deepEqual(
Array.from(traverseDir(".")),
[
"new-dir-name",
"new-dir-name/some-file.txt",
]
);

Пример перемещения файла:

assert.deepEqual(
Array.from(traverseDir(".")),
[
"dir",
"dir/subdir",
"dir/subdir/some-file.txt",
]
);

fs.renameSync("dir/subdir/some-file.txt", "some-file.txt");

assert.deepEqual(
Array.from(traverseDir(".")),
[
"dir",
"dir/subdir",
"some-file.txt",
]
);

6. Удаление файлов или директорий

6.1. Удаление файлов и директорий (rm, rm -r)

fs.rmSync(path, options?) удаляет файл или директорию по указанному пути. Полезные настройки:

  • recursive (false): директории (включая пустые) удаляются только когда данная настройка имеет значение true;
  • force (false): если имеет значение false, при отсутствии файла или директории по указанному пути выбрасывается исключение.

Пример удаления файла:

import * as fs from "node:fs";

assert.deepEqual(
Array.from(traverseDir(".")),
[
"dir",
"dir/some-file.txt",
]
);

fs.rmSync("dir/some-file.txt");

assert.deepEqual(
Array.from(traverseDir(".")),
[
"dir",
]
);

Пример рекурсивного удаления непустой директории:

import * as fs from "node:fs";

assert.deepEqual(
Array.from(traverseDir(".")),
[
"dir",
"dir/subdir",
"dir/subdir/some-file.txt",
]
);

fs.rmSync("dir/subdir", {recursive: true});

assert.deepEqual(
Array.from(traverseDir(".")),
[
"dir",
]
);

6.2. Удаление пустых директорий (rmdir)

fs.rmdirSync удаляет пустую директорию (если директория не является пустой, выбрасывается исключение):

import * as fs from "node:fs";

assert.deepEqual(
Array.from(traverseDir(".")),
[
"dir",
"dir/subdir",
]
);

fs.rmdirSync("dir/subdir");

assert.deepEqual(
Array.from(traverseDir(".")),
[
"dir",
]
);

6.3. Создание директорий

Следующая функция очищает директорию по указанному пути:

import * as path from "node:path";
import * as fs from "node:fs";

function clearDir(dirPath) {
for (const fileName of fs.readdirSync(dirPath)) {
const pathName = path.join(dirPath, fileName);

fs.rmSync(pathName, { recursive: true });
}
}

Здесь:

  • fs.readdirSync(path) возвращает названия всех потомков директории по указанному пути;
  • fs.rmSync(path, options?) удаляет файлы и директории.

Пример использования:

assert.deepEqual(
Array.from(traverseDir(".")),
[
"dir",
"dir/dir-file.txt",
"dir/subdir",
"dir/subdir/subdir-file.txt"
]
);

clearDirectory("dir");

assert.deepEqual(
Array.from(traverseDir(".")),
[
"dir",
]
);

6.4. Перемещение файлов или директорий в корзину

Библиотека trash перемещает файлы или директории в корзину. Она работает на macOS, Windows и Linux.

Пример использования:

import trash from "trash";

await trash(["*.png", "!rainbow.png"]);

trash() принимает строку или массив строк в качестве первого параметра. Любая строка может быть паттерном поиска (glob pattern) (со звездочками и другими метасимволами).

7. Чтение и изменение записей файловой системы

7.1. Определение наличия файла или директории

fs.existsSync(path) возвращает true, если файл или директория по указанному пути существует:

import * as fs from "node:fs";

assert.deepEqual(
Array.from(traverseDir(".")),
[
"dir",
"dir/some-file.txt",
]
);
assert.equal(
fs.existsSync("dir"), true
);
assert.equal(
fs.existsSync("dir/some-file.txt"), true
);
assert.equal(
fs.existsSync("dir/non-existent-file.txt"), false
);

7.2. Получение статистики файла: является ли файл директорией, когда он был создан и т.д.

fs.statSync(path, options?) возвращает экземпляр fs.Stats с полезной информацией о файле или директории по указанному пути. Основные настройки:

  • throwIfNoEntry (true): если true, при отсутствии записи выбрасывается исключение, если false, возвращается undefined;
  • bigint (false): если true, функция использует BigInt для числовых значений (таких как отметки времени, см. ниже).

Свойства экземпляров fs.Stats:

  • вид записи:
    • stats.isFile();
    • stats.isDirectory();
    • stats.isSymbolicLink();
  • stats.size: размер в байтах;
  • отметки времени:
    • 3 вида:
      • stats.atime: время последнего доступа;
      • stats.mtime: время последней модификации;
      • stats.birthtime: время создания;
    • каждый вид может использовать 3 единицы измерения, например, для atime:
      • stats.atime: экземпляр Date;
      • stats.atimeMS: миллисекунды с начала эпохи (POSIX);
      • stats.atimeNs: наносекунды с начала эпохи.

Пример реализации функции isDirectory с помощью fs.statsSync():

import * as fs from "node:fs";

function isDirectory(thePath) {
const stats = fs.statSync(thePath, { throwIfNoEntry: false });

return stats && stats.isDirectory();
}

assert.deepEqual(
Array.from(traverseDir(".")),
[
"dir",
"dir/some-file.txt",
]
);
assert.equal(
isDirectory("dir"), true
);
assert.equal(
isDirectory("dir/some-file.txt"), false
);
assert.equal(
isDirectory("non-existent-dir"), false
);

7.3. Изменение атрибутов файла: разрешения, владелец, группа, отметки времени

Функции для модификации атрибутов файла:

8. Работа со ссылками

Функции для работы с жесткими ссылками (hard links):

Функции для работы с символическими ссылками (symbolic links):

Следующие функции оперируют символическими ссылками без их разыменования (dereferencing) (обратите внимание на префикс l):

Еще одна полезная функция - fs.realpathSync(path, options?) вычисляет каноническое название пути посредством разрешения символов . и .., а также символических ссылок.

Настройки функций, влияющие на обработку символических ссылок:

  • fs.cpSync(srcPath, destPath, options?):
    • dereference (false): если true, копируется файл, на который указывает символическая ссылка, а не сама ссылка;
    • verbatimSymlinks (false): если false, обновляется указатель локации цели скопированной символической ссылки.

Ссылки для дальнейшего изучения.