iT邦幫忙

2025 iThome 鐵人賽

DAY 2
0
Modern Web

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

Day 1: Ruby 語法精要 - 在 Rails 環境中理解支撐框架的語言特性

  • 分享至 

  • xImage
  •  

開場:從熟悉到陌生的旅程

想像你是一位經驗豐富的建築師,精通鋼筋混凝土的現代建築工法。現在,你來到了日本,準備學習傳統的木造建築技術。你會發現,雖然都是建造房屋,但整個思維方式完全不同。木造建築不依賴釘子,而是透過精巧的榫卯結構讓每個部件完美契合。這就像從其他程式語言轉換到 Ruby 和 Rails 的體驗。

如果你來自 JavaScript 的世界,你習慣了事件驅動和非同步的思維模式,在 Express.js 中用中介軟體串連請求處理流程。如果你是 Java 開發者,你依賴強型別系統提供的安全網,在 Spring Boot 中用註解定義一切行為。Python 開發者則享受著簡潔的語法,在 FastAPI 中用型別提示來自動生成文檔。

今天,我們要開始探索 Ruby 這個獨特的世界。Ruby 的創造者松本行弘說過,Ruby 的設計目標是「讓程式設計師快樂」。這不是一句空洞的口號,而是深深烙印在語言每個細節中的設計理念。當你理解了 Ruby 的 blocks、procs、lambdas 和 symbols,你會發現 Rails 那些看似魔法的功能,其實都有著清晰而優雅的實現原理。

更重要的是,今天我們不會停留在抽象的語法討論。從第一刻起,我們就會在真實的 Rails 環境中探索這些特性,並且開始建構我們的線上學習管理系統(LMS)。這個專案將陪伴我們整個三十天的學習旅程,逐步成長為一個完整的生產級系統。每一天的學習都是為這個最終目標添磚加瓦。

環境準備:搭建你的 Rails 工作坊

在我們深入 Ruby 的優雅設計之前,需要先準備好開發環境。這個過程就像畫家在創作前需要準備畫布和顏料一樣重要。不同的是,我們的畫布是終端機,顏料是程式碼。

理解 Ruby 的版本管理哲學

Ruby 社群很早就意識到版本管理的重要性。不同的專案可能需要不同版本的 Ruby,就像不同的菜餚需要不同的烹飪溫度。rbenv 就是我們的溫度控制器,讓我們能在不同專案間自如切換。這個概念對 Node.js 開發者來說就像 nvm,對 Python 開發者來說就像 pyenv。

讓我們開始安裝過程。首先安裝 rbenv 和 Ruby。如果你使用 macOS,過程會透過 Homebrew 進行。打開終端機,輸入以下命令,每一行都有其特定的用途:

# macOS 使用者的安裝流程
brew install rbenv ruby-build  # 安裝 rbenv 和它的編譯助手
rbenv init                      # 初始化 rbenv 環境
echo 'eval "$(rbenv init -)"' >> ~/.zshrc  # 讓 rbenv 在每次開啟終端時自動啟動
source ~/.zshrc                 # 立即載入新的設定

# Linux (Ubuntu/Debian) 使用者的安裝流程
# 首先安裝必要的系統套件,這些是編譯 Ruby 所需的
sudo apt update
sudo apt install git curl libssl-dev libreadline-dev zlib1g-dev \
                 autoconf bison build-essential libyaml-dev \
                 libreadline-dev libncurses5-dev libffi-dev libgdbm-dev

# 使用官方安裝腳本
curl -fsSL https://github.com/rbenv/rbenv-installer/raw/HEAD/bin/rbenv-installer | bash

# 設定環境變數,確保系統能找到 rbenv
echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc
echo 'eval "$(rbenv init -)"' >> ~/.bashrc
source ~/.bashrc

現在我們可以安裝 Ruby 了。我們選擇 3.2.2 版本,這是 Rails 7.1 推薦的穩定版本:

rbenv install 3.2.2    # 這會需要幾分鐘,Ruby 會從原始碼編譯
rbenv global 3.2.2     # 設定為預設版本
ruby -v                # 確認安裝成功,應該顯示 ruby 3.2.2

# 安裝 Rails
gem install rails -v 7.1.3
rails -v               # 確認 Rails 版本

創建我們的 LMS 專案

現在來到激動人心的時刻,我們要創建真正的專案了。這不是練習用的玩具,而是接下來三十天我們要持續發展的系統。每個參數都有其深意:

# 創建 Rails API 專案
rails new lms_api --api --database=postgresql -T

# 讓我解釋每個參數的含義:
# lms_api: 我們的專案名稱,代表 Learning Management System API
# --api: 告訴 Rails 我們只需要 API 功能,不需要視圖層組件
# --database=postgresql: 使用 PostgreSQL 而非預設的 SQLite,因為這更接近生產環境
# -T: 暫時跳過測試框架,因為我們之後會使用 RSpec 而非預設的 Minitest

cd lms_api

讓我們花點時間理解 Rails 為我們創建的專案結構。每個資料夾都像是一個專門的工作間,有其特定的職責:

# 查看專案結構
ls -la

# 你會看到這樣的結構,讓我解釋每個部分的用途:
# app/           # 應用程式的核心,包含所有業務邏輯
#   ├── controllers/  # 處理 HTTP 請求的控制器
#   ├── models/       # 資料模型和業務邏輯
#   └── ...
# config/        # 所有配置文件的家
#   ├── routes.rb     # 定義 URL 與控制器動作的對應關係
#   ├── database.yml  # 資料庫連接設定
#   └── ...
# db/            # 資料庫相關文件
# Gemfile        # Ruby 的 package.json,定義專案依賴

在開始寫程式之前,我們需要設置資料庫。如果你還沒有安裝 PostgreSQL,Docker 提供了一個快速的解決方案:

# 使用 Docker 快速啟動 PostgreSQL
docker run --name lms_postgres \
  -e POSTGRES_PASSWORD=password \
  -p 5432:5432 \
  -d postgres:14

# 創建開發和測試資料庫
rails db:create

# 啟動 Rails 伺服器來確認一切正常
rails server

# 在瀏覽器訪問 http://localhost:3000
# 你應該會看到 Rails 的歡迎頁面

Ruby 核心概念:在 Rails Console 中的探索之旅

Rails Console 是一個神奇的學習環境。它不只是一個 REPL(Read-Eval-Print Loop),而是載入了整個 Rails 應用環境的互動式工作台。在這裡,你可以即時實驗任何想法,立即看到結果。這就像是一個配備了所有工具的實驗室,讓你能安全地探索和學習。

rails console  # 或簡寫為 rails c

理解「一切皆物件」的深層含義

在 Ruby 的世界觀中,真的是「一切皆物件」。這不是一種比喻或誇飾,而是字面上的事實。每個你能想到的東西,包括數字、字串、甚至 niltrue,都是完整的物件,擁有自己的方法和屬性。

讓我們在 console 中親身體驗這個概念。當你輸入這些程式碼時,請仔細觀察每個結果,思考它們的含義:

# 數字是物件,而且是功能豐富的物件
5.class          # => Integer
5.methods.count  # => 134(這個簡單的數字 5 有超過一百個方法!)

# 讓我們看看數字能做什麼
5.times { |i| puts "第 #{i + 1} 次迴圈" }  # 數字知道如何重複執行程式碼
5.even?          # => false(數字知道自己是奇數還是偶數)
5.next           # => 6(數字知道下一個數字是什麼)

# 字串當然也是物件
"Hello".class    # => String
"Hello".upcase   # => "HELLO"
"Hello".reverse  # => "olleH"
"Hello".include?("ll")  # => true

# 更令人驚訝的是,連 nil、true、false 都是物件
nil.class        # => NilClass
true.class       # => TrueClass
false.class      # => FalseClass

nil.to_s         # => ""(nil 知道如何轉換成字串)
true.to_i        # => 1(true 知道如何轉換成整數)

# Rails 擴展了這個概念,讓程式碼讀起來像自然語言
require 'active_support/all'  # 如果在純 Ruby 環境中需要載入
2.days.ago       # => 2 天前的時間物件
3.megabytes      # => 3145728(位元組數)
"user".pluralize # => "users"(自動複數化)
"users".singularize # => "user"(自動單數化)

這種設計哲學深深影響了 Rails 的 API 設計。當你寫 user.save 而不是 save(user) 時,你是在告訴物件去執行動作,而不是對物件執行操作。這種細微的差別造就了 Rails 程式碼的優雅和直觀。物件不是被動的資料容器,而是主動的參與者。

Blocks:Ruby 的靈魂與 Rails 的基石

Blocks 是 Ruby 最獨特也最強大的特性之一。如果說物件是 Ruby 的身體,那麼 Blocks 就是它的靈魂。Block 不只是匿名函數那麼簡單,它是一種優雅的控制流程方式,讓你能夠將程式碼片段像參數一樣傳遞。

讓我們從最簡單的例子開始,逐步深入理解 Blocks 的本質:

# 最基本的 Block:使用大括號的單行形式
[1, 2, 3].each { |number| puts number * 2 }

# 多行 Block:使用 do...end(這是 Rails 的慣例)
[1, 2, 3].each do |number|
  # Block 內可以有多行程式碼
  result = number * 2
  puts "#{number} 的兩倍是 #{result}"
end

# Block 最神奇的特性:閉包(Closure)
# Block 能夠「記住」它被定義時的環境
multiplier = 10
results = [1, 2, 3].map { |n| n * multiplier }
puts results  # => [10, 20, 30]

# 即使離開了原本的作用域,Block 仍然記得 multiplier
def create_multiplier(factor)
  # 返回一個 Proc(稍後會詳細解釋)
  lambda { |n| n * factor }
end

times_five = create_multiplier(5)
puts times_five.call(3)  # => 15(記住了 factor = 5)

現在讓我們創建自己的方法來理解 Block 的運作機制。這個例子會展示 Rails 中常見的模式:

# 定義一個接受 Block 的方法
def measure_time(operation_name = "操作")
  puts "開始 #{operation_name}..."
  start_time = Time.now
  
  # yield 是關鍵字,它執行傳入的 block
  result = yield if block_given?  # block_given? 檢查是否有提供 block
  
  end_time = Time.now
  duration = end_time - start_time
  
  puts "#{operation_name} 完成!"
  puts "耗時:#{duration.round(3)} 秒"
  
  result  # 返回 block 的執行結果
end

# 使用我們的計時器方法
total = measure_time("計算總和") do
  sum = 0
  1_000_000.times { |i| sum += i }
  sum  # block 的最後一行是返回值
end
puts "計算結果:#{total}"

# 這個模式在 Rails 中無處不在
# 例如,資料庫交易:
# ActiveRecord::Base.transaction do
#   user.save!
#   account.save!
# end

深入理解 Symbol:Ruby 的獨特標識符

Symbol 可能是 Ruby 中最讓新手困惑的概念,但它也是理解 Rails 的關鍵。讓我用一個類比來解釋:想像你在管理一個大型圖書館。每本書都需要一個識別方式,你有兩個選擇。第一種是每次都寫下完整的書名(這就像 String),第二種是給每本書一個永久的編號,之後都用編號來指稱(這就像 Symbol)。

Symbol 的本質是「名稱的名稱」,它代表的是標識本身,而不是可變的內容:

# 首先,讓我們理解 Symbol 的唯一性
# 每次使用相同的 Symbol,都指向同一個物件
:name.object_id  # 比如:1086748
:name.object_id  # 還是:1086748
:name.object_id  # 永遠:1086748

# 相比之下,String 每次都創建新物件
"name".object_id  # 比如:70234567890
"name".object_id  # 變成:70234567920  
"name".object_id  # 又變:70234567950

# 這個差異看似微小,但影響巨大
# 讓我們做個實驗來理解記憶體影響
def compare_memory_usage
  # 測試 String
  string_array = []
  10_000.times { string_array << "status" }
  
  # 測試 Symbol
  symbol_array = []
  10_000.times { symbol_array << :status }
  
  puts "10,000 個 'status' String 建立了 10,000 個物件"
  puts "10,000 個 :status Symbol 只引用 1 個物件"
end

compare_memory_usage

Symbol 在 Rails 中無處不在,理解它們的使用場景對於寫出地道的 Rails 程式碼至關重要:

# Hash 鍵:Symbol 的最常見用途
# 為什麼偏好 Symbol 作為 Hash 鍵?

# 使用 String 作為鍵(較慢,佔用更多記憶體)
user_with_strings = {
  "name" => "Alice",
  "email" => "alice@example.com",
  "role" => "admin"
}

# 使用 Symbol 作為鍵(較快,記憶體效率高)
user_with_symbols = {
  name: "Alice",      # 注意這個語法糖,等同於 :name => "Alice"
  email: "alice@example.com",
  role: "admin"
}

# 效能差異測試
require 'benchmark'

n = 1_000_000
Benchmark.bm(20) do |x|
  x.report("String keys 查詢:") do
    n.times { user_with_strings["name"] }
  end
  
  x.report("Symbol keys 查詢:") do
    n.times { user_with_symbols[:name] }
  end
end
# Symbol 通常快 20-30%,因為 Symbol 的比較是指標比較

但這裡有一個重要的陷阱需要注意,這是很多 Rails 新手會踩的坑:

# Symbol 與資料庫值的型別差異
class Course < ApplicationRecord
  # 在 Model 定義中,我們到處使用 Symbol
  validates :title, presence: true
  scope :published, -> { where(status: :published) }
end

# 建立課程時,我們可以使用 Symbol
course = Course.new
course.status = :published  # 可以賦值 Symbol
course.save

# 但是!從資料庫讀取時...
course = Course.first
puts course.status.class    # => String!不是 Symbol!

# 這意味著比較時要小心
course.status == :published      # => false(String != Symbol)  
course.status == "published"     # => true
course.status.to_sym == :published  # => true

# Rails 提供了優雅的解決方案:Enum
class Course < ApplicationRecord
  enum status: {
    draft: 0,
    published: 1,
    archived: 2
  }
end

# 現在 Rails 會自動處理型別轉換
course.published?   # => true/false(自動生成的方法)
course.published!   # 設定狀態為 published
Course.published    # 返回所有已發布的課程(自動生成的 scope)

最後,讓我分享一個關於 Symbol 的重要安全知識。這是一個許多開發者不知道,但可能造成嚴重問題的細節:

# 危險:Symbol 一旦創建就永遠不會被垃圾回收!
# 這個特性可能被利用來進行記憶體攻擊

def dangerous_method(user_input)
  # 永遠不要這樣做!
  user_input.to_sym  # 如果用戶不斷傳入不同的字串,會耗盡記憶體
end

# 安全的做法:使用白名單
ALLOWED_ACTIONS = %i[create read update delete].freeze

def safe_method(user_input)
  # 只轉換已知安全的值
  if ALLOWED_ACTIONS.map(&:to_s).include?(user_input)
    action = user_input.to_sym
    perform_action(action)
  else
    raise "不允許的操作"
  end
end

# 或者更好:完全避免動態 Symbol 轉換
def safest_method(user_input)
  case user_input
  when "create" then create_something
  when "read" then read_something
  when "update" then update_something
  when "delete" then delete_something
  else
    raise "不允許的操作"
  end
end

Proc 與 Lambda:Block 的物件化形態

當 Block 需要被儲存、傳遞或重複使用時,我們就需要將它物件化。Ruby 提供了兩種方式:Proc 和 Lambda。它們就像是 Block 的容器,讓 Block 能夠像普通物件一樣被操作。

# Proc 是 Block 的基本物件化形式
say_hello = Proc.new { |name| puts "Hello, #{name}!" }
say_hello.call("Ruby")     # => Hello, Ruby!
say_hello.("Rails")        # => Hello, Rails!(語法糖)
say_hello["World"]         # => Hello, World!(另一種語法糖)

# Lambda 是更嚴格的 Proc
greet = lambda { |name| puts "Greetings, #{name}!" }
greet.call("Developer")

# Lambda 的現代語法(Ruby 1.9+)
modern_greet = ->(name) { puts "Hi, #{name}!" }
modern_greet.call("Friend")

# 多參數的 Lambda
calculate = ->(a, b, operation) {
  case operation
  when :add then a + b
  when :multiply then a * b
  else raise "未知操作"
  end
}
puts calculate.call(5, 3, :add)  # => 8

Proc 和 Lambda 看起來很相似,但有兩個關鍵差異。理解這些差異對於選擇正確的工具很重要:

# 差異一:參數檢查的嚴格程度
my_proc = Proc.new { |a, b| puts "Proc: a=#{a}, b=#{b}" }
my_lambda = lambda { |a, b| puts "Lambda: a=#{a}, b=#{b}" }

# Proc 對參數數量很寬鬆
my_proc.call(1)        # 輸出 "Proc: a=1, b="(b 是 nil)
my_proc.call(1, 2, 3)  # 輸出 "Proc: a=1, b=2"(忽略多餘的 3)

# Lambda 嚴格檢查參數數量
# my_lambda.call(1)    # ArgumentError: wrong number of arguments
# my_lambda.call(1, 2, 3)  # ArgumentError: wrong number of arguments

# 差異二:return 的行為
def test_proc_return
  my_proc = Proc.new { return "從 Proc 返回" }
  my_proc.call
  "這行不會執行"  # Proc 的 return 會從方法返回
end

def test_lambda_return
  my_lambda = lambda { return "從 Lambda 返回" }
  result = my_lambda.call
  "Lambda 返回後繼續執行,結果是:#{result}"  # 這行會執行
end

puts test_proc_return    # => "從 Proc 返回"
puts test_lambda_return  # => "Lambda 返回後繼續執行,結果是:從 Lambda 返回"

在 Rails 中實踐:建立真實的應用

現在我們已經理解了 Ruby 的核心概念,是時候在 Rails 中應用這些知識了。讓我們建立真實的 Model 和 Controller,看看這些概念如何在實際開發中發揮作用。

建立資料模型

首先,我們要為 LMS 系統建立基礎的資料模型。Rails 的 generator 會幫我們生成必要的檔案:

# 退出 console(輸入 exit)
# 建立課程、章節、課時的 Model
rails generate model Course title:string description:text status:string
rails generate model Chapter course:references title:string position:integer
rails generate model Lesson chapter:references title:string duration:integer content_type:string

# 執行資料庫遷移,建立實際的資料表
rails db:migrate

# 重新進入 console 來探索我們的 Model
rails c

現在讓我們強化這些 Model,加入 Ruby 和 Rails 的特性:

# app/models/course.rb
class Course < ApplicationRecord
  # 關聯定義:使用 Symbol 指定關聯名稱和選項
  has_many :chapters, dependent: :destroy  # 刪除課程時連帶刪除章節
  has_many :lessons, through: :chapters    # 透過章節間接關聯課時
  
  # Validations:確保資料完整性
  validates :title, presence: true, length: { minimum: 3, maximum: 100 }
  validates :description, presence: true, length: { minimum: 10 }
  validates :status, inclusion: { 
    in: %w[draft published archived],
    message: "%{value} 不是有效的狀態" 
  }
  
  # Callbacks:在特定時機執行程式碼
  before_save :normalize_title
  after_create :log_creation
  before_validation :set_default_status, on: :create
  
  # Scopes:使用 Lambda 定義可重用的查詢
  scope :published, -> { where(status: 'published') }
  scope :draft, -> { where(status: 'draft') }
  scope :recent, -> { order(created_at: :desc) }
  scope :with_chapters, -> { includes(:chapters) }  # 預載入,避免 N+1 查詢
  
  # 自定義方法
  def publish!
    update(status: 'published', published_at: Time.current)
  end
  
  def published?
    status == 'published'
  end
  
  def total_duration
    lessons.sum(:duration)
  end
  
  private
  
  def normalize_title
    # 使用 Ruby 的字串方法來標準化標題
    self.title = title.strip.titleize if title.present?
  end
  
  def set_default_status
    self.status ||= 'draft'
  end
  
  def log_creation
    Rails.logger.info "新課程建立:#{title} (ID: #{id})"
  end
end

讓我們在 console 中測試這些功能,看看 Ruby 特性如何讓操作變得直觀:

# 建立課程,注意 Symbol 作為 Hash 鍵
course = Course.create(
  title: "  ruby on rails 基礎  ",  # 會被自動標準化
  description: "這是一門完整的 Rails 入門課程,適合初學者"
)

puts course.title  # => "Ruby On Rails 基礎"(自動標準化了!)
puts course.status # => "draft"(預設值)

# 使用 scope 進行查詢
Course.published.recent.each do |c|
  puts "#{c.title} - 發布於 #{c.published_at}"
end

# Block 在批次操作中的應用
Course.draft.find_each do |course|
  # find_each 會分批載入,避免記憶體問題
  puts "處理草稿課程:#{course.title}"
  # 可以在這裡執行批次操作
end

建立 API Controller

現在讓我們建立完整的 API Controller,這裡會大量使用我們學到的 Ruby 特性:

# app/controllers/api/v1/courses_controller.rb
module Api
  module V1
    class CoursesController < ApplicationController
      # before_action 使用 Symbol 指定方法名和動作
      before_action :set_course, only: [:show, :update, :destroy, :publish]
      
      # GET /api/v1/courses
      def index
        # 使用 scope 和 includes 優化查詢
        @courses = Course.published.recent.includes(:chapters)
        
        # 分頁處理
        page = params[:page] || 1
        per_page = params[:per_page] || 10
        @courses = @courses.page(page).per(per_page)
        
        # 使用 Block 來格式化回應
        render json: {
          courses: @courses.map { |course| serialize_course(course) },
          meta: pagination_meta(@courses)
        }
      end
      
      # GET /api/v1/courses/:id
      def show
        # 使用 Lambda 來定義序列化邏輯
        detailed_serializer = ->(course) {
          {
            id: course.id,
            title: course.title,
            description: course.description,
            status: course.status,
            total_duration: course.total_duration,
            chapters: course.chapters.map { |chapter|
              {
                id: chapter.id,
                title: chapter.title,
                position: chapter.position,
                lessons_count: chapter.lessons.count
              }
            },
            created_at: course.created_at,
            updated_at: course.updated_at
          }
        }
        
        render json: detailed_serializer.call(@course)
      end
      
      # POST /api/v1/courses
      def create
        @course = Course.new(course_params)
        
        # 使用交易確保資料一致性
        ActiveRecord::Base.transaction do
          if @course.save
            # 如果有章節資料,一併建立
            create_chapters if params[:chapters].present?
            
            render json: {
              status: 'success',
              data: serialize_course(@course),
              message: '課程建立成功'
            }, status: :created
          else
            # 交易會自動回滾
            render json: {
              status: 'error',
              errors: @course.errors.full_messages
            }, status: :unprocessable_entity
          end
        end
      end
      
      # PUT/PATCH /api/v1/courses/:id
      def update
        if @course.update(course_params)
          render json: {
            status: 'success',
            data: serialize_course(@course),
            message: '課程更新成功'
          }
        else
          render json: {
            status: 'error',
            errors: @course.errors.full_messages
          }, status: :unprocessable_entity
        end
      end
      
      # DELETE /api/v1/courses/:id
      def destroy
        @course.destroy
        head :no_content  # 返回 204 No Content
      end
      
      # PUT /api/v1/courses/:id/publish
      def publish
        if @course.publish!
          render json: {
            status: 'success',
            message: '課程已發布',
            published_at: @course.published_at
          }
        else
          render json: {
            status: 'error',
            message: '課程發布失敗'
          }, status: :unprocessable_entity
        end
      end
      
      private
      
      # Strong Parameters:使用 Symbol 指定允許的參數
      def course_params
        params.require(:course).permit(
          :title,
          :description,
          :status,
          chapters_attributes: [
            :title,
            :position,
            lessons_attributes: [:title, :duration, :content_type]
          ]
        )
      end
      
      def set_course
        @course = Course.find(params[:id])
      rescue ActiveRecord::RecordNotFound
        render json: {
          status: 'error',
          message: '找不到指定的課程'
        }, status: :not_found
      end
      
      def serialize_course(course)
        {
          id: course.id,
          title: course.title,
          description: course.description,
          status: course.status,
          chapters_count: course.chapters.count,
          lessons_count: course.lessons.count,
          total_duration: course.total_duration
        }
      end
      
      def pagination_meta(collection)
        {
          current_page: collection.current_page,
          total_pages: collection.total_pages,
          total_count: collection.total_count,
          per_page: collection.limit_value
        }
      end
      
      def create_chapters
        params[:chapters].each do |chapter_params|
          @course.chapters.create(
            title: chapter_params[:title],
            position: chapter_params[:position]
          )
        end
      end
    end
  end
end

設定路由:Ruby DSL 的優雅展現

Rails 的路由系統是 Ruby DSL 的絕佳範例。它展示了如何用 Block 和 Symbol 創造出既強大又易讀的配置語言:

# config/routes.rb
Rails.application.routes.draw do
  # namespace 使用 Block 來組織路由
  namespace :api do
    namespace :v1 do
      # resources 方法展現了 Rails 的約定優於配置
      # 一行程式碼定義了七個 RESTful 路由
      resources :courses do
        # member 區塊:作用於單一資源的額外動作
        member do
          put :publish    # PUT /api/v1/courses/:id/publish
          put :archive    # PUT /api/v1/courses/:id/archive
        end
        
        # collection 區塊:作用於資源集合的額外動作  
        collection do
          get :published  # GET /api/v1/courses/published
          get :drafts     # GET /api/v1/courses/drafts
          get :search     # GET /api/v1/courses/search
        end
        
        # 巢狀資源:表達資源間的階層關係
        resources :chapters, only: [:index, :create, :update, :destroy] do
          resources :lessons, only: [:index, :create, :update, :destroy]
        end
      end
      
      # 也可以定義單一資源(沒有 ID)
      resource :profile, only: [:show, :update]
    end
  end
  
  # 使用 Lambda 實現動態路由約束
  # 這展示了 Lambda 在 Rails 中的實際應用
  constraints(lambda { |request| 
    # 只有特定 header 的請求才能訪問 v2 API
    request.headers['API-Version'] == '2.0'
  }) do
    namespace :api do
      namespace :v2 do
        resources :courses
      end
    end
  end
  
  # 根路徑
  root to: proc { [200, {}, ['LMS API Server Running']] }
end

讓我們測試這些 API 端點,看看我們的成果:

# 啟動伺服器
rails server

# 在另一個終端測試 API

# 建立新課程
curl -X POST http://localhost:3000/api/v1/courses \
  -H "Content-Type: application/json" \
  -d '{
    "course": {
      "title": "Rails API 開發完整指南",
      "description": "從零開始學習如何使用 Rails 建構強大的 API"
    }
  }'

# 取得所有已發布的課程
curl http://localhost:3000/api/v1/courses

# 取得特定課程的詳細資訊
curl http://localhost:3000/api/v1/courses/1

# 發布課程
curl -X PUT http://localhost:3000/api/v1/courses/1/publish

建構領域特定語言(DSL):Ruby 魔法的展現

Rails 的優雅很大程度上來自於它大量使用 DSL。DSL 讓複雜的邏輯變得像自然語言一樣易讀。現在讓我們為 LMS 系統建立一個課程建構 DSL,這會綜合運用今天學到的所有概念:

# app/services/course_builder.rb
class CourseBuilder
  attr_reader :course
  
  def initialize(title = nil)
    @course = Course.new(title: title)
    @current_chapter = nil
    @chapter_position = 0
  end
  
  # DSL 方法:設定課程基本資訊
  def title(text)
    @course.title = text
    self  # 返回 self 以支援方法鏈
  end
  
  def description(text)
    @course.description = text
    self
  end
  
  def status(status_symbol)
    @course.status = status_symbol.to_s
    self
  end
  
  # DSL 方法:建立章節
  def chapter(title, &block)
    @chapter_position += 1
    @current_chapter = @course.chapters.build(
      title: title,
      position: @chapter_position
    )
    
    # 如果提供了 block,在章節的上下文中執行
    if block_given?
      # instance_eval 讓 block 在當前物件的上下文中執行
      # 這樣 block 內的方法調用會在 self 上執行
      instance_eval(&block)
    end
    
    @current_chapter = nil  # 重置當前章節
    self
  end
  
  # DSL 方法:建立課時(必須在章節內)
  def lesson(title, duration: 30, type: :video)
    unless @current_chapter
      raise "課時必須定義在章節內。請先使用 chapter 方法。"
    end
    
    @current_chapter.lessons.build(
      title: title,
      duration: duration,
      content_type: type.to_s
    )
    self
  end
  
  # DSL 方法:新增教材
  def material(name, url)
    unless @current_chapter
      raise "教材必須關聯到章節"
    end
    
    # 這裡可以建立教材關聯
    # @current_chapter.materials.build(name: name, url: url)
    self
  end
  
  # 儲存建構的課程
  def save
    @course.save
  end
  
  def save!
    @course.save!
  end
  
  # 類別方法:提供更優雅的建構方式
  def self.build(&block)
    builder = new
    builder.instance_eval(&block) if block_given?
    builder.course
  end
  
  # 類別方法:建構並儲存
  def self.create(&block)
    builder = new
    builder.instance_eval(&block) if block_given?
    builder.save!
    builder.course
  end
end

# 使用我們的 DSL 來建構課程
# 注意這讀起來幾乎像自然語言!
course = CourseBuilder.create do
  title "Ruby on Rails API 開發實戰"
  description "30 天掌握 Rails API 開發的完整指南"
  status :draft
  
  chapter "Ruby 語言基礎" do
    lesson "變數與資料型別", duration: 20
    lesson "控制結構", duration: 25
    lesson "物件導向程式設計", duration: 45, type: :video
    lesson "Blocks、Procs 與 Lambdas", duration: 60, type: :video
  end
  
  chapter "Rails 框架入門" do
    lesson "MVC 架構概述", duration: 30
    lesson "路由系統詳解", duration: 35
    lesson "控制器與動作", duration: 40
    lesson "模型與資料庫", duration: 50
  end
  
  chapter "API 開發實戰" do
    lesson "RESTful API 設計", duration: 45
    lesson "認證與授權", duration: 55
    lesson "錯誤處理", duration: 30
    lesson "API 版本控制", duration: 35
    lesson "效能優化", duration: 60
  end
end

# 檢視建構的結果
puts "課程:#{course.title}"
puts "章節數:#{course.chapters.count}"
puts "總課時:#{course.lessons.count}"
puts "總時長:#{course.total_duration} 分鐘"

course.chapters.each do |chapter|
  puts "\n章節 #{chapter.position}:#{chapter.title}"
  chapter.lessons.each do |lesson|
    puts "  - #{lesson.title} (#{lesson.duration} 分鐘, #{lesson.content_type})"
  end
end

建立可重用的模組:Concerns

Rails 鼓勵使用 Concerns 來封裝可重用的功能。讓我們建立一個狀態管理模組,展示如何運用 Ruby 的模組系統:

# app/models/concerns/statusable.rb
module Statusable
  extend ActiveSupport::Concern
  
  # 定義狀態常數(使用 Symbol 作為鍵)
  STATUSES = {
    draft: "草稿",
    published: "已發布",
    archived: "已封存"
  }.freeze
  
  # included 區塊:當模組被包含時執行
  included do
    # 定義 validations
    validates :status, inclusion: {
      in: STATUSES.keys.map(&:to_s),
      message: "%{value} 不是有效的狀態"
    }
    
    # 定義 scopes(使用 Lambda)
    scope :with_status, ->(status) { where(status: status.to_s) }
    scope :published, -> { with_status(:published) }
    scope :draft, -> { with_status(:draft) }
    scope :archived, -> { with_status(:archived) }
    
    # 定義 callbacks
    before_validation :set_default_status, on: :create
    
    # 如果有 published_at 欄位,自動設定
    before_save :set_published_at, if: :publishing?
    before_save :set_archived_at, if: :archiving?
  end
  
  # 類別方法
  class_methods do
    def status_options
      STATUSES.map { |key, label| [label, key.to_s] }
    end
    
    def status_options_for_select
      status_options
    end
  end
  
  # 實例方法
  def status_label
    STATUSES[status.to_sym] if status.present?
  end
  
  def draft?
    status == 'draft'
  end
  
  def published?
    status == 'published'
  end
  
  def archived?
    status == 'archived'
  end
  
  def publish!
    update(status: 'published')
  end
  
  def archive!
    update(status: 'archived')
  end
  
  def unpublish!
    update(status: 'draft')
  end
  
  private
  
  def set_default_status
    self.status ||= 'draft'
  end
  
  def publishing?
    status_changed? && status == 'published'
  end
  
  def archiving?
    status_changed? && status == 'archived'
  end
  
  def set_published_at
    self.published_at ||= Time.current if respond_to?(:published_at=)
  end
  
  def set_archived_at
    self.archived_at ||= Time.current if respond_to?(:archived_at=)
  end
end

# 在 Course Model 中使用
class Course < ApplicationRecord
  include Statusable  # 包含我們的模組
  
  # 其他 Course 特定的邏輯...
end

# 現在 Course 擁有了所有狀態管理功能
course = Course.new(title: "新課程")
puts course.draft?        # => true(預設狀態)
puts course.status_label  # => "草稿"

course.publish!
puts course.published?    # => true
puts course.published_at  # => 2024-01-27 14:30:00

# 使用 scope
Course.published.count    # 統計已發布的課程
Course.draft.each do |c|
  puts "草稿課程:#{c.title}"
end

實踐練習:綜合應用

現在讓我們通過一個綜合練習來鞏固今天學到的知識。我們要建立一個批次處理服務,它會運用 Block、Lambda、Symbol 等所有概念:

練習一:批次操作服務(30 分鐘)

# app/services/batch_processor.rb
class BatchProcessor
  attr_reader :results, :errors
  
  def initialize(model_class)
    @model_class = model_class
    @results = []
    @errors = []
    @filters = []
  end
  
  # 使用 Lambda 新增過濾條件
  def add_filter(&block)
    @filters << block if block_given?
    self
  end
  
  # 使用 Block 處理每個記錄
  def process(scope = @model_class.all, &block)
    # 應用所有過濾條件
    filtered_scope = @filters.reduce(scope) do |current_scope, filter|
      current_scope.where(filter)
    end
    
    # 批次處理,避免記憶體問題
    filtered_scope.find_each do |record|
      begin
        result = yield(record) if block_given?
        @results << {
          id: record.id,
          status: :success,
          result: result
        }
      rescue StandardError => e
        @errors << {
          id: record.id,
          status: :error,
          message: e.message,
          backtrace: e.backtrace.first(3)
        }
      end
    end
    
    self
  end
  
  # 條件處理:只處理符合條件的記錄
  def process_if(condition_lambda, &block)
    process do |record|
      if condition_lambda.call(record)
        yield(record)
      else
        { skipped: true, reason: "不符合條件" }
      end
    end
  end
  
  # 使用 Symbol 執行預定義的操作
  def execute_action(action_symbol)
    case action_symbol
    when :publish
      process { |record| record.publish! if record.respond_to?(:publish!) }
    when :archive
      process { |record| record.archive! if record.respond_to?(:archive!) }
    when :delete
      process { |record| record.destroy }
    else
      raise ArgumentError, "未知的操作:#{action_symbol}"
    end
  end
  
  def success_count
    @results.count { |r| r[:status] == :success }
  end
  
  def error_count
    @errors.count
  end
  
  def summary
    {
      total_processed: @results.count + @errors.count,
      successful: success_count,
      failed: error_count,
      success_rate: success_count.to_f / (success_count + error_count),
      results: @results,
      errors: @errors
    }
  end
end

# 使用範例
processor = BatchProcessor.new(Course)

# 新增過濾條件(使用 Lambda)
processor.add_filter { where(status: 'draft') }
processor.add_filter { where('created_at < ?', 1.week.ago) }

# 處理符合條件的記錄
processor.process do |course|
  course.publish!
  puts "發布課程:#{course.title}"
  { published_at: course.published_at }
end

# 檢視結果
summary = processor.summary
puts "處理完成!"
puts "成功:#{summary[:successful]} 個"
puts "失敗:#{summary[:failed]} 個"
puts "成功率:#{(summary[:success_rate] * 100).round(2)}%"

練習二:權限管理 DSL(1 小時)

# app/services/permission_dsl.rb
class PermissionDSL
  def initialize
    @permissions = Hash.new { |h, k| h[k] = [] }
    @current_role = nil
  end
  
  # DSL 方法:定義角色
  def role(role_name, &block)
    @current_role = role_name.to_sym
    instance_eval(&block) if block_given?
    @current_role = nil
    self
  end
  
  # DSL 方法:授予權限
  def can(action, resource, conditions = {})
    raise "必須在 role 區塊內定義權限" unless @current_role
    
    permission = {
      action: action.to_sym,
      resource: resource.to_sym,
      conditions: conditions
    }
    
    # 如果有條件,將它轉換為 Lambda
    if conditions[:if]
      permission[:condition_lambda] = conditions[:if]
    end
    
    @permissions[@current_role] << permission
    self
  end
  
  # DSL 方法:拒絕權限
  def cannot(action, resource)
    raise "必須在 role 區塊內定義權限" unless @current_role
    
    @permissions[@current_role] << {
      action: action.to_sym,
      resource: resource.to_sym,
      denied: true
    }
    self
  end
  
  # 檢查權限
  def can?(role, action, resource, context = {})
    role = role.to_sym
    action = action.to_sym
    resource = resource.to_sym
    
    return false unless @permissions[role]
    
    # 先檢查是否有明確的拒絕
    denied = @permissions[role].any? do |perm|
      perm[:denied] &&
      perm[:action] == action &&
      perm[:resource] == resource
    end
    
    return false if denied
    
    # 檢查是否有授權
    @permissions[role].any? do |perm|
      next if perm[:denied]
      next unless perm[:action] == action || perm[:action] == :manage
      next unless perm[:resource] == resource || perm[:resource] == :all
      
      # 如果有條件,檢查條件
      if perm[:condition_lambda]
        perm[:condition_lambda].call(context)
      else
        true
      end
    end
  end
  
  # 類別方法:建立權限系統
  def self.define(&block)
    dsl = new
    dsl.instance_eval(&block)
    dsl
  end
end

# 定義權限規則
permissions = PermissionDSL.define do
  role :student do
    can :read, :course
    can :read, :lesson
    can :create, :enrollment
    can :update, :profile, if: ->(context) {
      context[:profile_id] == context[:current_user_id]
    }
    cannot :delete, :course  # 明確禁止
  end
  
  role :instructor do
    can :create, :course
    can :update, :course, if: ->(context) {
      context[:course].instructor_id == context[:current_user_id]
    }
    can :delete, :course, if: ->(context) {
      course = context[:course]
      course.instructor_id == context[:current_user_id] &&
      course.enrollments.count == 0
    }
    can :manage, :lesson  # 可以對課時執行任何操作
  end
  
  role :admin do
    can :manage, :all  # 管理員可以做任何事
  end
end

# 測試權限系統
# 學生權限測試
context = { current_user_id: 1, profile_id: 1 }
puts permissions.can?(:student, :read, :course, context)    # => true
puts permissions.can?(:student, :delete, :course, context)  # => false
puts permissions.can?(:student, :update, :profile, context) # => true

# 講師權限測試
course = OpenStruct.new(instructor_id: 2, enrollments: [])
context = { current_user_id: 2, course: course }
puts permissions.can?(:instructor, :update, :course, context) # => true
puts permissions.can?(:instructor, :delete, :course, context) # => true

# 管理員權限測試
puts permissions.can?(:admin, :delete, :anything, {}) # => true

深度思考:轉換思維的關鍵

從其他程式語言轉換到 Ruby 和 Rails,最大的挑戰不是語法,而是思維模式的轉變。讓我分享一些關鍵的思維轉換點。

如果你來自 JavaScript 的世界,你可能習慣了回呼地獄和 Promise 鏈。在 Ruby 中,同步的程式碼流程讓邏輯更清晰。你不需要擔心 async/await,因為 Ruby 的設計就是讓程式碼像說故事一樣自然流暢。當你真正需要非同步處理時,Rails 提供了 ActiveJob 這個優雅的解決方案。

如果你來自 Java 的世界,你可能會覺得失去了型別系統的保護。但請記住,Ruby 社群用完善的測試來彌補這個差異。在 Ruby 中,測試不是可選的,而是開發流程的核心。當你習慣了測試驅動開發,你會發現動態型別帶來的靈活性遠超過它的風險。

如果你來自 Python 的世界,你會發現 Ruby 的表達力更強。Ruby 相信「不只一種方法做事」,這給了你更多的創造空間。但這也意味著你需要學習社群的慣例,理解什麼是「Ruby Way」。

最重要的是,要擁抱 Ruby 的哲學:讓程式設計師快樂。當你發現自己在寫 Ruby 時會心一笑,當你驚嘆於程式碼的優雅,你就真正理解了 Ruby 的精髓。

總結:奠定堅實的基礎

今天是我們三十天旅程的第一天,但我們已經走了很遠。我們不僅搭建了開發環境,創建了真實的 Rails 專案,更重要的是,我們深入理解了 Ruby 語言的核心特性,以及這些特性如何支撐 Rails 的優雅設計。

我們理解了「一切皆物件」不只是一個概念,而是 Ruby 世界的基本法則。我們學會了使用 Block 來創造優雅的控制結構,用 Lambda 和 Proc 來封裝可重用的邏輯。我們深入探討了 Symbol 這個 Ruby 獨特的概念,理解了它在效能優化和程式碼表達上的重要性。

更重要的是,我們在實踐中應用了這些知識。我們建立了 Model,創建了 Controller,設計了路由,甚至實作了自己的 DSL。這不是紙上談兵的理論學習,而是真實的專案開發經驗。

今日核心收穫總結

回顧今天的學習,你已經掌握了構建 Rails 應用的基礎工具。你理解了 Block 如何讓程式碼變得優雅,Symbol 如何優化效能,Lambda 和 Proc 如何提供靈活性。你也看到了這些特性如何在 Rails 中發揮作用,從 Model 的 scope 到 Controller 的 callbacks,從路由的 DSL 到服務物件的設計。

這些知識將成為你接下來學習的基石。明天當我們深入 Rails 的專案結構時,你會看到今天學的概念如何體現在框架的每個角落。第二週當我們處理複雜的業務邏輯時,今天學的 Ruby 特性會成為你的得力工具。最終在建構完整的 LMS 系統時,這些基礎知識會幫助你寫出優雅而高效的程式碼。

自我檢核清單

在結束今天的學習之前,請確認你已經達成以下目標:

  • [ ] 成功安裝 Ruby 3.2+ 和 Rails 7.1+ 開發環境
  • [ ] 創建並運行了 lms_api 專案
  • [ ] 理解 Block、Proc、Lambda 的差異和使用場景
  • [ ] 深入理解 Symbol 的本質和在 Rails 中的應用
  • [ ] 在 Rails Console 中實驗了各種 Ruby 特性
  • [ ] 建立了包含完整 CRUD 功能的 Courses API
  • [ ] 實作了使用 DSL 模式的 CourseBuilder
  • [ ] 創建了可重用的 Statusable 模組
  • [ ] 完成至少一個實踐練習

明日預告

明天,我們將深入探討 Rails 的專案結構與設計哲學。如果說今天我們學習的是建築材料和工具,明天我們將學習如何設計整座建築。你會理解為什麼 Rails 的目錄是這樣組織的,每個資料夾背後隱含著什麼樣的設計理念。

我們會探討「約定優於配置」不只是減少設定檔,而是一種將二十年最佳實踐內建到框架中的智慧。你會學習 Rails 的自動載入機制如何運作,理解 Zeitwerk 如何改變了 Ruby 的程式碼組織方式。最重要的是,你會開始理解 Rails 的「魔法」其實都有清晰的原理。

準備好深入 Rails 的內部世界了嗎?明天,我們將一起揭開 Rails 優雅背後的秘密。記得帶著今天學到的 Ruby 知識,因為你會看到它們如何在 Rails 的每個角落發揮作用,共同編織出這個強大而優雅的框架。

今天只是開始,但這是一個堅實的開始。保持你的好奇心和熱情,Rails 的世界正等待著你去探索。明天見!


上一篇
Day 0: Rails API 的真實樣貌 - 三十天轉職實戰之旅啟程
下一篇
Day 2: Rails 專案結構與設計哲學 - 從混沌到秩序的架構之道
系列文
30 天 Rails 新手村:從工作專案學會 Ruby on Rails3
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言