Файлы и асинхронное чтение в Node.js
В первом состоянии DeskQueue начальные заявки записаны прямо в программе. Это смешивает выполнение с исходными данными: изменение набора требует редактировать JavaScript. Вынесем заявки в data/tickets.json и прочитаем их через node:fs/promises. Заодно разберём, что означает ожидание файловой операции и почему путь следует привязать к месту модуля.
Работаем с полными файлами stages/02. В этой главе файл является источником начальных данных. Мы не строим постоянное многопользовательское хранилище на JSON и не обещаем безопасную параллельную запись. После чтения программа по-прежнему работает с массивом одного процесса. PostgreSQL появится позже, когда для сохранения изменений понадобятся ограничения и согласованность.
Путь и содержимое
Полный файл data/tickets.json хранит прежние две заявки. JSON требует двойные кавычки вокруг имён и строк, не допускает комментариев и завершающих запятых. Он описывает данные, а не выполняемые выражения языка.
[
{ "id": 1, "title": "Заменить кабель проектора", "status": "open" },
{ "id": 2, "title": "Проверить микрофон", "status": "working" }
]
Теперь заменим server.js целиком. new URL вычисляет адрес файла относительно текущего модуля. readFile понимает файловый URL, поэтому дополнительное преобразование в строку системного пути здесь не требуется. Кодировка utf8 возвращает текст вместо объекта Buffer.
import { readFile } from 'node:fs/promises';
const source = new URL('./data/tickets.json', import.meta.url);
try {
const text = await readFile(source, 'utf8');
const tickets = JSON.parse(text);
if (!Array.isArray(tickets)) throw new Error('Ticket list must be an array');
const open = tickets.filter((ticket) => ticket.status === 'open');
console.log(JSON.stringify({ service: 'DeskQueue', open: open.length }));
} catch (error) {
console.error(`Cannot load tickets: ${error.message}`);
process.exitCode = 1;
}
Ожидаемый успешный вывод сохраняется: сервис DeskQueue и одна открытая заявка. Это показывает, что мы изменили происхождение данных, а не предметный результат. Операции и особенности API рассмотрены в документации файловой системы Node.js. Возможность передать URL удобна для ES modules, в которых не следует бездумно использовать глобальные имена CommonJS.
Если вместо пути относительно модуля написать только './data/tickets.json', результат будет зависеть от текущего рабочего каталога процесса. Такая запись допустима, когда рабочий каталог является частью явного договора. Для нашей учебной программы этого требования нет, поэтому привязываем исходные данные к файлу. Позднее постоянные вложения получат другой путь из конфигурации и будут храниться вне сменяемого кода.
Ожидание не равно блокировке всех запросов
readFile из fs/promises возвращает обещание. await приостанавливает продолжение текущего модуля до появления результата. Он не превращает вызов в синхронное чтение, которое блокирует исполнение JavaScript до возврата. Это различие станет важным, когда сервер начнёт обслуживать несколько запросов и ожидать базу.
При этом асинхронный вызов не делает операцию бесплатной. Файл нужно прочитать с диска, выделить память для содержимого, затем разобрать JSON. Большой файл целиком попадёт в память, а JSON.parse выполнится синхронно над полученной строкой. Поэтому нельзя решать любое ограничение сервера простой заменой readFileSync на await readFile. Для крупных данных потребуются потоковое чтение, иной формат либо база с ограниченным запросом.
В нашем наборе всего две строки, поэтому полное чтение уместно. Однократная загрузка при старте также отличается от чтения при каждом HTTP-запросе. В первом случае последующие изменения файла не появятся в памяти автоматически. Во втором возрастут затраты и появится риск увидеть частично подготовленное содержимое. Мы выберем однократную загрузку для начальных данных, а изменение заявок перенесём в явный слой хранилища.
Ошибка файла и ошибка формата
Если файл отсутствует, ошибка возникает на чтении. Если он существует, но содержит неверный JSON, чтение успешно, а ошибка возникает в JSON.parse. Общий catch сообщает о неудачной загрузке и устанавливает ненулевой код завершения. Такой код позволяет вызывающей оболочке понять, что ожидаемый результат не получен. Сообщение в терминале помогает человеку, но не заменяет сигнал завершения программе.
Для самостоятельного наблюдения читатель может создать копию учебного каталога и убрать закрывающую скобку массива. Ожидается ошибка разбора, а строка успешного результата не должна появиться. Если вместо массива сохранить объект, проверка Array.isArray выдаст нашу собственную ошибку. Эти случаи различаются по причине: синтаксис JSON, форма данных и доступность файла являются независимыми условиями.
Пока проверяем только верхний уровень. Массив может содержать объект без status, дублирующийся идентификатор или неизвестное состояние. Такой объект не станет открытой заявкой при фильтрации, но отсутствие ошибки не означает, что набор корректен. Позже введём договор входных значений. Важно не выдавать удачный JSON.parse за полную проверку предметных данных.
Можно было бы добавлять новую заявку через writeFile, однако при двух одновременных изменениях каждый процесс способен прочитать прежний массив и перезаписать результат другого. Асинхронность сама по себе не обеспечивает согласованности. Даже временный файл с переименованием защищает лишь некоторые свойства отдельной замены, а не всю последовательность чтения и изменения. Поэтому JSON остаётся в курсе учебным источником, а не заменой транзакционного хранилища.
Результат этого состояния — прежний расчёт над внешним файлом с предсказуемым путём и явной ошибкой загрузки. Данные и точка входа уже разделены физически; в следующем уроке разделим обязанности программы на модули и опишем её зависимости.
Полезно отдельно рассмотреть кодировку. В JSON сохранены русские заголовки, поэтому чтение как UTF-8 является частью договора файла. Если редактор сохранит байты в другой кодировке, синтаксис иногда останется допустимым, но заголовки будут искажены. Удачный разбор тогда не обнаружит смысловую потерю текста. Проверяйте именно содержание двух известных заявок, а не только отсутствие исключения. На больших проектах формат исходных данных и редакторская настройка должны быть одинаковыми у всех участников.
Права доступа являются ещё одной независимой причиной ошибки. Файл может существовать по верному адресу, но процесс не иметь права его читать. Подставлять пустой массив в таком случае неправильно: пользователь увидит ложную пустую очередь. Также не стоит автоматически создавать новый файл на месте отсутствующего исходника, если договор требует подготовленный набор. Для стартовой загрузки полезнее прекратить выполнение и обозначить причину. В дальнейших уроках готовность приложения будет проверяться до открытия сервера, а HTTP-сбой хранилища не будет маскироваться под отсутствие заявок. Так одна понятная файловая операция подготавливает модель ошибок постоянного сервиса.
Загрузка при старте даёт ещё одно полезное свойство: все запросы одного процесса начинают с одинакового набора. Если редактор изменил файл после запуска, это не считается командой API. Требуется новый запуск или явная операция перечитывания. Последняя должна иметь собственную проверку и заменять набор целиком только после успешного разбора. Не допускайте, чтобы часть обработчиков работала со старым массивом, а другая часть неожиданно увидела новый из общей переменной без установленного порядка.
Полные файлы учебного проекта. Примеры при подготовке текста не запускались; ответы и вывод в уроке описывают ожидаемый результат читателя.