【CSDN 编者按】本文作者 Adam Chalmers 是一名 Cloudflare 工程师,最近他分享了为什么会选择使用 Rust。
链接:https://blog.adamchalmers.com/why-rust-on-backend/
译者:弯月
近几年来,我一直在使用 Rust 作为 Cloudflare 上的高级语言。我所需要的高级语言并不需要注重性能。我主要用这门语言来开发 API 服务器,总体延迟并不重要。我完全可以使用垃圾收集语言或解释语言,因为我不需要为了加快性能而压榨出每一微秒。我只希望服务器保持正常运行,并让我快速发布功能。
那么,为什么我要使用 Rust 来完成这样的任务呢?尽管 Rust 号称是低级系统语言,但实际上它的表现完全不输于高级语言。下面是我考虑使用 Rust 的原因,即便是对性能没有太高要求的项目也可以使用 Rust。
这个原因很简单。我个人很喜欢 Rust,而且我聘请了喜欢 Rust 的开发人员。我们使用 Rust 开发软件的效率非常高。那么,对于创业公司来说,如果开发人员不太了解 Rust,但公司愿意花大量时间让大家学习,应该坚持选择 Rust 吗?不赞成。我们之所以选用 Rust,是因为我们喜欢 Rust,用 Rust 编程会让我们感到快乐。
我必须强调:如果团队中没有人熟悉 Rust,那么让所有开发人员学习 Rust 的成本将会很高。他们需要很长一段时间,才能使用Rust高效工作,你需要指导和支持他们。在此期间内,你们的生产力会很低下。你应该选用团队成员都知道的语言,除非你真的非常需要 Rust。
我很幸运,因为我的团队成员都熟悉 Rust,喜欢 Rust,而且大家都希望成为更好的 Rust 程序员,所以这不是问题。
我们团队为 Cloudflare 构建了 Data Loss Prevention,这个服务本质上是对某些公司网络的流量进行“扫描”,以确保没有人恶意或无意地泄露私人数据。例如,检测并阻止黑客将数百万个信用卡号码从你的数据库上传到 pastebin.org,或者阻止某人将带有特定 Office 标签的 word 文档通过电子邮件发送到 yahoo.com的电子邮箱。
实际扫描 HTTP 流量以防止数据丢失的服务叫做 dlpscanner。从一开始,我们就知道 dlpscanner 对性能非常敏感,因为它代理了大量的 HTTP 请求,我们不希望在打开 DLP 时用户浏览网页速度变慢。所以,我们使用Rust编写了这个部分。对于后端 API,我们有两种选择:Rust 或 Go。在考虑了两种语言的复杂性后,我们决定使用Rust,因为我们不想在代码库中添加第二种语言。
当初在规划后端 API 时,我们知道后端必须与 dlpscanner 进行互操作。它需要共享很多类型,例如用户配置。API服务器将用户配置序列化为 JSON,而 dlpscanner 在需要扫描请求时反序列化这个 JSON。
我更倾向于使用 Rust 编写所有的序列化以及反序列化逻辑,而不是使用两种语言定义我的模型,并检查语言 A 是否可以反序列化语言 B 序列化的数据。我知道有很多工具可以简化不同服务之间的互操作,例如 Captain Proto 和 Protocol Buffers,但是跨语言模式和生成的代码绑定非常烦人。当然,我也可以直接编写一些JSON转换,并进行充分的单元测试。但我感觉编写普通的Rust代码似乎会简单许多。
从根本上来说,我喜欢系统的不同部分之间可以共享代码。无论是注重性能的服务,还是对性能并不敏感的服务都使用了Rust,这样可以大大简化整个代码库。
在多种编程语言之间切换上下文很难。每次打开Go或JS代码,我都需要一些时间来提醒自己:“公开字段的首字母需要大写”,“别忘了各种的陷阱”等等。坚持使用一种语言可以减轻我的负担,而且还可以最大限度地减少新团队成员需要学习的知识。
Serde
我非常喜欢Serde,所以打算专门介绍一下。在最初使用Go的几个月里,我为JSON反序列化编写了很多单元测试,由于Go采用了基于注释的方法,因此即便某个地方输入错误,编译器也无法捕捉到。例如,在下面的 Go 代码中:
type response struct {
PageCount int `json:"pageCount"`
FirstNames []string `json:"firstNames"`
}
我需要给每个字段加注释,说明JSON序列化或反序列化的键应该是什么。这种方法本身没问题,但如果你有很多字段,而且需要手动将它们统统转化为蛇形命名,而不是驼峰式命名,就会非常麻烦。一旦输入错误,就会得到一个运行时错误。此外,你必须给每个字段加注释,因为Go的JSON包只能反序列化公共字段(以大写字母开头),而其他服务希望看到的是以小写字母开头的字段。
但在Serde,我只需要这么写:
#[serde(rename_all = "camelCase")]
struct Response {
page_count: i32,
first_names: Vec<String>,
}
这样就可以生成反序列化的代码,不需要单元测试。Serde 提供了许多开箱即用的其他属性来帮助自动化 JSON 任务。对API后端来说,序列化与反序列化 JSON 是一个非常核心的问题,因此你应该确保这部分代码简单明了,而且不需要大量的自定义逻辑和单元测试。
Serde 很不错,因为刚开始的时候你可以只支持 JSON,然后再添加其他序列化标准的支持。如果想着对性能非常敏感的代码中重用这些类型,serde 可以处理大量其他数据格式,而且这些数据格式的序列化和反序列化速度更快。
在之前的项目中,我遇到过很多 JSON 序列化和反序列化的错误,但自从使用Serde以来,我从未遇到过任何问题。它为我节省了很多时间。如果我需要编写一个严重依赖反序列化数据的新项目,我会尝试使用Rust(或者JS,如果我知道数据只会以 JSON 格式传输并且两端都可以使用 JS的话)。
Rust 在数据库方面的表现并不出色,但我认为它也非常擅长数据库的处理。我很喜欢使用 Diesel,它可以根据 SQL 的迁移脚本生成类型化的 SQL 模式,然后再为你生成所有的 SQL 查询。这就解决了以下几个问题:
在删除或重命名 SQL 表中的列时,如何检查所有现有查询已被更新,可以理解最新的数据库模式?
如果在代码中为 SQ 的L表或行建模,并添加/更改/删除列,如何检查所有代码类型是否准确地建模了 SQL 类型?这称为双重模式问题。保持代码模式(JS、Go、Rust 等等)与 SQL 模式同步是非常繁琐的工作。
我不喜欢所有语言中的对象关系映射器,但 Diesel 非常好,因为当我更新 SQL 模式时,Diesel 将重新生成合适的 Rust 模型,而且 Rust 代码和 SQL 语句之间几乎所有的不匹配都变成了编译器错误,修复起来很方便。
在 Rust 类型系统中构建 SQL 类型系统的模型是一项非常了不起的工作,不过它也会引发很多非常烦人的问题,因为 Diesel 类型非常复杂。其中包括:
超过 60 行的错误信息。
毫无意义的错误信息(比如“这个特性没有实现”,好吧,我知道了,那么你能告诉我为什么没有吗?不行?好吧,让我独自哭一会儿)。
很难提取共用代码,放入共享函数中,因为两个看起来相似的查询具有截然不同的类型。
但总的来说,如果应用程序中的许多功能都非常依赖于数据库,那么我认为确保数据库查询得到正确的类型检查是值得的。数据库查询不是 API 后端中的一些可选的附加功能,它们几乎就是整个代码库。所以我们必须确保它们的正确性。
当然,你可以手动编写所有 SQL 查询,并非常仔细地进行单元测试,但是你需要认真考虑单元测试,保证它们与生产模式保持同步,并确保不会出现 SQL 注入。虽然有时 Diesel 很让人头疼,但总的来说值得考虑。
有了 Diesel 和 Serde,API 中几乎所有重要的代码(读取请求、执行数据库查询和编写响应)都可以生成了,这样你就有更多时间编写业务逻辑、发布功能并专注于业务领域建模。
存储用户配置的后端 API 可以在软件中正确地建模现实世界,这一点很重要。如果用户利用你的软件表示办公室的布局,那么你的类型系统就应该能够模拟办公室,而不是让用户推送无效的配置。
如果可能的话,我们都希望在编译时检测到这些无效配置,而不是等到运行时,以最大程度地减少测试和错误检查的代码。如果某种配置不会出现在现实世界中,例如任何用户的办公室都不可能位于两个时区,那么你的软件模型就不应该表示具有两个时区的办公室。这个概念被称之为“使非法状态无法表示”,这方面的文章有很多。
Rust 有两个功能可以帮助你准确地建模业务领域:枚举和不可克隆类型。
有一个非常巧妙的概念,叫做“求和类型”,根据使用的语言不同,又被称作“标记联合”、“代数数据类型”或“具有关联值的枚举”。我非常喜欢求和类型。我也曾在Haskell的业余项目中使用过,我在担任iPhone开发人员时使用Swift,如今在Cloudflare中使用Rust。因为它们非常适合业务领域建模。
你可以利用枚举确保函数要么返回一个错误,要么返回一个 Person 结构。不可以同时返回两者,也不可以哪个都不返回。必须是两个选项之中的一个。当没有枚举时,比如在Go中,我需要仔细阅读每个函数并检查理应返回(Person, *err)的函数永远不会出现哪个都不返回的现象。
我喜欢用枚举对域进行建模。我非常希望用户能够使用 TCP 套接字或 Unix 套接字启动我的软件,并且知道编译器会检查你不会意外地传递任何一种套接字、第三种套接字或其他东西。
准确地建模业务领域是高级API必须实现的。正确性很重要。所以,如果我需要确保软件模型准确地代表现实世界,那么Rust是更好的选择。
几年前,我需要在Cloudflare上为一组(10个)IP地址建模。这是因为Cloudflare边缘网络有10个公共 IP,而Cloudflare在服务器上运行,并连接到了其中的4个IP,以实现负载平衡。
如果其中一个 IP 是“不健康的”,并且断开了Cloudflare,那么Cloudflare就应该避免重复使用这个IP,转而使用一些以前没有使用过的 IP。对于这个建模,一种常见的方法是为每个IP分配三种可能的状态:正在使用、未使用以及以前使用过但现在不健康。这些IP中的每一个都可以分配给4个长期存在的 TCP 连接之一。
这个问题听起来像是很容易解决,但我们很难为“每个IP地址最多可以分配给一个连接”的想法建模。我不得不编写大量的单元测试来覆盖两个不同的连接获取相同 IP 地址的极端情况。这很难,因为在 Go 中,每个值都可以被复制。确保只有一个特定字符串的副本,比如“104.19.237.120”,需要付出大量的努力。一般Go函数会复制值,或复制指向该值的指针,因此很难确保只有一个 goroutine 正在读取值。
然而,Rust可以轻松确保特定值仅在一个地方“使用”。无论在任何时候,都只能有一个 &mut 引用一个值,所以只需让“使用”该值的函数获取 &mut 即可。或者,也可以让类型不实现 Clone,并确保“使用”它的函数完全拥有该值。这个值会被移动到函数中,而函数完成后会“返回”该值。
所以,如果我想用Rust实现这个系统,只需要保留一个包含10个IP地址的HashSet,并确保每个个连接都将 &mut 转移到正在使用的 IP。我还要确保 IP 是新类型UncloneableIp(std::net::IpAddr),并且我没有为该新类型派生克隆。
老实说,这种问题在实践中并不常见,但遇到这样的问题时,检查每个函数,并确保它们都没有复制值或共享引用是一件非常讨厌的事情。
可靠性
对于初创公司来说,性能可能不是问题,但可靠性有可能是问题。客户不喜欢为离线服务付费。我个人就曾有过这样的经历,维护了一些非常不可靠的服务。
我的 Rust 后端服务基本不会崩溃。没有引发恐慌的 nil 解引用。当然我们还是应该小心 Option,.unwrap() 一个 Option 的值就有可能引发崩溃。但是,这些问题很容易在代码审核中发现,看到 .unwrap() 我们就应该警惕程序崩溃。在实践中,与 unwrap 相比,Rust 有更好的方法来处理 Option,所以在我们的代码审查中很少出现这种情况。在我们的代码库中,95% 的 unwrap 都是单元测试在使用。
这种可靠性必然需要开发人员付出努力,比如考虑如何正确地使用模式匹配所有的结果和选项值。但对于许多领域来说,这种权衡是有意义的。我曾碰到过很多失败的项目,如果能避免半夜被警报吵醒,那么我很愿意付出一些努力。
Rust 不会使用太多内存或泄漏资源(如 TCP 连接或文件描述符),因为当函数终止时,一切都会被丢弃和清理。但也有一些例外,比如可能会“泄漏任务”,我的 Go 服务就会泄露 goroutines。你必须确保在所有地方都使用适当的超时。
我吸取的教训是,性能问题最终都会演变成可靠性问题。如果服务泄漏内存的时间足够长,或者摄入的数据足够多,那么性能瓶颈最终就会导致服务崩溃。虽然并不是每个人都会遇到这个问题,但是如果你认为你的流量或使用量有可能在一天内暴增几个数量级,例如出现一大批新用户注册,那么就值得考虑。
我认为 Rust 作为一种高级语言可以出色地完成工作。特别是在处理 Web 服务时,Rust 可以通过 serde 和 Diesel 等库为你节省很多时间。类型系统可以让你更轻松地建模业务领域,而且你的服务也不会经常中断。
但是,如果你的团队中没有人熟悉 Rust,或者没有太多经验,那么使用 Rust 构建 Web 服务可能是一个非常糟糕的主意。Rust 的难度曲线已经回落了,但依然非常高,你应该使用团队熟悉的语言。
网友:以上理由可以套在JS/Java/Python
评论1:我想指出一点有可能出现的副作用。如果你有一个系统开发人员组成的团队,每个人都非常熟悉 Rust,而团队的下一项任务是构建一个 Web API 的中间件,那么 Rust 是最显而易见的选择,因为每个人都熟悉它,而且喜欢它。但是,除非你打算永远让这些人负责这个 Web API,否则这个决定会导致你无法招聘任何熟悉 C#、JS、Java、Go、Python 等更容易构建后端的语言的开发人员。这个决策将你的招聘范围缩小到了 Rust 专家,哪怕你的需求是刚刚会启动 Flask 服务器的毕业生都能搞定的。