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:
| Mã | Server dùng khi |
|---|---|
| 200 OK | request ổn - kết quả nằm trong body |
| 201 Created | POST tạo xong tài nguyên mới - kèm bản ghi vừa tạo |
| 400 Bad Request | request hỏng (vd body không phải JSON) - lỗi phía người gọi |
| 404 Not Found | không có tài nguyên / endpoint này |
| 500 Internal Server Error | server 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
- ▸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?
Tiếp theo: cho 🐟 một chỗ ở bền
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.
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.
Trả lời vài câu để chắc rằng em đã nắm bài.
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
Mở kho cá
Chép server Bước 3 thành
kho-ca.mjs, chạynode kho-ca.mjs, rồi chạy lại phiên fetch của bài và thêm một requestGET /ca/3ngay sau khi POST.Hoàn thành khi: Bốn request của bài ra đúng 200/201/404/400;
GET /ca/3trả 200 kèm đúng con cá cơm vừa thêm. - 2
Thêm DELETE /ca/:id
Thêm nhánh
DELETEvà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/1xong thìGET /cakhông còn cá thu;DELETE /ca/1lần hai trả 404. - 3
Lọc bằng query string
Cho
GET /ca?itNhat=50chỉ trả những con cósoLuongtừ 50 trở lên - đọcurl.searchParams.get("itNhat")và nhớ nó là CHUỖI (hoặcnull).Hoàn thành khi: Với kho mặc định,
/ca?itNhat=50chỉ còn cá hồi; không có query thì trả đủ danh sách như cũ. - 4
Tự thử như Bước 4
Viết
thu-kho-ca.mjsmở kho cá ở cổng 0 rồifetchchính nó: kiểmGET /cara 200 vàPOST /cabody 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
Nói chuyện tay với server
Dùng
node:netnhư Bước 5 gửi request thôGET /ca HTTP/1.1tới kho cá, rồi so raw response với kết quảfetchcù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
Đúng đường, sai method → 405
Hiện
PUT /carơ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à/canhưng method lạ thì trả 405 kèm headerAllow: "GET, POST".Hoàn thành khi: fetch với method
PUTtới/canhận status 405 vàresponse.headers.get("allow")là"GET, POST".