iT邦幫忙

2025 iThome 鐵人賽

DAY 8
0
Modern Web

30 天 Rails 新手村:從工作專案學會 Ruby on Rails系列 第 8

Day 7: 模型層設計與業務邏輯 - 讓程式碼說出業務的語言

  • 分享至 

  • xImage
  •  

開場:從分層架構的困惑說起

如果你來自 Express 的世界,你可能習慣了在 controller 或 service 層處理所有業務邏輯,model 只是簡單的資料結構定義。在 Spring Boot 中,你會嚴格區分 Entity、Repository、Service、Controller,每一層都有明確的職責邊界。而在 FastAPI 中,你用 Pydantic 定義資料模型,業務邏輯則散落在不同的依賴注入函數中。

今天我們要探討的是 Rails 如何用完全不同的思維來組織業務邏輯。Rails 相信模型不只是資料的容器,更是業務知識的載體。當你的模型能夠「說出」業務的語言,你的程式碼就不再是技術實作的堆砌,而是業務規則的清晰表達。

這個知識點在我們的 LMS 系統中至關重要。想像一下,當學生註冊課程時,系統需要檢查先修課程、確認名額、計算費用、發送通知郵件。這些業務邏輯應該放在哪裡?Rails 的答案可能會讓你重新思考軟體設計的本質。

概念探索:理解「為什麼」

Rails 的選擇:Fat Model, Skinny Controller

Rails 社群有句名言:「Fat Model, Skinny Controller」。這不是隨意的偏好,而是基於深刻的設計考量。

**Rails 的設計決策:**
- 決策點:將業務邏輯放在模型層
- 歷史背景:源自 Domain-Driven Design 的影響
- 演進過程:從純 ActiveRecord 到引入 Concerns、Service Objects

**與其他框架的對比:**
| 框架 | 設計理念 | 業務邏輯位置 | 優劣權衡 |
|------|----------|--------------|----------|
| Rails | 領域模型驅動 | Model 為主,Service 為輔 | 表達力強,但可能過度耦合 |
| Express | 中介軟體鏈 | Controller/Middleware | 靈活但缺乏規範 |
| Spring Boot | 分層架構 | Service 層 | 職責清晰但可能過度設計 |
| FastAPI | 函數式組合 | 依賴注入函數 | 測試友好但業務概念分散 |

核心原則剖析

原則一:模型即領域

表層理解:多數人認為這只是把函數從 controller 搬到 model。

深層含義:模型應該反映業務領域的概念和規則。當業務專家說「學生註冊課程」時,程式碼應該是 student.enroll_in(course),而不是 EnrollmentService.new.process(student_id, course_id)

實際影響:這種設計讓非技術人員也能理解程式碼的意圖。更重要的是,當業務規則改變時,你知道該去哪裡修改。

原則二:Tell, Don't Ask

常見誤解:很多人以為這只是避免 getter/setter 的使用。

正確理解:物件應該負責自己的行為,而不是暴露內部狀態讓外部操作。不要問物件的狀態然後做決策,而是告訴物件你要做什麼。

實踐指南:

# 錯誤:Ask 模式
if course.students.count < course.max_students
  enrollment = Enrollment.create(student: student, course: course)
  if enrollment.valid?
    EmailService.send_confirmation(student, course)
  end
end

# 正確:Tell 模式
course.enroll(student)
# 所有邏輯都封裝在 enroll 方法內

技術實作:掌握「怎麼做」

漸進式程式碼演示

讓我們透過重構一個實際的 LMS 功能,來理解模型層設計的演進過程。

# 第一步:最簡單的實作
# 初學者常寫出這樣的 controller

class EnrollmentsController < ApplicationController
  def create
    course = Course.find(params[:course_id])
    student = current_user
    
    if course.available_seats > 0
      enrollment = Enrollment.new(
        student: student,
        course: course,
        enrolled_at: Time.current
      )
      
      if enrollment.save
        course.decrement!(:available_seats)
        # 發送郵件
        UserMailer.enrollment_confirmation(student, course).deliver_later
        render json: enrollment
      else
        render json: { errors: enrollment.errors }
      end
    else
      render json: { error: "Course is full" }
    end
  end
end

這段程式碼有什麼問題?Controller 知道太多業務細節:檢查名額、更新座位數、發送郵件。這違反了單一職責原則。

# 第二步:將業務邏輯移到模型
# 開始理解 Fat Model 的概念

class Course < ApplicationRecord
  has_many :enrollments
  has_many :students, through: :enrollments, source: :user
  
  def enroll(student)
    return false if full?
    return false if student_already_enrolled?(student)
    
    transaction do
      enrollments.create!(student: student, enrolled_at: Time.current)
      decrement!(:available_seats)
      UserMailer.enrollment_confirmation(student, self).deliver_later
    end
    
    true
  rescue ActiveRecord::RecordInvalid
    false
  end
  
  def full?
    available_seats <= 0
  end
  
  private
  
  def student_already_enrolled?(student)
    students.exists?(student.id)
  end
end

class EnrollmentsController < ApplicationController
  def create
    course = Course.find(params[:course_id])
    
    if course.enroll(current_user)
      render json: { message: "Successfully enrolled" }
    else
      render json: { error: "Enrollment failed" }, status: 422
    end
  end
end

Controller 變得簡潔了,但 Course 模型開始承擔更多責任。這是好事嗎?讓我們繼續深化。

# 第三步:生產級的實作
# 引入更多業務規則和設計模式

class Course < ApplicationRecord
  has_many :enrollments, dependent: :destroy
  has_many :students, through: :enrollments, source: :user
  has_many :prerequisites, class_name: 'CoursePrerequisite'
  has_many :required_courses, through: :prerequisites, source: :required_course
  
  # 使用 scope 定義常用查詢
  scope :published, -> { where(published: true) }
  scope :upcoming, -> { where('start_date > ?', Date.current) }
  scope :in_progress, -> { where('start_date <= ? AND end_date >= ?', Date.current, Date.current) }
  scope :with_seats, -> { where('available_seats > 0') }
  
  # 業務規則的明確表達
  def enrollable_by?(student)
    published? &&
      !full? &&
      !student_enrolled?(student) &&
      prerequisites_met_by?(student) &&
      !enrollment_deadline_passed?
  end
  
  def enroll(student)
    # 使用 Enrollment 作為業務實體,而不只是關聯表
    enrollment = enrollments.build(student: student)
    enrollment.process!
  end
  
  def full?
    available_seats <= 0
  end
  
  def student_enrolled?(student)
    enrollments.active.exists?(student: student)
  end
  
  def prerequisites_met_by?(student)
    return true if required_courses.empty?
    
    completed_course_ids = student.completed_courses.pluck(:id)
    required_course_ids = required_courses.pluck(:id)
    
    (required_course_ids - completed_course_ids).empty?
  end
  
  def enrollment_deadline_passed?
    enrollment_deadline.present? && enrollment_deadline < Date.current
  end
  
  # 類別方法處理集合層級的業務邏輯
  def self.recommended_for(student)
    # 複雜的推薦邏輯
    base_query = published.upcoming.with_seats
    
    # 根據學生的學習歷史和興趣推薦
    if student.has_learning_history?
      base_query.joins(:categories)
                .where(categories: { id: student.interested_category_ids })
                .where.not(id: student.enrolled_course_ids)
    else
      base_query.where(level: 'beginner')
    end
  end
end

# Enrollment 不只是關聯,更是業務實體
class Enrollment < ApplicationRecord
  belongs_to :student, class_name: 'User'
  belongs_to :course
  
  # 狀態機管理註冊流程
  enum status: {
    pending: 0,
    active: 1,
    completed: 2,
    dropped: 3,
    failed: 4
  }
  
  # Callbacks 處理副作用
  after_create :decrement_available_seats
  after_create :send_confirmation_email
  after_destroy :increment_available_seats
  
  # 註冊不只是創建記錄,而是完整的業務流程
  def process!
    transaction do
      validate_enrollment!
      save!
      trigger_post_enrollment_actions
    end
  end
  
  private
  
  def validate_enrollment!
    raise EnrollmentError, "Course is not enrollable" unless course.enrollable_by?(student)
  end
  
  def decrement_available_seats
    course.decrement!(:available_seats) if pending?
  end
  
  def increment_available_seats
    course.increment!(:available_seats) if active? || pending?
  end
  
  def send_confirmation_email
    EnrollmentMailer.confirmation(self).deliver_later
  end
  
  def trigger_post_enrollment_actions
    # 可以在這裡發布事件,觸發其他系統的反應
    Rails.logger.info "Enrollment created: #{id}"
    # EventBus.publish('enrollment.created', enrollment_id: id)
  end
end

關鍵決策點分析

決策點:使用 Concerns 還是 Service Objects

隨著模型變得「肥胖」,我們需要組織程式碼的策略。Rails 提供了 Concerns,但社群也發展出 Service Objects 模式。

# 方案 A:使用 Concerns 組織相關功能
module Enrollable
  extend ActiveSupport::Concern
  
  included do
    has_many :enrollments
    has_many :students, through: :enrollments
    
    scope :with_seats, -> { where('available_seats > 0') }
  end
  
  def enroll(student)
    # 註冊邏輯
  end
  
  def enrollable_by?(student)
    # 檢查邏輯
  end
end

class Course < ApplicationRecord
  include Enrollable
  include Schedulable
  include Gradable
end

# 方案 B:使用 Service Objects 處理複雜流程
class EnrollmentService
  def initialize(student, course)
    @student = student
    @course = course
  end
  
  def call
    return failure(:not_enrollable) unless @course.enrollable_by?(@student)
    
    ActiveRecord::Base.transaction do
      enrollment = create_enrollment
      process_payment if @course.paid?
      send_notifications
      update_analytics
      
      success(enrollment)
    end
  rescue => e
    failure(:system_error, e.message)
  end
  
  private
  
  def create_enrollment
    @course.enrollments.create!(student: @student)
  end
  
  # 其他私有方法...
end
維度 Concerns Service Objects
實作複雜度 低,Rails 原生支援 中,需要定義規範
測試難度 較難隔離測試 容易單元測試
程式碼組織 可能造成模型過大 清晰的職責分離
可重用性 高,可跨模型共享 中,特定用途

決策建議:

  • 如果邏輯與模型緊密相關且會重複使用,選擇 Concerns
  • 如果是跨多個模型的複雜流程,選擇 Service Objects
  • 兩者可以並用,不是互斥的選擇

實戰應用:LMS 系統案例

在 LMS 中的應用場景

在我們的 LMS 系統中,模型層設計的品質直接影響系統的可維護性。讓我們看看實際的業務需求如何轉化為優雅的模型設計。

功能需求:
LMS 需要支援複雜的學習路徑管理。學生完成某個課程後,系統要自動解鎖後續課程、更新學習進度、計算成就點數、可能頒發證書。

實作挑戰:

  • 挑戰 1:學習路徑的依賴關係可能很複雜(多對多的先修關係)
  • 挑戰 2:進度計算需要考慮不同類型的學習活動(影片、作業、測驗)
  • 挑戰 3:成就系統需要即時反應,但計算可能耗時

程式碼實作

# LMS 系統中的實際應用
module LMS
  # 學習路徑的模型設計
  class LearningPath < ApplicationRecord
    has_many :path_courses, -> { order(:position) }
    has_many :courses, through: :path_courses
    has_many :student_progresses
    
    # 使用類別方法提供查詢介面
    def self.for_level(level)
      where(difficulty_level: level)
    end
    
    def self.in_category(category)
      joins(courses: :categories).where(categories: { id: category.id }).distinct
    end
    
    # 業務邏輯:計算路徑完成度
    def completion_percentage_for(student)
      return 0 unless enrolled_by?(student)
      
      progress = student_progresses.find_by(student: student)
      progress&.percentage || 0
    end
    
    # 業務邏輯:檢查是否可以開始
    def startable_by?(student)
      return false if student.learning_paths.include?(self)
      
      prerequisites.all? { |prereq| prereq.completed_by?(student) }
    end
    
    def enroll(student)
      transaction do
        progress = student_progresses.create!(
          student: student,
          started_at: Time.current,
          status: 'in_progress'
        )
        
        # 自動註冊第一個課程
        first_course = courses.first
        first_course.enroll(student) if first_course.present?
        
        progress
      end
    end
  end
  
  # 學習進度追蹤
  class StudentProgress < ApplicationRecord
    belongs_to :student, class_name: 'User'
    belongs_to :learning_path
    has_many :course_progresses
    
    # 狀態管理
    enum status: {
      not_started: 0,
      in_progress: 1,
      completed: 2,
      abandoned: 3
    }
    
    # 自動更新進度
    after_save :check_completion
    after_save :award_achievements
    
    def update_progress!
      total_courses = learning_path.courses.count
      completed_courses = course_progresses.completed.count
      
      self.percentage = (completed_courses.to_f / total_courses * 100).round(2)
      self.status = 'completed' if percentage >= 100
      save!
    end
    
    private
    
    def check_completion
      return unless completed?
      
      # 解鎖下一個學習路徑
      unlock_next_paths
      
      # 頒發證書
      CertificateGeneratorJob.perform_later(self)
    end
    
    def unlock_next_paths
      # 找出依賴於當前路徑的其他路徑
      dependent_paths = LearningPath.where(id: learning_path.unlocks_path_ids)
      
      dependent_paths.each do |path|
        if path.startable_by?(student)
          # 發送通知
          PathUnlockedNotification.with(path: path).deliver(student)
        end
      end
    end
    
    def award_achievements
      AchievementCalculator.new(student, self).calculate
    end
  end
  
  # 成就系統(展示如何結合 Service Object)
  class AchievementCalculator
    def initialize(student, progress)
      @student = student
      @progress = progress
    end
    
    def calculate
      achievements = []
      
      achievements << award_speed_demon if completed_quickly?
      achievements << award_perfectionist if perfect_score?
      achievements << award_dedicated_learner if consistent_learning?
      
      achievements.compact
    end
    
    private
    
    def completed_quickly?
      return false unless @progress.completed?
      
      expected_duration = @progress.learning_path.expected_duration_days
      actual_duration = (@progress.completed_at - @progress.started_at).to_i / 1.day
      
      actual_duration < expected_duration * 0.8
    end
    
    def perfect_score?
      @progress.course_progresses.all? { |cp| cp.score >= 95 }
    end
    
    def consistent_learning?
      # 檢查是否每天都有學習記錄
      learning_days = @student.learning_activities
                              .where('created_at >= ?', 30.days.ago)
                              .pluck(:created_at)
                              .map(&:to_date)
                              .uniq
                              .count
      
      learning_days >= 25
    end
    
    def award_speed_demon
      @student.achievements.find_or_create_by(
        name: 'Speed Demon',
        description: 'Completed learning path 20% faster than expected'
      )
    end
    
    # 其他成就方法...
  end
end

架構影響分析

讓我們看看今天學習的模型層設計如何影響整個 LMS 系統架構:

%%{init: {'theme':'base', 'themeVariables': { 'fontSize': '16px'}}}%%
graph TB
    subgraph "LMS 系統架構"
        Controller[控制器層<br/>簡潔的請求處理]
        Model[模型層<br/>豐富的業務邏輯]
        Service[服務層<br/>複雜流程編排]
        Job[背景任務<br/>非同步處理]
        DB[(資料庫)]
        
        Controller -->|調用業務方法| Model
        Controller -->|複雜流程| Service
        Service -->|協調| Model
        Model -->|觸發| Job
        Model -->|持久化| DB
        Job -->|更新| Model
    end
    
    style Model fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px,color:#000
    style Controller fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000
    style Service fill:#fff9c4,stroke:#f57f17,stroke-width:2px,color:#000
    style Job fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#000

深度思考:常見陷阱與最佳實踐

轉職者常見誤區

誤區 1:過度使用 callbacks

來自其他框架的開發者看到 Rails 的 callbacks(before_save、after_create 等)時,往往會過度使用它們,導致難以追蹤的副作用。

錯誤表現:

class User < ApplicationRecord
  after_create :send_welcome_email
  after_create :create_default_settings
  after_create :sync_to_external_service
  after_update :update_search_index
  after_update :invalidate_cache
  # 太多 callbacks 讓模型變得不可預測
end

根本原因:callbacks 看起來很方便,自動處理各種邏輯。

正確做法:callbacks 應該只用於與模型狀態直接相關的邏輯。外部服務呼叫、郵件發送等應該明確呼叫或使用事件系統。

思維轉換:從「自動化一切」轉變為「明確表達意圖」。

誤區 2:忽視查詢效能

Fat Model 很容易導致效能問題,特別是當模型方法中包含資料庫查詢時。

錯誤表現:

class Course < ApplicationRecord
  def instructor_name
    instructor.name  # 每次呼叫都會查詢資料庫
  end
  
  def category_names
    categories.map(&:name)  # N+1 查詢問題
  end
end

根本原因:把模型當作純物件,忽視了它背後的資料庫操作。

正確做法:

class Course < ApplicationRecord
  # 使用 delegate 避免重複查詢
  delegate :name, to: :instructor, prefix: true, allow_nil: true
  
  # 使用 includes 預載入關聯
  scope :with_categories, -> { includes(:categories) }
  
  def category_names
    # 假設已經預載入
    categories.map(&:name)
  end
end

# 在 controller 中
@courses = Course.with_categories.includes(:instructor)

效能與優化考量

當模型變得複雜時,效能優化變得至關重要:

# 使用 counter_cache 優化計數查詢
class Course < ApplicationRecord
  has_many :enrollments, counter_cache: true
  # 需要在 courses 表加入 enrollments_count 欄位
end

# 使用 memoization 避免重複計算
class StudentProgress < ApplicationRecord
  def expensive_calculation
    @expensive_calculation ||= begin
      # 複雜的計算邏輯
    end
  end
end

# 使用批次載入避免記憶體問題
Course.find_each(batch_size: 100) do |course|
  course.update_statistics
end

測試策略

測試 Fat Model 需要不同的策略:

# 測試不只是驗證,更是設計工具
RSpec.describe Course, type: :model do
  describe '#enroll' do
    let(:course) { create(:course, available_seats: 1) }
    let(:student) { create(:user) }
    
    context 'when course has available seats' do
      it 'creates enrollment' do
        expect { course.enroll(student) }
          .to change { course.enrollments.count }.by(1)
      end
      
      it 'decrements available seats' do
        expect { course.enroll(student) }
          .to change { course.reload.available_seats }.by(-1)
      end
      
      it 'sends confirmation email' do
        expect { course.enroll(student) }
          .to have_enqueued_job(ActionMailer::MailDeliveryJob)
      end
    end
    
    context 'when course is full' do
      before { course.update!(available_seats: 0) }
      
      it 'returns false' do
        expect(course.enroll(student)).to be_falsey
      end
      
      it 'does not create enrollment' do
        expect { course.enroll(student) }
          .not_to change { Enrollment.count }
      end
    end
    
    # 測試邊界情況
    context 'when student already enrolled' do
      before { course.enroll(student) }
      
      it 'does not create duplicate enrollment' do
        expect { course.enroll(student) }
          .not_to change { Enrollment.count }
      end
    end
  end
  
  # 測試 scope
  describe 'scopes' do
    describe '.with_seats' do
      let!(:available_course) { create(:course, available_seats: 10) }
      let!(:full_course) { create(:course, available_seats: 0) }
      
      it 'returns only courses with available seats' do
        expect(Course.with_seats).to include(available_course)
        expect(Course.with_seats).not_to include(full_course)
      end
    end
  end
end

實踐練習:動手鞏固

基礎練習(預計 30 分鐘)

練習目標:
熟悉 Fat Model 的基本概念,練習將業務邏輯封裝到模型中。

練習內容:
建立一個圖書館借閱系統的模型:

  1. Book 模型:管理圖書資訊
  2. User 模型:管理讀者資訊
  3. Loan 模型:管理借閱記錄

實作以下業務邏輯:

  • 每本書同時只能被一人借閱
  • 每個讀者最多借 5 本書
  • 借閱期限為 14 天
  • 逾期歸還要計算罰金(每天 1 元)

解題思路說明:

首先,我們需要思考這三個模型之間的關係。Book 和 User 透過 Loan 形成多對多關係,但 Loan 不只是關聯表,它包含了借閱日期、歸還日期等重要業務資訊。這正是我們今天學習的重點:模型不只是資料容器,更是業務邏輯的載體。

讓我們從資料庫設計開始,這會幫助我們理解模型的結構:

# db/migrate/xxx_create_library_system.rb
class CreateLibrarySystem < ActiveRecord::Migration[7.0]
  def change
    create_table :books do |t|
      t.string :title, null: false
      t.string :author
      t.string :isbn, index: { unique: true }
      t.boolean :available, default: true
      t.timestamps
    end
    
    create_table :users do |t|
      t.string :name, null: false
      t.string :email, null: false, index: { unique: true }
      t.string :library_card_number, index: { unique: true }
      t.integer :active_loans_count, default: 0  # counter_cache
      t.timestamps
    end
    
    create_table :loans do |t|
      t.references :book, null: false, foreign_key: true
      t.references :user, null: false, foreign_key: true
      t.date :borrowed_at, null: false
      t.date :due_date, null: false
      t.date :returned_at
      t.decimal :fine_amount, precision: 10, scale: 2, default: 0
      t.timestamps
    end
    
    add_index :loans, [:book_id, :returned_at]
    add_index :loans, [:user_id, :returned_at]
  end
end

完整解答:

# app/models/book.rb
class Book < ApplicationRecord
  # 關聯設定
  has_many :loans
  has_one :current_loan, -> { where(returned_at: nil) }, class_name: 'Loan'
  has_many :borrowers, through: :loans, source: :user
  
  # 驗證規則
  validates :title, presence: true
  validates :isbn, uniqueness: true, allow_blank: true
  
  # Scopes 定義常用查詢
  scope :available, -> { where(available: true) }
  scope :borrowed, -> { where(available: false) }
  scope :overdue, -> { joins(:current_loan).where('loans.due_date < ?', Date.current) }
  
  # 核心業務方法:檢查是否可借閱
  def available?
    available && current_loan.nil?
  end
  
  # 核心業務方法:借書
  def borrow_by(user)
    # 使用 transaction 確保資料一致性
    transaction do
      # 業務規則檢查
      raise BorrowingError, "書籍不可借閱" unless available?
      raise BorrowingError, "使用者已達借閱上限" unless user.can_borrow_more?
      raise BorrowingError, "使用者有逾期未還的書籍" if user.has_overdue_loans?
      
      # 建立借閱記錄
      loan = loans.create!(
        user: user,
        borrowed_at: Date.current,
        due_date: Date.current + 14.days  # 借閱期限 14 天
      )
      
      # 更新書籍狀態
      update!(available: false)
      
      # 回傳借閱記錄
      loan
    end
  rescue ActiveRecord::RecordInvalid => e
    raise BorrowingError, "借閱失敗:#{e.message}"
  end
  
  # 核心業務方法:還書
  def return_by(user)
    transaction do
      # 找出當前借閱記錄
      loan = current_loan
      
      # 業務規則檢查
      raise ReturningError, "此書未被借出" unless loan
      raise ReturningError, "此書不是由該使用者借出" unless loan.user == user
      
      # 處理歸還
      loan.process_return!
      
      # 更新書籍狀態
      update!(available: true)
      
      loan
    end
  end
  
  # 自訂錯誤類別
  class BorrowingError < StandardError; end
  class ReturningError < StandardError; end
end

# app/models/user.rb
class User < ApplicationRecord
  # 關聯設定,使用 counter_cache 優化效能
  has_many :loans
  has_many :active_loans, -> { where(returned_at: nil) }, 
           class_name: 'Loan',
           counter_cache: :active_loans_count
  has_many :borrowed_books, through: :active_loans, source: :book
  
  # 驗證規則
  validates :name, :email, presence: true
  validates :email, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :library_card_number, uniqueness: true
  
  # 常數定義
  MAX_CONCURRENT_LOANS = 5
  
  # 業務查詢方法
  def can_borrow_more?
    active_loans_count < MAX_CONCURRENT_LOANS
  end
  
  def has_overdue_loans?
    active_loans.any?(&:overdue?)
  end
  
  def total_fines_owed
    active_loans.sum(&:calculate_fine)
  end
  
  # 取得借閱歷史統計
  def borrowing_statistics
    {
      total_borrowed: loans.count,
      currently_borrowing: active_loans_count,
      total_fines_paid: loans.where.not(returned_at: nil).sum(:fine_amount),
      favorite_author: calculate_favorite_author
    }
  end
  
  private
  
  def calculate_favorite_author
    # 找出最常借閱的作者
    loans.joins(:book)
         .group('books.author')
         .count
         .max_by { |_, count| count }
         &.first
  end
end

# app/models/loan.rb
class Loan < ApplicationRecord
  # 關聯設定
  belongs_to :book
  belongs_to :user, counter_cache: :active_loans_count
  
  # 驗證規則
  validates :borrowed_at, :due_date, presence: true
  validate :due_date_must_be_after_borrowed_date
  validate :book_must_be_available, on: :create
  
  # Callbacks
  before_validation :set_dates, on: :create
  
  # Scopes
  scope :active, -> { where(returned_at: nil) }
  scope :overdue, -> { active.where('due_date < ?', Date.current) }
  scope :returned, -> { where.not(returned_at: nil) }
  
  # 常數
  LOAN_PERIOD = 14.days
  DAILY_FINE_RATE = 1.0  # 每天罰金 1 元
  
  # 檢查是否逾期
  def overdue?
    return false if returned_at.present?
    due_date < Date.current
  end
  
  # 計算逾期天數
  def days_overdue
    return 0 unless overdue?
    (Date.current - due_date).to_i
  end
  
  # 計算罰金
  def calculate_fine
    return fine_amount if returned_at.present?  # 已歸還則返回記錄的罰金
    return 0 unless overdue?
    
    days_overdue * DAILY_FINE_RATE
  end
  
  # 處理歸還流程
  def process_return!
    raise "此書已歸還" if returned_at.present?
    
    self.returned_at = Date.current
    self.fine_amount = calculate_fine
    save!
    
    # 如果有罰金,可以在這裡觸發通知
    if fine_amount > 0
      Rails.logger.info "使用者 #{user.name} 需支付逾期罰金 #{fine_amount} 元"
      # FineNotificationJob.perform_later(self)
    end
  end
  
  private
  
  def set_dates
    self.borrowed_at ||= Date.current
    self.due_date ||= borrowed_at + LOAN_PERIOD
  end
  
  def due_date_must_be_after_borrowed_date
    return unless borrowed_at && due_date
    
    if due_date <= borrowed_at
      errors.add(:due_date, "必須在借閱日期之後")
    end
  end
  
  def book_must_be_available
    if book && !book.available?
      errors.add(:book, "已被借出")
    end
  end
end

使用範例與測試:

# 實際使用範例
book = Book.find(1)
user = User.find(1)

# 借書流程
begin
  loan = book.borrow_by(user)
  puts "借閱成功!請於 #{loan.due_date} 前歸還"
rescue Book::BorrowingError => e
  puts "借閱失敗:#{e.message}"
end

# 檢查書籍狀態
book.available?  # => false
book.current_loan.overdue?  # => 可能是 true 或 false

# 還書流程
begin
  loan = book.return_by(user)
  if loan.fine_amount > 0
    puts "歸還成功,但需支付逾期罰金 #{loan.fine_amount} 元"
  else
    puts "歸還成功!"
  end
rescue Book::ReturningError => e
  puts "歸還失敗:#{e.message}"
end

# 查詢使用者狀態
user.can_borrow_more?  # => true 或 false
user.has_overdue_loans?  # => true 或 false
user.total_fines_owed  # => 總罰金金額

進階挑戰(預計 1 小時)

挑戰目標:
設計 LMS 系統的作業提交與批改流程。

挑戰內容:
實作以下功能:

  1. Assignment 模型:作業定義(截止日期、總分、評分標準)
  2. Submission 模型:學生提交(提交時間、內容、分數、回饋)
  3. 自動評分系統:選擇題自動批改
  4. 同儕評審:學生互相評分
  5. 延遲提交處理:超過截止日期扣分

解題思路說明:

這個挑戰涉及更複雜的業務邏輯。我們需要處理不同類型的作業(選擇題、問答題、程式作業),每種類型有不同的評分方式。同時要考慮同儕評審的流程,這涉及多個學生之間的互動。延遲提交的扣分規則也需要靈活配置。

讓我們設計一個可擴展的架構,使用 STI(單表繼承)來處理不同類型的作業,用狀態機管理提交的生命週期。

完整解答:

# db/migrate/xxx_create_assignment_system.rb
class CreateAssignmentSystem < ActiveRecord::Migration[7.0]
  def change
    create_table :assignments do |t|
      t.references :course, null: false, foreign_key: true
      t.string :title, null: false
      t.text :description
      t.string :type  # STI 欄位
      t.datetime :due_date, null: false
      t.integer :max_score, default: 100
      t.boolean :peer_review_enabled, default: false
      t.integer :peer_reviews_required, default: 3
      t.jsonb :grading_criteria, default: {}  # 儲存評分標準
      t.jsonb :answer_key, default: {}  # 儲存選擇題答案
      t.integer :late_penalty_percent, default: 10  # 延遲提交扣分比例
      t.integer :max_late_days, default: 7  # 最多延遲天數
      t.timestamps
    end
    
    create_table :submissions do |t|
      t.references :assignment, null: false, foreign_key: true
      t.references :student, null: false, foreign_key: { to_table: :users }
      t.text :content
      t.jsonb :answers, default: {}  # 儲存選擇題答案
      t.datetime :submitted_at
      t.integer :status, default: 0  # enum: draft, submitted, grading, graded
      t.decimal :raw_score, precision: 5, scale: 2
      t.decimal :final_score, precision: 5, scale: 2
      t.decimal :late_penalty, precision: 5, scale: 2, default: 0
      t.text :instructor_feedback
      t.timestamps
    end
    
    create_table :peer_reviews do |t|
      t.references :submission, null: false, foreign_key: true
      t.references :reviewer, null: false, foreign_key: { to_table: :users }
      t.decimal :score, precision: 5, scale: 2
      t.text :feedback
      t.jsonb :rubric_scores, default: {}  # 各項評分標準的分數
      t.integer :status, default: 0  # enum: assigned, completed
      t.timestamps
    end
    
    add_index :assignments, [:course_id, :due_date]
    add_index :submissions, [:assignment_id, :student_id], unique: true
    add_index :submissions, :status
  end
end

# app/models/assignment.rb
class Assignment < ApplicationRecord
  # 使用 STI 處理不同類型的作業
  self.inheritance_column = :type
  
  # 關聯
  belongs_to :course
  has_many :submissions, dependent: :destroy
  
  # 驗證
  validates :title, :due_date, :max_score, presence: true
  validates :max_score, numericality: { greater_than: 0 }
  validates :late_penalty_percent, numericality: { in: 0..100 }
  
  # Scopes
  scope :upcoming, -> { where('due_date > ?', Time.current) }
  scope :past_due, -> { where('due_date < ?', Time.current) }
  scope :with_peer_review, -> { where(peer_review_enabled: true) }
  
  # 檢查是否過期
  def past_due?
    due_date < Time.current
  end
  
  # 計算延遲天數
  def days_late_for(submitted_at)
    return 0 if submitted_at <= due_date
    ((submitted_at - due_date) / 1.day).ceil
  end
  
  # 計算延遲扣分
  def calculate_late_penalty(submitted_at)
    days_late = days_late_for(submitted_at)
    return 0 if days_late == 0
    return 100 if days_late > max_late_days  # 超過最大延遲天數,0分
    
    days_late * late_penalty_percent
  end
  
  # 檢查學生是否可以提交
  def can_submit?(student, check_time = Time.current)
    return false if days_late_for(check_time) > max_late_days
    
    # 檢查是否已經提交過(除非是草稿狀態)
    existing = submissions.find_by(student: student)
    existing.nil? || existing.draft?
  end
  
  # 為學生創建或取得提交
  def submission_for(student)
    submissions.find_or_initialize_by(student: student)
  end
  
  # 分配同儕評審
  def assign_peer_reviews!
    return unless peer_review_enabled
    
    submissions.submitted.each do |submission|
      assign_reviewers_for(submission)
    end
  end
  
  private
  
  def assign_reviewers_for(submission)
    # 找出可以評審的學生(排除自己)
    potential_reviewers = course.students.where.not(id: submission.student_id)
    
    # 隨機選擇評審者
    selected_reviewers = potential_reviewers.sample(peer_reviews_required)
    
    selected_reviewers.each do |reviewer|
      submission.peer_reviews.create!(
        reviewer: reviewer,
        status: 'assigned'
      )
    end
  end
end

# app/models/assignments/multiple_choice_assignment.rb
class MultipleChoiceAssignment < Assignment
  # 選擇題作業的特殊行為
  
  # 自動評分
  def auto_grade(submission)
    return 0 if answer_key.blank?
    
    correct_count = 0
    total_questions = answer_key.keys.length
    
    answer_key.each do |question_id, correct_answer|
      if submission.answers[question_id] == correct_answer
        correct_count += 1
      end
    end
    
    (correct_count.to_f / total_questions * max_score).round(2)
  end
  
  # 驗證答案格式
  def validate_submission_format(submission)
    return false if submission.answers.blank?
    
    # 確保所有題目都有答案
    answer_key.keys.all? { |key| submission.answers.key?(key) }
  end
end

# app/models/assignments/essay_assignment.rb
class EssayAssignment < Assignment
  # 問答題作業的特殊行為
  
  # 檢查字數要求
  def meets_requirements?(submission)
    return true if grading_criteria['min_words'].blank?
    
    word_count = submission.content.split.length
    word_count >= grading_criteria['min_words'].to_i
  end
  
  # 取得評分標準項目
  def rubric_items
    grading_criteria['rubric'] || {}
  end
end

# app/models/submission.rb
class Submission < ApplicationRecord
  # 關聯
  belongs_to :assignment
  belongs_to :student, class_name: 'User'
  has_many :peer_reviews, dependent: :destroy
  
  # 狀態管理
  enum status: {
    draft: 0,
    submitted: 1,
    grading: 2,
    graded: 3
  }
  
  # 驗證
  validates :student_id, uniqueness: { scope: :assignment_id }
  validate :cannot_submit_after_max_late_days, if: :submitted?
  
  # Callbacks
  before_save :calculate_late_penalty, if: :submitted?
  after_save :trigger_auto_grading, if: :just_submitted?
  
  # Scopes
  scope :on_time, -> { where('submitted_at <= assignments.due_date') }
  scope :late, -> { where('submitted_at > assignments.due_date') }
  scope :needs_grading, -> { submitted.where(final_score: nil) }
  
  # 提交作業
  def submit!(content_params = {})
    transaction do
      # 檢查是否可以提交
      raise SubmissionError, "無法提交" unless assignment.can_submit?(student)
      
      # 更新內容
      self.content = content_params[:content] if content_params[:content]
      self.answers = content_params[:answers] if content_params[:answers]
      
      # 更新狀態和時間
      self.status = 'submitted'
      self.submitted_at = Time.current
      
      save!
      
      # 如果是選擇題,立即自動評分
      if assignment.is_a?(MultipleChoiceAssignment)
        process_auto_grading!
      end
    end
  end
  
  # 處理自動評分
  def process_auto_grading!
    return unless assignment.is_a?(MultipleChoiceAssignment)
    
    self.status = 'grading'
    self.raw_score = assignment.auto_grade(self)
    self.final_score = calculate_final_score
    self.status = 'graded'
    save!
  end
  
  # 處理人工評分
  def grade!(score, feedback = nil)
    transaction do
      self.raw_score = score
      self.instructor_feedback = feedback
      self.final_score = calculate_final_score
      self.status = 'graded'
      save!
    end
  end
  
  # 計算最終分數(包含延遲扣分和同儕評分)
  def calculate_final_score
    return nil if raw_score.nil?
    
    score = raw_score
    
    # 應用延遲扣分
    score = score * (100 - late_penalty) / 100.0
    
    # 如果有同儕評分,計算平均值
    if assignment.peer_review_enabled && peer_reviews.completed.any?
      peer_average = peer_reviews.completed.average(:score)
      # 假設同儕評分佔 30%,講師評分佔 70%
      score = score * 0.7 + peer_average * 0.3
    end
    
    [score, 0].max.round(2)  # 確保分數不為負
  end
  
  # 是否延遲提交
  def late?
    return false unless submitted_at
    submitted_at > assignment.due_date
  end
  
  # 取得延遲天數
  def days_late
    return 0 unless late?
    assignment.days_late_for(submitted_at)
  end
  
  private
  
  def calculate_late_penalty
    return unless submitted_at
    self.late_penalty = assignment.calculate_late_penalty(submitted_at)
  end
  
  def cannot_submit_after_max_late_days
    if submitted_at && assignment.days_late_for(submitted_at) > assignment.max_late_days
      errors.add(:submitted_at, "已超過最大延遲提交期限")
    end
  end
  
  def just_submitted?
    saved_change_to_status? && submitted?
  end
  
  def trigger_auto_grading
    # 可以改為背景任務
    AutoGradingJob.perform_later(self) if assignment.is_a?(MultipleChoiceAssignment)
  end
  
  class SubmissionError < StandardError; end
end

# app/models/peer_review.rb
class PeerReview < ApplicationRecord
  # 關聯
  belongs_to :submission
  belongs_to :reviewer, class_name: 'User'
  has_one :assignment, through: :submission
  
  # 狀態
  enum status: {
    assigned: 0,
    completed: 1
  }
  
  # 驗證
  validates :reviewer_id, uniqueness: { scope: :submission_id }
  validates :score, numericality: { in: 0..100 }, allow_nil: true
  validate :cannot_review_own_submission
  validate :score_required_when_completed
  
  # Callbacks
  after_save :update_submission_if_all_reviews_complete
  
  # 完成評審
  def complete!(score:, feedback:, rubric_scores: {})
    transaction do
      self.score = score
      self.feedback = feedback
      self.rubric_scores = rubric_scores
      self.status = 'completed'
      save!
    end
  end
  
  # 計算基於評分標準的分數
  def calculate_rubric_score
    return nil if rubric_scores.blank?
    
    total = rubric_scores.values.sum.to_f
    max_possible = assignment.rubric_items.length * 100  # 假設每項滿分 100
    
    (total / max_possible * assignment.max_score).round(2)
  end
  
  private
  
  def cannot_review_own_submission
    if reviewer_id == submission&.student_id
      errors.add(:reviewer, "不能評審自己的作業")
    end
  end
  
  def score_required_when_completed
    if completed? && score.nil?
      errors.add(:score, "完成評審時必須給分")
    end
  end
  
  def update_submission_if_all_reviews_complete
    return unless completed?
    
    if submission.peer_reviews.assigned.none?
      # 所有評審都完成了,重新計算最終分數
      submission.update!(final_score: submission.calculate_final_score)
    end
  end
end

使用範例與測試情境:

# 建立作業
assignment = MultipleChoiceAssignment.create!(
  course: course,
  title: "第一週測驗",
  due_date: 3.days.from_now,
  max_score: 100,
  answer_key: {
    "q1" => "A",
    "q2" => "C",
    "q3" => "B"
  },
  late_penalty_percent: 10,  # 每天扣 10%
  max_late_days: 7
)

# 學生提交作業
student = User.find(1)
submission = assignment.submission_for(student)

# 準時提交
submission.submit!(
  answers: {
    "q1" => "A",  # 正確
    "q2" => "B",  # 錯誤
    "q3" => "B"   # 正確
  }
)
puts "分數: #{submission.final_score}"  # 66.67

# 延遲提交的情況
assignment2 = EssayAssignment.create!(
  course: course,
  title: "期中報告",
  due_date: 1.day.ago,  # 已過期
  max_score: 100,
  peer_review_enabled: true,
  peer_reviews_required: 3,
  grading_criteria: {
    min_words: 500,
    rubric: {
      "論點清晰度" => 30,
      "論據支撐" => 30,
      "文字表達" => 20,
      "格式規範" => 20
    }
  }
)

# 延遲一天提交
submission2 = assignment2.submission_for(student)
submission2.submit!(content: "這是一篇長文章..." * 100)
puts "延遲扣分: #{submission2.late_penalty}%"  # 10%

# 同儕評審流程
assignment2.assign_peer_reviews!

# 評審者完成評審
reviewer = User.find(2)
peer_review = submission2.peer_reviews.find_by(reviewer: reviewer)
peer_review.complete!(
  score: 85,
  feedback: "論點清晰,但需要更多實例支撐",
  rubric_scores: {
    "論點清晰度" => 90,
    "論據支撐" => 70,
    "文字表達" => 85,
    "格式規範" => 95
  }
)

# 講師最終評分
submission2.grade!(88, "很好的分析,注意引用格式")
puts "最終分數: #{submission2.final_score}"  # 考慮延遲扣分和同儕評分

評估標準檢查:

這個解答展示了幾個重要的設計原則。首先,業務邏輯確實封裝在模型中,控制器只需要呼叫簡單的方法如 submit!grade!。其次,我們避免了控制器的複雜邏輯,所有的業務規則檢查都在模型層完成。第三,測試覆蓋考慮了正常流程、延遲提交、自動評分、同儕評審等多種情境。最後,效能優化方面,我們使用了適當的索引和避免 N+1 查詢的設計。

透過這兩個練習,你應該能深刻理解 Fat Model 的設計理念:讓模型不只是資料的容器,而是業務邏輯的載體,讓程式碼真正說出業務的語言。

知識連結:螺旋式深化

回顧與連結

與前期內容的連結:

  • Day 4 的 ActiveRecord 基礎:今天深化了模型不只是資料映射的理解
  • Day 5 的 RESTful 設計:模型的業務方法支撐了資源的操作
  • Day 6 的控制器模式:理解了為什麼要保持控制器簡潔

對後續內容的鋪墊:

  • Day 8 的進階關聯:複雜的業務邏輯需要精心設計的關聯結構
  • Day 13 的測試實踐:Fat Model 的測試策略會更加重要
  • Day 22-23 的 LMS 實作:今天的設計模式會大量應用

知識地圖

%%{init: {'theme':'base', 'themeVariables': { 'fontSize': '14px'}}}%%
graph LR
    subgraph "知識脈絡"
        Past[Day 6:控制器設計]
        Today[Day 7:模型層設計]
        Tomorrow[Day 8:進階關聯]
        LMS[Week 4:LMS 整合]
        
        Past -->|職責分離| Today
        Today -->|深化理解| Tomorrow
        Tomorrow -->|實戰應用| LMS
    end
    
    style Today fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px,color:#000
    style Past fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000
    style Tomorrow fill:#fff9c4,stroke:#f57f17,stroke-width:2px,color:#000
    style LMS fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#000

總結:內化與展望

核心收穫

經過今天的學習,我們在三個層次上都有所收穫:

知識層面:
學到了 Fat Model, Skinny Controller 的設計模式,理解了如何使用 scopes、class methods、concerns 來組織程式碼,掌握了在模型中封裝業務邏輯的技巧。

思維層面:
理解了模型不只是資料容器,更是業務知識的載體。程式碼應該用業務的語言來表達,而不是技術實作的堆砌。這種思維方式讓我們寫出更容易理解和維護的系統。

實踐層面:
能夠識別哪些邏輯應該放在模型中,哪些應該抽取到 Service Objects。知道如何避免常見的效能陷阱,理解如何為複雜的業務邏輯寫測試。

自我檢核清單

完成今天的學習後,你應該能夠:

  • [ ] 解釋 Fat Model 設計與其他框架的分層架構有何不同
  • [ ] 實作包含業務邏輯的 Rails 模型
  • [ ] 識別並避免過度使用 callbacks 的問題
  • [ ] 使用 scopes 和 class methods 組織查詢邏輯
  • [ ] 在 LMS 專案中合理設計模型層

延伸資源

深入閱讀:

相關 Gem:

  • aasm:狀態機管理,適合複雜的狀態轉換
  • draper:Decorator 模式,分離展示邏輯
  • interactor:Service Object 的優雅實作

明日預告

明天我們將探討 ActiveRecord 的進階關聯與查詢優化。如果說今天學習的是如何組織單一模型的業務邏輯,那明天就是理解多個模型如何協作來表達複雜的業務關係。

你將學會如何用 has_many :through 建立靈活的多對多關係、如何用多型關聯實現優雅的設計、最重要的是如何避免和解決 N+1 查詢這個 Rails 開發者的夢魘。

準備好了嗎?讓我們繼續深入 Rails 的核心,探索 ActiveRecord 更強大的能力!


上一篇
Day 6: 控制器與請求處理 - 在約定與彈性之間找到平衡點
系列文
30 天 Rails 新手村:從工作專案學會 Ruby on Rails8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言