构建单线程 Web 服务器
我们将从一个可工作的单线程 Web 服务器开始。在开始之前, 让我们快速了解一下构建 Web 服务器涉及的协议。这些协议的细节超出了 本书的范围,但简要介绍可以给你提供所需的信息。
Web 服务器涉及的两个主要协议是超文本传输协议 (HTTP)和传输控制协议 (TCP)。这两个协议 都是请求-响应协议,即由客户端发起请求,服务器监听请求并向客户端提供响应。 请求和响应的内容由协议定义。
TCP 是较低层的协议,它描述信息如何从一台服务器传输到另一台服务器的细节, 但不指定信息的内容。HTTP 构建在 TCP 之上,定义了请求和 响应的内容。从技术上讲,HTTP 可以与其他协议一起使用,但在 绝大多数情况下,HTTP 通过 TCP 发送数据。我们将处理 TCP 与 HTTP 请求和响应的原始字节。
监听 TCP 连接
我们的 Web 服务器需要监听 TCP 连接,所以这是我们要处理的第一部分。
标准库提供了一个 std::net 模块可以让我们完成这项工作。
让我们按照惯常的方式创建一个新项目:
$ cargo new hello
Created binary (application) `hello` project
$ cd hello
现在将示例 21-1 中的代码输入 src/main.rs 开始。这段代码将
在本地地址 127.0.0.1:7878 上监听传入的 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,我们可以监听地址
127.0.0.1:7878 上的 TCP 连接。在该地址中,冒号之前的部分是一个 IP 地址,
代表你的计算机(这在每台计算机上都是相同的,并不
特指作者使用的计算机),而 7878 是端口。我们
选择这个端口有两个原因:HTTP 通常不会使用这个端口,因此
我们的服务器不太可能与你机器上可能运行的任何其他 Web 服务器
发生冲突;另外 7878 是在电话键盘上拼出 rust 的数字。
在这个场景中,bind 函数的作用类似于 new 函数,它
会返回一个新的 TcpListener 实例。该函数之所以叫 bind,
是因为在网络编程中,连接到一个端口进行监听被称为“绑定
到端口”。
bind 函数返回一个 Result<T, E>,这表明绑定
有可能失败,例如,如果我们运行了程序的两个
实例,就会有两个程序监听同一个端口。因为我们
编写的只是一个用于学习的基础服务器,所以不用担心
处理这类错误;相反,我们使用 unwrap 在
发生错误时停止程序。
TcpListener 的 incoming 方法返回一个迭代器,它为我们提供
一系列流(更具体地说,是 TcpStream 类型的流)。单个
流(stream)代表客户端与服务器之间的一个打开的连接。
连接(connection)是完整的请求和响应过程的名称,在此过程中
客户端连接到服务器,服务器生成响应,然后服务器
关闭连接。因此,我们将从 TcpStream 中读取内容,以查看
客户端发送了什么,然后将我们的响应写入流中,把数据发送回
客户端。总体而言,这个 for 循环将依次处理每个连接,
并生成一系列流供我们处理。
目前,我们对流的处理包括调用 unwrap,以便在
流出现任何错误时终止程序;如果没有任何错误,
程序会打印一条消息。我们将在下一个示例中为成功情况
添加更多功能。当客户端连接到服务器时,我们可能会从 incoming 方法
收到错误,原因是我们实际上并不是在遍历
连接,而是在遍历连接尝试。连接
可能由于多种原因不成功,其中许多原因与
操作系统相关。例如,许多操作系统对它们可以支持的
同时打开的连接数有限制;超出该数量的新连接
尝试将产生错误,直到一些打开的
连接被关闭。
让我们试着运行这段代码!在终端中调用 cargo run,然后在
Web 浏览器中加载 127.0.0.1:7878。浏览器应该会显示一条错误消息,
比如“Connection reset”(连接已重置),因为服务器目前没有发回任何
数据。但当你查看终端时,应该会看到在浏览器连接到服务器时
打印的几条消息!
Running `target/debug/hello`
Connection established!
Connection established!
Connection established!
有时你会看到一次浏览器请求打印了多条消息;原因 可能是浏览器在请求页面的同时,还请求了其他资源,比如 出现在浏览器标签页中的 favicon.ico 图标。
也可能是因为服务器没有返回任何数据,浏览器正在尝试多次
连接服务器。当 stream 在循环结束处离开作用域并被丢弃时,连接
会作为 drop 实现的一部分被关闭。浏览器有时会通过重试来
处理关闭的连接,因为问题可能是暂时的。
浏览器有时也会在不发送任何请求的情况下打开多个到服务器的连接, 这样如果它们稍后发送请求,这些请求就能更快完成。 当这种情况发生时,我们的服务器会看到每个连接, 无论该连接上是否有任何请求。例如,许多基于 Chrome 的 浏览器版本都会这样做;你可以通过使用隐私浏览模式或 使用其他浏览器来禁用该优化。
重要的是,我们已经成功获取了到 TCP 连接的句柄!
当你运行完特定版本的代码后,记得按 ctrl-C
来停止程序。然后,在每次做出代码修改之后,通过调用 cargo run 命令
重启程序,以确保你运行的是最新的代码。
读取请求
让我们实现从浏览器读取请求的功能!为了
将「先获取连接」和「再对连接采取某些操作」这两件事的关注点
分开,我们将新建一个用于处理连接的函数。在
这个新的 handle_connection 函数中,我们将从 TCP 流中读取数据并
将其打印出来,以便查看浏览器发送的数据。将代码
修改为示例 21-2 的样子。
use std::{
io::{BufReader, prelude::*},
net::{TcpListener, 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 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:#?}");
}
TcpStream 读取数据并打印我们将 std::io::BufReader 和 std::io::prelude 引入作用域,以获得
让我们能够对流进行读写的 trait 和类型。在 main 函数的
for 循环中,我们不再打印表示已建立连接的消息,
而是调用新的 handle_connection 函数,并将
stream 传递给它。
在 handle_connection 函数中,我们创建了一个新的 BufReader 实例,它
包装了对 stream 的引用。BufReader 通过为我们管理
对 std::io::Read trait 方法的调用来增加缓冲。
我们创建了一个名为 http_request 的变量,来收集
浏览器发送到我们服务器的请求的各行。我们通过添加 Vec<_> 类型注解
来表明希望将这些行收集到一个向量中。
BufReader 实现了 std::io::BufRead trait,它提供了 lines
方法。lines 方法通过每当遇到换行
字节时切分数据流,返回一个 Result<String, std::io::Error> 的迭代器。为了得到每个 String,我们对每个 Result 进行 map 和 unwrap。如果
数据不是合法的 UTF-8,或者从流中读取时出现问题,
Result 可能是错误值。同样,生产级程序应该更优雅地
处理这些错误,但为了简单起见,我们选择在出错的情况下停止
程序。
浏览器通过连续发送两个换行 符来标识 HTTP 请求的结束,因此为了从流中获取一个请求,我们逐行读取,直到 得到一个空字符串的行。将各行收集到 向量之后,我们使用漂亮的 debug 格式化将它们打印出来,以便 查看 Web 浏览器正在向我们服务器发送的指令。
让我们试试这段代码!启动程序并再次在 Web 浏览器中发起请求 。请注意,我们在浏览器中仍然会得到一个错误页面,但 程序在终端中的输出现在将类似于这样:
$ cargo run
Compiling hello v0.1.0 (file:///projects/hello)
Finished `dev` profile [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 (Macintosh; Intel Mac OS X 10.15; rv:99.0) Gecko/20100101 Firefox/99.0",
"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language: en-US,en;q=0.5",
"Accept-Encoding: gzip, deflate, br",
"DNT: 1",
"Connection: keep-alive",
"Upgrade-Insecure-Requests: 1",
"Sec-Fetch-Dest: document",
"Sec-Fetch-Mode: navigate",
"Sec-Fetch-Site: none",
"Sec-Fetch-User: ?1",
"Cache-Control: max-age=0",
]
根据你使用的浏览器,输出可能会略有不同。既然
我们正在打印请求数据,就可以通过查看请求第一行中
GET 之后的路径,来了解为什么会从一个浏览器请求中得到多个连接
。如果重复的连接都在请求 /,我们就知道
浏览器正在尝试重复获取 /,因为它没有从
我们的程序中得到响应。
让我们拆解这些请求数据,以了解浏览器在向 我们的程序请求什么。
进一步观察 HTTP 请求
HTTP 是一个基于文本的协议,请求采用如下格式:
Method Request-URI HTTP-Version CRLF
headers CRLF
message-body
第一行是请求行(request line),它保存了客户端正在请求什么的信息。
请求行的第一部分指明了所使用的方法,比如 GET 或 POST,它描述了
客户端是如何发出这个请求的。我们的客户端使用了 GET 请求,这意味着
它在请求信息。
请求行的下一部分是 /,它指明了客户端请求的统一资源 标识符 (URI):URI 与统一资源定位符 (URL)几乎相同, 但并不完全一样。URI 与 URL 之间的区别对于本章的目的来说 并不重要,但 HTTP 规范使用了 URI 这个术语,所以这里在 心中把 URI 替换成 URL 即可。
最后一部分是客户端使用的 HTTP 版本,然后请求行
以 CRLF 序列结尾。(CRLF 代表回车(carriage return)和换行(line feed),
这是打字机时代的术语!)CRLF 序列也可以写成
\r\n,其中 \r 是回车符,\n 是换行符。
CRLF 序列将请求行与请求数据的其余部分分隔开。
请注意,当 CRLF 被打印时,我们看到的是另起一行,而不是 \r\n。
观察一下目前为止我们运行程序收到的请求行数据,
可以看到 GET 是方法,/ 是请求 URI,HTTP/1.1 是版本。
在请求行之后,从 Host: 开始的其余各行都是
请求头(header)。GET 请求没有请求体。
试着从另一个浏览器发出请求,或者请求一个不同的 地址,比如 127.0.0.1:7878/test,看看请求数据会如何变化。
现在我们已经知道了浏览器在请求什么,让我们回发一些数据吧!
编写响应
我们将实现向客户端请求发送数据作为响应。 响应具有以下格式:
HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body
第一行是状态行(status line),它包含响应中使用的 HTTP 版本、一个概括请求结果的数字状态码,以及 一个提供状态码文本描述的原因短语。在 CRLF 序列之后是任意头部、另一个 CRLF 序列,以及 响应的消息体。
下面是一个使用 HTTP 1.1 版本、状态码为 200、原因短语为 OK、没有头部、也没有消息体的响应示例:
HTTP/1.1 200 OK\r\n\r\n
状态码 200 是标准的成功响应。这段文本是一个极小的
成功 HTTP 响应。让我们把它写入流中,作为对
成功请求的响应!从 handle_connection 函数中,移除
用于打印请求数据的 println!,并用示例
21-3 中的代码替换它。
use std::{
io::{BufReader, prelude::*},
net::{TcpListener, 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 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();
}
第一处新增的代码定义了持有成功
消息数据的 response 变量。然后,我们对 response 调用 as_bytes,将
字符串数据转换为字节。stream 上的 write_all 方法接受一个 &[u8],并
将这些字节直接发送到连接中。因为 write_all
操作可能失败,所以我们像之前一样对任何错误结果使用 unwrap。同样,
在真实的应用中,你应该在这里添加错误处理。
有了这些修改,让我们运行代码并发起请求。我们不再 向终端打印任何数据,因此除了 Cargo 的输出之外,我们不会看到任何其他输出。当你在 Web 浏览器中加载 127.0.0.1:7878 时, 你应该得到一个空白页面而不是错误页面。你刚刚手工编写了接收 HTTP 请求并发送响应的代码!
返回真正的 HTML
让我们实现返回的不仅仅是空白页面的功能。在 项目根目录下(而不是 src 目录中)创建新文件 hello.html。你可以输入任何你想要的 HTML;示例 21-4 展示了其中一种 可能。
<!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>
这是一个带有一个标题和一些文本的最小化 HTML5 文档。为了在
服务器收到请求时返回它,我们将按照
示例 21-5 所示修改 handle_connection,读取该 HTML 文件,将其作为消息体
添加到响应中,然后发送出去。
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, TcpStream},
};
// --snip--
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 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();
}
我们在 use 语句中添加了 fs,将标准库的
文件系统模块引入作用域。将文件内容读取为
字符串的代码应该看起来很熟悉;我们在第 12 章的 I/O
项目中读取文件内容时使用过它,见示例 12-4。
接下来,我们使用 format! 将文件的内容添加为成功
响应的消息体。为了确保响应是合法的 HTTP 响应,我们添加了 Content-Length 头部,
它被我们响应体的大小所赋值——在本例中就是
hello.html 的大小。
用 cargo run 运行这段代码,并在浏览器中加载 127.0.0.1:7878;你
应该会看到渲染出来的 HTML!
目前,我们忽略了 http_request 中的请求数据,只是无条件地
发回 HTML 文件的内容。这意味着如果你在浏览器中尝试
请求 127.0.0.1:7878/something-else,你仍然会得到
相同的 HTML 响应。目前,我们的服务器非常有限,
不能做大多数 Web 服务器所做的事情。我们希望根据请求
定制响应,并且只对格式良好的 / 请求发回 HTML 文件。
校验请求并有选择地响应
目前,无论
客户端请求什么,我们的 Web 服务器都会返回文件中的 HTML。让我们添加功能,在
返回 HTML 文件之前检查浏览器是否
在请求 /,并在
浏览器请求任何其他内容时返回错误。为此,我们需要修改 handle_connection,
如示例 21-6 所示。这段新代码将收到的请求
内容与我们已知的 / 请求的样子进行检查,并添加 if 和
else 代码块来区别对待不同的请求。
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, 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);
}
}
// --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 请求的第一行,因此
不是将整个请求读入一个向量,而是调用 next 从
迭代器中获取第一项。第一个 unwrap 处理 Option,并
在迭代器没有元素时停止程序。第二个 unwrap 处理
Result,其效果与示例 21-2 中添加的 map 中的
unwrap 相同。
接下来,我们检查 request_line 是否等于对 / 路径的 GET 请求的
请求行。如果是,if 代码块就返回我们的
HTML 文件的内容。
如果 request_line 不等于对 / 路径的 GET 请求,
这意味着我们收到了某个其他的请求。稍后我们将在 else 代码块中
添加代码来响应所有其他请求。
现在运行这段代码并请求 127.0.0.1:7878;你应该会得到 hello.html 中的 HTML。如果你发起任何其他请求,比如 127.0.0.1:7878/something-else,你会得到类似运行示例 21-1 和示例 21-2 中的代码时看到的连接错误。
现在让我们将示例 21-7 中的代码添加到 else 代码块中,
以返回状态码为 404 的响应,这表示找不到请求的内容。
我们还会返回一些 HTML,用于在浏览器中渲染页面,
向最终用户展示响应。
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, 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 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();
// --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、原因短语为
NOT FOUND 的状态行。响应的消息体将是文件 404.html 中的 HTML。
你需要在 hello.html 旁边创建一个 404.html 文件用于错误
页面;同样,你可以随意使用任何你想要的 HTML,或者使用示例
21-8 中的示例 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>
有了这些修改,再次运行你的服务器。请求 127.0.0.1:7878 应该会 返回 hello.html 的内容,而任何其他请求,比如 127.0.0.1:7878/foo,应该会返回 404.html 中的错误 HTML。
重构
目前,if 和 else 代码块中有大量重复:它们
都在读取文件并将文件内容写入流。唯一的
区别是状态行和文件名。让我们把这些差异提取到独立的
if 和 else 行中,使代码更简洁,这些行
将状态行和文件名的值赋给变量;
然后我们就可以在读取文件和写入响应的代码中无条件地使用
这些变量。示例 21-9 展示了替换
大段 if 和 else 代码块之后得到的代码。
use std::{
fs,
io::{BufReader, prelude::*},
net::{TcpListener, 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);
}
}
// --snip--
fn handle_connection(mut stream: TcpStream) {
// --snip--
let buf_reader = BufReader::new(&stream);
let request_line = buf_reader.lines().next().unwrap().unwrap();
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();
}
if 和 else 代码块,使其只包含两种情况之间不同的代码现在 if 和 else 代码块只在一个元组中返回
状态行和文件名的相应值;然后我们如第 19 章所讨论的那样,
在 let 语句中使用模式通过解构将这两
个值赋给 status_line 和 filename。
之前重复的代码现在在 if 和 else 代码块之外,并
使用 status_line 和 filename 变量。这使得更容易看出
两种情况之间的区别,而且意味着如果我们想改变文件读取和响应写入
的工作方式,只需要在一个地方
更新代码。示例 21-9 中代码的行为将与
示例 21-7 中的行为相同。
太棒了!我们现在用大约 40 行 Rust 代码实现了一个简单的 Web 服务器, 它能对一个请求响应一个内容页面,并对所有其他 请求响应 404。
目前,我们的服务器在单个线程中运行,这意味着它一次只能处理一个 请求。让我们通过模拟一些 慢请求来研究这会如何成为问题。然后,我们将修复它,使我们的服务器能够一次处理多个 请求。