Bạn gọi fetch(), requests.get(), hay http.Get() mỗi ngày, nhưng đã bao giờ thấy một request HTTP thô trông thế nào chưa? Đằng sau mọi thư viện HTTP hào nhoáng là một sự thật đơn giản đến mức nhiều người không tin: HTTP/1.1 chỉ là vài dòng text, kết thúc bằng ký tự CRLF, gửi qua một socket TCP. Không mã hoá nhị phân bí ẩn, không nghi thức bắt tay phức tạp ở tầng ứng dụng. Bài mở đầu sê-ri này bóc lớp vỏ đó ra: ta xem từng byte curl gửi đi, rồi tự gõ tay một request mà không dùng thư viện HTTP nào — chạy thật trong container với curl 7.88 và Python 3.11.
Cấu trúc một message HTTP
Cả request lẫn response đều chung một khung bốn phần:
Dòng đầu GET /index.html HTTP/1.1 (request-line)
Các header Host: ... Content-Type: ...
Dòng TRỐNG <CRLF> (báo hết header)
Body <dữ liệu, có thể rỗng>
Ba điều đáng khắc cốt: (1) mỗi dòng kết thúc bằng CRLF (\r\n), không phải chỉ \n; (2) một dòng trống (\r\n đứng riêng) ngăn phần header với body — đây là ranh giới quan trọng nhất; (3) response chỉ khác request ở dòng đầu: thay request-line bằng status-line (HTTP/1.0 200 OK), phần còn lại y hệt. Nắm khung này là đọc được mọi trao đổi HTTP.
curl -s --trace-ascii - http://127.0.0.1:8099/index.html # in từng byte gửi/nhận
curl -i http://... # -i: in header response + body
curl -v http://... # -v: > gửi, < nhận, * ghi chú

Hình 1: Khung một message HTTP — dòng đầu, các header, một dòng trống, rồi body. Ta xem thô bằng curl --trace-ascii và chứng minh bằng một request gõ tay qua socket.
Đo thật: xem từng byte curl gửi và nhận
Dựng một server tĩnh đơn giản (python3 -m http.server 8099) phục vụ một index.html dài đúng 41 byte, rồi gọi curl với --trace-ascii để in ra chính xác những gì đi qua dây:

Hình 2: REQUEST curl gửi (dòng GET, ba header, một dòng trống); RESPONSE server trả (status-line, các header, dòng trống, rồi body dài đúng Content-Length: 41); và một request gõ tay bằng socket vẫn nhận lời đáp HTTP hợp lệ.
Kết quả xác nhận đúng khung lý thuyết. Request curl gửi vỏn vẹn bốn dòng: GET /index.html HTTP/1.1, Host:, User-Agent:, Accept:, rồi một dòng trống — không có body vì GET không mang body. Response mở đầu bằng HTTP/1.0 200 OK, một loạt header, một dòng trống, rồi 41 byte HTML đúng bằng Content-Length đã khai. Header Host là bắt buộc trong HTTP/1.1 (nhiều site chung một IP, server dựa vào nó để biết bạn hỏi site nào — sẽ nói kỹ ở bài header).
Tự gõ tay một request — không thư viện HTTP nào
Để chứng minh HTTP thật sự chỉ là text trên TCP, ta bỏ hết thư viện HTTP và chỉ dùng một socket thô:
import socket
s = socket.create_connection(("127.0.0.1", 8099))
req = ("GET /data.txt HTTP/1.1\r\n"
"Host: 127.0.0.1\r\n"
"Connection: close\r\n\r\n") # \r\n\r\n = dòng trống, hết header
s.sendall(req.encode())
print(s.recv(4096).decode()) # server trả về text HTTP
Server trả về HTTP/1.0 200 OK, Content-type: text/plain, Content-Length: 15, rồi nội dung file. Không có phép màu nào: chỉ cần một socket TCP và một chuỗi text đúng khuôn (chú ý \r\n\r\n ở cuối — thiếu dòng trống này server sẽ chờ mãi vì tưởng header chưa hết). Đây chính xác là những gì mọi thư viện HTTP làm bên dưới, chỉ khác chúng lo giúp bạn phần dựng chuỗi, phân tích response, quản lý kết nối và lỗi.
Đánh đổi và lưu ý
Text dễ đọc nhưng tốn byte — đó là lý do HTTP/2 ra đời. Header dạng text lặp lại ở mọi request (cùng User-Agent, cùng cookie...) rất phí băng thông. HTTP/2 nén header nhị phân (HPACK) để khắc phục — chủ đề một bài sau. Nhưng chính tính "text thuần" của HTTP/1.1 khiến nó dễ debug bằng mắt, và đó là giá trị lớn khi đi tìm lỗi.
CRLF không phải LF. Chuẩn HTTP yêu cầu \r\n. Đa số server dễ tính chấp nhận cả \n, nhưng đừng dựa vào đó — khi tự sinh request (proxy, test tool), dùng đúng \r\n để khỏi gặp lỗi khó hiểu.
http.server của Python là HTTP/1.0, chỉ để học/thử. Nó trả HTTP/1.0, không keep-alive mặc định, không chịu tải — tuyệt đối không dùng cho production. Ở đây nó hoàn hảo để soi giao thức vì đơn giản và trong suốt.
Ba ý mang về
- Một message HTTP là text đúng khuôn: dòng đầu (request-line hoặc status-line) → các header → một dòng trống (
\r\n) → body; mỗi dòng kết thúc bằng CRLF. Request và response chung khung, chỉ khác dòng đầu. - Xem thô bằng curl:
--trace-asciiin từng byte gửi/nhận,-iin header response,-vhiện cả hai chiều — đo thật, request GET chỉ bốn dòng, response có body dài đúngContent-Length. - HTTP chỉ là text trên TCP: một request gõ tay bằng socket Python (không thư viện HTTP) vẫn được server trả lời — mọi thư viện HTTP chỉ là lớp tiện lợi bọc quanh việc này; nhớ
\r\n\r\nbáo hết header nếu không server chờ mãi.
Nguồn
- MDN — An overview of HTTP và HTTP Messages: https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages
- RFC 9110 (HTTP Semantics) và RFC 9112 (HTTP/1.1 message format): https://www.rfc-editor.org/rfc/rfc9112
Phần sau ta soi kỹ các header quan trọng nhất — Host, Content-Type, và cặp Content-Length vs Transfer-Encoding: chunked — vì sao chọn sai một trong hai khiến response bị treo hoặc cắt cụt.