AIGC革命已来,如何在企业场景落地?如何选择模型、怎样应用RAG、需要哪些组织流程配套? 了解详情
写点什么

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

发布了 867 篇内容, 共 592.6 次阅读, 收获喜欢 2666 次。

关注

评论 1 条评论

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

FlyFish一周年,社区大咖邀你共话开源!

云智慧AIOps社区

低代码 可视化 数据可视化 大屏可视化 无代码

NAT基础:NAT技术原理,静态NAT、动态NAT、NAPT、Easy IP、NAT Server的原理,以及各NAT的配置方法和转换示例。

Python-派大星

10月月更

报名倒计时1天!平头哥、中科院软件所PLCT实验室等技术专家解读最新RISC-V技术

OpenAnolis小助手

报名 risc-v 云栖大会 Workshop 龙蜥峰会

36氪|元年科技发布新版数字化PaaS平台,更新多个组件

元年技术洞察

方舟 PaaS 中台战略 企业数字化

对话创始人:团队研发效能应该如何管理和度量?

LigaAI

团队管理 敏捷开发 研发管理 研发效能 企业号十月PK榜

订单中心架构设计与实践

小小怪下士

Java 程序员 系统架构 架构设计

如何提升研发效能?我们先从指标谈起

Kyligence

数据分析 指标管理

JUC 浅析(三)

Andy

区块链≠绿色?波卡或成Web3“生态环保”标杆

One Block Community

区块链 环保 波卡生态

深入理解JS作用域链与执行上下文

loveX001

JavaScript

2022最新CSS高频面试题指南

CoderBin

CSS 前端 面试题 秋招 10月月更

阿里云云边一体容器架构创新论文被云计算顶会 ACM SoCC 录用

阿里巴巴云原生

阿里云 云原生 容器服务

阿里是如何使用分布式架构的?阿里内部学习手册分享

Java全栈架构师

架构 分布式 微服务 后端 高并发

KubeVela 插件指南:轻松扩展你的平台专属能力

阿里巴巴云原生

阿里云 开源 容器 云原生 KubeVela

ACL访问控制列表 基础、创建ACL访问控制列表的两种方式、配置ACL访问控制列表规则、修改ACL规则的默认步长。子网掩码、反掩码、通配符掩码的区别和作用。

Python-派大星

10月月更

教你处理数仓慢SQL常见定位问题

华为云开发者联盟

数据库 后端 华为云 企业号十月 PK 榜

手把手教你从安装CentOS7.4镜像开始,搭建IoT视频监控系统

华为云开发者联盟

后端 开发 华为云 企业号十月 PK 榜

软件测试丨接口测试该怎么做?持证上岗的Charles,可以帮你做什么?

测试人

软件测试 接口测试 charles 测试开发

前端面试中小型公司都考些什么

loveX001

JavaScript

京东云开发者|京东云RDS数据迁移常见场景攻略

京东科技开发者

MySQL 数据同步 数据迁移 云迁移 数据订阅

华为数通HCIA小型拓扑综合实验,运用OSPF动态路由协议、ACL访问控制列表,交换机生成树协议,修改交换机根桥、交换机划分vlan、链路聚合等相关数通技术、NAT地址转换以及NAT网络地址转换的配置

Python-派大星

10月月更

JUC 浅析(四)

Andy

云小课|MRS基础原理之Hudi介绍

华为云开发者联盟

大数据 华为云 企业号十月 PK 榜

专访韩向东|元年科技:专业与技术并重,赋能财务数字化转型

元年技术洞察

数字化转型 财务数字化

从清华大学到苏州经贸,双一流和普通高校都在使用的数据科学教学实训平台

ModelWhale

大数据 人才培养 数据竞赛 实训 教学

软件要想做的好,测试必定少不了

华为云开发者联盟

测试 开发 华为云 企业号十月 PK 榜

在世界舞台MBBF一骑绝尘:永远更快一步的北京5G是怎样炼成的?

脑极体

27位技术实战派负责人齐聚 深聊降本增效 你一定不想错过!

阿里技术

云计算 云原生 云栖大会 降本增效

数字化时代,企业如何创新自己的客户服务

Baklib

要努力,但也别焦虑

源字节1号

程序人生

“程”风破浪的开发者|学习中的境界

林冲

学习方法 “程”风破浪的开发者

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