构建单线程 Web 服务器


我们首先让单线程 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 中输入示例 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,我们可以监听地址 地址中,冒号前面的部分是代表您的计算机的 IP 地址(这在每台计算机上都是相同的,并不专门代表作者的计算机),7878 是端口。我们选择这个端口有两个原因:这个端口通常不接受 HTTP,因此我们的服务器不太可能与您机器上运行的任何其他 Web 服务器发生冲突,并且 7878 是在电话上打的。


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


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


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


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


让我们尝试运行这段代码吧!在终端中调用 cargo run,然后加载 127.0.0.1:7878 在 Web 浏览器中。浏览器应显示一条错误消息,例如“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

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::p reludestd::io::BufReader 引入范围,以访问允许我们读取和写入流的 trait 和类型。在 for 循环,而不是打印一条消息说我们建立了连接,而是现在调用新的 handle_connection 函数并传递 到它。


handle_connection 函数中,我们创建一个新的 BufReader 实例,该实例包装对的引用。BufReader 通过管理对 std::io::Read trait 方法的调用来为我们添加缓冲。


我们创建一个名为 http_request 的变量来收集浏览器发送到我们服务器的请求行。我们通过添加 Vec<_> 类型注释来指示我们想要将这些行收集到向量中。


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


浏览器通过连续发送两个换行符来发出 HTTP 请求的结束信号,因此要从流中获取一个请求,我们获取行,直到获得一行为空字符串。一旦我们将行收集到 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


第一行是请求行,其中包含有关客户端请求的信息。请求行的第一部分表示方法 正在使用,例如 GETPOST,它描述客户端如何发出此请求。我们的客户使用了 GET 请求,这意味着它正在请求信息。


请求行的下一部分是 /,它表示 Uniform Resource 客户端请求的标识符 (URI):URI 几乎与统一资源定位符 (URL) 相同,但不完全相同。URI 和 URL 之间的区别对于我们在本章中的目的并不重要,但 HTTP 规范使用了术语 URI,因此我们可以在这里用 URL 代替 URI。


最后一部分是客户端使用的 HTTP 版本,然后请求行以 CRLF 序列结束。(CRLF 代表回车换行,这是打字机时代的术语!CRLF 序列也可以写为 \r\n,其中 \r 是回车,\n 是换行。CRLF 序列将请求行与其余请求数据分开。请注意,打印 CRLF 时,我们会看到一个新行 start 而不是 \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 正常\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 响应写入流


第一个新行定义保存成功消息数据的响应变量。然后,我们在响应中调用 as_bytes 以将字符串数据转换为字节。stream 上的 write_all 方法采用 &[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:在响应中返回的示例 HTML 文件


这是一个最小的 HTML5 文档,带有一个标题和一些文本。为了在收到请求时从服务器返回它,我们将修改示例 20-5 所示的 handle_connection 来读取 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!将文件的内容添加为 Success 响应的正文。为了确保 HTTP 响应有效,我们添加了 Content-Length 标头 它被设置为响应正文的大小,在本例中为 hello.html


使用 cargo run 运行此代码,并在浏览器中加载 127.0.0.1:7878;您应该会看到 HTML 已呈现!


目前,我们忽略了 http_request 中的 request 数据,只无条件地发回 HTML 文件的内容。这意味着如果你尝试在浏览器中请求 127.0.0.1:7878/something-else,你仍然会得到相同的 HTML 响应。目前,我们的服务器非常有限,无法执行大多数 Web 服务器所做的事情。我们希望根据请求自定义响应,并且仅将格式正确的请求的 HTML 文件发送回 /


验证请求并选择性响应


现在,无论客户端请求什么,我们的 Web 服务器都会返回文件中的 HTML。让我们添加功能,在返回 HTML 文件之前检查浏览器是否在请求 /,如果浏览器请求其他任何内容,则返回错误。为此,我们需要修改 handle_connection,如示例 20-6 所示。这个新代码根据我们知道的 / 请求的外观检查收到的请求的内容,并添加 ifelse 块来区别对待请求。


文件名: 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 以从迭代器中获取第一项。第一个 unwrap 负责 Option 并在迭代器没有项目时停止程序。第二个 unwrap 处理 Result 的 v,并且与示例 20-2 中添加的 map 中的 unwrap 具有相同的效果。


接下来,我们检查 request_line 以查看它是否等于对 / 路径的 GET 请求的请求行。如果是这样,则 if 块返回 HTML 文件的内容。


如果 request_line 不等于 / 路径的 GET 请求,则表示我们收到了其他请求。我们稍后将代码添加到 else 块中以响应所有其他请求。


现在运行此代码并请求 127.0.0.1:7878;您应该在 hello.html. 如果您提出任何其他请求,例如 127.0.0.1:7878/something-else,你会得到一个连接错误,就像你在运行示例 20-1 和示例 20-2 中的代码时看到的一样。


现在让我们将示例 20-7 中的代码添加到 else 块中,以返回状态代码为 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 短语 未找到。响应的正文将是文件 404.html 中的 HTML。您需要在 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>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。


一点重构


目前,ifelse 块有很多重复:它们都在读取文件并将文件内容写入流。唯一的区别是状态行和文件名。让我们将这些差异提取到单独的 ifelse 行中,将状态行的值和文件名分配给变量,从而使代码更简洁;然后,我们可以在代码中无条件地使用这些变量来读取文件并编写响应。示例 20-9 显示了替换大型 ifelse 块后的结果代码。


文件名: 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 块,使其仅包含两种情况之间不同的代码


现在 ifelse 块只返回 Tuples 中 status 行和 filename 的适当值;然后,我们使用解构将这两个值分配给 status_linefilename,并使用 let 中的模式 声明,如第 18 章所述。


以前复制的代码现在位于 ifelse 块之外,并使用 status_linefilename 变量。这使得更容易看到这两种情况之间的区别,这意味着如果我们想改变文件读取和响应写入的工作方式,我们只有一个地方来更新代码。示例 20-9 中代码的行为与示例 20-7 中的行为相同。


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


目前,我们的服务器在单个线程中运行,这意味着它一次只能处理一个请求。让我们通过模拟一些慢速请求来研究一下这如何成为一个问题。然后我们将修复它,以便我们的服务器可以一次处理多个请求。