0

# Bài 05 — Kết nối MongoDB & API đầu tiên

⬅️ Bài trước | Mục lục | Bài tiếp theo ➡️


🎯 Mục tiêu

  • Viết file model/db.js để kết nối MongoDB
  • Hiểu Router của Express — cách chia nhỏ route ra nhiều file
  • Viết API hoàn chỉnh đầu tiên: GET /categories
  • Test API bằng trình duyệt và Postman

💻 1. File backend/model/db.js

Tạo thư mục model trong backend, rồi tạo file db.js:

const { MongoClient } = require('mongodb');

const url = "mongodb://localhost:27017";
const dbName = 'lith_perfume';

async function connectDb() {
    const client = new MongoClient(url);
    await client.connect();
    console.log('Kết nối thành công đến server');
    return client.db(dbName);
}

module.exports = connectDb;

🔍 Giải thích từng dòng

const { MongoClient } = require('mongodb');

Lấy class MongoClient từ thư viện mongodb (destructuring — bài 02, mục 4).

const url = "mongodb://localhost:27017";

Connection string — địa chỉ MongoDB. Cấu trúc:

mongodb://[user:password@]host:port[/database][?options]
   ↑          ↑             ↑    ↑
giao thức  đăng nhập      máy   cổng

Chạy local không cần user/password. Bài 22 (deploy) sẽ dùng chuỗi dạng mongodb+srv://user:pass@cluster.mongodb.net/... của MongoDB Atlas.

const dbName = 'lith_perfume';

Tên database đã tạo ở bài 03. Phải khớp chính xác, phân biệt hoa thường.

async function connectDb() {
    const client = new MongoClient(url);
    await client.connect();
    return client.db(dbName);
}
  • new MongoClient(url) — tạo đối tượng client (chưa kết nối)
  • await client.connect() — thực sự mở kết nối, mất vài chục ms
  • client.db(dbName) — trả về đối tượng database để thao tác

Vì có await nên hàm phải là async, và mọi nơi gọi nó cũng phải await.


⚠️ 2. Vấn đề của cách viết này (quan trọng)

Cách viết trên là cách của source gốc — nó chạy được nhưng có nhược điểm lớn: mỗi request tạo một kết nối mới.

router.get("/", async (req, res) => {
  const db = await connectDb();   // ← mở kết nối MỚI
  // ...
});                                // ← và không bao giờ đóng

Bạn sẽ thấy console in ra Kết nối thành công đến server liên tục — mỗi lần load trang là vài lần. Với 100 người dùng đồng thời, MongoDB sẽ hết kết nối.

✅ Cách viết tốt hơn: dùng lại kết nối (connection reuse)

// backend/model/db.js — PHIÊN BẢN CẢI TIẾN
const { MongoClient } = require('mongodb');

const url = process.env.MONGODB_URI || "mongodb://localhost:27017";
const dbName = process.env.DB_NAME || 'lith_perfume';

// Biến lưu kết nối đã tạo, để dùng lại
let cachedDb = null;
const client = new MongoClient(url);

async function connectDb() {
    // Nếu đã kết nối rồi thì trả về luôn, không mở lại
    if (cachedDb) return cachedDb;

    await client.connect();
    console.log('✅ Đã kết nối MongoDB:', dbName);

    cachedDb = client.db(dbName);
    return cachedDb;
}

module.exports = connectDb;

Bạn dùng phiên bản nào cũng được — cả hai đều chạy đúng cho bài học. Tài liệu khuyến nghị dùng bản cải tiến vì:

  • Console sạch hơn (chỉ in 1 lần)
  • Nhanh hơn (không phải bắt tay lại mỗi request)
  • Chuẩn bị sẵn cho process.env khi deploy (bài 22)

Từ bài này trở đi, tài liệu dùng bản cải tiến.

💡 let cachedDb = null nằm ở cấp module nên chỉ chạy một lần khi file được require lần đầu. Node.js cache module — các lần require sau đều nhận cùng một đối tượng.


📚 3. Express Router — chia route ra nhiều file

Nếu viết tất cả route vào app.js, file đó sẽ dài hàng nghìn dòng. Router giải quyết việc này.

Router là "mini app"

var express = require("express");
var router = express.Router();   // tạo router con

router.get("/", handler);        // định nghĩa route

module.exports = router;         // xuất ra để app.js dùng

Đường dẫn được ghép lại

// Trong app.js
app.use('/categories', categoriesRouter);

// Trong routes/categories.js
router.get("/", ...)URL thật là  GET /categories
router.get("/hot", ...)URL thật là  GET /categories/hot
router.get("/id/:id", ...)URL thật là  GET /categories/id/123

Router không biết tiền tố của nó. Đây là điểm hay: bạn có thể đổi tiền tố trong app.js mà không cần sửa file router.


💻 4. API đầu tiên: GET /categories

Tạo file backend/routes/categories.js:

var express = require("express");
var router = express.Router();

// Import model
const connectDb = require("../model/db");
const { ObjectId } = require("mongodb");

// Lấy tất cả danh mục
router.get("/", async (req, res, next) => {
  const db = await connectDb();
  const categoriesCollection = db.collection("categories");
  const categories = await categoriesCollection.find().toArray();

  if (categories) {
    res.status(200).json(categories);
  } else {
    res.status(404).json({ message: "Không tìm thấy" });
  }
});

module.exports = router;

🔍 Bóc tách từng dòng

router.get("/", async (req, res, next) => {

Đăng ký: "khi có request GET tới đường dẫn / (của router này), chạy hàm sau". Hàm là async vì bên trong có await.

const db = await connectDb();

Lấy đối tượng database. Phải awaitconnectDb là async.

const categoriesCollection = db.collection("categories");

Lấy tham chiếu tới collection. Tên phải khớp chính xác với tên trong MongoDB (categories, không phải Categories hay category).

const categories = await categoriesCollection.find().toArray();
  • .find() không tham số = lấy tất cả, trả về một cursor (con trỏ)
  • .toArray() đọc hết cursor thành mảng JavaScript
res.status(200).json(categories);
  • .status(200) đặt mã trạng thái
  • .json(...) chuyển object/mảng thành chuỗi JSON, gắn header Content-Type: application/json, và gửi đi

Có thể nối chuỗi (chaining) vì mỗi hàm đều return res.


⚠️ 5. Ba vấn đề của đoạn code trên

Vấn đề 1 — if (categories) luôn đúng

.toArray() luôn trả về mảng, kể cả rỗng []. Mà []truthy trong JavaScript:

if ([]) console.log("chạy vào đây");   // ← có in ra!

→ Nhánh else (404) không bao giờ chạy. Với danh sách thì đây là hành vi đúng về mặt REST: danh sách rỗng vẫn là 200 [], không phải 404. Nhưng ý định của người viết rõ ràng không phải vậy.

Vấn đề 2 — Không có try/catch

Nếu MongoDB tắt, await connectDb() sẽ ném lỗi. Trong hàm async không có try/catch, lỗi biến thành unhandled promise rejection — Express 4 không bắt được.

Kết quả: request treo mãi, trình duyệt quay vòng, và bạn không biết lý do.

Vấn đề 3 — ObjectId được import nhưng không dùng

Ở file này ObjectId không cần thiết. Không gây lỗi, nhưng là code thừa.

✅ Phiên bản cải tiến

// backend/routes/categories.js — PHIÊN BẢN CẢI TIẾN
var express = require("express");
var router = express.Router();

const connectDb = require("../model/db");
const { ObjectId } = require("mongodb");

// GET /categories — lấy tất cả danh mục
router.get("/", async (req, res, next) => {
  try {
    const db = await connectDb();
    const categories = await db.collection("categories").find().toArray();
    res.status(200).json(categories);
  } catch (error) {
    next(error);   // đẩy lỗi cho middleware xử lý lỗi trong app.js
  }
});

// GET /categories/:id — lấy 1 danh mục (bổ sung, hữu ích cho bài 20)
router.get("/:id", async (req, res, next) => {
  try {
    if (!ObjectId.isValid(req.params.id)) {
      return res.status(400).json({ message: "ID không hợp lệ" });
    }

    const db = await connectDb();
    const category = await db.collection("categories")
      .findOne({ _id: new ObjectId(req.params.id) });

    if (!category) {
      return res.status(404).json({ message: "Không tìm thấy danh mục" });
    }

    res.status(200).json(category);
  } catch (error) {
    next(error);
  }
});

module.exports = router;

Ba cải tiến quan trọng:

  1. try/catch + next(error) — lỗi được xử lý đàng hoàng, không treo request
  2. ObjectId.isValid() — kiểm tra trước khi tạo ObjectId, tránh crash khi client gửi /categories/abc
  3. if (!category) — dùng ! để kiểm tra null, đúng logic hơn if (category)

💡 Mẹo: dùng return res.status(...) (có return) để dừng hàm ngay. Nếu quên return, code chạy tiếp xuống dưới và bạn sẽ gặp lỗi Cannot set headers after they are sent to the client.


💻 6. Gắn router vào app.js

Bỏ comment các dòng liên quan tới categories trong backend/app.js:

var categoriesRouter = require('./routes/categories');
// ...
app.use('/categories', categoriesRouter);

Xóa luôn route /ping tạm ở bài 04 nếu muốn.


✅ 7. Kiểm thử

Cách 1 — Trình duyệt

Chạy npm run dev rồi mở http://localhost:5000/categories

Bạn phải thấy JSON:

[
  { "_id": "66758da7cb0dded448a58ba7", "name": "Valentino", "image": "h3.png" },
  { "_id": "66758e26cb0dded448a58ba8", "name": "Chloé", "image": "h5.png" },
  ...
]

💡 Cài extension JSON Viewer cho Chrome để JSON hiển thị đẹp và có thể gập/mở.

Thử luôn route mới: http://localhost:5000/categories/669a978e6e282b058a8e3f2c → phải ra { "_id": "...", "name": "Gucci", "image": "h8.png" }

Thử ID sai: http://localhost:5000/categories/abc → phải ra { "message": "ID không hợp lệ" } với status 400 (không phải trang lỗi 500)

Cách 2 — Postman / Thunder Client

  1. Method: GET
  2. URL: http://localhost:5000/categories
  3. Send

Kiểm tra ở panel kết quả:

  • Status: 200 OK
  • Body: mảng 10 phần tử
  • Headers:Content-Type: application/jsonAccess-Control-Allow-Origin: *

Header CORS đó chứng minh app.use(cors()) đang hoạt động.

Cách 3 — curl (dòng lệnh)

curl http://localhost:5000/categories

🆘 Xử lý sự cố

<details> <summary>Trả về mảng rỗng []</summary>

Không có lỗi, chỉ là không tìm thấy dữ liệu. Kiểm tra theo thứ tự:

  1. Sai tên database? Trong db.js, dbName phải là 'lith_perfume'. Kiểm tra trong Compass tên chính xác là gì.
  2. Sai tên collection? db.collection("categories") — kiểm tra chính tả, phân biệt hoa thường, số nhiều.
  3. Chưa import dữ liệu? Trong Compass chạy db.categories.countDocuments(), phải ra 10. Nếu ra 0 → quay lại bài 03 mục 5.
</details> <details> <summary>Request bị treo, không có phản hồi</summary>

MongoDB chưa chạy. Đây là lý do phổ biến nhất.

Get-Service MongoDB        # phải là Running
Start-Service MongoDB      # nếu đang Stopped

Nếu dùng bản db.js cải tiến có try/catch trong route, bạn sẽ nhận được lỗi 500 kèm thông báo rõ ràng thay vì bị treo.

</details> <details> <summary>MongoServerSelectionError: connect ECONNREFUSED ::1:27017</summary>

Node đang phân giải localhost thành IPv6 (::1) nhưng MongoDB chỉ nghe IPv4.

Sửa trong db.js:

const url = "mongodb://127.0.0.1:27017";   // dùng IP thay vì localhost
</details> <details> <summary>Cannot GET /categories</summary>

Router chưa được gắn vào app.js. Kiểm tra:

  1. Dòng var categoriesRouter = require('./routes/categories'); đã bỏ comment chưa
  2. Dòng app.use('/categories', categoriesRouter); đã bỏ comment chưa
  3. File routes/categories.jsmodule.exports = router; ở cuối chưa
</details>

📚 8. Tóm tắt luồng hoạt động

Trình duyệt gọi: GET http://localhost:5000/categories
        │
        ▼
bin/www  →  server đang lắng nghe port 5000
        │
        ▼
app.js   →  chạy qua chuỗi middleware:
            cors() → morgan → express.json() → ...
        │
        ▼
app.use('/categories', categoriesRouter)
            URL bắt đầu bằng /categories → chuyển cho router
        │
        ▼
routes/categories.js  →  router.get("/") khớp
        │
        ▼
connectDb()  →  model/db.js  →  MongoClient kết nối
        │
        ▼
db.collection("categories").find().toArray()
        │
        ▼
MongoDB trả về 10 documents
        │
        ▼
res.status(200).json(categories)
        │
        ▼
Trình duyệt nhận JSON

📝 Bài tập

  1. Thêm route GET /categories/count trả về { total: 10 }. ⚠️ Chú ý thứ tự: route này phải đặt trước GET /:id, nếu không Express sẽ hiểu count là một id. Hãy thử đặt sau để tự chứng kiến lỗi.

  2. Thêm route POST /categories để tạo danh mục mới:

    { "name": "Dior", "image": "h11.png" }
    

    Nhớ validate: thiếu name thì trả về 400.

  3. Thêm query param sắp xếp: GET /categories?sort=name trả về danh sách đã sắp theo tên A→Z. Gợi ý: req.query.sort.sort({ name: 1 }).

  4. Tắt MongoDB (Stop-Service MongoDB) rồi gọi GET /categories. So sánh hành vi giữa bản có try/catch và bản không có.


⬅️ Bài trước | Mục lục | Bài tiếp theo: API sản phẩm ➡️


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í