0

# 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 /products nhậ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ểureq.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ặc req.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");
}

cbcallback — 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 file products.js.

Nếu bạn chạy npm run dev từ backend/ → lưu vào backend/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"));
}

__dirname luô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.exe thành virus.jpg. Kiểm tra thêm file.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 .JPG viế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 _idnull 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ưng price * quantity cho 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.fileundefinedTypeError: 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:

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.jpgsp12.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:

  1. File có thật ở backend/public/img/sp1.jpg?
  2. Dòng express.static trong app.js có chưa?
  3. 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 . SP1.jpg chạ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

  1. Method: POST
  2. URL: http://localhost:5000/products
  3. Tab Body → chọn form-data (⚠️ KHÔNG chọn raw/JSON)
  4. Đ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.

  1. 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ỗ

  1. File đã có trên đĩa? Kiểm tra backend/public/img/ có file mới
  2. Ảnh xem được? Mở http://localhost:5000/img/<tên-file-vừa-tạo>
  3. Document đã vào DB? Mở Compass, collection products phả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

  1. 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.

  2. Cho phép upload nhiều ảnh. Đổi sang upload.array("images", 5), lưu vào field images: req.files.map(f => f.filename).

  3. Nén ảnh trước khi lưu bằng thư viện sharp:

    npm install sharp
    

    Dùng memoryStorage, rồi resize về chiều rộng tối đa 800px trước khi ghi ra đĩa.

  4. Tự tạo bug rồi sửa: giữ nguyên _id: null trong route POST, thêm 2 sản phẩm liên tiếp. Đọc kỹ thông báo lỗi E11000 duplicate key. Đây là lỗi bạn sẽ gặp lại nhiều lần trong nghề.


⬅️ Bài trước | Mục lục | Bài tiếp theo: API đơn hàng ➡️


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí