如果你來自 Express 的世界,你可能習慣了在 controller 或 service 層處理所有業務邏輯,model 只是簡單的資料結構定義。在 Spring Boot 中,你會嚴格區分 Entity、Repository、Service、Controller,每一層都有明確的職責邊界。而在 FastAPI 中,你用 Pydantic 定義資料模型,業務邏輯則散落在不同的依賴注入函數中。
今天我們要探討的是 Rails 如何用完全不同的思維來組織業務邏輯。Rails 相信模型不只是資料的容器,更是業務知識的載體。當你的模型能夠「說出」業務的語言,你的程式碼就不再是技術實作的堆砌,而是業務規則的清晰表達。
這個知識點在我們的 LMS 系統中至關重要。想像一下,當學生註冊課程時,系統需要檢查先修課程、確認名額、計算費用、發送通知郵件。這些業務邏輯應該放在哪裡?Rails 的答案可能會讓你重新思考軟體設計的本質。
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 原生支援 | 中,需要定義規範 |
測試難度 | 較難隔離測試 | 容易單元測試 |
程式碼組織 | 可能造成模型過大 | 清晰的職責分離 |
可重用性 | 高,可跨模型共享 | 中,特定用途 |
決策建議:
在我們的 LMS 系統中,模型層設計的品質直接影響系統的可維護性。讓我們看看實際的業務需求如何轉化為優雅的模型設計。
功能需求:
LMS 需要支援複雜的學習路徑管理。學生完成某個課程後,系統要自動解鎖後續課程、更新學習進度、計算成就點數、可能頒發證書。
實作挑戰:
# 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
練習目標:
熟悉 Fat Model 的基本概念,練習將業務邏輯封裝到模型中。
練習內容:
建立一個圖書館借閱系統的模型:
實作以下業務邏輯:
解題思路說明:
首先,我們需要思考這三個模型之間的關係。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 # => 總罰金金額
挑戰目標:
設計 LMS 系統的作業提交與批改流程。
挑戰內容:
實作以下功能:
解題思路說明:
這個挑戰涉及更複雜的業務邏輯。我們需要處理不同類型的作業(選擇題、問答題、程式作業),每種類型有不同的評分方式。同時要考慮同儕評審的流程,這涉及多個學生之間的互動。延遲提交的扣分規則也需要靈活配置。
讓我們設計一個可擴展的架構,使用 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 的設計理念:讓模型不只是資料的容器,而是業務邏輯的載體,讓程式碼真正說出業務的語言。
與前期內容的連結:
對後續內容的鋪墊:
%%{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。知道如何避免常見的效能陷阱,理解如何為複雜的業務邏輯寫測試。
完成今天的學習後,你應該能夠:
深入閱讀:
相關 Gem:
aasm
:狀態機管理,適合複雜的狀態轉換draper
:Decorator 模式,分離展示邏輯interactor
:Service Object 的優雅實作明天我們將探討 ActiveRecord 的進階關聯與查詢優化。如果說今天學習的是如何組織單一模型的業務邏輯,那明天就是理解多個模型如何協作來表達複雜的業務關係。
你將學會如何用 has_many :through
建立靈活的多對多關係、如何用多型關聯實現優雅的設計、最重要的是如何避免和解決 N+1 查詢這個 Rails 開發者的夢魘。
準備好了嗎?讓我們繼續深入 Rails 的核心,探索 ActiveRecord 更強大的能力!