jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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.

PropertyTCPUDP (Part 3)
ConnectionYes — handshake firstNo — fire-and-forget
DeliveryGuaranteed (retransmit)Best-effort
OrderPreservedNot guaranteed
AbstractionOrdered byte streamIndependent datagrams
Typical useHTTP, SSH, databasesDNS, 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:

  1. Client → Server: SYN — “Tôi muốn kết nối; số thứ tự ban đầu của tôi là X”.
  2. Server → Client: SYN + ACK — “OK, số thứ tự của tôi là Y; tôi xác nhận X+1”.
  3. 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.

Client Server SYN SYN + ACK ACK connection established → reliable, ordered byte stream
SYN → SYN+ACK → ACK: only then does the reliable byte stream begin

Sau bắt tay, cả hai bên có thể write()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:

EventWhen it firesWhat to do
'connection'New client connects (on server)Hand off to handler
'data'Bytes arrive from peerProcess 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 downCleanup resources

socket.remoteAddresssocket.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:

MethodWhat happensWhen to use
socket.end()Sends FIN; drains write buffer; half-close (can still read)Normal shutdown after finishing
socket.destroy()Sends RST; drops everything immediatelyTimeout, 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'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 — ECONNRESET không bắt sẽ crash process Node.
  • Quên backpressure tồn tại — socket.write() có thể trả false khi 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 remoteAddress luôn là chuỗi IP — có thể undefined trướ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.

  1. Mở rộng server echo để thêm tiền tố [echo] trước mỗi reply.
  2. 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.
  3. Kết nối bằng nc, gõ message, rồi kill nc bằ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 differs

Bài 3: Ctrl+C gửi RST; server log kiểu socket error: read ECONNRESET'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, createServercreateConnection 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.