บทเรียน 120 · Rails · Day 3

เริ่ม Day 3 — เปิดโปรเจกต์กลางของคอร์ส และข้อมูลตัวอย่าง 30 บทความ

~20 นาที (ไม่รวมเวลาดาวน์โหลด) · ควรผ่าน บทเรียน 119 (Lab 2) มาก่อน · ใช้ของจาก บท 107 (seeds) และ บท 108 (db:prepare) ด้วย

Day 3 ไม่ได้ต่อจากแอปที่เราทำเองใน Day 2 · ทั้งห้องจะเปิดโปรเจกต์ชุดเดียวกันที่อาจารย์เตรียมไว้ แล้วใช้มันทั้งวัน
บทนี้พาทำทีละขั้น: ก็อปโปรเจกต์มาจาก GitHub · เปิดฐานข้อมูล · ใส่บทความตัวอย่าง 30 ชิ้นด้วยไฟล์ที่รันซ้ำกี่รอบก็ไม่เบิ้ล · แล้วเปิดหน้า /posts ดูของดิบ ๆ ที่เราจะเปลี่ยนให้เป็นเว็บจริงทั้งวัน
อ่านจบแล้วคุณจะ: มีโปรเจกต์ Day 3 ที่รันได้ในเครื่อง · รู้ว่า git clone / branch / checkout คืออะไรแบบภาษาคน · และอธิบายได้ว่าทำไม find_or_create_by! ปลอดภัยกว่า create! ในไฟล์ข้อมูลตัวอย่าง

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

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

📍 โค้ดเริ่มจากไหน: บทนี้เริ่มจากยังไม่มีโปรเจกต์ Day 3 เลย · จบบทคุณจะมีโปรเจกต์ของอาจารย์ (สาย day3-start) + ไฟล์ข้อมูลตัวอย่างของบทนี้ ที่ commit ไว้ในสายของเราเองชื่อ my-day3 (สาย = branch · หัวข้อ 4 อธิบาย) · บท 121–125 ทดลองบนสถานะนี้ · ผลรันทุกกล่องมาจากเครื่องจริง · เวลา ms · เลข commit · วันเวลาใน log ของคุณจะไม่ตรงกับของผม

1 · วันนี้ไม่ได้ "เรียน Rails" — วันนี้สร้างระบบหนึ่งระบบ

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

Day 1–2 เราอยู่หลังร้านเป็นส่วนใหญ่: พนักงานต้อนรับ (routes) · หัวหน้าเชฟ (controller) · คนดูแลสต็อกกับตู้เก็บแฟ้ม (model · database) · Day 3 ย้ายมาหน้าร้านกับการจัดจาน — หน้าเว็บที่คนอ่านรู้เรื่อง · หน้าตาสวย · และกดแล้วขยับได้โดยไม่ต้องโหลดใหม่ทั้งหน้า

สไลด์ Day 3 จะพูดคำว่า "Vibe" บ่อย (Lab Vibe 1 · 2 · 3) = เขียน prompt (ข้อความสั่งงาน — บท 89) ให้ AI ทำ แล้วเราตรวจ · บทเรียนของเว็บนี้จะพาทำเองทีละขั้นก่อน แล้วค่อยให้ prompt กับรายการตรวจไว้ใช้กับ AI ทีหลัง — เพราะคนที่ไม่เคยทำเองจะตรวจงานของ AI ไม่ออก

2 · แผนที่ของวัน และ 3 ประโยคที่ต้องจำ

สไลด์แบ่ง Day 3 เป็น 6 บล็อก A–F · ชุดที่มาถึงตอนนี้คือ ครึ่งเช้า (Part 1) = บล็อก A B C · บล็อก D E F (Part 2) อยู่ใน Rails Day 4 · Part 1 ของเว็บนี้ (D E = บท 136–145 · F ยังไม่ได้ทำ):

บทบล็อกทำอะไร (ภาษาคน)ปิดท้ายด้วย
120–121เตรียมตัวเปิดโปรเจกต์กลาง · ใส่ข้อมูลตัวอย่าง · อ่านคู่มือที่แนบให้ AI ทุกครั้งโปรเจกต์ที่รันได้
122–126A · Viewคนจัดจาน: ERB · แม่พิมพ์ใช้ซ้ำ (partial) · เครื่องมือจัดจาน (helper) · ฟอร์ม · ช่องโหว่ใน viewLab Vibe 1 — หน้ารายการ/หน้าบทความที่อ่านรู้เรื่อง
127–129B · CSS + assetของตกแต่งร้าน: ไฟล์ CSS/JS เดินทางยังไง · Tailwind · ชุดจานชามสำเร็จรูป (daisyUI)Lab Vibe 2 — แต่งให้เป็นเว็บ + สลับธีมสว่าง/มืด
130–135C · Hotwireเปลี่ยนเฉพาะจานบนโต๊ะเดิม ไม่ต้องรื้อโต๊ะ: แก้ในหน้าเดิม · คอมเมนต์เด้งทุกแท็บ · ตัวนับอักษรLab Vibe 3 — ทำให้มันขยับ
136–145D E F (Part 2 · อยู่ใน Rails Day 4 · Part 1)เนื้อหาแบบมีรูปแบบ/ไฟล์แนบ · งานเบื้องหลัง · ทำให้เร็ว (cache)Lab Vibe 4 · 5 (บล็อก F ยังไม่ได้ทำ)

สไลด์หน้า 8 บอกว่า "ถ้าวันนี้จำได้แค่ 3 อย่าง" · ตอนนี้ยังไม่ต้องเข้าใจทั้งหมด จำไว้ก่อน เดี๋ยวเจอ:

#ประโยคภาษาคน (ภาพร้านอาหาร)เจอเมื่อไหร่
①เว็บ interactive ไม่จำเป็นต้องมี state สองชุด state = ข้อมูลที่หน้าเว็บจำไว้ (แบบ useState บท 73) · ให้ครัวถือสมุดสต็อกเล่มเดียว · ถ้าโต๊ะลูกค้าจดสมุดเล่มที่สองเอง สักวันสองเล่มจะไม่ตรงกัน (บั๊ก sync)บท 122 · 130
②exactly-once ไม่มีอยู่จริง ออกแบบให้ทำซ้ำได้แทน ไม่มีทางรับประกันว่างานจะถูกทำ "ครั้งเดียวพอดี" · ให้ทำซ้ำแล้วผลไม่เพี้ยนแทนบทนี้ (seeds) · เต็ม ๆ ใน Part 2
③อย่า invalidate cache ให้ key เปลี่ยนแทน cache = สำเนาที่เก็บไว้ใช้ซ้ำ จะได้ไม่ต้องทำใหม่ทุกรอบ (บท 127 อธิบาย) · ลูกค้าประจำจำเมนูเล่มเก่าไว้ · อย่าไล่เก็บเมนูเล่มเก่าคืน แต่พิมพ์เลขรุ่นใหม่บนปก ทุกคนรู้เองว่าต้องหยิบเล่มใหม่บท 127 · เต็ม ๆ ใน Part 2

3 · ทำไม Day 3 ไม่ใช้แอปของ Day 2 ต่อ

สไลด์หน้า 3 ให้เหตุผลว่า งานจริงไม่เคยเริ่มจากศูนย์ — เข้าบริษัทวันแรก คุณจะได้โค้ดที่คนอื่นเขียนไว้แล้ว ไม่ได้ rails new เอง · และบอกด้วยว่า "ถ้า Lab 2 ยังไม่เสร็จ ไม่เป็นไร" = ทุกคนในห้องเริ่มที่จุดเดียวกัน ใครทำ Lab 2 ไม่เสร็จก็ไม่ตกขบวน

โปรเจกต์ของอาจารย์ชื่อ wongnok-blog-all-in-one · หน้าตาคล้ายของเรา แต่ไม่เหมือน:

แอป Day 2 ของเรา (wongnok_blog)โปรเจกต์ Day 3 (day3-start)
ใครสร้างเราเอง ตั้งแต่ rails new บท 98อาจารย์ · ทั้งห้องได้ชุดเดียวกัน
ผู้ใช้ (users)มี (สมชาย สมหญิง มานะ)ไม่มี · ระบบล็อกอินรอ Day 4
เนื้อหาบทความช่อง body (ว่างได้) + published · viewsช่อง description ห้ามว่าง · ไม่มี published/views
หมวดเพิ่มใน Lab บท 119 · บทความไม่มีหมวดได้ทุกบทความต้องมีหมวด (category_id ห้ามว่าง)
คอมเมนต์มี (ผูกกับผู้ใช้)ยังไม่มี · เราสร้างเองในบท 132
หน้าเว็บทำไปบางส่วนระหว่าง Day 1–2 (เช่น scaffold ของ Category บท 118–119)scaffold ดิบ ๆ ของทั้ง Post และ Category · ติดตั้ง Tailwind + daisyUI ไว้แล้ว
ข้อมูลตัวอย่าง6 บทความว่าง → เราใส่ 30 บทความในบทนี้

✅ แอป Day 2 เก็บไว้ได้ ไม่ต้องลบ — มันอยู่คนละโฟลเดอร์ และใช้ฐานข้อมูลคนละชื่อ (wongnok_blog_development กับ wongnok_blog_all_in_one_development) · อยากเปิดดูเทียบเมื่อไหร่ก็ได้

4 · ขั้น 1 — ก็อปโปรเจกต์ด้วย git clone แล้วสลับไป day3-start

โค้ดของอาจารย์เก็บอยู่บน GitHub เป็น repository (เรียกสั้น ๆ ว่า repo = โฟลเดอร์โปรเจกต์ที่มี git คอยจดประวัติ) · git clone คือก็อป repo ทั้งก้อนลงเครื่องเรา พร้อมประวัติการแก้ทุกครั้ง ไม่ใช่แค่ไฟล์ล่าสุด

ในประวัตินั้นมีหลายสาย แต่ละสายเรียกว่า branch — อาจารย์ทำสาย day3-start (จุดเริ่มเช้าวันนี้) · day3-a-done (ทำบล็อก A เสร็จ) ฯลฯ · git checkout ชื่อสาย คือสลับไฟล์ทั้งโฟลเดอร์ให้เป็นหน้าตาของสายนั้น

📒 เทียบให้เห็นภาพ — รับร้านต้นแบบพร้อมสมุดบันทึกการปรับปรุงร้าน

git clone คือแฟรนไชส์ยกร้านต้นแบบมาทั้งร้าน พร้อมสมุดบันทึกการปรับปรุงร้านทุกหน้าตั้งแต่วันเปิด · branch คือสมุดแยกเล่ม: เล่ม "เช้าวันนี้" · เล่ม "หลังจัดหน้าร้านเสร็จ" · checkout คือจัดร้านให้ตรงกับเล่มที่เลือก — โต๊ะ เก้าอี้ ป้ายเมนู ย้ายตามเล่มนั้นทันที

ที่ไม่เหมือนกัน: git จัดได้แค่ไฟล์ในโฟลเดอร์ · ของในตู้เก็บแฟ้ม (ข้อมูลใน PostgreSQL) ไม่ได้อยู่ใน git · สลับ branch แล้วข้อมูลในฐานข้อมูลยังอยู่ครบเหมือนเดิม (หลังใส่บทความ 30 ชิ้นในหัวข้อ 7 ผมลองสลับสายแล้ว ยังนับได้ 30) · และร้านจริงย้ายโต๊ะต้องใช้แรง แต่ git สลับทั้งโฟลเดอร์ได้ในเสี้ยววินาที

ไปที่โฟลเดอร์ที่เก็บโปรเจกต์ (โฟลเดอร์เดียวกับที่มี wongnok_blog ของ Day 1–2 · อย่า clone ซ้อนเข้าไปข้างในโปรเจกต์เดิม) แล้วพิมพ์ทีละบรรทัด:

$ git clone https://github.com/chonlawit-odds/wongnok-blog-all-in-one.git
Cloning into 'wongnok-blog-all-in-one'...
remote: Enumerating objects: 660, done.
remote: Counting objects: 100% (660/660), done.
remote: Compressing objects: 100% (453/453), done.
remote: Total 660 (delta 291), reused 537 (delta 178), pack-reused 0 (from 0)
Receiving objects: 100% (660/660), 291.02 KiB | 2.29 MiB/s, done.
Resolving deltas: 100% (291/291), done.
$ cd wongnok-blog-all-in-one
$ git checkout day3-start
branch 'day3-start' set up to track 'origin/day3-start'.
Switched to a new branch 'day3-start'

อยากดูว่ามีสายอะไรบ้าง พิมพ์ git branch -a (-a = all รวมสายบน GitHub) · ผลจริง (ตัดสาย dependabot/… ของบอทอัปเดต gem ออก):

$ git branch -a
* day3-start
  main
  remotes/origin/HEAD -> origin/main
  remotes/origin/day3-a-done
  remotes/origin/day3-b-done
  remotes/origin/day3-c-done
  remotes/origin/day3-d-done
  remotes/origin/day3-e-done
  remotes/origin/day3-f-done
  remotes/origin/day3-start
  remotes/origin/day4-final
  remotes/origin/day4-g-done
  remotes/origin/day4-j-done
  …
  remotes/origin/main

* = สายที่เราอยู่ตอนนี้ · สองบรรทัดแรกคือสายที่มีในเครื่องเรา · remotes/origin/… คือสายที่มีบน GitHub (git จำรายชื่อไว้ตอน clone) · HEAD -> origin/main = "สายหลักบน GitHub คือ main" ไม่ต้องสนใจ

สาย day3-a-done ฯลฯ เอาไว้ "ตามทัน" — หัวข้อ 9 จะเตือนเรื่องนี้ · สาย day3-d/e/f กับ day4-… เป็นของ Part 2 และ Day 4 ที่ยังไม่ได้เรียน

เทียบกับที่เคยทำ: ตอนเริ่ม Next.js (บท 70) เราใช้คำสั่งสร้างโปรเจกต์ใหม่ · ตอน Rails Day 1 เราใช้ rails new · ทั้งสองคือ "สร้างร้านใหม่เปล่า ๆ" · git clone คือ "รับร้านที่มีคนเปิดไว้แล้ว" — งานจริงส่วนใหญ่เริ่มแบบหลัง

🗣 ศัพท์ที่เพิ่งเรียนไป
repository (repo) · git clone
repo = โฟลเดอร์โปรเจกต์ที่ git จดประวัติไว้ · clone = ก็อป repo พร้อมประวัติทั้งหมดลงเครื่อง · ถาม senior วันแรกได้ว่า "ขอ URL ของ repo ที่ต้อง clone และ branch ที่ควรเริ่มทำงานหน่อยครับ"
branch · checkout · origin
branch = สายประวัติแยกเส้น · checkout = สลับไฟล์ทั้งโฟลเดอร์ไปเป็นของสายนั้น (git รุ่นใหม่มีคำสั่ง git switch ที่ทำงานเดียวกันแต่ชื่อชัดกว่า — ใช้ในหัวข้อ 7) · origin = ต้นทางที่ clone มา · บอก AI ได้ว่า "I'm on branch day3-start of a cloned repo; don't touch other branches"

5 · ขั้น 2 — ติดตั้ง gem แล้วเปิดฐานข้อมูลด้วย docker compose

repo มาแค่โค้ด · gem (library ของ Ruby) ที่โค้ดต้องใช้ยังไม่ได้ลงเครื่อง · bundle install อ่านรายการใน Gemfile + เลขรุ่นที่ล็อกไว้ใน Gemfile.lock แล้วติดตั้งให้ครบ — แบบเดียวกับ npm install ที่อ่าน package.json (บท 70)

$ bundle install
…
Bundle complete! 27 Gemfile dependencies, 128 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.
1 installed gem you directly depend on is looking for funding.
  Run `bundle fund` for details

เครื่องผมมี gem ครบอยู่แล้วจึงได้แค่บรรทัดสรุป (สองบรรทัดท้ายเรื่อง funding = gem ตัวหนึ่งรับเงินสนับสนุน ไม่ต้องสนใจ) · ของคุณครั้งแรกจะมีบรรทัด Installing … เป็นสิบ ๆ บรรทัดก่อน (repo นี้มี gem ที่แอป Day 2 ไม่มี เช่น bullet · rack-mini-profiler · strong_migrations) · ลงท้ายด้วย Bundle complete! = ผ่าน

ต่อไปคือตู้เก็บแฟ้ม · repo นี้มีไฟล์ compose.yaml ที่เขียนไว้ว่าต้องเปิดโปรแกรมอะไรบ้าง · docker compose up -d อ่านไฟล์นั้นแล้วเปิดให้ทั้งชุด ในกล่องแยก (container) ที่ไม่ปนกับโปรแกรมในเครื่อง · คุณเคยใช้ไฟล์แบบนี้เปิด PostgreSQL มาแล้วในฝั่ง Go (บท 27)

# compose.yaml (ตัดมาเฉพาะส่วนที่ต้องรู้)
services:
  postgres:
    image: postgres:18
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql
  minio:
    …
    ports: ["9000:9000", "9001:9001"]

เปิด Docker Desktop ให้ทำงานก่อน แล้วพิมพ์ docker compose up -d ในโฟลเดอร์โปรเจกต์ · -d = เปิดทิ้งไว้เบื้องหลัง คืนเทอร์มินัลให้พิมพ์ต่อได้ · ครั้งแรกจะดาวน์โหลดตัวโปรแกรมนานหน่อย · (เครื่องตรวจของผมไม่ได้รันคำสั่งนี้ เพราะใช้ PostgreSQL ที่เปิดไว้แล้ว — จึงไม่มีผลลัพธ์ให้ดู)

🚪 เทียบให้เห็นภาพ — ตู้เก็บแฟ้มสำเร็จรูป กับเลขที่ประตู

compose.yaml คือใบสั่งอุปกรณ์ของร้านต้นแบบ: "ตู้เก็บแฟ้มรุ่น 18 หนึ่งตู้ กุญแจชื่อ postgres · โกดังเก็บของหนึ่งหลัง" · docker compose up -d คือยกของตามใบสั่งมาวางแล้วเสียบปลั๊ก · แต่ละอย่างต้องตั้งตรงประตูหมายเลขของมัน (ตู้แฟ้มอยู่ประตู 5432)

ที่ไม่เหมือนกัน: ร้านจริงวางตู้สองใบหน้าประตูเดียวกันได้ · ในเครื่องคอมพิวเตอร์หนึ่งหมายเลขประตูมีเจ้าของได้แค่โปรแกรมเดียว · ถ้ามี PostgreSQL ตัวเก่าตั้งอยู่ที่ 5432 แล้ว ตู้ใหม่จะเข้าที่ไม่ได้ (ดูกล่องเตือนข้างล่าง) · และกล่องของ docker ไม่ใช่ตู้ที่จับต้องได้ — มันคือโปรแกรมที่ถูกกั้นให้มองไม่เห็นของอื่นในเครื่อง

⚠️ สองจุดที่พิมพ์ตามแล้วพังบ่อยที่สุด

(1) ประตู 5432 มีเจ้าของอยู่แล้ว — docker compose up -d ฟ้อง error ที่พูดถึง 5432 = มี PostgreSQL ตัวเก่าเปิดอยู่ เช่น container devpool-postgres ของฝั่ง Go บท 27 (ตั้ง restart: always ไว้ = เปิดเครื่องแล้วมันขึ้นเอง) หรือตัวที่ใช้กับแอป Day 2

(2) ค่า DATABASE_URL ค้างจาก Day 1–2 — ถ้าเคย export DATABASE_URL=… ไว้ (บท 98) Rails จะเชื่อค่านั้นก่อนไฟล์ตั้งค่า แล้วไปใช้ฐานข้อมูลของแอปเก่า · เช็คด้วย echo $DATABASE_URL ต้องได้บรรทัดว่าง · ถ้าไม่ว่างให้ unset DATABASE_URL (ลบตัวแปรนี้ในเทอร์มินัลนี้)

ทำไมไม่ต้องตั้งรหัสฐานข้อมูลเอง? เพราะ config/database.yml ของ repo ตั้งค่าเริ่มต้นไว้ตรงกับ compose.yaml แล้ว · ไฟล์นี้ใช้ ERB (<%= %> ที่เรียนบท 103 — ใช้ได้ในไฟล์ตั้งค่าด้วย):

# config/database.yml (ตัดมาบางส่วน)
  host: <%= ENV.fetch("DATABASE_HOST", "localhost") %>
  port: <%= ENV.fetch("DATABASE_PORT", 5432) %>
  username: <%= ENV.fetch("DATABASE_USER", "postgres") %>
  password: <%= ENV.fetch("DATABASE_PASSWORD", "postgres") %>

ENV.fetch("DATABASE_HOST", "localhost") = "อ่านตัวแปรสภาพแวดล้อม (ค่าที่ตั้งไว้ในเทอร์มินัล) ชื่อ DATABASE_HOST · ถ้าไม่มี ใช้ค่าหลังลูกน้ำ" · ของคุณไม่ได้ตั้งอะไร จึงได้ localhost · 5432 · postgres · postgres = ตรงกับตู้ใน compose.yaml พอดี · ของคุณไม่ต้องตั้งตัวแปรพวกนี้ (เครื่องตรวจของผมตั้งไว้ให้ชี้ไปตู้อื่น เพราะไม่ได้ใช้ docker)

🗣 ศัพท์ที่เพิ่งเรียนไป
bundle install · Gemfile.lock
ติดตั้ง gem ตามรายการ · ไฟล์ .lock ล็อกเลขรุ่นให้ทุกเครื่องได้รุ่นเดียวกัน (≈ package-lock.json · go.sum) · เจอ error ว่าหา gem ไม่เจอ ถาม senior ได้ว่า "ดึงโค้ดใหม่ (git pull) แล้วต้อง bundle install ใหม่ไหม" — ส่วนใหญ่ใช่
docker compose · container · port
compose = เปิดหลายโปรแกรมตามไฟล์ compose.yaml ทีเดียว · container = กล่องแยกที่โปรแกรมรันอยู่ · port = หมายเลขประตูที่โปรแกรมรอรับการเชื่อมต่อ (หนึ่งหมายเลข หนึ่งเจ้าของ) · บอก AI ได้ว่า "docker compose up fails because port 5432 is already in use — how do I find which container owns it?"

6 · ขั้น 3 — db:prepare แล้วเดินดูว่าได้อะไรมาบ้าง

ตู้เปิดแล้ว · สั่งช่างตั้งลิ้นชักตามแบบ (บท 108):

$ bin/rails db:prepare
Created database 'wongnok_blog_all_in_one_development'
Created database 'wongnok_blog_all_in_one_test'

ก่อนใส่ข้อมูล เดินดูไฟล์สำคัญสั้น ๆ (เปิดโฟลเดอร์ใน editor) — ไม่ต้องจำ แค่รู้ว่าอะไรอยู่ตรงไหน ทุกไฟล์มีบทของมันรออยู่:

ไฟล์คืออะไร (ภาษาคน)ลงลึกบท
db/schema.rbแบบลิ้นชักล่าสุด: categories (name · slug ห้ามซ้ำ) · posts (title · description · category_id ห้ามว่างทั้งสามช่อง)108 (ทวน)
app/models/post.rb · category.rbคนดูแลสต็อก: Post ต้องมีหมวด · title ≤ 120 ตัว · Category สร้าง slug จากชื่อให้เอง110 · 115 (ทวน)
app/controllers/posts_controller.rbหัวหน้าเชฟจาก scaffold · index หยิบ Post.all ทั้งลิ้นชัก118 (ทวน) · 126
app/views/posts/คนจัดจาน: index · show · _post · _form … หน้าตาดิบจาก scaffold122–126
prompts/00-context.mdคู่มือพนักงานใหม่ที่แนบให้ AI: stack ของโปรเจกต์ + กฎ 8 ข้อจาก Day 2121
AGENTS.md · CLAUDE.mdป้ายหน้าห้องพนักงานที่ AI อ่านก่อนเข้างาน · CLAUDE.md ไม่ใช่ไฟล์จริง เป็นป้ายชี้ (symlink) ไปที่ AGENTS.md — เปิดชื่อไหนก็เจอของเดียวกัน121
app/assets/tailwind/application.cssจุดตั้งค่า Tailwind + daisyUI (ชุดจานชามสำเร็จรูป)128
config/environments/development.rbท้ายไฟล์เปิด bullet ตัวจับ N+1 (บท 114) ไว้แล้ว125
Procfile.devรายการโปรแกรมที่ bin/dev เปิดพร้อมกันหัวข้อ 8 · 128

ไฟล์เดียวในตารางที่ขอดูโค้ดตอนนี้ คือท้าย config/environments/development.rb — เพราะมันจะมีผลกับหน้าเว็บที่เราเปิดในหัวข้อ 8:

  config.after_initialize do
    Bullet.enable = true
    Bullet.rails_logger = true
    Bullet.add_footer = true
  end

after_initialize = "ตั้งค่าเสร็จแล้วค่อยทำในบล็อกนี้" · สามบรรทัด = เปิด bullet (gem ตัวจับ N+1) · ให้มันเขียนเตือนลง log · และแปะแถบเตือนท้ายหน้าเว็บเวลาเจอ N+1 · ไฟล์นี้ใช้เฉพาะตอน development (ตอนเขียนโค้ดในเครื่อง) → ผู้ใช้จริงไม่เห็น

🗣 ศัพท์ที่เพิ่งเรียนไป
development environment
โหมดที่ใช้ตอนเขียนโค้ดในเครื่อง (คู่กับ test และ production) · ตั้งค่าแยกกันใน config/environments/ · ถาม senior ได้ว่า "gem ตัวนี้เปิดแค่ใน development หรือขึ้น production ด้วย"

7 · ขั้น 4 — ข้อมูลตัวอย่าง 30 บทความ ที่รันซ้ำกี่รอบก็ไม่เบิ้ล

ทำไมต้อง 30? สไลด์หน้า 20 ให้เหตุผลว่า ปัญหาที่เราจะเจอทั้งวัน — N+1 · ความช้า · การแบ่งหน้า (pagination = แสดงทีละหน้า หน้าละ 10 ชิ้น) — ไม่โผล่ตอนมีข้อมูล 3 แถว · ข้อมูลน้อยเกินไปคือเหตุผลอันดับหนึ่งที่บั๊กพวกนี้หลุดไปถึงผู้ใช้จริง

🍜 เทียบให้เห็นภาพ — ซ้อมเปิดร้านกับลูกค้า 3 คน

ซ้อมเปิดร้านโดยมีลูกค้าแค่ 3 โต๊ะ ทุกอย่างดูดี: คนเสิร์ฟเดินไปตู้ทีละใบก็ทัน · เมนูหน้าเดียวพอ · พอวันเปิดจริงลูกค้ามา 30 โต๊ะ คนเสิร์ฟเดินจนขาลาก (N+1) · รายการยาวจนต้องแบ่งหน้า · ปัญหามีอยู่ตั้งแต่วันซ้อม แค่ยังไม่มีใครเห็น

ที่ไม่เหมือนกัน: 30 แถวในเครื่องเรายังเร็วมาก — ตัวเลข ms ใน log แทบไม่ขยับ · สิ่งที่ 30 แถวทำให้เห็นคือจำนวนครั้งที่เดินไปตู้ (นับบรรทัดใน log ได้) และหน้าที่ยาวเกินไป ไม่ใช่ความช้าที่ผู้ใช้รู้สึก · ร้านจริงที่มีหลายหมื่นแถว ความช้าถึงจะโผล่ให้เห็นด้วยตา

ใส่ข้อมูลตัวอย่าง: เปิด db/seeds.rb · ลบของเดิมทิ้งทั้งไฟล์ (มีแค่ comment) แล้ววางอันนี้แทนทั้งไฟล์ · ไฟล์นี้เป็นชุดเดียวกับที่บท 121–135 ใช้ ผลรันของคุณจะได้ตรงกับบทเรียน:

# ข้อมูลตัวอย่างของ Rails Day 3 — หมวด 5 หมวด · บทความ 30 ชิ้น
# รันซ้ำกี่รอบก็ได้ ข้อมูลไม่เบิ้ล เพราะใช้ find_or_create_by! (มีอยู่แล้ว = ใช้ของเดิม)

CATEGORIES = {
  "thai-food"   => "อาหารไทย",
  "noodles"     => "ก๋วยเตี๋ยว",
  "desserts"    => "ของหวาน",
  "street-food" => "อาหารริมทาง",
  "drinks"      => "เครื่องดื่ม"
}

POSTS = {
  "thai-food" => [
    [ "ต้มยำกุ้งน้ำข้น", "ต้มยำกุ้งแบบน้ำข้นใส่นมข้นจืด รสเปรี้ยวเผ็ดกลมกล่อม กุ้งแม่น้ำตัวใหญ่ หอมตะไคร้ ข่า ใบมะกรูด กินกับข้าวสวยร้อน ๆ ได้ทั้งถ้วย ร้านนี้เปิดมาสามสิบปีแล้ว คิวยาวทุกวันเสาร์อาทิตย์" ],
    [ "แกงเขียวหวานไก่", "แกงเขียวหวานกะทิสด ใส่มะเขือเปราะกับใบโหระพา รสหวานนำ เผ็ดตามหลัง" ],
    [ "ผัดกะเพราหมูสับไข่ดาว", "เมนูสิ้นคิดที่ไม่เคยทำให้ผิดหวัง หมูสับผัดพริกกระเทียมไฟแรง ใบกะเพราเยอะ ๆ ไข่ดาวขอบกรอบ ราดข้าวแล้วตักพริกน้ำปลาเพิ่มอีกนิด" ],
    [ "ส้มตำปูปลาร้า", "ตำแบบอีสานแท้ ปลาร้าต้มสุกหอมกลิ่น ปูนาเค็มตัวเล็ก เผ็ดจัดจ้านจนต้องสั่งข้าวเหนียวเพิ่ม" ],
    [ "ข้าวซอยไก่", "ข้าวซอยเชียงใหม่ น้ำแกงเข้มข้นหอมเครื่องเทศ เส้นทอดกรอบด้านบน เสิร์ฟกับหอมแดงและผักกาดดอง" ],
    [ "มัสมั่นเนื้อ", "เนื้อตุ๋นจนนุ่มละลายในปาก มันฝรั่งกับถั่วลิสงคั่ว น้ำแกงหวานมันหอมอบเชย กินกับโรตีก็เข้ากัน" ]
  ],
  "noodles" => [
    [ "ก๋วยเตี๋ยวเรืออยุธยา", "น้ำซุปเข้มข้นใส่เลือดหมู ชามเล็กกินได้หลายชาม เส้นหมี่กับเส้นเล็กอร่อยพอกัน" ],
    [ "บะหมี่เกี๊ยวหมูแดง", "เส้นบะหมี่ไข่เหนียวนุ่ม เกี๊ยวกุ้งห่อแน่น หมูแดงหวานกำลังดี ขอแห้งใส่น้ำตาลพริกป่นตามชอบ" ],
    [ "ผัดไทยกุ้งสด", "เส้นจันท์ผัดกับซอสมะขามเปียกสูตรร้าน ใส่กุ้งสด ไข่ เต้าหู้ ถั่วงอก โรยถั่วป่น บีบมะนาวก่อนกิน" ],
    [ "เย็นตาโฟทรงเครื่อง", "น้ำซอสเย็นตาโฟสีชมพูหวานอมเปรี้ยว ลูกชิ้นปลาเหนียว เต้าหู้ทอด ผักบุ้งลวก และปลาหมึกกรอบ" ],
    [ "ก๋วยจั๊บน้ำข้น", "เส้นก๋วยจั๊บม้วนในน้ำซุปพริกไทยร้อน ๆ หมูกรอบ ไข่ต้มยางมะตูม กินตอนเช้าอุ่นท้องทั้งวัน" ],
    [ "ราดหน้าหมูหมัก", "เส้นใหญ่ผัดซีอิ๊วจนหอมกระทะ ราดน้ำราดหน้าข้นกำลังดี หมูหมักนุ่ม คะน้ากรอบ" ]
  ],
  "desserts" => [
    [ "ข้าวเหนียวมะม่วง", "มะม่วงน้ำดอกไม้สุกหวานหอม ข้าวเหนียวมูนกะทิเค็มนิด ๆ โรยถั่วทองกรอบ ของหวานที่นักท่องเที่ยวต้องลอง" ],
    [ "บัวลอยไข่หวาน", "บัวลอยแป้งนุ่มหลากสีในน้ำกะทิอุ่น ใส่ไข่หวานลูกกลม ๆ ทานตอนหน้าฝนดีที่สุด" ],
    [ "ทับทิมกรอบ", "แห้วหุ้มแป้งมันสีแดงกรอบเด้ง ในน้ำกะทิหอมใบเตย ใส่น้ำแข็งไสเย็นชื่นใจ" ],
    [ "ขนมครก", "ขนมครกหน้ากะทิหวานมัน ตัวขนมกรอบนอกนุ่มใน มีหน้าข้าวโพดกับต้นหอมให้เลือก" ],
    [ "ลอดช่องน้ำกะทิ", "ลอดช่องสีเขียวจากใบเตย น้ำกะทิหวานหอมน้ำตาลโตนด ใส่น้ำแข็งเกล็ด" ],
    [ "กล้วยบวชชี", "กล้วยน้ำว้าสุกต้มในกะทิ หวานมันเค็มนิด ๆ ของหวานบ้าน ๆ ที่ทำง่ายที่สุด" ]
  ],
  "street-food" => [
    [ "หมูปิ้งข้าวเหนียว", "หมูปิ้งหมักนมสด ปิ้งเตาถ่านจนขอบไหม้นิด ๆ กินกับข้าวเหนียวร้อน ๆ เป็นอาหารเช้าที่หาได้ทุกซอย" ],
    [ "ไก่ทอดหาดใหญ่", "ไก่ทอดหนังกรอบหอมเครื่องเทศ โรยหอมเจียว กินคู่ข้าวเหนียวและน้ำจิ้มหวาน" ],
    [ "ลูกชิ้นปิ้งน้ำจิ้มแจ่ว", "ลูกชิ้นหมูเด้งปิ้งบนเตาถ่าน ราดน้ำจิ้มเผ็ดหวาน ไม้ละห้าบาทหน้าโรงเรียน" ],
    [ "ข้าวมันไก่ประตูน้ำ", "ข้าวมันหอมไขมันไก่ ไก่ต้มนุ่ม น้ำจิ้มเต้าเจี้ยวขิงสับรสจัด ซุปฟักร้อน ๆ ฟรีทุกจาน" ],
    [ "โรตีกล้วยไข่", "แป้งโรตีบางกรอบ ห่อกล้วยหอมกับไข่ ราดนมข้นหวาน ร้านรถเข็นตอนหัวค่ำ" ],
    [ "หอยทอดกรอบ", "แป้งทอดกรอบ ใส่หอยแมลงภู่ตัวโต ไข่ ถั่วงอกผัด กินกับซอสพริกศรีราชา" ]
  ],
  "drinks" => [
    [ "ชาไทยเย็น", "ชาไทยสีส้มเข้ม ชงหอม ใส่นมข้นจืดกับนมข้นหวาน เย็นชื่นใจในวันที่อากาศร้อนสี่สิบองศา" ],
    [ "กาแฟโบราณ", "กาแฟชงถุงผ้าแบบร้านกาแฟสมัยก่อน เข้ม หวานมัน ทานคู่ปาท่องโก๋" ],
    [ "น้ำมะพร้าวปั่น", "มะพร้าวน้ำหอมปั่นกับน้ำแข็ง เนื้อมะพร้าวอ่อนเป็นชิ้น ๆ หวานธรรมชาติ" ],
    [ "ชามะนาว", "ชาดำเย็นบีบมะนาวสด เปรี้ยวหวานสดชื่น ดื่มคู่อาหารรสจัดเข้ากันมาก" ],
    [ "โอเลี้ยง", "กาแฟดำเย็นสูตรโบราณ หวานเข้ม ใส่น้ำแข็งเต็มแก้ว ราคาย่อมเยาที่สุดในร้าน" ],
    [ "นมเย็น", "นมสีชมพูจากน้ำแดง หวานหอม เด็ก ๆ ชอบที่สุด เติมไข่มุกได้" ]
  ]
}

categories = CATEGORIES.to_h do |slug, name|
  [ slug, Category.find_or_create_by!(slug: slug) { |c| c.name = name } ]
end

# สลับหมวดกันทีละชิ้น หน้าแรกจะได้มีหลายหมวดปนกัน · ชิ้นที่สร้างทีหลังใหม่กว่า
position = 0
6.times do |round|
  CATEGORIES.each_key do |slug|
    title, description = POSTS[slug][round]
    Post.find_or_create_by!(title: title) do |post|
      post.description = description
      post.category    = categories[slug]
      post.created_at  = (30 - position).days.ago
    end
    position += 1
  end
end

puts "categories: #{Category.count} · posts: #{Post.count}"

ไม่ต้องอ่านข้อความไทยทุกบรรทัด · ดูแค่โครงหลัก:

ท่อนแปลเป็นภาษาคน
CATEGORIES = { "thai-food" => "อาหารไทย", … } hash (บท 92) ของหมวด 5 หมวด: slug (ชื่อสั้นภาษาอังกฤษไว้ใส่ใน URL) => ชื่อไทย · ตัวแปรที่ขึ้นต้นด้วยตัวใหญ่ทั้งคำ = ค่าคงที่ (constant) ไม่ตั้งใจให้เปลี่ยน
POSTS = { "thai-food" => [ [ "ต้มยำ…", "…" ], … ] } หมวดละ 6 บทความ · แต่ละบทความเป็นคู่ [ ชื่อเรื่อง, คำอธิบาย ]
categories = CATEGORIES.to_h do |slug, name| … end วนทีละหมวด สร้าง (หรือหาของเดิม) แล้วเก็บเป็น hash ใหม่ slug => หมวดที่อยู่ในตู้ ไว้ใช้ท่อนถัดไป · to_h = "แปลงผลของการวนเป็น hash" (แต่ละรอบคืนคู่ [ slug, หมวด ])
Category.find_or_create_by!(slug: slug) { |c| c.name = name } "หาหมวดที่ slug นี้ · ไม่เจอค่อยสร้าง แล้วกรอกชื่อไทยให้" · { |c| … } คือบล็อกแบบบรรทัดเดียว เหมือน do |c| … end (บท 93) · คำว่า find_or_create_by! อธิบายละเอียดใต้ผลรัน
6.times do |round| + CATEGORIES.each_key do |slug| วน 6 รอบ · ในแต่ละรอบหยิบหมวดละหนึ่งชิ้น → ได้ลำดับ อาหารไทย ก๋วยเตี๋ยว ของหวาน ริมทาง เครื่องดื่ม แล้ววนใหม่ · หน้าแรกจะมีหลายหมวดปนกัน
title, description = POSTS[slug][round] หยิบคู่ [ ชื่อเรื่อง, คำอธิบาย ] ออกมา แล้วแตกใส่สองตัวแปรในบรรทัดเดียว (ตัวแรกไป title ตัวที่สองไป description)
post.created_at = (30 - position).days.ago ตั้งวันที่เขียน: ชิ้นแรก 30 วันก่อน · ชิ้นสุดท้าย 1 วันก่อน · days.ago = "นับถอยหลังจากตอนที่รัน" → วันที่ในเครื่องคุณจะต่างจากของผม · position += 1 = นับเพิ่มทีละหนึ่งหลังสร้างแต่ละชิ้น
post.category = categories[slug] ผูกบทความเข้ากับหมวด ด้วยหมวดที่เก็บไว้ในท่อนแรก (belongs_to บท 115 — Rails เติม category_id ให้เอง)
puts "categories: #{…} · posts: #{…}" พิมพ์สรุปจำนวนหลังรันเสร็จ (#{ } = แทรกค่าลงในข้อความ บท 91)

รันสองรอบติดกัน:

$ bin/rails db:seed
categories: 5 · posts: 30
$ bin/rails db:seed
# ↑ รอบที่สอง · ไฟล์เดิม ไม่ได้แก้อะไร

ตอนนี้ตู้มี 30 บทความแล้ว · รันซ้ำอีกรอบจะพิมพ์ว่า posts เท่าไหร่?

$ bin/rails db:seed
categories: 5 · posts: 30

ยัง 30 · รอบสองไม่ได้สร้างอะไรเพิ่มเลย · ผมล้าง log แล้วรันอีกรอบ เปิด log/development.log นับได้ว่ามีแต่การค้นหา (SELECT) 38 บรรทัด · การเพิ่มแถว (INSERT) 0 บรรทัด · บรรทัดแรก ๆ หน้าตาแบบนี้:

Category Load (0.4ms)  SELECT "categories".* FROM "categories" WHERE "categories"."slug" = 'thai-food' LIMIT 1 /*application='WongnokBlogAllInOne'*/
Category Load (0.3ms)  SELECT "categories".* FROM "categories" WHERE "categories"."slug" = 'noodles' LIMIT 1 /*application='WongnokBlogAllInOne'*/

= "ขอดูหมวดที่ slug เป็น thai-food หนึ่งแผ่น" · เจอ → ใช้ของเดิม ไม่สร้าง · ทำแบบนี้ทั้ง 5 หมวด 30 บทความ · ท้ายบรรทัด /*application='…'*/ = ป้ายที่ Rails แปะใน SQL บอกว่ามาจากแอปไหน ไม่ต้องสนใจ

ตัวที่ทำให้ไม่เบิ้ลคือ find_or_create_by! = "มีแล้วใช้ของเดิม ไม่มีค่อยสร้าง" · Post.find_or_create_by!(title: title) do |post| … end แปลว่า "หาบทความที่ title ตรงนี้ · เจอก็เอาอันนั้น · ไม่เจอค่อยสร้างใหม่ โดยกรอกช่องอื่นตามบล็อก" · ! ท้ายชื่อ = ถ้าสร้างไม่ผ่าน validation ให้ระเบิด ไม่เงียบ (บท 110)

🗂 เทียบให้เห็นภาพ — ใบสั่งงาน "มีแล้วข้าม"

seeds ของ Day 2 ใช้ create! = ใบสั่งคนดูแลสต็อกว่า "ใส่แฟ้มตัวอย่าง 30 แผ่นลงลิ้นชัก" · ส่งใบนี้สองรอบ ได้ 60 แผ่น ทุกเรื่องซ้ำสองแผ่น · find_or_create_by! = ใบสั่งว่า "เปิดลิ้นชักดูหัวแฟ้มก่อน ชื่อเรื่องนี้มีแล้วข้าม ไม่มีค่อยใส่" · ส่งกี่รอบก็ได้ 30 แผ่นเท่าเดิม

ที่ไม่เหมือนกัน: คนจริงอาจอ่านทั้งแฟ้มเทียบว่า "แผ่นเดียวกันไหม" · Rails ดูแค่ช่องที่เราบอก (ที่นี่คือ title) · ถ้าคุณแก้คำอธิบายใน seeds แล้วรันซ้ำ บทความเดิมไม่ถูกแก้ตาม — บล็อก do |post| … end ทำงานเฉพาะตอนสร้างใหม่ · ผมลองเปลี่ยนคำอธิบายของ "นมเย็น" แล้วรันซ้ำ ในตู้ยังเป็น นมสีชมพูจากน้ำแดง หวานหอม… ข้อความเดิม · และถ้าแก้ชื่อเรื่อง จะได้บทความใหม่เพิ่มอีกชิ้น

เทียบกับ Go: GORM มี FirstOrCreate ทำงานแบบเดียวกัน ("หาก่อน ไม่เจอค่อยสร้าง") ที่เจอในบท 53 · นี่คือร่างเล็ก ๆ ของประโยคที่ ② ของวัน — ไม่มีใครรับประกันได้ว่าคำสั่งจะถูกรันครั้งเดียว (เพื่อนกดซ้ำ · สคริปต์ติดตั้งรันใหม่) · ทางที่ปลอดภัยคือเขียนให้รันซ้ำแล้วผลเท่าเดิม

# ลองแก้ใน db/seeds.rb บรรทัดเดียว (ตอนนี้ตู้มี 30 บทความแล้ว)
    Post.find_or_create_by!(title: title) do |post|   # ก่อน
    Post.create!(title: title) do |post|               # หลัง — แบบ seeds ของ Day 2

$ bin/rails db:seed

จะพิมพ์ว่า posts เท่าไหร่? แล้วถ้าแก้บรรทัดของ Category เป็น create! ด้วย จะเกิดอะไร?

$ bin/rails db:seed
categories: 5 · posts: 60

ได้ 60 — บทความชื่อซ้ำกันเข้าตู้ได้หน้าตาเฉย เพราะ Post ไม่มีกฎห้ามชื่อเรื่องซ้ำ ทั้งใน model และใน DB · หน้า /posts จะมีทุกเรื่องสองชิ้น

ส่วนหมวดถ้าเปลี่ยนเป็น Category.create!(slug: slug, name: name) แล้วรันซ้ำ (ผลจริง ตัดท้าย):

$ bin/rails db:seed
bin/rails aborted!
ActiveRecord::RecordInvalid: Validation failed: Slug has already been taken (ActiveRecord::RecordInvalid)
…/db/seeds.rb:56:in 'block in <main>'
…

หมวดมีกฎ uniqueness ที่ slug (บท 111) จึงระเบิดตั้งแต่หมวดแรก · ทั้งสองแบบคือ "รันซ้ำแล้วพัง" — แบบแรกพังเงียบ (ข้อมูลเบิ้ล) แบบหลังพังดัง · แก้กลับเป็น find_or_create_by! ก่อนไปต่อ (ถ้าตู้เบิ้ลไปแล้ว ดูวิธีล้างในภารกิจท้ายบท)

🗣 ศัพท์ที่เพิ่งเรียนไป
seed data · find_or_create_by!
seed data = ข้อมูลตัวอย่าง/ข้อมูลตั้งต้นที่ใส่ด้วย db/seeds.rb · find_or_create_by! = หาตามช่องที่บอก เจอใช้ของเดิม ไม่เจอสร้าง · บอก AI ได้ว่า "make db/seeds.rb idempotent with find_or_create_by! keyed on title"
idempotent
(อ่านว่า ไอเดมโพเทนต์) = ทำซ้ำกี่รอบผลก็เท่ากับทำรอบเดียว · คำนี้จะกลับมาเต็ม ๆ ใน Part 2 · ถาม senior ได้ว่า "สคริปต์นี้ idempotent ไหม รันซ้ำแล้วข้อมูลเบิ้ลหรือเปล่า" — คำถามที่ช่วยกันบั๊กได้มากกว่าที่คิด

เก็บ seeds ไว้ด้วย commit — บท 122–125 เราจะทดลองแก้ไฟล์ แล้วสั่ง git restore (คืนไฟล์ที่แก้แต่ยังไม่ commit กลับเป็นสภาพตอน commit ล่าสุด) · ถ้า seeds ยังไม่ได้ commit มันจะถูกคืนเป็นไฟล์ว่างไปด้วย · และเราจะไม่ commit ทับสาย day3-start ของอาจารย์ แต่แตกสายของเราเองชื่อ my-day3:

$ git status
On branch day3-start
Your branch is up to date with 'origin/day3-start'.

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   db/seeds.rb

no changes added to commit (use "git add" and/or "git commit -a")
$ git switch -c my-day3
Switched to a new branch 'my-day3'
$ git add db/seeds.rb
$ git commit -m "seeds: 5 หมวด 30 บทความ"
[my-day3 0e10665] seeds: 5 หมวด 30 บทความ
 1 file changed, 73 insertions(+), 9 deletions(-)

ผมเทียบไฟล์ทั้งโปรเจกต์ของสาย my-day3 กับโค้ดที่บทถัด ๆ ไปใช้ตรวจแล้ว ตรงกันทุกไฟล์ · และวันหลังถ้าคุณตั้งตู้ใหม่ (เช่น db:drop แล้ว db:prepare) ข้อมูล 30 ชิ้นจะเข้าตู้ให้เองเลย เพราะ seeds อยู่ในสายแล้ว (ผมลองบนตู้ใหม่ได้ categories: 5 · posts: 30) · แต่ถ้าตู้มีอยู่แล้ว db:prepare จะไม่รัน seeds ซ้ำ — ต้องสั่ง db:seed เอง

8 · ขั้น 5 — bin/dev แล้วเปิด /posts

Day 1–2 เราเปิดร้านด้วย bin/rails server · Day 3 มีงานที่สองที่ต้องวิ่งคู่กัน: คอยสร้างไฟล์ CSS ของ Tailwind ใหม่ทุกครั้งที่เราแก้ view · bin/dev เปิดทั้งสองงานพร้อมกันตามรายการใน Procfile.dev — ทำนองเดียวกับ npm run dev ของ Next.js (บท 72)

# Procfile.dev
web: bin/rails server
css: bin/rails tailwindcss:watch

web: = ร้าน Rails ตัวเดิม · css: = คนคอยเฝ้าไฟล์ แก้เมื่อไหร่สร้าง CSS ใหม่ (ละเอียดบท 128) · ครั้งแรกถ้าเครื่องยังไม่มี gem ชื่อ foreman (ตัวที่อ่าน Procfile แล้วเปิดหลายงานพร้อมกัน) bin/dev จะติดตั้งให้เองก่อน — ดูได้จากบรรทัดต้น ๆ ของไฟล์ bin/dev

$ bin/dev
11:27:37 web.1  | started with pid 40007
11:27:37 css.1  | started with pid 40008
11:27:37 web.1  | => Booting Puma
11:27:37 web.1  | => Rails 8.1.4 application starting in development
…
11:27:37 web.1  | * Listening on http://127.0.0.1:3000
11:27:37 web.1  | * Listening on http://[::1]:3000
11:27:37 web.1  | Use Ctrl-C to stop
11:27:38 css.1  | ≈ tailwindcss v4.3.3
11:27:38 css.1  |
11:27:38 css.1  | /*! 🌼 daisyUI 5.7.50 */
11:27:38 css.1  | Done in 89ms
…

เปิดเบราว์เซอร์ไปที่ http://localhost:3000/posts · log ในหน้าต่างเดียวกับ bin/dev ขึ้นแบบนี้ (ผมตัดบรรทัด Rendered posts/_post.html.erb 30 บรรทัดที่ซ้ำกันออก) · ก้อนนี้ผมดึงหน้าด้วย curl บรรทัดที่สองจึงลงท้าย as */* · ถ้าเปิดด้วยเบราว์เซอร์ จะเป็น as HTML (ผมเปิดด้วย Chrome แล้วได้แบบนั้น)

11:27:40 web.1  | Started GET "/posts" for ::1 at 2026-11-07 11:27:40 +0700
11:27:40 web.1  | Processing by PostsController#index as */*
11:27:40 web.1  |   Rendering layout layouts/application.html.erb
11:27:40 web.1  |   Rendering posts/index.html.erb within layouts/application
11:27:40 web.1  |   Post Exists? (1.9ms)  SELECT 1 AS one FROM "posts" LIMIT 1 /*action='index',…*/
11:27:40 web.1  |   ↳ app/views/posts/index.html.erb:15
11:27:40 web.1  |   Post Load (0.7ms)  SELECT "posts".* FROM "posts" /*action='index',…*/
11:27:40 web.1  |   ↳ app/views/posts/index.html.erb:16
…
11:27:40 web.1  | Completed 200 OK in 65ms (Views: 28.5ms | ActiveRecord: 6.8ms (2 queries, 0 cached) | GC: 16.6ms)

30 ชิ้นยังไหว · ถ้ามีหมื่นชิ้นก็หยิบหมื่นชิ้น — กฎข้อ 6 ใน prompts/00-context.md ("index action ต้องมี limit หรือ pagination") ยังไม่ผ่าน · เราจะแก้ใน Lab บท 126

HTML ที่เบราว์เซอร์ได้รับจริง (ผมดึงด้วย curl -s localhost:3000/posts ตัดมาบทความแรก) · ใน development Rails แปะ comment บอกว่าท่อนไหนมาจากไฟล์ไหนให้ด้วย (ตั้งไว้ใน development.rb: annotate_rendered_view_with_filenames = true):

โค้ด — app/views/posts/_post.html.erb

<div id="<%= dom_id post %>" class="…">
  <div>
    <strong …>Title:</strong>
    <%= post.title %>
  </div>
  <div>
    <strong …>Description:</strong>
    <%= post.description %>
  </div>
  <div>
    <strong …>Category:</strong>
    <%= post.category_id %>
  </div>
</div>

HTML ที่ได้ (ของจริง)

<!-- BEGIN app/views/posts/_post.html.erb
--><div id="post_1" class="w-full sm:w-auto my-5 space-y-5">
  <div>
    <strong class="block font-medium mb-1">Title:</strong>
    ต้มยำกุ้งน้ำข้น
  </div>
  <div>
    <strong class="block font-medium mb-1">Description:</strong>
    ต้มยำกุ้งแบบน้ำข้นใส่นมข้นจืด รสเปรี้ยวเผ็ดกลมกล่อม …
  </div>
  <div>
    <strong class="block font-medium mb-1">Category:</strong>
    1
  </div>
</div>
<!-- END app/views/posts/_post.html.erb -->

บนจอเบราว์เซอร์ (ผมเปิดด้วย Chrome จริง) จะเห็นรายการยาว 30 ชิ้นต่อกันในหน้าเดียว · แต่ละชิ้นมีหัวข้อ Title: Description: Category: กับปุ่ม Show · Edit · Destroy · และป้ายเล็กมุมซ้ายบนหน้าตาประมาณ 699.6 ms ×17 = rack-mini-profiler (gem ตัวจับเวลาว่าหน้านี้ใช้เวลาเท่าไหร่ · ตัวเลขของคุณจะต่าง · เลขหลัง × ไม่ต้องสนใจตอนนี้ · หน้าแรกหลังเปิดร้านมักช้ากว่าหน้าถัด ๆ ไป) · ส่วนแถบเตือนของ bullet ยังไม่ขึ้น เพราะหน้านี้ยังไม่มี N+1 (มันพิมพ์แค่เลข category_id ไม่ได้เดินไปเปิดลิ้นชัก categories) — เราจะเห็นมันโผล่ในบท 125

✅ นี่คือจุดเริ่มของวัน: หน้ารายการที่หยิบทั้งลิ้นชัก · หมวดเป็นตัวเลข · ไม่มีแบ่งหน้า · หน้าตาเป็นฟอร์มราชการ · ทั้งวันเราจะเปลี่ยนหน้านี้ให้เป็นเว็บจริง — Lab 126 อ่านรู้เรื่อง · Lab 129 สวย · บท 131–135 ขยับได้

🗣 ศัพท์ที่เพิ่งเรียนไป
bin/dev · Procfile.dev · foreman
bin/dev = สคริปต์เปิดร้านแบบ Day 3 · Procfile.dev = รายการงาน "ชื่อ: คำสั่ง" · foreman = ตัวเปิดทุกงานในรายการพร้อมกันแล้วรวม log ไว้หน้าจอเดียว · ถาม senior ได้ว่า "โปรเจกต์นี้เปิดด้วย bin/dev หรือ bin/rails server — CSS จะได้ build ด้วย"
rack-mini-profiler
gem ที่แปะป้ายเวลาโหลดหน้าไว้มุมจอตอน development (ผู้ใช้จริงไม่เห็น) · บอก AI ได้ว่า "mini-profiler shows this page takes 700ms — help me find which query is slow"

9 · ตามไม่ทัน — branch สำเร็จรูปของอาจารย์

สไลด์บอกว่าทุกบล็อกมี branch "ทำเสร็จแล้ว" ให้ checkout ตามทันได้ (day3-a-done · day3-b-done · day3-c-done) · มีจริงตามที่เห็นใน git branch -a

⚠️ branch สำเร็จรูปของอาจารย์ไม่ตรงกับโค้ดในบทเรียนของเว็บนี้ — ผู้เตรียมแอปของเว็บนี้เปิดดูแล้ว พบว่าใช้ระบบแปลภาษา (t(".title") + gem rails-i18n) และ seeds คนละชุด · ถ้า checkout ไป ไฟล์จะหน้าตาต่างจากที่บทเรียนพาพิมพ์ และผลรันจะไม่ตรง · ทางที่ดีกว่า: ทุกบท Lab บอกไว้ต้นบทว่า "เริ่มจากโค้ดสถานะไหน" — ตามไม่ทันให้กลับไปทำบท Lab ก่อนหน้า (126 · 129 · 131–134) ตามลำดับ

🎯 ภารกิจ (~10 นาที) — ให้เห็น 30 บทความ แล้วนับใน console

  1. ทำขั้น 1–5 ให้ครบ: clone · git checkout day3-start · bundle install · docker compose up -d · bin/rails db:prepare · วาง seeds · db:seed สองรอบ · git switch -c my-day3 + commit
  2. bin/dev แล้วเปิด http://localhost:3000/posts · เลื่อนลงล่างสุด บทความสุดท้ายควรเป็น นมเย็น
  3. เปิดอีกแท็บเทอร์มินัล (ปล่อย bin/dev ไว้) · cd เข้าโฟลเดอร์โปรเจกต์ · bin/rails console
  4. ถามตู้ 4 คำถาม: มีบทความกี่ชิ้น (.count) · มีกี่หมวด · บทความใหม่สุดชื่ออะไร (ใช้ scope recent ที่มีใน post.rb แล้วเอาชิ้นแรก) · หมวดของหวาน (slug desserts) มีกี่ชิ้น (หาหมวดด้วย find_by แล้วต่อ .posts · บท 115)
  5. พิมพ์ exit ออกจาก console · รัน bin/rails db:seed อีกรอบ · เข้า console นับใหม่

ควรเห็น: 30 · 5 · "นมเย็น" · 6 · และหลัง seed รอบที่สามยังเป็น 30

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

ข้อ 3–4 ผลจริง (เวลาในวงเล็บ · เลข id ของหมวดของคุณอาจต่าง):

$ bin/rails console
Loading development environment (Rails 8.1.4)
wongnok-blog-all-in-one(dev):001> Post.count
  Post Count (39.7ms)  SELECT COUNT(*) FROM "posts" /*application='WongnokBlogAllInOne'*/
=> 30
wongnok-blog-all-in-one(dev):002> Category.count
  Category Count (1.0ms)  SELECT COUNT(*) FROM "categories" /*application='WongnokBlogAllInOne'*/
=> 5
wongnok-blog-all-in-one(dev):003> Post.recent.first.title
  Post Load (4.0ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT 1 /*application='WongnokBlogAllInOne'*/
=> "นมเย็น"
wongnok-blog-all-in-one(dev):004> Category.find_by(slug: "desserts").posts.count
  Category Load (0.4ms)  SELECT "categories".* FROM "categories" WHERE "categories"."slug" = 'desserts' LIMIT 1 /*application='WongnokBlogAllInOne'*/
  Post Count (0.7ms)  SELECT COUNT(*) FROM "posts" WHERE "posts"."category_id" = 3 /*application='WongnokBlogAllInOne'*/
=> 6
  • wongnok-blog-all-in-one(dev) = ชื่อโปรเจกต์ + โหมด development · ต่างจาก wongnok-blog(dev) ของ Day 2 → ใช้เช็คได้ว่าเปิดถูกโปรเจกต์
  • Post.recent.first → ORDER BY "posts"."created_at" DESC LIMIT 1 = "เรียงจากวันที่ใหม่ไปเก่า เอาแผ่นแรก" · ได้นมเย็นเพราะ seeds ให้มันเป็นชิ้นสุดท้าย (1 วันก่อน)
  • คำถามที่ 4 เดินไปตู้สองครั้ง: หาหมวดก่อน แล้วนับบทความที่ category_id ตรงกับหมวดนั้น (has_many :posts · บท 115)

ข้อ 5: db:seed พิมพ์ categories: 5 · posts: 30 และนับใหม่ได้ 30 เท่าเดิม · ถ้าต้องใช้ db:reset ผลจริงหน้าตาแบบนี้ (ทิ้งตู้ ตั้งใหม่จาก schema.rb แล้วรัน seeds · เลขที่แฟ้มเริ่มจาก 1 ใหม่):

$ bin/rails db:reset
Dropped database 'wongnok_blog_all_in_one_development'
Dropped database 'wongnok_blog_all_in_one_test'
Created database 'wongnok_blog_all_in_one_development'
Created database 'wongnok_blog_all_in_one_test'
categories: 5 · posts: 30

db:reset ลบข้อมูลทั้งหมดจริง · ใช้กับตู้ฝึกในเครื่องเท่านั้น

ทดสอบความเข้าใจ

ข้อ 1 · ตู้ว่าง แล้วรัน bin/rails db:seed ด้วย seeds ของบทนี้ 3 รอบติดกัน · สุดท้าย Post.count ได้เท่าไหร่?

ตัวเลือกที่ 2 ถูก · ผลจริงในหัวข้อ 7: รอบสองพิมพ์ posts: 30 และใน log มีแต่ SELECT ไม่มี INSERT สักบรรทัด

ตัวเลือกที่ 1 คือพฤติกรรมของ create! (seeds แบบ Day 2 — ในหัวข้อ 7 ผมเปลี่ยนเป็น create! แล้วรันรอบเดียวบนตู้ที่มี 30 ก็ได้ 60) · ตัวเลือกที่ 3 ผิดเพราะ find_or_create_by! หาก่อน · เจอแล้วไม่สร้าง จึงไม่มีอะไรให้ชน (และ Post ไม่มีกฎห้ามชื่อซ้ำด้วย) · ตัวเลือกที่ 4 ได้เลขถูกแต่เหตุผลผิด — ไม่มีบรรทัดไหนในไฟล์ลบของ และของเดิมยังเป็นแผ่นเดิม เลขที่แฟ้มไม่เปลี่ยน

จุดที่ต้องจำ: ไฟล์ seeds ที่ดี "รันซ้ำแล้วผลเท่าเดิม" — ร่างเล็กของประโยค ② ของวัน

ข้อ 2 · ทำไม Day 3 ให้ clone โปรเจกต์ของอาจารย์ แทนที่จะใช้แอป Day 2 ที่เราทำเองต่อ?

ตัวเลือกที่ 4 ถูก · สองเรื่องจากสไลด์หน้า 3: ใครทำ Lab 2 ไม่เสร็จก็ไม่ตกขบวน · และเข้าทำงานจริงแทบไม่มีใครได้เริ่มจาก rails new

ตัวเลือกที่ 1 ผิด — เครื่องตรวจใช้ Rails 8.1.4 ทั้งสองแอป · ตัวเลือกที่ 2 ผิด — ชื่อฐานข้อมูลต่างกัน (wongnok_blog_… กับ wongnok_blog_all_in_one_…) แอปเก่าเก็บไว้ได้ · ตัวเลือกที่ 3 ผิด — day3-start เป็น scaffold ดิบ ๆ หมวดยังโชว์เป็นตัวเลข · งานทั้งวันคือทำ view ให้เป็นเว็บจริง

จุดที่ต้องจำ: ทักษะที่วันนี้ฝึกคือ "เข้าไปต่อของที่มีอยู่" — อ่านโค้ดคนอื่นก่อน แล้วค่อยแก้

ข้อ 3 · อยู่สาย my-day3 ที่ตู้มี 30 บทความ แล้วสั่ง git switch day3-start (สายที่ seeds ยังว่าง) · ถาม Post.count ได้เท่าไหร่?

ตัวเลือกที่ 1 ถูก · ผมลองแล้ว: สลับไป day3-start ไฟล์ db/seeds.rb กลับเป็น comment ของ Rails แต่ Post.count ยังได้ 30

ตัวเลือกที่ 2 คิดว่าตู้เก็บแฟ้มเป็นส่วนหนึ่งของร้านที่ git จัดได้ — ไม่ใช่ ข้อมูลอยู่ใน PostgreSQL นอกโฟลเดอร์ · ตัวเลือกที่ 3 ผิด — ตู้ยังเป็นตู้เดิม ไม่มีอะไรต้องเตรียมใหม่ (ยกเว้นสายใหม่มี migration เพิ่ม ซึ่ง day3-start ไม่มี) · ตัวเลือกที่ 4 ผิด — git ไม่ได้แตะข้อมูลในตู้เลย

จุดที่ต้องจำ: git ดูแลไฟล์ · ข้อมูลในฐานข้อมูลต้องจัดการเอง (seeds · migration)

ข้อ 4 · หน้า /posts ช่อง Category โชว์เลข 1 2 3 แทนชื่อหมวด · เพราะอะไร?

ตัวเลือกที่ 3 ถูก · HTML จริงในหัวข้อ 8: <%= post.category_id %> ใน _post.html.erb → 1 · scaffold ไม่รู้ว่าเราอยากเห็นชื่อ มันพิมพ์ช่องที่มีในตารางตรง ๆ

ตัวเลือกที่ 1 ผิด — ใน console Category.count ได้ 5 และทุกหมวดมีชื่อ · ตัวเลือกที่ 2 ผิด — bullet แค่เตือน ไม่เคยแก้หน้าเว็บ · และหน้านี้ยังไม่มี N+1 เพราะไม่ได้เปิดลิ้นชัก categories เลย · ตัวเลือกที่ 4 ผิด — db:prepare โหลด schema ครบทั้งสองตารางแล้ว

จุดที่ต้องจำ: โชว์ชื่อหมวด (post.category.name) จะทำให้ต้องเดินไปลิ้นชัก categories ทีละบทความ = N+1 · Lab บท 126 จะแก้ทั้งสองเรื่องพร้อมกัน

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

หน้าสไลด์เขียนของจริงต้องสนใจไหม
3กล่องตามทัน: git clone → git checkout day3-start → bin/rails db:prepare db:seed ขาด bundle install กับ docker compose up -d ก่อน db:prepare · และ seeds ของ day3-start ว่าง db:seed จึงไม่ได้อะไร✅ ใช่ — ทำตามขั้น 1–4 ของบทนี้
3posts.description เป็น text (ไม่มี NOT NULL)schema.rb เป็น null: false + model มี validates :description, presence: trueนิดเดียว — บทความต้องมีคำอธิบายเสมอ
3"โฟลเดอร์ prompts/ ที่ยังว่างอยู่"มี prompts/00-context.md (stack + กฎ 8 ข้อจาก Day 2) แล้วไม่ — บท 121 อ่านไฟล์นี้
3"ทุกบล็อคจะมี branch แบบนี้" ไว้ตามทันมีจริง แต่ใช้ i18n + seeds คนละชุด ไม่ตรงกับบทเรียนของเว็บนี้ใช่ ถ้าจะใช้ตามทัน — อ่านหัวข้อ 9
5Stack ใน 00-context.md: Ruby 4.0.6 / Rails 8.1.3 / PostgreSQL 17ไฟล์จริงเขียน Ruby 4.0.5 / Rails 8.1.3 / PostgreSQL 18 · Gemfile ของ day3-start ขอ Rails ~> 8.1.4 · compose.yaml เปิด PostgreSQL 18 · เครื่องตรวจใช้ Ruby 4.0.9ไม่
20"seed 30 โพสต์ 5 หมวด" เป็นงานใน Lab Vibe 1เว็บนี้ทำไว้ตั้งแต่บทนี้ เพื่อให้บท 121–125 มีข้อมูลทดลองไม่ — Lab 126 ข้ามข้อนี้ได้

อ้างอิง