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