从Go迁移到Rust
摘要
一份为Go开发者迁移到Rust编写的全面指南,专注于后端服务,对比正确性、运行时和人体工程学方面的权衡,并提供关于渐进式迁移的实用建议。
暂无内容
查看缓存全文
缓存时间: 2026/05/24 21:41
# 从 Go 迁移到 Rust | Corrode Rust 咨询 来源:https://corrode.dev/learn/migration-guides/go-to-rust/ 在我帮助团队完成的所有迁移项目中,从 Go 迁移到 Rust 可以说是一个异类。这里的问题并非“Rust 更快吗?”或“Rust 有类型系统吗?”,因为 Go 在这方面已经做得相当不错。讨论的焦点主要集中在**正确性保证**、**运行时权衡**和**开发者体验**上。 在开始之前,先快速声明:本指南**高度聚焦于后端**。后端服务是 Go 最擅长的领域——生成小巧的静态二进制文件,拥有专注于网络的标准库,以及丰富的 HTTP 服务器、gRPC、数据库等库的生态系统。这也是大多数考虑 Rust 的团队(至少是联系我的那些团队)的背景,所以我认为这是在实际中最有意义的比较。 如果你正在编写 CLI 工具、嵌入式固件或游戏引擎,本文的部分内容可能仍然适用,但老实说,恐怕这不是最适合你的资源。 作为背景,我之前曾写过关于 Go 和 Rust 的文章: 2017 年的“Go vs Rust?选 Go。”(https://endler.dev/2017/go-vs-rust/),以及后来与 Shuttle 团队合作的“Rust vs Go:实践对比”(https://www.shuttle.dev/blog/2023/09/27/rust-vs-go-comparison),该文章逐步演示了如何使用这两种语言构建一个小型后端服务。 - Go 和 Rust 的重叠与差异之处。 - Go 的模式如何映射到 Rust。 - 你从借用检查器中获得了什么。 - 哪些场景我建议保留 Go,哪些场景值得为 Rust 承担迁移成本。 - 如何逐步迁移 Go 服务。 ## 我的立场 (https://corrode.dev/learn/migration-guides/go-to-rust/#where-i-m-coming-from) 我坦白说:我不是 Go 的粉丝。我认为它是一个设计**糟糕**的语言,即使它非常成功。它将**易用性**与**简单性**混淆了(https://www.youtube.com/watch?v=SxdOUGdseq4),并且它的几个核心设计权衡(到处是 `nil`,错误处理是纪律规则而非类型约束,长期缺乏泛型)指向了我不同意的一个方向。 话虽如此,成功很重要!Go 确实占据了相当大且稳定的开发者市场份额,根据 JetBrains 开发者生态系统调查,其使用率徘徊在 17-19% 左右。Rust 正在稳步增长,但占比仍然较小:2017-2024 年 Go 和 Rust 在开发者中的使用率。Go 稳定在 17-19% 左右;Rust 从 2% 增长到了 11%。 显然,Go 对很多人来说效果很好,一个假装不是这样的指南是无益的。因此,我会在本指南中尽力保持客观,而不是重提旧争论。但你应该了解我的先验立场,以便自行校准。 另一个值得披露的先验立场:我经营着一家 Rust 咨询公司;我当然有偏见!更多人使用 Rust 对我的生意有好处。但我也曾专业地使用过这两种语言,并将 Go 服务部署到生产环境中。 本指南面向那些希望诚实、并排了解迁移到 Rust 后会发生什么的 Go 开发者。关于刻意相反的论点,我推荐阅读 Blain Smith 的“Just Fucking Use Go”(https://blainsmith.com/articles/just-fucking-use-go/)。同时接受这两种观点,比只接受其中任何一种更有用。 如果你更喜欢观看而非阅读,这里有来自上述 Shuttle 文章的视频,由 Primeagen 朗读和评论: [YouTube 视频链接] ## 快速了解最重要的命令 (https://corrode.dev/learn/migration-guides/go-to-rust/#a-first-look-at-the-most-important-commands) Go 开发者已经拥有业界最简洁的工具链之一。当年,它引领了“开箱即用”工具链的潮流,提供了一套一致统一的接口来构建、测试、格式化、lint 和依赖管理。我很高兴 Rust 也效仿了这种做法,因为它是一个很棒的模型。这也是我最喜欢这两个生态系统的地方之一。 `cargo` 甚至内置了更多功能: | Go 工具 | Rust 等价物 | 说明 | |---|---|---| | `go.mod` / `go.sum` | `Cargo.toml` / `Cargo.lock` | 项目配置和依赖清单 | | `go get` / `go mod tidy` | `cargo add` / `cargo update` | 添加和解析依赖 | | `go build` | `cargo build` | 编译项目 | | `go run .` | `cargo run` | 构建并运行 | | `go test ./...` | `cargo test` | 测试内置于工具链 | | `go vet ./...` | `cargo clippy` | Linter,Clippy 比 `vet` 意见更多 | | `gofmt` / `goimports` | `cargo fmt` | 自动格式化器,零配置 | | `golangci-lint run` | `cargo clippy -- -D warnings` | 严格 lint 模式 | | `go install ./cmd/foo` | `cargo install --path .` | 安装二进制文件 | | `go doc` | `cargo doc --open` | 生成和查看 API 文档 | | `pprof` | `cargo flamegraph` / `samply` | CPU 性能分析 | | `govulncheck` | `cargo audit` | 根据公告数据库进行漏洞扫描 | 主要区别在于,在 Go 中你通常需要借助第三方工具(`golangci-lint`, `mockgen`, `air`, `goreleaser`)来填补空白。而在 Rust 中,第一方生态系统默认提供了更多功能。那些**确实**需要外部 crate 的工具(例如 `cargo watch`, `cargo nextest`)只需一条命令即可安装,并且感觉像原生工具,例如 `cargo install cargo-nextest` 就能让你立即使用 `cargo nextest`。 两个社区在格式化器方面达成了共识:一个单一的、规范的风格(即使是不完美的)比它消除的喋喋不休的争论更有价值。 > Gofmt 的风格不是任何人的最爱,然而 gofmt 是所有人心中的最爱。—— Rob Pike,Go 箴言 (https://go-proverbs.github.io/) `rustfmt` 也是如此:不是每个人都喜欢它的每个细节,但在代码审查中消除风格争论的价值,远大于偶尔你可能更喜欢的不同格式化偏好。 ## Go 和 Rust 的关键区别 (https://corrode.dev/learn/migration-guides/go-to-rust/#key-differences-between-go-and-rust) | 特性 | Go | Rust | |---|---|---| | 稳定发布 | 2012 | 2015 | | 类型系统 | 静态、结构化,泛型自 1.18 起 | 静态、名义化,泛型 + 特征 + 生命周期 | | 内存管理 | 垃圾回收(并发,低暂停) | 所有权和借用,无 GC | | 空安全 | 到处是 `nil` | 无 null;`Option` 是类型级别的替代方案 | | 错误处理 | `error` 接口,`if err != nil { ... }` | `Result`,`?` 操作符,穷尽匹配 | | 并发 | Goroutines + Channels (CSP) | `tokio` 上的 `async`/`await` + channels + 线程 | | 取消 | `context.Context`(约定,非强制) | `CancellationToken` / 显式、类型检查的管道 | | 数据竞争 | 运行时通过 `-race` 捕获(概率性,运行时) | 在**编译时**通过 `Send`/`Sync` 捕获 | | 编译时间 | 非常快 | 慢,尤其是干净构建 | | 运行时 | ~2 MB Go 运行时 + GC | 除了 `libc` 之外没有(或使用 MUSL 完全静态) | | 二进制大小 | 小到中等(几 MB) | 相当;使用 `panic = "abort"` + LTO 时非常小 | | 学习曲线 | 平缓 | 陡峭 | | 生态系统规模 | ~750k+ 模块 | 250,000+ crates | 核心要点是,Go 和 Rust 都是编译型、静态类型、可部署为单个二进制文件的语言,并且具有强大的并发能力。区别在于**你从编译器获得了什么保证**以及**你对运行时行为有多少控制权**。 在深入之前,有一个有用的框架:**从 Go 迁移到 Rust 时,大部分变化在于检查被拉入了类型系统**。空值处理、错误传播、数据竞争、资源生命周期、取消、泛型——这些都是 Go 依赖约定、工具(`go vet`, `errcheck`, `golangci-lint`, `-race`)或运行时检测来保证正确性的。而 Rust 将它们编码为类型,由编译器直接强制执行。 常见的反对意见是,这意味着“更多的认知负担”。我会对此提出挑战。它确实需要更多**前期投入**,但**更难出错**。Rust 中的 `Mutex` 不仅文档说明了数据需要锁,它还使得锁成为访问数据的**唯一**途径:你调用 `.lock()`,获得一个守卫,而守卫就是让你访问内部值的东西。放下守卫,锁自动释放。不存在“我忘了加锁”的路径,因为未加锁的路径在类型中根本不存在。一旦你内化了这个模式,并且发现它无处不在(`Option`, `Result`, `&mut T`, `Send`/`Sync`,RAII 守卫),Rust 就不再感觉沉重,而开始感觉像是编译器在帮你完成以前需要你大脑完成的工作。 ## Go 开发者为何考虑 Rust (https://corrode.dev/learn/migration-guides/go-to-rust/#why-go-developers-consider-rust) Go 开发者通常不是因为 Go “太慢”而转向 Rust。对于大多数后端工作负载来说,Go 已经足够快了。人们通常是对 Go 冗长的错误处理、`nil` 指针导致段错误的危险、曾经长期缺乏泛型,以及缺少枚举或特征等复杂类型系统功能感到有些沮丧。接口(Interfaces)并不能真正替代特征(traits),而且 Go 标准库存在一些奇怪的空白,例如缺少 `Set` 类型。(惯用的变通方法是 `map[T]struct{}`,它在实践中运行良好,但这表明类型系统并未完全发挥其作用。) ### 生产环境中的 `nil` 崩溃 (https://corrode.dev/learn/migration-guides/go-to-rust/#nil-panics-in-production) 你部署了一个 Go 服务,它平稳运行数月,然后某个代码路径被执行,其中有人忘了检查指针是否为空,导致 goroutine panic。一个常见的情况是查找返回了零值,或者某个结构体的指针字段在反序列化后未填充: ```go func (s *Service) Handle(req *Request) error { // Find 返回 (*User, error)。对于 "未找到",error 是 nil; // 调用者应该检查 user != nil,但这很容易忘记。 user, err := s.repo.Find(req.UserID) if err != nil { return err } return user.Account.Notify() // 如果 user 或 Account 为 nil,则会崩溃 } ``` Linter 和 IDE 检查可以捕获**部分**此类问题(`nilaway`, `staticcheck`),但它们是可选的、概率性的,并且不能可靠地跨越包边界。Go 编译器本身并不强制你考虑值为空的情况。而 Rust 的 `Option` 则强制你考虑: ```rust fn handle(&self, req: &Request) -> Result<(), ServiceError> { let user = self.repo.find(req.user_id)?; // 返回 Option;? 在 None 时短路返回错误 user.notify() } ``` 你根本无法解引用 `Option` 而不处理 `None` 的情况。一整类 PagerDuty 事件就此消失。 ### `-race` 未捕获的数据竞争 (https://corrode.dev/learn/migration-guides/go-to-rust/#data-races-that-race-didn-t-catch) `go test -race` 是一个很好的工具,但它是运行时检测器,只能发现那些**实际执行**了的竞争。在没有锁的情况下从两个 goroutine 并发修改 map,在 Go 中可以顺利编译,只有在生产环境的负载下才会爆发。 在 Rust 中,跨线程共享可变状态需要实现 `Send` 和 `Sync` 的类型。尝试在线程之间共享一个普通的 `HashMap`,**程序将无法编译**。你被迫将其包装在 `Arc<Mutex<HashMap>>`、`Arc<RwLock<HashMap>>` 中,或者使用 channel。那个数据竞争条件变成了一个类型错误。1 (https://corrode.dev/learn/migration-guides/go-to-rust/#fn-races) Paul Dix 非常坦率地讲述了促使重写 InfluxDB 3.0 的原因,而数据竞争问题位居榜首: > [主要好处是] 无畏并发——基本上消除了数据竞争,而我们之前存在这个问题。Influx 版本 1 中由于这个问题导致了一些非常棘手的 bug。—— Paul Dix,InfluxData 创始人兼 CTO,在《Rust in Production》(https://corrode.dev/podcast/s01e01-influxdata?t=55%3A40) 节目中 ### 可组合的错误处理 (https://corrode.dev/learn/migration-guides/go-to-rust/#composable-error-handling) `if err != nil { return err }` 在一段时间内是没问题的。但几年后,你会注意到三件事: 1. 样板代码稀释了函数的实际逻辑。 2. 使用 `fmt.Errorf("doing X: %w", err)` 进行包装是一个纪律规则,而不是编译器规则。很容易遗漏上下文信息。 3. 通过 `errors.Is`/`errors.As` 处理哨兵错误是可行的,但当你忘记处理新的错误变体时,编译器不会告诉你。 值得诚实地讨论一下反方论点,因为我在 Shuttle 文章下的 Lobste.rs 讨论 (https://lobste.rs/s/g44oeq/rust_vs_go_hands_on_comparison) 中看到了它:经验丰富的 Go 开发者指出,`errcheck` 和 `golangci-lint` 在实践中能捕获大多数“忘记处理错误”的情况,并且显式的 `if err != nil` 比密集的 `?` 链**更容易阅读**。这两个观点都是合理的,显式风格是一种刻意的文化价值观,并非偶然: > 我认为错误处理应该是显式的,这应该是该语言的核心价值。—— Peter Bourgon,GoTime #91 (https://changelog.com/gotime/91),引用自 Dave Cheney 的 Zen of Go (https://dave.cheney.net/2020/02/23/the-zen-of-go) 我的看法是,lint 工具是你需要记得设置的、可选的“安全网”,而 Rust 的 `Result` 就是类型签名本身,根本不可能忘记。样板代码与可读性之间的权衡则更为主观。 在 Rust 中: ```rust #[derive(Debug, thiserror::Error)] pub enum UserError { #[error("user {0} not found")] NotFound(UserId), #[error("user already exists")] AlreadyExists, #[error(transparent)] Repo(#[from] RepoError), } pub fn rename(id: UserId, name: &str) -> Result<User, UserError> { let mut user = repo::get(id)?; // ? 自动将 RepoError 转换为 UserError user.name = name.to_string(); Ok(user) } ``` `?` 操作符处理传播;`#[from]` 处理包装;对 `UserError` 的 `match` 是**穷尽检查的**。明天添加一个新变体,编译器会向你展示所有需要更新的地方。 ### 无需装箱的泛型 (https://corrode.dev/learn/migration-guides/go-to-rust/#generics-that-don-t-box) Go 在 1.18 中引入了泛型,它们很有用,但实现存在限制(没有带类型参数的方法、GC 形状模板化、偶尔出现令人惊讶的性能特征)。Rust 泛型会进行单态化,每个实例化都会产生零运行时开销的特化代码。结合 traits,这为你提供了真正的零成本抽象。这在处理程序代码中不那么重要,但在共享基础设施(中间件、通用仓库、解码器、解析器)中更重要,在这些地方,Go 通常会迫使你回到 `interface{}`/`any` 加上类型断言。 ### 可预测的延迟 (https://corrode.dev/learn/migration-guides/go-to-rust/#predictable-latency) Go 的 GC 非常出色:并发、低暂停、针对典型服务工作负载进行了良好调整。但“低暂停”并非“无暂停”。在高分配压力下,P99 延迟尾部明显比一个在热路径上根本不分配的 Rust 等效实现要差。我不会过分夸大这一点,对于绝大多数服务来说,Go 的 GC 都不是问题。但对于延迟敏感的系统(交易、实时竞价、网络代理、高吞吐量数据摄取),没有 GC 暂停是一个真正的卖点。PubNub 的 Stephen Blum 在节目中直言不讳: > Go 在我们的规模下表现出色,但我们确实需要一些能给我们带来更好的性价比性能容量的东西,而 Rust 能让我们实现这一点。这就是为什么现在几乎所有东西都在转向 Rust。—— Stephen Blum,PubNub CTO,在《Rust in Production》(https://corrode.dev/podcast/s01e02-pubnub?t=17%3A25) 节目中 ### 总结 (https://corrode.dev/learn/migration-guides/go-to-rust/#in-summary) Go 是“千刀万剐”式的折磨。它是一种非常实用的语言,如果你愿意忽略上述问题,你可以在其中非常高效地工作。但在一定的代码库规模下,问题开始累积。Go 失去吸引力的时刻并非单一时间点,而是当团队开始渴望更多(更多的安全性、更多的控制、更强的表现力)时,他们就会开始寻找替代方案。 ## 两种语言的并排比较 (https://corrode.dev/learn/migration-guides/go-to-rust/#comparing-both-languages-side-by-side) 让你在 Rust 中感到舒适的最快方法是将你已经知道的模式映射过来。关于使用两种语言构建相同后端服务的更长的、完整的示例,请参阅 Shuttle 的比较文章 (https://www.shuttle.dev/blog/2023/09/27/rust-vs-go-comparison)。
相似文章
从Rust到Ruby
开发人员描述使用LLM将一个15,000行的Rust Web应用转换为Ruby on Rails,发现Ruby版本明显更短,并评估了开发速度、安全性和可测试性方面的权衡。
我们如何(及为何)将生产环境的C++前端基础设施重写为Rust
NearlyFreeSpeech.NET 将其生产环境的C++前端基础设施(nfsncore)重写为Rust,该系统负责所有传入请求的路由、缓存和访问控制。迁移的动机是Rust的安全性保证、性能、生态系统优势以及老化的C++代码库的局限性。
Rust语言的性能
本次演讲分析了Rust相较于C++的性能优势与劣势,提供了基准测试和最佳实践。附有幻灯片和阅读材料。
模拟 Rust 代码的所有方法
一篇教程,涵盖了在 Rust 中模拟网络调用的多种策略,以发出事件的 Kubernetes 控制器为例,重点强调不降低生产代码的可测试性。
就用Go
一篇带有强烈观点的开发者文章倡导使用Go编程语言,强调其简洁的语法、强大的标准库、高效的并发模型以及单二进制部署,作为对过于复杂的现代技术栈的实用替代方案。