构建单线程 Web 服务器

RustRustBeginner
立即练习

This tutorial is from open-source community. Access the source code

💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版

简介

欢迎来到构建单线程Web服务器。本实验是《Rust 程序设计语言》的一部分。你可以在 LabEx 中练习你的 Rust 技能。

在本实验中,我们将构建一个单线程Web服务器,该服务器使用HTTP和TCP协议来处理客户端请求并提供响应。


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL rust(("Rust")) -.-> rust/DataStructuresandEnumsGroup(["Data Structures and Enums"]) rust(("Rust")) -.-> rust/AdvancedTopicsGroup(["Advanced Topics"]) rust(("Rust")) -.-> rust/BasicConceptsGroup(["Basic Concepts"]) rust(("Rust")) -.-> rust/DataTypesGroup(["Data Types"]) rust(("Rust")) -.-> rust/ControlStructuresGroup(["Control Structures"]) rust(("Rust")) -.-> rust/FunctionsandClosuresGroup(["Functions and Closures"]) rust/BasicConceptsGroup -.-> rust/variable_declarations("Variable Declarations") rust/DataTypesGroup -.-> rust/string_type("String Type") rust/ControlStructuresGroup -.-> rust/for_loop("for Loop") rust/FunctionsandClosuresGroup -.-> rust/function_syntax("Function Syntax") rust/FunctionsandClosuresGroup -.-> rust/expressions_statements("Expressions and Statements") rust/DataStructuresandEnumsGroup -.-> rust/method_syntax("Method Syntax") rust/AdvancedTopicsGroup -.-> rust/operator_overloading("Traits for Operator Overloading") subgraph Lab Skills rust/variable_declarations -.-> lab-100452{{"构建单线程 Web 服务器"}} rust/string_type -.-> lab-100452{{"构建单线程 Web 服务器"}} rust/for_loop -.-> lab-100452{{"构建单线程 Web 服务器"}} rust/function_syntax -.-> lab-100452{{"构建单线程 Web 服务器"}} rust/expressions_statements -.-> lab-100452{{"构建单线程 Web 服务器"}} rust/method_syntax -.-> lab-100452{{"构建单线程 Web 服务器"}} rust/operator_overloading -.-> lab-100452{{"构建单线程 Web 服务器"}} end

构建单线程Web服务器

我们将从让一个单线程Web服务器运行起来开始。在开始之前,让我们快速了解一下构建Web服务器所涉及的协议。这些协议的细节超出了本书的范围,但简要概述将为你提供所需的信息。

Web服务器涉及的两个主要协议是超文本传输协议Hypertext Transfer ProtocolHTTP)和传输控制协议Transmission Control ProtocolTCP)。这两个协议都是请求 - 响应协议,这意味着客户端发起请求,服务器监听请求并向客户端提供响应。这些请求和响应的内容由协议定义。

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() {
  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::preludestd::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请求

HTTP是一种基于文本的协议,请求采用以下格式:

方法 请求-URI HTTP版本 CRLF
头部信息 CRLF
消息体

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

请求行的下一部分是/_,它表示客户端请求的统一资源标识符uniform resource identifierURI):URI几乎与统一资源定位符uniform resource locatorURL)相同,但不完全一样。在本章中,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请求并发送响应的代码!

返回真实的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::{
  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所示。这段新代码会将接收到的请求内容与我们所知道的对/_的请求进行对比,并添加ifelse块来区别对待不同的请求。

文件名: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。

一点重构

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

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

现在,ifelse块只返回元组中状态行和文件名的适当值;然后我们使用解构,通过let语句中的模式将这两个值赋给status_linefilename,如第18章所述。

之前重复的代码现在在ifelse块之外,并使用status_linefilename变量。这使得更容易看出两种情况之间的区别,并且这意味着如果我们想更改文件读取和响应写入的工作方式,我们只需要在一个地方更新代码。清单20-9中的代码行为与清单20-8中的相同。

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

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

总结

恭喜你!你已经完成了“构建单线程Web服务器”实验。你可以在LabEx中练习更多实验来提升你的技能。