Як обробляти завантаження файлів в Express.js за допомогою Multer?
Multer - це middleware для Express, яке парсить запити multipart/form-data і перетворює сирі бінарні потоки на об'єкти req.file або req.files, з якими можуть працювати обробники маршрутів.
Теорія
TL;DR
- Multer як поштовий клерк: відкриває multipart-запит, відокремлює поля форми від файлів, передає обидва в обробник маршруту
- Express за замовчуванням парсить JSON та URL-encoded тіла, але файлові завантаження ігнорує повністю. Multer закриває цю прогалину
upload.single('field')для одного файлу,upload.array('field', n)для кількох з одного поля,upload.fields([...])для змішанихmemoryStorage()тримає файл у RAM якBuffer,diskStorage()записує на диск. Вибір залежить від того, що робитимеш з файлом далі- Завжди встановлюй
limitsтаfileFilterу продакшені. Без них будь-хто може надіслати 10GB «зображення» на твій сервер
Швидкий приклад
const express = require('express');
const multer = require('multer');
const app = express();
const upload = multer({ storage: multer.memoryStorage() });
app.post('/upload', upload.single('avatar'), (req, res) => {
// req.file = { fieldname, originalname, mimetype, size, buffer }
res.json({ message: 'Файл отримано', size: req.file.size });
});
app.listen(3000);
// POST /upload з form-data полем "avatar" = photo.jpg
// req.file.buffer готовий до обробки або відправки на S3upload.single('avatar') - це middleware. Він спрацьовує до твого обробника, парсить потік і заповнює req.file. Обробник просто читає результат.
Чому Express один не справиться
Express нативно парсить тіла application/json та application/x-www-form-urlencoded. Але файлові завантаження приходять як multipart/form-data - єдиний бінарний потік, розбитий boundary-маркерами з заголовка Content-Type. Express цей потік просто ігнорує.
Multer підключається до ланцюжка middleware, читає сирий потік через Busboy, ділить його по boundary-маркерах, буферизує файлові чанки в об'єкти Buffer і декодує текстові поля. Після Multer твій маршрут має req.body для полів і req.file для файлу.
Опції зберігання
Два вбудованих варіанти:
multer.memoryStorage() тримає файл у RAM як req.file.buffer. Підходить для обробки одразу після отримання: resize через sharp, відправка на S3, OCR. Не підходить для великих файлів або великого навантаження. 100 користувачів по 50MB кожен - це стрибок RAM на 5GB.
multer.diskStorage({ destination, filename }) записує прямо на диск. Підходить для файлів, які треба роздавати статично або обробити пізніше. Шлях і ім'я файлу контролюєш через callback-функції.
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, 'public/uploads/'),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${uuidv4()}${ext}`); // напр. 550e8400-e29b-41d4-a716-446655440001.jpg
}
});Якщо використовувати file.originalname напряму як ім'я файлу, виникнуть колізії. Двоє користувачів з файлом avatar.jpg перезапишуть один одного. UUID або Date.now() + random вирішують цю проблему.
Коли що використовувати
- Один файл з одного поля форми:
upload.single('fieldName') - Кілька файлів з одного поля:
upload.array('photos', 5)(другий аргумент - максимальна кількість) - Файли з різних полів:
upload.fields([{name: 'avatar', maxCount: 1}, {name: 'docs', maxCount: 3}]) - Без файлів, тільки поля форми: Multer не потрібен, використовуй
express.urlencoded() - Обробка в пам'яті та відправка на S3:
memoryStorage() - Роздача файлів з диску:
diskStorage()
Валідація та обмеження
Без обмежень Multer буферизує все, що приходить. Об'єкт limits і функція fileFilter закривають два основних вектори атак:
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 5 * 1024 * 1024, // макс 5MB на файл
files: 5 // макс 5 файлів на запит
},
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Дозволені тільки зображення'), false);
}
}
});fileFilter викликається до буферизації файлу. cb(null, false) відхиляє файл. cb(new Error(...)) передає помилку далі по ланцюжку middleware.
Важливий момент: mimetype береться з заголовка Content-Type клієнта. Зловмисник може надіслати image/jpeg для PHP-файлу. Для реального захисту перевіряй magic bytes у буфері. JPEG починається з 0xFF 0xD8, PNG з 0x89 0x50 0x4E 0x47. Читай req.file.buffer.slice(0, 4) і порівнюй.
Обробка помилок
Multer кидає два типи помилок: multer.MulterError при перевищенні вбудованих лімітів і звичайний Error з fileFilter або твого коду. Обробляй обидва в одному middleware:
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'Файл занадто великий (макс 5MB)' });
}
return res.status(400).json({ error: err.message });
}
if (err.message === 'Дозволені тільки зображення') {
return res.status(400).json({ error: err.message });
}
next(err);
});Якщо використовуєш diskStorage і файл зберігся на диск, але запит пізніше завалився - прибери файл: fs.unlink(req.file.path, () => {}).
Типові помилки
Забутий enctype="multipart/form-data" в HTML-формі
Без нього браузер відправляє application/x-www-form-urlencoded. Multer пропускає такий запит, req.file дорівнює undefined, і обробник падає на req.file.originalname.
<!-- ламається без жодних натяків на причину -->
<form action="/upload" method="post">
<input type="file" name="avatar">
</form>
<!-- правильно -->
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="avatar">
</form>Відсутність limits у продакшені
Надсилання 2GB файлу на endpoint з memoryStorage() і без ліміту fileSize буферизує все в RAM. Node падає. Додавай limits: { fileSize: 10 * 1024 * 1024 } як мінімум.
cb(null, false) у fileFilter без виключення
Коли fileFilter викликає cb(null, false), файл відхиляється тихо. req.file дорівнює undefined. Обробник падає при спробі прочитати req.file.size. Я бачив, як команди витрачали годину на дебаг того, чому завантаження зникають без помилок. Кидай виключення замість цього: cb(new Error('Невірний тип файлу'), false).
Оригінальне ім'я файлу на диску
cb(null, file.originalname) призводить до колізій. Десятеро користувачів з файлом photo.jpg перепишуть один і той самий файл. Використовуй uuidv4() або Date.now() + '-' + Math.round(Math.random() * 1e9).
memoryStorage для файлів, які все одно йдуть на диск
Якщо обробник робить fs.writeFile('uploads/' + file.originalname, req.file.buffer), файл спочатку буферизується в RAM і лише потім записується на диск. Використовуй diskStorage - Multer стримить його напряму і RAM не витрачається.
Де зустрічається у реальних проєктах
- API профілів:
memoryStorage(), потімs3.putObject({ Body: req.file.buffer }) - NestJS:
@UseInterceptors(FileInterceptor('file'))обгортає Multer, поведінка та сама - Strapi CMS: використовує
diskStorageдля медіабібліотеки - Пайплайни обробки зображень:
memoryStorage(), потімsharp(req.file.buffer).resize(200, 200)
Formidable - альтернатива для чистого Node.js без залежності від Express, з швидшим парсингом потоків. Busboy - це те, що Multer використовує під капотом. Якщо потрібна максимальна продуктивність при стримінгу гігабайтного файлу на S3 без буферизації, дивись у бік @aws-sdk/lib-storage або Busboy напряму.
Питання на співбесіді
Q: У чому різниця між upload.single(), upload.array() і upload.fields()?
A: single('avatar') очікує один файл у названому полі, кладе його в req.file. array('photos', 5) приймає кілька файлів з одного поля, кладе в масив req.files. fields([{name:'avatar'}, {name:'docs'}]) обробляє файли з різних полів, req.files стає об'єктом з ключами за іменами полів.
Q: Як обробляти великі файли без краша Node?
A: memoryStorage буферизує весь файл у RAM. Для великих файлів при навантаженні використовуй diskStorage - Multer стримить прямо на диск. Для S3 будуй stream pipeline через @aws-sdk/lib-storage Upload замість буферизації цілого файлу.
Q: Як перевірити реальний тип файлу, а не тільки MIME?
A: Перевіряй magic bytes у буфері. JPEG починається з 0xFF 0xD8, PNG з 0x89 0x50 0x4E 0x47. Читай req.file.buffer.slice(0, 4) і порівнюй. Поле mimetype береться з заголовка клієнта і може бути підроблене.
Q: Що станеться, якщо файл пройшов fileFilter, але запис на диск провалився?
A: Multer міг частково записати файл. Перевір req.file.path в обробнику помилок і виклич fs.unlink(req.file.path, () => {}) для прибирання. Конструкція try/finally в обробнику теж підходить.
Q: Чому Multer подвійно буферизує файли з memoryStorage при відправці на S3?
A: Multer збирає всі вхідні чанки в один Buffer через Busboy. Потім ти створюєш Readable stream з цього буфера для s3.putObject. Два екземпляри в пам'яті одночасно. Щоб уникнути цього, для великих S3-завантажень пропускай Multer і передавай req напряму через @aws-sdk/lib-storage Upload зі стримінгом.
Приклади
Базовий: завантаження одного файлу в пам'ять
const express = require('express');
const multer = require('multer');
const app = express();
const upload = multer({ storage: multer.memoryStorage() });
app.post('/api/avatar', upload.single('avatar'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'Файл не надіслано' });
}
// req.file.buffer містить сирі байти
// req.file.mimetype = 'image/jpeg'
// req.file.size = 45678
res.json({
name: req.file.originalname,
size: req.file.size,
type: req.file.mimetype
});
});
app.listen(3000);
// curl -F "avatar=@photo.jpg" http://localhost:3000/api/avatarupload.single('avatar') виконується як middleware до обробника. Якщо ім'я поля форми не збігається з 'avatar', req.file дорівнює undefined. Завжди перевіряй через if (!req.file).
Середній: завантаження аватара на диск з валідацією та обробкою помилок
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const multer = require('multer');
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, 'public/uploads/avatars/'),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `avatar-${uuidv4()}${ext}`);
// напр. avatar-550e8400-e29b-41d4-a716-446655440001.jpg
}
});
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Дозволені тільки зображення'), false);
}
},
limits: { fileSize: 5 * 1024 * 1024 } // 5MB
});
app.post('/api/users/avatar', upload.single('avatar'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Файл не надіслано' });
res.json({ avatarUrl: `/uploads/avatars/${req.file.filename}` });
});
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'Максимальний розмір файлу 5MB' });
}
if (err.message === 'Дозволені тільки зображення') {
return res.status(400).json({ error: err.message });
}
next(err);
});UUID-імена файлів запобігають колізіям при однакових іменах від різних користувачів. Middleware обробки помилок наприкінці перехоплює і вбудовані помилки Multer, і кастомні з fileFilter. Без нього помилки fileFilter стають необробленими 500-ми.
Просунутий: завантаження файлу на S3 через буфер
const multer = require('multer');
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
fileFilter: (req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (allowed.includes(file.mimetype)) cb(null, true);
else cb(new Error('Непідтримуваний формат'), false);
}
});
const s3 = new S3Client({ region: 'us-east-1' });
app.post('/api/photos', upload.single('photo'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'Файл не надіслано' });
const key = `photos/${Date.now()}-${req.file.originalname}`;
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
Body: req.file.buffer, // buffer з memoryStorage
ContentType: req.file.mimetype
}));
res.json({
url: `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${key}`
});
});memoryStorage тут правильний вибір: буфер потрібен для відправки на S3, файл ніколи не торкається диску. Для файлів більше 50MB розглянь @aws-sdk/lib-storage Upload зі стримінгом - він робить multipart S3-завантаження без буферизації всього файлу одразу.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.