JavaRush /Курсы /Модуль 4: Node.js, Next.js и Angular /Примеры работы с файловой системой в Node.js

Примеры работы с файловой системой в Node.js

Модуль 4: Node.js, Next.js и Angular
3 уровень , 9 лекция
Открыта

1. fs.promises: Асинхронность без боли (почти)

На прошлой лекции мы уже упоминали, что у многих методов модуля fs есть как синхронные, так и асинхронные версии с коллбэками. Но благодаря Promises и async/await, Node.js предлагает нам еще более элегантный способ работы с файловой системой – через fs.promises. Это по сути те же самые функции, но они возвращают промисы, что позволяет нам использовать любимый async/await и писать код, который выглядит как синхронный, но при этом остается асинхронным и не блокирует основной поток выполнения.

Чтобы получить доступ к промисифицированным версиям функций, нужно импортировать promises из модуля fs:

// journal.js
const fs = require('fs').promises; // Вот так импортируем "промисифицированную" версию
// ... ваш код дальше

Теперь, вместо fs.readFile(fileName, callback) или fs.readFileSync(fileName), мы можем использовать await fs.readFile(fileName).

Давайте представим, что каждая запись в нашем дневнике – это объект с полями id, date и text. Мы хотим сохранять их в файле entries.json внутри папки data.

Пример 1: Чтение JSON-файла с fs.promises

Представим, что у нас уже есть файл data/entries.json:

[
  { "id": 1, "date": "2024-07-15", "text": "Моя первая запись." },
  { "id": 2, "date": "2024-07-16", "text": "Сегодня было солнечно!" }
]

Теперь давайте попробуем прочитать его и превратить в массив JavaScript-объектов.

// journal.js
const fs = require('fs').promises; // Используем промисифицированный fs

async function loadEntries() {
    const filePath = './data/entries.json'; // Путь к файлу
    try {
        const data = await fs.readFile(filePath, 'utf8'); // Читаем файл, указываем кодировку
        console.log('Прочитанные данные:', data);
        const entries = JSON.parse(data); // Превращаем JSON-строку в JS-объект
        console.log('Записи дневника:', entries);
        return entries;
    } catch (error) {
        // Если файл не существует или JSON некорректен
        if (error.code === 'ENOENT') {
            console.log(`Файл ${filePath} не найден. Создаём пустой дневник.`);
            return []; // Возвращаем пустой массив, чтобы начать с чистого листа
        } else {
            console.error('Ошибка при загрузке записей:', error.message);
            // Можно бросить ошибку дальше или вернуть пустой массив, в зависимости от логики
            return []; 
        }
    }
}

// Запускаем функцию и обрабатываем результат
loadEntries();

В этом примере мы используем try...catch блок, чтобы изящно обработать ситуации, когда файла еще нет (ENOENT — Error NO ENTry) или когда содержимое файла не является корректным JSON-ом. Это очень важно для надёжных приложений! И да, .message у error позволяет получить более читабельное описание проблемы.

Пример 2: Запись JSON-файла с fs.promises

А теперь давайте научимся сохранять наши записи обратно в JSON-файл.

// journal.js (продолжение)
// ... (начало кода с импортом fs и loadEntries)

async function saveEntries(entries) {
    const filePath = './data/entries.json';
    try {
        // Превращаем JS-объект (массив записей) в JSON-строку
        // null, 2 делает JSON более читаемым (отступы в 2 пробела)
        const dataToSave = JSON.stringify(entries, null, 2); 
        await fs.writeFile(filePath, dataToSave, 'utf8'); // Записываем данные в файл
        console.log('Записи успешно сохранены!');
    } catch (error) {
        console.error('Ошибка при сохранении записей:', error.message);
    }
}

// Пример использования: добавляем новую запись и сохраняем
async function addNewEntry(text) {
    const entries = await loadEntries(); // Загружаем текущие записи
    const newId = entries.length > 0 ? Math.max(...entries.map(e => e.id)) + 1 : 1;
    const newEntry = {
        id: newId,
        date: new Date().toISOString().split('T')[0], // Дата в формате YYYY-MM-DD
        text: text
    };
    entries.push(newEntry); // Добавляем новую запись
    await saveEntries(entries); // Сохраняем обновленный список
    console.log('Новая запись добавлена:', newEntry);
}

// Давайте добавим запись
addNewEntry("Сегодня я узнал много нового о Node.js!");

Здесь мы видим мощь async/await — код выглядит последовательным, хотя на самом деле выполняет асинхронные операции. Функция JSON.stringify(entries, null, 2) форматирует JSON с отступами, что очень удобно для чтения человеком, но не обязательно для работы программы. null здесь означает, что мы не используем функцию-реплейсер, а 2 — это количество пробелов для отступа.

2. Модуль path: Навигация по файловой системе как профи

Мы уже пару раз использовали пути типа './data/entries.json'. Но что, если наше приложение будет запущено на Windows, где разделитель папок \? Или если путь нужно сформировать динамически? Вот тут на помощь приходит встроенный модуль path. Он предоставляет утилиты для работы с путями файлов и каталогов, делая ваш код кросс-платформенным и более надёжным.

Вместо того чтобы вручную склеивать строки с / или \, вы используете методы path, и он сам всё делает правильно для текущей операционной системы.

// journal.js (добавляем импорт path)
const fs = require('fs').promises;
const path = require('path'); // Вот он, наш герой!
// ... остальной код

path.join(): Склеиваем пути правильно

path.join() склеивает все переданные ему сегменты пути, используя правильный для операционной системы разделитель. Он также нормализует путь, удаляя лишние . или ...

// journal.js (примеры path.join)
const path = require('path');

const dataDir = 'data';
const fileName = 'entries.json';

// Создаем путь к файлу entries.json внутри папки data
const filePath = path.join(dataDir, fileName);
console.log(`Path.join: ${filePath}`); 
// Вывод на Linux/macOS: data/entries.json
// Вывод на Windows: data\entries.json

// Склеиваем больше сегментов
const fullPath = path.join(__dirname, '..', dataDir, fileName); // __dirname - текущая папка скрипта
console.log(`Полный путь с path.join: ${fullPath}`);
// Вывод на Linux/macOS: /path/to/your/project/data/entries.json
// Вывод на Windows: C:\path\to\your\project\data\entries.json

// path.join убирает лишние разделители
console.log(path.join('/foo', 'bar', 'baz/asdf', 'quux', '..')); // /foo/bar/baz/asdf

Использование path.join — это лучшая практика, потому что вы избавляетесь от проблем с кросс-платформенностью. Забудьте о ручном добавлении слешей!

path.resolve(): Превращаем относительные пути в абсолютные

path.resolve() похож на path.join(), но его основная задача — разрешить последовательность путей или сегментов пути в абсолютный путь. Если последний сегмент не начинается с /, path.resolve добавит текущую рабочую директорию. Это полезно, когда нужно убедиться, что вы работаете с конкретным, полным путём, независимо от того, откуда запущен скрипт.

// journal.js (примеры path.resolve)
const path = require('path');

// Предположим, что текущая рабочая директория (cwd) - /home/user/my-app
console.log(path.resolve('data', 'entries.json')); 
// /home/user/my-app/data/entries.json (если './data' находится в текущей директории)

console.log(path.resolve('/etc', 'data', '..', 'entries.json')); 
// /etc/entries.json (возвращается на одну директорию назад)

console.log(path.resolve('entries.json')); 
// /home/user/my-app/entries.json (добавляет текущую рабочую директорию)

Заметьте, path.resolve() всегда возвращает абсолютный путь, тогда как path.join() может вернуть относительный, если все его сегменты относительны.

path.basename(), path.dirname(), path.extname(): Извлекаем части пути

Эти методы помогают разобрать путь на составные части:

  • path.basename(path[, ext]): возвращает последнюю часть пути (имя файла или папки). Можно указать расширение, чтобы оно было удалено из имени.
  • path.dirname(path): возвращает часть пути, ведущую к файлу (название папки).
  • path.extname(path): возвращает расширение файла.
// journal.js (примеры path.basename, dirname, extname)
const path = require('path');

const fullFilePath = '/home/user/documents/report.txt';

console.log(`Базовое имя: ${path.basename(fullFilePath)}`); // report.txt
console.log(`Базовое имя без расширения: ${path.basename(fullFilePath, '.txt')}`); // report
console.log(`Имя директории: ${path.dirname(fullFilePath)}`); // /home/user/documents
console.log(`Расширение файла: ${path.extname(fullFilePath)}`); // .txt

const dirPath = '/home/user/my-folder/';
console.log(`Базовое имя для папки: ${path.basename(dirPath)}`); // my-folder
console.log(`Имя директории для папки: ${path.dirname(dirPath)}`); // /home/user

Эти методы очень полезны, когда вам нужно манипулировать именами файлов, генерировать новые пути или просто получить информацию о файле.

3. Чтение и запись JSON-файлов: Ваши данные становятся объектами (и наоборот)

Как уже говорилось, JSON (JavaScript Object Notation) — это формат обмена данными, который легко читается человеком и легко парсится машинами. Он идеально подходит для хранения структурированных данных, таких как записи нашего дневника.

В Node.js для работы с JSON есть два встроенных глобальных объекта (не требующих require()):

  • JSON.parse(jsonString): принимает JSON-строку и возвращает JavaScript-объект (или массив).
  • JSON.stringify(jsObject[, replacer[, space]]): принимает JavaScript-объект и возвращает его JSON-представление в виде строки.

Мы уже использовали их в примерах выше, но давайте еще раз закрепим.

Пример 3: Загрузка и сохранение записей дневника в JSON-файле

Давайте теперь полноценно интегрируем path и fs.promises для нашего приложения-дневника. Мы создадим функции, которые будут отвечать за загрузку всех записей из файла и сохранение их обратно.

// journal-app.js
const fs = require('fs').promises;
const path = require('path');

const DATA_DIR = 'data';
const ENTRIES_FILE = 'entries.json';
const fullFilePath = path.join(__dirname, DATA_DIR, ENTRIES_FILE); // Абсолютный путь к файлу данных

/**
 * Загружает записи дневника из JSON-файла.
 * Если файл не найден или некорректен, возвращает пустой массив.
 * @returns {Array} Массив записей дневника.
 */
async function loadJournalEntries() {
    try {
        const data = await fs.readFile(fullFilePath, 'utf8');
        return JSON.parse(data);
    } catch (error) {
        if (error.code === 'ENOENT') {
            console.log(`Файл ${ENTRIES_FILE} не найден. Начинаем с пустого дневника.`);
            return []; // Файл не существует, возвращаем пустой массив
        } else if (error instanceof SyntaxError) {
            console.error(`Ошибка парсинга JSON в файле ${ENTRIES_FILE}:`, error.message);
            return []; // Файл есть, но JSON некорректен
        } else {
            console.error('Неизвестная ошибка при загрузке записей:', error.message);
            throw error; // Бросаем ошибку дальше, если это что-то критическое
        }
    }
}

/**
 * Сохраняет записи дневника в JSON-файл.
 * @param {Array} entries - Массив записей дневника.
 */
async function saveJournalEntries(entries) {
    try {
        // Убедимся, что директория существует
        // { recursive: true } означает, что создаст все несуществующие родительские папки
        await fs.mkdir(path.join(__dirname, DATA_DIR), { recursive: true }); 
        
        const dataToSave = JSON.stringify(entries, null, 2);
        await fs.writeFile(fullFilePath, dataToSave, 'utf8');
        console.log('Записи дневника успешно сохранены.');
    } catch (error) {
        console.error('Ошибка при сохранении записей:', error.message);
        throw error;
    }
}

/**
 * Добавляет новую запись в дневник.
 * @param {string} text - Текст новой записи.
 */
async function addEntry(text) {
    const entries = await loadJournalEntries();
    const newId = entries.length > 0 ? Math.max(...entries.map(e => e.id)) + 1 : 1;
    const newEntry = {
        id: newId,
        date: new Date().toISOString().split('T')[0],
        text: text
    };
    entries.push(newEntry);
    await saveJournalEntries(entries);
    console.log(`Добавлена запись #${newEntry.id}: "${newEntry.text}"`);
}

// Запускаем наше приложение
async function runJournal() {
    console.log('--- Приложение "Мой Дневник" ---');
    
    // Добавим пару записей
    await addEntry('Сегодня я написал первую программу на Node.js!');
    await addEntry('Работа с файлами становится понятнее.');

    // Прочитаем все записи
    const allEntries = await loadJournalEntries();
    console.log('\n--- Все записи дневника ---');
    if (allEntries.length === 0) {
        console.log('Дневник пока пуст.');
    } else {
        allEntries.forEach(entry => {
            console.log(`${entry.date} [ID:${entry.id}]: ${entry.text}`);
        });
    }

    // Удалим одну запись (например, самую первую)
    if (allEntries.length > 0) {
        const entryToRemoveId = allEntries[0].id;
        const updatedEntries = allEntries.filter(e => e.id !== entryToRemoveId);
        await saveJournalEntries(updatedEntries);
        console.log(`\nЗапись с ID ${entryToRemoveId} удалена.`);
    }

    // Проверим, что осталось
    const remainingEntries = await loadJournalEntries();
    console.log('\n--- Записи после удаления ---');
    remainingEntries.forEach(entry => {
        console.log(`${entry.date} [ID:${entry.id}]: ${entry.text}`);
    });
}

runJournal().catch(console.error); // Запускаем и ловим любые ошибки

Этот код демонстрирует полный цикл работы: создание директории, загрузку, добавление, удаление и сохранение записей. Теперь наш дневник хранит записи структурированно, и нам не страшны разные операционные системы!

4. Работа с директориями

Файлы не могут просто так висеть в воздухе. Им нужны домики — директории. Node.js предоставляет удобные методы для создания, чтения и удаления директорий.

fs.mkdir(path[, options], callback) или fs.promises.mkdir(path[, options])

Для создания директорий используется mkdir. Самое полезное свойство здесь — recursive: true. Если вы попытаетесь создать data/subfolder/another без recursive: true и data/subfolder еще не существует, то получите ошибку. С recursive: true Node.js создаст всю цепочку недостающих папок.

// journal-app.js (добавим функцию для создания директории)
// ... (в начале кода)

async function ensureDataDirectory() {
    const dirPath = path.join(__dirname, DATA_DIR);
    try {
        await fs.mkdir(dirPath, { recursive: true }); // Создаем папку data, если её нет
        console.log(`Директория ${dirPath} готова.`);
    } catch (error) {
        if (error.code === 'EEXIST') { // EEXIST - Error EXISTS, директория уже есть
            console.log(`Директория ${dirPath} уже существует.`);
        } else {
            console.error(`Ошибка при создании директории ${dirPath}:`, error.message);
            throw error;
        }
    }
}

// Теперь добавим вызов этой функции в runJournal() перед сохранением
// await ensureDataDirectory();
// await saveJournalEntries(entries);

Обратите внимание, что в функции saveJournalEntries мы уже используем fs.mkdir с recursive: true прямо перед записью файла. Это гарантирует, что папка data (и любые родительские, если мы вдруг поменяем путь на another/nested/data) будет создана, если её не существует.

fs.readdir(path[, options], callback) или fs.promises.readdir(path[, options])

Для чтения содержимого директории (список файлов и поддиректорий) используется readdir.

// journal-app.js (пример readdir)
// ... (в начале кода)

async function listDataFiles() {
    const dirPath = path.join(__dirname, DATA_DIR);
    try {
        const files = await fs.readdir(dirPath); // Читаем содержимое директории
        console.log(`\nФайлы в директории ${DATA_DIR}:`);
        if (files.length === 0) {
            console.log('Директория пуста.');
        } else {
            files.forEach(file => console.log(`- ${file}`));
        }
    } catch (error) {
        if (error.code === 'ENOENT') {
            console.log(`Директория ${DATA_DIR} не существует.`);
        } else {
            console.error(`Ошибка при чтении директории ${dirPath}:`, error.message);
            throw error;
        }
    }
}

// Добавим в runJournal()
// await listDataFiles();

readdir возвращает просто имена файлов/папок, без полного пути. Чтобы получить полный путь, их нужно "склеить" с помощью path.join().

fs.rm(path[, options], callback) или fs.promises.rm(path[, options])

Для удаления файлов и директорий (даже непустых) используем fs.rm. Это более современный и гибкий метод, чем устаревшие fs.unlink (только для файлов) и fs.rmdir (только для пустых директорий).

// journal-app.js (пример rm)
// ... (в начале кода)

async function deleteDataDirectory() {
    const dirPath = path.join(__dirname, DATA_DIR);
    try {
        // Удаляем директорию и всё её содержимое
        // { recursive: true } - удалит все вложенные файлы и папки
        // { force: true }    - не будет бросать ошибку, если файла/папки не существует
        await fs.rm(dirPath, { recursive: true, force: true }); 
        console.log(`Директория ${DATA_DIR} и её содержимое успешно удалены.`);
    } catch (error) {
        console.error(`Ошибка при удалении директории ${dirPath}:`, error.message);
        throw error;
    }
}

// Можно добавить в runJournal() для очистки после тестов:
// await deleteDataDirectory();

Использование recursive: true и force: true при удалении директорий с fs.rm — очень мощная, но потенциально опасная комбинация. Убедитесь, что вы удаляете именно то, что нужно! force: true особенно полезен, чтобы избежать ошибок, если вы пытаетесь удалить то, чего уже нет.

Вот мы и освоили продвинутые методы работы с файловой системой. Ваше приложение-дневник теперь может безопасно хранить данные, независимо от операционной системы, и поддерживать порядок в файлах!

5. Типичные ошибки при работе с файловой системой

Ошибка №1: Забыли await при использовании fs.promises.
Если вы импортировали fs.promises, но забыли поставить await перед вызовом функции (const data = fs.readFile(...)), то data будет промисом, а не результатом чтения файла. Код продолжит выполняться, и попытка обработать data как обычную строку или объект приведет к немедленной ошибке, так как вы работаете с промисом, а не с его разрешенным значением. Всегда помните про await внутри async-функций!

Ошибка №2: Не обрабатываете ошибки при чтении/записи.
Это как играть в лотерею, но с гарантированным проигрышем. Если файл не существует, у вас нет прав на запись, или диск переполнен, без try...catch ваше приложение просто "упадёт" с ошибкой. Всегда оборачивайте файловые операции в try...catch и используйте error.code (ENOENT, EACCES, EEXIST и т.д.) для специфичной обработки проблем.

Ошибка №3: Некорректный JSON (при парсинге или при строке).
При чтении файла, если JSON.parse() пытается разобрать что-то, что не является валидным JSON-ом (например, пустой файл, обычный текст, или JSON с синтаксическими ошибками), он бросит SyntaxError. При записи же, если вы пытаетесь превратить в JSON объект, содержащий циклические ссылки или функции, JSON.stringify() тоже может вызвать ошибку. Всегда проверяйте, что данные корректны и сериализуемы.

Ошибка №4: Ручное склеивание путей с / или \.
Использование path.join() — это не просто хороший тон, это обязательное правило для кросс-платформенности. Если вы будете писать myFolder + '/' + myFile.json, ваш код сломается на Windows. Модуль path для того и существует, чтобы избавить вас от таких проблем.

Ошибка №5: Неправильное использование path.join() vs path.resolve().
Помните, path.join() склеивает сегменты пути, сохраняя их относительность, если они таковы. path.resolve() же всегда пытается получить абсолютный путь, используя текущую рабочую директорию как базу, если сегменты не являются абсолютными. Используйте join для построения относительных подпутей и resolve для получения гарантированно полного пути к файлу/папке.

Ошибка №6: Пытаетесь создать директорию без recursive: true, если родительской нет.
Если вы хотите создать /a/b/c, а /a и /a/b не существуют, fs.mkdir('/a/b/c') без { recursive: true } потерпит неудачу. Всегда используйте recursive: true при создании вложенных папок, если вы не уверены в существовании всех родительских директорий.

Ошибка №7: Удаляете непустые директории без recursive: true и force: true с fs.rm.
Попытка удалить директорию, в которой есть файлы или другие папки, с помощью fs.rm без опции recursive: true приведет к ошибке. Если вы хотите, чтобы Node.js "снёс всё на своём пути", используйте recursive: true. А если вы не хотите получать ошибку, если директории уже нет, добавьте force: true. Но будьте крайне осторожны с этой комбинацией!

1
Задача
Модуль 4: Node.js, Next.js и Angular, 3 уровень, 9 лекция
Недоступна
Корректное формирование пути к файлу
Корректное формирование пути к файлу
1
Задача
Модуль 4: Node.js, Next.js и Angular, 3 уровень, 9 лекция
Недоступна
Запись данных в JSON-файл с созданием директории
Запись данных в JSON-файл с созданием директории
3
Опрос
fs.promises, 3 уровень, 9 лекция
Недоступен
fs.promises
Модуль fs.promises
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ