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 在 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。防護措施涵蓋了遠端呼叫之前的時間窗口;將 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 應該封裝一個橫切關注點,可以被多個不相關的模型重複使用。