# Bài 07 — Upload ảnh với Multer
⬅️ Bài trước | Mục lục | Bài tiếp theo ➡️
🎯 Mục tiêu
- Hiểu vì sao upload file không dùng được
express.json() - Cấu hình Multer: nơi lưu, tên file, lọc định dạng, giới hạn dung lượng
- Viết API
POST /productsnhận cả dữ liệu text lẫn file ảnh - Phục vụ ảnh qua HTTP để frontend hiển thị
📚 1. Vì sao cần Multer?
Khi gửi form thường (chỉ có text), trình duyệt gửi:
Content-Type: application/json
{"name":"Gucci","price":5050000}
express.json() đọc được.
Nhưng khi form có file, trình duyệt phải dùng định dạng khác:
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWx
------WebKitFormBoundary7MA4YWx
Content-Disposition: form-data; name="name"
Gucci Flora
------WebKitFormBoundary7MA4YWx
Content-Disposition: form-data; name="image"; filename="sp1.jpg"
Content-Type: image/jpeg
<dữ liệu nhị phân của ảnh...>
------WebKitFormBoundary7MA4YWx--
Định dạng này trộn lẫn text và dữ liệu nhị phân, phân cách bằng chuỗi boundary.
express.json() không hiểu — req.body sẽ rỗng.
Multer là middleware chuyên phân tích multipart/form-data. Nó tách ra:
- Các trường text →
req.body - File →
req.file(1 file) hoặcreq.files(nhiều file)
💻 2. Cấu hình Multer
Thêm vào đầu backend/routes/products.js:
const multer = require("multer");
// 1. Cấu hình nơi lưu và tên file
let storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, "./public/img");
},
filename: function (req, file, cb) {
cb(null, file.originalname);
}
});
// 2. Hàm lọc định dạng file
function checkFileUpload(req, file, cb) {
if (!file.originalname.match(/\.(jpg|jpeg|png|gif)$/)) {
return cb(new Error("Định dạng oki la "));
} else {
cb(null, true);
}
}
// 3. Tạo instance multer
let upload = multer({
storage: storage,
fileFilter: checkFileUpload,
limits: { fileSize: 50 * 1024 * 1024 } // 50 MB
});
🔍 Giải thích
multer.diskStorage() — lưu file lên ổ đĩa
Multer có 2 kiểu lưu trữ:
diskStorage— ghi file ra ổ đĩa (dự án dùng cách này)memoryStorage— giữ trong RAM dưới dạng Buffer (dùng khi muốn upload tiếp lên Cloudinary/S3)
destination — lưu vào thư mục nào
destination: function (req, file, cb) {
cb(null, "./public/img");
}
cb là callback — cách Node.js xử lý bất đồng bộ kiểu cũ:
- Tham số 1: lỗi (
null= không lỗi) - Tham số 2: kết quả
⚠️ Cạm bẫy quan trọng:
"./public/img"là đường dẫn tương đối so với thư mục bạn đang đứng khi chạy lệnh, không phải so với fileproducts.js.Nếu bạn chạy
npm run devtừbackend/→ lưu vàobackend/public/img/✅ Nếu chạy từ thư mục cha → lưu vào<thư mục cha>/public/img/❌ và frontend không thấy ảnh.Cách viết an toàn:
const path = require("path"); destination: function (req, file, cb) { cb(null, path.join(__dirname, "../public/img")); }
__dirnameluôn là thư mục chứa file hiện tại → đường dẫn luôn đúng.
filename — đặt tên file
filename: function (req, file, cb) {
cb(null, file.originalname); // giữ nguyên tên gốc
}
⚠️ Rất nguy hiểm. Nếu 2 người cùng upload file tên
image.jpg, file sau ghi đè file trước → sản phẩm cũ mất ảnh.Tệ hơn, tên file do người dùng kiểm soát có thể chứa
../để ghi ra ngoài thư mục (path traversal), hoặc chứa ký tự đặc biệt gây lỗi.Cách viết an toàn:
filename: function (req, file, cb) { const ext = path.extname(file.originalname).toLowerCase(); const uniqueName = `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`; cb(null, uniqueName); // ví dụ: 1723800000000-847362819.jpg }
fileFilter — chỉ nhận ảnh
function checkFileUpload(req, file, cb) {
if (!file.originalname.match(/\.(jpg|jpeg|png|gif)$/)) {
return cb(new Error("Định dạng oki la "));
} else {
cb(null, true);
}
}
.match(/\.(jpg|jpeg|png|gif)$/) là regex kiểm tra phần mở rộng:
\.— dấu chấm thật(jpg|jpeg|png|gif)— một trong các đuôi này$— phải ở cuối chuỗi
Thông báo lỗi "Định dạng oki la " rõ ràng là viết vội và sai nghĩa (báo lỗi mà lại nói "ok").
Nên sửa thành: "Chỉ chấp nhận file ảnh (jpg, jpeg, png, gif)".
⚠️ Kiểm tra phần mở rộng không đảm bảo file đúng là ảnh. Kẻ xấu có thể đổi tên
virus.exethànhvirus.jpg. Kiểm tra thêmfile.mimetype:const allowed = ["image/jpeg", "image/png", "image/gif", "image/webp"]; if (!allowed.includes(file.mimetype)) { return cb(new Error("Chỉ chấp nhận file ảnh")); }Regex cũng nên thêm cờ
iđể chấp nhận.JPGviết hoa:/\.(jpg|jpeg|png|gif)$/i
limits — giới hạn dung lượng
limits: { fileSize: 50 * 1024 * 1024 } // 50 MB
50 * 1024 * 1024 = 52.428.800 byte. Viết vậy dễ đọc hơn số dài.
💡 50 MB là quá lớn cho ảnh sản phẩm. Ảnh web thường dưới 2 MB. Nên đặt
5 * 1024 * 1024(5 MB) để tránh người dùng upload nhầm file nặng.
💻 3. Route POST /products
Thêm vào cuối backend/routes/products.js, trước module.exports:
// POST /products — Thêm sản phẩm mới (có upload ảnh)
router.post("/", upload.single("image"), async (req, res, next) => {
const db = await connectDb();
const productCollection = db.collection("products");
const newProduct = {
_id: null,
name: req.body.name,
price: req.body.price,
description: req.body.description,
categoryId: new ObjectId(req.body.categoryId),
image: req.file.originalname,
rating: 0,
};
const products = await productCollection.insertOne(newProduct);
if (products.insertedId) {
res.status(200).json({ message: "thêm sản phẩm thành công" });
} else {
res.status(404).json({ message: "Không tìm thấy" });
}
});
🔍 Giải thích
router.post("/", upload.single("image"), async (req, res, next) => {
// ↑ middleware của Multer chạy TRƯỚC handler
upload.single("image") nghĩa là: "nhận 1 file từ trường tên image".
Chuỗi "image" phải khớp với name của input bên frontend:
<input type="file" name="image" />
Sau khi middleware này chạy xong:
| Biến | Chứa gì |
|---|---|
req.body |
Các trường text: name, price, description, categoryId |
req.file |
Thông tin file: originalname, filename, path, size, mimetype |
Các biến thể của upload
upload.single("image") // 1 file → req.file
upload.array("images", 5) // tối đa 5 file cùng tên → req.files
upload.fields([ // nhiều trường khác nhau → req.files
{ name: "avatar", maxCount: 1 },
{ name: "gallery", maxCount: 8 }
])
upload.none() // không nhận file, chỉ parse text
⚠️ 4. Bảy vấn đề của route này
Vấn đề 1 — _id: null
const newProduct = { _id: null, ... };
MongoDB sẽ lưu document với _id là null thật, không tự sinh ObjectId!
Tệ hơn: _id phải là duy nhất. Thêm sản phẩm thứ hai sẽ lỗi:
E11000 duplicate key error collection: lith_perfume.products index: _id_ dup key: { _id: null }
Nghĩa là bạn chỉ thêm được ĐÚNG 1 sản phẩm, cái thứ 2 trở đi sẽ lỗi.
✅ Cách sửa: bỏ hẳn dòng _id: null. MongoDB tự sinh _id khi bạn không chỉ định.
Vấn đề 2 — price lưu dưới dạng chuỗi
price: req.body.price, // ← "5050000" (chuỗi, vì FormData luôn gửi chuỗi)
Hậu quả:
- Sắp xếp theo giá sai:
"9000">"10000"khi so sánh chuỗi - Lọc
{ price: { $gt: 4000000 } }không khớp - Frontend gọi
product.price.toLocaleString()vẫn chạy nhưngprice * quantitycho kết quả lạ
✅ Cách sửa: price: Number(req.body.price)
Vấn đề 3 — req.file có thể undefined
image: req.file.originalname,
Nếu người dùng submit form mà không chọn ảnh, req.file là undefined
→ TypeError: Cannot read properties of undefined (reading 'originalname')
→ request treo (không có try/catch).
✅ Cách sửa:
if (!req.file) {
return res.status(400).json({ message: "Vui lòng chọn ảnh sản phẩm" });
}
Vấn đề 4 — Không validate dữ liệu
Gửi request rỗng cũng được chấp nhận, tạo ra document toàn undefined.
✅ Cách sửa: kiểm tra các trường bắt buộc trước khi insert.
Vấn đề 5 — new ObjectId(req.body.categoryId) có thể ném lỗi
Nếu categoryId không phải chuỗi 24 ký tự hex → lỗi, request treo.
✅ Cách sửa: ObjectId.isValid() trước.
Vấn đề 6 — Dùng req.file.originalname thay vì req.file.filename
image: req.file.originalname, // tên GỐC người dùng đặt
Hai giá trị này chỉ trùng nhau vì cấu hình filename giữ nguyên tên gốc.
Nếu bạn áp dụng cách đặt tên duy nhất (khuyến nghị ở mục 2), chúng sẽ khác nhau
và ảnh sẽ không hiển thị được.
✅ Cách đúng: luôn dùng req.file.filename — tên file thực tế trên đĩa.
Vấn đề 7 — Trả về 404 khi insert thất bại
404 Not Found nghĩa là "không tìm thấy tài nguyên" — không đúng ngữ cảnh tạo mới.
Đúng ra là 500 Internal Server Error. Và khi thành công nên trả 201 Created.
💻 5. Phiên bản hoàn chỉnh
// backend/routes/products.js — phần upload, PHIÊN BẢN CẢI TIẾN
var express = require("express");
var router = express.Router();
const path = require("path");
const multer = require("multer");
const connectDb = require("../model/db");
const { ObjectId } = require("mongodb");
// ===== Cấu hình Multer =====
const storage = multer.diskStorage({
destination: function (req, file, cb) {
// Dùng __dirname để đường dẫn luôn đúng dù chạy từ đâu
cb(null, path.join(__dirname, "../public/img"));
},
filename: function (req, file, cb) {
// Tên duy nhất: <timestamp>-<số ngẫu nhiên>.<đuôi>
const ext = path.extname(file.originalname).toLowerCase();
const uniqueName = `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`;
cb(null, uniqueName);
},
});
function checkFileUpload(req, file, cb) {
const allowedMime = ["image/jpeg", "image/png", "image/gif", "image/webp"];
const isValidExt = /\.(jpg|jpeg|png|gif|webp)$/i.test(file.originalname);
if (!isValidExt || !allowedMime.includes(file.mimetype)) {
return cb(new Error("Chỉ chấp nhận file ảnh: jpg, jpeg, png, gif, webp"));
}
cb(null, true);
}
const upload = multer({
storage,
fileFilter: checkFileUpload,
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
});
// ===== POST /products =====
router.post("/", upload.single("image"), async (req, res, next) => {
try {
const { name, price, description, categoryId } = req.body;
// --- Validate ---
if (!name || !name.trim()) {
return res.status(400).json({ message: "Tên sản phẩm là bắt buộc" });
}
if (!price || isNaN(Number(price)) || Number(price) <= 0) {
return res.status(400).json({ message: "Giá phải là số dương" });
}
if (!categoryId || !ObjectId.isValid(categoryId)) {
return res.status(400).json({ message: "Danh mục không hợp lệ" });
}
if (!req.file) {
return res.status(400).json({ message: "Vui lòng chọn ảnh sản phẩm" });
}
const db = await connectDb();
// Kiểm tra danh mục có tồn tại thật không
const category = await db.collection("categories")
.findOne({ _id: new ObjectId(categoryId) });
if (!category) {
return res.status(400).json({ message: "Danh mục không tồn tại" });
}
const newProduct = {
name: name.trim(),
price: Number(price),
description: description ? description.trim() : "",
categoryId: new ObjectId(categoryId),
image: req.file.filename, // tên file THỰC TẾ trên đĩa
rating: 0,
createdAt: new Date(),
};
const result = await db.collection("products").insertOne(newProduct);
if (!result.insertedId) {
return res.status(500).json({ message: "Thêm sản phẩm thất bại" });
}
res.status(201).json({
message: "Thêm sản phẩm thành công",
product: { ...newProduct, _id: result.insertedId },
});
} catch (error) {
next(error);
}
});
module.exports = router;
💻 6. Bắt lỗi của Multer
Multer ném lỗi theo cách riêng. Thêm middleware này vào backend/app.js,
sau các app.use(...router) nhưng trước middleware 404:
const multer = require('multer');
// Xử lý lỗi upload
app.use(function (err, req, res, next) {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ message: 'File quá lớn (tối đa 5MB)' });
}
return res.status(400).json({ message: `Lỗi upload: ${err.message}` });
}
if (err && err.message && err.message.includes('Chỉ chấp nhận file ảnh')) {
return res.status(400).json({ message: err.message });
}
next(err); // lỗi khác → chuyển cho handler chung
});
Các mã lỗi Multer thường gặp:
| Mã | Nghĩa |
|---|---|
LIMIT_FILE_SIZE |
File vượt quá limits.fileSize |
LIMIT_FILE_COUNT |
Quá nhiều file |
LIMIT_UNEXPECTED_FILE |
Tên trường không khớp (name ở form khác upload.single("...")) |
📁 7. Chuẩn bị thư mục và ảnh
Tạo thư mục lưu ảnh:
# Windows PowerShell
New-Item -ItemType Directory -Force -Path "backend/public/img"
# macOS / Linux
mkdir -p backend/public/img
Copy 12 ảnh sản phẩm mẫu (sp1.jpg → sp12.jpg) vào đó.
Kiểm tra ảnh phục vụ được chưa
Nhờ app.use(express.static(path.join(__dirname, 'public'))) ở bài 04:
Mở http://localhost:5000/img/sp1.jpg — phải thấy ảnh hiện ra.
Nếu 404, kiểm tra:
- File có thật ở
backend/public/img/sp1.jpg? - Dòng
express.statictrongapp.jscó chưa? - Tên file có đúng chính tả, đúng hoa thường?
⚠️ Windows không phân biệt hoa thường trong tên file, nhưng Linux có.
SP1.jpgchạy được trên máy bạn nhưng 404 khi deploy. Luôn dùng tên thường.
✅ 8. Kiểm thử API upload bằng Postman
- Method: POST
- URL:
http://localhost:5000/products - Tab Body → chọn form-data (⚠️ KHÔNG chọn raw/JSON)
- Điền các trường:
| Key | Type | Value |
|---|---|---|
name |
Text | Dior Sauvage Elixir |
price |
Text | 4200000 |
description |
Text | Hương đầu: Bạch đậu khấu, Quế |
categoryId |
Text | 669a978e6e282b058a8e3f2c |
image |
File | (chọn một file ảnh từ máy) |
💡 Để đổi Type sang File: rê chuột vào cột
Key, sẽ hiện dropdown Text/File.
- Bấm Send
Kết quả mong đợi:
{
"message": "Thêm sản phẩm thành công",
"product": {
"name": "Dior Sauvage Elixir",
"price": 4200000,
"image": "1723800000000-847362819.jpg",
"rating": 0,
"_id": "66c0..."
}
}
Xác nhận 3 chỗ
- File đã có trên đĩa? Kiểm tra
backend/public/img/có file mới - Ảnh xem được? Mở
http://localhost:5000/img/<tên-file-vừa-tạo> - Document đã vào DB? Mở Compass, collection
productsphải có thêm 1 bản ghi
Test các trường hợp lỗi
| Thử gì | Kết quả mong đợi |
|---|---|
Bỏ trống name |
400 "Tên sản phẩm là bắt buộc" |
price = abc |
400 "Giá phải là số dương" |
| Không chọn file | 400 "Vui lòng chọn ảnh sản phẩm" |
Upload file .txt |
400 "Chỉ chấp nhận file ảnh..." |
categoryId = xyz |
400 "Danh mục không hợp lệ" |
| Upload file > 5MB | 400 "File quá lớn (tối đa 5MB)" |
Nếu cả 6 test đều đúng, API của bạn đã vững chắc hơn source gốc rất nhiều.
📚 9. Ghi chú về lưu trữ ảnh khi deploy
Cách lưu ảnh lên ổ đĩa server (diskStorage) chỉ hoạt động tốt khi bạn tự quản lý máy chủ.
Trên các nền tảng như Render, Heroku, Vercel, hệ thống file là ephemeral — mọi file bạn ghi sẽ biến mất khi ứng dụng khởi động lại (mỗi lần deploy, hoặc tự động sau vài giờ không dùng).
Giải pháp thực tế: upload ảnh lên dịch vụ lưu trữ riêng.
| Dịch vụ | Miễn phí | Ghi chú |
|---|---|---|
| Cloudinary | 25 GB | Dễ tích hợp nhất, có tối ưu ảnh tự động |
| AWS S3 | 5 GB (12 tháng) | Chuẩn công nghiệp |
| Supabase Storage | 1 GB | Dễ dùng, có SDK JS |
Bài 22 sẽ nhắc lại vấn đề này khi deploy.
📝 Bài tập
-
Thêm route xóa ảnh cũ khi cập nhật sản phẩm. Gợi ý:
const fs = require("fs"); fs.unlinkSync(path.join(__dirname, "../public/img", tenFileCu));Nhớ bọc
try/catch— file có thể đã bị xóa. -
Cho phép upload nhiều ảnh. Đổi sang
upload.array("images", 5), lưu vào fieldimages: req.files.map(f => f.filename). -
Nén ảnh trước khi lưu bằng thư viện
sharp:npm install sharpDùng
memoryStorage, rồi resize về chiều rộng tối đa 800px trước khi ghi ra đĩa. -
Tự tạo bug rồi sửa: giữ nguyên
_id: nulltrong route POST, thêm 2 sản phẩm liên tiếp. Đọc kỹ thông báo lỗiE11000 duplicate key. Đây là lỗi bạn sẽ gặp lại nhiều lần trong nghề.
All rights reserved