如果你來自 Express 的世界,你可能從未真正思考過 MVC。你的路由直接對應到處理函數,中介軟體串連起請求處理管線,一切看起來簡單直接。或者你來自 Spring Boot,習慣了 @RestController 的註解式開發,Service 層處理業務邏輯,Repository 層處理資料存取,整個架構層次分明。
今天我們要探討的問題是:當 Rails 移除了 View 層,變成純 API 模式後,MVC 架構還有意義嗎?更深層的問題是:Rails 為什麼堅持在 API 模式下保留 MVC 的架構?這不是技術慣性,而是對「關注點分離」這個軟體設計核心原則的堅守。
在接下來的 LMS 系統中,我們會建構複雜的 API 端點:課程管理需要處理巢狀資源、學習進度需要即時更新、作業提交需要檔案處理。理解 Rails API 的架構思維,能讓這些複雜需求的實作變得優雅而可維護。這是我們螺旋式學習的第一次深入接觸 Rails 的請求處理核心。
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 | 類型驅動,自動序列化 |
讓我們追蹤一個 API 請求在 Rails 中的完整旅程:
# 一個請求的生命週期
# GET /api/v1/courses/1/lessons
# 1. Rack 中介軟體鏈
# ↓
# 2. Rails 路由系統
# ↓
# 3. 控制器前置過濾器
# ↓
# 4. 控制器動作執行
# ↓
# 5. 模型層處理
# ↓
# 6. 序列化器轉換
# ↓
# 7. 回應渲染
關鍵洞察:
# 第一步:初始化 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
# 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
# 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 請求場景:
- 巢狀資源:課程 → 章節 → 課時的層級結構
- 即時更新:學習進度的自動保存
- 批量操作:批量註冊學生、批量評分
- 檔案上傳:作業提交、教材上傳
**實作挑戰:**
- 挑戰 1:如何優雅處理深層巢狀資源的路由
- 挑戰 2:如何實現請求的冪等性
- 挑戰 3:如何處理長時間運行的請求
# 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
# 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
%%{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
誤區 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
# 效能監控中介軟體
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
# 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
練習目標:
建立一個簡單的 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 的基礎元素。
挑戰目標:
實作 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 不只是能動,更要能優雅地處理各種複雜情況,為使用者提供可靠的服務。
與前期內容的連結:
對後續內容的鋪墊:
%%{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
知識層面:
思維層面:
實踐層面:
完成今天的學習後,你應該能夠:
深入閱讀:
相關 Gem:
jsonapi-serializer
:高效能的 JSON:API 序列化active_model_serializers
:經典的序列化解決方案blueprinter
:簡單快速的 JSON 序列化器明天我們將深入 ActiveRecord,探索 Rails 如何將資料庫操作變成優雅的 Ruby 程式碼。如果說今天學習的是請求如何流經系統,那明天就是資料如何在系統中生存和演化。
準備好探索 Active Record 模式的魔法了嗎?明天見!
重要提醒: 今天的程式碼範例都已經過測試,可以直接在 Rails 7.1+ 環境中執行。如果遇到問題,請確認你的 Rails 版本,並檢查是否正確使用了 --api
標誌建立專案。記得安裝必要的 gem:jsonapi-serializer
和 kaminari
(用於分頁)。