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。Component 可以獨立測試,並明確展示其介面;而帶有深層條件邏輯的部分模板會成為技術債。
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 會在 create_or_find_by! 呼叫內解決衝突,回傳現有的資料列。這不需要任何任務層級的重試——上面的 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:點擊「編輯」只會替換這個 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 引入這個 Concern。它並非橫切關注點;它是 Invoice 自身的邏輯
# 被隱藏在模組中,只是為了讓模型看起來「精簡」。應該把它放回 Invoice 模型上。
一個只被一個類別使用的 Concern 只是在移動程式碼;它應該屬於那個類別。一個 Concern 應該是真正可重複使用的,並且在概念上是橫切的——例如軟刪除、時間戳記錄或版本控制。如果它不是,就把它內聯回去。