Пошук уроків, статей та іншого контенту
Обробляйте великі файли потоково, не завантажуючи весь їхній вміст у оперативну пам’ять.
Методи readFile і writeFile зручні для невеликих файлів, але вони завантажують або створюють увесь вміст у пам’яті:
const content = await readFile('large.log');
await writeFile('backup.log', content);Для файлу розміром 10 ГБ цей підхід може:
використати кілька гігабайтів оперативної пам’яті;
спричинити помилку JavaScript heap out of memory;
збільшити час до початку запису;
погіршити роботу інших частин застосунку.
Потік обробляє дані частинами. Файл читається невеликими блоками, кожен блок одразу передається далі, а після запису його пам’ять можна використати повторно.
Типова схема:
файл на диску → Readable stream → Writable stream → інший файлУ Node.js потоки поділяють на кілька типів:
Readable — джерело даних, наприклад файл для читання;
Writable — місце призначення, наприклад файл для запису;
Transform — одночасно читає та записує дані, змінюючи їх;
Duplex — окремі канали для читання і запису.
Для великих файлів найчастіше використовують:
createReadStream() для читання;
createWriteStream() для запису;
pipeline() для безпечного з’єднання потоків.
Потік читає дані як об’єкти Buffer. Тому потокове копіювання підходить не лише для текстових, а й для бінарних файлів.
Швидкість читання і швидкість запису можуть відрізнятися. Наприклад, диск може читати дані швидше, ніж інший диск або мережеве сховище встигає їх записувати.
Backpressure — механізм, який не дозволяє швидкому потоку необмежено накопичувати дані в пам’яті.
Коли внутрішній буфер потоку запису заповнений:
запис тимчасово призупиняється;
джерело перестає подавати нові блоки;
після звільнення місця передавання продовжується.
Саме тому потоки потрібно правильно з’єднувати. Використання pipeline() автоматично враховує backpressure і передає помилки між усіма потоками.
Найпростіший приклад — потокове копіювання файлу:
import { createReadStream, createWriteStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { pipeline } from 'node:stream/promises';
const [sourcePath, destinationPath] = process.argv.slice(2);
if (!sourcePath || !destinationPath) {
console.error('Використання: node copy-file.js <джерело> <призначення>');
process.exit(1);
}
if (sourcePath === destinationPath) {
console.error('Джерело та призначення мають бути різними файлами');
process.exit(1);
}
async function copyFile() {
const sourceInfo = await stat(sourcePath);
if (!sourceInfo.isFile()) {
throw new Error(`Шлях не є звичайним файлом: ${sourcePath}`);
}
const input = createReadStream(sourcePath, {
// Розмір одного блока. Це не максимальний обсяг пам'яті процесу.
highWaterMark: 1024 * 1024,
});
const output = createWriteStream(destinationPath);
await pipeline(input, output);
console.log(
`Скопійовано ${sourceInfo.size} байт: ${sourcePath} → ${destinationPath}`,
);
}
copyFile().catch((error) => {
console.error(`Помилка копіювання: ${error.message}`);
process.exitCode = 1;
});Запуск:
node copy-file.js large-input.bin large-output.binУ цьому прикладі:
файл не завантажується повністю в пам’ять;
createReadStream() читає його блоками;
createWriteStream() записує блоки в міру надходження;
pipeline() зупиняє потоки та відхиляє Promise, якщо виникла помилка.
highWaterMarkПараметр highWaterMark визначає бажаний поріг внутрішнього буфера потоку:
const input = createReadStream('input.bin', {
highWaterMark: 1024 * 1024,
});У цьому випадку читання відбувається блоками приблизно по 1 МБ.
Важливо:
highWaterMark не є жорстким лімітом усієї пам’яті;
у процесі можуть існувати буфери кількох потоків;
більший блок може зменшити кількість операцій введення-виведення;
надто великий блок збільшує затримку та використання пам’яті;
значення потрібно підбирати за результатами вимірювань, а не автоматично збільшувати.
Для звичайного копіювання часто достатньо стандартного значення. Явне значення має сенс, коли потрібно передбачувано налаштувати пропускну здатність або провести порівняння продуктивності.
Між читанням і записом можна розмістити Transform. Наприклад, цей потік перетворює ASCII-літери на верхній регістр:
import { createReadStream, createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { Transform } from 'node:stream';
const uppercase = new Transform({
transform(chunk, encoding, callback) {
// Обробляємо лише текстовий вхід у кодуванні UTF-8.
callback(null, chunk.toString('utf8').toUpperCase());
},
});
await pipeline(
createReadStream('input.txt'),
uppercase,
createWriteStream('output.txt'),
);pipeline() передає кожен блок від одного потоку до наступного. У пам’яті не з’являється копія всього файлу.
Однак просте перетворення блоків має важливу особливість: межа блока не обов’язково збігається з межею символу або рядка. Для бінарних даних це не проблема, але для тексту багатобайтовий символ може бути розділений між двома блоками.
Якщо обробка залежить від повних рядків або символів, потрібно використовувати відповідний потоковий декодер чи реалізовувати накопичення неповної частини між викликами transform(). Не можна припускати, що кожен chunk є завершеним рядком.
Без pipeline() легко пропустити помилки одного з потоків. Наприклад, помилка читання може виникнути вже після того, як запис почався.
pipeline():
підключає потоки в заданому порядку;
підтримує backpressure;
очікує завершення всього ланцюжка;
передає помилку в Promise;
закриває або знищує пов’язані потоки після помилки.
Якщо запис завершився невдало, файл призначення може залишитися частково записаним. Для критичних даних зазвичай записують результат у тимчасовий файл, а після успішного завершення перейменовують його в кінцеву назву.
readFileЦей код небезпечний для великих файлів:
const data = await readFile('large-file.bin');
const transformed = transform(data);
await writeFile('result.bin', transformed);Він може одночасно утримувати в пам’яті:
початковий файл;
результат перетворення;
тимчасові копії, створені функцією перетворення.
Потокова версія повинна передавати дані частинами:
Readable → Transform → WritableТак споживання пам’яті переважно залежить від розмірів буферів, а не від загального розміру файлу.
const chunks = [];
input.on('data', (chunk) => {
chunks.push(chunk);
});Це знову призводить до завантаження всього файлу в пам’ять. Якщо дані потрібно зберегти, обробляйте кожен блок одразу або передавайте його в наступний потік.
Ручний запис у потік без перевірки результату write() може переповнити буфер:
input.on('data', (chunk) => {
output.write(chunk);
});Для простого з’єднання використовуйте pipeline(). Якщо ручне керування необхідне, потрібно зупиняти читання, коли write() повертає false, і продовжувати після події drain.
Потоки можуть завершитися з помилкою через:
відсутність файла;
недостатні права доступу;
заповнений диск;
помилку файлової системи;
пошкоджене джерело даних.
Завершуйте pipeline() через try/catch або обробляйте відхилений Promise.
Потік не гарантує, що один chunk відповідає одному рядку. Рядок може бути розділений між кількома блоками, а один блок може містити багато рядків.
Потоки мають асинхронну модель і складніший контроль життєвого циклу. Для невеликих файлів readFile може бути простішим і цілком прийнятним. Потоки особливо важливі, коли розмір даних великий або заздалегідь невідомий.
Великі файли потрібно читати та записувати частинами.
createReadStream() створює потік читання з файла.
createWriteStream() записує дані без накопичення всього файла в пам’яті.
pipeline() з’єднує потоки, підтримує backpressure і коректно передає помилки.
highWaterMark керує розміром буферів, але не є загальним лімітом пам’яті.
Не накопичуйте всі chunk у масиві.
Для текстових даних враховуйте межі символів і рядків між блоками.
Після помилки запис призначення може бути частковим, тому для критичних операцій варто використовувати тимчасовий файл.