🔁 ทวนก่อนเริ่ม (คำจากบทก่อน ๆ ที่บทนี้ใช้)
- request = ใบสั่งอาหารที่ browser ส่งมาที่ร้าน · response = จานที่ยกกลับไป (บท 90)
- controller = หัวหน้าเชฟของแผนก เช่น
PostsControllerคือแผนกบทความ · action = เมนูแต่ละอย่างที่แผนกนั้นทำได้ (บท 100) - แอปตัวอย่างคือบล็อก · บทความหนึ่งชิ้นเรียกว่า
Post· หลายชิ้น/URL ใช้postsตัวเล็ก (บท 99) - Ruby ละวงเล็บตอนเรียก method ได้ และ
to: "..."ท้าย argument คือ hash ที่ไม่ต้องใส่ปีกกา (บท 97) do ... end= ก้อนโค้ด (block) ที่ส่งให้ method เอาไปใช้ (บท 93)curl= คำสั่งขอหน้าเว็บจาก terminal แทน browser ได้ผลกลับมาเป็นตัวหนังสือ (บท 99)
🧰 เครื่องที่ใช้ตรวจ: 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
ทีละบรรทัด:
Rails.application.routes.draw do ... end— “เปิดรายการของพนักงานต้อนรับ” · อ่านจุดทีละท่อน:Rails.application= แอปของเรา ·.routes= รายการของพนักงานต้อนรับ ·.draw= เริ่มเขียนรายการ · ทุกบรรทัดระหว่างdoกับendคือหนึ่งรายการ · บรรทัดนี้rails newเขียนให้แล้ว ไม่ต้องแตะget— เป็นการเรียก method ธรรมดา ที่ละวงเล็บไว้ · จะเขียนเต็ม ๆ ว่าget("/posts", to: "posts#index")ก็ได้ ผมลองแล้วผลเหมือนกันทุกอย่าง"/posts"— path ที่จะรับ เหมือนของ Gin เป๊ะto: "posts#index"— บอกว่าส่งไปหาใคร อ่านว่า “ส่งไปที่” · เป็น hash ท้าย argument แบบไม่ใส่ปีกกา (บท 97) · ข้อความ"posts#index"ผ่าครึ่งตรงเครื่องหมาย#: ซ้ายคือแผนกposts(=PostsControllerตามกฎตั้งชื่อในบท 99) · ขวาคือเมนูindex(= method ชื่อindexใน class นั้น)
# ที่อยู่ในเครื่องหมายคำพูด อย่าง "posts#index" เป็นแค่ตัวอักษรคั่นกลาง
ไม่ใช่คอมเมนต์ และไม่ใช่ #{} ที่แปะค่าลงข้อความ (บท 91) · # จะเป็นคอมเมนต์ก็ต่อเมื่ออยู่นอกเครื่องหมายคำพูด
| ชิ้น | Gin | Rails | ภาษาคน |
|---|---|---|---|
| verb | r.GET | get | ใบสั่งแบบ “ขอดู” |
| path | "/posts" | "/posts" | เรื่องที่ขอ |
| ส่งไปหาใคร | h.List (ส่งตัวฟังก์ชันเข้าไปเลย) | to: "posts#index" (ส่งแค่ชื่อเป็นข้อความ) | แผนก posts เมนู index |
อ่านค่าในช่อง :id | c.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=PostsControllermethodindex· คนทำ 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
class PostsController < ApplicationController— สร้าง class ชื่อPostsController·<แปลว่า “สืบทอดจาก” (บท 95) ·ApplicationControllerคือ class แม่ที่rails newเตรียมไว้ให้ (บท 99) จึงได้ความสามารถของ controller มาฟรีทั้งหมดdef index…end— เมนูชื่อindexหนึ่งเมนู · ชื่อนี้ต้องตรงกับขวามือของ#ใน routesrender plain: "..."— ตอบกลับเป็นข้อความเปล่า ๆ ยังไม่ต้องมีหน้า HTML (render ละเอียดในบท 102) ·\nคือขึ้นบรรทัดใหม่#{params[:id].inspect}—#{}คือเอาค่ามาแปะในข้อความ (บท 91) ·.inspectทำให้เห็นค่าพร้อมเครื่องหมายคำพูด ถ้าเป็นข้อความ — จะได้รู้ว่าเป็นข้อความหรือตัวเลข
แล้วใส่ 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 Pattern | path ที่รับ · (.: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 /posts | index | ดูบทความทั้งหมด | posts_path |
GET /posts/42 | show | ดูบทความชิ้นเดียว (เลข 42) | post_path(42) |
GET /posts/new | new | เปิดฟอร์มเขียนบทความใหม่ | new_post_path |
POST /posts | create | กดบันทึกบทความใหม่ | (ใช้ posts_path) |
GET /posts/42/edit | edit | เปิดฟอร์มแก้บทความ 42 | edit_post_path(42) |
PATCH /posts/42 | update | กดบันทึกที่แก้ | (ใช้ post_path(42)) |
PUT /posts/42 | update | บันทึกที่แก้ อีกวิธีเรียก — ไปเมนูเดียวกับ PATCH | (ใช้ post_path(42)) |
DELETE /posts/42 | destroy | ลบบทความ 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
- URL กลายเป็น
/admin/posts - แผนกที่รับคือ
admin/posts= classAdmin::PostsController·::อ่านว่า “ที่อยู่ข้างใน” —PostsControllerตัวที่อยู่ในกล่องชื่อAdmin(กล่องนี้คือ module จากบท 95) จึงไม่ชนกับPostsControllerตัวหน้าร้าน · Rails คาดว่าอยู่ในไฟล์app/controllers/admin/posts_controller.rb - helper ชื่อ
admin_posts_path(ผมเรียกแล้วได้"/admin/posts")
ญาติของมันคือ 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 นาที)
- ตั้งต้นให้เหมือนหัวข้อ “ลองของจริง”:
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 - ใน
config/routes.rbใส่get "/posts/popular", to: "posts#popular"ไว้ใต้บรรทัด/posts/:id(ตั้งใจวางผิดก่อน) - เปิดร้าน
bin/rails serverทิ้งไว้ในหน้าต่างหนึ่ง แล้วอีกหน้าต่างสั่งcurl localhost:3000/posts/popular— ควรเห็นเมนู show · params[:id] = "popular" - ย้ายบรรทัด popular ขึ้นไปไว้เหนือ
/posts/:idแล้ว save (ไม่ต้องปิดร้าน Rails โหลดไฟล์ใหม่ให้เอง) ·curlซ้ำ — ควรเห็นเมนู popular· ลอง/posts/42ต้องยังเข้า show - สั่ง
bin/rails routes -c postsแล้วหาว่า route ใหม่ได้ชื่ออะไรในคอลัมน์ Prefix
ถ้าเจอ error นี้ แปลว่า…
Unknown action·The action 'popular' could not be found for PostsController→ route ถูกแล้ว แต่ใน controller ยังไม่มีdef popularหรือพิมพ์ชื่อไม่ตรงกัน · แก้ controllerRouting Error·No route matchesตามด้วย verb กับ path ที่คุณยิง (หน้าตาแบบNo route matches [GET] "/comments"ที่เห็นในหัวข้อ “404 สองแบบ”) → ไม่มีบรรทัดไหนรับ path นี้เลย · เช็คว่าพิมพ์ path ในroutes.rbกับใน curl ตรงกันไหม และ save ไฟล์แล้วหรือยัง- หัวเรื่อง
SyntaxError(status 500) ข้อความsyntax errors foundพร้อมลูกศร^~ชี้ไปที่to:→ ลืมลูกน้ำหลัง path · ผมลองลบลูกน้ำในไฟล์ทดลองสั้น ๆ แล้วยิง ได้แบบนี้ (ตัดชื่อโฟลเดอร์ยาว ๆ ข้างหน้าออก · เลขบรรทัดในไฟล์ของคุณจะต่างไป):…/config/routes.rb:2: syntax errors found 1 | Rails.application.routes.draw do > 2 | get "/posts/popular" to: "posts#popular" | ^~ unexpected local variable or method, expecting end-of-input | ^ unexpected ':', ignoring it | ^ unexpected ':', expecting end-of-input 3 | end
# 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: |
| 56 | post_url(42) # => "http://localhost:3000/posts/42" |
ได้ค่านี้เฉพาะใน controller/view ตอนมีใบสั่งเข้ามาที่พอร์ต 3000 · เรียกใน bin/rails runner ได้ ArgumentError: Missing host to link to! |
ยังไม่ต้อง |
| 56 | link_to "อ่านต่อ", @post · redirect_to @post “ย่อได้” |
ย่อได้จริง แต่ต้องมี model Post (Day 2) และ route ชื่อ post |
รอ Day 2 |
| 57 | scope module: :admin do … end เว้นว่างไว้ |
ใส่ get "/posts", to: "posts#index" ข้างในแล้วได้ URL /posts เดิม แต่ไปแผนก admin/posts |
ยังไม่ต้อง |
ติดตรงไหน หรือรันแล้วได้ไม่ตรงกับในบท เวลาถาม AI หรือเพื่อน ให้แปะ config/routes.rb กับผล
bin/rails routes -c posts ไปด้วย จะได้คำตอบตรงจุดที่สุด
อ้างอิง
- Rails Guides — Rails Routing from the Outside In —
อ่านหัวข้อ 3.2 Dynamic Segments · 3.6 Naming Routes · 3.15 Using root
(สั้นทุกหัวข้อ ตรงกับ
:id·as:·rootในบทนี้) · แล้วอ่าน 2.2 CRUD, Verbs, and Actions ย่อหน้าที่ขึ้นต้นว่า Order matters in the routes.rb file ซึ่งคือคำยืนยันทางการของหัวข้อ “ลำดับสำคัญ” - Rails Guides — Rails Routing (หน้าเดียวกัน) —
อ่านหัวข้อ 5.1 Listing Existing Routes กับ 5.2 Searching Routes
เรื่องคอลัมน์ของ
bin/rails routesและการกรองด้วย-c/-g· ส่วน 2.6 Controller Namespaces and Routing มีตาราง namespace / scope ให้ดูถ้าอยากรู้ต่อ - Rails API — ActionDispatch::Routing::Mapper::Resources —
ดู method
resourcesตรงตัวอย่างที่ไล่รายการ route มาตรฐานทั้งหมดที่บรรทัดเดียวสร้างให้ (ชุดเดียวกับตาราง 8 แถวในบทนี้) - Rails API — ActionDispatch::Routing::Mapper::Scoping —
ดู method
namespaceกับscopeถ้าอยากรู้ว่าสองตัวนี้ต่างกันตรงไหน (ไม่บังคับสำหรับวันนี้)