写点什么

使用 Rust 编写 HTTP 服务器(第一部分)

  • 2019-10-30
  • 本文字数:0 字

    阅读完需:约 1 分钟

使用Rust编写HTTP服务器(第一部分)

如今,互联网工程任务组(Internet Engineering Task Force,IETF)能够帮助开发者做很多工作,还编撰了有用的规范,这让编写一个 HTTP 服务器看起来也不是很难。



首先需要阅读 57897 个字的RFC 2616规范。当然,该文档是 IETF 编撰的。


注意,这个规范描述的是 HTTP/1.1,如果仔细阅读,会发现它撰写于 1999 年 6 月。对于我们来说这已经足够了,本文并非介绍如何实现一个最新版本的 HTTP 服务器(HTTP/3规范在 2019 年 9 月 26 日才发布。),只是概要的介绍 HTTP 服务器如何工作,以及其背后的基本原理。以下内容也并非指导如何编写一个用于生产环境的服务器,如果有需要的话,还是建议直接使用诸如NginxApache之类可信赖的服务器。


如果您对 HTTP 协议不同版本之间差异以及协议历史感兴趣,这里有一篇不错的文章


什么是 HTTP 协议

HTTP 是超文本传输协议(Hyper Text Transfer Protocol)的缩写。它是万维网(World Wide Web)上几乎所有资源(文件和其他数据)的载具。大多数情况下,HTTP 协议用于替代直接使用 TCP/IP 套接字,TCP 协议是我们要使用的基础协议。


这并不是否认 HTTP 协议可以基于互联网上的其他协议,甚至是其他网络环境。HTTP 协议仅仅假设传输环境可靠。因此理论上任何提供类似可靠传输的协议都可以使用。不过,规范并没有明确如何将传输协议的传输数据单元映射成 HTTP/1.1 协议的请求和响应结构。



客户端和服务器之间的通信将使用 HTTP 协议(类似的,如果你对技术趋势比较敏感,可能还听说过Gopher协议;如果你是在 IoT 领域,那么应该会使用MQTT协议)。这里的客户端可能是一个浏览器或者其他实现了 HTTP 协议的客户端。TCP 和 HTTP 协议都是基于请求-响应的协议。这意味着刚开始客户端会发出一个请求到服务端,而服务端将会一直监听请求,同时对收到的请求做出响应。


HTTP 协议传输资源,它是由统一资源定位(Uniform Resource Locator,URL)标识的一块数据。资源可以是一个文件,也可以是一个生成的查询结果。


开发人员可能会问:


这些服务将什么内容如何发送回去?


好吧,这就是 RFC 文档的作用了,定义了这些格式。相比于 HTTP 协议,TCP 协议是更加底层的协议,它只描述了如何将数据从一个地方发送到另一个地方,并没有描述传输的内容。而在这方面 HTTP 协议则更加具体。


第一次接触

本文代码可以在GitHub仓库中查看(链接指向的是本文编写时对应的代码)。


首先,我们需要在特定端口监听并处理 TCP 连接。为了突出这个步骤,我将避免使用一切库(例如直接使用一个 http crate),因为本文重心就是关注服务器如何工作。



正在和国际空间站对接中的航天器


好了,让我们新建一个工程,暂且叫 Linda:


$ cargo new linda$ cd linda
复制代码


随后,我们将接受并处理连接。为了便于了解服务器运行情况,我还添加了了日志 crate log 以及其实现 simple_logger。


[dependencies]simple_logger = "1.3.0"log = "0.4.8"
复制代码


首先,需要打开一个套接字,以便客户端连接。这里我们使用TcpListener来绑定套接字。如果查看文档,可以发现 bind 函数返回值是 Result,它代表了绑定的地址。返回的 Result<>枚举表示该操作可能会失败,我们必须处理异常情况。TcpListener 实现了incoming()函数,通过它可以获得连接的迭代器,后面就要处理这些连接。


use log::{error, info};use std::net::TcpListener; fn main() {     simple_logger::init().unwrap();   info!("Starting server...");    let ip = "127.0.0.1:8594";    let listener = TcpListener::bind(ip).expect("Unable to create listener.");   info!("Server started on: {}{}", "http://", ip);
for stream in listener.incoming() { match stream { Ok(stream) => match handle_connection(stream) { Ok(_) => (), Err(e) => error!("Error handling connection: {}", e), }, Err(e) => error!("Connection failed: {}", e), } } }
复制代码


  • 第 8 行:定义需要绑定的 IP(localhost)和端口。

  • 第 10 行:创建绑定指定 IP:端口的监听器,如果失败返回错误。

  • 第 13 行:循环传入的连接。

  • 第 14 到 20 行:由于连接可能失败,使用match处理 Result<>枚举的两种可能情况。

  • 第 15 到 18 行: 使用match处理 handle_connection(stream) 返回的 Result<>枚举,该方法暂时还未实现。


Rust 没有异常。取而代之的是用于可恢复错误的 Result 枚举和用于无法恢复错误的 panic!宏。(如果对此还不熟悉,建议阅读 Result<>文档。)


现在,如果尝试在浏览器中访问http://127.0.0.1:8594,我们会收到“连接被重置”,因为服务器没有返回任何数据。


响应客户端

我们已经和 TCP 套接字建立了连接,现在我们要处理数据流。该功能通过之前代码块第 18 行的 handle_connection(stream)函数来实现。下面我们就要来实现该方法。


目前,我们只解析了 RFC 文档中指定的请求行(Request-Line),既 Request-Line = Method SP Request-URI SP HTTP-Version CRLF,而非整个请求头。


完整的请求体格式是这样的(从 RFC 规范中复制):


Request  = Request-Line              ; Section 5.1           *(( general-header        ; Section 4.5            | request-header         ; Section 5.3            | entity-header ) CRLF)  ; Section 7.1           CRLF           [ message-body ]          ; Section 4.3
复制代码


fn handle_connection(mut stream: TcpStream) -> Result<(), Error> {     // 512字节对于玩具HTTP服务器足够用了   let mut buffer = [0; 512];
// 将流写入缓存 stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..]); let request_line = request.lines().next().unwrap();
match parse_request_line(&request_line) { Ok(request) => { info!("\n{}", request); } Err(()) => error!("Badly formatted request: {}", &request_line), }
Ok(())}
复制代码


这里有许多新代码,因此让我们一段段来过。注意,该方法返回Result<(), Error>,匹配 main.rs 的代码。


将流读入缓存

首先,我们需要将可修改的 TcpStream 内容读如缓存,这里使用了一个 512 字节的 &[u8]数组作为缓存。如果要多次写入,我们可以将它们缓存起来,当写入都完成之后把所有内容一次性写入流。这对于处理分块数据非常有用,这种情况下我们应该使用 BufWriter;同时对于发送大文件也非常有效,此时能够大大提高效率。不过示例中要发送的文件已经在内存中了,因此不需要这些功能。


let mut buffer = [0; 512];
stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..]);let request_line = request.lines().next().unwrap();
复制代码


我们将缓存作为可变引用传入,然后将其转成 String,以便后面可以解析。lines()函数将字符串按行分隔,并返回一个迭代器。next()函数返回迭代器的下一个元素。


在 Rust 中 String 和 &str 是不同的,其中 String 是保存在堆内存中且可以增长,而 &str 保存在栈上无法增长。


来自/r/rust的harvey_bird_person的提醒:


的确,&str 无法增长,但这是因为它是不可变引用。任何不可变引用的数据都不可修改。&str 指向的实际文本可能存在任何地方,文本可以分配在堆内存中,也可能是一个常量字符串或者任何东西。我们不知道,也不需要知道。


解析请求行

match parse_request_line(&request_line) {    Ok(request) => {        info!("Request: {}", &request);    }    Err(e) => error!("Bad request: {}", e),}
Ok(())
复制代码


这里我们将请求行(按照 RFC 规范定义)传入目前没有实现的函数 parse_request_line()。这里我们按引用传递。如果解析函数返回 OK,就将其打印出来;如果不正确则返回错误。现在来看解析函数本身:


fn parse_request_line(request: &str) -> Result<Request, Box<dyn Error>> {     let mut parts = request.split_whitespace();
let method = parts.next().ok_or("Method not specified")?; // We only accept GET requests if method != "GET" { Err("Unsupported method")?; }
let uri = Path::new(parts.next().ok_or("URI not specified")?); let norm_uri = uri.to_str().expect("Invalid unicode!");
const ROOT: &str = "/path/to/your/static/files";
if !Path::new(&format!("{}{}", ROOT, norm_uri)).exists() { Err("Requested resource does not exist")?; }
let http_version = parts.next().ok_or("HTTP version not specified")?; if http_version != "HTTP/1.1" { Err("Unsupported HTTP version, use HTTP/1.1")?; }
Ok(Request { method, uri, http_version, })}
复制代码


第 2 行将请求行数据按照空格分隔,返回一个迭代器,后面可以循环。后面在第 4、10、19 行就调用了其 next()函数返回字符串的后面一部分,然后 ok_or()函数将返回值从 Option<>转换成 Result<>。(如果对 Rust 的 Result<>还不熟悉,请参阅文档。)如果 ok_or()函数返回错误,我们将打印出一些错误消息。


ok_or()函数将 Some(v)映射成 Ok(v),将 None 映射成 Err(err),最后我们将错误使用?传播出去。


第 13 行我们指定了文档根目录,该目录是服务器查询文件的地方。然后我们将静态的根目录和 uri 拼接起来,并检查文件是否存在。如果不存在,我们返回错误。观察这个函数的返回值签名 Result<Request, Box>,这里 dyn 表示动态的,既可以返回任何类型的错误。这样让我们以后能够返回格式化的错误消息。


最后,我们检查请求的方法是否为 GET(兼容 HTTP/1.1 实现也必须实现 HEAD 请求)。然后我们检查 URI 映射的文件系统文件是否存在,以及 HTTP 版本是否是 HTTP/1.1。如果不满足要求,我们将向上传播错误。


如果一切正常,我们将返回 Ok()包装的 Request 对象。


Request 结构体

其中一个至今没有说明的是 Request 结构体。我们会将请求行保存到这个结构体中,格式按照 RFC 规范中定义的:


Request-Line = Method SP Request-URI SP HTTP-Version CRLF
复制代码


SP 是空格字符,CRLF 表示回车和换行(起源于打字机时代)。我们用\r\n 来表示 CRLF,这里\r 表示回车,\n 表示换行。


用代码格式化的语句为:


format!("{} {} {}\r\n", self.method, self.uri.display(), self.http_version)
复制代码


以下是我们可用的请求方式列表:(来自于规范)


  • OPTIONS

  • GET

  • HEAD

  • POST

  • PUT

  • PATCH

  • COPY

  • MOVE

  • DELETE

  • LINK

  • UNLINK

  • TRACE

  • WRAPPED


目前我们只会实现 GET 请求。按照规范后面就是请求的 URI:


GET 请求表示获取的信息(以实体的形式)用请求 URI 来标识。


因此,如果我们通过 GET 请求获取/index.htm,并且服务器的根路径中有这个文件,我们会将其作为响应体返回。


它(HTTP 协议)构建于统一资源标识符(Uniform Resource Identifier,URI)[3]提供的参考原则之上,通过位置(URL)[4]或者名称(URN)[20]来标识资源,并应用指定的方法。


我们将 URI 保存为std::path::Path类型。


最后,我们将要使用的 HTTP 版本是 HTTP/1.1,我们使用 &str 类型存储。


struct Request<'a> {    method: &'a str,    uri: &'a Path,    http_version: &'a str,}
复制代码


注意,我们使用了字符串引用,而非 String 对象。因此必须给它们指定生命周期标记’a’。


然而,当我们尝试编译的时候,编译器给出了如下的错误:


error[E0277]: `Request<'_>` doesn't implement `std::fmt::Display`  --> src/main.rs:57:27   |57 |             info!("\n{}", request);   |                           ^^^^^^^ `Request<'_>` cannot be formatted with the default formatter   |   = help: the trait `std::fmt::Display` is not implemented for `Request<'_>`   = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead   = note: required by `std::fmt::Display::fmt`
复制代码


这意味着我们得自己手工实现 fmt::Display trait,因为 Rust 在打印的时候不知道如何正确的格式化 Request 结构体。


以下是 fmt::Display 的实现:


impl<'a> fmt::Display for Request<'a> {    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {        write!(            f,            "{} {} {}\r\n",            self.method,            self.uri.display(),            self.http_version        )    }}
复制代码


当然,在给 Request 结构体实现 Display 的时候,我们也得手工指定生命周期。


一个 hack 的响应

目前为止,我们的服务器实际上没有返回任何内容……因此我们需要一个临时解决方案:创建一个 index.html 文件,作为返回的一部分发送出去。


<!DOCTYPE html><html lang="en">  <head>    <meta charset="utf-8">    <title>This is a title</title>  </head>  <body>    <h1>Hello from Linda!</h1>  </body></html>
复制代码


理论上我们可以在文件内写任何内容,但是考虑到目前还没有兼容发送其他媒体问题,例如图片(为此我们需要实现 MIME 类型,该功能后续会支持)。让我们引入文件系统库:


use std::fs;
复制代码


match parse_request_line(&request_line) {     Ok(request) => {         info!("Request: {}", &request);
let contents = fs::read_to_string("index.html").unwrap(); let response = format!("{}{}", "HTTP/1.1 200 OK\r\n\r\n", contents);
info!("Response: {}", &response); stream.write(response.as_bytes()).unwrap(); stream.flush().unwrap(); } Err(()) => error!("Badly formatted request: {}", &request_line),}
复制代码


首先,我们将文件作为字符串从文件系统读入。然后按照 RFC 规范(目前我们只返回状态行和实体内容)构建响应内容:


Full-Response   = Status-Line               ; Section 6.1                  *( General-Header         ; Section 4.3                  | Response-Header        ; Section 6.2                  | Entity-Header )        ; Section 7.1                  CRLF                  [ Entity-Body ]           ; Section 7.2
复制代码


状态行定义为:Status-Line = HTTP-Version SP Status-Code SP Reason-Phrase CRLF。该行内容暂时硬编码,本文第二部分我们将“更恰当的”实现该功能。


let contents = fs::read_to_string("index.html").unwrap();let response = format!("{}{}", "HTTP/1.1 200 OK\r\n\r\n", contents);
复制代码


状态码第一个数字定义了响应类型。后两位没有任何分类作用。首位数字有以下 5 个值:


  - 1xx: 信息响应 - 请求已经收到,继续流程
- 2xx: 成功响应 - 请求已经成功接受、理解并处理
- 3xx: 重定向 - 为了完成请求,必须执行后续操作
- 4xx: 客户端响应 - 请求包含错误预发活无法被处理
- 5xx: 服务端响应 - 服务端无法处理正确的请求
复制代码


然后,我们对响应字符串调用了 as_bytes,它将字符串转换成了字节数组。产生的 &[u8]类型数据通过 stream 的 write 函数写入,最终通过 TCP 连接发送出去。注意,write 和 flush 操作可能会失败,因此我们使用了 unwrap()函数。这不是一个正确的错误处理方式,再下一篇文章中将会处理这个问题。


stream.write(response.as_bytes()).unwrap();stream.flush().unwrap();
复制代码


完整代码可以在GitHub上查看(链接指向的是本文编写时对应的代码)。


实际的实现中,我将大部分实现都放到了 lib.rs 模块中,仅仅对 main()暴露了 handle_connection()函数。后续文章我会对代码进行重构以适应各种响应类型。


运行

最终,关键时刻到了:当我们运行 cargo run,然后在浏览器中打开http://127.0.0.1:8594,如果一切正常,将会看见如下输出:


INFO  [linda] Request: GET / HTTP/1.1
复制代码


同时,在浏览器中我们可以看见 html 文件渲染后的样子。


当发现请求的文件存在时,将会发送 index.html。在我们情况下请求的根目录存在,因为代码中硬编码了对应的文件并读入 content 变量,因此我们看见的是 index.html 渲染之后的输出。后续我们将检测文件是否存在,再发送对应的文件。



注意,我们只通过日志输出了请求行,而不是完整的请求头。完整的请求头看上去是这样的:


GET / HTTP/1.1Host: 127.0.0.1:8594User-Agent: Mozilla/5.0 (Windows NT 10.0; rv:68.0) Gecko/20100101 Firefox/68.0Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8Accept-Language: en-US,en;q=0.5Accept-Encoding: gzip, deflateDNT: 1Connection: keep-aliveCookie: csrftoken=VbaHdSoP0mPmMqaeaEiaCOywh4ZKKy68MnHRNIZDVTqBgqGDFyFQspCguESsTbDy; sessionid=2xumbk29qxyhd8rsqltadllshxeftzaaUpgrade-Insecure-Requests: 1Cache-Control: max-age=0
复制代码


我们可以使用 http GET 命令(该命令来自于httpie包,也可以使用 curl 命令)来请求这个 URL。


如果我们使用了其他不支持的请求方法,例如 POST,将会收到一个错误:


http: error: ConnectionError: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response')) while doing POST request to URL: http://127.0.0.1:8594/
复制代码


代码运行日志看上去是这样的(我们简单的打印了请求行):


ERROR [linda] Bad request: Unsupported method
复制代码


当然,这个简单的服务器还有一些问题。例如,当我们有许多请求的时候,如果其中一个请求耗时较长,那么其他请求方可能无法获取任何数据,因为服务器是单线程的。


但是,这些问题和一些规范中未实现的内容将在下次实现。下次我们将实现:


  • 多线程运行。

  • 请求和响应头(例如 Content-Type 等)。

  • 返回成功和失败响应。

  • 响应体(从根目录提供静态文件)。

  • 状态码(200 OK、404 NOT FOUND)。


本文代码可以在GitHub仓库中查看(链接指向的是本文编写时对应的代码)。


原文链接:


https://curiosityoverflow.xyz/posts/linda/


2019-10-30 08:535137
用户头像
赵钰莹 InfoQ 主编

发布了 808 篇内容, 共 505.3 次阅读, 收获喜欢 2553 次。

关注

评论 1 条评论

发布
用户头像
返回时客户端这边经常接收到503,包括例子代码也是这样
2021-12-03 17:29
回复
没有更多了
发现更多内容

理解持续测试,才算理解DevOps

禅道项目管理

DevOps 测试 持续集成

Monorepo 原来像陈老师这么香!

admin

第二周课程作业

Geek_a327d3

作业

程序设计原则

技术小生

极客大学架构师训练营

第二周作业

技术小生

极客大学架构师训练营

软件设计原则

superman

架构师训练营第二周作业

好名字

极客大学架构师训练营 作业

java静态代理与动态代理

张瑞浩

依赖倒置原则

Coder的技术之路

嵌入SpreadJS,赋能计量器具检定信息化

葡萄城技术团队

SpreadJS 计量检定

软件设计原则-第二周总结

孙志平

架构师训练营-02作业

ashuai1106

架构师 极客大学架构师训练营 架构设计原则

依赖倒置原则联想

极客大学架构师训练营

「架构师训练营」第 2 周作业 - 设计原则

森林

江帅帅:精通 Spring Boot 系列 03

奈学教育

springboot

「架构师训练营」第 2 周作业 - 总结

森林

架构师训练营-第二章课程总结-软件设计&面向对象

而立

极客大学架构师训练营

Spring Web MVC 依赖倒置原则分析

Arvin

CVPR 2020 六小时教程上线!新视角生成的前沿方法

神经星星

人工智能 学习 计算机视觉 模式识别 教程

架构设计篇之领域驱动设计(DDD)

小诚信驿站

领域驱动设计 DDD 架构设计 架构设计原则 刘晓成

Centos6 内核升级

唯爱

江帅帅:精通 Spring Boot 系列 03

古月木易

Spring Boot

我写了10年博客,却被人说“不火”?我是这样怼回去的!

王磊

Java 程序人生 「Java 25周年」

架构师训练营-第二周命题作业

牛牛

极客大学架构师训练营 命题作业

程序员买买买,纸书半价,电子书55折,抢券叠加使用更划算

图灵社区

图灵教育 热门活动

江帅帅:精通 Spring Boot 系列 04

奈学教育

Spring Boot

架构师训练营-作业-2-架构设计原则

superman

在滴滴和字节跳动干了 2 年后端开发,太真实…

程序员生活志

程序员 字节跳动 后端 滴滴 开发

带功能隔离的cache设计

Coder的技术之路

【架构训练营】第二周作业

Mr.hou

极客大学架构师训练营

软件设计原则 - 第二周作业

孙志平

使用Rust编写HTTP服务器(第一部分)_云计算_Matas Peciukonis_InfoQ精选文章