新工具 2026年05月07日

一夜之间格式化 2500 万行代码:rubyfmt 是怎么在 Stripe 落地的

by BLL

一夜之间格式化 2500 万行代码:rubyfmt 是怎么在 Stripe 落地的

Stripe 跑着全世界最大的 Ruby 代码库,这个很多人都知道。可在 rubyfmt 出现之前,Ruby 这门语言其实一直没有一个真正意义上像样的自动格式化工具。

以前不是没人试过,而是工具一碰到大一点、怪一点的 Ruby 文件就容易翻车。有的慢得没法放进保存钩子里,有的干脆直接崩。结果就是,工程师每天都在和格式较劲,代码评审里也总有一堆“这个空格不对”“这个换行不统一”这种小意见。

这篇文章讲的,就是 rubyfmt 这件事从一个个人开源项目,怎么一点点长成 Stripe 基础设施的一部分。到最后,Stripe 甚至在一个周六早上,把当时整整 2500 万行 Ruby 代码一次性全格式化了。

这个规模,说实话,光想想都挺刺激的。

rubyfmt 一开始是怎么冒出来的

事情要从 2018 年的 RubyConf 说起。

作者当时和 Justin Searls、Aaron Patterson 在酒吧里聊着聊着,就聊急了:Ruby 太需要一个自动格式化工具了。那个时候 Ruby 的语言服务器还很早期,甚至很多场景下根本谈不上可用。再加上 Ruby 启动本来就不轻,带上 gem、再进 bundler 环境,启动成本更高,所以“保存时自动格式化”这件事,听上去就不现实。

那天晚上他们没把方案聊出来,但有几个方向已经很明确了:

一个是不能靠复杂配置。 一个是必须快。 还有一个是,哪怕你以前不是写 Ruby 的,也得能直接上手。

所以说,rubyfmt 从第一天开始,目标就不是做一个“可配置性很强的格式工具”,而是做 Ruby 世界里的 gofmt。

不要配置,也不要参数

大家如果在团队里待久一点,应该都见过这种场面:逗号到底放不放尾巴,关键字怎么换行,条件表达式到底要不要拆开,几个人能讨论半天。

如果你们团队还认真争论过 linter 到底该怎么配,那更能理解这里面的痛点。

Ruby 圈最常见的工具当然是 rubocop。它很强,也很好用,但它不是自动格式化器。它更像一套规则引擎,而且规则非常多,几百条都能单独调。问题就在这儿:只要工程师有空间分歧,那最后就一定会有分歧。

你以为是把“代码评审里的样式争论”搬走了,结果只是搬到了配置文件里。

作者在文里说了一句很实在的话:只要工程师有机会对某件事产生不同意见,他们通常就真的会有不同意见。代码风格这件事也一样,而且特别消耗时间。

所以 Ruby 缺的,其实就是一个像 gofmt 那样的工具:不问你想要什么风格,直接替你决定;跑得够快;从此大家别再为了格式争来争去。

速度必须快到能放进保存钩子

这里有个很关键的约束。

普通 Ruby 进程启动一次,大概就要 158ms。要是进 bundler 环境,差不多 345ms。对追求流畅编辑体验的人来说,这已经不是“有点慢”了,而是完全没法接受。

所以 rubyfmt 给自己定了一个很硬的目标:除了特别大的文件之外,大多数文件都要在 100ms 内格式化完成。

这个指标一出来,很多路其实就被堵死了。你不能老老实实按普通方式起 Ruby,再慢慢处理。为了抠出那几十毫秒,他们最后甚至直接写了 C 程序,贴着 libruby 去跑。

就是说,这工具从一开始就不是“能用就行”,而是明确要服务最快的开发工作流。

这对不写 Ruby 的工程师尤其重要

Stripe 有一个很典型的特点,就是他们招人并不会要求你一开始就精通某门语言。很多很强的工程师进来之前,可能一行 Ruby 都没写过,然后入职以后就得面对全世界最大的 Ruby 代码库。

这个时候,如果语言本身又足够灵活,代码长什么样还没有一个统一、明确、自动执行的标准,那新人会非常容易心里没底。

作者提到,他合作过的一些 Go 工程师就明确说过,他们很想念 gofmt。因为有了 gofmt,代码写完以后会自动“咔”一下归位,你不用反复猜“Ruby 社区这里通常怎么写”“Stripe 这里会不会要求另一种风格”。

严格的自动格式化器,价值就在这儿。它把这种不确定性拿掉了。新接触这门语言的人,不用老想着代码该长什么样,可以把脑子留给真正重要的事情:这段代码到底在做什么。

rubyfmt 是怎么做出来的

Ruby 这门语言的语法并不简单,这个大家都知道。作者先给了一个很典型的例子,一个完全合法、但读起来已经让人开始皱眉的 heredoc:

a = <<EOD
part 1 of heredoc #{ "not a heredoc" + <<EOM }
eom part
EOM
part 2 of heredoc
EOD

面对这种语法,作者当时的想法其实很朴素:最难的先啃下来,剩下的慢慢就顺了。

早期的 rubyfmt 是一个 Ruby 程序,底层基于 ripper。ripper 是 Ruby 官方解析器往上暴露出来的一层 Ruby API,所以它虽然不轻,但至少结果可靠。

最开始的实现,说白了就是一个很大的分发器。拿到 ripper 的语法树以后,根据节点类型分别处理:

def format_expression(ps, expression)
  type, rest = expression[0],expression[1...expression.length]
  {
        :return => lambda { |ps, rest| format_return(ps, rest) },
        :def => lambda { |ps, rest| format_def(ps, rest) },
        :if => lambda { |ps, rest| format_if(ps, rest) },
        # ...and so on for all expression types
  }.fetch(type).call(ps, rest)
end

因为速度要求太苛刻,rubyfmt 当时甚至建议用户带上 --disable=gems 启动,这样就不会去加载任何 gem,也不会进 bundler 环境。不然的话,100ms 的预算还没开始干活就已经花光了。

它那时候的分发方式也挺硬核:直接把所有源码拼成一个超大的 Ruby 文件,让用户丢进 PATH 里用。平时当然不推荐这么干,但在那个阶段,这是个合理的权衡。

后来又用 Rust 重写,而且越写越深

随着 rubyfmt 支持的语法越来越多,继续“用 Ruby 来格式化 Ruby”就开始显得有点力不从心了。

ripper 最初之所以被选中,是因为它直接建立在 Ruby 自己的解析器之上,结果最权威。但问题是,性能慢慢扛不住了。中等复杂度的文件都开始冲破 100ms 预算,于是作者决定把 rubyfmt 用 Rust 重写。

听上去像是常规的“性能不够,Rust 来救”路线,对吧?但真正做的时候,事情没那么简单。

当时 Ruby 世界里还没有一个“不依赖 Ruby VM 就能单独编译使用”的解析器。结果就是,构建流程变成了先把整套 Ruby 编出来,再把它链接进 Rust 程序里。最后甚至得在 Rust 二进制里执行 Ruby 代码:

pub unsafe fn load_rubyfmt() -> Result<(), ()> {
    let rubyfmt_program = include_str!("../rubyfmt_lib.rb");
    eval_str(rubyfmt_program)?;
    Ok(())
}

到这一步,作者又发现了一个很有意思的点:ripper 产出的数据结构,除了 symbol 这种细节之外,基本都能顺手转成 JSON。

比如说:

irb(main):001> Ripper.sexp("def foo; 1 + 2; end")
=> [:program, [[:def, [:@ident, "foo", [1, 4]], [:params, nil, nil, nil, nil, nil, nil, nil], [:bodystmt, [[:binary, [:@int, "1", [1, 9]], :+, [:@int, "2", [1, 13]]]], nil, nil, nil]]]]

于是早期的 Rust 版本走了一条很“土但有效”的路:先在 Ruby 里把整棵 ripper 语法树编码成 JSON,再交给 Rust 里的 serde 去反序列化。

这个办法确实有点糙,但好处也很明显:能先跑起来。

后面瓶颈又来了。因为“Ruby 对象 -> JSON -> Rust 对象”这条链路还是太慢。作者就继续往底层走,开始直接处理 Ruby 的 VALUE,也就是 Ruby C API 里那个最核心的对象类型。

核心思路是:既然 ripper 返回的大部分东西本质上就是字符串、数组、布尔值、整数这些原始类型,那能不能干脆教会 serde 直接在内存里遍历 Ruby 对象?

于是就有了这样的代码:

fn deserialize_any<V: de::Visitor<'de>>(self, visitor: V) -> Result<V::Value> {
    pub use ruby::ruby_value_type::*;

    match unsafe { ruby::rubyfmt_rb_type(self.0) } {
        RUBY_T_SYMBOL => visitor.visit_borrowed_str(sym_to_str(self.0)?),
        RUBY_T_STRING => visitor.visit_borrowed_str(rstring_to_str(self.0)?),
        RUBY_T_ARRAY => visitor.visit_seq(SeqAccess::new(self.0)),
        RUBY_T_NIL => visitor.visit_none(),
        RUBY_T_TRUE => visitor.visit_bool(true),
        RUBY_T_FALSE => visitor.visit_bool(false),
        RUBY_T_FIXNUM => visitor.visit_i64(unsafe { ruby::rubyfmt_rb_num2ll(self.0) }),
        other => Err(de::Error::custom(format_args!(
            "Unexpected type {:?}",
            other
        ))),
    }
}

像数组这种结构,还得单独补一套访问逻辑:

struct SeqAccess {
    arr: VALUE,
    idx: usize,
    len: usize,
}

impl SeqAccess {
    fn new(arr: VALUE) -> Self {
        let len = unsafe { ruby::rubyfmt_rb_ary_len(arr) } as usize;
        Self { arr, len, idx: 0 }
    }
}

impl<'de> de::SeqAccess<'de> for SeqAccess {
    type Error = Error;

    fn next_element_seed<T: de::DeserializeSeed<'de>>(
        &mut self,
        seed: T,
    ) -> Result<Option<T::Value>> {
        if self.idx < self.len {
            let elem = unsafe { ruby::rb_ary_entry(self.arr, self.idx as _) };
            self.idx += 1;
            seed.deserialize(Deserializer(elem)).map(Some)
        } else {
            Ok(None)
        }
    }

    fn size_hint(&self) -> Option<usize> {
        Some(self.len - self.idx)
    }
}

这样一来,ripper 那边的数据 schema 几乎不用改,只要把 VALUE 的反序列化实现补齐,整套东西就通了。

把完整 Ruby VM 链进 Rust 二进制,然后在内存里直接走解析树,这种做法确实不常见。但工程上很多时候就是这样,先别追求优雅,先把问题解决。

rubyfmt 是怎么被 Stripe 真正推起来的

他们已经没法继续忍受格式问题了

作者 2020 年加入 Stripe 的时候,rubyfmt 已经做了两年,但还没完全成熟。

Stripe 不是没尝试过别的方案。他们之前用过 prettier-ruby,但结果不理想。一方面它是 JavaScript 实现的,速度不够;另一方面稳定性也差,一些大文件直接就崩了。

更要命的是,格式问题已经不是“大家偶尔抱怨一下”的程度,而是能直接从工程师的工作过程里看出来了。

Stripe 的 Developer Productivity 团队会做一种叫 shoulder surfing 的观察,就是看工程师真实工作,找出那些日常摩擦点。结果他们很快就发现,不少工程师在和 rubocop 周旋,花了不少时间,只是为了把代码摆到“看起来对”的位置。

这就很说明问题了。大家本来是来写业务、解决问题的,不是来训练自己对 Ruby 排版的直觉的。

所以 Stripe 最后做了一个很明确的判断:在 2500 万行 Ruby 的规模下,他们需要一个就是为 Ruby、也就是为这种规模量身做的工具。

Ruby 团队的 manager 和 tech lead 找到作者,问得也很直接:rubyfmt 能不能成?要投多少钱、投多少人,才能把它做成?

虽然那时候 rubyfmt 还没完成,但它已经是最有希望的方向了。Stripe 最终决定下注。到 2022 年,Ruby 基础设施团队有两位工程师开始全职投入这件事,而且项目一直保持开源。

对作者来说,这基本就是最理想的结果:自己做的开源项目,有公司愿意投入资源把它真正做成,而且还不把它关进公司内网里。

一个周六早上,处理 62213 个文件

把一个全新的自动格式化器推到 2500 万行代码上,风险其实就两个。

一个是合并冲突。 一个是正确性。

这个很好理解。哪怕只有 0.01% 的代码行被错误处理,落到这么大的代码库里,影响的也会是成千上万个文件。

所以他们一开始采用的是按文件逐个开通。只有明确声明“我愿意被 rubyfmt 处理”的文件,rubyfmt 才会去动它。这个节奏也很符合 Developer Productivity 团队平时的做法:先从自己最了解、最容易观察的系统开始,确认没问题了,再慢慢往外扩。

光这样还不够,他们还专门做了一个工具,用来比较格式化前后的 ripper 语法树差异。像 rubyfmt 把单引号改成双引号这种情况,也要考虑进去。再加上 Stripe 本身那套很重的测试体系,信心就是这么一点点建立起来的。

然后,到了 2024 年的那个周六早上,他们把策略整个翻过来了。

不再是“文件主动申请接入”,而是“只有极少数 rubyfmt 还处理不了的文件先排除”。选在周六做,就是为了尽量躲开合并冲突。

测试虽然给了他们很高的把握,但真到要合并的时候,看到一个大到 GitHub 都渲染不出来的 diff,心里还是会发毛。文章里那句形容我觉得特别有画面感:GitHub 已经不显示改了多少文件了,直接写 files changed: infinity。

最后他们还是合进去了。

再往后,就是持续把剩下那点例外一点点清掉。

而且这件事做完以后,代码库还在继续增长。到今天,Stripe 已经有 4200 万行 Ruby,全部都交给 rubyfmt 了。

最好的基础设施,就是没人讨论它

作者说,他们后来最有感触的一点就是:大家几乎不怎么提 rubyfmt 了。

这其实反而说明它成了。

好的基础设施就是这样,平时几乎隐形。它不需要你关注,它就是稳定地在那里工作。

而最能感受到变化的人,往往是那些原本不是 Ruby 背景的工程师。有位进 Stripe 前从没写过 Ruby 的工程师说得很直接:以前在 code review 里收到格式相关的小意见,会觉得既浪费时间又挺烦的。因为他从 Python 过来,black 这种工具太常见了,所以他一直觉得“连自动格式化都没有”是 Stripe 开发体验里最不对劲的几个点之一。现在这个问题已经很多年都没再想起过了,他非常高兴它消失了。

别的工程师反馈也差不多,核心就几件事:PR 更快了,摩擦少了,脑子里少挂了一件没意义的事。

有人说,rubyfmt 很快,而且支持保存即格式化,是他这几年体验最好的格式化工具。

也有人说,不用再在 PR 里来回给格式建议,真的舒服很多。

还有人说得更到位:快,而且不用讨论格式。当一个工具让你连“该怎么想它”都不用想的时候,说明它已经做对了。

这事还没完,后面还有 Prism

当然了,rubyfmt 也不是从此就万事大吉了。

很多年里,rubyfmt 一直都需要链接完整 Ruby VM 才能完成解析。直到 2025 年,这件事才真正迎来转折点。Ruby 官方新的解析器 Prism 成了正式方案,而且最重要的是,它可以在不链接 Ruby VM 的情况下,直接构建解析树。

迁移过程也不是一把梭。他们先让解析链路变成可切换的,再让整套测试同时跑 ripper 和 Prism。Prism 那边一开始全部标成预期失败,然后一点点把失败项清掉,直到 rubyfmt 在两套解析器上产出完全一致的结果。

因为 Prism 可以把原生对象直接加载进内存,之前那套 serde 和对象反序列化逻辑也就不需要了。

结果很直接:二进制体积一下小了不少,rubyfmt 速度也明显更快。

这篇文章真正让我在意的,不只是“快”

我看完以后,一个挺强烈的感觉是:很多开发效率问题,最后拼的不是一个算法点子,而是你愿不愿意长期把那些“大家都觉得烦,但没人认真收拾”的小摩擦当成正式工程问题来做。

代码格式这件事就是典型。

表面上看,它只是 PR 里的几句评论、编辑器里的几次手动调整。但一旦团队够大、代码库够久,这种摩擦会在每个人身上反复发生。到最后,它消耗掉的注意力和情绪,可能比你想的多得多。

Stripe 做对的一点,不只是他们把 rubyfmt 做快了,而是他们把“统一格式”这件事从工程师的脑子里拿走了。

你不用判断。 你不用争论。 你不用学一套隐含的排版礼仪。

你保存,工具处理,大家继续往前写。

这个体验,实际上是很值钱的。

如果你也在带一个 Ruby 团队,或者哪怕不是 Ruby,只要你们还经常在评审里讨论格式、样式、谁的配置更合理,那这篇文章其实已经把答案说得很清楚了:别再把这种事留给人,尽量交给工具,而且这个工具最好没有商量空间。

好,这篇就聊到这里。

顺手也记一句原文最后的致谢:作者特别感谢已经离开 Stripe 的 Reese。因为他虽然离职了,还一直在给 rubyfmt 提交代码。没有这些持续投入的人,这种基础设施项目其实很难真正落地。