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),同时能干净地组合记录。
查询对象
对于在控制器或服务之间复用的 ActiveRecord 查询,或者对于复杂到无法使用 scope 的查询,提取一个查询对象,它接受一个 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: "Invoice created"
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: "Invoice created"
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("Notification dispatch failed for invoice #{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 选择预加载还是 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 在 call 内部解决冲突,返回现有行。这发生在没有任何任务级重试的情况下——上面的 retry_on 仅覆盖 AccountingApi::TransientError。守卫条件覆盖了远程调用之前的时间窗口;将 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", 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 应该封装一个跨多个模型的、可复用的行为。