บทเรียน 101 · Rails · Day 1

Routing — พนักงานต้อนรับที่ชี้ว่า URL ไหนไปหาใคร

~20 นาที · ควรผ่าน บทเรียน 100 (server · request · MVC) มาก่อน · ถ้าลืม router ของ Gin ทวนได้ที่ บทเรียน 38

อ่านจบแล้วคุณจะเปิดไฟล์ config/routes.rb แล้วอ่านออกทุกบรรทัดว่า “URL นี้ ส่งไปให้ใครทำ” · เขียน route เองได้ · ใช้ช่อง :id รับเลขบทความ · ตั้งชื่อ route ไว้สร้างลิงก์ · รู้ว่าบรรทัดเดียว resources :posts ให้อะไรมาบ้าง · และรู้ว่าทำไมลำดับบรรทัดในไฟล์นี้สำคัญ ซึ่งต่างจาก Gin ที่คุณเคยใช้

🔁 ทวนก่อนเริ่ม (คำจากบทก่อน ๆ ที่บทนี้ใช้)

🧰 เครื่องที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 (สไลด์ใช้ 4.0.6 · 8.1.3 — ต่างกันแค่รุ่นย่อย) · ทุกผลรันในบทนี้ก็อปมาจากเครื่องจริง บนแอปที่สร้างด้วย rails new wongnok_blog และเปิด server ที่พอร์ต 3000

routes คืออะไร — พนักงานต้อนรับหน้าร้าน

ใบสั่ง (request) ทุกใบที่เข้ามามีของสองอย่างติดมาเสมอ: verb คือ “อยากทำอะไร” (GET = ขอดู · POST = ส่งของใหม่มา) กับ path คือ “เรื่องอะไร” เช่น /posts

ไฟล์ config/routes.rb คือรายการที่บอกว่า verb + path แบบนี้ ให้ส่งไปแผนกไหน เมนูอะไร · ตัวที่อ่านรายการนี้เรียกว่า router · มันแค่ชี้ทาง ไม่ได้ทำอาหาร ไม่ได้แตะฐานข้อมูล

🍽 เทียบกับร้านอาหาร

router คือพนักงานต้อนรับหน้าร้าน ในมือถือรายการหนึ่งแผ่น เขียนว่า “ขอดูบทความทั้งหมด → แผนกบทความ เมนู index” · “ขอดูบทความจานที่ ___ → แผนกบทความ เมนู show”
ลูกค้ายื่นใบสั่งมา พนักงานไล่ดูรายการ เจอบรรทัดที่ตรงก็ส่งใบสั่งเข้าครัวแผนกนั้น · ไฟล์ config/routes.rb ก็คือรายการแผ่นนั้น

ที่ไม่เหมือนกัน: พนักงานตัวจริงใช้วิจารณญาณได้ ถ้าใบสั่งกำกวมเขาจะเลือกแผนกที่เข้าท่าที่สุดให้ · router ทำตามรายการเป๊ะ ๆ ไล่จากบนลงล่าง เจอบรรทัดแรกที่เข้าเงื่อนไขก็ส่งทันที ไม่คิดต่อ (หัวข้อ “ลำดับสำคัญ” จะเห็นว่าเรื่องนี้ทำให้พลาดยังไง) · และมันไม่เช็คด้วยว่าในครัวมีเชฟทำเมนูนั้นจริงไหม

เทียบกับ Go: ตอนเขียน Gin คุณเปิดร้านเอง — สร้างพนักงานต้อนรับเองด้วย r := gin.Default() แล้วสอนเขาทีละบรรทัดใน main.go · Rails เป็นร้านแฟรนไชส์ พนักงานต้อนรับมีให้แล้ว คุณแค่เขียนรายการลงไฟล์ที่แฟรนไชส์กำหนดไว้ คือ config/routes.rb

อ่านทีละชิ้น — Gin กับ Rails วางข้างกัน

นี่คือท่อนหนึ่งจาก main.go แบบที่คุณเคยเขียนใน Gin (ไม่ใช่ไฟล์เต็ม แค่สองบรรทัดที่ลง route):

// Go · Gin
r.GET("/posts", h.List)
r.GET("/posts/:id", h.Get)

และนี่คือของคู่กันใน Rails:

# config/routes.rb
Rails.application.routes.draw do
  get "/posts",     to: "posts#index"
  get "/posts/:id", to: "posts#show"
end

ทีละบรรทัด:

# ที่อยู่ในเครื่องหมายคำพูด อย่าง "posts#index" เป็นแค่ตัวอักษรคั่นกลาง ไม่ใช่คอมเมนต์ และไม่ใช่ #{} ที่แปะค่าลงข้อความ (บท 91) · # จะเป็นคอมเมนต์ก็ต่อเมื่ออยู่นอกเครื่องหมายคำพูด

ชิ้นGinRailsภาษาคน
verbr.GETgetใบสั่งแบบ “ขอดู”
path"/posts""/posts"เรื่องที่ขอ
ส่งไปหาใครh.List (ส่งตัวฟังก์ชันเข้าไปเลย)to: "posts#index" (ส่งแค่ชื่อเป็นข้อความ)แผนก posts เมนู index
อ่านค่าในช่อง :idc.Param("id")params[:id]อ่านเลขจานในใบสั่ง (params = รายละเอียดในใบสั่ง · ละเอียดในหัวข้อ :id ข้างล่าง)

แถวที่สามคือจุดที่ต่างกันจริง · Gin ส่งตัวฟังก์ชัน ถ้าพิมพ์ h.Lisst ผิด go build ฟ้องทันที · Rails ส่งแค่ชื่อเป็นข้อความ พิมพ์ชื่อเมนูผิด Rails ก็เปิดร้านได้ปกติ จะรู้ตัวก็ตอนมีใบสั่งเข้ามาจริง (เดี๋ยวเห็น error ตัวนี้ในหัวข้อ “404 สองแบบ”)

เปิดไฟล์ config/routes.rb ที่ rails new สร้างให้ จะเจอบรรทัดนี้อยู่แล้ว: get "up" => "rails/health#show", as: :rails_health_check — เป็นอีกวิธีเขียนที่แปลเหมือนกัน: ลูกศร => ทำหน้าที่เดียวกับ to: (“/up ส่งไปแผนก rails/health เมนู show”) · ส่วน as: :rails_health_check คือการตั้งชื่อให้ route เดี๋ยวเจอในหัวข้อ “ชื่อ route” · บรรทัดนี้มีไว้ให้ระบบภายนอกเช็คว่าร้านยังเปิดอยู่ไหม · เก็บไว้ได้ ไม่ต้องลบ · บรรทัดคอมเมนต์ (ขึ้นต้นด้วย #) ในไฟล์ก็ปล่อยไว้ได้

🗣 ศัพท์ที่เพิ่งเรียนไป
route
หนึ่งบรรทัดในรายการ = กฎหนึ่งข้อที่จับคู่ verb + path เข้ากับ controller กับ action · ถาม senior ได้ว่า “URL นี้ route ไปที่ controller#action ไหนครับ”
router / config/routes.rb
router คือตัวที่อ่านรายการ · config/routes.rb คือไฟล์รายการ (Rails มีไฟล์นี้ไฟล์เดียวสำหรับทั้งแอป) · พิมพ์บอก AI ได้ว่า “show me the routes.rb entry for GET /posts”
controller#action
วิธีเขียนปลายทางแบบสั้นของ Rails เช่น posts#index = PostsController method index · คนทำ Rails พูดกันแบบนี้ตลอด เช่น “หน้านี้อยู่ที่ posts#show”

ลองของจริง — เขียนสามบรรทัด แล้วยิงดู

ก่อนจะยิงได้ ต้องมี “เชฟ” รอรับก่อน · ผมเขียน controller ชั่วคราวที่ยังไม่ทำอะไรจริง แค่ตอบกลับว่า “ใบสั่งมาถึงแผนกไหน เมนูอะไร” (ของจริงที่ดึงบทความจากฐานข้อมูลรอ Day 2)

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    render plain: "แผนก posts · เมนู index (ดูบทความทั้งหมด)\n"
  end

  def show
    render plain: "แผนก posts · เมนู show · params[:id] = #{params[:id].inspect}\n"
  end
end

แล้วใส่ route ลงในไฟล์ · บรรทัด root ไฟล์ของคุณมีรอไว้แล้วเป็นคอมเมนต์ # root "posts#index" แค่ลบ # ข้างหน้าออก:

# config/routes.rb
Rails.application.routes.draw do
  get "/posts",     to: "posts#index"
  get "/posts/:id", to: "posts#show"
  root "posts#index"
end

ดูรายการที่ Rails เข้าใจ — bin/rails routes

คำสั่งนี้พิมพ์รายการทั้งหมดที่ router ถืออยู่ออกมาเป็นตาราง · ถ้าสั่งเปล่า ๆ จะได้ราว 30 บรรทัด ส่วนใหญ่เป็น route ที่ Rails ใส่มาเอง เลยกรองด้วย -c posts (เอาเฉพาะที่ไปแผนก posts):

$ bin/rails routes -c posts
Prefix Verb URI Pattern          Controller#Action
 posts GET  /posts(.:format)     posts#index
       GET  /posts/:id(.:format) posts#show
  root GET  /                    posts#index
คอลัมน์แปลว่า
Prefixชื่อเล่นของ route (หัวข้อ “ชื่อ route” จะอธิบาย) · ช่องว่าง = ยังไม่มีชื่อ
Verbใบสั่งแบบไหน GET / POST / …
URI Patternpath ที่รับ · (.:format) ต่อท้ายคือส่วนเสริมให้ขอแบบ /posts.json ได้ — ตอนนี้ข้ามไปได้เลย
Controller#Actionส่งไปแผนกไหน เมนูอะไร

เปิดร้านด้วย bin/rails server (บท 100) ทิ้งไว้ในหน้าต่าง terminal หนึ่ง แล้วเปิดอีกหน้าต่างมายิงด้วย curl · บรรทัดที่ขึ้นต้นด้วย $ คือคำสั่งที่พิมพ์ (ไม่ต้องพิมพ์ $) · บรรทัดถัดไปคือคำตอบจากร้าน:

$ curl localhost:3000/posts
แผนก posts · เมนู index (ดูบทความทั้งหมด)
$ curl localhost:3000/
แผนก posts · เมนู index (ดูบทความทั้งหมด)
$ curl localhost:3000/posts/42
แผนก posts · เมนู show · params[:id] = "42"
$ curl localhost:3000/posts/abc
แผนก posts · เมนู show · params[:id] = "abc"

สองบรรทัดแรกไปหาเชฟคนเดียวกัน · root คือ “หน้าแรกของร้าน” (path /) จึงเขียนสั้นกว่า ไม่ต้องบอก path · สไลด์บอกว่า root มีได้ตัวเดียว — ผมลองเขียน root สองบรรทัด ร้านยังเปิดได้ แต่ใบสั่งแรกที่เข้ามาได้ error (status 500) และ bin/rails routes ก็ไม่ยอมพิมพ์ตาราง · ทั้งสองที่บอกตรง ๆ ว่าชื่อ root ถูกใช้แล้ว (ตัดชื่อโฟลเดอร์ยาว ๆ ข้างหน้าออก):

Invalid route name, already in use: 'root'  (ArgumentError)

(ร้านเปิดได้เพราะตอนพัฒนาในเครื่อง Rails ยังไม่อ่าน routes.rb จนกว่าจะมีใบสั่งใบแรกเข้ามา)

:id — ช่องว่างในใบสั่งที่รอเติม

ตัวที่ขึ้นต้นด้วย : ใน path เช่น :id แปลว่า “ตรงนี้เป็นอะไรก็ได้ เก็บไว้ให้ฉันด้วย” · URL ส่งอะไรมาในตำแหน่งนั้น Rails เก็บใส่ params[:id] ให้ controller หยิบไปใช้ · มีไว้เพราะเราไม่อยากเขียน route แยกให้บทความทุกชิ้น

🧾 เทียบกับร้านอาหาร

"/posts/:id" เหมือนใบสั่งที่พิมพ์ไว้ว่า “ขอดูบทความจานที่ ____” · ลูกค้าเติมเลขอะไรลงในช่องว่างก็ได้ · พนักงานต้อนรับไม่สนว่าเติมอะไร ส่งเข้าแผนก posts เมนู show ทุกใบ แล้วเชฟอ่านเลขในช่องว่างจาก params[:id] (= รายละเอียดในใบสั่ง)

ที่ไม่เหมือนกัน: พนักงานตัวจริงเห็นใบสั่งเขียนว่า “จานที่ abc” จะทักว่าแปลก ๆ · router ไม่ทัก — /posts/abc ก็ส่งเข้า show สบาย ๆ (เห็นในผลรันข้างบน) · และสิ่งที่เติมมาเป็นข้อความเสมอ แม้จะดูเป็นตัวเลขก็ตาม

เทียบกับ Go: เหมือน Gin ทุกอย่าง — ฝั่ง Gin เขียน r.GET("/posts/:id", h.Get) แล้วอ่านค่าด้วย c.Param("id") ซึ่งได้ string · Rails อ่านด้วย params[:id] (:id ตรงนี้คือ symbol จากบท 92) ก็ได้ข้อความ (String) เหมือนกัน

ดูผลข้างบนอีกที: params[:id] = "42" มีเครื่องหมายคำพูด แปลว่าเป็นข้อความ "42" ไม่ใช่เลข 42 · ถ้าจะเอาไปคำนวณเองต้องแปลงด้วย .to_i ก่อน ไม่งั้นได้การต่อข้อความ:

p "42" + "1"        # => "421"   ← ต่อข้อความ ไม่ใช่บวกเลข
p "42".to_i        # => 42
p "42".to_i + 1    # => 43

(p = พิมพ์ค่าออกมาให้ดู จากบท 93 · รันด้วย ruby จริง) · ข่าวดีคือ Day 2 ตอนหาบทความจากฐานข้อมูล Rails แปลงให้เอง ส่วนใหญ่จึงไม่ต้องทำเอง

🗣 ศัพท์ที่เพิ่งเรียนไป
dynamic segment
ท่อนของ path ที่ขึ้นต้นด้วย : เช่น :id · รับค่าอะไรก็ได้ · คู่กับ static segment คือท่อนตายตัว เช่น posts · ถาม AI ได้ว่า “how do I read a dynamic segment in a Rails controller”
params
ของทุกอย่างที่มากับใบสั่ง รวมไว้ที่เดียว อ่านด้วย params[:ชื่อ] · ค่าจาก URL เป็น String เสมอ · เจอบั๊กบวกเลขแล้วได้ "421" ให้บอก AI ว่า “params[:id] is a String, I forgot to_i”

404 สองแบบ — ไม่มีในรายการ กับ เชฟไม่มีเมนูนี้

ตอนพลาดจะได้หน้า error สีแดงที่มีหัวเรื่องต่างกัน · อ่านหัวเรื่องให้ออกแล้วจะรู้ทันทีว่าต้องไปแก้ไฟล์ไหน

แบบที่ 1 · ยิง URL ที่ไม่มีบรรทัดไหนในรายการรับ เช่น /comments → status 404 หัวเรื่องในหน้าคือ:

Routing Error
No route matches [GET] "/comments"

แปลว่าพนักงานต้อนรับหาในรายการไม่เจอ ใบสั่งไม่ถึงครัวเลย → ไปแก้ config/routes.rb

แบบที่ 2 · ผมเพิ่ม get "/posts/popular", to: "posts#popular" ไว้ในรายการ (วางไว้เหนือบรรทัด /posts/:id — เหตุผลอยู่ในหัวข้อ “ลำดับสำคัญ”) แต่ไม่ได้เขียน def popular ใน controller แล้วยิง /posts/popular → status 404 เหมือนกัน แต่หัวเรื่องคนละอย่าง:

Unknown action
The action 'popular' could not be found for PostsController

แปลว่าพนักงานต้อนรับส่งใบสั่งเข้าครัวแล้ว แต่เชฟแผนก posts ไม่มีเมนูชื่อ popular → ไปแก้ controller (เพิ่ม def popular หรือแก้ชื่อที่พิมพ์ผิด) · นี่คือราคาของการ “ส่งแค่ชื่อเป็นข้อความ” ที่พูดถึงตอนเทียบกับ Gin

🚫 เทียบกับร้านอาหาร

แบบที่ 1 = พนักงานต้อนรับเปิดรายการแล้วบอกลูกค้าว่า “ร้านเราไม่รับใบสั่งแบบนี้” ใบสั่งไม่เคยเข้าครัว · แบบที่ 2 = พนักงานต้อนรับส่งเข้าครัวแผนกบทความตามรายการแล้ว แต่หัวหน้าเชฟบอกว่า “เมนูนี้ผมทำไม่เป็น”

ที่ไม่เหมือนกัน: ในร้านจริงลูกค้าจะได้ยินคำอธิบายต่างกัน · ใน Rails ทั้งสองแบบส่ง status 404 เหมือนกัน ต้องอ่านหัวเรื่องในหน้า error (หรือใน log ของ server) ถึงจะรู้ว่าเป็นแบบไหน · และหน้า error สีแดงละเอียดแบบนี้โชว์เฉพาะตอนพัฒนาในเครื่องเรา

เทียบกับ Go: ใน Gin แบบที่ 1 ก็มี (ยิง path ที่ไม่ได้ลงไว้ได้ 404) · แต่แบบที่ 2 แทบไม่เกิด เพราะถ้าฟังก์ชัน handler ไม่มีอยู่จริง go build ฟ้องตั้งแต่ก่อนเปิดร้าน

🗣 ศัพท์ที่เพิ่งเรียนไป
Routing Error (No route matches)
router หาบรรทัดที่ตรงไม่เจอ · แก้ที่ routes.rb · ส่งข้อความเต็ม No route matches [GET] "/comments" ให้ AI ดีกว่าบอกว่า “มัน 404” เพราะ 404 มีสองแบบที่แก้คนละที่
Unknown action (ActionNotFound)
router เจอแล้ว แต่ใน controller ไม่มี method ชื่อนั้น · แก้ที่ controller · ถาม senior ได้ว่า “route มีแล้วแต่ขึ้น Unknown action ผมต้องเพิ่ม action ใน controller ใช่ไหมครับ”

ชื่อ route และ _path — เรียกโต๊ะด้วยชื่อเล่น

เวลาหน้าเว็บจะมีลิงก์ไปบทความเลข 42 เราต้องสร้าง URL /posts/42 ขึ้นมา · ถ้าพิมพ์ "/posts/#{id}" ตรง ๆ ไว้ 40 ที่ วันที่เปลี่ยน URL เป็น /articles ต้องไล่แก้ 40 จุด และจะลืมสักจุดแน่นอน · Rails แก้ด้วยการให้ route มีชื่อ แล้วสร้าง method ช่วยประกอบ URL จากชื่อนั้น

ดูคอลัมน์ Prefix ในตารางเมื่อกี้ — /posts ได้ชื่อ posts · / ได้ชื่อ root · แต่ /posts/:id ช่องว่าง · Rails ตั้งชื่อให้เองเฉพาะ path ที่ไม่มีช่อง : · อันที่มี :id ต้องตั้งชื่อเองด้วย as: (:post คือ symbol ใช้เป็นชื่อ):

# config/routes.rb
Rails.application.routes.draw do
  get "/posts",     to: "posts#index"
  get "/posts/:id", to: "posts#show", as: :post
  root "posts#index"
end
$ bin/rails routes -c posts
Prefix Verb URI Pattern          Controller#Action
 posts GET  /posts(.:format)     posts#index
  post GET  /posts/:id(.:format) posts#show
  root GET  /                    posts#index

ช่อง Prefix ของ /posts/:id มีคำว่า post แล้ว · เอาชื่อไปต่อท้ายด้วย _path จะได้ method สำเร็จรูปทันที · ผมลองเรียกด้วย bin/rails runner (สั่งรันโค้ด Ruby หนึ่งก้อนในแอป โดยไม่ต้องเปิดร้าน):

post_path(42)   # => "/posts/42"
post_path(7)    # => "/posts/7"
posts_path     # => "/posts"
root_path      # => "/"

ส่งเลขเข้าไป ได้ URL กลับมา · ก่อนใส่ as: :post ผมลองเรียก post_path(42) แล้วได้ undefined method 'post_path' — ไม่มีชื่อ = ไม่มี method ให้เรียก

🪑 เทียบกับร้านอาหาร

ในร้าน ไม่มีใครเรียกโต๊ะว่า “โต๊ะที่พิกัดแถว 3 ช่อง 5” · เราตั้งชื่อเล่นให้โต๊ะ เช่น “โต๊ะริมหน้าต่าง” แล้วทุกคนเรียกชื่อนั้น · วันที่ย้ายโต๊ะไปอีกมุม แก้ผังร้านที่เดียว พนักงานทุกคนยังเรียก “โต๊ะริมหน้าต่าง” ได้เหมือนเดิม
ชื่อ route (post) คือชื่อเล่น · URL จริง (/posts/42) คือพิกัด · routes.rb คือผังร้าน

ที่ไม่เหมือนกัน: ชื่อเล่นโต๊ะชี้ไปโต๊ะเดียว · แต่ post_path ต้องรับเลข (42, 7, …) แล้วประกอบ URL ใหม่ทุกครั้ง ชื่อเดียวจึงสร้าง URL ได้ไม่จำกัด · และถ้าวันหนึ่งคุณเปลี่ยนชื่อเล่นเอง (แก้ as:) หน้าที่ยังเรียกชื่อเก่าจะขึ้น error ตอนเปิดหน้านั้น (undefined method แบบที่เห็นข้างบน) ไม่ใช่ลิงก์เสียเงียบ ๆ

เทียบกับ Go: ในคอร์ส Go เราไม่ได้ใช้ตัวช่วยแบบนี้ ถ้าต้องสร้างลิงก์ก็ต่อข้อความเอง เช่น fmt.Sprintf("/posts/%d", id) · ใน Rails ให้ใช้ helper แทนเสมอ

ยังมีคู่แฝด _url ที่ให้ URL เต็มพร้อมชื่อโดเมน (ใช้ในอีเมล ที่ไม่มี “หน้าปัจจุบัน” ให้อ้างอิง) · ผมแก้ index ชั่วคราวให้พิมพ์ทั้งสองแบบออกมา:

def index
  render plain: "post_path(42) = #{post_path(42)}\npost_url(42)  = #{post_url(42)}\n"
end
$ curl localhost:3000/posts
post_path(42) = /posts/42
post_url(42)  = http://localhost:3000/posts/42

localhost:3000 ไม่ได้ฝังอยู่ใน helper · Rails ยืมมาจากใบสั่งที่เพิ่งเข้ามา · ลิงก์ในเว็บตัวเองใช้ _path พอ browser เติมโดเมนให้เอง

🗣 ศัพท์ที่เพิ่งเรียนไป
named route
route ที่มีชื่อ (คอลัมน์ Prefix ไม่ว่าง) · ได้จาก as: หรือ Rails ตั้งให้เอง · ถาม senior ได้ว่า “route นี้มีชื่อหรือยังครับ ผมจะใช้ _path helper”
path helper / URL helper
post_path ให้แค่ path · post_url ให้ URL เต็มพร้อมโดเมน · Rails สร้างจากชื่อ route · พิมพ์บอก AI ได้ว่า “replace hardcoded URLs with Rails route helpers”

resources :posts — ชุดเมนูมาตรฐาน 8 เส้นในบรรทัดเดียว

แอปที่จัดการของ “ชนิดเดียว” อย่างบทความ ต้องการหน้าชุดเดิมเกือบทุกครั้ง: ดูทั้งหมด · ดูชิ้นเดียว · ฟอร์มเขียนใหม่ · บันทึก · ฟอร์มแก้ · บันทึกที่แก้ · ลบ · Rails เลยมีคำสั่งเดียวที่เขียนรายการชุดนี้ให้ทั้งหมด:

# config/routes.rb
Rails.application.routes.draw do
  resources :posts
end
$ bin/rails routes -c posts
   Prefix Verb   URI Pattern               Controller#Action
    posts GET    /posts(.:format)          posts#index
          POST   /posts(.:format)          posts#create
 new_post GET    /posts/new(.:format)      posts#new
edit_post GET    /posts/:id/edit(.:format) posts#edit
     post GET    /posts/:id(.:format)      posts#show
          PATCH  /posts/:id(.:format)      posts#update
          PUT    /posts/:id(.:format)      posts#update
          DELETE /posts/:id(.:format)      posts#destroy

:posts คือ symbol (บท 92) บอกว่าเป็นชุดของ “บทความ” · ในตารางมี verb ใหม่สามตัว: PATCH = ขอแก้ของที่มีอยู่ · PUT = ขอแก้เหมือนกัน (Rails รุ่นเก่าใช้ตัวนี้ ยังเก็บไว้ให้) · DELETE = ขอลบ · หนึ่งบรรทัด ได้ 8 เส้น · แปลเป็นภาษาคนทีละแถว:

ใบสั่งเมนู (action)ภาษาคนhelper
GET /postsindexดูบทความทั้งหมดposts_path
GET /posts/42showดูบทความชิ้นเดียว (เลข 42)post_path(42)
GET /posts/newnewเปิดฟอร์มเขียนบทความใหม่new_post_path
POST /postscreateกดบันทึกบทความใหม่(ใช้ posts_path)
GET /posts/42/editeditเปิดฟอร์มแก้บทความ 42edit_post_path(42)
PATCH /posts/42updateกดบันทึกที่แก้(ใช้ post_path(42))
PUT /posts/42updateบันทึกที่แก้ อีกวิธีเรียก — ไปเมนูเดียวกับ PATCH(ใช้ post_path(42))
DELETE /posts/42destroyลบบทความ 42(ใช้ post_path(42))

แถวที่ Prefix ว่างไม่ได้แปลว่าไม่มีลิงก์ · path ของมันเหมือนแถวข้างบน จึงใช้ helper ตัวเดียวกัน ต่างกันแค่ verb · สังเกตด้วยว่า Rails วาง /posts/new ไว้เหนือ /posts/:id ให้เอง — เหตุผลอยู่ในหัวข้อถัดไป · Day 2 จะใช้ครบทั้ง 8 เส้น ตอนต่อฐานข้อมูลจริง วันนี้แค่รู้ว่ามันได้อะไรมา

🍱 เทียบกับร้านอาหาร

resources :posts เหมือนบอกแฟรนไชส์ว่า “ขอเซ็ตเมนูมาตรฐานของแผนกบทความ” · แฟรนไชส์พิมพ์รายการ 8 บรรทัดให้พนักงานต้อนรับครบ ชื่อเมนูก็เป็นชื่อมาตรฐานที่ทุกสาขาใช้เหมือนกัน ใครย้ายมาจากร้าน Rails อื่นก็อ่านออกทันที

ที่ไม่เหมือนกัน: เซ็ตเมนูของแฟรนไชส์จริงมาพร้อมสูตรอาหาร · resources ให้แค่รายการของพนักงานต้อนรับ · เชฟยังไม่ได้ทำเมนูไหนเลย ต้องเขียน def index def show … ใน controller เอง ไม่งั้นยิงไปก็เจอ Unknown action

เทียบกับ Go: ใน Gin ถ้าอยากได้ชุดนี้ต้องเขียน r.GET r.POST r.PATCH r.DELETE เองทีละบรรทัดให้ครบ · Rails ใช้บรรทัดเดียว เพราะทุกสาขาตกลงกันแล้วว่าชุดนี้หน้าตาแบบนี้ (Convention over Configuration จากบท 90)

🗣 ศัพท์ที่เพิ่งเรียนไป
resources / resourceful routes
คำสั่งที่สร้าง route มาตรฐาน 8 เส้น (7 action) พร้อมชื่อครบ · ถาม senior ได้ว่า “ที่นี่ใช้ resources แล้วตัดเส้นที่ไม่ใช้ออก หรือเขียน route ทีละเส้นครับ”
CRUD
Create · Read · Update · Delete — สร้าง อ่าน แก้ ลบ · ทั้ง 7 action คือ CRUD ในรูปของหน้าเว็บ · เรื่องหลักของ Day 2 · พิมพ์บอก AI ได้ว่า “scaffold CRUD routes for posts using resources”

ลำดับสำคัญ — พนักงานอ่านรายการจากบนลงล่าง

router ไล่รายการจากบรรทัดบนสุดลงมา และหยุดที่บรรทัดแรกที่เข้าเงื่อนไข · ปกติไม่มีปัญหา จนกว่าจะมี URL หนึ่งที่เข้าได้สองบรรทัด · ลองทายดู (controller มีครบทั้ง show และ popular แล้ว):

# config/routes.rb
Rails.application.routes.draw do
  get "/posts",         to: "posts#index"
  get "/posts/:id",     to: "posts#show"
  get "/posts/popular", to: "posts#popular"
  root "posts#index"
end

# $ curl localhost:3000/posts/popular

ได้ “เมนู popular” หรือเปล่า? ถ้าไม่ใช่ ได้อะไร?

$ curl localhost:3000/posts/popular
แผนก posts · เมนู show · params[:id] = "popular"

เข้าเมนู show โดยถือว่า "popular" คือเลขบทความ · เพราะ :id รับอะไรก็ได้ คำว่า popular จึงเข้าเงื่อนไขบรรทัดที่สองก่อน แล้ว router หยุดหาทันที บรรทัดที่สามไม่มีวันได้ทำงาน

ที่น่ากลัวคือ ไม่มีอะไรเตือนเลย — ร้านเปิดได้ปกติ bin/rails routes ก็โชว์ครบทั้งสามบรรทัดตามปกติ

วิธีแก้: ย้ายบรรทัดที่เจาะจงกว่า (คำตายตัว popular) ขึ้นไปไว้เหนือบรรทัดที่กว้างกว่า (:id รับทุกอย่าง):

  get "/posts/popular", to: "posts#popular"   # เจาะจง → ไว้บน
  get "/posts/:id",     to: "posts#show"      # กว้าง → ไว้ล่าง
$ curl localhost:3000/posts/popular
แผนก posts · เมนู popular (บทความยอดนิยม)
$ curl localhost:3000/posts/42
แผนก posts · เมนู show · params[:id] = "42"

ลำดับสำคัญเมื่อไหร่? เฉพาะตอนที่ URL เดียวเข้าได้หลายบรรทัด · ส่วน /posts กับ /posts/:id สลับกันได้ไม่มีผล เพราะ /posts ไม่มีท่อนที่สองให้ :id รับ (ผมลองเอา :id ขึ้นก่อน แล้วถาม router ว่า /posts ไปไหน — ยังได้ posts#index เหมือนเดิม)

📋 เทียบกับร้านอาหาร

นึกถึงพนักงานต้อนรับที่ทำตามรายการอย่างเคร่งครัด · บรรทัดสองเขียนว่า “ขอดูบทความอะไรก็ได้ → เมนู show” พอลูกค้ายื่นใบสั่ง “ขอดูบทความ popular” เขาอ่านถึงบรรทัดสองก็เห็นว่าเข้าเงื่อนไข ส่งเข้า show เลย ไม่อ่านต่อไปถึงบรรทัดสามที่เขียนว่า popular ตรง ๆ

ที่ไม่เหมือนกัน: พนักงานคนจริงทำผิดสักสองสามครั้งก็เริ่มจำได้ว่า popular ไม่ใช่เลขบทความ · router ไม่มีวันเรียนรู้ ทำแบบเดิมทุกครั้ง — ซึ่งก็มีข้อดี เพราะคาดเดาได้ 100%: อ่านตาราง bin/rails routes จากบนลงล่าง แถวแรกที่เข้าเงื่อนไขคือคำตอบเสมอ

เทียบกับ Go: นี่คือจุดที่คนมาจาก Gin โดนแน่นอน · ใน บทเรียน 38 เราลง /users/:id กับ /users/me แล้วพบว่า Gin เลือกตัวที่เจาะจงกว่าให้เอง ไม่สนว่าลงอันไหนก่อน · Rails ไม่ทำแบบนั้น บรรทัดที่มาก่อนชนะเสมอ

🗣 ศัพท์ที่เพิ่งเรียนไป
route order / first match wins
router เลือกบรรทัดแรกที่เข้าเงื่อนไข · กฎจำง่าย: เจาะจงไว้บน กว้างไว้ล่าง · เจอ URL ไปผิดที่ ให้ถาม AI ว่า “why does /posts/popular hit posts#show in Rails” — คำตอบจะพามาที่ลำดับบรรทัด
shadowed route
route ที่ถูกบรรทัดข้างบน “บัง” จนไม่มีวันถูกเรียก (แบบ popular ใน predict) · ถาม senior ได้ว่า “route นี้โดน :id ข้างบนบังอยู่หรือเปล่าครับ”

namespace — แยกโซนหลังร้าน (แค่รู้จักไว้ก่อน)

พอแอปโตจะมีหน้า “หลังบ้าน” สำหรับแอดมิน ที่อยากแยกออกจากหน้าคนทั่วไปให้ชัด · namespace ครอบรายการกลุ่มหนึ่งแล้วเติมคำนำหน้าให้ทั้ง URL ทั้งแผนกที่รับ ทั้งชื่อ helper:

Rails.application.routes.draw do
  namespace :admin do
    get "/posts", to: "posts#index"
  end
end
$ bin/rails routes -c posts
     Prefix Verb URI Pattern            Controller#Action
admin_posts GET  /admin/posts(.:format) admin/posts#index

ญาติของมันคือ scope "/api" ที่เติมแค่คำหน้า URL (ได้ /api/posts แต่ยังไปแผนก posts ตัวเดิม) · วันนี้รู้จักชื่อไว้พอ

🚪 เทียบกับร้านอาหาร

namespace :admin คือโซนหลังร้าน · ทุกห้องในโซนนี้มีป้ายขึ้นต้นว่า “admin” · มีหัวหน้าเชฟชุดของตัวเอง (Admin::PostsController) แยกจากเชฟที่ทำอาหารให้ลูกค้าหน้าร้าน (PostsController)

ที่ไม่เหมือนกัน: หลังร้านจริงมีประตูล็อก คนนอกเดินเข้าไม่ได้ · namespace แค่จัดป้ายกับที่นั่ง ไม่ได้ล็อกอะไรเลย ใครพิมพ์ /admin/posts ก็เข้าถึงได้ · การล็อก (ต้อง login และมีสิทธิ์) เป็นเรื่องของ Day 4

🗣 ศัพท์ที่เพิ่งเรียนไป
namespace
จัดกลุ่ม route ที่เปลี่ยนทั้ง URL · controller · ชื่อ helper พร้อมกัน · ใช้กับหลังบ้าน · พิมพ์บอก AI ได้ว่า “put these routes under an admin namespace and create Admin::PostsController”
scope
จัดกลุ่มแบบเลือกเปลี่ยนบางอย่าง เช่น scope "/api" เปลี่ยนแค่ URL · คล้าย r.Group("/api") ของ Gin · ถาม senior ได้ว่า “API ของเราแยกด้วย namespace หรือแค่ scope ครับ”

ภารกิจ — เพิ่มหน้า “บทความยอดนิยม” ให้ถูกที่

🎯 ทำในโปรเจกต์ wongnok_blog ของคุณ (~10 นาที)

  1. ตั้งต้นให้เหมือนหัวข้อ “ลองของจริง”: app/controllers/posts_controller.rb มี index กับ show แบบแรก (ถ้าลองแก้ index ให้พิมพ์ post_url ไว้ ให้แก้กลับ) · และ config/routes.rb มีสามบรรทัด /posts · /posts/:id · root (ถ้าลอง resources หรือ namespace ไว้ ให้ลบออกก่อน) · แล้วเพิ่ม เมนูใหม่เข้าไปใน class controller (ก่อน end ตัวสุดท้าย):
      def popular
        render plain: "แผนก posts · เมนู popular (บทความยอดนิยม)\n"
      end
  2. ใน config/routes.rb ใส่ get "/posts/popular", to: "posts#popular" ไว้ใต้บรรทัด /posts/:id (ตั้งใจวางผิดก่อน)
  3. เปิดร้าน bin/rails server ทิ้งไว้ในหน้าต่างหนึ่ง แล้วอีกหน้าต่างสั่ง curl localhost:3000/posts/popular — ควรเห็น เมนู show · params[:id] = "popular"
  4. ย้ายบรรทัด popular ขึ้นไปไว้เหนือ /posts/:id แล้ว save (ไม่ต้องปิดร้าน Rails โหลดไฟล์ใหม่ให้เอง) · curl ซ้ำ — ควรเห็น เมนู popular · ลอง /posts/42 ต้องยังเข้า show
  5. สั่ง bin/rails routes -c posts แล้วหาว่า route ใหม่ได้ชื่ออะไรในคอลัมน์ Prefix

ถ้าเจอ error นี้ แปลว่า…

# config/routes.rb (ฉบับถูก)
Rails.application.routes.draw do
  get "/posts",         to: "posts#index"
  get "/posts/popular", to: "posts#popular"
  get "/posts/:id",     to: "posts#show"
  root "posts#index"
end

ผลรันจริง:

$ bin/rails routes -c posts
       Prefix Verb URI Pattern              Controller#Action
        posts GET  /posts(.:format)         posts#index
posts_popular GET  /posts/popular(.:format) posts#popular
              GET  /posts/:id(.:format)     posts#show
         root GET  /                        posts#index

$ curl localhost:3000/posts/popular
แผนก posts · เมนู popular (บทความยอดนิยม)
$ curl localhost:3000/posts/42
แผนก posts · เมนู show · params[:id] = "42"
$ curl localhost:3000/posts
แผนก posts · เมนู index (ดูบทความทั้งหมด)

ข้อ 5: Rails ตั้งชื่อให้เองว่า posts_popular (เอาคำใน path มาต่อด้วย _ เพราะ path นี้ไม่มีช่อง :) · จึงสร้างลิงก์ได้ด้วย posts_popular_path ทันที · และตารางเรียงตามลำดับในไฟล์เป๊ะ — popular อยู่เหนือ :id แล้ว

ลองตอบดู

ใน get "/posts/:id", to: "posts#show" ข้อความ "posts#show" บอกอะไร?

ตัวเลือกที่ 3 ถูก · ซ้ายของ # คือแผนก (posts → PostsController) ขวาคือเมนู (method show) · ตัวเลือกที่ 1 ผิดเพราะ route ไม่ได้ชี้ไปที่ไฟล์ HTML · ตัวเลือกที่ 2 ผิดเพราะ router ไม่แตะฐานข้อมูลเลย หน้าที่ของมันแค่ชี้ทาง · ตัวเลือกที่ 4 ผิดเพราะ route ไม่ได้ส่ง browser ไปไหน มันส่งใบสั่งต่อให้ controller ภายในร้าน
จุดที่ต้องจำ: "แผนก#เมนู" = "controller#action"

route คือ get "/posts/:id", to: "posts#show" แล้วมีใบสั่ง GET /posts/42 เข้ามา · ใน show ค่า params[:id] คืออะไร?

ตัวเลือกที่ 1 ถูก · ผล curl จริงคือ params[:id] = "42" มีเครื่องหมายคำพูด · ตัวเลือกที่ 2 ผิดเพราะ router ไม่เดาชนิดให้ (/posts/abc ก็ได้ "abc") · ตัวเลือกที่ 3 ผิดเพราะ params แค่อ่านจาก URL ไม่เกี่ยวกับฐานข้อมูล · ตัวเลือกที่ 4 ผิดเพราะ : ใน path แปลว่า “ช่องว่างรอเติม” ไม่ได้ทำให้ค่ากลายเป็น symbol
จุดที่ต้องจำ: ค่าจาก URL เป็น String เสมอ · จะคำนวณเองต้อง .to_i

routes.rb เขียน get "/posts/:id" ไว้ก่อน get "/posts/popular" · ยิง /posts/popular จะได้อะไร?

ตัวเลือกที่ 4 ถูก · ผล curl จริงคือ เมนู show · params[:id] = "popular" · ตัวเลือกที่ 1 คือพฤติกรรมของ Gin (บท 38) ไม่ใช่ Rails · ตัวเลือกที่ 2 ผิดเพราะร้านเปิดได้ปกติ ไม่มีอะไรเตือน (ที่ Rails ฟ้องคือชื่อซ้ำ เช่น root สองบรรทัด ซึ่งเป็นคนละเรื่องกัน) · ตัวเลือกที่ 3 ผิดเพราะ router ไม่เคยสับสน มันหยุดที่บรรทัดแรกที่เข้าเงื่อนไข
จุดที่ต้องจำ: เจาะจงไว้บน กว้างไว้ล่าง

ทำไมควรเขียน post_path(42) แทนการพิมพ์ "/posts/42" ตรง ๆ?

ตัวเลือกที่ 2 ถูก · helper ประกอบ URL จากรายการใน routes.rb ทุกครั้ง แก้ที่เดียวจบ · ตัวเลือกที่ 1 ผิดเพราะ URL ที่พิมพ์เองก็เปิดได้ปกติ ปัญหาคือตอนต้องเปลี่ยน · ตัวเลือกที่ 3 ผิดเพราะ helper ไม่แตะฐานข้อมูล — ผมเรียก post_path(42) ในแอปที่ยังไม่มีตารางบทความเลยก็ได้ "/posts/42" · ตัวเลือกที่ 4 ผิดเพราะผลลัพธ์เป็นข้อความเดียวกัน browser ไม่รู้ด้วยซ้ำว่ามาจาก helper
จุดที่ต้องจำ: ลิงก์ในแอปใช้ ชื่อ_path เสมอ · route ที่มี :id ต้องตั้งชื่อด้วย as: หรือใช้ resources

ถ้าเปิดสไลด์ดูประกอบ

สไลด์หน้า 54–59 ถูกเป็นส่วนใหญ่ · มีไม่กี่จุดที่ถ้าพิมพ์ตามแล้วจะเห็นไม่ตรงกับสไลด์:

หน้าสไลด์เขียนของจริงต้องสนใจไหม
58ตาราง bin/rails routes 6 แถว resources :posts ได้ 8 แถว — สไลด์ไม่มี edit_post GET /posts/:id/edit กับ PUT /posts/:id เห็น 8 แถวไม่ได้ทำอะไรผิด
54 → 58ตารางหน้า 58 วางต่อจากโค้ดหน้า 54 โค้ดหน้า 54 ได้ชื่อ posts_new (ไม่ใช่ new_post) และ /posts/:id ไม่มีชื่อ · ตารางหน้า 58 มาจาก resources :posts สนใจ — อยากได้ new_post_path / post_path ต้องใช้ resources หรือ as:
56post_url(42) # => "http://localhost:3000/posts/42" ได้ค่านี้เฉพาะใน controller/view ตอนมีใบสั่งเข้ามาที่พอร์ต 3000 · เรียกใน bin/rails runner ได้ ArgumentError: Missing host to link to! ยังไม่ต้อง
56link_to "อ่านต่อ", @post · redirect_to @post “ย่อได้” ย่อได้จริง แต่ต้องมี model Post (Day 2) และ route ชื่อ post รอ Day 2
57scope module: :admin do … end เว้นว่างไว้ ใส่ get "/posts", to: "posts#index" ข้างในแล้วได้ URL /posts เดิม แต่ไปแผนก admin/posts ยังไม่ต้อง

ติดตรงไหน หรือรันแล้วได้ไม่ตรงกับในบท เวลาถาม AI หรือเพื่อน ให้แปะ config/routes.rb กับผล bin/rails routes -c posts ไปด้วย จะได้คำตอบตรงจุดที่สุด

อ้างอิง