如果你來自 Node.js 的世界,你可能習慣了 Sequelize 或 TypeORM 那種需要明確定義每個欄位類型的方式。在 Java 的 Spring Boot 中,你會用 JPA 註解來描述實體關係,每個映射都需要精確配置。Python 的 SQLAlchemy 給你兩種選擇:Core 的細粒度控制或 ORM 的高階抽象。今天我們要探討的是 Rails ActiveRecord 如何用「約定優於配置」的思維,將資料建模變成一種優雅的藝術。
還記得第一次接觸 ORM 時的困惑嗎?為什麼要在程式碼中再定義一次資料庫結構?Rails 的答案令人驚喜:你不需要。透過 migrations,Rails 讓資料庫結構成為程式碼的一部分,而模型則專注於行為和關係。這種分離不是技術上的妥協,而是深思熟慮的設計選擇。
今天的學習將為我們的 LMS 系統奠定資料基礎。我們會設計 User、Course、Enrollment 三個核心模型,理解它們如何透過 ActiveRecord 的魔法相互連結。這不只是學習語法,而是理解 Rails 如何將複雜的業務關係轉化為直觀的程式碼。
在軟體架構的世界裡,有兩大 ORM 模式的流派。Data Mapper 追求純粹的分離:領域物件完全不知道資料庫的存在,持久化邏輯獨立存在。這種方式在理論上很美,但實踐中呢?
# Data Mapper 風格(想像的 Rails)
class User
attr_accessor :name, :email
def full_name
"#{first_name} #{last_name}"
end
end
class UserMapper
def find(id)
row = DB.execute("SELECT * FROM users WHERE id = ?", id)
hydrate_user(row)
end
def save(user)
DB.execute("INSERT INTO users...", user.attributes)
end
end
# Active Record 風格(真實的 Rails)
class User < ApplicationRecord
def full_name
"#{first_name} #{last_name}"
end
end
# 就這樣,CRUD 操作已經內建
user = User.find(1)
user.save
Rails 選擇 Active Record 不是因為它在架構上更純粹,而是因為它在實踐中更有效。DHH(Rails 創造者)相信:在 Web 應用的領域中,資料和行為本來就是緊密相關的。當你說「使用者」時,你同時指的是資料(姓名、信箱)和行為(登入、註冊)。將它們分離只會增加認知負擔。
來看看其他框架如何處理模型定義:
// Sequelize (Node.js) - 需要明確定義一切
const User = sequelize.define('User', {
firstName: {
type: DataTypes.STRING,
allowNull: false
},
email: {
type: DataTypes.STRING,
unique: true,
validate: {
isEmail: true
}
}
}, {
tableName: 'users',
timestamps: true
});
// Hibernate (Java) - 註解地獄
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String firstName;
@Column(unique = true)
@Email
private String email;
}
而在 Rails 中?
class User < ApplicationRecord
# 就這樣,Rails 知道:
# - 對應到 users 表
# - id 是主鍵
# - created_at 和 updated_at 自動維護
# - 所有欄位自動映射為屬性
end
這不是魔法,是約定。Rails 假設你會遵循合理的命名規範,然後為你處理所有瑣碎的配置。當你需要打破約定時,Rails 也提供了方式,但 80% 的情況下,約定就是你想要的。
如果你來自使用 SQL 腳本管理資料庫的背景,Migrations 可能看起來像是多此一舉。為什麼不直接寫 SQL?讓我們透過一個實際場景理解:
# 20240309100000_create_users.rb
class CreateUsers < ActiveRecord::Migration[7.1]
def change
create_table :users do |t|
t.string :email, null: false
t.string :name
t.timestamps # 自動加入 created_at 和 updated_at
end
add_index :users, :email, unique: true
end
end
這個 migration 不只是建立表格,它是:
rails db:migrate
在生產環境中,migration 不只是建立表格這麼簡單:
class AddIndexToUsersEmailSafely < ActiveRecord::Migration[7.1]
disable_ddl_transaction! # 關閉交易,允許並行建立索引
def up
# 在生產環境中不鎖表的方式建立索引
add_index :users, :email,
algorithm: :concurrently,
if_not_exists: true
end
def down
remove_index :users, :email, algorithm: :concurrently
end
end
這個範例展示了生產級的 migration 考量:
disable_ddl_transaction!
允許使用 PostgreSQL 的 CONCURRENTLYif_not_exists
避免重複執行時出錯up
和 down
方法提供更精確的控制現在讓我們為 LMS 系統設計核心資料模型。這不是隨意的練習,而是真實系統的基礎。
在 LMS 中,同一個使用者可能在不同情境下有不同角色:
# migration
class CreateUsers < ActiveRecord::Migration[7.1]
def change
create_table :users do |t|
t.string :email, null: false
t.string :encrypted_password, null: false
t.string :first_name
t.string :last_name
t.integer :role, default: 0 # 使用 enum
t.boolean :active, default: true
t.timestamps
end
add_index :users, :email, unique: true
add_index :users, :role # 方便按角色查詢
end
end
# model
class User < ApplicationRecord
# 使用 enum 定義角色,自動產生 student?、teacher? 等方法
enum role: {
student: 0,
teacher: 1,
admin: 2,
teaching_assistant: 3
}
# 關聯
has_many :enrollments, dependent: :destroy
has_many :enrolled_courses, through: :enrollments, source: :course
has_many :taught_courses, class_name: 'Course', foreign_key: 'teacher_id'
# 驗證
validates :email, presence: true, uniqueness: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :first_name, :last_name, presence: true
# 商業邏輯
def full_name
"#{first_name} #{last_name}"
end
def can_enroll_in?(course)
return false if enrolled_courses.include?(course)
return false unless active?
return false if course.full?
true
end
end
注意這裡的設計決策:
enum
而非字串儲存角色,效能更好且提供便利方法dependent: :destroy
確保刪除使用者時清理相關資料enrolled_courses
和 taught_courses
,清楚表達不同關係課程有章節,章節有課時,這種階層結構如何優雅表達?
# migrations
class CreateCourses < ActiveRecord::Migration[7.1]
def change
create_table :courses do |t|
t.string :title, null: false
t.text :description
t.references :teacher, foreign_key: { to_table: :users }
t.integer :status, default: 0
t.integer :max_students, default: 30
t.datetime :start_date
t.datetime :end_date
# 快取欄位,避免重複計算
t.integer :enrollments_count, default: 0
t.decimal :average_rating, precision: 3, scale: 2
t.timestamps
end
add_index :courses, :status
add_index :courses, [:teacher_id, :status] # 複合索引
end
end
# model
class Course < ApplicationRecord
# 狀態管理
enum status: {
draft: 0,
published: 1,
archived: 2
}
# 關聯
belongs_to :teacher, class_name: 'User'
has_many :enrollments, dependent: :destroy
has_many :students, through: :enrollments, source: :user
has_many :chapters, -> { order(:position) }, dependent: :destroy
has_many :lessons, through: :chapters
# 驗證
validates :title, presence: true, length: { minimum: 5, maximum: 100 }
validates :max_students, numericality: { greater_than: 0 }
validate :dates_are_logical
# 範圍查詢
scope :active, -> { published.where('start_date <= ? AND end_date >= ?', Date.current, Date.current) }
scope :upcoming, -> { published.where('start_date > ?', Date.current) }
# Callbacks - 謹慎使用!
after_update :notify_students_if_published, if: :published_recently?
# 商業邏輯
def full?
enrollments_count >= max_students
end
def enrollment_rate
return 0 if max_students.zero?
(enrollments_count.to_f / max_students * 100).round(2)
end
private
def dates_are_logical
return unless start_date && end_date
errors.add(:end_date, "必須在開始日期之後") if end_date <= start_date
end
def published_recently?
saved_change_to_status? && published?
end
def notify_students_if_published
# 使用背景工作,而非直接在 callback 中執行
CourseNotificationJob.perform_later(self)
end
end
關鍵設計要點:
enrollments_count
避免每次都要 COUNT[:teacher_id, :status]
優化常見查詢Enrollment 是 User 和 Course 的橋樑,但它本身也是重要的業務實體:
# migration
class CreateEnrollments < ActiveRecord::Migration[7.1]
def change
create_table :enrollments do |t|
t.references :user, null: false, foreign_key: true
t.references :course, null: false, foreign_key: true
t.integer :status, default: 0
t.decimal :progress, precision: 5, scale: 2, default: 0.0
t.decimal :grade, precision: 5, scale: 2
t.datetime :completed_at
t.timestamps
end
# 確保一個學生不能重複註冊同一門課
add_index :enrollments, [:user_id, :course_id], unique: true
add_index :enrollments, :status
end
end
# model
class Enrollment < ApplicationRecord
enum status: {
active: 0,
completed: 1,
dropped: 2,
failed: 3
}
belongs_to :user, counter_cache: true
belongs_to :course, counter_cache: true
# 追蹤學習進度的關聯
has_many :lesson_completions, dependent: :destroy
has_many :assignment_submissions, dependent: :destroy
validates :user_id, uniqueness: { scope: :course_id,
message: "已經註冊這門課程" }
validates :progress, inclusion: { in: 0..100 }
# 狀態轉換的商業邏輯
def complete!
return false unless can_complete?
transaction do
update!(
status: 'completed',
completed_at: Time.current,
progress: 100.0
)
# 發放證書
Certificate.create!(user: user, course: course)
end
end
def update_progress!
total_lessons = course.lessons.count
completed_lessons = lesson_completions.count
new_progress = (completed_lessons.to_f / total_lessons * 100).round(2)
update!(progress: new_progress)
complete! if new_progress >= 100
end
private
def can_complete?
active? && progress >= 100
end
end
Validations 確保資料完整性,但過度使用會讓模型臃腫:
class User < ApplicationRecord
# ✅ 好的驗證:資料完整性
validates :email, presence: true, uniqueness: true
validates :age, numericality: { greater_than_or_equal_to: 18 },
if: :requires_age_verification?
# ❌ 壞的驗證:業務邏輯
# validate :has_sufficient_credits_for_enrollment
# 這應該在 Service Object 中處理
# 自定義驗證器
validate :email_from_allowed_domain
private
def email_from_allowed_domain
return unless email.present?
allowed_domains = ['edu.tw', 'ac.tw']
domain = email.split('@').last
unless allowed_domains.any? { |d| domain.ends_with?(d) }
errors.add(:email, '必須使用教育機構信箱')
end
end
end
Callbacks 強大但危險,應該只用於維護資料一致性:
class Course < ApplicationRecord
# ✅ 好的 callback:資料正規化
before_save :normalize_title
# ✅ 好的 callback:維護關聯資料
after_create :create_default_chapter
# ❌ 壞的 callback:外部依賴
# after_create :send_notification_email # 用 Job
# after_save :update_search_index # 用 Job
# before_destroy :check_business_rules # 用 Service Object
private
def normalize_title
self.title = title.strip.squeeze(' ') if title.present?
end
def create_default_chapter
chapters.create!(
title: '課程簡介',
position: 0
)
end
end
Callbacks 反模式警示:
這是每個 Rails 開發者的必修課:
# ❌ N+1 問題:1 次查詢課程 + N 次查詢老師
courses = Course.published
courses.each do |course|
puts "#{course.title} by #{course.teacher.name}"
end
# ✅ 解決方案:使用 includes
courses = Course.published.includes(:teacher)
courses.each do |course|
puts "#{course.title} by #{course.teacher.name}"
end
# 更複雜的情況:多層關聯
enrollments = Enrollment.active
.includes(
:user,
course: [:teacher, :chapters]
)
理解這三者的差異是進階 Rails 開發者的標誌:
# includes:Rails 自動選擇最佳策略
Course.includes(:students) # 可能用 preload 或 eager_load
# preload:永遠使用分離的查詢
Course.preload(:students) # 兩個獨立的 SELECT
# eager_load:永遠使用 LEFT JOIN
Course.eager_load(:students) # 一個 SELECT with LEFT JOIN
# joins:只 JOIN 不載入關聯資料
Course.joins(:students).where(users: { role: 'student' })
使用原則:
includes
preload
避免記憶體爆炸eager_load
joins
現在讓我們動手實作前面學到的概念。這個練習分為兩個部分:基礎練習讓你熟悉 Rails 的基本操作,進階挑戰則深入探討複雜的資料結構設計。
練習目標:
這個練習將幫助你理解 Rails 的 migration 機制,體驗 ActiveRecord 關聯的便利性,並實際感受「約定優於配置」帶來的開發效率。我們會建立一個簡化版的 LMS 系統,包含使用者、課程和註冊三個核心模型。
Step 1: 建立新的 Rails API 專案
首先,讓我們從零開始建立專案。選擇 API 模式是因為現代應用大多採用前後端分離架構:
# 建立專案,使用 PostgreSQL 作為資料庫
rails new lms_system --api --database=postgresql
cd lms_system
# 建立資料庫
rails db:create
Step 2: 產生核心模型
Rails 的 generator 是強大的工具,它不只產生模型檔案,還會自動建立對應的 migration:
# 產生 User 模型
# 注意 :uniq 會自動加上唯一索引
rails generate model User \
email:string:uniq \
first_name:string \
last_name:string \
role:integer \
active:boolean
# 產生 Course 模型
# references 會自動建立外鍵關聯
rails generate model Course \
title:string \
description:text \
teacher:references \
status:integer \
max_students:integer \
enrollments_count:integer \
start_date:datetime \
end_date:datetime
# 產生 Enrollment 模型
rails generate model Enrollment \
user:references \
course:references \
status:integer \
progress:decimal \
grade:decimal \
completed_at:datetime
Step 3: 修改 Migrations 加入必要約束
產生的 migration 只是起點,我們需要加入更多的約束來確保資料完整性。這是許多初學者忽略但極為重要的步驟:
# db/migrate/xxx_create_users.rb
class CreateUsers < ActiveRecord::Migration[7.1]
def change
create_table :users do |t|
t.string :email, null: false # null: false 確保必填
t.string :first_name, null: false
t.string :last_name, null: false
t.integer :role, default: 0, null: false # 預設為學生
t.boolean :active, default: true, null: false
t.timestamps
end
# 索引對查詢效能至關重要
add_index :users, :email, unique: true # 唯一索引防止重複
add_index :users, :role # 加速按角色查詢
add_index :users, :active # 加速查詢活躍使用者
end
end
# db/migrate/xxx_create_courses.rb
class CreateCourses < ActiveRecord::Migration[7.1]
def change
create_table :courses do |t|
t.string :title, null: false
t.text :description
# foreign_key 確保參照完整性
t.references :teacher, null: false, foreign_key: { to_table: :users }
t.integer :status, default: 0, null: false
t.integer :max_students, default: 30
t.integer :enrollments_count, default: 0, null: false # counter cache
t.datetime :start_date
t.datetime :end_date
t.timestamps
end
add_index :courses, :status
# 複合索引優化多條件查詢
add_index :courses, [:teacher_id, :status]
add_index :courses, [:start_date, :end_date]
end
end
# db/migrate/xxx_create_enrollments.rb
class CreateEnrollments < ActiveRecord::Migration[7.1]
def change
create_table :enrollments do |t|
t.references :user, null: false, foreign_key: true
t.references :course, null: false, foreign_key: true
t.integer :status, default: 0, null: false
# precision 和 scale 定義小數精度
t.decimal :progress, precision: 5, scale: 2, default: 0.0
t.decimal :grade, precision: 5, scale: 2
t.datetime :completed_at
t.timestamps
end
# 複合唯一索引防止重複註冊
add_index :enrollments, [:user_id, :course_id], unique: true
add_index :enrollments, :status
add_index :enrollments, :completed_at
end
end
Step 4: 實作完整的模型邏輯
現在讓我們為模型加入關聯、驗證和商業邏輯。這是 ActiveRecord 真正發揮威力的地方:
# app/models/user.rb
class User < ApplicationRecord
# Enum 讓狀態管理變得優雅
# Rails 會自動產生 student?、teacher? 等便利方法
enum role: {
student: 0,
teacher: 1,
admin: 2,
teaching_assistant: 3
}
# 關聯定義了模型間的關係
has_many :enrollments, dependent: :destroy
has_many :enrolled_courses,
through: :enrollments,
source: :course
# 反向關聯需要明確指定
has_many :taught_courses,
class_name: 'Course',
foreign_key: 'teacher_id',
dependent: :restrict_with_exception # 防止誤刪有課程的老師
# 驗證確保資料品質
validates :email,
presence: true,
uniqueness: { case_sensitive: false },
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :first_name, :last_name,
presence: true,
length: { minimum: 2, maximum: 50 }
# Scope 讓查詢更語意化
scope :active, -> { where(active: true) }
scope :students, -> { where(role: 'student') }
scope :teachers, -> { where(role: 'teacher') }
# 商業邏輯方法封裝了領域知識
def full_name
"#{first_name} #{last_name}"
end
def display_name
"#{full_name} (#{role.humanize})"
end
def can_teach?
teacher? || admin?
end
def enrolled_in?(course)
enrolled_courses.include?(course)
end
# 複雜的商業規則
def can_enroll_in?(course)
return false if enrolled_in?(course)
return false unless active?
return false if course.full?
return false unless course.published?
return false if course.past?
true
end
end
# app/models/course.rb
class Course < ApplicationRecord
# 狀態機制
enum status: {
draft: 0,
published: 1,
archived: 2
}
# 關聯設定
belongs_to :teacher,
class_name: 'User',
inverse_of: :taught_courses
has_many :enrollments, dependent: :destroy
has_many :students,
through: :enrollments,
source: :user
# 多層級的驗證
validates :title,
presence: true,
length: { minimum: 5, maximum: 100 }
validates :max_students,
numericality: {
greater_than: 0,
less_than_or_equal_to: 1000
}
# 自定義驗證器處理複雜邏輯
validate :validate_dates_logic
validate :teacher_can_teach
# Callback 只用於資料處理,不做業務邏輯
before_save :normalize_title
# 豐富的 Scope 提供查詢介面
scope :active, -> {
published.where(
'start_date <= ? AND end_date >= ?',
Date.current,
Date.current
)
}
scope :upcoming, -> {
published.where('start_date > ?', Date.current)
}
scope :past, -> {
where('end_date < ?', Date.current)
}
# 查詢方法可以鏈接
scope :available, -> { active.joins(:enrollments).group('courses.id').having('COUNT(enrollments.id) < courses.max_students') }
# 封裝的商業邏輯
def full?
enrollments_count >= max_students
end
def available_slots
max_students - enrollments_count
end
def enrollment_percentage
return 0.0 if max_students.zero?
(enrollments_count.to_f / max_students * 100).round(2)
end
def active?
published? &&
start_date <= Date.current &&
end_date >= Date.current
end
def past?
end_date < Date.current
end
private
def normalize_title
self.title = title.strip.squeeze(' ') if title.present?
end
def validate_dates_logic
return unless start_date && end_date
if end_date <= start_date
errors.add(:end_date, '必須在開始日期之後')
end
if start_date < Date.current && new_record?
errors.add(:start_date, '不能是過去的日期')
end
end
def teacher_can_teach
return unless teacher
unless teacher.can_teach?
errors.add(:teacher, '必須是教師或管理員')
end
end
end
# app/models/enrollment.rb
class Enrollment < ApplicationRecord
# 註冊狀態管理
enum status: {
active: 0,
completed: 1,
dropped: 2,
failed: 3
}
# counter_cache 自動更新計數
belongs_to :user, counter_cache: true
belongs_to :course, counter_cache: true
# 複合唯一驗證
validates :user_id,
uniqueness: {
scope: :course_id,
message: '已經註冊過這門課程'
}
validates :progress,
numericality: {
greater_than_or_equal_to: 0,
less_than_or_equal_to: 100
}
validates :grade,
numericality: {
greater_than_or_equal_to: 0,
less_than_or_equal_to: 100
},
allow_nil: true
# Callback 確保業務規則
before_create :check_course_availability
after_update :check_completion, if: :saved_change_to_progress?
# 查詢介面
scope :in_progress, -> { active.where('progress < 100') }
scope :need_grading, -> { completed.where(grade: nil) }
scope :recent, -> { order(created_at: :desc) }
# 狀態轉換方法
def complete!
return false unless can_complete?
transaction do # 確保原子性
self.status = 'completed'
self.completed_at = Time.current
self.progress = 100.0
save!
# 可以在這裡觸發其他動作
# Certificate.create!(user: user, course: course)
end
end
def drop!(reason = nil)
return false unless active?
self.status = 'dropped'
save!
end
def passed?
completed? && grade.present? && grade >= 60
end
def grade_letter
return 'N/A' unless grade.present?
case grade
when 90..100 then 'A'
when 80..89 then 'B'
when 70..79 then 'C'
when 60..69 then 'D'
else 'F'
end
end
private
def check_course_availability
if course.full?
errors.add(:course, '已額滿')
throw :abort # 阻止儲存
end
end
def check_completion
complete! if progress >= 100 && active?
end
def can_complete?
active? && progress >= 100
end
end
Step 5: 建立豐富的種子資料
好的種子資料對開發和測試都很重要。讓我們建立一個完整的測試環境:
# db/seeds.rb
puts "🌱 開始建立種子資料..."
puts "⚠️ 清除現有資料..."
# 清除現有資料(注意順序,避免外鍵約束錯誤)
Enrollment.destroy_all
Course.destroy_all
User.destroy_all
puts "\n👤 建立使用者..."
# 建立管理員
admin = User.create!(
email: 'admin@lms.edu.tw',
first_name: 'System',
last_name: 'Admin',
role: 'admin'
)
puts " ✅ 管理員:#{admin.email}"
# 建立老師
teachers = []
teacher_names = [
['王', '小明', 'ming.wang'],
['李', '美玲', 'meiling.li'],
['張', '大衛', 'david.zhang']
]
teacher_names.each do |last, first, email_prefix|
teacher = User.create!(
email: "#{email_prefix}@lms.edu.tw",
first_name: first,
last_name: last,
role: 'teacher'
)
teachers << teacher
puts " ✅ 老師:#{teacher.full_name}"
end
puts "\n📚 建立課程..."
# 課程資料
course_templates = [
{
title: 'Ruby on Rails 從入門到精通',
description: '學習如何使用 Rails 建構現代化的 Web 應用程式',
max_students: 30
},
{
title: '資料結構與演算法',
description: '深入理解電腦科學的核心概念',
max_students: 25
},
{
title: '前端開發實戰',
description: 'React、Vue 和現代前端框架完整教學',
max_students: 35
},
{
title: '資料庫設計與優化',
description: '從關聯式資料庫到 NoSQL 的完整介紹',
max_students: 20
},
{
title: 'DevOps 與雲端部署',
description: 'Docker、Kubernetes 和 CI/CD 實踐',
max_students: 25
},
{
title: '機器學習入門',
description: '使用 Python 探索 AI 的世界',
max_students: 40
}
]
courses = []
course_templates.each_with_index do |template, index|
teacher = teachers[index % teachers.length]
# 隨機設定課程時間
start_offset = rand(-30..60)
duration = rand(30..120)
course = Course.create!(
title: template[:title],
description: template[:description],
teacher: teacher,
status: ['draft', 'published', 'published', 'published'].sample,
max_students: template[:max_students],
start_date: Date.current + start_offset.days,
end_date: Date.current + (start_offset + duration).days
)
courses << course
status_icon = course.published? ? '🟢' : '🟡'
puts " #{status_icon} #{course.title} (#{teacher.full_name})"
end
puts "\n👥 建立學生並註冊課程..."
# 建立更多學生
students = []
30.times do |i|
student = User.create!(
email: "student#{i+1}@example.com",
first_name: ['小華', '小美', '小強', '小芳', '大明', '阿傑'].sample,
last_name: ['王', '李', '張', '陳', '林', '黃'].sample,
role: 'student',
active: [true, true, true, false].sample # 少數非活躍學生
)
students << student
end
puts " ✅ 建立了 #{students.count} 位學生"
# 模擬真實的註冊模式
published_courses = courses.select(&:published?)
students.each do |student|
next unless student.active?
# 每個學生註冊 1-3 門課
num_courses = [1, 1, 2, 2, 2, 3].sample
selected_courses = published_courses.sample(num_courses)
selected_courses.each do |course|
next if course.full?
# 模擬不同的學習進度
progress = case rand(1..10)
when 1..3 then 0 # 剛開始
when 4..6 then rand(10..50) # 進行中
when 7..8 then rand(51..90) # 快完成
when 9..10 then 100 # 已完成
end
enrollment = Enrollment.create!(
user: student,
course: course,
progress: progress,
status: progress >= 100 ? 'completed' : 'active'
)
# 如果完成了,給予成績
if enrollment.completed?
enrollment.update!(
grade: rand(60..100),
completed_at: rand(1..30).days.ago
)
end
end
end
puts "\n📊 資料統計:"
puts "=" * 50
puts "總使用者數:#{User.count}"
puts " 管理員:#{User.where(role: 'admin').count}"
puts " 老師:#{User.teachers.count}"
puts " 學生:#{User.students.count} (活躍: #{User.students.active.count})"
puts "\n課程總數:#{Course.count}"
puts " 草稿:#{Course.draft.count}"
puts " 已發布:#{Course.published.count}"
puts " 已歸檔:#{Course.archived.count}"
puts "\n註冊總數:#{Enrollment.count}"
puts " 進行中:#{Enrollment.active.count}"
puts " 已完成:#{Enrollment.completed.count}"
puts " 已退選:#{Enrollment.dropped.count}"
puts "\n平均每門課註冊人數:#{(Enrollment.count.to_f / Course.published.count).round(2)}"
puts "平均完成率:#{(Enrollment.completed.count.to_f / Enrollment.count * 100).round(2)}%"
puts "=" * 50
puts "✨ 種子資料建立完成!"
Step 6: 執行並在 Console 中測試
現在讓我們執行 migration、載入種子資料,並在 Rails Console 中測試我們的模型:
# 執行 migration
rails db:migrate
# 載入種子資料
rails db:seed
# 進入 Rails Console
rails console
在 Console 中,我們可以測試各種功能:
# 測試基本查詢
puts "系統中有 #{User.count} 位使用者"
puts "其中 #{User.students.count} 位是學生"
# 測試關聯
teacher = User.teachers.first
puts "\n#{teacher.full_name} 老師的課程:"
teacher.taught_courses.each do |course|
puts " - #{course.title} (#{course.students.count}/#{course.max_students} 學生)"
end
# 測試 N+1 問題
puts "\n測試 N+1 問題:"
# 錯誤示範 - 會產生 N+1 查詢
puts "錯誤方式(觀察 SQL 輸出):"
Course.published.limit(3).each do |course|
puts "#{course.title} - 老師: #{course.teacher.full_name}"
end
# 正確做法 - 使用 includes
puts "\n正確方式(只有兩個查詢):"
Course.published.includes(:teacher).limit(3).each do |course|
puts "#{course.title} - 老師: #{course.teacher.full_name}"
end
# 測試商業邏輯
student = User.students.active.first
course = Course.published.first
puts "\n測試註冊邏輯:"
if student.can_enroll_in?(course)
puts "#{student.full_name} 可以註冊 #{course.title}"
else
puts "#{student.full_name} 不能註冊 #{course.title}"
end
# 測試 Scope 鏈
puts "\n找出可以註冊的活躍課程:"
Course.active.available.each do |course|
puts " #{course.title}: #{course.available_slots} 個名額"
end
# 測試複雜查詢
puts "\n完成率最高的課程:"
Course.published
.joins(:enrollments)
.where(enrollments: { status: 'completed' })
.group('courses.id')
.order('COUNT(enrollments.id) DESC')
.limit(3)
.each do |course|
completed = course.enrollments.completed.count
total = course.enrollments.count
rate = (completed.to_f / total * 100).round(2)
puts " #{course.title}: #{rate}%"
end
常見問題排解:
如果遇到問題,以下是一些常見的錯誤和解決方法:
config/database.yml
gem 'bullet', group: :development
)挑戰目標:
這個進階挑戰將帶你深入理解複雜的資料關係設計。我們要建立課程的完整階層結構,包含章節(Chapter)和課時(Lesson),並實作學習進度追蹤系統。這將展示 Rails 如何優雅地處理多層級的資料關係。
Step 1: 建立章節和課時模型
# 產生 Chapter 模型
rails generate model Chapter \
course:references \
title:string \
description:text \
position:integer
# 產生 Lesson 模型
rails generate model Lesson \
chapter:references \
title:string \
content:text \
content_type:integer \
duration:integer \
position:integer
# 產生 LessonCompletion 模型
rails generate model LessonCompletion \
user:references \
lesson:references \
enrollment:references \
completed_at:datetime
Step 2: 設計精巧的 Migrations
# db/migrate/xxx_create_chapters.rb
class CreateChapters < ActiveRecord::Migration[7.1]
def change
create_table :chapters do |t|
t.references :course, null: false, foreign_key: true
t.string :title, null: false
t.text :description
t.integer :position, null: false, default: 0
t.timestamps
end
# 複合唯一索引確保同一課程內位置不重複
add_index :chapters, [:course_id, :position], unique: true
end
end
# db/migrate/xxx_create_lessons.rb
class CreateLessons < ActiveRecord::Migration[7.1]
def change
create_table :lessons do |t|
t.references :chapter, null: false, foreign_key: true
t.string :title, null: false
t.text :content
t.integer :content_type, default: 0, null: false
t.integer :duration # 影片長度(秒)
t.integer :position, null: false, default: 0
t.boolean :required, default: true # 是否必修
t.timestamps
end
add_index :lessons, [:chapter_id, :position], unique: true
add_index :lessons, :content_type
add_index :lessons, :required
end
end
# db/migrate/xxx_create_lesson_completions.rb
class CreateLessonCompletions < ActiveRecord::Migration[7.1]
def change
create_table :lesson_completions do |t|
t.references :user, null: false, foreign_key: true
t.references :lesson, null: false, foreign_key: true
t.references :enrollment, null: false, foreign_key: true
t.datetime :completed_at, null: false
t.integer :time_spent # 花費時間(秒)
t.timestamps
end
# 防止重複完成
add_index :lesson_completions,
[:user_id, :lesson_id],
unique: true,
name: 'index_lesson_completions_on_user_and_lesson'
end
end
Step 3: 實作進階模型邏輯
現在讓我們實作這些模型的完整邏輯,包含排序、進度追蹤等複雜功能:
# app/models/chapter.rb
class Chapter < ApplicationRecord
belongs_to :course
has_many :lessons, -> { order(:position) }, dependent: :destroy
validates :title, presence: true
validates :position,
uniqueness: { scope: :course_id },
numericality: { greater_than_or_equal_to: 0 }
# 自動設定位置
before_create :set_position
# 排序功能
def move_up
return if first?
transaction do
previous_chapter.increment!(:position)
decrement!(:position)
end
end
def move_down
return if last?
transaction do
next_chapter.decrement!(:position)
increment!(:position)
end
end
def first?
position == 0
end
def last?
self == course.chapters.order(:position).last
end
# 統計方法
def total_duration
lessons.where(content_type: 'video').sum(:duration)
end
def completion_rate_for(user)
total = lessons.required.count
return 100.0 if total.zero?
completed = lessons.required
.joins(:lesson_completions)
.where(lesson_completions: { user: user })
.count
(completed.to_f / total * 100).round(2)
end
private
def set_position
self.position = course.chapters.maximum(:position).to_i + 1
end
def previous_chapter
course.chapters.find_by(position: position - 1)
end
def next_chapter
course.chapters.find_by(position: position + 1)
end
end
# app/models/lesson.rb
class Lesson < ApplicationRecord
belongs_to :chapter
has_one :course, through: :chapter
has_many :lesson_completions, dependent: :destroy
has_many :completed_by_users,
through: :lesson_completions,
source: :user
# 定義內容類型
enum content_type: {
video: 0,
article: 1,
quiz: 2,
assignment: 3,
external_resource: 4,
live_session: 5
}
validates :title, presence: true
validates :position,
uniqueness: { scope: :chapter_id },
numericality: { greater_than_or_equal_to: 0 }
validates :duration,
numericality: { greater_than: 0 },
if: :video?
# Scopes
scope :videos, -> { where(content_type: 'video') }
scope :required, -> { where(required: true) }
scope :optional, -> { where(required: false) }
before_create :set_position
# 使用者相關方法
def completed_by?(user)
lesson_completions.exists?(user: user)
end
def mark_as_completed_by(user)
enrollment = user.enrollments.find_by(course: course)
return false unless enrollment&.active?
completion = lesson_completions.find_or_initialize_by(
user: user,
enrollment: enrollment
)
if completion.new_record?
completion.completed_at = Time.current
completion.save!
# 非同步更新進度
UpdateEnrollmentProgressJob.perform_later(enrollment)
end
completion
end
# 統計方法
def completion_percentage
total_students = course.students.count
return 0 if total_students.zero?
completed_count = lesson_completions.count
(completed_count.to_f / total_students * 100).round(2)
end
def average_time_spent
return 0 if lesson_completions.empty?
lesson_completions.average(:time_spent).to_i
end
# 取得下一個課時
def next_lesson
# 同章節的下一個
next_in_chapter = chapter.lessons
.where('position > ?', position)
.order(:position)
.first
return next_in_chapter if next_in_chapter
# 下一章節的第一個
next_chapter = course.chapters
.where('position > ?', chapter.position)
.order(:position)
.first
next_chapter&.lessons&.first
end
def previous_lesson
# 同章節的上一個
prev_in_chapter = chapter.lessons
.where('position < ?', position)
.order(position: :desc)
.first
return prev_in_chapter if prev_in_chapter
# 上一章節的最後一個
prev_chapter = course.chapters
.where('position < ?', chapter.position)
.order(position: :desc)
.first
prev_chapter&.lessons&.last
end
private
def set_position
self.position = chapter.lessons.maximum(:position).to_i + 1
end
end
# app/models/lesson_completion.rb
class LessonCompletion < ApplicationRecord
belongs_to :user
belongs_to :lesson
belongs_to :enrollment
validates :user_id,
uniqueness: {
scope: :lesson_id,
message: '已經完成這個課時'
}
validates :completed_at, presence: true
# 確保使用者有註冊該課程
validate :user_enrolled_in_course
# 計算花費時間
before_save :calculate_time_spent
# 完成後更新進度
after_create :update_enrollment_progress
after_destroy :update_enrollment_progress
# Scopes
scope :recent, -> { order(completed_at: :desc) }
scope :today, -> { where(completed_at: Date.current.all_day) }
scope :this_week, -> { where(completed_at: Date.current.beginning_of_week..Date.current.end_of_week) }
private
def user_enrolled_in_course
return if enrollment&.user == user
errors.add(:user, '必須註冊該課程才能完成課時')
end
def calculate_time_spent
# 如果有開始時間記錄,計算實際花費時間
if respond_to?(:started_at) && started_at.present?
self.time_spent = (completed_at - started_at).to_i
end
end
def update_enrollment_progress
UpdateEnrollmentProgressJob.perform_later(enrollment)
end
end
# 更新 Enrollment 模型,加入完整的進度追蹤
class Enrollment < ApplicationRecord
# ... 原有程式碼 ...
has_many :lesson_completions, dependent: :destroy
has_many :completed_lessons, through: :lesson_completions, source: :lesson
# 更新進度的核心方法
def update_progress!
total_required = course.lessons.required.count
return if total_required.zero?
completed_required = lesson_completions
.joins(:lesson)
.where(lessons: { required: true })
.count
new_progress = (completed_required.to_f / total_required * 100).round(2)
# 更新進度並檢查是否完成
transaction do
update!(progress: new_progress)
if new_progress >= 100 && active?
complete!
end
end
end
# 取得下一個應該學習的課時
def next_lesson
completed_lesson_ids = lesson_completions.pluck(:lesson_id)
course.lessons
.required
.joins(:chapter)
.where.not(id: completed_lesson_ids)
.order('chapters.position', 'lessons.position')
.first
end
# 取得某章節的學習進度
def chapter_progress(chapter)
total = chapter.lessons.required.count
return 100.0 if total.zero?
completed = lesson_completions
.joins(:lesson)
.where(lessons: { chapter_id: chapter.id, required: true })
.count
(completed.to_f / total * 100).round(2)
end
# 產生學習報告
def learning_report
{
enrolled_at: created_at,
last_activity: lesson_completions.maximum(:completed_at),
progress: progress,
total_time_spent: lesson_completions.sum(:time_spent),
completed_lessons: completed_lessons.count,
total_lessons: course.lessons.required.count,
chapters: course.chapters.map do |chapter|
{
title: chapter.title,
progress: chapter_progress(chapter),
lessons_completed: lesson_completions
.joins(:lesson)
.where(lessons: { chapter_id: chapter.id })
.count,
total_lessons: chapter.lessons.required.count
}
end
}
end
# 視覺化的進度顯示
def progress_bar
filled = (progress / 10).to_i
empty = 10 - filled
"▓" * filled + "░" * empty + " #{progress}%"
end
end
# 更新 Course 模型加入章節支援
class Course < ApplicationRecord
# ... 原有程式碼 ...
has_many :chapters, -> { order(:position) }, dependent: :destroy
has_many :lessons, through: :chapters
# 建立預設課程結構
def create_default_structure!
transaction do
# 建立導論章節
intro_chapter = chapters.create!(
title: '課程導論',
description: '認識本課程的目標與內容',
position: 0
)
# 加入導論課時
intro_chapter.lessons.create!([
{
title: '歡迎來到本課程',
content: '課程介紹與學習目標說明',
content_type: 'video',
duration: 300,
position: 0
},
{
title: '如何有效學習',
content: '學習策略與平台使用指南',
content_type: 'article',
position: 1
},
{
title: '課前評估',
content: '了解你的起點',
content_type: 'quiz',
position: 2,
required: false
}
])
# 建立主要內容章節
main_chapter = chapters.create!(
title: '核心內容',
description: '課程主要知識點',
position: 1
)
# 建立總結章節
conclusion_chapter = chapters.create!(
title: '課程總結',
description: '回顧與展望',
position: 2
)
conclusion_chapter.lessons.create!(
title: '課程回顧與下一步',
content: '總結所學並規劃未來學習路徑',
content_type: 'video',
duration: 600,
position: 0
)
end
end
# 統計方法
def total_duration
lessons.videos.sum(:duration)
end
def formatted_duration
total_seconds = total_duration
hours = total_seconds / 3600
minutes = (total_seconds % 3600) / 60
if hours > 0
"#{hours}小時#{minutes}分鐘"
else
"#{minutes}分鐘"
end
end
def average_completion_rate
return 0 if enrollments.empty?
enrollments.average(:progress).to_f.round(2)
end
# 產生課程大綱
def syllabus
chapters.map do |chapter|
{
title: chapter.title,
description: chapter.description,
duration: chapter.total_duration,
lessons: chapter.lessons.map do |lesson|
{
title: lesson.title,
type: lesson.content_type,
duration: lesson.duration,
required: lesson.required?
}
end
}
end
end
end
Step 4: 建立豐富的測試資料
# db/seeds_advanced.rb
puts "🏗️ 建立進階課程結構..."
# 選擇一個課程來建立完整結構
course = Course.published.first || Course.first
unless course
puts "❌ 沒有找到課程,請先執行基礎種子資料"
exit
end
puts "\n📚 為課程《#{course.title}》建立章節結構"
# 定義完整的課程結構
course_structure = [
{
title: '第一章:基礎概念',
description: '建立紮實的基礎知識',
lessons: [
{
title: '1.1 課程介紹與學習目標',
type: 'video',
duration: 600,
content: '歡迎來到本課程!在這個影片中,我們將介紹...'
},
{
title: '1.2 開發環境設置',
type: 'article',
content: '在開始學習之前,我們需要準備好開發環境...'
},
{
title: '1.3 第一個 Hello World',
type: 'video',
duration: 900,
content: '讓我們開始寫第一個程式...'
},
{
title: '1.4 練習:環境測試',
type: 'assignment',
content: '請完成以下練習來確認環境設置正確...',
required: false
}
]
},
{
title: '第二章:核心概念',
description: '深入理解核心技術',
lessons: [
{
title: '2.1 基礎理論',
type: 'video',
duration: 1200,
content: '這一章我們要學習的核心概念包括...'
},
{
title: '2.2 實作範例詳解',
type: 'video',
duration: 1500,
content: '透過實際的範例,我們來理解...'
},
{
title: '2.3 常見問題與解答',
type: 'article',
content: '在學習過程中,你可能會遇到這些問題...'
},
{
title: '2.4 延伸閱讀資源',
type: 'external_resource',
content: '以下是一些推薦的外部資源...',
required: false
},
{
title: '2.5 章節測驗',
type: 'quiz',
content: '測試你對本章內容的理解...'
}
]
},
{
title: '第三章:進階技巧',
description: '掌握進階開發技巧',
lessons: [
{
title: '3.1 效能優化策略',
type: 'video',
duration: 1800,
content: '如何讓你的程式跑得更快...'
},
{
title: '3.2 設計模式應用',
type: 'article',
content: '在實際開發中如何應用設計模式...'
},
{
title: '3.3 實戰:建構小型專案',
type: 'assignment',
content: '運用所學知識完成一個小型專案...'
}
]
},
{
title: '第四章:實戰應用',
description: '整合所學知識',
lessons: [
{
title: '4.1 專案架構設計',
type: 'video',
duration: 2100,
content: '如何設計一個可擴展的專案架構...'
},
{
title: '4.2 團隊協作最佳實踐',
type: 'article',
content: '在團隊中如何有效協作...'
},
{
title: '4.3 部署與維運',
type: 'video',
duration: 1500,
content: '將應用部署到生產環境...'
},
{
title: '4.4 期末專案',
type: 'assignment',
content: '綜合運用所有知識完成期末專案...'
}
]
},
{
title: '第五章:總結與展望',
description: '回顧與未來學習方向',
lessons: [
{
title: '5.1 課程總結',
type: 'video',
duration: 600,
content: '回顧我們學習的內容...'
},
{
title: '5.2 進階學習資源',
type: 'article',
content: '如果你想繼續深入學習...'
},
{
title: '5.3 結業證書',
type: 'quiz',
content: '完成最終測驗獲得證書...'
}
]
}
]
# 建立章節和課時
course_structure.each_with_index do |chapter_data, chapter_index|
chapter = course.chapters.create!(
title: chapter_data[:title],
description: chapter_data[:description],
position: chapter_index
)
puts "\n 📖 #{chapter.title}"
chapter_data[:lessons].each_with_index do |lesson_data, lesson_index|
lesson = chapter.lessons.create!(
title: lesson_data[:title],
content: lesson_data[:content],
content_type: lesson_data[:type],
duration: lesson_data[:duration],
required: lesson_data.fetch(:required, true),
position: lesson_index
)
icon = case lesson.content_type
when 'video' then '🎬'
when 'article' then '📄'
when 'quiz' then '❓'
when 'assignment' then '📝'
else '🔗'
end
required_mark = lesson.required? ? '' : ' (選修)'
puts " #{icon} #{lesson.title}#{required_mark}"
end
end
puts "\n✅ 課程結構建立完成:"
puts " 章節數:#{course.chapters.count}"
puts " 課時數:#{course.lessons.count}"
puts " 必修課時:#{course.lessons.required.count}"
puts " 選修課時:#{course.lessons.optional.count}"
puts " 總時長:#{course.formatted_duration}"
# 模擬學生的學習進度
puts "\n👨🎓 模擬學生學習進度..."
# 取得已註冊這門課的學生
enrollments = course.enrollments.active.includes(:user).limit(10)
if enrollments.empty?
puts " ⚠️ 沒有學生註冊這門課"
else
enrollments.each do |enrollment|
student = enrollment.user
# 根據現有進度決定要完成多少課時
lessons_to_complete = if enrollment.progress == 0
# 新學生,完成前幾個課時
course.lessons.required.limit(rand(1..5))
else
# 有進度的學生,繼續學習
completed_ids = enrollment.lesson_completions.pluck(:lesson_id)
course.lessons
.required
.where.not(id: completed_ids)
.limit(rand(1..3))
end
lessons_to_complete.each do |lesson|
# 模擬完成課時
completion = lesson.lesson_completions.create!(
user: student,
enrollment: enrollment,
completed_at: rand(1..30).days.ago,
time_spent: lesson.video? ? lesson.duration + rand(-60..120) : rand(300..1800)
)
end
# 更新進度
enrollment.update_progress!
puts " 👤 #{student.full_name}:"
puts " 進度:#{enrollment.progress_bar}"
puts " 已完成 #{enrollment.lesson_completions.count}/#{course.lessons.required.count} 個必修課時"
# 顯示下一個要學的課時
if next_lesson = enrollment.next_lesson
puts " 下一課:#{next_lesson.title}"
else
puts " 🎉 已完成所有必修課時!"
end
end
end
puts "\n📊 學習統計:"
puts " 平均完成率:#{course.average_completion_rate}%"
puts " 已完成學生:#{course.enrollments.completed.count}"
puts " 學習中學生:#{course.enrollments.active.where('progress > 0').count}"
# 執行:rails runner db/seeds_advanced.rb
Step 5: 測試進階功能
在 Rails Console 中測試我們的進階功能:
# 測試課程結構
course = Course.published.first
puts "\n📚 課程大綱:#{course.title}"
puts "=" * 50
course.chapters.each do |chapter|
puts "\n#{chapter.title}"
puts " #{chapter.description}"
puts " 課時數:#{chapter.lessons.count}"
puts " 總時長:#{chapter.total_duration / 60} 分鐘"
chapter.lessons.each do |lesson|
icon = case lesson.content_type
when 'video' then '🎬'
when 'article' then '📄'
when 'quiz' then '❓'
when 'assignment' then '📝'
else '🔗'
end
required = lesson.required? ? '[必修]' : '[選修]'
duration = lesson.video? ? " (#{lesson.duration / 60}分鐘)" : ""
puts " #{icon} #{lesson.title} #{required}#{duration}"
end
end
# 測試學習進度追蹤
student = User.students.active.first
enrollment = student.enrollments.active.first
if enrollment
puts "\n📈 學習進度報告"
puts "=" * 50
report = enrollment.learning_report
puts "學生:#{student.full_name}"
puts "課程:#{enrollment.course.title}"
puts "註冊時間:#{report[:enrolled_at].strftime('%Y-%m-%d')}"
puts "最後學習:#{report[:last_activity]&.strftime('%Y-%m-%d %H:%M') || '尚未開始'}"
puts "總體進度:#{enrollment.progress_bar}"
puts "學習時間:#{(report[:total_time_spent] / 3600.0).round(2)} 小時"
puts "\n章節進度:"
report[:chapters].each do |chapter|
puts " #{chapter[:title]}"
puts " 完成:#{chapter[:lessons_completed]}/#{chapter[:total_lessons]}"
puts " 進度:#{chapter[:progress]}%"
end
# 找出下一個要學習的課時
if next_lesson = enrollment.next_lesson
puts "\n下一個學習目標:"
puts " 📍 #{next_lesson.title}"
puts " 章節:#{next_lesson.chapter.title}"
puts " 類型:#{next_lesson.content_type}"
else
puts "\n🎉 恭喜!已完成所有必修課時"
end
end
# 測試課時導航
lesson = course.lessons.first
puts "\n🧭 課時導航測試"
puts "當前課時:#{lesson.title}"
if prev = lesson.previous_lesson
puts "上一課:#{prev.title}"
else
puts "上一課:(無,這是第一課)"
end
if next_lesson = lesson.next_lesson
puts "下一課:#{next_lesson.title}"
else
puts "下一課:(無,這是最後一課)"
end
# 測試完成課時
puts "\n✅ 測試完成課時"
lesson_to_complete = enrollment.next_lesson
if lesson_to_complete
puts "準備完成:#{lesson_to_complete.title}"
completion = lesson_to_complete.mark_as_completed_by(student)
if completion
puts "成功!"
enrollment.reload
puts "新進度:#{enrollment.progress}%"
else
puts "失敗:可能已經完成或未註冊課程"
end
end
# 測試效能查詢
puts "\n⚡ 測試查詢效能"
# 使用 includes 避免 N+1
puts "載入課程及所有關聯資料..."
courses = Course.includes(
:teacher,
chapters: :lessons
).published
courses.each do |c|
total_lessons = c.chapters.sum { |ch| ch.lessons.count }
puts "#{c.title}: #{c.chapters.count} 章節, #{total_lessons} 課時"
end
驗證要點與常見問題:
資料完整性檢查:
效能考量:
includes
預載所有需要的關聯邊界情況處理:
透過這個完整的實踐練習,你不只學會了 ActiveRecord 的基本操作,更理解了如何設計和實作複雜的資料模型。記住,好的資料模型是應用成功的基礎,而 Rails 的 ActiveRecord 讓這個過程變得優雅而高效。
今天我們不只學習了 ActiveRecord 的使用,更理解了它背後的設計哲學。Active Record 模式選擇簡單而非純粹,選擇實用而非理論。這個選擇讓 Rails 成為最高效的 Web 開發框架之一。
今天的核心收穫:
知識層面:
思維層面:
實踐層面:
明天我們將探討 RESTful 路由設計。如果說今天學習的是資料的靜態結構,那明天就是資料的動態流轉。我們會看到 Rails 如何用七個標準動作,優雅地表達所有的資源操作。準備好了嗎?讓我們繼續這段旅程。