← Lập trình JavaScript nâng cao

Bài 13 · Nâng cao · 32 phút

Dự án: dịch vụ backend nhỏ

Biên soạn bởi Nguyễn Anh Tuấn

Dự án cuối khoá Node: ghép mọi thứ đã học thành một dịch vụ backend nhỏ - REST API, lưu dữ liệu bằng SQLite, và một worker chạy nền xử lý tác vụ.

Mười một bài qua, mèo con đã rèn từng món riêng lẻ: REST API với node:http, node:sqlite, Worker Threads, và node:test. Bài cuối ráp tất cả thành một dịch vụ backend hoàn chỉnh - kho cá 🐟 sống trên đĩa, có REST API để thêm/đọc, một worker tính báo cáo, và test giữ phần lõi không vỡ. Không cài gì ngoài Node.

Cám dỗ đầu tiên là nhét hết vào một file server.js. Đừng. Tách theo vai trò - đúng nguyên tắc xuyên suốt site: lõi thuần tách khỏi I/O. Bốn mảnh:

cây thư mục dự án - mỗi file một việc

kho-ca/
├── package.json   # "type": "module" + scripts start/test
├── db.js          # TẦNG DỮ LIỆU: hàm thuần trên DB (không đụng HTTP)
├── server.js      # HTTP: định tuyến REST, gọi xuống db.js + giao việc cho worker
├── worker.js      # VIỆC NỀN: chạy luồng riêng, nhận việc qua message
└── db.test.js     # TEST: kiểm tầng dữ liệu trên kho :memory:

Ranh giới quan trọng nhất: db.js KHÔNG biết gì về HTTP, còn server.js KHÔNG tự viết SQL. Nhờ vậy db.test.js gọi thẳng được tầng dữ liệu mà chẳng cần mở server. Khai báo dự án là ESM và đặt hai lối tắt quen tay:

package.json - npm start chạy server, npm test chạy mọi *.test.js

{
  "name": "kho-ca",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "test": "node --test"
  }
}
  • Tách theo vai trò: db.js (dữ liệu thuần) · server.js (HTTP) · worker.js (việc nền) · *.test.js.
  • db.js không đụng HTTP, server.js không tự viết SQL - ranh giới này là lý do tầng dữ liệu test được.
  • "type": "module" cho dùng import/export; scripts start/test gói lệnh dài thành lối tắt quen tay.

Bắt đầu từ đáy. db.js mở (hoặc tạo) tệp kho, dựng bảng, rồi prepare sẵn các câu lệnh và phơi ra bốn hàm: themCa, dsCa, layCa, datMua. Mọi giá trị người dùng đi qua ? (prepared statement của bài Cơ sở dữ liệu với node:sqlite) - không bao giờ nối chuỗi SQL.

db.js - tầng dữ liệu, không một dòng HTTP nào ở đây

import { DatabaseSync } from "node:sqlite";

// Mở (hoặc tạo) tệp kho-ca.db rồi dựng bảng nếu chưa có.
// Trả về một object gồm DB và các hàm truy vấn - gọi từ server/test.
export function moKho(duongDan = "kho-ca.db") {
  const db = new DatabaseSync(duongDan);
  db.exec(`CREATE TABLE IF NOT EXISTS ca (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    ten        TEXT NOT NULL,
    khoi_luong REAL NOT NULL
  )`);

  // prepare MỘT lần, dùng lại nhiều lần (nhanh + an toàn injection)
  const stmtThem = db.prepare("INSERT INTO ca (ten, khoi_luong) VALUES (?, ?)");
  const stmtDs = db.prepare("SELECT id, ten, khoi_luong FROM ca ORDER BY id");
  const stmtLay = db.prepare("SELECT id, ten, khoi_luong FROM ca WHERE id = ?");
  const stmtMua = db.prepare("UPDATE ca SET khoi_luong = ? WHERE id = ?");

  return {
    db,
    // Tầng dữ liệu là hàm THUẦN trên DB: không đụng HTTP, không in gì → test thẳng.
    themCa(ten, khoiLuong) {
      const kq = stmtThem.run(ten, khoiLuong);
      return { id: Number(kq.lastInsertRowid), ten, khoi_luong: khoiLuong };
    },
    dsCa() {
      return stmtDs.all();
    },
    layCa(id) {
      return stmtLay.get(id) ?? null; // không có → null (server dịch thành 404)
    },
    datMua(id, khoiLuongMoi) {
      return stmtMua.run(khoiLuongMoi, id).changes; // số dòng đổi: 0 = không có id đó
    }
  };
}

Thử riêng tầng này bằng một script nhỏ - chưa cần server, chưa cần mạng:

demo-db.js - gọi thẳng tầng dữ liệu (lược 2 dòng cảnh báo experimental ở cuối)

import { moKho } from "./db.js";

const kho = moKho("kho-ca.db");
console.log(kho.themCa("cá thu", 2.4));
console.log(kho.themCa("cá hồi", 5.2));
console.log("cả kho:", kho.dsCa());
console.log("lấy id 2:", kho.layCa(2));
console.log("số dòng đổi:", kho.datMua(1, 2.6));
kho.db.close();

Kết quả khi chạy

{ id: 1, ten: 'cá thu', khoi_luong: 2.4 }
{ id: 2, ten: 'cá hồi', khoi_luong: 5.2 }
cả kho: [
  [Object: null prototype] { id: 1, ten: 'cá thu', khoi_luong: 2.4 },
  [Object: null prototype] { id: 2, ten: 'cá hồi', khoi_luong: 5.2 }
]
lấy id 2: [Object: null prototype] { id: 2, ten: 'cá hồi', khoi_luong: 5.2 }
số dòng đổi: 1

dsCa()layCa() trả object [Object: null prototype] - node:sqlite cố tình tạo object "trần" để tên cột không đụng thuộc tính có sẵn; cứ dùng ca.ten như object thường (điều này sẽ chạm lại ở Bước 5 khi viết test).

Trung thực: node:sqlite đang experimental

Module node:sqlite có từ Node 22.5, tới nay vẫn experimental: mỗi lần chạy in một dòng ExperimentalWarning (các ví dụ trong bài lược đi cho gọn) và API có thể đổi. Dự án thật hôm nay nhiều người dùng better-sqlite3 - cùng triết lý, API gần như trùng, nên đổi qua gần như không phải học lại.
  • moKho() trả về { db, themCa, dsCa, layCa, datMua } - tầng dữ liệu gọn trong một object.
  • Mọi giá trị đi qua ? (prepared statement); .changes nói thật bao nhiêu dòng bị đổi.
  • layCa trả null khi không có id - server sẽ dịch null đó thành 404.

Giờ dựng lớp HTTP. server.js import moKho rồi định tuyến tay bằng req.method + url.pathname (bài Mạng: HTTP, fetch & API): mỗi endpoint chỉ làm hai việc - gọi đúng hàm trong kho, rồi trả JSON kèm status đúng. (Phần worker/bao-cao thuộc Bước 4 - tạm lướt qua, sẽ quay lại.)

server.js - REST API: định tuyến tay, gọi xuống kho (worker ở Bước 4)

import { createServer } from "node:http";
import { Worker } from "node:worker_threads";
import { moKho } from "./db.js";

const kho = moKho("kho-ca.db");
const worker = new Worker("./worker.js"); // một worker chạy nền - xem Bước 4

function tinhTongKg(dsCa) { // giao việc cho worker, chờ đúng một message - Bước 4
  return new Promise((ok) => {
    worker.once("message", ok);
    worker.postMessage(dsCa);
  });
}

function traJSON(res, status, duLieu) {
  const body = JSON.stringify(duLieu);
  res.writeHead(status, {
    "Content-Type": "application/json; charset=utf-8",
    "Content-Length": Buffer.byteLength(body)
  });
  res.end(body);
}

const server = createServer(async (req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);
  const khopId = url.pathname.match(/^\/ca\/(\d+)$/); // "/ca/2" -> khopId[1] là "2"

  if (req.method === "GET" && url.pathname === "/bao-cao") {        // Bước 4
    return traJSON(res, 200, await tinhTongKg(kho.dsCa()));
  }

  if (req.method === "GET" && url.pathname === "/ca") return traJSON(res, 200, kho.dsCa());

  if (req.method === "POST" && url.pathname === "/ca") {
    const chunks = [];
    for await (const chunk of req) chunks.push(chunk); // gom body: req là Readable
    try {
      const { ten, khoiLuong } = JSON.parse(Buffer.concat(chunks).toString("utf8"));
      return traJSON(res, 201, kho.themCa(ten, khoiLuong));
    } catch {
      return traJSON(res, 400, { loi: "body không phải JSON hợp lệ" });
    }
  }

  if (req.method === "GET" && khopId) {
    const ca = kho.layCa(Number(khopId[1]));
    return ca ? traJSON(res, 200, ca) : traJSON(res, 404, { loi: "không có cá id " + khopId[1] });
  }

  return traJSON(res, 404, { loi: "không có endpoint này" });
});

server.listen(3000, () => console.log("Kho Cá mở cửa: http://localhost:3000"));

Chạy npm start ở một terminal, rồi từ terminal thứ hai gửi một phiên đủ mặt status code. Body POST là dữ liệu không tin được - gom chunk cho hết, JSON.parse trong try/catch, hỏng thì 400 thay vì để server sập:

phien-fetch.js - terminal thứ hai (chạy trên kho-ca.db vừa tạo mới): đủ 200 / 201 / 404 / 400 / báo cáo

async function goi(method, duong, body) {
  const res = await fetch("http://localhost:3000" + duong, {
    method,
    headers: body ? { "Content-Type": "application/json" } : {},
    body
  });
  console.log(method, duong, "->", res.status, await res.text());
}

await goi("GET", "/ca");
await goi("POST", "/ca", JSON.stringify({ ten: "cá thu", khoiLuong: 2.4 }));
await goi("POST", "/ca", JSON.stringify({ ten: "cá hồi", khoiLuong: 5.2 }));
await goi("GET", "/ca/1");
await goi("GET", "/ca/99");
await goi("POST", "/ca", "đây không phải JSON");
await goi("GET", "/bao-cao");

Kết quả khi chạy

GET /ca -> 200 []
POST /ca -> 201 {"id":1,"ten":"cá thu","khoi_luong":2.4}
POST /ca -> 201 {"id":2,"ten":"cá hồi","khoi_luong":5.2}
GET /ca/1 -> 200 {"id":1,"ten":"cá thu","khoi_luong":2.4}
GET /ca/99 -> 404 {"loi":"không có cá id 99"}
POST /ca -> 400 {"loi":"body không phải JSON hợp lệ"}
GET /bao-cao -> 200 {"soCa":2,"tongKg":7.6}
  • Mỗi endpoint chỉ gọi đúng hàm trong kho rồi trả JSON - server.js không tự viết SQL.
  • Body POST không tin được: gom chunk → JSON.parse trong try/catch → hỏng trả 400.
  • layCa trả null → server đáp 404; phía fetch sẽ thấy response.ok là false.

Endpoint /bao-cao cộng khối lượng cả kho. Ở đây phép tính nhỏ, nhưng hãy coi nó là đại diện cho một việc nặng (gộp số liệu lớn, sinh báo cáo). Nếu tính ngay trong vòng phục vụ request, mọi request khác phải đứng chờ - vì JavaScript đơn luồng (bài Đa luồng & đa tiến trình). Cách đúng: đẩy sang một Worker Thread chạy luồng riêng.

worker.js - chạy trong luồng RIÊNG, nhận việc qua message

import { parentPort } from "node:worker_threads";

// Worker chạy trong luồng RIÊNG: nhận danh sách cá qua message,
// cộng tổng khối lượng (việc CPU mẫu) rồi gửi kết quả về luồng chính.
// Luồng phục vụ request không phải đứng chờ phép tính này.
parentPort.on("message", (dsCa) => {
  const tongKg = dsCa.reduce((tong, ca) => tong + ca.khoi_luong, 0);
  parentPort.postMessage({ soCa: dsCa.length, tongKg });
});

Bên server.js đã có hai mảnh ghép cho việc này (xem lại code Bước 3): tạo một worker lúc khởi động và dùng lại cho mọi request (đừng tạo worker mới mỗi lần - tốn), và hàm tinhTongKg bọc cặp gửi-nhận message vào một Promise để await được:

trích từ server.js - giao việc cho worker rồi await kết quả

const worker = new Worker("./worker.js"); // tạo MỘT lần, dùng lại cho mọi request

function tinhTongKg(dsCa) {
  return new Promise((ok) => {
    worker.once("message", ok);  // chờ đúng một message trả về
    worker.postMessage(dsCa);    // giao việc - luồng chính chạy tiếp ngay
  });
}

// trong handler:  return traJSON(res, 200, await tinhTongKg(kho.dsCa()));

Phiên fetch ở Bước 3 đã cho thấy kết quả thật: GET /bao-cao -> 200 {"soCa":2,"tongKg":7.6} - đúng 2.4 + 5.2. Phép cộng chạy trong worker; luồng chính chỉ giao việc rồi await, sẵn sàng nhận request khác trong lúc chờ.

Trung thực: đây là khung, không phải lúc cần tối ưu

Cộng vài con số thì chạy tức thì - lôi worker ra ở quy mô này là thừa, đo thực tế còn chậm hơn tính tại chỗ vì tốn chi phí gửi message. Bài giữ nhỏ để mèo con thấy RÕ bộ khung giao việc: postMessage → worker tính → trả về. Khung đó mới đáng tiền khi việc nền thật sự nặng.
  • Việc nặng đẩy sang Worker Thread để vòng phục vụ request không bị chặn (JS đơn luồng).
  • Tạo MỘT worker lúc khởi động, dùng lại - đừng tạo/huỷ worker mỗi request.
  • Bọc cặp postMessage/once("message") vào Promise để handler async await được kết quả.

Mảnh cuối giữ cho dịch vụ không vỡ khi sửa: test. Tầng dữ liệu đã là hàm thuần nên test thẳng được - không cần mở server, không cần cổng. Mẹo: mở kho ":memory:" (kho SQLite sống trong RAM) để mỗi test một kho sạch, không lẫn dữ liệu lần trước, không để lại tệp rác (bài Kiểm thử với node:test).

db.test.js - kiểm tầng dữ liệu trên kho :memory: (lược 2 dòng cảnh báo experimental)

import { test } from "node:test";
import assert from "node:assert/strict";
import { moKho } from "./db.js";

// ':memory:' - kho tạm trong RAM, mỗi test một kho sạch, không đụng đĩa thật.
test("themCa rồi dsCa trả đúng con cá vừa thêm", () => {
  const kho = moKho(":memory:");
  const ca = kho.themCa("cá thu", 2.4);
  assert.equal(ca.id, 1);
  const ds = kho.dsCa();
  assert.equal(ds.length, 1);
  assert.equal(ds[0].ten, "cá thu"); // node:sqlite trả object null-prototype → so từng cột
  assert.equal(ds[0].khoi_luong, 2.4);
});

test("layCa trả null khi không có id đó", () => {
  const kho = moKho(":memory:");
  kho.themCa("cá hồi", 5.2);
  assert.equal(kho.layCa(99), null);
});

test("datMua đổi khối lượng và đếm đúng số dòng", () => {
  const kho = moKho(":memory:");
  const ca = kho.themCa("cá cơm", 0.1);
  assert.equal(kho.datMua(ca.id, 0.3), 1); // 1 dòng đổi
  assert.equal(kho.datMua(99, 0.3), 0);    // id không có → 0 dòng
  assert.equal(kho.layCa(ca.id).khoi_luong, 0.3);
});

Kết quả khi chạy

✔ themCa rồi dsCa trả đúng con cá vừa thêm (1.4ms)
✔ layCa trả null khi không có id đó (0.5ms)
✔ datMua đổi khối lượng và đếm đúng số dòng (0.5ms)
ℹ tests 3
ℹ pass 3
ℹ fail 0

npm test xanh. Một lưu ý đã hẹn từ Bước 2: vì dòng kho là object null-prototype, ở đây ta so từng cột (ds[0].ten…) thay vì deepEqual cả object - gọn và tránh bẫy so prototype. Mảnh cuối cùng: server thật phải biết tắt cho sạch. Bắt SIGINT (Ctrl+C) và SIGTERM (lệnh kill, Docker dùng) để ngừng nhận request → đóng worker + DB → thoát mã 0 (bài Process, môi trường & công cụ CLI):

thêm vào cuối server.js - graceful shutdown

// Graceful shutdown: nhận tín hiệu → ngừng nhận request mới → đóng worker + DB → thoát.
function dongGonGang() {
  console.log("\nNhận tín hiệu dừng - đóng gọn gàng...");
  server.close(() => {       // 1. ngừng nhận kết nối MỚI, chờ request đang dở xong
    worker.terminate();      // 2. đóng worker chạy nền
    kho.db.close();          // 3. đóng cơ sở dữ liệu
    console.log("Đã đóng server, worker và DB. Tạm biệt!");
    process.exit(0);         // 4. dọn xong mới thoát, mã 0 = ổn
  });
}
process.on("SIGINT", dongGonGang);
process.on("SIGTERM", dongGonGang);

Kết quả khi chạy

Kho Cá mở cửa: http://localhost:3000
^C
Nhận tín hiệu dừng - đóng gọn gàng...
Đã đóng server, worker và DB. Tạm biệt!

Vậy là đủ bốn mảnh ráp thành một dịch vụ: dữ liệu bền trên đĩa, REST API để thêm/đọc, một worker lo việc nền, test bảo vệ phần lõi, và một lối tắt mở/tắt gọn gàng.

Hết khoá - mèo con đã đi trọn 12 bài 🎉

Nhìn lại chặng đường: event loop & libuv → bộ nhớ & GC → npm nâng cao → tệp & thư mục → generator & async iterator → Buffer & Stream → HTTP & RESTnode:sqliteđa luồngprocess & CLItest → và dịch vụ backend này. Không chỉ VIẾT được Node, mèo con đã HIỂU nó vận hành ra sao. Bước tiếp tuỳ mèo con: thay phần định tuyến tay bằng một framework (Fastify, Hono - giờ nhìn là biết nó đỡ việc gì), hoặc đem Kho Cá ra đời thật (Docker, deploy, HTTPS). Trước đó, hãy mở rộng kho cá ở phần bài tập cho thật vững.

Câu hỏi thường gặp

Được, nhưng sẽ mất thứ quý nhất: khả năng test. Để logic kho (themCa/dsCa…) trong db.js TÁCH khỏi HTTP thì db.test.js gọi thẳng được, không cần dựng server, không cần cổng. Trộn chung vào server.js thì muốn test một phép tính cũng phải mở server rồi fetch - chậm và giòn. Đây đúng nguyên tắc xuyên suốt site: tách lõi thuần khỏi I/O. Bốn file nhỏ, mỗi file một việc, vẫn dễ đọc hơn một file to ôm tất.

Không, vì hai việc khác bản chất. Query SQLite xong trong vài chục micro giây ngay trên đĩa máy mình - đồng bộ là rẻ, không đáng đẩy sang luồng khác. Còn việc CPU nặng (ở đây là phép tính báo cáo, thật ra nhỏ nhưng đóng vai "việc nặng") mới đáng giao cho worker để vòng phục vụ request không phải đứng chờ. Quy tắc cũ từ bài Đa luồng: I/O nhẹ + query nhanh thì async/đồng bộ tại chỗ là đủ; CPU nặng mới cần worker.

Đúng là không đáng - ở quy mô này phép tính chạy tức thì, dùng worker là "vẽ rắn thêm chân". Bài giữ nó nhỏ để mèo con thấy RÕ khung giao việc: server.postMessage → worker tính → trả về qua message, vòng request không bị chặn. Khi việc nền thật sự nặng (gộp số liệu cả triệu dòng, sinh báo cáo PDF, nén ảnh) thì đúng khung này mới phát huy. Học khung trên ví dụ nhỏ, áp vào việc lớn sau.

Bài Đa luồng & đa tiến trình Node.js →

Có. :memory: là một kho SQLite đầy đủ, chỉ khác chỗ ở: sống trong RAM thay vì trên đĩa, nên mỗi test mở một kho sạch tinh, không lẫn dữ liệu lần trước và không để lại tệp rác. Cùng một code db.js chạy trên cả hai; ta chỉ đổi đường dẫn truyền vào moKho. Logic kho được kiểm đúng như nhau, còn phần "ghi xuống đĩa thật" thì để cho server.

Nhiều: chưa giới hạn kích thước body (ai POST 1 GB là RAM lãnh đủ), chưa xác thực (authentication) ai được thêm/sửa cá, chưa HTTPS, chưa kiểm tra dữ liệu vào kỹ (tên rỗng, khối lượng âm vẫn lọt), chưa log đàng hoàng, chưa phân trang khi kho phình to. Kho Cá đủ để hiểu BỘ KHUNG ráp lại với nhau ra sao; lên production thì từng món trên đều cần thêm - phần lớn là lúc một framework (Fastify/Hono) bắt đầu trả công cho mình.

Hai hướng. Một là thay phần định tuyến tay bằng một framework: Fastify (nhanh, kiểm schema sẵn) hay Hono (siêu nhẹ, chạy cả trên Cloudflare Workers) - giờ mèo con nhìn là biết nó đỡ việc gì, vì đã tự làm tay hết. Hai là đem dịch vụ ra đời thật: đóng gói Docker, deploy lên một máy chủ, đặt sau một reverse proxy cho HTTPS. Cả hai đều xây trên đúng những mảnh mèo con vừa ráp - không có gì phải học lại từ đầu.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Vì sao dự án Kho Cá tách tầng dữ liệu (db.js) ra khỏi server HTTP (server.js)?

  1. 1

    Dựng lại Kho Cá

    Tự gõ lại bốn file (package.json, db.js, server.js, worker.js) từ bài, npm start, rồi chạy phiên fetch của Bước 3 từ một terminal thứ hai.

    Hoàn thành khi: Bảy request ra đúng 200/201/201/200/404/400/200; GET /bao-cao trả tongKg bằng tổng khối lượng các con cá đã POST.

  2. 2

    Test trước, sửa sau

    Viết db.test.js như Bước 5 rồi npm test. Sau đó cố ý đổi datMua cho trả về sai (vd luôn trả 1), chạy lại test đọc dòng đỏ, rồi sửa cho xanh.

    Hoàn thành khi: Lần đầu 3 test xanh; sau khi phá thì test "datMua" đỏ và chỉ đúng chỗ lệch; sửa xong lại xanh cả ba.

  3. 3

    Endpoint DELETE /ca/:id

    Thêm hàm thuần xoaCa(id) vào db.js (prepared DELETE, trả .changes) rồi nhánh DELETE trong server: có id thì 200, không thì 404. Viết thêm một test cho xoaCa.

    Hoàn thành khi: DELETE /ca/1 xong thì GET /ca không còn con đó; gọi lần hai trả 404; test mới xanh.

  4. 4

    Chặn dữ liệu rác ở POST

    Trong server, sau khi parse body: nếu ten rỗng hoặc khoiLuong không phải số dương thì trả 400 kèm lý do, KHÔNG ghi vào kho.

    Hoàn thành khi: POST {"ten":"","khoiLuong":-2} nhận 400 và kho không thêm dòng nào; POST hợp lệ vẫn 201 như cũ.

  5. 5

    Báo cáo nặng hơn cho worker

    Đổi worker để trả thêm: con cá nặng nhất và khối lượng trung bình. Server GET /bao-cao trả cả ba số. Giữ nguyên khung giao việc qua message.

    Hoàn thành khi: GET /bao-cao trả soCa, tongKg, con nặng nhất, trung bình - tự cộng tay trên kho nhỏ thấy khớp.

  6. 6

    Tự tắt cho sạch

    Chạy server rồi gửi kill -TERM <pid> (hoặc Ctrl+C). Quan sát log đóng. Thêm một dòng đếm số request đã phục vụ và in ra lúc shutdown.

    Hoàn thành khi: Log in "đóng gọn gàng" rồi tới dòng tổng số request; tiến trình thoát với exit code 0 (kiểm bằng echo $?).