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

Controller — หัวหน้าเชฟที่รับใบสั่ง, params, render กับ redirect

~20 นาที · ควรผ่าน บทเรียน 101 (Routing) มาก่อน · ใช้ของจาก บท 94 และ บท 95 ด้วย

บทที่แล้วเรามีพนักงานต้อนรับ (routes) ที่ชี้ว่าใบสั่งไหนไปแผนกไหนแล้ว แต่ยังไม่มีใครในครัวทำอาหารเลย
บทนี้คุณจะเขียนหน้าเว็บแรกของ Rails ด้วยมือครบ 3 ไฟล์ แล้วเปิดดูได้จริงที่ http://localhost:3000/hello
อ่านจบแล้วคุณจะรู้ว่า controller ทำอะไร · อ่านค่าที่ผู้ใช้ส่งมากับ URL (params) เป็น · และเลือกได้ว่าจะยกจานให้ลูกค้าเลย (render) หรือบอกลูกค้าให้ไปสั่งที่เคาน์เตอร์อื่น (redirect_to)

🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้ทั้งหมด

🧰 เวอร์ชันที่ใช้ตรวจ: 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 หาเจอเอง

ลูกค้า (browser) │ ใบสั่ง: GET /hello?name=Somchai ▼ พนักงานต้อนรับ (routes) "/hello ไปแผนก greetings เมนู index" ▼ หัวหน้าเชฟแผนก greetings GreetingsController → def index │ วางของไว้บนถาด: @name = "Somchai" ▼ คนจัดจาน (view) app/views/greetings/index.html.erb │ <h1>Hello Somchai</h1> ▼ ลูกค้าได้จาน (response = HTML)
🍳 เทียบให้เห็นภาพ — หัวหน้าเชฟของแต่ละแผนก

ร้านอาหาร 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

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>

ครบ 3 ไฟล์แล้ว เปิดร้านด้วย bin/rails server (ค้างหน้าต่างนี้ไว้) แล้วเปิดอีกหน้าต่าง terminal มาทดสอบ · ผมใช้ curl (ขอหน้าเว็บจาก terminal แทน browser แบบบท 101) โดยเติมของสองอย่าง:

$ 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" ไว้บรรทัดสุดท้ายให้เราโดยอัตโนมัติ:

"greetings#index" │ │ │ └─▶ เมนู index → def index → ไฟล์ view ชื่อ index.html.erb └──────────▶ แผนก greetings → GreetingsController → อยู่ในโฟลเดอร์ app/views/greetings/

นี่คือ 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"}

เทียบกับ 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 ต้องจบด้วยการตอบลูกค้าอย่างใดอย่างหนึ่งจากสองแบบนี้:

ลองทำหน้า /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
$ 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 (ชี้ไปเคาน์เตอร์อื่น)
จำนวน request1 รอบ จบ2 รอบ (browser ยิงรอบสองเอง)
status ที่เห็นใน curl200 + หน้าเว็บ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

เทียบกับ 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

เทียบกับ 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 แล้ว):

  1. แก้ def index ให้ /hello?name= และ /hello?name=%20%20 (%20 คือวิธีเขียน "ช่องว่าง" ใน URL · อันนี้คือชื่อที่เป็นช่องว่างล้วน) ขึ้น Hello PEA — ใช้ .presence
  2. เพิ่ม route get "/start", to: "greetings#start" ใน config/routes.rb
  3. เพิ่ม action start ใน controller ที่ redirect_to ไป /hello?name=Rails
  4. เปิด 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 อะไร

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

หน้าสไลด์เขียนของจริงต้องสนใจไหม
60class GreetingsController กับ < ApplicationController คนละบรรทัด SyntaxError · เปิดหน้าเว็บได้ 500ใช่ — เขียนบรรทัดเดียวกัน
60routes.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
63only: %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 ที่เขียนแล้ว

อ้างอิง