如果你來自 Express.js 的世界,你習慣了中介軟體(middleware)的鏈式處理模式。每個請求像是通過一條流水線,你可以在任何點插入處理邏輯。在 Spring Boot 中,你可能習慣了 @RestController
註解和依賴注入,享受著強型別帶來的安全感。而在 FastAPI 中,你依賴型別提示和 Pydantic 模型來自動處理請求驗證。
今天我們要探討的是 Rails 如何在「約定」和「彈性」之間找到優雅的平衡點。Rails 的控制器不只是請求的處理器,它是整個 MVC 架構中的協調者,負責在模型和視圖(在 API 模式下是序列化器)之間傳遞訊息。
在第一週的學習旅程中,我們已經建立了 Ruby 語法基礎、理解了 Rails 的專案結構、探討了 MVC 在 API 模式下的實踐、掌握了 ActiveRecord 基礎、設計了 RESTful 路由。今天,我們要深入控制器的內部運作機制,理解一個請求從進入 Rails 到返回回應的完整旅程。這些知識將直接應用在 LMS 系統的 API 設計中,特別是處理複雜的業務邏輯如課程註冊、作業提交、成績計算等功能。
讓我們先從宏觀的角度理解 Rails 的請求處理架構。不同於 Express 的單一中介軟體鏈,Rails 的請求處理有著清晰的層級結構:
# 一個請求的完整旅程
# 1. Web Server (Puma/Unicorn) 接收 HTTP 請求
# 2. Rack 介面標準化請求
# 3. Rails 中介軟體鏈處理
# 4. 路由系統分發
# 5. 控制器動作執行
# 6. 回應渲染和返回
讓我們追蹤一個實際的請求。假設學生要查看課程資訊:
# GET /api/v1/courses/ruby-advanced HTTP/1.1
# Host: lms.example.com
# Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
# 這個請求會經過以下處理流程:
# 1. Rack 層級
# config/application.rb
module LMS
class Application < Rails::Application
# Rack 中介軟體在這裡配置
config.middleware.use Rack::Cors do
allow do
origins '*'
resource '*', headers: :any, methods: [:get, :post, :put, :patch, :delete, :options]
end
end
# API 模式自動移除了不必要的中介軟體
config.api_only = true
end
end
# 2. Rails 中介軟體
# 使用 rake middleware 查看完整的中介軟體鏈
# 在 API 模式下,Rails 自動優化了中介軟體:
# - 移除了 ActionDispatch::Cookies
# - 移除了 ActionDispatch::Session::CookieStore
# - 移除了 ActionDispatch::Flash
# - 保留了 ActionDispatch::ParamsParser 處理 JSON
Rack 是 Ruby web 應用的標準介面,就像 Node.js 生態系統中的 Connect 或 Python 的 WSGI。但 Rails 將 Rack 的概念發揮到了極致:
# 自定義中介軟體:請求時間記錄
# app/middleware/request_timer.rb
class RequestTimer
def initialize(app)
@app = app
end
def call(env)
# env 是 Rack 環境,包含所有請求資訊
started_at = Time.current
# 呼叫下一個中介軟體或應用
status, headers, response = @app.call(env)
# 計算處理時間
duration = Time.current - started_at
# 添加自定義 header
headers['X-Runtime'] = "#{(duration * 1000).round(2)}ms"
# 記錄慢請求
if duration > 1.0
Rails.logger.warn "Slow request: #{env['REQUEST_METHOD']} #{env['PATH_INFO']} took #{duration}s"
# 在 LMS 系統中,我們可能要記錄更多資訊
SlowRequestLogger.log(
user_id: env['current_user_id'],
path: env['PATH_INFO'],
duration: duration,
params: env['action_dispatch.request.parameters']
)
end
[status, headers, response]
end
end
# 註冊中介軟體
# config/application.rb
config.middleware.insert_after ActionDispatch::RequestId, RequestTimer
與 Express 的中介軟體相比,Rails 的 Rack 中介軟體有幾個關鍵差異:
call(env)
協議在 Rails 的設計哲學中,控制器應該是「瘦」的(Skinny Controller),主要職責是協調而非實作業務邏輯:
# app/controllers/api/v1/courses_controller.rb
module Api
module V1
class CoursesController < ApplicationController
# before_action 就像 Express 的路由級中介軟體
# 但更加結構化和可測試
before_action :authenticate_user!
before_action :set_course, only: [:show, :update, :destroy, :enroll]
before_action :authorize_instructor!, only: [:update, :destroy]
def index
# 控制器不應該包含複雜的查詢邏輯
# 錯誤示範:
# @courses = Course.joins(:enrollments)
# .where(enrollments: { user_id: current_user.id })
# .where('start_date > ?', Date.today)
# .includes(:instructor, :chapters)
# 正確做法:將邏輯委託給模型或服務
@courses = CourseQuery.new(current_user)
.available
.with_details
.paginate(params[:page])
render json: CourseSerializer.new(@courses,
include: [:instructor, :chapters],
meta: pagination_meta(@courses)
).serializable_hash
end
def show
# 使用 Serializer 而非直接 render json
# 這樣可以控制 API 回應的結構
render json: CourseSerializer.new(@course,
include: params[:include]&.split(','),
fields: sparse_fields
).serializable_hash
end
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
def enroll
# 複雜的業務邏輯應該封裝在 Service Object 中
enrollment_service = Enrollment::ProcessService.new(
course: @course,
student: current_user,
payment_method: params[:payment_method]
)
result = enrollment_service.call
if result.success?
# 觸發背景任務
CourseEnrollmentMailer.welcome_email(result.enrollment).deliver_later
render json: EnrollmentSerializer.new(result.enrollment).serializable_hash,
status: :created
else
render json: {
errors: result.errors,
error_code: result.error_code
}, status: result.status
end
end
private
def set_course
# 使用 friendly_id 讓 URL 更友善
@course = Course.friendly.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'Course not found' }, status: :not_found
end
def authorize_instructor!
unless @course.instructor == current_user || current_user.admin?
render json: { error: 'Unauthorized' }, status: :forbidden
end
end
def course_params
# Strong Parameters 是 Rails 的安全防護
# 明確定義允許的參數,防止 mass assignment 攻擊
params.require(:course).permit(
:title, :description, :price, :start_date,
:category_id, :difficulty_level,
chapters_attributes: [:title, :description, :position,
lessons_attributes: [:title, :content, :video_url, :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 sparse_fields
# JSON API 規範的 sparse fields 支援
params[:fields]&.split(',')&.map(&:to_sym)
end
end
end
end
Strong Parameters 是 Rails 4 引入的安全機制,取代了之前的 attr_accessible
。如果你來自其他框架,可能會覺得這很繁瑣,但它體現了 Rails 的安全優先原則:
# 深入理解 Strong Parameters
class Api::V1::AssignmentsController < ApplicationController
def create
# 從不信任的參數開始
raw_params = params
# => ActionController::Parameters (預設是 "不允許" 的)
# 方法一:簡單的 permit
safe_params = params.permit(:title, :description)
# 方法二:使用 require 確保必要參數存在
assignment_params = params.require(:assignment)
.permit(:title, :description, :due_date)
# 方法三:處理巢狀屬性
complex_params = params.require(:assignment).permit(
:title, :description,
questions_attributes: [
:id, :content, :points, :_destroy,
options_attributes: [:id, :content, :is_correct, :_destroy]
],
rubric: [:criteria, :points, :description]
)
# 方法四:動態允許參數(謹慎使用)
def assignment_params
allowed = [:title, :description, :due_date]
allowed << :published_at if current_user.instructor?
allowed << :override_grades if current_user.admin?
params.require(:assignment).permit(*allowed)
end
# 方法五:處理陣列參數
# 當參數包含陣列時
params.permit(tags: []) # 允許 tags 陣列
params.permit(metadata: {}) # 允許任意的 metadata hash(危險!)
end
private
# 實際應用:為 LMS 的作業提交設計參數過濾
def submission_params
# 學生提交作業時的參數
base_params = params.require(:submission).permit(
:content,
:draft,
attachments: [],
answers_attributes: [:question_id, :content]
)
# 根據作業類型調整允許的參數
case @assignment.submission_type
when 'file_upload'
base_params.permit(:file_url, :file_name, :file_size)
when 'text_entry'
base_params.permit(:text_content)
when 'online_quiz'
base_params.permit(
quiz_answers_attributes: [:question_id, :selected_option_id, :text_answer]
)
when 'external_tool'
base_params.permit(:external_submission_id, :external_grade)
end
end
end
與其他框架的參數處理比較:
@Valid
註解和 Bean ValidationAction Callbacks(before_action、after_action、around_action)提供了精細的請求流程控制:
class Api::V1::BaseController < ApplicationController
# 執行順序:從上到下,從父類到子類
# 1. around_action 開始
around_action :measure_performance
# 2. before_action 執行
before_action :set_locale
before_action :authenticate_user!
before_action :check_user_status
before_action :track_request
# 3. 實際的 action 執行
# 4. after_action 執行(反向順序)
after_action :set_pagination_headers, only: [:index]
after_action :track_response
# 5. around_action 結束
private
def measure_performance
# around_action 可以包裹整個請求
start_time = Time.current
# 執行 action 和其他 callbacks
yield
# 記錄效能指標
duration = Time.current - start_time
PerformanceTracker.record(
controller: controller_name,
action: action_name,
duration: duration,
user_id: current_user&.id
)
end
def set_locale
# 從多個來源決定語言設定
I18n.locale = locale_from_header ||
locale_from_params ||
locale_from_user ||
I18n.default_locale
end
def check_user_status
if current_user&.suspended?
render json: {
error: 'Account suspended',
reason: current_user.suspension_reason,
until: current_user.suspension_ends_at
}, status: :forbidden
end
end
def track_request
# 使用 Rails 的 CurrentAttributes 儲存請求級別的資料
Current.request_id = request.request_id
Current.user = current_user
Current.ip_address = request.remote_ip
# 記錄請求資訊供後續分析
RequestLog.create!(
user: current_user,
controller: controller_name,
action: action_name,
params: filtered_params,
ip_address: request.remote_ip,
user_agent: request.user_agent
)
end
def set_pagination_headers
# 在回應 header 中加入分頁資訊
if @pagination_meta
response.headers['X-Total-Count'] = @pagination_meta[:total_count].to_s
response.headers['X-Page'] = @pagination_meta[:current_page].to_s
response.headers['X-Per-Page'] = @pagination_meta[:per_page].to_s
end
end
def filtered_params
# 過濾敏感資訊
params.except(:password, :token, :secret)
end
end
Rails 的 callbacks 系統非常靈活,支援多種條件控制:
class Api::V1::LessonsController < ApplicationController
# 條件式 callback
before_action :authenticate_user!, except: [:preview]
before_action :check_enrollment, only: [:show, :complete]
before_action :load_course_and_lesson
before_action :check_prerequisites, if: :prerequisites_required?
before_action :track_learning_progress, unless: -> { @lesson.preview? }
# 使用 block 形式的 callback
around_action only: [:show] do |controller, action|
# 記錄學習時間
learning_session = LearningSession.create!(
user: current_user,
lesson: @lesson,
started_at: Time.current
)
# 執行 action
action.call
# 更新學習時間
learning_session.update!(
ended_at: Time.current,
completed: @completed
)
end
def show
# 檢查是否可以存取課程內容
unless @enrollment.can_access_lesson?(@lesson)
return render json: {
error: 'Lesson locked',
reason: @lesson.lock_reason_for(@enrollment),
unlock_requirements: @lesson.unlock_requirements
}, status: :locked
end
# 記錄學習進度
@enrollment.record_lesson_view(@lesson)
render json: LessonSerializer.new(
@lesson,
params: {
current_user: current_user,
include_quiz: params[:include_quiz]
}
).serializable_hash
end
def complete
# Service Object 處理完成邏輯
result = Lessons::CompleteService.new(
lesson: @lesson,
user: current_user,
quiz_answers: params[:quiz_answers]
).call
if result.success?
# 觸發成就系統
AchievementChecker.perform_async(current_user.id, @lesson.id)
render json: {
completed: true,
next_lesson: result.next_lesson,
achievement_unlocked: result.achievement,
progress: result.progress_percentage
}
else
render json: { errors: result.errors }, status: :unprocessable_entity
end
end
private
def check_enrollment
@enrollment = current_user.enrollments.find_by(course: @lesson.course)
unless @enrollment
render json: { error: 'Not enrolled in this course' }, status: :forbidden
end
end
def check_prerequisites
unmet_prerequisites = @lesson.unmet_prerequisites_for(current_user)
if unmet_prerequisites.any?
render json: {
error: 'Prerequisites not met',
unmet_prerequisites: unmet_prerequisites.map do |prereq|
{
id: prereq.id,
title: prereq.title,
type: prereq.class.name
}
end
}, status: :precondition_required
end
end
def prerequisites_required?
@lesson.has_prerequisites? && !current_user.instructor_for?(@lesson.course)
end
end
不同於 Express 需要手動配置錯誤處理中介軟體,Rails 提供了結構化的錯誤處理機制:
# app/controllers/concerns/error_handler.rb
module ErrorHandler
extend ActiveSupport::Concern
included do
# 定義錯誤處理的順序很重要
# 從最具體到最通用
rescue_from ActiveRecord::RecordNotFound do |e|
render_error('Resource not found', :not_found, 'RESOURCE_NOT_FOUND')
end
rescue_from ActiveRecord::RecordInvalid do |e|
render_error(
'Validation failed',
:unprocessable_entity,
'VALIDATION_ERROR',
details: e.record.errors.full_messages
)
end
rescue_from ActionController::ParameterMissing do |e|
render_error(
"Required parameter missing: #{e.param}",
:bad_request,
'PARAMETER_MISSING'
)
end
rescue_from Pundit::NotAuthorizedError do |e|
render_error(
'You are not authorized to perform this action',
:forbidden,
'INSUFFICIENT_PERMISSIONS'
)
end
rescue_from JWT::DecodeError do |e|
render_error(
'Invalid authentication token',
:unauthorized,
'INVALID_TOKEN'
)
end
rescue_from Stripe::StripeError do |e|
# 處理支付錯誤
error_code = case e
when Stripe::CardError then 'CARD_DECLINED'
when Stripe::InvalidRequestError then 'INVALID_PAYMENT_REQUEST'
when Stripe::AuthenticationError then 'PAYMENT_AUTH_FAILED'
else 'PAYMENT_ERROR'
end
render_error(e.message, :payment_required, error_code)
end
# 自定義業務邏輯錯誤
rescue_from CourseFullError do |e|
render_error(
'Course is full',
:conflict,
'COURSE_FULL',
details: {
course_id: e.course.id,
max_students: e.course.max_students,
waitlist_available: e.course.waitlist_available?
}
)
end
# 捕獲所有其他錯誤(生產環境)
rescue_from StandardError do |e|
# 記錄到錯誤追蹤服務
Sentry.capture_exception(e)
# 開發環境顯示詳細錯誤,生產環境顯示通用訊息
if Rails.env.development?
render_error(e.message, :internal_server_error, 'INTERNAL_ERROR',
backtrace: e.backtrace.first(10))
else
render_error('Internal server error', :internal_server_error, 'INTERNAL_ERROR')
end
end
end
private
def render_error(message, status, code, details: nil, backtrace: nil)
error_response = {
error: {
message: message,
code: code,
timestamp: Time.current.iso8601,
request_id: request.request_id
}
}
error_response[:error][:details] = details if details.present?
error_response[:error][:backtrace] = backtrace if backtrace.present? && Rails.env.development?
render json: error_response, status: status
end
end
# 使用這個 concern
class ApplicationController < ActionController::API
include ErrorHandler
# 其他共用功能...
end
當業務邏輯變得複雜時,Service Objects 是保持控制器簡潔的關鍵:
# app/services/enrollment/process_service.rb
module Enrollment
class ProcessService
include ActiveModel::Model
attr_accessor :course, :student, :payment_method
attr_reader :enrollment, :errors, :error_code, :status
def initialize(course:, student:, payment_method: nil)
@course = course
@student = student
@payment_method = payment_method
@errors = []
@status = :ok
end
def call
ActiveRecord::Base.transaction do
validate_enrollment!
create_enrollment!
process_payment! if @course.paid?
send_notifications!
update_course_statistics!
ServiceResult.new(success: true, enrollment: @enrollment)
end
rescue StandardError => e
handle_error(e)
ServiceResult.new(success: false, errors: @errors, error_code: @error_code, status: @status)
end
private
def validate_enrollment!
# 檢查是否已經註冊
if @student.enrolled_in?(@course)
raise EnrollmentError, 'Already enrolled in this course'
end
# 檢查課程是否已滿
if @course.full? && !@course.waitlist_available?
raise CourseFullError.new(@course)
end
# 檢查先修課程
unmet_prerequisites = @course.unmet_prerequisites_for(@student)
if unmet_prerequisites.any?
raise PrerequisiteError, "Prerequisites not met: #{unmet_prerequisites.map(&:title).join(', ')}"
end
# 檢查註冊時間
unless @course.registration_open?
raise EnrollmentError, 'Registration is not open for this course'
end
end
def create_enrollment!
@enrollment = Enrollment.create!(
course: @course,
user: @student,
status: @course.full? ? 'waitlisted' : 'active',
enrolled_at: Time.current,
payment_status: @course.free? ? 'not_required' : 'pending'
)
# 記錄註冊來源
@enrollment.track_source(
referrer: Current.referrer,
utm_source: Current.utm_source,
utm_campaign: Current.utm_campaign
)
end
def process_payment!
return if @course.free?
payment_service = PaymentService.new(
amount: @course.price_for(@student), # 可能有折扣
currency: @course.currency,
payment_method: @payment_method,
description: "Enrollment for #{@course.title}"
)
payment_result = payment_service.charge!
@enrollment.update!(
payment_status: 'completed',
payment_reference: payment_result.reference,
paid_amount: payment_result.amount
)
end
def send_notifications!
# 發送歡迎郵件
CourseEnrollmentMailer.welcome_email(@enrollment).deliver_later
# 通知講師
InstructorNotificationMailer.new_enrollment(@enrollment).deliver_later
# 如果是等待名單,發送不同的通知
if @enrollment.waitlisted?
CourseEnrollmentMailer.waitlist_confirmation(@enrollment).deliver_later
end
# 觸發即時通知
ActionCable.server.broadcast(
"instructor_#{@course.instructor.id}",
{
type: 'new_enrollment',
course_id: @course.id,
student_name: @student.name,
timestamp: Time.current
}
)
end
def update_course_statistics!
# 使用 Rails 的 update_counters 避免競態條件
Course.update_counters(
@course.id,
enrollments_count: 1,
revenue: @enrollment.paid_amount || 0
)
# 更新課程的熱門度分數
@course.recalculate_popularity_score!
end
def handle_error(error)
case error
when CourseFullError
@error_code = 'COURSE_FULL'
@status = :conflict
@errors << error.message
when PrerequisiteError
@error_code = 'PREREQUISITES_NOT_MET'
@status = :precondition_required
@errors << error.message
when Stripe::CardError
@error_code = 'PAYMENT_FAILED'
@status = :payment_required
@errors << 'Payment was declined'
else
@error_code = 'ENROLLMENT_FAILED'
@status = :unprocessable_entity
@errors << 'Failed to process enrollment'
# 記錄未預期的錯誤
Rails.logger.error "Enrollment failed: #{error.message}"
Sentry.capture_exception(error)
end
end
end
# 結果物件封裝
class ServiceResult
attr_reader :enrollment, :errors, :error_code, :status
def initialize(success:, enrollment: nil, errors: [], error_code: nil, status: nil)
@success = success
@enrollment = enrollment
@errors = errors
@error_code = error_code
@status = status
end
def success?
@success
end
end
end
在 LMS 系統中,我們需要精心設計控制器的組織結構:
# app/controllers/api/v1/base_controller.rb
module Api
module V1
class BaseController < ApplicationController
include ErrorHandler
include Authenticatable
include Authorizable
# 所有 API 共用的功能
before_action :set_default_format
before_action :check_api_version
private
def set_default_format
request.format = :json unless params[:format]
end
def check_api_version
if request.headers['API-Version'].present? &&
request.headers['API-Version'] < 'v1'
render json: {
error: 'API version deprecated',
min_version: 'v1',
current_version: 'v1'
}, status: :gone
end
end
end
# 講師專用的基礎控制器
class InstructorBaseController < BaseController
before_action :require_instructor!
private
def require_instructor!
unless current_user.instructor? || current_user.admin?
render json: { error: 'Instructor access required' }, status: :forbidden
end
end
end
# 管理員專用的基礎控制器
class AdminBaseController < BaseController
before_action :require_admin!
private
def require_admin!
unless current_user.admin?
render json: { error: 'Admin access required' }, status: :forbidden
end
end
end
end
end
讓我們實作 LMS 中作業提交的完整流程:
# app/controllers/api/v1/assignment_submissions_controller.rb
module Api
module V1
class AssignmentSubmissionsController < BaseController
before_action :set_assignment
before_action :check_enrollment
before_action :set_submission, only: [:show, :update]
before_action :check_deadline, only: [:create, :update]
def create
# 使用 Form Object 處理複雜的提交邏輯
form = SubmissionForm.new(
submission_params.merge(
assignment: @assignment,
student: current_user
)
)
if form.save
# 觸發自動評分(如果適用)
if @assignment.auto_gradable?
AutoGradingJob.perform_later(form.submission)
end
# 發送提交確認
SubmissionMailer.confirmation(form.submission).deliver_later
render json: SubmissionSerializer.new(form.submission).serializable_hash,
status: :created
else
render json: { errors: form.errors.full_messages },
status: :unprocessable_entity
end
end
private
def check_deadline
if @assignment.past_deadline? && !current_user.can_submit_late?(@assignment)
render json: {
error: 'Assignment deadline has passed',
deadline: @assignment.due_date,
late_submission_allowed: @assignment.allow_late_submission,
penalty: @assignment.late_penalty_percentage
}, status: :forbidden
end
end
def check_enrollment
unless current_user.enrolled_in?(@assignment.course)
render json: { error: 'Must be enrolled in the course' }, status: :forbidden
end
end
end
end
end
誤區 1:在控制器中寫太多業務邏輯
來自 Express 背景的開發者習慣在路由處理器中直接寫邏輯。在 Rails 中,這違反了 Skinny Controller 原則:
# 錯誤示範:Fat Controller
def calculate_final_grade
student = User.find(params[:student_id])
course = Course.find(params[:course_id])
assignments = course.assignments
total_weight = 0
weighted_sum = 0
assignments.each do |assignment|
submission = student.submissions.find_by(assignment: assignment)
if submission && submission.graded?
weighted_sum += submission.grade * assignment.weight
total_weight += assignment.weight
end
end
final_grade = total_weight > 0 ? (weighted_sum / total_weight) : 0
# 更多複雜邏輯...
render json: { final_grade: final_grade }
end
# 正確做法:將邏輯移到適當的地方
def calculate_final_grade
enrollment = current_user.enrollments.find_by(course_id: params[:course_id])
grade_calculator = GradeCalculator.new(enrollment)
render json: {
final_grade: grade_calculator.final_grade,
breakdown: grade_calculator.breakdown,
letter_grade: grade_calculator.letter_grade
}
end
誤區 2:忽視 Rails 的約定
來自 Spring Boot 的開發者可能想要自定義所有東西:
# 錯誤:試圖重新發明輪子
class CustomAuthenticationFilter
def authenticate(request)
token = request.headers['Authorization']
# 自己實作 JWT 驗證...
end
end
# 正確:使用 Rails 的方式
class ApplicationController < ActionController::API
include ActionController::HttpAuthentication::Token::ControllerMethods
before_action :authenticate!
private
def authenticate!
authenticate_or_request_with_http_token do |token, options|
User.find_by_auth_token(token)
end
end
end
控制器層的效能優化不只是查詢優化:
class Api::V1::DashboardController < ApplicationController
def student_dashboard
# 使用 Rails 的快取機制
@dashboard_data = Rails.cache.fetch(
["student_dashboard", current_user.id, current_user.updated_at],
expires_in: 1.hour
) do
{
enrolled_courses: serialize_courses(current_user.enrolled_courses.active),
recent_activities: serialize_activities(current_user.recent_activities),
upcoming_deadlines: serialize_deadlines(current_user.upcoming_deadlines),
learning_stats: calculate_learning_stats(current_user)
}
end
render json: @dashboard_data
end
private
def serialize_courses(courses)
# 使用 includes 避免 N+1
courses.includes(:instructor, :category, :current_lesson)
.map { |course| CourseSerializer.new(course).as_json }
end
end
練習目標:熟悉控制器的基本概念,實作 CRUD 操作、Strong Parameters、Callbacks 和錯誤處理。
實作步驟:
rails new todo_api --api
cd todo_api
rails generate model Todo title:string description:text completed:boolean user_id:integer
rails db:migrate
建立檔案 app/controllers/api/v1/todos_controller.rb
:
module Api
module V1
class TodosController < ApplicationController
# 實作 before_action callbacks
before_action :set_todo, only: [:show, :update, :destroy]
before_action :validate_user, only: [:create, :update]
# 統計請求次數(示範 after_action)
after_action :increment_request_count
# 錯誤處理
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
def index
# 實作分頁和篩選
@todos = Todo.all
@todos = @todos.where(completed: params[:completed]) if params[:completed].present?
@todos = @todos.page(params[:page]).per(params[:per_page] || 10)
render json: {
todos: @todos,
meta: {
current_page: @todos.current_page,
total_pages: @todos.total_pages,
total_count: @todos.total_count
}
}
end
def show
render json: @todo
end
def create
@todo = Todo.new(todo_params)
if @todo.save
render json: @todo, status: :created
else
render json: { errors: @todo.errors.full_messages },
status: :unprocessable_entity
end
end
def update
if @todo.update(todo_params)
render json: @todo
else
render json: { errors: @todo.errors.full_messages },
status: :unprocessable_entity
end
end
def destroy
@todo.destroy
head :no_content
end
private
# Strong Parameters 實作
def todo_params
params.require(:todo).permit(:title, :description, :completed, :user_id)
end
def set_todo
@todo = Todo.find(params[:id])
end
def validate_user
# 簡單的使用者驗證(實際應該使用認證系統)
if params[:todo][:user_id].blank?
render json: { error: 'User ID is required' }, status: :bad_request
end
end
def increment_request_count
# 示範 after_action 的使用
Rails.cache.increment("api_requests_#{Date.today}")
end
# 錯誤處理方法
def not_found
render json: { error: 'Todo not found' }, status: :not_found
end
def unprocessable_entity(exception)
render json: {
error: 'Validation failed',
details: exception.record.errors.full_messages
}, status: :unprocessable_entity
end
end
end
end
編輯 config/routes.rb
:
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :todos
end
end
end
編輯 app/models/todo.rb
:
class Todo < ApplicationRecord
validates :title, presence: true, length: { minimum: 3, maximum: 100 }
validates :description, length: { maximum: 500 }
validates :user_id, presence: true
scope :completed, -> { where(completed: true) }
scope :pending, -> { where(completed: false) }
end
使用 curl 或 Postman 測試:
# 建立 Todo
curl -X POST http://localhost:3000/api/v1/todos \
-H "Content-Type: application/json" \
-d '{"todo": {"title": "學習 Rails", "description": "完成 Day 6 練習", "user_id": 1}}'
# 取得所有 Todos
curl http://localhost:3000/api/v1/todos
# 更新 Todo
curl -X PATCH http://localhost:3000/api/v1/todos/1 \
-H "Content-Type: application/json" \
-d '{"todo": {"completed": true}}'
# 刪除 Todo
curl -X DELETE http://localhost:3000/api/v1/todos/1
預期學習成果:
挑戰目標:實作具有複雜權限控制的討論區系統,包含巢狀路由、Service Objects、即時通知等進階功能。
實作步驟:
# 建立討論區相關模型
rails generate model Course title:string instructor_id:integer
rails generate model Forum course:references title:string description:text
rails generate model Topic forum:references user:references title:string content:text pinned:boolean locked:boolean
rails generate model Reply topic:references user:references content:text parent_reply:references
rails generate model Enrollment course:references user:references role:string
rails db:migrate
# app/models/course.rb
class Course < ApplicationRecord
belongs_to :instructor, class_name: 'User'
has_one :forum
has_many :enrollments
has_many :students, through: :enrollments, source: :user
end
# app/models/forum.rb
class Forum < ApplicationRecord
belongs_to :course
has_many :topics, -> { order(pinned: :desc, created_at: :desc) }
def can_create_topic?(user)
course.enrollments.exists?(user: user)
end
end
# app/models/topic.rb
class Topic < ApplicationRecord
belongs_to :forum
belongs_to :user
has_many :replies, -> { where(parent_reply_id: nil) }
validates :title, presence: true, length: { minimum: 5, maximum: 200 }
validates :content, presence: true, length: { minimum: 10 }
scope :pinned, -> { where(pinned: true) }
scope :regular, -> { where(pinned: false) }
def can_pin?(user)
user == forum.course.instructor || user.admin?
end
def can_edit?(user)
user == self.user || can_pin?(user)
end
end
# app/models/reply.rb
class Reply < ApplicationRecord
belongs_to :topic
belongs_to :user
belongs_to :parent_reply, class_name: 'Reply', optional: true
has_many :child_replies, class_name: 'Reply', foreign_key: :parent_reply_id
validates :content, presence: true, length: { minimum: 2, maximum: 1000 }
def nested_level
level = 0
current = self
while current.parent_reply
level += 1
current = current.parent_reply
end
level
end
end
# app/controllers/api/v1/forums_controller.rb
module Api
module V1
class ForumsController < ApplicationController
before_action :authenticate_user!
before_action :set_course
before_action :check_enrollment
def show
@forum = @course.forum
topics = @forum.topics.includes(:user, replies: :user)
render json: {
forum: {
id: @forum.id,
title: @forum.title,
description: @forum.description,
topics_count: @forum.topics.count,
can_create_topic: @forum.can_create_topic?(current_user)
},
topics: topics.map { |t| serialize_topic(t) }
}
end
private
def set_course
@course = Course.find(params[:course_id])
end
def check_enrollment
unless @course.enrollments.exists?(user: current_user)
render json: { error: 'Not enrolled in this course' }, status: :forbidden
end
end
def serialize_topic(topic)
{
id: topic.id,
title: topic.title,
content: topic.content.truncate(200),
author: topic.user.name,
pinned: topic.pinned,
locked: topic.locked,
replies_count: topic.replies.count,
last_reply_at: topic.replies.maximum(:created_at),
can_edit: topic.can_edit?(current_user),
can_pin: topic.can_pin?(current_user)
}
end
end
end
end
# app/controllers/api/v1/topics_controller.rb
module Api
module V1
class TopicsController < ApplicationController
before_action :authenticate_user!
before_action :set_forum
before_action :set_topic, only: [:show, :update, :destroy, :pin, :lock]
before_action :authorize_action!, only: [:update, :destroy, :pin, :lock]
def show
replies = @topic.replies.includes(:user, child_replies: :user)
render json: {
topic: {
id: @topic.id,
title: @topic.title,
content: @topic.content,
author: @topic.user.name,
created_at: @topic.created_at,
pinned: @topic.pinned,
locked: @topic.locked
},
replies: serialize_replies(replies),
can_reply: can_reply?
}
end
def create
result = Topics::CreateService.new(
forum: @forum,
user: current_user,
params: topic_params
).call
if result.success?
# 觸發即時通知
broadcast_new_topic(result.topic)
render json: { topic: serialize_topic(result.topic) }, status: :created
else
render json: { errors: result.errors }, status: :unprocessable_entity
end
end
def pin
@topic.update!(pinned: !@topic.pinned)
render json: { pinned: @topic.pinned }
end
def lock
@topic.update!(locked: !@topic.locked)
render json: { locked: @topic.locked }
end
private
def set_forum
@forum = Forum.find(params[:forum_id])
end
def set_topic
@topic = @forum.topics.find(params[:id])
end
def authorize_action!
case action_name
when 'pin', 'lock'
unless @topic.can_pin?(current_user)
render json: { error: 'Not authorized' }, status: :forbidden
end
when 'update', 'destroy'
unless @topic.can_edit?(current_user)
render json: { error: 'Not authorized' }, status: :forbidden
end
end
end
def can_reply?
!@topic.locked && @forum.course.enrollments.exists?(user: current_user)
end
def topic_params
params.require(:topic).permit(:title, :content)
end
def serialize_replies(replies, parent_id = nil)
replies.select { |r| r.parent_reply_id == parent_id }.map do |reply|
{
id: reply.id,
content: reply.content,
author: reply.user.name,
created_at: reply.created_at,
nested_level: reply.nested_level,
child_replies: serialize_replies(replies, reply.id)
}
end
end
def broadcast_new_topic(topic)
ActionCable.server.broadcast(
"forum_#{@forum.id}",
{
type: 'new_topic',
topic: serialize_topic(topic)
}
)
end
end
end
end
# app/services/topics/create_service.rb
module Topics
class CreateService
attr_reader :topic, :errors
def initialize(forum:, user:, params:)
@forum = forum
@user = user
@params = params
@errors = []
end
def call
return error_result('Cannot post in this forum') unless can_create?
ActiveRecord::Base.transaction do
create_topic!
notify_participants!
update_forum_stats!
ServiceResult.new(success: true, topic: @topic)
end
rescue ActiveRecord::RecordInvalid => e
error_result(e.record.errors.full_messages)
rescue StandardError => e
Rails.logger.error "Topic creation failed: #{e.message}"
error_result('Failed to create topic')
end
private
def can_create?
@forum.can_create_topic?(@user)
end
def create_topic!
@topic = @forum.topics.create!(
user: @user,
title: @params[:title],
content: @params[:content],
pinned: false,
locked: false
)
end
def notify_participants!
# 通知所有課程參與者
NotificationService.new.notify_forum_participants(
forum: @forum,
topic: @topic,
exclude_user: @user
)
end
def update_forum_stats!
# 更新論壇統計
@forum.increment!(:topics_count)
@forum.touch(:last_activity_at)
end
def error_result(errors)
ServiceResult.new(
success: false,
errors: Array(errors)
)
end
end
class ServiceResult
attr_reader :topic, :errors
def initialize(success:, topic: nil, errors: [])
@success = success
@topic = topic
@errors = errors
end
def success?
@success
end
end
end
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :courses do
resource :forum, only: [:show] do
resources :topics do
member do
patch :pin
patch :lock
end
resources :replies, only: [:create, :update, :destroy]
end
end
end
end
end
end
# spec/requests/api/v1/topics_spec.rb
require 'rails_helper'
RSpec.describe 'Topics API', type: :request do
let(:instructor) { create(:user, :instructor) }
let(:student) { create(:user, :student) }
let(:course) { create(:course, instructor: instructor) }
let(:forum) { create(:forum, course: course) }
let!(:enrollment) { create(:enrollment, course: course, user: student) }
describe 'POST /api/v1/courses/:course_id/forum/topics' do
context 'when user is enrolled' do
it 'creates a new topic' do
post "/api/v1/courses/#{course.id}/forum/topics",
params: { topic: { title: 'New Topic', content: 'Topic content' } },
headers: auth_headers(student)
expect(response).to have_http_status(:created)
expect(json_response['topic']['title']).to eq('New Topic')
end
end
context 'when user is not enrolled' do
let(:other_user) { create(:user) }
it 'returns forbidden error' do
post "/api/v1/courses/#{course.id}/forum/topics",
params: { topic: { title: 'New Topic', content: 'Topic content' } },
headers: auth_headers(other_user)
expect(response).to have_http_status(:forbidden)
end
end
end
describe 'PATCH /api/v1/courses/:course_id/forum/topics/:id/pin' do
let(:topic) { create(:topic, forum: forum, user: student) }
context 'when user is instructor' do
it 'pins the topic' do
patch "/api/v1/courses/#{course.id}/forum/topics/#{topic.id}/pin",
headers: auth_headers(instructor)
expect(response).to have_http_status(:ok)
expect(topic.reload.pinned).to be true
end
end
context 'when user is student' do
it 'returns forbidden error' do
patch "/api/v1/courses/#{course.id}/forum/topics/#{topic.id}/pin",
headers: auth_headers(student)
expect(response).to have_http_status(:forbidden)
end
end
end
end
預期學習成果:
常見問題與解決方案:
includes
預載入關聯今天我們深入探討了 Rails 控制器的設計哲學和實作技巧。從請求的生命週期到錯誤處理,從 Strong Parameters 到 Service Objects,我們看到了 Rails 如何在約定和彈性之間找到平衡。
知識層面,我們學到了 Rack 中介軟體、Action Callbacks、Strong Parameters 等核心概念。
思維層面,我們理解了 Skinny Controller, Fat Model 的設計原則,以及如何用 Service Objects 處理複雜邏輯。
實踐層面,我們能夠設計結構良好的控制器,處理複雜的業務流程,並確保 API 的安全性和可維護性。
完成今天的學習後,你應該能夠:
明天我們將探討模型層設計與業務邏輯封裝。如果說今天學習的是如何協調和控制,那明天就是如何組織和封裝業務邏輯的核心。準備好深入 Rails 的模型層了嗎?讓我們繼續這段充滿挑戰與成長的旅程。