Rust-网络编程-全-
Rust 网络编程(全)
原文:
annas-archive.org/md5/f0abb6b7ddf669dcca0db5f55e157053译者:飞龙
前言
Rust 近年来稳步成为最重要的新编程语言之一。就像 C 或 C++一样,Rust 允许开发者编写足够低级的代码,使 Rust 代码运行得更快。由于 Rust 在设计上就是内存安全的,它不允许代码在空指针异常上崩溃。这些特性使其成为编写低级网络应用的天然选择。这本书将帮助开发者开始使用 Rust 编写网络应用。
本书面向的对象
本书的目标读者是那些对使用 Rust 编写网络软件感兴趣的软件工程师。
本书涵盖的内容
第一章,客户端/服务器网络简介,从零开始温和地介绍计算机网络。这包括 IP 地址、TCP/UDP 和 DNS。这构成了我们在后续章节讨论的基础。
第二章,Rust 及其生态系统简介,包含了对 Rust 的介绍。这是一个全面的介绍,应该足以让读者开始。我们假设读者对编程有一定的了解。
第三章,使用 Rust 进行 TCP 和 UDP,深入探讨了使用 Rust 进行网络编程。我们首先使用标准库进行基本的套接字编程。然后我们看看生态系统中可用于网络编程的一些 crate。
第四章,数据序列化、反序列化和解析,解释了网络计算的一个重要方面是处理数据。本章是关于使用 Serde 进行序列化和反序列化的介绍。我们还探讨了使用 nom 和其他框架进行解析。
第五章,应用层协议,上升一层来查看在 TCP/IP 之上运行的协议。我们查看了一些可以与之合作的 crate,如 RPC、SMTP、FTP 和 TFTP。
第六章,在互联网中谈论 HTTP,解释了互联网最常见应用之一是 HTTP。我们探讨了 Hyper 和 Rocket 等用于编写 HTTP 服务器和客户端的 crate。
第七章,使用 Tokio 进行异步网络编程,探讨了使用 futures、streams 和事件循环进行异步编程的 Tokio 堆栈。
第八章,安全性,深入探讨了保护我们之前描述的服务。这是使用证书和密钥。
第九章,附录讨论了已经出现的一些 crate,它们提出了本书已涵盖的做事方式的替代方法。这包括 async/await 语法、使用 Pest 进行解析等。我们将在附录中讨论其中的一些。
要充分利用本书
-
他们要么已经熟悉 Rust,要么计划开始学习这门语言。
-
他们有使用其他编程语言进行软件工程商业背景,并且了解使用不同编程语言开发软件的权衡。
-
他们对网络概念有基本的了解。
-
他们可以理解为什么分布式系统在现代计算中很重要。
下载示例代码文件
您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packtpub.com登录或注册。
-
选择 SUPPORT 标签页。
-
点击代码下载与勘误。
-
在搜索框中输入书名,并遵循屏幕上的说明。
文件下载后,请确保使用最新版本解压缩或提取文件夹:
-
Windows 上的 WinRAR/7-Zip
-
Mac 上的 Zipeg/iZip/UnRarX
-
Linux 上的 7-Zip/PeaZip
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Network-Programming-with-Rust。我们还有其他来自我们丰富图书和视频目录的代码包可用,网址为github.com/PacktPublishing/。请查看它们!
使用的约定
本书使用了多种文本约定。
CodeInText: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“target 目录包含编译工件。”
代码块设置如下:
[package]
name = "hello-rust"
version = "0.1.0"
authors = ["Foo Bar <foo.bar@foobar.com>"]
任何命令行输入或输出都应如下编写:
# cargo new --bin hello-rust
粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“它不需要为相同的连接调用 connect:”
警告或重要提示看起来是这样的。
小贴士和技巧看起来是这样的。
联系我们
我们始终欢迎读者的反馈。
一般反馈: 发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书名。如果您对本书的任何方面有疑问,请发送电子邮件至 questions@packtpub.com。
勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将不胜感激,如果你能向我们报告这个错误。请访问www.packtpub.com/submit-errata,选择你的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并附上材料的链接。
如果你有兴趣成为作者: 如果你有一个你擅长的主题,并且你对撰写或为书籍做出贡献感兴趣,请访问authors.packtpub.com。
评论
请留下评论。一旦你阅读并使用了这本书,为什么不在你购买它的网站上留下评论呢?潜在的读者可以看到并使用你的无偏见意见来做出购买决定,我们 Packt 可以了解你对我们的产品有何看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解更多关于 Packt 的信息,请访问packtpub.com。
第一章:客户端/服务器网络简介
本书是关于如何在 Rust 中编写网络应用程序的入门指南。这个标题引出了两个问题:为什么有人会关心网络?为什么有人想要用 Rust 编写网络应用程序?我们试图在本章中回答第一个问题。在随后的章节中,我们将介绍 Rust 和使用 Rust 进行网络编程。首先,在本章中,我们将从一点历史开始,试图了解网络架构在过去一百年是如何演变的。在随后的部分,我们将看到现代网络是如何分层和寻址的。之后,我们将描述网络中常用的服务模型。我们将以 Linux 提供的网络相关编程接口的总结结束。请注意,本书故意忽略了其他操作系统的网络编程,为了简化,只关注 Linux。虽然 Rust 编译器是平台无关的,但在其他平台上可能会有一些与 Linux 不同的情况。我们将随着进展指出这些差异。
在本章中,我们将涵盖以下主题:
-
网络的历史:为什么网络被使用以及互联网是如何演变的
-
网络分层:分层和封装是如何工作的
-
寻址:如何在互联网上唯一标识网络和单个主机
-
IP 路由是如何工作的
-
域名系统(DNS)是如何工作的
-
数据传输的服务模型
-
Linux 中的网络编程接口
网络简史
现代互联网彻底改变了我们相互沟通的方式。然而,它的起源可以追溯到维多利亚时代。互联网最早的先驱之一是电报网络,这些网络早在 1850 年就开始运营。当时,通过海上发送一条从欧洲到北美洲的消息需要 10 天时间。电报网络将这个时间缩短到了 17 小时。到 19 世纪末,电报已经成为一种完全成功的通信技术,在两次世界大战中被广泛使用。在那个时期,人们开始建造计算机来帮助破解敌军密码。与我们的现代移动电话和笔记本电脑不同,这些计算机器通常很大,需要专门的环境才能平稳运行。因此,有必要将这些机器放在特殊的位置,而操作员则坐在终端上。终端需要能够在短距离内与计算机通信。一系列局域网技术实现了这一点,其中最突出的是以太网。随着时间的推移,这些网络逐渐发展,到 20 世纪 60 年代,一些网络开始相互连接,形成一个更大的网络网络。高级研究计划署网络(ARPANET)于 1969 年建立,成为第一个类似于现代互联网的互联网。到 1973 年左右,世界各地都有许多这样的互联网,每个都使用自己的协议和方法进行通信。最终,这些协议被标准化,以便网络能够无缝地相互通信。所有这些网络后来都被合并,形成了今天的互联网。
由于网络在全球范围内独立发展,它们通常根据地理位置组织。局域网(LAN)是在小范围内(如一栋楼或一个小社区)的计算机集合。广域网(WAN)是连接多个社区的网;全球互联网位于这个层次结构的顶端。下一张图展示了 1977 年的 ARPANET 地图。这张地图上的每个节点都是一台计算机(按照今天的说法,是一台服务器)。其中大部分位于像斯坦福大学这样的大型大学或像劳伦斯伯克利国家实验室这样的国家实验室(来源:commons.wikimedia.org/wiki/File:Arpanet_logical_map,_march_1977.png))。
在网络中,请求评论(RFC)是一份描述拟议系统应如何工作的文档。这些是标准化协议或系统的第一步。术语“互联网”首次出现在 RFC 675 中,该文档提出了 TCP 的标准。

网络分层
计算机科学通常关注将问题细分为更小、希望独立的组件,这些组件可以独立解决。一旦这样做,所需要的只是一套规则,说明这些组件应该如何通信,以便对更大的问题有一个解决方案。这组规则,连同预先同意的数据格式,被称为协议。网络由多个层次组成,每个层次都有一个固定的目的。因此,这些层次中的每一个都运行一个或多个协议,形成一个协议栈。在网络的早期,不同的人以不同的方式实现了他们的网络。当互联网被构想出来时,需要使这些网络无缝地通信。由于它们的构建方式不同,这最终变得很困难。
明确需要就标准协议和接口达成一致,以使互联网工作。第一次尝试标准化网络协议是在 1977 年,这导致了 OSI 模型。此模型具有以下层次:
-
物理层:它定义了从其电气和物理特性方面如何在物理介质中传输数据。这可以是通过电线、光纤或无线介质。
-
数据链路层:它定义了通过物理介质连接的两个节点之间如何传输数据。此层处理多个试图同时访问线的实体之间的优先级问题。此层的重要功能还包括在传输的位中包含一些冗余,以最小化传输过程中的错误。这被称为编码。
-
网络层:它定义了由多个数据单元组成的数据包如何在网络之间传输。因此,此层需要定义如何唯一地识别主机和网络。
-
传输层:它定义了将可变长度的消息可靠地传输到主机(在同一网络或不同网络中)的机制。此层定义了一组数据包流,接收者可以监听这些数据包。
-
会话层:它定义了在主机上运行的应用程序应该如何通信。此层需要区分同一主机上运行的应用程序并将数据包发送给它们。
-
表示层:它定义了数据表示的通用格式,以便不同的应用程序可以无缝地互连。在某些情况下,此层还负责安全。
-
应用层:它定义了以用户为中心的应用程序应该如何发送和接收数据。一个例子是网络浏览器(一个以用户为中心的应用程序)使用 HTTP(一个应用层协议)与网络服务器通信。
下图展示了该模型的视觉表示(来源:commons.wikimedia.org/wiki/File:Osi-model-jb.svg)。这也显示了两种垂直分类,运行网络栈的主机和物理媒体(包括电线和网络设备)。每一层都有自己的数据单元,即它所处理信息的表示,并且由于每一层封装了其下的一层,数据单元也进行了封装。若干比特组成一个帧,若干帧组成一个数据包,依此类推,直至顶层:

OSI 模型及其层
当 OSI 致力于标准化这一模型时,国防高级研究计划局(DARPA)提出了一个完整的、更为简单的 TCP/IP 模型的实现(也称为IP(互联网协议)套件)。从最接近物理媒体到最远的顺序,该模型具有以下层:
-
硬件接口层:这是 OSI 模型第一层和第二层的组合。这一层负责管理媒体访问控制、处理比特的传输和接收、重传以及编码(一些关于网络技术的文本将硬件接口层和链路层区分开来。这导致了一个五层模型而不是四层模型。但在实践中这几乎无关紧要。)
-
IP 层:这一层对应 OSI 模型的第三层。因此,这一层负责两大主要任务:为主机和网络分配地址,以便它们能够被唯一识别并赋予源地址和目标地址,以及在给定一系列约束条件(路由)的情况下计算路径。
-
传输层:这一层对应 OSI 模型的第四层。这一层将原始数据包转换为带有某些保证的数据包流:按顺序交付(对于 TCP)和随机顺序交付(对于 UDP)。
-
应用层:这一层结合了 OSI 模型的五到七层,负责识别进程、数据格式化以及与所有用户级应用程序的接口。
注意,当我们从一层移动到另一层时,特定层处理的内容的定义会发生变化。硬件接口层处理主机传输的比特和字节,IP 层处理数据包(主机以特定格式发送的一组字节),传输层将来自主机上某个进程的数据包聚集到另一个主机上的另一个进程,以形成一个段(对于 TCP)或数据报(对于 UDP),应用层则从底层流中构建特定应用的表现形式。对于这些每一层,它们处理的数据表示称为该层的协议数据单元(PDU)。由于这种分层,当一个在主机上运行的过程想要向另一个主机发送数据时,数据必须被分割成单独的块。当块从一个层移动到另一个层时,每个层都会给块添加一个头部(有时是尾部),形成该层的 PDU。这个过程被称为封装。因此,每一层都为其上层提供一组服务,这些服务以协议的形式指定。
现代互联网表现出一种地理层次结构。想象一下,许多家庭由许多互联网服务提供商(ISP)提供服务。这些家庭中的每一个都位于一个局域网(通过以太网,或者更常见的是,通过 Wi-Fi)。ISP 将许多这样的局域网连接起来形成一个广域网。每个 ISP 连接一个或多个广域网,以形成他们自己的网络。这些跨越城市、由单一商业实体控制的大型网络被称为管理系统(AS)。在多个 ISP 之间的路由通常比常规 IP 路由更复杂,因为他们必须考虑诸如贸易协议等问题。这由专门的协议如边界网关协议(BGP)来处理。
如前所述,最早的、最成功的网络技术之一是以太网。1974 年首次推出,由于其低成本和相对易于维护,它迅速成为局域网和广域网的主导技术。以太网是一种共享媒体协议,其中所有主机必须使用相同的物理介质来发送和接收帧。帧被发送到所有主机,这些主机将检查目标 MAC 地址(这些地址将在下一节中描述)是否与其自己的地址匹配。如果匹配,则接受该帧,否则丢弃。由于物理介质在任何给定时刻只能携带一个信号,因此存在帧在传输过程中发生碰撞的概率。如果发生碰撞,发送者可以通过在传输其帧的同时感知来自其他主机的传输来检测到碰撞。然后,它终止传输并发送一个干扰信号,让其他主机知道发生了碰撞。然后,它等待指数退避的时间量,并重试传输。在固定次数的尝试之后,如果传输没有成功,它将放弃。
这个方案被称为载波侦听多路访问与碰撞检测(CSMA/CD)。以太网的一个问题是它的相对较短的范围。根据所使用的物理布线技术,以太网段的最大长度在 100 米到 500 米之间变化。因此,必须连接多个段来形成一个更大的网络。最常见的方法是在相邻的两个以太网段之间使用第二层交换机。这些交换机的每个端口形成不同的碰撞域,从而降低了整体碰撞的概率。这些交换机还可以监控流量,以了解哪些 MAC 地址连接到哪个端口,这样最终它们只会在该端口发送针对该 MAC 地址的帧(称为学习交换机)。在现代家庭中,与以太网相比,Wi-Fi 通常是占主导地位的局域网技术。
网络寻址
我们已经看到,为了可靠地递送数据包,识别主机和网络是唯一重要的。根据规模,有三种主要的方法来实现这一点;我们将在本节中讨论这些方法中的每一个。IP 路由的端到端过程将在下一节中讨论。一个值得注意的有趣事实是,对于这些寻址模式中的每一个,都保留了一个或多个地址用于特殊用途。通常,这些地址通过在已知模式中已知位是开还是关来标记:
-
以太网地址:这也被称为媒体访问控制(MAC)地址。它是一个 48 位的唯一标识符,分配给网络设备(通常存储在卡上),用于在子网中识别它。通常,这些地址由网卡制造商编程,但所有现代操作系统都允许用户修改它。以太网地址的标准写法是六组两位十六进制数字(01-23-45-67-89-ab-cd-ef)。另一种常见的方法是使用冒号分隔数字(01:23:45:67:89🆎cd:ef)。一些特殊的比特序列被保留用于特殊地址:发送者可以请求以太网帧被该段的所有主机接收,通过将第一个八位字节的最不重要位设置为 1 来实现;这被称为多播。如果该位设置为 0,则帧应仅发送给一个接收者。今天,这些在以太网和 Wi-Fi 中得到了广泛的应用。
-
IP 地址:这是分配给 IP 网络中每个设备的地址。最初的 IP 地址标准(IPv4)在 1980 年定义了 32 位的地址。然而,到了 1995 年,很明显,互联网上可用的地址总数不足以覆盖所有设备。这导致了 IPv6 的发展,它将地址空间扩展到 128 位。处理一组 IP 地址的标准方式是使用 CIDR 表示法,例如,192.168.100.1/26(IPv4)。斜杠后的十进制数字表示网络掩码中前导 1 的数量。因此,在这个特定的情况下,从 192.168.100.0 到 192.168.100.63 的网络中有2^(32-26) = 64个地址。互联网名称与数字地址分配机构(IANA)将公开可路由的 IP 地址块分配给组织。许多 IPv4 和 v6 地址被预留用于各种目的,如私有网络中的寻址等。在家庭网络(将始终使用特殊的私有范围地址)中,这些地址由 Wi-Fi 路由器通过动态主机配置协议(DHCP)分配。
-
自治系统编号:这是一个 32 位的数字,用于唯一标识自治系统。像 IP 地址一样,这些由互联网名称与数字地址分配机构(IANA)分配和维护。
除了这些,主机之间的通信通常使用端口号来区分进程。当操作系统为进程分配一个特定的端口时,它会更新其进程标识符和端口号之间的映射数据库。因此,当它在该端口上接收传入的数据包时,它知道要将这些数据包发送给哪个进程。如果到那时进程已经退出,操作系统将丢弃这些数据包,在 TCP 的情况下,将启动连接的关闭。在随后的章节中,我们将看到 TCP 在实际中是如何工作的。
操作系统为 0 到 1024 之间的端口范围预留了常见服务。其他应用程序可以请求 1024 以上的任何端口。
IP 路由工作原理
要理解 IP 路由是如何工作的,我们必须首先从 IPv4 地址的结构开始。如上一节所述,这些地址长度为 32 位。它们以每组 4 字节的点分十进制表示法书写(例如,192.168.122.5)。网络前缀中一定数量的位被用来标识数据包应该被发送到的网络,其余的位标识特定的主机。因此,同一网络中的所有主机必须具有相同的网络前缀。传统上,前缀用 CIDR 表示法描述,起始地址和地址的网络部分中的位数由一个斜杠分隔(192.168.122.0/30)。这个数字可以用来找出网络中可用于主机的地址数量(在这种情况下,2^(32-30) = 4)。给定一个 IP 地址和一个前缀,可以通过与网络部分全 1 的掩码进行位与操作来提取网络地址。计算主机地址正好相反;我们需要与网络掩码的逻辑否定(主机掩码)进行与操作,该掩码在网络部分全 0,在主机部分全 1。给定一个地址和一个前缀,如 192.168.122.5/27,我们将按以下图示进行计算。因此,对于给定的 CIDR,网络地址是 192.168.122.0,主机地址是 0.0.0.5:

CIDR 到网络和主机地址的转换
如前所述,每个 IP 网络都将有一个保留的广播地址,该地址可以被主机用来向该网络中的所有主机发送消息。这可以通过与主机掩码进行 OR 操作来计算。在我们的例子中,结果是 192.168.122.31。请注意,网络地址不能是一个有效的主机地址。
IP 地址大致分为两大类;一些地址块可以在公共互联网中路由,这些被称为公共 IP 地址。还有一些其他地址块只能用于不直接与互联网接口的私有网络,这些被称为私有地址。如果互联网上的一个路由器收到一个目的地为私有 IP 地址的数据包,它将不得不丢弃该数据包。除了这两类之外,IP 地址还可以根据各种参数进行分类:一些地址仅保留用于文档(192.0.2.0/24),一些地址仅保留用于两个主机之间的点对点通信(169.254.0.0/16),等等。Rust 标准库提供了方便的方法来根据 IP 地址的类型进行分类。
所有路由器都维护一个路由表,该表将前缀映射到路由器的输出接口(虽然路由器管理员可能会决定存储单个地址而不是前缀,但这将很快导致繁忙路由器上的路由表变得很大)。表中的条目基本上表示“如果数据包需要到达这个网络,它应该通过这个接口发送”。接收数据包的下一位主机可能是另一个路由器或目标主机。路由器是如何确定这个表的?多个路由器在那些计算这些表的路由器之间运行路由协议。一些常见的例子是 OSPF、RIP 和 BGP。给定这些原语,实际的路由机制相当简单,如图中所示。
IP 的一个有趣方面是使用生存时间(TTL)字段,这也被称为跳数限制。主机以固定的 TTL 值(通常是 64)发送数据包。每个数据包经过的路由器都会减少 TTL。当它达到 0 时,数据包将被丢弃。这种机制确保数据包不会在路由器之间陷入无限循环:

通用路由算法
互联网控制消息协议(ICMP)用于在网络设备之间交换操作信息。在前面的示例中,一个或多个路由器可能会决定发送回 ICMP 错误,如果它们被配置为这样做的话。
注意,在尝试将前缀与路由表中的路由匹配时,可能会有多个路由匹配。如果发生这种情况,路由器必须选择最具体的匹配项,并使用该匹配项进行转发。由于最具体的路由将具有最多的前导 1 位,因此具有最大的前缀,这被称为最长前缀匹配。假设我们的路由器具有以下路由表,如图所示。eth1、eth2和eth3是我们路由器上的三个网络接口,每个接口在不同的网络中具有不同的 IP 地址:

最长前缀匹配示例
在这一点上,如果我们的设备收到一个目标地址设置为 192.168.1.33 的数据包,所有三个前缀都有这个地址,但最后一个是最长的。因此,数据包将通过eth3发送出去。
我们之前关于 IPv4 地址所描述的许多内容对于 IPv6 来说并没有改变,当然,IPv6 具有更大的 128 位地址空间。在这种情况下,网络掩码和主机掩码的长度取决于地址类型。
可能有人会想知道,路由器是如何构建路由表的?一如既往,有协议来帮助完成这项工作。路由协议主要有两种类型:内部网关协议,用于在自治系统内部进行路由,以及外部网关协议,用于在自治系统之间进行路由;后者的一个例子是 BGP。内部网关协议又可以分为两种类型,这取决于它们如何看待整个网络。在链路状态路由中,每个参与协议的路由器维护整个网络拓扑的视图。在距离向量路由中,每个路由器只知道其单跳邻居。前者的一个例子是 路由信息协议(RIP),后者的一个例子是 开放最短路径优先(OSPF)。关于这些的详细信息超出了本书的范围。然而,我们可以注意到,所有路由协议的共同主题是它们通过在路由器之间交换信息来工作。因此,它们有自己的数据包格式来封装这些信息。
DNS 的工作原理
注意,没有人能够记住互联网上每个和每个服务的 IP 地址。幸运的是,有一个协议可以解决这个问题!域名服务器(DNS)通过在分布式数据库中维护一个人类可读的分层名称到服务 IP 地址的映射来解决此问题。因此,当用户在浏览器中输入 www.google.com 并按下 Enter 键时,第一步是使用 DNS 查找名称 www.google.com 的 IP 地址。下图显示了此类查询所需的步骤。在这次讨论中,我们将 本地 DNS 解析器、本地 DNS 服务器 和 本地 DNS 域名服务器 互换使用:

DNS 的工作原理
需要解析名称的应用程序将使用类似 getaddrinfo 的系统调用。这本质上是在请求操作系统去解析该名称。这一步在图中没有显示。接下来的步骤如下:
-
通常情况下,网络中的每一台计算机都会在文件
/etc/resolv.conf中配置一个本地 DNS 服务器。在大多数情况下,这指向 ISP 的 DNS 服务器。这也可能指向家庭 Wi-Fi 路由器的 DNS 服务器。在这种情况下,DNS 将透明地代理请求到 ISP 的 DNS 服务器。然后操作系统将查询该服务器,询问给定名称 www.google.com 的 IP 地址。 -
本地 DNS 服务器将反过来向预先填充的根域名服务器列表提出相同的问题。这些服务器由 ICANN 维护,它们的地址是众所周知的。它们维护顶级域名服务器的地址。这意味着它们知道
.com域名服务器的地址。 -
在这一步中,根域名服务器将回复
.com域名的顶级域名服务器地址。这些服务器维护它们自己域中域名服务器的地址列表。 -
本地 DNS 服务器随后联系这些服务器之一并询问相同的问题。
-
TLD 名称服务器回复
google.com域中服务器的地址。google.com域的管理员为该域维护了一组名称服务器。这些名称服务器对该域中的所有记录拥有完全的权威性,并且每个记录都被标记为权威性,以表明这一点。 -
本地 DNS 服务器随后向这些服务器之一提出相同的问题。
-
(希望)该服务器知道www.google.com的地址。如果它知道,它准备一个响应,将其标记为权威性,并将其发送回本地 DNS 服务器。答案还可以与一个生存时间相关联,这样本地 DNS 服务器就可以将其缓存以供将来使用,并在给定时间过后将其删除。如果它不知道,名称解析将失败,并将发送回一个称为 NXDOMAIN 的特殊响应。
-
本地 DNS 服务器随后将相同的响应发送回操作系统,操作系统再将它传递给应用程序。本地服务器将此响应标记为非权威性,表示它从别处获得了这个答案。
有趣的是,DNS 就像向朋友询问某人的地址,朋友说我不知道,但我认识一个认识一个认识一个可能知道的人。我可以帮你找到!然后他们四处询问并带回来一个回复。
DNS 数据包通常非常小,因为它们包含一个小的疑问和答案以及一些控制信息,并且由于 DNS 不需要从传输层获得非常高的可靠性,这使得它成为使用 UDP(在下节中描述)的理想候选者。然而,大多数实现都包括一个选项,如果传输不可靠,则回退到 TCP。
DNS 支持多种记录类型,用于各种事物。A记录将名称映射到 IPv4 地址,AAAA记录将名称映射到 IPv6 地址,等等。反向查找可以使用PTR记录支持。
常见的服务模型
为了两个主机通过网络进行通信,它们需要互相发送消息。有两种交换消息的模型,每种模型都有其最佳使用场景。在本节中,我们将探讨这些。请注意,服务模型是协议的特性,并且它们设定了消费者对这些协议的期望。
面向连接的服务
当每个参与方在发送实际数据之前协商一个虚拟连接时,协议提供给其消费者的服务是面向连接的。在设置过程中,必须就连接的一些参数达成一致。这与较老的固定电话系统类似,其中两个主机之间会建立一个专用连接。在现代网络中,一个例子是 TCP。TCP 的 PDU 是一个段,它由一个头部和一个数据部分组成。头部有几个字段,用于在协议状态机的状态之间进行转换。下图显示了 TCP 头部在实际中的样子。这张图中的每一行都是 32 位(因此,每一行是两个八位字节),其中一些被分割成多个段:

TCP 头部格式
我们将查看其中一些用于在主机之间操作连接的:
-
控制位(标志位)是一组用于各种目的的 9 位。这里感兴趣的标志位有 SYN、ACK、FIN 和 RST。SYN 触发序列号的同步。ACK 标志表示接收方应关注相应的确认号。FIN 标志开始断开连接的过程。RST 标志在出现错误时重置连接。
-
序列号是一个 32 位字段,用于在接收方重新排序消息。当 SYN 标志被设置(这应该只适用于连接中的第一个数据包),序列号是初始序列号;否则,它是到目前为止累积的序列号。
-
确认号是一个 32 位字段,用于启用消息的可靠交付。如果 ACK 标志被设置,这个值是发送方期望的下一个序列号。
在两个运行 TCP 协议的主机开始交换数据之前,它们必须进行三次握手来建立连接。这个过程是这样的:想要发起通信的客户端向服务器发送一个 SYN 数据包。序列号被设置为随机值,SYN 标志被设置为 1。服务器响应一个同时设置了 SYN 和 ACK 标志的数据包。这个数据包的确认号设置为比从客户端接收到的值多 1,序列号被设置为随机数。最后,客户端响应一个设置了 ACK 标志的数据包,序列号设置为上一步接收到的确认号,确认号设置为上一步序列号加 1。成功完成这个过程后,客户端和服务器就同意了序列号和确认号。这种模型的优势在于它有一个可靠的连接,发送方和接收方都知道可以期待什么。发送方可以根据接收方的速度或网络拥堵程度调整发送数据的速率。这里的缺点是连接建立的成本较高。假设向另一个大陆的主机发送一个数据包需要 100 毫秒,我们至少需要交换 3 个数据包才能开始发送数据。这相当于 300 毫秒的延迟。虽然这可能看起来不多,但记住,在任何给定时刻,用于访问 Facebook 的笔记本电脑可能已经打开了成千上万的连接到世界各地的服务器。面向连接的服务模型对于大量用例来说工作得很好,但也有一些情况,其中开销要么很大,要么是不必要的。一个例子是视频流。在这种情况下,丢失几个数据包不会造成大问题,因为没有人会注意到视频中的少量错位像素。这些应用程序更喜欢无连接模型,如下所述。
无连接服务
第二种情况是无连接服务。当多个消息之间没有关系时,就会使用这种服务。在发送任何数据之前,这些协议不需要进行任何连接协商步骤。一个例子是 UDP,它不提供传输消息的顺序或可靠性保证(然而,它确实有一个校验和字段来保证数据报的正确性)。应该注意的是,运行在 UDP 之上的协议总是可以自由实现可靠性,如果需要的话。有趣的是,IP 路由也是一个无连接服务。UDP 头部格式如下所示:

UDP 头部格式
很容易看出这里的头部比 TCP 头部小得多。它还缺少 TCP 用来管理连接并根据网络拥堵等因素调整连接的许多字段。由于 UDP 没有这些字段,它不能提供这些保证。
Linux 中的网络编程接口
在本节中,我们将了解 Linux(以及 Unix 家族中的许多其他成员)如何实现常见的网络模式,以及用户在编写网络应用程序时如何与这些模式交互。本节的所有讨论都将严格基于具有标准 C 库(glibc)的类似 Linux 的操作系统。可移植操作系统接口(POSIX)标准包括所有这些,使它们可移植到任何符合 POSIX 的操作系统。这里的所有函数和数据结构都遵循 C(和 C++)编码约定,但正如我们稍后将看到的,其中一些也通过 libc 绑定在 Rust 中可用。
操作系统提供的最重要的网络原语是套接字。那么,什么是套接字?套接字是一个被美化的文件描述符,是一个在类 Unix 操作系统中分配给每个文件的唯一 ID。这源于 Unix 哲学,即一切都应该是一个文件;将两个主机之间的网络连接视为一个文件,使得操作系统可以将其作为文件描述符暴露出来。然后程序员可以自由使用传统的 I/O 相关系统调用来从这个文件写入和接收数据。
现在,显然,套接字需要比常规文件描述符持有更多的数据。例如,它需要跟踪远程 IP 和端口(以及本地 IP 和端口)。因此,套接字是两个主机之间连接的逻辑抽象,包括在那些主机之间传输数据所需的所有信息。
存在两种主要的套接字类别:UNIX 套接字用于与同一主机上的进程通信,以及互联网套接字用于通过 IP 网络进行通信。
标准库还提供了一些系统调用用于与套接字交互。其中一些是针对套接字的特定调用,而另一些是通用的 I/O 系统调用,支持写入文件描述符。由于套接字基本上是一个文件描述符,因此可以使用这些调用与套接字交互。其中一些将在下一张图中描述。请注意,并非所有应用程序都需要使用所有这些系统调用。例如,一旦创建套接字,服务器将需要调用 listen 来开始监听传入的连接,它不需要为该连接调用 connect:

常见网络系统调用
任何类 Unix 操作系统都会在 man 手册页中对这些系统调用提供详细的文档。例如,套接字系统调用的文档可以通过命令man socket访问。man命令的第二个参数是 man 手册页的章节。
让我们更详细地查看这些系统调用的签名。除非另有说明,否则所有这些在成功时返回 0,在失败时返回-1,并相应地设置 errno 的值。
int socket(int domain, int type, int protocol);
socket系统调用的第一个参数告诉它将使用哪种类型的通信socket。常见的类型有 AF_INET 用于 IPv4,AF_INET6 用于 IPv6,AF_UNIX 用于 IPC 等。第二个参数告诉它应该创建哪种类型的套接字,常见的值包括 SOCK_STREAM 用于 TCP 套接字,SOCK_DGRAM 用于 UDP 套接字,SOCK_RAW 用于提供数据包级别的直接网络硬件访问的原始套接字等。最后一个参数表示要使用的第 3 层协议;在我们的情况下,这仅限于 IP。支持的所有协议的完整列表可在文件/etc/protocols中找到。
在成功的情况下,此操作返回一个新文件描述符,内核将其分配给创建的套接字。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
bind的第一个参数是文件描述符,通常是套接字系统调用返回的。第二个参数是要分配给给定套接字的地址,作为结构指针传递。第三个参数是给定地址的长度。
int listen(int sockfd, int backlog);
listen是一个函数,它接受套接字的文件描述符。请注意,当应用程序在套接字上监听传入的连接时,它可能无法像数据包到达那样快地从中读取。为了处理这种情况,内核为每个套接字维护一个数据包队列。这里的第二个参数是给定套接字队列的最大长度。如果在此之后有更多客户端尝试连接,连接将被关闭,并显示连接被拒绝的错误。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
此调用用于在 TCP 套接字上接受连接。它从给定套接字的队列中获取一个连接,创建一个新的套接字,并将新套接字的文件描述符返回给调用者。第二个参数是一个指向套接字地址struct的指针,其中填充了新套接字的信息。第三个参数是其长度。
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
此函数将第一个参数指定的套接字连接到第二个参数指定的地址(第三个参数是地址struct的长度)。
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
这用于通过套接字发送数据。第一个参数告诉它使用哪个套接字。第二个参数是要发送的数据的指针,第三个参数是其长度。最后一个参数是多个选项的按位或,这些选项决定了在此连接中如何交付数据包。
此系统调用在成功时返回发送的字节数。
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
这是发送的对应函数。通常,第一个参数告诉它从哪个套接字读取。第二个参数是它应该写入读取数据的分配空间的指针,第三个参数是其长度。这里的flags与发送的情况具有相同的意义。
此函数在成功时返回接收的字节数:
int shutdown(int sockfd, int how);
此函数关闭一个套接字。第一个参数告诉它要关闭哪个套接字。第二个参数指定在关闭套接字之前是否允许任何进一步的传输或接收。
int close(int fd);
此系统调用用于销毁文件描述符。因此,在给定其文件描述符编号的情况下,这也可以用来关闭和清理套接字。虽然 shutdown 允许套接字接收挂起数据而不接受新连接,但 close 将丢弃所有现有连接并清理资源。
除了上面提到的之外,主机还需要使用 DNS 解析远程主机的 IP 地址。getaddrinfo 系统调用执行此操作。还有一些其他系统调用为编写应用程序提供了各种有用的信息:gethostname 返回当前计算机的主机名,setsockopt 在套接字上设置各种控制选项,等等。
注意,上面描述的许多系统调用都是阻塞的,这意味着它们会在等待给定操作完成时阻塞调用线程。例如,read 系统调用会在提供的缓冲区不足以填充足够数据时在套接字上阻塞。通常,这并不是所希望的,尤其是在现代的多线程环境中,因为阻塞调用将无法充分利用可用的计算能力,因为线程将循环执行无用的操作。
Unix 提供了一些其他系统调用,它们使用标准 C 库实现异步、非阻塞应用程序。有两种标准方法来做这件事:
-
使用 select 系统调用:此系统调用监视给定套接字列表,并通知调用者是否有任何套接字有可读数据。然后,调用者可以使用一些特殊宏检索这些文件描述符并从中读取。
-
使用 poll 系统调用:这里的高级语义与 select 类似:它接受一个套接字文件描述符列表和一个超时时间。它异步监视这些套接字在给定超时时间内,如果其中任何一个有数据,则通知调用者。与 select 不同,select 会检查所有文件描述符上的所有条件(可读性、可写性和错误),而 poll 只关心它接收到的文件描述符列表和条件。这使得 poll 更易于使用,并且比 select 更快。
然而,在实践中,对于可能需要监视大量套接字连接的应用程序,select 和 poll 都非常慢。对于此类应用程序,epoll 或基于事件的网络库(如 libevent 或 libev)可能更适合。性能的提升是以可移植性为代价的;这些库并非在所有系统中都可用,因为它们不是标准库的一部分。另一个代价是编写和维护基于外部库的应用程序的复杂性。
在下一节中,我们将逐步讲解通过网络通信的 TCP 服务器和客户端的状态转换。这里为了简化,做了一些理想化的假设:我们假设没有中间错误或任何类型的延迟,服务器和客户端可以以相同的速率处理数据,并且在通信过程中服务器和客户端都不会崩溃。我们还假设客户端发起连接(主动打开)并关闭它(主动关闭)。我们没有展示状态机的所有可能状态,因为那将过于繁琐:

TCP 服务器和客户端的状态转换
服务器和客户端都从关闭状态开始。假设服务器首先启动,它将首先获取一个套接字,将地址绑定到它上,并开始在其上监听。客户端启动并调用connect到服务器的地址和端口。当服务器看到连接时,它会在其上调用accept。这个调用返回一个新的套接字,服务器可以通过它读取数据。但在实际数据传输发生之前,服务器和客户端必须进行三次握手。客户端通过发送一个SYN来启动这个过程,服务器读取该信息,并以一个SYN + ACK消息响应,进入SYN_RCVD状态。客户端进入SYN_SENT状态。
当客户端收到SYN + ACK后,它发送一个最终的ACK并进入建立状态。服务器在收到最终的ACK后进入建立状态。只有在双方都处于建立状态时,实际的连接才建立。在这个时候,服务器和客户端都可以发送和接收数据。这些操作不会引起状态变化。过了一段时间后,客户端可能想要关闭连接。为此,它发送一个FIN数据包并进入FIN_WAIT_1状态。服务器收到这个数据包,发送一个ACK并进入CLOSE_WAIT状态。当客户端收到这个数据包后,它进入FIN_WAIT_2状态。这标志着连接终止的第一轮结束。然后服务器调用close,发送一个FIN并进入LAST_ACK状态。当客户端收到这个数据包后,它发送一个ACK并进入TIME_WAIT状态。当服务器收到最终的ACK后,它回到关闭状态。从这一点开始,所有与此连接相关的服务器资源都将被释放。然而,客户端在移动到关闭状态(在那里它释放所有客户端资源)之前会等待一个超时。
我们在这里的假设非常基础且理想化。在现实世界中,通信通常会更为复杂。例如,服务器可能想要推送数据,然后它必须发起连接。数据包在传输过程中可能会损坏,导致任一方请求重传,等等。
最大段生存时间(MSL)被定义为 TCP 段在网络中存在的最大时间。在大多数现代系统中,它被设置为 60 秒。
摘要
本章从在现代世界中编写网络应用程序的动机开始。我们还回顾了网络的发展历程。我们研究了常见的网络技术和理念,并探讨了它们是如何协同工作的;从简单的 IP 路由和 DNS 到 TCP 和 UDP。然后我们研究了 Linux(和 POSIX)通常如何支持同步和异步网络编程。
在下一章中,我们将探讨 Rust 并尝试理解它相对于现有平台的优势。在激发了对网络和 Rust 的兴趣之后,我们将继续使用 Rust 进行网络编程。
第二章:Rust 及其生态系统的介绍
Rust 编程语言由 Mozilla 赞助,并得到全球开发者社区的支持。Rust 被推广为一种支持自动内存管理(无需运行时或垃圾收集器开销)、无数据竞争的并发(由编译器强制执行)以及零成本抽象和泛型的系统编程语言。在随后的章节中,我们将更详细地讨论这些特性。Rust 是静态类型,并借鉴了许多函数式编程思想。Rust 的一个迷人之处在于,它使用类型系统来保证内存安全,而不使用运行时。这使得 Rust 特别适合低资源嵌入式设备和实时系统,这些系统需要对代码正确性提供强有力的保证。另一方面,这通常意味着编译器必须做更多的工作来确保语法正确性,然后翻译源代码,从而导致构建时间更长。尽管社区正在努力尽可能减少编译时间,但这仍然是许多开发者遇到的一个重要问题。
低级虚拟机(LLVM)项目最初是一个大学研究项目,旨在开发一套构建编译器的工具,这些编译器可以为一系列 CPU 架构生成机器代码。这是通过使用LLVM 中间表示(LLVM IR)实现的。工具链可以将任何高级语言编译成 LLVM IR,然后针对特定的 CPU 进行编译。Rust 编译器严重依赖于 LLVM 项目以实现互操作性,将其用作后端。它实际上将 Rust 代码翻译成 LLVM 的中间表示,并根据需要进行优化。然后 LLVM 将其转换为特定平台的机器代码,该代码在 CPU 上运行。
在本章中,我们将涵盖以下主题:
-
生态系统介绍和 Rust 的工作原理
-
安装 Rust 和设置工具链
-
从借用检查器和所有权的工作原理开始,介绍其主要特性
-
泛型和如何与所有权模型一起工作的特质系统
-
错误处理和宏系统
-
并发原语
-
测试原语
注意,本章是对语言及其一些最显著特性的非常高级概述,而不是深入探讨。
Rust 生态系统
开源项目的成功或失败往往取决于其周围社区的强度。拥有一个连贯的生态系统有助于构建一个强大的社区。由于 Rust 主要由 Mozilla 推动,因此他们能够围绕它构建一个强大的生态系统,主要组成部分包括:
-
源代码:Rust 在 GitHub 上托管所有源代码。开发者被鼓励在 GitHub 上报告错误和提交拉取请求。在撰写本书时,GitHub 上的 Rust 仓库有 1,868 个独特的贡献者,超过 2,700 个开放的错误报告和 90 个开放的拉取请求。Rust 的核心团队由 Mozilla 员工和其他组织(如 Google、百度等)的贡献者组成。团队使用 GitHub 进行所有协作;即使是任何组件的重大更改,也必须首先通过撰写 请求评论(RFC)来提出。这样,每个人都有机会查看它并协作改进它。一旦获得批准,实际更改就可以实施。
-
编译器:Rust 编译器的名称为 rustc。由于 Rust 对编译器版本采用语义版本控制,因此在小版本之间不可能有任何向后不兼容的破坏性更改。在撰写本书时,编译器已经达到 1.0 版本,因此可以假设在 2.0 版本之前不会有任何破坏性更改。请注意,偶尔确实会有破坏性更改发生。但在所有这些情况下,它们都被视为错误,并尽快修复。
为了在不破坏现有依赖库的情况下方便添加新的编译器功能,Rust 以阶段的方式发布新的编译器版本。在任何时候,都维护着三个不同的编译器版本(以及标准库)。
-
第一个版本被称为 nightly。正如其名所示,它每晚从源代码树的顶端构建。由于这个版本只经过单元和集成测试,因此在现实世界中通常会有更多错误。
-
第二阶段是 beta,这是一个计划中的发布版本。当一个夜间版本达到这个阶段时,它已经经过了多次单元、集成和回归测试。此外,社区也有时间在实际项目中使用它并与 Rust 团队分享反馈。
-
一旦每个人都对发布版本有信心,它就会被标记为 稳定 版本并发布。由于编译器支持各种平台(从 Windows 到 Redox)和各种架构(amd64),每个版本都为所有平台和架构的组合提供了预构建的二进制文件。
-
安装机制:社区支持的安装机制是通过一个名为 rustup 的工具。这个工具可以安装指定版本的 Rust 以及使用它所需的所有内容(包括编译器、标准库、包管理器等)。
-
包管理器:Rust 的包管理器称为 Cargo,而单个包称为 crates。所有外部库和应用程序都可以打包成一个 crate,并使用 Cargo CLI 工具发布。用户可以使用它来搜索和安装包。所有 crate 都可以使用以下网站进行搜索:
crates.io/。对于所有托管在 crates.io 上的包,相应的文档可在:docs.rs/上找到。
Rust 入门
Rust 工具链安装程序可在www.rustup.rs/找到。以下命令将在系统上安装工具链的所有三个版本。对于本书中的示例,我们将使用运行 Ubuntu 16.04 的 Linux 机器。虽然 Rust 的大部分内容不应依赖于操作系统,但可能会有一些细微的差异。
我们将指出对操作系统的任何严格依赖:
# curl https://sh.rustup.rs -sSf | sh
# source $HOME/.cargo/env
# rustup install nightly beta
我们需要通过编辑.bashrc将 Cargo 的 bin 目录添加到我们的PATH中。运行以下命令来完成此操作:
$ echo "export PATH=$HOME/.cargo/bin:$PATH" >> ~/.bashrc
Rust 的安装自带了大量内置文档;可以通过运行以下命令来访问它们。这应该在浏览器窗口中打开文档:
# rustup doc
下一步是设置 Rust 项目并运行它,全部使用 Cargo:
# cargo new --bin hello-rust
这告诉 Cargo 在当前目录下设置一个名为hello-rust的新项目。Cargo 将创建一个同名目录并设置基本结构。由于此项目的类型被设置为二进制,Cargo 将生成一个名为main.rs的文件,该文件将包含一个空的main函数,这是应用程序的入口点。这里的另一个(默认)选项是库,在这种情况下,将生成一个名为lib.rs的文件。名为Cargo.toml的文件包含当前项目的元数据,并由 Cargo 使用。所有源代码都位于src目录中:
# tree hello-rust/
hello-rust/
├── Cargo.toml
└── src
└── main.rs
1 directory, 2 files
然后,可以使用以下命令构建和运行项目。请注意,此命令应在 Cargo 之前创建的hello-rust目录中运行:
# cargo run
有趣的是,此命令对目录进行了相当大的修改。target目录包含编译工件。其结构高度依赖于平台,但始终包括在给定构建模式下运行应用程序所需的所有内容。默认构建模式是debug,它包括用于调试器的调试信息和符号:
# tree hello-rust/
hello-rust/
├── Cargo.lock
├── Cargo.toml
├── src
│ └── main.rs
└── target
└── debug
├── build
├── deps
│ └── hello_rust-392ba379262c5523
├── examples
├── hello-rust
├── hello-rust.d
├── incremental
└── native
8 directories, 6 files
crates.io:
# cargo search term
Updating registry `https://github.com/rust-lang/crates.io-index`
term = "0.4.6" # A terminal formatting library
ansi_term = "0.9.0" # Library for ANSI terminal colours and styles (bold, underline)
term-painter = "0.2.4" # Coloring and formatting terminal output
term_size = "0.3.0" # functions for determining terminal sizes and dimensions
rust_erl_ext = "0.2.1" # Erlang external term format codec.
slog-term = "2.2.0" # Unix terminal drain and formatter for slog-rs
colored = "1.5.2" # The most simple way to add colors in your terminal
term_grid = "0.1.6" # Library for formatting strings into a grid layout
rust-tfidf = "1.0.4" # Library to calculate TF-IDF (Term Frequency - Inverse Document Frequency) for generic documents
aterm = "0.20.0" # Implementation of the Annotated Terms data structure
... and 1147 crates more (use --limit N to see more)
hello and world in green and red, respectively:
[package]
name = "hello-rust"
version = "0.1.0"
authors = ["Foo Bar <foo.bar@foobar.com>"]
[dependencies]
term = "0.4.6"
hello world! on the screen. Further, it prints hello in green and world! in red:
// chapter2/hello-rust/src/main.rs
extern crate term;
fn main() {
let mut t = term::stdout().unwrap();
t.fg(term::color::GREEN).unwrap();
write!(t, "hello, ").unwrap();
t.fg(term::color::RED).unwrap();
writeln!(t, "world!").unwrap();
t.reset().unwrap();
}
在 Rust 中,每个应用程序都必须有一个名为main的单个入口点,该入口点应定义为不带参数的函数。函数使用fn关键字定义。短语extern crate term告诉工具链我们想要将外部 crate 作为当前应用程序的依赖项。
现在,我们可以使用 Cargo 运行它。它将自动下载和构建所需的库及其所有依赖项。最后,它调用 Rust 编译器,以便我们的应用程序与库链接并运行可执行文件。Cargo 还生成一个名为Cargo.lock的文件,该文件包含以一致方式运行应用程序所需的所有内容的快照。此文件不应手动编辑。由于 cargo 在本地缓存所有依赖项,后续调用不需要互联网访问:
$ cargo run
Updating registry `https://github.com/rust-lang/crates.io-index`
Compiling term v0.4.6
Compiling hello-rust v0.1.0 (file:///Users/Abhishek/Desktop/rust-book/src/chapter2/hello-rust)
Finished dev [unoptimized + debuginfo] target(s) in 3.20 secs
Running `target/debug/hello-rust`
hello, world!
借用检查器简介
Rust 最重要的方面是所有权和借用模型。基于对借用规则的严格执行,编译器可以在没有外部垃圾收集器的情况下保证内存安全。这是通过借用检查器完成的,它是编译器的一个子系统。根据定义,每个创建的资源都有一个生命周期和与之关联的所有者,它遵循以下规则:
-
在任何时间点,每个资源都恰好有一个所有者。默认情况下,所有者是创建该资源的变量,其生命周期与包含的作用域相同。其他人如果需要可以借用或复制该资源。请注意,资源可以是任何东西,从变量或函数。函数从其调用者那里接管资源;从函数返回时,所有权会转移回去。
-
当所有者的作用域执行完毕后,它拥有的所有资源都将被丢弃。这是由编译器静态计算的,然后根据此产生机器代码。
以下代码片段展示了这些规则的一些示例:
// chapter2/ownership-heap.rs
fn main() {
let s = String::from("Test");
heap_example(s);
}
fn heap_example(input: String) {
let mystr = input;
let _otherstr = mystr;
println!("{}", mystr);
}
在 Rust 中,使用let关键字声明变量。所有变量默认是不可变的,可以通过使用mut关键字使其可变。::语法指的是给定命名空间中的对象,在这种情况下是from函数。println!是编译器提供的一个内置宏,用于写入标准输出并带有换行符。函数使用fn关键字定义。当我们尝试构建它时,我们得到以下错误:
# rustc ownership-heap.rs
error[E0382]: use of moved value: `mystr`
--> ownership-heap.rs:9:20
|
8 | let _otherstr = mystr;
| --------- value moved here
9 | println!("{}", mystr);
| ^^^^^ value used here after move
|
= note: move occurs because `mystr` has type `std::string::String`, which does not implement the `Copy` trait
error: aborting due to previous error
在这个例子中,创建了一个字符串资源,由函数heap_example中的变量mystr拥有。因此,其生命周期与作用域相同。在底层,由于编译器在编译时不知道字符串的长度,它必须将其放置在堆上。所有者变量在栈上创建,并指向堆上的资源。当我们将资源分配给新变量时,资源现在由新变量拥有。Rust 会在此时将mystr标记为无效,以防止与资源相关的内存可能被多次释放的情况。因此,编译失败以确保内存安全。我们可以强制编译器复制资源,并让第二个所有者指向新创建的资源。为此,我们需要.clone()名为mystr的资源。以下是它的样子:
// chapter2/ownership-heap-fixed.rs
fn main() {
let s = String::from("Test");
heap_example(s);
}
fn heap_example(input: String) {
let mystr = input;
let _otherstr = mystr.clone();
println!("{}", mystr);
}
如预期的那样,在编译时没有抛出任何错误,并且在运行时打印了给定的字符串"Test"。注意,到目前为止,我们一直在使用 Cargo 来运行我们的代码。由于在这种情况下,我们只有一个简单的文件,没有外部依赖,我们将直接使用 Rust 编译器来编译我们的代码,并且我们将手动运行它:
$ rustc ownership-heap-fixed.rs && ./ownership-heap-fixed
Test
考虑以下代码示例,它展示了资源存储在栈上的情况:
// chapter2/ownership-stack.rs
fn main() {
let i = 42;
stack_example(i);
}
fn stack_example(input: i32) {
let x = input;
let _y = x;
println!("{}", x);
}
有趣的是,尽管它与之前的代码块看起来完全相同,但这不会抛出编译错误。我们直接使用 Rust 编译器从命令行构建和运行这个程序:
# rustc ownership-stack.rs && ./ownership-stack
42
差别在于变量的类型。在这里,原始所有者和资源都是在栈上创建的。当资源被重新分配时,它会被复制到新的所有者那里。这是可能的,因为编译器知道整数的尺寸总是固定的(因此可以放在栈上)。Rust 提供了一种特殊的方式来表示一个类型可以通过 Copy 特性放在栈上。我们的例子之所以可行,仅仅是因为内置整数(以及一些其他类型)被标记了此特性。我们将在后续章节中更详细地解释特性系统。
有些人可能已经注意到,将未知长度的资源复制到一个函数中可能会导致内存膨胀。在许多语言中,调用者会传递一个指向内存位置的指针,然后传递给函数。Rust 通过使用引用来实现这一点。这些引用允许你引用一个资源,而不必真正拥有它。当一个函数接收一个资源的引用时,我们说它借用了那个资源。在下面的例子中,函数 heap_example 借用了变量 s 所拥有的资源。由于借用不是绝对的所有权,借用变量的作用域不会影响与资源相关的内存释放方式。这也意味着在函数中不可能多次释放借用的资源,因为函数作用域内没有人真正拥有那个资源。因此,之前失败的代码在这个情况下是可行的:
// chapter2/ownership-borrow.rs
fn main() {
let s = String::from("Test");
heap_example(&s);
}
fn heap_example(input: &String) {
let mystr = input;
let _otherstr = mystr;
println!("{}", mystr);
}
借用规则还意味着借用是不可变的。然而,可能会有需要修改借用的借用的情况。为了处理这些情况,Rust 允许可变引用(或借用)。正如预期的那样,这让我们回到了第一个例子中的问题,并且编译失败,以下代码:
// chapter2/ownership-mut-borrow.rs
fn main() {
let mut s = String::from("Test");
heap_example(&mut s);
}
fn heap_example(input: &mut String) {
let mystr = input;
let _otherstr = mystr;
println!("{}", mystr);
}
注意,一个资源在作用域内只能被可变借用一次。编译器将拒绝编译尝试做其他事情的代码。虽然这看起来可能像是一个令人烦恼的错误,但你需要记住,在一个工作应用程序中,这些函数通常会从竞争的线程中被调用。如果由于编程错误导致同步错误,我们最终会得到一个数据竞争,其中多个未同步的线程竞相修改相同的资源。这个特性有助于防止这种情况。
另一个与引用密切相关的高级语言特性是生存期的概念。引用在作用域内存在时存活,因此其生存期是封装作用域的生存期。在 Rust 中声明的所有变量都可以有一个显式的生存期省略,这给它赋予了一个名称。这对于借用检查器推理变量的相对生存期很有用。一般来说,不需要为每个变量都指定显式的生存期名称,因为编译器会管理这一点。在某些场景下,这很有必要,尤其是在自动生存期确定无法工作的情况下。让我们看看一个发生这种情况的例子:
// chapter2/lifetime.rs
fn main() {
let v1 = vec![1, 2, 3, 4, 5];
let v2 = vec![1, 2];
println!("{:?}", longer_vector(&v1, &v2));
}
fn longer_vector(x: &[i32], y: &[i32]) -> &[i32] {
if x.len() > y.len() { x } else { y }
}
vec! 宏从给定的一组对象列表中构建一个向量。请注意,与之前的例子不同,我们这里的函数需要返回一个值给调用者。我们需要使用箭头语法来指定返回类型。这里,我们给出了两个向量,我们想打印出两个中最长的。我们的 longer_vector 函数正是这样做的。它接收两个向量的引用,计算它们的长度,并返回长度较大的那个向量的引用。这会导致以下错误而无法编译:
# rustc lifetime.rs
error[E0106]: missing lifetime specifier
--> lifetime.rs:8:43
|
8 | fn longer_vector(x: &[i32], y: &[i32]) -> &[i32] {
| ^ expected lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
error: aborting due to previous error
这告诉我们编译器无法确定返回的引用应该指向第一个参数还是第二个参数,因此它无法确定其应该存活多长时间。由于我们没有控制输入,所以在编译时无法确定这一点。这里的一个关键洞察是,我们不需要在编译时知道所有引用的生存期。我们需要确保以下事项成立:
-
两个输入应该具有相同的生存期,因为我们想在函数中比较它们的长度
-
返回值应该具有与输入相同的生存期,即两者中较长的那个
给定这两个公理,可以得出结论,两个输入和返回值应该具有相同的生存期。我们可以像以下代码片段所示那样进行注释:
fn longer_vector<'a>(x: &'a[i32], y: &'a[i32]) -> &'a[i32] {
if x.len() > y.len() { x } else { y }
}
这正如预期的那样工作,因为编译器可以愉快地保证代码的正确性。生存期参数也可以附加到结构体和方法定义上。有一个特殊的生存期称为 'static,它指的是整个程序的生命周期。
Rust 最近接受了一个提议,要添加一个新的指定生存期 'fn,它将与最内层函数或闭包的作用域相等。
泛型和特质系统
Rust 支持编写在编译时或运行时绑定到更具体类型的泛型代码。熟悉 C++ 模板的开发者可能会注意到,Rust 中的泛型在语法上与模板非常相似。以下示例说明了如何使用泛型编程。我们还将介绍一些之前未讨论过的新结构,我们将在继续进行时进行解释。
与 C 和 C++类似,Rust 的struct定义了一个用户自定义的类型,它将多个逻辑上连接的资源聚合在一个单元中。我们这里的struct定义了一个包含两个变量的元组。我们定义了一个泛型struct,并使用了一个泛型type parameter,在这里写作<T>。struct中的每个成员都被定义为该类型。我们后来定义了一个泛型函数,用于计算元组的两个元素的和。让我们看看这个简单的实现:
// chapter2/generic-function.rs
struct Tuple<T> {
first: T,
second: T,
}
fn main() {
let tuple_u32: Tuple<u32> = Tuple {first: 4u32, second: 2u32 };
let tuple_u64: Tuple<u64> = Tuple {first: 5u64, second: 6u64 };
println!("{}", sum(tuple_u32));
println!("{}", sum(tuple_u64));
let tuple: Tuple<String> = Tuple {first: "One".to_owned(), second: "Two".to_owned() };
println!("{}", sum(tuple));
}
fn sum<T>(tuple: Tuple<T>) -> T
{
tuple.first + tuple.second
}
这将无法编译,编译器抛出了以下错误:
$ rustc generic-function.rs
error[E0369]: binary operation `+` cannot be applied to type `T`
--> generic-function-error.rs:18:5
|
18 | tuple.first + tuple.second
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: `T` might need a bound for `std::ops::Add`
error: aborting due to previous error
这个错误很重要。编译器告诉我们它不知道如何将两个类型为T的操作数相加。它还(正确地)猜测T类型需要通过Add特质进行绑定。这意味着T可能的实际类型列表应该只包含实现了Add特质的类型,这些类型的实际引用可以进行相加。让我们继续在sum函数中添加这个特质绑定。现在我们的代码应该看起来像这样:
// chapter2/generic-function-fixed.rs
use std::ops::Add;
struct Tuple<T> {
first: T,
second: T,
}
fn main() {
let tuple_u32: Tuple<u32> = Tuple {first: 4u32, second: 2u32 };
let tuple_u64: Tuple<u64> = Tuple {first: 5u64, second: 6u64 };
println!("{}", sum(tuple_u32));
println!("{}", sum(tuple_u64));
// These lines fail to compile
let tuple: Tuple<String> = Tuple {first: "One".to_owned(), second: "Two".to_owned() };
println!("{}", sum(tuple));
}
// We constrain the possible types of T to those which implement the Add trait
fn sum<T: Add<Output = T>>(tuple: Tuple<T>) -> T
{
tuple.first + tuple.second
}
为了使这可行,元素必须是可求和的;它们相加应该有一个逻辑意义。因此,我们将T参数可能的类型限制为实现了Add特质的那些类型。我们还需要让编译器知道这个函数的输出应该是T类型。有了这些信息,我们可以构造元组并调用求和函数,它们将按预期行为。此外,请注意,字符串的元组将无法编译,因为字符串没有实现Add特质。
从最后一个例子中,人们可能会注意到特质对于正确实现泛型是必不可少的。它们帮助编译器推理泛型类型的属性。本质上,一个特质定义了一个类型的属性。库定义了一组常用特质及其内置类型的实现。对于任何用户自定义类型,用户需要通过定义和实现特质来定义这些类型应该具有的属性。
// chapter2/traits.rs
trait Max<T> {
fn max(&self) -> T;
}
struct ThreeTuple<T> {
first: T,
second: T,
third: T,
}
// PartialOrd enables comparing
impl<T: PartialOrd + Copy> Max<T> for ThreeTuple<T> {
fn max(&self) -> T {
if self.first >= self.second && self.first >= self.third {
self.first
}
else if self.second >= self.first && self.second >= self.third {
self.second
}
else {
self.third
}
}
}
struct TwoTuple<T> {
first: T,
second: T,
}
impl<T: PartialOrd + Copy> Max<T> for TwoTuple<T> {
fn max(&self) -> T {
if self.first >= self.second {
self.first
} else { self.second }
}
}
fn main() {
let two_tuple: TwoTuple<u32> = TwoTuple {first: 4u32, second: 2u32 };
let three_tuple: ThreeTuple<u64> = ThreeTuple {first: 6u64,
second: 5u64, third: 10u64 };
println!("{}", two_tuple.max());
println!("{}", three_tuple.max());
}
我们首先定义一个泛型特性类型T。我们的特性有一个单一的功能,它将返回实现它的给定类型的最大值。什么算是最大值是一个实现细节,在这个阶段并不重要。然后我们定义一个包含三个元素的元组,每个元素都是相同的泛型类型。稍后,我们为定义的类型实现我们的特性。在 Rust 中,如果没有显式的返回语句,函数会返回最后一个表达式。使用这种风格在社区中被认为是惯用的。我们的max函数在if...else块中使用了这个特性。为了使实现工作,泛型类型必须在它们之间定义一个排序关系,这样我们就可以比较它们。在 Rust 中,这是通过将可能的类型限制为实现了PartialOrd特性的类型来实现的。我们还需要对Copy特性施加约束,以便编译器可以在从函数返回之前对 self 参数进行复制。我们继续定义另一个包含两个元素的元组。我们以类似的方式在这里实现相同的特性。当我们实际上在main函数中使用这些特性时,它们按预期工作。
特性也可以用来扩展内置类型并添加新的功能。让我们看看以下示例:
// chapter2/extending-types.rs
// Trait for our behavior
trait Sawtooth {
fn sawtooth(&self) -> Self;
}
// Extending the builtin f64 type
impl Sawtooth for f64 {
fn sawtooth(&self) -> f64 {
self - self.floor()
}
}
fn main() {
println!("{}", 2.34f64.sawtooth());
}
在这里,我们想要在内置的f64类型上实现锯齿波(en.wikipedia.org/wiki/Sawtooth_wave)函数。这个函数在标准库中不可用,因此我们需要编写一些代码来通过扩展标准库使其工作。为了无缝集成到类型系统中,我们需要定义一个特性和为f64类型实现它。这使我们能够使用新的函数,就像使用f64类型上的任何其他内置函数一样,通过点符号使用。
标准库提供了一些内置特性;其中最常见的是Display和Debug。这两个特性用于在打印时格式化类型。Display对应于空格式化器{},而Debug对应于格式化调试输出。所有的数学运算都定义为特性,例如Add、Div等。如果用户定义的类型带有#[derive]属性,编译器会尝试提供默认实现。然而,如果需要,实现可以选择覆盖这些中的任何一个。以下代码片段展示了这种情况:
// chapter2/derive.rs
use std::fmt;
use std::fmt::Display;
#[derive(Debug, Hash)]
struct Point<T> {
x: T,
y: T,
}
impl<T> fmt::Display for Point<T> where T: Display {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
fn main() {
let p: Point<u32> = Point { x: 4u32, y: 2u32 };
// uses Display
println!("{}", p);
// uses Debug
println!("{:?}", p);
}
我们定义了一个具有两个字段的泛型点结构。我们让编译器生成一些常见特质的实现。然而,我们必须手动实现 Display 特质,因为编译器无法确定显示用户定义类型的最优方式。我们必须使用 where 子句将泛型类型限制为实现了 Display 特质的类型。这个例子还展示了基于特质约束类型的另一种方法。在设置好所有这些之后,我们可以使用默认格式化程序显示我们的点。这会产生以下输出:
# rustc derive.rs && ./derive
(4, 2)
Point { x: 4, y: 2 }
错误处理
Rust 的一个主要目标就是让开发者能够编写健壮的软件。这其中的一个关键组成部分就是高级错误处理。在本节中,我们将更深入地探讨 Rust 是如何处理错误的。但在那之前,让我们先偏离一下,看看一些类型理论。具体来说,我们感兴趣的是代数数据类型(ADT),这是通过组合其他类型形成的类型。最常见的两种 ADT 是求和类型和乘积类型。Rust 中的 struct 是乘积类型的一个例子。这个名字来源于这样一个事实:给定一个结构体,其类型的范围本质上是其各个组成部分的范围的笛卡尔积,因为类型的实例具有其所有组成部分类型的值。相反,当 ADT 只能假设其组成部分之一的数据类型时,就是求和类型。Rust 中的 enum 就是这样一个例子。虽然 Rust 中的枚举与 C 语言和其他语言中的枚举类似,但 Rust 枚举提供了一些增强功能:它们允许变体携带数据。
现在,回到错误处理。Rust 强制要求可能产生错误的操作必须返回一个特殊的 enum,该 enum 携带结果。方便的是,这个 enum 看起来是这样的:
enum Result<T, E> {
Ok(T),
Err(E),
}
两种可能的选择被称为变体。在这种情况下,它们分别代表非错误情况和错误情况。请注意,这是通用的定义,因此实现可以自由地在两种情况下定义类型。这在需要扩展标准错误类型并实现自定义错误的程序中非常有用。让我们看看一个实际应用的例子:
// chapter2/custom-errors.rs
use std::fmt;
use std::error::Error;
#[derive(Debug)]
enum OperationsError {
DivideByZeroError,
}
// Useful for displaying the error nicely
impl fmt::Display for OperationsError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
OperationsError::DivideByZeroError => f.write_str("Cannot divide by zero"),
}
}
}
// Registers the custom error as an error
impl Error for OperationsError {
fn description(&self) -> &str {
match *self {
OperationsError::DivideByZeroError => "Cannot divide by zero",
}
}
}
// Divides the dividend by the divisor and returns the result. Returns
// an error if the divisor is zero
fn divide(dividend: u32, divisor: u32) -> Result<u32, OperationsError> {
if divisor == 0u32 {
Err(OperationsError::DivideByZeroError)
} else {
Ok(dividend / divisor)
}
}
fn main() {
let result1 = divide(100, 0);
println!("{:?}", result1);
let result2 = divide(100, 2);
println!("{:?}", result2.unwrap());
}
对于这个示例,我们定义了一个函数,该函数简单地返回第一个操作数除以第二个操作数时的商。这个函数必须处理除数为零的错误情况。我们还想让它在其调用者遇到这种情况时发出错误信号。此外,让我们假设这是一个将扩展以包含更多此类操作的库的一部分。为了使代码易于管理,我们为我们的库创建了一个错误类,其中包含一个表示除以零错误的元素。为了让 Rust 编译器知道枚举是一个错误类型,我们的枚举必须实现标准库中的Error特质。它还需要手动实现Display特质。在设置好这些样板代码之后,我们可以定义我们的除法方法。我们将利用泛型Result特质来注解,在成功的情况下,它应该返回一个u32类型的值,与操作数相同。在失败的情况下,它应该返回类型为OperationsError的错误。在函数中,如果我们的除数为零,我们将引发错误。否则,我们执行除法,将结果包装在Ok中,使其成为Result枚举的一个变体,并返回它。在我们的main函数中,我们用零除数调用这个函数。结果将是一个错误,如第一个打印宏所示。在第二次调用中,我们知道除数不是零。因此,我们可以安全地解包结果,将其从Ok(50)转换为50。标准库提供了一些实用函数来处理Result类型,安全地向调用者报告错误。
这里是上一个示例的运行样本:
$ rustc custom-errors.rs && ./custom-errors
Err(DivideByZeroError)
50
None variant. The Some variant handles the case where it holds the actual value of the type T:
pub enum Option<T> {
None,
Some(T),
}
给定这种类型,我们可以这样编写我们的除法函数:
// chapter2/options.rs
fn divide(dividend: u32, divisor: u32) -> Option<u32> {
if divisor == 0u32 {
None
} else {
Some(dividend / divisor)
}
}
fn main() {
let result1 = divide(100, 0);
match result1 {
None => println!("Error occurred"),
Some(result) => println!("The result is {}", result),
}
let result2 = divide(100, 2);
println!("{:?}", result2.unwrap());
}
我们修改我们的函数以返回一个类型为u32的Option。在我们的main函数中,我们调用我们的函数。在这种情况下,我们可以根据返回类型进行匹配。如果它恰好是None,我们知道函数没有成功。在这种情况下,我们可以打印一个错误。如果它返回Some,我们提取其底层值并打印它。第二次调用工作正常,因为我们知道它没有收到零除数。使用Option进行错误处理可能更容易管理,因为它涉及更少的样板代码。然而,在具有自定义错误类型的库中,这可能有点难以管理,因为错误不是由类型系统处理的。
注意,Option可以表示为给定类型和单位类型的Result。
type Option<T> = Result<T, ()>;。
我们之前描述的错误处理已经使用了可恢复的错误。然而,在某些情况下,如果发生错误,中止执行可能是明智的。标准库提供了panic!宏来处理这种情况。调用此宏将停止当前线程的执行,在屏幕上打印一条消息,并回滚调用栈。然而,需要谨慎使用,因为在很多情况下,更好的选择是正确处理错误并将错误向上传递给调用者。
许多内置方法和函数在出错时会调用这个宏。让我们看看以下示例:
// chapter2/panic.rs
fn parse_int(s: String) -> u64 {
return s.parse::<u64>().expect("Could not parse as integer")
}
fn main() {
// works fine
let _ = parse_int("1".to_owned());
// panics
let _ = parse_int("abcd".to_owned());
}
这会引发以下错误:
# ./panic
thread 'main' panicked at 'Could not parse as integer: ParseIntError { kind: InvalidDigit }', src/libcore/result.rs:906:4
note: Run with `RUST_BACKTRACE=1` for a backtrace.
调用 panic 的一些方法是expect()和unwrap()。
宏系统
Rust 支持一个经过多年演变的宏系统。Rust 宏的一个显著特点是它们保证不会意外地引用其作用域之外的标识符,因此 Rust 中的宏实现是“卫生”的。正如预期的那样,Rust 宏在编译前被展开为源代码,并与翻译单元一起编译。编译器对展开的宏执行作用域规则以使它们卫生。Rust 宏与其他构造不同,因为它们总是以感叹号!结尾。
现代 Rust 有两种处理宏的方法;较老的,基于语法的宏方法,以及较新的,过程宏方法。让我们看看这些方法中的每一个:
语法宏
这个宏系统自 Rust 1.0 之前的版本以来一直是 Rust 的一部分。这些宏是通过一个名为macro_rules!的宏定义的。让我们看看一个示例:
// chapter2/syntactic-macro.rs
macro_rules! factorial {
($x:expr) => {
{
let mut result = 1;
for i in 1..($x+1) {
result = result * i;
}
result
}
};
}
fn main() {
let arg = std::env::args().nth(1).expect("Please provide only one argument");
println!("{:?}", factorial!(arg.parse::<u64>().expect("Could not parse to an integer")));
}
我们从定义阶乘宏开始。由于我们不希望编译器拒绝编译我们的代码,因为它可能会溢出宏栈,我们将使用非递归实现。Rust 中的语法宏是一组规则,其中左侧指定规则应该如何与输入匹配,右侧指定它应该展开成什么。规则通过=>运算符映射到右侧的表达式。规则局部变量使用$符号声明。匹配规则使用一种特殊的宏语言表达,它有一套自己的保留关键字。我们的声明表明我们希望接受任何有效的 Rust 表达式;在这种情况下,它应该评估为整数。我们将把它留给调用者来确保这是真的。然后我们从这个范围的最后一个整数开始循环到 1,同时累积结果。一旦完成,我们使用隐式返回语法返回结果。
我们的调用者是主函数,因为我们使用std::env模块从用户那里获取输入。我们获取列表中的第一个输入,如果没有输入则抛出错误。然后我们打印出宏的结果,并在传递之前尝试将输入解析为u64。我们还处理解析可能失败的情况。这按预期工作:
# rustc syntactic-macro.rs && ./syntactic-macro 5
120
Rust 还提供了一些调试宏的工具。有人可能会对查看展开的宏是什么样子感兴趣。trace_macros! 宏正是这样做的。为了使其工作,我们需要启用一个功能门,如下面的代码片段所示(由于在 Rust 中尚不稳定,此代码只能在 Rust nightly 版本中工作):
#![feature(trace_macros)]
trace_macros!(true);
注意,展开还包括println!,因为它是在标准库中定义的宏。
同样的展开也可以使用以下命令来调用编译器进行检查:
rustc -Z unstable-options --pretty expanded syntactic-macro.rs。
过程宏
虽然常规的语法宏在许多场景下很有用,但某些应用需要更高级的代码生成功能,这些功能最好使用编译器操作的抽象语法树(AST)来实现。因此,有必要扩展宏系统以包括这一功能。后来决定,旧的宏系统和这个新系统,即所谓的过程宏,将共存。随着时间的推移,这个新系统预计将取代语法宏系统。编译器支持从外部 crate 加载插件;这些插件可以在编译器生成 AST 后接收 AST。有 API 可以修改 AST 以添加所需的新代码。关于这个系统的详细讨论超出了本书的范围。
Rust 中的函数式特性
Rust 受到了像 Haskell 和 OCaml 这样的函数式语言的影响。不出所料,Rust 在语言和标准库中都对函数式编程提供了丰富的支持。在本节中,我们将探讨其中的一些。
高阶函数
我们之前已经看到,Rust 函数定义了一个独立的范围,其中所有局部变量都存在。因此,除非它们被明确地作为参数传递,否则范围外的变量永远不会泄漏到其中。可能会有这种情况,这不是期望的行为;闭包提供了一个类似匿名函数的机制,它能够访问其定义范围内定义的所有资源。这使得编译器能够强制执行相同的借用检查规则,同时使代码的重用更容易。在 Rust 术语中,一个典型的闭包会借用其周围作用域的所有绑定。可以通过使用move关键字标记来强制闭包拥有这些绑定。让我们看看一些例子:
// chapter2/closure-borrow.rs
fn main() {
// closure with two parameters
let add = |a, b| a + b;
assert_eq!(add(2, 3), 5);
// common use cases are on iterators
println!("{:?}", (1..10).filter(|x| x % 2 == 0).collect::<Vec<u32>>());
// using a variable from enclosing scope
let times = 2;
println!("{:?}", (1..10).map(|x| x * times).collect::<Vec<i32>>());
}
第一个例子是一个简单的闭包,它接受两个数字并对其求和。第二个例子更为复杂;它展示了闭包在函数式编程中的实际应用。我们感兴趣的是过滤一个整数列表,只收集其中的偶数。因此,我们从 1 到 10 的范围开始,它返回内置类型Range的一个实例。由于该类型实现了IntoIterator特质,该类型表现得像一个迭代器。因此,我们可以通过传递一个只返回输入可以被 2 整除的闭包来过滤它。最后,我们将结果迭代器收集到一个u32类型的向量中,并打印出来。最后一个例子在结构上相似。它从闭包的包围作用域中借用变量 times,并使用它来映射到范围的项目。
让我们看看在闭包中使用move关键字的一个例子:
// chapter2/closure-move.rs
fn main() {
let mut times = 2;
{
// This is in a new scope
let mut borrow = |x| times += x;
borrow(5);
}
assert_eq!(times, 7);
let mut own = move |x| times += x;
own(5);
assert_eq!(times, 7);
}
第一个闭包(borrow)与我们之前讨论的闭包之间的区别是,它会修改从封装作用域继承的变量。我们必须声明变量和闭包为mut。我们还需要将闭包放在不同的作用域中,这样编译器就不会在我们尝试断言其值时抱怨双重借用。如断言,名为borrow的闭包从其父作用域借用变量,这就是为什么它的原始值变为7。第二个名为own的闭包是一个移动闭包,因此它获得了变量times的一个副本。为了使这可行,变量必须实现Copy特质,这样编译器才能将其复制到闭包中,所有内置类型都这样做。由于闭包获取的变量和原始变量不是同一个,编译器不会抱怨双重借用。此外,变量的原始值不会改变。这类闭包在实现线程时非常重要,我们将在后面的章节中看到。标准库还支持使用多个内置特质在用户定义的函数或方法中接受和返回闭包,如下表所示:
| 特质名称 | 函数 |
|---|---|
std::ops::Fn |
由不接受可变捕获变量的闭包实现。 |
std::ops::FnMut |
由需要修改捕获变量的闭包实现。 |
std::ops::FnOnce |
由所有闭包实现。表示闭包可以被调用正好一次。 |
迭代器
另一个重要的功能性方面是惰性迭代。给定一组类型,应该能够以任何给定的顺序遍历这些类型或其子集。在 Rust 中,一个常见的迭代器是一个范围,它有一个起始点和结束点。让我们看看它们是如何工作的:
// chapter2/range.rs
#![feature(inclusive_range_syntax)]
fn main() {
let numbers = 1..5;
for number in numbers {
println!("{}", number);
}
println!("------------------");
let inclusive = 1..=5;
for number in inclusive {
println!("{}", number);
}
}
第一个范围是一个排他性范围,从第一个元素到倒数第二个元素。第二个范围是一个包含性范围,一直延伸到最后一个元素。请注意,包含性范围是一个实验性特性,未来可能会发生变化。
如预期的那样,Rust 确实提供了接口,允许用户定义的类型可以迭代。类型只需要实现std::iterator::Iterator特质。让我们看看一个例子。我们感兴趣的是生成给定整数的柯勒茨序列(en.wikipedia.org/wiki/Collatz_conjecture),这是由以下递归关系给出的,给定一个整数:
-
如果它是偶数,就除以二
-
如果它是奇数,就乘以 3 然后加 1
根据猜想,这个序列将始终终止于 1。我们将假设这是真的,并定义我们的代码以尊重这一点:
// chapter2/collatz.rs
// This struct holds state while iterating
struct Collatz {
current: u64,
end: u64,
}
// Iterator implementation
impl Iterator for Collatz {
type Item = u64;
fn next(&mut self) -> Option<u64> {
if self.current % 2 == 0 {
self.current = self.current / 2;
} else {
self.current = 3 * self.current + 1;
}
if self.current == self.end {
None
} else {
Some(self.current)
}
}
}
// Utility function to start iteration
fn collatz(start: u64) -> Collatz {
Collatz { current: start, end: 1u64 }
}
fn main() {
let input = 10;
// First 2 items
for n in collatz(input).take(2) {
println!("{}", n);
}
// Dropping first 2 items
for n in collatz(input).skip(2) {
println!("{}", n);
}
}
在我们的代码中,当前迭代的态由名为 Collatz 的结构体表示。我们在其上实现了 Iterator 协议。为此,我们需要实现 next 函数,该函数接收当前态并生成下一个态。当它达到结束态时,它必须返回一个 None,以便调用者知道迭代器已经耗尽。这由函数的可空返回值表示。鉴于递归关系,实现是直接的。在我们的主函数中,我们实例化初始态,我们可以使用常规的 for 循环进行迭代。Iterator 特质自动实现了许多有用的函数;take 函数从迭代器中取出给定数量的元素,而 skip 函数则跳过给定数量的元素。所有这些对于处理可迭代集合都非常重要。
以下是我们示例运行的结果:
$ rustc collatz.rs && ./collatz
5
16
8
4
2
并发原语
Rust 的一个承诺是能够实现 无畏的并发。很自然地,Rust 通过多种机制支持编写并发代码。在本章中,我们将讨论其中的一些。我们已经看到 Rust 编译器如何使用借用检查来确保程序在编译时的正确性。事实证明,这些原语在验证并发代码的正确性时也非常有用。现在,有多种方式在语言中实现线程。最简单的方式是为平台中创建的每个线程创建一个新的操作系统线程。这通常被称为 1:1 线程。另一方面,多个应用程序线程可以映射到一个操作系统线程。这被称为 N:1 线程。虽然这种方法资源消耗较少,因为我们最终得到的实际线程较少,但上下文切换的开销更高。一种中间方案称为 M:N 线程,其中多个应用程序线程映射到多个操作系统级别的线程。这种方法需要最大的保护措施,并且使用运行时实现,而 Rust 避免使用运行时。因此,Rust 使用 1:1 模型。在 Rust 中,一个线程对应一个操作系统线程,这与 Go 等语言不同。让我们先看看 Rust 是如何使编写多线程应用程序成为可能的:
// chapter2/threads.rs
use std::thread;
fn main() {
for i in 1..10 {
let handle = thread::spawn(move || {
println!("Hello from thread number {}", i);
});
let _ = handle.join();
}
}
我们首先导入线程库。在我们的主函数中,我们创建一个空向量,我们将使用它来存储我们创建的线程的引用,以便我们可以等待它们退出。线程实际上是通过 thread::spawn 创建的,我们必须传递一个闭包,该闭包将在每个线程中执行。由于我们必须从封装作用域(循环索引 i)借用变量,因此闭包本身必须是一个移动闭包。在退出闭包之前,我们调用当前线程句柄的 join,以便所有线程相互等待。这产生了以下输出:
# rustc threads.rs && ./threads
Hello from thread number 1
Hello from thread number 2
Hello from thread number 3
Hello from thread number 4
Hello from thread number 5
Hello from thread number 6
Hello from thread number 7
Hello from thread number 8
Hello from thread number 9
多线程应用程序的真正力量在于线程可以合作完成有意义的工作。为此,需要两个重要的事情。线程需要能够相互传递数据,并且应该有方法来协调线程的调度,以便它们不会相互干扰。对于第一个问题,Rust 通过通道提供了消息传递机制。让我们看看以下示例:
// chapter2/channels.rs
use std::thread;
use std::sync::mpsc;
fn main() {
let rhs = vec![10, 20, 30, 40, 50, 60, 70];
let lhs = vec![1, 2, 3, 4, 5, 6, 7];
let (tx, rx) = mpsc::channel();
assert_eq!(rhs.len(), lhs.len());
for i in 1..rhs.len() {
let rhs = rhs.clone();
let lhs = lhs.clone();
let tx = tx.clone();
let handle = thread::spawn(move || {
let s = format!("Thread {} added {} and {}, result {}", i,
rhs[i], lhs[i], rhs[i] + lhs[i]);
tx.clone().send(s).unwrap();
});
let _ = handle.join().unwrap();
}
drop(tx);
for result in rx {
println!("{}", result);
}
}
这个例子与上一个例子非常相似。我们导入必要的模块以便能够处理通道。我们定义了两个向量,并将为两个向量中的每一对元素创建一个线程,以便我们可以将它们相加并返回结果。我们创建了通道,它返回发送端和接收端的句柄。作为一个安全检查,我们确保两个向量确实具有相同的长度。然后,我们继续创建我们的线程。由于我们在这里需要访问外部变量,线程需要接受一个类似于上一个例子的move闭包。此外,编译器将尝试使用Copy特质来复制这些变量到线程中。在这种情况下,这将会失败,因为向量类型没有实现Copy。我们需要显式地clone资源,这样它们就不需要被复制。我们运行计算,并将结果发送到管道的发送端。稍后,我们连接所有线程。在我们遍历接收端并打印结果之前,我们需要显式地丢弃对原始发送端句柄的引用,这样在我们开始接收之前,所有发送者都会被销毁(复制的发送者将在线程退出时自动销毁)。这会打印出以下内容,正如预期的那样:
# rustc channels.rs && ./channels
Thread 1 added 20 and 2, result 22
Thread 2 added 30 and 3, result 33
Thread 3 added 40 and 4, result 44
Thread 4 added 50 and 5, result 55
Thread 5 added 60 and 6, result 66
Thread 6 added 70 and 7, result 77
还要注意,mpsc 代表多个生产者单消费者。
在与多个线程一起工作时,另一个常见的习惯用法是在所有这些线程之间共享一个公共状态。然而,在许多情况下,这可能会成为一个问题。调用者需要仔细设置排除机制,以确保状态在无竞争的情况下共享。幸运的是,借用检查器可以帮助确保这一点更容易。Rust 有几个智能指针用于处理共享状态。库还提供了一个通用的互斥锁类型,可以在多线程工作时用作锁。但可能最重要的是Send和Sync特质。任何实现了Send特质的数据类型都可以在多个线程之间安全共享。Sync特质表示对给定数据的访问在多个线程中是安全的。这些特质有一些规则:
-
所有内置类型都实现了
Send和Sync特质,除了任何unsafe类型,一些智能指针类型如Rc<T>和UnsafeCell<T>。 -
如果复合类型没有包含任何没有实现
Send和Sync的类型,它将自动实现这两个特质。
std::sync包提供了许多类型和辅助工具,用于处理并行代码。
在上一段中,我们提到了 unsafe Rust。让我们稍微详细地看看这一点。Rust 编译器通过使用健壮的类型系统提供了一些关于安全编程的强保证。然而,在某些情况下,这些可能更多地成为负担。为了处理这些情况,语言提供了一种退出这些保证的方法。标记为 unsafe 关键字的代码块可以做 Rust 可以做的任何事情,以及以下:
-
解引用原始指针类型 (*mut T 或
*const T) -
调用 unsafe 函数或方法
-
实现一个标记为
unsafe的特质 -
修改静态变量
让我们看看一个使用 unsafe 代码块来解引用指针的示例:
// chapter2/unsafe.rs
fn main() {
let num: u32 = 42;
let p: *const u32 = #
unsafe {
assert_eq!(*p, num);
}
}
这里,我们创建了一个变量及其指针;如果我们尝试在不使用 unsafe 块的情况下解引用指针,编译器将拒绝编译。在 unsafe 块内部,我们在解引用时获得了原始值。虽然与 unsafe 代码一起工作可能很危险,但它对于底层编程(如内核(RedoxOS)和嵌入式系统)非常有用。
测试
Rust 将测试视为一等构造;生态系统中的所有工具都支持测试。编译器提供了一个内置的配置属性,用于指定测试模块。还有一个测试属性,用于指定函数为测试。当 Cargo 从头生成项目时,它会设置这个样板代码。让我们看看一个示例项目;我们将称之为阶乘。它将导出一个宏,用于计算给定整数的阶乘。由于我们之前已经方便地编写了这样一个宏,所以我们在这里将重用那段代码。请注意,由于这个 crate 将用作库,它没有主函数:
# cargo new factorial --lib
# cargo test
Compiling factorial v0.1.0 (file:///Users/Abhishek/Desktop/rust-book/src/ch2/factorial)
Finished dev [unoptimized + debuginfo] target(s) in 1.6 secs
Running target/debug/deps/factorial-364286f171614349
running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Doc-tests factorial
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
运行 cargo test 会运行 Cargo 为我们生成的存根测试。我们将复制阶乘宏的代码到 lib.rs,它看起来像这样:
// chapter2/factorial/src/lib.rs
#[allow(unused_macros)]
#[macro_export]
macro_rules! factorial {
($x:expr) => {
{
let mut result = 1;
for i in 1..($x+1) {
result = result * i;
}
result
}
};
}
#[cfg(test)]
mod tests {
#[test]
fn test_factorial() {
assert_eq!(factorial!(5), 120);
}
}
我们还添加了一个测试,以确保阶乘确实按预期工作。#[macro_export] 属性告诉编译器这个宏将在 crate 外部使用。编译器内置的 assert_eq! 宏检查两个参数是否确实相等。我们还需要放置 #[allow(unused_macros)] 属性,因为没有它,编译器会抱怨这个宏在非测试代码中没有使用。如果我们添加一个类似的测试:
#[test]
fn test_factorial_fail() {
assert_eq!(factorial!(5), 121);
}
这显然是错误的,并且如预期的那样失败,并给出了描述性的错误。编译器还支持一个名为 #[should_panic] 的属性,用于标记应该引发恐慌的测试。在这种情况下,只有当发生恐慌时,测试才会通过。另一种编写测试的方法是在文档中,这也将在 Cargo 调用中运行。
这是一个在文档中带有工作示例的重要工具,这些示例在代码库演变过程中保证能够正常工作。让我们继续添加一些关于阶乘宏的 doctest:
// chapter2/factorial/src/lib.rs
/// The factorial crate provides a macro to compute factorial of a given
/// integer
/// # Examples
///
/// ```
/// # #[macro_use] extern crate factorial;
/// # fn main() {
/// assert_eq!(factorial!(0), 1);
/// assert_eq!(factorial!(6), 720);
/// # }
/// ```rs
///
#[macro_export]
macro_rules! factorial {
($x:expr) => {
{
let mut result = 1;
for i in 1..($x+1) {
result = result * i;
}
result
}
};
}
宏的 doctests 与其他内容的 doctests 略有不同:
-
他们必须使用
#[macro_use]属性来标记这里正在使用宏。请注意,依赖于导出宏的 crate 的外部 crate 也必须使用该属性。 -
他们必须定义主函数并在 doctests 中包含一个
extern crate指令。对于其他所有内容,编译器会根据需要生成主函数。额外的#标记隐藏了这些内容,使其不会出现在生成的文档中。
通常,测试模块、doctests 和#[test]属性仅应用于单元测试。集成测试应放置在顶级测试目录中。
Rust 团队正在努力在测试系统中添加运行基准测试的支持。目前这仅限于 nightly 版本。
摘要
本章是对 Rust 语言及其生态系统的非常简短的介绍。鉴于在 Rust 方面的这一背景,让我们来看一个经常被问到的问题:公司是否应该采用 Rust?像工程中的许多事情一样,正确的答案取决于许多因素。采用 Rust 的一个主要原因是能够以尽可能小的足迹编写健壮的代码。因此,Rust 适合针对嵌入式设备的项目。这个领域传统上使用汇编、C 和 C++。Rust 可以提供相同的表现力保证,同时确保代码的正确性。Rust 也很好地用于从 Python 或 Ruby 卸载性能密集型计算。Rust 的主要痛点是学习曲线可能很陡峭。因此,试图采用 Rust 的团队可能会花很多时间与编译器斗争,试图运行代码。然而,这会随着时间的推移而缓解。幸运的是,编译器错误信息通常非常有帮助。2017 年,Rust 团队决定将人体工程学作为优先事项。这一推动使得新开发者的入职变得更加容易。对于大型 Rust 项目,编译时间可能比 C、C++或 Go 更长。这可能会成为某些团队的问题。有几种方法可以解决这个问题,其中之一是增量编译。因此,很难找到一个适合所有情况的解决方案。希望这篇简短的介绍能帮助决定是否在新项目中选择 Rust。
在下一章中,我们将通过研究 Rust 如何在网络中处理两个主机之间的 TCP 和 UDP 连接,来在此基础上构建我们在这里学到的内容。
第三章:使用 Rust 进行 TCP 和 UDP
作为一种系统编程语言,Rust 标准库支持与网络栈交互。所有与网络相关的功能都位于std::net命名空间中;读取和写入套接字也使用了来自std::io的Read和Write特质。这里一些最重要的结构包括IpAddr,它表示一个通用的 IP 地址,可以是 v4 或 v6,SocketAddr表示一个通用的套接字地址(主机上的 IP 和端口的组合),TcpListener和TcpStream用于 TCP 通信,UdpSocket用于 UDP,等等。目前,标准库不提供任何用于处理较低级别网络栈的 API。虽然这可能在将来改变,但许多 crate 填补了这一空白。其中最重要的是libpnet,它提供了一套用于低级别网络编程的 API。
一些其他重要的网络 crate 是net2和socket2。这些原本是为了孵化可能被移至标准库的 API 而设计的。当认为某些功能有用且足够稳定时,这些功能会被移植到 Rust 核心仓库。不幸的是,在所有情况下这并不按计划进行。总的来说,社区现在建议使用 tokio 生态系统中的 crate 来编写不需要精细控制套接字语义的高性能网络应用程序。请注意,tokio 不在本章的范围内,我们将在下一章中介绍它。
在本章中,我们将涵盖以下主题:
-
Rust 中一个简单的多线程 TCP 客户端和服务器看起来是什么样子
-
编写一个简单的多线程 UDP 客户端和服务器
-
std::net中的许多功能 -
学习如何使用
net2、ipnetwork和libpnet
为了简化,本章中的所有代码都将仅处理 IPv4。将给定的示例扩展到 IPv6 应该是微不足道的。
一个简单的 TCP 服务器和客户端
大多数网络示例都是从 echo 服务器开始的。所以,让我们先写一个基本的 echo 服务器,看看所有部件是如何组合在一起的。我们将使用标准库中的线程模型来并行处理多个客户端。代码如下:
// chapter3/tcp-echo-server.rs
use std::net::{TcpListener, TcpStream};
use std::thread;
use std::io::{Read, Write, Error};
// Handles a single client
fn handle_client(mut stream: TcpStream) -> Result<(), Error> {
println!("Incoming connection from: {}", stream.peer_addr()?);
let mut buf = [0; 512];
loop {
let bytes_read = stream.read(&mut buf)?;
if bytes_read == 0 { return Ok(()); }
stream.write(&buf[..bytes_read])?;
}
}
fn main() {
let listener = TcpListener::bind("0.0.0.0:8888")
.expect("Could not bind");
for stream in listener.incoming() {
match stream {
Err(e) => { eprintln!("failed: {}", e) }
Ok(stream) => {
thread::spawn(move || {
handle_client(stream)
.unwrap_or_else(|error| eprintln!("{:?}", error));
});
}
}
}
}
在我们的main函数中,我们创建一个新的TcpListener,在 Rust 中,它代表一个监听客户端传入连接的 TCP 套接字。在我们的示例中,我们硬编码了本地地址和端口;将本地地址设置为0.0.0.0告诉内核将此套接字绑定到该主机上所有可用的接口。在这里设置一个知名端口很重要,因为我们需要知道这个端口才能从客户端连接。在实际应用中,这应该是一个可配置的参数,从 CLI 或配置文件中获取。我们在本地 IP 和端口对上调用bind以创建一个本地监听套接字。如前所述,我们选择的 IP 将绑定此套接字到主机上的所有接口,端口为 8888。因此,任何可以连接到连接到该主机的网络的客户端都将能够与该主机通信。正如我们在上一章中看到的,expect函数在没有错误的情况下返回监听器。如果情况不是这样,它将使用给定的消息恐慌。在这里,在绑定端口失败时恐慌实际上是可行的,因为如果失败了,服务器将无法继续工作。listener上的incoming方法返回一个迭代器,遍历已连接到服务器的流。我们遍历它们并检查是否有任何遇到错误。在这种情况下,我们可以打印错误并继续下一个已连接的客户端。请注意,在这种情况下恐慌是不适当的,因为如果一些客户端由于某种原因遇到错误,服务器仍然可以正常工作。
现在,我们必须在一个无限循环中从每个客户端读取数据。但在主线程中运行无限循环将会阻塞它,其他客户端将无法连接。这种行为肯定是不希望的。因此,我们必须创建一个工作线程来处理每个客户端连接。从每个流中读取并写回的逻辑封装在名为handle_client的函数中。每个线程接收一个调用此函数的闭包。这个闭包必须是一个move闭包,因为必须从封装的作用域中读取一个变量(stream)。在函数中,我们打印远程端点的地址和端口,然后定义一个缓冲区来临时存储数据。我们还确保缓冲区被清零。然后我们运行一个无限循环,在其中读取流中的所有数据。流中的读取方法返回它已读取的数据长度。它可以在两种情况下返回零,如果它已到达流的末尾,或者如果给定的缓冲区长度为零。我们肯定第二种情况是不正确的。因此,当读取方法返回零时,我们跳出循环(和函数)。在这种情况下,我们返回一个Ok()。然后我们使用切片语法将相同的数据写回流。请注意,我们使用了eprintln!来输出错误。这个宏将给定的字符串写入标准错误,最近已经稳定下来。
有人在读取和写入流时可能注意到明显的错误处理缺失。但实际上并非如此。我们已经使用了?运算符来处理这些调用中的错误。如果一切顺利,此运算符将结果展开为Ok;否则,它将错误提前返回给调用函数。考虑到这种设置,函数的返回类型必须是空类型,以处理成功情况,或者io::Error类型,以处理错误情况。注意,在这种情况下实现自定义错误可能是一个好主意,并返回这些错误而不是内置错误。另外,请注意,目前?运算符不能在main函数中使用,因为main函数不返回Result。
Rust 最近接受了一个 RFC,该 RFC 提议在main函数中使用?运算符。
从终端与服务器交互很简单。当我们在 Linux 机器上运行服务器,并在另一个终端运行nc时,输入到nc的任何文本都应该被回显。注意,如果客户端和服务器在同一节点上运行,我们可以使用 127.0.0.1 作为服务器地址:
$ nc <server ip> 8888
test
test
foobar
foobar
foobarbaz
foobarbaz
^C
虽然使用nc与服务器交互是可以的,但从头开始编写客户端会更有趣。在本节中,我们将看到一个简单的 TCP 客户端可能的样子。这个客户端将从stdin读取输入作为字符串,并将其发送到服务器。当它收到回复时,它将在stdout中打印出来。在我们的示例中,客户端和服务器运行在同一物理主机上,因此我们可以使用 127.0.0.1 作为服务器地址:
// chapter3/tcp-client.rs
use std::net::TcpStream;
use std::str;
use std::io::{self, BufRead, BufReader, Write};
fn main() {
let mut stream = TcpStream::connect("127.0.0.1:8888")
.expect("Could not connect to server");
loop {
let mut input = String::new();
let mut buffer: Vec<u8> = Vec::new();
io::stdin().read_line(&mut input)
.expect("Failed to read from stdin");
stream.write(input.as_bytes())
.expect("Failed to write to server");
let mut reader = BufReader::new(&stream);
reader.read_until(b'\n', &mut buffer)
.expect("Could not read into buffer");
print!("{}", str::from_utf8(&buffer)
.expect("Could not write buffer as string"));
}
}
在这种情况下,我们首先导入所有必需的库。然后,我们使用TcpStream::connect设置到服务器的连接,该函数接受远程端点地址作为字符串。像所有 TCP 连接一样,客户端需要知道远程 IP 和端口才能连接。如果设置连接失败,我们将使用错误消息终止我们的程序。然后我们启动一个无限循环,在这个循环中,我们将初始化一个空字符串以读取本地用户输入,以及一个u8向量以读取来自服务器的响应。由于 Rust 中的向量会根据需要增长,我们不需要在每次迭代中手动分块数据。read_line函数从标准输入读取一行并将其存储在名为input的变量中。然后,它作为字节流写入连接。此时,如果一切按预期进行,服务器应该已经发送了响应。我们将使用BufReader读取该响应,该BufReader负责内部分块数据。这也使得读取更高效,因为将不会进行比必要的更多系统调用。read_until方法读取缓冲区中的数据,该缓冲区根据需要增长。最后,我们可以使用from_utf8方法将缓冲区打印为字符串。
运行客户端很简单,并且正如预期的那样,其行为与nc完全相同:
$ rustc tcp-client.rs && ./tcp-client
test
test
foobar
foobar
foobarbaz
foobarbaz
^C
实际应用通常比这更复杂。服务器可能需要一些时间来处理输入然后再返回响应。让我们通过在 handle_client 函数中随机睡眠一段时间来模拟这种情况;main 函数将保持与上一个示例完全相同。第一步是使用 cargo 创建我们的项目:
$ cargo new --bin tcp-echo-random
注意,我们需要在 Cargo.toml 中添加 rand crate,如下代码片段所示:
[package]
name = "tcp-echo-random"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
rand = "0.3.17"
在设置好依赖项后,让我们修改 handle_client 函数,在发送响应之前先随机睡眠一段时间:
// chapter3/tcp-echo-random/src/main.rs
extern crate rand;
use std::net::{TcpListener, TcpStream};
use std::thread;
use rand::{thread_rng, Rng};
use std::time::Duration;
use std::io::{Read, Write, Error};
fn handle_client(mut stream: TcpStream) -> Result<(), Error> {
let mut buf = [0; 512];
loop {
let bytes_read = stream.read(&mut buf)?;
if bytes_read == 0 { return Ok(()) }
let sleep = Duration::from_secs(*thread_rng()
.choose(&[0, 1, 2, 3, 4, 5])
.unwrap());
println!("Sleeping for {:?} before replying", sleep);
std::thread::sleep(sleep);
stream.write(&buf[..bytes_read])?;
}
}
fn main() {
let listener = TcpListener::bind("127.0.0.1:8888").expect("Could
not bind");
for stream in listener.incoming() {
match stream {
Err(e) => eprintln!("failed: {}", e),
Ok(stream) => {
thread::spawn(move || {
handle_client(stream).unwrap_or_else(|error|
eprintln!("{:?}", error));
});
}
}
}
}
在我们的主文件中,我们必须声明对 rand crate 的依赖,并将其声明为 extern crate。我们使用 thread_rng 函数随机选择一个介于零和五之间的整数,然后使用 std::thread::sleep 睡眠相应的时间长度。在客户端,我们将设置读取和连接超时,因为来自服务器的回复不会立即出现:
// chapter3/tcp-client-timeout.rs
use std::net::TcpStream;
use std::str;
use std::io::{self, BufRead, BufReader, Write};
use std::time::Duration;
use std::net::SocketAddr;
fn main() {
let remote: SocketAddr = "127.0.0.1:8888".parse().unwrap();
let mut stream = TcpStream::connect_timeout(&remote,
Duration::from_secs(1))
.expect("Could not connect to server");
stream.set_read_timeout(Some(Duration::from_secs(3)))
.expect("Could not set a read timeout");
loop {
let mut input = String::new();
let mut buffer: Vec<u8> = Vec::new();
io::stdin().read_line(&mut input).expect("Failed to read from
stdin");
stream.write(input.as_bytes()).expect("Failed to write to
server");
let mut reader = BufReader::new(&stream);
reader.read_until(b'\n', &mut buffer)
.expect("Could not read into buffer");
print!("{}", str::from_utf8(&buffer)
.expect("Could not write buffer as string"));
}
}
在这里,我们使用 set_read_timeout 将超时设置为三秒。因此,如果服务器睡眠超过三秒,客户端将终止连接。这个函数很奇怪,因为它接受 Option<Duration> 来能够指定一个 None 的 Duration。因此,我们需要在传递给这个函数之前将我们的 Duration 包装在 Some 中。现在,如果我们打开两个会话,一个使用 cargo 运行服务器,另一个运行客户端,我们会看到以下内容;服务器打印出它为每个接受的客户端睡眠了多长时间:
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/tcp-echo-random`
Sleeping for Duration { secs: 2, nanos: 0 } before replying
Sleeping for Duration { secs: 1, nanos: 0 } before replying
Sleeping for Duration { secs: 1, nanos: 0 } before replying
Sleeping for Duration { secs: 5, nanos: 0 } before replying
在客户端,我们有一个单独的文件(不是一个 cargo 项目),我们将使用 rustc 构建,并在编译后直接运行可执行文件:
$ rustc tcp-client-timeout.rs && ./tcp-client-timeout
test
test
foo
foo
bar
bar
baz
thread 'main' panicked at 'Could not read into buffer: Error { repr: Os { code: 35, message: "Resource temporarily unavailable" } }', src/libcore/result.rs:906:4
note: Run with `RUST_BACKTRACE=1` for a backtrace.
对于前三个输入,服务器选择的延迟小于三秒。客户端在三秒内收到响应,并且没有终止连接。对于最后一条消息,延迟为五秒,这导致客户端终止读取。
一个简单的 UDP 服务器和客户端
与我们之前编写的 UDP 服务器和 TCP 服务器之间有一些语义上的差异。与 TCP 不同,UDP 没有流结构。这源于两种协议之间的语义差异。让我们看看一个 UDP 服务器可能的样子:
// chapter3/udp-echo-server.rs
use std::thread;
use std::net::UdpSocket;
fn main() {
let socket = UdpSocket::bind("0.0.0.0:8888")
.expect("Could not bind socket");
loop {
let mut buf = [0u8; 1500];
let sock = socket.try_clone().expect("Failed to clone socket");
match socket.recv_from(&mut buf) {
Ok((_, src)) => {
thread::spawn(move || {
println!("Handling connection from {}", src);
sock.send_to(&buf, &src)
.expect("Failed to send a response");
});
},
Err(e) => {
eprintln!("couldn't recieve a datagram: {}", e);
}
}
}
}
与 TCP 一样,我们从绑定到给定端口上的本地地址开始,并处理绑定可能失败的可能性。由于 UDP 是无连接协议,我们不需要进行滑动窗口来读取所有数据。因此,我们可以分配一个给定大小的静态缓冲区。动态检测底层网络卡的 MTU 并将缓冲区大小设置为该值会更好,因为这是每个 UDP 数据包的最大大小。然而,由于常见局域网的 MTU 大约是 1,500,我们可以在这里分配一个同样大小的缓冲区。try_clone 方法克隆给定的套接字并返回一个新的套接字,该套接字被移动到闭包中。
我们然后从套接字读取,在Ok()情况下返回读取的数据长度和源。然后我们启动一个新线程,在该线程中我们将相同的缓冲区写回到给定的套接字。对于任何可能失败的操作,我们都需要像处理 TCP 服务器那样处理错误。
与上次一样,与这个服务器交互——使用nc。唯一的区别是,在这种情况下,我们需要传递-u标志来强制nc只使用 UDP。看看下面的例子:
$ nc -u 127.0.0.1 8888
test
test
test
test
^C
现在,让我们编写一个简单的 UDP 客户端以实现相同的结果。正如我们将看到的,TCP 服务器和这个之间有一些细微的差别:
// chapter3/udp-client.rs
use std::net::UdpSocket;
use std::{str,io};
fn main() {
let socket = UdpSocket::bind("127.0.0.1:8000")
.expect("Could not bind client socket");
socket.connect("127.0.0.1:8888")
.expect("Could not connect to server");
loop {
let mut input = String::new();
let mut buffer = [0u8; 1500];
io::stdin().read_line(&mut input)
.expect("Failed to read from stdin");
socket.send(input.as_bytes())
.expect("Failed to write to server");
socket.recv_from(&mut buffer)
.expect("Could not read into buffer");
print!("{}", str::from_utf8(&buffer)
.expect("Could not write buffer as string"));
}
}
与上一节中看到的 TCP 客户端相比,这个基本客户端有一个主要的不同之处。在这种情况下,在连接到服务器之前,bind到客户端套接字是绝对必要的。一旦完成,其余的例子基本上是相同的。在客户端和服务器端运行它会产生与 TCP 案例相似的结果。以下是服务器端的一个会话:
$ rustc udp-echo-server.rs && ./udp-echo-server
Handling connection from 127.0.0.1:8000
Handling connection from 127.0.0.1:8000
Handling connection from 127.0.0.1:8000
^C
在客户端,我们看到的是:
$ rustc udp-client.rs && ./udp-client
test
test
foo
foo
bar
bar
^C
UDP 多播
UdpSocket类型有几个方法,而相应的 TCP 类型没有。其中最有趣的是用于多播和广播的方法。让我们通过一个示例服务器和客户端来看看多播是如何工作的。对于这个例子,我们将客户端和服务器合并到一个文件中。在main函数中,我们将检查是否传递了 CLI 参数。如果有,我们将运行客户端;否则,我们将运行服务器。请注意,参数的值将不会被使用;它将被视为一个布尔值:
// chapter3/udp-multicast.rs
use std::{env, str};
use std::net::{UdpSocket, Ipv4Addr};
fn main() {
let mcast_group: Ipv4Addr = "239.0.0.1".parse().unwrap();
let port: u16 = 6000;
let any = "0.0.0.0".parse().unwrap();
let mut buffer = [0u8; 1600];
if env::args().count() > 1 {
// client case
let socket = UdpSocket::bind((any, port))
.expect("Could not bind client socket");
socket.join_multicast_v4(&mcast_group, &any)
.expect("Could not join multicast group");
socket.recv_from(&mut buffer)
.expect("Failed to write to server");
print!("{}", str::from_utf8(&buffer)
.expect("Could not write buffer as string"));
} else {
// server case
let socket = UdpSocket::bind((any, 0))
.expect("Could not bind socket");
socket.send_to("Hello world!".as_bytes(), &(mcast_group, port))
.expect("Failed to write data");
}
}
这里的客户端和服务器部分与我们之前讨论的大多数相似。一个不同之处在于join_multicast_v4调用使当前套接字加入一个带有传递地址的多播组。对于服务器和客户端,我们在绑定时没有指定单个地址。相反,我们使用特殊地址0.0.0.0,表示任何可用的地址。这相当于向底层的setsockopt调用传递INADDR_ANY。在服务器的情况下,我们将其发送到多播组。运行这个稍微有点棘手。由于标准库中没有方法可以设置SO_REUSEADDR和SO_REUSEPORT,我们需要在多个不同的机器上运行客户端,并在另一台机器上运行服务器。为了使其工作,所有这些都需要在同一个网络中,并且多播组的地址需要是一个有效的多播地址(前四个位应该是 1110)。UdpSocket类型还支持离开多播组、广播等。请注意,由于广播在定义上是由两个主机之间的连接,所以对于 TCP 来说没有意义。
运行前面的例子很简单;在一个主机上,我们将运行服务器,在另一个主机上运行客户端。在这种配置下,服务器端的输出应该如下所示:
$ rustc udp-multicast.rs && ./udp-multicast server
Hello world!
std::net 中的杂项实用工具
标准库中另一个重要的类型是 IpAddr,它表示一个 IP 地址。不出所料,它是一个有两个变体的枚举,一个用于 v4 地址,另一个用于 v6 地址。所有这些类型都有方法根据它们的类型(全局、环回、多播等)对地址进行分类。请注意,其中许多方法尚未稳定,因此仅在夜间编译器中可用。它们位于名为 ip 的功能标志之后,必须在 crate 根目录中包含该标志,以便可以使用这些方法。一个与之密切相关的类型是 SocketAddr,它是 IP 地址和端口号的组合。因此,它也有两个变体,一个用于 v4,一个用于 v6。让我们看看一些例子:
// chapter3/ip-socket-addr.rs
#![feature(ip)]
use std::net::{IpAddr, SocketAddr};
fn main() {
// construct an IpAddr from a string and check it
// represents the loopback address
let local: IpAddr = "127.0.0.1".parse().unwrap();
assert!(local.is_loopback());
// construct a globally routable IPv6 address from individual
octets
// and assert it is classified correctly
let global: IpAddr = IpAddr::from([0, 0, 0x1c9, 0, 0, 0xafc8, 0,
0x1]);
assert!(global.is_global());
// construct a SocketAddr from a string an assert that the
underlying
// IP is a IPv4 address
let local_sa: SocketAddr = "127.0.0.1:80".parse().unwrap();
assert!(local_sa.is_ipv4());
// construct a SocketAddr from a IPv6 address and a port, assert
that
// the underlying address is indeed IPv6
let global_sa = SocketAddr::new(global, 80u16);
assert!(global_sa.is_ipv6());
}
feature(ip) 声明是必要的,因为 is_global 函数尚未稳定。这个例子没有产生任何输出,因为所有断言都应该评估为真。
一个常见的功能是 DNS 查询,给定一个主机名。Rust 使用 lookup_host 函数来完成这项工作,该函数返回 LookupHost 类型,实际上是一个 DNS 响应的迭代器。让我们看看如何使用它。这个函数由 looup_host 标志控制,并且必须包含在夜间编译器中使用此函数:
// chapter3/lookup-host.rs
#![feature(lookup_host)]
use std::env;
use std::net::lookup_host;
fn main() {
let args: Vec<_> = env::args().collect();
if args.len() != 2 {
eprintln!("Please provide only one host name");
std::process::exit(1);
} else {
let addresses = lookup_host(&args[1]).unwrap();
for address in addresses {
println!("{}", address.ip());
}
}
}
在这里,我们读取一个 CLI 参数,如果没有给出恰好一个名称来解析,则退出。否则,我们使用给定的主机名调用 lookup_host。我们遍历返回的结果并打印每个的 IP 地址。请注意,每个返回的结果都是 SocketAddr 类型;因为我们只对 IP 地址感兴趣,所以我们使用 ip() 方法提取它。这个函数对应于 libc 中的 getaddrinfo 调用,因此它只返回 A 和 AAAA 记录类型。运行结果符合预期:
$ rustc lookup-host.rs && ./lookup-host google.com
2a00:1450:4009:810::200e
216.58.206.110
目前,标准库中无法进行反向 DNS 查询。在下一节中,我们将讨论生态系统中可以用于高级网络功能的一些 crate。例如,trust-dns crate 支持与 DNS 服务器进行更详细的交互,并且它还支持查询所有记录类型以及反向 DNS。
一些相关的 crate
一个细心的读者可能会注意到,许多常见的网络相关功能都缺失在标准库中。例如,没有处理 IP 网络(CIDR)的方法。让我们看看 ipnetwork crate 如何帮助解决这个问题。由于我们将使用外部 crate,示例必须在 cargo 项目中。我们需要将其添加为 Cargo.toml 中的依赖项。让我们先设置一个项目:
$ cargo new --bin ipnetwork-example
这将生成一个 Cargo.toml 文件,我们需要修改它以声明我们的依赖项。一旦我们这样做,它应该看起来像这样:
[package]
name = "ipnetwork-example"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
ipnetwork = "0.12.7"
在设置好项目后,让我们看看我们的 main 函数:
// chapter3/ipnetwork-example/src/main.rs
extern crate ipnetwork;
use std::net::Ipv4Addr;
use ipnetwork::{IpNetwork, Ipv4Network, Ipv6Network};
fn main() {
let net = IpNetwork::new("192.168.122.0".parse().unwrap(), 22)
.expect("Could not construct a network");
let str_net: IpNetwork = "192.168.122.0/22".parse().unwrap();
assert!(net == str_net);
assert!(net.is_ipv4());
let net4: Ipv4Network = "192.168.121.0/22".parse().unwrap();
assert!(net4.size() == 2u64.pow(32 - 22));
assert!(net4.contains(Ipv4Addr::new(192, 168, 121, 3)));
let _net6: Ipv6Network = "2001:db8::0/96".parse().unwrap();
for addr in net4.iter().take(10) {
println!("{}", addr);
}
}
前两行展示了构建 IpNetwork 实例的两种不同方式,要么使用构造函数,要么通过解析字符串。接下来的 assert 确保它们确实是相同的。之后的 assert 确保我们创建的网络是一个 v4 网络。接下来,我们特别创建了 Ipv4Network 对象,正如预期的那样,网络的大小与 2^(32 - prefix) 匹配。下一个 assert 确保在该网络中的 contains 方法对 IP 地址正确工作。然后我们创建了一个 Ipv6Network,由于所有这些类型都实现了迭代器协议,我们可以通过 for 循环遍历网络并打印出单个地址。以下是运行最后一个示例时应看到的输出:
$ cargo run
Compiling ipnetwork v0.12.7
Compiling ipnetwork-example v0.1.0 (file:///Users/Abhishek/Desktop/rust-book/src/chapter3/ipnetwork-example)
Finished dev [unoptimized + debuginfo] target(s) in 1.18 secs
Running `target/debug/ipnetwork-example`
192.168.120.0
192.168.120.1
192.168.120.2
192.168.120.3
192.168.120.4
192.168.120.5
192.168.120.6
192.168.120.7
192.168.120.8
192.168.120.9
标准库也缺乏对套接字和连接的精细控制,一个例子就是设置 SO_REUSEADDR 的能力,正如我们之前所描述的。主要原因在于社区尚未能够就如何最好地暴露这些功能同时保持一个干净的 API 达成强有力的共识。在这个上下文中,一个有用的库是 mio,它提供了基于线程的并发性的替代方案。mio 实质上运行一个事件循环,所有参与者都在其中注册。当有事件发生时,每个监听器都会被通知,并且他们有处理该事件的选项。让我们看看以下示例。像上次一样,我们需要使用 cargo 设置项目:
$ cargo new --bin mio-example
下一步是将 mio 添加为依赖项;Cargo.toml 文件应如下所示:
[package]
name = "mio-example"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
mio = "0.6.11"
就像所有其他的 cargo 项目一样,我们需要在 Cargo.toml 中声明 mio 作为依赖项,并将其固定到特定版本,这样 cargo 就可以下载它并将其链接到我们的应用程序:
// chapter3/mio-example/src/main.rs
extern crate mio;
use mio::*;
use mio::tcp::TcpListener;
use std::net::SocketAddr;
use std::env;
// This will be later used to identify the server on the event loop
const SERVER: Token = Token(0);
// Represents a simple TCP server using mio
struct TCPServer {
address: SocketAddr,
}
// Implementation for the TCP server
impl TCPServer {
fn new(port: u32) -> Self {
let address = format!("0.0.0.0:{}", port)
.parse::<SocketAddr>().unwrap();
TCPServer {
address,
}
}
// Actually binds the server to a given address and runs it
// This function also sets up the event loop that dispatches
// events. Later, we use a match on the token on the event
// to determine if the event is for the server.
fn run(&mut self) {
let server = TcpListener::bind(&self.address)
.expect("Could not bind to port");
let poll = Poll::new().unwrap();
poll.register(&server,
SERVER,
Ready::readable(),
PollOpt::edge()).unwrap();
let mut events = Events::with_capacity(1024);
loop {
poll.poll(&mut events, None).unwrap();
for event in events.iter() {
match event.token() {
SERVER => {
let (_stream, remote) =
server.accept().unwrap();
println!("Connection from {}", remote);
}
_ => {
unreachable!();
}
}
}
}
}
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() != 2 {
eprintln!("Please provide only one port number as argument");
std::process::exit(1);
}
let mut server = TCPServer::new(args[1].parse::<u32>()
.expect("Could not parse as u32"));
server.run();
}
与我们之前的示例不同,这是一个仅打印客户端源 IP 和端口的 TCP 服务器。在 mio 中,事件循环上的每个监听器都会分配一个令牌,该令牌可以用来在事件传递时区分监听器。我们在构造函数中定义了一个用于我们的服务器(TCPServer)的结构体,并将其 bind 到所有本地地址,并返回该结构体的一个实例。该结构体的 run 方法将套接字绑定到给定的套接字地址;然后它使用 Poll 结构体来实例化事件循环。
然后它注册了服务器套接字,并在实例上设置了一个令牌。我们还指示当事件准备好读取或写入时应该发出警报。最后,我们指示我们只想接收边缘触发的事件,这意味着事件在接收时应完全消耗,否则在相同令牌上的后续调用可能会阻塞它。然后我们为我们的事件设置了一个空的容器。完成所有样板代码后,我们进入一个无限循环,并使用我们刚刚创建的事件容器开始轮询。我们遍历事件列表,如果任何事件令牌与服务器令牌匹配,我们知道它是为服务器准备的。然后我们可以接受连接并打印远程端的信息。然后我们回到下一个事件,依此类推。在我们的main函数中,我们首先处理 CLI 参数,确保我们传递了一个整数端口号。然后,我们实例化服务器并调用其上的run方法。
这里是一个示例会话,展示了当两个客户端连接到服务器时运行服务器的情况。请注意,可以使用nc或之前提到的 TCP 客户端来连接到这个服务器:
$ cargo run 4321
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/mio-example 4321`
Connection from 127.0.0.1:60955
Connection from 127.0.0.1:60956
^C
从标准库和这里讨论的 crates 中缺失的一些主要功能包括与物理网络设备工作的能力、一个更友好的 API 来构建和解析数据包等。有一个 crate 可以帮助处理libpnet中的底层网络相关事务。让我们使用它来编写一个小的数据包转储器:
$ cat Cargo.toml
[package]
name = "pnet-example"
version = "0.1.0"
authors = ["Foo Bar <foo@bar.com>"]
[dependencies]
pnet = "0.20.0"
我们初始化我们的 Cargo 项目如下:
$ cargo new --bin pnet-example
然后我们将pnet添加为依赖项,将其锁定到特定版本(目前可用的最新版本)。然后我们可以继续到我们的源代码,它应该看起来像这样:
// chapter3/pnet-example/src/main.rs
extern crate pnet;
use pnet::datalink::{self, NetworkInterface};
use pnet::datalink::Channel::Ethernet;
use pnet::packet::ethernet::{EtherTypes, EthernetPacket};
use pnet::packet::ipv4::Ipv4Packet;
use pnet::packet::tcp::TcpPacket;
use pnet::packet::ip::IpNextHeaderProtocols;
use pnet::packet::Packet;
use std::env;
// Handles a single ethernet packet
fn handle_packet(ethernet: &EthernetPacket) {
match ethernet.get_ethertype() {
EtherTypes::Ipv4 => {
let header = Ipv4Packet::new(ethernet.payload());
if let Some(header) = header {
match header.get_next_level_protocol() {
IpNextHeaderProtocols::Tcp => {
let tcp = TcpPacket::new(header.payload());
if let Some(tcp) = tcp {
println!(
"Got a TCP packet {}:{} to {}:{}",
header.get_source(),
tcp.get_source(),
header.get_destination(),
tcp.get_destination()
);
}
}
_ => println!("Ignoring non TCP packet"),
}
}
}
_ => println!("Ignoring non IPv4 packet"),
}
}
fn main() {
let interface_name = env::args().nth(1).unwrap();
// Get all interfaces
let interfaces = datalink::interfaces();
// Filter the list to find the given interface name
let interface = interfaces
.into_iter()
.filter(|iface: &NetworkInterface| iface.name == interface_name)
.next()
.expect("Error getting interface");
let (_tx, mut rx) = match datalink::channel(&interface, Default::default()) {
Ok(Ethernet(tx, rx)) => (tx, rx),
Ok(_) => panic!("Unhandled channel type"),
Err(e) => {
panic!(
"An error occurred when creating the datalink channel:
{}",e
)
}
};
// Loop over packets arriving on the given interface
loop {
match rx.next() {
Ok(packet) => {
let packet = EthernetPacket::new(packet).unwrap();
handle_packet(&packet);
}
Err(e) => {
panic!("An error occurred while reading: {}", e);
}
}
}
}
如往常一样,我们首先声明pnet为一个外部 crate。然后我们导入我们将要使用的一堆东西。我们通过 CLI 参数接收要嗅探的接口名称。datalink::interfaces()给我们当前主机上所有可用接口的列表,我们通过提供的接口名称过滤这个列表。如果我们找不到匹配项,我们抛出一个错误并退出。datalink::channel()调用给我们一个发送和接收数据包的通道。在这种情况下,我们不需要关心发送端,因为我们只对嗅探数据包感兴趣。我们根据返回的通道类型进行匹配,以确保我们只处理以太网。通道的接收端rx给我们一个迭代器,它在每次next()调用时产生数据包。
数据包随后传递给handle_packet函数,该函数提取相关信息并打印出来。对于这个玩具示例,我们将只处理基于 IPv4 的 TCP 数据包。现实中的网络显然会得到带有 UDP 和 TCP 的 IPv6 和 ICMP 数据包。所有这些组合都将在此忽略。
在 handle_packet 函数中,我们根据数据包的 ethertype 匹配以确保我们只处理 IPv4 数据包。由于整个以太网数据包的有效负载是 IP 数据包(参看第一章,客户端/服务器网络简介),我们从有效负载中构造一个 IP 数据包。get_next_level_protocol() 调用返回传输协议,如果它匹配 TCP,我们就从前一层的有效负载中构造一个 TCP 数据包。在这个时候,我们可以从 TCP 数据包中打印出源端口和目标端口。源 IP 和目标 IP 将在封装的 IP 数据包中。以下代码块显示了示例运行。我们需要将监听接口的名称作为命令行参数传递给我们的程序。以下是在 Linux 中获取接口名称的方法:
$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: enp1s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
link/ether f4:4d:30:ac:88:ee brd ff:ff:ff:ff:ff:ff
inet 192.168.5.15/22 brd 192.168.7.255 scope global enp1s0
valid_lft forever preferred_lft forever
inet6 fe80::58c6:9ccc:e78c:caa6/64 scope link
valid_lft forever preferred_lft forever
在这个示例中,我们将忽略回环接口 l0,因为它不接收很多流量,并使用其他接口 enp1s0。我们还将以 root 权限(使用 sudo)运行此示例,因为它需要直接访问网络设备。
第一步是使用 cargo 构建项目并运行可执行文件。请注意,这个示例的确切输出可能略有不同,这取决于到达的数据包:
$ cargo build
$ sudo ./target/debug/pnet-example enp1s0
Got a TCP packet 192.168.0.2:53041 to 104.82.249.116:443
Got a TCP packet 104.82.249.116:443 to 192.168.0.2:53041
Got a TCP packet 192.168.0.2:53064 to 17.142.169.200:443
Got a TCP packet 192.168.0.2:53064 to 17.142.169.200:443
Got a TCP packet 17.142.169.200:443 to 192.168.0.2:53064
Got a TCP packet 17.142.169.200:443 to 192.168.0.2:53064
Got a TCP packet 192.168.0.2:53064 to 17.142.169.200:443
Got a TCP packet 192.168.0.2:52086 to 52.174.153.60:443
Ignoring non IPv4 packet
Got a TCP packet 52.174.153.60:443 to 192.168.0.2:52086
Got a TCP packet 192.168.0.2:52086 to 52.174.153.60:443
Ignoring non IPv4 packet
Ignoring non IPv4 packet
Ignoring non IPv4 packet
Ignoring non IPv4 packet
在上一节中,我们看到了标准库中 DNS 相关功能的局限性。trust-dns 是一个在 DNS 相关事务中广泛流行的 crate。让我们看看如何使用它来查询给定名称的示例。让我们从一个空项目开始:
$ cargo new --bin trust-dns-example
然后,我们首先在 Cargo.toml 中添加所需 crate 的版本:
[package]
name = "trust-dns-example"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
trust-dns-resolver = "0.6.0"
trust-dns = "0.12.0"
我们的应用程序依赖于 trust-dns 来处理 DNS 相关的事务。像往常一样,在使用我们的应用程序之前,我们将它添加到 Cargo.toml 中:
// chapter3/trust-dns-example/src/main.rs
extern crate trust_dns_resolver;
extern crate trust_dns;
use std::env;
use trust_dns_resolver::Resolver;
use trust_dns_resolver::config::*;
use trust_dns::rr::record_type::RecordType;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() != 2 {
eprintln!("Please provide a name to query");
std::process::exit(1);
}
let resolver = Resolver::new(ResolverConfig::default(),
ResolverOpts::default()).unwrap();
// Add a dot to the given name
let query = format!("{}.", args[1]);
// Run the DNS query
let response = resolver.lookup_ip(query.as_str());
println!("Using the synchronous resolver");
for ans in response.iter() {
println!("{:?}", ans);
}
println!("Using the system resolver");
let system_resolver = Resolver::from_system_conf().unwrap();
let system_response = system_resolver.lookup_ip(query.as_str());
for ans in system_response.iter() {
println!("{:?}", ans);
}
let ns = resolver.lookup(query.as_str(), RecordType::NS);
println!("NS records using the synchronous resolver");
for ans in ns.iter() {
println!("{:?}", ans);
}
}
我们设置了所有必需的导入和 extern crate 声明。在这里,我们期望从 CLI 参数中获取要解析的名称,如果一切顺利,它应该在 args[1] 中。这个 crate 支持两种类型的同步 DNS 解析器。Resolver::new 创建一个同步解析器,默认情况下,它将使用 Google 的公共 DNS 作为上游服务器。Resolver::from_system_conf 创建一个使用系统 resolv.conf 配置的同步解析器。因此,这个第二个选项仅在 Unix 系统上可用。在我们将查询传递给 resolver 之前,我们使用 format! 宏将名称格式化为 FQDN,并在名称后附加一个 .。我们使用 lookup_ip 函数传递查询,该函数随后返回 DNS 问题的答案的迭代器。一旦我们得到它,我们就可以遍历它并打印出每个答案。正如其名所示,lookup_ip 函数只查找 A 和 AAAA 记录。还有一个更通用的 lookup 函数,可以接受要查询的记录类型。在最后一步,我们想要获取给定名称的所有 NS 记录。一旦我们得到答案,我们就遍历它并打印结果。
trust-dns 也支持基于 tokio 的异步 DNS 解析器。
一个示例会话将看起来像这样:
$ cargo run google.com
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/trust-dns-example google.com`
Using the synchronous resolver
LookupIp(Lookup { rdatas: [AAAA(2a00:1450:4009:811::200e), A(216.58.206.142)] })
Using the system resolver
LookupIp(Lookup { rdatas: [A(216.58.206.110), AAAA(2a00:1450:4009:810::200e)] })
NS records using the synchronous resolver
Lookup { rdatas: [NS(Name { is_fqdn: true, labels: ["ns3", "google", "com"] }), NS(Name { is_fqdn: true, labels: ["ns1", "google", "com"] }), NS(Name { is_fqdn: true, labels: ["ns4", "google", "com"] }), NS(Name { is_fqdn: true, labels: ["ns2", "google", "com"] })] }
在本例中,所有打印都使用结构的调试表示形式。实际应用可能需要按需格式化这些内容。
摘要
本章简要介绍了 Rust 中的基本网络功能。我们从 std::net 中给出的功能开始,并使用这些功能编写了一些 TCP 和 UDP 服务器。然后,我们查看了一些同一命名空间中的其他实用工具。最后,我们回顾了几个旨在扩展标准库网络功能功能的 crate 的示例。请注意,始终可以使用基于 POSIX 兼容网络代码并具有对套接字和网络设备细粒度控制的 libc crate 来编写网络代码,但这可能会导致代码不安全,违反 Rust 的安全性保证。另一个名为 nix 的 crate 旨在提供原生的 Rust libc 功能,以便它保留编译器提供的所有内存和类型安全性保证:这可能是一个需要非常细粒度控制网络的人的有用替代方案。
在下一章中,我们将探讨在服务器或客户端接收到数据后,如何使用 Rust 生态系统中的多种序列化/反序列化方法来处理数据。
第四章:数据序列化、反序列化和解析
在上一章中,我们介绍了在 Rust 中编写简单的套接字服务器。传输协议,如 TCP 和 UDP,仅提供传输消息的机制,因此需要更高层次的协议来实际构建和发送这些消息。此外,TCP 和 UDP 协议始终处理字节;我们在将字符串发送到套接字之前调用 as_bytes 时看到了这一点。将数据转换为可以存储或传输的格式(在网络的案例中是字节流)的过程称为序列化。相反的过程是反序列化,它将原始数据格式转换为数据结构。任何网络软件都必须处理接收到的或即将发送的数据的序列化和反序列化。对于更复杂的数据类型,如用户定义的数据类型,或甚至是简单的集合类型,这种简单的转换并不总是可能的。Rust 生态系统有一些特殊的包可以处理各种情况。
在本章中,我们将涵盖以下主题:
-
使用 Serde 进行序列化和反序列化。我们将从基本用法开始,然后继续介绍如何使用 Serde 编写自定义序列化器。
-
使用 nom 解析文本数据。
-
最后一个主题将是解析二进制数据,这是网络中非常常用的一种技术。
使用 Serde 进行序列化和反序列化
Serde 是 Rust 中序列化和反序列化数据的既定标准方式。Serde 支持多种数据结构,它可以直接将这些数据结构序列化成多种给定的数据格式(包括 JSON、TOML 和 CSV)。理解 Serde 的最简单方式是将其视为一个可逆函数,它将给定的数据结构转换成字节流。除了标准数据类型外,Serde 还提供了一些宏,这些宏可以应用于用户定义的数据类型,使它们(反)序列化。
在 第二章,“Rust 及其生态系统简介”中,我们讨论了如何使用过程宏为给定的数据类型实现自定义推导。Serde 使用该机制提供两个自定义推导,分别命名为 Serialize 和 Deserialize,这些推导可以应用于由 Serde 支持的数据类型组成的用户定义数据类型。让我们看看一个小例子,看看它是如何工作的。我们首先使用 Cargo 创建一个空项目:
$ cargo new --bin serde-basic
这是 Cargo 清单应该看起来像的样子:
[package]
name = "serde-basic"
version = "0.1.0"
authors = ["Foo<foo@bar.com>"]
[dependencies]
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"
serde_yaml = "0.7.1"
serde 包是 Serde 生态系统的核心。serde_derive 包提供了必要的工具,使用过程宏来推导 Serialize 和 Deserialize。接下来的两个包分别提供了 Serde 特定的功能,用于将数据序列化和反序列化为 JSON 和 YAML:
// chapter4/serde-basic/src/main.rs
#[macro_use]
extern crate serde_derive;
extern crate serde;
extern crate serde_json;
extern crate serde_yaml;
// We will serialize and deserialize instances of
// this struct
#[derive(Serialize, Deserialize, Debug)]
struct ServerConfig {
workers: u64,
ignore: bool,
auth_server: Option<String>
}
fn main() {
let config = ServerConfig {
workers: 100,
ignore: false,
auth_server: Some("auth.server.io".to_string())
};
{
println!("To and from YAML");
let serialized = serde_yaml::to_string(&config).unwrap();
println!("{}", serialized);
let deserialized: ServerConfig =
serde_yaml::from_str(&serialized).unwrap();
println!("{:?}", deserialized);
}
println!("\n\n");
{
println!("To and from JSON");
let serialized = serde_json::to_string(&config).unwrap();
println!("{}", serialized);
let deserialized: ServerConfig =
serde_json::from_str(&serialized).unwrap();
println!("{:?}", deserialized);
}
}
由于 serde_derive crate 导出宏,我们需要用 macro_use 声明来标记它;然后我们声明所有依赖项为 extern crate。设置好这些后,我们可以定义我们的自定义数据类型。在这种情况下,我们感兴趣的是具有不同类型参数的服务器配置。auth_server 参数是可选的,这就是为什么它被包裹在 Option 中。我们的 struct 从 Serde 继承了两个特质,还继承了编译器提供的 Debug 特质,我们将在反序列化后使用它来显示。在我们的主函数中,我们实例化我们的类,并对其调用 serde_yaml::to_string 来将其序列化为字符串;其逆操作是 serde_yaml::from_str。
一个示例运行应该看起来像这样:
$ cargo run
Compiling serde-basic v0.1.0 (file:///Users/Abhishek/Desktop/rust-book/src/chapter4/serde-basic)
Finished dev [unoptimized + debuginfo] target(s) in 1.88 secs
Running `target/debug/serde-basic`
To and from YAML
---
workers: 100
ignore: false
auth_server: auth.server.io
ServerConfig { workers: 100, ignore: false, auth_server: Some("auth.server.io") }
To and from JSON
{"workers":100,"ignore":false,"auth_server":"auth.server.io"}
ServerConfig { workers: 100, ignore: false, auth_server: Some("auth.server.io") }
让我们继续一个更高级的示例,展示如何在网络上使用 Serde。在这个例子中,我们将设置一个 TCP 服务器和一个客户端。这部分将与我们上一章中做的一样。但这次,我们的 TCP 服务器将作为一个计算器运行,它接受一个沿三个轴有三个分量的 3D 空间中的点,并返回它在同一参考系中与原点的距离。让我们这样设置我们的 Cargo 项目:
$ cargo new --bin serde-server
清单应该看起来像这样:
$ cat Cargo.toml
[package]
name = "serde-server"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"
通过这种方式,我们就可以继续定义我们的代码。在这个例子中,服务器和客户端将在同一个二进制文件中。应用程序将接受一个标志,指示它应该作为服务器还是客户端运行。正如我们在上一章中做的那样,在服务器的情况下,我们将绑定到已知端口上的所有本地接口并监听传入的连接。客户端的情况将连接到该已知端口,并在控制台上等待用户输入。客户端期望输入为三个整数,每个轴一个,用逗号分隔。在获取输入后,客户端构建一个给定定义的 struct,使用 Serde 进行序列化,并将字节流发送到服务器。服务器将流反序列化为相同类型的 struct。然后它计算距离并发送回结果,客户端随后显示该结果。代码如下:
// chapter4/serde-server/src/main.rs
#[macro_use]
extern crate serde_derive;
extern crate serde;
extern crate serde_json;
use std::net::{TcpListener, TcpStream};
use std::io::{stdin, BufRead, BufReader, Error, Write};
use std::{env, str, thread};
#[derive(Serialize, Deserialize, Debug)]
struct Point3D {
x: u32,
y: u32,
z: u32,
}
// Like previous examples of vanilla TCP servers, this function handles
// a single client.
fn handle_client(stream: TcpStream) -> Result<(), Error> {
println!("Incoming connection from: {}", stream.peer_addr()?);
let mut data = Vec::new();
let mut stream = BufReader::new(stream);
loop {
data.clear();
let bytes_read = stream.read_until(b'\n', &mut data)?;
if bytes_read == 0 {
return Ok(());
}
let input: Point3D = serde_json::from_slice(&data)?;
let value = input.x.pow(2) + input.y.pow(2) + input.z.pow(2);
write!(stream.get_mut(), "{}", f64::from(value).sqrt())?;
write!(stream.get_mut(), "{}", "\n")?;
}
}
fn main() {
let args: Vec<_> = env::args().collect();
if args.len() != 2 {
eprintln!("Please provide --client or --server as argument");
std::process::exit(1);
}
// The server case
if args[1] == "--server" {
let listener = TcpListener::bind("0.0.0.0:8888").expect("Could
not bind");
for stream in listener.incoming() {
match stream {
Err(e) => eprintln!("failed: {}", e),
Ok(stream) => {
thread::spawn(move || {
handle_client(stream).unwrap_or_else(|error|
eprintln!("{:?}", error));
});
}
}
}
}
// Client case begins here
else if args[1] == "--client" {
let mut stream = TcpStream::connect("127.0.0.1:8888").expect("Could not connect to server");
println!("Please provide a 3D point as three comma separated
integers");
loop {
let mut input = String::new();
let mut buffer: Vec<u8> = Vec::new();
stdin()
.read_line(&mut input)
.expect("Failed to read from stdin");
let parts: Vec<&str> = input
.trim_matches('\n')
.split(',')
.collect();
let point = Point3D {
x: parts[0].parse().unwrap(),
y: parts[1].parse().unwrap(),
z: parts[2].parse().unwrap(),
};
stream
.write_all(serde_json::to_string(&point).unwrap().as_bytes())
.expect("Failed to write to server");
stream.write_all(b"\n").expect("Failed to write to
server");
let mut reader = BufReader::new(&stream);
reader
.read_until(b'\n', &mut buffer)
.expect("Could not read into buffer");
let input = str::from_utf8(&buffer).expect("Could not write
buffer as string");
if input == "" {
eprintln!("Empty response from server");
}
print!("Response from server {}", input);
}
}
}
我们首先设置 Serde,就像在上一例子中做的那样。然后我们定义我们的 3D 点为一个包含三个元素的 struct。在我们的主函数中,我们处理 CLI 参数,并根据传递的内容分支到客户端或服务器。在两种情况下,我们都通过发送换行符来表示传输结束。客户端从 stdin 读取一行,清理它,并在循环中创建 struct 的实例。在两种情况下,我们都用 BufReader 包装我们的流,以便更容易处理。我们使用 Cargo 运行我们的代码。服务器上的一个示例会话如下:
server$ cargo run -- --server
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/serde-server --server`
Incoming connection from: 127.0.0.1:49630
在客户端方面,我们看到以下与服务器交互。正如预期的那样,客户端读取输入,对其进行序列化,并将其发送到服务器。然后它等待响应,并在收到响应时将结果打印到标准输出:
client$ cargo run -- --client
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/serde-server --client`
Please provide a 3D point as three comma separated integers
1,2,3
Response from server 3.7416573867739413
3,4,5
Response from server 7.0710678118654755
4,5,6
Response from server 8.774964387392123
自定义序列化和反序列化
如我们之前所见,Serde 通过宏为所有原始数据类型以及许多复杂数据类型提供了内置的序列化和反序列化功能。然而,在某些情况下,Serde 可能无法自动实现。这可能发生在更复杂的数据类型上。在这些情况下,您将需要手动实现这些。这些示例展示了 Serde 的高级用法,这也允许在输出中重命名字段。对于日常使用,几乎从不必要使用这些高级功能。这些可能更常见于网络通信,处理新的协议等情况。
假设我们有一个包含三个字段的结构体。我们将假设 Serde 无法实现 Serialize 和 Deserialize,因此我们需要手动实现这些。我们使用 Cargo 初始化我们的项目:
$ cargo new --bin serde-custom
然后,我们声明我们的依赖项;生成的 Cargo 配置文件应如下所示:
[package]
name = "serde-custom"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"
serde_test = "1.0"
我们的结构体看起来像这样:
// chapter4/serde-custom/src/main.rs
// We will implement custom serialization and deserialization
// for this struct
#[derive(Debug, PartialEq)]
struct KubeConfig {
port: u8,
healthz_port: u8,
max_pods: u8,
}
我们需要为 Serde 使用内部功能而派生 Debug 和 PartialEq。在现实世界中,可能还需要手动实现这些。现在,我们需要为 kubeconfig 实现 Serialize 特征。这个特征看起来像这样:
pub trait Serialize {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where S: Serializer;
}
序列化我们的结构体的基本工作流程将是简单地序列化结构体名称,然后是每个元素,然后按此顺序发出序列化结束的信号。Serde 内置了可以与所有基本类型一起工作的序列化方法,因此实现不需要担心处理内置类型。让我们看看我们如何序列化我们的结构体:
// chapter4/serde-custom/src/main.rs
// Implementing Serialize for our custom struct defines
// how instances of that struct should be serialized.
// In essence, serialization of an object is equal to
// sum of the serializations of it's components
impl Serialize for KubeConfig {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where S: Serializer
{
let mut state = serializer.serialize_struct("KubeConfig", 3)?;
state.serialize_field("port", &self.port)?;
state.serialize_field("healthz_port", &self.healthz_port)?;
state.serialize_field("max_pods", &self.max_pods)?;
state.end()
}
}
结构体的序列化将始终以调用 serialize_struct 方法开始,该方法以结构体名称和字段数量作为参数(对于其他类型也有类似命名的函数)。然后,我们按顺序序列化每个字段,同时传递一个将在结果 json 中使用的键名。一旦完成,我们调用特殊的 end 方法作为信号。
实现反序列化稍微复杂一些,有一些样板代码。相关的特征看起来像这样:
pub trait Deserialize<'de>: Sized {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where D: Deserializer<'de>;
}
对于类型的实现,需要实现访问者模式。Serde 定义了一个特殊的 Visitor 特征,如下所示样本所示。请注意,这为所有内置类型提供了 visit_* 方法,但在此处未显示。此外,在以下样本中,我们使用符号 ... 来表示还有更多方法,这些方法对于我们的讨论并不重要。
pub trait Visitor<'de>: Sized {
type Value;
fn expecting(&self, formatter: &mut Formatter) -> Result;
fn visit_bool<E>(self, v: bool) -> Result<Self::Value,
E>
where
E: Error,
{ }
...
}
这个特征的实现被反序列化器内部使用,以构建结果类型。在我们的情况下,它将如下所示:
// chapter4/serde-custom/src/main.rs
// Implementing Deserialize for our struct defines how
// an instance of the struct should be created from an
// input stream of bytes
impl<'de> Deserialize<'de> for KubeConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where D: Deserializer<'de>
{
enum Field { Port, HealthzPort, MaxPods };
impl<'de> Deserialize<'de> for Field {
fn deserialize<D>(deserializer: D) ->
Result<Field,
D::Error>
where D: Deserializer<'de>
{
struct FieldVisitor;
impl<'de> Visitor<'de> for FieldVisitor {
type Value = Field;
fn expecting(&self, formatter: &mut fmt::Formatter)
-> fmt::Result {
formatter.write_str("`port` or `healthz_port`
or `max_pods`")
}
fn visit_str<E>(self, value: &str) ->
Result<Field,
E>
where E: de::Error
{
match value {
"port" => Ok(Field::Port),
"healthz_port" =>
Ok(Field::HealthzPort),
"max_pods" => Ok(Field::MaxPods),
_ => Err(de::Error::unknown_field(value,
FIELDS)),
}
}
}
deserializer.deserialize_identifier(FieldVisitor)
}
}
}
现在,反序列化器的输入是 json,它可以被视为一个映射。因此,我们只需要实现 Visitor 特征中的 visit_map。如果将任何非 json 数据传递给我们的反序列化器,它将在调用该特征中的某些其他函数时出错。大部分之前的实现都是样板代码。它归结为几个部分:为字段实现 Visitor,以及实现 visit_str(因为我们的所有字段都是字符串)。在这个阶段,我们应该能够反序列化单个字段。第二部分是实现整体结构的 Visitor,并实现 visit_map。在所有情况下都必须适当地处理错误。最后,我们可以调用 deserializer.deserialize_struct 并传递结构体的名称、字段列表以及整个结构的访问者实现。
此实现将看起来像这样:
// chapter4/serde-custom/src/main.rs
impl<'de> Deserialize<'de> for KubeConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where D: Deserializer<'de>
{
struct KubeConfigVisitor;
impl<'de> Visitor<'de> for KubeConfigVisitor {
type Value = KubeConfig;
fn expecting(&self, formatter: &mut fmt::Formatter) ->
fmt::Result {
formatter.write_str("struct KubeConfig")
}
fn visit_map<V>(self, mut map: V) ->
Result<KubeConfig,
V::Error>
where V: MapAccess<'de>
{
let mut port = None;
let mut hport = None;
let mut max = None;
while let Some(key) = map.next_key()? {
match key {
Field::Port => {
if port.is_some() {
return
Err(de::Error::duplicate_field("port"));
}
port = Some(map.next_value()?);
}
Field::HealthzPort => {
if hport.is_some() {
return
Err(de::Error::duplicate_field
("healthz_port"));
}
hport = Some(map.next_value()?);
}
Field::MaxPods => {
if max.is_some() {
return
Err(de::Error::duplicate_field
("max_pods"));
}
max = Some(map.next_value()?);
}
}
}
let port = port.ok_or_else(||
de::Error::missing_field("port"))?;
let hport = hport.ok_or_else(||
de::Error::missing_field("healthz_port"))?;
let max = max.ok_or_else(||
de::Error::missing_field("max_pods"))?;
Ok(KubeConfig {port: port, healthz_port: hport,
max_pods: max})
}
}
const FIELDS: &'static [&'static str] = &["port",
"healthz_port", "max_pods"];
deserializer.deserialize_struct("KubeConfig", FIELDS,
KubeConfigVisitor)
}
}
Serde 还提供了一个可以用于使用类似令牌流界面的接口来对自定义序列化器和反序列化器进行单元测试的 crate。要使用它,我们需要将 serde_test 添加到我们的 Cargo.toml 中,并在我们的主文件中将其声明为外部 crate。以下是我们反序列化器的测试用例:
// chapter4/serde-custom/src/main.rs
#[test]
fn test_ser_de() {
let c = KubeConfig { port: 10, healthz_port: 11, max_pods: 12};
assert_de_tokens(&c, &[
Token::Struct { name: "KubeConfig", len: 3 },
Token::Str("port"),
Token::U8(10),
Token::Str("healthz_port"),
Token::U8(11),
Token::Str("max_pods"),
Token::U8(12),
Token::StructEnd,
]);
}
assert_de_tokens 调用检查给定的令牌流是否反序列化为我们的结构体,从而测试我们的反序列化器。我们还可以添加一个主函数来驱动序列化器,如下所示:
// chapter4/serde-custom/src/main.rs
fn main() {
let c = KubeConfig { port: 10, healthz_port: 11, max_pods: 12};
let serialized = serde_json::to_string(&c).unwrap();
println!("{:?}", serialized);
}
所有这些现在都可以使用 Cargo 运行。使用 cargo test 应该运行我们刚刚编写的测试,它应该通过。cargo run 应该运行主函数并打印序列化的 json:
$ cargo test
Compiling serde-custom v0.1.0 (file:///serde-custom)
Finished dev [unoptimized + debuginfo] target(s) in 0.61 secs
Running target/debug/deps/serde_custom-81ee5105cf257563
running 1 test
test test_ser_de ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
$ cargo run
Compiling serde-custom v0.1.0 (file:///serde-custom)
Finished dev [unoptimized + debuginfo] target(s) in 0.54 secs
Running `target/debug/serde-custom`
"{\"port\":10,\"healthz_port\":11,\"max_pods\":12}"
解析文本数据
数据解析是与反序列化密切相关的问题。思考解析的最常见方式是从形式语法开始,并基于此构建解析器。这导致了一个自下而上的解析器,其中较小的规则解析整个输入的较小组件。一个最终的组合规则按照给定的顺序将所有较小的规则组合起来形成最终的解析器。这种形式定义有限规则集的方式称为 解析表达式语法(PEG)。这确保了解析是无歧义的;如果解析成功,则只有一个有效的解析树。在 Rust 生态系统中,有几种不同的方法可以实现 PEG,每种方法都有其自身的优点和缺点。第一种方法是使用宏来定义解析的领域特定语言。
此方法通过新的宏系统很好地与编译器集成,并可以生成快速代码。然而,这通常更难调试和维护。由于此方法不允许重载运算符,实现必须定义一个 DSL,这可能会给学习者带来更大的认知负担。第二种方法是使用特征系统。这种方法有助于定义自定义运算符,并且通常更容易调试和维护。使用第一种方法的解析器示例是 nom;使用第二种方法的解析器示例是 pom 和 pest。
我们解析的使用场景主要在网络应用程序的上下文中。在这些情况下,有时处理原始字符串(或字节流)并解析所需信息比反序列化到复杂的数据结构更有用。这种情况的一个常见例子是任何基于文本的协议,如 HTTP。服务器可能会通过套接字接收一个原始请求作为字节流,并将其解析以提取信息。在本节中,我们将研究 Rust 生态系统中的常见解析技术。
现在,nom 是一个解析器组合框架,这意味着它可以组合较小的解析器来构建更强大的解析器。这是一个自下而上的方法,通常从编写非常具体的解析器开始,这些解析器从输入中解析一个定义良好的东西。然后框架提供方法将这些小型解析器链接成一个完整的解析器。这种方法与 lex 和 yacc 的情况中的自顶向下方法形成对比,在那里人们会从定义语法开始。它可以处理字节流(二进制数据)或字符串,并提供 Rust 的所有常用保证。让我们从解析一个简单的字符串开始,在这个例子中是一个 HTTP GET 或 POST 请求。像所有 cargo 项目一样,我们首先设置结构:
$ cargo new --bin nom-http
然后我们将添加我们的依赖项(在这个例子中是 nom)。生成的清单应该看起来像这样:
$ cat Cargo.toml
[package]
name = "nom-http"
version = "0.1.0"
authors = ["Foo<foo@bar.com>"]
[dependencies.nom]
version = "3.2.1"
features = ["verbose-errors", "nightly"]
该包提供了一些额外的功能,这些功能在调试时通常很有用;默认情况下这些功能是禁用的,可以通过传递列表到features标志来打开,如前例所示。现在,让我们继续到我们的主文件:
// chapter4/nom-http/src/main.rs
#[macro_use]
extern crate nom;
use std::str;
use nom::{ErrorKind, IResult};
#[derive(Debug)]
enum Method {
GET,
POST,
}
#[derive(Debug)]
struct Request {
method: Method,
url: String,
version: String,
}
// A parser that parses method out of a HTT request
named!(parse_method<&[u8], Method>,
return_error!(ErrorKind::Custom(12), alt!(map!(tag!("GET"), |_| Method::GET) | map!(tag!("POST"), |_| Method::POST))));
// A parser that parses the request part
named!(parse_request<&[u8], Request>, ws!(do_parse!(
method: parse_method >>
url: map_res!(take_until!(" "), str::from_utf8) >>
tag!("HTTP/") >>
version: map_res!(take_until!("\r"), str::from_utf8) >>
(Request { method: method, url: url.into(), version: version.into() })
)));
// Driver function for running the overall parser
fn run_parser(input: &str) {
match parse_request(input.as_bytes()) {
IResult::Done(rest, value) => println!("Rest: {:?} Value: {:?}",
rest, value),
IResult::Error(err) => println!("{:?}", err),
IResult::Incomplete(needed) => println!("{:?}", needed)
}
}
fn main() {
let get = "GET /home/ HTTP/1.1\r\n";
run_parser(get);
let post = "POST /update/ HTTP/1.1\r\n";
run_parser(post);
let wrong = "WRONG /wrong/ HTTP/1.1\r\n";
run_parser(wrong);
}
如可能显而易见,nom 在代码生成中大量使用宏,其中最重要的一个是named!,它接受一个函数签名并根据该签名定义一个解析器。nom 解析器返回IResult类型的实例;这被定义为枚举,并具有三个变体:
-
Done(rest, value)变体表示当前解析器成功的情形。在这种情况下,值将包含当前解析的值,而剩余的将包含需要解析的剩余输入。 -
Error(Err<E>)变体表示解析过程中的错误。底层错误将包含错误代码、错误位置以及更多信息。在一个大的解析树中,这也可以包含指向更多错误的指针。 -
最后一个变体
Incomplete(needed)表示由于某种原因解析不完整的情况。需要的枚举再次有两个变体;第一个变体表示不知道需要多少数据。第二个变体表示所需数据的精确大小。
我们从 HTTP 方法的表示和完整的请求作为结构体开始。在我们的玩具示例中,我们只处理 GET 和 POST 方法,忽略其他所有内容。然后我们定义了一个用于 HTTP 方法的解析器;我们的解析器将接受一个字节数组切片并返回Method枚举。这很简单,只需读取输入并查找字符串 GET 或 POST。在每种情况下,基础解析器都是使用tag!宏构建的,该宏解析输入以提取给定的字符串。如果解析成功,我们使用map!宏将结果转换为Method,该宏将解析器的结果映射到函数。现在,对于解析方法,我们可能有一个 POST 或一个 GET,但不可能两者都有。我们使用alt!宏来表示之前构建的两个解析器的逻辑或。alt!宏将构建一个解析器,如果其构成宏中的任何一个可以解析给定的输入,则解析输入。最后,所有这些都被return_error!宏包裹起来,如果在当前解析器中解析失败,则提前返回,而不是传递到树中的下一个解析器。
然后,我们继续通过定义parse_request来解析整个请求。我们首先使用ws!宏从输入中去除额外的空白。然后我们调用do_parse!宏,该宏链接着多个子解析器。这个与其他组合器不同,因为它允许存储中间解析器的结果。这在构建结构体实例的同时返回结果时很有用。在do_parse!中,我们首先调用parse_method并将结果存储在一个变量中。从请求中移除方法后,我们应该在找到对象位置之前遇到空白的空格。这由take_until!(" ")调用处理,它消耗输入直到找到空格。结果使用map_res!转换为str。列表中的下一个解析器是使用tag!宏移除序列HTTP/的解析器。接下来,我们通过读取输入直到看到\r来解析 HTTP 版本,并将其映射回str。一旦完成所有解析,我们就构建一个Request对象并返回它。注意在解析器序列中使用>>符号作为分隔符。
我们还定义了一个名为run_parser的辅助函数,用于在给定输入中运行我们的解析器并打印结果。该函数调用解析器并匹配结果以显示结果结构或错误。然后我们定义我们的主函数,包含三个 HTTP 请求,前两个是有效的,最后一个无效,因为方法错误。运行此函数后,输出如下:
$ cargo run
Compiling nom-http v0.1.0 (file:///Users/Abhishek/Desktop/rust-book/src/ch4/nom-http)
Finished dev [unoptimized + debuginfo] target(s) in 0.60 secs
Running `target/debug/nom-http`
Rest: [] Value: Request { method: GET, url: "/home/", version: "1.1" }
Rest: [] Value: Request { method: POST, url: "/update/", version: "1.1" }
NodePosition(Custom(128), [87, 82, 79, 78, 71, 32, 47, 119, 114, 111, 110, 103, 47, 32, 72, 84, 84, 80, 47, 49, 46, 49, 13, 10], [Position(Alt, [87, 82, 79, 78, 71, 32, 47, 119, 114, 111, 110, 103, 47, 32, 72, 84, 84, 80, 47, 49, 46, 49, 13, 10])])
在前两种情况下,所有内容都按预期解析,并返回了结果。正如预期的那样,在最后一种情况下解析失败,并返回了自定义错误。
正如我们之前讨论的,nom 的一个常见问题是调试,因为宏的调试要困难得多。宏还鼓励使用特定的 DSL(如使用 >> 分隔符),这可能会让一些人难以使用。在撰写本文时,nom 的一些错误消息在查找给定解析器的问题时并不足够有帮助。这些将在未来肯定会有所改进,但在此期间,nom 提供了一些辅助宏来帮助调试。
例如,dbg! 在底层解析器没有返回 Done 时打印结果和输入。dbg_dump! 宏类似,但还会打印出输入缓冲区的十六进制转储。根据我们的经验,可以使用一些技术进行调试:
-
通过向
rustc传递编译器选项来扩展宏。Cargo 通过以下调用启用此功能:cargo rustc -- -Z unstable-options --pretty=expanded将展开并格式化打印给定项目中的所有宏。有人可能会发现展开宏以跟踪执行和调试是有用的。Cargo 中相关的命令rustc -- -Z trace-macros仅展开宏。 -
独立运行较小的解析器。给定一系列解析器和另一个组合这些解析器的解析器,运行每个子解析器直到其中一个出错可能更容易。然后,可以继续调试仅失败的较小解析器。这在隔离故障时非常有用。
-
使用提供的调试宏
dbg!和dbg_dump!。这些可以像调试打印语句一样用于跟踪执行。
pretty=expanded 目前是一个不稳定的编译器选项。在未来某个时候,它将被稳定化(或删除)。在这种情况下,将不需要传递 -Z unstable-options 标志来使用它。
让我们看看另一个名为 pom 的解析器组合器的例子。正如我们之前讨论的,这个解析器组合器在很大程度上依赖于特性和运算符重载来实现解析器组合。在撰写本文时,当前版本是 1.1.0,我们将使用这个版本作为我们的示例项目。像往常一样,第一步是设置我们的项目并将 pom 添加到我们的依赖项中:
$ cargo new --bin pom-string
Cargo.toml 文件将看起来像这样:
[package]
name = "pom-string"
version = "0.1.0"
authors = ["Foo<foo@bar.com>"]
[dependencies]
pom = "1.1.0"
在这个例子中,我们将解析一个示例 HTTP 请求,就像上次一样。它看起来是这样的:
// chapter4/pom-string/src/main.rs
extern crate pom;
use pom::DataInput;
use pom::parser::{sym, one_of, seq};
use pom::parser::*;
use std::str;
// Represents one or more occurrence of an empty whitespace
fn space() -> Parser<'static, u8, ()> {
one_of(b" \t\r\n").repeat(0..).discard()
}
// Represents a string in all lower case
fn string() -> Parser<'static, u8, String> {
one_of(b"abcdefghijklmnopqrstuvwxyz").repeat(0..).convert(String::from_utf8)
}
fn main() {
let get = b"GET /home/ HTTP/1.1\r\n";
let mut input = DataInput::new(get);
let parser = (seq(b"GET") | seq(b"POST")) * space() * sym(b'/') *
string() * sym(b'/') * space() * seq(b"HTTP/1.1");
let output = parser.parse(&mut input);
println!("{:?}", str::from_utf8(&output.unwrap()));
}
我们首先声明对pom的依赖。在我们的主函数中,我们将最终的解析器定义为多个子解析器的序列。*运算符被重载,以便按顺序应用多个解析器。seq运算符是一个内置解析器,用于匹配输入中的给定字符串。|运算符对两个操作数进行逻辑或操作。我们定义了一个名为space()的函数,它表示输入中的空格。此函数接受每种空格字符中的一个,重复 0 次或多次,然后丢弃它。因此,该函数返回一个没有返回类型的Parser,表示为()。字符串函数被定义为英语字母中的一个字符,重复 0 次或多次,然后转换为std::String。
这个函数的返回类型是一个Parser,它有一个String,正如预期的那样。设置好这些之后,我们的主解析器将有一个空格,后面跟着符号/,然后是一个字符串,一个符号/,再次是一个空格,并以序列HTTP/1.1结束。正如预期的那样,当我们用我们编写的解析器解析一个示例字符串时,它产生了一个Ok:
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/pom-string`
Ok("HTTP/1.1")
基于 PEG 的解析器组合器可能更容易调试和操作。它们还倾向于产生更好的错误消息,但不幸的是,目前它们还不够成熟。围绕它们的社区不如 nom 周围的社区大。因此,在 nom 问题上通常更容易获得帮助。最终,选择适合他们的解决方案取决于程序员。
解析二进制数据
一个相关的问题是解析二进制数据。这种情况下常见的例子包括解析二进制文件和二进制协议。让我们看看nom如何被用来解析二进制数据。在我们的玩具示例中,我们将编写一个解析 IPv6 头部的解析器。我们的Cargo.toml将和上次一样。使用 CLI 设置项目:
$ cargo new --bin nom-ipv6
我们的主要文件将看起来像这样:
// chapter4/nom-ipv6/src/main.rs
#[macro_use]
extern crate nom;
use std::net::Ipv6Addr;
use nom::IResult;
// Struct representing an IPv6 header
#[derive(Debug, PartialEq, Eq)]
pub struct IPv6Header {
version: u8,
traffic_class: u8,
flow_label: u32,
payload_length: u16,
next_header: u8,
hop_limit: u8,
source_addr: Ipv6Addr,
dest_addr: Ipv6Addr,
}
// Converts a given slice of [u8] to an array of 16 u8 given by
// [u8; 16]
fn slice_to_array(input: &[u8]) -> [u8; 16] {
let mut array = [0u8; 16];
for (&x, p) in input.iter().zip(array.iter_mut()) {
*p = x;
}
array
}
// Converts a reference to a slice [u8] to an instance of
// std::net::Ipv6Addr
fn to_ipv6_address(i: &[u8]) -> Ipv6Addr {
let arr = slice_to_array(i);
Ipv6Addr::from(arr)
}
// Parsers for each individual section of the header
named!(parse_version<&[u8], u8>, bits!(take_bits!(u8, 4)));
named!(parse_traffic_class<&[u8], u8>, bits!(take_bits!(u8, 8)));
named!(parse_flow_label<&[u8], u32>, bits!(take_bits!(u32, 20)));
named!(parse_payload_length<&[u8], u16>, bits!(take_bits!(u16, 16)));
named!(parse_next_header<&[u8], u8>, bits!(take_bits!(u8, 8)));
named!(parse_hop_limit<&[u8], u8>, bits!(take_bits!(u8, 8)));
named!(parse_address<&[u8], Ipv6Addr>, map!(take!(16), to_ipv6_address));
// The primary parser
named!(ipparse<&[u8], IPv6Header>,
do_parse!(
ver: parse_version >>
cls: parse_traffic_class >>
lbl: parse_flow_label >>
len: parse_payload_length >>
hdr: parse_next_header >>
lim: parse_hop_limit >>
src: parse_address >>
dst: parse_address >>
(IPv6Header {
version: ver,
traffic_class: cls,
flow_label: lbl,
payload_length: len,
next_header: hdr,
hop_limit: lim,
source_addr: src,
dest_addr : dst
})
));
// Wrapper for the parser
pub fn parse_ipv6_header(i: &[u8]) -> IResult<&[u8], IPv6Header> {
ipparse(i)
}
fn main() {
const EMPTY_SLICE: &'static [u8] = &[];
let bytes = [0x60,
0x00,
0x08, 0x19,
0x80, 0x00, 0x14, 0x06,
0x40,
0x2a, 0x02, 0x0c, 0x7d, 0x2e, 0x5d, 0x5d, 0x00,
0x24, 0xec, 0x4d, 0xd1, 0xc8, 0xdf, 0xbe, 0x75,
0x2a, 0x00, 0x14, 0x50, 0x40, 0x0c, 0x0c, 0x0b,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xbd
];
let expected = IPv6Header {
version: 6,
traffic_class: 0,
flow_label: 33176,
payload_length: 20,
next_header: 6,
hop_limit: 64,
source_addr:
"2a02:c7d:2e5d:5d00:24ec:4dd1:c8df:be75".parse().unwrap(),
dest_addr: "2a00:1450:400c:c0b::bd".parse().unwrap(),
};
assert_eq!(ipparse(&bytes), IResult::Done(EMPTY_SLICE, expected));
}
在这里,我们首先声明一个结构体,用于表示在 RFC 2460 中定义的 IPv6 固定头部(tools.ietf.org/html/rfc2460)。我们首先定义一个辅助函数to_ipv6_address,它接受一个u8切片并将其转换为 IPv6 地址。为此,我们需要另一个辅助函数,该函数将切片转换为固定大小的数组(在这个例子中是16)。设置好这些之后,我们定义了多个解析器,使用named!宏来解析结构体成员的每个成员。
parse_version 函数接收一个字节数组切片并返回一个 u8 类型的版本。这是通过从输入中读取 4 位作为 u8,使用 take_bits! 宏来完成的。然后它被 bits! 宏包装,该宏将输入转换为底层解析器的位流。以同样的方式,我们继续定义头部结构中所有其他字段的解析器。对于每一个,我们根据 RFC 中它们占用的位数将它们转换为足够大的类型。解析地址的最后一种情况不同。在这里,我们使用 take! 宏读取 16 个字节并将它们映射到 to_ipv6_address 函数,使用 map! 宏将字节流转换为地址。
到目前为止,所有用于解析整个结构体的小片段都已准备就绪,我们可以使用 do_parse! 宏来定义一个函数。在那里,我们在临时变量中累积结果并构建一个 IPv6Header 结构体的实例,然后返回。在我们的主函数中,有一个从 IPv6 数据包转储中取出的字节数组,它应该代表一个有效的 IPv6 头部。我们使用定义的解析器来解析它,并断言输出与预期相符。因此,我们解析器的成功运行之前不会抛出异常。
让我们回顾一下到目前为止使用的所有 nom 宏:
| 宏 | 目的 |
|---|---|
named! |
通过组合较小的函数创建一个解析函数。这(或其变体)总是链中的顶级调用。 |
ws! |
启用解析器消耗标记之间的所有空白字符(\t、\r 和 \n)。 |
do_parse! |
以给定顺序应用子解析器,可以存储中间结果。 |
tag! |
声明一个静态字节序列,封装的解析器应该识别。 |
take_until! |
消耗输入直到给定的标记。 |
take_bits! |
从输入中消耗给定数量的位并将它们转换为给定的类型。 |
take! |
从输入中消耗指定数量的字节。 |
map_res! |
将函数(返回结果)映射到解析器的输出。 |
map! |
将函数映射到解析器的输出。 |
bits! |
将给定的切片转换为位流。 |
摘要
在本节中,我们更详细地研究了处理数据的方法。具体来说,是序列化和解析。在撰写本文时,Serde 和相关 crate 是 Rust 社区支持的数据序列化和反序列化方式,而 nom 是最常用的解析组合器。这些工具在 nightly 编译器上通常会生成更好的错误信息,并且当开启一些功能标志时,因为它们通常依赖于一些仅夜间的前沿特性。随着时间的推移,这些特性将可用于稳定编译器,并且这些工具将无缝工作。
在下一章中,我们将讨论在套接字上理解传入数据之后的下一步。通常情况下,这涉及到处理应用层协议。
第五章:应用层协议
正如我们在前几章中看到的,网络中的两个主机在流或离散的包中交换字节。通常,这些字节的处理工作由高级应用程序来完成,使其对应用程序有意义。这些应用程序在传输层之上定义了一个新的协议层,通常称为应用层协议。在本章中,我们将探讨这些协议中的一些。
设计应用层协议时有许多重要的考虑因素。实现需要至少了解以下详细信息:
-
通信是广播还是点对点?在前一种情况下,底层传输协议必须是 UDP。在后一种情况下,可以是 TCP 或 UDP。
-
协议需要可靠的传输吗?如果是,TCP 是唯一的选择。否则,UDP 也可能适用。
-
应用程序需要字节流(TCP)吗,还是可以按包逐个处理(UDP)?
-
双方之间如何表示输入的结束?
-
使用的数据格式和编码是什么?
一些非常常用的应用层协议包括 DNS(我们在前面的章节中学习过)和 HTTP(我们将在下一章学习)。除此之外,一个非常重要的应用层工具集,常用于基于微服务的架构,是 gRPC。另一个每个人至少使用过几次的应用层协议是 SMTP,这是电子邮件的协议。
在本章中,我们将研究以下主题:
-
RPC 是如何工作的。具体来说,我们将探讨 gRPC,并使用工具包编写一个小的服务器和客户端。
-
我们将查看一个可以用于发送电子邮件的 crate 调用
lettre。 -
最后一个主题将是用 Rust 编写一个简单的 FTP 客户端和 TFTP 服务器。
RPC 简介
在常规编程中,将常用逻辑封装在函数中通常很有用,这样就可以在多个地方重用。随着网络化和分布式系统的兴起,让一组公共操作可以通过网络访问变得必要,这样经过验证的客户端就可以调用它们。这通常被称为远程过程调用(RPC)。在第四章,数据序列化、反序列化和解析*中,我们看到了一个简单的例子,当服务器返回给定点与原点的距离时。现实世界的 RPC 定义了许多应用层协议,这些协议要复杂得多。最受欢迎的 RPC 实现之一是 gRPC,它最初由谷歌引入,后来转为开源模式。gRPC 在互联网规模网络上提供高性能 RPC,并被广泛应用于许多项目中,包括 Kubernetes。
在深入研究 gRPC 之前,让我们看看相关的工具——协议缓冲区。它是一套机制,用于在应用程序之间构建语言和平台中立的交换结构化数据。它定义了自己的接口定义语言(IDL)来描述数据格式,以及一个可以将该格式转换为代码并生成代码以进行转换的编译器。IDL 还允许定义抽象服务:编译器可以用来生成给定语言的存根的输入和输出消息格式。我们将在后续示例中看到一个数据格式定义的例子。编译器有插件可以生成大量语言的输出代码,包括 Rust。在我们的示例中,我们将在构建脚本中使用这样的插件来自动生成 Rust 模块。现在,gRPC 使用协议缓冲区来定义底层数据和消息。消息在 TCP/IP 之上通过 HTTP/2 进行交换。在实践中,这种通信模式通常更快,因为它可以更好地利用现有连接,并且 HTTP/2 支持双向异步连接。gRPC 作为一个有偏见的系统,代表我们做出了许多关于我们在上一节讨论的考虑的假设。大多数这些默认值(如 HTTP/2 通过 TCP)都是因为它们支持 gRPC 提供的先进功能(如双向流)。一些其他默认值,如使用protobuf,可以与其他消息格式实现交换。
对于我们的 gRPC 示例,我们将构建一个类似于 Uber 的服务。它有一个中央服务器,客户端(出租车)可以记录他们的名字和位置。然后,当用户请求出租车并给出他们的位置时,服务器会发送一个靠近该用户的出租车列表。理想情况下,这个服务器应该有两个客户端类别,一个用于出租车,一个用于用户。但为了简单起见,我们将假设我们只有一种类型的客户端。
让我们从设置项目开始。像往常一样,我们将使用 Cargo CLI 初始化项目:
$ cargo new --bin grpc_example
protoc-rust-grpc crate. Hence, we will need to add that as a build dependency:
$ cat Cargo.toml
[package]
name = "grpc_example"
version = "0.1.0"
authors = ["Foo<foo@bar.com>"]
[dependencies]
protobuf = "1.4.1"
grpc = "0.2.1"
tls-api = "0.1.8"
[build-dependencies]
protoc-rust-grpc = "0.2.1"
以下脚本是我们的构建脚本。它只是一个 Rust 可执行文件(具有主函数),Cargo 在调用给定项目的编译器之前构建并运行它。请注意,此脚本的默认名称为build.rs,并且它必须位于项目根目录中。然而,这些参数可以在 Cargo 配置文件中进行配置:
// ch5/grpc/build.rs
extern crate protoc_rust_grpc;
fn main() {
protoc_rust_grpc::run(protoc_rust_grpc::Args {
out_dir: "src",
includes: &[],
input: &["foobar.proto"],
rust_protobuf: true,
}).expect("Failed to generate Rust src");
}
构建脚本最常见的使用案例之一是代码生成(如我们当前的项目)。它们还可以用于在主机上查找和配置本地库等。
在脚本中,我们使用protoc_rust_grpc包来从我们的proto文件(称为foobar.proto)生成 Rust 模块。我们还设置了rust_protobuf标志以使其生成 protobuf 消息。请注意,protoc二进制文件必须在$PATH中可用才能正常工作。这是 protobuf 包的一部分。按照以下步骤从源代码安装它:
- 从 GitHub 下载预构建的二进制文件:
$ curl -Lo https://github.com/google/protobuf/releases/download/v3.5.1/protoc-3.5.1-linux-x86_64.zip
- 解压缩存档:
$ unzip protoc-3.5.1-linux-x86_64.zip -d protoc3
- 将二进制文件复制到
$PATH中的某个位置:
$ sudo mv protoc3/bin/* /usr/local/bin/
当前示例已在 Ubuntu 16.04 上使用 protoc 版本 3.5.1 进行了测试。
接下来,我们需要协议定义,如下代码片段所示:
// ch5/grpc/foobar.proto
syntax = "proto3";
package foobar;
// Top level gRPC service with two RPC calls
service FooBarService {
rpc record_cab_location(CabLocationRequest) returns
(CabLocationResponse);
rpc get_cabs(GetCabRequest) returns (GetCabResponse);
}
// A request to record location of a cab
// Name: unique name for a cab
// Location: current location of the given cab
message CabLocationRequest {
string name = 1;
Location location = 2;
}
// A response for a CabLocationRequest
// Accepted: a boolean indicating if this
// request was accepted for processing
message CabLocationResponse {
bool accepted = 1;
}
// A request to return cabs at a given location
// Location: a given location
message GetCabRequest {
Location location = 1;
}
// A response for GetCabLocation
// Cabs: list of cabs around the given location
message GetCabResponse {
repeated Cab cabs = 1;
}
// Message that the CabLocationRequest passes
// to the server
message Cab {
string name = 1;
Location location = 2;
}
// Message with the location of a cab
message Location {
float latitude = 1;
float longitude = 2;
}
proto 文件以 protobuf IDP 规范版本的声明开始;我们将使用版本 3。包声明表明所有生成的代码都将放置在一个名为 foobar 的 Rust 模块中,所有其他生成的代码都将放置在一个名为 foobar_grpc 的模块中。我们定义了一个名为 FooBarService 的服务,它有两个 RPC 函数;record_cab_location 记录出租车的位置,给定其名称和位置,而 get_cabs 返回一组出租车,给定一个位置。我们还需要为每个请求和响应定义所有相关的 protobuf 消息。规范还定义了多个与编程语言中的数据类型(字符串、浮点数等)紧密对应的自定义数据类型。
在设置好与 protobuf 消息格式和函数相关的一切之后,我们可以使用 Cargo 生成实际的 Rust 代码。生成的代码将位于 src 目录中,并命名为 foobar.rs 和 foobar_grpc.rs。这些名称由编译器自动分配。lib.rs 文件应使用 pub mod 语法重新导出这些模块。请注意,Cargo 构建不会为我们修改 lib.rs 文件;这需要手动完成。让我们继续我们的服务器和客户端。以下是服务器的外观:
// ch5/grpc/src/bin/server.rs
extern crate grpc_example;
extern crate grpc;
extern crate protobuf;
use std::thread;
use grpc_example::foobar_grpc::*;
use grpc_example::foobar::*;
struct FooBarServer;
// Implementation of RPC functions
impl FooBarService for FooBarServer {
fn record_cab_location(&self,
_m: grpc::RequestOptions,
req: CabLocationRequest)
->
grpc::SingleResponse<CabLocationResponse> {
let mut r = CabLocationResponse::new();
println!("Recorded cab {} at {}, {}", req.get_name(),
req.get_location().latitude, req.get_location().longitude);
r.set_accepted(true);
grpc::SingleResponse::completed(r)
}
fn get_cabs(&self,
_m: grpc::RequestOptions,
_req: GetCabRequest)
-> grpc::SingleResponse<GetCabResponse> {
let mut r = GetCabResponse::new();
let mut location = Location::new();
location.latitude = 40.7128;
location.longitude = -74.0060;
let mut one = Cab::new();
one.set_name("Limo".to_owned());
one.set_location(location.clone());
let mut two = Cab::new();
two.set_name("Merc".to_owned());
two.set_location(location.clone());
let vec = vec![one, two];
let cabs = ::protobuf::RepeatedField::from_vec(vec);
r.set_cabs(cabs);
grpc::SingleResponse::completed(r)
}
}
fn main() {
let mut server = grpc::ServerBuilder::new_plain();
server.http.set_port(9001);
server.add_service(FooBarServiceServer::new_service_def(FooBarServer));
server.http.set_cpu_pool_threads(4);
let _server = server.build().expect("Could not start server");
loop {
thread::park();
}
}
注意,这个服务器与我们之前章节中编写的服务器非常不同。这是因为 grpc::ServerBuilder 封装了编写服务器时的大部分复杂性。FooBarService 是为我们生成的 protobuf 编译器服务,在 foobar_grpc.rs 文件中定义为 trait。正如预期的那样,这个 trait 有两个方法:record_cab_location 和 get_cabs。因此,对于我们的服务器,我们需要在一个结构体上实现这个 trait 并将其传递给 ServerBuilder 以在指定的端口上运行。
在我们的玩具示例中,我们实际上不会记录出租车位置。一个真实世界的应用会希望将这些信息存储在数据库中以供以后查询。相反,我们只需打印一条消息,说明我们收到了一个新的位置。我们还需要处理一些样板代码,以确保所有 gRPC 语义都得到满足。在 get_cabs 函数中,我们总是为所有请求返回一个静态的出租车列表。请注意,由于所有 protobuf 消息都是为我们生成的,我们免费获得了一些实用函数,如 get_name 和 get_location。最后,在 main 函数中,我们将我们的服务器结构传递给 gRPC,在指定的端口上创建一个新的服务器并无限循环运行它。
我们的客户端实际上是在由 protobuf 编译器生成的源代码中定义为一个结构体。我们只需要确保客户端具有与我们运行服务器相同的端口号。我们使用客户端结构体的 new_plain 方法,并传递一个地址和端口号给它,以及一些默认选项。然后我们可以通过 RPC 调用 record_cab_location 和 get_cabs 方法并处理响应:
// ch5/grpc/src/bin/client.rs
extern crate grpc_example;
extern crate grpc;
use grpc_example::foobar::*;
use grpc_example::foobar_grpc::*;
fn main() {
// Create a client to talk to a given server
let client = FooBarServiceClient::new_plain("127.0.0.1", 9001,
Default::default()).unwrap();
let mut req = CabLocationRequest::new();
req.set_name("foo".to_string());
let mut location = Location::new();
location.latitude = 40.730610;
location.longitude = -73.935242;
req.set_location(location);
// First RPC call
let resp = client.record_cab_location(grpc::RequestOptions::new(),
req);
match resp.wait() {
Err(e) => panic!("{:?}", e),
Ok((_, r, _)) => println!("{:?}", r),
}
let mut nearby_req = GetCabRequest::new();
let mut location = Location::new();
location.latitude = 40.730610;
location.longitude = -73.935242;
nearby_req.set_location(location);
// Another RPC call
let nearby_resp = client.get_cabs(grpc::RequestOptions::new(),
nearby_req);
match nearby_resp.wait() {
Err(e) => panic!("{:?}", e),
Ok((_, cabs, _)) => println!("{:?}", cabs),
}
}
客户端运行的过程如下。如前所述,这并不像应有的那样动态,因为它只返回硬编码的值:
$ cargo run --bin client
Blocking waiting for file lock on build directory
Compiling grpc_example v0.1.0 (file:///rust-book/src/ch5/grpc)
Finished dev [unoptimized + debuginfo] target(s) in 3.94 secs
Running `/rust-book/src/ch5/grpc/target/debug/client`
accepted: true
cabs {name: "Limo" location {latitude: 40.7128 longitude: -74.006}} cabs {name: "Merc" location {latitude: 40.7128 longitude: -74.006}}
注意它在与服务器通信后立即退出。另一方面,服务器在一个无限循环中运行,直到接收到信号才退出:
$ cargo run --bin server
Compiling grpc_example v0.1.0 (file:///rust-book/src/ch5/grpc)
Finished dev [unoptimized + debuginfo] target(s) in 5.93 secs
Running `/rust-book/src/ch5/grpc/target/debug/server`
Recorded cab foo at 40.73061, -73.93524
SMTP 简介
互联网电子邮件使用一种称为 简单邮件传输协议(SMTP)的协议,这是一个 IETF 标准。与 HTTP 类似,它是一个简单的基于 TCP 的文本协议,默认使用端口 25。在本节中,我们将查看使用 lettre 发送电子邮件的小示例。为了使这成为可能,让我们首先设置我们的项目:
$ cargo new --bin lettre-example
现在,我们的 Cargo.toml 文件应该看起来像这样:
$ cat Cargo.toml
[package]
name = "lettre-example"
version = "0.1.0"
authors = ["Foo<foo@bar.com>"]
[dependencies]
lettre = "0.7"
uuid = "0.5.1"
native-tls = "0.1.4"
假设我们想要自动发送服务器的崩溃报告。为了实现这一点,我们需要有一个可访问的 SMTP 服务器在运行。我们还需要有一个用户,可以使用在该服务器上设置的密码进行身份验证。设置好这些后,我们的代码将看起来像这样:
// ch5/lettre-example/src/main.rs
extern crate uuid;
extern crate lettre;
extern crate native_tls;
use std::env;
use lettre::{SendableEmail, EmailAddress, EmailTransport};
use lettre::smtp::{SmtpTransportBuilder, SUBMISSION_PORT};
use lettre::smtp::authentication::Credentials;
use lettre::smtp::client::net::ClientTlsParameters;
use native_tls::TlsConnector;
// This struct represents our email with all the data
// we want to send.
struct CrashReport {
to: Vec<EmailAddress>,
from: EmailAddress,
message_id: String,
message: Vec<u8>,
}
// A simple constructor for our email.
impl CrashReport {
pub fn new(from_address: EmailAddress,
to_addresses: Vec<EmailAddress>,
message_id: String,
message: String) -> CrashReport {
CrashReport { from: from_address,
to: to_addresses,
message_id: message_id,
message: message.into_bytes()
}
}
}
impl<'a> SendableEmail<'a, &'a [u8]> for CrashReport {
fn to(&self) -> Vec<EmailAddress> {
self.to.clone()
}
fn from(&self) -> EmailAddress {
self.from.clone()
}
fn message_id(&self) -> String {
self.message_id.clone()
}
fn message(&'a self) -> Box<&[u8]> {
Box::new(self.message.as_slice())
}
}
fn main() {
let server = "smtp.foo.bar";
let connector = TlsConnector::builder().unwrap().build().unwrap();
let mut transport = SmtpTransportBuilder::new((server, SUBMISSION_PORT), lettre::ClientSecurity::Opportunistic(<ClientTlsParameters>::new(server.to_string(), connector)))
.expect("Failed to create transport")
.credentials(Credentials::new(env::var("USERNAME").unwrap_or_else(|_| "user".to_string()),
env::var("PASSWORD").unwrap_or_else
(|_| "password".to_string())))
.build();
let report = CrashReport::new(EmailAddress::
new("foo@bar.com".to_string()), vec!
[EmailAddress::new("foo@bar.com".to_string())]
, "foo".to_string(), "OOPS!".to_string());
transport.send(&report).expect("Failed to send the report");
}
我们的电子邮件由 CrashReport 结构体表示;正如预期的那样,它有一个 from 发件人电子邮件地址。to 字段是一个电子邮件地址的向量,使我们能够向多个地址发送电子邮件。我们为该结构体实现了一个构造函数。lettre 包含一个名为 SendableEmail 的特质,它具有 SMTP 服务器发送电子邮件所需的一组属性。为了使用户定义的电子邮件可发送,它需要实现该特质。在我们的例子中,CrashReport 需要实现它。我们继续实现特质中所有必需的方法。此时,一个新的 CrashReport 实例应该可以作为电子邮件发送。
在我们的主函数中,我们需要对 SMTP 服务器进行 auth 以发送我们的电子邮件。我们创建一个包含与 SMTP 服务器通信所需所有信息的传输对象。用户名和密码可以作为环境变量(或默认值)传递。然后我们创建一个 CrashReport 实例,并使用传输对象的 send 方法发送它。运行此操作不会输出任何信息(如果它成功运行)。
可能有人已经注意到 lettre 暴露的 API 并不容易使用。这主要是因为在撰写本文时,该库在很大程度上还不成熟,处于 0.7 版本。因此,我们应该期待在达到 1.0 版本之前,API 会发生重大变化。
FTP 和 TFTP 简介
另一个常见的应用层协议是 文件传输协议(FTP)。这是一个基于文本的协议,其中服务器和客户端通过交换文本命令来上传和下载文件。Rust 生态系统有一个名为 rust-ftp 的 crate,可以用来与 FTP 服务器进行程序性交互。让我们看看它的一个使用示例。我们使用 Cargo 设置我们的项目:
$ cargo new --bin ftp-example
我们的 Cargo.toml 应该看起来像这样:
[package]
name = "ftp-example"
version = "0.1.0"
authors = ["Foo<foo@bar.com>"]
[dependencies.ftp]
version = "2.2.1"
为了使这个例子工作,我们需要一个运行中的 FTP 服务器。一旦我们设置好并确保常规 FTP 客户端可以连接到它,我们就可以继续我们的主要代码:
// ch5/ftp-example/src/main.rs
extern crate ftp;
use std::str;
use std::io::Cursor;
use ftp::{FtpStream, FtpError};
fn run_ftp(addr: &str, user: &str, pass: &str) -> Result<(), FtpError> {
let mut ftp_stream = FtpStream::connect((addr, 21))?;
ftp_stream.login(user, pass)?;
println!("current dir: {}", ftp_stream.pwd()?);
let data = "A random string to write to a file";
let mut reader = Cursor::new(data);
ftp_stream.put("my_file.txt", &mut reader)?;
ftp_stream.quit()
}
fn main() {
run_ftp("ftp.dlptest.com", "dlpuser@dlptest.com", "eiTqR7EMZD5zy7M").unwrap();
}
在我们的例子中,我们将连接到一个位于 ftp.dlptest.com 的免费公共 FTP 服务器。使用该服务器的凭据位于此网站:dlptest.com/ftp-test/。我们称为 run_ftp 的辅助函数接收一个 FTP 服务器的地址,其中用户名和密码作为字符串。然后它连接到端口号 21(FTP 的默认端口)。接着使用提供的凭据登录,然后打印当前目录(应该是 /)。然后我们使用 put 函数在那里写入文件,最后关闭与服务器的连接。在我们的 main 函数中,我们只需调用辅助函数并传入所需的参数。
这里需要注意的一个问题是 cursor 的使用;它代表一个内存缓冲区,并提供了在该缓冲区上实现 Read、Write 和 Seek 的方法。put 函数期望一个实现了 Read 的输入;将我们的数据包装在 Cursor 中会自动为我们完成这一点。
运行此示例时,我们会看到以下内容:
$ cargo run
Compiling ftp-example v0.1.0 (file:///Users/Abhishek/Desktop/rust-book/src/ch5/ftp-example)
Finished dev [unoptimized + debuginfo] target(s) in 1.5 secs
Running `target/debug/ftp-example`
current dir: /
在这个例子中,我们使用了一个公开的 FTP 服务器。由于这个服务器不在我们的控制之下,它可能会在没有通知的情况下离线。如果发生这种情况,示例需要修改以使用另一个服务器。
与 FTP 密切相关的一个协议称为 简单文件传输协议(TFTP)。TFTP 是基于文本的,就像 FTP 一样,但与 FTP 不同,它更容易实现和维护。它使用 UDP 进行传输,并且不提供任何认证原语。由于它更快、更轻量,它经常在嵌入式系统和引导协议(如 PXE 和 BOOTP)中实现。让我们看看使用名为 tftp_server 的 crate 的简单 TFTP 服务器。在这个例子中,我们将从 Cargo 开始,如下所示:
$ cargo new --bin tftp-example
我们的清单非常简单,看起来像这样:
[package]
name = "tftp-example"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
tftp_server = "0.0.2"
我们的主要文件将看起来像这样:
// ch5/tftp-example/src/main.rs
extern crate tftp_server;
use tftp_server::server::TftpServer;
use std::net::SocketAddr;
use std::str::FromStr;
fn main() {
let addr = format!("0.0.0.0:{}", 69);
let socket_addr = SocketAddr::from_str(addr.as_str()).expect("Error
parsing address");
let mut server =
TftpServer::new_from_addr(&socket_addr).expect("Error creating
server");
match server.run() {
Ok(_) => println!("Server completed successfully!"),
Err(e) => println!("Error: {:?}", e),
}
}
这可以在任何安装了 TFTP 客户端的 Unix 机器上轻松测试。如果我们在一个终端上运行此示例,并在另一个终端上运行客户端,我们需要将客户端连接到本地的端口号 69。然后我们应该能够从服务器下载文件。
运行此可能需要 root 权限。如果是这种情况,请使用 sudo。
$ sudo ./target/debug/tftp-example
一个示例会话如下:
$ tftp
tftp> connect 127.0.0.1 69
tftp> status
Connected to 127.0.0.1.
Mode: netascii Verbose: off Tracing: off
Rexmt-interval: 5 seconds, Max-timeout: 25 seconds
tftp> get Cargo.toml
Received 144 bytes in 0.0 seconds
tftp> ^D
摘要
在本章中,我们基于之前所学的内容进行了扩展。本质上,我们将网络栈提升到了应用层。我们研究了构建应用层协议的一些主要考虑因素。然后我们探讨了 RPC,特别是 gRPC,研究了它是如何帮助开发者构建大规模网络服务的。接着我们查看了一个可以用于通过 SMTP 服务器发送电子邮件的 Rust crate。在本书的其他部分也涵盖了其他应用层协议的情况下,我们应该对这些协议有很好的理解。
HTTP 是一种基于文本的应用层协议,它值得单独一章来介绍。在下一章中,我们将更深入地研究它,并编写一些代码使其工作。
第六章:在互联网上谈论 HTTP
最重要的应用层协议之一,它极大地改变了我们的生活,必须是 HTTP。它是万维网(WWW)的骨干。在本章中,我们将探讨 Rust 如何使编写快速 HTTP 服务器变得更加容易。我们还将探讨编写客户端通过网络与这些服务器通信。
在本章中,我们将涵盖以下主题:
-
Hyper 的简要介绍,它是编写 HTTP 服务器中最广泛使用的 crate 之一
-
我们将研究 Rocket,一个由于接口更简单而变得非常流行的 crate
-
我们将继续介绍 reqwest,一个 HTTP 客户端库
介绍 Hyper
Hyper 可以说是基于 Rust 的 HTTP 框架中最稳定和最知名的。它有两个不同的组件,一个用于编写 HTTP 服务器,另一个用于编写客户端。最近,服务器组件被移动到一个基于 tokio 和 futures 的新异步编程模型。因此,它非常适合高流量工作负载。然而,像生态系统中的许多其他库一样,Hyper 尚未达到版本 1.0,因此应该预期 API 会有破坏性的变化。
我们将从在 Hyper 中编写一个小型 HTTP 服务器开始。像往常一样,我们需要使用 Cargo 设置我们的项目。
$ cargo new --bin hyper-server
现在我们将添加依赖项,包括hyper和futures。Cargo.toml文件将如下所示:
[package]
name = "hyper-server"
version = "0.1.0"
authors = ["Foo<foo@bar.com>"]
[dependencies]
hyper = "0.11.7"
futures = "0.1.17"
我们的主要文件相当简单。在现实世界中,HTTP 服务器通常与后端进行通信,所有这些操作可能需要一段时间才能完成。因此,回复稍微延迟是很常见的。我们将使用一个每次调用时暂停 200 毫秒的函数来模拟这种情况,然后返回一个固定的字符串。
// ch6/hyper-server/src/main.rs
extern crate hyper;
extern crate futures;
use std::{ thread, time };
use futures::future::FutureResult;
use hyper::{Get, StatusCode};
use hyper::header::ContentLength;
use hyper::server::{Http, Service, Request, Response};
// Simulate CPU intensive work by sleeping for 200 ms
fn heavy_work() -> String {
let duration = time::Duration::from_millis(200);
thread::sleep(duration);
"done".to_string()
}
#[derive(Clone, Copy)]
struct Echo;
impl Service for Echo {
type Request = Request;
type Response = Response;
type Error = hyper::Error;
type Future = FutureResult<Response, hyper::Error>;
// This method handles actually processing requests
// We only handle GET requests on /data and ignore everything else
// returning a HTTP 404
fn call(&self, req: Request) -> Self::Future {
futures::future::ok(match (req.method(), req.path()) {
(&Get, "/data") => {
let b = heavy_work().into_bytes();
Response::new()
.with_header(ContentLength(b.len() as u64))
.with_body(b)
}
_ => Response::new().with_status(StatusCode::NotFound),})
}
}
fn main() {
let addr = "0.0.0.0:3000".parse().unwrap();
let server = Http::new().bind(&addr, || Ok(Echo)).unwrap();
server.run().unwrap();
}
由于 Hyper 严重依赖tokio来处理异步请求,Hyper 中的 HTTP 服务器需要实现来自tokio的内置特质Service。这本质上是一个将Request映射到Response的函数,通过实现call方法。此方法返回一个Future,表示给定任务的最终完成。在该实现中,我们匹配传入请求的方法和路径。如果方法是GET且路径是/data,我们调用heavy_work并获取结果。然后,我们通过设置返回字符串的大小作为Content-Length头和响应体来组合响应。在我们的main函数中,我们通过绑定到一个已知端口来构建我们的服务器。最后,我们调用run来启动服务器。
使用curl与服务器交互很简单;会话应该如下所示:
$ curl http://127.0.0.1:3000/data
done$
让我们基准测试我们的服务器。为此,我们将安装 ApacheBench (httpd.apache.org/docs/trunk/programs/ab.html)。我们将通过向 ApacheBench 传递一些命令行参数,并行运行来自 100 个客户端的 1,000 个总请求。这需要一段时间才能完成,我们在返回每个响应之前等待 200 毫秒。因此,对于 1,000 个请求,我们将至少等待 200 秒。一次运行的结果如下:
$ ab -n 1000 -c 100 http://127.0.0.1:3000/data
Benchmarking 127.0.0.1 (be patient)
Completed 100 requests
Completed 200 requests
Completed 300 requests
Completed 400 requests
Completed 500 requests
Completed 600 requests
Completed 700 requests
Completed 800 requests
Completed 900 requests
Completed 1000 requests
Finished 1000 requests
Server Software:
Server Hostname: 127.0.0.1
Server Port: 3000
Document Path: /data
Document Length: 4 bytes
Concurrency Level: 100
Time taken for tests: 203.442 seconds
Complete requests: 1000
Failed requests: 0
Total transferred: 79000 bytes
HTML transferred: 4000 bytes
Requests per second: 4.92 [#/sec] (mean)
Time per request: 20344.234 [ms] (mean)
Time per request: 103.442 [ms] (mean, across all concurrent requests)
Transfer rate: 0.38 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 2 0.7 2 3
Processing: 5309 20123 8061.9 20396 33029
Waiting: 203 12923 5518.0 14220 20417
Total: 5311 20124 8061.9 20397 33029
Percentage of the requests served within a certain time (ms)
50% 20397
66% 25808
75% 26490
80% 27263
90% 28373
95% 28568
98% 33029
99% 33029
100% 33029 (longest request)
注意,在整个请求过程中,服务器大约需要 103.4 毫秒来回复。这与我们预期的 100 毫秒相符,额外的耗时花在其他事情上。此外,我们的服务器每秒处理 4.92 个请求,这对于一个合理的服务器来说太低了。这全部是因为我们的服务器是单线程的,只有一个线程为所有客户端服务。这个服务器也忽略了主机上可用的多个 CPU 核心的事实。
让我们继续编写一个服务器,它基本上做同样的事情,区别在于这个服务器大量使用多线程并使用所有 CPU 核心。Cargo 设置应该是这样的:
$ cargo new --bin hyper-server-faster
我们需要添加一些额外的 crate 作为依赖项,我们的Cargo.toml应该是这样的:
[package]
name = "hyper-server-faster"
version = "0.1.0"
authors = ["Foo<foo@bar.com>"]
[dependencies]
hyper = "0.11.7"
futures = "0.1.17"
net2 = "0.2.31"
tokio-core = "0.1.10"
num_cpus = "1.0"
我们这里有一些额外的事情。tokio-core将被用来运行一个事件循环(就像我们在mio中在第三章,使用 Rust 的 TCP 和 UDP),net2将被用来进行一些高级套接字配置,而num_cpus将被用来确定机器上的 CPU 核心数。设置好这些后,我们的主文件相当简单:
// ch6/hyper-server-faster/src/main.rs
extern crate futures;
extern crate hyper;
extern crate net2;
extern crate tokio_core;
extern crate num_cpus;
use futures::Stream;
use net2::unix::UnixTcpBuilderExt;
use tokio_core::reactor::Core;
use tokio_core::net::TcpListener;
use std::{thread, time};
use std::net::SocketAddr;
use std::sync::Arc;
use futures::future::FutureResult;
use hyper::{Get, StatusCode};
use hyper::header::ContentLength;
use hyper::server::{Http, Service, Request, Response};
// Same method like last example
fn heavy_work() -> String {
let duration = time::Duration::from_millis(200);
thread::sleep(duration);
"done".to_string()
}
#[derive(Clone, Copy)]
struct Echo;
impl Service for Echo {
type Request = Request;
type Response = Response;
type Error = hyper::Error;
type Future = FutureResult<Response, hyper::Error>;
fn call(&self, req: Request) -> Self::Future {
futures::future::ok(match (req.method(), req.path()) {
(&Get, "/data") => {
let b = heavy_work().into_bytes();
Response::new()
.with_header(ContentLength(b.len() as u64))
.with_body(b)
}
_ => Response::new().with_status(StatusCode::NotFound),
})
}
}
// One server instance
fn serve(addr: &SocketAddr, protocol: &Http) {
let mut core = Core::new().unwrap();
let handle = core.handle();
let listener = net2::TcpBuilder::new_v4()
.unwrap()
.reuse_port(true)
.unwrap()
.bind(addr)
.unwrap()
.listen(128)
.unwrap();
let listener = TcpListener::from_listener(listener, addr,
&handle).unwrap();
core.run(listener.incoming().for_each(|(socket, addr)| {
protocol.bind_connection(&handle, socket, addr, Echo);
Ok(())
})).unwrap();
}
// Starts num number of serving threads
fn start_server(num: usize, addr: &str) {
let addr = addr.parse().unwrap();
let protocol = Arc::new(Http::new());
{
for _ in 0..num - 1 {
let protocol = Arc::clone(&protocol);
thread::spawn(move || serve(&addr, &protocol));
}
}
serve(&addr, &protocol);
}
fn main() {
start_server(num_cpus::get(), "0.0.0.0:3000");
}
功能上,这个服务器与上一个完全相同。在架构上,它们非常不同。我们的Service实现是相同的。主要变化是我们将启动服务器分成两个函数;serve函数创建一个新的事件循环(及其句柄)。我们使用net2创建监听器,这样我们就可以使用TcpBuilder模式设置一系列选项。具体来说,我们在套接字上设置了SO_REUSEPORT,这样在高负载下,操作系统可以公平地将连接分配给所有线程。我们还为监听套接字设置了 128 个回压。然后我们在监听器上循环处理传入的连接,并对每个连接运行我们的服务实现。我们的start_server方法接受一个整数,对应于主机上的核心数,以及一个字符串形式的地址。然后我们启动一个循环,在新线程中运行serve方法。在这种情况下,我们的Http实例将被传递到多个线程。因此,我们需要将其包装在一个自动引用计数(ARC)指针中,因为这保证了底层类型的线程安全性。最后,我们在main函数中调用start_server,使用num_cpus::get获取机器上的核心数。
以与上次相同的方式基准测试,结果显示如下:
$ ab -n 1000 -c 100 http://127.0.0.1:3000/data
Benchmarking 127.0.0.1 (be patient)
Completed 100 requests
Completed 200 requests
Completed 300 requests
Completed 400 requests
Completed 500 requests
Completed 600 requests
Completed 700 requests
Completed 800 requests
Completed 900 requests
Completed 1000 requests
Finished 1000 requests
Server Software:
Server Hostname: 127.0.0.1
Server Port: 3000
Document Path: /data
Document Length: 4 bytes
Concurrency Level: 100
Time taken for tests: 102.724 seconds
Complete requests: 1000
Failed requests: 0
Total transferred: 79000 bytes
HTML transferred: 4000 bytes
Requests per second: 9.73 [#/sec] (mean)
Time per request: 10272.445 [ms] (mean)
Time per request: 102.724 [ms] (mean, across all concurrent requests)
Transfer rate: 0.75 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 2 1.0 2 6
Processing: 304 10036 1852.8 10508 10826
Waiting: 106 5482 2989.3 5458 10316
Total: 305 10038 1852.7 10510 10828
Percentage of the requests served within a certain time (ms)
50% 10510
66% 10569
75% 10685
80% 10686
90% 10756
95% 10828
98% 10828
99% 10828
100% 10828 (longest request)
这个服务器的吞吐量大约是上一个的两倍,主要是因为它更好地使用了线程。请求的处理时间仍然略超过 100 毫秒,正如预期的那样。请注意,实际所需时间将取决于运行此机器的硬件和条件。
介绍 Rocket
可能最广为人知的 Rust 网络框架是 Rocket。它最初是一个人的项目,并在过去一年左右的时间里逐渐发展成为一个简单、优雅且快速的框架。Rocket 非常注重简洁性,这是许多 Flask 用户会欣赏的。像 Flask 使用 Python 装饰器来声明路由一样,Rocket 使用自定义属性来达到同样的效果。不幸的是,这意味着 Rocket 必须大量使用仅限夜间构建的功能。因此,截至目前,Rocket 应用程序只能使用夜间 Rust 来构建。然而,随着越来越多的功能得到稳定(转移到稳定 Rust),这种限制最终将消失。
让我们从 Rocket 的一个基本示例开始,首先是设置项目:
$ cargo new --bin rocket-simple
我们的 Cargo 设置需要将 Rocket 组件作为依赖项添加,并且应该看起来像这样:
[package]
name = "rocket-simple"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
rocket = "0.3.6"
rocket_codegen = "0.3.6"
让我们看看主文件。正如我们将看到的,Rocket 需要一些样板设置:
// ch6/rocket-simple/src/main.rs
#![feature(plugin)]
#![plugin(rocket_codegen)]
extern crate rocket;
#[get("/")]
fn blast_off() -> &'static str {
"Hello, Rocket!"
}
fn main() {
rocket::ignite().mount("/", routes![blast_off]).launch();
}
被称为 blast_off 的函数定义了一个路由,即一个传入请求与输出之间的映射。在这种情况下,对 / 路由的 GET 请求应返回一个静态字符串。在我们的主函数中,我们初始化 Rocket,添加我们的路由,并调用 launch。使用 Cargo 运行它:
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/rocket-simple`
Configured for development.
=> address: localhost
=> port: 8000
=> log: normal
=> workers: 16
=> secret key: generated
=> limits: forms = 32KiB
=> tls: disabled
Mounting '/':
=> GET /
Rocket has launched from http://localhost:8000
在另一个终端中,如果我们使用 curl 来访问该端点,我们应该看到以下内容:
$ curl http://localhost:8000
Hello, Rocket!$
经验丰富的 Python 用户会发现 Rocket 与名为 Flask 的框架类似。
让我们来看一个更复杂的例子:使用 Rocket 编写 API 服务器。我们的应用程序是一个博客服务器,具有以下端点:
| 端点 | 方法 | 目的 |
|---|---|---|
/posts |
GET |
获取所有帖子 |
/posts/<id> |
GET |
获取给定 ID 的帖子 |
/posts |
POST |
添加新帖子 |
/posts/<id> |
PATCH |
编辑帖子 |
/posts/<id> |
DELETE |
删除给定 ID 的帖子 |
对于这个例子,我们将使用 SQLite 版本 3 作为我们的数据库。一个真正的应用程序应该使用更可扩展的数据库,例如 PostgreSQL 或 MySQL。我们将使用 diesel crate 作为我们的 对象关系映射(ORM)工具,并使用 r2d2 进行数据库连接池。为此,第一步是安装 diesel CLI 以处理数据库模式迁移。这可以通过 Cargo 来安装。
$ cargo install diesel_cli --no-default-features --features sqlite
为了使此功能正常工作,必须在主机系统上安装 SQLite 版本 3。有关更多信息,请访问以下链接:
现在,我们可以使用 diesel CLI 来设置我们的数据库。它将读取我们的迁移脚本并创建整个模式。由于 SQLite 是基于文件的数据库,如果不存在,它还将创建一个空的 db 文件。
$ DATABASE_URL=db.sql diesel migration run
记住,最后一个命令必须从包含迁移目录的目录中运行,否则它将无法找到迁移。我们将使用 Cargo 来设置项目:
$ cargo new --bin rocket-blog
我们将在这里添加一些依赖项;Cargo.toml 应该看起来像以下代码片段:
[package]
authors = ["Foo <foo@bar.com>"]
name = "rocket-blog"
version = "0.1.0"
[dependencies]
rocket = "0.3.5"
rocket_codegen = "0.3.5"
rocket_contrib = "0.3.5"
diesel = { version = "0.16.0", features = ["sqlite"] }
diesel_codegen = { version = "0.16.0", features = ["sqlite"] }
dotenv = "0.10.1"
serde = "1.0.21"
serde_json = "1.0.6"
serde_derive = "1.0.21"
lazy_static = "0.2.11"
r2d2 = "0.7.4"
r2d2-diesel = "0.16.0"
这个应用程序比我们之前的例子要复杂一些。它由多个模块组成,每个模块执行特定的功能。以下是 src 目录的看起来:
$ tree src/
src/
├── db.rs
├── error.rs
├── main.rs
├── models.rs
├── post.rs
└── schema.rs
运行此应用程序的第一步是设置与数据库的连接。我们将使用 r2d2 进行数据库连接池;所有与 db 设置相关的操作都将放在 db.rs 中。这看起来像以下代码片段:
// ch6/rocket-blog/src/db.rs
use dotenv::dotenv;
use std::env;
use diesel::sqlite::SqliteConnection;
use r2d2;
use r2d2_diesel::ConnectionManager;
use rocket::request::{Outcome, FromRequest};
use rocket::Outcome::{Success, Failure};
use rocket::Request;
use rocket::http::Status;
// Statically initialize our DB pool
lazy_static! {
pub static ref DB_POOL:
r2d2::Pool<ConnectionManager<SqliteConnection>> = {
dotenv().ok();
let database_url = env::var("DATABASE_URL").
expect("DATABASE_URL must be set");
let config = r2d2::Config::builder()
.pool_size(32)
.build();
let manager = ConnectionManager::
<SqliteConnection>::new(database_url);
r2d2::Pool::new(config, manager).expect("Failed to create
pool.")
};
}
pub struct DB(r2d2::PooledConnection<ConnectionManager<SqliteConnection>>);
// Make sure DB pointers deref nicely
impl Deref for DB {
type Target = SqliteConnection;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<'a, 'r> FromRequest<'a, 'r> for DB {
type Error = r2d2::GetTimeout;
fn from_request(_: &'a Request<'r>) -> Outcome<Self, Self::Error> {
match DB_POOL.get() {
Ok(conn) => Success(DB(conn)),
Err(e) => Failure((Status::InternalServerError, e)),
}
}
}
我们的 DB 结构体有一个数据库连接池的实例,并在调用名为 conn 的函数时返回它。FromRequest 是来自 Rocket 的一个请求保护特质,它确保特定的请求可以被匹配的处理程序满足。在我们的情况下,我们使用它来确保连接池中有一个新的连接可用,如果没有,则返回 HTTP 500 错误。现在这个特质将用于整个程序生命周期中的所有传入请求。因此,为了正确工作,数据库连接池的引用必须在整个程序生命周期中存在,而不是局部作用域。我们使用 lazy_static! crate 来确保常量 DB_POOL 只初始化一次,并贯穿整个程序的生命周期。在宏中,我们设置了 dotenv,它将被用于稍后解析数据库位置以及大小为 32 个连接的连接池。我们还为我们的数据库包装器实现了 Deref 特质,这样 &*DB 就可以透明地转换为 &SqliteConnection。
下一步是在代码中设置数据库模式。幸运的是,diesel 使得这一过程非常简单,因为它可以读取数据库模式并生成相应的 Rust 代码来表示。生成的 Rust 代码被放置在一个与文件名对应的模块中(在这个例子中,模块将被命名为 schema)。我们使用 dotenv crate 将这些信息传递给 diesel 宏。这是在文件 schema.rs 中完成的:
// ch6/rocket-blog/src/schema.rs
infer_schema!("dotenv:DATABASE_URL");
注意,一旦新的宏系统可用,这个调用将使用 dotenv! 宏。然后我们可以使用生成的模式来构建我们的模型。这是在 models.rs 中完成的。这个文件看起来像以下代码片段:
// ch6/rocket-blog/src/models.rs
use super::schema::posts;
use rocket::{Request, Data};
use rocket::data::{self, FromData};
use rocket::http::Status;
use rocket::Outcome::*;
use serde_json;
// Represents a blog post in the database
#[derive(Queryable)]
#[derive(Serialize,Deserialize)]
pub struct Post {
pub id: i32,
pub title: String,
pub body: String,
pub pinned: bool,
}
// Represents a blog post as incoming request data
#[derive(Insertable, Deserialize, AsChangeset)]
#[table_name="posts"]
pub struct PostData {
pub title: String,
pub body: String,
pub pinned: bool,
}
// This enables using PostData from incoming request data
impl FromData for PostData {
type Error = String;
#[allow(unused_variables)]
fn from_data(req: &Request, data: Data) -> data::Outcome<Self,
String> {
let reader = data.open();
match serde_json::from_reader(reader).map(|val| val) {
Ok(value) => Success(value),
Err(e) => Failure((Status::BadRequest, e.to_string())),
}
}
}
在这里,我们有两大结构:Post 结构代表数据库中的博客文章,而 PostData 结构代表在传入的创建请求中看到的博客文章。由于 PostData 尚未保存到数据库中,它没有 ID。Diesel 要求所有可以查询的类型都应该实现 Queryable 特性,这可以通过 #[derive(Queryable)] 自动完成。我们还启用了使用 serde 的序列化和反序列化,因为这将作为 JSON 传递到 API。相比之下,PostData 结构不继承 Queryable;它继承了一些其他特性。Insertable 特性表示这个结构可以用作在表中插入一行数据(指定表名)。因为我们只需要从传入的请求中反序列化这个结构,所以我们只实现了 Deserialize。最后,AsChangeSet 特性使得这个结构可以用作更新数据库中的记录。
FromData 特性来自 Rocket,用于验证传入的数据,确保其正确解析为 JSON。这与一个名为数据守卫的功能相关。当 Rocket 找到适合传入请求的处理程序时,它会调用请求处理程序中指定数据类型的数据守卫对传入数据进行处理。只有当数据守卫成功时,路由才会实际调用。这些守卫是通过 FromData 特性实现的。在我们的案例中,实现尝试将输入解析为 JSON(使用 SerDe)。在成功的情况下,我们返回 JSON 以供进一步处理,或者返回 Status::BadRequest,这会发送 HTTP 400 错误。
目前所需的唯一数据库相关事物是模型。这将定义一系列方便的方法,可以用来使用 diesel 操作记录。文件 post.rs 包含了这些方法,如下代码片段所示:
// ch6/rocket-blog/src/post.rs
use diesel::result::Error;
use diesel;
use diesel::sqlite::SqliteConnection;
use models::*;
use diesel::prelude::*;
use schema::posts;
// Returns post with given id
pub fn get_post(conn: &SqliteConnection, id: i32) -> Result<Post, Error> {
posts::table
.find(id)
.first::<Post>(conn)
}
// Returns all posts
pub fn get_posts(conn: &SqliteConnection) -> Result<Vec<Post>, Error> {
posts::table
.load::<Post>(conn)
}
// Creates a post with the given PostData, assigns a ID
pub fn create_post(conn: &SqliteConnection, post: PostData) -> bool {
diesel::insert(&post)
.into(posts::table).execute(conn).is_ok()
}
// Deletes a post with the given ID
pub fn delete_post(conn: &SqliteConnection, id: i32) -> Result<usize, Error> {
diesel::delete(posts::table.find(id))
.execute(conn)
}
// Updates a post with the given ID and PostData
pub fn update_post(conn: &SqliteConnection, id: i32, updated_post: PostData) -> bool {
diesel::update(posts::table
.find(id))
.set(&updated_post).execute(conn).is_ok()
}
函数名称非常直观。我们大量使用了 diesel API,这些 API 帮助我们与数据库交互,而无需直接编写 SQL。所有这些函数都接受数据库连接的引用。get_post 函数接受一个额外的帖子 ID,它使用 find 方法在帖子表中查找帖子,然后返回第一个结果作为 Post 实例。get_posts 函数类似,但它返回帖子表中的所有记录作为 Post 实例的向量。create_post 函数接受 PostData 的引用,并将该记录插入到数据库中。此函数返回一个 bool 值,指示成功或失败。delete_post 函数接受帖子 ID 并尝试在数据库中删除它。update_post 再次接受 PostData 的引用和一个帖子 ID。然后它尝试用新的 PostData 替换具有给定 ID 的帖子。
让我们继续定义我们 API 的错误。这将位于名为error.rs的文件中。正如我们将看到的,错误需要实现多个特性,以便在 Rocket 和 Diesel 中无缝使用。
// ch6/rocket-blog/src/error.rs
use std::error::Error;
use std::convert::From;
use std::fmt;
use diesel::result::Error as DieselError;
use rocket::http::Status;
use rocket::response::{Response, Responder};
use rocket::Request;
#[derive(Debug)]
pub enum ApiError {
NotFound,
InternalServerError,
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
ApiError::NotFound => f.write_str("NotFound"),
ApiError::InternalServerError => f.write_str("InternalServerError"),
}
}
}
// Translates a database error to an API error
impl From<DieselError> for ApiError {
fn from(e: DieselError) -> Self {
match e {
DieselError::NotFound => ApiError::NotFound,
_ => ApiError::InternalServerError,
}
}
}
impl Error for ApiError {
fn description(&self) -> &str {
match *self {
ApiError::NotFound => "Record not found",
ApiError::InternalServerError => "Internal server error",
}
}
}
// This enables sending back an API error from a route
impl<'r> Responder<'r> for ApiError {
fn respond_to(self, _request: &Request) -> Result<Response<'r>, Status> {
match self {
ApiError::NotFound => Err(Status::NotFound),
_ => Err(Status::InternalServerError),
}
}
}
我们的错误是一个名为ApiError的enum;为了简单起见,我们只返回一个对象未找到错误和一个通用的内部服务器错误。正如我们在前面的章节中看到的,为了在 Rust 中声明错误,我们需要在那种类型上实现fmt::Display和std::error::Error。我们还为我们类型实现了From<DieselError>,以便在数据库查找失败时能够适当地报告。我们需要实现的最后一个特性是 Rocket 的Responder,这使我们能够将其用作请求处理器的返回类型。
在完成所有准备工作后,系统的最后一部分是我们主要的文件,当使用 Cargo 调用时将运行。它应该看起来像以下代码片段:
// ch6/rocket-blog/src/main.rs
#![feature(plugin)]
#![plugin(rocket_codegen)]
extern crate rocket;
#[macro_use]
extern crate diesel;
#[macro_use]
extern crate diesel_codegen;
extern crate dotenv;
extern crate serde_json;
#[macro_use]
extern crate lazy_static;
extern crate rocket_contrib;
#[macro_use]
extern crate serde_derive;
extern crate r2d2;
extern crate r2d2_diesel;
mod schema;
mod db;
mod post;
mod models;
mod error;
use db::DB;
use post::{get_posts, get_post, create_post, delete_post, update_post};
use models::*;
use rocket_contrib::Json;
use rocket::response::status::{Created, NoContent};
use rocket::Rocket;
use error::ApiError;
#[get("/posts", format = "application/json")]
fn posts_get(db: DB) -> Result<Json<Vec<Post>>, ApiError> {
let posts = get_posts(&db)?;
Ok(Json(posts))
}
#[get("/posts/<id>", format = "application/json")]
fn post_get(db: DB, id: i32) -> Result<Json<Post>, ApiError> {
let post = get_post(&db, id)?;
Ok(Json(post))
}
#[post("/posts", format = "application/json", data = "<post>")]
fn post_create(db: DB, post: PostData) -> Result<Created<String>, ApiError> {
let post = create_post(&db, post);
let url = format!("/post/{}", post);
Ok(Created(url, Some("Done".to_string())))
}
#[patch("/posts/<id>", format = "application/json", data = "<post>")]
fn post_edit(db: DB, id: i32, post: PostData) -> Result<Json<bool>, ApiError> {
let post = update_post(&db, id, post);
Ok(Json(post))
}
#[delete("/posts/<id>")]
fn post_delete(db: DB, id: i32) -> Result<NoContent, ApiError> {
delete_post(&db, id)?;
Ok(NoContent)
}
// Helper method to setup a rocket instance
fn rocket() -> Rocket {
rocket::ignite().mount("/", routes![post_create, posts_get, post_delete, post_edit, post_get])
}
fn main() {
rocket().launch();
}
这里最重要的东西是路由处理器。这些只是具有特殊属性的标准函数,用于确定路径、格式和参数。此外,请注意在处理器中使用DB实例作为请求保护。我们有一个名为rocket的辅助函数,用于设置一切,而main函数只是调用ignite方法来启动服务器。当rocket看到传入的请求时,这就是生成响应的方式:
-
它会遍历所有处理器的列表,并找到一个与 HTTP 方法、类型和格式匹配的处理器。如果找到了一个,它会确保处理器的参数可以使用
FormData从请求中的数据中推导出来。这个过程会一直持续到找到一个有效的处理器或者所有处理器都尝试过。在后一种情况下,会返回一个 404 错误。 -
处理函数随后会接收到解析到给定数据类型中的数据的副本。在它完成处理之后,它必须使用
Responder实现将输出转换为有效的返回类型。 -
最后,
Rocket将响应发送回客户端。
在设置好一切后,运行服务器非常简单:
$ DATABASE_URL=db.sql cargo run
jq for formatting the JSON nicely by piping curl's output to jq:
$ curl -X POST -H "Content-Type: application/json" -d '{"title": "Hello Rust!", "body": "Rust is awesome!!", "pinned": true}' http://localhost:8000/posts
Done
$ curl http://localhost:8000/posts | jq
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 130 100 130 0 0 130 0 0:00:01 --:--:-- 0:00:01 8125
[
{
"id": 1,
"title": "test",
"body": "test body",
"pinned": true
},
{
"id": 2,
"title": "Hello Rust!",
"body": "Rust is awesome!!",
"pinned": true
}
]
为了进行比较,以下是负载测试会话的示例:
$ ab -n 10000 -c 100 http://localhost:8000/posts
Benchmarking localhost (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requests
Server Software: Rocket
Server Hostname: localhost
Server Port: 8000
Document Path: /posts
Document Length: 130 bytes
Concurrency Level: 100
Time taken for tests: 2.110 seconds
Complete requests: 10000
Failed requests: 0
Total transferred: 2740000 bytes
HTML transferred: 1300000 bytes
Requests per second: 4740.00 [#/sec] (mean)
Time per request: 21.097 [ms] (mean)
Time per request: 0.211 [ms] (mean, across all concurrent requests)
Transfer rate: 1268.32 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 0.4 0 4
Processing: 3 21 20.3 19 229
Waiting: 2 20 19.8 18 228
Total: 7 21 20.3 19 229
Percentage of the requests served within a certain time (ms)
50% 19
66% 19
75% 20
80% 20
90% 21
95% 22
98% 26
99% 214
100% 229 (longest request)
现在,为了公平起见,我们之前的服务器那里有一个 100 毫秒的延迟。在这种情况下,每个请求平均需要 21 毫秒。所以,假设每个请求需要 100 毫秒,我们将有五分之一的吞吐量。这将达到大约每秒 950 个请求——比我们之前的服务器快得多!
现在,很明显,一个 HTTP 服务器不能只关注 REST 端点。它还必须能够提供静态和动态内容。为此,Rocket 提供了一系列功能,能够生成 HTML。让我们看看一个例子,这是一个简单的网页,它接受一个作为 URL 参数的名称,并输出该名称。该页面还计算总访问次数,并将其显示出来。这个项目的 Cargo 设置很简单:我们只需运行以下命令:
$ cargo new --bin rocket-templates
这次,我们只需要在Cargo.toml文件中包含 Rocket:
[package]
authors = ["Foo<foo@bar.com>"]
name = "rocket-templates"
version = "0.1.0"
[dependencies]
rocket = "0.3.5"
rocket_codegen = "0.3.5"
[dependencies.rocket_contrib]
version = "*"
default-features = false
features = ["tera_templates"]
我们的网页将从一个模板生成。我们将使用一个名为 Tera 的模板引擎,它受到 Jinja2 的启发,并使用 Rust 编写。Rocket 支持一个名为 rocket_contrib 的不同 crate 中的模板,我们将通过所需功能将其拉入。我们的模板非常简单,应该看起来像这样:
// ch6/rocket-templates/templates/webpage.html.tera
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Rocket template demo</title>
</head>
<body>
<h1>Hi {{name}}, you are visitor number {{ visitor_number }}</h1>
</body>
</html>
注意,模板必须位于项目根目录下的 templates 目录中,否则 Rocket 将无法找到它。在这种情况下,模板非常简单。它需要是一个完整的 HTML 页面,因为我们打算在浏览器中显示它。我们使用两个临时变量,name 和 visitor_number,它们将在执行期间被替换。我们的主文件将如下代码片段所示:
// ch6/rocket-templates/src/main.rs
#![feature(plugin)]
#![plugin(rocket_codegen)]
extern crate rocket_contrib;
extern crate rocket;
use rocket_contrib::Template;
use rocket::{Rocket, State};
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
struct VisitorCounter {
visitor_number: AtomicUsize,
}
#[get("/webpage/<name>")]
fn webpage(name: String, visitor: State<VisitorCounter>) -> Template {
let mut context = HashMap::new();
context.insert("name", name);
let current = visitor.visitor_number.fetch_add(1, Ordering::SeqCst);
context.insert("visitor_number", current.to_string());
Template::render("webpage", &context)
}
fn rocket() -> Rocket {
rocket::ignite()
.manage(VisitorCounter { visitor_number: AtomicUsize::new(1) })
.mount("/", routes![webpage])
.attach(Template::fairing())
}
fn main() {
rocket().launch();
}
我们这次的设置与上次基本相同;唯一的区别是,我们使用了模板 fairing,它在 Rocket 中类似于中间件。要使用它,我们需要在 rocket 实例上调用 attach(Template::fairing())。另一个区别是使用了托管状态,我们用它来自动管理我们的计数器。这是通过在实例上调用 manage 并传递托管对象的初始状态来实现的。我们的计数器是一个结构体,它只有一个元素,用于保存当前计数。现在我们的计数器将在多个线程之间共享,所有这些线程都在运行 rocket 实例。为了使计数器线程安全,我们使用了原始的 AtomicUsize,它保证了线程安全。在我们的路由中,我们匹配 GET 动词,并接受一个作为 URL 参数的名称。为了渲染我们的模板,我们需要构建一个上下文并填充它。每当传入的请求匹配此路由时,我们可以在我们的上下文中插入名称。然后我们调用底层数计器的 fetch_add。此方法增加计数器并返回之前的值,我们将它存储在上下文中,键名为 visitor_number。完成后,我们可以渲染我们的模板,并将其返回给客户端。注意 fetch_add 中使用 Ordering::SeqCst,这保证了所有竞争线程对计数器的顺序一致性视图。还要注意,上下文中键的名称必须与模板中使用的临时变量匹配,否则渲染将失败。
运行这个程序很简单;我们只需要使用 cargo run。这是我们在命令行界面看到的内容:
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/rocket-templates`
Configured for development.
=> address: localhost
=> port: 8000
=> log: normal
=> workers: 16
=> secret key: generated
=> limits: forms = 32KiB
=> tls: disabled
Mounting '/':
=> GET /webpage/<name>
Rocket has launched from http://localhost:8000
然后,我们可以使用网络浏览器访问页面,看到以下类似截图的内容:

注意,当 Rocket 实例重启时,计数器会重置。在实际应用中,可能会决定将此类指标持久化到数据库中,以便在重启之间不会丢失。这也适用于 curl,它只是将原始 HTML 输出到控制台:
$ curl http://localhost:8000/webpage/foo
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Rocket template demo</title>
</head>
<body>
<h1>Hi foo, you are visitor number 2</h1>
</body>
</html>
使用这段代码的最后实验是性能分析。和往常一样,我们将启动 apache bench 并指向端点。这是单次运行的结果:
$ ab -n 10000 -c 100 http://localhost:8000/webpage/foobar
Benchmarking localhost (be patient)
Completed 1000 requests
Completed 2000 requests
Completed 3000 requests
Completed 4000 requests
Completed 5000 requests
Completed 6000 requests
Completed 7000 requests
Completed 8000 requests
Completed 9000 requests
Completed 10000 requests
Finished 10000 requests
Server Software: Rocket
Server Hostname: localhost
Server Port: 8000
Document Path: /webpage/foobar
Document Length: 191 bytes
Concurrency Level: 100
Time taken for tests: 2.305 seconds
Complete requests: 10000
Failed requests: 0
Total transferred: 3430000 bytes
HTML transferred: 1910000 bytes
Requests per second: 4337.53 [#/sec] (mean)
Time per request: 23.055 [ms] (mean)
Time per request: 0.231 [ms] (mean, across all concurrent requests)
Transfer rate: 1452.90 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 2.8 0 200
Processing: 3 23 18.6 21 215
Waiting: 3 22 18.0 20 214
Total: 7 23 18.8 21 215
Percentage of the requests served within a certain time (ms)
50% 21
66% 21
75% 22
80% 22
90% 24
95% 25
98% 28
99% 202
100% 215 (longest request)
在这种情况下,性能与上次相当,以每秒请求数来衡量。这个稍微慢一些,因为它每次都必须增加计数器并渲染模板。这也反映在平均请求时间上,增加了 2 毫秒。
Rocket 有许多其他功能,从 cookies 到流式数据。它还支持通过读取放置在应用程序根目录中的特殊配置文件来开箱即用 SSL。然而,这些高级功能超出了本书的范围。
介绍 reqwest
到目前为止,我们只讨论了编写服务器并使用 curl 访问它们。有时,以编程方式访问服务器变得是必需的。在本节中,我们将讨论 reqwest crate 并查看如何使用它;这大量借鉴了 Python 中的 requests 库。因此,它非常容易设置和使用,首先从项目设置开始:
$ cargo new --bin reqwest-example
我们演示的下一步是包含我们的依赖项。我们的 Cargo 配置应该看起来像这样:
[package]
name = "reqwest-example"
version = "0.1.0"
authors = ["Foo<foo@bar.com>"]
[dependencies]
reqwest = "0.8.1"
serde_json = "1.0.6"
serde = "1.0.21"
serde_derive = "1.0.21"
在这里,我们将使用 Serde 来将我们的数据序列化和反序列化为 JSON。非常方便的是,我们将使用上一节中编写的 Rocket 服务器。我们的主文件将看起来像这样:
// ch6/reqwest-example/src/main.rs
extern crate serde_json;
#[macro_use]
extern crate serde_derive;
extern crate reqwest;
#[derive(Debug,Serialize, Deserialize)]
struct Post {
title: String,
body: String,
pinned: bool,
}
fn main() {
let url = "http://localhost:8000/posts";
let post: Post = Post {title: "Testing this".to_string(), body: "Try to write something".to_string(), pinned: true};
let client = reqwest::Client::new();
// Creates a new blog post using the synchronous client
let res = client.post(url)
.json(&post)
.send()
.unwrap();
println!("Got back: {}", res.status());
// Retrieves all blog posts using the synchronous client
let mut posts = client.get(url).send().unwrap();
let json: Vec<Post> = posts.json().unwrap();
for post in json {
println!("{:?}", post);
}
}
我们从一个表示我们的博客文章的 struct 开始,这与上一节中的完全相同。在我们的 main 函数中,我们创建了一个客户端实例,并使用构建器模式将我们的文章作为 JSON 传递给它。最后,我们在它上面调用 send 并打印出返回状态。确保将 url 改变为目标 Rocket 运行的位置。然后,我们在同一端点上发出一个 GET 请求。我们将响应反序列化为 Post 对象的列表,并在循环中打印这些对象。内部,reqwest 使用 SerDe 将数据序列化和反序列化为 JSON,使 API 非常用户友好。
这里是一个运行前面代码的示例会话。在我们的服务器中,我们已经有了两个现有的条目,在我们的代码中,我们添加了一个。然后,我们得到了所有三个,它们在这里打印出来。请看以下代码片段:
$ cargo run
Compiling reqwest-example v0.1.0 (file:///src/ch6/reqwest-example)
Finished dev [unoptimized + debuginfo] target(s) in 1.94 secs
Running `target/debug/reqwest-example`
Got back: 201 Created
Post { title: "test", body: "test body", pinned: true }
Post { title: "Hello Rust!", body: "Rust is awesome!!", pinned: true }
Post { title: "Testing this", body: "Try to write something", pinned: true }
最近,reqwest 添加了对异步编程的支持,使用 tokio。所有这些 API 都位于 reqwest::unstable 中,正如其名所示,这些还不是稳定的。让我们看看如何使用异步客户端达到相同的目的。在这种情况下,我们将使用 futures 和 tokio crate,因此我们需要在我们的 cargo manifest 中包含它们,它将看起来像这样:
[package]
name = "reqwest-async"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
serde_json = "1.0.6"
serde = "1.0.21"
serde_derive = "1.0.21"
futures = "0.1.17"
tokio-core = "0.1.10"
[dependencies.reqwest]
version = "0.8.1"
features = ["unstable"]
我们需要激活 reqwest 中的名为 unstable 的功能。我们的主文件将看起来像以下代码片段:
// ch6/reqwest-async/src/main.rs
extern crate serde_json;
#[macro_use]
extern crate serde_derive;
extern crate reqwest;
extern crate futures;
extern crate tokio_core;
use futures::Future;
use tokio_core::reactor::Core;
use reqwest::unstable::async::{Client, Decoder};
use std::mem;
use std::io::{self, Cursor};
use futures::Stream;
#[derive(Debug, Serialize, Deserialize)]
struct Post {
title: String,
body: String,
pinned: bool,
}
fn main() {
let mut core = Core::new().expect("Could not create core");
let url = "http://localhost:8000/posts";
let post: Post = Post {
title: "Testing this".to_string(),
body: "Try to write something".to_string(),
pinned: true,
};
let client = Client::new(&core.handle());
// Creates a new post using the async client
let res = client.post(url).json(&post).send().and_then(|res| {
println!("{}", res.status());
Ok(())
});
core.run(res).unwrap();
// Gets all current blog posts using the async client
let posts = client
.get(url)
.send()
.and_then(|mut res| {
println!("{}", res.status());
let body = mem::replace(res.body_mut(), Decoder::empty());
body.concat2().map_err(Into::into)
})
.and_then(|body| {
let mut body = Cursor::new(body);
let mut writer: Vec<u8> = vec![];
io::copy(&mut body, &mut writer).unwrap();
let posts: Vec<Post> = serde_json::from_str(std::str::from_utf8(&writer).unwrap())
.unwrap();
for post in posts {
println!("{:?}", post);
}
Ok(())
});
core.run(posts).unwrap();
}
老实说,这比上一个版本要复杂得多!Post结构的某些支架是相同的,我们引入了所有需要的额外库。在我们的main函数中,我们创建了一个 tokio 核心,然后基于该核心创建了一个异步客户端。我们像上次一样链式调用json和send方法。从这里开始,事情开始有所不同;对于异步客户端,send调用返回一个 future。一旦该 future 解析完成,and_then调用就会基于第一个 future 执行另一个 future。在这里,我们打印出我们得到的状态,并通过返回Ok(())来解析 future。最后,我们在核心上运行我们的 future。
从端点获取数据稍微复杂一些,因为我们还需要处理返回的数据。在这里,我们链式调用get和send。然后我们链式调用另一个 future 来收集响应体。第二个 future 随后被链式调用到另一个 future 上,该 future 消费该 body 并将其复制到名为writer的Vec<u8>中。然后我们使用std::str::from_utf8将向量转换为str。然后,我们将str传递给serde_json::from_str,它尝试将其反序列化为Post对象的向量,然后我们可以通过迭代这些对象来打印出来。最后,通过返回Ok(())来解析 future 链。运行时,这的行为与上一个例子完全相同。
摘要
在本章中,我们介绍了一些帮助我们在 Rust 中使用 Hyper 和 Rocket 处理基于 HTTP 的 REST 端点的 crate。我们还探讨了如何使用请求程序化地访问这些端点,请求主要基于 Hyper。这些 crate 处于不同的开发阶段。正如我们所见,Rocket 只能运行在 nightly 版本上,因为它使用了一些尚未稳定的特性。我们还简要介绍了 tokio,它是 Hyper 和 Rocket 的动力。
现在,作为 Rust 中事实上的异步编程库,tokio 值得所有它能得到的关注。因此,我们将在下一章详细讨论 tokio 堆栈。
第七章:使用 Tokio 进行异步网络编程
在顺序编程模型中,代码总是按照编程语言的语义顺序执行。因此,如果某个操作由于某种原因(等待资源等)而阻塞,整个执行就会阻塞,并且只有在该操作完成后才能继续进行。这通常会导致资源利用率低下,因为主线程会忙于等待某个操作。在 GUI 应用程序中,这也导致了较差的用户交互性,因为负责管理 GUI 的主线程正忙于等待其他事情。在我们特定的网络编程案例中,这是一个主要问题,因为我们经常需要等待套接字上的数据可用。在过去,我们通过使用多个线程来解决这个问题。在那个模型中,我们将一个昂贵的操作委托给后台线程,使主线程可以用于用户交互或其他任务。相比之下,异步编程模型规定,任何操作都不应该阻塞。相反,应该有一种机制来检查它们是否已经完成,从主线程中进行检查。但我们如何实现这一点呢?一种简单的方法是让每个操作在自己的线程中运行,然后对所有这些线程进行连接。实际上,由于潜在的线程数量众多以及它们之间的协调,这种方法是麻烦的。
Rust 提供了一些 crate,支持使用基于 futures 的事件循环驱动模型进行异步编程。我们将在本章中详细研究这一点。以下是本章我们将涉及的主题:
-
Rust 中的未来抽象
-
使用 tokio 堆栈进行异步编程
展望未来
Rust 异步编程故事的核心是 futures crate。这个 crate 提供了一个名为future的结构。这本质上是一个操作结果的占位符。正如你所期望的,操作的结果可以是两种状态之一——要么操作仍在进行中,结果尚未可用,要么操作已完成,结果可用。请注意,在第二种情况下,可能发生错误,使得结果变得无关紧要。
该库提供了一个名为Future的特质(以及其他一些东西),任何类型都可以实现这个特质来能够像未来一样行动。这个特质看起来是这样的:
trait Future {
type Item;
type Error;
fn poll(&mut self) -> Poll<Self::Item, Self::Error>;
...
}
在这里,Item指的是操作成功完成时返回的结果类型,而Error是操作失败时返回的类型。实现必须指定这些类型,并实现获取计算当前状态的poll方法。如果它已经完成,将返回结果。如果没有,未来将注册当前任务对给定操作的结果感兴趣。这个函数返回一个Poll,其外观如下:
type Poll<T, E> = Result<Async<T>, E>;
Poll被类型化为另一个名为Async(以及给定的错误类型)的类型的结果,该类型将在下面定义。
pub enum Async<T> {
Ready(T),
NotReady,
}
Async是一个枚举,可以是Ready(T)或NotReady。这两个最后的状态对应于操作的状态。因此,轮询函数可以返回三种可能的状态:
-
当操作成功完成且结果在内部变量
result中时,会返回Ok(Async::Ready(result))。 -
当操作尚未完成且结果不可用时,会返回
Ok(Async::NotReady)。请注意,这并不表示错误条件。 -
当操作遇到错误时,会返回
Err(e)。在这种情况下,没有结果可用。
很容易注意到,Future本质上是一个Result,它可能还在运行以实际产生那个Result。如果移除Result可能在任何时间点未准备好的情况,我们只剩下两个选项:Ok和Err,它们正好对应于Result。
因此,一个Future可以代表任何需要非平凡时间才能完成的事情。这可以是一个网络事件、磁盘读取等等。现在,在这个阶段最常见的疑问是:我们如何从一个给定的函数中返回一个Future?有几种方法可以做到这一点。让我们在这里看一个例子。项目设置和以往一样。
$ cargo new --bin futures-example
我们需要在我们的 Cargo 配置中添加一些库,它看起来像这样:
[package]
name = "futures-example"
version = "0.1.0"
authors = ["Foo<foo@bar.com>"]
[dependencies]
futures = "0.1.17"
futures-cpupool = "0.1.7"
在我们的主文件中,我们像往常一样设置一切。我们感兴趣的是找出一个给定的整数是否为素数,这将代表我们操作中需要一些时间才能完成的部分。我们有两个函数,正是为了做到这一点。这两个函数使用两种不同的风格返回Future,我们稍后会看到。实际上,原始的素性测试方法并没有慢到足以成为一个好的例子。因此,我们不得不随机休眠一段时间来模拟缓慢。
// ch7/futures-example/src/main.rs
#![feature(conservative_impl_trait)]
extern crate futures;
extern crate futures_cpupool;
use std::io;
use futures::Future;
use futures_cpupool::CpuPool;
// This implementation returns a boxed future
fn check_prime_boxed(n: u64) -> Box<Future<Item = bool, Error = io::Error>> {
for i in 2..n {
if n % i == 0 { return Box::new(futures::future::ok(false)); }
}
Box::new(futures::future::ok(true))
}
// This returns a future using impl trait
fn check_prime_impl_trait(n: u64) -> impl Future<Item = bool, Error = io::Error> {
for i in 2..n {
if n % i == 0 { return futures::future::ok(false); }
}
futures::future::ok(true)
}
// This does not return a future
fn check_prime(n: u64) -> bool {
for i in 2..n {
if n % i == 0 { return false }
}
true
}
fn main() {
let input: u64 = 58466453;
println!("Right before first call");
let res_one = check_prime_boxed(input);
println!("Called check_prime_boxed");
let res_two = check_prime_impl_trait(input);
println!("Called check_prime_impl_trait");
println!("Results are {} and {}", res_one.wait().unwrap(),
res_two.wait().unwrap());
let thread_pool = CpuPool::new(4);
let res_three = thread_pool.spawn_fn(move || {
let temp = check_prime(input);
let result: Result<bool, ()> = Ok(temp);
result
});
println!("Called check_prime in another thread");
println!("Result from the last call: {}", res_three.wait().unwrap());
}
返回未来的主要方法有几种。第一种是使用特质对象,就像在 check_prime_boxed 中做的那样。现在,Box 是一个指向堆上对象的指针类型。它是一个受管理的指针,意味着当对象超出作用域时,对象将被自动清理。函数的返回类型是一个特质对象,它可以代表任何将 Item 设置为 bool 且将 Error 设置为 io::Error 的未来。因此,这代表了动态分派。返回未来的第二种方法是使用 impl 特质特性。在 check_prime_impl_trait 的情况下,我们就是这样做的。我们说该函数返回一个实现了 Future<Item=bool, Error=io::Error> 的类型,并且由于任何实现了 Future 特质的类型都是未来,我们的函数正在返回一个未来。请注意,在这种情况下,我们不需要在返回结果之前装箱。因此,这种方法的一个优点是返回未来不需要进行分配。我们的两个函数都使用 future::ok 函数来表示我们的计算已经成功完成,并给出了给定的结果。另一种选择是实际上不返回一个未来,而是使用基于未来的线程池 crate 来执行创建未来和管理未来的繁重工作。这就是 check_prime 的情况,它只返回一个 bool。在我们的主函数中,我们使用 futures-cpupool crate 设置了一个线程池,并在该池中运行最后一个函数。我们得到一个可以调用 wait 来获取结果的未来。为了达到相同的目标,还有一个完全不同的选项,即返回一个实现了 Future 特质的自定义类型。这是最不便捷的,因为它需要编写一些额外的代码,但它是最灵活的方法。
impl 特性目前还不是稳定特性。因此,check_prime_impl_trait 只能在 nightly Rust 上工作。
构建了一个未来之后,下一个目标是执行它。有三种方法可以做到这一点:
-
在当前线程中:这将导致当前线程被阻塞,直到未来完成执行。在我们之前的例子中,
res_one和res_two在主线程上执行,阻止了用户交互。 -
在线程池中:对于
res_three来说,这是这种情况,它在名为thread_pool的线程池中执行。因此,在这种情况下,调用线程可以自由地继续自己的处理。 -
在事件循环中:在某些情况下,上述两种方法都不可能。那时唯一的选择是在事件循环中执行未来。方便的是,tokio-core crate 提供了面向未来的 API 来使用事件循环。我们将在下一节更深入地探讨这个模型。
在我们的主函数中,我们在主线程中调用了前两个函数。因此,它们将阻塞主线程的执行。然而,最后一个函数是在不同的线程上运行的。在这种情况下,主线程可以立即自由地打印出check_prime已被调用的信息。当在future上调用wait时,它再次被阻塞。请注意,在所有情况下,future都是惰性评估的。当我们运行这个程序时,我们应该看到以下内容:
$ cargo run
Compiling futures-example v0.1.0 (file:///src/ch7/futures-example)
Finished dev [unoptimized + debuginfo] target(s) in 0.77 secs
Running `target/debug/futures-example`
Right before first call
Called check_prime_boxed
Called check_prime_impl_trait
Results are true and true
Called check_prime in another thread
Result from the last call: true
将future与常规线程区分开来的是,它们可以以符合人体工程学的方式链式连接。这就像说,下载网页,然后解析 html,然后提取一个特定的单词。这些串联步骤中的每一个都是一个future,下一个步骤不能开始,除非第一个步骤已经完成。整个操作也是一个Future,由多个组成部分future组成。当这个更大的future正在执行时,它被称为任务。该 crate 在futures::task命名空间中提供了一些用于与任务交互的 API。该库提供了一些函数来以这种方式处理future。当一个给定的类型实现了Future特质(实现了poll方法)时,编译器可以提供所有这些组合器的实现。让我们看看使用链式连接实现超时功能的示例。我们将使用tokio-timer crate 作为超时future,在我们的代码中,我们有两个相互竞争的函数,它们会随机休眠一段时间,然后向调用者返回一个固定的字符串。我们将同时调度所有这些函数,如果我们收到第一个函数对应的字符串,我们就宣布它获胜。同样,这也适用于第二个函数。如果我们都没有收到,我们知道超时future已经触发。让我们从项目设置开始:
$ cargo new --bin futures-chaining
然后,我们在Cargo.toml中添加我们的依赖项
[package]
name = "futures-chaining"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
tokio-timer = "0.1.2"
futures = "0.1.17"
futures-cpupool = "0.1.7"
rand = "0.3.18"
和上次一样,我们使用线程池来执行我们的future,使用futures-cpupool crate。让我们看看代码:
// ch7/futures-chaining/src/main.rs
extern crate futures;
extern crate futures_cpupool;
extern crate tokio_timer;
extern crate rand;
use futures::future::select_ok;
use std::time::Duration;
use futures::Future;
use futures_cpupool::CpuPool;
use tokio_timer::Timer;
use std::thread;
use rand::{thread_rng, Rng};
// First player, identified by the string "player_one"
fn player_one() -> &'static str {
let d = thread_rng().gen_range::<u64>(1, 5);
thread::sleep(Duration::from_secs(d));
"player_one"
}
// Second player, identified by the string "player_two"
fn player_two() -> &'static str {
let d = thread_rng().gen_range::<u64>(1, 5);
thread::sleep(Duration::from_secs(d));
"player_two"
}
fn main() {
let pool = CpuPool::new_num_cpus();
let timer = Timer::default();
// Defining the timeout future
let timeout = timer.sleep(Duration::from_secs(3))
.then(|_| Err(()));
// Running the first player in the pool
let one = pool.spawn_fn(|| {
Ok(player_one())
});
// Running second player in the pool
let two = pool.spawn_fn(|| {
Ok(player_two())
});
let tasks = vec![one, two];
// Combining the players with the timeout future
// and filtering out result
let winner = select_ok(tasks).select(timeout).map(|(result, _)|
result);
let result = winner.wait().ok();
match result {
Some(("player_one", _)) => println!("Player one won"),
Some(("player_two", _)) => println!("Player two won"),
Some((_, _)) | None => println!("Timed out"),
}
}
我们的两个玩家非常相似;它们都生成一个介于 1 和 5 之间的随机数,并睡眠相应的时间。之后,它们返回一个与它们名称对应的固定字符串。我们稍后会使用这些字符串来唯一标识它们。在我们的主函数中,我们初始化线程池和计时器。我们使用计时器上的组合器来返回一个在 3 秒后出错的未来。然后我们在线程池中启动两个玩家,并从这些玩家返回 Result 作为未来。请注意,这些函数现在实际上并没有真正运行,因为 futures 是懒加载的。然后我们将这些未来放入一个列表中,并使用 select_ok 组合器并行运行这些。这个函数接受一个未来的可迭代集合,并选择第一个成功的未来;这里的唯一限制是传递给此函数的所有未来都应该属于同一类型。因此,我们不能将超时未来传递到这里。我们使用接受两个未来的 select 组合器将 select_ok 的结果与超时未来链接起来,该组合器等待任一完成执行。结果未来将包含已经完成和尚未完成的部分。然后我们使用 map 组合器丢弃第二部分。最后,我们阻塞在未来的上,并使用 ok() 信号结束链。然后我们可以将结果与已知的字符串进行比较,以确定哪个未来获胜,并相应地打印出消息。
这是一些运行的结果。由于我们的超时时间小于两个函数中任一的最大睡眠时间,我们应该看到一些超时。每当一个函数选择的时间小于超时时间时,它就有机会获胜。
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/futures-chaining`
Player two won
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/futures-chaining`
Player one won
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/futures-chaining`
Timed out
与流和汇一起工作
futures crate 提供了另一个用于懒加载一系列事件的有用抽象,称为 Stream。如果 Future 对应于 Result,那么 Stream 就对应于 Iterator。从语义上看,它们与 futures 非常相似,看起来是这样的:
trait Stream {
type Item;
type Error;
fn poll(& mut self) -> Poll<Option<Self::Item>, Self::Error>;
...
}
这里的唯一区别是返回类型被包裹在一个 Option 中,就像 Iterator 特性一样。因此,这里的 None 会表示流已终止。此外,所有流都是未来,可以使用 into_future 转换。让我们看看使用这个构造的一个例子。我们将部分重用之前章节中的 collatz 示例。第一步是设置项目:
$ cargo new --bin streams
添加了所有依赖项后,我们的 Cargo 配置看起来如下:
[package]
name = "streams"
version = "0.1.0"
authors = ["Foo<foo@bar.com>"]
[dependencies]
futures = "0.1.17"
rand = "0.3.18"
在设置好一切之后,我们的主文件将如下所示。在这种情况下,我们有一个名为 CollatzStream 的结构体,它有两个字段用于当前状态和结束状态(始终为 1)。我们将在这个结构体上实现 Stream 特性,使其表现得像一个流:
// ch7/streams/src/main.rs
extern crate futures;
extern crate rand;
use std::{io, thread};
use std::time::Duration;
use futures::stream::Stream;
use futures::{Poll, Async};
use rand::{thread_rng, Rng};
use futures::Future;
// This struct holds the current state and the end condition
// for the stream
#[derive(Debug)]
struct CollatzStream {
current: u64,
end: u64,
}
// A constructor to initialize the struct with defaults
impl CollatzStream {
fn new(start: u64) -> CollatzStream {
CollatzStream {
current: start,
end: 1
}
}
}
// Implementation of the Stream trait for our struct
impl Stream for CollatzStream {
type Item = u64;
type Error = io::Error;
fn poll(&mut self) -> Poll<Option<Self::Item>, io::Error> {
let d = thread_rng().gen_range::<u64>(1, 5);
thread::sleep(Duration::from_secs(d));
if self.current % 2 == 0 {
self.current = self.current / 2;
} else {
self.current = 3 * self.current + 1;
}
if self.current == self.end {
Ok(Async::Ready(None))
} else {
Ok(Async::Ready(Some(self.current)))
}
}
}
fn main() {
let stream = CollatzStream::new(10);
let f = stream.for_each(|num| {
println!("{}", num);
Ok(())
});
f.wait().ok();
}
我们通过在 1 到 5 秒之间随机暂停来模拟返回结果的延迟。在我们的轮询实现中,当达到 1 时,我们返回 Ok(Async::Ready(None)) 来表示流已结束。否则,我们返回当前状态作为 Ok(Async::Ready(Some(self.current)))。很容易注意到,除了流语义外,这个实现与迭代器的实现相同。在我们的主函数中,我们初始化结构体并使用 for_each 组合子来打印流中的每个项目。这个组合子返回一个未来,我们在其上调用 wait 和 ok 来阻塞并获取所有结果。以下是运行最后一个示例时我们看到的内容:
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/streams`
5
16
8
4
2
就像 Future 特性一样,Stream 特性也支持许多其他组合子,这些组合子适用于不同的目的。Stream 的对偶是 Sink,它是异步事件的接收者。这在模拟 Rust 通道的发送端、网络套接字、文件描述符等时非常有用。
在任何异步系统中,一个常见的模式是同步。这变得很重要,因为组件通常需要相互通信以传递数据或协调任务。我们过去使用通道解决了这个确切的问题。但是,这些构造在这里不适用,因为标准库中的通道实现不是异步的。因此,futures 有自己的通道实现,它提供了您从异步系统期望的所有保证。让我们看看一个例子;我们的项目设置应该如下所示:
$ cargo new --bin futures-ping-pong
Cargo 配置应该如下所示:
[package]
name = "futures-ping-pong"
version = "0.1.0"
authors = ["Foo<foo@bar.com>"]
[dependencies]
futures = "0.1"
tokio-core = "0.1"
rand = "0.3.18"
现在我们有两个函数。一个函数会等待随机的时间,然后随机返回 "ping" 或 "pong"。这个函数将是我们的发送者。以下是它的样子:
// ch7/futures-ping-pong/src/main
extern crate futures;
extern crate rand;
extern crate tokio_core;
use std::thread;
use std::fmt::Debug;
use std::time::Duration;
use futures::Future;
use rand::{thread_rng, Rng};
use futures::sync::mpsc;
use futures::{Sink, Stream};
use futures::sync::mpsc::Receiver;
// Randomly selects a sleep duration between 1 and 5 seconds. Then
// randomly returns either "ping" or "pong"
fn sender() -> &'static str {
let mut d = thread_rng();
thread::sleep(Duration::from_secs(d.gen_range::<u64>(1, 5)));
d.choose(&["ping", "pong"]).unwrap()
}
// Receives input on the given channel and prints each item
fn receiver<T: Debug>(recv: Receiver<T>) {
let f = recv.for_each(|item| {
println!("{:?}", item);
Ok(())
});
f.wait().ok();
}
fn main() {
let (tx, rx) = mpsc::channel(100);
let h1 = thread::spawn(|| {
tx.send(sender()).wait().ok();
});
let h2 = thread::spawn(|| {
receiver::<&str>(rx);
});
h1.join().unwrap();
h2.join().unwrap();
}
futures 库提供了两种类型的通道:一种是一次性使用的 oneshot 通道,可以用来发送和接收任何消息,还有一种是可以多次使用的常规 mpsc 通道。在我们的主函数中,我们获取通道的两端,并在另一个线程中将发送者作为未来启动。接收者也在另一个线程中启动。在这两种情况下,我们记录处理程序以便稍后等待它们完成(使用 join)。请注意,我们的接收者将通道的接收端作为参数。因为 Receiver 实现了 Stream,我们可以使用 and_then 组合子在它上面来打印值。最后,我们在退出接收函数之前在未来的 wait() 和 ok() 上调用。在主函数中,我们通过两个线程处理程序来连接它们,以驱动它们完成。
运行最后一个示例将随机打印 "ping" 或 "pong",具体取决于通过通道发送的内容。请注意,实际的打印操作发生在接收端。
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/futures-ping-pong`
"ping"
futures crate 还在 futures::sync::BiLock 中提供了一个锁定机制,这与 std::sync::Mutex 非常相似。这是一个面向未来的互斥锁,用于在两个所有者之间仲裁资源共享。请注意,BiLock 只适用于两个未来,这是一个令人烦恼的限制。以下是它是如何工作的:我们感兴趣的是修改我们的最后一个示例,以在发送函数被调用时显示一个计数器。现在我们的计数器需要是线程安全的,以便可以在消费者之间共享。使用 Cargo 设置项目:
$ cargo new --bin future-bilock
我们的 Cargo.toml 文件应该完全相同,以下是主文件的外观:
// ch7/future-bilock/src/main.rs
extern crate futures;
extern crate rand;
use std::thread;
use std::fmt::Debug;
use std::time::Duration;
use futures::{Future, Async};
use rand::{thread_rng, Rng};
use futures::sync::{mpsc, BiLock};
use futures::{Sink, Stream};
use futures::sync::mpsc::Receiver;
// Increments the shared counter if it can acquire a lock, then
// sleeps for a random duration between 1 and 5 seconds, then
// randomly returns either "ping" or "pong"
fn sender(send: &BiLock<u64>) -> &'static str {
match send.poll_lock() {
Async::Ready(mut lock) => *lock += 1,
Async::NotReady => ()
}
let mut d = thread_rng();
thread::sleep(Duration::from_secs(d.gen_range::<u64>(1, 5)));
d.choose(&["ping", "pong"]).unwrap()
}
// Tries to acquire a lock on the shared variable and prints it's
// value if it got the lock. Then prints each item in the given
// stream
fn receiver<T: Debug>(recv: Receiver<T>, recv_lock: BiLock<u64>) {
match recv_lock.poll_lock() {
Async::Ready(lock) => println!("Value of lock {}", *lock),
Async::NotReady => ()
}
let f = recv.for_each(|item| {
println!("{:?}", item);
Ok(())
});
f.wait().ok();
}
fn main() {
let counter = 0;
let (send, recv) = BiLock::new(counter);
let (tx, rx) = mpsc::channel(100);
let h1 = thread::spawn(move || {
tx.send(sender(&send)).wait().ok();
});
let h2 = thread::spawn(|| {
receiver::<&str>(rx, recv);
});
h1.join().unwrap();
h2.join().unwrap();
}
虽然这基本上与上一个示例相同,但有一些区别。在我们的主函数中,我们将计数器设置为零。然后我们在计数器上创建一个 BiLock。构造函数返回两个类似于通道的句柄,然后我们可以将它们传递出去。然后我们创建我们的通道并启动发送者。现在,如果我们查看发送者,它已经被修改为接受一个 BiLock 的引用。在该函数中,我们尝试使用 poll_lock 获取锁,如果成功,我们增加计数器。否则,我们不做任何事情。然后我们继续我们通常的业务,返回 "ping" 或 "pong"。接收者也被修改为接受一个 BiLock。在那里,我们尝试获取锁,如果成功,我们打印出被锁定数据的值。在我们的主函数中,我们使用线程启动这些 futures,并在它们上等待它们完成。
这是一个在尝试获取锁失败时发生的情况,当双方都未能获取锁。在真实示例中,我们希望优雅地处理错误并重试。我们为了简洁起见省略了这部分内容:
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/futures-bilock`
thread '<unnamed>' panicked at 'no Task is currently running', libcore/option.rs:917:5
note: Run with `RUST_BACKTRACE=1` for a backtrace.
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Any', libcore/result.rs:945:5
这是一个良好的运行示例:
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/futures-bilock`
Value of lock 1
"pong"
正在前往 tokio
tokio 生态系统是 Rust 中网络堆栈的一个实现。它具有标准库的所有主要功能,主要区别在于它是非阻塞的(大多数常见调用不会阻塞当前线程)。这是通过使用 mio 来完成所有底层繁重工作,并使用 futures 来抽象长运行操作来实现的。该生态系统有两个基本 crate,其他所有内容都是围绕这些构建的:
-
tokio-proto提供了构建异步服务器和客户端的原语。这严重依赖于 mio 进行底层网络和 futures 进行抽象。 -
tokio-core提供了一个事件循环来运行 futures,以及一些相关的 API。当应用程序需要精细控制 IO 时,这很有用。
正如我们在上一节中提到的,运行 futures 的一种方法是在事件循环上。事件循环(在 tokio 中称为 reactor)是一个无限循环,它监听定义的事件,并在接收到一个事件时采取适当的行动。以下是它是如何工作的:我们将借用我们之前的示例,该示例确定给定的输入是否为素数。这返回一个包含结果的 future,然后我们将其打印出来。项目设置与以往相同:
$ cargo new --bin futures-loop
这里是Cargo.toml应该看起来像什么:
[package]
name = "futures-loop"
version = "0.1.0"
authors = ["Foo<foo@bar.com>"]
[dependencies]
futures = "0.1"
tokio-core = "0.1"
对于这个例子,我们将在一个无限循环中接收输入。对于每个输入,我们将去除换行符和空格,并尝试将其解析为u64。看起来是这样的:
// ch7/futures-loop/src/main.rs
extern crate futures;
extern crate tokio_core;
use std::io;
use std::io::BufRead;
use futures::Future;
use tokio_core::reactor::Core;
fn check_prime_boxed(n: u64) -> Box<Future<Item = bool, Error = io::Error>> {
for i in 2..n {
if n % i == 0 {
return Box::new(futures::future::ok(false));
}
}
Box::new(futures::future::ok(true))
}
fn main() {
let mut core = Core::new().expect("Could not create event loop");
let stdin = io::stdin();
loop {
let mut line = String::new();
stdin
.lock()
.read_line(&mut line)
.expect("Could not read from stdin");
let input = line.trim()
.parse::<u64>()
.expect("Could not parse input as u64");
let result = core.run(check_prime_boxed(input))
.expect("Could not run future");
println!("{}", result);
}
}
在我们的主函数中,我们创建核心并启动我们的无限循环。我们使用核心的run方法来启动一个任务以异步执行未来。结果被收集并在标准输出上打印。一个会话应该看起来像这样:
$ cargo run
12
false
13
true
991
true
tokio-proto是一个异步服务器(和客户端)构建工具包。任何使用此工具包的服务器都有以下三个不同的层:
-
一个编解码器,它决定了如何从底层套接字读取和写入数据,形成我们协议的传输层。随后,这一层是最低的(最接近物理介质)。在实践中,编写编解码器相当于实现库中的一些给定特质,这些特质处理字节流。
-
一个协议位于编解码器之上和实际运行协议的事件循环之下。这充当粘合剂,将它们绑定在一起。tokio 支持多种协议类型,具体取决于应用程序:一个简单的请求-响应类型协议、一个多路复用协议和一个流协议。我们将很快深入探讨这些。
-
一个实际运行所有这些操作作为未来的服务。由于这只是一个未来,一个简单的方式来思考它是一个将输入转换为最终响应(可能是错误)的异步函数。在实践中,大部分计算都在这一层完成。
由于层是可互换的,实现可以完全自由地交换协议类型、服务或编解码器。让我们看看一个使用tokio-proto的简单服务的例子。这是一个传统的请求-响应服务,提供基于文本的接口。它接收一个数字并返回其 Collatz 序列作为数组。如果输入不是一个有效的整数,它将返回一条消息指出这一点。我们的项目设置相当简单:
$ cargo new --bin collatz-proto
Cargo 配置看起来像以下示例:
[package]
name = "collatz-proto"
version = "0.1.0"
authors = ["Foo<foo@bar.com>"]
[dependencies]
bytes = "0.4"
futures = "0.1"
tokio-io = "0.1"
tokio-core = "0.1"
tokio-proto = "0.1"
tokio-service = "0.1"
如前所述,我们需要实现不同的层。在我们的当前情况下,我们的每一层都不需要保留太多状态。因此,它们可以用单元结构体来表示。如果不是这种情况,我们就需要在这些层中放入一些数据。
// ch7/collatz-proto/src/main.rs
extern crate bytes;
extern crate futures;
extern crate tokio_io;
extern crate tokio_proto;
extern crate tokio_service;
use std::io;
use std::str;
use bytes::BytesMut;
use tokio_io::codec::{Encoder, Decoder};
use tokio_io::{AsyncRead, AsyncWrite};
use tokio_io::codec::Framed;
use tokio_proto::pipeline::ServerProto;
use tokio_service::Service;
use futures::{future, Future};
use tokio_proto::TcpServer;
// Codec implementation, our codec is a simple unit struct
pub struct CollatzCodec;
// Decoding a byte stream from the underlying socket
impl Decoder for CollatzCodec {
type Item = String;
type Error = io::Error;
fn decode(&mut self, buf: &mut BytesMut) -> io::Result<Option<String>> {
// Since a newline denotes end of input, read till a newline
if let Some(i) = buf.iter().position(|&b| b == b'\n') {
let line = buf.split_to(i);
// and remove the newline
buf.split_to(1);
// try to decode into an UTF8 string before passing
// to the protocol
match str::from_utf8(&line) {
Ok(s) => Ok(Some(s.to_string())),
Err(_) => Err(io::Error::new(io::ErrorKind::Other,
"invalid UTF-8")),
}
} else {
Ok(None)
}
}
}
// Encoding a string to a newline terminated byte stream
impl Encoder for CollatzCodec {
type Item = String;
type Error = io::Error;
fn encode(&mut self, msg: String, buf: &mut BytesMut) ->
io::Result<()> {
buf.extend(msg.as_bytes());
buf.extend(b"\n");
Ok(())
}
}
// Protocol implementation as an unit struct
pub struct CollatzProto;
impl<T: AsyncRead + AsyncWrite + 'static> ServerProto<T> for CollatzProto {
type Request = String;
type Response = String;
type Transport = Framed<T, CollatzCodec>;
type BindTransport = Result<Self::Transport, io::Error>;
fn bind_transport(&self, io: T) -> Self::BindTransport {
Ok(io.framed(CollatzCodec))
}
}
// Service implementation
pub struct CollatzService;
fn get_sequence(n: u64) -> Vec<u64> {
let mut n = n.clone();
let mut result = vec![];
result.push(n);
while n > 1 {
if n % 2 == 0 {
n /= 2;
} else {
n = 3 * n + 1;
}
result.push(n);
}
result
}
impl Service for CollatzService {
type Request = String;
type Response = String;
type Error = io::Error;
type Future = Box<Future<Item = Self::Response, Error = Self::Error>>;
fn call(&self, req: Self::Request) -> Self::Future {
match req.trim().parse::<u64>() {
Ok(num) => {
let res = get_sequence(num);
Box::new(future::ok(format!("{:?}", res)))
}
Err(_) => Box::new(future::ok("Could not parse input as an
u64".to_owned())),
}
}
}
fn main() {
let addr = "0.0.0.0:9999".parse().unwrap();
let server = TcpServer::new(CollatzProto, addr);
server.serve(|| Ok(CollatzService));
}
如前所述,第一步是告诉编解码器如何从套接字读取数据。这是通过从tokio_io::codec实现Encoder和Decoder来完成的。请注意,我们在这里不需要处理原始套接字;我们得到一个字节流作为输入,我们可以自由地处理它。根据我们之前定义的协议,换行符表示输入的结束。因此,在我们的解码器中,我们读取到换行符,然后返回去除该换行符后的数据作为一个 UTF-8 编码的字符串。在发生错误的情况下,我们返回None。
Encoder实现正好相反:它将字符串转换成字节流。下一步是协议定义,这个协议非常简单,因为它既不做多路复用也不做流处理。我们实现bind_transport将编解码器绑定到我们的原始套接字,我们稍后会得到它。这里的唯一问题是这里的Request和Response类型应该与编解码器匹配。设置好这些之后,下一步是实现服务,通过声明一个单元结构体并在其上实现Service特质。我们的辅助函数get_sequence根据输入的u64返回柯朗序列。Service中的call方法实现了计算响应的逻辑。我们将输入解析为u64(记住,我们的编解码器将输入作为字符串返回)。如果没有出错,我们调用我们的辅助函数并返回一个静态字符串作为结果,否则返回一个错误。我们的主函数看起来与使用标准网络类型的函数类似,但我们使用 tokio 的TcpServer类型,它接受我们的套接字(将其绑定到编解码器)和我们的协议定义。最后,我们调用serve方法,并将我们的服务作为闭包传递。这个方法负责管理事件循环并在退出时清理事物。
让我们使用telnet与之交互。下面是一个会话的示例:
$ telnet localhost 9999
Connected to localhost.
Escape character is '^]'.
12
[12, 6, 3, 10, 5, 16, 8, 4, 2, 1]
30
[30, 15, 46, 23, 70, 35, 106, 53, 160, 80, 40, 20, 10, 5, 16, 8, 4, 2, 1]
foobar
Could not parse input as an u64
和往常一样,编写一个客户端来与我们的服务器交互将更有用。我们将从在事件循环中运行 future 的示例中借用很多内容。我们首先设置我们的项目:
$ cargo new --bin collatz-client
我们的货物设置将看起来像这样:
[package]
name = "collatz-client"
version = "0.1.0"
authors = ["Abhishek Chanda <abhishek.becs@gmail.com>"]
[dependencies]
futures = "0.1"
tokio-core = "0.1"
tokio-io = "0.1"
下面是我们的主文件:
// ch7/collatz-client/src/main.rs
extern crate futures;
extern crate tokio_core;
extern crate tokio_io;
use std::net::SocketAddr;
use std::io::BufReader;
use futures::Future;
use tokio_core::reactor::Core;
use tokio_core::net::TcpStream;
fn main() {
let mut core = Core::new().expect("Could not create event loop");
let handle = core.handle();
let addr: SocketAddr = "127.0.0.1:9999".parse().expect("Could not parse as SocketAddr");
let socket = TcpStream::connect(&addr, &handle);
let request = socket.and_then(|socket| {
tokio_io::io::write_all(socket, b"110\n")
});
let response = request.and_then(|(socket, _request)| {
let sock = BufReader::new(socket);
tokio_io::io::read_until(sock, b'\n', Vec::new())
});
let (_socket, data) = core.run(response).unwrap();
println!("{}", String::from_utf8_lossy(&data));
}
当然,这个设置会将单个整数发送到服务器(十进制中的 110),但将这个操作放入循环以读取输入并发送这些数据是微不足道的。我们将这个任务留给读者去练习。在这里,我们创建了一个事件循环并获取其句柄。然后,我们使用异步的TcpStream实现连接到给定地址的服务器。这会返回一个 future,我们使用and_then将其与闭包结合以写入给定的套接字。整个结构返回一个新的 future,称为request,它与一个读取 future 链在一起。最终的 future 称为response,并在事件循环上运行。最后,我们读取响应并将其打印出来。在每一步,我们必须遵守我们的协议,即换行符表示服务器和客户端的输入结束。下面是一个会话的示例:
$ cargo run
Compiling futures-loop v0.1.0 (file:///src/ch7/collatz-client)
Finished dev [unoptimized + debuginfo] target(s) in 0.94 secs
Running `target/debug/futures-loop`
[110, 55, 166, 83, 250, 125, 376, 188, 94, 47, 142, 71, 214, 107, 322, 161, 484, 242, 121, 364, 182, 91, 274, 137, 412, 206, 103, 310, 155, 466, 233, 700, 350, 175, 526, 263, 790, 395, 1186, 593, 1780, 890, 445, 1336, 668, 334, 167, 502, 251, 754, 377, 1132, 566, 283, 850, 425, 1276, 638, 319, 958, 479, 1438, 719, 2158, 1079, 3238, 1619, 4858, 2429, 7288, 3644, 1822, 911, 2734, 1367, 4102, 2051, 6154, 3077, 9232, 4616, 2308, 1154, 577, 1732, 866, 433, 1300, 650, 325, 976, 488, 244, 122, 61, 184, 92, 46, 23, 70, 35, 106, 53, 160, 80, 40, 20, 10, 5, 16, 8, 4, 2, 1]
tokio 中的套接字多路复用
服务器中异步请求处理的一种模式是通过多路复用传入的连接。在这种情况下,每个连接被分配一个某种类型的唯一 ID,并且每当有一个准备好时就会发出回复,而不考虑它接收的顺序。因此,这允许更高的吞吐量,因为最短的任务隐含地获得了最高的优先级。这种模型也使得服务器在面对大量不同复杂度的传入请求时具有高度的响应性。传统的类 Unix 系统使用 select 和 poll 系统调用来支持套接字多路复用。
在 tokio 生态系统中,这通过一系列特性来实现,这些特性使得实现多路复用协议成为可能。服务器的基本结构与简单服务器相同:我们有编解码器、使用编解码器的协议,以及实际运行协议的服务。这里唯一的区别是我们将给每个传入的请求分配一个请求 ID。这将用于稍后发送响应时的去歧义。我们还需要实现tokio_proto::multiplex命名空间中的一些特性。作为一个例子,我们将修改我们的 collatz 服务器并为其添加多路复用功能。在这个情况下,我们的项目设置略有不同,因为我们计划使用 Cargo 运行二进制文件,并且我们的项目将是一个库。我们是这样设置的:
$ cargo new collatz-multiplexed
Cargo 配置类似:
[package]
name = "collatz-multiplexed"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
bytes = "0.4"
futures = "0.1"
tokio-io = "0.1"
tokio-core = "0.1"
tokio-proto = "0.1"
tokio-service = "0.1"
下面是lib.rs文件的样子:
// ch7/collatz-multiplexed/src/lib.rs
extern crate bytes;
extern crate futures;
extern crate tokio_core;
extern crate tokio_io;
extern crate tokio_proto;
extern crate tokio_service;
use futures::{future, Future};
use tokio_io::{AsyncRead, AsyncWrite};
use tokio_io::codec::{Decoder, Encoder, Framed};
use tokio_core::net::TcpStream;
use tokio_core::reactor::Handle;
use tokio_proto::TcpClient;
use tokio_proto::multiplex::{ClientProto, ClientService, RequestId, ServerProto};
use tokio_service::Service;
use bytes::{BigEndian, Buf, BufMut, BytesMut};
use std::{io, str};
use std::net::SocketAddr;
// Everything client side
// Represents a client connecting to our server
pub struct Client {
inner: ClientService<TcpStream, CollatzProto>,
}
impl Client {
pub fn connect(
addr: &SocketAddr,
handle: &Handle,
) -> Box<Future<Item = Client, Error = io::Error>> {
let ret = TcpClient::new(CollatzProto)
.connect(addr, handle)
.map(|service| Client {
inner: service,
});
Box::new(ret)
}
}
impl Service for Client {
type Request = String;
type Response = String;
type Error = io::Error;
type Future = Box<Future<Item = String, Error = io::Error>>;
fn call(&self, req: String) -> Self::Future {
Box::new(self.inner.call(req).and_then(move |resp| Ok(resp)))
}
}
// Everything server side
pub struct CollatzCodec;
pub struct CollatzProto;
// Represents a frame that has a RequestId and the actual data (String)
type CollatzFrame = (RequestId, String);
impl Decoder for CollatzCodec {
type Item = CollatzFrame;
type Error = io::Error;
fn decode(&mut self, buf: &mut BytesMut) ->
Result<Option<CollatzFrame>, io::Error> {
// Do not proceed if we haven't received at least 6 bytes yet
// 4 bytes for the RequestId + data + 1 byte for newline
if buf.len() < 5 {
return Ok(None);
}
let newline = buf[4..].iter().position(|b| *b == b'\n');
if let Some(n) = newline {
let line = buf.split_to(n + 4);
buf.split_to(1);
let request_id = io::Cursor::new(&line[0..4]).get_u32:
:<BigEndian>();
return match str::from_utf8(&line.as_ref()[4..]) {
Ok(s) => Ok(Some((u64::from(request_id),
s.to_string()))),
Err(_) => Err(io::Error::new(io::ErrorKind::Other,
"invalid string")),
};
}
// Frame is not complete if it does not have a newline at the
end
Ok(None)
}
}
impl Encoder for CollatzCodec {
type Item = CollatzFrame;
type Error = io::Error;
fn encode(&mut self, msg: CollatzFrame, buf: &mut BytesMut) ->
io::Result<()> {
// Calculate final message length first
let len = 4 + msg.1.len() + 1;
buf.reserve(len);
let (request_id, msg) = msg;
buf.put_u32::<BigEndian>(request_id as u32);
buf.put_slice(msg.as_bytes());
buf.put_u8(b'\n');
Ok(())
}
}
impl<T: AsyncRead + AsyncWrite + 'static> ClientProto<T> for CollatzProto {
type Request = String;
type Response = String;
type Transport = Framed<T, CollatzCodec>;
type BindTransport = Result<Self::Transport, io::Error>;
fn bind_transport(&self, io: T) -> Self::BindTransport {
Ok(io.framed(CollatzCodec))
}
}
impl<T: AsyncRead + AsyncWrite + 'static> ServerProto<T> for CollatzProto {
type Request = String;
type Response = String;
type Transport = Framed<T, CollatzCodec>;
type BindTransport = Result<Self::Transport, io::Error>;
fn bind_transport(&self, io: T) -> Self::BindTransport {
Ok(io.framed(CollatzCodec))
}
}
pub struct CollatzService;
fn get_sequence(mut n: u64) -> Vec<u64> {
let mut result = vec![];
result.push(n);
while n > 1 {
if n % 2 == 0 {
n /= 2;
} else {
n = 3 * n + 1;
}
result.push(n);
}
result
}
impl Service for CollatzService {
type Request = String;
type Response = String;
type Error = io::Error;
type Future = Box<Future<Item = Self::Response, Error = Self::Error>>;
fn call(&self, req: Self::Request) -> Self::Future {
match req.trim().parse::<u64>() {
Ok(num) => {
let res = get_sequence(num);
Box::new(future::ok(format!("{:?}", res)))
}
Err(_) => Box::new(future::ok("Could not parse input as an
u64".to_owned())),
}
}
}
Tokio 提供了一个内置类型RequestId来表示传入请求的唯一 ID,所有与其相关的状态都由 tokio 内部管理。我们定义了一个名为CollatzFrame的自定义数据类型来表示我们的帧;它包含RequestId和用于我们数据的String。我们继续实现Decoder和Encoder,就像上次一样,为CollatzCodec。但是,在这两种情况下,我们必须考虑到头部中的请求 ID 和尾随的换行符。因为RequestId类型在底层是u64,它总是占用四个字节,再加上一个换行符的字节。因此,如果我们收到了少于 5 个字节,我们知道整个帧还没有收到。请注意,这并不是一个错误情况,帧仍在传输中,所以我们返回Ok(None)。然后我们检查缓冲区是否有换行符(符合我们的协议)。如果一切看起来都很好,我们从前 4 个字节解析请求 ID(请注意,这将是在网络字节序)。然后我们构造一个CollatzFrame实例并返回它。编码器的实现是相反的;我们只需要将请求 ID 放回,然后是实际的数据,最后以换行符结束。
下一步是实现ServerProto和ClientProto的CollatzProto;这两个都是绑定编解码器与传输的样板代码。像上次一样,最后一步是实现服务。这一步没有任何变化。请注意,在实现编解码器后,我们不需要关心处理请求 ID,因为后续阶段根本看不到它。编解码器处理并管理它,同时将实际数据传递到后续层。
这是我们帧的外观:

我们的请求帧,其中包含作为头部的 RequestId 和一个尾随换行符
这次,我们的客户端也将基于 tokio。我们的Client结构体封装了一个ClientService实例,它接受底层的 TCP 流和要使用的协议实现。我们有一个名为connect的便利函数,用于Client类型,它连接到指定的服务器并返回一个 future。最后,我们在Client中实现Service,其中call方法返回一个 future。我们将服务器和客户端作为示例放入名为examples的目录中。这样,cargo 就知道这些应该作为与此 crate 关联的示例运行。服务器看起来是这样的:
// ch7/collatz-multiplexed/examples/server.rs
extern crate collatz_multiplexed as collatz;
extern crate tokio_proto;
use tokio_proto::TcpServer;
use collatz::{CollatzService, CollatzProto};
fn main() {
let addr = "0.0.0.0:9999".parse().unwrap();
TcpServer::new(CollatzProto, addr).serve(|| Ok(CollatzService));
}
这基本上和上次一样,只是在一个不同的文件中。我们必须将父 crate 声明为外部依赖,这样 Cargo 才能正确地链接一切。这是客户端的外观:
// ch7/collatz-multiplexed/examples/client.rs
extern crate collatz_multiplexed as collatz;
extern crate futures;
extern crate tokio_core;
extern crate tokio_service;
use futures::Future;
use tokio_core::reactor::Core;
use tokio_service::Service;
pub fn main() {
let addr = "127.0.0.1:9999".parse().unwrap();
let mut core = Core::new().unwrap();
let handle = core.handle();
core.run(
collatz::Client::connect(&addr, &handle)
.and_then(|client| {
client.call("110".to_string())
.and_then(move |response| {
println!("We got back: {:?}", response);
Ok(())
})
})
).unwrap();
}
我们在事件循环中运行我们的客户端,使用 tokio-core。我们使用客户端上定义的connect方法获取一个封装连接的 future。我们使用and_then组合器,并使用call方法向服务器发送一个字符串。由于此方法也返回一个 future,我们可以在内部 future 上使用and_then组合器来提取响应,然后通过返回Ok(())来解析它。这也解析了外部 future。
现在,如果我们打开两个终端,在一个终端中运行服务器,在另一个终端中运行客户端,我们应该在客户端看到以下内容。请注意,由于我们没有复杂的重试和错误处理,服务器应该在客户端之前运行:
$ cargo run --example client
Compiling collatz-multiplexed v0.1.0 (file:///src/ch7/collatz-multiplexed)
Finished dev [unoptimized + debuginfo] target(s) in 0.93 secs
Running `target/debug/examples/client`
We got back: "[110, 55, 166, 83, 250, 125, 376, 188, 94, 47, 142, 71, 214, 107, 322, 161, 484, 242, 121, 364, 182, 91, 274, 137, 412, 206, 103, 310, 155, 466, 233, 700, 350, 175, 526, 263, 790, 395, 1186, 593, 1780, 890, 445, 1336, 668, 334, 167, 502, 251, 754, 377, 1132, 566, 283, 850, 425, 1276, 638, 319, 958, 479, 1438, 719, 2158, 1079, 3238, 1619, 4858, 2429, 7288, 3644, 1822, 911, 2734, 1367, 4102, 2051, 6154, 3077, 9232, 4616, 2308, 1154, 577, 1732, 866, 433, 1300, 650, 325, 976, 488, 244, 122, 61, 184, 92, 46, 23, 70, 35, 106, 53, 160, 80, 40, 20, 10, 5, 16, 8, 4, 2, 1]"
如预期的那样,这个输出与之前的结果匹配。
编写流式协议
在许多情况下,一个协议有一个数据单元,其中包含一个附加的头部。服务器通常首先读取头部,然后根据这个头部决定如何处理数据。在某些情况下,服务器可能能够根据头部进行一些处理。一个这样的例子是 IP,它有一个包含目标地址的头部。服务器可能开始根据该信息运行最长前缀匹配,然后再读取主体。在本节中,我们将探讨如何使用 tokio 编写这样的服务器。我们将扩展我们的玩具 collatz 协议,包括一个头部和一些数据主体,并从这里开始。让我们从一个例子开始,我们的项目设置将与上次完全相同,使用 Cargo 进行设置:
$ cargo new collatz-streaming
Cargo 配置没有太大变化:
[package]
name = "collatz-streaming"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
bytes = "0.4"
futures = "0.1"
tokio-io = "0.1"
tokio-core = "0.1"
tokio-proto = "0.1"
tokio-service = "0.1"
由于这个示例较大,我们将其分解为几个组成部分,如下所示。第一部分展示了设置客户端的过程:
// ch7/collatz-streaming/src/lib.rs
extern crate bytes;
extern crate futures;
extern crate tokio_core;
extern crate tokio_io;
extern crate tokio_proto;
extern crate tokio_service;
use futures::{future, Future, Poll, Stream};
use futures::sync::mpsc;
use tokio_io::{AsyncRead, AsyncWrite};
use tokio_io::codec::{Decoder, Encoder, Framed};
use tokio_core::reactor::Handle;
use tokio_proto::TcpClient;
use tokio_proto::streaming::{Body, Message};
use tokio_proto::streaming::pipeline::{ClientProto, Frame, ServerProto};
use tokio_proto::util::client_proxy::ClientProxy;
use tokio_service::Service;
use std::str::FromStr;
use bytes::{BufMut, BytesMut};
use std::{io, str};
use std::net::SocketAddr;
// Everything about clients
type CollatzMessage = Message<String, Body<String, io::Error>>;
#[derive(Debug)]
pub enum CollatzInput {
Once(String),
Stream(CollatzStream),
}
pub struct CollatzProto;
pub struct Client {
inner: ClientProxy<CollatzMessage, CollatzMessage, io::Error>,
}
impl Client {
pub fn connect(
addr: &SocketAddr,
handle: &Handle,
) -> Box<Future<Item = Client, Error = io::Error>> {
let ret = TcpClient::new(CollatzProto)
.connect(addr, handle)
.map(|cp| Client { inner: cp });
Box::new(ret)
}
}
impl Service for Client {
type Request = CollatzInput;
type Response = CollatzInput;
type Error = io::Error;
type Future = Box<Future<Item = Self::Response, Error =
io::Error>>;
fn call(&self, req: CollatzInput) -> Self::Future {
Box::new(self.inner.call(req.into()).map(CollatzInput::from))
}
}
总是首先设置外部 crate 并包含所有必需的内容。我们定义了一些我们将要使用的类型。CollatzMessage 是我们的协议接收到的消息;它有一个头部和一个主体,都是 String 类型。CollatzInput 是协议的输入流,它是一个枚举,有两个变体:Once 代表我们以非流式方式接收数据的情况,而 Stream 是第二种情况。协议实现是一个名为 CollatzProto 的单元结构。然后我们定义了一个客户端结构,它有一个内部的 ClientProxy 实例,这是实际客户端的实现。它接受三种类型,前两种是整个服务器的请求和响应,最后一种是错误。然后我们为 Client 结构实现一个连接方法,使用 CollatzProto 进行连接,并返回一个包含连接的 future。最后一步是实现 Client 的 Service,输入和输出都是 CollatzInput 类型,因此我们必须使用 future 上的 map 将输出转换为该类型。让我们继续到服务器;它看起来是这样的:
//ch7/collatz-streaming/src/lib.rs
// Everything about server
#[derive(Debug)]
pub struct CollatzStream {
inner: Body<String, io::Error>,
}
impl CollatzStream {
pub fn pair() -> (mpsc::Sender<Result<String, io::Error>>,
CollatzStream) {
let (tx, rx) = Body::pair();
(tx, CollatzStream { inner: rx })
}
}
impl Stream for CollatzStream {
type Item = String;
type Error = io::Error;
fn poll(&mut self) -> Poll<Option<String>, io::Error> {
self.inner.poll()
}
}
pub struct CollatzCodec {
decoding_head: bool,
}
// Decodes a frame to a byte slice
impl Decoder for CollatzCodec {
type Item = Frame<String, String, io::Error>;
type Error = io::Error;
fn decode(&mut self, buf: &mut BytesMut) -> Result<Option<Self::Item>, io::Error> {
if let Some(n) = buf.as_ref().iter().position(|b| *b == b'\n') {
let line = buf.split_to(n);
buf.split_to(1);
return match str::from_utf8(line.as_ref()) {
Ok(s) => {
if s == "" {
let decoding_head = self.decoding_head;
self.decoding_head = !decoding_head;
if decoding_head {
Ok(Some(Frame::Message {
message: s.to_string(),
body: true,
}))
} else {
Ok(Some(Frame::Body { chunk: None }))
}
} else {
if self.decoding_head {
Ok(Some(Frame::Message {
message: s.to_string(),
body: false,
}))
} else {
Ok(Some(Frame::Body {
chunk: Some(s.to_string()),
}))
}
}
}
Err(_) => Err(io::Error::new(io::ErrorKind::Other,
"invalid string")),
};
}
Ok(None)
}
}
// Encodes a given byte slice to a frame
impl Encoder for CollatzCodec {
type Item = Frame<String, String, io::Error>;
type Error = io::Error;
fn encode(&mut self, msg: Self::Item, buf: &mut BytesMut) ->
io::Result<()> {
match msg {
Frame::Message { message, body } => {
buf.reserve(message.len());
buf.extend(message.as_bytes());
}
Frame::Body { chunk } => {
if let Some(chunk) = chunk {
buf.reserve(chunk.len());
buf.extend(chunk.as_bytes());
}
}
Frame::Error { error } => {
return Err(error);
}
}
buf.put_u8(b'\n');
Ok(())
}
}
impl<T: AsyncRead + AsyncWrite + 'static> ClientProto<T> for CollatzProto {
type Request = String;
type RequestBody = String;
type Response = String;
type ResponseBody = String;
type Error = io::Error;
type Transport = Framed<T, CollatzCodec>;
type BindTransport = Result<Self::Transport, io::Error>;
fn bind_transport(&self, io: T) -> Self::BindTransport {
let codec = CollatzCodec {
decoding_head: true,
};
Ok(io.framed(codec))
}
}
impl<T: AsyncRead + AsyncWrite + 'static> ServerProto<T> for CollatzProto {
type Request = String;
type RequestBody = String;
type Response = String;
type ResponseBody = String;
type Error = io::Error;
type Transport = Framed<T, CollatzCodec>;
type BindTransport = Result<Self::Transport, io::Error>;
fn bind_transport(&self, io: T) -> Self::BindTransport {
let codec = CollatzCodec {
decoding_head: true,
};
Ok(io.framed(codec))
}
}
如预期,CollatzStream 的主体要么是一个字符串,要么是发生了错误。现在,对于流式协议,我们需要提供一个函数的实现,该函数返回流的一半发送者;我们在 CollatzStream 的 pair 函数中这样做。接下来,我们为我们的自定义流实现 Stream 特性;在这种情况下,poll 方法简单地轮询内部 Body 以获取更多数据。在设置好流之后,我们可以实现编解码器。在这里,我们需要维护一种方式来知道我们此刻正在处理帧的哪一部分。这是通过一个名为 decoding_head 的布尔值来完成的,我们需要根据需要翻转它。我们需要为我们的编解码器实现 Decoder,这基本上和上次一样;只需注意我们需要跟踪流和非流的情况以及之前定义的布尔值。Encoder 的实现是相反的。我们还需要将协议实现绑定到编解码器;这是通过实现 ClientProto 和 ServerProto 为 CollatzProto 来完成的。在两种情况下,我们都将布尔值设置为 true,因为接收消息后要读取的第一个东西是头部。
在堆栈中的最后一步是通过实现 Service 特性为 CollatzService 实现服务。在那里,我们读取头部并尝试将其解析为 u64。如果这做得很好,我们就继续计算该 u64 的 collatz 序列,并以 CollatzInput::Once 的形式在 leaf future 中返回结果。在另一种情况下,我们遍历主体并在控制台上打印它。最后,我们向客户端返回一个固定的字符串。这就是它的样子:
//ch7/collatz-streaming/src/lib.rs
pub struct CollatzService;
// Given an u64, returns it's collatz sequence
fn get_sequence(mut n: u64) -> Vec<u64> {
let mut result = vec![];
result.push(n);
while n > 1 {
if n % 2 == 0 {
n /= 2;
} else {
n = 3 * n + 1;
}
result.push(n);
}
result
}
// Removes leading and trailing whitespaces from a given line
// and tries to parse it as a u64
fn clean_line(line: &str) -> Result<u64, <u64 as FromStr>::Err> {
line.trim().parse::<u64>()
}
impl Service for CollatzService {
type Request = CollatzInput;
type Response = CollatzInput;
type Error = io::Error;
type Future = Box<Future<Item = Self::Response, Error = Self::Error>>;
fn call(&self, req: Self::Request) -> Self::Future {
match req {
CollatzInput::Once(line) => {
println!("Server got: {}", line);
let res = get_sequence(clean_line(&line).unwrap());
Box::new(future::done(Ok(CollatzInput::Once
(format!("{:?}", res)))))
}
CollatzInput::Stream(body) => {
let resp = body.for_each(|line| {
println!("{}", line);
Ok(())
}).map(|_| CollatzInput::Once("Foo".to_string()));
Box::new(resp) as Box<Future<Item = Self::
Response, Error = io::Error>>
}
}
}
}
我们还编写了两个从CollatzMessage到CollatzInput以及相反的转换辅助器,通过相应地实现From特质。像其他所有内容一样,我们不得不面对两种情况:当消息有主体时,以及当它没有主体时(换句话说,头部已经到达,但消息的其他部分还没有到达)。以下是这些:
// ch7/collatz-streaming/src/lib.rs
// Converts a CollatzMessage to a CollatzInput
impl From<CollatzMessage> for CollatzInput {
fn from(src: CollatzMessage) -> CollatzInput {
match src {
Message::WithoutBody(line) => CollatzInput::Once(line),
Message::WithBody(_, body) => CollatzInput::Stream(CollatzStream { inner: body }),
}
}
}
// Converts a CollatzInput to a Message<String, Body>
impl From<CollatzInput> for Message<String, Body<String, io::Error>> {
fn from(src: CollatzInput) -> Self {
match src {
CollatzInput::Once(line) => Message::WithoutBody(line),
CollatzInput::Stream(body) => {
let CollatzStream { inner } = body;
Message::WithBody("".to_string(), inner)
}
}
}
}
在设置好服务器和客户端之后,我们将像上次一样以示例的形式实现我们的测试。它们看起来是这样的:
// ch7/collatz-streaming/examples/server.rs
extern crate collatz_streaming as collatz;
extern crate futures;
extern crate tokio_proto;
use tokio_proto::TcpServer;
use collatz::{CollatzProto, CollatzService};
fn main() {
let addr = "0.0.0.0:9999".parse().unwrap();
TcpServer::new(CollatzProto, addr).serve(|| Ok(CollatzService));
}
客户端看起来是这样的。与服务器相比,这里有一些内容需要消化:
// ch7/collatz-streaming/examples/client.rs
extern crate collatz_streaming as collatz;
extern crate futures;
extern crate tokio_core;
extern crate tokio_service;
use collatz::{CollatzInput, CollatzStream};
use std::thread;
use futures::Sink;
use futures::Future;
use tokio_core::reactor::Core;
use tokio_service::Service;
pub fn main() {
let addr = "127.0.0.1:9999".parse().unwrap();
let mut core = Core::new().unwrap();
let handle = core.handle();
// Run the client in the event loop
core.run(
collatz::Client::connect(&addr, &handle)
.and_then(|client| {
client.call(CollatzInput::Once("10".to_string()))
.and_then(move |response| {
println!("Response: {:?}", response);
let (mut tx, rx) = CollatzStream::pair();
thread::spawn(move || {
for msg in &["Hello", "world", "!"] {
tx =
tx.send(Ok(msg.to_string()))
.wait().unwrap();
}
});
client.call(CollatzInput::Stream(rx))
})
.and_then(|response| {
println!("Response: {:?}", response);
Ok(())
})
})
).unwrap();
}
我们使用之前定义的connect方法在已知地址和端口上设置到服务器的连接。我们使用and_then组合器向服务器发送一个固定字符串,并打印响应。此时,我们已经发送了头部,接下来我们将发送主体。这是通过将流分成两半并使用发送者发送多个字符串来完成的。一个最终的组合器打印响应并解决未来。所有之前的内容都在事件循环中运行。
这是服务器会话的样子:
$ cargo run --example server
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/examples/server`
Server got: 10
Hello
world
!
而客户端看起来是这样的:
$ cargo run --example client
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/examples/client`
Response: Once("[10, 5, 16, 8, 4, 2, 1]")
Response: Once("Foo")
如预期,服务器在收到请求的实际主体之前处理了头部(我们知道这一点是因为我们在发送头部之后发送了主体)。
除了我们在这里讨论的内容之外,tokio 还支持许多其他功能。例如,你可以通过更改ServerProto和ClientProto实现来交换设置消息,在构建BindTransport未来之前实现协议握手。这一点非常重要,因为许多网络协议需要某种形式的握手来设置共享状态以协同工作。请注意,一个协议可以是流式和管道化的,或者流式和复用的。对于这些,实现需要分别替换streaming::pipeline或streaming::multiplex命名空间中的特质。
更大的 tokio 生态系统
让我们看看 tokio 生态系统当前的状态。以下是写作时在tokio-rs GitHub 组织中的常用 crate:
| Crate | 函数 |
|---|---|
tokio-minihttp |
在 tokio 中的简单 HTTP 服务器实现;不应在生产中使用。 |
tokio-core |
具有未来感知的网络实现;核心事件循环。 |
tokio-io |
tokio 的 IO 原语。 |
tokio-curl |
使用 tokio 的基于libcurl的 HTTP 客户端实现。 |
tokio-uds |
使用 tokio 的非阻塞 Unix 域套接字。 |
tokio-tls |
基于 tokio 的 TLS 和 SSL 实现。 |
tokio-service |
提供了我们广泛使用的Service特质。 |
tokio-proto |
提供了一个使用 tokio 构建网络协议的框架;我们广泛使用了这个。 |
tokio-socks5 |
使用 tokio 的 SOCKS5 服务器,尚未准备好用于生产。 |
tokio-middleware |
用于 tokio 服务的中间件集合;目前缺少基本服务。 |
tokio-times |
基于 tokio 的定时器相关功能。 |
tokio-line |
用于演示 tokio 的样本行协议。 |
tokio-redis |
基于 tokio 的证明概念 Redis 客户端;不应在生产中使用。 |
service-fn |
为给定的闭包提供实现 Service 特性的函数。 |
注意,其中许多已经很长时间没有更新,或者是一些应该不用于任何有用目的的证明概念实现。但这不是问题。自它推出以来,大量独立的实用工具已经采用了 tokio,从而形成了一个充满活力的生态系统。而且,据我们观察,这是任何开源项目成功的真正标志。
让我们看看前面列表中一些常用的库,首先是 tokio-curl。在我们的示例中,我们将简单地从已知位置下载单个文件,将其写入本地磁盘,并打印出我们从服务器获取的头部信息。由于这是一个二进制文件,我们将按照以下方式设置项目:
$ cargo new --bin tokio-curl
下面是 Cargo 设置的示例:
[package]
name = "tokio-curl"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
tokio-curl = "0.1"
tokio-core = "0.1"
curl = "0.4.8"
由于 tokio-curl 库是 Rust curl 库的包装器,因此我们还需要包含它。以下是主文件:
// ch7/toki-curl/src/main.rs
extern crate curl;
extern crate tokio_core;
extern crate tokio_curl;
use curl::easy::Easy;
use tokio_core::reactor::Core;
use tokio_curl::Session;
use std::io::Write;
use std::fs::File;
fn main() {
let mut core = Core::new().unwrap();
let session = Session::new(core.handle());
let mut handle = Easy::new();
let mut file = File::create("foo.zip").unwrap();
handle.get(true).unwrap();
handle.url("http://ipv4.download.thinkbroadband.com/5MB.zip").unwrap();
handle.header_function(|header| {
print!("{}", std::str::from_utf8(header).unwrap());
true
}).unwrap();
handle.write_function(move |data| {
file.write_all(data).unwrap();
Ok(data.len())
}).unwrap();
let request = session.perform(handle);
let mut response = core.run(request).unwrap();
println!("{:?}", response.response_code());
}
我们将使用来自 curl crate 的 Easy API。我们首先创建我们的事件循环和 HTTP 会话。然后创建一个 libcurl 将用于处理我们请求的句柄。我们使用布尔值调用 get 方法来表示我们感兴趣进行 HTTP GET 操作。然后我们将 URL 传递给句柄。接下来,我们设置两个作为闭包传递的回调函数。第一个被称为 header_function;这个函数显示客户端的每个头部信息。第二个被称为 write_function,它将我们获取的数据写入我们的文件。最后,我们通过调用会话的 perform 函数创建一个请求。最后,我们在事件循环中运行请求并打印出我们得到的状态码。
运行此操作将执行以下操作:
$ cargo run
Compiling tokio-curl v0.1.0 (file:///src/ch7/tokio-curl)
Finished dev [unoptimized + debuginfo] target(s) in 0.97 secs
Running `target/debug/tokio-curl`
HTTP/1.1 200 OK
Server: nginx
Date: Mon, 25 Dec 2017 20:16:12 GMT
Content-Type: application/zip
Content-Length: 5242880
Last-Modified: Mon, 02 Jun 2008 15:30:42 GMT
Connection: keep-alive
ETag: "48441222-500000"
Access-Control-Allow-Origin: *
Accept-Ranges: bytes
Ok(200)
这将在当前目录中生成一个名为 foo.zip 的文件。您可以使用常规文件下载该文件,并比较两个文件的 SHA 哈希值,以验证它们确实相同。
结论
本章是 Rust 更大生态系统中最激动人心的组件之一的一个介绍。Futures 和 Tokio 生态系统提供了强大的原语,这些原语可以广泛应用于应用程序中,包括网络软件。本身,Futures 可以用来模拟任何其他情况下缓慢的或依赖于外部资源的计算。与 tokio 结合使用,它可以用来模拟复杂的流水线或复用等协议行为。
使用这些工具的主要缺点集中在缺乏适当的文档和示例。此外,这些应用程序的错误信息通常非常模板化,因此往往冗长。因为 Rust 编译器本身并不了解这些抽象,它经常会抱怨类型不匹配,用户必须深入推理嵌套的类型。在某个阶段,实现一个能够将这些错误转换为更直观形式的编译器插件可能是有意义的。
在下一章中,我们将探讨在 Rust 中实现常见的与安全相关的原语。
第八章:安全
安全性通常在系统设计中被视为事后考虑。这在常见协议中很明显;安全相关的 RFC 在历史上是在主要协议之后提出的。请注意,任何在公共媒介(如互联网)上的通信都容易受到中间人攻击。对手可能会通过仔细检查来自双方的输入数据包来劫持通信。鉴于这一点,一些安全相关问题是合理的:当客户端连接到服务器时,它如何验证服务器是它声称的那个?他们如何决定用于加密的共享密钥?在本章中,我们将看到这些问题通常是如何解决的。
我们将涵盖以下主题:
-
使用证书保护基于 Web 的应用程序
-
使用 Diffie-Hellman 方法进行密钥交换
保护网络
在前一章中,我们学习了 HTTP。我们注意到 HTTP 在使我们的生活变得更轻松方面是多么重要。然而,HTTP 容易受到各种攻击,可能导致有效载荷泄露。因此,在 HTTP 通信的各方之间添加某种形式的安全措施是必要的。RFC 2818 提出了 HTTPS(HTTP 安全)作为 HTTP 的一个使用安全流协议的版本。最初,这被称为安全套接字层(SSL),后来演变为传输层安全性(TLS)。
基本方案是这样的:
-
客户端和服务器建立 TCP 连接。
-
客户端和服务器就整个连接期间使用的加密算法和散列函数达成一致。为此,客户端发送一个加密算法和散列函数列表。服务器从该列表中选择一个,并通知客户端。
-
服务器向客户端发送证书。客户端将其与本地拥有的证书颁发机构列表进行验证。
-
两者都同意使用会话密钥来加密连接期间的数据。
-
到这一点,一个普通的 HTTP 会话可以开始了。
以下图像展示了这一步骤:

使用 SSL 的客户端-服务器通信
这里最重要的步骤之一是验证服务器的身份。这回答了一个基本问题:当客户端与服务器通信时,它如何知道服务器确实是它想要的服务器?在实践中,这是通过证书实现的。证书是由证书颁发机构签发的文件,这是一个受法律允许为他人作保的可信提供商。客户端有一份受信任的 CA 列表,如果颁发给定证书的 CA 在该列表中,客户端可以信任提供该证书的服务器。在实践中,通常有一系列证书作为信任关系链发行,追溯到根 CA。
近年来,搜索引擎非常重视网站使用 HTTPS,通常会将这些网站排名更高。然而,为网站颁发证书一直是传统上繁琐的过程。网站所有者必须登录到 CA 的网站并提供某种形式的身份证明。颁发证书通常需要几个小时,对小企业主来说成本很高。2015 年,Let's Encrypt 作为一个非营利性 CA 成立,旨在为互联网上的所有网站提供免费、短期有效的证书。他们通过向服务器管理员发出挑战来自动化验证过程。这通常涉及在网站上已知位置放置一个文件或创建一个具有给定内容的 DNS 记录。一旦letsencrypt验证了服务器,它就会颁发一个有效期为 90 天的证书。因此,证书需要定期续订。
最近,letsencrypt将挑战响应协议标准化为 HTTPS 上的 JSON,并将其命名为 ACME。以下是它是如何工作的:
-
本地客户端生成一个私钥-公钥对,并使用公钥联系 letsencrypt 服务器。
-
服务器为给定的密钥创建一个账户并将其注册。
-
根据挑战偏好,客户端将展示一个可以满足的挑战列表,并要求服务器验证域名。目前支持两种挑战:基于 HTTP 的挑战,其中在已知位置放置一个文件,服务器将读取它以进行验证;或基于 DNS 的挑战,其中操作员必须在域名上创建一个具有给定内容的
TXT记录。 -
服务器生成挑战并将其发送回。
-
在这一点上,客户端将轮询服务器以获取确认。
-
当服务器返回 OK 时,客户端可以继续为服务器生成一个证书签名请求(CSR)并将其发送过去。
-
然后,服务器生成一个证书并将其发送回。
以下图表说明了 ACME 协议中的各个步骤:

ACME 协议操作
使用 Rust 的 Let's Encrypt
目前,有一个 crate 允许使用 Rust 访问letsencrypt。名为acme-client的 CLI 工具可以与 API 交互以获取或撤销证书或运行所有权验证。该二进制文件由名为 acme-client 的 crate 支持,它允许程序与 API 进行交互。让我们看看如何使用它来保护运行在 Rocket 上的 HTTP 服务器。记住,为了使这生效,letsencrypt需要能够访问服务器。因此,这需要在互联网上公开访问。
第一步是使用 Cargo 安装 CLI 工具:
$ cargo install acme-client
在我们的例子中,我们将运行我们的 Rocket 博客通过 TLS。虽然 Rocket 默认支持 TLS,但它不是默认启用的。我们需要更改Cargo.toml文件以将 TLS 添加为功能标志。它应该看起来像这样:
[package]
authors = ["Foo <foo@bar.com>"]
name = "rocket-blog"
version = "0.1.0"
[dependencies]
rocket = { version="0.3.5", features = ["tls"] }
rocket_codegen = "0.3.5"
rocket_contrib = "0.3.5"
diesel = { version = "0.16.0", features = ["sqlite"] }
diesel_codegen = { version = "0.16.0", features = ["sqlite"] }
dotenv = "0.10.1"
serde = "1.0.21"
serde_json = "1.0.6"
serde_derive = "1.0.21"
lazy_static = "0.2.11"
r2d2 = "0.7.4"
r2d2-diesel = "0.16.0"
我们还将运行 Rocket 在公共接口上。为此,我们将在存储库的根目录中放置一个名为 Rocket.toml 的配置文件。这是它的样子;其他所有设置都保留为默认值:
$ cat Rocket.toml
[global]
address = "0.0.0.0"
和之前一样,我们可以使用 Cargo 运行我们的服务器:
$ DATABASE_URL=./db.sql cargo run
Let'sencrypt 还要求所有服务器都必须有一个域名。因此,我们需要在我们的 DNS 提供商中为我们的服务器创建一个记录。在我们的例子中,该 DNS 名称是 my.domain.io,我们将在后续步骤中使用它。一旦该记录在所有地方都传播开来,我们就可以继续到下一步:生成证书。以下是我们将如何使用 CLI 来完成这项工作:
$ acme-client -vvvvv sign --dns -D my.domain.io -P /var/www -o domain.crt
INFO:acme_client: Registering account
DEBUG:acme_client: User successfully registered
INFO:acme_client: Sending identifier authorization request for foo.datasine.com
Please create a TXT record for _acme-challenge.my.domain.io: MFfatN9I3UFQk2WP_f1uRWi4rLnr4qMVaI
Press enter to continue
INFO:acme_client: Triggering dns-01 validation
DEBUG:acme_client: Status is pending, trying again...
INFO:acme_client: Signing certificate
DEBUG:acme_client: Certificate successfully signed
由于我们无法提供挑战,因此在这里我们必须使用基于 DNS 的验证。CLI 要求我们创建一个具有给定名称和内容的 TXT 记录。一旦我们创建了记录,我们就需要等待一段时间,以便在继续之前它能够传播。在继续之前,使用 dig 检查记录是否已更新是个好主意。以下是 dig 输出的样子:
$ dig _acme-challenge.my.domain.io TXT
; <<>> DiG 9.9.7-P3 <<>> _acme-challenge.my.domain.io TXT
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 49153
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 8192
;; QUESTION SECTION:
;_acme-challenge.my.domain.io. IN TXT
;; ANSWER SECTION:
_acme-challenge.my.domain.io. 60 IN TXT "MFfatN9I3UFQk2WP_f1uRWi4rLnr4qMVaI"
;; Query time: 213 msec
;; SERVER: 192.168.0.1#53(192.168.0.1)
;; WHEN: Fri Jan 05 18:04:01 GMT 2018
;; MSG SIZE rcvd: 117
当 dig 的输出表明我们拥有正确的 TXT 记录时,我们可以继续安装证书。letsencrypt 将查询 TXT 记录并运行域名验证。当一切看起来都正常时,我们将获得一个名为 domain.crt 的新证书。让我们检查证书以确保一切正常。主题应该匹配我们的域名,而发行者始终应该是 Let's Encrypt,如下面的代码片段所示:
$ openssl x509 -in domain.crt -subject -issuer -noout
subject= /CN=my.domain.io
issuer= /C=US/O=Let's Encrypt/CN=Let's Encrypt Authority X3
现在,我们已经准备好在我们的 Rocket 应用程序中使用这个证书了。我们需要将其放置在一个运行中的 Rocket 有权限读取的位置。现在,如果我们重启 Rocket 并使用 curl 通过 HTTPS 访问端点,它应该和上次一样工作:
$ curl -sS -D - https://my.domain.io:8000/posts
HTTP/1.1 200 OK
Content-Type: application/json
Server: Rocket
Content-Length: 992
Date: Fri, 05 Jan 2018 18:37:58 GMT
[{"id":1,"title":"test","body":"test body","pinned":true},{"id":2,"title":"Hello Rust!","body":"Rust is awesome!!","pinned":true},{"id":3,"title":"Testing this","body":"Try to write something","pinned":true},{"id":4,"title":"Testing this","body":"Try to write something","pinned":true},{"id":5,"title":"Testing this","body":"Try to write something","pinned":true},{"id":6,"title":"Testing this","body":"Try to write something","pinned":true},{"id":7,"title":"Testing this","body":"Try to write something","pinned":true},{"id":8,"title":"Testing this","body":"Try to write something","pinned":true},{"id":9,"title":"Testing this","body":"Try to write something","pinned":true},{"id":10,"title":"Testing this","body":"Try to write something","pinned":true},{"id":11,"title":"Testing this","body":"Try to write something","pinned":true},{"id":12,"title":"Testing this","body":"Try to write something","pinned":true},{"id":13,"title":"Testing this","body":"Try to write something","pinned":true}]
到编写本文时为止,Let's Encrypt 对每个域名每周有五个证书的限制。对于测试,可能很快就会达到这个限制。如果发生这种情况,客户端将只显示“Acme 服务器错误”。
让我们在这里为我们的服务器编写一个简单的客户端,使用 rustls。我们使用 Cargo 设置项目:
$ cargo new --bin rustls-client
然后,我们将 rustls 添加到我们的项目中作为依赖项:
[package]
name = "rustls-client"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
rustls = "0.12.0"
webpki = "0.18.0-alpha"
webpki-roots = "0.14.0"
客户端看起来是这样的;请注意,webpki 包执行 DNS 解析,然后由 rustls 使用:
// ch8/rustls-client/src/main.rs
use std::sync::Arc;
use std::net::TcpStream;
use std::io::{Read, Write};
extern crate rustls;
extern crate webpki;
extern crate webpki_roots;
fn main() {
let mut tls = rustls::ClientConfig::new();
tls.root_store.add_server_trust_anchors
(&webpki_roots::TLS_SERVER_ROOTS);
let name = webpki::DNSNameRef::try_from_ascii_str("my.domain.io")
.expect("Could not resolve name");
let mut sess = rustls::ClientSession::new(&Arc::new(tls), name);
let mut conn = TcpStream::connect("my.domain.io:8000").unwrap();
let mut stream = rustls::Stream::new(&mut sess, &mut conn);
stream.write(concat!("GET /posts HTTP/1.1\r\n",
"Connection: close\r\n",
"\r\n")
.as_bytes())
.expect("Could not write request");
let mut plaintext = Vec::new();
stream.read_to_end(&mut plaintext).expect("Could not read");
println!("{}", String::from_utf8(plaintext)
.expect("Could not print output"));
}
在这里,我们正在连接到我们的服务器并运行相同的 HTTP 请求。我们导入所有三个必需的包。第一步是初始化一个 TLS 会话并向其中添加根证书。然后,我们将给定的服务器解析为一个 DNS 名称引用,并与之建立 TLS 会话。设置好这些后,我们可以通过使用 connect 来设置一个 TCP 会话。最后,rustls 的 Stream 是 SSL 会话和 TCP 会话的组合。一旦所有这些都正常工作,我们就可以手动编写 HTTP 查询。这里我们在 /posts 端点运行一个 GET。稍后,我们读取响应并将其打印出来。输出应该和使用其他客户端一样:
HTTP/1.1 200 OK
Content-Type: application/json
Server: Rocket
Content-Length: 992
Date: Fri, 05 Jan 2018 18:37:58 GMT
[{"id":1,"title":"test","body":"test body","pinned":true},{"id":2,"title":"Hello Rust!","body":"Rust is awesome!!","pinned":true},{"id":3,"title":"Testing this","body":"Try to write something","pinned":true},{"id":4,"title":"Testing this","body":"Try to write something","pinned":true},{"id":5,"title":"Testing this","body":"Try to write something","pinned":true},{"id":6,"title":"Testing this","body":"Try to write something","pinned":true},{"id":7,"title":"Testing this","body":"Try to write something","pinned":true},{"id":8,"title":"Testing this","body":"Try to write something","pinned":true},{"id":9,"title":"Testing this","body":"Try to write something","pinned":true},{"id":10,"title":"Testing this","body":"Try to write something","pinned":true},{"id":11,"title":"Testing this","body":"Try to write something","pinned":true},{"id":12,"title":"Testing this","body":"Try to write something","pinned":true},{"id":13,"title":"Testing this","body":"Try to write something","pinned":true}]
使用 Rust 的 OpenSSL
OpenSSL 库和 CLI 是一套完整的工具,用于处理 SSL(和 TLS)对象。它是一个开源项目,并被全球各地的公司广泛使用。正如预期的那样,Rust 为在 Rust 项目中作为库使用提供了绑定。在本讨论中,我们将更详细地查看上一节中看到的证书。
这些通常由一个称为 X.509 的标准(在 RFC 5280 中定义)定义,并具有以下字段:
-
版本号:几乎总是设置为 2,对应于版本 3(因为第一个版本是 0)。根据标准,此字段可以省略,并应假定版本为 1(值设置为 0)。
-
序列号:一个 20 字节标识符,对于签发此证书的 CA 是唯一的。
-
签名:一个唯一 ID,用于标识用于签发此证书的算法。这通常是在后续 RFC 中定义的字符串,例如
sha1WithRSAEncryption。 -
发行者名称:标识签发证书的 CA。这必须至少包含一个区分名称(DN),由多个组件组成,包括通用名称(CN)、州(ST)、国家(C)等。
-
有效期:定义证书何时有效。它有两个子字段;
notBefore表示何时开始有效,notAfter表示何时过期。 -
主题名称:标识此证书所证明的实体。对于根证书,这将与发行者相同。它具有与发行者名称相同的格式,并且至少应包含一个 DN。
-
主题公钥信息:关于主题加密公钥的信息。它有两个子字段;第一个是一个加密算法的 ID(如签名字段中所示),第二个是一个包含加密公钥的位流。
-
发行者唯一标识符:一个可选字段,可用于唯一标识发行者。
-
主题唯一标识符:一个可选字段,可用于标识主题。
-
扩展:此字段仅在版本设置为 3 时适用。表示可以用于将附加信息附加到证书的多个可选字段。
-
证书签名算法:用于签发此证书的算法;必须与之前签名属性中使用的算法相同。
-
证书签名值:一个实际签名的位字符串,用于验证。
在以下示例中,我们将使用 rust-openssl 从头开始生成和检查证书。然而,安装这个库可能有点复杂。因为它围绕 libopenssl 包装,所以需要链接到本地库。因此,必须安装这个库。该软件包的文档提供了设置此环境的说明。项目设置是常规的:
$ cargo new --bin openssl-example
这里是Cargo.toml文件:
[package]
name = "openssl-example"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
openssl = { git = "https://github.com/sfackler/rust-openssl" }
此外,对于这个示例,我们将使用存储库的 master 分支,因为我们需要一些尚未发布的特性。为此,我们需要指定存储库链接,如前所述。以下是主文件:
// ch8/openssl-example/src/main.rs
extern crate openssl;
use std::env;
use std::fs::File;
use std::io::Write;
use openssl::x509::{X509, X509Name};
use openssl::nid::Nid;
use openssl::pkey::{PKey, Private};
use openssl::rsa::Rsa;
use openssl::error::ErrorStack;
use openssl::asn1::Asn1Time;
use openssl::bn::{BigNum, MsbOption};
use openssl::hash::MessageDigest;
fn create_cert() -> Result<(X509, PKey<Private>), ErrorStack> {
let mut cert_builder = X509::builder()?;
cert_builder.set_version(2)?;
let serial_number = {
let mut serial = BigNum::new()?;
serial.rand(160, MsbOption::MAYBE_ZERO, false)?;
serial.to_asn1_integer()?
};
cert_builder.set_serial_number(&serial_number)?;
let mut name = X509Name::builder()?;
name.append_entry_by_text("C", "UK")?;
name.append_entry_by_text("CN", "Our common name")?;
let cert_name = name.build();
cert_builder.set_issuer_name(&cert_name)?;
let not_before = Asn1Time::days_from_now(0)?;
cert_builder.set_not_before(¬_before)?;
let not_after = Asn1Time::days_from_now(365)?;
cert_builder.set_not_after(¬_after)?;
cert_builder.set_subject_name(&cert_name)?;
let private_key = PKey::from_rsa(Rsa::generate(3072)?)?;
cert_builder.set_pubkey(&private_key)?;
cert_builder.sign(&private_key, MessageDigest::sha512())?;
let cert = cert_builder.build();
Ok((cert, private_key))
}
fn main() {
if let Some(arg) = env::args().nth(1) {
let (cert, _key) = create_cert().expect("could not create
cert");
let cert_data = cert.to_pem().expect("could not convert cert
to pem");
let mut cert_file = File::create(arg)
.expect("could not create cert file");
cert_file
.write_all(&cert_data)
.expect("failed to write cert");
let subject = cert.subject_name();
let cn = subject
.entries_by_nid(Nid::COMMONNAME)
.next()
.expect("failed to get subject");
println!("{}",String::from_utf8(cn.data()
.as_slice().to_vec()).unwrap()
);
} else {
eprintln!("Expected at least one argument");
std::process::exit(1);
}
}
在这里,我们接受一个命令行参数作为文件名,以将证书写入。证书创建被委托给一个名为create_cert的辅助函数,该函数返回一个包含生成的证书和私钥的元组,或者作为一个Result的错误列表。
第一步是初始化一个证书构建器对象,我们将添加到它上面,并最终构建我们的证书。我们使用set_version方法将版本设置为 3(数值设置为2)。现在我们需要生成一个序列号并设置它。我们通过随机采样 160 位(每个 8 位 20 个八位字节)来生成它。我们使用set_serial_number方法来设置序列号。下一步是使用名称构建器生成一个名称。然后使用append_entry_by_text方法将国家名称和通用名称添加到我们的名称构建器中。我们使用set_issuer_name将name对象附加到证书上。我们将到期日期设置为当前日期后的 365 天,并使用set_not_before和set_not_after来设置这两个日期。使用set_subject_name将主题名称设置为相同的名称对象。最后,我们需要生成一个私钥,我们使用Rsa模块生成它,并将私钥设置在证书上。现在,证书需要使用 SHA512 进行签名。完成后,我们可以使用build方法创建证书。函数结束时,我们将证书和私钥返回给调用者。
在我们的main函数中,我们调用helper函数。对于我们的示例,我们将忽略密钥,但实际应用确实需要保存它以供稍后验证。我们将certificate对象转换为 PEM 编码,并将其写入磁盘上的文件。下一步是程序化地读取主题名称。为此,我们使用certificate对象上的subject_name方法,并将其作为字符串打印出来。这应该与之前设置的名称相匹配。
这是使用 Cargo 运行此代码的方法。请注意,这将在当前目录中创建一个名为bar.crt的证书:
$ cargo run bar.crt
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/openssl-example bar.crt`
Our common name
$ ls -la bar.crt
-rw-r--r--+ 1 Abhishek staff 1399 19 Feb 22:02 bar.crt
保护 Tokio 应用程序
在实现基于 Tokio 的协议时,一个常见问题是如何确保其安全性。幸运的是,Tokio 生态系统提供了tokio-tls来解决这个问题。让我们看看如何使用这个工具来保护我们之前章节中的超示例。以下是我们的设置:
$ cargo new --bin tokio-tls-example
Cargo 清单应该看起来像这样:
[package]
name = "tokio-tls-example"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
hyper = "0.11.7"
futures = "0.1.17"
net2 = "0.2.31"
tokio-core = "0.1.10"
num_cpus = "1.0"
native-tls = "*"
tokio-service = "*"
tokio-proto = "*"
tokio-tls = { version = "0.1", features = ["tokio-proto"] }
我们需要使用tokio-proto功能在tokio-tls上启用与tokio-proto的集成。下一步是为我们的服务器生成一个自签名证书。tokio-tls在底层使用native-tls库,而该库在撰写本文时不支持从 X509 证书构建接受者。因此,我们需要使用 PKCS12 证书。前面显示的命令生成一个有效期为 365 天的自签名证书,格式为 PEM。这将要求输入证书的密码短语。在我们的例子中,我们使用了foobar。请确保在tokio-tls-example目录中运行此命令,以便我们的代码可以读取证书:
$ openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365
以下命令将给定的证书转换为 PKCS12 格式。这将在当前目录中生成一个名为cert.pfx的文件,我们将在代码中使用它:
$ openssl pkcs12 -export -out cert.pfx -inkey key.pem -in cert.pem
这里是我们的主要文件,为了启用 SSL 进行了一些修改:
// ch8/tokio-tls-example/src/main.rs
extern crate futures;
extern crate hyper;
extern crate native_tls;
extern crate tokio_proto;
extern crate tokio_service;
extern crate tokio_tls;
use std::io;
use std::{thread, time};
use futures::future::{ok, Future};
use hyper::server::Http;
use hyper::header::ContentLength;
use hyper::{Request, Response, StatusCode};
use native_tls::{Pkcs12, TlsAcceptor};
use tokio_proto::TcpServer;
use tokio_service::Service;
use tokio_tls::proto;
fn heavy_work() -> String {
let duration = time::Duration::from_millis(100);
thread::sleep(duration);
"done".to_string()
}
struct SlowMo;
impl Service for SlowMo {
type Request = Request;
type Response = Response;
type Error = io::Error;
type Future = Box<Future<Item = Response, Error = io::Error>>;
fn call(&self, req: Request) -> Self::Future {
let b = heavy_work().into_bytes();
println!("Request: {:?}", req);
Box::new(ok(Response::new()
.with_status(StatusCode::Ok)
.with_header(ContentLength(b.len() as u64))
.with_body(b)))
}
}
fn main() {
let raw_cert = include_bytes!("../cert.pfx");
let cert = Pkcs12::from_der(raw_cert, "foobar").unwrap();
let acceptor = TlsAcceptor::builder(cert).unwrap().build().unwrap();
let proto = proto::Server::new(Http::new(), acceptor);
let addr = "0.0.0.0:9999".parse().unwrap();
let srv = TcpServer::new(proto, addr);
println!("Listening on {}", addr);
srv.serve(|| Ok(SlowMo));
}
这里主要的改变是在主函数中,我们使用include_bytes宏以原始字节读取证书。然后我们使用from_der通过传递证书字节和我们在创建证书时使用的密码短语来构造一个Pkcs12对象。下一步是使用给定的Pkcs12证书对象创建一个TlsAcceptor对象。然后我们需要将acceptor对象和hyper 协议对象包装到一个Server中。这被传递给TcpServer构造函数,然后我们启动它。
这是客户端视角下的会话看起来像什么:
$ curl -k https://localhost:9999
done
这里是服务器打印的内容;这源自调用函数中的println!宏:
$ cargo run
Compiling rustls-example v0.1.0 (file:///src/ch8/rustls-example)
Finished dev [unoptimized + debuginfo] target(s) in 3.14 secs
Running `target/debug/rustls-example`
Listening on 0.0.0.0:9999
Request: Request { method: Get, uri: "/", version: Http11, remote_addr: None, headers: {"Host": "localhost:9999", "User-Agent": "curl/7.57.0", "Accept": "*/*"} }
^C
有趣的是,openssl命令行工具有一个 TCP 客户端,可以用来测试 SSL 连接。以下是使用它来测试我们的服务器的方法:
$ openssl s_client -connect 127.0.0.1:9999
CONNECTED(00000003)
depth=0 C = UK, ST = Scotland, O = Internet Widgits Pty Ltd
verify error:num=18:self signed certificate
verify return:1
depth=0 C = UK, ST = Scotland, O = Internet Widgits Pty Ltd
verify return:1
---
Certificate chain
0 s:/C=UK/ST=Scotland/O=Internet Widgits Pty Ltd
i:/C=UK/ST=Scotland/O=Internet Widgits Pty Ltd
---
Server certificate
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
subject=/C=UK/ST=Scotland/O=Internet Widgits Pty Ltd
issuer=/C=UK/ST=Scotland/O=Internet Widgits Pty Ltd
---
No client certificate CA names sent
Peer signing digest: SHA256
Server Temp Key: ECDH, P-256, 256 bits
---
SSL handshake has read 2128 bytes and written 433 bytes
---
New, TLSv1/SSLv3, Cipher is ECDHE-RSA-AES256-GCM-SHA384
Server public key is 4096 bit
Secure Renegotiation IS supported
Compression: NONE
Expansion: NONE
No ALPN negotiated
SSL-Session:
Protocol : TLSv1.2
Cipher : ECDHE-RSA-AES256-GCM-SHA384
Session-ID: 9FAB89D29DB02891EF52C825AC23E3A658FDCE228A1A7E4FD97652AC3A5E24F3
Session-ID-ctx:
Master-Key: 940EF0C4FA1A929133C2D273739C8042FAF1BD5057E793ED1D7A0F0187F0236EF9E43D236DF8C17D663D7B77F1B4CEDD
Key-Arg : None
PSK identity: None
PSK identity hint: None
SRP username: None
Start Time: 1515350045
Timeout : 300 (sec)
Verify return code: 18 (self signed certificate)
---
GET / HTTP/1.1
host: foobar
HTTP/1.1 200 OK
Content-Length: 4
Date: Sun, 07 Jan 2018 18:34:52 GMT
done
此工具协商 SSL 会话,并像之前显示的那样输出服务器证书(为了简洁,我们已替换了实际的证书)。注意,它正确地检测到给定的服务器正在使用自签名证书。最后,它启动一个 TCP 会话。由于这是裸 TCP,我们需要手动构建我们的 HTTP 请求。如果我们对/使用简单的GET请求,我们会收到一个200 OK的响应,字符串就完成了。在服务器那一侧,这是它打印的内容:
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/tokio-tls-example`
Listening on 0.0.0.0:9999
Request: Request { method: Get, uri: "/", version: Http11, remote_addr: None, headers: {"Host": "foobar"} }
注意,这里打印了Host头,设置为字符串foobar,正如我们在客户端所写的那样。
使用 ring 的加密
一个常用的加密库叫做ring。这个库支持许多底层加密原语,如随机数生成、密钥交换等。在本节中,我们将以密钥交换为例,看看这个库如何在客户端-服务器应用程序中使用。
通信中一个常见的问题是加密信息,以便第三方无法解密它。在私钥系统中,客户端和服务器都需要就一个用于此目的的密钥达成一致。现在,这个密钥不能在不可信的连接上以明文形式传输。Diffie-Hellman 密钥交换方法定义了一种机制,其中两个通过安全链路交谈的当事人可以协商一个双方共享的密钥,但这个密钥没有通过连接传输。这种方法在许多平台上都有多种实现,包括所讨论的 crate。
以下图表显示了该协议的工作方式:

Diffie-Hellman 密钥交换在实际操作中
初始时,服务器将监听传入的客户端。当客户端连接时,这是事件序列:
-
TCP 会话首先被建立。
-
服务器和客户端都会生成私钥和公钥。
-
客户端然后将它的公钥发送给服务器。
-
服务器通过发送它生成的公钥进行响应。
-
在这一点上,双方可以使用他们的私钥和收到的公钥生成共享的秘密密钥。
-
进一步的通信可以使用共享的秘密密钥进行加密。
我们将为这个示例创建一个空的库项目。然后我们将创建一个示例目录并将稍后显示的两个文件放在那里。项目设置将如下进行:
$ cargo new key-exchange
此外,这是Cargo.toml应该看起来像的样子:
[package]
name = "key-exchange"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
ring = "0.12.1"
untrusted = "0.5.1"
让我们先看看客户端。这主要借鉴了我们在第二章中编写的简单 TCP 服务器。这是完全同步和阻塞的:
// ch8/key-exchange/examples/client.rs
extern crate ring;
extern crate untrusted;
use std::net::TcpStream;
use std::io::{BufRead, BufReader, Write};
use ring::{agreement, rand};
use untrusted::Input;
fn main() {
let mut stream = TcpStream::connect("127.0.0.1:8888")
.expect("Could not connect to server");
let rng = rand::SystemRandom::new();
// Generate the client's private key
let client_private_key =
agreement::EphemeralPrivateKey::generate(&agreement::X25519, &rng)
.expect("Failed to generate key");
let mut client_public_key = [0u8; agreement::PUBLIC_KEY_MAX_LEN];
let client_public_key = &mut
client_public_key[..client_private_key.public_key_len()];
// Generate the client's public key
client_private_key.compute_public_key
(client_public_key).expect("Failed to
generate key");
// Send client's public key to server
stream.write(client_public_key)
.expect("Failed to write to server");
let mut buffer: Vec<u8> = Vec::new();
let mut reader = BufReader::new(&stream);
// Read server's public key
reader.read_until(b'\n', &mut buffer)
.expect("Could not read into buffer");
let peer_public_key = Input::from(&buffer);
println!("Received: {:?}", peer_public_key);
// Generate shared secret key
let res = agreement::agree_ephemeral
(client_private_key, &agreement::X25519,
peer_public_key,
ring::error::Unspecified,
|key_material| {
let mut key = Vec::new();
key.extend_from_slice(key_material);
Ok(key)
});
println!("{:?}", res.unwrap());
}
我们当然需要ringcrate 作为外部依赖。另一个 crate,称为untrusted,是用于从不受信任的源数据到ring作为输入的辅助工具。然后我们初始化 ring 的随机数生成器;这将用于稍后生成密钥。然后我们使用generate方法为客户生成一个私钥。客户端的公钥是基于私钥使用compute_public_key生成的。在这个时候,客户端准备好将公钥发送给服务器。它将密钥作为字节流写入之前创建的连接。理想情况下,服务器应该在这个时候发送其公钥,客户端需要从相同的连接中读取。这是通过read_until调用完成的,该调用将接收到的数据放入缓冲区。然后,传入的数据通过untrustedcrate 传递,以便ring可以消费它。最后,客户端使用agree_ephemeral生成密钥,该函数接受收集到的两个密钥(客户端的私钥和服务器公钥)、一个错误值和一个用于生成的字节流的闭包。在闭包中,我们收集所有数据到一个向量中并返回它。最后一步是打印那个向量。
服务器类似,如下代码片段所示:
// ch8/key-exchange/src/examples/server.rs
extern crate ring;
extern crate untrusted;
use std::net::{TcpListener, TcpStream};
use std::thread;
use std::io::{Read, Write};
use ring::{agreement, rand};
use untrusted::Input;
use ring::error::Unspecified;
fn handle_client(mut stream: TcpStream) -> Result<(), Unspecified> {
let rng = rand::SystemRandom::new();
// Generate server's private key
let server_private_key =
agreement::EphemeralPrivateKey::generate
(&agreement::X25519, &rng)?;
let mut server_public_key = [0u8; agreement::PUBLIC_KEY_MAX_LEN];
let server_public_key = &mut
server_public_key[..server_private_key.public_key_len()];
// Generate server's public key
server_private_key.compute_public_key(server_public_key)?;
let mut peer_public_key_buf = [0u8; 32];
// Read client's public key
stream.read(&mut peer_public_key_buf).expect("Failed to read");
let peer_public_key = Input::from(&peer_public_key_buf);
println!("Received: {:?}", peer_public_key);
// Send server's public key
stream.write(&server_public_key)
.expect("Failed to send server public key");
// Generate shared secret key
let res = agreement::agree_ephemeral(server_private_key,
&agreement::X25519,
peer_public_key,
ring::error::Unspecified,
|key_material| {
let mut key = Vec::new();
key.extend_from_slice(key_material);
Ok(key)
});
println!("{:?}", res.unwrap());
Ok(())
}
fn main() {
let listener = TcpListener::bind("0.0.0.0:8888").expect("Could not bind");
for stream in listener.incoming() {
match stream {
Err(e) => { eprintln!("failed: {}", e) }
Ok(stream) => {
thread::spawn(move || {
handle_client(stream)
.unwrap_or_else(|error| eprintln!(
"{:?}", error));
});
}
}
}
}
就像上次一样,我们为每个客户端处理一个新的线程。handle_client 函数的语义与客户端相似;我们首先生成一个私钥和一个公钥。下一步是读取客户端发送的公钥,然后发送服务器的公钥。一旦确定,我们就可以使用 agree_ephemeral 生成共享密钥,这个密钥应该与客户端生成的密钥相匹配。
下面是在服务器端的一个示例运行:
$ cargo run --example server
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/examples/server`
Received: Input { value: Slice { bytes: [60, 110, 82, 192, 131, 173, 255, 92, 134, 0, 185, 186, 87, 178, 51, 71, 136, 201, 15, 179, 204, 137, 125, 32, 87, 94, 227, 209, 47, 243, 75, 73] } }
Generated: [184, 9, 123, 15, 139, 191, 170, 9, 133, 143, 81, 45, 254, 15, 234, 12, 223, 57, 131, 145, 127, 231, 93, 101, 92, 251, 163, 179, 219, 24, 81, 111]
并且这里有一个客户端的例子:
$ cargo run --example client
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/examples/client`
Received: Input { value: Slice { bytes: [83, 44, 93, 28, 132, 238, 70, 152, 163, 73, 185, 146, 142, 5, 172, 255, 219, 52, 51, 151, 99, 134, 35, 98, 154, 192, 210, 137, 141, 167, 60, 67] } }
Generated: [184, 9, 123, 15, 139, 191, 170, 9, 133, 143, 81, 45, 254, 15, 234, 12, 223, 57, 131, 145, 127, 231, 93, 101, 92, 251, 163, 179, 219, 24, 81, 111]
注意,生成的密钥对于服务器和客户端都是相同的,这正是我们所期望的。
在撰写本文时,ring crate 在最新的 nightly 版本上无法工作。为了使其工作,请在项目目录中运行以下命令:
**$ rustup component add rustfmt-preview --toolchain nightly-2017-12-21**
**$ rustup override set nightly-2017-12-21**
摘要
在本章中,我们快速浏览了在公共网络上确保通信的安全性。我们从证书的概述开始,了解了它们是如何用于在网络上识别服务器的。我们探讨了在 Rust 中使用 letsencrypt 和 openssl。然后,我们继续使用相同的技术来确保 Tokio 应用程序的安全性。最后,我们简要地看了一下使用 DH 方法进行密钥交换。
以下部分是附录;在那里,我们将探讨一些在社区中变得越来越流行的额外 crate 和技术。
附录
Rust 是一个开源项目,拥有来自世界各地的众多贡献者。与任何此类项目一样,通常存在多种解决同一问题的方法。crate 生态系统使得这变得更容易,因为人们可以发布多个 crate,提出解决同一组问题的多种方法。这种方法在真正的开源精神中培养了健康的竞争感。在本书中,我们已经涵盖了生态系统中的许多不同主题。本章将是对这些主题的补充。我们将讨论:
-
基于协程和生成器的并发方法
-
async/await 抽象
-
数据并行
-
使用 Pest 进行解析
-
生态系统中的各种实用工具
协程和生成器简介
我们之前探讨了 Tokio 生态系统。我们看到了在 Tokio 中链式连接 future 是非常常见的,这产生了一个更大的任务,然后可以按需调度。在实践中,以下通常看起来像伪代码:
fn download_parse(url: &str) {
let task = download(url)
.and_then(|data| parse_html(data))
.and_then(|link| process_link(link))
.map_err(|e| OurErr(e));
Core.run(task);
}
在这里,我们的函数接收一个 URL 并递归地下载原始 HTML。然后它解析并收集文档中的链接。我们的任务在事件循环中运行。由于所有回调及其交互,这里的控制流可能更难以跟踪。随着任务规模增大和条件分支增多,这变得更加复杂。
协程的概念帮助我们更好地理解这些例子中的非线性。协程(也称为生成器)是函数的一种推广,它可以随意挂起或恢复自身,并将控制权交给另一个实体。由于它们不需要被超实体抢占,因此通常更容易处理。请注意,我们始终假设一个协作模型,其中协程在等待计算完成或 I/O 时会挂起,并且我们忽略了协程可能以其他方式恶意行为的案例。当协程再次开始执行时,它会从上次离开的地方继续,提供一种连续性。它们通常有多个入口和出口点。
在设置好之后,一个子程序(函数)就变成了协程的一个特殊情况,它有且只有一个入口和出口点,并且不能被外部挂起。计算机科学文献也区分了生成器和协程,认为前者在挂起后无法控制执行继续的位置,而后者可以。然而,在我们当前的环境中,我们将互换使用生成器和协程。
协程可以分为两大类:无栈协程和有栈协程。无栈协程在挂起时不会维护栈。因此,它们不能在任意位置恢复。相反,有栈协程始终维护一个小栈。这使得它们可以从执行中的任意点挂起和恢复。它们在挂起时总是保留完整的状态。因此,从调用者的角度来看,它们的行为就像任何可以独立于当前执行运行的常规函数。在实践中,有栈协程通常更占用资源,但更容易与调用者一起工作。请注意,与线程和进程相比,所有协程的资源消耗都较小。
在最近过去,Rust 在标准库中实验了一种生成器实现。这些位于 std::ops 中,并且像所有新功能一样,这背后有多个功能门:generators 和 generator_trait。这个实现有几个部分。首先,有一个新的 yield 关键字用于从生成器中 yield。生成器通过重载闭包语法来定义。其次,有一些项目如下定义:
pub trait Generator {
type Yield;
type Return;
fn resume(&mut self) -> GeneratorState<Self::Yield, Self::Return>;
}
由于这些是生成器(而不是协程),它们只能有一个 yield 和一个 return。
Generator 特性有两种类型:一种用于 yield 的情况,另一种用于 return 的情况。resume 函数从最后一个点恢复执行。resume 的返回值是 GeneratorState,这是一个枚举类型,看起来如下:
pub enum GeneratorState<Y, R> {
Yielded(Y),
Complete(R)
}
有两个变体;Yielded 代表 yield 语句的变体,而 Complete 代表 return 语句的变体。此外,Yielded 变体表示生成器可以从最后一个 yield 语句继续执行。Complete 表示生成器已经完成执行。
让我们看看一个使用这个生成 Collatz 序列的例子:
// appendix/collatz-generator/src/main.rs
#![feature(generators, generator_trait)]
use std::ops::{Generator, GeneratorState};
use std::env;
fn main() {
let input = env::args()
.nth(1)
.expect("Please provide only one argument")
.parse::<u64>()
.expect("Could not convert input to integer");
// The given expression will evaluate to our generator
let mut generator = || {
let end = 1u64;
let mut current: u64 = input;
while current != end {
yield current;
if current % 2 == 0 {
current /= 2;
} else {
current = 3 * current + 1;
}
}
return end;
};
loop {
// The resume call can have two results. If we have an
// yielded value, we print it. If we have a completed
// value, we print it and break from the loop (this is
// the return case)
match generator.resume() {
GeneratorState::Yielded(el) => println!("{}", el),
GeneratorState::Complete(el) => {
println!("{}", el);
break;
}
}
}
}
由于这些在标准库中,我们不需要外部依赖。我们从功能门和导入开始。在我们的主函数中,我们使用闭包语法定义一个生成器。注意它如何从封装作用域捕获名为 input 的变量。我们不是返回序列的当前位置,而是 yield 它。当我们完成时,我们从生成器返回。现在我们需要在生成器上调用 resume 来实际运行它。我们在一个无限循环中这样做,因为我们事先不知道需要迭代多少次。在这个循环中,我们对 resume 调用进行 match;在两个分支中,我们打印出我们拥有的值。此外,在 Complete 分支中,我们需要从循环中 break 出来。
注意,我们在这里没有使用隐式返回语法;而是执行了显式的 return end;。这不是必需的,但在这个情况下这使得代码更容易阅读。
这就是它产生的结果:
$ cargo run 10
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/collatz-generator 10`
10
5
16
8
4
2
1
目前,生成器仅在 nightly Rust 中可用。它们的语法预计会随着时间的推移而改变,可能变化很大。
May 处理协程的方式
Rust 生态系统有几个协程实现,而核心团队正在努力进行语言内实现。在这些实现中,一个广泛使用的是名为May的实现,它是一个基于生成器的独立堆栈式协程库。May 力求足够用户友好,以便可以使用一个简单的宏异步调用函数,该宏接受函数作为参数。与 Go 编程语言的功能相匹配,这个宏被称为go!。让我们看看使用 May 的一个小例子。
我们将使用我们熟悉的老朋友,Collatz 序列,来举例;这将展示我们实现相同目标的多种方式。让我们从使用 Cargo 设置我们的项目开始:
[package]
name = "collatz-may"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
may = "0.2.0"
generator = "0.6"
以下为主要文件。这里有两个示例;一个使用生成器 crate 在 Collatz 序列中产生数字,充当协程。另一个是一个常规函数,它使用go!宏作为协程运行:
// appendix/collatz-may/src/main.rs
#![feature(conservative_impl_trait)]
#[macro_use]
extern crate generator;
#[macro_use]
extern crate may;
use std::env;
use generator::Gn;
// Returns a generator as an iterator
fn collatz_generator(start: u64) -> impl Iterator<Item = u64> {
Gn::new_scoped(move |mut s| {
let end = 1u64;
let mut current: u64 = start;
while current != end {
s.yield_(current);
if current % 2 == 0 {
current /= 2;
} else {
current = 3 * current + 1;
}
}
s.yield_(end);
done!();
})
}
// A regular function returning result in a vector
fn collatz(start: u64) -> Vec<u64> {
let end = 1u64;
let mut current: u64 = start;
let mut result = Vec::new();
while current != end {
result.push(current);
if current % 2 == 0 {
current /= 2;
} else {
current = 3 * current + 1;
}
}
result.push(end);
result
}
fn main() {
let input = env::args()
.nth(1)
.expect("Please provide only one argument")
.parse::<u64>()
.expect("Could not convert input to integer");
// Using the go! macro
go!(move || {
println!("{:?}", collatz(input));
}).join()
.unwrap();
// results is a generator expression that we can
// iterate over
let results = collatz_generator(input);
for result in results {
println!("{}", result);
}
}
让我们从collatz_generator函数开始,它接受一个起始输入并返回一个类型为u64的迭代器。为了能够指定这一点,我们需要激活conservative_impl_trait功能。我们使用生成器 crate 中的Gn::new_scoped创建一个作用域生成器。它接受一个闭包,实际上执行计算。我们使用yield_函数产生当前值,并使用done!宏表示计算结束。
我们的第二个示例是一个返回 Collatz 序列中数字向量的常规函数。它在向量中收集中间结果,并在序列达到1时最终返回它。在我们的主函数中,我们像往常一样解析和清理输入。然后我们使用go!宏异步地在协程中调用我们的非生成器函数。然而,collatz_generator返回一个迭代器,我们可以在循环中迭代它,同时打印出数字。
如预期的那样,输出看起来是这样的:
$ cargo run 10
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/collatz-may 10`
[10, 5, 16, 8, 4, 2, 1]
10
5
16
8
4
2
1
May 还包括一个异步网络堆栈实现(如 Tokio),在过去的几个月里,它已经聚集了一个由几个依赖 crate 组成的迷你生态系统。除了生成器和协程实现之外,还有一个基于这些的 HTTP 库,一个 RPC 库,以及一个支持基于 actor 编程的 crate。让我们看看一个使用 May 编写 hyper HTTP 的示例。以下是 Cargo 配置的样子:
[package]
name = "may-http"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
may_minihttp = { git = "https://github.com/Xudong-Huang/may_minihttp.git" }
may = "0.2.0"
num_cpus = "1.7.0"
在撰写本文时,may_minihttp尚未在crates.io上发布,因此我们需要使用仓库来构建。以下是主要文件:
// appendix/may-http/src/main.rs
extern crate may;
extern crate may_minihttp;
extern crate num_cpus;
use std::io;
use may_minihttp::{HttpServer, HttpService, Request, Response};
use std::{thread, time};
fn heavy_work() -> String {
let duration = time::Duration::from_millis(100);
thread::sleep(duration);
"done".to_string()
}
#[derive(Clone, Copy)]
struct Echo;
// Implementation of HttpService for our service struct Echo
// This implementation defines how content should be served
impl HttpService for Echo {
fn call(&self, req: Request) -> io::Result<Response> {
let mut resp = Response::new();
match (req.method(), req.path()) {
("GET", "/data") => {
let b = heavy_work();
resp.body(&b).status_code(200, "OK");
}
(&_, _) => {
resp.status_code(404, "Not found");
}
}
Ok(resp)
}
}
fn main() {
// Set the number of IO workers to the number of cores
may::config().set_io_workers(num_cpus::get());
let server = HttpServer(Echo).start("0.0.0.0:3000").unwrap();
server.join().unwrap();
}
这次,我们的代码比 Hyper 要短得多。这是因为 May 很好地抽象了很多东西,同时让我们拥有类似的功能集。就像之前的Service trait 一样,HttpService trait 定义了服务器。功能由call函数定义。在函数调用和响应构建方面有一些细微的差异。这里的另一个优点是它不暴露 future,并且与常规Result一起工作。可以说,这个模型更容易理解和遵循。在main函数中,我们将 I/O 工作者的数量设置为我们的核心数。然后我们在端口3000上启动服务器并等待其退出。根据 GitHub 页面上的某些粗略基准测试,基于 May 的 HTTP 服务器比 Tokio 实现略快。
运行服务器后,我们看到的是这个。在这个特定的运行中,它收到了两个GET请求:
$ cargo run
Compiling may-http v0.1.0 (file:///Users/Abhishek/Desktop/rust-book/src/appendix/may-http)
Finished dev [unoptimized + debuginfo] target(s) in 1.57 secs
Running `target/debug/may-http`
Incoming request <HTTP Request GET /data>
Incoming request <HTTP Request GET /data>
^C
我们客户端只是curl,并且我们看到每个请求都会打印出done。请注意,由于我们的服务器和客户端在同一台物理机器上,我们可以使用127.0.0.1作为服务器的地址。如果不是这种情况,应该使用实际的地址:
$ curl http://127.0.0.1:3000/data
done
等待 future
在上一节中,我们看到了由多个 future 组成的任务通常难以编写和调试。一个试图解决这个问题的方法是使用一个封装 future 和相关错误处理的 crate,从而产生更线性的代码流。这个 crate 被称为futures-await,并且正在积极开发中。
这个 crate 提供了处理 future 的两种主要机制:
-
可以应用于函数的
#[async]属性,将其标记为异步。这些函数必须返回它们的计算Result作为 future。 -
可以与异步函数一起使用的
await!宏,用于消耗 future,返回一个Result。
给定这些构造,我们之前的示例下载将看起来像这样:
#[async]
fn download(url: &str) -> io::Result<Data> {
...
}
#[async]
fn parse_html(data: &Data) -> io::Result<Links> {
...
}
#[async]
fn process_links(links: &Links) -> io::Result<bool> {
...
}
#[async]
fn download_parse(url: &str) -> io::Result<bool> {
let data = await!(download(url));
let links = await!(parse_html(data));
let result = await!(process_links(links));
Ok(result)
}
fn main() {
let task = download_parse("foo.bar");
Core.run(task).unwrap();
}
这可能比使用 future 的例子更容易阅读。内部,编译器将此转换为类似于我们之前的示例的代码。此外,由于每个步骤都返回一个Result,我们可以使用?运算符优雅地冒泡错误。最终的任务可以像往常一样在事件循环中运行。
让我们来看一个更具体的例子,使用这个 crate 重写我们的超服务器项目。在这种情况下,我们的 Cargo 设置看起来是这样的:
[package]
name = "hyper-async-await"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
hyper = "0.11.7"
futures = "0.1.17"
net2 = "0.2.31"
tokio-core = "0.1.10"
num_cpus = "1.0"
futures-await = "0.1.0"
下面是我们的代码,如下面的代码片段所示。请注意,我们没有使用像上次那样的 futures crate 中的类型。相反,我们使用了从 futures-await 重新导出的类型,这些类型是这些类型的封装版本:
// appendix/hyper-async-await/src/main.rs
#![feature(proc_macro, conservative_impl_trait, generators)]
extern crate futures_await as futures;
extern crate hyper;
extern crate net2;
extern crate tokio_core;
extern crate num_cpus;
use futures::prelude::*;
use net2::unix::UnixTcpBuilderExt;
use tokio_core::reactor::Core;
use tokio_core::net::TcpListener;
use std::{thread, time};
use std::net::SocketAddr;
use std::sync::Arc;
use hyper::{Get, StatusCode};
use hyper::header::ContentLength;
use hyper::server::{Http, Service, Request, Response};
use futures::future::FutureResult;
use std::io;
// Our blocking function that waits for a random
// amount of time and then returns a fixed string
fn heavy_work() -> String {
let duration = time::Duration::from_millis(100);
thread::sleep(duration);
"done".to_string()
}
#[derive(Clone, Copy)]
struct Echo;
// Service implementation for the Echo struct
impl Service for Echo {
type Request = Request;
type Response = Response;
type Error = hyper::Error;
type Future = FutureResult<Response, hyper::Error>;
fn call(&self, req: Request) -> Self::Future {
futures::future::ok(match (req.method(), req.path()) {
(&Get, "/data") => {
let b = heavy_work().into_bytes();
Response::new()
.with_header(ContentLength(b.len() as u64))
.with_body(b)
}
_ => Response::new().with_status(StatusCode::NotFound),
})
}
}
// Sets up everything and runs the event loop
fn serve(addr: &SocketAddr, protocol: &Http) {
let mut core = Core::new().unwrap();
let handle = core.handle();
let listener = net2::TcpBuilder::new_v4()
.unwrap()
.reuse_port(true)
.unwrap()
.bind(addr)
.unwrap()
.listen(128)
.unwrap();
let listener = TcpListener::from_listener
(listener, addr, &handle).unwrap();
let server = async_block! {
#[async]
for (socket, addr) in listener.incoming() {
protocol.bind_connection(&handle, socket, addr, Echo);
}
Ok::<(), io::Error>(())
};
core.run(server).unwrap();
}
fn start_server(num: usize, addr: &str) {
let addr = addr.parse().unwrap();
let protocol = Arc::new(Http::new());
{
for _ in 0..num - 1 {
let protocol = Arc::clone(&protocol);
thread::spawn(move || serve(&addr, &protocol));
}
}
serve(&addr, &protocol);
}
fn main() {
start_server(num_cpus::get(), "0.0.0.0:3000");
}
async_block!宏接受一个闭包并将其转换为async函数。因此,我们这里的服务器是一个async函数。我们还使用异步的for循环(一个标记为#[async]的for循环)异步地遍历连接流。其余的代码与上次完全相同。运行服务器很简单;我们将使用 Cargo:
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/hyper-server-faster`
在客户端,我们可以使用 curl:
$ curl http://127.0.0.1:3000/data
done$ curl http://127.0.0.1:3000/data
done$
在撰写本文时,运行此示例将产生关于使用 bind_connection 的警告。由于没有明确的弃用时间表,我们将暂时忽略此警告。
数据并行
数据并行是一种通过使数据成为中心实体来加速计算的方法。这与我们迄今为止看到的基于协程和线程的并行处理形成对比。在那些情况下,我们首先确定可以独立运行的任务。然后根据需要将可用数据分配给那些任务。这种方法通常被称为 任务并行。本节讨论的主题是数据并行。在这种情况下,我们需要找出哪些输入数据部分可以独立使用;然后可以将多个任务分配给各个部分。这也符合分而治之的方法,一个强有力的例子是 mergesort。
Rust 生态系统有一个名为 Rayon 的库,它提供了编写数据并行代码的简单 API。让我们看看使用 Rayon 对给定切片进行二分查找的简单示例。我们首先使用 cargo 设置我们的项目:
$ cargo new --bin rayon-search
让我们看看 Cargo 配置文件:
[package]
name = "rayon-search"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
rayon = "0.9.0"
在我们的代码中,我们实现了两种二分查找函数,两者都是递归的。原始实现称为 binary_search_recursive,并且不执行任何数据并行处理。另一种版本,称为 binary_search_rayon,并行计算两种情况。这两个函数都接受一个类型为 T 的切片,该切片实现了一些特质。它们还接受相同类型的元素。这些函数将在切片中查找指定的元素,如果存在则返回 true,否则返回 false。现在让我们看看代码:
// appendix/rayon-search/src/main.rs
extern crate rayon;
use std::fmt::Debug;
use rayon::scope;
// Parallel binary search, searches the two halves in parallel
fn binary_search_rayon<T: Ord + Send + Copy + Sync + Debug>(src: &mut [T], el: T) -> bool {
src.sort();
let mid = src.len() / 2;
let srcmid = src[mid];
if src.len() == 1 && src[0] != el {
return false;
}
if el == srcmid {
true
} else {
let mut left_result = false;
let mut right_result = false;
let (left, right) = src.split_at_mut(mid);
scope(|s| if el < srcmid {
s.spawn(|_| left_result = binary_search_rayon(left, el))
} else {
s.spawn(|_| right_result = binary_search_rayon(right, el))
});
left_result || right_result
}
}
// Non-parallel binary search, goes like this:
// 1\. Sort input
// 2\. Find middle point, return if middle point is target
// 3\. If not, recursively search left and right halves
fn binary_search_recursive<T: Ord + Send + Copy>(src: &mut [T], el: T) -> bool {
src.sort();
let mid = src.len() / 2;
let srcmid = src[mid];
if src.len() == 1 && src[0] != el {
return false;
}
if el == srcmid {
true
} else {
let (left, right) = src.split_at_mut(mid);
if el < srcmid {
binary_search_recursive(left, el)
} else {
binary_search_recursive(right, el)
}
}
}
fn main() {
let mut v = vec![100, 12, 121, 1, 23, 35];
println!("{}", binary_search_recursive(&mut v, 5));
println!("{}", binary_search_rayon(&mut v, 5));
println!("{}", binary_search_rayon(&mut v, 100));
}
在这两种情况下,首先要做的事情是对输入切片进行排序,因为二分查找需要排序的输入。binary_search_recursive 是直接的;我们计算中间点,如果那里的元素是我们想要的,我们返回 true。我们还包括一个检查切片中是否只剩下一个元素,并且如果那个元素是我们想要的。相应地返回 true 或 false。这个情况形成了我们递归的基础情况。然后我们可以检查我们想要的元素是否小于或大于当前的中点。根据这个检查,我们递归地搜索两边。
Rayon 的情况基本上相同,唯一的区别在于我们如何递归。我们使用 scope 并行生成两种情况并收集它们的结果。作用域接受一个闭包,并在这个命名作用域 s 中调用它。作用域还确保在退出之前每个任务都已完成。我们从每个半部分收集结果到两个变量中,最后,我们返回这些结果的逻辑或,因为我们关心的是在两个半部分中的任何一个找到元素。以下是一个示例运行情况:
$ cargo run
Compiling rayon-search v0.1.0 (file:///Users/Abhishek/Desktop/rust-book/src/appendix/rayon-search)
Finished dev [unoptimized + debuginfo] target(s) in 0.88 secs
Running `target/debug/rayon-search`
false
false
true
Rayon 还提供了一个并行迭代器,这是一个与标准库中的迭代器具有相同语义的迭代器,但元素可能以并行方式访问。这种结构在每种数据单元都可以完全独立处理,且每个处理任务之间没有任何同步的情况下非常有用。让我们看看如何使用这些迭代器的例子,从使用 Cargo 的项目设置开始:
$ cargo new --bin rayon-parallel
在这种情况下,我们将使用 Rayon 比较常规迭代器和并行迭代器的性能。为此,我们需要使用 rustc-test 包。以下是 Cargo 设置的看起来:
[package]
name = "rayon-parallel"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
rayon = "0.9.0"
rustc-test = "0.3.0"
下面是代码,如以下代码片段所示。我们有两个执行完全相同操作的函数。它们都接收一个整数向量,然后遍历该向量并过滤出偶数整数。最后,它们返回奇数整数的平方:
// appendix/rayon-parallel/src/main.rs
#![feature(test)]
extern crate rayon;
extern crate test;
use rayon::prelude::*;
// This function uses a parallel iterator to
// iterate over the input vector, filtering
// elements that are even and then squares the remaining
fn filter_parallel(src: Vec<u64>) -> Vec<u64> {
src.par_iter().filter(|x| *x % 2 != 0)
.map(|x| x * x)
.collect()
}
// This function does exactly the same operation
// but uses a regular, sequential iterator
fn filter_sequential(src: Vec<u64>) -> Vec<u64> {
src.iter().filter(|x| *x % 2 != 0)
.map(|x| x * x)
.collect()
}
fn main() {
let nums_one = (1..10).collect();
println!("{:?}", filter_sequential(nums_one));
let nums_two = (1..10).collect();
println!("{:?}", filter_parallel(nums_two));
}
#[cfg(test)]
mod tests {
use super::*;
use test::Bencher;
#[bench]
fn bench_filter_sequential(b: &mut Bencher) {
b.iter(|| filter_sequential((1..1000).collect::<Vec<u64>>()));
}
#[bench]
fn bench_filter_parallel(b: &mut Bencher) {
b.iter(|| filter_parallel((1..1000).collect::<Vec<u64>>()));
}
}
我们首先从 Rayon 导入所有内容。在 filter_parallel 中,我们使用 par_iter 来获取一个并行迭代器。filter_sequential 与之相同,唯一的区别在于它使用 iter 函数来获取一个常规迭代器。在我们的主函数中,我们创建两个序列并将它们传递给我们的函数,同时打印输出。以下是我们应该看到的内容:
$ cargo run
Compiling rayon-parallel v0.1.0 (file:///Users/Abhishek/Desktop/rust-book/src/appendix/rayon-parallel)
Finished dev [unoptimized + debuginfo] target(s) in 1.65 secs
Running `target/debug/rayon-parallel`
[1, 9, 25, 49, 81]
[1, 9, 25, 49, 81]
并不令人意外,两者返回相同的结果。这个例子中最重要的一部分是基准测试。为了使其工作,我们需要使用 #![feature(test)] 激活测试功能并声明一个新的测试模块。在那里,我们从顶层模块导入所有内容,在这个案例中,顶层模块是主文件。我们还导入了 test::Bencher,它将被用于运行基准测试。基准测试由 #[bench] 属性定义,这些属性应用于接受一个对象作为 Bencher 类型可变引用的函数。我们将需要基准测试的函数传递给基准测试器,基准测试器负责运行这些函数并打印结果。
可以使用 Cargo 运行基准测试:
$ cargo bench
Finished release [optimized] target(s) in 0.0 secs
Running target/release/deps/rayon_parallel-333850e4b1422ead
running 2 tests
test tests::bench_filter_parallel ... bench: 92,630 ns/iter (+/- 4,942)
test tests::bench_filter_sequential ... bench: 1,587 ns/iter (+/- 269)
test result: ok. 0 passed; 0 failed; 0 ignored; 2 measured; 0 filtered out
这个输出显示了两个函数以及执行每个迭代所需的时间。括号中的数字表示给定测量的置信区间。虽然并行版本的置信区间比非并行版本大,但它确实执行了比非并行版本多 58 倍的迭代。因此,并行版本要快得多。
使用 Pest 进行解析
我们在第四章数据序列化、反序列化和解析中研究了不同的解析技术。我们探讨了使用 Nom 的解析器组合器,从较小的部分构建一个大型解析器。解决解析文本数据相同问题的另一种完全不同的方法是使用解析表达式语法(PEG)。PEG 是一种形式语法,它定义了解析器应该如何行为。因此,它包括一组有限的规则,从基本标记到更复杂的结构。可以接受此类语法的库是 Pest。让我们看看使用 Pest 重写我们的 HTTP 解析示例第四章数据序列化、反序列化和解析的例子。从 Cargo 项目设置开始:
$ cargo new --bin pest-example
和往常一样,我们需要声明对 Pest 组件的依赖,如下所示:
[package]
name = "pest-example"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
pest = "¹.0"
pest_derive = "¹.0"
下一步是定义我们的语法,它是一系列解析规则的线性集合。像之前一样,我们对解析HTTP GET或POST请求感兴趣。以下是语法的样子:
// src/appendix/pest-example/src/grammar.pest
newline = { "\n" }
carriage_return = { "\r" }
space = { " " }
get = { "GET" }
post = { "POST" }
sep = { "/" }
version = { "HTTP/1.1" }
chars = { 'a'..'z' | 'A'..'Z' }
request = { get | post }
ident_list = _{ request ~ space ~ sep ~ chars+ ~ sep ~ space ~ version ~ carriage_return ~ newline }
第一步是定义要逐字匹配的文本规则。这些对应于 Nom 中的叶解析器。我们定义了换行符、回车符、空格、两个请求字符串、分隔符以及 HTTP 版本固定字符串的文本。我们还定义了request作为两个请求文本的逻辑或。字符列表是所有小写字母和所有大写字母的逻辑或。到此为止,我们已经有了定义最终规则所需的一切。这由ident_list给出,由请求、一个空格、然后是分隔符组成;然后我们指出我们的解析器应使用*接受一个或多个字符。下一个有效输入又是分隔符,后面跟一个空格、版本字符串、回车符,最后是换行符。请注意,连续输入由~字符分隔。前面的下划线_表示这是一个静默规则,并且应该仅在顶层使用,正如我们很快就会看到的。
主文件看起来是这样的:
// src/appendix/pest-example/src/main.rs
extern crate pest;
#[macro_use]
extern crate pest_derive;
use pest::Parser;
#[derive(Parser)]
#[grammar = "grammar.pest"]
// Unit struct that will be used as the parser
struct RequestParser;
fn main() {
let get = RequestParser::parse(Rule::ident_list, "GET /foobar/ HTTP/1.1\r\n")
.unwrap_or_else(|e| panic!("{}", e));
for pair in get {
println!("Rule: {:?}", pair.as_rule());
println!("Span: {:?}", pair.clone().into_span());
println!("Text: {}", pair.clone().into_span().as_str());
}
let _ = RequestParser::parse(Rule::ident_list, "WRONG /foobar/
HTTP/1.1\r\n")
.unwrap_or_else(|e| panic!("{}", e));
}
代码很简单;库提供了一个基本特性,称为Parser。这可以通过使用名为grammar的属性为单元结构自定义派生,以基于语法文件生成一个功能解析器。值得注意的是,这个库非常高效地使用自定义派生和自定义属性来提供更好的用户体验。在我们的例子中,单元结构被称为RequestParser,它实现了parse方法。在我们的主函数中,我们调用这个方法,传入解析应该开始的规则(在我们的情况下,那恰好是最终的顶级规则,称为ident_list)以及要解析的字符串。由于解析失败后继续没有太多意义,错误通过终止来处理。
在设置好这个结构后,我们尝试解析两个字符串。第一个是一个正常的 HTTP 请求。parse方法返回一个解析令牌流上的迭代器。我们遍历它们并打印出与令牌匹配的规则名称,输入中包含令牌的范围,以及该令牌中的文本。稍后,我们尝试解析一个没有有效 HTTP 请求的字符串。以下是输出:
$ cargo run
Compiling pest-example v0.1.0 (file:///Users/Abhishek/Desktop/rust-book/src/chapter4/pest-example)
Finished dev [unoptimized + debuginfo] target(s) in 1.0 secs
Running `target/debug/pest-example`
Rule: request
Span: Span { start: 0, end: 3 }
Text: GET
Rule: space
Span: Span { start: 3, end: 4 }
Text:
Rule: sep
Span: Span { start: 4, end: 5 }
Text: /
Rule: chars
Span: Span { start: 5, end: 6 }
Text: f
Rule: chars
Span: Span { start: 6, end: 7 }
Text: o
Rule: chars
Span: Span { start: 7, end: 8 }
Text: o
Rule: chars
Span: Span { start: 8, end: 9 }
Text: b
Rule: chars
Span: Span { start: 9, end: 10 }
Text: a
Rule: chars
Span: Span { start: 10, end: 11 }
Text: r
Rule: sep
Span: Span { start: 11, end: 12 }
Text: /
Rule: space
Span: Span { start: 12, end: 13 }
Text:
Rule: version
Span: Span { start: 13, end: 21 }
Text: HTTP/1.1
Rule: carriage_return
Span: Span { start: 21, end: 22 }
Text:
Rule: newline
Span: Span { start: 22, end: 23 }
Text:
thread 'main' panicked at ' --> 1:1
|
1 | WRONG /foobar/ HTTP/1.1
| ^---
|
= expected request', src/main.rs:21:29
note: Run with `RUST_BACKTRACE=1` for a backtrace.
首先要注意的是解析错误的 HTTP 请求失败了。错误信息很友好且清晰,解释了解析失败的确切位置。正确的请求解析成功并打印出了所有必要的令牌和详细信息,以便进一步处理。
杂项实用工具
在 C 和 C++中,一个常见的流程是定义一组位作为标志。它们通常定义为 2 的幂,因此第一个标志的十进制值为 1,第二个标志的值为 2,依此类推。这有助于执行这些标志的逻辑组合。Rust 生态系统有一个 crate 来简化相同的流程。让我们看看使用 bitflags crate 处理标志的示例。让我们从使用 Cargo 初始化一个空项目开始:
$ cargo new --bin bitflags-example
我们将设置项目清单以添加bitflags作为依赖项:
$ cat Cargo.toml
[package]
name = "bitflags-example"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
bitflags = "1.0"
当所有这些都准备好了,我们的主文件将看起来像这样:
// appendix/bitflags-example/src/main.rs
#[macro_use]
extern crate bitflags;
// This macro defines a struct that holds our
// flags. This also defines a number of convinience
// methods on the struct.
bitflags! {
struct Flags: u32 {
const X = 0b00000001;
const Y = 0b00000010;
}
}
// We define a custom trait to print a
// given bitflag in decimal
pub trait Format {
fn decimal(&self);
}
// We implement our trait for the Flags struct
// which is defined by the bitflags! macro
impl Format for Flags {
fn decimal(&self) {
println!("Decimal: {}", self.bits());
}
}
// Main driver function
fn main() {
// A logical OR of two given bitflags
let flags = Flags::X | Flags::Y;
// Prints the decimal representation of
// the logical OR
flags.decimal();
// Same as before
(Flags::X | Flags::Y).decimal();
// Prints one individual flag in decimal
(Flags::Y).decimal();
// Examples of the convenience methods mentioned
// earlier. The all method gets the current state
// as a human readable string. The contain method
// returns a bool indicating if the given bitflag
// has the other flag.
println!("Current state: {:?}", Flags::all());
println!("Contains X? {:?}", flags.contains(Flags::X));
}
我们导入我们的依赖项,然后使用bitflags!宏定义一系列标志,如前所述,我们将它们的值设置为 2 的幂。我们还展示了使用特性系统附加到bitflags上的额外属性。为此,我们有一个自定义特性Format,它将给定输入打印为十进制。转换是通过使用返回给定输入中所有位的bits()方法来实现的。下一步是实现我们的特性为Flags结构。
在完成这些后,我们继续到main函数;在那里,我们构造两个给定标志的逻辑或。我们使用decimal方法打印出位标志的表示,并确保它们相等。最后,我们使用all函数显示标志的人类可读形式。在这里,contains函数返回true,因为标志X确实在X和Y的逻辑或中。
运行此代码后我们应该看到以下内容:
$ cargo run
Compiling bitflags-example v0.1.0 (file:///Users/Abhishek/Desktop/rust-book/src/appendix/bitflags-example)
Finished dev [unoptimized + debuginfo] target(s) in 0.48 secs
Running `target/debug/bitflags-example`
Decimal: 3
Decimal: 3
Decimal: 2
Current state: X | Y
Contains X? true
单个标志的值始终应该是整数类型。
网络编程中另一个有用的实用工具是url crate。这个 crate 提供了解析 URL 部分的功能,从网页链接到相对地址。让我们看一个非常简单的例子,从项目设置开始:
$ cargo new --bin url-example
货物清单应该看起来像这样:
$ cat Cargo.toml
[package]
name = "url-example"
version = "0.1.0"
authors = ["Foo <foo@bar.com>"]
[dependencies]
url = "1.6.0"
让我们看看主文件。在这个相对简短的示例中,我们正在解析 GitLab URL 以提取一些重要的信息:
// appendix/url-example/src/main.rs
extern crate url;
use url::Url;
fn main() {
// We are parsing a gitlab URL. This one happens to be using
// git and https, a given username/password and a fragment
// pointing to one line in the source
let url = Url::parse("git+https://foo:bar@gitlab.com/gitlab-org/gitlab-ce/blob/master/config/routes/development.rb#L8").unwrap();
// Prints the scheme
println!("Scheme: {}", url.scheme());
// Prints the username
println!("Username: {}", url.username());
// Prints the password
println!("Password: {}", url.password().unwrap());
// Prints the fragment (everything after the #)
println!("Fragment: {}", url.fragment().unwrap());
// Prints the host
println!("Host: {:?}", url.host().unwrap());
}
这个示例 URL 包含一个片段,指向文件中的一行数字。方案设置为 git,并且为基于 HTTP 的认证设置了用户名和密码。URL crate 提供了一个名为parse的方法,它接受一个字符串并返回一个包含所有所需信息的结构体。我们随后可以调用该变量的单个方法来打印出相关信息。
下面是这段代码的输出结果,符合我们的预期:
$ cargo run
Compiling url-example v0.1.0 (file:///Users/Abhishek/Desktop/rust-book/src/appendix/url-example)
Finished dev [unoptimized + debuginfo] target(s) in 0.58 secs
Running `target/debug/url-example`
Scheme: git+https
Username: foo
Password: bar
Fragment: L8
Host: Domain("gitlab.com")
摘要
这最后一章包含了多个主题,我们认为它们不够主流,不适合放在其他章节中。但我们应该记住,在一个像 Rust 这样的庞大生态系统中,事物发展非常迅速。所以,今天可能不是主流的一些想法,明天可能会被社区采纳。
总体来说,Rust 是一种非常棒的语言,具有巨大的潜力。我们真诚地希望这本书能帮助读者了解如何利用其力量进行网络编程。


浙公网安备 33010602011771号