旅行的小七仔 發表於 2025-6-26 09:46:27

Rust 中单线程 Web 服务器的实现

<div id="navCategory"><h5 class="catalogue">目录</h5><ul class="first_class_ul"><li>监听 TCP 连接</li><li>阅读请求</li><li>仔细看看 HTTP 请求</li><li>编写响应</li><li>返回真正的 HTML</li><li>验证请求并选择性地响应</li><li>代码重构</li><li>总结</li></ul></div><p>Web 服务器中涉及的两个主要协议是超文本传输协议(HTTP)和传输控制协议(TCP)。这两种协议都是请求-响应协议,这意味着客户端发起请求,服务器侦听请求并向客户端提供响应。这些请求和响应的内容由协议定义。</p>
<p>TCP 是较低级别的协议,它描述了信息如何从一台服务器传递到另一台服务器的细节,但没有指定该信息是什么。HTTP 通过定义请求和响应的内容建立在 TCP 之上。在技术上可以将 HTTP 与其他协议一起使用,但在绝大多数情况下,HTTP 通过 TCP 发送数据。</p>
<p>我们将处理 TCP 和 HTTP 请求和响应的原始字节。</p>
<p class="maodian"></p><h2>监听 TCP 连接</h2>
<p>标准库提供了一个 std::net 模块,可以让我们监听 TCP 连接。</p>
<p>下面这段代码将在本地地址 127.0.0.1:7878 上监听传入的 TCP 流。当它收到一个传入流时,它将打印 Connection established!</p>
<div class="jb51code"><pre class="brush:plain;">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!");
    }
}
</pre></div>
<p>使用 TcpListener,我们可以监听地址为 127.0.0.1:7878 的 TCP 连接。在地址中,冒号之前的部分是代表本地地址,7878 是端口。</p>
<p>bind 函数类似于 new 函数,作用是监听一个端口,它返回 Result&lt;T, E&gt;,这表明绑定有可能失败。若成功,则得到一个新的 TcpListener 实例;若失败,我们使用 unwrap 来停止程序。</p>
<p>TcpListener 上的 incoming 方法返回一个迭代器,该迭代器为我们提供一个 TcpStream 类型的流。单个流表示客户端和服务器之间的连接,在该过程中,客户机连接到服务器,服务器生成响应,服务器关闭连接。因此,我们将从 TcpStream 中读取以查看客户端发送的内容,然后将响应写入流以将数据发送回客户端。总的来说,这个 for 循环将依次处理每个连接,并产生一系列流供我们处理。</p>
<p>目前,我们对流的处理包括:如果流有任何错误,调用 unwrap 来终止程序;如果没有任何错误,程序将打印一条消息。</p>
<p>在终端中调用 cargo run,然后在浏览器中加载 127.0.0.1:7878。浏览器应该显示一个错误消息,因为服务器当前没有发回任何数据。</p>
<p style="text-align:center"><img alt="在这里插入图片描述" src="https://img.jbzj.com/file_images/article/202506/2025062609414210.png" /></p>
<p>但是终端上有浏览器连接到服务器时打印的几条消息。</p>
<p style="text-align:center"><img alt="在这里插入图片描述" src="https://img.jbzj.com/file_images/article/202506/2025062609414289.png" /></p>
<p class="maodian"></p><h2>阅读请求</h2>
<p>实现一个 handle_connection 函数,从 TCP 流中读取数据并打印出来,这样我们就可以看到从浏览器发送的数据。</p>
<div class="jb51code"><pre class="brush:plain;">use std::net::{TcpListener, TcpStream};
use std::io::{BufReader, prelude::*};

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&amp;stream);
    let http_request: Vec&lt;_&gt; = buf_reader
      .lines()
      .map(|result| result.unwrap())
      .take_while(|line| !line.is_empty())
      .collect();

    println!("Request: {http_request:#?}");
}

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
      let stream = stream.unwrap();

      handle_connection(stream);
    }
}
</pre></div>
<p>在 handle_connection 函数中,我们创建了一个新的 BufReader 实例,该实例包装了对流的引用。BufReader 通过为我们管理对 std::io::Read trait 方法的调用来增加缓冲。</p>
<p>我们创建了一个名为 http_request 的变量来收集浏览器发送到服务器的请求行。我们通过添加 Vec&lt;_&gt; 类型注释来表示希望将这些行收集到一个向量中。</p>
<p>BufReader 实现了 std::io::BufRead trait,它提供了 lines 方法。lines 方法返回一个 Result&lt;String, std::io::Error&gt; 的迭代器,方法是在看到换行符时拆分数据流。为了获得每个 String,我们使用 map 方法展开每个 Result。</p>
<p>浏览器通过在一行中发送两个换行符来表示 HTTP 请求的结束,因此为了从流中获得一个请求,我们一直读取行,直到得到空字符串的行。一旦我们将这些行收集到 vector 中,我们将使用 #? 调试格式将它们打印出来,这样我们就可以查看 Web 浏览器发送给服务器的指令。</p>
<p>运行程序并再次在 Web 浏览器中发出请求。我们仍然会在浏览器中得到一个错误页面,但是我们的程序在终端中的输出现在看起来像这样:</p>
<div class="jb51code"><pre class="brush:plain;">Request: [
    "GET / HTTP/1.1",
    "Host: 127.0.0.1:7878",
    "Connection: keep-alive",
    "sec-ch-ua: \"Google Chrome\";v=\"137\", \"Chromium\";v=\"137\", \"Not/A)Brand\";v=\"24\"",
    "sec-ch-ua-mobile: ?0",
    "sec-ch-ua-platform: \"Windows\"",
    "Upgrade-Insecure-Requests: 1",
    "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
    "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
    "Sec-Fetch-Site: none",
    "Sec-Fetch-Mode: navigate",
    "Sec-Fetch-User: ?1",
    "Sec-Fetch-Dest: document",
    "Accept-Encoding: gzip, deflate, br, zstd",
    "Accept-Language: zh-CN,zh;q=0.9",
]
</pre></div>
<p>让我们分解这个请求数据来理解浏览器对程序的要求。</p>
<p class="maodian"></p><h2>仔细看看 HTTP 请求</h2>
<p>HTTP 是一个基于文本的协议,请求采用以下格式:</p>
<div class="jb51code"><pre class="brush:plain;">Method Request-URI HTTP-Version CRLF
headers CRLF
message-body
</pre></div>
<p>第一行是请求行,包含有关客户端请求内容的信息。</p>
<p>请求行的第一部分表明正在使用的方法,例如 GET 或 POST,它描述了客户端如何发出此请求。我们的客户端使用 GET 请求,这意味着它正在请求信息。</p>
<p>请求行的下一部分是/,它指示客户机请求的统一资源标识符(URI)。URI 类似于 URL,但是 HTTP 规范使用术语 URI。</p>
<p>请求行的最后一部分是客户端使用的 HTTP 版本,然后请求行以 CRLF 序列 \r\n 结束,其中 \r 是回车,\n 是换行符。CRLF 序列将请求行与请求数据的其余部分分开。</p>
<p>查看我们收到的请求行数据,可以看到 GET 是方法,/ 是请求 URI, HTTP/1.1 是版本。</p>
<p>在请求行之后,从 Host: 开始的其余行是请求头。GET 请求没有请求体。</p>
<p>现在我们知道了浏览器在请求什么,让我们发回一些数据吧!</p>
<p class="maodian"></p><h2>编写响应</h2>
<p>我们将实现发送数据以响应客户机请求。HTTP 响应的格式如下:</p>
<div class="jb51code"><pre class="brush:plain;">HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
message-body
</pre></div>
<p>第一行是状态行,其中包含响应中使用的 HTTP 版本、总结请求结果的数字状态码,以及提供状态码文本描述的原因短语。在 CRLF 序列之后是任何响应头、另一个 CRLF 序列和响应体。</p>
<p>下面是一个使用 HTTP 1.1 版本的响应示例,它的状态码是 200,一个 OK 原因短语,没有响应头、响应体:</p>
<div class="jb51code"><pre class="brush:plain;">HTTP/1.1 200 OK\r\n\r\n
</pre></div>
<p>状态码 200 是标准的成功响应。让我们将其写入流,作为对成功请求的响应。修改 handle_connection 函数:</p>
<div class="jb51code"><pre class="brush:plain;">fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&amp;stream);
    let http_request: Vec&lt;_&gt; = 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();
}
</pre></div>
<p>as_bytes 方法将字符串数据转换为字节。流上的 write_all 方法接受 &amp;,并将这些字节直接发送到连接。因为 write_all 操作可能失败,所以我们像以前一样对任何错误结果使用 unwrap。</p>
<p>通过这些更改,让我们运行代码并在浏览器中加载 127.0.0.1:7878。你应该得到一个空白页面,而不是一个错误页面。</p>
<p style="text-align:center"><img alt="在这里插入图片描述" src="https://img.jbzj.com/file_images/article/202506/2025062609414270.png" /></p>
<p class="maodian"></p><h2>返回真正的 HTML</h2>
<p>让我们实现不止返回一个空白页的功能。在项目目录的根目录中创建新文件 hello.html。</p>
<div class="jb51code"><pre class="brush:xhtml;">&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
    &lt;meta charset="utf-8"&gt;
    &lt;title&gt;Hello!&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
    &lt;h1&gt;Hello!&lt;/h1&gt;
    &lt;p&gt;Hi from Rust&lt;/p&gt;
&lt;/body&gt;
&lt;/html&gt;
</pre></div>
<p>接着修改 handle_connection 函数,读取 HTML 文件,将其作为正文添加到响应中,然后发送。</p>
<div class="jb51code"><pre class="brush:plain;">fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&amp;stream);
    let http_request: Vec&lt;_&gt; = 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();
}
</pre></div>
<p>我们使用 format! 将 hello.html 的内容添加到响应体中。为了确保有效的 HTTP 响应,我们添加了 Content-Length,该报头设置为响应体的大小,在本例中为 hello.html 的大小。</p>
<p>运行这段代码,并在浏览器中加载 127.0.0.1:7878。我们看到浏览器接收并渲染了 hello.html。</p>
<p style="text-align:center"><img alt="在这里插入图片描述" src="https://img.jbzj.com/file_images/article/202506/2025062609414222.png" /></p>
<p>目前,我们忽略了 http_request 中的请求数据,只是无条件地发回 HTML 文件的内容。</p>
<p>我们希望根据请求定制响应,只响应格式良好的请求。</p>
<p class="maodian"></p><h2>验证请求并选择性地响应</h2>
<p>让我们添加一些功能,在返回 HTML 文件之前检查浏览器是否正在请求 /,如果浏览器请求任何其他内容,则返回一个错误。</p>
<p>我们需要修改 handle_connection 函数,检查收到的请求的内容,并添加 if 和 else 块以区别对待请求。</p>
<div class="jb51code"><pre class="brush:plain;">// --snip--

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&amp;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
    }
}
</pre></div>
<p>我们将只查看 HTTP 请求的第一行,因此我们将调用 next 来从迭代器中获取第一项,而不是将整个请求读入 vector。第一次 unwrap 处理 Option,如果迭代器没有项,则停止程序。第二个 unwrap 处理 Result,取出请求内容。</p>
<p>接下来,我们检查 request_line,看看它是否等于对 / 路径的 GET 请求的请求行。如果是,if 块返回 hello.html 文件的内容。</p>
<p>现在运行此代码并请求 127.0.0.1:7878,还是成功的。</p>
<p style="text-align:center"><img alt="在这里插入图片描述" src="https://img.jbzj.com/file_images/article/202506/2025062609414222.png" /></p>
<p>如果发出任何其他请求,例如 127.0.0.1:7878/other,你将得到一个连接错误。</p>
<p style="text-align:center"><img alt="在这里插入图片描述" src="https://img.jbzj.com/file_images/article/202506/2025062609414294.png" /></p>
<p>现在,让我们完善 else 块中的代码,返回一个状态码为 404、原因短语为 NOT FOUND 的响应,响应体是 404.html 文件。</p>
<div class="jb51code"><pre class="brush:plain;">    // --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();
    }
</pre></div>
<p>404.html:</p>
<div class="jb51code"><pre class="brush:xhtml;">&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
    &lt;meta charset="utf-8"&gt;
    &lt;title&gt;Hello!&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;h1&gt;Oops!&lt;/h1&gt;
&lt;p&gt;Sorry, I don't know what you're asking for.&lt;/p&gt;
&lt;/body&gt;
&lt;/html&gt;
</pre></div>
<p>通过这些更改,再次运行服务器。请求 127.0.0.1:7878 应该返回 hello.html 的内容。而任何其他请求,如 127.0.0.1:7878/other,应该返回 404.html 中的错误 HTML。</p>
<p style="text-align:center"><img alt="在这里插入图片描述" src="https://img.jbzj.com/file_images/article/202506/2025062609414262.png" /></p>
<p class="maodian"></p><h2>代码重构</h2>
<p>目前,if 和 els e块有很多重复:它们都在读取文件并将文件的内容写入流,唯一的区别是状态行和文件名。</p>
<p>让我们将这些差异提取到单独的 if 和 else 行中,将状态行和文件名的值分配给变量,然后使用这些变量来读取文件并写入响应。</p>
<div class="jb51code"><pre class="brush:plain;">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\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}
</pre></div>
<p class="maodian"></p><h2>总结</h2>
<p>我们只用 32 行 Rust 代码就实现了一个简单的单线程 Web 服务器,用hello.html 响应一个请求,用 404.html 响应所有其他请求。</p>
<p>目前,我们的服务器在单线程中运行,这意味着它一次只能处理一个请求。在下一个项目中,我们先通过模拟一些慢速请求来检查这是如何造成问题的。然后我们将修复它,以便我们的服务器可以同时处理多个请求。</p>
<p></p>
                           
                            <div class="art_xg">
                              <b>您可能感兴趣的文章:</b><ul><li>通过rust实现自己的web登录图片验证码功能</li><li>Rust如何使用Sauron实现Web界面交互</li><li>rust中间件actix_web在项目中的使用实战</li><li>RustDesk Server服务器搭建教程含api服务器和webclient服务器</li><li>rust 创建多线程web server的详细过程</li><li>Rust多线程Web服务器搭建过程</li><li>在Rust web服务中使用Redis的方法</li><li>Rust开发WebAssembly在Html和Vue中的应用小结(推荐)</li></ul>
                            </div>

                        </div>
                        <!--endmain-->
頁: [1]
查看完整版本: Rust 中单线程 Web 服务器的实现