构建单线程 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
现在在 src/main.rs 中输入清单 21-1 中的代码以开始。此代码将监听本地地址 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>
,这表明绑定可能会失败。例如,连接到端口 80 需要管理员权限(非管理员只能监听高于 1023 的端口),因此如果我们尝试在没有管理员身份的情况下连接到端口 80,绑定将无法工作。例如,如果我们运行我们程序的两个实例,从而有两个程序监听同一个端口,绑定也无法工作。因为我们正在编写一个仅用于学习目的的基本服务器,所以我们不会担心处理这些类型的错误;相反,我们使用 unwrap
在发生错误时停止程序。
TcpListener
上的 incoming
方法返回一个迭代器,它为我们提供一系列流(更具体地说,是 TcpStream
类型的流)。单个流表示客户端和服务器之间的开放连接。连接是客户端连接到服务器、服务器生成响应以及服务器关闭连接的完整请求和响应过程的名称。因此,我们将从 TcpStream
读取以查看客户端发送的内容,然后将我们的响应写入流以将数据发送回客户端。总的来说,这个 for
循环将依次处理每个连接,并生成一系列流供我们处理。
目前,我们对流的处理包括调用 unwrap
以在流有任何错误时终止我们的程序;如果没有错误,程序会打印一条消息。我们将在下一个清单中为成功案例添加更多功能。当客户端连接到服务器时,我们可能会从 incoming
方法收到错误的原因是我们实际上没有迭代连接。相反,我们正在迭代连接尝试。连接可能由于多种原因而未成功,其中许多原因特定于操作系统。例如,许多操作系统对它们可以支持的并发打开连接数有限制;超出该数量的新连接尝试将产生错误,直到一些打开的连接关闭。
让我们尝试运行这段代码!在终端中调用 cargo run
,然后在 Web 浏览器中加载 127.0.0.1:7878。浏览器应显示类似“连接重置”的错误消息,因为服务器当前未发送任何数据。但是当您查看您的终端时,您应该会看到当浏览器连接到服务器时打印的多条消息!
Running `target/debug/hello`
Connection established!
Connection established!
Connection established!
有时,您会看到为一个浏览器请求打印的多条消息;原因可能是浏览器正在请求页面以及其他资源,例如浏览器选项卡中显示的 favicon.ico 图标。
也可能是浏览器正在尝试多次连接到服务器,因为服务器没有响应任何数据。当 stream
超出范围并在循环结束时被丢弃时,连接会作为 drop
实现的一部分被关闭。浏览器有时会通过重试来处理关闭的连接,因为问题可能是暂时的。重要的因素是我们已经成功地获得了 TCP 连接的句柄!
请记住在完成运行特定版本的代码后按 ctrl-c 停止程序。然后在您对代码进行每组更改后,通过调用 cargo run
命令重新启动程序,以确保您正在运行最新的代码。
读取请求
让我们实现从浏览器读取请求的功能!为了分离首先获取连接,然后对连接执行某些操作的关注点,我们将启动一个新函数来处理连接。在这个新的 handle_connection
函数中,我们将从 TCP 流读取数据并打印它,以便我们可以看到从浏览器发送的数据。更改代码以使其看起来像清单 21-2。
use std::{
io::{prelude::*, BufReader},
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::prelude
和 std::io::BufReader
引入作用域,以访问允许我们从流中读取和写入流的 traits 和类型。在 main
函数的 for
循环中,我们现在调用新的 handle_connection
函数并将 stream
传递给它,而不是打印一条消息说我们建立了连接。
在 handle_connection
函数中,我们创建一个新的 BufReader
实例,它包装了对 stream
的引用。BufReader
通过为我们管理对 std::io::Read
trait 方法的调用来添加缓冲。
我们创建一个名为 http_request
的变量来收集浏览器发送到我们服务器的请求行。我们通过添加 Vec<_>
类型注释来指示我们想要在一个 vector 中收集这些行。
BufReader
实现了 std::io::BufRead
trait,它提供了 lines
方法。lines
方法通过在看到换行符字节时拆分数据流,返回 Result<String, std::io::Error>
的迭代器。为了获得每个 String
,我们映射并 unwrap
每个 Result
。如果数据不是有效的 UTF-8 或从流中读取时出现问题,则 Result
可能是错误。同样,生产程序应该更优雅地处理这些错误,但为了简单起见,我们选择在错误情况下停止程序。
浏览器通过连续发送两个换行符来表示 HTTP 请求的结束,因此要从流中获取一个请求,我们获取行,直到我们得到一个空字符串的行。一旦我们将这些行收集到 vector 中,我们就会使用漂亮的调试格式打印出来,以便我们可以查看 Web 浏览器发送到我们服务器的指令。
让我们尝试一下这段代码!启动程序并在 Web 浏览器中再次发出请求。请注意,我们仍然会在浏览器中收到错误页面,但我们程序在终端中的输出现在将类似于这样
$ 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 (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
第一行是请求行,其中包含有关客户端请求的信息。请求行的第一部分指示正在使用的方法,例如 GET
或 POST
,它描述了客户端如何发出此请求。我们的客户端使用了 GET
请求,这意味着它正在请求信息。
请求行的下一部分是 /,它指示客户端正在请求的统一资源标识符 (URI):URI 几乎与统一资源定位符 (URL) 相同,但不完全相同。URI 和 URL 之间的差异对于我们在本章中的目的并不重要,但 HTTP 规范使用术语 URI,因此我们可以在此处将 URL mentally 替换为 URI。
最后一部分是客户端使用的 HTTP 版本,然后请求行以 CRLF 序列结束。(CRLF 代表回车和换行,这些是打字机时代的术语!)CRLF 序列也可以写为 \r\n
,其中 \r
是回车,\n
是换行。CRLF 序列将请求行与请求数据的其余部分分隔开。请注意,当打印 CRLF 时,我们看到的是新行开始,而不是 \r\n
。
查看我们从目前运行的程序中收到的请求行数据,我们看到 GET
是方法,/ 是请求 URI,HTTP/1.1
是版本。
在请求行之后,从 Host:
开始的剩余行是标头。GET
请求没有正文。
尝试从其他浏览器发出请求或请求其他地址,例如 127.0.0.1:7878/test,以查看请求数据如何变化。
现在我们知道浏览器在请求什么,让我们发送回一些数据!
编写响应
我们将实现发送数据以响应客户端请求的功能。响应具有以下格式
HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body
第一行是状态行,其中包含响应中使用的 HTTP 版本、总结请求结果的数字状态代码以及提供状态代码文本描述的原因短语。在 CRLF 序列之后是任何标头、另一个 CRLF 序列和响应正文。
这是一个示例响应,它使用 HTTP 版本 1.1,状态代码为 200,OK 原因短语,没有标头,也没有正文
HTTP/1.1 200 OK\r\n\r\n
状态代码 200 是标准的成功响应。文本是一个小的成功 HTTP 响应。让我们将此写入流作为我们对成功请求的响应!从 handle_connection
函数中,删除打印请求数据的 println!
,并将其替换为清单 21-3 中的代码。
第一个新行定义了 response
变量,该变量保存成功消息的数据。然后我们在 response
上调用 as_bytes
以将字符串数据转换为字节。stream
上的 write_all
方法接受 &[u8]
并将这些字节直接向下发送到连接。由于 write_all
操作可能会失败,因此我们像以前一样对任何错误结果使用 unwrap
。同样,在实际应用程序中,您将在此处添加错误处理。
进行这些更改后,让我们运行我们的代码并发出请求。我们不再向终端打印任何数据,因此除了 Cargo 的输出外,我们不会看到任何输出。当您在 Web 浏览器中加载 127.0.0.1:7878 时,您应该会得到一个空白页而不是错误。您刚刚手工编码了接收 HTTP 请求和发送响应!
返回真实的 HTML
让我们实现返回不仅仅是空白页的功能。在您的项目目录的根目录中创建新文件 hello.html,而不是在 src 目录中。您可以输入任何您想要的 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 文件,将其添加到响应中作为正文,然后发送它。
我们已将 fs
添加到 use
语句中,以将标准库的文件系统模块引入作用域。将文件内容读取到字符串的代码应该看起来很熟悉;我们在第 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
代码块以区别对待请求。
我们只打算查看 HTTP 请求的第一行,因此我们没有将整个请求读取到 vector 中,而是调用 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,用于在浏览器中渲染的页面,以向最终用户指示响应。
在这里,我们的响应具有状态行为状态代码 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
代码块后的结果代码。
if
和 else
代码块,使其仅包含两个案例之间不同的代码现在 if
和 else
代码块仅返回状态行和文件名的适当值(在一个元组中);然后我们使用解构将这两个值分配给 status_line
和 filename
,方法是在 let
语句中使用模式,如第 19 章中所述。
先前重复的代码现在在 if
和 else
代码块之外,并使用 status_line
和 filename
变量。这使得更容易看到两个案例之间的差异,并且意味着如果我们想更改文件读取和响应写入的工作方式,我们只有一个地方可以更新代码。清单 21-9 中的代码行为将与清单 21-7 中的代码行为相同。
太棒了!我们现在有一个简单的 Web 服务器,大约 40 行 Rust 代码,它可以响应一个请求并返回页面内容,并以 404 响应响应所有其他请求。
目前,我们的服务器在单线程中运行,这意味着它一次只能处理一个请求。让我们通过模拟一些慢速请求来检查这可能如何成为问题。然后我们将修复它,以便我们的服务器可以一次处理多个请求。