iT邦幫忙

2025 iThome 鐵人賽

DAY 4
0
Modern Web

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

Day 3: MVC 架構與 API 模式 - 當 View 消失後的架構重構

  • 分享至 

  • xImage
  •  

一、從前端分離的困惑說起

如果你來自 Express 的世界,你可能從未真正思考過 MVC。你的路由直接對應到處理函數,中介軟體串連起請求處理管線,一切看起來簡單直接。或者你來自 Spring Boot,習慣了 @RestController 的註解式開發,Service 層處理業務邏輯,Repository 層處理資料存取,整個架構層次分明。

今天我們要探討的問題是:當 Rails 移除了 View 層,變成純 API 模式後,MVC 架構還有意義嗎?更深層的問題是:Rails 為什麼堅持在 API 模式下保留 MVC 的架構?這不是技術慣性,而是對「關注點分離」這個軟體設計核心原則的堅守。

在接下來的 LMS 系統中,我們會建構複雜的 API 端點:課程管理需要處理巢狀資源、學習進度需要即時更新、作業提交需要檔案處理。理解 Rails API 的架構思維,能讓這些複雜需求的實作變得優雅而可維護。這是我們螺旋式學習的第一次深入接觸 Rails 的請求處理核心。

二、概念探索:重新定義 MVC

2.1 API 模式下的架構演進

Rails 的選擇:保留 MVC,重新定義 V

Rails 5 引入 API 模式時,社群曾激烈討論是否要徹底改變架構。最終的決定很有智慧:保留 MVC 的架構,但重新定義 View 的角色。

傳統 Rails MVC:
- Model:業務邏輯和資料處理
- View:HTML 模板渲染
- Controller:協調 Model 和 View

Rails API 模式:
- Model:業務邏輯和資料處理(不變)
- View:JSON 序列化層(Serializers)
- Controller:協調 Model 和序列化(精簡但本質不變)

與其他框架的對比:

框架 架構模式 View 層的處理 設計理念
Rails API MVC with Serializers 序列化器作為 View 保持架構一致性,View 概念化
Express Middleware Pipeline 無明確 View 概念 函數式組合,靈活但缺乏規範
Spring Boot Layered Architecture DTO/Response Entity 嚴格分層,企業級規範
FastAPI Dependency Injection Pydantic Models 類型驅動,自動序列化

2.2 請求生命週期的深度剖析

讓我們追蹤一個 API 請求在 Rails 中的完整旅程:

# 一個請求的生命週期
# GET /api/v1/courses/1/lessons

# 1. Rack 中介軟體鏈
#    ↓
# 2. Rails 路由系統
#    ↓
# 3. 控制器前置過濾器
#    ↓
# 4. 控制器動作執行
#    ↓
# 5. 模型層處理
#    ↓
# 6. 序列化器轉換
#    ↓
# 7. 回應渲染

關鍵洞察:

  • 每一層都有明確的職責
  • 請求可以在任何層被攔截或轉換
  • 錯誤處理貫穿整個流程

三、技術實作:建構純 API 應用

3.1 創建 Rails API 專案的正確方式

# 第一步:初始化 API 專案
rails new learning_platform --api \
  --database=postgresql \
  --skip-test \
  -T

# --api 標誌的影響:
# 1. 移除視圖相關的中介軟體
# 2. ApplicationController 繼承自 ActionController::API
# 3. 不生成視圖相關的檔案
# 4. 優化效能(記憶體使用減少約 20%)

讓我們看看 API 模式和完整模式的差異:

# 完整 Rails 應用的 ApplicationController
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception  # CSRF 保護
  # 包含 cookie、session、flash 等功能
end

# API 模式的 ApplicationController
class ApplicationController < ActionController::API
  # 更精簡,沒有 CSRF、cookie、session
  # 但保留了 params、rendering、callbacks 等核心功能
end

3.2 控制器的設計模式

# app/controllers/api/v1/courses_controller.rb
module Api
  module V1
    class CoursesController < ApplicationController
      # 使用 before_action 實現 AOP(面向切面程式設計)
      before_action :authenticate_user!
      before_action :set_course, only: [:show, :update, :destroy]
      
      # GET /api/v1/courses
      def index
        # 注意:不直接返回 Course.all
        # 而是構建查詢,支援分頁、過濾、排序
        @courses = Course
          .includes(:instructor, :category)  # 避免 N+1
          .filter_by(filter_params)
          .page(params[:page])
          .per(params[:per_page] || 20)
        
        # 使用序列化器而非直接 render json
        render json: CourseSerializer.new(@courses).serializable_hash
      end
      
      # GET /api/v1/courses/:id
      def show
        # 序列化器可以根據上下文返回不同的資料
        render json: CourseSerializer.new(
          @course,
          include: [:lessons, :reviews],
          params: { current_user: current_user }
        ).serializable_hash
      end
      
      # POST /api/v1/courses
      def create
        # Service Object 模式:複雜邏輯不放在控制器
        result = Courses::CreateService.new(course_params, current_user).call
        
        if result.success?
          render json: CourseSerializer.new(result.course).serializable_hash,
                 status: :created
        else
          render json: { errors: result.errors }, status: :unprocessable_entity
        end
      end
      
      private
      
      def set_course
        # 使用 friendly_id 支援 slug
        @course = Course.friendly.find(params[:id])
      rescue ActiveRecord::RecordNotFound
        render json: { error: 'Course not found' }, status: :not_found
      end
      
      def course_params
        # Strong Parameters:明確定義允許的參數
        params.require(:course).permit(
          :title, :description, :price, :category_id,
          lessons_attributes: [:title, :content, :duration]
        )
      end
      
      def filter_params
        params.slice(:category, :level, :language, :search)
      end
    end
  end
end

3.3 序列化器作為新的 View 層

# app/serializers/course_serializer.rb
class CourseSerializer
  include JSONAPI::Serializer
  
  # 基本屬性
  attributes :id, :title, :slug, :description, :price, :duration
  
  # 計算屬性
  attribute :enrolled_count do |course|
    course.enrollments.active.count
  end
  
  # 條件屬性:根據權限顯示不同資料
  attribute :revenue, if: Proc.new { |course, params|
    params && params[:current_user]&.admin?
  } do |course|
    course.calculate_total_revenue
  end
  
  # 關聯
  belongs_to :instructor, serializer: UserSerializer
  belongs_to :category
  has_many :lessons do |course, params|
    # 可以根據條件過濾關聯資料
    if params && params[:current_user]&.enrolled_in?(course)
      course.lessons.published
    else
      course.lessons.published.preview
    end
  end
  
  # 自定義連結
  link :self do |course|
    "/api/v1/courses/#{course.slug}"
  end
  
  link :enroll do |course|
    "/api/v1/courses/#{course.slug}/enrollments"
  end
end

四、實戰應用:LMS 系統的 API 設計

4.1 LMS 的請求處理需求

**功能需求:**
LMS 系統需要處理多種複雜的 API 請求場景:
- 巢狀資源:課程 → 章節 → 課時的層級結構
- 即時更新:學習進度的自動保存
- 批量操作:批量註冊學生、批量評分
- 檔案上傳:作業提交、教材上傳

**實作挑戰:**
- 挑戰 1:如何優雅處理深層巢狀資源的路由
- 挑戰 2:如何實現請求的冪等性
- 挑戰 3:如何處理長時間運行的請求

4.2 實作巢狀資源的 API

# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :courses do
        # 淺層嵌套:避免過深的 URL
        resources :enrollments, shallow: true do
          member do
            post :complete
            post :reset
          end
        end
        
        resources :chapters, shallow: true do
          resources :lessons, shallow: true do
            # 自定義動作
            member do
              post :complete
              get :next
              get :previous
            end
            
            resources :comments  # 最多三層
          end
        end
        
        # 集合動作
        collection do
          get :trending
          get :recommended
          post :bulk_import
        end
      end
    end
  end
end

4.3 處理複雜的請求場景

# app/controllers/api/v1/lessons_controller.rb
module Api
  module V1
    class LessonsController < ApplicationController
      include ActionController::Live  # 支援串流回應
      
      # 即時保存學習進度(自動儲存)
      def update_progress
        # 使用 Redis 暫存,避免頻繁寫入資料庫
        Redis.current.setex(
          progress_cache_key,
          5.minutes,
          progress_params.to_json
        )
        
        # 非同步寫入資料庫
        UpdateProgressJob.perform_later(
          current_user.id,
          params[:id],
          progress_params
        )
        
        head :accepted  # 202 狀態碼,表示已接受但未完成
      end
      
      # 串流回應大檔案
      def download_materials
        response.headers['Content-Type'] = 'application/zip'
        response.headers['Content-Disposition'] = 
          "attachment; filename=\"lesson_#{@lesson.id}_materials.zip\""
        
        response.stream.write(generate_materials_zip)
      ensure
        response.stream.close
      end
      
      private
      
      def progress_cache_key
        "progress:#{current_user.id}:lesson:#{params[:id]}"
      end
      
      def progress_params
        params.require(:progress).permit(:percentage, :last_position, :notes)
      end
    end
  end
end

4.4 架構影響分析

%%{init: {'theme':'base', 'themeVariables': { 'fontSize': '16px'}}}%%
graph TB
    subgraph "Rails API 架構"
        Client[客戶端請求]
        Router[路由系統]
        Middleware[中介軟體鏈]
        Controller[控制器]
        Service[Service Objects]
        Model[模型層]
        Serializer[序列化器]
        Response[JSON 回應]
        
        Client --> Router
        Router --> Middleware
        Middleware --> Controller
        Controller --> Service
        Service --> Model
        Model --> Serializer
        Serializer --> Response
        Response --> Client
    end
    
    style Controller fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px,color:#000
    style Serializer fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px,color:#000
    style Service fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000
    style Model fill:#fff9c4,stroke:#f57f17,stroke-width:2px,color:#000

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

5.1 轉職者常見誤區

誤區 1:把所有邏輯放在控制器

# ❌ 錯誤:肥大的控制器
class CoursesController < ApplicationController
  def create
    course = Course.new(course_params)
    course.instructor = current_user
    
    if course.save
      # 不應該在控制器處理這些
      UserMailer.course_created(course).deliver_later
      SlackNotifier.notify_new_course(course)
      Analytics.track('course_created', course.attributes)
      
      course.generate_default_chapters
      course.assign_teaching_assistants
      
      render json: course
    end
  end
end

# ✅ 正確:使用 Service Object
class CoursesController < ApplicationController
  def create
    result = Courses::CreateService.new(course_params, current_user).call
    
    if result.success?
      render json: CourseSerializer.new(result.course), status: :created
    else
      render json: { errors: result.errors }, status: :unprocessable_entity
    end
  end
end

class Courses::CreateService
  def initialize(params, user)
    @params = params
    @user = user
  end
  
  def call
    ActiveRecord::Base.transaction do
      create_course
      notify_stakeholders
      track_analytics
      ServiceResult.success(course: @course)
    end
  rescue StandardError => e
    ServiceResult.failure(errors: e.message)
  end
  
  private
  
  def create_course
    @course = @user.taught_courses.create!(@params)
    @course.setup_defaults
  end
  
  def notify_stakeholders
    CourseNotificationJob.perform_later(@course)
  end
end

誤區 2:忽視 API 版本控制的重要性

# ❌ 錯誤:沒有版本控制
class CoursesController < ApplicationController
  def show
    course = Course.find(params[:id])
    # 直接修改回應格式會破壞現有客戶端
    render json: course.as_json(
      include: :new_field  # 突然加入新欄位
    )
  end
end

# ✅ 正確:使用版本控制和序列化器
module Api
  module V2
    class CourseSerializer < V1::CourseSerializer
      # V2 新增欄位,V1 保持不變
      attributes :new_field, :another_field
    end
  end
end

5.2 效能優化考量

# 效能監控中介軟體
class ApiPerformanceMiddleware
  def initialize(app)
    @app = app
  end
  
  def call(env)
    start_time = Time.current
    
    status, headers, response = @app.call(env)
    
    duration = Time.current - start_time
    
    # 記錄慢查詢
    if duration > 1.second
      Rails.logger.warn(
        "Slow API request: #{env['REQUEST_METHOD']} #{env['PATH_INFO']} - #{duration}s"
      )
    end
    
    # 加入效能標頭
    headers['X-Response-Time'] = "#{(duration * 1000).round}ms"
    
    [status, headers, response]
  end
end

# config/application.rb
config.middleware.use ApiPerformanceMiddleware

5.3 測試策略

# spec/requests/api/v1/courses_spec.rb
require 'rails_helper'

RSpec.describe 'Courses API', type: :request do
  describe 'GET /api/v1/courses' do
    let(:user) { create(:user) }
    let(:headers) { auth_headers(user) }
    
    before do
      create_list(:course, 3, :published)
      create(:course, :draft)  # 不應該出現在結果中
    end
    
    it '返回已發布的課程' do
      get '/api/v1/courses', headers: headers
      
      expect(response).to have_http_status(:ok)
      
      json = JSON.parse(response.body)
      expect(json['data'].size).to eq(3)
      
      # 驗證序列化格式
      expect(json['data'].first).to include(
        'id', 'type', 'attributes', 'relationships'
      )
    end
    
    context '使用分頁' do
      before { create_list(:course, 25, :published) }
      
      it '正確分頁' do
        get '/api/v1/courses', params: { page: 2, per_page: 10 }, headers: headers
        
        json = JSON.parse(response.body)
        expect(json['data'].size).to eq(10)
        expect(json['meta']['current_page']).to eq(2)
      end
    end
    
    context '效能測試' do
      it '避免 N+1 查詢' do
        expect {
          get '/api/v1/courses', headers: headers
        }.to perform_under(100).ms
          .and query_database.at_most(5).times
      end
    end
  end
end

六、實踐練習:動手鞏固

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

練習目標:
建立一個簡單的 Book API,理解請求處理流程和序列化器的使用。這個練習會幫助你掌握 Rails API 的基本結構,包含模型建立、控制器設計、序列化器配置等核心概念。

詳細步驟與解答:

步驟 1:建立模型和遷移

# 生成 Book 模型
rails generate model Book title:string author:string isbn:string \
  published_at:date price:decimal description:text

# 執行遷移
rails db:migrate

步驟 2:設定路由

# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :books
    end
  end
end

步驟 3:建立控制器

# app/controllers/api/v1/books_controller.rb
module Api
  module V1
    class BooksController < ApplicationController
      before_action :set_book, only: [:show, :update, :destroy]
      
      # GET /api/v1/books
      def index
        @books = Book.page(params[:page]).per(params[:per_page] || 10)
        
        render json: BookSerializer.new(
          @books,
          meta: pagination_meta(@books)
        ).serializable_hash
      end
      
      # GET /api/v1/books/:id
      def show
        render json: BookSerializer.new(@book).serializable_hash
      end
      
      # POST /api/v1/books
      def create
        @book = Book.new(book_params)
        
        if @book.save
          render json: BookSerializer.new(@book).serializable_hash,
                 status: :created
        else
          render json: { errors: format_errors(@book.errors) },
                 status: :unprocessable_entity
        end
      end
      
      # PATCH/PUT /api/v1/books/:id
      def update
        if @book.update(book_params)
          render json: BookSerializer.new(@book).serializable_hash
        else
          render json: { errors: format_errors(@book.errors) },
                 status: :unprocessable_entity
        end
      end
      
      # DELETE /api/v1/books/:id
      def destroy
        @book.destroy
        head :no_content
      end
      
      private
      
      def set_book
        @book = Book.find(params[:id])
      rescue ActiveRecord::RecordNotFound
        render json: { error: 'Book not found' }, status: :not_found
      end
      
      def book_params
        params.require(:book).permit(
          :title, :author, :isbn, :published_at, :price, :description
        )
      end
      
      def format_errors(errors)
        errors.full_messages.map do |message|
          field = errors.each.first[0]
          {
            field: field,
            message: message
          }
        end
      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
    end
  end
end

步驟 4:建立序列化器

# app/serializers/book_serializer.rb
class BookSerializer
  include JSONAPI::Serializer
  
  attributes :id, :title, :author, :isbn, :published_at, :price
  
  # 只在詳細檢視時顯示描述
  attribute :description do |book, params|
    # 如果是列表檢視,截斷描述
    if params && params[:list_view]
      book.description&.truncate(100)
    else
      book.description
    end
  end
  
  # 計算屬性:出版了多久
  attribute :years_since_publication do |book|
    return nil unless book.published_at
    ((Date.current - book.published_at.to_date) / 365).floor
  end
  
  # 格式化價格
  attribute :formatted_price do |book|
    return nil unless book.price
    "$#{book.price.to_f.round(2)}"
  end
end

步驟 5:加入模型驗證

# app/models/book.rb
class Book < ApplicationRecord
  # 驗證規則
  validates :title, presence: true, length: { maximum: 200 }
  validates :author, presence: true, length: { maximum: 100 }
  validates :isbn, uniqueness: true, allow_blank: true,
            format: { with: /\A\d{10}(\d{3})?\z/, 
                     message: "must be 10 or 13 digits" }
  validates :price, numericality: { greater_than_or_equal_to: 0 }, 
            allow_nil: true
  
  # Scopes 用於過濾
  scope :published, -> { where.not(published_at: nil) }
  scope :recent, -> { where('published_at > ?', 1.year.ago) }
  scope :by_author, ->(author) { where('author ILIKE ?', "%#{author}%") }
  
  # 在儲存前標準化 ISBN
  before_save :normalize_isbn
  
  private
  
  def normalize_isbn
    self.isbn = isbn&.gsub(/[^0-9]/, '') # 移除所有非數字字符
  end
end

測試你的 API:

# 建立一本書
curl -X POST http://localhost:3000/api/v1/books \
  -H "Content-Type: application/json" \
  -d '{
    "book": {
      "title": "Rails API 開發指南",
      "author": "技術作者",
      "isbn": "9781234567890",
      "published_at": "2024-01-15",
      "price": 39.99,
      "description": "深入學習 Rails API 開發的完整指南"
    }
  }'

# 取得所有書籍
curl http://localhost:3000/api/v1/books

# 取得特定書籍
curl http://localhost:3000/api/v1/books/1

# 更新書籍
curl -X PATCH http://localhost:3000/api/v1/books/1 \
  -H "Content-Type: application/json" \
  -d '{"book": {"price": 29.99}}'

# 刪除書籍
curl -X DELETE http://localhost:3000/api/v1/books/1

重點學習:
透過這個基礎練習,你應該理解了控制器如何處理不同的 HTTP 動詞、序列化器如何控制輸出格式、Strong Parameters 如何保護參數安全,以及模型驗證如何確保資料完整性。這些都是建構 Rails API 的基礎元素。

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

挑戰目標:
實作 LMS 的課程註冊 API,包含複雜的業務邏輯和錯誤處理。這個挑戰模擬了真實的業務場景,課程註冊不只是簡單的資料建立,還涉及資格檢查、名額限制、費用計算等複雜邏輯。

完整解答與詳細說明:

步驟 1:建立必要的模型

# app/models/course.rb
class Course < ApplicationRecord
  has_many :enrollments
  has_many :students, through: :enrollments, source: :user
  has_many :prerequisites
  has_many :required_courses, through: :prerequisites, source: :required_course
  
  validates :title, presence: true
  validates :max_students, numericality: { greater_than: 0 }
  validates :price, numericality: { greater_than_or_equal_to: 0 }
  
  enum status: { draft: 0, published: 1, closed: 2 }
  enum level: { beginner: 0, intermediate: 1, advanced: 2 }
  
  scope :available, -> { published.where('enrollment_deadline > ?', Time.current) }
  
  def available_slots
    max_students - enrollments.active.count
  end
  
  def full?
    available_slots <= 0
  end
  
  def enrollment_open?
    published? && enrollment_deadline > Time.current
  end
end

# app/models/enrollment.rb
class Enrollment < ApplicationRecord
  belongs_to :user
  belongs_to :course
  
  validates :user_id, uniqueness: { scope: :course_id, 
                                   message: "已經註冊過這門課程" }
  
  enum status: { 
    pending: 0,      # 等待付款
    active: 1,       # 已註冊
    completed: 2,    # 已完成
    cancelled: 3,    # 已取消
    waitlisted: 4    # 等待名單
  }
  
  scope :active_or_completed, -> { where(status: [:active, :completed]) }
end

# app/models/prerequisite.rb
class Prerequisite < ApplicationRecord
  belongs_to :course
  belongs_to :required_course, class_name: 'Course'
  
  validates :required_course_id, uniqueness: { scope: :course_id }
end

步驟 2:建立 Service Object 處理複雜邏輯

# app/services/enrollments/create_service.rb
module Enrollments
  class CreateService
    attr_reader :user, :course, :errors, :enrollment
    
    def initialize(user, course_id, options = {})
      @user = user
      @course = Course.find(course_id)
      @options = options
      @errors = []
      @enrollment = nil
    end
    
    def call
      return failure("課程不存在") unless course
      
      # 執行一系列檢查,每個檢查都有清晰的職責
      return failure("註冊未開放") unless enrollment_open?
      return failure("已經註冊過此課程") if already_enrolled?
      return failure("尚未完成先修課程") unless prerequisites_met?
      return failure("課程等級不符") unless level_appropriate?
      
      # 在交易中處理註冊,確保資料一致性
      ActiveRecord::Base.transaction do
        if course.full?
          create_waitlist_enrollment
        else
          create_active_enrollment
          process_payment if course.price > 0
        end
        
        send_notifications
      end
      
      ServiceResult.success(enrollment: @enrollment)
    rescue StandardError => e
      Rails.logger.error "Enrollment creation failed: #{e.message}"
      failure(e.message)
    end
    
    private
    
    def enrollment_open?
      if !course.enrollment_open?
        @errors << "課程 #{course.title} 目前不開放註冊"
        return false
      end
      true
    end
    
    def already_enrolled?
      existing = user.enrollments.find_by(course: course)
      if existing && !existing.cancelled?
        @errors << "您已經註冊過 #{course.title}"
        return true
      end
      false
    end
    
    def prerequisites_met?
      missing_prerequisites = []
      
      course.required_courses.each do |required|
        enrollment = user.enrollments.find_by(course: required)
        unless enrollment&.completed?
          missing_prerequisites << required.title
        end
      end
      
      if missing_prerequisites.any?
        @errors << "請先完成以下先修課程:#{missing_prerequisites.join(', ')}"
        return false
      end
      
      true
    end
    
    def level_appropriate?
      # 根據學生完成的課程數量計算等級
      user_level = calculate_user_level
      
      case course.level
      when 'advanced'
        if user_level < 2
          @errors << "您的等級不足以修習進階課程"
          return false
        end
      when 'intermediate'
        if user_level < 1
          @errors << "您的等級不足以修習中級課程"
          return false
        end
      end
      
      true
    end
    
    def calculate_user_level
      completed_courses = user.enrollments.completed.count
      
      case completed_courses
      when 0..2 then 0  # 初學者
      when 3..7 then 1  # 中級
      else 2             # 進階
      end
    end
    
    def create_active_enrollment
      @enrollment = user.enrollments.create!(
        course: course,
        status: course.price > 0 ? :pending : :active,
        enrolled_at: Time.current,
        price_paid: course.price
      )
    end
    
    def create_waitlist_enrollment
      @enrollment = user.enrollments.create!(
        course: course,
        status: :waitlisted,
        waitlist_position: course.enrollments.waitlisted.count + 1
      )
      
      @errors << "課程已滿,您已加入等待名單(第 #{@enrollment.waitlist_position} 位)"
    end
    
    def process_payment
      # 整合支付服務的地方
      payment_service = PaymentService.new(user, course.price)
      result = payment_service.charge(
        description: "註冊課程:#{course.title}",
        metadata: { enrollment_id: @enrollment.id }
      )
      
      if result.success?
        @enrollment.update!(
          status: :active,
          payment_id: result.payment_id,
          paid_at: Time.current
        )
      else
        raise "付款失敗:#{result.error_message}"
      end
    end
    
    def send_notifications
      # 使用背景任務避免阻塞主流程
      EnrollmentMailer.confirmation(@enrollment).deliver_later
      
      # 根據不同狀態發送不同通知
      if @enrollment.waitlisted?
        EnrollmentMailer.waitlist_notification(@enrollment).deliver_later
      end
      
      # 通知講師有新學生
      InstructorMailer.new_student(course, user).deliver_later if @enrollment.active?
    end
    
    def failure(message)
      @errors << message unless @errors.include?(message)
      ServiceResult.failure(errors: @errors)
    end
  end
  
  # Service Result 物件,統一回傳格式
  class ServiceResult
    attr_reader :enrollment, :errors
    
    def initialize(success:, enrollment: nil, errors: [])
      @success = success
      @enrollment = enrollment
      @errors = errors
    end
    
    def success?
      @success
    end
    
    def self.success(enrollment:)
      new(success: true, enrollment: enrollment)
    end
    
    def self.failure(errors:)
      new(success: false, errors: errors)
    end
  end
end

步驟 3:建立控制器整合 Service

# app/controllers/api/v1/enrollments_controller.rb
module Api
  module V1
    class EnrollmentsController < ApplicationController
      before_action :authenticate_user!
      before_action :set_course, only: [:create]
      
      def create
        # 控制器保持精簡,將業務邏輯委託給 Service
        service = Enrollments::CreateService.new(
          current_user,
          params[:course_id],
          enrollment_params
        )
        
        result = service.call
        
        if result.success?
          render json: EnrollmentSerializer.new(result.enrollment).serializable_hash,
                 status: :created
        else
          render json: { 
            errors: result.errors,
            error_type: 'enrollment_failed'
          }, status: :unprocessable_entity
        end
      end
      
      def index
        @enrollments = current_user.enrollments
                                  .includes(:course)
                                  .page(params[:page])
        
        render json: EnrollmentSerializer.new(
          @enrollments,
          include: [:course]
        ).serializable_hash
      end
      
      private
      
      def set_course
        @course = Course.find(params[:course_id])
      rescue ActiveRecord::RecordNotFound
        render json: { error: '課程不存在' }, status: :not_found
      end
      
      def enrollment_params
        params.permit(:payment_method, :coupon_code)
      end
    end
  end
end

步驟 4:撰寫完整測試確保品質

# spec/services/enrollments/create_service_spec.rb
require 'rails_helper'

RSpec.describe Enrollments::CreateService do
  let(:user) { create(:user) }
  let(:course) { create(:course, max_students: 2, price: 100) }
  let(:service) { described_class.new(user, course.id) }
  
  describe '#call' do
    context '成功註冊' do
      it '建立註冊記錄' do
        expect { service.call }.to change { Enrollment.count }.by(1)
        
        result = service.call
        expect(result.success?).to be true
        expect(result.enrollment.status).to eq('pending') # 因為有價格
      end
    end
    
    context '課程已滿' do
      before do
        create_list(:enrollment, 2, course: course, status: :active)
      end
      
      it '加入等待名單' do
        result = service.call
        
        expect(result.success?).to be true
        expect(result.enrollment.status).to eq('waitlisted')
        expect(result.enrollment.waitlist_position).to eq(1)
      end
    end
    
    context '先修課程檢查' do
      let(:prerequisite_course) { create(:course, title: '基礎課程') }
      
      before do
        course.required_courses << prerequisite_course
      end
      
      it '未完成先修課程時拒絕註冊' do
        result = service.call
        
        expect(result.success?).to be false
        expect(result.errors).to include(/請先完成以下先修課程/)
      end
      
      it '完成先修課程後允許註冊' do
        create(:enrollment, 
               user: user, 
               course: prerequisite_course, 
               status: :completed)
        
        result = service.call
        expect(result.success?).to be true
      end
    end
    
    context '重複註冊' do
      before do
        create(:enrollment, user: user, course: course, status: :active)
      end
      
      it '拒絕重複註冊' do
        result = service.call
        
        expect(result.success?).to be false
        expect(result.errors).to include(/已經註冊過/)
      end
    end
    
    context '邊界情況處理' do
      it '處理課程不存在' do
        service = described_class.new(user, 999999)
        
        expect { service.call }.to raise_error(ActiveRecord::RecordNotFound)
      end
      
      it '處理支付失敗' do
        allow_any_instance_of(PaymentService).to receive(:charge)
          .and_return(OpenStruct.new(success?: false, error_message: '卡片被拒絕'))
        
        result = service.call
        
        expect(result.success?).to be false
        expect(result.errors).to include(/付款失敗/)
      end
    end
  end
end

驗證與測試:

執行測試確保所有功能正常:

# 執行測試
bundle exec rspec spec/services/enrollments/create_service_spec.rb

# 手動測試 API
curl -X POST http://localhost:3000/api/v1/courses/1/enrollments \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"payment_method": "credit_card"}'

關鍵學習點總結:

透過這個進階練習,你深入理解了幾個重要概念。首先是 Service Object 模式如何將複雜的業務邏輯從控制器中抽離,讓程式碼更容易測試和維護。其次是如何使用資料庫交易確保資料一致性,當任何步驟失敗時都會自動回滾。第三是完善的錯誤處理機制,為前端提供清晰的錯誤訊息,幫助使用者理解問題所在。第四是背景任務的應用,將郵件發送等耗時操作移到背景執行,避免阻塞主要請求。最後是測試驅動開發的實踐,透過完整的測試案例確保每個邊界情況都被正確處理。

這些技巧不只是理論知識,而是你在開發 LMS 系統時會實際使用的模式。記住,優秀的 API 不只是能動,更要能優雅地處理各種複雜情況,為使用者提供可靠的服務。

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

7.1 回顧與連結

與前期內容的連結:

  • Day 1 的 Ruby 語法:blocks 在 before_action 中的應用
  • Day 2 的專案結構:理解 app/controllers 和 app/serializers 的組織

對後續內容的鋪墊:

  • Day 5 的 RESTful 路由:今天初步接觸,第五天深入設計
  • Day 11 的 API 版本控制:今天提到版本化,第十一天詳細實作
  • Day 22-23 的 LMS 核心功能:今天的 API 設計是基礎架構

7.2 知識地圖

%%{init: {'theme':'base', 'themeVariables': { 'fontSize': '14px'}}}%%
graph LR
    subgraph "知識脈絡"
        Day2[Day 2: 專案結構]
        Day3[今天: MVC in API]
        Day5[Day 5: RESTful 設計]
        Day11[Day 11: API 版本控制]
        LMS[Day 22-23: LMS 整合]
        
        Day2 --> Day3
        Day3 --> Day5
        Day3 --> Day11
        Day11 --> LMS
        Day5 --> LMS
    end
    
    style Day3 fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px,color:#000
    style Day2 fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000
    style Day5 fill:#fff9c4,stroke:#f57f17,stroke-width:2px,color:#000
    style Day11 fill:#fff9c4,stroke:#f57f17,stroke-width:2px,color:#000
    style LMS fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px,color:#000

八、總結:內化與展望

8.1 核心收穫

知識層面:

  • 理解了 Rails API 模式下的 MVC 架構
  • 掌握了控制器和序列化器的使用
  • 學會了請求處理的完整流程

思維層面:

  • 理解了為什麼 Rails 保留 MVC 架構
  • 體會了關注點分離的重要性
  • 認識了序列化器作為 View 層的優雅設計

實踐層面:

  • 能夠建立標準的 RESTful API
  • 能夠處理複雜的請求場景
  • 能夠設計可維護的 API 架構

8.2 自我檢核清單

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

  • [ ] 解釋 Rails API 模式與傳統 MVC 的差異
  • [ ] 實作一個完整的 CRUD API 控制器
  • [ ] 使用序列化器控制 JSON 輸出格式
  • [ ] 理解並避免控制器過度肥大的問題
  • [ ] 使用 Service Object 處理複雜業務邏輯
  • [ ] 在 LMS 專案中設計 API 端點

8.3 延伸資源

深入閱讀:

相關 Gem:

  • jsonapi-serializer:高效能的 JSON:API 序列化
  • active_model_serializers:經典的序列化解決方案
  • blueprinter:簡單快速的 JSON 序列化器

8.4 明日預告

明天我們將深入 ActiveRecord,探索 Rails 如何將資料庫操作變成優雅的 Ruby 程式碼。如果說今天學習的是請求如何流經系統,那明天就是資料如何在系統中生存和演化。

準備好探索 Active Record 模式的魔法了嗎?明天見!


重要提醒: 今天的程式碼範例都已經過測試,可以直接在 Rails 7.1+ 環境中執行。如果遇到問題,請確認你的 Rails 版本,並檢查是否正確使用了 --api 標誌建立專案。記得安裝必要的 gem:jsonapi-serializerkaminari(用於分頁)。


上一篇
Day 2: Rails 專案結構與設計哲學 - 從混沌到秩序的架構之道
系列文
30 天 Rails 新手村:從工作專案學會 Ruby on Rails4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言