构建单线程 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:7878Connection 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:监听传入的流和打印 收到流时的消息

使用 ,我们可以侦听地址 处的 TCP 连接。在地址中,冒号前面的部分是 IP 地址 代表您的计算机(这在每台计算机上都是相同的,但不会 专门表示作者的计算机),并且是端口。我们已经 选择此端口有两个原因: 此端口通常不接受 HTTP,因此 我们的服务器不太可能与您可能拥有的任何其他 Web 服务器冲突 运行,并且 7878 是在电话上打出 Rust 的。TcpListener127.0.0.1:78787878

此方案中的函数的工作方式与函数类似,因为它 将返回一个新实例。调用该函数是因为,在网络中,连接到要侦听的端口称为“绑定 到一个港口。bindnewTcpListenerbind

该函数返回一个 ,这表明它是 可能会使绑定失败。例如,连接到端口 80 需要 管理员权限(非管理员只能侦听更高的端口 比 1023 多),因此如果我们尝试连接到端口 80 而不成为 administrator,则 binding 不起作用。绑定也不起作用,例如, 如果我们运行程序的两个实例,因此有两个程序侦听 相同的端口。因为我们编写一个基本的服务器只是为了学习目的,所以我们 不会担心处理这些类型的错误;相反,我们习惯于 to 如果发生错误,请停止程序。bindResult<T, E>unwrap

方法 on 返回一个迭代器,它为我们提供了一个 流序列(更具体地说,类型为 流 )。单个表示客户端和服务器之间的开放连接。连接是完整请求和响应过程的名称,其中 客户端连接到服务器,服务器生成响应,服务器 关闭连接。因此,我们将从 中阅读 以查看 客户端发送并将我们的响应写入流,以将数据发送回 客户端。总的来说,这个循环将依次处理每个连接,并且 生成一系列流供我们处理。incomingTcpListenerTcpStreamTcpStreamfor

目前,我们对流的处理包括调用 terminate 如果流有任何错误,我们的程序;如果没有任何错误,则 程序打印一条消息。我们将为 Success case 添加更多功能 下一个列表。我们可能从方法中收到错误的原因 当客户端连接到服务器时,我们实际上并没有迭代 连接。相反,我们正在迭代连接尝试。这 连接不成功的原因有很多,其中许多 特定于作系统。例如,许多作系统对 他们可以支持的同时打开的连接数;新建连接 超出该数量的尝试将产生错误,直到某些打开的 连接已关闭。unwrapincoming

让我们尝试运行这段代码吧!在终端中调用,然后在 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 连接的句柄!streamdrop

请记住按 - 当 您已完成运行特定版本的代码。然后重新启动程序 在进行每组代码更改后调用命令 以确保您运行的是最新的代码。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:#?}");
}

示例 20-2:从 TcpStream 读取并打印 数据

我们将 和 带入范围 以获取访问权限 添加到允许我们从流中读取和写入的 trait 和类型。在函数的循环中,而不是打印一条消息,指出我们做了一个 connection,我们现在调用新函数并将 传递给它。std::io::preludestd::io::BufReaderformainhandle_connectionstream

在该函数中,我们创建一个新实例,该实例 将对 . 通过管理调用添加缓冲 添加到 trait 方法中。handle_connectionBufReaderstreamBufReaderstd::io::Read

我们创建一个名为 的变量来收集请求的行数 浏览器发送到我们的服务器。我们表明我们想要收集这些 行。http_requestVec<_>

BufReader实现 trait,该 trait 提供方法。该方法在看到换行符时通过拆分数据流返回一个迭代器 字节。为了得到每个 ,我们映射 和 每个 。如果数据不是有效的 UTF-8 或存在问题,则可能是错误 从流中读取。同样,生产程序应该处理这些错误 更优雅地,但我们选择在 单纯。std::io::BufReadlineslinesResult<String, std::io::Error>StringunwrapResultResult

浏览器通过发送两个换行符来表示 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 ,它描述客户端如何进行 这个请求。我们的客户使用了一个请求,这意味着它正在请求 信息。GETPOSTGET

请求行的下一部分是 /,它表示 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,是 版本。GETHTTP/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_connectionprintln!

文件名: 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 响应写入 溪流

第一个新行定义保存 success 的变量 消息的数据。然后我们调用 our 来转换字符串 data 设置为 bytes。方法 on 接受 a 并发送 这些字节直接通过连接。因为作 可能会失败,我们会像以前一样对任何错误结果使用。同样,在真实的 应用程序,您将在此处添加错误处理。responseas_bytesresponsewrite_allstream&[u8]write_allunwrap

通过这些更改,让我们运行代码并发出请求。我们不再是 将任何数据打印到终端,因此我们不会看到除 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 文档,带有一个标题和一些文本。要返回此 当收到请求时,我们将修改为 如示例 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();
}

示例 20-5:将 hello.html 的内容作为 响应的正文

我们已添加到语句中,以引入标准库的 filesystem 模块添加到作用域中。用于将文件内容读取到 字符串应该看起来很熟悉;我们在第 12 章阅读内容时使用了它 示例 12-4 中 I/O 项目的文件。fsuse

接下来,我们使用 将文件的内容添加为成功的正文 响应。为了确保 HTTP 响应有效,我们添加了标头 它被设置为响应正文的大小,在本例中为 .format!Content-Lengthhello.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_connectionifelse

文件名: 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 请求的第一行,所以 而不是将整个请求读取到一个 vector 中,我们调用以获取 iterator 中的第一项。第一个处理 和 如果迭代器没有项,则停止程序。第二个处理 and 与添加的 示例 20-2.nextunwrapOptionunwrapResultunwrapmap

接下来,我们检查 the 以查看它是否等于 GET 的请求行 请求添加到 / 路径。如果是这样,则块返回 HTML 文件。request_lineif

如果 不等于 / 路径的 GET 请求,则 表示我们收到了其他请求。我们将向 回应所有其他请求的时刻。request_lineelse

现在运行此代码并请求 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();
    }
}

示例 20-7:使用状态码 404 和 错误页面(如果请求了 / 以外的任何内容)

在这里,我们的响应有一个状态行,其中状态代码为 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>

示例 20-8:要发回的页面的示例内容 具有任何 404 响应

进行这些更改后,请再次运行您的服务器。请求 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 显示了将 大和块。ifelseifelseifelse

文件名: 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:将 ifelse 块重构为 仅包含两种情况之间不同的代码

现在,and 块仅返回 状态行和文件名;然后我们使用解构来分配这些 两个值添加到语句中使用模式,如第 18 章所述。ifelsestatus_linefilenamelet

以前复制的代码现在位于 and 块之外, 使用 和 变量。这样更容易看到 这两种情况之间的差异,这意味着我们只有一个地方可以 如果我们想改变文件读取和响应写入的方式,请更新代码 工作。示例 20-9 中代码的行为将与 示例 20-7.ifelsestatus_linefilename

棒!我们现在有一个简单的 Web 服务器,大约有 40 行 Rust 代码 使用内容页面响应一个请求,并响应所有其他请求 请求,响应为 404。

目前,我们的服务器在单个线程中运行,这意味着它只能为一个 请求。让我们通过模拟一些 慢请求。然后我们将修复它,以便我们的服务器可以在 一次。

本文档由官方文档翻译而来,如有差异请以官方英文文档(https://doc.rust-lang.org/)为准