写点什么

使用 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:533813
用户头像
赵钰莹 InfoQ高级编辑

发布了 744 篇内容, 共 438.2 次阅读, 收获喜欢 2386 次。

关注

评论

发布
暂无评论
发现更多内容

Electron团队为什么要干掉remote模块

刘晓伦

Electron Node

API与ESB 、ServiceMesh、微服务究竟关系如何?

BoCloud博云

云管理

电信运营商基于 MQTT 协议构建千万级 IoT 设备管理平台

EMQ映云科技

物联网 IoT mqtt 通信运营商 emq

在线JSON转JAVA工具

入门小站

工具

模块(二)如何设计架构

我是一只小小鸟

【Flutter 专题】58 图解 Flutter 嵌入原生 AndroidView 小尝试

阿策小和尚

Flutter 小菜 0 基础学习 Flutter Android 小菜鸟 9月日更

聊聊什么样的代码是可读性强的代码?

卢卡多多

代码质量 代码 9月日更

如何采购ARM六核RK3399安卓工控开发主板?

双赞工控

安卓主板 工控主板 rk3399主板

Linux之lastlog命令

入门小站

Linux

Vue进阶(九十):过滤器

No Silver Bullet

Vue 9月日更

祝贺 StreamNative 工程师张勇成功跻身 Apache BookKeeper Committer

Apache Pulsar

bookKeeper Apache Pulsar StreamNative

Java中的变量与常量

IT蜗壳-Tango

9月日更

Lombok 安装及使用指南

村雨遥

Java IntelliJ IDEA lombok 9月日更

国产接口管理工具APIPOST中的常见设置项

Proud lion

前端 后端 Postman 开发工具 接口文档

浪潮云说丨上云迁移——快,准,稳!

浪潮云

云计算

❤️用武侠小说的形式来阅读LinkedList的源码,绝了!

沉默王二

Java

IoT边缘,你究竟是何方神圣?

华为云开发者社区

IoT 边缘 IoT边缘 实时数据 IoT Edge

【Vue2.x 源码学习】第四十三篇 - 组件部分 - 组件相关流程总结

Brave

源码 vue2 9月日更

多线程知识体系01-线程池源码阅读讲解-Executor

小马哥

多线程 高并发 源码阅读 源码剖析 日更

华为云PB级数据库GaussDB(for Redis)揭秘:如何搞定推荐系统存储难题

华为云开发者社区

数据库 推荐系统 存储 华为云 GaussDB(for Redis)

netty系列之:搭建自己的下载文件服务器

程序那些事

Java Netty io nio 程序那些事

深入分析3种线程池执行任务的逻辑方法

华为云开发者社区

Java 线程 线程池 ThreadPoolExecutor类

就靠这一篇文章,我就弄懂了 Python Django 的 django-admin 命令行工具集

梦想橡皮擦

9月日更

从一个并发异常问题引起的想法

卢卡多多

并发编程 9月日更

【LeetCode】 二叉树中和为某一值的路径Java题解

HQ数字卡

算法 LeetCode 9月日更

如何设计企业特色的数字化转型架构?

博文视点Broadview

企业级即时通信市场能否告别“孤岛时代”?

WorkPlus Lite

移动数字化底座 企业即时通讯平台 移动数字化平台 即时通讯IM 移动办公

升级mysql-connector-java-8.x踩坑纪实

小江

Java MySQL 时间戳 服务器时区 夏令时

架构学习模块一

George

柯基数据通过Rainbond完成云原生改造,实现离线持续交付客户

Rex

需求落地 离线部署 可持续交付 云原生应用

IDC:2021年全球大数据和分析支出预计达2157亿美元

WorkPlus Lite

阅读

数据cool谈(第2期)寻找下一代企业级数据库

数据cool谈(第2期)寻找下一代企业级数据库

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