Rails 模式
适用于现代 Ruby on Rails 应用(Rails 7.1+ 和 8.x)的框架模式。Rails 本质上是约定优于配置的;以下是社区在应用超过 50 个模型后为保持可维护性而达成的模式。本技能侧重于“怎么做”。关于“做什么”和“何时做”(即选择哪种模式的决策),请参见 Ruby 模式规则——本仓库中的 rules/ruby/patterns.md,安装后位于 rules/ecc/ruby/patterns.md。
何时激活
- 构建 Rails 应用(全栈、API 单用或混合模式)
- 审查涉及
app/或config/的 PR - 生成模型、控制器、服务或任务
- 控制器操作超过约 10 行代码
- 模型文件超过约 200 行代码
- 在控制器或视图中开始使用 ActiveRecord 查询
核心概念
目录约定
Rails 应用遵循可预测的结构。应有意地、而非随意地添加目录。
app/
models/ ActiveRecord 模型。持久化及领域逻辑贴近数据。
controllers/ HTTP 请求处理。仅薄层编排。
views/ ERB 模板。不含业务逻辑。
components/ ViewComponent 类。需要测试的视图逻辑。
services/ 服务对象。多步骤业务操作。
forms/ 表单对象。跨多个模型的复杂表单处理。
queries/ 查询对象。可复用、可组合的 ActiveRecord 查询。
jobs/ 后台任务。通过 Solid Queue、Sidekiq 或 GoodJob 实现的异步工作。
mailers/ ActionMailer 类。
helpers/ 视图助手。仅限微小的展示逻辑。
policies/ 授权策略(如果使用 Pundit)。可选。
channels/ 用于 WebSocket 工作的 ActionCable 通道。
避免使用 app/lib/、app/utils/、app/managers/。如果某事物不适合上述目录,通常意味着设计需要重新思考,而不是创建一个新目录。真正通用的代码放在 lib/ 中。
精简控制器
控制器接收请求,委托给正确的对象,并渲染响应。业务逻辑放在别处。(根据 Ruby 模式规则,当控制器开始承担多个职责时,提取到服务对象。)
服务对象
处理涉及多个模型保存的业务操作时的默认选择。保持一致的约定:
- 按领域命名空间(
Invoices::Create),而非按后缀命名(InvoiceCreator)。 - 类方法
.call委托给实例方法#call。 - 返回一个 Result 对象,而非布尔值或裸记录,以便调用方可以根据成功、错误和受影响的记录进行分支处理。
- 将多记录写入包装在事务中。
- 保持每个服务单一用途(
Invoices::Create、Invoices::MarkPaid),绝不要用Invoices::Manager。
表单对象
当表单跨多个模型或具有不对应于列的字段时,使用表单对象而不是嵌套属性或错误模型上的虚拟属性。它对视图来说就像一个模型(form_with model: @form),同时清晰地组合记录。
查询对象
对于在控制器或服务中重复使用,或过于复杂而无法使用 scope 的 ActiveRecord 查询,提取一个查询对象,它接受一个 scope 作为输入以便于组合。经验法则:一个 scope 增长到超过三个链式条件或开始接受参数时,就应成为一个查询对象。
后台任务
将耗时的操作卸载到后台。(根据 Ruby 模式规则,对于吞吐量适中的全新 Rails 8 项目使用 Solid Queue;当需要成熟的观测性、高吞吐量或现有 Redis 时使用 Sidekiq。)无论使用何种适配器:传递 ID 而非记录,使 perform 幂等,并显式设置 retry_on/discard_on。
ViewComponent 优于局部视图
对于具有条件渲染、超过两个参数或在超过三个地方复用的视图逻辑,优先使用 ViewComponent。组件可以独立测试并显式暴露其接口;而具有深层条件逻辑的局部视图会成为技术债务。
Hotwire:Turbo 和 Stimulus
默认的 Rails 前端栈。(根据 Ruby 模式规则,对于服务器渲染的应用优先使用 Hotwire;仅在交互复杂性需要客户端支持时再考虑 React/Vue。)Turbo Frames 用于部分页面更新,Turbo Streams 用于服务器驱动的更新,Stimulus 用于标记旁的小型客户端行为。
Rails 8 Solid 堆栈
Rails 8 提供了数据库原生的默认组件,以前需要 Redis:Solid Queue(任务)、Solid Cache(缓存)、Solid Cable(ActionCable)。其权衡是增加数据库负载以减少一个基础设施组件;适用于中等吞吐量,在高扩展场景下 Redis 仍然胜出。Kamal 是默认的基于 Docker 的部署工具。
代码示例
使用服务对象的精简控制器
# 不好:业务逻辑在控制器中
class InvoicesController < ApplicationController
def create
@invoice = Invoice.new(invoice_params)
@invoice.user = current_user
@invoice.line_items.build(invoice_params[:line_items])
@invoice.tax_total = TaxCalculator.new(@invoice).calculate
@invoice.total = @invoice.line_items.sum(&:amount) + @invoice.tax_total
if @invoice.save
InvoiceMailer.created(@invoice).deliver_later
AccountingExportJob.perform_later(@invoice.id)
redirect_to @invoice, notice: "发票已创建"
else
render :new
end
end
end
# 好:控制器进行编排,服务完成工作
class InvoicesController < ApplicationController
def create
result = Invoices::Create.call(params: invoice_params, user: current_user)
if result.success?
redirect_to result.invoice, notice: "发票已创建"
else
@invoice = result.invoice
render :new, status: :unprocessable_entity
end
end
end
服务对象
# app/services/invoices/create.rb
module Invoices
class Create
# Struct 保持此代码在 Rails 7.1 支持的所有 Ruby 版本上可运行。
# 在 Ruby 3.2+ 上,`Data.define(:success?, :invoice, :errors)` 是一个
# 更简洁的不可变替代方案。
Result = Struct.new(:success, :invoice, :errors, keyword_init: true) do
def success?
success
end
end
def self.call(params:, user:)
new(params: params, user: user).call
end
def initialize(params:, user:)
@params = params
@user = user
end
def call
invoice = build_invoice
ApplicationRecord.transaction do
invoice.save!
end
begin
send_notifications(invoice)
rescue StandardError => e
Rails.logger.error("通知派发失败,发票 #{invoice.id}: #{e.message}")
end
Result.new(success: true, invoice: invoice, errors: nil)
rescue ActiveRecord::RecordInvalid => e
Result.new(success: false, invoice: e.record, errors: e.record.errors)
end
private
attr_reader :params, :user
def build_invoice
invoice = user.invoices.new(params.except(:line_items))
invoice.line_items.build(params[:line_items])
invoice.tax_total = TaxCalculator.call(invoice)
invoice.total = invoice.line_items.sum(&:amount) + invoice.tax_total
invoice
end
def send_notifications(invoice)
InvoiceMailer.created(invoice).deliver_later
AccountingExportJob.perform_later(invoice.id)
end
end
end
表单对象
# app/forms/signup_form.rb
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :email, :string
attribute :password, :string
attribute :company_name, :string
attribute :terms_accepted, :boolean
validates :email, presence: true, format: URI::MailTo::EMAIL_REGEXP
validates :password, presence: true, length: { minimum: 12 }
validates :company_name, presence: true
validates :terms_accepted, acceptance: true
attr_reader :user, :company
def save
return false unless valid?
ApplicationRecord.transaction do
@company = Company.create!(name: company_name)
@user = @company.users.create!(email: email, password: password, role: :owner)
end
true
rescue ActiveRecord::RecordInvalid => e
errors.merge!(e.record.errors)
false
end
end
查询对象
# app/queries/invoices/overdue.rb
module Invoices
class Overdue
def self.call(scope: Invoice.all, as_of: Time.current)
new(scope: scope, as_of: as_of).call
end
def initialize(scope:, as_of:)
@scope = scope
@as_of = as_of
end
def call
scope
.where(status: :sent)
.where(due_date: ..as_of)
.where.not(id: paid_invoice_ids)
.includes(:customer, :line_items)
end
private
attr_reader :scope, :as_of
def paid_invoice_ids
Payment.where(created_at: ..as_of).pluck(:invoice_id)
end
end
end
查询对象接受一个 scope,因此它们是可组合的:Invoices::Overdue.call(scope: current_user.invoices)。
N+1 预防
# 不好:当视图调用 post.author.name 时产生 N+1
@posts = Post.published
# 好:预先加载
@posts = Post.published.includes(:author)
includes 让 Rails 选择 preload 还是 eager_load。当需要基于关联进行过滤时,使用 preload 生成独立查询,使用 eager_load 生成 JOIN。从 Rails 6.1 开始,strict_loading 会在意外延迟加载时抛出异常。
计数器缓存
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
end
add_column :posts, :comments_count, :integer, default: 0, null: false
post.comments_count 变为一个列读取,而不是 COUNT(*)。此示例假设是新表;向已有行的表添加计数器缓存需要进行回填,这超出了本文的范围。
后台任务结构
传递记录 ID,而不是记录本身。重试使得投递是至少一次的,因此任何调用外部服务的任务必须是幂等的——否则在远程调用成功后的瞬时失败会在下一次尝试时重复该效果。
class AccountingExportJob < ApplicationJob
queue_as :exports
retry_on AccountingApi::TransientError, wait: :polynomially_longer, attempts: 5
discard_on AccountingApi::PermanentError
def perform(invoice_id)
invoice = Invoice.find(invoice_id)
export = AccountingExport.create_or_find_by!(
invoice: invoice,
idempotency_key: "invoice-export-#{invoice.id}-#{invoice.updated_at.to_i}"
)
return if export.completed_at?
receipt = AccountingApi.export(invoice, idempotency_key: export.idempotency_key)
export.update!(completed_at: Time.current, external_id: receipt.id)
end
end
add_index :accounting_exports, :idempotency_key, unique: true
唯一索引是保证安全的关键:当两次尝试竞争时,数据库拒绝第二次插入,Active Record 在调用内部解决冲突,返回现有行。这在没有任何任务级重试的情况下发生——上面的 retry_on 仅覆盖 AccountingApi::TransientError。return if export.completed_at? 守卫覆盖了远程调用前的时间窗口;通过将 idempotency_key 传递给 API 覆盖了调用后的时间窗口,因此 API 调用和 update! 之间的崩溃仍然会解析为一次导出。
ViewComponent
# app/components/invoice_status_badge_component.rb
class InvoiceStatusBadgeComponent < ViewComponent::Base
STATUS_CLASSES = {
draft: "bg-gray-100 text-gray-800",
sent: "bg-blue-100 text-blue-800",
paid: "bg-green-100 text-green-800",
overdue: "bg-red-100 text-red-800"
}.freeze
def initialize(invoice:)
@invoice = invoice
end
def call
tag.span(@invoice.status.humanize, class: "rounded-full px-2 py-1 text-sm #{status_class}")
end
private
def status_class
STATUS_CLASSES.fetch(@invoice.status.to_sym, "bg-gray-100")
end
end
<%= render InvoiceStatusBadgeComponent.new(invoice: @invoice) %>
Hotwire
<%# Turbo Frame: 点击编辑仅替换此框架 %>
<%= turbo_frame_tag "invoice_#{@invoice.id}" do %>
<div class="invoice">
<%= link_to "编辑", edit_invoice_path(@invoice) %>
</div>
<% end %>
<%# Turbo Stream: app/views/comments/create.turbo_stream.erb %>
<%= turbo_stream.append "comments", @comment %>
<%= turbo_stream.update "comment_form", partial: "form", locals: { comment: Comment.new } %>
// app/javascript/controllers/copy_to_clipboard_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["source"]
copy() {
navigator.clipboard.writeText(this.sourceTarget.value)
}
}
可接受与不可接受的回调
# 可接受:纯数据规范化
class User < ApplicationRecord
before_validation :normalize_email
private
def normalize_email
self.email = email.to_s.downcase.strip
end
end
# 移至服务:副作用隐藏在回调中
# class User < ApplicationRecord
# after_create :send_welcome_email # 难以选择退出,难以测试
# end
好的 Concern 与坏的 Concern
# 好的:真正的跨切面,可跨无关模型复用
# app/models/concerns/soft_deletable.rb
module SoftDeletable
extend ActiveSupport::Concern
included do
scope :active, -> { where(deleted_at: nil) }
scope :deleted, -> { where.not(deleted_at: nil) }
end
def soft_delete! = update!(deleted_at: Time.current)
def restore! = update!(deleted_at: nil)
end
# 坏的:一个“concern”只被一个模型使用,包含了本该属于它的逻辑
# app/models/concerns/invoice_calculations.rb
module InvoiceCalculations
extend ActiveSupport::Concern
def calculate_total
line_items.sum(&:amount) + tax_total
end
end
# 只有 Invoice 包含了它。它并非跨切面;它是 Invoice 自身的逻辑,
# 为了“精简”模型的外观而隐藏在模块中。应将其放回 Invoice 中。
一个只被一个类使用的 concern 仅仅是移动代码;它应该属于那个类。一个 concern 应该被至少两个不相关的模型使用才有价值。在 app/models/concerns/ 中,如果不是为跨多个模型共享,就要质疑它。