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

Bài 8 · Nâng cao · 26 phút

Mạng: HTTP, fetch & API

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

Lập trình mạng trong Node: dựng HTTP server, gọi API bằng global fetch, viết một REST API nhỏ, và làm việc với TCP qua module net.

Ở khoá Cơ bản (bài Fetch & JSON: gọi API), mèo con đứng ở phía gọi: fetch một URL, “ai đó” ở đầu kia trả JSON về. Bài này bước sang đầu kia của sợi dây - tự viết “ai đó”. Node có sẵn module node:http, không phải cài gì:

server.mjs - một HTTP server trọn vẹn trong 8 dòng; chạy: node server.mjs

import { createServer } from "node:http";

const server = createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
  res.end("Chào mèo con! Server đầu tiên đây. 🐟");
});

server.listen(3000, () => console.log("Đang nghe ở http://localhost:3000"));

Kết quả khi chạy

Đang nghe ở http://localhost:3000

createServer nhận một hàm: mỗi request đến, Node gọi hàm đó một lần với cặp (req, res). Cả hai đều là stream của bài Buffer & Stream: req là Readable - body về theo từng chunk; res là Writable - res.end(...) ghi mảnh cuối rồi đóng. Script không tự thoát vì server còn nghe (event loop còn việc - bài 1). Mở terminal thứ hai, thử ngay bằng fetch quen thuộc:

goi-thu.mjs - terminal thứ hai, trong lúc server đang chạy (dừng server: Ctrl+C)

const res = await fetch("http://localhost:3000");
console.log(res.status, await res.text());

Kết quả khi chạy

200 Chào mèo con! Server đầu tiên đây. 🐟
  • node:http có sẵn - createServer((req, res) => ...): mỗi request đến là một lần hàm được gọi.
  • req là Readable stream, res là Writable stream (bài 6): đọc body = gom chunk; trả lời = ghi rồi end().
  • Server không thoát là ĐÚNG: còn nghe là event loop (bài 1) còn việc - dừng bằng Ctrl+C.

Server 8 dòng trả lời mọi request y hệt nhau. Server thật phải nhìn vào cặp req.method (“GET”, “POST”…) và req.url để chọn việc - gọi là định tuyến (routing). Cẩn thận: req.url chỉ là chuỗi - đừng cắt tay, đưa cho class URL tách giúp:

hai dòng gặp trong mọi server - lắp vào kho cá ở Bước 3

// req.url là CHUỖI, vd "/ca/2?itNhat=50" - new URL tách phần đường và query:
const url = new URL(req.url, `http://${req.headers.host}`);

url.pathname;                   // "/ca/2" - đem đi định tuyến
url.searchParams.get("itNhat"); // "50" (luôn là chuỗi) - không có thì null

Chiều trả lời cũng phải đúng chuẩn: chọn status code và khai header Content-Type cho client biết body là gì. Ở khoá Cơ bản mèo con đã đọc các mã này qua response.status / response.ok; giờ mèo con là người chọn chúng:

Server dùng khi
200 OKrequest ổn - kết quả nằm trong body
201 CreatedPOST tạo xong tài nguyên mới - kèm bản ghi vừa tạo
400 Bad Requestrequest hỏng (vd body không phải JSON) - lỗi phía người gọi
404 Not Foundkhông có tài nguyên / endpoint này
500 Internal Server Errorserver gãy giữa chừng - lỗi phía server

Ghép các mảnh lại thành một REST API: tài nguyên là cá 🐟 trong kho, mỗi endpoint = method + đường. Dữ liệu nằm trong một mảng - “cơ sở dữ liệu” bằng RAM:

kho-ca.mjs - REST API hoàn chỉnh bằng node:http trần, không cài gì

import { createServer } from "node:http";

const khoCa = [{ id: 1, ten: "cá thu", soLuong: 30 }, { id: 2, ten: "cá hồi", soLuong: 90 }];
let idKeTiep = 3;

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) // đo bằng BYTE: "á" chiếm 2 byte
  });
  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 === "/ca") return traJSON(res, 200, khoCa);

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

  if (req.method === "GET" && khopId) {
    const ca = khoCa.find((c) => c.id === 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"));

Kết quả khi chạy

Kho cá mở cửa: http://localhost:3000

Chỗ đáng dừng mắt nhất là POST: body do client gửi là dữ liệu không tin được - gom chunk cho hết (for await...of của bài 5, vì req là Readable của bài 6), rồi JSON.parse trong try/catch: body hỏng thì trả 400, thay vì để server sập. Chạy kho cá rồi thử một phiên 4 request từ terminal thứ hai:

phien-fetch.mjs - terminal thứ hai: đủ mặt 200 / 201 / 404 / 400

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á cơm", soLuong: 99 }));
await goi("GET", "/ca/99");
await goi("POST", "/ca", "đây không phải JSON");

Kết quả khi chạy

GET /ca -> 200 [{"id":1,"ten":"cá thu","soLuong":30},{"id":2,"ten":"cá hồi","soLuong":90}]
POST /ca -> 201 {"id":3,"ten":"cá cơm","soLuong":99}
GET /ca/99 -> 404 {"loi":"không có cá id 99"}
POST /ca -> 400 {"loi":"body không phải JSON hợp lệ"}

Trung thực: đồ chơi vs production

Kho cá đủ để hiểu bản chất nhưng thiếu nhiều thứ của server thật: chưa giới hạn kích thước body (ai gửi 1 GB là RAM lãnh đủ), chưa có xác thực (authentication), chưa có HTTPS. Đừng mở nó ra Internet.
  • REST: tài nguyên nằm trong đường dẫn (/ca, /ca/2), hành động nằm ở method - endpoint = method + đường.
  • Body của client là dữ liệu không tin được: gom chunk xong mới JSON.parse, bọc try/catch, hỏng trả 400.
  • Không tìm thấy → 404 kèm JSON báo lỗi; phía fetch sẽ thấy response.ok là false - đúng bẫy đã học từ phía client.

Từ Node 18, fetch là hàm global - cùng chuẩn với trình duyệt, bên dưới do thư viện undici hiện thực. Nghĩa là Node đứng được cả hai vai: một script vừa mở server, vừa làm client gọi chính nó. Đây chính là mầm của test tự động (gặp lại ở bài 11 - Kiểm thử với node:test): không cần terminal thứ hai, không cần nhớ cổng nào trống.

tu-thu.mjs - một script đóng cả hai vai; chạy xong TỰ thoát, không cần Ctrl+C

import { createServer } from "node:http";

const server = createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
  res.end(JSON.stringify({ thongDiep: "mèo tự hỏi, mèo tự đáp" }));
});

// Cổng 0 = nhờ hệ điều hành cấp một cổng trống - không sợ đụng cổng ai
server.listen(0, async () => {
  const { port } = server.address();
  const res = await fetch(`http://localhost:${port}/`);
  console.log("status:", res.status);
  console.log("body:", await res.json());
  server.close(); // đóng server -> Node hết việc, script TỰ thoát
});

Kết quả khi chạy

status: 200
body: { thongDiep: 'mèo tự hỏi, mèo tự đáp' }

Mọi thứ ở trên chạy trên một tầng thấp hơn: TCP - “đường ống byte” hai chiều mà hệ điều hành mở giữa hai chương trình; mỗi đầu ống là một socket. Module node:net cho mèo con dùng ống trần, không kèm luật lệ gì - gửi byte nào, đầu kia nhận byte nấy:

echo.mjs - TCP trần: gửi gì vọng nấy (server + client chung một script cho dễ thử)

import net from "node:net";

// Mỗi kết nối TCP là một socket - stream HAI CHIỀU: đọc được và ghi được
const server = net.createServer((socket) => {
  socket.on("data", (chunk) => socket.write("vọng lại: " + chunk));
});

server.listen(4000, () => {
  const khach = net.connect(4000, "localhost", () => khach.write("meo meo"));
  khach.on("data", (chunk) => {
    console.log(chunk.toString("utf8"));
    khach.end();      // khách cúp máy...
    server.close();   // ...server đóng cửa -> script tự thoát
  });
});

Kết quả khi chạy

vọng lại: meo meo

Giờ là khoảnh khắc “à ra thế”: HTTP/1.1 chỉ là văn bản có format chạy trong ống đó. Vậy thì khỏi cần fetch - mở kho cá (Bước 3) rồi tự gõ một request bằng tay:

raw-http.mjs - tự gõ HTTP qua socket, trong lúc kho cá (Bước 3) đang chạy

import net from "node:net";

// Nối TCP "tay không" tới kho cá (Bước 3) rồi tự gõ một request HTTP
const socket = net.connect(3000, "localhost", () => {
  socket.write("GET /ca/2 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n");
});

let raw = "";
socket.on("data", (chunk) => (raw += chunk));
socket.on("end", () => console.log(raw));

Kết quả khi chạy

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 39
Date: Thu, 11 Jun 2026 03:06:37 GMT
Connection: close

{"id":2,"ten":"cá hồi","soLuong":90}

Server trả lời như thường - nó không biết (và không cần biết) đầu kia là fetch, trình duyệt hay một chú mèo gõ tay. Raw response đọc được bằng mắt: dòng đầu là status line, rồi các header (mỗi dòng kết thúc bằng \r\n; Date là thời điểm của lần chạy này), một dòng trống, rồi body. fetch không có phép màu: nó chỉ soạn và đọc giúp loại văn bản này.

Express, Fastify, Hono đứng ở đâu?

Cả ba là lớp tiện nghi bên trên node:http: khai báo route gọn thay cho match tay, parse body sẵn, middleware. Bảng nhắc nhanh - Express: phổ biến nhất, hệ middleware đồ sộ · Fastify: nhanh, kiểm tra schema sẵn · Hono: siêu nhẹ, chạy cả trên Cloudflare Workers/Deno/Bun. Học “trần” trước rồi mới tới framework: nhìn là biết nó đỡ việc gì - khoá này đi trần tới tận dự án cuối khoá.

Tiếp theo: cho 🐟 một chỗ ở bền

Kho cá đang sống trong một mảng JavaScript - tắt server (Ctrl+C) là cá bay sạch, vì RAM thuộc về tiến trình. Bài kế - Cơ sở dữ liệu với node:sqlite - cho dữ liệu một chỗ ở thật trên đĩa: CRUD bằng SQL, transaction; kho cá của mèo con sẽ sống sót qua mọi lần khởi động lại.

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

Không - node:http là nền đầy đủ: Express, Fastify hay Hono đều xây BÊN TRÊN nó. Framework cho khai báo route gọn (app.get("/ca", ...)), parse body sẵn, middleware… tức là làm hộ đúng những việc mèo con vừa tự làm trong bài này. Học “trần” trước, sau này nhìn framework là biết chính xác nó đỡ việc gì - và khi có lỗi thì đoán được lỗi nằm ở tầng nào.

TCP cắt dữ liệu thành từng gói, Node giao lại cho bạn theo chunk khi chúng tới nơi. Body vài chục byte thường về đúng một chunk - nhưng đó là MAY, không phải LUẬT: body lớn (upload tệp), mạng chậm hay proxy đứng giữa đều khiến nó về nhiều mảnh. Gom cho đến hết (for await chạy cạn, hoặc sự kiện end) là cách đúng duy nhất; code tin “một chunk” sẽ sinh bug chỉ xuất hiện trên mạng thật.

Nhiều hơn bạn nghĩ - hàng nghìn kết nối đồng thời là bình thường, vì server chủ yếu CHỜ I/O, mà chờ thì event loop (bài 1) lo gần như miễn phí. Nút nghẽn thật thường nằm ở phần TÍNH (nén, mã hoá, parse nặng). Khi một lõi CPU không đủ, cluster nhân server ra nhiều tiến trình cùng nghe một cổng.

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

localhost (tức 127.0.0.1) là chính máy của bạn: request đi một vòng bên trong hệ điều hành, không hề ra tới mạng. Cổng (port) là con số để nhiều chương trình cùng nghe trên một máy mà không giẫm chân nhau; 3000/8080 là thói quen khi dev, web thật dùng 80 (HTTP) và 443 (HTTPS). Hai server đòi cùng một cổng sẽ lỗi EADDRINUSE - vì thế Bước 4 mới có mẹo “cổng 0”.

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

Trong handler POST /ca, vì sao phải gom toàn bộ chunk RỒI mới JSON.parse, và bọc trong try/catch?

  1. 1

    Mở kho cá

    Chép server Bước 3 thành kho-ca.mjs, chạy node kho-ca.mjs, rồi chạy lại phiên fetch của bài và thêm một request GET /ca/3 ngay sau khi POST.

    Hoàn thành khi: Bốn request của bài ra đúng 200/201/404/400; GET /ca/3 trả 200 kèm đúng con cá cơm vừa thêm.

  2. 2

    Thêm DELETE /ca/:id

    Thêm nhánh DELETE vào kho cá: id có thật thì xoá khỏi mảng và trả 200 kèm con cá vừa xoá; không có thì 404.

    Hoàn thành khi: DELETE /ca/1 xong thì GET /ca không còn cá thu; DELETE /ca/1 lần hai trả 404.

  3. 3

    Lọc bằng query string

    Cho GET /ca?itNhat=50 chỉ trả những con có soLuong từ 50 trở lên - đọc url.searchParams.get("itNhat") và nhớ nó là CHUỖI (hoặc null).

    Hoàn thành khi: Với kho mặc định, /ca?itNhat=50 chỉ còn cá hồi; không có query thì trả đủ danh sách như cũ.

  4. 4

    Tự thử như Bước 4

    Viết thu-kho-ca.mjs mở kho cá ở cổng 0 rồi fetch chính nó: kiểm GET /ca ra 200 và POST /ca body hỏng ra 400, in PASS/FAIL từng ca.

    Hoàn thành khi: Script in 2 dòng PASS rồi TỰ thoát (không cần Ctrl+C) - tức là server.close() nằm đúng chỗ.

  5. 5

    Nói chuyện tay với server

    Dùng node:net như Bước 5 gửi request thô GET /ca HTTP/1.1 tới kho cá, rồi so raw response với kết quả fetch cùng endpoint.

    Hoàn thành khi: Cùng status 200, cùng body JSON - và mèo con chỉ ra được dòng nào là status line, dòng nào là header.

  6. 6

    Đúng đường, sai method → 405

    Hiện PUT /ca rơi vào 404 “không có endpoint này” - chưa khéo: đường có, chỉ method sai. Thêm nhánh: pathname là /ca nhưng method lạ thì trả 405 kèm header Allow: "GET, POST".

    Hoàn thành khi: fetch với method PUT tới /ca nhận status 405 và response.headers.get("allow")"GET, POST".