业务云原生架构、推荐系统以及线上生活等热点方向的高可用高性能业务架构有哪些?点击了解 了解详情
写点什么

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

2019 年 10 月 30 日

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

发布了 702 篇内容, 共 413.3 次阅读, 收获喜欢 2282 次。

关注

评论

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

使用JavaScript解析XML文件

空城机

JavaScript xml 前端 递归 4月日更

计算机原理学习笔记 Day6

穿过生命散发芬芳

计算机原理 4月日更

安于现状的人,不值得同情

小天同学

深度思考 个人感悟 4月日更 突破现状

GraphX图计算组件最短路算法实战

小舰

4月日更

浅谈 MySQL 集群高可用架构

民工哥

MySQL MySQL 高可用 集群 linux运维

流计算:流式处理框架

正向成长

流式计算框架

Java 常见 bean mapper 的性能及原理分析

Java小咖秀

Java bean Copier

json基础学习

ベ布小禅

四月日更

Vue3、Vuex4、Ant Design2的实战项目开发管理系统

devpoint

vite Vue3 and design of vue

我常用的两个外国应用

彭宏豪95

产品 产品经理 工具 社交 Slack

Java-技术专题-Stream.foreach和foreach

李浩宇/Alex

Java stream collection

Golang Slice 数组和切片

escray

go 极客时间 学习笔记 4月日更 Go 语言从入门到实践

从被踢出局到5个30K+的offer,一路坎坷走来,沉下心,何尝不是前程万里

北游学Java

Java 数据库 分布式 微服务

Python基础之:struct和格式化字符

程序那些事

Python 数据分析 程序那些事

阿里高工熬夜18天码出Java150K字面试宝典,却遭Github全面封杀

程序员小毕

Java 程序员 架构 面试 分布式

曝光!互联网广告流量虚假,异常流量占比率8.6%!

󠀛Ferry

四月日更

LeetCode题解:17. 电话号码的字母组合,回溯,JavaScript,详细注释

Lee Chen

算法 LeetCode 前端进阶训练营

学会这15点,让你分分钟拿下Redis数据库

民工哥

linux运维 redis cluster 后端技术

智慧城市现状调研

程序员架构进阶

华为 智慧城市 28天写作 四月日更 4月日更

Markdown 文档可折叠化展示

耳东

4月日更

Python OpenCV 图像2D直方图,取经之旅第 27 天

梦想橡皮擦

Python OpenCV 4月日更

1分钟搞定 Nginx 版本的平滑升级与回滚

民工哥

nginx linux运维 后端技术

接口的幂等性怎么设计?

xcbeyond

设计 幂等性 4月日更

专访中寰卫星导航项目管理部负责人卜钢:如何演绎人生之路

打工人!

采访 调查采访能力考核

中国SaaS的终局:神仙打架,小鬼遭殃

ToB行业头条

不想搞Java了,4年经验去面试10分钟结束,现在Java面试为何这么难

云流

Java 编程 程序员 面试 计算机

隐私安全的城池营垒,能成为手机品牌高端化的赛点吗?

脑极体

mosquitto支持websocket搭建记录

风翱

4月日更 web socket mosquitto

const与指针交集的那些事

Bob

c++ 编程语言 四月日更

建议收藏!看完全面掌握,最详细的Redis总结(2021最新版)

民工哥

运维 redis cluster NoSQL数据库 后端技术

车行易携手睿象云:告警管理体系全升级

睿象云

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