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

Lab 1 — สร้างแอปเองตั้งแต่ศูนย์ แล้วเพิ่มหน้า /about

~20 นาทีอ่าน + ลงมือทำตามอีก ~45 นาที · ควรผ่าน บท 101 (routes) · บท 102 (controller) · บท 103 (view) มาก่อน · ถ้ามีเวลาอ่าน บท 98 (rails new) ด้วยจะดีมาก

บทนี้เป็น Lab (บทลงมือทำตามทีละขั้น) ไม่มี syntax ใหม่ให้จำ — เราจะเปิดร้านของตัวเองหนึ่งร้าน ตั้งแต่ไม่มีอะไรเลย แล้วเพิ่มหน้าเว็บสองหน้า: /hello (ทำไปพร้อมกัน) กับ /about (ทำเอง)
จากนั้นเราจะทำให้มันพังโดยตั้งใจ 4 แบบ แล้วฝึกอ่านหน้าจอสีแดงที่ Rails โชว์
อ่านจบแล้วคุณจะทำหน้าเว็บใหม่ใน Rails ได้เอง และพอเจอหน้าสีแดง จะรู้ว่าต้องไปแก้ไฟล์ไหน แทนที่จะนั่งเดา

🔁 ทวนก่อนเริ่ม — บทนี้ใช้คำพวกนี้ตลอด ถ้าข้อไหนไม่คุ้นให้กลับไปบทนั้นก่อน

🍜 แอปตัวอย่าง: wongnok_blog คือแอปบล็อก (เว็บเขียนบทความ) แบบในสไลด์ · ของหลักคือบทความ — ภาษาอังกฤษ post (Post = บทความหนึ่งชิ้น · posts = หลายชิ้น) · วันนี้เรายังไม่แตะบทความ เพราะต้องมี database ก่อน (รอ Day 2) — ทำแค่หน้าทักทายกับหน้าแนะนำร้าน

🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 17 (สไลด์ใช้ Ruby 4.0.6 · Rails 8.1.3 — เรื่องในบทนี้ไม่ต่างกัน)

ผลรันทุกก้อนในบทนี้รันจริงแล้วก็อปมา · ค่าที่เปลี่ยนทุกครั้ง เช่น PID เวลา และรหัส commit ของคุณจะไม่ตรงกับของผม — เป็นเรื่องปกติ · path ยาว ๆ ของโฟลเดอร์ทดลองในเครื่องผมย่อเป็น …

แผนที่ของ Lab: ใบสั่งหนึ่งใบเดินผ่านสามคน

ทุกหน้าเว็บที่เราจะทำวันนี้ ใช้ไฟล์ 3 ไฟล์เสมอ: route · controller · view
พอผู้ใช้เปิด /hello ใบสั่งจะเดินผ่านสามไฟล์นี้ตามลำดับ แล้วได้ HTML กลับไป

ลูกค้า (browser) เปิด http://localhost:3000/hello │ ใบสั่ง: "ขอหน้า /hello" ▼ ① config/routes.rb ← พนักงานต้อนรับ get "/hello", to: "greetings#index" │ "ส่งไปแผนก greetings · เมนู index" ▼ ② app/controllers/greetings_controller.rb ← หัวหน้าเชฟแผนก greetings def index @name = "PEA" ← วางวัตถุดิบไว้บนถาด │ ▼ ③ app/views/greetings/index.html.erb ← คนจัดจาน <h1>Hello <%= @name %></h1> │ ▼ จานอาหาร (HTML) กลับไปที่ลูกค้า

สังเกตว่าสามไฟล์นี้ไม่ได้เรียกกันด้วยโค้ด — มันเชื่อมกันด้วยชื่อล้วน ๆ
คำว่า greetings ใน route ทำให้ Rails ไปหา class GreetingsController ในไฟล์ greetings_controller.rb · คำว่า index ทำให้ Rails ไปหา view ชื่อ index.html.erb ในโฟลเดอร์ greetings/ (กฎตั้งชื่อชุดนี้อยู่ใน บท 99)
greetings แปลว่า "คำทักทาย" — เป็นชื่อแผนกที่เราตั้งเอง (ใช้รูปพหูพจน์ตามกฎของ Rails) ไม่ใช่คำพิเศษ

ใน Go ที่เคยเรียน คุณส่งตัวฟังก์ชันเข้าไปตรง ๆ:

// Go + Gin — ส่งตัวฟังก์ชัน helloHandler เข้าไปเลย
r.GET("/hello", helloHandler)

ถ้าพิมพ์ helloHandler ผิด Go ไม่ยอม build ตั้งแต่ก่อนรัน · แต่ใน Rails สิ่งที่ส่งคือข้อความ "greetings#index" — สะกดผิดก็ไม่มีใครฟ้อง จนกว่าจะมีคนเปิดหน้านั้นจริง ๆ · นั่นคือเหตุผลที่ครึ่งหลังของ Lab ให้ฝึกอ่านหน้า error

🍽 เทียบให้เห็นภาพ — ร้านแฟรนไชส์ที่ส่งงานกันด้วยป้ายชื่อ

พนักงานต้อนรับ (route) ไม่ได้เดินพาใบสั่งไปส่งเชฟด้วยมือ เขาแค่เขียนชื่อแผนกกับชื่อเมนูลงใบสั่ง แล้ววางไว้
ร้านแฟรนไชส์มีกฎว่าแผนก "greetings" ต้องนั่งที่โต๊ะป้าย greetings_controller.rb และเมนู "index" ต้องจัดจานตามแบบในแฟ้ม greetings/index.html.erb
ทุกคนทำตามกฎตำแหน่งเดียวกัน ใบสั่งจึงถึงมือคนที่ถูกเสมอ — โดยไม่มีใครต้องบอกทาง

ที่ไม่เหมือนกัน: พนักงานจริงเห็นใบสั่งเขียนว่า "แผนก page" (ขาด s) ก็คงเดาออกว่าหมายถึง "pages" แล้วส่งให้ถูกแผนก
Rails ไม่เดาให้ — ชื่อต้องตรงเป๊ะทุกตัวอักษร ไม่งั้นได้หน้าแดงทันที (เราจะทำพังแบบนี้ให้ดูในหัวข้อ "พังแบบที่ 1")

🗣 ศัพท์ของภาพรวม
convention over configuration
แนวคิดของ Rails ที่ว่า "ตั้งชื่อตามกฎ แล้วไม่ต้องเขียนบอกว่าอะไรอยู่ที่ไหน" · ถาม senior ได้ว่า "ตรงนี้ Rails หาไฟล์เจอได้ยังไง ใช้ convention ข้อไหน" — คำถามนี้ช่วยให้เข้าใจโค้ดที่ดูเหมือน "เกิดขึ้นเอง"
"controller#action"
รูปแบบที่ route ใช้ชี้ไปหางาน · ซ้ายของ # คือชื่อ controller (ตัวเล็ก พหูพจน์ ไม่มีคำว่า Controller) · ขวาคือชื่อ method · พิมพ์บอก AI ได้ว่า "add a route GET /about to pages#about" AI จะเข้าใจทันทีว่าต้องสร้างไฟล์อะไรบ้าง

ขั้น 1 — สร้างร้าน: rails new แล้ว commit เอง

เปิด terminal ไปที่โฟลเดอร์ที่อยากเก็บงาน แล้วพิมพ์คำสั่งเดียวกับสไลด์ · คำสั่งนี้คือ "ขอครัวแฟรนไชส์สำเร็จรูปหนึ่งชุด"

$ rails new wongnok_blog --database=postgresql --css=tailwind
      create
      create  README.md
      create  Rakefile
      create  .ruby-version
      create  config.ru
      create  .gitignore
      create  .gitattributes
      create  Gemfile
         run  git init -b main from "."
Initialized empty Git repository in …/wongnok_blog/.git/
  … (อีกราว 170 บรรทัด) …
  Compile initial Tailwind build
         run    rails tailwindcss:build from "."
≈ tailwindcss v4.3.3

Done in 47ms
  …
       force  config/cable.yml

เครื่องผมใช้เวลาราว 8 วินาที เพราะ gem (ของสำเร็จรูปที่แอปต้องใช้) ลงไว้ครบแล้ว — ครั้งแรกของคุณอาจนานเป็นนาที
ดูบรรทัด git init — Rails สร้าง git repo (ที่เก็บประวัติโค้ด) ให้ แต่ยังไม่ commit:

$ cd wongnok_blog
$ git log --oneline
fatal: your current branch 'main' does not have any commits yet

ให้ commit เองทันที ก่อนแตะอะไร — เหมือนถ่ายรูปครัวไว้ก่อนรื้อ พอทำพังในครึ่งหลังจะได้รู้ว่าตัวเองแก้อะไรไป และย้อนกลับได้:

$ git add . && git commit -m "rails new"
$ git log --oneline
7a44a5e rails new

git add . = เลือกทุกไฟล์ · && = ถ้าคำสั่งแรกสำเร็จ ค่อยทำคำสั่งถัดไป · git commit -m "…" = บันทึกพร้อมข้อความ · รหัส 7a44a5e ของคุณจะต่างไป

ขั้น 2 — เปิดร้าน: เตรียม database แล้ว bin/rails server

วันนี้เรายังไม่เก็บบทความลง database เลย แต่ Rails ในโหมดพัฒนาต้องคุยกับ database ตอนมีคนเข้าเว็บครั้งแรก (มันเช็คว่าตู้เก็บแฟ้มพร้อมไหม) · ถ้าไม่มีตู้ หน้าเว็บจะพังตั้งแต่หน้าแรก

ต้องมี PostgreSQL เปิดอยู่ในเครื่องก่อน (ยังไม่มี → เปิด container แบบบท 31 ตามที่บท 98แนะนำ) · แล้วบอกแอปว่าตู้เก็บแฟ้มอยู่ที่ไหน ด้วยวิธีเดียวกับบท 98 จากนั้นสั่งสร้าง database:

$ export DATABASE_URL=postgres://postgres:sandbox@127.0.0.1:55432/a105
$ bin/rails db:prepare
Created database 'a105'

เปิดร้าน (พิมพ์ในโฟลเดอร์ wongnok_blog):

$ bin/rails server
=> Booting Puma
=> Rails 8.1.4 application starting in development
=> Run `bin/rails server --help` for more startup options
Puma starting in single mode...
* Puma version: 8.0.2 ("Into the Arena")
* Ruby version: ruby 4.0.9 (2026-10-23 revision 3e93b23afc) +PRISM [arm64-darwin25]
*  Min threads: 3
*  Max threads: 3
*  Environment: development
*          PID: 15190
* Listening on http://127.0.0.1:3000
* Listening on http://[::1]:3000
Use Ctrl-C to stop

terminal นี้จะ "ค้าง" อยู่แบบนี้ตลอด — ไม่ใช่เสีย มันกำลังรอลูกค้า และจะพิมพ์บันทึกทุกใบสั่งที่เข้ามา · เปิด terminal แท็บใหม่ไว้พิมพ์คำสั่งอื่น · แล้วเปิด http://localhost:3000 ในเบราว์เซอร์ ต้องเห็นหน้าต้อนรับของ Rails ที่มีโลโก้ตัวใหญ่

⚠️ ถ้าเปิดหน้าแรกแล้วเจอ ActiveRecord::NoDatabaseError

แปลว่าข้าม bin/rails db:prepare ไป หรือ DATABASE_URL ใน terminal ที่เปิด server ไม่ได้ตั้งไว้ · ผมลองเปิด server ก่อนสร้าง database แล้วเปิดหน้าแรก ได้ status 500 และ terminal ของ server ขึ้นข้อความนี้:

ActiveRecord::NoDatabaseError (Database not found: a105. Available database configurations can be found in config/database.yml.

To resolve this error:

- Create the database by running:

    bin/rails db:create

- Verify that config/database.yml contains the correct database name.
)

ทางแก้: กด Ctrl-C ปิด server → bin/rails db:prepare → เปิด server ใหม่ · export มีผลแค่ใน terminal แท็บที่พิมพ์ — แท็บใหม่ต้องพิมพ์ซ้ำ

ขั้น 3 — เมนูแรก /hello (ทำไปพร้อมกัน)

ก่อนเขียนอะไร ลองเปิด http://localhost:3000/hello ดูก่อน — จะได้หน้าแดงหน้าแรกของวัน:

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

แปลตรง ๆ: "ไม่มี route ไหนรับ GET /hello" — พนักงานต้อนรับไม่มีกฎสำหรับใบสั่งนี้ · ไม่ต้องตกใจ นี่คือสิ่งที่ควรเห็น เพราะเรายังไม่ได้เขียน route · status ที่ได้คือ 404 (หาไม่เจอ)

3.1 · route: บอกพนักงานต้อนรับ

เปิด config/routes.rb แล้วเพิ่มบรรทัดเดียวก่อน end บรรทัดสุดท้าย:

# config/routes.rb
Rails.application.routes.draw do
  # … บรรทัดเดิมที่ Rails เขียนไว้ ปล่อยไว้ตามเดิม …

  get "/hello", to: "greetings#index"
end

3.2 · controller: หัวหน้าเชฟแผนก greetings

สร้างไฟล์ใหม่ app/controllers/greetings_controller.rb:

# app/controllers/greetings_controller.rb
class GreetingsController < ApplicationController
  def index
    @name = "PEA"
  end
end

ถ้าเทียบกับ Go มันคือ handler หนึ่งตัว · แต่ใน Gin ท้าย handler คุณต้องสั่ง c.HTML(…) เอง พร้อมบอกชื่อไฟล์ template กับข้อมูลที่จะส่งไป
ใน Rails ไม่ต้องสั่ง — พอ method จบ Rails ไปหา view ที่ชื่อตรงกับ action ให้เอง และ view เห็น @ตัวแปร ทุกตัวเอง

3.3 · view: คนจัดจาน

สร้างโฟลเดอร์ app/views/greetings/ แล้วสร้างไฟล์ index.html.erb ในนั้น ใส่สองบรรทัดนี้:

<h1>Hello <%= @name %></h1>
<p>หน้านี้มาจาก GreetingsController#index</p>

ไม่ต้องปิดเปิด server ใหม่ — โหมด development โหลดไฟล์ใหม่ให้เอง · กลับไปกด refresh หน้า /hello · ผมดึง HTML ด้วย curl (โปรแกรมเปิดเว็บใน terminal) แล้วตัดมาเฉพาะส่วนเนื้อหา:

$ curl -s http://localhost:3000/hello
  … ส่วนหัว <head> ของ layout …
    <main class="container mx-auto mt-28 px-5 flex">
      <!-- BEGIN app/views/greetings/index.html.erb
--><h1>Hello PEA</h1>
<p>หน้านี้มาจาก GreetingsController#index</p>
<!-- END app/views/greetings/index.html.erb -->
    </main>

แล้วดู terminal ที่เปิด server ไว้ — จะเห็นบันทึกว่าใบสั่งเดินผ่านใครบ้าง:

Started GET "/hello" for ::1 at 2026-11-03 21:15:59 +0700
Processing by GreetingsController#index as */*
  Rendering layout layouts/application.html.erb
  Rendering greetings/index.html.erb within layouts/application
  …
Completed 200 OK in 18ms (Views: 6.6ms | …)

ทุกครั้งที่งง ให้ดู terminal นี้ก่อน — มันบอกเส้นทางจริงที่ใบสั่งเดินผ่าน ไม่ใช่เส้นทางที่เราคิดว่าเขียนไว้

📒 เทียบให้เห็นภาพ — สมุดบันทึกใบสั่งหน้าครัว

terminal ที่เปิด server คือสมุดบันทึกที่แขวนไว้หน้าครัว ใบสั่งทุกใบที่เข้ามาถูกจดลงไป: มาจากใคร ส่งให้แผนกไหน เมนูอะไร จัดจานด้วยแบบไหน เสิร์ฟทันไหม
ถ้าลูกค้าบอกว่า "ได้จานผิด" ให้เปิดสมุดนี้ก่อน — จะเห็นทันทีว่าใบสั่งไปผิดแผนกตั้งแต่ต้น หรือไปถูกแต่จัดจานพัง

ที่ไม่เหมือนกัน: สมุดจริงมีคนเลือกจดแค่เรื่องสำคัญ แต่ Rails จดทุกใบอัตโนมัติ ไม่ว่าสำคัญหรือไม่ · เปิดเว็บไปสักพักสมุดจะยาวมาก ให้มองหาบรรทัด Started ของ URL ที่เราสนใจ แล้วอ่านลงมา

ฝั่ง Go ก็มีของคล้ายกัน: ตอนใช้ gin.Default() Gin พิมพ์บรรทัด [GIN] หนึ่งบรรทัดต่อหนึ่ง request · ของ Rails ละเอียดกว่า เพราะบอกด้วยว่าไปถึง controller ไหน และใช้ view ไฟล์ไหน

🗣 ศัพท์ของสมุดบันทึก
server log (development log)
ข้อความที่ server พิมพ์ทุกครั้งที่มี request เข้ามา (Rails เขียนลงไฟล์ log/development.log ด้วย) · เวลาขอความช่วยเหลือ ส่งบรรทัดตั้งแต่ Started … จนถึง Completed … ของ request นั้นไปทั้งก้อน — พิมพ์บอก AI ได้ว่า "here is my Rails server log for GET /about, why does it render the wrong template?"

ภารกิจ: เมนูที่สอง /about — ทำเอง

ตอนนี้คุณทำครบ loop มาแล้วหนึ่งรอบ · รอบนี้ทำเองทั้งหมด แล้วเพิ่มการรับชื่อจาก URL (ข้อ 2–3 ของ Lab ในสไลด์)

🎯 ภารกิจ Lab 1 · หน้า /about (~10 นาที)

  1. เพิ่ม route: URL /about ให้ไปที่ controller pages action about
  2. สร้างไฟล์ controller ที่ชื่อตรงกฎ · ใน action ให้ตั้ง @name เป็น params[:name] ถ้ามี ถ้าไม่มีให้เป็น "PEA" (ใช้ || แบบ บท 94)
  3. สร้างไฟล์ view ที่ชื่อตรงกฎ · มีหัวข้อ About wongnok_blog และบรรทัด Hello ตามด้วยชื่อ
  4. เปิดสามแบบ แล้วจดว่าเห็นอะไร: /about · /about?name=Nan · /about?name= (มี = แต่ไม่มีค่า)
  5. ทำได้แล้ว commit: git add . && git commit -m "add /hello and /about"

ควรเห็น: /about → Hello PEA · /about?name=Nan → Hello Nan

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

① route — เพิ่มใน config/routes.rb ใต้บรรทัด /hello:

get "/about", to: "pages#about"

② controller — ไฟล์ใหม่ app/controllers/pages_controller.rb:

class PagesController < ApplicationController
  def about
    @name = params[:name] || "PEA"
  end
end

③ view — ไฟล์ใหม่ app/views/pages/about.html.erb (สร้างโฟลเดอร์ pages/ ก่อน):

<h1>About wongnok_blog</h1>
<p>Hello <%= @name %></p>
  • params[:name] — หยิบค่า name จาก URL · :name คือ symbol (ป้ายชื่อ บท 92)
  • || "PEA" — "ถ้าทางซ้ายไม่มีค่า (nil) ให้ใช้ทางขวา"
  • ชื่อ pages เป็นพหูพจน์ → class PagesController → ไฟล์ pages_controller.rb → โฟลเดอร์ view pages/

ผลจริงจาก curl (ตัดมาเฉพาะสองบรรทัดที่เราเขียน):

$ curl -s http://localhost:3000/about
<h1>About wongnok_blog</h1>
<p>Hello PEA</p>
$ curl -s "http://localhost:3000/about?name=Nan"
<h1>About wongnok_blog</h1>
<p>Hello Nan</p>
$ curl -s "http://localhost:3000/about?name="
<h1>About wongnok_blog</h1>
<p>Hello </p>

แบบที่สามได้ Hello เฉย ๆ ไม่ใช่ Hello PEA — เพราะ ?name= ส่งมาเป็น ข้อความว่าง "" ไม่ใช่ nil · และใน Ruby "" นับเป็น "จริง" || เลยไม่หยิบ "PEA" (บท 94)

ถ้าอยากให้ค่าว่างได้ PEA ด้วย ใช้ presence ของ Active Support (ของที่ Rails เติมให้ Ruby — แปลว่า "ถ้าว่างให้ถือว่าไม่มี"):

@name = params[:name].presence || "PEA"

# ผลจริงหลังแก้
/about           <p>Hello PEA</p>
/about?name=Nan  <p>Hello Nan</p>
/about?name=     <p>Hello PEA</p>

commit แล้วดูประวัติ (รหัสของคุณจะต่างไป):

$ git log --oneline
f6575cd add /hello and /about
7a44a5e rails new

ทำพังโดยตั้งใจ — อ่าน "ใบแจ้งปัญหาของครัว"

เวลาโค้ดพังในโหมด development Rails จะไม่ส่งหน้าว่าง ๆ มาให้ แต่ส่งหน้าสีแดงที่บอกรายละเอียด ว่าพังเพราะอะไร ที่ไฟล์ไหน บรรทัดไหน
มือใหม่มักเห็นตัวหนังสือเยอะแล้วปิดทิ้ง · จริง ๆ ส่วนที่ต้องอ่านมีแค่ 3 จุดบนสุด

นี่คือหน้าที่ได้จาก "พังแบบที่ 4" ข้างล่าง (พิมพ์ params ผิดเป็น parms) — เขียนตามที่เห็นบนจอจริง ติดเลขไว้ทีละส่วน:

① แถบแดงบนสุด NameError in PagesController#about [Copy as text] ② ตัวแดงตัวใหญ่ undefined local variable or method 'parms' for an instance of PagesController ③ Did you mean? params params= @_params ④ Extracted source (around line #3): 1 class PagesController < ApplicationController 2 def about ▶ 3 @name = parms[:name] || "PEA" ← แถบแดง = บรรทัดที่พัง 4 end 5 end ⑤ Rails.root: …/wongnok_blog Application Trace | Framework Trace | Full Trace …/wongnok_blog/app/controllers/pages_controller.rb:3:in 'PagesController#about' ⑥ Request Parameters: None ⑦ >> ← กล่องดำล่างจอ: พิมพ์ Ruby ได้ (ไม่ต้องใช้ตอนนี้)

วิธีอ่าน 3 จังหวะ (ใช้ได้กับทุกหน้าแดง):

  1. ① ชื่อปัญหา + เกิดที่ไหน — NameError คือชนิดของ error · in PagesController#about คือแผนกกับเมนูที่กำลังทำงานตอนพัง
  2. ② ประโยคเต็ม — อ่านเป็นภาษาคน: "ไม่รู้จักตัวแปรหรือ method ชื่อ parms ใน PagesController" · ถ้ามี ③ Did you mean? ให้ดูด้วย มักเฉลยให้เลย — ดูแค่ชื่อแรก (params) พอ สองชื่อล่างเป็นของข้างในที่ไม่ต้องสนใจ
  3. ④⑤ หาไฟล์ของเรา — มองหา path ที่ขึ้นต้นด้วย app/ กับเลขหลัง : (ที่นี่คือ pages_controller.rb:3 = บรรทัด 3) · ถ้าไม่มีไฟล์ของเราเลย แปลว่าปัญหาไม่ได้อยู่ในโค้ดของ method แต่อยู่ที่ชื่อ (route · ชื่อไฟล์ · ชื่อ class)

ส่วน ⑥ Request คือสำเนาใบสั่ง — ดูได้ว่า params ที่ส่งมามีอะไร (ครั้งนี้ไม่มี จึงเป็น None) · ส่วน Framework Trace ยาว ๆ คือโค้ดข้างในของ Rails เอง มือใหม่ข้ามได้เลย

🧾 เทียบให้เห็นภาพ — ใบแจ้งปัญหาของครัว

หน้าแดงคือใบแจ้งปัญหาที่ครัวส่งออกมาแทนจานอาหาร:
หัวกระดาษ (①) บอกว่าปัญหาประเภทไหน เกิดที่แผนกไหน เมนูอะไร · ประโยคตัวแดง (②) บอกว่าขาดอะไร · Did you mean? (③) คือเชฟเขียนโน้ตว่า "หรือหมายถึงอันนี้?" · กรอบโค้ด (④) คือรูปถ่ายจุดเกิดเหตุ · trace (⑤) คือเส้นทางที่ใบสั่งเดินผ่านมา · Request (⑥) คือสำเนาใบสั่งของลูกค้า

ที่ไม่เหมือนกัน: ใบแจ้งที่คนเขียนจะเลือกเขียนแค่เรื่องที่สำคัญ แต่ Rails แนบทุกอย่างมาหมด รวมถึงโค้ดข้างในของ Rails เอง — บางครั้งรูปถ่ายจุดเกิดเหตุ (④) เป็นโค้ดของ Rails ไม่ใช่ของเรา (เห็นในพังแบบที่ 1)
และใบแจ้งนี้ส่งถึงลูกค้า (เบราว์เซอร์) ไม่ใช่ถึงเจ้าของร้าน — terminal ที่เปิด server ก็มีสำเนาเดียวกันอยู่ บางเรื่องอ่านใน terminal ง่ายกว่า (เห็นในพังแบบที่ 3)

ก่อนเริ่มทำพัง: ทุกแบบให้แก้กลับจนหน้า /about ขึ้นปกติก่อนลองแบบถัดไป · ถ้าจำไม่ได้ว่าแก้อะไรไป ใช้ git ที่ commit ไว้:

$ git diff                                        # ดูว่าแก้อะไรไปจาก commit ล่าสุด
diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb
…
-    @name = params[:name] || "PEA"
+    @name = parms[:name] || "PEA"
…
$ git restore app/controllers/pages_controller.rb  # ย้อนไฟล์นี้กลับไปเหมือนตอน commit
$ git status
On branch main
nothing to commit, working tree clean

บรรทัดที่ขึ้นต้นด้วย - คือของเดิม · + คือของใหม่ที่เราแก้

พังแบบที่ 1 · route ชี้ไปหา controller ที่ไม่มี

ใน config/routes.rb ลบ s ออกหนึ่งตัว:

get "/about", to: "page#about"     # ← page ขาด s

เปิด /about ได้ status 500 และหน้านี้:

ActionDispatch::MissingController in PageController#about
uninitialized constant PageController
Extracted source (around line #101):
        rescue NameError => error
          if error.missing_name == const_name || const_name.start_with?("#{error.missing_name}::")
            raise MissingController.new(error.message, error.name)
…

แก้: ใส่ s กลับเป็น "pages#about"

พังแบบที่ 2 · ชื่อไฟล์ view ผิด

เปลี่ยนชื่อไฟล์ app/views/pages/about.html.erb เป็น abuot.html.erb (สลับตัวอักษร) แล้วเปิด /about:

No view template for interactive request
PagesController#about is missing a template for request formats: text/html

NOTE: Rails usually expects a controller action to render a view template with the same name.
For example, a PagesController#about action defined in app/controllers/pages_controller.rb should have a corresponding view template
in a file named app/views/pages/about.html.erb.
…

แก้: เปลี่ยนชื่อไฟล์กลับเป็น about.html.erb

พังแบบที่ 3 · ลืม end

ใน pages_controller.rb ลบ end ของ def about ออก:

class PagesController < ApplicationController
  def about
    @name = params[:name] || "PEA"
end                                      # ← เหลือ end ตัวเดียว

เปิด /about ได้ status 500 · ตัวนี้หัวกระดาษหลอกตา:

ArgumentError
Reported error must be an Exception, got: #<SyntaxError:"…/app/controllers/pages_controller.rb:4: syntax errors found\n> 1 | class PagesController < Application ...\n    | ^~~~~ expected an `end` to close the `class` statement\n  2 |   def about\n  3 |     @name = params[:name] || \"PEA\"\n> 4 | end\n    |    ^ unexpected end-of-input, assuming it is closing the parent top level context\n">

หัวบอกว่า ArgumentError ซึ่งไม่ใช่ตัวปัญหาจริง — มันคือ error ที่เกิดซ้อนขึ้นตอน Rails พยายามรายงานปัญหาจริง · ปัญหาจริงซ่อนอยู่ในประโยคแดง: หาคำว่า SyntaxError กับ syntax errors found · และ \n ที่เห็นคือ "ขึ้นบรรทัดใหม่" ที่ถูกพิมพ์ติดกันจนอ่านยาก

ตรงนี้ terminal ที่เปิด server อ่านง่ายกว่ามาก — ข้อความเดียวกันแต่ขึ้นบรรทัดให้:

Caused by: SyntaxError (…/app/controllers/pages_controller.rb:4: syntax errors found
> 1 | class PagesController < Application ...
    | ^~~~~ expected an `end` to close the `class` statement
  2 |   def about
  3 |     @name = params[:name] || "PEA"
> 4 | end
    |    ^ unexpected end-of-input, assuming it is closing the parent top level context
)

อีกทางที่ไม่ต้องเปิดเว็บ: ให้ Ruby ตรวจไวยากรณ์ของไฟล์เดียว (-c = check)

$ ruby -c app/controllers/pages_controller.rb
ruby: app/controllers/pages_controller.rb:4: syntax errors found (SyntaxError)
> 1 | class PagesController < Application ...
    | ^~~~~ expected an `end` to close the `class` statement
  2 |   def about
  3 |     @name = params[:name] || "PEA"
> 4 | end
    |    ^ unexpected end-of-input, assuming it is closing the parent top level context

# แก้แล้วรันอีกที
$ ruby -c app/controllers/pages_controller.rb
Syntax OK

แก้: ใส่ end กลับใต้บรรทัด @name = …

พังแบบที่ 4 · พิมพ์ชื่อตัวแปรผิด

ใน controller เปลี่ยน params เป็น parms — ได้หน้า NameError ที่เราแยกส่วนดูไปแล้วข้างบน (status 500) · ตัวนี้อ่านง่ายที่สุด: กรอบโค้ดชี้บรรทัด 3 ในไฟล์ของเรา และ Did you mean? params เฉลยให้เลย

เทียบกับ Go: ถ้าพิมพ์ชื่อตัวแปรผิดแบบเดียวกัน Go ไม่ยอม build ตั้งแต่แรก · ผมเขียนสองบรรทัดนี้ไว้ใน func main() ของไฟล์ main.go (มี package main กับ import "fmt" ครบ) แล้วสั่ง go build:

params := map[string]string{"name": "Nan"}
fmt.Println(parms["name"])   // พิมพ์ผิด: parms

$ go build main.go
# command-line-arguments
./main.go:6:2: declared and not used: params
./main.go:7:14: undefined: parms

บรรทัด # command-line-arguments คือ Go บอกว่ากำลัง build ไฟล์ที่เราสั่งตรง ๆ — ไม่ต้องสนใจ · error บรรทัดล่างคือตัวที่ตรงกับของเรา: undefined: parms = "ไม่รู้จักชื่อ parms" · บรรทัดบนเป็นโบนัสของ Go — มันเห็นว่า params ประกาศแล้วไม่ได้ใช้ (เพราะเราพิมพ์ผิดไปใช้ parms) ก็ฟ้องด้วย

Ruby ไม่มีขั้น build แบบนั้น — ไฟล์ที่พิมพ์ผิดยังโหลดได้ปกติ จะพังก็ต่อเมื่อบรรทัดนั้นถูกรันจริง · ทีนี้ลองผิดอีกที่หนึ่ง: แก้ controller กลับให้ถูก แล้วไปพิมพ์ผิดใน view แทน

ไฟล์ app/views/pages/about.html.erb — พิมพ์ @name ผิดเป็น @nmae (controller ถูกต้องแล้ว):

<h1>About wongnok_blog</h1>
<p>Hello <%= @nmae %></p>

เปิด /about?name=Nan — จะได้หน้าแดงแบบไหน?

ไม่มีหน้าแดงเลย — ได้ status 200 กับหน้าที่ชื่อหายไปเงียบ ๆ:

$ curl -s -H 'Accept: text/html' "http://localhost:3000/about?name=Nan"
…
<p>Hello </p>

(-H 'Accept: text/html' = ให้ curl บอกว่า "ขอเป็น HTML" เหมือนเบราว์เซอร์)

ตัวแปรที่มี @ นำหน้า ถ้าไม่เคยถูกตั้งค่า Ruby ไม่ฟ้อง แต่ให้ค่าเป็น nil (ไม่มีค่า) และ <%= nil %> พิมพ์ออกมาเป็นความว่างเปล่า (บท 94) · ต่างจาก parms ในพังแบบที่ 4 ที่ไม่มี @ — ชื่อแบบนั้นถ้าไม่รู้จัก Ruby ฟ้อง NameError ทันที

เทียบกับ Go: template ของ Go ที่อ้างชื่อ field ผิด ({{.Nmae}}) ฟ้อง — ผมลองแล้วได้ can't evaluate field Nmae in type main.Page

บทเรียน: ถ้าหน้าเว็บขึ้นปกติแต่ข้อมูลบางส่วนหายไปเฉย ๆ ให้สงสัยชื่อ @ตัวแปร สะกดไม่ตรงกันระหว่าง controller กับ view ก่อนอย่างอื่น

🍛 เทียบให้เห็นภาพ — ถาดที่ติดป้ายผิด

เชฟ (controller) วางวัตถุดิบบนถาดที่ติดป้าย @name · คนจัดจาน (view) เดินไปหยิบถาดป้าย @nmae — ไม่มีถาดนี้ ก็เลยจัดจานโดยไม่ใส่อะไรตรงนั้นแล้วเสิร์ฟไป ไม่มีใครส่งใบแจ้งปัญหาออกมาเลย

ที่ไม่เหมือนกัน: คนจัดจานจริงหาถาดไม่เจอก็คงตะโกนถามเชฟ แต่ Ruby ถือว่า "ถาด @ ที่ไม่มี" = ถาดเปล่า (nil) ซึ่งถูกกติกา จึงเงียบ
ส่วนชื่อที่ไม่มี @ (เช่น parms) ไม่ใช่ถาด แต่เป็น "คำสั่ง" ที่ Ruby ต้องรู้จัก — ไม่รู้จักจึงหยุดครัวทันที

สรุปการพังทั้งหมดของวันนี้

ทำอะไรผิดหัวหน้าแดงที่เห็นstatusไปแก้ที่ไหน
ยังไม่มี routeRouting Error
No route matches [GET] "/hello"
404config/routes.rb
route ชี้ controller ที่ไม่มี (page#about)ActionDispatch::MissingController
uninitialized constant PageController
500ชื่อใน route หรือชื่อไฟล์/ชื่อ class ของ controller
ชื่อไฟล์ view ผิดNo view template for interactive request406ชื่อไฟล์ใน app/views/… (หน้า error บอกชื่อที่ถูกให้)
ลืม endArgumentError ที่ข้างในมี SyntaxError500คู่ def/end ในไฟล์ที่บอก · ตรวจด้วย ruby -c
พิมพ์ชื่อผิดใน controller (parms)NameError + Did you mean?500บรรทัดที่กรอบโค้ดชี้
พิมพ์ @ตัวแปร ผิดใน viewไม่มี — ค่าหายเงียบ ๆ200เทียบชื่อ @… ใน controller กับ view
🗣 ศัพท์สำหรับคุยเรื่อง error
exception · error class
ชื่อชนิดของ error เช่น NameError · SyntaxError · ActionDispatch::MissingController (ส่วนหน้า :: คือชื่อกล่องที่มันอยู่) · เวลาถามใคร ให้ก็อปชื่อนี้ไปทั้งก้อน — พิมพ์บอก AI ได้ว่า "Rails 8 dev error page: ActionDispatch::MissingController uninitialized constant PageController — what file should I check?"
stack trace (backtrace)
รายการเส้นทางว่าโค้ดไหนเรียกโค้ดไหนมาจนถึงจุดที่พัง · บรรทัดบนสุดคือจุดที่พัง · Application Trace = เฉพาะไฟล์ของเรา (อ่านอันนี้) · Framework Trace = โค้ดของ Rails · ส่งให้ senior ดู ให้ส่งชื่อ error + ประโยคแดง + 3–5 บรรทัดแรกของ Application Trace ไม่ต้องส่งทั้งกอง
development error page · web console
หน้าแดงนี้เป็นของโหมด development (คำว่า development ที่เห็นตอนเปิด server) · กล่องดำ >> ล่างจอคือ web console — พิมพ์ Ruby ได้ ณ จุดที่พัง คล้าย console ใน บท 104 · ถาม senior ได้ว่า "ใช้ web console ดูค่า params ตรงจุดที่พังยังไง"
🗣 ศัพท์เรื่องตัวแปรหายเงียบ
instance variable (@ตัวแปร)
ตัวแปรที่มี @ นำหน้า เป็นของ object ตัวนั้น · ใน Rails คือทางเดียวที่ controller ส่งของให้ view · ถ้าอ่านตัวที่ไม่เคยตั้งค่า จะได้ nil ไม่ใช่ error · พิมพ์บอก AI ได้ว่า "my view shows blank — check if the instance variable names match between controller and view"
NameError · "Did you mean?"
error เมื่อ Ruby ไม่รู้จักชื่อที่เราพิมพ์ (ตัวแปรธรรมดา หรือ method) · ท่อน Did you mean? คือ Ruby ลองหาชื่อที่สะกดใกล้กันมาเสนอ · คุยกับเพื่อนได้ว่า "ได้ NameError บรรทัด 3 มันเสนอว่า params น่าจะพิมพ์ผิด"

ของแถมจากสไลด์ (ข้อ 4 และ 6 ของ Lab) — ทำต่อถ้ายังมีแรง

ข้อ 4 · เพิ่ม /about/:id แล้วสลับลำดับ — :id คือช่องว่างใน URL ที่รับค่าอะไรก็ได้ (บท 101) · ผมเพิ่ม action ชื่อ member (ชื่อนี้ตั้งเอง สไลด์ไม่ได้กำหนด) แล้วลองสองแบบ:

# แบบ A
get "/about",     to: "pages#about"
get "/about/:id", to: "pages#member"

# แบบ B — สลับสองบรรทัด
get "/about/:id", to: "pages#member"
get "/about",     to: "pages#about"

# action ที่เพิ่มใน PagesController กับ view ของมัน
def member
  @id = params[:id]
end
# app/views/pages/member.html.erb มีบรรทัดเดียว:  <p>id = <%= @id %></p>

# ผลจริง (ทั้งสองแบบได้เหมือนกัน)
/about    <p>Hello PEA</p>
/about/7  <p>id = 7</p>

ไม่มีอะไรเปลี่ยน — เพราะ /about/:id ต้องมีอะไรต่อท้าย /about/ เสมอ สองเส้นนี้จึงไม่มีวันแย่ง URL เดียวกัน · ลำดับมีผลจริงเฉพาะเมื่อสองเส้นรับ URL เดียวกันได้ — ดู quiz ข้อ 4

ข้อ 6 · เปิด console (ครัวทดลองหลังร้าน บท 104) แล้วลองสองคำสั่ง:

$ bin/rails console
Loading development environment (Rails 8.1.4)
wongnok-blog(dev):001> 3.days.ago
=> 2026-10-31 14:17:32.615684000 UTC +00:00
wongnok-blog(dev):002> "post".pluralize
=> "posts"
wongnok-blog(dev):003> exit

Checklist ท้ายวัน — ควรเข้าใจอะไรแล้ว

อ่านทีละข้อ ถ้าข้อไหนอธิบายให้เพื่อนฟังไม่ได้ ให้กลับไปบทนั้น — ไม่ต้องจำ syntax ได้หมด แค่รู้ว่าเปิดดูที่ไหน

Day 2 จะได้อะไรต่อ

ตอนนี้ทุกอย่างบนหน้าเว็บของเราเขียนตายตัวไว้ในโค้ด — "PEA" มาจากสตริงใน controller ไม่ได้มาจากที่ไหน · ตู้เก็บแฟ้ม (database) ที่สร้างด้วย db:prepare ยังว่างอยู่

ถ้าเทียบกับฝั่ง Go: Active Record ทำหน้าที่เดียวกับ GORM ที่คุณเคยใช้ แต่มากับ Rails เลย ไม่ต้องเลือกเอง · สไลด์สั่งให้อ่านล่วงหน้า: Rails Guides — Active Record Basics (ลิงก์อยู่ในหัวข้ออ้างอิง ท้ายบท)

Quiz ท้ายวัน

สี่ข้อนี้มาจากคำถามท้ายวันในสไลด์ เขียนใหม่ให้อ่านง่ายขึ้น · เฉลยทุกข้อรันพิสูจน์แล้ว

ข้อ 1 · route เขียนว่า get "/hello", to: "greetings#index" — ไฟล์ view (คนจัดจาน) ที่ Rails จะไปหาคือไฟล์ไหน?

ตัวเลือกที่ 2 ถูก · พิสูจน์ด้วยขั้น 3 ของบทนี้เอง: ไฟล์ app/views/greetings/index.html.erb ทำให้ /hello ขึ้นจริง และ comment BEGIN app/views/greetings/index.html.erb ใน HTML ก็ยืนยัน · ส่วนพังแบบที่ 2 หน้า error บอกตรง ๆ ว่าไฟล์ต้องชื่อ app/views/ชื่อ controller/ชื่อ action.html.erb

ตัวเลือกที่ 1 เอาชื่อ URL มาตั้ง — แต่ Rails ไม่ดู URL เลย ดูแค่ "greetings#index" · ตัวเลือกที่ 3 ใช้ชื่อ controller แต่ลืมว่า action คือชื่อไฟล์ในโฟลเดอร์ของ controller · ตัวเลือกที่ 4 ผิดสองชั้น: ใช้ชื่อ URL และอยู่นอก app/views/

จุดที่ต้องจำ: "แผนก#เมนู" → app/views/แผนก/เมนู.html.erb · เปลี่ยน URL เป็น /hi ไฟล์ view ก็ยังเป็นตัวเดิม

ข้อ 2 · ใน Ruby เขียน if 0 แล้วตามด้วยโค้ด — โค้ดข้างในจะทำงานไหม?

ตัวเลือกที่ 2 ถูก · ผลรันจริงบน Ruby 4.0.9:

if 0
  puts "เข้า if"
else
  puts "ไม่เข้า"
end
# ผลที่ได้: เข้า if

ตัวเลือกที่ 1 คือกับดักของคนที่มาจาก JavaScript/TypeScript ซึ่ง 0 เป็นเท็จ · ตัวเลือกที่ 3 เป็นกฎของ Go ไม่ใช่ Ruby — Go ไม่ยอม build (ผมลองแล้วได้ non-boolean condition in if statement) แต่ Ruby รันได้ · ตัวเลือกที่ 4 ผิด — เอกสารทางการของ Ruby เขียนกฎนี้ไว้ตรง ๆ ว่ามีแค่ nil กับ false ที่เป็นเท็จ

จุดที่ต้องจำ: ใน Ruby 0 · "" · [] เป็นจริงหมด · นี่คือเหตุผลที่ ?name= ในภารกิจได้ Hello ว่าง ๆ

ข้อ 3 · posts คือ array ของบทความ (ในตัวอย่างนี้จำลองด้วย Struct — แม่พิมพ์ข้อมูลแบบย่อที่มีช่อง title — เพราะยังไม่มี database) — posts.map(&:title) ได้ผลเท่ากับข้อไหน?

ตัวเลือกที่ 1 ถูก · ผลรันจริงกับบทความจำลองสองชิ้น:

Post = Struct.new(:title)
posts = [Post.new("Hello Rails"), Post.new("Day 1")]

p posts.map(&:title)
# ["Hello Rails", "Day 1"]
p posts.map { |post| post.title }
# ["Hello Rails", "Day 1"]
p posts.map { |post| :title }
# [:title, :title]
p posts.select { |post| post.title }
# [#<struct Post title="Hello Rails">, #<struct Post title="Day 1">]
p posts.each { |post| post.title }
# [#<struct Post title="Hello Rails">, #<struct Post title="Day 1">]

(p หน้าบรรทัดคือคำสั่งพิมพ์ค่าออกจอ บท 93 · บรรทัด # ใต้แต่ละบรรทัดคือผลที่ได้จริง · #<struct Post …> คือหน้าตาของบทความจำลองทั้งชิ้น)

ตัวเลือกที่ 2 คืนป้ายชื่อ :title เฉย ๆ ทุกรอบ ไม่ได้ไปเอาชื่อบทความ · ตัวเลือกที่ 3 ใช้ select ซึ่ง "คัดของ" ไม่ใช่ "แปลงของ" — ได้บทความทั้งชิ้นกลับมา ไม่ใช่รายชื่อ · ตัวเลือกที่ 4 ใช้ each ซึ่ง "เดินดูทีละชิ้น" แล้วคืน array เดิมกลับมา — ค่าที่ block คืนถูกทิ้งไป (บท 92–93)

จุดที่ต้องจำ: &:ชื่อ = "เรียก method ชื่อนี้กับสมาชิกทุกตัว" — ทางลัดของ block ที่ทำแค่เรียก method ตัวเดียว

ข้อ 4 · routes.rb เขียนสองบรรทัดเรียงแบบนี้: บนคือ get "/posts/:id", to: "posts#show" ล่างคือ get "/posts/popular", to: "posts#popular" — เปิด /posts/popular แล้วไปที่ action ไหน?

ตัวเลือกที่ 2 ถูก · ผลรันจริง (ผมสร้าง PostsController ชั่วคราวที่ตอบเป็นข้อความสั้น ๆ) · bin/rails runner '…' = สั่งรันโค้ด Ruby หนึ่งบรรทัดในแอปโดยไม่ต้องเปิด server · recognize_path = ถามพนักงานต้อนรับตรง ๆ ว่า "URL นี้จะส่งไปแผนกไหน เมนูอะไร":

$ bin/rails runner 'p Rails.application.routes.recognize_path("/posts/popular")'
{controller: "posts", action: "show", id: "popular"}

# เปิด /posts/popular จริง
show id=popular

พนักงานต้อนรับอ่านกฎจากบนลงล่าง เจอข้อแรกที่เข้าได้ก็ส่งเลย · :id รับอะไรก็ได้ รวมถึงคำว่า popular · ตัวเลือกที่ 1 คือสิ่งที่อยากให้เป็น แต่ Rails ไม่เลือกเส้นที่ตรงที่สุด เลือกเส้นแรก · ตัวเลือกที่ 3 ผิด — server เปิดได้ปกติ ไม่มีคำเตือน · ตัวเลือกที่ 4 ผิด — ใบสั่งหนึ่งใบไปได้ action เดียว

จุดที่ต้องจำ: เส้นที่เจาะจง (/posts/popular) ไว้บน เส้นที่มี :id ไว้ล่าง · ต่างจากของแถมข้อ 4 ที่สองเส้นไม่แย่ง URL กัน ลำดับจึงไม่มีผล

ถามกลับได้เลย

ถ้าคุณเจอหน้าแดงที่ไม่อยู่ในตารางสรุป — นั่นคือของดีที่สุดที่จะเอาไปถามเพื่อน senior หรือ AI · ส่งไปสามอย่าง: ชื่อ error · ประโยคแดง · ผลของ git diff (เห็นว่าแก้อะไรไปจาก commit ล่าสุด) · คนที่ช่วยจะเห็นภาพทันทีโดยไม่ต้องเดา

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

หน้าสไลด์เขียนของจริงต้องสนใจไหม
40 (ข้อ 1 ของ Lab อ้างถึง)rails new ได้ git repo พร้อม commit แรกสร้าง repo ให้ แต่ไม่ commit — git log ฟ้อง fatalสนใจ — commit เองตามขั้น 1
72 ข้อ 4เพิ่ม /about/:id แล้วลองสลับลำดับดูว่าเกิดอะไรไม่มีอะไรเปลี่ยน เพราะสองเส้นไม่แย่ง URL กันไม่ต้องตกใจ — quiz ข้อ 4 คือกรณีที่ลำดับมีผลจริง
72 ข้อ 6ลอง 3.days.agoตอบเป็นเวลา UTC ไม่ใช่เวลาไทยแค่รู้ไว้
72 ฝั่ง Day 2resources :posts บรรทัดเดียวแทน 6 บรรทัดได้ 8 เส้น (มีฟอร์มแก้ไข edit และ PUT เพิ่ม)ไม่ต้อง — Day 2 สอนละเอียด
70–71quiz ให้ตอบ A–D ในแชทบทนี้เรียงตัวเลือกตามสไลด์ แต่เขียนคำถามใหม่ให้อ่านง่าย · ข้อ 3 ตัวเลือกสุดท้ายเปลี่ยนจาก posts.title เป็น posts.each และใช้ |post| แทน |p|ไม่ต้อง
ทั้งชุดRuby 4.0.6 · Rails 8.1.3เครื่องตรวจใช้ Ruby 4.0.9 · Rails 8.1.4ไม่ต้อง — ผลในบทนี้ไม่ต่างกัน

อ้างอิง