使用 Rust 建立網頁伺服器(一):單執行緒伺服器

前言

本文為「The Rust Programming Language」語言指南的學習筆記。

做法

網頁伺服器會涉及到兩大協定,分別是超文本傳輸協定(HTTP)與傳輸控制協定(TCP)。

TCP 是個較底層的協定並描述資訊如何從一個伺服器傳送到另一個伺服器的細節,但是它並不指定資訊內容為何。HTTP 建立在 TCP 之上並定義請求與回應的內容。技術上來說,HTTP 是可以與其他協定組合的,但是對大多數場合中,HTTP 主要還是透過 TCP 來傳送資訊。

首先,建立專案。

1
cargo new rust-web-server

修改 main.rs 檔,網頁伺服器需要監聽一個 TCP 連線。

1
2
3
4
5
6
7
8
9
10
11
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!("連線建立!");
}
}

透過 TcpListener 可以監聽 127.0.0.1:7878 位址上的 TCP 連線。在此情境中的 bind 函式與常見的 new 函式行為類似,這會回傳一個新的 TcpListener 實例。此函式會叫做 bind 的原因是因為在網際網路中,連接一個通訊埠並監聽就稱為「綁定(bind)通訊埠」。

bind 函式會回傳 Result<T, E>,也就是說綁定可能會失敗。如果遇到錯誤的話,採用 unwrap 來停止程式。

TcpListenerincoming 方法會回傳一個疊代器,給予一連串的流(更準確地來說是 TcpStream 型別的流)。一個流代表的是客戶端與伺服器之間的開啟的連線。而連線(connection)指的是整個請求與回應的過程,這之中客戶端會連線至伺服器、伺服器會產生回應,然後伺服器會關閉連線。這樣一來,TcpStream 就能讀取自己的內容來看看客戶端傳送了什麼,然後讓回應寫入流之中。整體來說,此 for 迴圈會依序遍歷每個連線,然後產生一系列的流能夠加以處理。

目前處理流的方式包含呼叫 unwrap,這當流有任何錯誤時,就會結束程式。如果沒有任何錯誤的話,程式就會顯示訊息。當客戶端連接伺服器時,可能會從 incoming 方法取得錯誤的原因,是因為實際上不是在遍歷每個連線。反之,是在遍歷連線嘗試。連線不成功可能有很多原因,而其中許多都與作業系統有關。舉例來說,許多作業系統都會限制它們能支援的同時連線開啟次數,當新的連線超出此範圍時就會產生錯誤,直到有些連線被關閉為止。

執行程式,並用瀏覽器訪問 http://127.0.0.1:7878,會看到一些訊息。

1
2
3
連線建立!
連線建立!
連線建立!

從一次的瀏覽器請求會看到數個訊息顯示出來,原因很可能是因為瀏覽器除了請求頁面內容以外,也嘗試請求其他資源,像是出現在瀏覽器分頁上的 favicon.ico 圖示。

接著,需要實際讀取流。在此分成兩個步驟:首先,在堆疊上宣告 buffer 來儲存讀取到的資料。緩衝區(buffer)的大小為 1024 位元組,這足以儲存基本請求的資料。如果想要處理任意大小的請求,緩衝區管理會變得更複雜,在此先以簡單的方式處理。將緩衝區傳至 stream.read,這會讀取 TcpStream 的位元組並置入緩衝區中。

再來,將緩衝區的位元組轉換成字串並顯示出來。String::from_utf8_lossy 函式接收一個 &[u8] 並以此產生 String。名稱中的「lossy」指的是此函式的行為,當它看到無效的 UTF-8 序列時,它會將其替換成「�」,也就是 U+FFFD REPLACEMENT CHARACTER

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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!("請求:{}", String::from_utf8_lossy(&buffer[..]));
}

HTTP 是基於文字的協定,而請求格式如下:

1
2
3
4
Method Request-URI HTTP-Version CRLF
headers CRLF
CRLF
message-body

第一行是請求行(request line)並持有客戶端想請求什麼的資訊。請求行的第一個部分代表著想使用的方法(method),像是 GETPOST

請求行的下一個部分是 /,這代表客戶端請求的統一資源標誌符(URI),URI 絕大多數(但不是絕對)就等於統一資源定位符(URL)。

最後一個部分是客戶端使用的 HTTP 版本,然後請求行最後以 CRLF 序列做結尾,CRLF 指的是回車(carriage return)與換行(line feed),這是打字機時代的術語!也可以寫成 \r\n\r 指的是回車,而 \n 指的是換行。CRLF 序列將請求行與剩餘的請求資料區隔開來。注意到當 CRLF 印出時,會看到的是新的一行而不是 \r\n

狀態碼 200 是標準的成功回應。將此寫入流中作為對成功請求的回應。

1
2
3
4
5
6
7
8
9
10
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();
}

建立 index.html 檔。

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
Home
</body>
</html>

建立 404.html 檔。

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
Page Not Found
</body>
</html>

修改 handle_connection 來讀取 HTML 檔案、加進回應本體中然後傳送出去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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();
}

加個功能來在回傳 HTML 檔案前檢查瀏覽器請求是否為 /,如果瀏覽器請求的是其他的話就回傳錯誤。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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 {
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();
}
}

最後,做一些重構。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
use std::{
fs,
io::{Read, Write},
net::{TcpListener, TcpStream},
};

fn main() {
let listener = TcpListener::bind("localhost: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[..]));

let get = b"GET / HTTP/1.1\r\n";
let (status_line, filename) = if buffer.starts_with(get) {
("HTTP/1.1 200 OK", "index.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();
}

注意,目前此伺服器只能跑在單一執行緒,這意味著它一次只能處理一個請求。

程式碼

參考資料