Xây Dựng Server HTTP Của Riêng Bạn Bằng TypeScript: Hành Trình Khám Phá Tầng Sâu Mạng

Trong thế giới phát triển web hiện đại, chúng ta thường làm việc với các framework và thư viện cấp cao mà ít khi phải đụng chạm đến những nền tảng cốt lõi. Tuy nhiên, để thực sự nắm vững cách hoạt động của internet, việc tự tay xây dựng một server HTTP từ đầu là một trải nghiệm không thể bỏ qua. Bài viết này sẽ chia sẻ hành trình khám phá và xây dựng một server HTTP đơn giản bằng TypeScript, từ đó hé mở những bí mật về giao thức mạng, vòng lặp sự kiện JavaScript và các kỹ thuật tối ưu hiệu suất.

Bắt Đầu Từ Nền Tảng: HTTP và TCP Là Gì?

Khi mới chập chững bước vào con đường phát triển web, có lẽ bạn cũng từng nghe đi nghe lại các thuật ngữ “request” (yêu cầu) và “response” (phản hồi). Một câu hỏi tự nhiên sẽ nảy sinh: “Request là gì? Response là gì? Tại sao lại có HTTP, và nó được gọi là HTTP vì lý do gì?” Sự mơ hồ này đã thôi thúc tôi tìm kiếm câu trả lời tận gốc rễ.

Tôi phát hiện ra rằng HTTP chỉ đơn thuần là Hypertext Transfer Protocol – một tập hợp các quy tắc để truyền dữ liệu qua web. Tuy nhiên, điều thực sự quan trọng là hiểu rằng HTTP không hoạt động độc lập; nó được xây dựng dựa trên một giao thức cấp thấp hơn: TCP (Transmission Control Protocol). TCP đóng vai trò là xương sống, đảm bảo rằng dữ liệu được truyền tải một cách đáng tin cậy. Để thực sự hiểu rõ, tôi quyết định bắt tay vào việc tự xây dựng một server HTTP của riêng mình.

Lời Khuyên Thay Đổi Cuộc Chơi: Đừng Ngại “Phát Minh Lại Bánh Xe”

Trong quá trình tìm hiểu, tôi có hỏi một người bạn đã từng tự xây dựng server của riêng mình. Anh ấy đã tạo ra một dự án ấn tượng, khiến tôi ngưỡng mộ. Khi tôi hỏi làm thế nào để có thể học được tất cả những điều liên quan đến phát triển web, anh ấy đã đưa ra một lời khuyên bất ngờ và đơn giản: “Hãy tự xây dựng server của riêng bạn, hoặc ít nhất là giả vờ xây dựng. Cuối cùng, bạn sẽ học được tất cả.”

Lời khuyên này đã mở ra một lối suy nghĩ mới. Mọi người thường nói “đừng phát minh lại bánh xe”, nhưng tôi lại không hiểu tại sao. Tôi tin rằng việc “phát minh lại bánh xe” – tức là tự xây dựng lại những thứ đã có sẵn từ đầu – chính là cách tốt nhất để học hỏi và hiểu sâu sắc hơn về mọi thứ, ngay cả khi những thứ tôi xây dựng ban đầu còn “ngớ ngẩn”. Những kiến thức tôi thu lượm được từ việc đọc blog của bạn tôi (ví dụ về cách Node.js đã khiến anh ấy xây dựng ứng dụng thời gian thực bằng C++) còn hữu ích hơn cả hai khóa học phát triển web toàn diện mà tôi đã học ở đại học.

Giải Mã TCP: Giao Thức Đảm Bảo Sự Tin Cậy

Bạn có thể thấy lạ, nhưng mặc dù đã tạo ra nhiều endpoint và API trong các lớp học web, sử dụng các thư viện và framework HTTP, tôi vẫn không thực sự biết TCP là gì. Nhờ sự giúp đỡ của bạn bè và quá trình tìm kiếm thông tin, tôi đã nắm bắt được ý chính: TCP (Transmission Control Protocol) cung cấp khả năng giao tiếp đáng tin cậy giữa các thiết bị trên mạng. HTTP định nghĩa định dạng của các thông điệp (yêu cầu và phản hồi), trong khi TCP đảm bảo rằng những thông điệp đó thực sự đến nơi một cách chính xác và theo đúng thứ tự.

Về cơ bản, TCP hoạt động dựa trên quy trình “bắt tay ba bước” (3-way handshake) để thiết lập một kết nối. Hãy tưởng tượng một cuộc trò chuyện giữa client (máy khách) và server (máy chủ):

  1. Client gửi tín hiệu SYN: “Này server, tôi muốn kết nối!”
  2. Server phản hồi SYN-ACK: “À, tôi nhận được tin nhắn của bạn rồi! Cảm ơn bạn đã liên hệ, tôi sẵn sàng kết nối.”
  3. Client gửi tín hiệu ACK: “Cảm ơn bạn đã xác nhận. Vậy là chúng ta có thể bắt đầu giao tiếp được rồi.”

Đó chính là TCP một cách đơn giản nhất. Sau khi quá trình bắt tay này hoàn tất, client và server có thể trao đổi dữ liệu một cách đáng tin cậy, bởi vì TCP sẽ xử lý việc phân mảnh, đánh số thứ tự, gửi lại gói tin bị mất và kiểm soát luồng dữ liệu.

Phân Tích Yêu Cầu HTTP: Không Phức Tạp Như Bạn Tưởng

Ban đầu, tôi nghe rất nhiều về việc phân tích yêu cầu HTTP (parsing HTTP requests), khiến nó nghe có vẻ như một vấn đề khoa học máy tính phức tạp. Tuy nhiên, sau khi thực sự triển khai, tôi nhận ra rằng nó không hề khó khăn. Về cơ bản, bạn chỉ đang đọc một chuỗi văn bản và chia nó thành nhiều phần nhỏ.

Đây là cấu trúc của một yêu cầu HTTP thực tế khi nó được gửi qua mạng:

GET /api/users?id=123 HTTP/1.1\r\n
Host: localhost:8080\r\n
User-Agent: curl/7.64.1\r\n
Accept: */*\r\n
\r\n

Chỉ đơn giản là văn bản thuần túy với `\r\n` (ký tự xuống dòng – carriage return + line feed) để phân tách các dòng, và `\r\n\r\n` để đánh dấu kết thúc của phần headers (tiêu đề), trước khi bắt đầu phần body (thân).

Logic phân tích của tôi trông như thế này:

static parse(buffer: Buffer): HttpRequest {
    const request = new HttpRequest();
    const rawRequest = buffer.toString('utf-8');
    const lines = rawRequest.split('\r\n');

    // Bước 1: Phân tích dòng đầu tiên "GET /api/users?id=1 HTTP/1.1"
    const requestLineData = RequestLine.parse(lines[0]);
    request.method = requestLineData.method;
    request.path = requestLineData.path;
    request.version = requestLineData.version;

    // Bước 2: Phân tích các headers
    // (Duyệt qua các dòng tiếp theo cho đến khi gặp dòng trống)

    // Bước 3: Phân tích body
    // (Phần còn lại sau dòng trống)

    return request;
}

Nói một cách đơn giản, chúng ta chỉ cần chia chuỗi theo `\r\n`. Lấy dòng đầu tiên để có phương thức yêu cầu (GET, POST…), đường dẫn và phiên bản HTTP. Lấy tất cả các dòng cho đến khi gặp một dòng trống để có các headers. Và mọi thứ còn lại sau dòng trống đó chính là body của yêu cầu. Phần “phức tạp” mà mọi người thường nói hóa ra chỉ là thao tác chia chuỗi và phân tích cơ bản. Khi tôi nhìn thấy định dạng thực tế, tôi nhận ra mình đã suy nghĩ quá nhiều về nó.

Xây Dựng Phản Hồi HTTP: Biến Dữ Liệu Thành Thông Điệp

Nếu việc phân tích yêu cầu chỉ là đọc và chia chuỗi, thì việc xây dựng phản hồi là quá trình ngược lại: lấy dữ liệu của bạn và định dạng nó trở lại thành định dạng HTTP mà trình duyệt hoặc client có thể hiểu được.

export class ResponseBuilder {
    private statusLine = new StatusLine();
    private headers = new HeaderBuilder();
    private body = "";

    setStatus(code: HttpStatusCode, message?: string): this {
        this.statusLine.set(code, message);
        return this;
    }

    setHeader(name: string, value: string): this {
        this.headers.set(name, value);
        return this;
    }

    setBody(body: string): this {
        this.body = body;
        return this;
    }

    build(): string {
        // Đảm bảo Content-Length được thiết lập nếu có body
        if (!this.headers.get('Content-Length') && this.body) {
            this.setHeader('Content-Length',
                Buffer.byteLength(this.body).toString());
        }
        return (
            this.statusLine.toString() +    // "HTTP/1.1 200 OK\r\n"
            this.headers.toString() +        // "Content-Type: text/html\r\n..."
            "\r\n" +                         // Dòng trống phân tách headers và body
            this.body                        // "<h1>Hello World!</h1>"
        );
    }
}

Mẫu thiết kế được sử dụng ở đây được gọi là “Builder Pattern”. Bạn gọi chuỗi các phương thức để cấu hình phản hồi (ví dụ: `setStatus`, `setHeader`, `setBody`), sau đó gọi `build()` để nhận chuỗi phản hồi cuối cùng. Đây là một cách sạch sẽ và có tổ chức hơn để xây dựng một đối tượng phức tạp từ nhiều phần khác nhau.

Tối Ưu Hiệu Suất Với HTTP Keep-Alive

Đây là phần mà mọi thứ trở nên thú vị. Ban đầu, server của tôi mở một kết nối TCP mới cho mỗi yêu cầu:

  1. Yêu cầu 1: Mở kết nối → Gửi yêu cầu → Nhận phản hồi → Đóng kết nối
  2. Yêu cầu 2: Mở kết nối → Gửi yêu cầu → Nhận phản hồi → Đóng kết nối
  3. Yêu cầu 3: Mở kết nối → Gửi yêu cầu → Nhận phản hồi → Đóng kết nối

Mỗi bước “mở kết nối” đều liên quan đến quy trình bắt tay ba bước của TCP đã đề cập ở trên. Điều này gây ra độ trễ khoảng 1.5 lần thời gian khứ hồi (RTT – Round-Trip Time), vốn có thể là 5ms trên một mạng tốt. Với 100 yêu cầu, chúng ta sẽ mất 500ms chi phí thuần túy chỉ để mở và đóng kết nối!

Sau đó, tôi tìm hiểu về HTTP Keep-Alive (còn được gọi là kết nối bền vững). Ý tưởng rất đơn giản: giữ kết nối mở và tái sử dụng nó cho nhiều yêu cầu.

  1. Yêu cầu 1: Mở kết nối → Gửi yêu cầu → Nhận phản hồi
  2. Yêu cầu 2:                         Gửi yêu cầu → Nhận phản hồi (trên cùng một kết nối!)
  3. Yêu cầu 3:                         Gửi yêu cầu → Nhận phản hồi (trên cùng một kết nối!)
  4. …sau 100 yêu cầu hoặc hết thời gian chờ…
  5.                         Đóng kết nối

Việc triển khai khá đơn giản:

export class KeepAliveManager {
    private requestCount = 0;
    private lastActivityTime: number;
    private readonly config: KeepAliveConfig;

    constructor(config: Partial<KeepAliveConfig> = {}) {
        this.config = {
            enabled: config.enabled ?? true,
            timeoutMs: config.timeoutMs ?? 60000,    // 60 giây
            maxRequests: config.maxRequests ?? 100    // Tối đa 100 yêu cầu trên mỗi kết nối
        };
        this.lastActivityTime = Date.now();
    }
    
    // ... các phương thức để xử lý yêu cầu và kiểm tra timeout
}

Logic trực tiếp: Đầu tiên, đếm số lượng yêu cầu đã được phục vụ trên kết nối này. Sau đó, theo dõi thời điểm hoạt động cuối cùng xảy ra. Cuối cùng, giữ kết nối sống cho đến khi đạt số yêu cầu tối đa hoặc hết thời gian chờ. Kỹ thuật này được mô tả chi tiết trong đặc tả HTTP/1.1, giúp tôi hiểu rõ hơn về nó.

Khám Phá Sâu Vòng Lặp Sự Kiện (Event Loop) Của JavaScript

Tôi đã nghĩ mình hiểu JavaScript cho đến khi bắt đầu sử dụng callbacks (hàm gọi lại) cho lập trình mạng. Tôi buộc phải dừng lại và thực sự học cách vòng lặp sự kiện (Event Loop) hoạt động.

Sau khi xem một video giải thích, cuối cùng tôi đã hiểu rằng quá trình thực thi JavaScript được chia thành các phần:

  • Call Stack: Xử lý việc thực thi hàm theo thứ tự vào sau ra trước (Last-In-First-Out).
  • Web APIs (hoặc Node.js APIs): Nơi các hoạt động bất đồng bộ diễn ra (ví dụ: setTimeout, XMLHttpRequest, thao tác I/O mạng).
  • Task Queue (hàng đợi tác vụ): Nơi các hoạt động bất đồng bộ đã hoàn thành chờ đợi.
  • Event Loop: Liên tục kiểm tra Call Stack. Nếu Call Stack trống, nó sẽ di chuyển một tác vụ từ Task Queue vào Call Stack để thực thi.

Đây là những gì xảy ra khi bạn đăng ký một callback cho sự kiện mạng:

conn.onData((data) => {
    // Callback này không thực thi ngay lập tức
    // Nó chờ dữ liệu đến trên kết nối mạng
    console.log("Dữ liệu đã đến:", data.toString());
});

Trong ví dụ trên, hàm `onData` đăng ký callback và trả về ngay lập tức. Sau đó, khi dữ liệu đến trên socket mạng, Node.js sẽ đặt callback này vào Task Queue. Event Loop sẽ liên tục kiểm tra: “Call Stack có trống không? Có? Được rồi, di chuyển callback này từ Task Queue vào Call Stack.” Sau đó, callback sẽ được thực thi.

Phần thú vị là Event Loop không bao giờ kết thúc. Bằng cách đăng ký các callbacks, về cơ bản tôi đang tạo ra các vòng lặp vô tận. Server tiếp tục chạy vì luôn có các callbacks chờ đợi các sự kiện diễn ra.

Xử Lý Dữ Liệu Hiệu Quả: Buffers so với Strings

Cách làm ban đầu của tôi (khá “ngu ngốc”):

// Nối chuỗi (cực kỳ chậm)
let requestData = "";
conn.onData((data) => {
    requestData += data.toString();  // Tạo một chuỗi mới mỗi lần!
    // Bộ nhớ: Sao chép, sao chép, sao chép...
});

Mỗi khi bạn nối chuỗi trong JavaScript, nó sẽ tạo ra một chuỗi hoàn toàn mới trong bộ nhớ và sao chép tất cả dữ liệu cũ cùng với dữ liệu mới. Đối với vài byte, điều này không đáng kể. Nhưng đối với một lượng lớn các khối dữ liệu mạng, đây sẽ trở thành một thảm họa về hiệu suất và quản lý bộ nhớ.

Cách tiếp cận hợp lý hơn:

// Tích lũy Buffer (nhanh chóng)
class RequestBuffer {
    private chunks: Buffer[] = [];
    private totalSize: number = 0;

    append(chunk: Buffer): void {
        this.chunks.push(chunk);      // Chỉ lưu trữ tham chiếu!
        this.totalSize += chunk.length;
    }

    toBuffer(): Buffer {
        return Buffer.concat(this.chunks);  // Kết hợp một lần khi cần
    }

    toString(encoding: BufferEncoding = 'utf-8'): string {
        return this.toBuffer().toString(encoding);
    }
}

Thay vì sao chép dữ liệu liên tục, chúng ta chỉ cần lưu trữ các tham chiếu đến các “chunk” (khối dữ liệu) dạng Buffer. Khi bạn thực sự cần dữ liệu hoàn chỉnh, bạn mới kết hợp tất cả chúng lại một lần duy nhất bằng `Buffer.concat()`. Điều này giúp tiết kiệm đáng kể tài nguyên bộ nhớ và cải thiện hiệu suất, đặc biệt trong các ứng dụng mạng xử lý lượng lớn dữ liệu nhị phân.

Kết Luận: Hành Trình Không Ngừng Học Hỏi

Hành trình xây dựng một server HTTP bằng TypeScript này đã dạy cho tôi nhiều hơn bất kỳ hướng dẫn nào từng có. Tôi đã học được về các kết nối TCP, cấu trúc giao thức HTTP, lập trình hướng sự kiện, và cơ chế bên trong cách các server thực sự hoạt động. Thành thật mà nói, mạng là chủ đề mà tôi thường chủ động phớt lờ. Bất cứ khi nào nó xuất hiện trong các khóa học hoặc cuộc trò chuyện, tôi sẽ lờ đi hoặc tìm cớ để bỏ qua.

Nhưng việc đi sâu vào dự án này đã thay đổi hoàn toàn quan điểm của tôi. Giờ đây, tôi đang cố gắng hết sức để tìm hiểu thêm về mạng, các giao thức và mọi thứ mà tôi từng né tránh. Dự án này vẫn còn rất xa mới hoàn thành, và tôi sẽ tiếp tục cải thiện nó khi sự hiểu biết của mình sâu sắc hơn. Nếu bạn phát hiện bất kỳ lỗi, hiểu lầm hoặc những điểm mà tôi vẫn còn bối rối, xin vui lòng chỉ ra. Tôi hoàn toàn cởi mở với những lời phê bình vì đó là cách tôi học tốt nhất. Dù đó là sự hiểu biết của tôi về Event Loop, cách triển khai Keep-Alive hay bất kỳ điều gì khác trong bài đăng này, tôi luôn hoan nghênh phản hồi của bạn.

Chỉ mục