如果你來自 Node.js 的世界,你可能在 Sequelize、TypeORM 或 Prisma 之間做過選擇。每次建立關聯時,你需要明確定義外鍵、選擇聯結策略、手動處理關聯載入。在 Spring Boot 中使用 Hibernate,你會習慣於 @OneToMany
、@ManyToMany
這些註解,以及 FetchType.LAZY
與 FetchType.EAGER
的權衡。如果你用過 SQLAlchemy,你知道 relationship()
的強大,也體會過 backref
和 back_populates
的微妙差異。
今天我們要探討的是 Rails 如何用完全不同的思維解決相同的問題。ActiveRecord 不只是一個 ORM,它是一種用程式碼表達業務關係的語言。當你寫下 has_many :students, through: :enrollments
時,你不只是在定義資料庫關聯,更是在描述業務領域的真實關係。
我們今天的學習直接指向 LMS 系統的核心挑戰:如何優雅地表達課程、學生、作業、成績這些複雜實體之間的關係?如何在保持程式碼可讀性的同時,避免效能陷阱?這些都是我們要解答的問題。
Rails 的關聯設計有三個核心理念,理解它們能讓你寫出更好的程式碼:
理念一:關聯是雙向的契約
# 當你定義一個關聯
class Course < ApplicationRecord
has_many :enrollments
end
# Rails 自動建立了反向關聯的可能性
class Enrollment < ApplicationRecord
belongs_to :course # 這是契約的另一端
end
與 Sequelize 需要手動定義雙向關聯不同,Rails 透過命名約定自動推斷關係。但這不是魔法,而是基於一個深刻的洞察:業務關係本質上是雙向的。一個課程有多個註冊,一個註冊必然屬於某個課程。
理念二:關聯物件是活的
# 在其他框架中,關聯通常返回純資料
# 在 Rails 中,關聯返回的是 ActiveRecord::Relation
course = Course.find(1)
enrollments = course.enrollments # 這不是陣列,是可查詢物件
# 你可以繼續添加條件
active_enrollments = enrollments.where(status: 'active')
.includes(:user)
.order(created_at: :desc)
這種設計讓你能夠漸進式地構建查詢,而不是一次性載入所有資料。
理念三:中間表是一等公民
這是 Rails 最重要的洞察之一。在許多 ORM 中,多對多關聯的中間表只是技術細節。Rails 認為中間表往往代表重要的業務概念:
# 不好的設計:把中間表當作技術細節
class Course < ApplicationRecord
has_and_belongs_to_many :users # 使用隱含的 courses_users 表
end
# 好的設計:承認 Enrollment 的業務價值
class Course < ApplicationRecord
has_many :enrollments
has_many :students, through: :enrollments, source: :user
# Enrollment 不只是關聯,它記錄了:
# - 註冊時間
# - 學習進度
# - 成績
# - 完成狀態
# - 付費資訊
end
has_many :through
不只是為了建立多對多關係,它是一種表達「透過某個業務實體產生關聯」的方式:
# LMS 系統中的核心關聯設計
class User < ApplicationRecord
# 作為學生
has_many :enrollments
has_many :enrolled_courses, through: :enrollments, source: :course
# 作為講師
has_many :teaching_courses, class_name: 'Course', foreign_key: 'instructor_id'
# 作為助教
has_many :assistantships
has_many :assisting_courses, through: :assistantships, source: :course
# 複雜查詢:找出某個學生的所有講師
def instructors
User.joins(:teaching_courses)
.where(courses: { id: enrolled_courses.pluck(:id) })
.distinct
end
end
class Course < ApplicationRecord
belongs_to :instructor, class_name: 'User'
has_many :enrollments
has_many :students, through: :enrollments, source: :user
# 透過章節找到所有的課時
has_many :chapters
has_many :lessons, through: :chapters
# 透過作業找到所有提交
has_many :assignments
has_many :submissions, through: :assignments
# 計算課程的平均完成率
def average_completion_rate
enrollments.active.average(:progress_percentage) || 0
end
end
Rails 支援多層級的 through 關聯,這在表達複雜業務關係時特別有用:
class Course < ApplicationRecord
has_many :chapters
has_many :lessons, through: :chapters
has_many :lesson_completions, through: :lessons
# 找出完成特定課程所有課時的學生
def students_completed_all_lessons
total_lessons = lessons.count
students.joins(:lesson_completions)
.where(lesson_completions: { lesson_id: lessons.pluck(:id) })
.group('users.id')
.having('COUNT(DISTINCT lesson_completions.lesson_id) = ?', total_lessons)
end
end
class Chapter < ApplicationRecord
belongs_to :course
has_many :lessons, -> { order(:position) }
# 使用 scope 來組織複雜查詢
scope :published, -> { where(published: true) }
scope :with_video_lessons, -> { joins(:lessons).where(lessons: { content_type: 'video' }).distinct }
end
有時我們需要根據條件建立不同的關聯:
class Course < ApplicationRecord
# 基本關聯
has_many :enrollments
# 條件關聯:只載入活躍的註冊
has_many :active_enrollments, -> { where(status: 'active') },
class_name: 'Enrollment'
# 帶參數的關聯
has_many :recent_enrollments, ->(days = 7) {
where('enrollments.created_at > ?', days.days.ago)
}, class_name: 'Enrollment'
# 複雜的條件關聯
has_many :top_students,
-> {
joins(:lesson_completions)
.group('users.id')
.having('COUNT(lesson_completions.id) > ?', 10)
.order('COUNT(lesson_completions.id) DESC')
.limit(10)
},
through: :enrollments,
source: :user
end
多型關聯讓一個模型可以屬於多個不同類型的父模型。在 LMS 系統中,這特別適合用於評論、標籤、通知等跨實體的功能:
# 評論系統:可以評論課程、課時、作業
class Comment < ApplicationRecord
belongs_to :commentable, polymorphic: true
belongs_to :user
# 巢狀評論
belongs_to :parent, class_name: 'Comment', optional: true
has_many :replies, class_name: 'Comment', foreign_key: 'parent_id'
# 智慧的 scope 設計
scope :root_comments, -> { where(parent_id: nil) }
scope :recent, -> { order(created_at: :desc) }
# 取得評論的上下文標題
def context_title
case commentable_type
when 'Course'
"課程:#{commentable.title}"
when 'Lesson'
"課時:#{commentable.title}(#{commentable.chapter.course.title})"
when 'Assignment'
"作業:#{commentable.title}"
end
end
end
# 使用多型關聯的模型
class Course < ApplicationRecord
has_many :comments, as: :commentable, dependent: :destroy
# 取得所有相關評論(包含課時和作業的評論)
def all_related_comments
course_comments = comments
lesson_comments = Comment.where(commentable: lessons)
assignment_comments = Comment.where(commentable: assignments)
Comment.where(id: course_comments.or(lesson_comments).or(assignment_comments))
end
end
class Lesson < ApplicationRecord
has_many :comments, as: :commentable, dependent: :destroy
# 包含回覆的評論統計
def comments_count_with_replies
comments.includes(:replies).sum { |c| 1 + c.replies.count }
end
end
多型關聯的一個挑戰是無法使用外鍵約束,而且預載入較複雜。以下是優化策略:
# 問題:N+1 查詢
comments = Comment.recent.limit(20)
comments.each do |comment|
puts comment.commentable.title # 每個都會查詢資料庫
end
# 解決方案 1:手動預載入
comments = Comment.recent.limit(20).includes(:commentable)
# 但這會載入所有類型的 commentable,可能浪費記憶體
# 解決方案 2:分組預載入
class Comment < ApplicationRecord
# 自訂預載入方法
def self.with_commentables
comments = all.to_a
# 按類型分組
grouped = comments.group_by(&:commentable_type)
# 分別載入每種類型
grouped.each do |type, comments_group|
ids = comments_group.map(&:commentable_id)
# 使用 where(id: ids) 一次載入所有記錄
records = type.constantize.where(id: ids).index_by(&:id)
# 手動設定關聯,避免額外查詢
comments_group.each do |comment|
comment.association(:commentable).target = records[comment.commentable_id]
end
end
comments
end
end
# 使用優化後的查詢
Comment.recent.limit(20).with_commentables.each do |comment|
puts comment.commentable.title # 不會產生額外查詢
end
N+1 查詢是 ORM 最常見的效能問題。理解它的本質能幫助我們寫出更好的查詢:
# 典型的 N+1 問題
def display_course_list
courses = Course.all # 1 個查詢
courses.each do |course|
puts course.instructor.name # N 個查詢(每個課程一個)
puts course.enrollments.count # 又 N 個查詢
puts course.chapters.count # 再 N 個查詢
end
# 總共:1 + 3N 個查詢
end
# 解決方案:使用 includes
def optimized_course_list
courses = Course.includes(:instructor, :enrollments, :chapters)
# 產生 4 個查詢(比 1 + 3N 好太多)
courses.each do |course|
puts course.instructor.name
puts course.enrollments.size # 注意:用 size 而非 count
puts course.chapters.size
end
end
這三個方法都能解決 N+1,但實作方式不同:
# includes:智慧選擇策略
# Rails 會根據查詢條件自動選擇使用 preload 或 eager_load
Course.includes(:enrollments).where('enrollments.status = ?', 'active')
# 因為 WHERE 條件涉及關聯表,Rails 會使用 LEFT OUTER JOIN
Course.includes(:enrollments).to_a
# 沒有涉及關聯表的條件,Rails 會使用兩個獨立查詢
# preload:總是使用獨立查詢
Course.preload(:enrollments, :instructor)
# 產生三個查詢:
# SELECT * FROM courses
# SELECT * FROM enrollments WHERE course_id IN (...)
# SELECT * FROM users WHERE id IN (...)
# eager_load:總是使用 LEFT OUTER JOIN
Course.eager_load(:enrollments, :instructor)
# 產生一個複雜的 JOIN 查詢
# 優點:單一查詢
# 缺點:可能返回大量重複資料
# joins:只做 INNER JOIN,不預載入
Course.joins(:enrollments).where(enrollments: { status: 'active' })
# 用於過濾,但不會預載入關聯資料
讓我們看一個真實的 LMS 查詢優化案例:
class CourseService
# 糟糕的實作:大量 N+1
def self.dashboard_data_bad(user)
courses = user.enrolled_courses
courses.map do |course|
{
title: course.title,
instructor: course.instructor.name,
progress: calculate_progress(user, course),
upcoming_lessons: course.lessons
.where('scheduled_at > ?', Time.current)
.limit(3),
recent_announcements: course.announcements
.where('created_at > ?', 7.days.ago)
.count
}
end
end
# 優化版本:精心設計的預載入
def self.dashboard_data_optimized(user)
# 一次載入所有需要的資料
courses = user.enrolled_courses
.includes(
:instructor,
:lessons,
:announcements,
chapters: :lessons
)
.where(status: 'active')
# 批次計算進度
progress_data = calculate_bulk_progress(user, courses.pluck(:id))
courses.map do |course|
{
title: course.title,
instructor: course.instructor.name,
progress: progress_data[course.id],
upcoming_lessons: course.lessons
.select { |l| l.scheduled_at > Time.current }
.first(3),
recent_announcements: course.announcements
.select { |a| a.created_at > 7.days.ago }
.size
}
end
end
private
def self.calculate_bulk_progress(user, course_ids)
# 使用單一查詢計算所有課程的進度
LessonCompletion
.joins(lesson: { chapter: :course })
.where(user: user, 'courses.id': course_ids)
.group('courses.id')
.count
.transform_values { |count| (count.to_f / total_lessons * 100).round(2) }
end
end
當 ActiveRecord 的 DSL 不夠用時,Arel 提供了更強大的查詢能力:
class Course < ApplicationRecord
# 找出熱門課程:註冊人數多且完成率高
def self.popular_with_high_completion
courses = arel_table
enrollments = Enrollment.arel_table
# 建構子查詢
enrollment_counts = enrollments
.project(enrollments[:course_id], enrollments[:id].count.as('enrollment_count'))
.where(enrollments[:status].eq('active'))
.group(enrollments[:course_id])
.as('enrollment_stats')
# 主查詢
joins(
courses.join(enrollment_counts, Arel::Nodes::OuterJoin)
.on(courses[:id].eq(enrollment_counts[:course_id]))
.join_sources
)
.where('enrollment_stats.enrollment_count > ?', 50)
.where('completion_rate > ?', 0.7)
.order('enrollment_stats.enrollment_count DESC')
end
end
有時我們需要使用原始 SQL,但要注意安全性:
class CourseSearchService
def self.advanced_search(params)
courses = Course.all
# 安全的參數化查詢
if params[:keyword].present?
keyword = "%#{sanitize_sql_like(params[:keyword])}%"
courses = courses.where(
"title ILIKE :keyword OR description ILIKE :keyword",
keyword: keyword
)
end
# 使用 Arel 處理複雜條件
if params[:duration_range].present?
range = params[:duration_range]
courses = courses.where(duration_hours: range[:min]..range[:max])
end
# 子查詢:找出有特定標籤的課程
if params[:tags].present?
tag_ids = params[:tags]
courses = courses.where(
"EXISTS (
SELECT 1 FROM course_tags
WHERE course_tags.course_id = courses.id
AND course_tags.tag_id IN (?)
)",
tag_ids
)
end
# 複雜排序
case params[:sort_by]
when 'popularity'
courses.left_joins(:enrollments)
.group('courses.id')
.order('COUNT(enrollments.id) DESC')
when 'rating'
courses.left_joins(:reviews)
.group('courses.id')
.order('AVG(reviews.rating) DESC NULLS LAST')
else
courses.order(created_at: :desc)
end
end
private
def self.sanitize_sql_like(string)
string.gsub(/[%_\\]/, '\\\\\\&')
end
end
Bullet 是偵測 N+1 查詢的利器:
# Gemfile
group :development do
gem 'bullet'
end
# config/environments/development.rb
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.bullet_logger = true
Bullet.console = true
Bullet.rails_logger = true
Bullet.add_footer = true
end
end
# 實際使用時,Bullet 會提醒你:
# GET /courses
# USE eager loading detected
# Course => [:instructor]
# Add to your query: .includes([:instructor])
# 使用 explain 分析查詢計劃
Course.joins(:enrollments)
.where(enrollments: { status: 'active' })
.group('courses.id')
.having('COUNT(enrollments.id) > ?', 10)
.explain
# 建立複合索引
class AddIndexesToOptimizeQueries < ActiveRecord::Migration[7.1]
def change
# 複合索引:順序很重要
add_index :enrollments, [:course_id, :status, :created_at]
add_index :lessons, [:chapter_id, :position]
# 部分索引:只索引需要的資料
add_index :enrollments, :user_id, where: "status = 'active'"
# 表達式索引(PostgreSQL)
execute <<-SQL
CREATE INDEX index_courses_on_lower_title
ON courses (LOWER(title));
SQL
end
end
練習目標: 設計一個簡化版的 LMS 資料模型,學習如何用 Rails 的關聯表達複雜的業務關係。這個練習會幫助你理解為什麼中間表在 Rails 中如此重要,以及如何利用多型關聯實現靈活的系統設計。
需求說明:
完整解答與說明:
# app/models/user.rb
class User < ApplicationRecord
# 使用角色系統而非 STI,因為一個使用者可能同時是學生和講師
# 這種設計提供了更大的靈活性
# 作為學生的關聯
has_many :enrollments, dependent: :destroy
has_many :enrolled_courses, through: :enrollments, source: :course
# 作為講師的關聯
# 注意這裡使用 class_name 和 foreign_key 來明確指定關聯
has_many :teaching_courses, class_name: 'Course', foreign_key: 'instructor_id'
# 作業提交
has_many :submissions, dependent: :destroy
# 評論
has_many :comments, dependent: :destroy
# 輔助方法:檢查是否為特定課程的講師
def instructor_of?(course)
teaching_courses.include?(course)
end
# 輔助方法:檢查是否為特定課程的學生
def student_of?(course)
enrolled_courses.include?(course)
end
end
# app/models/course.rb
class Course < ApplicationRecord
# 基本關聯
belongs_to :instructor, class_name: 'User'
# 學生關聯:透過 enrollments 中間表
# 使用 dependent: :destroy 確保刪除課程時清理相關資料
has_many :enrollments, dependent: :destroy
has_many :students, through: :enrollments, source: :user
# 課程結構:章節和課時
# 使用 -> { order(:position) } lambda 確保章節按順序載入
has_many :chapters, -> { order(:position) }, dependent: :destroy
has_many :lessons, through: :chapters
# 作業系統
has_many :assignments, dependent: :destroy
has_many :submissions, through: :assignments
# 多型評論
has_many :comments, as: :commentable, dependent: :destroy
# 驗證
validates :title, presence: true
validates :instructor, presence: true
# Scope 用於常見查詢
scope :published, -> { where(published: true) }
scope :by_instructor, ->(user) { where(instructor: user) }
# 商業邏輯方法
def enrollment_count
enrollments.count
end
def average_progress
enrollments.average(:progress_percentage) || 0
end
end
# app/models/enrollment.rb
class Enrollment < ApplicationRecord
# Enrollment 是一個重要的業務實體,不只是關聯表
# 它記錄了學生的學習狀態和進度
belongs_to :user
belongs_to :course
# 追蹤學習進度的欄位
# progress_percentage: 0-100 的整數,表示完成百分比
# status: enrolled, active, completed, dropped
# grade: 最終成績
# 驗證:確保同一使用者不能重複註冊同一課程
validates :user_id, uniqueness: { scope: :course_id,
message: "已經註冊過這門課程" }
# 狀態管理
enum status: {
enrolled: 0,
active: 1,
completed: 2,
dropped: 3
}
# Scopes
scope :active_students, -> { where(status: 'active') }
scope :completed, -> { where(status: 'completed') }
# 計算進度的方法
def update_progress!
total_lessons = course.lessons.count
completed_lessons = user.lesson_completions
.joins(:lesson)
.where(lessons: { chapter_id: course.chapters.pluck(:id) })
.count
self.progress_percentage = (completed_lessons.to_f / total_lessons * 100).round
save!
end
end
# app/models/chapter.rb
class Chapter < ApplicationRecord
belongs_to :course
has_many :lessons, -> { order(:position) }, dependent: :destroy
# 多型評論(章節也可以被評論)
has_many :comments, as: :commentable, dependent: :destroy
# 驗證
validates :title, presence: true
validates :position, presence: true,
uniqueness: { scope: :course_id }
# 自動設定位置
before_validation :set_position, on: :create
private
def set_position
self.position ||= course.chapters.maximum(:position).to_i + 1
end
end
# app/models/lesson.rb
class Lesson < ApplicationRecord
belongs_to :chapter
# 委派給 chapter 來取得 course
# 這樣可以直接呼叫 lesson.course
delegate :course, to: :chapter
# 課時完成記錄
has_many :lesson_completions, dependent: :destroy
has_many :completed_by_users, through: :lesson_completions, source: :user
# 多型評論
has_many :comments, as: :commentable, dependent: :destroy
# 內容類型:video, text, quiz
enum content_type: {
video: 0,
text: 1,
quiz: 2
}
# 驗證
validates :title, presence: true
validates :position, presence: true,
uniqueness: { scope: :chapter_id }
validates :content_type, presence: true
# Scopes
scope :videos, -> { where(content_type: 'video') }
scope :published, -> { where(published: true) }
# 排序相關
before_validation :set_position, on: :create
private
def set_position
self.position ||= chapter.lessons.maximum(:position).to_i + 1
end
end
# app/models/comment.rb
class Comment < ApplicationRecord
# 多型關聯:評論可以屬於任何 commentable 的物件
belongs_to :commentable, polymorphic: true
belongs_to :user
# 巢狀評論結構
# optional: true 允許 parent_id 為 nil(根評論)
belongs_to :parent, class_name: 'Comment', optional: true
has_many :replies, class_name: 'Comment',
foreign_key: 'parent_id',
dependent: :destroy
# 驗證
validates :content, presence: true
validate :validate_reply_depth
# Scopes
scope :root_comments, -> { where(parent_id: nil) }
scope :recent, -> { order(created_at: :desc) }
# 防止評論巢狀太深
def validate_reply_depth
if parent && parent.depth >= 2
errors.add(:parent, "回覆層級不能超過兩層")
end
end
# 計算評論深度
def depth
parent ? parent.depth + 1 : 0
end
# 取得評論的完整上下文
def full_context
case commentable
when Course
"在課程《#{commentable.title}》的評論"
when Lesson
"在課時《#{commentable.title}》的評論"
when Assignment
"在作業《#{commentable.title}》的評論"
else
"評論"
end
end
end
# app/models/assignment.rb
class Assignment < ApplicationRecord
belongs_to :course
has_many :submissions, dependent: :destroy
has_many :comments, as: :commentable, dependent: :destroy
validates :title, presence: true
validates :due_date, presence: true
scope :upcoming, -> { where('due_date > ?', Time.current).order(:due_date) }
scope :past_due, -> { where('due_date < ?', Time.current) }
end
# app/models/submission.rb
class Submission < ApplicationRecord
belongs_to :assignment
belongs_to :user
# 透過 assignment 取得 course
delegate :course, to: :assignment
validates :user_id, uniqueness: { scope: :assignment_id,
message: "已經提交過這份作業" }
enum status: {
draft: 0,
submitted: 1,
graded: 2,
returned: 3
}
scope :pending_grading, -> { where(status: 'submitted') }
end
# app/models/lesson_completion.rb
class LessonCompletion < ApplicationRecord
belongs_to :user
belongs_to :lesson
validates :user_id, uniqueness: { scope: :lesson_id }
# 完成課時後自動更新註冊進度
after_create :update_enrollment_progress
private
def update_enrollment_progress
enrollment = user.enrollments.find_by(course: lesson.course)
enrollment&.update_progress!
end
end
關鍵設計決策說明:
為什麼選擇角色系統而非 STI? 因為在 LMS 中,同一個使用者可能在不同課程扮演不同角色。使用角色系統提供了更大的靈活性。
為什麼 Enrollment 是獨立模型? Enrollment 不只記錄關聯,還包含進度、成績、狀態等重要業務資訊。這是 Rails 「中間表是一等公民」理念的體現。
為什麼使用多型關聯實作評論? 這讓評論系統可以輕易擴展到新的實體,而不需要修改評論模型本身。
為什麼使用 delegate? 這讓我們可以寫 lesson.course
而不是 lesson.chapter.course
,提升程式碼可讀性。
挑戰目標: 實作一個智慧的課程推薦服務,學習如何在保持查詢效率的同時處理複雜的業務邏輯。這個練習會讓你深入理解如何避免 N+1 查詢,以及如何設計高效的資料庫查詢。
完整解答與詳細說明:
# app/services/course_recommendation_service.rb
class CourseRecommendationService
def self.recommend_for_user(user, limit: 10)
# 第一步:收集使用者的學習資料
# 使用 includes 預載入關聯,避免 N+1 查詢
enrolled_course_ids = user.enrollments.pluck(:course_id)
# 找出使用者已完成或正在學習的課程類別
# 使用 joins 而非 includes,因為我們只需要類別 ID
user_categories = Category
.joins(:courses)
.where(courses: { id: enrolled_course_ids })
.distinct
.pluck(:id)
# 第二步:找出使用者完成課程的難度等級
# 這幫助我們推薦適當難度的課程
completed_levels = user.enrollments
.completed
.joins(:course)
.pluck('courses.difficulty_level')
.uniq
# 計算推薦的難度範圍
max_completed_level = completed_levels.max || 0
recommended_levels = (max_completed_level..(max_completed_level + 1))
# 第三步:建構主查詢
# 使用子查詢來計算課程的熱門度分數
courses = Course
.where.not(id: enrolled_course_ids) # 排除已註冊的課程
.where(category_id: user_categories) # 相同類別
.where(difficulty_level: recommended_levels) # 適當難度
.where(published: true) # 只推薦已發布的課程
# 第四步:加入熱門度和評分的計算
# 使用 left_joins 確保即使沒有註冊或評價的課程也會被包含
courses = courses
.left_joins(:enrollments, :reviews)
.select(
'courses.*',
'COUNT(DISTINCT enrollments.id) as enrollment_count',
'AVG(reviews.rating) as average_rating',
# 計算綜合分數:70% 基於註冊數,30% 基於評分
'(COUNT(DISTINCT enrollments.id) * 0.7 +
COALESCE(AVG(reviews.rating), 3) * 10 * 0.3) as popularity_score'
)
.group('courses.id')
# 第五步:加入個人化因素
# 檢查使用者的學習偏好(如果有記錄的話)
if user.learning_preferences.present?
courses = apply_user_preferences(courses, user)
end
# 第六步:加入協同過濾
# 找出相似使用者也學習的課程
similar_user_course_ids = find_similar_users_courses(user, enrolled_course_ids)
# 使用 CASE WHEN 給相似使用者的課程加權
courses = courses.select(
"CASE
WHEN courses.id IN (#{similar_user_course_ids.join(',').presence || 'NULL'})
THEN 1.2
ELSE 1.0
END as similarity_boost"
)
# 第七步:最終排序和限制
courses
.having('COUNT(DISTINCT enrollments.id) > ?', 5) # 至少要有 5 個人註冊
.order('popularity_score * similarity_boost DESC')
.limit(limit)
.includes(:instructor, :category, :tags) # 預載入展示所需的關聯
end
private
# 應用使用者偏好設定
def self.apply_user_preferences(courses, user)
preferences = user.learning_preferences
# 根據偏好的學習時長過濾
if preferences['preferred_duration'].present?
case preferences['preferred_duration']
when 'short'
courses = courses.where('duration_hours <= ?', 10)
when 'medium'
courses = courses.where(duration_hours: 10..30)
when 'long'
courses = courses.where('duration_hours > ?', 30)
end
end
# 根據偏好的內容類型加權
if preferences['content_type'].present?
preferred_type = preferences['content_type']
courses = courses.select(
"CASE
WHEN courses.primary_content_type = '#{preferred_type}'
THEN 1.3
ELSE 1.0
END as content_preference_boost"
)
end
courses
end
# 使用協同過濾找出相似使用者的課程
def self.find_similar_users_courses(user, user_course_ids)
return [] if user_course_ids.empty?
# 找出也註冊了使用者課程的其他使用者
# 並計算相似度(基於共同課程數)
similar_users = User
.joins(:enrollments)
.where(enrollments: { course_id: user_course_ids })
.where.not(id: user.id)
.group('users.id')
.having('COUNT(enrollments.course_id) >= ?', 2) # 至少有 2 門共同課程
.order('COUNT(enrollments.course_id) DESC')
.limit(20)
.pluck(:id)
return [] if similar_users.empty?
# 找出這些相似使用者註冊但目標使用者還沒註冊的課程
Course
.joins(:enrollments)
.where(enrollments: { user_id: similar_users })
.where.not(id: user_course_ids)
.group('courses.id')
.having('COUNT(DISTINCT enrollments.user_id) >= ?', 3) # 至少 3 個相似使用者註冊
.pluck(:id)
end
end
# 進階版本:加入快取和批次處理
class OptimizedCourseRecommendationService < CourseRecommendationService
CACHE_EXPIRY = 1.hour
def self.recommend_for_user(user, limit: 10)
# 使用快取避免重複計算
cache_key = "recommendations/user_#{user.id}/limit_#{limit}"
Rails.cache.fetch(cache_key, expires_in: CACHE_EXPIRY) do
recommendations = super(user, limit)
# 預先計算和快取一些昂貴的資料
preload_recommendation_data(recommendations)
recommendations
end
end
private
def self.preload_recommendation_data(courses)
# 批次載入所有需要的關聯資料
ActiveRecord::Associations::Preloader.new(
records: courses,
associations: [
:instructor,
:category,
:tags,
{ chapters: :lessons },
{ enrollments: :user }
]
).call
# 批次計算統計資料
course_ids = courses.map(&:id)
# 一次查詢取得所有課程的完成率
completion_rates = Enrollment
.where(course_id: course_ids, status: 'completed')
.group(:course_id)
.count
# 一次查詢取得所有課程的平均學習時間
avg_completion_times = Enrollment
.where(course_id: course_ids, status: 'completed')
.group(:course_id)
.average('completed_at - created_at')
# 將統計資料附加到課程物件
courses.each do |course|
course.instance_variable_set(:@completion_rate,
completion_rates[course.id].to_f / course.enrollments.count)
course.instance_variable_set(:@avg_completion_time,
avg_completion_times[course.id])
end
courses
end
end
# 使用範例
user = User.find(1)
recommendations = OptimizedCourseRecommendationService.recommend_for_user(user)
# 顯示推薦結果
recommendations.each do |course|
puts "推薦課程:#{course.title}"
puts " 講師:#{course.instructor.name}"
puts " 類別:#{course.category.name}"
puts " 難度:#{course.difficulty_level}"
puts " 註冊人數:#{course.enrollment_count}"
puts " 平均評分:#{course.average_rating&.round(2) || 'N/A'}"
puts " 推薦分數:#{course.popularity_score.round(2)}"
puts "---"
end
關鍵優化技巧說明:
避免 N+1 查詢的策略:
includes
預載入需要顯示的關聯資料joins
進行過濾(不需要載入完整物件)pluck
只取需要的欄位使用資料庫進行計算:
批次處理的重要性:
where(id: ids)
而非多次 find
快取策略:
協同過濾的實作:
效能比較:
# 測試效能差異
require 'benchmark'
user = User.find(1)
Benchmark.bm do |x|
x.report("未優化版本:") do
# 簡單的實作,會產生大量查詢
courses = Course.where.not(id: user.enrolled_courses.pluck(:id))
courses.each do |course|
course.enrollments.count # N+1 查詢
course.reviews.average(:rating) # 又一個 N+1
end
end
x.report("優化版本:") do
CourseRecommendationService.recommend_for_user(user)
end
x.report("快取版本:") do
OptimizedCourseRecommendationService.recommend_for_user(user)
end
end
透過這些練習,你不只學會了如何使用 Rails 的關聯功能,更重要的是理解了如何設計高效、可維護的資料查詢層。這些技能在建構真實的 LMS 系統時將會非常重要。
我們在 Day 4 初次接觸 ActiveRecord,學習了基本的 CRUD 操作。今天我們深入到關聯的設計哲學,理解了 Rails 如何將業務關係轉化為優雅的程式碼。這些知識將在以下場景繼續深化:
特別要注意的是,今天學習的查詢優化技巧不只是為了效能,更是為了保持程式碼的可維護性。當你的 LMS 系統成長到數萬使用者、數千課程時,這些優化將成為系統能否順利運行的關鍵。
完成今天的學習後,我們獲得了三個層次的成長:
知識層面,我們學會了 has_many :through
的靈活運用、多型關聯的設計模式、N+1 查詢的識別與解決。這些技術知識是建構複雜系統的基礎工具。
思維層面,我們理解了 Rails 將中間表視為業務實體的設計哲學、關聯作為雙向契約的概念、查詢優化與程式碼可讀性的平衡藝術。這種思維方式會改變你設計系統的方式。
實踐層面,我們能夠設計 LMS 系統的複雜資料關係、優化大規模查詢的效能、使用工具持續監控和改進查詢效率。這些能力讓你能應對真實世界的挑戰。
完成今天的學習後,你應該能夠:
has_many :through
與 has_and_belongs_to_many
的差異與選擇時機深入閱讀:
相關 Gem:
bullet
:N+1 查詢偵測工具active_record_doctor
:資料庫健康檢查工具prosopite
:另一個 N+1 偵測工具,更輕量級明天我們將探討認證系統的實作。如果說今天學習的是如何優雅地表達資料關係,那明天就是如何安全地保護這些資料。我們會從零開始實作 JWT 認證系統,理解 token 的生命週期管理,探討無狀態認證的優勢與挑戰。
準備好了嗎?讓我們繼續這段深入 Rails 核心的旅程。