🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้ทั้งหมด
- route (บท 101) — กฎใน
config/routes.rbที่บอกว่า URL นี้ไปหาใคร ·"greetings#index"อ่านว่า "แผนก greetings เมนู index" class A < B(บท 95) — A สืบทอดจาก B คือได้ความสามารถทุกอย่างของ B มาใช้ฟรี@ตัวแปร(บท 95) — ตัวแปรที่ติดอยู่กับ object ไม่หายไปตอน method จบ.presence(บท 94) — ถ้าค่าว่างหรือมีแต่ช่องว่าง ให้กลายเป็นnil· ถ้ามีเนื้อหา ได้ค่าเดิมคืน- กฎตั้งชื่อ (บท 99) —
greetings↔GreetingsController↔ โฟลเดอร์app/views/greetings/
🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 (สไลด์ใช้ 4.0.6 · 8.1.3 — เรื่องในบทนี้ไม่ต่างกัน) ·
แอปสร้างด้วย rails new wongnok_blog --database=postgresql --css=tailwind ·
ผลรันทุกกล่องมาจากเครื่องจริง · เวลาใน log ของคุณจะไม่ตรงกับของผม
1 · Controller คือใคร ทำไมต้องมี
เวลามีคนเปิด /hello ต้องมีใครสักคนตัดสินใจว่าจะตอบอะไรกลับไป
เช่น ดูว่าผู้ใช้ส่งชื่อมาไหม แล้วเตรียมข้อความ "Hello ชื่อนั้น" ไว้
ใน Rails คนคนนั้นคือ controller — เป็น class หนึ่งตัวที่รวม "งานที่ทำได้" ของเรื่องเดียวกันไว้ด้วยกัน
งานแต่ละอย่างข้างใน class เรียกว่า action — เขียนเป็น method หนึ่งตัว (def index · def show …)
หนึ่ง action ≈ หนึ่งหน้าเว็บหรือหนึ่งงาน เช่น "หน้ารายการบทความ" หรือ "หน้าบทความชิ้นเดียว"
เทียบกับ Go: ใน Gin คุณเขียน handler func(c *gin.Context) { ... } ทีละตัว แล้วผูกกับ URL เอง ·
Rails เอา handler ที่เป็นเรื่องเดียวกันมาเป็น method ใน class เดียวกัน แล้วตั้งชื่อ class ตามกฎ เพื่อให้ Rails หาเจอเอง
ร้านอาหาร Rails แบ่งครัวเป็นแผนก · พนักงานต้อนรับ (routes) ดูใบสั่งแล้วส่งไปแผนกที่ถูก ·
หัวหน้าเชฟของแผนก (controller) ดูว่าลูกค้าสั่งเมนูไหน (action) แล้วลงมือทำเมนูนั้น
แผนก greetings มีเมนูเดียวคือ index · ถ้าวันหลังมีแผนกบทความ ก็จะมี PostsController ที่มีหลายเมนู (index · show · new …)
ที่ไม่เหมือนกัน: หัวหน้าเชฟจริงคนเดียวทำงานทั้งวันและจำลูกค้าประจำได้ · แต่ Rails สร้างหัวหน้าเชฟคนใหม่ทุกครั้งที่มีใบสั่งเข้ามา (สร้าง object ของ controller ใหม่ต่อหนึ่ง request) ทำจานเสร็จก็หายไป ใบสั่งใบถัดไปเจอเชฟคนใหม่ที่จำอะไรจากใบก่อนไม่ได้เลย · และหัวหน้าเชฟไม่ได้จัดจานเอง — ส่งของต่อให้คนจัดจาน (view) เสมอ
- controller
- class ที่รับ request ต่อจาก routes เตรียมข้อมูล แล้วตัดสินใจว่าจะตอบอะไร · ชื่อลงท้ายด้วย
Controllerเสมอ · ถาม senior ได้ว่า "ฟีเจอร์นี้ควรเพิ่ม action ใน controller เดิม หรือแยก controller ใหม่" - action
- method หนึ่งตัวใน controller ที่ route ชี้มาหาได้ (method ที่ไม่ได้ซ่อนไว้ใต้คำว่า
private— หัวข้อ 7) · เทียบได้กับ handler หนึ่งตัวใน Gin · ใน log ของ Rails จะเห็นเป็นGreetingsController#index· คุยกับทีมได้ว่า "request นี้วิ่งเข้า greetings#index" ทุกคนเข้าใจตรงกัน
2 · ไฟล์ที่ 1 — route: บอกพนักงานต้อนรับ
แผนกหลักของแอปบล็อก wongnok_blog คือแผนกบทความ (PostsController) แต่บทความต้องเก็บในฐานข้อมูล ซึ่งรอ Day 2 ·
วันนี้จึงฝึกกับแผนกเล็ก ๆ ชื่อ greetings (คำทักทาย · ตั้งชื่อเป็นพหูพจน์ตามกฎเดียวกับ posts) ไปก่อน
เป้าหมายคือหน้า /hello ที่ขึ้นว่า "Hello PEA" หรือ "Hello ชื่อที่ส่งมา" ·
ต้องเขียน 3 ไฟล์ เรียงตามทางที่ใบสั่งเดิน: route → controller → view
ไฟล์แรกคือกฎของพนักงานต้อนรับ (เปิดแอปที่ได้จาก rails new แล้วแก้ไฟล์นี้):
# config/routes.rb
Rails.application.routes.draw do
get "/hello", to: "greetings#index"
root "greetings#index"
end
Rails.application.routes.draw do … end— "นี่คือรายการกฎของพนักงานต้อนรับ" · กฎทุกข้ออยู่ระหว่างdoกับend(block — บท 93)get "/hello", to: "greetings#index"— "ถ้ามีคนขอดู (GET)/helloส่งไปแผนก greetings เมนู index" ·to:คือ hash ท้าย argument ที่ไม่ต้องใส่ปีกกา (บท 97)root "greetings#index"— หน้าแรกของเว็บ (/เปล่า ๆ) ก็ไปที่เดียวกัน
3 · ไฟล์ที่ 2 — controller: หัวหน้าเชฟแผนก greetings
ชื่อไฟล์ต้องตรงกฎตั้งชื่อ (บท 99): แผนก greetings → ไฟล์ greetings_controller.rb → class GreetingsController
สร้างไฟล์ใหม่ที่ app/controllers/greetings_controller.rb:
# app/controllers/greetings_controller.rb
class GreetingsController < ApplicationController
def index
@name = params[:name] || "PEA"
end
end
อ่านทีละบรรทัด:
| บรรทัด | แปลเป็นภาษาคน |
|---|---|
class GreetingsController < ApplicationController |
สร้าง class ชื่อ GreetingsController · เครื่องหมาย < แปลว่าสืบทอด (บท 95) จาก ApplicationController
ซึ่ง rails new สร้างไว้ให้แล้วที่ app/controllers/application_controller.rb ·
การสืบทอดนี้แหละที่ทำให้เราใช้ params · render · redirect_to ได้โดยไม่ต้องเขียนเอง |
def index |
เมนูชื่อ index (action) · ชื่อต้องตรงกับคำหลัง # ใน route |
@name = params[:name] || "PEA" |
อ่านชื่อจากใบสั่ง (params[:name] — หัวข้อ 5) · ถ้าไม่มี (nil) ใช้ "PEA" แทน (|| = "ถ้าซ้ายเป็น nil หรือ false ให้เอาขวา" — บท 94) ·
@ ข้างหน้า = วางไว้บนถาดให้คนจัดจานหยิบ (หัวข้อ 4) |
end · end |
ตัวแรกปิด def · ตัวที่สองปิด class |
⚠️ สไลด์หน้า 60 ตัด class GreetingsController กับ < ApplicationController ไว้คนละบรรทัด — พิมพ์ตามแล้วพังจริง ·
ผมตรวจด้วย ruby -c (ให้ Ruby ตรวจไวยากรณ์ของไฟล์โดยยังไม่รัน · คำ ruby: หน้าบรรทัดแรก ของจริงเป็นที่อยู่เต็มของ ruby ในเครื่อง ผมย่อไว้):
$ ruby -c app/controllers/greetings_controller.rb
ruby: app/controllers/greetings_controller.rb:2: syntax error found (SyntaxError)
1 | class GreetingsController
> 2 | < ApplicationController
| ^ unexpected '<', ignoring it
3 | def index
4 | @name = params[:name] ||
Ruby อ่าน class GreetingsController จบในบรรทัดเดียว เลยงงว่า < บรรทัดถัดไปเป็นของใคร (เปิดหน้าเว็บจะได้ error 500) ·
วิธีแก้: เขียน < ApplicationController ไว้บรรทัดเดียวกับ class เสมอ
เทียบกับ Go: handler ใน Gin รับ c *gin.Context เป็น argument แล้วเรียก c.Query(…) จากตัวนั้น ·
action ของ Rails ไม่มี argument เลย — params เป็น method ที่ได้มาจากการสืบทอด เรียกเฉย ๆ ได้ทุกที่ใน class
(นี่คือเหตุที่ params "โผล่มาเฉย ๆ" ได้ — ได้มาจากการสืบทอดแบบเดียวกับที่ Recipe ได้ greeting มาจาก Post ในบท 95)
4 · ไฟล์ที่ 3 — view แล้วเปิดร้านดู
ไฟล์สุดท้ายคือ "การจัดจาน" — HTML ที่มีช่องให้เติมค่า · สร้างโฟลเดอร์ app/views/greetings/ แล้วสร้างไฟล์ index.html.erb:
<%# app/views/greetings/index.html.erb %>
<h1>
Hello <%= @name %>
</h1>
- ทุกอย่างเป็น HTML ธรรมดา ยกเว้น
<%= @name %>ซึ่งแปลว่า "เอาค่าของ@nameมาใส่ตรงนี้" - ไฟล์ลงท้าย
.html.erb= HTML ที่มี Ruby แทรกได้ (ERB · ละเอียดใน บท 103) - บรรทัดแรก
<%# … %>เป็นแค่คอมเมนต์บอกชื่อไฟล์ ไม่ต้องพิมพ์ก็ได้
ครบ 3 ไฟล์แล้ว เปิดร้านด้วย bin/rails server (ค้างหน้าต่างนี้ไว้) แล้วเปิดอีกหน้าต่าง terminal มาทดสอบ ·
ผมใช้ curl (ขอหน้าเว็บจาก terminal แทน browser แบบบท 101) โดยเติมของสองอย่าง:
-s= เงียบ ไม่ต้องโชว์แถบความคืบหน้า| grep Hello= ส่งผลที่ได้ต่อให้grepซึ่งเก็บไว้แค่บรรทัดที่มีคำว่า Hello · เพราะหน้าเต็มมีของที่ Rails ห่อให้ทุกหน้ายาวมาก (เรียกว่า layout = จานรองแบบเดียวกันทุกจาน — บท 103)
$ curl -s http://localhost:3000/hello | grep Hello
Hello PEA
$ curl -s "http://localhost:3000/hello?name=Somchai" | grep Hello
Hello Somchai
$ curl -s http://localhost:3000/ | grep Hello
Hello PEA
ถ้าเปิดด้วย browser ที่ http://localhost:3000/hello จะเห็นคำว่า Hello PEA ·
/ ได้ผลเดียวกันเพราะ root ชี้ไปเมนูเดียวกัน
มีของน่าสงสัยสองอย่าง: (ก) @name เดินทางจาก controller ไป view ได้ยังไง ·
(ข) controller ไม่ได้สั่ง "ส่งหน้านี้" เลย แล้ว HTML มาจากไหน
(ก) @name คือถาดส่งของจากเชฟไปคนจัดจาน
ตัวแปรที่ขึ้นต้นด้วย @ ใน controller เป็นทางเดียวที่ view มองเห็น ·
จำจากบท 95 ได้ว่า @ตัวแปร ติดอยู่กับ object ไม่หายตอน method จบ — Rails จึงหยิบค่าของ @ตัวแปร ทุกตัวไปให้ view ได้ ·
ส่วนตัวแปรธรรมดา (ไม่มี @) หายไปทันทีที่ def index จบ
เทียบกับ Go: ใน Gin คุณต้องยื่นข้อมูลให้ template เองทีละชิ้น เช่น
c.HTML(http.StatusOK, "index.tmpl", gin.H{"name": name}) ·
Rails ยื่น @ตัวแปร ทุกตัวให้ view อัตโนมัติ ไม่ต้องเขียนบรรทัดนี้
หัวหน้าเชฟทำของเสร็จแล้ววางบนถาดหน้าครัว (@name) · คนจัดจาน (view) เดินมาหยิบจากถาดไปจัดใส่จาน ·
ของที่เชฟวางบนเขียงส่วนตัว (ตัวแปรธรรมดา name) คนจัดจานมองไม่เห็น
ที่ไม่เหมือนกัน: ในครัวจริง ถ้าถาดว่าง คนจัดจานจะตะโกนถาม · แต่ Rails ไม่ฟ้องเลย —
ผมลองลบ @ ออกเป็น name = params[:name] || "PEA" แล้วเปิด /hello?name=Somchai
ได้ status 200 กับคำว่า Hello เปล่า ๆ ไม่มี error สักบรรทัด เพราะ @name ที่ view ขอไม่เคยถูกตั้ง จึงเป็น nil ·
และถาดนี้ใช้ได้แค่ใบสั่งเดียว ใบสั่งถัดไปได้ถาดใหม่เปล่า ๆ
(ข) ไม่ต้องเขียน render เพราะ Rails เดาชื่อ view ให้
ถ้า action จบโดยไม่ได้สั่งว่าจะตอบอะไร Rails จะไปหา view ที่ชื่อตรงกับชื่อแผนกและชื่อเมนูเอง —
เหมือนเขียน render "greetings/index" ไว้บรรทัดสุดท้ายให้เราโดยอัตโนมัติ:
นี่คือ Convention over Configuration ที่บท 90 พูดถึง (ร้านแฟรนไชส์ที่ทุกสาขาวางของตำแหน่งเดียวกัน) · ตั้งชื่อถูก Rails หาเจอเอง · ชื่อผิดไฟล์เดียว ทั้งสายหาไม่เจอ
เทียบกับ Go: ใน Gin ทุกอย่างอยู่ในฟังก์ชันเดียว และคุณต้องสั่งตอบกลับเองทุกครั้ง ·
ผมเขียนตัวเทียบไว้ (ไฟล์เต็มมี package main + import และ go build ผ่าน · ข้างล่างตัดมาเฉพาะ handler):
Go (Gin) — handler ตัวเดียวทำทุกอย่าง
r.GET("/hello", func(c *gin.Context) {
name := c.Query("name")
if name == "" {
name = "PEA"
}
// ต้องสั่งตอบกลับเองเสมอ
c.String(http.StatusOK, "Hello %s", name)
})
Rails — แยกเป็น 3 ไฟล์ตามหน้าที่
# routes.rb — ใครรับ
get "/hello", to: "greetings#index"
# controller — เตรียมของ
def index
@name = params[:name] || "PEA"
end # ไม่ต้องสั่งตอบ
# view — จัดจาน
Hello <%= @name %>
ฝั่ง Go: c.Query("name") อ่านค่าหลัง ? · %s ใน c.String คือช่องที่ name จะถูกเติมลงไป (แบบ fmt.Sprintf) ·
ฝั่ง Rails: งานเดียวกันถูกแบ่งให้สามคน และไม่มีบรรทัด "ส่งคำตอบ" เลย
- instance variable (
@name) - ตัวแปรที่ขึ้นต้นด้วย
@· ใน controller คือทางส่งข้อมูลไปให้ view · พิมพ์บอก AI ได้ว่า "which instance variables does this view expect the controller to set?" เวลาหน้าเว็บโชว์ค่าว่างแล้วไม่รู้ว่าตัวแปรไหนหาย - implicit render
- action จบโดยไม่สั่ง
renderหรือredirect_to→ Rails เปิด view ชื่อapp/views/<แผนก>/<เมนู>.html.erbให้เอง · ถาม senior ได้ว่า "action นี้ render view ไหนอยู่ — ตามชื่อ หรือมีคนสั่ง render ตัวอื่นไว้"
5 · params — รายละเอียดในใบสั่ง
params คือกล่องที่เก็บทุกอย่างที่ผู้ใช้ส่งมากับ request · หยิบของด้วยชื่อ เช่น params[:name]
(:name คือ symbol — ป้ายชื่อแบบสั้น บท 92)
วันนี้เจอสองทางที่ค่ามากับ URL: หลังเครื่องหมาย ? (/hello?name=Somchai) กับ
เป็นท่อนหนึ่งของ path (/hello/Somchai)
ทางที่สองต้องบอกพนักงานต้อนรับก่อนว่าท่อนนั้นชื่ออะไร ด้วย :name ใน route (บท 101) · เพิ่มบรรทัดนี้ใน config/routes.rb:
get "/hello/:name", to: "greetings#index" # ส่งไปเมนูเดิม ไม่ต้องแก้ controller
$ curl -s http://localhost:3000/hello/Somchai | grep Hello
Hello Somchai
controller ไม่ได้แก้สักตัวอักษร แต่ใช้ได้ทันที · ดู log ของ server (หน้าต่างที่รัน bin/rails server) จะเห็นว่า
สองทางเข้าไปลงกล่องเดียวกันหน้าตาเหมือนกันเป๊ะ:
# log ตอนเปิด /hello?name=Somchai
Processing by GreetingsController#index as */*
Parameters: {"name" => "Somchai"}
# log ตอนเปิด /hello/Somchai
Processing by GreetingsController#index as */*
Parameters: {"name" => "Somchai"}
Processing by GreetingsController#index= ใบสั่งนี้เข้าแผนก greetings เมนู index ·as */*ท้ายบรรทัดคือ curl บอกว่ารับคำตอบได้ทุกแบบ — ข้ามไปได้Parameters: {"name" => "Somchai"}= ของในกล่อง params หน้าตาแบบ Hash (บท 92) ·=>อ่านว่า "คู่กับ"- ใน log ชื่อช่องเป็นข้อความ
"name"แต่เราหยิบด้วย symbol:nameได้ เพราะ params ยอมทั้งสองแบบ (ผมลองแล้วparams[:name]กับparams["name"]ได้"Somchai"เหมือนกัน · ส่วน Hash ธรรมดาของ Ruby{"name" => "Somchai"}[:name]ได้nil— อย่าเอานิสัยนี้ไปใช้กับ Hash ทั่วไป)
เทียบกับ Go: Gin แยกสองทางนี้เป็นสอง method — c.Query("name") สำหรับหลัง ? และ c.Param("name") สำหรับใน path ·
Rails เทรวมกล่องเดียว · และค่าจากสองทางนี้เป็นข้อความ (string) เสมอ — เปิด /hello/42 ได้ "42" ไม่ใช่ตัวเลข 42
ลูกค้าบอกรายละเอียดได้สองแบบ: เขียนในช่องหมายเหตุ ("ชื่อ Somchai" = ?name=Somchai)
หรือเขียนเป็นส่วนหนึ่งของชื่อเมนู ("Hello จาน Somchai" = /hello/Somchai) ·
หัวหน้าเชฟไม่ต้องสนใจว่าเขียนไว้ช่องไหน หยิบใบสั่งใบเดียวแล้วอ่านตามชื่อ params[:name]
ที่ไม่เหมือนกัน: ใบสั่งในร้านจริงพนักงานช่วยดูว่าสมเหตุสมผลไหม ·
แต่ params มาจากลูกค้าล้วน ๆ ไม่มีใครตรวจให้ — ใครจะพิมพ์อะไรต่อท้าย URL ก็ได้ ·
และทุกอย่างในกล่องเป็นแค่ข้อความ ถ้าอยากได้ตัวเลขต้องแปลงเอง (.to_i = แปลงข้อความเป็นเลขจำนวนเต็ม เช่น "42".to_i ได้ 42)
- params
- กล่องรวมค่าที่ผู้ใช้ส่งมากับ request ทุกทาง · ใช้เหมือน Hash (
params[:name]) · พิมพ์บอก AI ได้ว่า "read params[:page] as a positive integer, default 1" เวลาอยากได้โค้ดที่ไม่พังตอนผู้ใช้ส่งค่าแปลก ๆ - query string · path parameter
- query string = ส่วนหลัง
?(?name=Somchai) · path parameter = ท่อนใน path ที่ route ตั้งชื่อด้วย:name· เวลารายงานบั๊ก บอกทีมได้ว่า "id มาทาง path แต่ page มาทาง query string" คนฟังจะรู้ว่าต้องไปดูที่ routes หรือที่ลิงก์
# controller ยังเป็นแบบเดิม: @name = params[:name] || "PEA"
$ curl -s "http://localhost:3000/hello?name=" | grep Hello
# ↑ มี = แต่ไม่ได้ใส่ชื่อ
คิดว่าได้ Hello PEA หรืออะไร? (ใบ้: บท 94 — ใน Ruby อะไรบ้างที่นับว่า "เท็จ")
$ curl -s "http://localhost:3000/hello?name=" | grep Hello
Hello
# log
Parameters: {"name" => ""}
ได้ Hello เปล่า ๆ ไม่ใช่ PEA · ?name= ทำให้ params[:name] เป็น "" (ข้อความว่าง) ไม่ใช่ nil ·
และใน Ruby มีแค่ nil กับ false ที่นับว่าเท็จ — "" นับว่าจริง
ดังนั้น "" || "PEA" ได้ "" · ฝั่ง Go ไม่เจอปัญหานี้ในโค้ดข้างบน เพราะเราเช็ค name == "" ตรง ๆ
ทางแก้คือ .presence (บท 94) ที่เปลี่ยนค่าว่างให้เป็น nil ก่อนเข้า || — เป็นข้อแรกของภารกิจท้ายบท
6 · render กับ redirect_to — ยกจานให้เลย หรือส่งไปเคาน์เตอร์อื่น
ทุก action ต้องจบด้วยการตอบลูกค้าอย่างใดอย่างหนึ่งจากสองแบบนี้:
render— ทำหน้าเว็บแล้วส่งกลับไปใน request นี้เลย ·indexของเราทำแบบนี้อยู่แล้ว (Rails สั่งให้เองตามหัวข้อ 4)redirect_to— ไม่ส่งหน้าเว็บ ส่งแค่ "ที่อยู่ใหม่" กลับไป แล้ว browser ไปขอที่อยู่นั้นเองอีกรอบ
ลองทำหน้า /hi ที่ส่งต่อไป /hello · เพิ่ม route หนึ่งบรรทัด และ action หนึ่งตัว:
# config/routes.rb (เพิ่ม)
get "/hi", to: "greetings#hi"
# app/controllers/greetings_controller.rb (เพิ่มใต้ def index … end)
def hi
redirect_to "/hello"
end
ทีนี้เทียบสองหน้าด้วย curl -i · -i = โชว์ header ด้วย (ข้อมูลกำกับที่แปะมากับคำตอบ เหมือนป้ายหน้ากล่องพัสดุ) ·
บรรทัดแรกคือ status code — เลขบอกผลของคำตอบที่เคยเจอในฝั่ง Go และบท 100 (200 = สำเร็จ · 404 = หาไม่เจอ)
header มีหลายบรรทัด ผมใช้ grep -iE "^HTTP|^location|^content-length" เก็บไว้แค่บรรทัดที่ขึ้นต้น (^) ด้วยคำใดคำหนึ่งในสามคำนี้
(| ในเครื่องหมายคำพูด = "หรือ" · -E = เปิดให้ใช้ | แบบนี้ได้ · -i = ไม่สนตัวพิมพ์เล็กใหญ่):
$ curl -s -i http://localhost:3000/hello | grep -iE "^HTTP|^location|^content-length"
HTTP/1.1 200 OK
content-length: 2310
$ curl -s -i http://localhost:3000/hi | grep -iE "^HTTP|^location|^content-length"
HTTP/1.1 302 Found
location: http://localhost:3000/hello
content-length: 0
/hello→ 200 OK พร้อมหน้าเว็บยาว 2310 ไบต์ (render)/hi→ 302 Found ·content-length: 0คือไม่มีหน้าเว็บมาเลย · มีแค่location:บอกว่า "ไปที่ /hello นะ"curlไม่เดินตามเอง (ต้องใส่-L) · แต่ browser เดินตามทันทีจนคุณไม่ทันเห็น เห็นแค่ URL ในแถบที่อยู่เปลี่ยนเป็น/hello
$ curl -s -L http://localhost:3000/hi | grep Hello # -L = เดินตาม location แบบ browser
Hello PEA
# log ของ server — สอง request ต่อกัน
Processing by GreetingsController#hi as */*
Redirected to http://localhost:3000/hello
Completed 302 Found in 1ms
Processing by GreetingsController#index as */*
Completed 200 OK in 1ms
log ข้างบนตัดมาเฉพาะบรรทัดที่เกี่ยว · บรรทัด Completed … ของจริงมีวงเล็บต่อท้าย เช่น (Views: 0.5ms | ActiveRecord: … | GC: 0.0ms) ผมตัดออกให้อ่านง่าย
render = เชฟทำเสร็จแล้วยกจานให้ลูกค้าเลย · สั่งรอบเดียว ได้ของรอบเดียว
redirect_to = พนักงานบอกลูกค้าว่า "เมนูนี้ไปสั่งที่เคาน์เตอร์ 2 นะคะ" ·
ลูกค้าเดินไปเขียนใบสั่งใหม่ที่เคาน์เตอร์ 2 เอง (request ที่สอง) แล้วได้จานจากที่นั่น
ที่ไม่เหมือนกัน: ในร้านจริงเคาน์เตอร์ 2 อาจได้ยินว่าเคาน์เตอร์แรกคุยอะไรกับลูกค้า ·
แต่ใน Rails ใบสั่งใบที่สองคือ request ใหม่ล้วน ๆ — @ตัวแปร ที่ตั้งไว้ใน action แรกหายหมด ·
ถ้าอยากส่งอะไรข้ามไป ต้องใส่ไว้ในที่อยู่ใหม่ เช่น redirect_to "/hello?name=Rails" ·
และลูกค้า (browser) เดินไปเองอัตโนมัติ ผู้ใช้ไม่ต้องกดอะไรเลย
render (ยกจาน) | redirect_to (ชี้ไปเคาน์เตอร์อื่น) | |
|---|---|---|
| จำนวน request | 1 รอบ จบ | 2 รอบ (browser ยิงรอบสองเอง) |
| status ที่เห็นใน curl | 200 + หน้าเว็บ | 302 + location: ไม่มีหน้าเว็บ |
| URL บนแถบที่อยู่ | ไม่เปลี่ยน | เปลี่ยนเป็นที่อยู่ใหม่ |
@ตัวแปร | ใช้ต่อใน view ได้ | หายหมด |
| ใช้ตอนไหน (สไลด์หน้า 62) | กรอกฟอร์มไม่ผ่าน → โชว์ฟอร์มเดิมให้แก้ | ทำงานสำเร็จ → พาไปหน้าผลลัพธ์ |
แถวสุดท้ายคือโค้ดตัวอย่างในสไลด์หน้า 62 — หน้า "บันทึกบทความใหม่" ของแอปบล็อก ·
Post (ตัวใหญ่ เอกพจน์) คือแม่พิมพ์ของบทความหนึ่งชิ้นที่เก็บในฐานข้อมูล ซึ่งรอเรียน Day 2 จึงยังรันไม่ได้ ·
วันนี้อ่านเป็นภาษาคนพอ ดูแค่ว่าตรงไหน redirect ตรงไหน render:
def create
@post = Post.new(post_params) # สร้างบทความใหม่จากที่ผู้ใช้กรอก (Day 2)
if @post.save # บันทึกลงฐานข้อมูลได้ไหม
redirect_to @post, notice: "สร้างแล้ว" # ได้ → พาไปหน้าบทความนั้น
else
render :new, status: :unprocessable_entity # ไม่ได้ → โชว์ฟอร์มเดิม
end
end
post_params= method ช่วยที่หยิบเฉพาะช่องที่อนุญาตจากฟอร์ม (เขียนจริงใน Day 2) ·redirect_to @post= ส่งบทความไปทั้งชิ้น แล้ว Rails คิด URL ของบทความนั้นให้เอง (เช่น/posts/42)- สำเร็จ → redirect เพราะถ้าผู้ใช้กด refresh หน้าปลายทาง browser แค่ขอดูหน้าบทความซ้ำ ไม่ได้ส่งฟอร์มซ้ำจนได้บทความสองชิ้น
- ไม่ผ่าน → render เพราะ
@postยังอยู่ ช่องในฟอร์มจึงมีของที่ผู้ใช้พิมพ์ไว้ ไม่ต้องพิมพ์ใหม่ทั้งหมด ·render :newแปลว่า "ใช้ไฟล์ view ชื่อ new" (ไม่ได้รันdef new) ·status: :unprocessable_entityคือชื่อเล่นของเลข 422 ("ข้อมูลที่ส่งมาใช้ไม่ได้") · ชื่อนี้เป็นชื่อเก่าที่สไลด์ใช้ — ตอนเขียนเองให้ใช้:unprocessable_contentตามตารางในบท 100 ได้ 422 เหมือนกัน notice: "สร้างแล้ว"คือข้อความสั้น ๆ ที่ฝากไปโชว์ในหน้าถัดไปครั้งเดียว (เรียกว่า flash — ใช้จริงใน Day 2)
เทียบกับ Go: ใน Gin ก็มีสองแบบเหมือนกัน — c.HTML(...) / c.String(...) คือ render ·
c.Redirect(http.StatusFound, "/hello") คือ redirect_to · ได้ 302 กับ header Location แบบเดียวกัน เพราะเป็นกติกาของ HTTP ไม่ใช่ของ Rails
- render
- ตอบกลับด้วยเนื้อหา (ส่วนใหญ่คือ view) ใน request นี้เลย · ถาม senior ได้ว่า "ตรงนี้ควร render พร้อม 422 หรือ redirect — ผู้ใช้ต้องเห็นข้อมูลที่กรอกไว้ไหม"
- redirect_to · 302 · Location
- ตอบ status 302 พร้อม header
Locationบอกที่อยู่ใหม่ แล้ว browser ไปขอเอง · พิมพ์บอก AI ได้ว่า "after a successful form submit, redirect instead of rendering (Post/Redirect/Get)" — PRG คือชื่อเรียกของท่า "สำเร็จแล้ว redirect" นี้
7 · before_action — งานที่ทำก่อนทุกเมนู (แค่ปูไว้)
สมมติแผนกบทความมีเมนู show (ดู) · edit (แก้) · update (บันทึกที่แก้) ·
ทั้งสามต้องเริ่มด้วยงานเดียวกันคือ "หาบทความตามเลข id" · เขียนซ้ำสามที่ก็ได้ แต่ Rails มีทางที่สั้นกว่า
before_action = "ก่อนเข้าเมนูพวกนี้ ให้เรียก method นี้ก่อนเสมอ" ·
นี่คือโค้ดจากสไลด์หน้า 63 (ใช้ Post แม่พิมพ์บทความ จึงยังรันไม่ได้จนกว่าจะถึง Day 2):
class PostsController < ApplicationController
before_action :set_post, only: %i[show edit update]
def show; end # ว่าง เพราะ @post ถูกเตรียมไว้แล้ว
private
def set_post
@post = Post.find(params[:id]) # หาบทความเลข id (Day 2)
end
end
before_action :set_post— "ก่อนเข้า action ให้เรียก method ชื่อset_post" (ส่งชื่อ method เป็น symbol)only: %i[show edit update]— "เฉพาะสามเมนูนี้" ·%i[…]คือวิธีเขียน array ของ symbol แบบสั้น: รันruby -e 'p %i[show edit update]'ได้[:show, :edit, :update]def show; end— action ว่างที่เขียนบรรทัดเดียว (;ใช้แทนการขึ้นบรรทัดใหม่) · ที่เหลือ implicit render ทำให้private— method ที่อยู่ข้างล่างคำนี้ไม่ใช่เมนู route ชี้มาหาไม่ได้ ใช้ได้แค่ภายใน class
เทียบกับ Go: คล้าย middleware ของ Gin ที่ผูกกับบาง route (บท 39) — ทำงานก่อน handler · ต่างกันที่ before_action อยู่ในแผนกเดียว ไม่ใช่ด่านหน้าครัวที่ทุกใบสั่งต้องผ่าน (middleware ของ Rails คือด่านแบบนั้น — บท 100)
แผนกบทความติดป้ายไว้ว่า "ก่อนทำเมนู ดู · แก้ · บันทึก ให้ไปหยิบแฟ้มบทความเลขที่ลูกค้าบอกจากตู้มาวางบนถาดก่อน" ·
หัวหน้าเชฟเลยไม่ต้องเขียนขั้นตอนหยิบแฟ้มซ้ำในทุกเมนู เมนู show จึงว่างได้ทั้งเมนู เพราะ @post อยู่บนถาดแล้ว
ที่ไม่เหมือนกัน: คนในครัวจริงดูออกเองว่าเมนูไหนไม่ต้องใช้แฟ้ม ·
แต่ Rails ทำตามป้ายเป๊ะ ๆ — มีรายชื่อใน only: ก็ทำเฉพาะเมนูพวกนั้น · ไม่ใส่ only: เลย = ทำก่อนทุกเมนูในแผนก
- before_action (callback / filter)
- method ที่ Rails เรียกให้ก่อน action ที่ระบุ · ใช้รวมงานที่ซ้ำกันหลายเมนู เช่น หาข้อมูล หรือเช็คสิทธิ์ · พิมพ์บอก AI ได้ว่า "extract the repeated lookup into a before_action limited to show, edit, update"
- private method
- method ใน controller ที่อยู่ใต้คำว่า
private· ไม่นับเป็น action · route เรียกไม่ได้ · ถาม senior ได้ว่า "method ช่วยแบบนี้ ทีมเราวางไว้ใต้ private ของ controller หรือแยกไปที่อื่น"
ภารกิจ — ซ่อมหน้า Hello และเพิ่มหน้า /start
🎯 ภารกิจ (~10 นาที)
ต่อจากโค้ดในบทนี้ (มี route /hello · /hello/:name · /hi · root แล้ว):
- แก้
def indexให้/hello?name=และ/hello?name=%20%20(%20คือวิธีเขียน "ช่องว่าง" ใน URL · อันนี้คือชื่อที่เป็นช่องว่างล้วน) ขึ้นHello PEA— ใช้.presence - เพิ่ม route
get "/start", to: "greetings#start"ในconfig/routes.rb - เพิ่ม action
startใน controller ที่redirect_toไป/hello?name=Rails - เปิด server ไว้ แล้วทดสอบในอีกหน้าต่าง:
curl -s "http://localhost:3000/hello?name=" | grep Hello curl -s -i http://localhost:3000/start | grep -iE "^HTTP|^location" curl -s -L http://localhost:3000/start | grep Hello
ควรเห็น: บรรทัดแรก Hello PEA · บรรทัดที่สอง 302 Found กับ location: …/hello?name=Rails · บรรทัดที่สาม Hello Rails
ถ้าเจอ error นี้ แปลว่า… (ข้อความจริงจาก log ของ server · เปิดด้วย browser จะเห็นเป็นหน้า error สีแดงที่มีหัวข้อตามวงเล็บ)
| เห็นอะไร | แปลว่า |
|---|---|
ActionController::RoutingError (No route matches [GET] "/start") · 404 (หน้า Routing Error) |
พนักงานต้อนรับไม่รู้จัก URL นี้ → ลืมเพิ่มบรรทัดใน routes.rb หรือพิมพ์ URL ไม่ตรง |
AbstractController::ActionNotFound (The action 'start' could not be found for GreetingsController) · 404 (หน้า Unknown action) |
route มีแล้ว แต่แผนกนี้ไม่มีเมนูชื่อนี้ → ลืม def start หรือชื่อใน controller ไม่ตรงกับคำหลัง # |
SyntaxError (…greetings_controller.rb:2: syntax error found · unexpected '<', ignoring it · 500 |
เขียน < ApplicationController แยกบรรทัดตามสไลด์ → ย้ายขึ้นไปบรรทัดเดียวกับ class |
# config/routes.rb
Rails.application.routes.draw do
get "/hello", to: "greetings#index"
get "/hello/:name", to: "greetings#index"
get "/hi", to: "greetings#hi"
get "/start", to: "greetings#start"
root "greetings#index"
end
# app/controllers/greetings_controller.rb
class GreetingsController < ApplicationController
def index
@name = params[:name].presence || "PEA"
end
def hi
redirect_to "/hello"
end
def start
redirect_to "/hello?name=Rails"
end
end
params[:name].presence—""กับ" "กลายเป็นnil· ไม่ส่ง name มาเลยก็ได้nilอยู่แล้ว (nil.presenceได้nil) →||จึงหยิบ"PEA"ได้ตามที่ตั้งใจstartส่งชื่อข้ามไปทาง URL เพราะ@ตัวแปรข้าม redirect ไม่ได้
ผลรันจริง:
$ curl -s "http://localhost:3000/hello?name=" | grep Hello
Hello PEA
$ curl -s "http://localhost:3000/hello?name=%20%20" | grep Hello
Hello PEA
$ curl -s "http://localhost:3000/hello?name=Somchai" | grep Hello
Hello Somchai
$ curl -s -i http://localhost:3000/start | grep -iE "^HTTP|^location|^content-length"
HTTP/1.1 302 Found
location: http://localhost:3000/hello?name=Rails
content-length: 0
$ curl -s -L http://localhost:3000/start | grep Hello
Hello Rails
ทดสอบความเข้าใจ
1. view เขียน Hello <%= @name %> แต่ controller เขียน name = params[:name] || "PEA" (ลืม @) · เปิด /hello?name=Somchai จะเห็นอะไร?
ตัวเลือกที่ 3 ถูก · ผมลองจริง ได้ status 200 กับคำว่า Hello เปล่า ๆ ไม่มี error · view เห็นแค่ของบน "ถาด @" · @name ที่ไม่เคยตั้งค่าคือ nil ซึ่งแสดงเป็นค่าว่าง
ตัวเลือกที่ 1 ผิด — name กับ @name เป็นคนละตัวกัน Rails ไม่จับคู่ให้ ·
ตัวเลือกที่ 2 ผิด — @ตัวแปร ที่ไม่มีค่าไม่ทำให้พัง แค่ได้ nil (นี่แหละที่อันตราย เพราะไม่มีอะไรเตือน) ·
ตัวเลือกที่ 4 ผิด — ค่า "PEA" ถูกเก็บไว้ใน name ตัวธรรมดาที่หายไปตอน action จบ ไม่ได้ไปถึง view
จุดที่ต้องจำ: ของที่จะส่งไป view ต้องขึ้นต้นด้วย @ · หน้าเว็บโชว์ค่าว่างแบบเงียบ ๆ ให้สงสัยว่าลืม @ ก่อน
2. def index ของ GreetingsController ไม่มีคำว่า render เลย · HTML ที่ได้มาจากไหน?
ตัวเลือกที่ 1 ถูก · ถ้า action จบโดยไม่สั่งตอบ Rails หา view ที่ชื่อตรงกับ controller + action (implicit render) — กฎตั้งชื่อจากบท 99
ตัวเลือกที่ 2 ผิด — Rails ไม่เดาสุ่ม ต้องชื่อตรงเท่านั้น · ตัวเลือกที่ 3 ผิด — route แค่ชี้ทาง ไม่ได้ทำหน้าเว็บ · ตัวเลือกที่ 4 ผิด — ค่าที่ action คืนออกมาไม่ถูกใช้เป็นหน้าเว็บ (ต่างจาก Ruby method ทั่วไปที่ค่าบรรทัดสุดท้ายคือค่าที่คืน — บท 93)
จุดที่ต้องจำ: ไม่มี render = Rails ใช้ app/views/<แผนก>/<เมนู>.html.erb · ชื่อไฟล์ผิดตัวเดียวก็หาไม่เจอ
3. controller เขียน @name = params[:name] || "PEA" · เปิด /hello?name= (มี = แต่ไม่ใส่ชื่อ) ได้ Hello เปล่า ๆ · ทำไม?
ตัวเลือกที่ 4 ถูก · log จริงขึ้นว่า Parameters: {"name" => ""} · ใน Ruby มีแค่ nil กับ false ที่เป็นเท็จ · "" || "PEA" จึงได้ "" · แก้ด้วย params[:name].presence || "PEA"
ตัวเลือกที่ 1 ผิด — log เห็นค่าชัดเจน แค่เป็นข้อความว่าง · ตัวเลือกที่ 2 ผิด — || ใช้ได้กับทุกค่า ·
ตัวเลือกที่ 3 ผิด — ถ้าเป็น nil จริง nil || "PEA" จะได้ "PEA" (นั่นคือกรณีไม่ส่ง name มาเลย)
จุดที่ต้องจำ: ค่าใน params อาจเป็นข้อความว่าง ไม่ใช่ nil · อยากให้ค่าว่างนับว่า "ไม่มี" ให้ใช้ .presence
4. action hi สั่ง redirect_to "/hello" · curl -i /hi ได้ 302 Found กับ location: …/hello · ถ้าเปิด /hi ด้วย browser จะเกิดอะไร?
ตัวเลือกที่ 2 ถูก · 302 คือ "ไปสั่งที่เคาน์เตอร์อื่น" · browser เดินตาม location ทันที (log เห็นสอง request ต่อกัน: #hi แล้ว #index) · เป็น request ใหม่ ของใน @ตัวแปร จึงไม่ข้ามไป
ตัวเลือกที่ 1 ผิด — content-length: 0 ไม่มีหน้าให้โชว์ และ browser ตามไปเองไม่ต้องกด ·
ตัวเลือกที่ 3 ผิด — นั่นคือพฤติกรรมของ render ไม่ใช่ redirect · ตัวเลือกที่ 4 ผิด — ไม่มีการเปิดแท็บใหม่ เกิดในแท็บเดิม
จุดที่ต้องจำ: render = 1 request ยกจานให้เลย · redirect_to = 2 request ส่งแค่ที่อยู่ใหม่ ของใน @ ไม่ข้ามไป
ถามกลับได้เลย — ถ้าลองแล้วได้ผลไม่ตรงกับในบทนี้ ให้ก็อปบรรทัดตั้งแต่ Processing by … ถึง Completed … ใน log ของ server มาถาม ·
ไม่กี่บรรทัดนั้นบอกเกือบทุกอย่าง: เข้าเมนูไหน · params เป็นอะไร · render หรือ redirect · จบด้วย status อะไร
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง | ต้องสนใจไหม |
|---|---|---|---|
| 60 | class GreetingsController กับ < ApplicationController คนละบรรทัด |
SyntaxError · เปิดหน้าเว็บได้ 500 | ใช่ — เขียนบรรทัดเดียวกัน |
| 60 | routes.rb ตัดบรรทัด (Rails.application / .routes.draw do) |
ใช้ได้ปกติ (ruby -c ขึ้น Syntax OK) | ไม่ต้อง |
| 61 | ตัวอย่าง form body (post[title]) และ JSON body ใน params |
ถ้าลองยิง POST ด้วย curl เปล่า ๆ จะได้ 422 เพราะไม่มีบัตรผ่านกันเว็บปลอม (CSRF token) | รู้ไว้ — อธิบายใน บท 103 |
| 62–63 | โค้ดใช้ Post · post_params · Post.find · notice: |
ต้องมีฐานข้อมูลก่อน ยังรันใน Day 1 ไม่ได้ | ไม่ต้อง — รอ Day 2 |
| 63 | only: %i[show edit update] แต่โชว์แค่ def show |
ถ้าเขียน action ไม่ครบตามรายชื่อใน only: ตอน development ทุก URL ของ controller นั้นได้ 404 (ActionNotFound … but it is listed in the controller's :only option) |
ใช่ — ใส่ใน only: เฉพาะ action ที่เขียนแล้ว |
อ้างอิง
- Rails Guides — Action Controller Overview — อ่านหัวข้อ Parameters (กล่อง params รวมค่าจาก query string และ path) และ before_action ในหมวด Controller Callbacks
- Rails Guides — Layouts and Rendering in Rails — อ่าน Rendering by Default: Convention Over Configuration in Action (เรื่องไม่ต้องเขียน render) และ The Difference Between render and redirect_to (ตารางในหัวข้อ 6)
- Rails Guides — Rails Routing from the Outside In —
ทวนเรื่อง
get … to:และ:nameใน path ถ้ายังไม่แน่นจากบท 101 - Rails API — ActionController::Redirecting —
ดู
redirect_toว่ารับอะไรได้บ้าง (ข้อความ URL · ชื่อ route · object) และ status ตั้งต้นคือ 302