构建单线程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!
。
使用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
,然后在网页浏览器中加载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所示。
我们引入 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请求的结束,因此为了从流中获取一个请求,我们接收行直到我们接收到一个空字符串的行。一旦我们将这些行收集到向量中,我们就会使用漂亮的调试格式打印它们,这样我们就可以查看网页浏览器发送给我们的服务器的指令。
让我们尝试这段代码!启动程序并在网页浏览器中再次发出请求。请注意,我们仍然会在浏览器中看到错误页面,但程序在终端的输出现在将类似于以下内容:
$ 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 这个术语,因此我们可以在这里将 URI 理解为 URL。
最后一部分是客户端使用的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 的输出外,我们不会看到任何其他输出。当您在网页浏览器中加载 127.0.0.1:7878 时,您应该会得到一个空白页面而不是错误。您刚刚手动编写了接收 HTTP 请求和发送响应的代码!
返回真实的 HTML
让我们实现返回不仅仅是空白页面的功能。在你的项目目录的根目录下,而不是在 src 目录中,创建新文件 hello.html。你可以输入任何你想要的 HTML;清单 21-4 显示了一种可能性。
这是一个包含标题和一些文本的最小 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请求的第一行,因此我们调用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。
有了这些更改,再次运行您的服务器。请求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
块只返回状态行和文件名的适当值的元组;然后我们使用解构将这两个值分配给 status_line
和 filename
,使用 let
语句中的模式,如第 19 章所述。
先前复制的代码现在位于 if
和 else
块之外,并使用了 status_line
和 filename
变量。这使得更容易看到两种情况之间的差异,并且如果我们想要更改文件读取和响应写入的方式,我们只需要在一个地方更新代码。列表 21-9 中的代码行为将与列表 21-7 中的相同。
太棒了!我们现在有了一个大约40行Rust代码的简单Web服务器,它对一个请求响应一个页面内容,对所有其他请求响应404。
目前,我们的服务器在单线程中运行,这意味着它一次只能处理一个请求。让我们通过模拟一些慢请求来 examine 这可能会如何成为一个问题。然后我们将修复它,使我们的服务器能够同时处理多个请求。