Network Programming · Part 2 — TCP Sockets: A Reliable Byte Stream
Open real TCP sockets in Node.js: the three-way handshake, echo server + client with createServer/createConnection, socket events, graceful close, and common pitfalls — bilingual with exercises.
Đây là Phần 2 của series 10 bài về lập trình mạng với Node.js + TypeScript. Phần 1 đã cho bạn mô hình phân tầng; giờ ta mở socket TCP thật và truyền byte giữa hai chương trình.
TCP là công cụ chủ lực của internet — HTTP, WebSocket, và hầu hết database chạy trên nó. Hiểu socket trực tiếp giúp debug mọi giao thức tầng cao dễ hơn.
TCP mang lại gì
TCP nằm ở tầng Transport (Phần 1) và đưa ra ba đảm bảo:
- Hướng kết nối — hai bên đồng ý liên kết tồn tại trước khi dữ liệu chảy.
- Tin cậy — packet mất được gửi lại; bản trùng bị loại.
- Luồng byte có thứ tự — byte đến đúng thứ tự gửi, thành một luồng liên tục.
TCP không cung cấp: ranh giới message. Nếu bạn gửi "HELLO" rồi "WORLD", bên nhận có thể nhận "HEL" + "LOWORLD" trong hai sự kiện 'data' — cùng byte, khác cách cắt. Ta sửa ở Phần 8 bằng framing.
| Property | TCP | UDP (Part 3) |
|---|---|---|
| Connection | Yes — handshake first | No — fire-and-forget |
| Delivery | Guaranteed (retransmit) | Best-effort |
| Order | Preserved | Not guaranteed |
| Abstraction | Ordered byte stream | Independent datagrams |
| Typical use | HTTP, SSH, databases | DNS, gaming, video |
Bắt tay ba bước
Trước khi dữ liệu ứng dụng chảy, client và server thực hiện bắt tay ba bước:
- Client → Server: SYN — “Tôi muốn kết nối; số thứ tự ban đầu của tôi là X”.
- Server → Client: SYN + ACK — “OK, số thứ tự của tôi là Y; tôi xác nhận X+1”.
- Client → Server: ACK — “Tôi xác nhận Y+1; kết nối thiết lập”.
Bạn không viết tay bước này — OS làm khi bạn gọi connect() hoặc accept(). Nhưng biết nó giải thích độ trễ kết nối và trạng thái half-open.
Sau bắt tay, cả hai bên có thể write() và read() tự do cho đến khi ai đó đóng kết nối.
Xây server TCP echo
Server echo đọc bất cứ gì bạn gửi và gửi lại — “Hello, World” của socket. Module node:net của Node cung cấp mọi thứ ta cần.
import { createServer, type Socket } from 'node:net';
const PORT = 3000;
function handleConnection(socket: Socket): void {
console.log(
`connected: ${socket.remoteAddress}:${socket.remotePort}`,
);
socket.on('data', (chunk: Buffer) => {
const text = chunk.toString('utf8');
console.log(`received (${chunk.length} bytes):`, JSON.stringify(text));
socket.write(text); // echo back
});
socket.on('end', () => {
console.log('client finished sending (FIN received)');
});
socket.on('error', (err: Error) => {
console.error('socket error:', err.message);
});
socket.on('close', (hadError: boolean) => {
console.log(`connection closed (hadError=${hadError})`);
});
}
const server = createServer(handleConnection);
server.on('error', (err: Error) => {
console.error('server error:', err.message);
});
server.listen(PORT, () => {
console.log(`echo server listening on port ${PORT}`);
});
Lưu thành echo-server.ts và chạy:
npx tsx echo-server.ts # or: node --import tsx echo-server.ts
Thử ở terminal khác:
nc localhost 3000
hello tcp
hello tcp # echoed back
# Ctrl+D to close (sends FIN)
Sự kiện chính trên socket server:
| Event | When it fires | What to do |
|---|---|---|
'connection' | New client connects (on server) | Hand off to handler |
'data' | Bytes arrive from peer | Process chunk; may fire multiple times per “message” |
'end' | Peer sent FIN (done writing) | Stop expecting more input |
'error' | Something broke (e.g. ECONNRESET) | Log; don’t let it go uncaught |
'close' | Socket fully shut down | Cleanup resources |
socket.remoteAddress và socket.remotePort cho biết ai kết nối — vô giá cho logging và kiểm soát truy cập.
Xây client TCP
Client khởi tạo kết nối bằng createConnection():
import { createConnection } from 'node:net';
const client = createConnection({ port: 3000, host: '127.0.0.1' }, () => {
console.log('connected to server');
client.write('hello from the client\n');
});
client.on('data', (chunk: Buffer) => {
console.log('server replied:', chunk.toString('utf8'));
client.end(); // send FIN — graceful close
});
client.on('close', () => {
console.log('connection closed');
});
client.on('error', (err: Error) => {
console.error('client error:', err.message);
});
Chạy server echo trước, rồi:
npx tsx echo-client.ts
Output mong đợi:
connected to server
server replied: hello from the client
connection closed
Callback truyền vào createConnection chạy khi 'connect' — bắt tay thành công. Bạn cũng có thể dùng .on('connect', ...) nếu thích kiểu event.
Vòng đời socket
Socket TCP đi qua vòng đời có thể dự đoán:
[created] → connect/handshake → [established] → read/write data → [closing] → [closed]
Ở server, createServer lắng nghe; mỗi kết nối đến tạo một instance Socket mới. Một process server có thể xử lý hàng nghìn socket đồng thời.
'data' giao Buffer chunk, không phải string. Luôn gọi .toString('utf8') (hoặc encoding khác) khi mong đợi text. Giao thức nhị phân giữ nguyên Buffer.
Tích lũy dữ liệu một phần khi cần message hoàn chỉnh:
let buffer = '';
socket.on('data', (chunk: Buffer) => {
buffer += chunk.toString('utf8');
// Example: process line-delimited messages
let newlineIndex: number;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
console.log('complete line:', line);
}
});
Đây là dạng framing sơ khai — Phần 8 đi sâu hơn.
Đóng nhẹ nhàng vs destroy
Hai cách kết thúc kết nối — chúng không giống nhau:
| Method | What happens | When to use |
|---|---|---|
socket.end() | Sends FIN; drains write buffer; half-close (can still read) | Normal shutdown after finishing |
socket.destroy() | Sends RST; drops everything immediately | Timeout, bad state, force-kill |
Đóng nhẹ nhàng: gọi socket.end() khi viết xong. Peer nhận 'end' khi FIN của bạn đến. Cả hai gọi end(), cả hai nhận 'close' — tắt sạch.
socket.write('goodbye\n');
socket.end(); // FIN sent — "I'm done writing"
Đóng đột ngột: socket.destroy() phá ngay lập tức; peer có thể thấy ECONNRESET. Dùng khi phục hồi lỗi, không phải luồng bình thường.
setNoDelay và setKeepAlive
Hai nút tinh chỉnh socket đáng biết:
socket.setNoDelay(true) — tắt thuật toán Nagle, gom các lần ghi nhỏ để giảm số packet. Với giao thức tương tác (game, chat) cần độ trễ thấp, hãy tắt. Với truyền bulk, giữ mặc định (Nagle bật).
socket.setNoDelay(true);
socket.setKeepAlive(true) — gửi packet probe định kỳ trên kết nối idle để firewall/NAT không ngắt. Hữu ích cho kết nối sống lâu (pool database, tiền thân WebSocket). Độ trễ ban đầu tùy chọn tính bằng millisecond:
socket.setKeepAlive(true, 30_000); // probe after 30s idle
Không thay thế heartbeat tầng ứng dụng để phát hiện peer chết.
Gắn lại: chạy và thử
Terminal 1 — khởi động server echo:
npx tsx echo-server.ts
Terminal 2 — client thô với netcat:
nc localhost 3000
ping
ping
pong would be wrong — you get "ping" echoed
Terminal 3 — chạy client TypeScript:
npx tsx echo-client.ts
Xem log server: bạn sẽ thấy remoteAddress, số byte, 'end', và 'close' cho mỗi phiên.
Lỗi người mới hay mắc
- Coi mỗi
'data'là một message hoàn chỉnh — TCP là luồng byte; một lần write có thể tách qua nhiều'data', hoặc nhiều write gộp thành một (Phần 8). - Không xử lý
'error'trên socket —ECONNRESETkhông bắt sẽ crash process Node. - Quên backpressure tồn tại —
socket.write()có thể trảfalsekhi buffer kernel đầy; phải tạm dừng và chờ'drain'(Phần 8). - Gọi
destroy()mỗi lần ngắt thay vìend()— peer thấy reset và không flush dữ liệu đang chờ. - Tưởng
remoteAddressluôn là chuỗi IP — có thểundefinedtrước connect, hoặc'::ffff:127.0.0.1'cho IPv4-mapped IPv6.
Bài tập
Thử từng bài trước khi mở lời giải.
- Mở rộng server echo để thêm tiền tố
[echo]trước mỗi reply. - Viết client gửi ba dòng mà không chờ reply giữa các lần write; log số sự kiện
'data'server bắn ra. - Kết nối bằng
nc, gõ message, rồi killncbằng Ctrl+C (RST) thay vì Ctrl+D (FIN). Quan sát output'error'/'close'của server.
Lời giải
// 1 — prefixed echo (change the write line in handleConnection)
socket.write(`[echo] ${text}`);
// 2 — fire three lines, count server data events
import { createConnection } from 'node:net';
const client = createConnection({ port: 3000, host: '127.0.0.1' }, () => {
client.write('line1\n');
client.write('line2\n');
client.write('line3\n');
client.end();
});
client.on('data', (chunk) => {
console.log('got chunk:', JSON.stringify(chunk.toString()));
});
// On the server, add a counter in 'data':
let dataEvents = 0;
socket.on('data', (chunk) => {
dataEvents += 1;
console.log(`data event #${dataEvents}, ${chunk.length} bytes`);
socket.write(`[echo] ${chunk.toString('utf8')}`);
});
// Typical result: 1 data event with all three lines merged, or 2–3 if timing differsBài 3: Ctrl+C gửi RST; server log kiểu socket error: read ECONNRESET và 'close' với hadError=true. Ctrl+D gửi FIN nhẹ nhàng — bạn nhận 'end' rồi 'close' với hadError=false.
Điều cốt lõi
TCP cho bạn luồng byte tin cậy, có thứ tự trên kết nối thiết lập bằng bắt tay ba bước. Trong Node.js, createServer và createConnection từ node:net bọc luồng đó bằng sự kiện 'data', 'end', 'error', và 'close'. Coi socket là luồng byte, xử lý lỗi, đóng nhẹ bằng end(), và nhớ framing cùng backpressure là việc của bạn — ta sẽ làm ở Phần 8. Tiếp theo: UDP — lựa chọn nhanh, không kết nối ở Phần 3.