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

เปิดร้าน — จาก bin/rails server ถึงหน้าเว็บ และ MVC ของจริง

~20 นาที (ไม่รวมภารกิจ) · ควรผ่าน บทเรียน 98 (rails new) และ บทเรียน 99 (กฎตั้งชื่อ) มาก่อน

บทก่อน ๆ เราดูโปรเจกต์ Rails ตอนที่มันยังนิ่งอยู่ บทนี้เราจะเปิดร้านจริง แล้วเดินตามใบสั่งหนึ่งใบ ตั้งแต่ลูกค้าพิมพ์ URL จนได้หน้าเว็บกลับไป
อ่านจบแล้วคุณจะทำหน้า “บทความทั้งหมด” ได้เองหนึ่งหน้า · อ่าน log ของ server ออกว่าแต่ละบรรทัดบอกอะไร · และเข้าใจว่า Model / View / Controller ต่างจาก handler ของ Gin ที่เคยเขียนตรงไหน

🔁 ทวนก่อนเริ่ม

ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · Puma 8.0.2 (สไลด์ใช้ Ruby 4.0.6 · Rails 8.1.3 ต่างกันแค่รุ่นย่อย ผลเหมือนกัน) · ทุก log และทุกหน้า HTML ในบทนี้ก็อปมาจากการรันจริงบนพอร์ต 3000 · เวลา · PID · ตัวเลข ms ของคุณจะต่างไป เป็นเรื่องปกติ

ภาพรวม — ใบสั่งหนึ่งใบเดินผ่านใครบ้าง

เวลาคุณพิมพ์ localhost:3000/posts แล้วกด Enter browser จะส่งใบสั่ง (request) ไปที่ร้าน ใบสั่งใบนี้ไม่ได้ไปถึงคนทำอาหารทันที มันต้องส่งต่อกันหลายมือ แต่ละมือทำงานแค่อย่างเดียว

บทนี้ทั้งบทคือการเดินตามเส้นข้างล่างนี้ทีละช่วง จำภาพนี้ไว้ก่อน เดี๋ยวเราจะเห็นของจริงทุกช่อง

ลูกค้า (browser) พิมพ์ localhost:3000/posts │ ใบสั่ง: GET /posts ▼ ประตูร้าน (Puma) โปรแกรมที่เปิดพอร์ต 3000 รอรับใบสั่ง │ ▼ ด่านหน้าครัว middleware — ทุกใบสั่งต้องผ่าน (จดบันทึก · ตรวจบัตร ฯลฯ) │ ▼ พนักงานต้อนรับ config/routes.rb — "GET /posts → แผนกบทความ เมนู index" │ ▼ หัวหน้าเชฟ PostsController#index — เตรียมข้อมูลบทความ │ ╲ │ ╲ (Day 2) ห้องเก็บวัตถุดิบ = model Post + database ▼ จัดจาน app/views/posts/index.html.erb — เอาข้อมูลมาประกอบเป็น HTML │ ▼ ลูกค้าได้จาน response: HTML + เลข 200
🍜 เทียบให้เห็นภาพ — ใบสั่งในร้านอาหาร

ลูกค้าเขียนใบสั่ง “ขอดูบทความทั้งหมด” ยื่นที่ประตูร้าน · ใบสั่งผ่านด่านหน้าครัวที่จดลงสมุดว่ามีใบสั่งเข้ามา · พนักงานต้อนรับอ่านแล้วชี้ว่า “ใบนี้ของแผนกบทความ เมนู index” · หัวหน้าเชฟแผนกบทความเตรียมของ · คนจัดจานจัดลงจานสวย ๆ · แล้วยกไปเสิร์ฟที่โต๊ะ

ที่ไม่เหมือนกัน: ในร้านจริงแต่ละคนเป็นคนละคน เดินส่งใบสั่งกันจริง ๆ · แต่ใน Rails ทุก “คน” คือโค้ดที่อยู่ในโปรแกรมเดียวกัน เรียกต่อกันเป็นทอด ๆ ทั้งเส้นใช้เวลาแค่ไม่กี่มิลลิวินาที และคุณเขียนเองแค่ 3 ช่อง (routes · controller · view) ที่เหลือ Rails เตรียมไว้ให้แล้ว

ถ้าเทียบกับ Go: ใน Gin คุณเขียนเกือบทุกช่องเอง — สร้าง router เอง (gin.Default()) · ใส่ middleware เอง (r.Use(...)) · ลง route เอง · เขียน handler ที่ทำทั้งเตรียมข้อมูลและตอบกลับ · แล้วสั่ง r.Run() เปิดประตูเอง · Rails คือร้านแฟรนไชส์ที่ได้ครัวครบชุด คุณแค่วางของลงช่องที่แฟรนไชส์กำหนด

🗣 ศัพท์ที่เพิ่งเห็น
request cycle / request lifecycle
เส้นทางทั้งหมดของ request หนึ่งตัว ตั้งแต่เข้ามาจนตอบกลับ · พิมพ์ถาม AI ได้ว่า “walk me through the Rails request cycle for GET /posts”
controller#action
วิธีเขียนสั้น ๆ ว่า “แผนกไหน เมนูอะไร” เช่น posts#index = action index ของ PostsController · จะเห็นคำนี้ทั้งใน routes.rb และใน log

ก่อนเปิดร้าน — เกิดอะไรขึ้นตอนพิมพ์ bin/rails server

ร้านอาหารไม่ได้เปิดประตูปุ๊บแล้วขายได้เลย ต้องเตรียมครัวก่อน — เปิดไฟ ตั้งเตา เช็ควัตถุดิบ Rails ก็เหมือนกัน ช่วงเตรียมตัวก่อนรับใบสั่งแรกนี้เรียกว่า boot

คุณเคยเปิดร้านมาแล้วหนึ่งครั้งใน บท 98 · รอบนี้เราจะอ่านช้าลงว่าแต่ละบรรทัดบอกอะไร · เข้าไปในโฟลเดอร์โปรเจกต์ wongnok_blog แล้วพิมพ์คำสั่งนี้ในเทอร์มินัล (bin/rails คือโปรแกรมประจำโปรเจกต์ที่รู้จักแล้วในบท 98):

ใช้โปรเจกต์เดิมจากบท 98 ที่ตั้ง DATABASE_URL และสั่ง bin/rails db:prepare แล้ว · ถ้ายังไม่ได้ทำ server จะเปิดได้ แต่พอเปิดหน้าเว็บจะได้ error 500 เพราะต่อ database ไม่ติด (วิธีแก้อยู่ในบท 98)

$ 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: 2674
* Listening on http://127.0.0.1:3000
* Listening on http://[::1]:3000
Use Ctrl-C to stop

อ่านทีละบรรทัด:

ก่อนบรรทัด Listening on จะโผล่ Rails แอบทำงานเตรียมครัวไปหลายอย่าง โดยอ่านไฟล์ตั้งค่าในโฟลเดอร์ config/ เช่น config/application.rb (ตั้งค่าของทั้งร้าน) · config/environments/development.rb (กฎเฉพาะสาขาทดลอง) · และไฟล์ใน config/initializers/ (งานเตรียมเล็ก ๆ ทีละข้อ)
คุณไม่ต้องจำลำดับไฟล์ · จำแค่ว่า “ตั้งค่าอยู่ใน config/ และถูกอ่านตอนเปิดร้าน” ก็พอ

🔥 เทียบให้เห็นภาพ — เตรียมครัวก่อนเปิดร้าน

เช้าก่อนเปิดร้าน ผู้จัดการเดินดูตามเช็คลิสต์: เปิดแก๊ส · ตั้งหม้อน้ำซุป · เช็คของในตู้ · ติดเมนูหน้าร้าน · แล้วค่อยพลิกป้ายหน้าประตูเป็น “เปิด” · ป้าย “เปิด” นั่นแหละคือบรรทัด Listening on

ที่ไม่เหมือนกัน: ร้านจริงต้องปิดร้านทุกครั้งที่อยากเปลี่ยนเมนู · แต่ในโหมด development คุณแก้ routes · controller · view แล้วกด refresh ได้เลย ไม่ต้องปิดเปิด server (ผมลองแก้ทั้งสามไฟล์ตอน server เปิดอยู่ หน้าเว็บเปลี่ยนตามทันที) · ยกเว้นไฟล์ใน config/initializers/ ที่อ่านแค่ตอนเปิดร้าน แก้แล้วต้องปิดเปิดใหม่

🗣 ศัพท์ของการเปิดร้าน
boot
ช่วงที่แอปเตรียมตัวก่อนพร้อมรับ request · ถ้าเปิดช้า คนจะพูดว่า “boot ช้า” · ถาม senior ได้ว่า “แก้ไฟล์ไหนบ้างที่ต้อง restart server”
Puma (web server)
โปรแกรมที่เปิดพอร์ตแล้วรับ request จริง แล้วส่งต่อให้ Rails · Rails ใช้ตัวนี้เป็นค่าเริ่มต้น · ตั้งค่าที่ config/puma.rb
environment (development / production)
โหมดที่แอปกำลังรัน · development = เครื่องเรา แก้โค้ดแล้วเห็นผลทันที · production = เว็บจริง · พิมพ์บอก AI ได้ว่า “this should only run in development” เวลาอยากให้โค้ดบางอย่างทำงานแค่บนเครื่องตัวเอง

ลงมือ — ทำหน้า “บทความทั้งหมด” ด้วย 3 ไฟล์

ร้านเปิดแล้ว แต่ยังไม่มีเมนูอะไรเลย · เราจะเพิ่มหน้า /posts ที่โชว์รายชื่อบทความ โดยแตะแค่ 3 ช่องที่ Rails ให้เราเขียน: พนักงานต้อนรับ → หัวหน้าเชฟ → จัดจาน

วันนี้ยังไม่มี database (รอ Day 2) เราจะจำลองบทความด้วย Hash สองก้อน วางไว้ใน controller ตรง ๆ — เป็นของชั่วคราว ท้ายบทจะเห็นว่ามันควรย้ายไปอยู่ที่ไหน

ไฟล์ที่ 1 · พนักงานต้อนรับ — config/routes.rb

เปิดไฟล์นี้ (มีอยู่แล้วตั้งแต่ rails new) แล้วเพิ่มบรรทัด get "/posts", … เข้าไป · ไฟล์จริงมีบรรทัด comment (ขึ้นต้นด้วย #) อีกหลายบรรทัด ข้างล่างตัดออกให้สั้น — จะลบหรือเก็บไว้ก็ได้ ไม่มีผล:

# config/routes.rb
Rails.application.routes.draw do
  get "up" => "rails/health#show", as: :rails_health_check   # มีมาให้แล้ว ไม่ต้องแตะ

  get "/posts", to: "posts#index"      # ← เพิ่มบรรทัดนี้
end

ไฟล์ที่ 2 · หัวหน้าเชฟ — app/controllers/posts_controller.rb

สร้างไฟล์ใหม่ ชื่อต้องเป๊ะตามกฎตั้งชื่อ (บท 99):

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def index
    # ข้อมูลจำลอง — ยังไม่มี database (รอ Day 2)
    @posts = [
      { title: "ข้าวซอยเจ้าเด็ด", author: "สมชาย" },
      { title: "ส้มตำปูปลาร้า", author: "สมหญิง" }
    ]
  end
end

ไฟล์ที่ 3 · จัดจาน — app/views/posts/index.html.erb

สร้างโฟลเดอร์ app/views/posts/ แล้วสร้างไฟล์ index.html.erb ข้างใน:

<h1>บทความทั้งหมด</h1>

<% @posts.each do |post| %>
  <h2><%= post[:title] %></h2>
  <p>โดย <%= post[:author] %></p>
<% end %>

ทำไม Rails รู้ว่าต้องใช้ไฟล์นี้? กฎตั้งชื่อจาก บท 99: PostsController + action index → app/views/posts/index.html.erb ไม่ต้องเขียนบอกเลย

เปิดดูผล

เปิด browser ไปที่ http://localhost:3000/posts จะเห็นหัวข้อ “บทความทั้งหมด” กับบทความสองชิ้น · ถ้าดู HTML ที่ได้กลับมา (กด View Source) ช่วงท้ายของหน้า ตั้งแต่ <main> จะเป็นแบบนี้:

    <main class="container mx-auto mt-28 px-5 flex">
      <!-- BEGIN app/views/posts/index.html.erb
--><h1>บทความทั้งหมด</h1>

  <h2>ข้าวซอยเจ้าเด็ด</h2>
  <p>โดย สมชาย</p>
  <h2>ส้มตำปูปลาร้า</h2>
  <p>โดย สมหญิง</p>
<!-- END app/views/posts/index.html.erb -->
    </main>
  </body>
</html>
<!-- END app/views/layouts/application.html.erb -->

อ่าน log — สมุดบันทึกของร้าน

กลับไปดูเทอร์มินัลที่เปิด server ไว้ ทุกครั้งที่มีใบสั่งเข้ามา Rails จะจดลงสมุด (log) ให้เอง · นี่คือ log จริงตอนเปิดหน้า /posts:

Started GET "/posts" for ::1 at 2026-11-03 20:47:30 +0700
Processing by PostsController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering posts/index.html.erb within layouts/application
  Rendered posts/index.html.erb within layouts/application (Duration: 0.3ms | GC: 0.0ms)
  Rendered layout layouts/application.html.erb (Duration: 6.5ms | GC: 0.0ms)
Completed 200 OK in 18ms (Views: 7.7ms | ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)

อ่านคู่กับภาพร้านอาหาร (ตัวเลขเวลาของคุณจะต่าง):

บรรทัดใน logแปลว่าในร้านอาหาร
Started GET "/posts" for ::1 มี request แบบ GET มาที่ /posts จากเครื่องตัวเอง (::1) ด่านหน้าครัวจดว่า “ใบสั่งใหม่เข้ามาแล้ว”
Processing by PostsController#index as HTML routes จับคู่ได้แล้ว ส่งให้ action index ของ PostsController · ลูกค้าขอเป็น HTML พนักงานต้อนรับชี้แผนกเสร็จ หัวหน้าเชฟแผนกบทความรับใบสั่ง
Rendering posts/index.html.erb within layouts/application เริ่มสร้าง HTML จาก view ของเรา วางใน layout เริ่มจัดจาน วางบนจานรองของร้าน
Rendered … (Duration: 0.3ms …) สร้าง HTML เสร็จ ใช้เวลาเท่านี้ จัดจานเสร็จ
Completed 200 OK in 18ms ส่ง response กลับแล้ว · status 200 = สำเร็จ · ทั้งเส้นใช้ 18 มิลลิวินาที ยกจานถึงโต๊ะเรียบร้อย

ส่วน ActiveRecord: 0.0ms (0 queries …) ท้ายบรรทัดสุดท้าย บอกว่ารอบนี้ไม่ได้แตะ database เลย (Active Record คือส่วนของ Rails ที่คุยกับ database — เจอจริง Day 2) — ตรงกับที่เราจำลองข้อมูลไว้ใน controller · Day 2 ตัวเลขนี้จะเริ่มขยับ

request แรกหลังเปิด server คุณอาจเห็นบรรทัด ActiveRecord::SchemaMigration Load … แทรกมาอีกหนึ่งบรรทัด — Rails แค่แง้มดูว่าตู้เก็บแฟ้ม (database) พร้อมไหม ไม่ต้องตกใจ (:: อ่านว่า “ที่อยู่ข้างใน” แบบที่เจอในบท 99)

ถ้าเทียบกับ Gin: log ของ Gin ต่อหนึ่ง request มีบรรทัดเดียว (ผลจริงจากโปรแกรม Go ในหัวข้อ MVC ข้างล่าง):

[GIN] 2026/11/03 - 21:12:17 | 200 | 86.291µs |             ::1 | GET      "/posts"

Rails จดละเอียดกว่า เพราะมันแบ่งงานเป็นหลายช่อง แล้วบอกว่า request ผ่านช่องไหนบ้าง · เวลาหน้าเว็บพัง ให้ดู log ก่อนเสมอ — มันบอกว่าใบสั่งไปติดอยู่ที่มือใคร

# routes.rb มีแค่ get "/posts" (ไม่มี /menu)
# เปิด browser ไปที่
http://localhost:3000/menu

log จะมีบรรทัด Processing by … ไหม? แล้วได้ status เลขอะไร?

ไม่มีบรรทัด Processing by เลย · log จริง:

Started GET "/menu" for ::1 at 2026-11-03 20:47:31 +0700

ActionController::RoutingError (No route matches [GET] "/menu"):

และ browser ได้ status 404 พร้อมหน้าแดง ๆ หัวข้อ Routing Error เขียนว่า No route matches [GET] "/menu"

ใบสั่งผ่านด่านหน้าครัวมาแล้ว (มีบรรทัด Started) แต่พนักงานต้อนรับหาแผนกไม่เจอ เลยไม่มีหัวหน้าเชฟคนไหนได้รับใบสั่ง (ไม่มี Processing by) · เจอ No route matches เมื่อไหร่ ให้ไปดูที่ config/routes.rb ก่อนเลย

ด่านหน้าครัว — middleware

ก่อนใบสั่งจะถึงพนักงานต้อนรับ มันต้องผ่านด่านหลายด่านเรียงกัน แต่ละด่านทำงานหนึ่งอย่างกับทุกใบสั่ง เช่น จดลงสมุด · อ่านบัตรสมาชิกของลูกค้า (cookie) · กันคนแปลกหน้า · ด่านพวกนี้เรียกว่า middleware

บรรทัด Started GET "/posts" … ที่เห็นใน log ก็มาจาก middleware ตัวหนึ่ง — มันคือคนจดสมุดที่ประตูครัว · อยากดูว่ามีด่านอะไรบ้าง สั่งได้ด้วยคำสั่งข้างล่าง (ชื่อแต่ละด่านมี :: คั่น เช่น ActionDispatch::Static = ด่าน Static ที่อยู่ในกล่อง ActionDispatch):

$ bin/rails middleware
use ActionDispatch::HostAuthorization
use Rack::Sendfile
use ActionDispatch::Static
…(ตัดออก)…
run WongnokBlog::Application.routes

ในโปรเจกต์ที่เพิ่ง rails new โหมด development มี 28 ด่าน (บรรทัด use) · อ่านจากบนลงล่าง = ลำดับที่ใบสั่งเดินผ่าน · บรรทัดสุดท้าย run … routes บอกว่าผ่านด่านสุดท้ายแล้วส่งให้ routes · คุณไม่ต้องจำชื่อสักตัว — รู้แค่ว่ามีด่านพวกนี้ และ Rails ใส่มาให้ครบแล้ว

🧼 เทียบให้เห็นภาพ — ด่านหน้าครัว

ร้านอาหารที่ดีมีกติกาว่า ใบสั่งทุกใบต้องผ่านจุดเดียวกันก่อนเข้าครัว: จดเวลารับลงสมุด · ตรวจบัตรสมาชิก · เช็คว่าลูกค้ามาถูกร้าน · ไม่ว่าจะสั่งเมนูอะไร ด่านพวกนี้ไม่สน มันทำงานเหมือนกันทุกใบ

ที่ไม่เหมือนกัน: ด่านในร้านจริงทำงานแค่ขาเข้า · แต่ middleware ทำงานทั้งขาเข้าและขากลับ — จานที่ทำเสร็จต้องเดินย้อนผ่านทุกด่านเดิมก่อนถึงลูกค้า เช่นมีด่านหนึ่งแปะ “รหัสประจำจาน” ตอนขากลับ ให้ browser จำได้ว่าเคยได้จานนี้แล้ว (รหัสนี้คือ header ชื่อ etag · header = ป้ายข้อมูลเล็ก ๆ ที่แนบไปกับจาน ลูกค้าไม่เห็นบนหน้าเว็บ)

ใน Go คุณรู้จักของนี้แล้ว — gin.Default() ใส่ middleware ให้ 2 ตัว (Logger กับ Recovery) และคุณเพิ่มเองด้วย r.Use(...) (บท 39) · แนวคิดเดียวกันเป๊ะ ต่างแค่ Rails ใส่มาให้ 28 ตัวตั้งแต่วันแรก

🗣 ศัพท์ของด่านหน้าครัว
middleware / middleware stack
โค้ดที่ทุก request ต้องผ่านก่อนถึง routes เรียงกันเป็นแถว (stack) · ดูได้ด้วย bin/rails middleware · ถาม senior ได้ว่า “ทีมนี้มี middleware ที่เขียนเองไหม ทำหน้าที่อะไร”
log / server log
ข้อความที่ server จดไว้ทุก request · ใน development โชว์ในเทอร์มินัลและเก็บที่ log/development.log · เวลาถาม AI เรื่อง error ให้ก็อป log ตั้งแต่บรรทัด Started ถึง Completed ไปด้วย มันจะตอบตรงกว่ามาก

เลข status ที่เจอใน log — ภาษาคน

บรรทัด Completed … ทุกครั้งมีเลขสามหลัก เรียกว่า status code — เป็นป้ายสั้น ๆ ที่ติดไปกับจาน บอกลูกค้าว่าใบสั่งนี้จบแบบไหน (เคยเรียนฝั่ง Go แล้วใน บท 34) · ใน Rails มีชื่อเป็น symbol (คำที่ขึ้นต้นด้วย : — บท 92) ให้ใช้แทนตัวเลขได้ · วันนี้แค่อ่านเลขใน log ให้ออก ยังไม่ต้องเขียนเอง · คู่ชื่อกับเลขในตารางนี้ผมเช็คกับ Rails 8.1.4 จริงแล้ว:

เลขชื่อใน Railsใน Goในร้านอาหารเจอตอนไหน
200:okhttp.StatusOK จานถึงโต๊ะเรียบร้อยปกติ ทุกอย่างผ่าน
302:foundhttp.StatusFound “เมนูนี้ย้ายไปอีกเคาน์เตอร์ค่ะ” ลูกค้าเดินไปสั่งใหม่เองredirect — ส่งไปอีกหน้า
404:not_foundhttp.StatusNotFound ร้านไม่มีเมนูนี้URL ไม่มีใน routes
422:unprocessable_contenthttp.StatusUnprocessableEntity ใบสั่งกรอกไม่ครบ ส่งคืนให้แก้ฟอร์มกรอกไม่ผ่าน (เจอจริง Day 2)
500:internal_server_errorhttp.StatusInternalServerError ครัวไฟไหม้ — ความผิดของร้าน ไม่ใช่ลูกค้าโค้ดของเรา error

ลองดู 302 ของจริง · สมมติร้านเคยใช้ URL /articles แล้วย้ายมาเป็น /posts เราอยากให้คนที่ยังเข้า URL เก่าถูกส่งต่อไป URL ใหม่ · เพิ่ม route กับ action อีกอย่างละหนึ่ง:

# config/routes.rb — เพิ่มใต้ get "/posts"
  get "/articles", to: "posts#articles"

# app/controllers/posts_controller.rb — เพิ่มต่อจาก def index … end (ก่อน end ตัวสุดท้าย)
  def articles
    redirect_to "/posts"
  end

redirect_to "/posts" แปลว่า “ไม่ต้องจัดจาน บอกลูกค้าให้ไปสั่งที่ /posts แทน” (ละเอียดใน บท 102) · เปิด localhost:3000/articles แล้วดู log:

Started GET "/articles" for ::1 at 2026-11-03 20:47:30 +0700
Processing by PostsController#articles as HTML
Redirected to http://localhost:3000/posts
↳ app/controllers/posts_controller.rb:11:in 'PostsController#articles'
Completed 302 Found in 1ms (ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)

MVC ของจริง — Gin ทำในฟังก์ชันเดียว · Rails แบ่งแผนก

ถึงตรงนี้เราเห็นสองช่องจากสามช่องของ MVC แล้ว: Controller (หัวหน้าเชฟ) กับ View (จัดจาน) · ช่องที่สามคือ Model = ห้องเก็บวัตถุดิบ + คนดูแลสต็อก ที่รู้ว่าข้อมูลอยู่ตู้ไหนใน database · MVC คือการแบ่งโค้ดเป็นสามกองตามหน้าที่ แทนที่จะกองรวมกัน

ลองเขียนหน้าเดียวกันด้วย Gin ดูก่อน (โค้ดนี้ go build ผ่าน · รันแล้วได้บทความสองชิ้นเหมือนกัน แค่ไม่มี layout ห่อ):

// main.go
package main

import (
	"fmt"
	"net/http"

	"github.com/gin-gonic/gin"
)

type Post struct {
	Title  string
	Author string
}

func main() {
	r := gin.Default() // พนักงานต้อนรับ + ด่านหน้าครัว (Logger, Recovery)

	r.GET("/posts", func(c *gin.Context) {
		// 1) หาข้อมูล (งานของ Model)
		posts := []Post{
			{"ข้าวซอยเจ้าเด็ด", "สมชาย"},
			{"ส้มตำปูปลาร้า", "สมหญิง"},
		}
		// 2) จัดจานเป็น HTML (งานของ View)
		html := "<h1>บทความทั้งหมด</h1>"
		for _, p := range posts {
			html += fmt.Sprintf("<h2>%s</h2><p>โดย %s</p>", p.Title, p.Author)
		}
		// 3) ยกจานออกไป (งานของ Controller)
		c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(html))
	})

	r.Run(":8080")
}

สังเกตว่า handler ตัวเดียวทำทั้งสามงาน: หาข้อมูล · ประกอบ HTML · ตอบกลับ · ตอนหน้ามีหน้าเดียวก็สบายดี แต่พอมี 30 หน้า ทุกหน้าต้องมี HTML ปนอยู่ใน Go · ทุกหน้าต้องรู้ว่าข้อมูลอยู่ตาราง SQL ไหน

Rails เตรียมห้องแยกไว้ให้ตั้งแต่ rails new แล้วใช้กฎตั้งชื่อจับคู่ให้: หาข้อมูลไป app/models/ · ประกอบ HTML ไป app/views/ · ประสานงานอยู่ app/controllers/ · สไลด์หน้า 52 สรุปไว้แบบนี้:

ModelViewController
ในร้านห้องเก็บวัตถุดิบ + คนดูแลสต็อกคนจัดจานหัวหน้าเชฟ
หน้าที่ข้อมูล + กฎของธุรกิจ (เช่น “บทความต้องมีชื่อเรื่อง”)หน้าตาที่ผู้ใช้เห็นรับใบสั่ง · ขอข้อมูลจาก model · เลือก view
ไม่ควรทำไม่รู้จัก URL · params (รายละเอียดในใบสั่ง เช่น “บทความเลข 42” — บท 102) · redirectไม่ควรไปค้นข้อมูลเองควรบางที่สุด ไม่แบกกฎธุรกิจ
ไฟล์app/models/post.rbapp/views/posts/index.html.erbapp/controllers/posts_controller.rb

กฎตัดสินใจง่าย ๆ จากสไลด์: เป็นข้อมูลหรือกฎของธุรกิจ → Model · เกี่ยวกับ HTTP เช่น URL, params, redirect → Controller · หน้าตาล้วน ๆ → View

🏪 เทียบให้เห็นภาพ — ร้านที่เปิดเองคนเดียว vs ร้านแฟรนไชส์

handler ของ Gin เหมือนร้านข้างทางที่เจ้าของทำเองทุกอย่าง — รับออเดอร์ หยิบของในตู้ ผัด จัดจาน เสิร์ฟ คนเดียวจบ · เร็วและง่ายตอนร้านเล็ก แต่พอเมนูเป็นร้อย ทุกอย่างอยู่ในหัวคนคนเดียว
Rails เหมือนร้านแฟรนไชส์ — มีห้องเก็บของ (Model) · หัวหน้าเชฟ (Controller) · คนจัดจาน (View) แยกกัน · และทุกสาขาวางของตำแหน่งเดียวกัน ใครย้ายมาทำงานสาขาไหนก็หาของเจอ

ที่ไม่เหมือนกัน: ในแฟรนไชส์จริง คนจัดจานเดินเข้าห้องเก็บของไม่ได้ · แต่ใน Rails ไม่มีใครห้าม — view จะเขียนโค้ดไปค้น database เองก็ทำงานได้ ไม่มี error · การแบ่งแผนกเป็นแค่ข้อตกลงที่ทีมต้องรักษาเอง สไลด์ถึงเรียกว่า “เส้นแบ่งที่คนข้ามบ่อยที่สุด” · และใน Go คุณก็แยกได้เหมือนกัน (แตกเป็นหลายฟังก์ชัน หลายไฟล์) แค่ต้องตั้งโครงเอง

แล้ว Model ของเราอยู่ไหน? วันนี้ยังไม่มี · ข้อมูลจำลองที่วางใน controller คือ หัวหน้าเชฟเก็บวัตถุดิบไว้ในกระเป๋าตัวเองชั่วคราว ซึ่งผิดแผนก · Day 2 เราจะสร้าง model Post กับตาราง posts ใน database จริง แล้ว controller จะเหลือบรรทัดเดียวแบบภาพในสไลด์หน้า 53:

# Day 2 — ยังรันไม่ได้วันนี้ เพราะยังไม่มี model Post และตาราง posts
def index
  @posts = Post.all   # "ห้องเก็บของ ขอบทความทั้งหมด"
end

Post (ตัวใหญ่ เอกพจน์) คือคนดูแลสต็อกของบทความ · .all คือขอ “ทุกชิ้น” · Post ไม่ได้โผล่มาเฉย ๆ — Day 2 จะมีไฟล์ app/models/post.rb ที่ประกาศ class Post ไว้ แล้ว Rails หาเจอเองจากชื่อ ไม่ต้อง import (autoload — บท 99) · เส้นทางทั้งเส้นในสไลด์หน้า 53 จึงเป็น: browser → routes → PostsController → Post → database → กลับมาที่ controller → ส่ง @posts ให้ view → HTML กลับไปที่ browser — เส้นเดียวกับที่เราทำวันนี้ แค่เพิ่มห้องเก็บของเข้ามา

🗣 ศัพท์ของ MVC
MVC (Model–View–Controller)
การแบ่งโค้ดเป็นสามกองตามหน้าที่: ข้อมูล · หน้าตา · ผู้ประสานงาน · ถาม senior ได้ว่า “ทีมนี้ยอมให้ logic อยู่ใน controller ได้แค่ไหน” — แต่ละทีมขีดเส้นไม่เท่ากัน
skinny controller
คำขวัญของชาว Rails = controller ควรบาง ทำแค่ประสานงาน · กฎของธุรกิจย้ายไปอยู่ model · พิมพ์บอก AI ได้ว่า “keep the controller skinny, move this rule into the model”
business logic
กฎที่ยังจริงอยู่แม้ไม่มีเว็บ เช่น “บทความต้องมีชื่อเรื่อง” · ลองถามตัวเองว่า ถ้าทำเป็นแอปมือถือแทนเว็บ กฎนี้ยังต้องมีไหม — ถ้าต้องมี มันคือของ Model
status code
เลขสามหลักที่บอกว่า request จบแบบไหน (200 · 302 · 404 · 422 · 500) · เวลารายงานบั๊กให้บอกเลขนี้ด้วยเสมอ เช่น “หน้า /posts ได้ 500” ชัดกว่า “หน้าพัง” มาก

ภารกิจ — เพิ่มหน้า “บทความแรก” ด้วยมือตัวเอง

🎯 ทำหน้า /posts/first (ประมาณ 10 นาที)

ใช้โปรเจกต์เดิมที่ทำหน้า /posts ไว้แล้ว เปิด server ทิ้งไว้ได้เลย ไม่ต้องปิด

  1. พนักงานต้อนรับ: ใน config/routes.rb เพิ่ม get "/posts/first", to: "posts#first" ต่อจาก route ที่มีอยู่
  2. หัวหน้าเชฟ: ใน PostsController เพิ่ม action first ที่ตั้ง @post เป็น Hash บทความหนึ่งชิ้น (ข้อมูลจำลอง)
  3. จัดจาน: สร้างไฟล์ app/views/posts/first.html.erb โชว์ชื่อบทความใน <h1> และชื่อคนเขียนใน <p>
  4. เปิด http://localhost:3000/posts/first ควรเห็นชื่อบทความตัวใหญ่
  5. ดู log ในเทอร์มินัล หาให้เจอ 3 บรรทัด: Started GET · Processing by · Completed 200 OK แล้วพูดออกมาว่าแต่ละบรรทัดคือใครในร้าน

ถ้าเจอ error ให้ดูตรงนี้ก่อน (ทุกข้อความก็อปจากที่ผมลองทำพังจริง):

# config/routes.rb
Rails.application.routes.draw do
  get "up" => "rails/health#show", as: :rails_health_check

  get "/posts", to: "posts#index"
  get "/articles", to: "posts#articles"
  get "/posts/first", to: "posts#first"
end
# app/controllers/posts_controller.rb — เพิ่ม action นี้ (ตรงไหนก็ได้ระหว่าง class … end)
  def first
    @post = { title: "ข้าวซอยเจ้าเด็ด", author: "สมชาย" }
  end

app/views/posts/first.html.erb

<h1><%= @post[:title] %></h1>
<p>โดย <%= @post[:author] %></p>

log จริง (เวลาของคุณจะต่าง):

Started GET "/posts/first" for ::1 at 2026-11-03 20:46:21 +0700
Processing by PostsController#first as HTML
  Rendering layout layouts/application.html.erb
  Rendering posts/first.html.erb within layouts/application
  Rendered posts/first.html.erb within layouts/application (Duration: 0.1ms | GC: 0.0ms)
  Rendered layout layouts/application.html.erb (Duration: 1.2ms | GC: 0.0ms)
Completed 200 OK in 3ms (Views: 2.0ms | ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)
  • Started GET "/posts/first" = ด่านหน้าครัวจดว่ามีใบสั่งเข้ามา
  • Processing by PostsController#first = พนักงานต้อนรับส่งถึงหัวหน้าเชฟแผนกบทความ เมนู first
  • Completed 200 OK = ยกจานถึงโต๊ะเรียบร้อย

ถ้าคุณทำข้อ 3 ไม่ทันแล้วเปิดหน้าดูก่อน log จะเป็น Completed 406 Not Acceptable ตามด้วย ActionController::MissingExactTemplate — แปลว่าหัวหน้าเชฟรับใบสั่งแล้ว (มี Processing by) แต่ไม่มีจานให้จัด

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

พิมพ์ localhost:3000/posts แล้ว ใบสั่งเดินผ่านลำดับไหน?

ตัวเลือกที่ 3 ถูก · ประตูร้าน (Puma) รับใบสั่ง → ผ่านด่านหน้าครัว (middleware) → พนักงานต้อนรับ (routes) ชี้แผนก → หัวหน้าเชฟ (controller) เตรียมข้อมูล → จัดจาน (view) → ยกกลับไป

ตัวเลือกที่ 1 ให้หัวหน้าเชฟรับใบสั่งก่อนพนักงานต้อนรับ — ไม่มีใครบอกเชฟว่าใบนี้ของแผนกไหน · ตัวเลือกที่ 2 จัดจานก่อนเชฟทำอาหาร · ตัวเลือกที่ 4 กลับหัวกลับหางทั้งเส้น · จุดที่ต้องจำ: ประตู → ด่าน → ต้อนรับ → เชฟ → จัดจาน

log มีบรรทัด Started GET "/menu" แล้วตามด้วย No route matches [GET] "/menu" ทันที ไม่มี Processing by · ควรไปแก้ที่ไหนก่อน?

ตัวเลือกที่ 2 ถูก · ไม่มี Processing by แปลว่าใบสั่งไปไม่ถึงหัวหน้าเชฟ — ติดที่พนักงานต้อนรับ ซึ่งก็คือ routes

ตัวเลือกที่ 1 กับ 3 เป็นปัญหาที่เกิดหลัง Processing by (เชฟรับใบสั่งแล้ว) · ตัวเลือกที่ 4 ไม่จำเป็น เพราะโหมด development อ่าน routes ใหม่ให้เองเมื่อไฟล์เปลี่ยน · จุดที่ต้องจำ: ดูว่า log ไปหยุดที่บรรทัดไหน แล้วจะรู้ว่าต้องแก้ไฟล์ไหน

log จบด้วย Redirected to http://localhost:3000/posts และ Completed 302 Found · แปลว่าอะไร?

ตัวเลือกที่ 4 ถูก · 302 คือ “เมนูนี้ย้ายไปอีกเคาน์เตอร์” · หัวหน้าเชฟไม่ได้จัดจาน (ไม่มีบรรทัด Rendering) แค่บอกที่อยู่ใหม่ แล้ว browser ยื่นใบสั่งใบที่สองไปที่ /posts เอง

ตัวเลือกที่ 1 สับสนกับ 500 · ตัวเลือกที่ 2 สับสนกับ 404 (Rails ไม่เดา URL ให้) · ตัวเลือกที่ 3 ผิดเพราะ 302 กับ 200 เป็นคนละความหมาย · จุดที่ต้องจำ: redirect = 2 request · ใบแรกได้ 302 ใบที่สองได้หน้าจริง

วันนี้เราวางข้อมูลบทความจำลองไว้ใน PostsController · ตามหลัก MVC ข้อมูลนี้ควรย้ายไปอยู่ที่ไหน?

ตัวเลือกที่ 1 ถูก · ข้อมูลกับกฎของธุรกิจเป็นงานของห้องเก็บวัตถุดิบ (Model) · Day 2 controller จะเหลือแค่ @posts = Post.all คือ “ขอของจากห้องเก็บ” แล้วส่งต่อให้ view

ตัวเลือกที่ 2 ผิด — view แค่จัดจาน ไม่ควรเก็บหรือค้นข้อมูลเอง · ตัวเลือกที่ 3 ผิด — routes แค่ชี้ว่า URL ไหนไปแผนกไหน ไม่แตะข้อมูล · ตัวเลือกที่ 4 ผิด — layout คือจานรองที่ใช้ทุกหน้า ไม่ใช่ที่เก็บข้อมูล · จุดที่ต้องจำ: ข้อมูล/กฎ → Model · HTTP/redirect → Controller · หน้าตา → View

ติดตรงไหน ถามกลับได้เลย · ถ้าหน้าเว็บพัง ก็อป log ตั้งแต่ Started ถึง Completed มาทั้งก้อน พร้อมบอกว่าเปิด URL อะไร · แค่นั้นก็เดาได้แล้วเกือบทุกครั้งว่าติดที่มือใครในร้าน

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

สไลด์หน้า 49–53 สรุปไว้สั้น บางจุดไม่ตรงกับที่เห็นในเครื่องจริง · ทั้งหมดนี้ไม่ทำให้โค้ดในบทนี้พัง อ่านผ่าน ๆ ได้

หน้าสไลด์เขียนของจริง (Rails 8.1.4)ต้องสนใจไหม
49config.ru คือจุดเริ่ม แล้วไล่ไป boot.rb → application.rb สั่งผ่าน bin/rails server จะโหลด boot.rb กับ application.rb ก่อน แล้ว Puma ค่อยอ่าน config.ru ไม่ต้อง — ภาพรวม “ตั้งค่าก่อน แล้วค่อยเปิดประตู” ยังถูก
49ไม่มีไฟล์ config/environment.rb ในลำดับ มีจริง เป็นไฟล์ที่สั่งให้ environments กับ initializers ทำงาน ไม่ต้อง
49โหลด routes.rb ก่อน Puma เปิดพอร์ต จริงใน production · ใน development โหลดตอน request แรกมาถึง นิดเดียว — ถ้า routes.rb พิมพ์ผิด server ยังเปิดได้ แล้วไปพังตอนเปิดหน้าแรก
50CSRF อยู่ในกล่อง Middleware การตรวจ CSRF เกิดใน controller (หลังบรรทัด Processing by) ไม่ใช่ middleware ไม่ต้องวันนี้ — CSRF อยู่ใน บท 103
51 · 62 · 77หน้า 51 ใช้ :unprocessable_content · หน้า 62/77 ใช้ :unprocessable_entity ได้ 422 ทั้งคู่ · ชื่อเก่า (_entity) โดน Rack (ชิ้นส่วนพื้นฐานที่ Rails ใช้คุยกับ Puma) เตือนว่ากำลังจะเลิกใช้ เขียนใหม่ให้ใช้ :unprocessable_content

อ้างอิง