构建单线程 Web 服务器
我们首先让单线程 Web 服务器正常工作。在我们开始之前, 让我们快速了解一下构建 Web 所涉及的协议 服务器。这些协议的细节不在本书的讨论范围之内,但是 简要概述将为您提供所需的信息。
Web 服务器涉及的两个主要协议是超文本传输 协议 (HTTP) 和传输控制协议 (TCP)。两种协议 是请求-响应协议,这意味着客户端发起请求,服务器侦听请求并向客户端提供响应。这 这些请求和响应的内容由协议定义。
TCP 是描述信息如何的详细信息的较低级别协议 从一个服务器获取到另一个服务器,但未指定该信息是什么。 HTTP 通过定义请求的内容和 反应。从技术上讲,可以将 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 中的代码开始。此代码将
在本地地址侦听传入的 TCP 流。当它
获取传入流,它将打印 .127.0.0.1:7878
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!"); } }
使用 ,我们可以侦听地址 处的 TCP 连接。在地址中,冒号前面的部分是 IP 地址
代表您的计算机(这在每台计算机上都是相同的,但不会
专门表示作者的计算机),并且是端口。我们已经
选择此端口有两个原因: 此端口通常不接受 HTTP,因此
我们的服务器不太可能与您可能拥有的任何其他 Web 服务器冲突
运行,并且 7878 是在电话上打出 Rust 的。TcpListener
127.0.0.1:7878
7878
此方案中的函数的工作方式与函数类似,因为它
将返回一个新实例。调用该函数是因为,在网络中,连接到要侦听的端口称为“绑定
到一个港口。bind
new
TcpListener
bind
该函数返回一个 ,这表明它是
可能会使绑定失败。例如,连接到端口 80 需要
管理员权限(非管理员只能侦听更高的端口
比 1023 多),因此如果我们尝试连接到端口 80 而不成为
administrator,则 binding 不起作用。绑定也不起作用,例如,
如果我们运行程序的两个实例,因此有两个程序侦听
相同的端口。因为我们编写一个基本的服务器只是为了学习目的,所以我们
不会担心处理这些类型的错误;相反,我们习惯于 to
如果发生错误,请停止程序。bind
Result<T, E>
unwrap
方法 on 返回一个迭代器,它为我们提供了一个
流序列(更具体地说,类型为 流 )。单个流表示客户端和服务器之间的开放连接。连接是完整请求和响应过程的名称,其中
客户端连接到服务器,服务器生成响应,服务器
关闭连接。因此,我们将从 中阅读 以查看
客户端发送并将我们的响应写入流,以将数据发送回
客户端。总的来说,这个循环将依次处理每个连接,并且
生成一系列流供我们处理。incoming
TcpListener
TcpStream
TcpStream
for
目前,我们对流的处理包括调用 terminate
如果流有任何错误,我们的程序;如果没有任何错误,则
程序打印一条消息。我们将为 Success case 添加更多功能
下一个列表。我们可能从方法中收到错误的原因
当客户端连接到服务器时,我们实际上并没有迭代
连接。相反,我们正在迭代连接尝试。这
连接不成功的原因有很多,其中许多
特定于作系统。例如,许多作系统对
他们可以支持的同时打开的连接数;新建连接
超出该数量的尝试将产生错误,直到某些打开的
连接已关闭。unwrap
incoming
让我们尝试运行这段代码吧!在终端中调用,然后在 Web 浏览器中加载 127.0.0.1:7878。浏览器应显示错误消息
例如“Connection reset”,因为服务器当前没有发回任何
数据。但是,当您查看终端时,您应该会看到几条消息
是当浏览器连接到服务器时打印的!cargo run
Running `target/debug/hello`
Connection established!
Connection established!
Connection established!
有时,您会看到为一个浏览器请求打印了多条消息;这 原因可能是浏览器正在请求该页面以及 请求其他资源,favicon.ico例如 browser (浏览器) 选项卡。
也可能是浏览器正在尝试连接到服务器多个
次,因为服务器没有响应任何数据。外出时
of 范围,并在循环结束时删除,则连接将关闭为
实现的一部分。浏览器有时会处理已关闭的
连接,因为问题可能是暂时的。重要的
因素是我们已成功获取 TCP 连接的句柄!stream
drop
请记住按 - 当
您已完成运行特定版本的代码。然后重新启动程序
在进行每组代码更改后调用命令
以确保您运行的是最新的代码。ctrlccargo run
读取请求
让我们实现从浏览器读取请求的功能!自
将先获得连接然后再采取一些行动的担忧分开
有了 CONNECTION,我们将启动一个用于处理 CONNECTIONS 的新函数。在
这个新功能,我们将从 TCP 流中读取数据,并且
打印它,以便我们可以看到从浏览器发送的数据。将代码更改为
如示例 20-2 所示。handle_connection
文件名: 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(&stream); let http_request: Vec<_> = buf_reader .lines() .map(|result| result.unwrap()) .take_while(|line| !line.is_empty()) .collect(); println!("Request: {http_request:#?}"); }
我们将 和 带入范围 以获取访问权限
添加到允许我们从流中读取和写入的 trait 和类型。在函数的循环中,而不是打印一条消息,指出我们做了一个
connection,我们现在调用新函数并将 传递给它。std::io::prelude
std::io::BufReader
for
main
handle_connection
stream
在该函数中,我们创建一个新实例,该实例
将对 . 通过管理调用添加缓冲
添加到 trait 方法中。handle_connection
BufReader
stream
BufReader
std::io::Read
我们创建一个名为 的变量来收集请求的行数
浏览器发送到我们的服务器。我们表明我们想要收集这些
行。http_request
Vec<_>
BufReader
实现 trait,该 trait 提供方法。该方法在看到换行符时通过拆分数据流返回一个迭代器
字节。为了得到每个 ,我们映射 和 每个 。如果数据不是有效的 UTF-8 或存在问题,则可能是错误
从流中读取。同样,生产程序应该处理这些错误
更优雅地,但我们选择在
单纯。std::io::BufRead
lines
lines
Result<String, std::io::Error>
String
unwrap
Result
Result
浏览器通过发送两个换行符来表示 HTTP 请求的结束 字符,因此要从流中获取一个请求,我们采用行 until 我们得到一行是空字符串。将行收集到 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",
]
根据您的浏览器,您可能会得到略有不同的输出。既然
我们正在打印请求数据,我们可以看到为什么我们会获得多个连接
通过查看第一行中 next 的路径,从一个浏览器请求
请求。如果重复的连接都是请求 /,则我们知道
浏览器正在尝试获取 / 重复,因为它没有得到响应
从我们的计划。GET
让我们分解这些请求数据,以了解浏览器的要求 我们的计划。
仔细观察 HTTP 请求
HTTP 是一种基于文本的协议,请求采用以下格式:
Method Request-URI HTTP-Version CRLF
headers CRLF
message-body
第一行是请求行,其中包含有关
客户端正在请求。请求行的第一部分指示正在使用的方法,例如 or ,它描述客户端如何进行
这个请求。我们的客户使用了一个请求,这意味着它正在请求
信息。GET
POST
GET
请求行的下一部分是 /,它表示 Uniform Resource 客户端请求的标识符 (URI):URI 几乎但不完全是 与统一资源定位符 (URL) 相同。URI 之间的区别 和 URLs 对于我们在本章中的目的并不重要,但 HTTP 规范 使用术语 URI,因此我们可以在这里将 URL 替换为 URI。
最后一部分是客户端使用的 HTTP 版本,然后是请求行
以 CRLF 序列结尾。(CRLF 代表回车和换行,
这些是打字机时代的术语!CRLF 序列也可以是
编写为 ,其中 是回车符,是换行符。这
CRLF 序列将请求行与其余请求数据分开。
请注意,打印 CRLF 时,我们会看到一个新行 start 而不是 。\r\n
\r
\n
\r\n
查看到目前为止我们从运行程序中收到的请求行数据,
我们看到 是方法,/ 是请求 URI,是
版本。GET
HTTP/1.1
在请求行之后,从以后开始的其余行是
头。 请求没有正文。Host:
GET
尝试从其他浏览器发出请求或请求其他浏览器 address,例如 127.0.0.1:7878/test,以查看请求数据的变化情况。
现在我们知道浏览器在请求什么,让我们发回一些数据!
编写响应
我们将实现响应客户端请求发送数据。 响应采用以下格式:
HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body
第一行是状态行,其中包含 response,一个汇总请求结果的数字状态代码,以及 一个 reason 短语,用于提供状态代码的文本描述。在 CRLF 序列是任何标头、另一个 CRLF 序列和 响应。
下面是一个使用 HTTP 版本 1.1 的响应示例,其状态代码为 200,一个 OK 原因短语,没有标题,也没有正文:
HTTP/1.1 200 OK\r\n\r\n
状态代码 200 是标准成功响应。文本很小
成功的 HTTP 响应。让我们将此写入流中,作为我们对
请求成功!从函数中删除打印请求数据的内容,并将其替换为
示例 20-3.handle_connection
println!
文件名: 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(&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(); }
第一个新行定义保存 success 的变量
消息的数据。然后我们调用 our 来转换字符串
data 设置为 bytes。方法 on 接受 a 并发送
这些字节直接通过连接。因为作
可能会失败,我们会像以前一样对任何错误结果使用。同样,在真实的
应用程序,您将在此处添加错误处理。response
as_bytes
response
write_all
stream
&[u8]
write_all
unwrap
通过这些更改,让我们运行代码并发出请求。我们不再是 将任何数据打印到终端,因此我们不会看到除 Cargo 的输出。在 Web 浏览器中加载 127.0.0.1:7878 时,应 获取空白页而不是错误。您刚刚手动编码接收 HTTP 请求并发送响应!
返回真实的 HTML
让我们实现返回多个空白页的功能。创造 新文件hello.html项目目录的根目录中,而不是 src 目录中。您可以输入任何您想要的 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 文档,带有一个标题和一些文本。要返回此
当收到请求时,我们将修改为
如示例 20-5 所示,要读取 HTML 文件,请将其作为正文添加到响应中,
并发送它。handle_connection
文件名: 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(&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(); }
我们已添加到语句中,以引入标准库的
filesystem 模块添加到作用域中。用于将文件内容读取到
字符串应该看起来很熟悉;我们在第 12 章阅读内容时使用了它
示例 12-4 中 I/O 项目的文件。fs
use
接下来,我们使用 将文件的内容添加为成功的正文
响应。为了确保 HTTP 响应有效,我们添加了标头
它被设置为响应正文的大小,在本例中为 .format!
Content-Length
hello.html
在浏览器中运行此代码并加载 127.0.0.1:7878;你
应该会看到你的 HTML 被渲染了!cargo run
目前,我们忽略了 request 数据,只发送
无条件地返回 HTML 文件的内容。这意味着,如果您尝试
在浏览器中请求 127.0.0.1:7878/something-else,您仍然会得到
返回相同的 HTML 响应。目前,我们的服务器非常有限,并且
不执行大多数 Web 服务器执行的作。我们希望自定义我们的响应
,并且仅发回格式正确的 HTML 文件
请求设置为 /。http_request
验证请求并选择性响应
现在,我们的 Web 服务器将返回文件中的 HTML,无论
客户请求。让我们添加功能来检查浏览器是否
在返回 HTML 文件之前请求 /,并在返回 HTML 文件时返回错误(如果
browser 请求任何其他内容。为此,我们需要修改 ,
如示例 20-6 所示。此新代码检查请求的内容
received 根据我们知道的 / 请求 look like 和 adds 和 blocks 来区别对待请求。handle_connection
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(&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 请求的第一行,所以
而不是将整个请求读取到一个 vector 中,我们调用以获取
iterator 中的第一项。第一个处理 和
如果迭代器没有项,则停止程序。第二个处理 and 与添加的
示例 20-2.next
unwrap
Option
unwrap
Result
unwrap
map
接下来,我们检查 the 以查看它是否等于 GET 的请求行
请求添加到 / 路径。如果是这样,则块返回
HTML 文件。request_line
if
如果 不等于 / 路径的 GET 请求,则
表示我们收到了其他请求。我们将向
回应所有其他请求的时刻。request_line
else
现在运行此代码并请求 127.0.0.1:7878;您应该在 hello.html 中获取 HTML。如果您发出任何其他请求,例如 127.0.0.1:7878/something-else,您将收到与您 在运行示例 20-1 和示例 20-2 中的代码时看到。
现在让我们将示例 20-7 中的代码添加到块中以返回响应
状态代码为 404,这表示请求的内容为
未找到。我们还将返回一些 HTML,以便页面在浏览器中呈现
指示对最终用户的响应。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); } } 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 和原因短语 。响应的正文将是文件 404.html 中的 HTML。
您需要在错误hello.html旁边创建一个 404.html 文件
页;同样,请随意使用您想要的任何 HTML 或使用示例 HTML
示例 20-8.NOT FOUND
文件名: 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。
一点重构
目前 and 块有很多重复:它们都是
读取文件并将文件内容写入流。唯一的
区别在于 Status 行和 filename。让我们让代码更
通过将这些差异拉出单独的 AND 行来简洁
它将 status 行和 filename 的值分配给 variables;
然后,我们可以在代码中无条件地使用这些变量来读取文件
并编写响应。示例 20-9 显示了将
大和块。if
else
if
else
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(&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(); }
现在,and 块仅返回
状态行和文件名;然后我们使用解构来分配这些
两个值添加到语句中使用模式,如第 18 章所述。if
else
status_line
filename
let
以前复制的代码现在位于 and 块之外,
使用 和 变量。这样更容易看到
这两种情况之间的差异,这意味着我们只有一个地方可以
如果我们想改变文件读取和响应写入的方式,请更新代码
工作。示例 20-9 中代码的行为将与
示例 20-7.if
else
status_line
filename
棒!我们现在有一个简单的 Web 服务器,大约有 40 行 Rust 代码 使用内容页面响应一个请求,并响应所有其他请求 请求,响应为 404。
目前,我们的服务器在单个线程中运行,这意味着它只能为一个 请求。让我们通过模拟一些 慢请求。然后我们将修复它,以便我们的服务器可以在 一次。
本文档由官方文档翻译而来,如有差异请以官方英文文档(https://doc.rust-lang.org/)为准