# คำถามสัมภาษณ์ Ruby on Rails: 25 อันดับในปี 2026 > 25 คำถามสัมภาษณ์ Ruby on Rails ที่ถูกถามบ่อยที่สุด สถาปัตยกรรม MVC, Active Record, migration, การทดสอบ RSpec, REST API พร้อมคำตอบและตัวอย่างโค้ดอย่างละเอียด - Published: 2026-02-03 - Updated: 2026-04-27 - Author: Anthony Fillion-Maillet - Tags: ruby on rails, ruby, สัมภาษณ์, active record, สัมภาษณ์เทคนิค - Reading time: 22 min --- การสัมภาษณ์ Ruby on Rails ประเมินความเชี่ยวชาญในเฟรมเวิร์ก Ruby ที่ได้รับความนิยมที่สุด ความเข้าใจในสถาปัตยกรรม MVC, ORM Active Record และความสามารถในการสร้างเว็บแอปพลิเคชันที่แข็งแกร่งตามปรัชญา "Convention over Configuration" คู่มือนี้ครอบคลุม 25 คำถามที่ถูกถามบ่อยที่สุด ตั้งแต่พื้นฐานของ Rails ไปจนถึงรูปแบบการใช้งานจริงขั้นสูง > **คำแนะนำสำหรับการสัมภาษณ์** > > ผู้สรรหาให้ความสำคัญกับผู้สมัครที่เข้าใจปรัชญาของ Rails: "Convention over Configuration", DRY (Don't Repeat Yourself) และรูปแบบ Rails Way การอธิบายว่าทำไม Rails จึงเลือกแนวทางสถาปัตยกรรมแบบใดแบบหนึ่งจะสร้างความแตกต่าง ## พื้นฐาน Ruby on Rails ### คำถามที่ 1: อธิบายรูปแบบ MVC ใน Ruby on Rails รูปแบบ Model-View-Controller (MVC) เป็นแกนกลางของสถาปัตยกรรม Rails โดยแบ่งความรับผิดชอบออกเป็นสามชั้นที่ชัดเจน เพื่อให้บำรุงรักษาและทดสอบโค้ดได้ดียิ่งขึ้น ```ruby # app/models/article.rb # Model จัดการข้อมูลและตรรกะทางธุรกิจ class Article < ApplicationRecord # การตรวจสอบความถูกต้องของข้อมูล validates :title, presence: true, length: { minimum: 5 } validates :body, presence: true # ความสัมพันธ์กับโมเดลอื่น belongs_to :author, class_name: 'User' has_many :comments, dependent: :destroy has_many :tags, through: :article_tags # Scope สำหรับ query ที่ใช้ซ้ำได้ scope :published, -> { where(published: true) } scope :recent, -> { order(created_at: :desc).limit(10) } # Callback ของวงจรชีวิต before_save :generate_slug private def generate_slug self.slug = title.parameterize if title_changed? end end ``` ```ruby # app/controllers/articles_controller.rb # Controller รับคำขอและประสานการตอบกลับ class ArticlesController < ApplicationController before_action :authenticate_user!, except: [:index, :show] before_action :set_article, only: [:show, :edit, :update, :destroy] def index @articles = Article.published.recent.includes(:author) end def show @comments = @article.comments.includes(:user) end def create @article = current_user.articles.build(article_params) if @article.save redirect_to @article, notice: 'สร้างบทความสำเร็จแล้ว' else render :new, status: :unprocessable_entity end end private def set_article @article = Article.find(params[:id]) end def article_params params.require(:article).permit(:title, :body, :published, tag_ids: []) end end ``` ```erb <%# app/views/articles/show.html.erb %> <%# View แสดงข้อมูลในรูปแบบ HTML %>

<%= @article.title %>

โดย <%= @article.author.name %> • <%= l @article.created_at, format: :long %>

<%= simple_format @article.body %>
<%# Partial สำหรับความคิดเห็น %> <%= render @comments %>
``` ขั้นตอนปกติ: คำขอเข้าสู่ Router ซึ่งจะส่งต่อไปยัง Controller ที่เหมาะสม Controller จะโต้ตอบกับ Model เพื่อดึงหรือแก้ไขข้อมูล จากนั้นส่งข้อมูลให้ View เพื่อทำการเรนเดอร์ HTML ### คำถามที่ 2: Active Record คืออะไร และ ORM ของ Rails ทำงานอย่างไร? Active Record คือ ORM (Object-Relational Mapping) ของ Rails ที่นำรูปแบบ Active Record มาใช้ คลาส Model แต่ละคลาสแทนตารางในฐานข้อมูล และอินสแตนซ์แต่ละตัวแทนแถวเดียว ```ruby # app/models/user.rb # Active Record จับคู่คอลัมน์กับแอตทริบิวต์โดยอัตโนมัติ class User < ApplicationRecord # ตาราง 'users' จะถูกเชื่อมโยงโดยอัตโนมัติ # คอลัมน์: id, email, name, created_at, updated_at has_secure_password # BCrypt สำหรับรหัสผ่าน has_many :articles, foreign_key: :author_id has_one :profile, dependent: :destroy has_and_belongs_to_many :roles # การตรวจสอบความถูกต้อง validates :email, presence: true, uniqueness: { case_sensitive: false }, format: { with: URI::MailTo::EMAIL_REGEXP } # Callback before_save :normalize_email # เมธอดของคลาสสำหรับ query def self.admins joins(:roles).where(roles: { name: 'admin' }) end private def normalize_email self.email = email.downcase.strip end end ``` ```ruby # ตัวอย่าง query ของ Active Record # Rails console หรือภายใน service # การสร้าง user = User.create!(email: 'dev@example.com', name: 'Alice', password: 'secret123') # การอ่านพร้อมเงื่อนไข active_users = User.where(active: true).order(:name) user = User.find_by(email: 'dev@example.com') # Query แบบเชื่อมต่อกัน (lazy evaluation) recent_admins = User.admins .where('created_at > ?', 1.month.ago) .includes(:profile) .limit(10) # ป้องกัน N+1 ด้วย eager loading articles = Article.includes(:author, :comments).published # การอัปเดต user.update!(name: 'Alice Martin') # Transaction User.transaction do user.debit_balance!(100) recipient.credit_balance!(100) Payment.create!(from: user, to: recipient, amount: 100) end ``` Active Record แปลงเมธอด Ruby ให้เป็น query SQL ที่ปรับให้เหมาะสม เมธอดอย่าง `where`, `joins`, `includes` เป็นแบบ lazy: query จะถูกประมวลผลเมื่อมีการวนซ้ำหรือเรียก `to_a` เท่านั้น ### คำถามที่ 3: อธิบายระบบ migration ของ Rails Migration ช่วยให้สามารถจัดการเวอร์ชันของ schema ฐานข้อมูลด้วย Ruby ได้ Migration สามารถย้อนกลับได้ และช่วยให้โครงสร้างข้อมูลพัฒนาได้อย่างมีการควบคุม ```ruby # db/migrate/20260203100000_create_products.rb # Migration สำหรับสร้างตาราง class CreateProducts < ActiveRecord::Migration[7.1] def change create_table :products do |t| t.string :name, null: false t.text :description t.decimal :price, precision: 10, scale: 2, null: false t.integer :stock_quantity, default: 0 t.references :category, null: false, foreign_key: true t.boolean :active, default: true t.timestamps # created_at และ updated_at โดยอัตโนมัติ end # Index เพื่อประสิทธิภาพ add_index :products, :name add_index :products, [:category_id, :active] end end ``` ```ruby # db/migrate/20260203110000_add_slug_to_products.rb # Migration สำหรับแก้ไขตารางที่มีอยู่ class AddSlugToProducts < ActiveRecord::Migration[7.1] def change add_column :products, :slug, :string add_index :products, :slug, unique: true # เติม slug ที่มีอยู่ reversible do |dir| dir.up do Product.find_each do |product| product.update_column(:slug, product.name.parameterize) end end end # ทำให้ NOT NULL หลังเติมแล้ว change_column_null :products, :slug, false end end ``` ```bash # คำสั่ง migration ที่จำเป็น rails db:migrate # รัน migration ที่ค้างอยู่ rails db:rollback # ย้อนกลับ migration ล่าสุด rails db:rollback STEP=3 # ย้อนกลับ migration 3 รายการล่าสุด rails db:migrate:status # ดูสถานะ migration rails db:seed # รัน db/seeds.rb rails db:reset # Drop, create, migrate, seed ``` Migration ต้องสามารถย้อนกลับได้ เมธอด `change` ฉลาดและสามารถย้อนกลับการดำเนินการทั่วไปได้โดยอัตโนมัติ สำหรับกรณีที่ซับซ้อน ใช้ `up` และ `down` แยกกัน ## Active Record ขั้นสูง ### คำถามที่ 4: จะปรับ query N+1 ใน Rails ได้อย่างไร? ปัญหา N+1 เกิดขึ้นเมื่อ query เริ่มต้นตามมาด้วย query เพิ่มเติมอีก N รายการเพื่อโหลดความสัมพันธ์ Rails มีเมธอด eager loading หลายแบบเพื่อแก้ปัญหานี้ ```ruby # app/controllers/orders_controller.rb class OrdersController < ApplicationController def index # ❌ ปัญหา N+1: 1 query + N query ต่อหนึ่งคำสั่งซื้อ # @orders = Order.all # ใน view: order.user.name สร้าง query ต่อแต่ละคำสั่งซื้อ # ✅ วิธีแก้ด้วย includes (eager loading) @orders = Order.includes(:user, :items) .where(status: 'completed') .order(created_at: :desc) # สร้างเพียง 3 query ทั้งหมด end def show # includes: โหลดความสัมพันธ์แยกกัน (2-3 query) @order = Order.includes(items: :product).find(params[:id]) # preload: บังคับให้โหลดแยกกัน @order = Order.preload(:items, :user).find(params[:id]) # eager_load: บังคับใช้ LEFT OUTER JOIN (1 query) @order = Order.eager_load(:items).find(params[:id]) end end ``` ```ruby # app/models/order.rb class Order < ApplicationRecord belongs_to :user has_many :items, class_name: 'OrderItem' has_many :products, through: :items # Scope พร้อม includes ค่าเริ่มต้น scope :with_details, -> { includes(:user, items: :product) } # Counter cache เพื่อหลีกเลี่ยง query COUNT # ต้องการ: add_column :users, :orders_count, :integer, default: 0 belongs_to :user, counter_cache: true end ``` ```ruby # ตรวจจับ N+1 ด้วย gem Bullet (development) # config/environments/development.rb config.after_initialize do Bullet.enable = true Bullet.alert = true Bullet.bullet_logger = true Bullet.rails_logger = true end # Bullet จะแสดงการแจ้งเตือนเมื่อ: # - ตรวจพบ query N+1 # - มี eager loading ที่ไม่จำเป็น # - ควรใช้ counter cache ``` หลักการ: ใช้ `includes` เป็นค่าเริ่มต้น (Rails จะเลือกกลยุทธ์ที่เหมาะสมที่สุด) ใช้ `preload` เมื่อต้องการบังคับ query แยก และ `eager_load` เมื่อกรองตามความสัมพันธ์ ### คำถามที่ 5: อธิบาย Scope และ Query Object ใน Rails Scope ห่อหุ้มเงื่อนไข query ที่ใช้ซ้ำได้ สำหรับ query ที่ซับซ้อน Query Object ให้การจัดระเบียบและความสามารถในการทดสอบที่ดีกว่า ```ruby # app/models/product.rb class Product < ApplicationRecord # Scope แบบง่าย scope :active, -> { where(active: true) } scope :in_stock, -> { where('stock_quantity > 0') } scope :featured, -> { where(featured: true) } # Scope ที่มีพารามิเตอร์ scope :cheaper_than, ->(price) { where('price < ?', price) } scope :in_category, ->(category) { where(category: category) } # Scope ที่เชื่อมต่อกันได้ scope :available, -> { active.in_stock } # Scope ที่มี joins scope :with_recent_orders, -> { joins(:order_items) .where('order_items.created_at > ?', 30.days.ago) .distinct } # Scope ที่มี subquery scope :bestsellers, -> { where(id: OrderItem.group(:product_id) .order('COUNT(*) DESC') .limit(10) .select(:product_id)) } end ``` ```ruby # app/queries/products_search_query.rb # Query Object สำหรับการค้นหาที่ซับซ้อน class ProductsSearchQuery def initialize(relation = Product.all) @relation = relation end def call(params) @relation = filter_by_category(params[:category]) @relation = filter_by_price_range(params[:min_price], params[:max_price]) @relation = filter_by_search(params[:q]) @relation = apply_sorting(params[:sort]) @relation end private def filter_by_category(category) return @relation if category.blank? @relation.where(category_id: category) end def filter_by_price_range(min, max) @relation = @relation.where('price >= ?', min) if min.present? @relation = @relation.where('price <= ?', max) if max.present? @relation end def filter_by_search(query) return @relation if query.blank? @relation.where('name ILIKE ? OR description ILIKE ?', "%#{query}%", "%#{query}%") end def apply_sorting(sort) case sort when 'price_asc' then @relation.order(price: :asc) when 'price_desc' then @relation.order(price: :desc) when 'newest' then @relation.order(created_at: :desc) else @relation.order(:name) end end end # การใช้งานใน controller @products = ProductsSearchQuery.new(Product.active).call(params) ``` Scope เหมาะสำหรับเงื่อนไขที่เรียบง่ายและใช้ซ้ำได้ Query Object เหมาะสำหรับการค้นหาที่ซับซ้อนซึ่งมีฟิลเตอร์ทางเลือกหลายแบบและตรรกะการประกอบ ## Routing และ Controller ### คำถามที่ 6: RESTful routing ใน Rails ทำงานอย่างไร? Rails ส่งเสริมเส้นทางแบบ RESTful ซึ่งจับคู่ HTTP verb กับการกระทำ CRUD Router แปลง URL ให้เป็นการเรียก controller ที่เฉพาะเจาะจง ```ruby # config/routes.rb Rails.application.routes.draw do # เส้นทาง RESTful มาตรฐาน (7 การกระทำ) resources :articles do # เส้นทางซ้อนกัน resources :comments, only: [:create, :destroy] # เส้นทาง member (ทำงานบนอินสแตนซ์) member do post :publish delete :archive end # เส้นทาง collection (ทำงานบนชุดข้อมูล) collection do get :drafts get :search end end # เส้นทาง API พร้อม namespace namespace :api do namespace :v1 do resources :products, only: [:index, :show, :create, :update] do resources :reviews, shallow: true end end end # เส้นทางกำหนดเอง get 'dashboard', to: 'dashboard#index' # ข้อจำกัดเส้นทาง constraints(SubdomainConstraint.new) do resources :admin_settings end # เส้นทางหลัก root 'home#index' end ``` ```bash # rails routes - แสดงเส้นทางทั้งหมดที่สร้างขึ้น # # Verb URI Pattern Controller#Action # GET /articles articles#index # POST /articles articles#create # GET /articles/new articles#new # GET /articles/:id/edit articles#edit # GET /articles/:id articles#show # PATCH /articles/:id articles#update # DELETE /articles/:id articles#destroy # POST /articles/:id/publish articles#publish # GET /articles/drafts articles#drafts ``` Helper เส้นทางที่สร้างขึ้น (`article_path(@article)`, `new_article_path`) ช่วยให้สามารถอ้างอิง URL ได้อย่างไดนามิกและง่ายต่อการบำรุงรักษา ### คำถามที่ 7: อธิบาย callback และ filter ใน controller Callback (before_action, after_action, around_action) ช่วยให้สามารถรันโค้ดก่อน หลัง หรือรอบ ๆ การกระทำของ controller ```ruby # app/controllers/application_controller.rb class ApplicationController < ActionController::Base # การป้องกัน CSRF เปิดใช้งานโดยเริ่มต้น protect_from_forgery with: :exception # Callback สากลสำหรับการยืนยันตัวตน before_action :authenticate_user! # การจัดการข้อผิดพลาดสากล rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from ActionController::ParameterMissing, with: :bad_request private def not_found render json: { error: 'ไม่พบทรัพยากร' }, status: :not_found end def bad_request(exception) render json: { error: exception.message }, status: :bad_request end end ``` ```ruby # app/controllers/admin/products_controller.rb class Admin::ProductsController < ApplicationController # Callback พร้อมตัวเลือก before_action :require_admin before_action :set_product, only: [:show, :edit, :update, :destroy] after_action :log_activity, only: [:create, :update, :destroy] # Callback แบบมีเงื่อนไข before_action :check_stock, only: [:update], if: :stock_changed? def create @product = Product.new(product_params) if @product.save redirect_to [:admin, @product], notice: 'สร้างสินค้าแล้ว' else render :new, status: :unprocessable_entity end end def update if @product.update(product_params) redirect_to [:admin, @product], notice: 'อัปเดตสินค้าแล้ว' else render :edit, status: :unprocessable_entity end end private def require_admin redirect_to root_path unless current_user&.admin? end def set_product @product = Product.find(params[:id]) end def stock_changed? params[:product][:stock_quantity].present? end def log_activity ActivityLog.create!( user: current_user, action: action_name, resource: @product ) end def product_params params.require(:product).permit(:name, :price, :description, :stock_quantity) end end ``` Callback จะถูกรันตามลำดับการประกาศ ใช้ `skip_before_action` ในคลาสย่อยเพื่อปิดการใช้งาน callback ที่สืบทอดมา หลีกเลี่ยง callback ที่มีตรรกะทางธุรกิจมากเกินไป ให้เลือกใช้ Service Object แทน ## Service และสถาปัตยกรรม ### คำถามที่ 8: จะนำ Service Object มาใช้ใน Rails ได้อย่างไร? Service Object ห่อหุ้มตรรกะทางธุรกิจที่ซับซ้อนซึ่งไม่ควรอยู่ใน Model หรือ Controller ช่วยให้ทดสอบได้ดีขึ้นและสอดคล้องกับหลักการความรับผิดชอบเดียว ```ruby # app/services/order_processor.rb # Service Object ที่มีอินเทอร์เฟซมาตรฐาน class OrderProcessor def initialize(order, payment_method:) @order = order @payment_method = payment_method end def call return failure('คำสั่งซื้อถูกประมวลผลแล้ว') if @order.processed? ActiveRecord::Base.transaction do validate_stock! process_payment! update_inventory! send_confirmation! @order.update!(status: 'completed', processed_at: Time.current) end success(@order) rescue PaymentError => e failure("การชำระเงินล้มเหลว: #{e.message}") rescue InsufficientStockError => e failure("สต็อกไม่เพียงพอ: #{e.message}") end private def validate_stock! @order.items.each do |item| unless item.product.stock_quantity >= item.quantity raise InsufficientStockError, item.product.name end end end def process_payment! result = PaymentGateway.charge( amount: @order.total, method: @payment_method, description: "คำสั่งซื้อ ##{@order.id}" ) raise PaymentError, result.error unless result.success? @order.update!(payment_reference: result.transaction_id) end def update_inventory! @order.items.each do |item| item.product.decrement!(:stock_quantity, item.quantity) end end def send_confirmation! OrderMailer.confirmation(@order).deliver_later end def success(data) Result.new(success: true, data: data) end def failure(error) Result.new(success: false, error: error) end Result = Struct.new(:success, :data, :error, keyword_init: true) do def success? = success def failure? = !success end end ``` ```ruby # app/controllers/orders_controller.rb class OrdersController < ApplicationController def create @order = current_user.orders.build(order_params) if @order.save result = OrderProcessor.new(@order, payment_method: params[:payment_method]).call if result.success? redirect_to @order, notice: 'ยืนยันคำสั่งซื้อแล้ว!' else @order.update!(status: 'payment_failed') flash.now[:alert] = result.error render :new, status: :unprocessable_entity end else render :new, status: :unprocessable_entity end end end ``` รูปแบบ Service Object ปฏิบัติตามแบบแผนง่าย ๆ คือ คลาสเดียว ความรับผิดชอบเดียว เมธอด `call` สาธารณะหนึ่งเมธอด การคืนค่าออบเจกต์ Result ช่วยให้จัดการความสำเร็จและความล้มเหลวได้อย่างเป็นระเบียบ ### คำถามที่ 9: อธิบาย Concern ใน Rails Concern ช่วยให้สามารถดึงและแชร์โค้ดระหว่าง Model หรือ Controller ได้ ใช้ `ActiveSupport::Concern` เพื่อให้ syntax การ include สะอาด ```ruby # app/models/concerns/sluggable.rb # Concern ที่ใช้ซ้ำได้สำหรับสร้าง slug module Sluggable extend ActiveSupport::Concern included do # โค้ดที่รันเมื่อ include before_validation :generate_slug, if: :should_generate_slug? validates :slug, presence: true, uniqueness: true end # เมธอดของคลาส class_methods do def find_by_slug!(slug) find_by!(slug: slug) end def sluggable_source(column = :title) @sluggable_source = column end def sluggable_source_column @sluggable_source || :title end end # เมธอดของอินสแตนซ์ def to_param slug end private def should_generate_slug? slug.blank? || send("#{self.class.sluggable_source_column}_changed?") end def generate_slug source = send(self.class.sluggable_source_column) return if source.blank? base_slug = source.parameterize self.slug = unique_slug(base_slug) end def unique_slug(base) slug = base counter = 1 while self.class.where(slug: slug).where.not(id: id).exists? slug = "#{base}-#{counter}" counter += 1 end slug end end ``` ```ruby # app/models/article.rb class Article < ApplicationRecord include Sluggable sluggable_source :title # ทางเลือก ค่าเริ่มต้นคือ :title end # app/models/product.rb class Product < ApplicationRecord include Sluggable sluggable_source :name end ``` ```ruby # app/controllers/concerns/pagination.rb # Concern สำหรับ controller module Pagination extend ActiveSupport::Concern included do helper_method :page_param, :per_page_param end private def paginate(relation) relation.page(page_param).per(per_page_param) end def page_param params[:page]&.to_i || 1 end def per_page_param [params[:per_page]&.to_i || 25, 100].min end end ``` Concern มีประโยชน์สำหรับโค้ดที่แชร์กันจริง ๆ หลีกเลี่ยงการสร้าง Concern เพียงเพื่อ "ทำให้ Model สั้นลง" เพราะจะซ่อนความซับซ้อนโดยไม่ลดมัน ## การทดสอบด้วย RSpec ### คำถามที่ 10: จะจัดโครงสร้างการทดสอบ RSpec ใน Rails อย่างไร? RSpec คือเฟรมเวิร์กการทดสอบมาตรฐานสำหรับ Rails โครงสร้างการทดสอบที่ดีรวมถึง Model spec, Controller spec, Service spec และการทดสอบการรวม ```ruby # spec/models/user_spec.rb require 'rails_helper' RSpec.describe User, type: :model do # Factory ด้วย FactoryBot let(:user) { build(:user) } let(:admin) { build(:user, :admin) } describe 'validations' do it { is_expected.to validate_presence_of(:email) } it { is_expected.to validate_uniqueness_of(:email).case_insensitive } it 'ตรวจสอบรูปแบบของ email' do user.email = 'invalid' expect(user).not_to be_valid expect(user.errors[:email]).to include('is invalid') end end describe 'associations' do it { is_expected.to have_many(:articles).dependent(:destroy) } it { is_expected.to have_one(:profile) } it { is_expected.to belong_to(:organization).optional } end describe '#full_name' do it 'คืนค่าชื่อจริงและนามสกุลรวมกัน' do user = build(:user, first_name: 'John', last_name: 'Doe') expect(user.full_name).to eq('John Doe') end it 'จัดการกรณีที่ไม่มีนามสกุล' do user = build(:user, first_name: 'John', last_name: nil) expect(user.full_name).to eq('John') end end describe '.active' do it 'คืนเฉพาะผู้ใช้ที่ยังใช้งาน' do active = create(:user, active: true) inactive = create(:user, active: false) expect(User.active).to include(active) expect(User.active).not_to include(inactive) end end end ``` ```ruby # spec/services/order_processor_spec.rb require 'rails_helper' RSpec.describe OrderProcessor do let(:user) { create(:user) } let(:product) { create(:product, stock_quantity: 10, price: 100) } let(:order) { create(:order, user: user, items: [build(:order_item, product: product, quantity: 2)]) } subject { described_class.new(order, payment_method: 'card') } describe '#call' do context 'เมื่อคำสั่งซื้อถูกต้อง' do before do allow(PaymentGateway).to receive(:charge).and_return( OpenStruct.new(success?: true, transaction_id: 'txn_123') ) end it 'ประมวลผลคำสั่งซื้อสำเร็จ' do result = subject.call expect(result).to be_success expect(order.reload.status).to eq('completed') end it 'ลดสต็อกของสินค้า' do expect { subject.call }.to change { product.reload.stock_quantity }.by(-2) end it 'ส่งอีเมลยืนยัน' do expect { subject.call } .to have_enqueued_mail(OrderMailer, :confirmation) .with(order) end end context 'เมื่อการชำระเงินล้มเหลว' do before do allow(PaymentGateway).to receive(:charge).and_return( OpenStruct.new(success?: false, error: 'Card declined') ) end it 'คืนค่าผลลัพธ์ว่าล้มเหลว' do result = subject.call expect(result).to be_failure expect(result.error).to include('Card declined') end it 'ไม่อัปเดตสถานะคำสั่งซื้อ' do expect { subject.call }.not_to change { order.reload.status } end end end end ``` ```ruby # spec/requests/api/v1/products_spec.rb require 'rails_helper' RSpec.describe 'API V1 Products', type: :request do let(:user) { create(:user) } let(:headers) { { 'Authorization' => "Bearer #{user.api_token}" } } describe 'GET /api/v1/products' do let!(:products) { create_list(:product, 3, :active) } it 'คืนค่ารายการสินค้า' do get '/api/v1/products', headers: headers expect(response).to have_http_status(:ok) expect(json_response['data'].size).to eq(3) end it 'กรองตามหมวดหมู่' do category = create(:category) categorized = create(:product, category: category) get '/api/v1/products', params: { category_id: category.id }, headers: headers expect(json_response['data'].map { |p| p['id'] }).to eq([categorized.id]) end end describe 'POST /api/v1/products' do let(:valid_params) do { product: { name: 'สินค้าใหม่', price: 99.99, category_id: create(:category).id } } end it 'สร้างสินค้าใหม่' do expect { post '/api/v1/products', params: valid_params, headers: headers }.to change(Product, :count).by(1) expect(response).to have_http_status(:created) end end end ``` แนวปฏิบัติที่ดี: ใช้ `let` สำหรับข้อมูล `describe` สำหรับเมธอด/บริบท `context` สำหรับเงื่อนไข และ `it` สำหรับ assertion ที่เฉพาะเจาะจง การทดสอบหนึ่งควรทดสอบเพียงเรื่องเดียว ### คำถามที่ 11: จะใช้ factory กับ FactoryBot อย่างไร? FactoryBot ช่วยสร้างข้อมูลทดสอบในรูปแบบ declarative และง่ายต่อการบำรุงรักษา Factory แทนที่ fixture แบบสถิต ```ruby # spec/factories/users.rb FactoryBot.define do factory :user do # Sequence เพื่อรับประกันความเป็นเอกลักษณ์ sequence(:email) { |n| "user#{n}@example.com" } first_name { Faker::Name.first_name } last_name { Faker::Name.last_name } password { 'password123' } confirmed_at { Time.current } # Trait สำหรับรูปแบบต่าง ๆ trait :admin do role { 'admin' } after(:create) do |user| user.permissions.create!(name: 'admin_access') end end trait :unconfirmed do confirmed_at { nil } end trait :with_profile do after(:create) do |user| create(:profile, user: user) end end trait :with_articles do transient do articles_count { 3 } end after(:create) do |user, evaluator| create_list(:article, evaluator.articles_count, author: user) end end # Factory ที่สืบทอด factory :admin_user do admin with_profile end end end ``` ```ruby # spec/factories/orders.rb FactoryBot.define do factory :order do user status { 'pending' } trait :with_items do transient do items_count { 2 } end after(:create) do |order, evaluator| create_list(:order_item, evaluator.items_count, order: order) order.recalculate_total! end end trait :completed do status { 'completed' } processed_at { Time.current } with_items end trait :high_value do after(:create) do |order| create(:order_item, order: order, quantity: 10, unit_price: 500) order.recalculate_total! end end end end ``` ```ruby # การใช้งานในการทดสอบ RSpec.describe OrderProcessor do # build: อินสแตนซ์ที่ไม่บันทึก let(:user) { build(:user) } # create: บันทึกในฐานข้อมูล let(:order) { create(:order, :with_items, user: user) } # create_list: หลายอินสแตนซ์ let(:products) { create_list(:product, 5) } # รวม trait let(:admin) { create(:user, :admin, :with_profile) } # เขียนทับแอตทริบิวต์ let(:expensive_order) { create(:order, :with_items, items_count: 10) } # build_stubbed: เร็วกว่า สำหรับ unit test let(:stubbed_user) { build_stubbed(:user) } end ``` ใช้ `build` หรือ `build_stubbed` แทน `create` เมื่อไม่จำเป็นต้องบันทึก: สิ่งนี้ทำให้การทดสอบเร็วขึ้นอย่างมาก ## งานเบื้องหลัง ### คำถามที่ 12: จะใช้ Active Job และ Sidekiq ใน Rails อย่างไร? Active Job ให้อินเทอร์เฟซที่เป็นหนึ่งเดียวสำหรับงานเบื้องหลัง โดยไม่ขึ้นอยู่กับ backend (Sidekiq, Resque ฯลฯ) Sidekiq เป็นตัวเลือกที่ได้รับความนิยมเนื่องจากประสิทธิภาพกับ Redis ```ruby # app/jobs/process_order_job.rb class ProcessOrderJob < ApplicationJob queue_as :default # การกำหนดค่าการลองใหม่ retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3 retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 10 discard_on ActiveJob::DeserializationError # ตัวเลือก Sidekiq (หาก backend เป็น Sidekiq) sidekiq_options retry: 5, backtrace: true def perform(order_id) order = Order.find(order_id) OrderProcessor.new(order).call rescue ActiveRecord::RecordNotFound # คำสั่งซื้อถูกลบระหว่างคิวและการรัน Rails.logger.warn("Order #{order_id} not found, skipping job") end end ``` ```ruby # app/jobs/batch_email_job.rb class BatchEmailJob < ApplicationJob queue_as :mailers # จำกัดอัตราด้วย Sidekiq Enterprise หรือ gem throttle sidekiq_options throttle: { threshold: 100, period: 1.minute } def perform(user_ids, template_id) template = EmailTemplate.find(template_id) User.where(id: user_ids).find_each do |user| UserMailer.custom_email(user, template).deliver_later end end end ``` ```ruby # จัดคิวงาน # ทันที ProcessOrderJob.perform_later(order.id) # ล่าช้า ProcessOrderJob.set(wait: 5.minutes).perform_later(order.id) # ในเวลาที่กำหนด ProcessOrderJob.set(wait_until: Date.tomorrow.noon).perform_later(order.id) # คิวเฉพาะ ProcessOrderJob.set(queue: :critical).perform_later(order.id) # แบบซิงโครนัส (สำหรับการทดสอบหรือดีบัก) ProcessOrderJob.perform_now(order.id) ``` ```ruby # config/sidekiq.yml :concurrency: 10 :queues: - [critical, 3] # ลำดับความสำคัญสูง น้ำหนัก 3 - [default, 2] # ลำดับความสำคัญปานกลาง น้ำหนัก 2 - [mailers, 1] # ลำดับความสำคัญต่ำ น้ำหนัก 1 - [low, 1] :schedule: cleanup_job: cron: '0 3 * * *' # ทุกวันเวลา 3 นาฬิกา class: CleanupJob ``` Active Job แยกตัว backend ออก แต่การเข้าถึงคุณสมบัติเฉพาะ (batch, rate limiting) มักต้องการการเชื่อมโยงกับ backend ที่เลือก ## การพัฒนา API ### คำถามที่ 13: จะสร้าง RESTful API ด้วย Rails อย่างไร? Rails ทำให้การสร้าง API JSON ง่ายขึ้นด้วย Controller แบบ API-only และ serializer API ที่ดีต้องมีเวอร์ชัน เอกสารและความปลอดภัย ```ruby # app/controllers/api/v1/base_controller.rb module Api module V1 class BaseController < ActionController::API include ActionController::HttpAuthentication::Token::ControllerMethods before_action :authenticate_token! rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity rescue_from ActionController::ParameterMissing, with: :bad_request private def authenticate_token! authenticate_or_request_with_http_token do |token, options| @current_user = User.find_by(api_token: token) end end def current_user @current_user end def not_found(exception) render json: { error: 'ไม่พบทรัพยากร', details: exception.message }, status: :not_found end def unprocessable_entity(exception) render json: { error: 'การตรวจสอบล้มเหลว', details: exception.record.errors }, status: :unprocessable_entity end def bad_request(exception) render json: { error: 'คำขอไม่ถูกต้อง', details: exception.message }, status: :bad_request end end end end ``` ```ruby # app/controllers/api/v1/products_controller.rb module Api module V1 class ProductsController < BaseController before_action :set_product, only: [:show, :update, :destroy] def index @products = Product.active .includes(:category) .page(params[:page]) .per(params[:per_page] || 20) render json: { data: ProductSerializer.new(@products).serializable_hash, meta: pagination_meta(@products) } end def show render json: ProductSerializer.new(@product, include: [:category, :reviews]) end def create @product = Product.new(product_params) @product.save! render json: ProductSerializer.new(@product), status: :created end def update @product.update!(product_params) render json: ProductSerializer.new(@product) end def destroy @product.destroy! head :no_content end private def set_product @product = Product.find(params[:id]) end def product_params params.require(:product).permit(:name, :description, :price, :category_id) end def pagination_meta(collection) { current_page: collection.current_page, total_pages: collection.total_pages, total_count: collection.total_count } end end end end ``` ```ruby # app/serializers/product_serializer.rb # ด้วย gem jsonapi-serializer class ProductSerializer include JSONAPI::Serializer attributes :id, :name, :description, :price, :created_at attribute :formatted_price do |product| "$#{product.price.to_f.round(2)}" end belongs_to :category has_many :reviews link :self do |product| Rails.application.routes.url_helpers.api_v1_product_url(product) end end ``` แนวปฏิบัติ API ที่ดี: จัดเวอร์ชันผ่าน namespace ใช้รหัส HTTP ที่เหมาะสม แบ่งหน้าชุดข้อมูล และให้ข้อความข้อผิดพลาดที่ชัดเจน ### คำถามที่ 14: จะนำการยืนยันตัวตนแบบ JWT มาใช้ใน Rails อย่างไร? JWT (JSON Web Tokens) เป็นวิธีการยืนยันตัวตนแบบ stateless ที่ได้รับความนิยมสำหรับ API Token เข้ารหัสตัวตนและความถูกต้องของผู้ใช้ ```ruby # app/services/jwt_service.rb class JwtService SECRET_KEY = Rails.application.credentials.secret_key_base ALGORITHM = 'HS256'.freeze class << self def encode(payload, exp = 24.hours.from_now) payload[:exp] = exp.to_i payload[:iat] = Time.current.to_i JWT.encode(payload, SECRET_KEY, ALGORITHM) end def decode(token) decoded = JWT.decode(token, SECRET_KEY, true, algorithm: ALGORITHM) HashWithIndifferentAccess.new(decoded.first) rescue JWT::ExpiredSignature raise AuthenticationError, 'Token หมดอายุ' rescue JWT::DecodeError raise AuthenticationError, 'Token ไม่ถูกต้อง' end end end ``` ```ruby # app/controllers/api/v1/auth_controller.rb module Api module V1 class AuthController < ActionController::API def login user = User.find_by(email: params[:email]) if user&.authenticate(params[:password]) token = JwtService.encode(user_id: user.id) render json: { token: token, user: UserSerializer.new(user), expires_at: 24.hours.from_now } else render json: { error: 'ข้อมูลรับรองไม่ถูกต้อง' }, status: :unauthorized end end def refresh token = JwtService.encode(user_id: current_user.id) render json: { token: token, expires_at: 24.hours.from_now } end end end end ``` ```ruby # app/controllers/concerns/jwt_authenticatable.rb module JwtAuthenticatable extend ActiveSupport::Concern included do before_action :authenticate_jwt! end private def authenticate_jwt! header = request.headers['Authorization'] token = header&.split(' ')&.last raise AuthenticationError, 'ไม่มี Token' unless token decoded = JwtService.decode(token) @current_user = User.find(decoded[:user_id]) rescue AuthenticationError => e render json: { error: e.message }, status: :unauthorized rescue ActiveRecord::RecordNotFound render json: { error: 'ไม่พบผู้ใช้' }, status: :unauthorized end def current_user @current_user end end ``` สำหรับ production พิจารณา: refresh token, การ blacklist token เมื่อออกจากระบบ และเวลาหมดอายุที่สั้น Gem เช่น `devise-jwt` ทำให้การติดตั้งใช้งานง่ายขึ้น ## การ Cache และประสิทธิภาพ ### คำถามที่ 15: จะนำการ cache มาใช้ใน Rails อย่างไร? Rails มีระดับการ cache หลายระดับ: fragment caching, Russian Doll caching, low-level caching การเลือกขึ้นอยู่กับกรณีการใช้งาน ```ruby # config/environments/production.rb config.action_controller.perform_caching = true config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'], namespace: 'myapp:cache', expires_in: 1.day, race_condition_ttl: 10.seconds } ``` ```erb <%# app/views/products/index.html.erb %> <%# Fragment caching ด้วยกุญแจ cache อัตโนมัติ %> <% @products.each do |product| %> <%# Cache อิงตาม updated_at ของสินค้า %> <% cache product do %> <%= render product %> <% end %> <% end %> <%# Russian Doll caching - cache ซ้อนกัน %> <% cache ['v1', @category] do %>

<%= @category.name %>

<% @category.products.each do |product| %> <% cache ['v1', product] do %> <%= render product %> <% end %> <% end %> <% end %> <%# Cache แบบมีเงื่อนไข %> <% cache_if current_user.nil?, @product do %> <%= render @product %> <% end %> ``` ```ruby # app/models/product.rb class Product < ApplicationRecord # Touch parent เพื่อยกเลิก cache แบบ Russian Doll belongs_to :category, touch: true # กุญแจ cache แบบกำหนดเอง def cache_key_with_version "#{super}/#{reviews.maximum(:updated_at)&.to_i}" end end ``` ```ruby # Low-level caching ใน service class DashboardStatsService def call Rails.cache.fetch('dashboard:stats', expires_in: 15.minutes) do { total_users: User.count, active_users: User.where('last_sign_in_at > ?', 30.days.ago).count, total_orders: Order.completed.count, revenue_mtd: Order.completed.where(created_at: Time.current.beginning_of_month..).sum(:total) } end end end # Cache พร้อมป้องกัน race condition Rails.cache.fetch('popular_products', expires_in: 1.hour, race_condition_ttl: 10.seconds) do Product.bestsellers.limit(10).to_a end # การยกเลิกอย่างชัดเจน Rails.cache.delete('dashboard:stats') Rails.cache.delete_matched('products:*') ``` Russian Doll caching มีประสิทธิภาพเพราะมีการสร้างใหม่เฉพาะส่วนที่แก้ไขเท่านั้น ใช้ `touch: true` บนความสัมพันธ์เพื่อกระจายการยกเลิก ### คำถามที่ 16: จะปรับประสิทธิภาพของแอปพลิเคชัน Rails ได้อย่างไร? การปรับ Rails ครอบคลุมหลายแง่มุม: query DB, cache, asset และสถาปัตยกรรม วิธีการที่เป็นระบบพร้อมการมอนิเตอร์เป็นสิ่งสำคัญ ```ruby # การปรับฐานข้อมูล # config/database.yml production: pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> prepared_statements: true advisory_locks: true # app/models/order.rb class Order < ApplicationRecord # Index แบบรวมสำหรับ query ที่ใช้บ่อย # add_index :orders, [:user_id, :status, :created_at] # เลือกเฉพาะคอลัมน์ที่จำเป็น scope :summary, -> { select(:id, :status, :total, :created_at) } # ประมวลผลแบบ batch สำหรับปริมาณมาก def self.process_pending pending.find_each(batch_size: 1000) do |order| ProcessOrderJob.perform_later(order.id) end end # หลีกเลี่ยงการคำนวณซ้ำ def self.revenue_by_month completed .group("DATE_TRUNC('month', created_at)") .sum(:total) end end ``` ```ruby # การปรับหน่วยความจำ # config/puma.rb workers ENV.fetch("WEB_CONCURRENCY") { 2 } threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } threads threads_count, threads_count preload_app! before_fork do ActiveRecord::Base.connection_pool.disconnect! end on_worker_boot do ActiveRecord::Base.establish_connection end ``` ```ruby # Profiling ด้วย rack-mini-profiler # Gemfile group :development do gem 'rack-mini-profiler' gem 'memory_profiler' gem 'stackprof' end # config/initializers/mini_profiler.rb if defined?(Rack::MiniProfiler) Rack::MiniProfiler.config.position = 'bottom-right' Rack::MiniProfiler.config.start_hidden = true end ``` ```ruby # Lazy loading และการแบ่งหน้า class ProductsController < ApplicationController def index @products = Product.active .includes(:category, :primary_image) .page(params[:page]) .per(24) # Prefetch สำหรับหน้าถัดไป if @products.next_page Rails.cache.fetch("products:page:#{@products.next_page}", expires_in: 5.minutes) do Product.active.page(@products.next_page).per(24).to_a end end end end ``` เครื่องมือสำคัญ: `rack-mini-profiler` สำหรับ profiling, `bullet` สำหรับตรวจหา N+1, `New Relic` หรือ `Scout` สำหรับมอนิเตอร์ production ## ความปลอดภัย ### คำถามที่ 17: แนวปฏิบัติที่ดีด้านความปลอดภัยใน Rails คืออะไร? Rails มีการป้องกันค่าเริ่มต้นต่อช่องโหว่ที่พบบ่อย การเข้าใจและกำหนดค่าการป้องกันเหล่านี้อย่างถูกต้องเป็นสิ่งสำคัญ ```ruby # การป้องกัน CSRF # app/controllers/application_controller.rb class ApplicationController < ActionController::Base # เปิดใช้งานโดยเริ่มต้น โยน exception หาก token ไม่ถูกต้อง protect_from_forgery with: :exception # สำหรับ API ใช้ :null_session # protect_from_forgery with: :null_session end # ใน view, token จะถูกแทรกในแบบฟอร์มโดยอัตโนมัติ # <%= form_with ... %> รวม authenticity_token # สำหรับคำขอ AJAX # เพิ่ม header X-CSRF-Token พร้อมค่าจาก csrf_meta_tags ``` ```ruby # การป้องกัน SQL Injection # ✅ พารามิเตอร์แบบ interpolation จะถูก escape อัตโนมัติ User.where('email = ?', params[:email]) User.where(email: params[:email]) # ❌ อันตราย - การ interpolation โดยตรง User.where("email = '#{params[:email]}'") # ✅ สำหรับ ORDER แบบไดนามิก ALLOWED_SORTS = %w[name created_at price].freeze sort_column = ALLOWED_SORTS.include?(params[:sort]) ? params[:sort] : 'name' Product.order(sort_column) ``` ```ruby # การป้องกัน XSS # Rails escape HTML ใน view โดยอัตโนมัติ # ✅ Escape อัตโนมัติ <%= user.name %> # ❌ อันตราย - เนื้อหาที่ไม่ escape <%== user.bio %> <%= raw user.bio %> <%= user.bio.html_safe %> # ✅ สำหรับ HTML ที่ปลอดภัย ใช้ sanitize <%= sanitize user.bio, tags: %w[p br strong em] %> ``` ```ruby # Strong Parameters class UsersController < ApplicationController def update @user.update!(user_params) end private def user_params # Whitelist ที่ชัดเจนของ attribute ที่อนุญาต params.require(:user).permit(:name, :email, :avatar) # เฉพาะสำหรับผู้ดูแลระบบ if current_user.admin? params.require(:user).permit(:name, :email, :role, :active) else params.require(:user).permit(:name, :email) end end end ``` ```ruby # Header ความปลอดภัย # config/initializers/secure_headers.rb Rails.application.config.action_dispatch.default_headers = { 'X-Frame-Options' => 'SAMEORIGIN', 'X-XSS-Protection' => '1; mode=block', 'X-Content-Type-Options' => 'nosniff', 'X-Download-Options' => 'noopen', 'X-Permitted-Cross-Domain-Policies' => 'none', 'Referrer-Policy' => 'strict-origin-when-cross-origin' } # Content Security Policy Rails.application.config.content_security_policy do |policy| policy.default_src :self policy.script_src :self policy.style_src :self, :unsafe_inline policy.img_src :self, :data, 'https:' end ``` ตรวจสอบเป็นประจำด้วย `brakeman` (การวิเคราะห์ความปลอดภัยแบบสถิต) และอัปเดต gem ด้วย `bundle audit` ### คำถามที่ 18: จะจัดการการยืนยันตัวตนและการอนุญาตใน Rails อย่างไร? การยืนยันตัวตนตรวจสอบตัวตน การอนุญาตควบคุมสิทธิ์ Devise จัดการการยืนยันตัวตน Pundit หรือ CanCanCan จัดการการอนุญาต ```ruby # การตั้งค่า Devise # app/models/user.rb class User < ApplicationRecord devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable, :confirmable, :lockable, :trackable enum role: { user: 0, moderator: 1, admin: 2 } def admin? role == 'admin' end end ``` ```ruby # Policy ของ Pundit # app/policies/article_policy.rb class ArticlePolicy < ApplicationPolicy def index? true end def show? record.published? || owner_or_admin? end def create? user.present? end def update? owner_or_admin? end def destroy? owner_or_admin? end def publish? user&.admin? || user&.moderator? end # Scope สำหรับชุดข้อมูล class Scope < Scope def resolve if user&.admin? scope.all elsif user scope.where(published: true).or(scope.where(author: user)) else scope.where(published: true) end end end private def owner_or_admin? user&.admin? || record.author == user end end ``` ```ruby # Controller พร้อม Pundit class ArticlesController < ApplicationController include Pundit::Authorization after_action :verify_authorized, except: :index after_action :verify_policy_scoped, only: :index def index @articles = policy_scope(Article).includes(:author).page(params[:page]) end def show @article = Article.find(params[:id]) authorize @article end def update @article = Article.find(params[:id]) authorize @article if @article.update(article_params) redirect_to @article, notice: 'อัปเดตบทความแล้ว' else render :edit, status: :unprocessable_entity end end def publish @article = Article.find(params[:id]) authorize @article @article.update!(published: true, published_at: Time.current) redirect_to @article, notice: 'เผยแพร่บทความแล้ว' end rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized private def user_not_authorized flash[:alert] = "คุณไม่ได้รับอนุญาตให้ดำเนินการนี้" redirect_back(fallback_location: root_path) end end ``` Pundit ชัดเจนและทดสอบได้ง่ายกว่า CanCanCan การกระทำแต่ละอย่างมีเมธอด policy ที่สอดคล้องกัน และ scope จะกรองชุดข้อมูลโดยอัตโนมัติ ## Rails ขั้นสูง ### คำถามที่ 19: อธิบายรูปแบบ Repository ใน Rails รูปแบบ Repository แยกตรรกะการเข้าถึงข้อมูลออกจากส่วนที่เหลือของแอปพลิเคชัน แม้ว่า Rails จะใช้ Active Record (รูปแบบที่แตกต่างกัน) แต่ Repository อาจมีประโยชน์ในกรณีที่ซับซ้อน ```ruby # app/repositories/base_repository.rb class BaseRepository def initialize(model_class) @model_class = model_class end def all @model_class.all end def find(id) @model_class.find(id) end def find_by(attributes) @model_class.find_by(attributes) end def create(attributes) @model_class.create(attributes) end def update(record, attributes) record.update(attributes) end def delete(record) record.destroy end end ``` ```ruby # app/repositories/product_repository.rb class ProductRepository < BaseRepository def initialize super(Product) end def active @model_class.where(active: true) end def in_category(category_id) @model_class.where(category_id: category_id) end def search(query) @model_class.where('name ILIKE ? OR description ILIKE ?', "%#{query}%", "%#{query}%") end def with_stock @model_class.where('stock_quantity > 0') end def bestsellers(limit: 10) @model_class .joins(:order_items) .group(:id) .order('COUNT(order_items.id) DESC') .limit(limit) end def for_homepage active .with_stock .includes(:category, :primary_image) .order(featured: :desc, created_at: :desc) .limit(12) end end ``` ```ruby # การใช้งานใน service class ProductSearchService def initialize(repository: ProductRepository.new) @repository = repository end def call(params) products = @repository.active products = products.in_category(params[:category]) if params[:category] products = products.search(params[:query]) if params[:query].present? products = products.with_stock if params[:in_stock] products end end # ทำให้ทดสอบด้วย mock ได้สะดวก RSpec.describe ProductSearchService do let(:repository) { instance_double(ProductRepository) } let(:service) { described_class.new(repository: repository) } it 'กรองตามหมวดหมู่' do products = double('products') allow(repository).to receive(:active).and_return(products) allow(products).to receive(:in_category).with(1).and_return(products) service.call(category: 1) expect(products).to have_received(:in_category).with(1) end end ``` Repository เป็นทางเลือกใน Rails เนื่องจาก Active Record เป็นรูปแบบที่ยอดเยี่ยมอยู่แล้ว ใช้สำหรับ query ที่ซับซ้อนหรือเมื่อการแยกส่วนเก็บข้อมูลเป็นสิ่งสำคัญ ### คำถามที่ 20: จะนำรูปแบบ CQRS มาใช้ใน Rails อย่างไร? CQRS (Command Query Responsibility Segregation) แยกการอ่านและเขียนข้อมูล ใน Rails หมายถึงคลาสแยกกันสำหรับ query และ command ```ruby # app/commands/base_command.rb class BaseCommand include ActiveModel::Validations def self.call(*args) new(*args).call end def call return failure(errors) unless valid? execute end private def execute raise NotImplementedError end def success(data = nil) CommandResult.success(data) end def failure(errors) CommandResult.failure(errors) end end CommandResult = Struct.new(:success, :data, :errors, keyword_init: true) do def success? = success def failure? = !success def self.success(data) new(success: true, data: data, errors: []) end def self.failure(errors) new(success: false, data: nil, errors: Array(errors)) end end ``` ```ruby # app/commands/orders/create_order_command.rb module Orders class CreateOrderCommand < BaseCommand attr_reader :user, :items, :shipping_address validates :user, presence: true validates :items, presence: true validate :validate_items_availability def initialize(user:, items:, shipping_address:) @user = user @items = items @shipping_address = shipping_address end private def execute order = nil ActiveRecord::Base.transaction do order = Order.create!( user: user, shipping_address: shipping_address, status: 'pending' ) items.each do |item| order.items.create!( product_id: item[:product_id], quantity: item[:quantity], unit_price: Product.find(item[:product_id]).price ) end order.calculate_total! end OrderCreatedEvent.broadcast(order) success(order) rescue ActiveRecord::RecordInvalid => e failure(e.message) end def validate_items_availability items.each do |item| product = Product.find_by(id: item[:product_id]) unless product&.stock_quantity&.>= item[:quantity] errors.add(:items, "สินค้า #{item[:product_id]} ไม่พร้อมใช้งาน") end end end end end ``` ```ruby # app/queries/orders/user_orders_query.rb module Orders class UserOrdersQuery def initialize(user, params = {}) @user = user @params = params end def call orders = @user.orders.includes(:items, items: :product) orders = apply_status_filter(orders) orders = apply_date_filter(orders) orders = apply_sorting(orders) orders.page(@params[:page]).per(@params[:per_page] || 20) end private def apply_status_filter(orders) return orders unless @params[:status] orders.where(status: @params[:status]) end def apply_date_filter(orders) orders = orders.where('created_at >= ?', @params[:from]) if @params[:from] orders = orders.where('created_at <= ?', @params[:to]) if @params[:to] orders end def apply_sorting(orders) case @params[:sort] when 'oldest' then orders.order(created_at: :asc) when 'total_desc' then orders.order(total: :desc) else orders.order(created_at: :desc) end end end end ``` ```ruby # Controller ที่ใช้ CQRS class OrdersController < ApplicationController def index @orders = Orders::UserOrdersQuery.new(current_user, filter_params).call end def create result = Orders::CreateOrderCommand.call( user: current_user, items: order_params[:items], shipping_address: order_params[:shipping_address] ) if result.success? redirect_to result.data, notice: 'สร้างคำสั่งซื้อแล้ว!' else flash.now[:alert] = result.errors.join(', ') render :new, status: :unprocessable_entity end end end ``` CQRS เด่นในแอปพลิเคชันที่ซับซ้อนซึ่งมีความต้องการอ่าน/เขียนไม่สมมาตร สำหรับ CRUD ทั่วไปจะเป็นการ over-engineering ### คำถามที่ 21: จะจัดการ WebSocket ด้วย Action Cable อย่างไร? Action Cable รวม WebSocket เข้ากับ Rails สำหรับการสื่อสารสองทางแบบเรียลไทม์ ใช้ Redis เพื่อซิงค์ระหว่างเซิร์ฟเวอร์ ```ruby # app/channels/application_cable/connection.rb module ApplicationCable class Connection < ActionCable::Connection::Base identified_by :current_user def connect self.current_user = find_verified_user end private def find_verified_user # ผ่าน cookie ของเซสชัน if verified_user = User.find_by(id: cookies.encrypted[:user_id]) verified_user # ผ่าน JWT สำหรับ API elsif verified_user = verify_jwt_token verified_user else reject_unauthorized_connection end end def verify_jwt_token token = request.params[:token] return nil unless token decoded = JwtService.decode(token) User.find(decoded[:user_id]) rescue nil end end end ``` ```ruby # app/channels/chat_channel.rb class ChatChannel < ApplicationCable::Channel def subscribed @room = ChatRoom.find(params[:room_id]) # ตรวจสอบสิทธิ์ unless @room.accessible_by?(current_user) reject return end stream_for @room # แจ้งคนอื่นเรื่องการเข้ามา broadcast_presence(:joined) end def unsubscribed broadcast_presence(:left) if @room end def send_message(data) message = @room.messages.create!( user: current_user, content: data['content'] ) # กระจายไปยังผู้สมัครรับทุกคน ChatChannel.broadcast_to(@room, { type: 'message', message: MessageSerializer.new(message).as_json }) end def typing ChatChannel.broadcast_to(@room, { type: 'typing', user: current_user.name }) end private def broadcast_presence(action) ChatChannel.broadcast_to(@room, { type: 'presence', action: action, user: current_user.name, online_count: @room.online_users_count }) end end ``` ```javascript // app/javascript/channels/chat_channel.js import consumer from "./consumer" const chatChannel = consumer.subscriptions.create( { channel: "ChatChannel", room_id: roomId }, { connected() { console.log("Connected to chat") }, disconnected() { console.log("Disconnected from chat") }, received(data) { switch(data.type) { case 'message': this.appendMessage(data.message) break case 'typing': this.showTypingIndicator(data.user) break case 'presence': this.updatePresence(data) break } }, sendMessage(content) { this.perform('send_message', { content: content }) }, notifyTyping() { this.perform('typing') } } ) ``` Action Cable จัดการการเชื่อมต่อใหม่และการซิงค์โดยอัตโนมัติ ใน production ให้ตั้ง Redis เป็น adapter และปรับขนาดตามจำนวนการเชื่อมต่อพร้อมกัน ### คำถามที่ 22: จะนำ multi-tenancy มาใช้ใน Rails อย่างไร? Multi-tenancy ช่วยให้แอปพลิเคชันให้บริการลูกค้า (tenant) หลายรายที่แยกออกจากกัน มีสามวิธีหลัก: ระดับฐานข้อมูล, schema หรือแถว ```ruby # Multitenancy ระดับแถวด้วย ActsAsTenant หรือทำเอง # app/models/concerns/tenant_scoped.rb module TenantScoped extend ActiveSupport::Concern included do belongs_to :tenant # Scope เริ่มต้นที่ tenant ปัจจุบัน default_scope -> { where(tenant: Current.tenant) if Current.tenant } # การตรวจสอบ tenant before_validation :set_tenant, on: :create end private def set_tenant self.tenant ||= Current.tenant end end # app/models/current.rb class Current < ActiveSupport::CurrentAttributes attribute :tenant, :user end ``` ```ruby # app/controllers/application_controller.rb class ApplicationController < ActionController::Base before_action :set_current_tenant private def set_current_tenant Current.tenant = resolve_tenant Current.user = current_user end def resolve_tenant # ผ่าน subdomain if request.subdomain.present? && request.subdomain != 'www' Tenant.find_by!(subdomain: request.subdomain) # ผ่าน header (สำหรับ API) elsif request.headers['X-Tenant-ID'].present? Tenant.find(request.headers['X-Tenant-ID']) # ผ่านผู้ใช้ elsif current_user current_user.tenant end rescue ActiveRecord::RecordNotFound redirect_to root_url(subdomain: 'www'), alert: 'ไม่พบ tenant' end end ``` ```ruby # app/models/project.rb class Project < ApplicationRecord include TenantScoped has_many :tasks belongs_to :owner, class_name: 'User' end # app/models/user.rb class User < ApplicationRecord include TenantScoped has_many :projects, foreign_key: :owner_id # ผู้ดูแลสามารถเป็นสมาชิกของหลาย tenant has_many :tenant_memberships has_many :accessible_tenants, through: :tenant_memberships, source: :tenant end ``` ```ruby # ระดับ schema ด้วย gem Apartment (PostgreSQL) # config/initializers/apartment.rb Apartment.configure do |config| config.excluded_models = %w[Tenant User] config.tenant_names = -> { Tenant.pluck(:subdomain) } end # การใช้งาน Apartment::Tenant.switch('acme') do # query ทั้งหมดในบล็อกนี้ใช้ schema 'acme' Project.all # SELECT * FROM acme.projects end ``` ระดับแถวง่ายที่สุดแต่ต้องเฝ้าระวังการรั่วไหลตลอดเวลา ระดับ schema ให้การแยกที่ดีกว่าแต่ทำให้ migration ซับซ้อน เลือกตามความต้องการด้านความปลอดภัยและความสามารถในการขยายขนาด ### คำถามที่ 23: จะตั้งค่าสถาปัตยกรรม microservice ด้วย Rails อย่างไร? Rails สามารถใช้เป็นพื้นฐานสำหรับสถาปัตยกรรม microservice ที่สื่อสารผ่าน HTTP/gRPC หรือ message queue กุญแจคือการกำหนดขอบเขตที่ชัดเจน ```ruby # Client ของบริการ HTTP # app/services/payment_service_client.rb class PaymentServiceClient include HTTParty base_uri ENV.fetch('PAYMENT_SERVICE_URL') def initialize @options = { headers: { 'Content-Type' => 'application/json', 'X-Service-Token' => ENV.fetch('SERVICE_TOKEN') }, timeout: 10 } end def create_charge(amount:, currency:, source:, metadata: {}) response = self.class.post('/charges', @options.merge( body: { amount: amount, currency: currency, source: source, metadata: metadata }.to_json )) handle_response(response) end def get_charge(charge_id) response = self.class.get("/charges/#{charge_id}", @options) handle_response(response) end private def handle_response(response) case response.code when 200..299 ServiceResult.success(response.parsed_response) when 400..499 ServiceResult.failure(response.parsed_response['error'], code: response.code) else ServiceResult.failure('บริการไม่พร้อมใช้งาน', code: response.code) end rescue Net::OpenTimeout, Net::ReadTimeout ServiceResult.failure('Timeout ของบริการ') end end ``` ```ruby # การสื่อสารแบบ event-driven ด้วย Sidekiq/Redis # app/events/order_events.rb module OrderEvents class Created include Wisper::Publisher def call(order) broadcast(:order_created, order) end end end # app/listeners/inventory_listener.rb class InventoryListener def order_created(order) order.items.each do |item| InventoryServiceClient.new.reserve_stock( product_id: item.product_id, quantity: item.quantity, reference: order.id ) end end end # config/initializers/wisper.rb Wisper.subscribe(InventoryListener.new, async: true) Wisper.subscribe(NotificationListener.new, async: true) ``` ```ruby # รูปแบบ API Gateway # app/controllers/api/v1/gateway_controller.rb module Api module V1 class GatewayController < BaseController # รวมหลายบริการ def dashboard results = Parallel.map([:orders, :inventory, :analytics], in_threads: 3) do |service| fetch_from_service(service) end render json: { orders: results[0], inventory: results[1], analytics: results[2] } end private def fetch_from_service(service) case service when :orders OrderServiceClient.new.recent_orders(limit: 5) when :inventory InventoryServiceClient.new.low_stock_alerts when :analytics AnalyticsServiceClient.new.daily_summary end rescue => e { error: "#{service} ไม่พร้อมใช้งาน", message: e.message } end end end end ``` สำหรับ microservice ของ Rails: กำหนดสัญญา API ที่ชัดเจน (OpenAPI), ติดตั้ง circuit breaker (gem `circuitbox`) และใช้ tracing แบบกระจาย (gem `opentelemetry`) ### คำถามที่ 24: จะ deploy แอปพลิเคชัน Rails ไปยัง production อย่างไร? การ deploy Rails สมัยใหม่ใช้คอนเทนเนอร์หรือ PaaS การกำหนดค่า production ที่แข็งแรงครอบคลุม asset, ฐานข้อมูลและการมอนิเตอร์ ```ruby # config/environments/production.rb Rails.application.configure do config.cache_classes = true config.eager_load = true config.consider_all_requests_local = false # Asset config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? config.assets.compile = false config.assets.digest = true # Logging config.log_level = ENV.fetch('LOG_LEVEL', 'info').to_sym config.log_tags = [:request_id] config.logger = ActiveSupport::Logger.new(STDOUT) .tap { |logger| logger.formatter = Logger::Formatter.new } .then { |logger| ActiveSupport::TaggedLogging.new(logger) } # Cache config.cache_store = :redis_cache_store, { url: ENV['REDIS_URL'], expires_in: 1.day } # บังคับใช้ SSL config.force_ssl = true config.ssl_options = { hsts: { subdomains: true } } # Action Mailer config.action_mailer.delivery_method = :smtp config.action_mailer.smtp_settings = { address: ENV['SMTP_HOST'], port: ENV['SMTP_PORT'], user_name: ENV['SMTP_USER'], password: ENV['SMTP_PASSWORD'], authentication: :plain, enable_starttls_auto: true } end ``` ```dockerfile # Dockerfile FROM ruby:3.3-alpine AS builder RUN apk add --no-cache build-base postgresql-dev nodejs yarn WORKDIR /app COPY Gemfile Gemfile.lock ./ RUN bundle config set --local deployment true && \ bundle config set --local without 'development test' && \ bundle install COPY package.json yarn.lock ./ RUN yarn install --frozen-lockfile COPY . . RUN bundle exec rails assets:precompile # Image production FROM ruby:3.3-alpine RUN apk add --no-cache postgresql-client tzdata WORKDIR /app COPY --from=builder /app /app COPY --from=builder /usr/local/bundle /usr/local/bundle ENV RAILS_ENV=production ENV RAILS_LOG_TO_STDOUT=true EXPOSE 3000 CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"] ``` ```yaml # docker-compose.production.yml version: '3.8' services: web: build: . environment: - DATABASE_URL=postgres://user:pass@db/app_production - REDIS_URL=redis://redis:6379/0 - SECRET_KEY_BASE=${SECRET_KEY_BASE} depends_on: - db - redis deploy: replicas: 3 resources: limits: memory: 512M sidekiq: build: . command: bundle exec sidekiq environment: - DATABASE_URL=postgres://user:pass@db/app_production - REDIS_URL=redis://redis:6379/0 depends_on: - db - redis deploy: replicas: 2 db: image: postgres:15-alpine volumes: - postgres_data:/var/lib/postgresql/data environment: - POSTGRES_PASSWORD=${DB_PASSWORD} redis: image: redis:7-alpine volumes: - redis_data:/data volumes: postgres_data: redis_data: ``` Checklist สำหรับ production: บังคับใช้ SSL, secret ผ่าน ENV, health check, สำรองข้อมูล DB อัตโนมัติ, มอนิเตอร์ (APM + log + metric) และตั้งค่าการแจ้งเตือน ### คำถามที่ 25: คุณสมบัติใหม่ใน Rails 7+ ที่ควรรู้มีอะไรบ้าง? Rails 7+ นำมาซึ่งการเปลี่ยนแปลงสำคัญ: Hotwire ค่าเริ่มต้น, import map, credentials ที่เข้ารหัสปรับปรุงและการเพิ่มประสิทธิภาพมากมาย ```ruby # Hotwire - Turbo Frame # app/views/articles/index.html.erb <%= turbo_frame_tag "articles" do %> <% @articles.each do |article| %> <%= turbo_frame_tag dom_id(article) do %> <%= render article %> <% end %> <% end %> <%= link_to "โหลดเพิ่ม", articles_path(page: @page + 1), data: { turbo_frame: "articles" } %> <% end %> # Turbo Stream สำหรับการอัปเดตเรียลไทม์ # app/controllers/comments_controller.rb def create @comment = @article.comments.create!(comment_params.merge(user: current_user)) respond_to do |format| format.turbo_stream format.html { redirect_to @article } end end # app/views/comments/create.turbo_stream.erb <%= turbo_stream.append "comments", @comment %> <%= turbo_stream.update "comments_count", @article.comments.count %> <%= turbo_stream.replace "comment_form", partial: "comments/form", locals: { comment: Comment.new } %> ``` ```ruby # Controller Stimulus # app/javascript/controllers/search_controller.js import { Controller } from "@hotwired/stimulus" import { debounce } from "lodash-es" export default class extends Controller { static targets = ["input", "results"] static values = { url: String } connect() { this.search = debounce(this.search.bind(this), 300) } async search() { const query = this.inputTarget.value if (query.length < 2) return const response = await fetch(`${this.urlValue}?q=${encodeURIComponent(query)}`) this.resultsTarget.innerHTML = await response.text() } } ``` ```ruby # Import Map (โดยไม่ใช้ JavaScript bundler) # config/importmap.rb pin "application" pin "@hotwired/turbo-rails", to: "turbo.min.js" pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" pin_all_from "app/javascript/controllers", under: "controllers" # Pin จาก CDN pin "lodash-es", to: "https://ga.jspm.io/npm:lodash-es@4.17.21/lodash.js" ``` ```ruby # Active Record Encryption (Rails 7+) # app/models/user.rb class User < ApplicationRecord encrypts :email, deterministic: true # อนุญาตให้ค้นหา encrypts :phone_number # ไม่กำหนดได้โดยเริ่มต้น encrypts :ssn, deterministic: true, downcase: true end # config/credentials.yml.enc active_record_encryption: primary_key: abc123... deterministic_key: def456... key_derivation_salt: ghi789... ``` ```ruby # การปรับปรุงอินเทอร์เฟซ query # Rails 7.1+ # Query แบบ asynchronous users = User.where(active: true).load_async # ทำงานต่อขณะ query ทำงานอยู่ # เข้าถึงผลลัพธ์ด้วย users.to_a # Common Table Expressions (CTE) User.with( recent_orders: Order.where('created_at > ?', 30.days.ago) ).joins('JOIN recent_orders ON recent_orders.user_id = users.id') # ตรวจจับ inverse_of อัตโนมัติ class Author < ApplicationRecord has_many :books # inverse_of ตรวจจับโดยอัตโนมัติ end # Strict loading โดยเริ่มต้น (ป้องกัน N+1) class ApplicationRecord < ActiveRecord::Base self.strict_loading_by_default = true end ``` Rails 7+ เน้นความเรียบง่าย (ไม่มี Webpack โดยเริ่มต้น) และ HTML-over-the-wire ด้วย Hotwire แนวทางนี้ลดความซับซ้อนของ JavaScript ในขณะที่ให้ประสบการณ์ผู้ใช้ที่ทันสมัย ## บทสรุป การสัมภาษณ์ Ruby on Rails ประเมินความเชี่ยวชาญในเฟรมเวิร์กทั้งหมดและความเข้าใจในแบบแผนต่าง ๆ จุดสำคัญที่ควรจดจำ: ✅ **พื้นฐาน**: MVC, Active Record, migration, การตรวจสอบและความสัมพันธ์ ✅ **สถาปัตยกรรม**: Service Object, Concern, Query Object และรูปแบบ CQRS ✅ **ประสิทธิภาพ**: query N+1, caching (fragment, Russian Doll, low-level), eager loading ✅ **การทดสอบ**: RSpec, FactoryBot, request spec และแนวปฏิบัติที่ดีในการทดสอบ ✅ **ความปลอดภัย**: CSRF, SQL injection, XSS, Strong Parameters และการยืนยันตัวตน/อนุญาต ✅ **API**: การออกแบบ RESTful, JWT, serializer และการจัดการเวอร์ชัน ✅ **Production**: งานเบื้องหลัง, WebSocket, การ deploy และการมอนิเตอร์ ปรัชญาของ Rails (Convention over Configuration, DRY และ Rails Way) ชี้นำการตัดสินใจด้านสถาปัตยกรรมทั้งหมด การเชี่ยวชาญในหลักการเหล่านี้และการรู้ว่าเมื่อใดควรเบี่ยงเบนจากมัน แสดงให้เห็นถึงความเชี่ยวชาญที่มั่นคง --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/th/blog/ruby-on-rails/ruby-on-rails-interview-questions