用 Rust 重构——为什么要重构为 Rust?

365 阅读20分钟

这章内容涵盖:

  • 为什么你可能想要对应用进行重构
  • 为什么 Rust 是重构的一个好选择
  • 何时适合、何时不适合启动重构项目
  • 用来将代码重构为 Rust 的方法的高级概览

如果你听说过 Rust 编程语言,可能也听说过一些软件公司将他们原先使用较慢的解释型语言编写的代码,重写成 Rust。这些公司中有一些发布了博客,称赞 Rust 相较于之前系统带来的性能提升,并且讲述了一个很简洁的故事:其他语言慢,而 Rust 快。因此,把代码重写成 Rust,你的系统就会变快。

虽然大家可能都觉得,只要有更好的语言出现,我们就可以直接重写所有代码,但现实是软件开发并非存在于一个资源无限的真空中。性能提升和技术债务的偿还必须与新功能开发、用户需求以及现代软件工作中无数其他事务之间取得平衡。在用新语言重新实现功能的同时,还需要保证对用户提供一致且可靠的服务。那么,开发者怎样才能在保持快速开发节奏和可靠性的同时改善代码基础呢?答案并不是一刀切式的大规模重写,而是通过渐进式重构来实现。

1.1 什么是重构?

重构是对代码进行重组的过程,使其性能更好、更易维护,或满足其他某种“更好”的标准。不过,重构和重写之间存在一个模糊但重要的区别,关键在于操作的规模大小。

重写是指对整个应用或应用的大部分进行重新实现,通常是从头开始。我们可能出于利用新编程语言、新数据存储模型,或者因为当前系统难以维护,觉得丢弃重写比改进更容易,而选择重写。

而重构则是在更小的范围内进行重写。不是想要整体替换现有系统,而是找出系统中最需要改进的部分,用尽可能少的代码改动来提升系统。重构相比重写的好处有很多:

  • 由于当前系统就是“新系统”,重构进行时系统可以继续运行并为客户提供服务。我们可以部署一系列非常小的代码变更,这样能够明确是哪次变更引发了问题。如果我们一次性重写并部署整个新系统,一旦出现错误,怎么知道是哪部分出了问题?
  • 现有代码可能已经积累了多年的生产经验和监控数据。其他人对现有代码的运维和调试经验非常宝贵,不应被忽视。如果新系统出现了你完全没有经验处理的问题,你该如何排查?
  • 理想情况下,现有代码应有自动化测试。利用这些测试可以验证重构后的代码是否与现有代码保持相同的功能契约。如果你的现有代码还没有自动化测试,重构是一个很好的契机来开始编写测试!

图 1.1 展示了重写和重构在时间线上部署策略可能的不同。

image.png

在重写系统时,变更通常需要打包在一起部署。这会降低开发速度并增加部署出错的风险。功能在分支或过时的测试环境中停留的时间越长,部署时调试代码就越困难。如果所有软件都存在一定的缺陷风险,提高变更频率并减少每次部署的代码量,有助于我们在最短时间内发现并修复缺陷。

而在重构时,我们希望做出小而独立的改动,能够尽快部署。我们会为改动添加指标和监控,确保部署后结果保持一致。这个过程使我们能够快速且稳定地部署修复缺陷、添加功能或提升系统性能的小改动。

尽管如此,在对已有正常运行的代码进行重构时,我们仍需考虑多个因素:

  • 确保新旧代码行为一致
  • 利用现有的自动化测试
  • 编写针对重构引入的新数据结构的新测试
  • 部署新代码
  • 确定新旧代码部署环境之间的隔离程度
  • 决定如何在两个系统同时运行时比较性能
  • 控制新系统的发布,让只有一小部分用户访问新代码路径

在本书中,我们将探讨将运行缓慢或难以理解的代码重构为 Rust 的技术和方法。内容包括如何找到最需要重构的关键代码部分,如何让现有代码与 Rust 互通,如何测试重构后的代码,以及更多相关内容。

1.2 什么是 Rust?

Rust 是一种强调运行速度快、高可靠性和内存安全的编程语言。根据 rust-lang.org 的描述,Rust 是“一种赋能每个人构建可靠且高效软件的语言。”这句话是什么意思?

  • 赋能(Empowering) — Rust 致力于为开发者提供他们原本无法获得的能力。
  • 包容(Welcoming) — Rust 社区对所有人都非常友好,无论背景如何。Rust 开发者涵盖各个技能层次:有些人将 Rust 作为第一门编程语言,有些人则精通多种语言。有些来自底层编程背景,也有使用 Python、Ruby、JavaScript 等语言的应用开发者。
  • 可靠(Reliable) — Rust 软件旨在具有容错能力,并且对错误处理方式有明确规定,确保没有错误被忽略。
  • 高效(Efficient) — 由于直接编译为机器码且没有运行时垃圾回收器,Rust 代码相比 Python、Ruby、JavaScript 等解释型语言的代码,开箱即用就快得多。此外,Rust 为开发者提供了控制内存分配等底层细节的工具,这不仅可以显著提升速度,同时还能保持应用代码的易理解性。

1.3 为什么选择 Rust?

Rust 结合了内存安全、性能和出色的类型系统,这些特性共同作用,确保你的应用程序能够正确运行。强类型系统确保数据交换遵循正确的契约,避免意外数据导致意外结果。生命周期和所有权系统允许你在跨外部函数接口(FFI)边界时直接共享内存,而无需担心资源释放的责任归属。对线程安全的强保证让你可以轻松添加以前不可能或风险极高的并行处理。当你将这些最初为了帮助开发者编写更好 Rust 程序而设计的特性结合起来时,你会发现它们非常适合帮助你将几乎任何语言的代码逐步重构为 Rust。

1.4 是否应该重构为 Rust?

你可能有各种理由想要将应用的部分代码重构为 Rust,但本书重点讨论的两个主要目标是性能和内存安全。

1.4.1 性能

假设你正在开发一个用 Python、Node.js 或 Ruby 这类语言编写的应用。你已经为应用添加了很多新功能,代码库变得很大。然而,随着用户规模增长,你发现为了扩展服务需要投入大量计算资源,成本变高。应用的某些请求处理环节导致性能瓶颈,但你还不确定具体位置。

本书将指导你使用基准测试和性能分析等技术,帮你找到最适合通过性能导向的重构获益的代码部分。找到这些热点后,我们将探索如何用 Rust 实现相同功能,并进行性能调优,使代码运行尽可能快。

来看一个小例子。假设下面代码片段中的 CSV 解析代码是在你的 Web 应用中。

# 代码示例 1.1 Python 函数:计算 CSV 字符串中某列数值的总和

def sum_csv_column(data, column):
  sum = 0

  for line in data.split("\n"):
    if len(line) == 0:
      continue

    value_str = line.split(",")[column]
    sum += int(value_str)

  return sum

这个 Python 函数很简单,返回 CSV 字符串中指定列所有数值的总和。用 Rust 写相同函数看起来也很相似:

// 代码示例 1.2 Rust 版本的 CSV 列求和函数

fn sum_csv_column(data: &str, column: usize) -> i64 {  // #1
  let mut sum = 0;  // #2

  for line in data.lines() {
    if line.len() == 0 {
      continue;
    }

    let value_str = line
      .split(",")
      .nth(column)
      .unwrap();  // #3
    sum += value_str.parse::<i64>().unwrap();  // #4
  }

  sum
}
  • #1 Rust 函数参数和返回类型必须显式标注。
  • #2 mut 表示变量可变,值可以改变。
  • #3 unwrap 表示这些调用可能失败,如果失败程序会直接崩溃(panic)。
  • #4 ::<i64> 是 Rust 中被称为“turbofish”的语法,告诉编译器 parse 返回什么类型,用于消除歧义(详见第3章)。

Rust 版本起初看上去稍显复杂,但和 Python 版本相似:

  • 两者都接受两个参数:CSV 字符串和要求和的列号。Rust 明确标注类型,Python 虽未标注但也默认变量类型。
  • 两个函数都返回数值,Rust 在函数声明顶部标注,Python 没有。
  • 两者都会在数据异常时抛错,Python 抛出异常,Rust 会 panic(详见第2章错误处理)。
  • 两者都用简单的 CSV 解析算法。

尽管外观相似,这两个函数性能差异巨大。Python 会先将 CSV 字符串拆成每行字符串列表,再为每行拆分逗号分隔的字段并生成新字符串列表。而 Rust 因编译器能精准控制内存的分配和释放,直接用底层字符串内存,不进行额外分配。同时,Rust 的 .split 返回迭代器(Iterator),按需逐个遍历子串,不像 Python 一次性创建完整列表。这个区别在第3章有更详细讲解。如果数据量巨大,性能差距更明显。

我们用同样的输入文件(100 万行,100 列)跑了两个版本,结果如下表:

版本运行时间最大内存使用
Python2.9 秒800 MiB
Rust146 毫秒350 MiB

Rust 版本大约快 20 倍,内存使用不到 Python 的一半。这是显著的性能提升,且代码复杂度无显著增加。我们挑选了一个典型示例,具体表现可能因用例而异。

1.4.2 内存安全

另外,你可能在开发 C 或 C++ 项目,想利用 Rust 相较这两种语言在安全性上的优势。Rust 在编译时能验证程序避免数据竞争、悬垂指针等内存错误。通过渐进式将关键代码重构为 Rust,你可以更快发布软件,减少对代码内存不变式的担忧,让编译器帮你把关!

许多 C/C++ 常见的错误在普通 Rust 代码中根本写不出来。如果尝试写出这类错误,Rust 编译器会拒绝通过,因为它管理着 C/C++ 中最难处理的内存所有权问题。

注意
有经验的 C++ 开发者可能关心像 Boost 这样的框架。在 Rust 里,类似的库生态不存在,Rust 大多数包(crate)都基于标准库类型,且相互兼容。

经验丰富的 C/C++ 程序员大多熟悉内存所有权概念,但最终都必须面对它。后续章节会详细讲解,但核心是:内存的分配与释放由一个“所有者”控制。在传统 C/C++ 程序中,程序员需手动维护内存所有权状态,语言工具很少。Rust 编译器强制程序严格遵循所有权模型。

内存所有权是 Rust 最大优势之一。Rust 将传统运行时可能出现的、后果不可预测或危险的错误,转化为编译期可修复的错误,大幅提升代码安全性。

1.4.3 可维护性

当用动态类型语言编写的项目代码量达到数万行时,你可能会开始问:“这个对象是什么?”、“有哪些属性?”这样的问题。Rust 旨在通过强大且静态的类型系统来解决这些疑惑。静态类型意味着在编译时,程序中每一个值的类型都是确定的。近年来,静态类型正在强势回归。像 TypeScript、Mypy 和 Sorbet 这些项目,分别为 JavaScript、Python 和 Ruby 添加了类型检查。这些语言此前并不支持类型检查,而开发这些类型系统投入的大量精力,也说明了提前知道值类型的重要性。

Rust 的类型系统非常强大,但在大多数情况下不会妨碍你的开发。函数必须显式标注输入和输出的类型,但函数内部变量的类型通常可以由编译器静态推断,无需额外注解。类型虽然没显式标注,但并不意味着不知道。比如,若函数声明只接受布尔值作为输入,就不能传入字符串。很多 IDE 和编辑器插件能显示这些隐式类型,帮助开发者理解,但开发者自己不必写出所有类型。

部分开发者可能对静态类型感到不安,可能还记得 Java 曾经要求用下面这种 Kafka 式的写法:

// 代码示例 1.3 Java 1.6 中初始化数字映射到数字列表的 Map

HashMap<Integer, ArrayList<Integer>> map
  = new HashMap<Integer, ArrayList<Integer>>();

ArrayList<Integer> list = new ArrayList<Integer>();
list.add(4);
list.add(10);

map.put(1, list);

在每个函数里为每个局部变量都指定类型特别累人,尤其语言还要求多次重复。Rust 只需两行代码,且通常不需显式写类型:

// 代码示例 1.4 Rust 中初始化数字映射到数字列表的 Map

let mut map = HashMap::new();
map.insert(1, vec![4, 10]);

编译器怎么知道 map 里存放什么类型的值?它看 insert 调用,发现传入的是整数键和整数列表值。Rust 也支持显式类型注解,虽然大部分情况下是可选的,后续章节(第2章)会详细讲解。

// 代码示例 1.5 Rust 显式标注类型的写法

let mut map: HashMap<i32, Vec<i32>> = HashMap::new();
map.insert(1, vec![4, 10]);

这种强类型系统保证你后续再来看代码时,可以把更多时间用来写新功能或优化性能,而不是纠结 perform_action 函数里第五个未标注类型的参数到底是什么。

1.5 什么时候不应该重构为 Rust

如果你在做一个新项目(greenfield project),根本不需要先用别的语言写再重构为 Rust,你可以直接用 Rust 编写初始版本!本书主要假设你已经有一个现有的软件项目,想要改进它。如果你刚开始学习,可能一本通用的 Rust 编程书籍对你更有帮助。另外,如果你的项目运行在你无法很好控制的环境中,比如 PHP 共享主机或企业内严格受控的服务器(你无法安装新软件),那么本书介绍的一些技术可能无法顺利应用。

任何软件项目上线部署都需要计划。你如何把新代码推给用户?本书讲的重构方式假设部署新代码成本较低,且可以频繁进行。如果你需要通过物理介质发版,或者所在组织有非常死板的发布流程,这本书可能不适合你。

开发新软件时,你应当规划好其未来多年的维护。如果在你所在的大公司只有你一个人对 Rust 充满热情,那么你可能会被贴上“Rust 人”的标签,未来系统出现问题时,责任都会落到你头上。你想成为那个唯一负责维护系统的人吗?

1.6 它是如何工作的?

对一个成熟的生产系统进行渐进式重构并非易事,但可以拆解为以下几个关键步骤:

规划
  • 通过重构为 Rust,你希望改善什么?
  • 如果现有代码是用 C 或 C++ 写的,应考虑 Rust 如何提升应用的内存安全性。
  • 如果现有代码是用 Python 这类解释型带垃圾回收的语言写的,主要关注点是提升性能。
  • 哪些代码部分需要重构?
  • 现有代码和新代码如何通信?
实现
  • 用 Rust 代码实现现有功能的镜像。
  • 将 Rust 代码集成进现有代码库。
验证
  • 使用 Rust 的测试功能测试新功能。
  • 用已有测试比较新旧代码路径的结果。
部署
  • 根据之前的决策,你的 Rust 代码在服务用户时可能有不同的运行方式。
  • 如何在不影响最终用户的情况下,有效地逐步推出重构代码?

图 1.2 对这些步骤及其细节进行了更详细的列举。

image.png

正如图 1.2 所示,这个过程中的最大部分是规划。执行这种类型的重构工作非常复杂,你需要在替换代码之前了解替换代码的影响。同时,还必须仔细考虑引入新代码模式所带来的性能和可维护性。规划之后,最大的环节是部署,在这个阶段你可以控制哪些用户访问新功能,而不是旧功能。

1.7 本书你将学到什么?

本书首先从抽象层面介绍渐进式重构的概念,接着讲述 Rust 如何特别地帮助实现渐进式重构,以及如何将 Rust 融入你的应用程序。将 Rust 代码集成到现有应用中主要有两种技术,每种技术都有若干变体。

1.7.1 从你的程序中直接调用 Rust 函数

在这种模式下,你编写一个 Rust 库,像你现有编程语言中的库一样被调用。本节从宏观角度讨论了这些技术,后续章节会进行详细展开。图 1.3 展示了这种模式。

image.png

比如说,如果你正在重构一个 Python 项目,你的 Rust 库将会暴露出类似于 Python 函数和类的函数和类。由于现有代码和新的 Rust 代码都运行在同一个操作系统进程中,且可以直接共享内存,这种方法在两者之间的通信开销最低。

这种方法有几个分支:

  • 使用 C FFI(外部函数接口)
    这一主题在第 3 章有详细讲解,核心是 Rust 允许你编写看起来像 C 函数的函数,而许多语言都能调用 C 函数。
    这种方式最通用,因为大多数主流编程语言都支持 C FFI。
    但是它也最容易引发内存错误,因为程序员必须直接负责内存的分配、释放及正确传递,并且必须始终确保内存所有权清晰。
    如果你的项目是用 C 或 C++ 写的,这通常是你会用到的集成技术。
  • 使用 Rust 库直接绑定到其他语言的解释器
    通过这种技术,你可以写一个看起来就像 Python、Ruby 或 Node.js 库的 Rust 库。
    这种方式通常比 C FFI 容易实现,但如果目标语言没有对应的 Rust 绑定支持,这种方法就不可行。
  • 将 Rust 编译为 WebAssembly(WASM)并使用 WASM FFI
    WASM 是 JavaScript 引擎使用的一种字节码格式,类似于 Java 字节码。包括 Rust 在内的许多语言都能编译成 WASM,而不是本地机器码。
    当你需要在浏览器的 JavaScript 引擎或 Node.js 中使用 Rust 时,这种方法非常有用。

1.7.2 通过网络与 Rust 服务通信

这种技术依赖使用网络协议与新创建的 Rust 服务通信。图 1.4 说明了这一概念。

image.png

这种方法与前面讨论的直接调用模型相比,有以下优缺点:

优势

  • 由于该技术不直接访问内存,避免了两种语言互操作时内存损坏的风险。
  • 允许你的 Rust 系统独立于现有应用进行扩展。
  • 更多开发者有应用间网络通信的经验,因此这种方法在概念上比多语言共存于同一应用中更容易理解。

劣势

  • 如上节所述,数据通过网络传输会带来额外时间开销,导致性能有所下降。
  • 增加了额外服务的运维成本,需要独立的日志、监控和部署管理。

1.8 本书适合谁?

本书面向已经有多年非 Rust 语言应用开发经验、希望提升其应用性能、安全性或可维护性的程序员。
同时也适合想将 Rust 知识应用于提升其他语言编写的现有应用性能或内存安全的 Rust 程序员。毕竟,非 Rust 代码远比 Rust 代码多得多。

书中代码示例主要是 Rust,但由于本书涉及从其他语言迁移到 Rust,也需要比较对象。第 3 章包含大量 C 和 C++ 代码示例,其他章节有不少 Python 示例,用以突出它们与 Rust 的差异并展示集成方式。你不必精通这些语言,具备 C 语言家族其他过程式语言经验即可。

第 3 章讨论了许多内存安全相关的内容,可能对惯用垃圾回收语言的开发者较为陌生。这些内容非必须掌握,只为有 C/C++ 背景的读者提供帮助。

1.9 需要准备哪些工具?

所有所需工具都易于获取且免费。你需要:

  • 一个较新的 Rust 编译器 — 安装指南见 www.rust-lang.org/tools/insta…

  • 一款适合编程的文本编辑器

  • 一台运行 GNU/Linux 操作系统的电脑或虚拟机 — 本书大部分严格 Rust 代码示例在任何操作系统均可运行,但部分示例假设 GNU/Linux 环境:

    • 如果你使用 Windows,Windows Subsystem for Linux(WSL)是运行 Linux 程序并与 Windows 环境集成的便捷方式。
    • 本书所有示例均在 WSL 下的 Ubuntu 20.04 测试通过。
  • Libclang 开发包 — Rust 纯代码练习不严格要求,但许多章节间接使用 Libclang 生成 Rust 与 C/C++ 交互代码。

  • Python 3、virtualenv 和 pip — 用于后续章节运行基于 Rust 的 Python 扩展模块。

总结

  • 重构可以一次替换代码中小部分,频繁的小改动能提升性能,避免大规模重写的痛苦和时间成本。
  • Rust 拥有强大的静态类型系统,确保输入输出明确定义,边界情况被妥善处理。
  • Rust 易于实现并行,能充分利用多核 CPU 最大化性能。
  • Rust 可轻松与其他语言集成,助你专注于价值交付,而非重复造轮子。
  • 重构为 Rust 能提升性能、内存安全和可维护性,有助于软件系统更快且更经济地实现扩展。