site logo

Marico's space

Rails 生成器本质上是个 API,只是没人用设计 API 的方式去设计它

前端技术 2026-04-22 17:37:28 3

我是那种写代码时会认真设计 API 的人——命名、参数、错误信息,都会反复斟酌。但直到最近才意识到,自己写 Rails 生成器时的态度完全是两回事。

这篇文章的灵感来自作者在 Tropical on Rails 2026 上的演讲,核心观点很直接:Rails 生成器本质上是一种 API,应该用设计 API 的方式去设计它们。

Rails generator code on screen

我们怎么看生成器的?

大多数人对 Rails 生成器的认知是这样的:

输入一个名字 → 得到一堆文件 → 继续干活

这个模型本身没问题——生成器就是代码生成工具,给名字,出文件,继续干。但这个框架漏掉了一个关键点:每次开发者在项目里运行生成器,他们其实是在接受别人做出的一个决定——文件夹结构、命名规范、什么东西默认生成、什么东西不生成、出错了怎么处理。

如果这个决定做得不错,开发者感受不到问题——只是拿到好结果。如果做得不好,那就等着在 Slack 里被问吧:"我们这里 X 是怎么做的来着?"

这不是自动化,是影响力,是设计。

生成器就是 API:类比一下

生成器之所以值得关注,是因为它们和 API 共享同一个接口契约。对比一下:

API 概念 生成器等价物
端点名 生成器名称
请求参数 参数和 flags
响应体 生成的文件
错误消息 失败时的输出

我们对 API 要求严格,因为知道开发者会反复使用。我们仔细考虑命名、记录每个参数、返回一致的响应、写清楚错误的处理方式——如果 API 命名混乱,就提 bug;如果输入不可预期,就抱怨;如果错误信息毫无用处,就上网发帖骂。

然后我们自己写了个自定义生成器,没有 --help,没有输出信息,失败时抛 Ruby 堆栈——没人觉得有什么不对

差别不在于 API 更重要,差别在于我们从来没想过生成器也值得设计。这篇文章就是想论证:应该想了。

糟糕的 DX 长什么样?

说三个真实的反例,来源是作者做的 jet_ui 这个 gem。注意这些都是说明场景,不是 gem 实际的问题——可以理解为"如果 DX 不是优先设计目标,jet_ui:eject 会变成什么样"。

🔴 问题 1:歧义

如果生成器没有记录好的命名规范,开发者只能靠猜。组件名应该大写?缩写?写成路径?

$ rails g jet_ui:eject Button
$ rails g jet_ui:eject button
$ rails g jet_ui:eject btn

三种写法看起来都合理。没有文档或清晰的规范传达,新来的开发者根本不知道哪种是对的——或者这事根本没有对错。这导致代码库里命名越来越乱,因为不同的人选了不同的形式。

成本不只是第一天的困惑。是整个代码库随时间的持续不一致。

🔴 问题 2:没有任何反馈

静默运行的生成器,什么都不说。看看一个没有任何输出调用的 eject_components 方法长什么样:

def eject_components
  components.map(&:downcase).each do |name|
    MANIFEST[name][:files].each do |entry|
      template entry[:src], entry[:dest]
    end
  end
end

跑出来的结果:

$ rails g jet_ui:eject btn

  create app/components/jet_ui/btn/component.rb

One file created.

一个文件创建了。没有 CSS。没有测试。没有确认操作成功。没有提到跳过了什么、为什么跳。更没有下一步指导。开发者只能懵着:还有其他文件要手动创建吗?要重启服务吗?要跑什么命令吗?

好工具会告诉你它做了什么。伟大工具会告诉你接下来做什么。

🔴 问题 3:没得选

一个生成器不管项目需不需要、永远生成所有东西——开发者要么接受不想用的文件,要么每次跑完手动删。这问题不在于输出本身,而在于没有选择

class EjectGenerator < Rails::Generators::Base
  argument :components, type: :array,
    banner: "component [component ...]",
    desc: "Component(s) to eject (e.g. btn card)"

  # No class_option :skip_test
  # No class_option :skip_preview
end

这样实现的话,生成组件就永远包含组件文件、CSS 文件、测试文件、预览文件——每次都是,不例外。但要是项目用 RSpec 而不是 Minitest 呢?要是根本不需要 previews 呢?要是开发者只想拿 CSS 用来覆盖样式呢?这些都不重要——生成器替他们决定了,而且没给他们任何发言权。

设计更好的 DX:五个原则

这三个问题根源相同:生成器是被写出来的,而不是被设计出来的。写出来意味着产出能用的东西。设计出来意味着产出能沟通、能引导、尊重使用者的东西。下面是具体怎么做,五个原则。

Code and API design on monitor

🟢 原则 1:命名即契约

生成器的名字、以及它的参数名字,应该精确传达它的作用和用法。这不是要写得聪明或详细,是要消除歧义。开发者读完命令,应该能明确知道运行后会得到什么。

实现工具是 desc 块和参数定义。它们不只是文档——它们是让 --help 能正常工作的前提,而 --help 是开发者遇到不确定情况时的第一反应。

class EjectGenerator < Rails::Generators::Base
  desc "Ejects JetUi component(s) into your application for local customisation."

  argument :components, type: :array,
    banner: "component [component ...]",
    desc: "Component(s) to eject (e.g. btn card)"
end

加上这个之后,--help 输出是有用的:

$ rails g jet_ui:eject --help

Usage:
  rails generate jet_ui:eject component [component ...] [options]

Description:
  Ejects JetUi component(s) into your application for local customisation.

Example:
  rails g jet_ui:eject btn card

命名规范通过生成器本身传达出去了,不需要发 Slack 问。

🟢 原则 2:可预期的输入

生成器接受的每个 flag,都是对开发者的一个承诺。它在说:这是一个你可以做的选择,这里是它控制什么,这是默认行为是什么。当 flag 没文档或干脆没有,开发者要么不知道这个选择存在,要么没法做选择。

class_option 是做这个的地方,每个选项都应该有通俗易懂的 desc

class_option :skip_test, type: :boolean,
  default: false,
  desc: "Skip test files for each component"

class_option :skip_preview, type: :boolean,
  default: false,
  desc: "Skip preview files for each component"

这样"没得选"的问题就马上解决了。运行 rails g jet_ui:eject btn --skip-test 跳过测试文件,加 --skip-preview 跳过预览。默认行为仍然是生成全部,照顾到想要完整输出的开发者。每个人各取所需,不需要跟工具较劲。

可预期的输入也让生成器更容易自动化、脚本化和集成到 CI——因为行为是明确的,不是猜的。

🟢 原则 3:透明的输出

不说话的生成器是黑箱。开发者跑它,文件出现,然后只能手动检查结果来理解发生了什么。简单场景还行——但不可扩展。生成器变复杂后,静默执行就变成了负担。

Thor 提供了 saysay_status 方法,干这个正合适,但大多数自定义生成器根本没用上。用上的才是真正能沟通的工具。

def eject_components
  # ... validation + template logic

  say "\nEjecting #{name}...", :cyan
  # ... generate files
  say " #{name} ejected.", :green
end

def show_summary
  say "\nDone! Ejected: #{components.map(&:downcase).join(\", \")}\n", :green
  say "The local files in app/components/jet_ui/ now take precedence over the gem."
  say "Run your tests to confirm everything still works:\n"
  say " bundle exec rake test\n"
end

这是 jet_ui:eject 的真实输出:

Ejecting btn...
  create app/components/jet_ui/btn/component.rb
  create app/assets/stylesheets/jet_ui/btn.css
  create test/components/jet_ui/btn/component_test.rb
  create test/components/previews/jet_ui/btn/component_preview.rb
  btn ejected.

Done! Ejected: btn
The local files in app/components/jet_ui/ now take precedence over the gem.
Run your tests to confirm everything still works:
  bundle exec rake test

每行都是有意为之的。执行前宣布什么、执行后确认什么、最后总结什么、告诉开发者下一步做什么。最后这块——下一步——是大多数生成器最常缺失的部分,也是直接减少新手问题数量的关键。

🟢 原则 4:有用的错误信息

不解释问题的错误信息不是信息,是噪音。最糟糕的是 Ruby 堆栈跟踪:技术上准确,对刚输错组件名的开发者完全没用。更好的做法是提前捕获问题、清楚命名、展示什么是有效的。

def eject_components
  unknown = components.map(&:downcase) - MANIFEST.keys

  if unknown.any?
    say "\nUnknown component(s): #{unknown.join(\", \")}", :red
    say "Available: #{MANIFEST.keys.join(\", \")}\n", :red
    exit 1
  end

  # ... rest of the logic
end

结果是明确告诉开发者哪里错了以及怎么修:

$ rails g jet_ui:eject buton

Unknown component(s): buton
Available: btn, card

这个模式——提前验证、失败时大声、显示有效选项——适用于所有接受受限输入的生成器。实现成本极低,效果极大降低第一次失败时的摩擦。

🟢 原则 5:为扩展而设计

一个生成器如果只能原样使用——没有办法在不碰源码的情况下扩展或修改行为——那它是脆弱的。它逼开发者在"按规范使用"和"完全 fork"之间二选一。都不是好选项。

Rails 生成器基于 Thor,所以它们继承自 Rails::Generators::Base——这意味着子类化是头等公民模式。设计得好的生成器利用这点,把逻辑组织成小的、专注的方法,可以被单独覆盖或扩展。

class EjectWithStorybookGenerator < JetUi::Generators::EjectGenerator
  desc "Like jet_ui:eject, but also generates Storybook stories."

  def generate_stories
    components.map(&:downcase).each do |name|
      template "#{name}/story.js.tt",
        "stories/jet_ui/#{name}/component.stories.js"
    end
  end
end

这个子类从父类白嫖一切——参数处理、验证、say 调用、摘要输出——只新增一个行为。原始生成器永远不需要改。写这个扩展的人不需要知道基础生成器内部怎么工作的——直接当.foundation 用就行了。

如果一个生成器不能在不修改它的情况下被扩展,它就不是工具,是脚本。

更大的图景

这五个原则是战术层面的。但背后还有一个战略论证,不只涉及单个生成器。

每次开发者运行生成器,他们其实在执行设计时做出的一个决定。如果决定做得好——清晰的命名、可预期的输入、透明的输出、有用的错误信息、扩展性考虑——它会扩展。它扩展到团队的每个开发者、他们建的每个功能、每个沿相同惯例的后续项目。生成器变成架构决策的活编码——这些决策原本只存在于文档、部落知识、或写原始代码的人的记忆里。

如果决定做得不好,也扩展。扩展不一致、困惑和技术债务——一次运行累加一次。

这就是生成器重要的原因,不是因为它们复杂或花哨,而是因为它们很平凡。我们每天习以为常、毫不思考的工具,恰恰是塑造我们思维方式的东西。用意图去设计它们,那种意图就会静默地扩散到整个团队。

明天可以做的三件事

  • 🔎 审计项目中一个生成器,把它当公共 API 来看。跑一下 --help。输出有用吗?flags 有文档吗?错误信息可操作吗?你会把这个发给外部开发者吗?
  • ⚡ 给任何没有 desc 块的自定义生成器加上。一行代码。十分钟。你的生成器刚获得了一个帮助页面,团队里每个开发者现在不用读源码就能发现它干什么。
  • 💡 下次写生成器的时候问自己:一个新手开发者能在不问我任何问题的情况下用这个吗?这一问——如果诚实地问——本身就涵盖了本文的所有内容。

结语

我们构建软件时有个安静的讽刺。花了时间争论 API 设计、写文档、审查 PR——都是为了质量。然后我们发了一个自定义生成器,没有 --help,没有反馈,没有错误信息——没人觉得有什么不对。

生成器是无形的基础设施。它们跑一次,生成文件,然后就消失在对话里了。但它们编码的决定——文件夹结构、命名规范、关于开发者需要什么的假设——那些会留下来。会重复。会继承给每个新团队成员、每个新功能、每个沿相同模式的后续项目。

这就是这事重要的原因。不因为生成器复杂或耀眼,而是因为它们很平凡。下次写生成器,把它当公共 API 来对待。因为对于将使用它的开发者来说——它就是


译自:Generators are APIs — Designing Better DX in Rails