Rust 中單線程 Web 服務(wù)器的實現(xiàn)
Web 服務(wù)器中涉及的兩個主要協(xié)議是超文本傳輸協(xié)議(HTTP)和傳輸控制協(xié)議(TCP)。這兩種協(xié)議都是請求-響應(yīng)協(xié)議,這意味著客戶端發(fā)起請求,服務(wù)器偵聽請求并向客戶端提供響應(yīng)。這些請求和響應(yīng)的內(nèi)容由協(xié)議定義。
TCP 是較低級別的協(xié)議,它描述了信息如何從一臺服務(wù)器傳遞到另一臺服務(wù)器的細節(jié),但沒有指定該信息是什么。HTTP 通過定義請求和響應(yīng)的內(nèi)容建立在 TCP 之上。在技術(shù)上可以將 HTTP 與其他協(xié)議一起使用,但在絕大多數(shù)情況下,HTTP 通過 TCP 發(fā)送數(shù)據(jù)。
我們將處理 TCP 和 HTTP 請求和響應(yīng)的原始字節(jié)。
監(jiān)聽 TCP 連接
標準庫提供了一個 std::net 模塊,可以讓我們監(jiān)聽 TCP 連接。
下面這段代碼將在本地地址 127.0.0.1:7878 上監(jiān)聽傳入的 TCP 流。當它收到一個傳入流時,它將打印 Connection established!
use std::net::TcpListener;
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
println!("Connection established!");
}
}
使用 TcpListener,我們可以監(jiān)聽地址為 127.0.0.1:7878 的 TCP 連接。在地址中,冒號之前的部分是代表本地地址,7878 是端口。
bind 函數(shù)類似于 new 函數(shù),作用是監(jiān)聽一個端口,它返回 Result<T, E>,這表明綁定有可能失敗。若成功,則得到一個新的 TcpListener 實例;若失敗,我們使用 unwrap 來停止程序。
TcpListener 上的 incoming 方法返回一個迭代器,該迭代器為我們提供一個 TcpStream 類型的流。單個流表示客戶端和服務(wù)器之間的連接,在該過程中,客戶機連接到服務(wù)器,服務(wù)器生成響應(yīng),服務(wù)器關(guān)閉連接。因此,我們將從 TcpStream 中讀取以查看客戶端發(fā)送的內(nèi)容,然后將響應(yīng)寫入流以將數(shù)據(jù)發(fā)送回客戶端??偟膩碚f,這個 for 循環(huán)將依次處理每個連接,并產(chǎn)生一系列流供我們處理。
目前,我們對流的處理包括:如果流有任何錯誤,調(diào)用 unwrap 來終止程序;如果沒有任何錯誤,程序?qū)⒋蛴∫粭l消息。
在終端中調(diào)用 cargo run,然后在瀏覽器中加載 127.0.0.1:7878。瀏覽器應(yīng)該顯示一個錯誤消息,因為服務(wù)器當前沒有發(fā)回任何數(shù)據(jù)。

但是終端上有瀏覽器連接到服務(wù)器時打印的幾條消息。

閱讀請求
實現(xiàn)一個 handle_connection 函數(shù),從 TCP 流中讀取數(shù)據(jù)并打印出來,這樣我們就可以看到從瀏覽器發(fā)送的數(shù)據(jù)。
use std::net::{TcpListener, TcpStream};
use std::io::{BufReader, prelude::*};
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let http_request: Vec<_> = buf_reader
.lines()
.map(|result| result.unwrap())
.take_while(|line| !line.is_empty())
.collect();
println!("Request: {http_request:#?}");
}
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
在 handle_connection 函數(shù)中,我們創(chuàng)建了一個新的 BufReader 實例,該實例包裝了對流的引用。BufReader 通過為我們管理對 std::io::Read trait 方法的調(diào)用來增加緩沖。
我們創(chuàng)建了一個名為 http_request 的變量來收集瀏覽器發(fā)送到服務(wù)器的請求行。我們通過添加 Vec<_> 類型注釋來表示希望將這些行收集到一個向量中。
BufReader 實現(xiàn)了 std::io::BufRead trait,它提供了 lines 方法。lines 方法返回一個 Result<String, std::io::Error> 的迭代器,方法是在看到換行符時拆分數(shù)據(jù)流。為了獲得每個 String,我們使用 map 方法展開每個 Result。
瀏覽器通過在一行中發(fā)送兩個換行符來表示 HTTP 請求的結(jié)束,因此為了從流中獲得一個請求,我們一直讀取行,直到得到空字符串的行。一旦我們將這些行收集到 vector 中,我們將使用 #? 調(diào)試格式將它們打印出來,這樣我們就可以查看 Web 瀏覽器發(fā)送給服務(wù)器的指令。
運行程序并再次在 Web 瀏覽器中發(fā)出請求。我們?nèi)匀粫跒g覽器中得到一個錯誤頁面,但是我們的程序在終端中的輸出現(xiàn)在看起來像這樣:
Request: [
"GET / HTTP/1.1",
"Host: 127.0.0.1:7878",
"Connection: keep-alive",
"sec-ch-ua: \"Google Chrome\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"",
"sec-ch-ua-mobile: ?0",
"sec-ch-ua-platform: \"Windows\"",
"Upgrade-Insecure-Requests: 1",
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Sec-Fetch-Site: none",
"Sec-Fetch-Mode: navigate",
"Sec-Fetch-User: ?1",
"Sec-Fetch-Dest: document",
"Accept-Encoding: gzip, deflate, br, zstd",
"Accept-Language: zh-CN,zh;q=0.9",
]
讓我們分解這個請求數(shù)據(jù)來理解瀏覽器對程序的要求。
仔細看看 HTTP 請求
HTTP 是一個基于文本的協(xié)議,請求采用以下格式:
Method Request-URI HTTP-Version CRLF headers CRLF message-body
第一行是請求行,包含有關(guān)客戶端請求內(nèi)容的信息。
請求行的第一部分表明正在使用的方法,例如 GET 或 POST,它描述了客戶端如何發(fā)出此請求。我們的客戶端使用 GET 請求,這意味著它正在請求信息。
請求行的下一部分是/,它指示客戶機請求的統(tǒng)一資源標識符(URI)。URI 類似于 URL,但是 HTTP 規(guī)范使用術(shù)語 URI。
請求行的最后一部分是客戶端使用的 HTTP 版本,然后請求行以 CRLF 序列 \r\n 結(jié)束,其中 \r 是回車,\n 是換行符。CRLF 序列將請求行與請求數(shù)據(jù)的其余部分分開。
查看我們收到的請求行數(shù)據(jù),可以看到 GET 是方法,/ 是請求 URI, HTTP/1.1 是版本。
在請求行之后,從 Host: 開始的其余行是請求頭。GET 請求沒有請求體。
現(xiàn)在我們知道了瀏覽器在請求什么,讓我們發(fā)回一些數(shù)據(jù)吧!
編寫響應(yīng)
我們將實現(xiàn)發(fā)送數(shù)據(jù)以響應(yīng)客戶機請求。HTTP 響應(yīng)的格式如下:
HTTP-Version Status-Code Reason-Phrase CRLF headers CRLF message-body
第一行是狀態(tài)行,其中包含響應(yīng)中使用的 HTTP 版本、總結(jié)請求結(jié)果的數(shù)字狀態(tài)碼,以及提供狀態(tài)碼文本描述的原因短語。在 CRLF 序列之后是任何響應(yīng)頭、另一個 CRLF 序列和響應(yīng)體。
下面是一個使用 HTTP 1.1 版本的響應(yīng)示例,它的狀態(tài)碼是 200,一個 OK 原因短語,沒有響應(yīng)頭、響應(yīng)體:
HTTP/1.1 200 OK\r\n\r\n
狀態(tài)碼 200 是標準的成功響應(yīng)。讓我們將其寫入流,作為對成功請求的響應(yīng)。修改 handle_connection 函數(shù):
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let http_request: Vec<_> = buf_reader
.lines()
.map(|result| result.unwrap())
.take_while(|line| !line.is_empty())
.collect();
let response = "HTTP/1.1 200 OK\r\n\r\n";
stream.write_all(response.as_bytes()).unwrap();
}
as_bytes 方法將字符串數(shù)據(jù)轉(zhuǎn)換為字節(jié)。流上的 write_all 方法接受 &[u8],并將這些字節(jié)直接發(fā)送到連接。因為 write_all 操作可能失敗,所以我們像以前一樣對任何錯誤結(jié)果使用 unwrap。
通過這些更改,讓我們運行代碼并在瀏覽器中加載 127.0.0.1:7878。你應(yīng)該得到一個空白頁面,而不是一個錯誤頁面。

返回真正的 HTML
讓我們實現(xiàn)不止返回一個空白頁的功能。在項目目錄的根目錄中創(chuàng)建新文件 hello.html。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello!</title>
</head>
<body>
<h1>Hello!</h1>
<p>Hi from Rust</p>
</body>
</html>
接著修改 handle_connection 函數(shù),讀取 HTML 文件,將其作為正文添加到響應(yīng)中,然后發(fā)送。
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let http_request: Vec<_> = buf_reader
.lines()
.map(|result| result.unwrap())
.take_while(|line| !line.is_empty())
.collect();
let status_line = "HTTP/1.1 200 OK";
let contents = fs::read_to_string("hello.html").unwrap();
let length = contents.len();
let response =
format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).unwrap();
}
我們使用 format! 將 hello.html 的內(nèi)容添加到響應(yīng)體中。為了確保有效的 HTTP 響應(yīng),我們添加了 Content-Length,該報頭設(shè)置為響應(yīng)體的大小,在本例中為 hello.html 的大小。
運行這段代碼,并在瀏覽器中加載 127.0.0.1:7878。我們看到瀏覽器接收并渲染了 hello.html。

目前,我們忽略了 http_request 中的請求數(shù)據(jù),只是無條件地發(fā)回 HTML 文件的內(nèi)容。
我們希望根據(jù)請求定制響應(yīng),只響應(yīng)格式良好的請求。
驗證請求并選擇性地響應(yīng)
讓我們添加一些功能,在返回 HTML 文件之前檢查瀏覽器是否正在請求 /,如果瀏覽器請求任何其他內(nèi)容,則返回一個錯誤。
我們需要修改 handle_connection 函數(shù),檢查收到的請求的內(nèi)容,并添加 if 和 else 塊以區(qū)別對待請求。
// --snip--
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
if request_line == "GET / HTTP/1.1" {
let status_line = "HTTP/1.1 200 OK";
let contents = fs::read_to_string("hello.html").unwrap();
let length = contents.len();
let response = format!(
"{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
);
stream.write_all(response.as_bytes()).unwrap();
} else {
// some other request
}
}
我們將只查看 HTTP 請求的第一行,因此我們將調(diào)用 next 來從迭代器中獲取第一項,而不是將整個請求讀入 vector。第一次 unwrap 處理 Option,如果迭代器沒有項,則停止程序。第二個 unwrap 處理 Result,取出請求內(nèi)容。
接下來,我們檢查 request_line,看看它是否等于對 / 路徑的 GET 請求的請求行。如果是,if 塊返回 hello.html 文件的內(nèi)容。
現(xiàn)在運行此代碼并請求 127.0.0.1:7878,還是成功的。

如果發(fā)出任何其他請求,例如 127.0.0.1:7878/other,你將得到一個連接錯誤。

現(xiàn)在,讓我們完善 else 塊中的代碼,返回一個狀態(tài)碼為 404、原因短語為 NOT FOUND 的響應(yīng),響應(yīng)體是 404.html 文件。
// --snip--
} else {
let status_line = "HTTP/1.1 404 NOT FOUND";
let contents = fs::read_to_string("404.html").unwrap();
let length = contents.len();
let response = format!(
"{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}"
);
stream.write_all(response.as_bytes()).unwrap();
}
404.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello!</title>
</head>
<body>
<h1>Oops!</h1>
<p>Sorry, I don't know what you're asking for.</p>
</body>
</html>
通過這些更改,再次運行服務(wù)器。請求 127.0.0.1:7878 應(yīng)該返回 hello.html 的內(nèi)容。而任何其他請求,如 127.0.0.1:7878/other,應(yīng)該返回 404.html 中的錯誤 HTML。

代碼重構(gòu)
目前,if 和 els e塊有很多重復(fù):它們都在讀取文件并將文件的內(nèi)容寫入流,唯一的區(qū)別是狀態(tài)行和文件名。
讓我們將這些差異提取到單獨的 if 和 else 行中,將狀態(tài)行和文件名的值分配給變量,然后使用這些變量來讀取文件并寫入響應(yīng)。
fn handle_connection(mut stream: TcpStream) {
// --snip--
let (status_line, filename) = if request_line == "GET / HTTP/1.1" {
("HTTP/1.1 200 OK", "hello.html")
} else {
("HTTP/1.1 404 NOT FOUND", "404.html")
};
let contents = fs::read_to_string(filename).unwrap();
let length = contents.len();
let response =
format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");
stream.write_all(response.as_bytes()).unwrap();
}
總結(jié)
我們只用 32 行 Rust 代碼就實現(xiàn)了一個簡單的單線程 Web 服務(wù)器,用hello.html 響應(yīng)一個請求,用 404.html 響應(yīng)所有其他請求。
目前,我們的服務(wù)器在單線程中運行,這意味著它一次只能處理一個請求。在下一個項目中,我們先通過模擬一些慢速請求來檢查這是如何造成問題的。然后我們將修復(fù)它,以便我們的服務(wù)器可以同時處理多個請求。

