4.3 HTTP 伺服器實作:手寫最小 HTTP/1.1#
HTTP 的三十年#
1991 年 Tim Berners-Lee 發布 HTTP/0.9(只有 GET,回應是純 HTML);1996 年 RFC 1945 定稿 HTTP/1.0;1997 年 RFC 2068(HTTP/1.1)加入 keep-alive、chunked transfer、Host header(使一個 IP 服務多個網站,催生虛擬主機託管產業);2015 年 HTTP/2(多工復用,Google SPDY 計畫 2012 年的成果被標準化);2022 年 HTTP/3 基於 QUIC(UDP 之上,Google 2012 年起實驗)。
本節手寫 HTTP/1.1 的子集,目標不是取代 nginx,而是理解「應用層協定如何騎在 4.1 節的 TCP 位元組流上」。核心難點:TCP 不保訊息邊界,HTTP 用文字協定定界——
GET /index.html HTTP/1.1\r\n
Host: example.com\r\n
User-Agent: curl/8\r\n
\r\n
請求 = 起始行 + 多個 Key: Value\r\n header + 空行 + body。空行 \r\n\r\n 是 header 的結束記號;body 長度由 Content-Length 或 chunked 編碼決定。
狀態機:從位元組流重建訊息#
讀 TCP 的迴圈會遇到「一次 read 可能只有半個請求」的問題(TCP 分段由 MSS/擁塞視窗決定,與應用無關),所以解析器必須是增量式的。用一個簡單的讀取器:
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
#[derive(Debug)]
struct Request {
method: String,
path: String,
version: String,
headers: Vec<(String, String)>,
body: Vec<u8>,
}
fn read_request(stream: &mut BufReader<TcpStream>) -> std::io::Result<Option<Request>> {
let mut start_line = String::new();
if stream.read_line(&mut start_line)? == 0 {
return Ok(None); // 連線關閉
}
let mut parts = start_line.split_whitespace();
let (method, path, version) = (
parts.next().unwrap_or("").to_string(),
parts.next().unwrap_or("/").to_string(),
parts.next().unwrap_or("HTTP/0.9").to_string(),
);
let mut headers = Vec::new();
loop {
let mut line = String::new();
if stream.read_line(&mut line)? == 0 { break; }
let line = line.trim_end(); // 去掉 \r\n
if line.is_empty() { break; } // 空行 = header 結束
if let Some((k, v)) = line.split_once(':') {
headers.push((k.trim().to_string(), v.trim().to_string()));
}
}
let len = headers.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("content-length"))
.and_then(|(_, v)| v.parse::<usize>().ok())
.unwrap_or(0);
let mut body = vec![0u8; len];
stream.read_exact(&mut body)?; // 正好讀 len 位元組
Ok(Some(Request { method, path, version, headers, body }))
}
read_exact 是關鍵:它迴圈讀到填滿為止,吸收了 TCP 分段的複雜性——而它回傳 Result,短讀(對端斷線)必須處理,這種「不檢查編譯不過」的特性在 C 的 read() 世界裡是日常漏洞來源。
伺服器主體#
use std::io::{BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
fn respond(stream: &mut TcpStream, status: &str, body: &str, keep_alive: bool) -> std::io::Result<()> {
let resp = format!(
"HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\n\
Content-Length: {}\r\nConnection: {}\r\n\r\n{body}",
body.len(),
if keep_alive { "keep-alive" } else { "close" },
);
stream.write_all(resp.as_bytes())
}
fn handle(stream: TcpStream) -> std::io::Result<()> {
let peer = stream.peer_addr()?;
let mut writer = stream.try_clone()?; // 讀寫分離:複製 fd,各一個所有權
let mut reader = BufReader::new(stream);
while let Some(req) = read_request(&mut reader)? {
println!("{peer} {} {}", req.method, req.path);
let body = match (req.method.as_str(), req.path.as_str()) {
("GET", "/") => "Hello from a hand-written Rust HTTP server!\n".to_string(),
("GET", p) if p.starts_with("/hello/") => {
format!("Hi, {}\n", &p["/hello/".len()..])
}
("POST", "/echo") => String::from_utf8_lossy(&req.body).into_owned(),
_ => { respond(&mut writer, "404 Not Found", "not found\n", false)?; continue; }
};
respond(&mut writer, "200 OK", &body, true)?; // keep-alive:同一連線續讀下一請求
}
Ok(())
}
fn main() -> std::io::Result<()> {
let listener = TcpListener::bind("0.0.0.0:8080")?;
for stream in listener.incoming() {
let stream = stream?;
thread::spawn(move || { let _ = handle(stream); });
}
Ok(())
}
測試:
$ curl -v http://localhost:8080/hello/world
$ curl -X POST -d 'data=123' http://localhost:8080/echo
幾個設計點對應歷史:
- Content-Length 必填:HTTP/1.0 沒有它就得靠「關連線」表示 body 結束(無法 keep-alive);HTTP/1.1 加 Content-Length/chunked 才使持久連線可行,網頁載入速度因此大幅提升。
- Connection: keep-alive:1997 年 RFC 2068 將其定為預設;本例的
while let迴圈就是在實作它——每個請求讀完繼續等下一個,直到對端關閉。 try_clone:讀寫各持一個 fd 拷貝(dup),所有權各自獨立,借用檢查不會把讀寫一起卡死。
借用檢查如何影響協定解析器#
寫 HTTP 解析時 Rust 會逼你做出正確的架構選擇。常見錯誤情境:
// 想邊讀邊存「指向緩衝區的切片」:
// let headers: Vec<&str> = buf.split(...).collect();
// buf 再 read 一次 → 借用錯誤:buf 被 &mut 借走
編譯器拒絕「拿舊緩衝區的引用繼續寫緩衝區」——而這正是 HTTP 請求走私(request smuggling,2005 年 Amit Klein 提出、2019 年 PortSwigger 系統性研究)等攻擊的土壤:解析器對「已解析內容」與「緩衝區狀態」的假設不一致。Rust 的解法是二選一:要嘛 .to_string() 拷貝出所有權(本節做法,慢一點但安全),要嘛用 httparse 這類「索引而非引用」的零拷貝解析器(2015 年由 pyfisch 等開發,hyperfine/servo 系生態標配),在解析完成的瞬間把 (start, len) 轉成 &str,生命週期由型別標註。
進一步:HTTP/2 與二進制化#
HTTP/2(2015)把文字協定換成二進制幀,解決 HTTP/1.1 的隊頭阻塞(一條 TCP 連線上多個 stream 交錯)。但 TCP 層的丟包重傳仍會卡住所有 stream,所以 HTTP/3 乾脆換 QUIC/UDP。本節的文字協定便是這段演化的起點:協定設計的每個決定(文字 vs 二進制、有無邊界、連線複用)都直接影響之後十年能否修漏洞。
習題#
- 加入 chunked 請求的解析(
Transfer-Encoding: chunked),格式:<hex>\r\n<data>\r\n,以0\r\n\r\n結束。 - 用
httparse重寫解析,對比手寫版在 10k 請求/秒下的差異(可用wrk)。 - 嘗試走私攻擊的思路:若一個 header 值裡含
\r\n(未驗證),會發生什麼?在你的解析器加防護。