iT邦幫忙

2025 iThome 鐵人賽

DAY 3
0
Modern Web

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

Day 2: Rails 專案結構與設計哲學 - 從混沌到秩序的架構之道

  • 分享至 

  • xImage
  •  

一、開場:從經驗出發

如果你來自 Express.js 的世界,你可能習慣了極致的自由。每個專案的目錄結構都像是一張白紙,你可以選擇 MVC、可以選擇 Domain-Driven Design,甚至可以創造自己獨特的組織方式。在建立一個新的 API 專案時,你會花費相當的時間決定:controllers 該放哪裡?models 如何組織?middleware 怎麼分類?

如果你來自 Spring Boot,你享受著 Maven 或 Gradle 帶來的標準專案結構,但同時也被大量的配置檔案和註解包圍。每個 Bean 需要明確宣告,每個依賴需要手動注入,專案結構雖然規範,但靈活性往往需要透過更多的配置來實現。

如果你來自 Python 的 FastAPI 世界,你可能欣賞它的漸進式組織方式。你可以從單一檔案開始,隨著專案成長逐步拆分成模組。FastAPI 給你建議但不強制執行,你需要自己決定是否要使用 routers、models、schemas 這樣的結構。每個團隊都可能發展出自己的「最佳實踐」,從簡單的功能分組到複雜的 Clean Architecture,選擇權完全在你手上。你可能也習慣了顯式的 import 語句,每個模組都需要明確地導入依賴,這給了你完全的控制權,但也意味著更多的樣板程式碼。

今天我們要探討的是 Rails 如何在自由與約束之間找到完美的平衡點。Rails 的專案結構不是隨意的規定,而是二十年實戰經驗的結晶。更重要的是,這個結構背後隱藏著一個強大的秘密武器:Zeitwerk,一個能讓你的程式碼「自動認識彼此」的自動載入系統。

在接下來的學習中,我們會發現 Rails 的目錄結構就像是一座精心設計的城市。每個區域都有明確的功能定位,道路(檔案命名)遵循統一的規則,而 Zeitwerk 就像是這座城市的智慧交通系統,確保所有組件都能順暢運作。這個設計將直接影響我們 LMS 系統的架構:課程模組該如何組織?權限系統該放在哪裡?背景任務如何管理?這些問題都會在今天找到答案。

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

2.1 歷史脈絡與設計決策

Rails 的選擇:

Rails 在 2004 年誕生時,做出了一個大膽的決定:與其讓每個開發者重新發明輪子,不如提供一個經過驗證的最佳結構。這個決定在當時引起了激烈的討論,有人認為這限制了創意,有人則認為這解放了生產力。

從 Rails 1 到 Rails 7,專案結構經歷了幾次重要的演進:

  • Rails 3(2010):引入了 Bundler,徹底改變了依賴管理方式
  • Rails 4(2013):加入了 concerns 目錄,提供了更好的程式碼組織方式
  • Rails 5(2016):API 模式的出現,移除了不必要的中介軟體和視圖層組件
  • Rails 6(2019):Zeitwerk 成為預設的自動載入器,帶來了更可靠的載入機制
  • Rails 7(2021):引入了更多的預設配置,如 Stimulus 和 Turbo

與其他框架的對比:

框架 設計理念 實作方式 優劣權衡
Rails 約定優於配置 固定的目錄結構 + 自動載入 快速開發,但需要學習約定
Express 極致自由 無預設結構 完全彈性,但容易混亂
Spring Boot 註解驅動 標準結構 + 顯式配置 明確控制,但較為繁瑣
FastAPI 漸進式組織 模組化設計 + 顯式導入 平衡性好,但缺乏統一標準

2.2 核心原則剖析

原則一:Convention over Configuration(約定優於配置)

表層理解:很多人認為這只是「減少配置檔案」的意思。

深層含義:這其實是一種「集體智慧的傳承」。每個約定背後都是無數專案的經驗總結。當你遵循 Rails 的約定時,你站在了整個社群的肩膀上。

實際影響:

# 在 Express 中,你需要明確設定路由
app.get('/courses', coursesController.index)
app.get('/courses/:id', coursesController.show)
app.post('/courses', coursesController.create)
# ... 還有更多

# 在 Rails 中,一行搞定
resources :courses
# 自動產生 7 個 RESTful 路由
# 自動對應到 CoursesController 的標準動作

原則二:Separation of Concerns(關注點分離)

常見誤解:把程式碼按類型分到不同資料夾就是關注點分離。

正確理解:Rails 的目錄結構反映的是「責任」的分離,而不只是「類型」的分離。每個目錄代表系統的一個特定關注點:

  • models/ - 業務邏輯和資料
  • controllers/ - 請求協調
  • jobs/ - 非同步處理
  • channels/ - 即時通訊
  • mailers/ - 郵件發送

實踐指南:當你不確定程式碼該放哪裡時,問自己:「這段程式碼的主要責任是什麼?」

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

3.1 建立 Rails API 專案

讓我們從零開始建立一個 Rails API 專案,深入理解每個部分的作用:

# 第一步:建立專案
# --api 標誌告訴 Rails 我們要建立 API-only 應用
# --database=postgresql 指定使用 PostgreSQL(LMS 系統的最佳選擇)
rails new learning_hub --api --database=postgresql

cd learning_hub

3.2 深入理解目錄結構

讓我們探索剛建立的專案結構,理解每個目錄的設計意圖:

# 專案根目錄結構解析
learning_hub/
├── app/                    # 應用程式核心
│   ├── controllers/        # HTTP 請求處理器
│   │   ├── application_controller.rb  # 所有控制器的基類
│   │   └── concerns/       # 共用的控制器行為
│   ├── models/            # 業務實體和邏輯
│   │   ├── application_record.rb      # 所有模型的基類
│   │   └── concerns/      # 共用的模型行為
│   ├── jobs/              # 背景任務
│   │   └── application_job.rb         # 所有任務的基類
│   ├── mailers/           # 郵件發送器
│   │   └── application_mailer.rb      # 所有郵件程式的基類
│   └── channels/          # WebSocket 頻道(ActionCable)
│       └── application_cable/
├── config/                # 配置檔案
│   ├── routes.rb          # 路由定義(URL 到控制器的映射)
│   ├── database.yml       # 資料庫配置
│   ├── application.rb     # 應用程式配置
│   └── environments/      # 環境特定配置
│       ├── development.rb
│       ├── test.rb
│       └── production.rb
├── db/                    # 資料庫相關
│   ├── migrate/           # 資料庫遷移檔案
│   ├── schema.rb          # 資料庫結構(自動生成)
│   └── seeds.rb           # 種子資料
├── lib/                   # 自定義程式庫
│   ├── tasks/             # 自定義 Rake 任務
│   └── assets/            # 靜態資源(API 模式通常不用)
├── public/                # 公開檔案(錯誤頁面等)
├── test/ 或 spec/         # 測試檔案
├── vendor/                # 第三方程式碼
├── Gemfile                # Ruby 依賴定義
└── Gemfile.lock           # 依賴版本鎖定

3.3 Zeitwerk 自動載入機制

Zeitwerk 是 Rails 6 引入的革命性功能,讓我們深入理解它的運作方式:

# config/application.rb
module LearningHub
  class Application < Rails::Application
    # Rails 7 預設使用 Zeitwerk
    config.load_defaults 7.1
    
    # Zeitwerk 的神奇之處:檔案路徑 = 常數路徑
    # app/models/course.rb → Course
    # app/models/course/chapter.rb → Course::Chapter
    # app/controllers/api/v1/courses_controller.rb → Api::V1::CoursesController
  end
end

讓我們創建一個實例來理解 Zeitwerk 的威力:

# app/models/course.rb
class Course < ApplicationRecord
  # Zeitwerk 會自動載入這個類別
  # 不需要 require 語句
end

# app/models/course/enrollment.rb
# 巢狀類別的組織方式
class Course::Enrollment < ApplicationRecord
  # 這個類別會被自動識別為 Course 的子類別
  belongs_to :course
  belongs_to :user
end

# app/services/course_enrollment_service.rb
# 自定義的 services 目錄也能被自動載入
class CourseEnrollmentService
  def initialize(user, course)
    @user = user
    @course = course
  end
  
  def enroll
    # 業務邏輯
    # 注意:我們可以直接使用 Course::Enrollment
    # 不需要任何 require 或 import
    Course::Enrollment.create!(
      user: @user,
      course: @course,
      enrolled_at: Time.current
    )
  end
end

3.4 自定義目錄結構

Rails 允許我們在遵循約定的同時,加入自己的組織方式:

# config/application.rb
module LearningHub
  class Application < Rails::Application
    # 添加自定義的自動載入路徑
    # 這些目錄下的檔案會被 Zeitwerk 自動載入
    config.autoload_paths += %W[
      #{config.root}/app/services
      #{config.root}/app/serializers
      #{config.root}/app/policies
      #{config.root}/app/validators
    ]
    
    # 設定不要自動載入的路徑(例如:只在特定時機載入)
    config.autoload_once_paths += %W[
      #{config.root}/app/middleware
    ]
  end
end

現在讓我們建立這些自定義目錄並理解它們的用途:

# app/services/authentication_service.rb
# Service Objects:封裝複雜的業務邏輯
class AuthenticationService
  def self.authenticate(email, password)
    user = User.find_by(email: email)
    return nil unless user&.authenticate(password)
    
    # 生成 JWT token
    JwtService.encode(user_id: user.id)
  end
end

# app/serializers/course_serializer.rb
# Serializers:控制 API 回應的格式
class CourseSerializer
  def initialize(course)
    @course = course
  end
  
  def as_json
    {
      id: @course.id,
      title: @course.title,
      description: @course.description,
      instructor: instructor_info,
      chapters_count: @course.chapters.count,
      created_at: @course.created_at.iso8601
    }
  end
  
  private
  
  def instructor_info
    {
      id: @course.instructor.id,
      name: @course.instructor.name,
      avatar_url: @course.instructor.avatar_url
    }
  end
end

# app/policies/course_policy.rb
# Policies:集中管理授權邏輯
class CoursePolicy
  attr_reader :user, :course
  
  def initialize(user, course)
    @user = user
    @course = course
  end
  
  def update?
    # 只有課程擁有者或管理員可以更新
    user.admin? || course.instructor == user
  end
  
  def enroll?
    # 檢查是否可以註冊
    !enrolled? && course.published? && !course.full?
  end
  
  private
  
  def enrolled?
    course.students.include?(user)
  end
end

四、實戰應用:LMS 系統案例

4.1 在 LMS 中的應用場景

我們的 LMS 系統需要處理複雜的業務邏輯,良好的專案結構是成功的關鍵:

功能需求:

  • 課程管理(建立、編輯、發布)
  • 使用者系統(學生、講師、管理員)
  • 學習進度追蹤
  • 作業提交與批改
  • 即時討論區
  • 影片串流

實作挑戰:

  • 挑戰 1:不同角色有不同的權限,如何組織授權邏輯?
  • 挑戰 2:課程包含章節,章節包含課時,如何表達這種階層關係?
  • 挑戰 3:大量的背景任務(影片轉碼、郵件發送),如何管理?

4.2 LMS 專案結構設計

# 完整的 LMS 專案結構
learning_hub/
├── app/
│   ├── controllers/
│   │   ├── api/
│   │   │   ├── v1/
│   │   │   │   ├── base_controller.rb      # API v1 基礎控制器
│   │   │   │   ├── courses_controller.rb   # 課程管理
│   │   │   │   ├── enrollments_controller.rb # 註冊管理
│   │   │   │   ├── lessons_controller.rb   # 課時管理
│   │   │   │   └── assignments_controller.rb # 作業管理
│   │   │   └── v2/                        # 未來的 API 版本
│   │   └── concerns/
│   │       ├── authenticatable.rb         # 認證相關
│   │       └── error_handler.rb           # 錯誤處理
│   ├── models/
│   │   ├── user.rb                        # 使用者模型
│   │   ├── course.rb                      # 課程模型
│   │   ├── course/                        # 課程相關的子模型
│   │   │   ├── chapter.rb                 # 章節
│   │   │   ├── lesson.rb                  # 課時
│   │   │   └── enrollment.rb              # 註冊記錄
│   │   ├── assignment/                    # 作業相關
│   │   │   ├── submission.rb              # 提交記錄
│   │   │   └── review.rb                  # 批改記錄
│   │   └── concerns/
│   │       ├── trackable.rb               # 追蹤行為
│   │       └── publishable.rb             # 發布功能
│   ├── services/                          # 業務邏輯服務
│   │   ├── enrollment_service.rb          # 註冊服務
│   │   ├── grading_service.rb             # 評分服務
│   │   ├── notification_service.rb        # 通知服務
│   │   └── video_processing_service.rb    # 影片處理
│   ├── jobs/                              # 背景任務
│   │   ├── video_transcode_job.rb         # 影片轉碼
│   │   ├── certificate_generation_job.rb   # 證書生成
│   │   └── email_notification_job.rb      # 郵件通知
│   ├── serializers/                       # API 序列化
│   │   ├── course_serializer.rb
│   │   ├── user_serializer.rb
│   │   └── lesson_progress_serializer.rb
│   ├── policies/                          # 授權策略
│   │   ├── course_policy.rb
│   │   ├── assignment_policy.rb
│   │   └── admin_policy.rb
│   └── validators/                        # 自定義驗證器
│       ├── email_validator.rb
│       └── video_format_validator.rb

4.3 實際程式碼實作

讓我們實作 LMS 的核心結構:

# app/models/course.rb
module LMS
  class Course < ApplicationRecord
    # 關聯定義:展現課程的結構
    has_many :chapters, -> { order(:position) }, 
             dependent: :destroy,
             class_name: 'Course::Chapter'
    
    has_many :lessons, through: :chapters
    
    has_many :enrollments, 
             class_name: 'Course::Enrollment',
             dependent: :destroy
    
    has_many :students, through: :enrollments, 
             source: :user
    
    belongs_to :instructor, class_name: 'User'
    
    # 引入共用行為
    include Publishable  # 提供 publish!, unpublish! 等方法
    include Trackable    # 提供進度追蹤功能
    
    # 業務邏輯方法
    def enroll_student(user)
      # 使用 Service Object 處理複雜邏輯
      EnrollmentService.new(user, self).execute
    end
    
    def completion_rate_for(user)
      # 計算使用者的課程完成率
      total_lessons = lessons.count
      return 0 if total_lessons.zero?
      
      completed_lessons = lessons
        .joins(:progress_records)
        .where(progress_records: { 
          user_id: user.id, 
          completed: true 
        })
        .count
      
      (completed_lessons.to_f / total_lessons * 100).round(2)
    end
  end
end

# app/models/concerns/publishable.rb
# Concern:可重用的模組化行為
module Publishable
  extend ActiveSupport::Concern
  
  included do
    # 加入到包含此模組的類別中
    scope :published, -> { where(published: true) }
    scope :draft, -> { where(published: false) }
    
    # 狀態機或簡單的狀態管理
    enum status: {
      draft: 0,
      published: 1,
      archived: 2
    }
  end
  
  # 實例方法
  def publish!
    return if published?
    
    transaction do
      self.published = true
      self.published_at = Time.current
      save!
      
      # 觸發相關的背景任務
      CoursePublishedJob.perform_later(self)
    end
  end
  
  def unpublish!
    update!(published: false, published_at: nil)
  end
  
  # 類別方法
  class_methods do
    def publish_all!
      draft.find_each(&:publish!)
    end
  end
end

# app/services/enrollment_service.rb
# Service Object:封裝複雜的業務邏輯
class EnrollmentService
  class EnrollmentError < StandardError; end
  
  def initialize(user, course)
    @user = user
    @course = course
  end
  
  def execute
    validate_enrollment!
    
    ActiveRecord::Base.transaction do
      # 建立註冊記錄
      enrollment = create_enrollment
      
      # 初始化學習進度
      initialize_progress
      
      # 發送通知
      send_notifications
      
      # 更新統計
      update_statistics
      
      enrollment
    end
  rescue ActiveRecord::RecordInvalid => e
    raise EnrollmentError, "註冊失敗:#{e.message}"
  end
  
  private
  
  def validate_enrollment!
    raise EnrollmentError, "課程未發布" unless @course.published?
    raise EnrollmentError, "已經註冊過此課程" if already_enrolled?
    raise EnrollmentError, "課程已額滿" if @course.full?
    raise EnrollmentError, "權限不足" unless can_enroll?
  end
  
  def already_enrolled?
    @course.enrollments.exists?(user: @user)
  end
  
  def can_enroll?
    CoursePolicy.new(@user, @course).enroll?
  end
  
  def create_enrollment
    Course::Enrollment.create!(
      user: @user,
      course: @course,
      enrolled_at: Time.current,
      status: 'active'
    )
  end
  
  def initialize_progress
    @course.lessons.find_each do |lesson|
      LessonProgress.create!(
        user: @user,
        lesson: lesson,
        status: 'not_started'
      )
    end
  end
  
  def send_notifications
    # 非同步發送通知
    EnrollmentNotificationJob.perform_later(@user, @course)
  end
  
  def update_statistics
    # 更新課程統計資料
    @course.increment!(:enrollments_count)
    @user.increment!(:enrolled_courses_count)
  end
end

4.4 架構影響分析

使用 Mermaid 展示在系統架構中的位置:

%%{init: {'theme':'base', 'themeVariables': { 'fontSize': '16px'}}}%%
graph TB
    subgraph "LMS 系統架構"
        A[專案結構設計]
        B[Controllers 層]
        C[Models 層]
        D[Services 層]
        E[資料庫層]
        
        A --> B
        A --> C
        A --> D
        B --> D
        C --> E
        D --> C
        D --> E
    end
    
    style A fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px,color:#000
    style B fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000
    style C fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000
    style D fill:#fff9c4,stroke:#f57f17,stroke-width:2px,color:#000
    style E fill:#ffebee,stroke:#c62828,stroke-width:2px,color:#000

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

5.1 轉職者常見誤區

誤區 1:試圖重新組織 Rails 的目錄結構

錯誤表現:

# 錯誤:試圖建立 Express 風格的結構
app/
├── src/
│   ├── routes/
│   ├── middlewares/
│   └── utils/

根本原因:習慣了其他框架的組織方式,想要保持熟悉感。

正確做法:擁抱 Rails 的約定,在約定的基礎上擴展:

# 正確:遵循 Rails 約定,必要時添加自定義目錄
app/
├── controllers/  # Rails 標準
├── models/       # Rails 標準
├── services/     # 自定義:業務邏輯
├── presenters/   # 自定義:展示邏輯

思維轉換:Rails 的目錄結構是經過千錘百煉的最佳實踐,先理解和遵循,再根據需要調整。

誤區 2:過度使用 Concerns

錯誤表現:

# 錯誤:為了「DRY」而過度抽象
module Timestampable
  extend ActiveSupport::Concern
  
  included do
    # 只是為了共用兩個欄位...
    scope :recent, -> { order(created_at: :desc) }
  end
end

根本原因:誤解了 DRY 原則,認為任何重複都應該被消除。

正確做法:

# 正確:只在有明確業務含義時使用 Concern
module Enrollable
  extend ActiveSupport::Concern
  
  included do
    has_many :enrollments, as: :enrollable
    has_many :enrolled_users, through: :enrollments, source: :user
    
    # 包含完整的業務邏輯
    def enroll(user)
      # ...
    end
    
    def unenroll(user)
      # ...
    end
    
    def enrolled?(user)
      # ...
    end
  end
end

5.2 效能與優化考量

自動載入的效能影響:

# 開發環境:Zeitwerk 會在每次請求時檢查檔案變更
# 這提供了良好的開發體驗,但會有些許效能開銷

# config/environments/development.rb
Rails.application.configure do
  # 開啟程式碼重新載入
  config.cache_classes = false
  config.eager_load = false
end

# 生產環境:所有類別都會被預先載入
# config/environments/production.rb
Rails.application.configure do
  # 關閉程式碼重新載入,提升效能
  config.cache_classes = true
  config.eager_load = true
end

優化策略:

  1. 合理使用自動載入路徑
# 避免將大量檔案加入自動載入路徑
# 錯誤
config.autoload_paths += Dir["#{config.root}/lib/**/*"]

# 正確:只加入需要的目錄
config.autoload_paths << "#{config.root}/lib/validators"
  1. 使用 eager_load_paths 提升生產環境效能
# 將關鍵路徑加入預先載入
config.eager_load_paths << "#{config.root}/app/services"

5.3 測試策略

# spec/services/enrollment_service_spec.rb
RSpec.describe EnrollmentService do
  let(:user) { create(:user) }
  let(:course) { create(:course, :published) }
  let(:service) { described_class.new(user, course) }
  
  describe '#execute' do
    context '當滿足所有條件時' do
      it '成功建立註冊記錄' do
        expect {
          service.execute
        }.to change { Course::Enrollment.count }.by(1)
      end
      
      it '初始化學習進度' do
        service.execute
        
        expect(user.lesson_progresses.count).to eq(course.lessons.count)
      end
      
      it '發送通知任務' do
        expect {
          service.execute
        }.to have_enqueued_job(EnrollmentNotificationJob)
      end
    end
    
    context '當課程未發布時' do
      let(:course) { create(:course, :draft) }
      
      it '拋出錯誤' do
        expect {
          service.execute
        }.to raise_error(EnrollmentService::EnrollmentError, /課程未發布/)
      end
    end
  end
end

六、實踐練習:動手鞏固

6.1 基礎練習(預計 30 分鐘)

練習目標:
熟悉 Rails 專案結構和 Zeitwerk 自動載入機制

練習內容:
建立一個簡單的書店 API,實踐今天學到的專案組織概念。

步驟一:建立專案

# 建立新的 Rails API 專案
rails new bookstore_api --api --database=postgresql
cd bookstore_api

# 建立資料庫
rails db:create

步驟二:建立 Book 模型

# 產生 Book 模型
rails generate model Book title:string author:string isbn:string price:decimal description:text
rails db:migrate

步驟三:建立自定義目錄結構

首先,我們需要告訴 Rails 關於自定義目錄:

# config/application.rb
module BookstoreApi
  class Application < Rails::Application
    config.load_defaults 7.1
    config.api_only = true
    
    # 添加自定義目錄到自動載入路徑
    config.autoload_paths += %W[
      #{config.root}/app/services
      #{config.root}/app/serializers
    ]
  end
end

步驟四:實作 Book 模型

# app/models/book.rb
class Book < ApplicationRecord
  # 加入 Paginatable concern
  include Paginatable
  
  # 驗證規則
  validates :title, presence: true
  validates :author, presence: true
  validates :isbn, presence: true, uniqueness: true
  validates :price, numericality: { greater_than_or_equal_to: 0 }
  
  # 搜尋範圍
  scope :by_author, ->(author) { where("author ILIKE ?", "%#{author}%") }
  scope :by_title, ->(title) { where("title ILIKE ?", "%#{title}%") }
end

步驟五:建立 Paginatable Concern

# app/models/concerns/paginatable.rb
module Paginatable
  extend ActiveSupport::Concern
  
  included do
    # 每頁預設筆數
    DEFAULT_PER_PAGE = 20
    MAX_PER_PAGE = 100
    
    # 分頁 scope
    scope :paginate, ->(page: 1, per_page: DEFAULT_PER_PAGE) do
      # 確保參數在合理範圍內
      page = [page.to_i, 1].max
      per_page = [[per_page.to_i, MAX_PER_PAGE].min, 1].max
      
      # 計算 offset 並返回結果
      offset = (page - 1) * per_page
      limit(per_page).offset(offset)
    end
  end
  
  # 類別方法
  class_methods do
    # 計算總頁數
    def total_pages(per_page = DEFAULT_PER_PAGE)
      (count.to_f / per_page).ceil
    end
    
    # 取得分頁資訊
    def pagination_info(page: 1, per_page: DEFAULT_PER_PAGE)
      {
        current_page: page,
        per_page: per_page,
        total_count: count,
        total_pages: total_pages(per_page)
      }
    end
  end
end

步驟六:建立 BookSerializer

# app/serializers/book_serializer.rb
class BookSerializer
  def initialize(book)
    @book = book
  end
  
  def as_json
    {
      id: @book.id,
      title: @book.title,
      author: @book.author,
      isbn: @book.isbn,
      price: format_price(@book.price),
      description: @book.description,
      created_at: @book.created_at.iso8601,
      updated_at: @book.updated_at.iso8601
    }
  end
  
  # 序列化集合
  def self.collection(books)
    books.map { |book| new(book).as_json }
  end
  
  private
  
  def format_price(price)
    return nil if price.nil?
    "$%.2f" % price
  end
end

步驟七:建立 BookSearchService

# app/services/book_search_service.rb
class BookSearchService
  def initialize(params = {})
    @query = params[:query]
    @author = params[:author]
    @title = params[:title]
    @page = params[:page] || 1
    @per_page = params[:per_page] || 20
  end
  
  def search
    books = Book.all
    
    # 全文搜尋(搜尋標題、作者、ISBN)
    if @query.present?
      books = books.where(
        "title ILIKE :query OR author ILIKE :query OR isbn ILIKE :query",
        query: "%#{@query}%"
      )
    end
    
    # 依作者搜尋
    books = books.by_author(@author) if @author.present?
    
    # 依標題搜尋
    books = books.by_title(@title) if @title.present?
    
    # 分頁
    paginated_books = books.paginate(page: @page, per_page: @per_page)
    
    # 返回結果和分頁資訊
    {
      books: paginated_books,
      pagination: books.pagination_info(page: @page, per_page: @per_page)
    }
  end
  
  # 進階搜尋功能:支援多個條件組合
  def advanced_search(filters = {})
    books = Book.all
    
    # 價格範圍
    if filters[:min_price].present?
      books = books.where("price >= ?", filters[:min_price])
    end
    
    if filters[:max_price].present?
      books = books.where("price <= ?", filters[:max_price])
    end
    
    # 出版日期範圍
    if filters[:published_after].present?
      books = books.where("created_at >= ?", filters[:published_after])
    end
    
    if filters[:published_before].present?
      books = books.where("created_at <= ?", filters[:published_before])
    end
    
    # 組合基本搜尋
    basic_result = search
    
    # 合併進階條件的結果
    {
      books: books.merge(basic_result[:books]),
      pagination: basic_result[:pagination]
    }
  end
end

步驟八:建立控制器來測試

# app/controllers/api/v1/books_controller.rb
module Api
  module V1
    class BooksController < ApplicationController
      def index
        # 使用 Service 來處理搜尋邏輯
        search_service = BookSearchService.new(search_params)
        result = search_service.search
        
        # 使用 Serializer 來格式化輸出
        render json: {
          books: BookSerializer.collection(result[:books]),
          pagination: result[:pagination]
        }
      end
      
      def show
        book = Book.find(params[:id])
        render json: BookSerializer.new(book).as_json
      rescue ActiveRecord::RecordNotFound
        render json: { error: "Book not found" }, status: :not_found
      end
      
      private
      
      def search_params
        params.permit(:query, :author, :title, :page, :per_page)
      end
    end
  end
end

步驟九:設定路由

# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :books, only: [:index, :show]
    end
  end
end

驗證練習成果

# 在 Rails console 中測試
rails console

# 建立測試資料
Book.create!(
  title: "The Rails 7 Way",
  author: "Obie Fernandez",
  isbn: "978-0134657677",
  price: 49.99,
  description: "The comprehensive guide to Rails 7"
)

Book.create!(
  title: "Agile Web Development with Rails 7",
  author: "Sam Ruby",
  isbn: "978-1680509298",
  price: 55.00,
  description: "Learn Rails the Agile way"
)

# 測試 Serializer
book = Book.first
serializer = BookSerializer.new(book)
puts serializer.as_json
# 應該看到格式化的 JSON 輸出

# 測試 Search Service
service = BookSearchService.new(query: "Rails")
results = service.search
puts results[:books].count
# 應該返回 2 筆結果

# 測試分頁
service = BookSearchService.new(page: 1, per_page: 1)
results = service.search
puts results[:pagination]
# 應該看到分頁資訊

# 測試 Concern
Book.paginate(page: 1, per_page: 10)
# 應該返回分頁結果

6.2 進階挑戰(預計 1 小時)

挑戰目標:
為 LMS 系統設計並實作完整的專案結構

完整解答:LMS 課程註冊系統

步驟一:建立專案並設定結構
rails new lms_api --api --database=postgresql
cd lms_api
rails db:create
步驟二:配置自動載入路徑
# config/application.rb
module LmsApi
  class Application < Rails::Application
    config.load_defaults 7.1
    config.api_only = true
    
    # 自定義目錄
    config.autoload_paths += %W[
      #{config.root}/app/services
      #{config.root}/app/serializers
      #{config.root}/app/policies
    ]
  end
end
步驟三:建立資料模型
# 建立 User 模型
rails g model User email:string name:string role:integer password_digest:string

# 建立 Course 模型  
rails g model Course title:string description:text instructor_id:integer max_students:integer status:integer

# 建立 Enrollment 模型(注意:這是關聯表,但包含額外欄位)
rails g model Enrollment user:references course:references enrolled_at:datetime completed_at:datetime progress:integer status:integer

rails db:migrate
步驟四:實作模型
# app/models/user.rb
class User < ApplicationRecord
  has_secure_password
  
  # 角色定義
  enum role: {
    student: 0,
    instructor: 1,
    admin: 2
  }
  
  # 關聯
  has_many :enrollments, dependent: :destroy
  has_many :enrolled_courses, through: :enrollments, source: :course
  has_many :teaching_courses, class_name: 'Course', foreign_key: 'instructor_id'
  
  # 驗證
  validates :email, presence: true, uniqueness: true
  validates :name, presence: true
  
  # 商業邏輯方法
  def enrolled_in?(course)
    enrollments.exists?(course: course)
  end
  
  def can_teach?(course)
    instructor? && course.instructor_id == id
  end
end

# app/models/course.rb
class Course < ApplicationRecord
  # 狀態定義
  enum status: {
    draft: 0,
    published: 1,
    archived: 2
  }
  
  # 關聯
  belongs_to :instructor, class_name: 'User'
  has_many :enrollments, dependent: :destroy
  has_many :students, through: :enrollments, source: :user
  
  # 驗證
  validates :title, presence: true
  validates :description, presence: true
  validates :max_students, numericality: { greater_than: 0 }
  
  # 範圍查詢
  scope :available, -> { published.where('max_students > ?', 0) }
  scope :by_instructor, ->(instructor) { where(instructor: instructor) }
  
  # 商業邏輯
  def full?
    return false if max_students.nil?
    enrollments.active.count >= max_students
  end
  
  def available_spots
    return nil if max_students.nil?
    max_students - enrollments.active.count
  end
  
  def enrollment_rate
    return 0 if max_students.nil? || max_students.zero?
    (enrollments.active.count.to_f / max_students * 100).round(2)
  end
end

# app/models/enrollment.rb
class Enrollment < ApplicationRecord
  # 狀態定義
  enum status: {
    active: 0,
    completed: 1,
    dropped: 2,
    suspended: 3
  }
  
  # 關聯
  belongs_to :user
  belongs_to :course
  
  # 驗證
  validates :user_id, uniqueness: { scope: :course_id, message: "已經註冊過此課程" }
  validates :progress, numericality: { 
    greater_than_or_equal_to: 0, 
    less_than_or_equal_to: 100 
  }, allow_nil: true
  
  # 回調
  before_create :set_enrolled_at
  
  # 範圍查詢
  scope :active, -> { where(status: :active) }
  scope :recent, -> { order(enrolled_at: :desc) }
  
  private
  
  def set_enrolled_at
    self.enrolled_at ||= Time.current
  end
end
步驟五:實作 Service Object
# app/services/enrollment_service.rb
class EnrollmentService
  class EnrollmentError < StandardError; end
  
  attr_reader :user, :course, :errors
  
  def initialize(user, course)
    @user = user
    @course = course
    @errors = []
  end
  
  def execute
    validate_enrollment!
    
    ActiveRecord::Base.transaction do
      enrollment = create_enrollment
      update_course_statistics
      send_confirmation_email
      
      { success: true, enrollment: enrollment }
    end
  rescue EnrollmentError => e
    { success: false, error: e.message, errors: @errors }
  rescue ActiveRecord::RecordInvalid => e
    { success: false, error: "註冊失敗", errors: e.record.errors.full_messages }
  end
  
  private
  
  def validate_enrollment!
    # 收集所有錯誤,提供完整的錯誤訊息
    @errors << "課程尚未發布" unless @course.published?
    @errors << "您已經註冊過此課程" if @user.enrolled_in?(@course)
    @errors << "課程已額滿" if @course.full?
    @errors << "您沒有權限註冊此課程" unless can_enroll?
    
    raise EnrollmentError, @errors.join(", ") if @errors.any?
  end
  
  def can_enroll?
    # 使用 Policy 來判斷權限
    CoursePolicy.new(@user, @course).enroll?
  end
  
  def create_enrollment
    Enrollment.create!(
      user: @user,
      course: @course,
      status: :active,
      progress: 0
    )
  end
  
  def update_course_statistics
    # 這裡可以更新快取的統計資料
    # 例如:更新 Redis 中的註冊人數
    Rails.logger.info "Updated enrollment count for course #{@course.id}"
  end
  
  def send_confirmation_email
    # 在實際應用中,這會觸發背景任務
    # EnrollmentMailer.confirmation(@user, @course).deliver_later
    Rails.logger.info "Enrollment confirmation email queued for #{@user.email}"
  end
end

# app/services/enrollment_cancellation_service.rb
class EnrollmentCancellationService
  attr_reader :enrollment, :reason
  
  def initialize(enrollment, reason: nil)
    @enrollment = enrollment
    @reason = reason
  end
  
  def execute
    return { success: false, error: "註冊記錄不存在" } unless @enrollment
    return { success: false, error: "此註冊已經取消" } unless @enrollment.active?
    
    ActiveRecord::Base.transaction do
      @enrollment.update!(
        status: :dropped,
        completed_at: Time.current
      )
      
      log_cancellation
      notify_instructor
      
      { success: true, message: "成功取消註冊" }
    end
  rescue => e
    { success: false, error: e.message }
  end
  
  private
  
  def log_cancellation
    Rails.logger.info "Enrollment #{@enrollment.id} cancelled. Reason: #{@reason}"
  end
  
  def notify_instructor
    # 通知講師有學生退選
    Rails.logger.info "Instructor notified about cancellation"
  end
end
步驟六:實作 Policy
# app/policies/course_policy.rb
class CoursePolicy
  attr_reader :user, :course
  
  def initialize(user, course)
    @user = user
    @course = course
  end
  
  # 查看課程
  def show?
    # 已發布的課程所有人都可以看
    # 草稿只有講師和管理員可以看
    @course.published? || @user.admin? || @user.can_teach?(@course)
  end
  
  # 更新課程
  def update?
    @user.admin? || @user.can_teach?(@course)
  end
  
  # 刪除課程
  def destroy?
    @user.admin?
  end
  
  # 註冊課程
  def enroll?
    # 學生可以註冊
    # 講師不能註冊自己的課程
    # 管理員可以註冊任何課程(用於測試)
    return false unless @course.published?
    return false if @course.full?
    return true if @user.admin?
    
    @user.student? && !@user.can_teach?(@course) && !@user.enrolled_in?(@course)
  end
  
  # 管理註冊(查看學生名單等)
  def manage_enrollments?
    @user.admin? || @user.can_teach?(@course)
  end
end

# app/policies/enrollment_policy.rb
class EnrollmentPolicy
  attr_reader :user, :enrollment
  
  def initialize(user, enrollment)
    @user = user
    @enrollment = enrollment
  end
  
  # 查看註冊詳情
  def show?
    # 本人、講師、管理員可以查看
    @enrollment.user == @user || 
    @user.can_teach?(@enrollment.course) || 
    @user.admin?
  end
  
  # 更新進度
  def update_progress?
    # 只有講師和管理員可以更新進度
    @user.can_teach?(@enrollment.course) || @user.admin?
  end
  
  # 取消註冊
  def cancel?
    # 本人可以取消,管理員也可以
    @enrollment.user == @user || @user.admin?
  end
end
步驟七:實作 Serializer
# app/serializers/course_serializer.rb
class CourseSerializer
  def initialize(course, current_user: nil)
    @course = course
    @current_user = current_user
  end
  
  def as_json
    base_attributes.merge(conditional_attributes)
  end
  
  def self.collection(courses, current_user: nil)
    courses.map { |course| new(course, current_user: current_user).as_json }
  end
  
  private
  
  def base_attributes
    {
      id: @course.id,
      title: @course.title,
      description: @course.description,
      status: @course.status,
      instructor: instructor_info,
      max_students: @course.max_students,
      available_spots: @course.available_spots,
      enrollment_rate: @course.enrollment_rate,
      created_at: @course.created_at.iso8601
    }
  end
  
  def conditional_attributes
    attrs = {}
    
    # 如果當前使用者已註冊,顯示註冊資訊
    if @current_user && @current_user.enrolled_in?(@course)
      enrollment = @current_user.enrollments.find_by(course: @course)
      attrs[:enrollment] = {
        enrolled_at: enrollment.enrolled_at.iso8601,
        progress: enrollment.progress,
        status: enrollment.status
      }
    end
    
    # 如果是講師或管理員,顯示額外資訊
    if @current_user && can_manage?
      attrs[:students_count] = @course.enrollments.active.count
      attrs[:completion_rate] = calculate_completion_rate
    end
    
    attrs
  end
  
  def instructor_info
    {
      id: @course.instructor.id,
      name: @course.instructor.name,
      email: @course.instructor.email
    }
  end
  
  def can_manage?
    @current_user.admin? || @current_user.can_teach?(@course)
  end
  
  def calculate_completion_rate
    total = @course.enrollments.count
    return 0 if total.zero?
    
    completed = @course.enrollments.completed.count
    (completed.to_f / total * 100).round(2)
  end
end

# app/serializers/enrollment_serializer.rb
class EnrollmentSerializer
  def initialize(enrollment)
    @enrollment = enrollment
  end
  
  def as_json
    {
      id: @enrollment.id,
      user: user_info,
      course: course_info,
      enrolled_at: @enrollment.enrolled_at.iso8601,
      completed_at: @enrollment.completed_at&.iso8601,
      progress: @enrollment.progress,
      status: @enrollment.status
    }
  end
  
  private
  
  def user_info
    {
      id: @enrollment.user.id,
      name: @enrollment.user.name,
      email: @enrollment.user.email
    }
  end
  
  def course_info
    {
      id: @enrollment.course.id,
      title: @enrollment.course.title
    }
  end
end
步驟八:實作控制器
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  include ErrorHandler
  
  before_action :authenticate_user!
  
  private
  
  def authenticate_user!
    # 簡化版的認證,實際應使用 JWT
    @current_user = User.find_by(id: request.headers['User-Id'])
    render json: { error: 'Unauthorized' }, status: :unauthorized unless @current_user
  end
  
  def current_user
    @current_user
  end
end

# app/controllers/concerns/error_handler.rb
module ErrorHandler
  extend ActiveSupport::Concern
  
  included do
    rescue_from ActiveRecord::RecordNotFound do |e|
      render json: { error: e.message }, status: :not_found
    end
    
    rescue_from ActiveRecord::RecordInvalid do |e|
      render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
    end
    
    rescue_from StandardError do |e|
      Rails.logger.error "Unexpected error: #{e.message}"
      Rails.logger.error e.backtrace.join("\n")
      
      render json: { error: '內部伺服器錯誤' }, status: :internal_server_error
    end
  end
end

# app/controllers/api/v1/courses_controller.rb
module Api
  module V1
    class CoursesController < ApplicationController
      skip_before_action :authenticate_user!, only: [:index, :show]
      
      def index
        courses = Course.published
        render json: {
          courses: CourseSerializer.collection(courses, current_user: current_user)
        }
      end
      
      def show
        course = Course.find(params[:id])
        
        # 使用 Policy 檢查權限
        unless CoursePolicy.new(current_user, course).show?
          render json: { error: '您沒有權限查看此課程' }, status: :forbidden
          return
        end
        
        render json: CourseSerializer.new(course, current_user: current_user).as_json
      end
    end
  end
end

# app/controllers/api/v1/enrollments_controller.rb
module Api
  module V1
    class EnrollmentsController < ApplicationController
      def create
        course = Course.find(params[:course_id])
        service = EnrollmentService.new(current_user, course)
        result = service.execute
        
        if result[:success]
          render json: {
            message: '註冊成功',
            enrollment: EnrollmentSerializer.new(result[:enrollment]).as_json
          }, status: :created
        else
          render json: {
            error: result[:error],
            errors: result[:errors]
          }, status: :unprocessable_entity
        end
      end
      
      def destroy
        enrollment = current_user.enrollments.find(params[:id])
        
        # 檢查權限
        unless EnrollmentPolicy.new(current_user, enrollment).cancel?
          render json: { error: '您沒有權限取消此註冊' }, status: :forbidden
          return
        end
        
        service = EnrollmentCancellationService.new(enrollment)
        result = service.execute
        
        if result[:success]
          render json: { message: result[:message] }
        else
          render json: { error: result[:error] }, status: :unprocessable_entity
        end
      end
    end
  end
end
步驟九:設定路由
# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :courses, only: [:index, :show] do
        resources :enrollments, only: [:create]
      end
      
      resources :enrollments, only: [:destroy]
    end
  end
end
測試整個系統
# 在 Rails console 中測試
rails console

# 建立測試資料
instructor = User.create!(
  email: "instructor@example.com",
  name: "Dr. Smith",
  password: "password",
  role: :instructor
)

student = User.create!(
  email: "student@example.com",
  name: "John Doe",
  password: "password",
  role: :student
)

course = Course.create!(
  title: "Ruby on Rails 實戰",
  description: "深入學習 Rails 開發",
  instructor: instructor,
  max_students: 30,
  status: :published
)

# 測試註冊服務
service = EnrollmentService.new(student, course)
result = service.execute
puts result
# 應該看到 { success: true, enrollment: ... }

# 測試 Policy
policy = CoursePolicy.new(student, course)
puts policy.enroll?
# 應該返回 false(已經註冊過了)

# 測試 Serializer
serializer = CourseSerializer.new(course, current_user: student)
puts serializer.as_json
# 應該包含註冊資訊

# 測試取消註冊
enrollment = student.enrollments.first
cancel_service = EnrollmentCancellationService.new(enrollment)
result = cancel_service.execute
puts result
# 應該看到成功取消的訊息

常見問題與解決方案

問題 1:Zeitwerk 找不到類別

如果遇到 uninitialized constant 錯誤,檢查:

  • 檔案名稱是否符合類別名稱(snake_case vs CamelCase)
  • 目錄結構是否正確反映命名空間
  • 是否已經將自定義目錄加入 autoload_paths

問題 2:Service Object 的測試

# spec/services/enrollment_service_spec.rb
RSpec.describe EnrollmentService do
  let(:student) { create(:user, :student) }
  let(:course) { create(:course, :published, max_students: 2) }
  
  describe '#execute' do
    context '成功註冊' do
      it '建立註冊記錄' do
        service = described_class.new(student, course)
        result = service.execute
        
        expect(result[:success]).to be true
        expect(result[:enrollment]).to be_persisted
        expect(student.enrolled_in?(course)).to be true
      end
    end
    
    context '驗證失敗' do
      it '當課程已額滿時' do
        # 先讓其他學生註冊滿
        create_list(:enrollment, 2, course: course)
        
        service = described_class.new(student, course)
        result = service.execute
        
        expect(result[:success]).to be false
        expect(result[:errors]).to include("課程已額滿")
      end
    end
  end
end

這樣完整的練習和解答能讓學習者真正動手實作,並理解每個部分的設計理念。透過基礎練習熟悉概念,再透過進階挑戰整合所有知識,形成完整的學習循環。

七、知識連結:螺旋式深化

7.1 回顧與連結

與前期內容的連結:

  • Day 1 的 Ruby 語法:今天我們看到了 modules、classes 如何在實際專案中組織
  • Day 1 的 blocks:在 has_many 的 scope 中使用了 lambda

對後續內容的鋪墊:

  • Day 3 將深入探討 MVC 架構,理解 controllers 和 models 的互動
  • Day 4 的 ActiveRecord 會充分利用今天建立的 models 目錄
  • Day 5 的路由設計會在 config/routes.rb 中實作

7.2 知識地圖

%%{init: {'theme':'base', 'themeVariables': { 'fontSize': '14px'}}}%%
graph LR
    subgraph "學習脈絡"
        Day1[Day 1: Ruby 語法基礎]
        Day2[Day 2: 專案結構與哲學]
        Day3[Day 3: MVC 架構]
        LMS[LMS 系統架構設計]
        
        Day1 -->|語言特性| Day2
        Day2 -->|結構基礎| Day3
        Day3 -->|架構理解| LMS
    end
    
    style Day2 fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px,color:#000
    style Day1 fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000
    style Day3 fill:#fff9c4,stroke:#f57f17,stroke-width:2px,color:#000
    style LMS fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#000

八、總結:內化與展望

8.1 核心收穫

知識層面:

  • 學到了 Rails 專案的標準目錄結構
  • 掌握了 Zeitwerk 自動載入機制
  • 學會了如何組織自定義目錄

思維層面:

  • 理解了「約定優於配置」的實際價值
  • 體會了關注點分離在專案組織中的體現
  • 認識到結構即是架構的一部分

實踐層面:

  • 能夠建立符合 Rails 慣例的專案結構
  • 能夠合理組織 Service Objects、Policies 等元件
  • 能夠為 LMS 系統設計清晰的程式碼組織

8.2 自我檢核清單

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

  • [ ] 解釋 Rails 專案結構與 Express/Spring Boot/FastAPI 的差異
  • [ ] 說明 Zeitwerk 如何實現自動載入
  • [ ] 建立包含自定義目錄的 Rails 專案
  • [ ] 正確使用 Concerns 來組織共用程式碼
  • [ ] 為複雜的業務系統設計合理的目錄結構

8.3 延伸資源

深入閱讀:

相關 Gem:

  • dry-system:提供更靈活的依賴注入和組件管理
  • packwerk:Shopify 開發的模組化工具,適合大型應用

8.4 明日預告

明天我們將探討 MVC 架構在 API 模式下的實踐。如果說今天學習的是城市的規劃藍圖,那明天就是理解這座城市中不同區域如何協同運作。我們會深入控制器如何協調請求、模型如何封裝業務邏輯,以及在沒有傳統 View 的情況下,API 如何優雅地回應客戶端。

準備好了嗎?讓我們繼續深入 Rails 的架構之美。


上一篇
Day 1: Ruby 語法精要 - 在 Rails 環境中理解支撐框架的語言特性
系列文
30 天 Rails 新手村:從工作專案學會 Ruby on Rails3
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言