คำถามสัมภาษณ์ Ruby on Rails: 25 อันดับในปี 2026
25 คำถามสัมภาษณ์ Ruby on Rails ที่ถูกถามบ่อยที่สุด สถาปัตยกรรม MVC, Active Record, migration, การทดสอบ RSpec, REST API พร้อมคำตอบและตัวอย่างโค้ดอย่างละเอียด

การสัมภาษณ์ 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 โดยแบ่งความรับผิดชอบออกเป็นสามชั้นที่ชัดเจน เพื่อให้บำรุงรักษาและทดสอบโค้ดได้ดียิ่งขึ้น
# 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# 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<%# app/views/articles/show.html.erb %>
<%# View แสดงข้อมูลในรูปแบบ HTML %>
<article class="article-detail">
<header>
<h1><%= @article.title %></h1>
<p class="meta">
โดย <%= @article.author.name %> •
<%= l @article.created_at, format: :long %>
</p>
</header>
<div class="content">
<%= simple_format @article.body %>
</div>
<%# Partial สำหรับความคิดเห็น %>
<%= render @comments %>
</article>ขั้นตอนปกติ: คำขอเข้าสู่ Router ซึ่งจะส่งต่อไปยัง Controller ที่เหมาะสม Controller จะโต้ตอบกับ Model เพื่อดึงหรือแก้ไขข้อมูล จากนั้นส่งข้อมูลให้ View เพื่อทำการเรนเดอร์ HTML
คำถามที่ 2: Active Record คืออะไร และ ORM ของ Rails ทำงานอย่างไร?
Active Record คือ ORM (Object-Relational Mapping) ของ Rails ที่นำรูปแบบ Active Record มาใช้ คลาส Model แต่ละคลาสแทนตารางในฐานข้อมูล และอินสแตนซ์แต่ละตัวแทนแถวเดียว
# 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# ตัวอย่าง 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)
endActive Record แปลงเมธอด Ruby ให้เป็น query SQL ที่ปรับให้เหมาะสม เมธอดอย่าง where, joins, includes เป็นแบบ lazy: query จะถูกประมวลผลเมื่อมีการวนซ้ำหรือเรียก to_a เท่านั้น
คำถามที่ 3: อธิบายระบบ migration ของ Rails
Migration ช่วยให้สามารถจัดการเวอร์ชันของ schema ฐานข้อมูลด้วย Ruby ได้ Migration สามารถย้อนกลับได้ และช่วยให้โครงสร้างข้อมูลพัฒนาได้อย่างมีการควบคุม
# 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# 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# คำสั่ง 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, seedMigration ต้องสามารถย้อนกลับได้ เมธอด change ฉลาดและสามารถย้อนกลับการดำเนินการทั่วไปได้โดยอัตโนมัติ สำหรับกรณีที่ซับซ้อน ใช้ up และ down แยกกัน
Active Record ขั้นสูง
คำถามที่ 4: จะปรับ query N+1 ใน Rails ได้อย่างไร?
ปัญหา N+1 เกิดขึ้นเมื่อ query เริ่มต้นตามมาด้วย query เพิ่มเติมอีก N รายการเพื่อโหลดความสัมพันธ์ Rails มีเมธอด eager loading หลายแบบเพื่อแก้ปัญหานี้
# 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# 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# ตรวจจับ 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 ให้การจัดระเบียบและความสามารถในการทดสอบที่ดีกว่า
# 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# 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 เหมาะสำหรับการค้นหาที่ซับซ้อนซึ่งมีฟิลเตอร์ทางเลือกหลายแบบและตรรกะการประกอบ
พร้อมที่จะพิชิตการสัมภาษณ์ Ruby on Rails แล้วหรือยังครับ?
ฝึกฝนด้วยตัวจำลองแบบโต้ตอบ, flashcards และแบบทดสอบเทคนิคครับ
Routing และ Controller
คำถามที่ 6: RESTful routing ใน Rails ทำงานอย่างไร?
Rails ส่งเสริมเส้นทางแบบ RESTful ซึ่งจับคู่ HTTP verb กับการกระทำ CRUD Router แปลง URL ให้เป็นการเรียก controller ที่เฉพาะเจาะจง
# 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# 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#draftsHelper เส้นทางที่สร้างขึ้น (article_path(@article), new_article_path) ช่วยให้สามารถอ้างอิง URL ได้อย่างไดนามิกและง่ายต่อการบำรุงรักษา
คำถามที่ 7: อธิบาย callback และ filter ใน controller
Callback (before_action, after_action, around_action) ช่วยให้สามารถรันโค้ดก่อน หลัง หรือรอบ ๆ การกระทำของ controller
# 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# 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
endCallback จะถูกรันตามลำดับการประกาศ ใช้ skip_before_action ในคลาสย่อยเพื่อปิดการใช้งาน callback ที่สืบทอดมา หลีกเลี่ยง callback ที่มีตรรกะทางธุรกิจมากเกินไป ให้เลือกใช้ Service Object แทน
Service และสถาปัตยกรรม
คำถามที่ 8: จะนำ Service Object มาใช้ใน Rails ได้อย่างไร?
Service Object ห่อหุ้มตรรกะทางธุรกิจที่ซับซ้อนซึ่งไม่ควรอยู่ใน Model หรือ Controller ช่วยให้ทดสอบได้ดีขึ้นและสอดคล้องกับหลักการความรับผิดชอบเดียว
# 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# 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 สะอาด
# 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# 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# 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
endConcern มีประโยชน์สำหรับโค้ดที่แชร์กันจริง ๆ หลีกเลี่ยงการสร้าง Concern เพียงเพื่อ "ทำให้ Model สั้นลง" เพราะจะซ่อนความซับซ้อนโดยไม่ลดมัน
การทดสอบด้วย RSpec
คำถามที่ 10: จะจัดโครงสร้างการทดสอบ RSpec ใน Rails อย่างไร?
RSpec คือเฟรมเวิร์กการทดสอบมาตรฐานสำหรับ Rails โครงสร้างการทดสอบที่ดีรวมถึง Model spec, Controller spec, Service spec และการทดสอบการรวม
# 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# 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# 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 แบบสถิต
# 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# 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# การใช้งานในการทดสอบ
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
# 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# 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# จัดคิวงาน
# ทันที
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)# 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: CleanupJobActive Job แยกตัว backend ออก แต่การเข้าถึงคุณสมบัติเฉพาะ (batch, rate limiting) มักต้องการการเชื่อมโยงกับ backend ที่เลือก
พร้อมที่จะพิชิตการสัมภาษณ์ Ruby on Rails แล้วหรือยังครับ?
ฝึกฝนด้วยตัวจำลองแบบโต้ตอบ, flashcards และแบบทดสอบเทคนิคครับ
การพัฒนา API
คำถามที่ 13: จะสร้าง RESTful API ด้วย Rails อย่างไร?
Rails ทำให้การสร้าง API JSON ง่ายขึ้นด้วย Controller แบบ API-only และ serializer API ที่ดีต้องมีเวอร์ชัน เอกสารและความปลอดภัย
# 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# 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# 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 เข้ารหัสตัวตนและความถูกต้องของผู้ใช้
# 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# 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# 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 การเลือกขึ้นอยู่กับกรณีการใช้งาน
# 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
}<%# 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 %>
<h2><%= @category.name %></h2>
<% @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 %># 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# 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 และสถาปัตยกรรม วิธีการที่เป็นระบบพร้อมการมอนิเตอร์เป็นสิ่งสำคัญ
# การปรับฐานข้อมูล
# 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# การปรับหน่วยความจำ
# 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# 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# 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 มีการป้องกันค่าเริ่มต้นต่อช่องโหว่ที่พบบ่อย การเข้าใจและกำหนดค่าการป้องกันเหล่านี้อย่างถูกต้องเป็นสิ่งสำคัญ
# การป้องกัน 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# การป้องกัน 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)# การป้องกัน 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] %># 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# 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 จัดการการอนุญาต
# การตั้งค่า 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# 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# 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
endPundit ชัดเจนและทดสอบได้ง่ายกว่า CanCanCan การกระทำแต่ละอย่างมีเมธอด policy ที่สอดคล้องกัน และ scope จะกรองชุดข้อมูลโดยอัตโนมัติ
Rails ขั้นสูง
คำถามที่ 19: อธิบายรูปแบบ Repository ใน Rails
รูปแบบ Repository แยกตรรกะการเข้าถึงข้อมูลออกจากส่วนที่เหลือของแอปพลิเคชัน แม้ว่า Rails จะใช้ Active Record (รูปแบบที่แตกต่างกัน) แต่ Repository อาจมีประโยชน์ในกรณีที่ซับซ้อน
# 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# 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# การใช้งานใน 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
endRepository เป็นทางเลือกใน Rails เนื่องจาก Active Record เป็นรูปแบบที่ยอดเยี่ยมอยู่แล้ว ใช้สำหรับ query ที่ซับซ้อนหรือเมื่อการแยกส่วนเก็บข้อมูลเป็นสิ่งสำคัญ
คำถามที่ 20: จะนำรูปแบบ CQRS มาใช้ใน Rails อย่างไร?
CQRS (Command Query Responsibility Segregation) แยกการอ่านและเขียนข้อมูล ใน Rails หมายถึงคลาสแยกกันสำหรับ query และ command
# 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# 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# 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# 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
endCQRS เด่นในแอปพลิเคชันที่ซับซ้อนซึ่งมีความต้องการอ่าน/เขียนไม่สมมาตร สำหรับ CRUD ทั่วไปจะเป็นการ over-engineering
คำถามที่ 21: จะจัดการ WebSocket ด้วย Action Cable อย่างไร?
Action Cable รวม WebSocket เข้ากับ Rails สำหรับการสื่อสารสองทางแบบเรียลไทม์ ใช้ Redis เพื่อซิงค์ระหว่างเซิร์ฟเวอร์
# 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# 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
endimport 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 หรือแถว
# 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# 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# 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# ระดับ 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 กุญแจคือการกำหนดขอบเขตที่ชัดเจน
# 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# การสื่อสารแบบ 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)# รูปแบบ 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, ฐานข้อมูลและการมอนิเตอร์
# 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
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 /app /app
COPY /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"]# 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 ที่เข้ารหัสปรับปรุงและการเพิ่มประสิทธิภาพมากมาย
# 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 } %># 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()
}
}# 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"# 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...# การปรับปรุงอินเทอร์เฟซ 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
endRails 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) ชี้นำการตัดสินใจด้านสถาปัตยกรรมทั้งหมด การเชี่ยวชาญในหลักการเหล่านี้และการรู้ว่าเมื่อใดควรเบี่ยงเบนจากมัน แสดงให้เห็นถึงความเชี่ยวชาญที่มั่นคง
เริ่มฝึกซ้อมเลย!
ทดสอบความรู้ของคุณด้วยตัวจำลองสัมภาษณ์และแบบทดสอบเทคนิคครับ
แท็ก
แชร์
บทความที่เกี่ยวข้อง

ActiveRecord: แก้ปัญหาคิวรี N+1 ใน Ruby on Rails
คู่มือฉบับสมบูรณ์ในการตรวจจับและแก้ไขคิวรี N+1 ใน Rails ด้วย ActiveRecord เชี่ยวชาญ includes, preload, eager_load และเครื่องมือตรวจจับอัตโนมัติ

Ruby on Rails 7: Hotwire lae Turbo samrap Aepplikheishan Riaethaim
Khumue sombun khong Hotwire lae Turbo nai Rails 7. Sang aepplikheishan riaethaim doi mai tong khian JavaScript duai Turbo Drive, Frames lae Streams.

Rails API Mode ปี 2026: สร้าง RESTful API ด้วย Serialization, Authentication และคำถามสัมภาษณ์งาน
เจาะลึก Rails 8 API Mode สร้าง RESTful API ด้วย Alba, JWT Authentication และ RSpec พร้อมคำถามสัมภาษณ์ปี 2026