HarmonyOS开发者限时福利来啦!最高10w+现金激励等你拿~ 了解详情
写点什么

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

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

    阅读完需:约 38 分钟

使用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:536899
用户头像
赵钰莹 InfoQ 主编

发布了 882 篇内容, 共 642.0 次阅读, 收获喜欢 2679 次。

关注

评论 1 条评论

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

Java中的复用类

爱好编程进阶

Java 程序员 后端开发

Java代理模式,一次复习完4种动态代理实现方式

爱好编程进阶

Java 程序员 后端开发

3.0.0 alpha 重磅发布!九大新功能、全新 UI 解锁调度系统新能力

白鲸开源

Bigdata DolphinScheduler workflow Open Source apache 社区

netty系列之:使用Jboss Marshalling来序列化java对象

程序那些事

Java Netty 程序那些事 4月月更

企业如何应对知识管理中的文档管理

小炮

知识管理

离AI无处不在还有多远?从一个英特尔开源平台开始实现

科技新消息

必示科技入围未来银行科技服务商Top100榜单

BizSeer必示科技

it资产管理系统解决方案

低代码小观

资产管理 企业管理系统 CRM系统 IT治理 资产安全

智能手表的下半场,机遇与挑战并存

Speedoooo

物联网 小程序容器 智能手表 智能穿戴

智慧运维平台之全息监控

鲸品堂

运维 通信 运营商

Java基础06 数组基础

爱好编程进阶

Java 程序员 后端开发

9个国内/外行业 NPS (净推荐值)基准网站

龙国富

NPS

RNG战队LPL春季赛夺冠!中国电竞产业未来如何实现“破与立”?

易观分析

电竞产业

堡垒机是什么意思?别称是啥?

行云管家

网络安全 防火墙 数据安全 堡垒机

DRBD是什么意思?优缺点是什么?

行云管家

高可用 运维 HA高可用

一个平面设计师的异想世界

万事ONES

研发管理 设计师 ONES workbalance

OpenHarmony技术日成功举办,全球下载次数高达6300万

科技汇

基于Sharding-JDBC的订单分库⽅案

领创集团Advance Intelligence Group

直播预告|青藤云安全 x 极狐,云原生 DevSecOps 安全左移全解析

极狐GitLab

云原生 DevSecOps 主机安全 容器安全 软件安全

列举GaussDB(DWS)常见的查询时索引失效场景

华为云开发者联盟

索引 GaussDB(DWS) 隐式类型转化 GIN索引 analyze

如何成为一名亚马逊云科技 Community Builder

亚马逊云科技 (Amazon Web Services)

Cloud 亚马逊云科技 career

Docker 镜像知多少?

Daocloud 道客

云原生 Docker 镜像

阿里云人工智能创新发布-工业五金图片搜索

视觉智能

拍照购物 以图搜图 图像搜索 拍立淘

有更新!鸿蒙智联生态产品《接入智慧生活App开发指导》(官方版)

HarmonyOS开发者

HarmonyOS 鸿蒙智联

开发改了接口,经常忘通知测试,有什么好的解决方案吗?

Liam

测试 Postman 自动化测试 测试工具 测试自动化

OpenHarmony技术日圆满举行 | 3.1 Release版本重磅发布,生态落地初具规模

OpenHarmony开发者

OpenHarmony 技术日

华为推出OpenHarmony生态使能服务 加速OpenHarmony商用发行版落地

科技汇

带你认识2种基于深度学习的场景文字检索算法

华为云开发者联盟

深度学习 计算机视觉 文本检测 场景文本检索 文字检索

Cube 技术解读 | Cube 渲染设计的前世今生

蚂蚁集团移动开发平台 mPaaS

mPaaS Android; cube

macOS 安装 Nebula Graph 看这篇就够了

NebulaGraph

macos 图数据库 安装部署

在亚马逊云科技上搭建静态无服务器 Wordpress,每天仅需 0.01 美元

亚马逊云科技 (Amazon Web Services)

Serverless CDN WordPress

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