iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0
Modern Web

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

Day 6: 控制器與請求處理 - 在約定與彈性之間找到平衡點

  • 分享至 

  • xImage
  •  

從其他框架的經驗出發

如果你來自 Express.js 的世界,你習慣了中介軟體(middleware)的鏈式處理模式。每個請求像是通過一條流水線,你可以在任何點插入處理邏輯。在 Spring Boot 中,你可能習慣了 @RestController 註解和依賴注入,享受著強型別帶來的安全感。而在 FastAPI 中,你依賴型別提示和 Pydantic 模型來自動處理請求驗證。

今天我們要探討的是 Rails 如何在「約定」和「彈性」之間找到優雅的平衡點。Rails 的控制器不只是請求的處理器,它是整個 MVC 架構中的協調者,負責在模型和視圖(在 API 模式下是序列化器)之間傳遞訊息。

在第一週的學習旅程中,我們已經建立了 Ruby 語法基礎、理解了 Rails 的專案結構、探討了 MVC 在 API 模式下的實踐、掌握了 ActiveRecord 基礎、設計了 RESTful 路由。今天,我們要深入控制器的內部運作機制,理解一個請求從進入 Rails 到返回回應的完整旅程。這些知識將直接應用在 LMS 系統的 API 設計中,特別是處理複雜的業務邏輯如課程註冊、作業提交、成績計算等功能。

請求的生命之旅:從 Rack 到回應

Rails 請求處理的層級架構

讓我們先從宏觀的角度理解 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 中介軟體

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 中介軟體有幾個關鍵差異:

  1. 標準化的介面:所有中介軟體都遵循相同的 call(env) 協議
  2. 明確的順序控制:可以精確控制中介軟體的插入位置
  3. 環境隔離:每個請求的 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 的安全哲學

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

與其他框架的參數處理比較:

  • Express + Joi:需要手動配置驗證規則
  • Spring Boot:使用 @Valid 註解和 Bean Validation
  • FastAPI:透過 Pydantic 模型自動驗證
  • Rails:Strong Parameters 內建在框架中,強制安全實踐

請求流程的精細控制

Action Callbacks:Rails 的鉤子系統

Action 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

條件式 Callbacks 與動態控制

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:超越 Skinny Controller

當業務邏輯變得複雜時,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 的控制器架構

設計控制器的層級結構

在 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

實踐練習

基礎練習:待辦事項 API(30 分鐘)

練習目標:熟悉控制器的基本概念,實作 CRUD 操作、Strong Parameters、Callbacks 和錯誤處理。

實作步驟

  1. 建立 Rails API 專案
rails new todo_api --api
cd todo_api
  1. 建立 Todo 模型
rails generate model Todo title:string description:text completed:boolean user_id:integer
rails db:migrate
  1. 實作控制器

建立檔案 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
  1. 設定路由

編輯 config/routes.rb

Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :todos
    end
  end
end
  1. 加入模型驗證

編輯 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
  1. 測試 API

使用 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

預期學習成果

  • 理解 before_action 和 after_action 的執行順序
  • 掌握 Strong Parameters 的使用方法
  • 學會基本的錯誤處理模式
  • 了解 RESTful 控制器的標準結構

進階挑戰:LMS 討論區功能(1 小時)

挑戰目標:實作具有複雜權限控制的討論區系統,包含巢狀路由、Service Objects、即時通知等進階功能。

實作步驟

  1. 建立必要的模型
# 建立討論區相關模型
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
  1. 設定模型關聯
# 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
  1. 實作控制器層級結構
# 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
  1. 實作 Service Object
# 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
  1. 設定路由
# 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
  1. 測試討論區功能
# 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

預期學習成果

  • 掌握巢狀路由的設計與實作
  • 理解複雜的權限控制邏輯
  • 學會使用 Service Objects 封裝業務邏輯
  • 實作即時通知功能的基礎
  • 理解控制器層級結構的設計

常見問題與解決方案

  1. N+1 查詢問題:使用 includes 預載入關聯
  2. 權限檢查重複:抽取到 concern 或基礎控制器
  3. 複雜的巢狀回覆:限制巢狀層級,避免無限遞迴
  4. 即時通知效能:使用背景任務處理大量通知

知識連結

與前期內容的連結

  • Day 3 的 MVC 架構:今天深入了控制器這個關鍵組件
  • Day 5 的 RESTful 路由:理解了路由如何對應到控制器動作

對後續內容的鋪墊

  • Day 7 的模型層設計:明天我們將探討如何將業務邏輯適當地放在模型中
  • Day 12 的例外處理:會更深入探討錯誤處理策略

總結:控制器的平衡藝術

今天我們深入探討了 Rails 控制器的設計哲學和實作技巧。從請求的生命週期到錯誤處理,從 Strong Parameters 到 Service Objects,我們看到了 Rails 如何在約定和彈性之間找到平衡。

知識層面,我們學到了 Rack 中介軟體、Action Callbacks、Strong Parameters 等核心概念。

思維層面,我們理解了 Skinny Controller, Fat Model 的設計原則,以及如何用 Service Objects 處理複雜邏輯。

實踐層面,我們能夠設計結構良好的控制器,處理複雜的業務流程,並確保 API 的安全性和可維護性。

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

  • ✓ 解釋 Rails 請求處理流程與其他框架的差異
  • ✓ 實作包含完整錯誤處理的控制器
  • ✓ 識別並避免 Fat Controller 的反模式
  • ✓ 在 LMS 專案中設計清晰的控制器架構

明天我們將探討模型層設計與業務邏輯封裝。如果說今天學習的是如何協調和控制,那明天就是如何組織和封裝業務邏輯的核心。準備好深入 Rails 的模型層了嗎?讓我們繼續這段充滿挑戰與成長的旅程。


上一篇
Day 5: RESTful 路由設計 - 用資源思維重新理解 Web API
系列文
30 天 Rails 新手村:從工作專案學會 Ruby on Rails7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言