构建单线程 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* 中输入代码清单 20-1 中的代码以开始。此代码将在本地地址 127.0.0.1:7878
侦听传入的 TCP 流。当它收到传入流时,它将打印 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!"); } }
使用 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 流中读取数据并将其打印出来,以便我们可以看到从浏览器发送的数据。将代码更改为类似于代码清单 20-2。
文件名:src/main.rs
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(&mut stream); let http_request: Vec<_> = buf_reader .lines() .map(|result| result.unwrap()) .take_while(|line| !line.is_empty()) .collect(); println!("Request: {http_request:#?}"); }
我们将 std::io::prelude
和 std::io::BufReader
引入作用域,以访问允许我们从流中读取和写入流的特征和类型。在 main
函数的 for
循环中,我们不再打印表示已建立连接的消息,而是调用新的 handle_connection
函数并将 stream
传递给它。
在 handle_connection
函数中,我们创建了一个新的 BufReader
实例,它包装了对 stream
的可变引用。BufReader
通过为我们管理对 std::io::Read
特征方法的调用来添加缓冲。
我们创建了一个名为 http_request
的变量来收集浏览器发送到我们服务器的请求行。我们通过添加 Vec<_>
类型注释来指示我们希望在向量中收集这些行。
BufReader
实现了 std::io::BufRead
特征,该特征提供了 lines
方法。lines
方法通过在每次看到换行符字节时拆分数据流来返回 Result<String, std::io::Error>
的迭代器。为了获取每个 String
,我们映射并 unwrap
每个 Result
。如果数据不是有效的 UTF-8 或从流中读取数据时出现问题,则 Result
可能是错误。同样,生产程序应该更优雅地处理这些错误,但为了简单起见,我们选择在错误情况下停止程序。
浏览器通过连续发送两个换行符来表示 HTTP 请求的结束,因此要从流中获取一个请求,我们获取行,直到我们得到一个为空字符串的行。一旦我们将这些行收集到向量中,我们就使用漂亮的调试格式将它们打印出来,以便我们可以查看 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 替换 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!
并将其替换为代码清单 20-3 中的代码。
文件名:src/main.rs
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(&mut 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;代码清单 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>
这是一个最小的 HTML5 文档,带有一个标题和一些文本。为了在收到请求时从服务器返回此内容,我们将修改 handle_connection
,如代码清单 20-5 所示,以读取 HTML 文件,将其作为正文添加到响应中,然后发送它。
文件名:src/main.rs
use std::{ fs, io::{prelude::*, BufReader}, 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(&mut 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
,如代码清单 20-6 所示。这段新代码根据我们对 */ * 请求的了解检查接收到的请求的内容,并添加 if
和 else
块以区别对待请求。
文件名:src/main.rs
use std::{ fs, 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); } } // --snip-- fn handle_connection(mut stream: TcpStream) { let buf_reader = BufReader::new(&mut 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
,其效果与代码清单 20-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*,您将收到类似于运行代码清单 20-1 和 20-2 中的代码时看到的连接错误。
现在,让我们将代码清单 20-7 中的代码添加到 else
块中,以返回状态码为 404 的响应,该响应表示未找到请求的内容。我们还将返回一些 HTML 代码,用于在浏览器中呈现页面,向最终用户指示响应。
文件名:src/main.rs
use std::{ fs, 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(&mut 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,也可以使用代码清单 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>
完成这些更改后,再次运行您的服务器。请求 *127.0.0.1:7878* 应该返回 *hello.html* 的内容,而任何其他请求(例如 *127.0.0.1:7878/foo*)应该返回 *404.html* 中的错误 HTML。
一些重构
目前,if
和 else
块有很多重复:它们都在读取文件并将文件内容写入流。唯一的区别是状态行和文件名。让我们通过将这些差异提取到单独的 if
和 else
行中来使代码更简洁,这些行会将状态行和文件名的值分配给变量;然后,我们可以在代码中无条件地使用这些变量来读取文件并写入响应。代码清单 20-9 显示了替换大型 if
和 else
块后的结果代码。
文件名:src/main.rs
use std::{ fs, 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); } } // --snip-- fn handle_connection(mut stream: TcpStream) { // --snip-- let buf_reader = BufReader::new(&mut 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
块仅在元组中返回状态行和文件名的适当值;然后,我们使用解构,如第 18 章所述,使用 let
语句中的模式将这两个值分配给 status_line
和 filename
。
以前重复的代码现在位于 if
和 else
块之外,并使用 status_line
和 filename
变量。这使得更容易看出两种情况之间的区别,并且这意味着如果我们想改变文件读取和响应写入的工作方式,我们只需要在一个地方更新代码。代码清单 20-9 中的代码的行为将与代码清单 20-7 中的代码相同。
太棒了!我们现在有了一个大约 40 行 Rust 代码的简单 Web 服务器,它可以用一个内容页面响应一个请求,并用 404 响应响应所有其他请求。
目前,我们的服务器在单个线程中运行,这意味着它一次只能服务一个请求。让我们通过模拟一些缓慢的请求来检查这可能会出现什么问题。然后,我们将对其进行修复,以便我们的服务器可以同时处理多个请求。