简介
欢迎来到构建单线程 Web 服务器。本实验是《Rust 程序设计语言》的一部分。你可以在 LabEx 中练习你的 Rust 技能。
在本实验中,我们将构建一个单线程 Web 服务器,该服务器使用 HTTP 和 TCP 协议来处理客户端请求并提供响应。
欢迎来到构建单线程 Web 服务器。本实验是《Rust 程序设计语言》的一部分。你可以在 LabEx 中练习你的 Rust 技能。
在本实验中,我们将构建一个单线程 Web 服务器,该服务器使用 HTTP 和 TCP 协议来处理客户端请求并提供响应。
我们将从让一个单线程 Web 服务器运行起来开始。在开始之前,让我们快速了解一下构建 Web 服务器所涉及的协议。这些协议的细节超出了本书的范围,但简要概述将为你提供所需的信息。
Web 服务器涉及的两个主要协议是超文本传输协议(Hypertext Transfer Protocol,HTTP)和传输控制协议(Transmission Control Protocol,TCP)。这两个协议都是请求 - 响应协议,这意味着客户端发起请求,服务器监听请求并向客户端提供响应。这些请求和响应的内容由协议定义。
TCP 是较低级别的协议,它描述了信息如何从一个服务器传输到另一个服务器的细节,但没有指定该信息是什么。HTTP 通过定义请求和响应的内容构建在 TCP 之上。从技术上讲,可以将 HTTP 与其他协议一起使用,但在绝大多数情况下,HTTP 通过 TCP 发送其数据。我们将处理 TCP 和 HTTP 请求与响应的原始字节。
我们的 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() {
1 let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
2 for stream in listener.incoming() {
3 let stream = stream.unwrap();
4 println!("Connection established!");
}
}
清单 20-1:监听传入流并在接收到流时打印消息
使用TcpListener,我们可以在地址127.0.0.1:7878监听 TCP 连接[1]。在这个地址中,冒号之前的部分是表示你的计算机的 IP 地址(在每台计算机上都是相同的,并不特指作者的计算机),而7878是端口号。我们选择这个端口号有两个原因:HTTP 通常不会在这个端口被接受,所以我们的服务器不太可能与你机器上可能运行的任何其他 Web 服务器冲突,并且 7878 在电话上拼出来是“rust”。
在这种情况下,bind函数的工作方式类似于new函数,它会返回一个新的TcpListener实例。这个函数被称为bind,是因为在网络中,连接到一个端口进行监听被称为“绑定到一个端口”。
bind函数返回一个Result<T, E>,这表明绑定有可能失败。例如,连接到端口 80 需要管理员权限(非管理员只能监听高于 1023 的端口),所以如果我们在不是管理员的情况下尝试连接到端口 80,绑定就不会成功。例如,如果我们运行程序的两个实例,因此有两个程序监听同一个端口,绑定也不会成功。因为我们只是为了学习目的编写一个基本的服务器,所以我们不会担心处理这类错误;相反,如果发生错误,我们使用unwrap来终止程序。
TcpListener上的incoming方法返回一个迭代器,它为我们提供一系列流[2](更具体地说,是TcpStream类型的流)。单个流表示客户端和服务器之间的一个开放连接。连接是指客户端连接到服务器、服务器生成响应并关闭连接的整个请求和响应过程的名称。因此,我们将从TcpStream读取以查看客户端发送了什么,然后将我们的响应写入流以将数据发送回客户端。总体而言,这个for循环将依次处理每个连接,并为我们生成一系列流以供处理。
目前,我们对流的处理包括如果流有任何错误就调用unwrap来终止我们的程序[3];如果没有错误,程序就打印一条消息[4]。在下一个清单中,我们将为成功的情况添加更多功能。当客户端连接到服务器时,我们可能从incoming方法收到错误的原因是,我们实际上不是在遍历连接。相反,我们是在遍历连接尝试。由于许多原因,连接可能不成功,其中许多原因与操作系统有关。例如,许多操作系统对它们可以支持的同时打开的连接数量有限制;超过该数量的新连接尝试将产生错误,直到一些打开的连接被关闭。
让我们尝试运行这段代码!在终端中调用cargo run,然后在 Web 浏览器中加载127.0.0.1:7878。浏览器应该会显示一个类似“Connection reset”的错误消息,因为服务器目前没有发送回任何数据。但是当你查看终端时,你应该会看到在浏览器连接到服务器时打印的几条消息!
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
1 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();
2 handle_connection(stream);
}
}
fn handle_connection(mut stream: TcpStream) {
3 let buf_reader = BufReader::new(&mut stream);
4 let http_request: Vec<_> = buf_reader
5.lines()
6.map(|result| result.unwrap())
7.take_while(|line|!line.is_empty())
.collect();
8 println!("Request: {:#?}", http_request);
}
清单 20-2:从TcpStream读取并打印数据
我们引入std::io::prelude和std::io::BufReader到作用域中,以获取让我们能够对流进行读写的 trait 和类型[1]。在main函数的for循环中,我们现在不再打印表示建立了连接的消息,而是调用新的handle_connection函数并将stream传递给它[2]。
在handle_connection函数中,我们创建一个新的BufReader实例,它包装了对stream的可变引用[3]。BufReader通过为我们管理对std::io::Read trait 方法的调用来添加缓冲。
我们创建一个名为http_request的变量来收集浏览器发送到我们服务器的请求行。通过添加Vec<_>类型注释,我们表明希望将这些行收集到一个向量中[4]。
BufReader实现了std::io::BufRead trait,该 trait 提供了lines方法[5]。lines方法通过在每次看到换行符时分割数据流来返回一个Result<String, std::io::Error>的迭代器。为了获取每个String,我们对每个Result进行映射并调用unwrap[6]。如果数据不是有效的 UTF-8 编码或者从流中读取时有问题,Result可能是一个错误。同样,一个生产程序应该更优雅地处理这些错误,但为了简单起见,我们选择在出错时停止程序。
浏览器通过连续发送两个换行符来表示 HTTP 请求的结束,所以为了从流中获取一个请求,我们读取行直到得到一个空字符串的行[7]。一旦我们将行收集到向量中,我们就使用漂亮的调试格式将它们打印出来[8],这样我们就可以查看 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 是一种基于文本的协议,请求采用以下格式:
方法 请求-URI HTTP版本 CRLF
头部信息 CRLF
消息体
第一行是请求行,包含有关客户端请求内容的信息。请求行的第一部分表示所使用的方法,例如GET或POST,它描述了客户端如何发出此请求。我们的客户端使用了GET请求,这意味着它在请求信息。
请求行的下一部分是/_,它表示客户端请求的统一资源标识符(uniform resource identifier,URI):URI 几乎与统一资源定位符(uniform resource locator,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版本 状态码 原因短语 CRLF
头部信息 CRLF
消息体
第一行是状态行,包含响应中使用的 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
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();
1 let response = "HTTP/1.1 200 OK\r\n\r\n";
2 stream.write_all(response.3 as_bytes()).unwrap();
}
清单 20-3:向流中写入一个简短的成功 HTTP 响应
新的第一行定义了保存成功消息数据的response变量[1]。然后我们对response调用as_bytes将字符串数据转换为字节[3]。stream上的write_all方法接受一个&[u8]并将这些字节直接通过连接发送出去[2]。因为write_all操作可能失败,所以我们像之前一样对任何错误结果使用unwrap。同样,在实际应用中你应该在这里添加错误处理。
有了这些更改后,让我们运行代码并发出请求。我们不再向终端打印任何数据,所以除了 Cargo 的输出外我们不会看到任何其他输出。当你在 Web 浏览器中加载127.0.0.1:7878时,你应该会得到一个空白页面而不是错误页面。你刚刚手动编写了接收 HTTP 请求并发送响应的代码!
让我们实现返回不仅仅是一个空白页面的功能。在项目目录的根目录下创建新文件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:一个用于在响应中返回的示例 HTML 文件
这是一个包含标题和一些文本的最小 HTML5 文档。为了在接收到请求时从服务器返回这个文件,我们将按照清单 20-5 修改handle_connection,以读取 HTML 文件,将其作为主体添加到响应中并发送。
文件名:src/main.rs
use std::{
1 fs,
io::{prelude::*, BufReader},
net::{TcpListener, TcpStream},
};
--snip--
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();
2 let response = format!(
"{status_line}\r\n\
Content-Length: {length}\r\n\r\n\
{contents}"
);
stream.write_all(response.as_bytes()).unwrap();
}
清单 20-5:将hello.html的内容作为响应主体发送
我们在use语句中添加了fs,以将标准库的文件系统模块引入作用域[1]。将文件内容读取为字符串的代码应该看起来很熟悉;我们在清单 12-4 中为 I/O 项目读取文件内容时使用过它。
接下来,我们使用format!将文件内容作为成功响应的主体添加进去[2]。为了确保有效的 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
--snip--
fn handle_connection(mut stream: TcpStream) {
let buf_reader = BufReader::new(&mut stream);
1 let request_line = buf_reader
.lines()
.next()
.unwrap()
.unwrap();
2 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\n\
Content-Length: {length}\r\n\r\n\
{contents}"
);
stream.write_all(response.as_bytes()).unwrap();
3 } else {
// 其他请求
}
}
清单 20-6:对请求/_和其他请求进行不同处理
我们只关注 HTTP 请求的第一行,所以不是将整个请求读入一个向量,而是调用next从迭代器中获取第一个元素[1]。第一个unwrap处理Option,如果迭代器没有元素则停止程序。第二个unwrap处理Result,其效果与清单 20-2 中添加的map里的unwrap相同。
接下来,我们检查request_line是否等于对/_路径的 GET 请求的请求行[2]。如果是,if块返回我们 HTML 文件的内容。
如果request_line不等于对/_路径的 GET 请求,这意味着我们收到了其他请求。我们稍后会在else块中添加代码[3]来响应所有其他请求。
现在运行这段代码并请求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
--snip--
} else {
1 let status_line = "HTTP/1.1 404 NOT FOUND";
2 let contents = fs::read_to_string("404.html").unwrap();
let length = contents.len();
let response = format!(
"{status_line}\r\n\
Content-Length: {length}\r\n\r\n
{contents}"
);
stream.write_all(response.as_bytes()).unwrap();
}
清单 20-7:如果请求的不是/_,则返回状态码 404 和错误页面
在这里,我们的响应状态行包含状态码 404 和原因短语NOT FOUND[1]。响应的主体将是404.html文件中的 HTML[1]。你需要在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>哎呀!</h1>
<p>对不起,我不知道你在请求什么。</p>
</body>
</html>
清单 20-8:用于在任何 404 响应中返回的页面的示例内容
有了这些更改后,再次运行你的服务器。请求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
--snip--
fn handle_connection(mut stream: TcpStream) {
--snip--
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\n\
Content-Length: {length}\r\n\r\n\
{contents}"
);
stream.write_all(response.as_bytes()).unwrap();
}
清单 20-9:重构if和else块,使其仅包含两种情况之间不同的代码
现在,if和else块只返回元组中状态行和文件名的适当值;然后我们使用解构,通过let语句中的模式将这两个值赋给status_line和filename,如第 18 章所述。
之前重复的代码现在在if和else块之外,并使用status_line和filename变量。这使得更容易看出两种情况之间的区别,并且这意味着如果我们想更改文件读取和响应写入的工作方式,我们只需要在一个地方更新代码。清单 20-9 中的代码行为与清单 20-8 中的相同。
太棒了!我们现在用大约 40 行 Rust 代码就有了一个简单的 Web 服务器,它对一个请求用一页内容进行响应,对所有其他请求用 404 响应进行响应。
目前,我们的服务器在单个线程中运行,这意味着它一次只能处理一个请求。让我们通过模拟一些慢速请求来研究这可能会带来什么问题。然后我们将修复它,以便我们的服务器能够一次处理多个请求。
恭喜你!你已经完成了“构建单线程 Web 服务器”实验。你可以在 LabEx 中练习更多实验来提升你的技能。