ch20-01-single-threaded.md
commit 9c0fa2714859738ff73cbbb829592e4c037d7e46
首先讓我們創(chuàng)建一個可運行的單線程 web server,不過在開始之前,我們將快速了解一下構(gòu)建 web server 所涉及到的協(xié)議。這些協(xié)議的細(xì)節(jié)超出了本書的范疇,不過一個簡單的概括會提供我們所需的信息。
web server 中涉及到的兩個主要協(xié)議是 超文本傳輸協(xié)議(Hypertext Transfer Protocol,HTTP)和 傳輸控制協(xié)議(Transmission Control Protocol,TCP)。這兩者都是 請求-響應(yīng)(request-response)協(xié)議,也就是說,有 客戶端(client)來初始化請求,并有 服務(wù)端(server)監(jiān)聽請求并向客戶端提供響應(yīng)。請求與響應(yīng)的內(nèi)容由協(xié)議本身定義。
TCP 是一個底層協(xié)議,它描述了信息如何從一個 server 到另一個的細(xì)節(jié),不過其并不指定信息是什么。HTTP 構(gòu)建于 TCP 之上,它定義了請求和響應(yīng)的內(nèi)容。為此,技術(shù)上講可將 HTTP 用于其他協(xié)議之上,不過對于絕大部分情況,HTTP 通過 TCP 傳輸。我們將要做的就是處理 TCP 和 HTTP 請求與響應(yīng)的原始字節(jié)數(shù)據(jù)。
所以我們的 web server 所需做的第一件事便是能夠監(jiān)聽 TCP 連接。標(biāo)準(zhǔn)庫提供了 std::net
模塊處理這些功能。讓我們一如既往新建一個項目:
$ cargo new hello
Created binary (application) `hello` project
$ cd hello
并在 src/main.rs
輸入示例 20-1 中的代碼作為開始。這段代碼會在地址 127.0.0.1:7878
上監(jiān)聽傳入的 TCP 流。當(dāng)獲取到傳入的流,它會打印出 Connection established!
:
文件名: src/main.rs
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!");
}
}
示例 20-1: 監(jiān)聽傳入的流并在接收到流時打印信息
TcpListener
用于監(jiān)聽 TCP 連接。我們選擇監(jiān)聽地址 127.0.0.1:7878
。將這個地址拆開,冒號之前的部分是一個代表本機的 IP 地址(這個地址在每臺計算機上都相同,并不特指作者的計算機),而 7878
是端口。選擇這個端口出于兩個原因:通常 HTTP 接受這個端口而且 7878 在電話上打出來就是 "rust"(譯者注:九宮格鍵盤上的英文)。
在這個場景中 bind
函數(shù)類似于 new
函數(shù),在這里它返回一個新的 TcpListener
實例。這個函數(shù)叫做 bind
是因為,在網(wǎng)絡(luò)領(lǐng)域,連接到監(jiān)聽端口被稱為 “綁定到一個端口”(“binding to a port”)
bind
函數(shù)返回 Result<T, E>
,這表明綁定可能會失敗,例如,連接 80 端口需要管理員權(quán)限(非管理員用戶只能監(jiān)聽大于 1024 的端口),所以如果不是管理員嘗試連接 80 端口,則會綁定失敗。另一個例子是如果運行兩個此程序的實例這樣會有兩個程序監(jiān)聽相同的端口,綁定會失敗。因為我們是出于學(xué)習(xí)目的來編寫一個基礎(chǔ)的 server,將不用關(guān)心處理這類錯誤,使用 unwrap
在出現(xiàn)這些情況時直接停止程序。
TcpListener
的 incoming
方法返回一個迭代器,它提供了一系列的流(更準(zhǔn)確的說是 TcpStream
類型的流)。流(stream)代表一個客戶端和服務(wù)端之間打開的連接。連接(connection)代表客戶端連接服務(wù)端、服務(wù)端生成響應(yīng)以及服務(wù)端關(guān)閉連接的全部請求
/ 響應(yīng)過程。為此,TcpStream
允許我們讀取它來查看客戶端發(fā)送了什么,并可以編寫響應(yīng)??傮w來說,這個 for
循環(huán)會依次處理每個連接并產(chǎn)生一系列的流供我們處理。
目前為止,處理流的過程包含 unwrap
調(diào)用,如果出現(xiàn)任何錯誤會終止程序,如果沒有任何錯誤,則打印出信息。下一個示例我們將為成功的情況增加更多功能。當(dāng)客戶端連接到服務(wù)端時 incoming
方法返回錯誤是可能的,因為我們實際上沒有遍歷連接,而是遍歷 連接嘗試(connection attempts)。連接可能會因為很多原因不能成功,大部分是操作系統(tǒng)相關(guān)的。例如,很多系統(tǒng)限制同時打開的連接數(shù);新連接嘗試產(chǎn)生錯誤,直到一些打開的連接關(guān)閉為止。
讓我們試試這段代碼!首先在終端執(zhí)行 cargo run
,接著在瀏覽器中加載 127.0.0.1:7878
。瀏覽器會顯示出看起來類似于“連接重置”(“Connection reset”)的錯誤信息,因為 server 目前并沒響應(yīng)任何數(shù)據(jù)。但是如果我們觀察終端,會發(fā)現(xiàn)當(dāng)瀏覽器連接 server 時會打印出一系列的信息!
Running `target/debug/hello`
Connection established!
Connection established!
Connection established!
有時會看到對于一次瀏覽器請求會打印出多條信息;這可能是因為瀏覽器在請求頁面的同時還請求了其他資源,比如出現(xiàn)在瀏覽器 tab 標(biāo)簽中的 favicon.ico。
這也可能是因為瀏覽器嘗試多次連接 server,因為 server 沒有響應(yīng)任何數(shù)據(jù)。當(dāng) stream
在循環(huán)的結(jié)尾離開作用域并被丟棄,其連接將被關(guān)閉,作為 drop
實現(xiàn)的一部分。瀏覽器有時通過重連來處理關(guān)閉的連接,因為這些問題可能是暫時的?,F(xiàn)在重要的是我們成功的處理了 TCP 連接!
記得當(dāng)運行完特定版本的代碼后使用 ctrl-C 來停止程序。并在做出最新的代碼修改之后執(zhí)行 cargo run
重啟服務(wù)。
讓我們實現(xiàn)讀取來自瀏覽器請求的功能!為了分離獲取連接和接下來對連接的操作的相關(guān)內(nèi)容,我們將開始一個新函數(shù)來處理連接。在這個新的 handle_connection
函數(shù)中,我們從 TCP 流中讀取數(shù)據(jù)并打印出來以便觀察瀏覽器發(fā)送過來的數(shù)據(jù)。將代碼修改為如示例 20-2 所示:
文件名: src/main.rs
use std::io::prelude::*;
use std::net::TcpListener;
use std::net::TcpStream;
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
let mut buffer = [0; 1024];
stream.read(&mut buffer).unwrap();
println!("Request: {}", String::from_utf8_lossy(&buffer[..]));
}
示例 20-2: 讀取 TcpStream
并打印數(shù)據(jù)
這里將 std::io::prelude
引入作用域來獲取讀寫流所需的特定 trait。在 main
函數(shù)的 for
循環(huán)中,相比獲取到連接時打印信息,現(xiàn)在調(diào)用新的 handle_connection
函數(shù)并向其傳遞 stream
。
在 handle_connection
中,stream
參數(shù)是可變的。這是因為 TcpStream
實例在內(nèi)部記錄了所返回的數(shù)據(jù)。它可能讀取了多于我們請求的數(shù)據(jù)并保存它們以備下一次請求數(shù)據(jù)。因此它需要是 mut
的因為其內(nèi)部狀態(tài)可能會改變;通常我們認(rèn)為 “讀取” 不需要可變性,不過在這個例子中則需要 mut
關(guān)鍵字。
接下來,需要實際讀取流。這里分兩步進(jìn)行:首先,在棧上聲明一個 buffer
來存放讀取到的數(shù)據(jù)。這里創(chuàng)建了一個 1024 字節(jié)的緩沖區(qū),它足以存放基本請求的數(shù)據(jù)并滿足本章的目的需要。如果希望處理任意大小的請求,緩沖區(qū)管理將更為復(fù)雜,不過現(xiàn)在一切從簡。接著將緩沖區(qū)傳遞給 stream.read
,它會從 TcpStream
中讀取字節(jié)并放入緩沖區(qū)中。
接下來將緩沖區(qū)中的字節(jié)轉(zhuǎn)換為字符串并打印出來。String::from_utf8_lossy
函數(shù)獲取一個 &[u8]
并產(chǎn)生一個 String
。函數(shù)名的 “l(fā)ossy” 部分來源于當(dāng)其遇到無效的 UTF-8 序列時的行為:它使用 ?
,U+FFFD REPLACEMENT CHARACTER
,來代替無效序列。你可能會在緩沖區(qū)的剩余部分看到這些替代字符,因為他們沒有被請求數(shù)據(jù)填滿。
讓我們試一試!啟動程序并再次在瀏覽器中發(fā)起請求。注意瀏覽器中仍然會出現(xiàn)錯誤頁面,不過終端中程序的輸出現(xiàn)在看起來像這樣:
$ cargo run
Compiling hello v0.1.0 (file:///projects/hello)
Finished dev [unoptimized + debuginfo] target(s) in 0.42s
Running `target/debug/hello`
Request: GET / HTTP/1.1
Host: 127.0.0.1:7878
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64; rv:52.0) Gecko/20100101
Firefox/52.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: keep-alive
Upgrade-Insecure-Requests: 1
????????????????????????????????????
根據(jù)使用的瀏覽器不同可能會出現(xiàn)稍微不同的數(shù)據(jù)?,F(xiàn)在我們打印出了請求數(shù)據(jù),可以通過觀察 Request: GET
之后的路徑來解釋為何會從瀏覽器得到多個連接。如果重復(fù)的連接都是請求 /,就知道了瀏覽器嘗試重復(fù)獲取 / 因為它沒有從程序得到響應(yīng)。
拆開請求數(shù)據(jù)來理解瀏覽器向程序請求了什么。
HTTP 是一個基于文本的協(xié)議,同時一個請求有如下格式:
Method Request-URI HTTP-Version CRLF
headers CRLF
message-body
第一行叫做 請求行(request line),它存放了客戶端請求了什么的信息。請求行的第一部分是所使用的 method,比如 GET
或 POST
,這描述了客戶端如何進(jìn)行請求。這里客戶端使用了 GET
請求。
請求行接下來的部分是 /,它代表客戶端請求的 統(tǒng)一資源標(biāo)識符(Uniform Resource Identifier,URI) —— URI 大體上類似,但也不完全類似于 URL(統(tǒng)一資源定位符,Uniform Resource Locators)。URI 和 URL 之間的區(qū)別對于本章的目的來說并不重要,不過 HTTP 規(guī)范使用術(shù)語 URI,所以這里可以簡單的將 URL 理解為 URI。
最后一部分是客戶端使用的HTTP版本,然后請求行以 CRLF序列 (CRLF代表回車和換行,carriage return line feed,這是打字機時代的術(shù)語?。┙Y(jié)束。CRLF序列也可以寫成\r\n
,其中\r
是回車符,\n
是換行符。 CRLF序列將請求行與其余請求數(shù)據(jù)分開。 請注意,打印CRLF時,我們會看到一個新行,而不是\r\n
。
觀察目前運行程序所接收到的數(shù)據(jù)的請求行,可以看到 GET
是 method,/ 是請求 URI,而 HTTP/1.1
是版本。
從 Host:
開始的其余的行是 headers;GET
請求沒有 body。
如果你希望的話,嘗試用不同的瀏覽器發(fā)送請求,或請求不同的地址,比如 127.0.0.1:7878/test
,來觀察請求數(shù)據(jù)如何變化。
現(xiàn)在我們知道了瀏覽器請求了什么。讓我們返回一些數(shù)據(jù)!
我們將實現(xiàn)在客戶端請求的響應(yīng)中發(fā)送數(shù)據(jù)的功能。響應(yīng)有如下格式:
HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body
第一行叫做 狀態(tài)行(status line),它包含響應(yīng)的 HTTP 版本、一個數(shù)字狀態(tài)碼用以總結(jié)請求的結(jié)果和一個描述之前狀態(tài)碼的文本原因短語。CRLF 序列之后是任意 header,另一個 CRLF 序列,和響應(yīng)的 body。
這里是一個使用 HTTP 1.1 版本的響應(yīng)例子,其狀態(tài)碼為 200,原因短語為 OK,沒有 header,也沒有 body:
HTTP/1.1 200 OK\r\n\r\n
狀態(tài)碼 200 是一個標(biāo)準(zhǔn)的成功響應(yīng)。這些文本是一個微型的成功 HTTP 響應(yīng)。讓我們將這些文本寫入流作為成功請求的響應(yīng)!在 handle_connection
函數(shù)中,我們需要去掉打印請求數(shù)據(jù)的 println!
,并替換為示例 20-3 中的代碼:
文件名: src/main.rs
fn handle_connection(mut stream: TcpStream) {
let mut buffer = [0; 1024];
stream.read(&mut buffer).unwrap();
let response = "HTTP/1.1 200 OK\r\n\r\n";
stream.write(response.as_bytes()).unwrap();
stream.flush().unwrap();
}
示例 20-3: 將一個微型成功 HTTP 響應(yīng)寫入流
新代碼中的第一行定義了變量 response
來存放將要返回的成功響應(yīng)的數(shù)據(jù)。接著,在 response
上調(diào)用 as_bytes
,因為 stream
的 write
方法獲取一個 &[u8]
并直接將這些字節(jié)發(fā)送給連接。
因為 write
操作可能會失敗,所以像之前那樣對任何錯誤結(jié)果使用 unwrap
。同理,在真實世界的應(yīng)用中這里需要添加錯誤處理。最后,flush
會等待并阻塞程序執(zhí)行直到所有字節(jié)都被寫入連接中;TcpStream
包含一個內(nèi)部緩沖區(qū)來最小化對底層操作系統(tǒng)的調(diào)用。
有了這些修改,運行我們的代碼并進(jìn)行請求!我們不再向終端打印任何數(shù)據(jù),所以不會再看到除了 Cargo 以外的任何輸出。不過當(dāng)在瀏覽器中加載 127.0.0.1:7878 時,會得到一個空頁面而不是錯誤。太棒了!我們剛剛手寫了一個 HTTP 請求與響應(yīng)。
讓我們實現(xiàn)不只是返回空頁面的功能。在項目根目錄創(chuàng)建一個新文件,hello.html —— 也就是說,不是在 src
目錄。在此可以放入任何你期望的 HTML;列表 20-4 展示了一個可能的文本:
文件名: 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>
示例 20-4: 一個簡單的 HTML 文件用來作為響應(yīng)
這是一個極小化的 HTML5 文檔,它有一個標(biāo)題和一小段文本。為了在 server 接受請求時返回它,需要如示例 20-5 所示修改 handle_connection
來讀取 HTML 文件,將其加入到響應(yīng)的 body 中,并發(fā)送:
文件名: src/main.rs
use std::fs;
// --snip--
fn handle_connection(mut stream: TcpStream) {
let mut buffer = [0; 1024];
stream.read(&mut buffer).unwrap();
let contents = fs::read_to_string("hello.html").unwrap();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",
contents.len(),
contents
);
stream.write(response.as_bytes()).unwrap();
stream.flush().unwrap();
}
示例 20-5: 將 hello.html 的內(nèi)容作為響應(yīng) body 發(fā)送
在開頭增加了一行來將標(biāo)準(zhǔn)庫中的 File
引入作用域。打開和讀取文件的代碼應(yīng)該看起來很熟悉,因為第十二章 I/O 項目的示例 12-4 中讀取文件內(nèi)容時出現(xiàn)過類似的代碼。
接下來,使用 format!
將文件內(nèi)容加入到將要寫入流的成功響應(yīng)的 body 中。
使用 cargo run
運行程序,在瀏覽器加載 127.0.0.1:7878,你應(yīng)該會看到渲染出來的 HTML 文件!
目前忽略了 buffer
中的請求數(shù)據(jù)并無條件的發(fā)送了 HTML 文件的內(nèi)容。這意味著如果嘗試在瀏覽器中請求 127.0.0.1:7878/something-else 也會得到同樣的 HTML 響應(yīng)。如此其作用是非常有限的,也不是大部分 server 所做的;讓我們檢查請求并只對格式良好(well-formed)的請求 /
發(fā)送 HTML 文件。
目前我們的 web server 不管客戶端請求什么都會返回相同的 HTML 文件。讓我們增加在返回 HTML 文件前檢查瀏覽器是否請求 /,并在其請求任何其他內(nèi)容時返回錯誤的功能。為此需要如示例 20-6 那樣修改 handle_connection
。新代碼接收到的請求的內(nèi)容與已知的 / 請求的一部分做比較,并增加了 if
和 else
塊來區(qū)別處理請求:
文件名: src/main.rs
// --snip--
fn handle_connection(mut stream: TcpStream) {
let mut buffer = [0; 1024];
stream.read(&mut buffer).unwrap();
let get = b"GET / HTTP/1.1\r\n";
if buffer.starts_with(get) {
let contents = fs::read_to_string("hello.html").unwrap();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",
contents.len(),
contents
);
stream.write(response.as_bytes()).unwrap();
stream.flush().unwrap();
} else {
// some other request
}
}
示例 20-6: 匹配請求并區(qū)別處理 / 請求與其他請求
首先,將與 / 請求相關(guān)的數(shù)據(jù)硬編碼進(jìn)變量 get
。因為我們將原始字節(jié)讀取進(jìn)了緩沖區(qū),所以在 get
的數(shù)據(jù)開頭增加 b""
字節(jié)字符串語法將其轉(zhuǎn)換為字節(jié)字符串。接著檢查 buffer
是否以 get
中的字節(jié)開頭。如果是,這就是一個格式良好的 / 請求,也就是 if
塊中期望處理的成功情況,并會返回 HTML 文件內(nèi)容的代碼。
如果 buffer
不 以 get
中的字節(jié)開頭,就說明接收的是其他請求。之后會在 else
塊中增加代碼來響應(yīng)所有其他請求。
現(xiàn)在如果運行代碼并請求 127.0.0.1:7878,就會得到 hello.html 中的 HTML。如果進(jìn)行任何其他請求,比如 127.0.0.1:7878/something-else,則會得到像運行示例 20-1 和 20-2 中代碼那樣的連接錯誤。
現(xiàn)在向示例 20-7 的 else
塊增加代碼來返回一個帶有 404 狀態(tài)碼的響應(yīng),這代表了所請求的內(nèi)容沒有找到。接著也會返回一個 HTML 向瀏覽器終端用戶表明此意:
文件名: src/main.rs
// --snip--
} else {
let status_line = "HTTP/1.1 404 NOT FOUND";
let contents = fs::read_to_string("404.html").unwrap();
let response = format!(
"{}\r\nContent-Length: {}\r\n\r\n{}",
status_line,
contents.len(),
contents
);
stream.write(response.as_bytes()).unwrap();
stream.flush().unwrap();
}
示例 20-7: 對于任何不是 / 的請求返回 404
狀態(tài)碼的響應(yīng)和錯誤頁面
這里,響應(yīng)的狀態(tài)行有狀態(tài)碼 404 和原因短語 NOT FOUND
。仍然沒有返回任何 header,而其 body 將是 404.html 文件中的 HTML。需要在 hello.html 同級目錄創(chuàng)建 404.html 文件作為錯誤頁面;這一次也可以隨意使用任何 HTML 或使用示例 20-8 中的示例 HTML:
文件名: 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>
示例 20-8: 任何 404 響應(yīng)所返回錯誤頁面內(nèi)容樣例
有了這些修改,再次運行 server。請求 127.0.0.1:7878 應(yīng)該會返回 hello.html 的內(nèi)容,而對于任何其他請求,比如 127.0.0.1:7878/foo,應(yīng)該會返回 404.html 中的錯誤 HTML!
目前 if
和 else
塊中的代碼有很多的重復(fù):他們都讀取文件并將其內(nèi)容寫入流。唯一的區(qū)別是狀態(tài)行和文件名。為了使代碼更為簡明,將這些區(qū)別分別提取到一行 if
和 else
中,對狀態(tài)行和文件名變量賦值;然后在讀取文件和寫入響應(yīng)的代碼中無條件的使用這些變量。重構(gòu)后取代了大段 if
和 else
塊代碼后的結(jié)果如示例 20-9 所示:
文件名: src/main.rs
// --snip--
fn handle_connection(mut stream: TcpStream) {
// --snip--
let (status_line, filename) = if buffer.starts_with(get) {
("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 response = format!(
"{}\r\nContent-Length: {}\r\n\r\n{}",
status_line,
contents.len(),
contents
);
stream.write(response.as_bytes()).unwrap();
stream.flush().unwrap();
}
示例 20-9: 重構(gòu)使得 if
和 else
塊中只包含兩個情況所不同的代碼
現(xiàn)在 if
和 else
塊所做的唯一的事就是在一個元組中返回合適的狀態(tài)行和文件名的值;接著使用第十八章講到的使用模式的 let
語句通過解構(gòu)元組的兩部分為 filename
和 header
賦值。
之前讀取文件和寫入響應(yīng)的冗余代碼現(xiàn)在位于 if
和 else
塊之外,并會使用變量 status_line
和 filename
。這樣更易于觀察這兩種情況真正有何不同,還意味著如果需要改變?nèi)绾巫x取文件或?qū)懭腠憫?yīng)時只需要更新一處的代碼。示例 20-9 中代碼的行為與示例 20-8 完全一樣。
好極了!我們有了一個 40 行左右 Rust 代碼的小而簡單的 server,它對一個請求返回頁面內(nèi)容而對所有其他請求返回 404 響應(yīng)。
目前 server 運行于單線程中,它一次只能處理一個請求。讓我們模擬一些慢請求來看看這如何會成為一個問題,并進(jìn)行修復(fù)以便 server 可以一次處理多個請求。
更多建議: