บทเรียน 112 · Rails · Day 2

Callbacks และที่อยู่ของโค้ด — ขั้นตอนอัตโนมัติก่อนเก็บแฟ้ม

~25 นาที · ควรผ่าน บทเรียน 110 (validation) และ บทเรียน 111 (transaction) มาก่อน

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

🔁 ทวนก่อนเริ่ม — คำจากบทก่อน ๆ ที่บทนี้ใช้

🧰 ตรวจด้วย Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 17 (สไลด์ใช้ Ruby 4.0.6 · Rails 8.1.3 — รุ่นแก้บั๊กในสายเดียวกัน ใช้แทนกันได้)

📦 ตัวอย่างในบทนี้ใช้ตาราง users / posts / comments และข้อมูลตัวอย่าง (seeds) ชุดเดียวกับบท 107 · บทนี้จะเพิ่มคอลัมน์ slug ให้ posts เฉพาะบทนี้ แล้วคืนสภาพตอนท้ายภารกิจ เพื่อให้บทถัดไปได้ผลตรงกัน

✂️ โค้ด model ในบทนี้โชว์เฉพาะบรรทัดที่เกี่ยวกับเรื่องของบท · validates ที่คุณเติมไว้ตามบท 110–111 เก็บไว้ตามเดิม แล้วแทรกบรรทัดใหม่เพิ่มเข้าไป · ผลรันในบทนี้รันกับ model ที่ไม่มี validates — log ของคุณจึงอาจมีบรรทัดเพิ่ม เช่น User Exists? (Rails เช็คอีเมลซ้ำตามกฎบท 111) ไม่ต้องตกใจ

ผลทุกก้อนรันจริงแล้วก็อปมา · เวลา (ms) · วันเวลา · เลข id · เลขหน้าชื่อไฟล์ migration ของคุณจะต่าง · ทุกคำสั่งพิมพ์ใน bin/rails console ธรรมดา ไม่ใช้ --sandbox (แผ่นใสของ sandbox ทำให้ log รกและดูเรื่อง after_commit ท้ายบทไม่ออก)

callback คืออะไร — กฎ "ทำทุกครั้ง" ที่ติดไว้ข้างตู้

callback คือ method ที่เราฝากไว้กับ model ว่า "ทุกครั้งที่จะเก็บแฟ้ม (หรือเพิ่งเก็บเสร็จ) ให้เรียก method นี้ด้วย" · ไม่ต้องมีใครสั่ง ไม่ว่าการบันทึกจะมาจาก controller · console · หรือ seeds ก็โดนหมด

มีไว้สำหรับงานที่ต้องทำทุกครั้งโดยไม่มีข้อยกเว้น — ถ้าปล่อยให้คนเขียนเองในทุก controller สักวันต้องมีที่หนึ่งที่ลืม

🏷 เทียบให้เห็นภาพ — ป้ายกฎที่ติดไว้ข้างตู้เก็บแฟ้ม

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

ที่ไม่เหมือนกัน: คนจริงเห็นแฟ้มแปลก ๆ ก็หยุดถามก่อนได้ · callback ไม่คิด ทำทุกครั้งเป๊ะ ๆ · และคนที่อ่านโค้ด controller มองไม่เห็นป้ายนี้ — เห็นแค่ post.save บรรทัดเดียว ไม่รู้ว่าข้างในมีงานแอบทำอีกกี่อย่าง (นี่คือที่มาของคำว่า "สะดวกมากและเจ็บมาก" — มีหัวข้อของมันเองข้างล่าง)

หน้าตาคุ้นตาแน่นอน — เพราะคุณเคยเห็นญาติของมันแล้วใน controller: before_action :set_post (บท 102) · callback ของ model เขียนแบบเดียวกันเป๊ะ ต่างกันแค่จังหวะ: before_action ทำก่อนเข้าเมนู · callback ทำก่อน/หลังเก็บแฟ้ม

class Post < ApplicationRecord
  before_validation :generate_slug   # ก่อนตรวจแฟ้ม ให้เรียก method ชื่อ generate_slug
end

เทียบกับ Go: GORM ก็มีของแบบนี้ เรียกว่า hook — ถ้า struct มี method ชื่อ BeforeSave สะกดตรงเป๊ะ GORM จะเรียกให้เองก่อนบันทึก (ไฟล์นี้ go run ผ่านจริง):

// main.go
package main

import (
	"fmt"
	"strings"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

type Post struct {
	ID    uint
	Title string
	Slug  string
}

// BeforeSave คือ hook ของ GORM — ชื่อต้องสะกดตรงเป๊ะ GORM ถึงจะเรียกให้เองก่อน save
func (p *Post) BeforeSave(tx *gorm.DB) error {
	if p.Slug == "" {
		p.Slug = strings.ToLower(strings.ReplaceAll(p.Title, " ", "-"))
	}
	return nil
}

func main() {
	db, _ := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
	db.AutoMigrate(&Post{})

	post := Post{Title: "Green Curry Chicken"}
	db.Create(&post)
	fmt.Println(post.Slug)
}
$ go run main.go
green-curry-chicken

อ่านเฉพาะส่วนที่เกี่ยวกับเรื่องนี้:

🗣 ศัพท์ที่เพิ่งเรียนไป
callback (Active Record callback)
method ที่ Rails เรียกให้เองตอนแฟ้มถูกสร้าง · บันทึก · ลบ · ประกาศด้วยคำอย่าง before_validation · after_commit · GORM เรียกของแบบเดียวกันว่า hook · ถาม senior ได้ว่า “model นี้มี callback อะไรซ่อนอยู่บ้าง ก่อนที่ผมจะไปเรียก save จากที่ใหม่”
lifecycle (วงจรชีวิตของ object)
ลำดับจังหวะที่แฟ้มหนึ่งแผ่นผ่าน ตั้งแต่ "จะบันทึก" จนถึง "ตู้ยืนยันแล้ว" · callback แต่ละตัวคือจุดแวะในเส้นทางนี้ (หัวข้อถัดไป) · พิมพ์บอก AI ได้ว่า “at which point in the Active Record lifecycle should I set this attribute?”

ลำดับที่ callback ทำงาน — แฟ้มเดินผ่านจุดแวะไหนบ้าง

เวลาสั่ง create หนึ่งครั้ง แฟ้มไม่ได้กระโดดเข้าตู้ทันที แต่เดินผ่านจุดแวะเรียงกันตามนี้ (สไลด์หน้า 24 · ผมเรียงตามผลรันจริงข้างล่าง):

สั่ง post.save / Post.create / post.update
        │
        ▼
 1  before_validation   ← เตรียมของก่อนตรวจ (สร้าง slug ทำตรงนี้)
        │  ··· ตรวจกฎ validation (บท 110) ···
 2  after_validation
 3  before_save         ← ทำทั้งตอนสร้างใหม่ และตอนแก้ของเดิม
 4  before_create       ← เฉพาะแฟ้มใหม่ (ถ้าแก้ของเดิม ตัวนี้ข้ามไป)
        │
   ═══ INSERT (เก็บแฟ้มใหม่) หรือ UPDATE (แก้แฟ้มเดิม) ลงตู้ ═══
        │
 5  after_create        ← เฉพาะแฟ้มใหม่
 6  after_save
        │
   ═══ COMMIT — ตู้ยืนยันว่ารับแฟ้มแล้วจริง ═══
        │
 7  after_commit        ← ของที่ออกไปนอกร้าน (อีเมล · แจ้งเตือน) ทำตรงนี้

อย่าเชื่อแผนภาพ ลองให้ callback ทุกตัว "ตะโกนชื่อตัวเอง" ดีกว่า · เปิดไฟล์ app/models/post.rb แล้วเพิ่ม 7 บรรทัดนี้ชั่วคราว (ใต้ has_many):

# app/models/post.rb — ตัวทดลอง: ให้ callback ทุกตัวตะโกนชื่อตัวเอง
class Post < ApplicationRecord
  belongs_to :user
  has_many :comments

  before_validation { puts "1 before_validation" }
  after_validation  { puts "2 after_validation" }
  before_save       { puts "3 before_save" }
  before_create     { puts "4 before_create" }
  after_create      { puts "5 after_create" }
  after_save        { puts "6 after_save" }
  after_commit      { puts "7 after_commit" }
end

เปิด bin/rails console แล้วสร้างบทความหนึ่งชิ้น (ผลจริง):

wongnok-blog(dev):001> somchai = User.find_by(name: "สมชาย"); nil
  User Load (2.0ms)  SELECT "users".* FROM "users" WHERE "users"."name" = 'สมชาย' LIMIT 1 /*application='WongnokBlog'*/
=> nil
wongnok-blog(dev):002> post = somchai.posts.create(title: "Pad Thai", body: "เส้นจันท์"); nil
1 before_validation
2 after_validation
3 before_save
4 before_create
  TRANSACTION (0.9ms)  BEGIN /*application='WongnokBlog'*/
  Post Create (3.1ms)  INSERT INTO "posts" ("title", "body", "created_at", "updated_at", "published", "views", "user_id") VALUES ('Pad Thai', 'เส้นจันท์', '2026-11-04 12:23:32.692780', '2026-11-04 12:23:32.692780', FALSE, 0, 1) RETURNING "id" /*application='WongnokBlog'*/
5 after_create
6 after_save
  TRANSACTION (2.1ms)  COMMIT /*application='WongnokBlog'*/
7 after_commit
=> nil
wongnok-blog(dev):003> post.update(title: "Pad Thai Goong")

ทีนี้แก้บทความเดิม ไม่ได้สร้างใหม่ · ตัวเลข 1–7 ตัวไหนจะโผล่บ้าง?

ผลจริง — หายไปสองตัว คือ 4 กับ 5:

1 before_validation
2 after_validation
3 before_save
  TRANSACTION (1.6ms)  BEGIN /*application='WongnokBlog'*/
  Post Update (3.5ms)  UPDATE "posts" SET "title" = 'Pad Thai Goong', "updated_at" = '2026-11-04 11:53:04.914520' WHERE "posts"."id" = 7 /*application='WongnokBlog'*/
6 after_save
  TRANSACTION (1.5ms)  COMMIT /*application='WongnokBlog'*/
7 after_commit
=> true

before_create / after_create มีไว้เฉพาะแฟ้มใหม่ · ส่วนคำว่า save ครอบทั้งสร้างและแก้ จึงยังโผล่ · SQL เปลี่ยนจาก INSERT เป็น UPDATE "posts" SET "title" = … WHERE "posts"."id" = 7 = "แก้ช่อง title ของแฟ้มในลิ้นชัก posts เฉพาะแผ่นเลขที่ 7" (เลข id ของคุณจะต่าง)

ทดลองเสร็จแล้ว ลบ callback ทั้ง 7 บรรทัดนั้นออก ให้ไฟล์กลับเป็นเหมือนก่อนเริ่มหัวข้อนี้ — หัวข้อถัดไปจะใส่ของจริง

ลงมือ: ให้บทความสร้าง slug เองทุกครั้ง

slug คือชื่อเรื่องที่แปลงให้ใส่ใน URL ได้ เช่น "Green Curry Chicken" → green-curry-chicken (เคยเห็นแล้วในบท 95 ตอนทำ module Sluggable · คือ "ป้ายชื่อย่อที่สันแฟ้ม" ในภาพข้างบน) · เว็บจะได้มี URL แบบ /posts/green-curry-chicken แทน /posts/8 · งานนี้ต้องทำทุกครั้งที่มีบทความใหม่ — เหมาะกับ callback ที่สุด

ขั้นที่ 1 — ต่อช่อง slug บนแฟ้ม (ทวนบท 108)

ตาราง posts ยังไม่มีช่องนี้ ต้องสั่งช่างมาต่อก่อน · คอลัมน์นี้ใช้เฉพาะบทนี้ ท้ายภารกิจจะรื้อออก (ผลจริง):

$ bin/rails generate migration AddSlugToPosts slug:string
      invoke  active_record
      create    db/migrate/20261104115254_add_slug_to_posts.rb
$ bin/rails db:migrate
== 20261104115254 AddSlugToPosts: migrating ===================================
-- add_column(:posts, :slug, :string)
   -> 0.0021s
== 20261104115254 AddSlugToPosts: migrated (0.0021s) ==========================

ขั้นที่ 2 — ติดป้ายกฎไว้ข้างตู้

# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
  has_many :comments

  before_validation :generate_slug

  private

  def generate_slug
    self.slug ||= title.to_s.parameterize
  end
end

⚠️ ถ้าข้ามขั้นที่ 1 (สไลด์หน้า 24 ก็ไม่ได้บอกให้เพิ่มคอลัมน์) โค้ดนี้จะพังทันทีที่สร้างบทความ — ผลจริง:

app/models/post.rb:11:in 'Post#generate_slug': undefined method 'slug' for an instance of Post (NoMethodError)

แปลว่า "แฟ้มบทความไม่มีช่องชื่อ slug" · Rails สร้าง method ให้ตามคอลัมน์ที่มีในตารางจริงเท่านั้น (บท 106) · เลข 11 คือบรรทัด self.slug ||= … (นับรวมบรรทัดคอมเมนต์ชื่อไฟล์ด้วย)

ลองใน console · แก้ไฟล์ model แล้วให้ปิด console เปิดใหม่ (หรือ reload! แล้วหาตัวแปรใหม่ทุกตัว — ตัวแปรที่หาไว้ก่อน reload ยังใช้โค้ดเก่า ผมลองแล้ว) · ผลจริง:

wongnok-blog(dev):001> somchai = User.find_by(name: "สมชาย"); nil
  User Load (1.3ms)  SELECT "users".* FROM "users" WHERE "users"."name" = 'สมชาย' LIMIT 1 /*application='WongnokBlog'*/
=> nil
wongnok-blog(dev):002> curry = somchai.posts.create(title: "Green Curry Chicken", body: "แกงเขียวหวาน"); nil
  TRANSACTION (2.0ms)  BEGIN /*application='WongnokBlog'*/
  Post Create (4.2ms)  INSERT INTO "posts" ("title", "body", "created_at", "updated_at", "published", "views", "user_id", "slug") VALUES ('Green Curry Chicken', 'แกงเขียวหวาน', '2026-11-04 11:53:15.359458', '2026-11-04 11:53:15.359458', FALSE, 0, 1, 'green-curry-chicken') RETURNING "id" /*application='WongnokBlog'*/
  TRANSACTION (1.8ms)  COMMIT /*application='WongnokBlog'*/
=> nil
wongnok-blog(dev):003> curry.slug
=> "green-curry-chicken"
wongnok-blog(dev):004> curry.update(title: "Best Green Curry")
  TRANSACTION (2.0ms)  BEGIN /*application='WongnokBlog'*/
  Post Update (4.1ms)  UPDATE "posts" SET "title" = 'Best Green Curry', "updated_at" = '2026-11-04 11:53:16.597786' WHERE "posts"."id" = 8 /*application='WongnokBlog'*/
  TRANSACTION (1.6ms)  COMMIT /*application='WongnokBlog'*/
=> true
wongnok-blog(dev):005> curry.slug
=> "green-curry-chicken"

⚠️ ชื่อเรื่องภาษาไทย ได้ slug เป็นข้อความว่าง — ผลจริง:

wongnok-blog(dev):006> thai = somchai.posts.create(title: "ผัดไทยกุ้งสด", body: "เส้นจันท์"); nil
  …
  Post Create (3.1ms)  INSERT INTO "posts" (…, "slug") VALUES (…, '') RETURNING "id" /*application='WongnokBlog'*/
  …
wongnok-blog(dev):007> thai.slug
=> ""

parameterize เก็บไว้แค่ตัวอังกฤษ ตัวเลข ขีด และขีดล่าง _ (ลองแล้ว "ต้มยำ Tom Yum".parameterize ได้ "tom-yum") · ตัวไทยหายหมด · และ "" ไม่ใช่ nil — ||= จึงถือว่า "มีค่าแล้ว" ไม่เติมซ้ำอีกเลย · บทนี้ใช้ชื่อภาษาอังกฤษเพื่อให้เห็นผลชัด · เว็บไทยจริงต้องเลือกวิธีอื่น (เช่น ใช้เลข id ใน URL) — เกินขอบเขตบทนี้

🗣 ศัพท์ที่เพิ่งเรียนไป
slug · parameterize
slug = ชื่อสั้นที่อ่านออกสำหรับใส่ใน URL (green-curry-chicken) · parameterize = method ของ Rails ที่ทำ slug จากข้อความ · ถาม senior ได้ว่า “บทความภาษาไทยของเรา ทำ slug ยังไง เพราะ parameterize ให้ค่าว่าง”

ถ้าใส่กฎ validates :slug, presence: true (ห้าม slug ว่าง · บท 110) ไว้ด้วย · generate_slug ต้องผูกกับ callback ตัวไหน บทความใหม่ถึงจะบันทึกผ่าน?

ตัวเลือกที่ 2 ถูก — ดูแผนภาพ: การตรวจ validation อยู่ระหว่างจุดที่ 1 กับ 2 · ต้องเติม slug ก่อนนั้น

ตัวเลือกที่ 1 ผิด เพราะ before_save มาหลังการตรวจ · ผมลองย้ายไปไว้ตรงนั้นจริง ได้ post.persisted? เป็น false และ post.errors.full_messages เป็น ["Slug can't be blank"] — ตรวจตอน slug ยังว่าง เลยไม่รับ · ตัวเลือกที่ 3 และ 4 ผิด เพราะทั้งคู่อยู่หลัง INSERT — แฟ้มเข้าตู้ไปแล้ว (หรือถูกปฏิเสธไปแล้ว) ค่อยมาเติมก็สายเกินไป

จุดที่ต้องจำ: อยากเตรียมค่าให้ผ่านการตรวจ → before_validation

self. — ตัวเล็ก ๆ ที่ขาดแล้วพังเงียบ

ใน method ของ model การอ่านช่องบนแฟ้มเขียนชื่อเฉย ๆ ได้ (title ในบรรทัดเดียวกันนั่นแหละ) · แต่การเขียนค่าลงช่องต้องมี self. นำหน้าเสมอ · self แปลว่า "แฟ้มแผ่นที่กำลังทำงานอยู่นี้"

ลองลบ self. ออกดู แล้วสร้างบทความใหม่ (ผลจริง):

def generate_slug
  slug ||= title.to_s.parameterize      # ← ลืม self.
end
wongnok-blog(dev):001> oops = User.first.posts.create(title: "Massaman Curry", body: "มัสมั่น"); nil
  User Load (1.0ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1 /*application='WongnokBlog'*/
  TRANSACTION (0.6ms)  BEGIN /*application='WongnokBlog'*/
  Post Create (2.2ms)  INSERT INTO "posts" ("title", "body", "created_at", "updated_at", "published", "views", "user_id", "slug") VALUES ('Massaman Curry', 'มัสมั่น', '2026-11-04 11:53:30.754274', '2026-11-04 11:53:30.754274', FALSE, 0, 1, NULL) RETURNING "id" /*application='WongnokBlog'*/
  TRANSACTION (0.6ms)  COMMIT /*application='WongnokBlog'*/
=> nil
wongnok-blog(dev):002> oops.slug
=> nil

ไม่มี error สักตัว แต่ช่อง slug ใน INSERT เป็น NULL · เพราะ Ruby อ่าน slug ||= … ว่า "สร้างตัวแปร local ชื่อ slug" (ตัวแปรที่อยู่แค่ใน method นี้ จบ method ก็หายไป) — ไม่ได้แตะแฟ้มเลย

เห็นชัดกว่าถ้าลองกับ Ruby ล้วน ๆ ไม่มี Rails (ผลจริง):

# self_demo.rb
class Post
  attr_accessor :slug

  def forget_self
    slug = "hello"        # Ruby เข้าใจว่า: สร้างตัวแปร local ชื่อ slug
  end

  def with_self
    self.slug = "hello"   # Ruby เข้าใจว่า: เรียก method slug= ของ object นี้
  end
end

post = Post.new
post.forget_self
p post.slug
post.with_self
p post.slug
$ ruby self_demo.rb
nil
"hello"
📝 เทียบให้เห็นภาพ — โน้ตในมือ กับช่องบนแฟ้ม

คนดูแลสต็อกถือแฟ้มบทความอยู่ และมีกระดาษโน้ตแผ่นเล็กในมือ · slug = … คือจดลงกระดาษโน้ต — จบงานก็ขยำทิ้ง แฟ้มไม่ได้อะไรเลย · self.slug = … คือเขียนลงช่อง slug บนแฟ้มแผ่นนี้ — แฟ้มเข้าตู้พร้อมค่านั้น

ที่ไม่เหมือนกัน: คนจริงก้มดูก็รู้ว่าเขียนผิดที่ · Ruby ไม่เตือนสักคำ เพราะการสร้างตัวแปรชื่อ slug ถูกกฎทุกอย่าง · และการอ่าน (title) ไม่ต้องมี self. เพราะไม่มีตัวแปรชื่อ title ให้สับสน — ต้องระวังแค่ตอนเขียน

เทียบกับ Go: ใน hook ของ GORM คุณเขียน p.Slug = … — มี p. นำหน้าเสมอเพราะ Go บังคับ · self. ของ Ruby คือ p. ตัวนั้น ต่างกันที่ Ruby ยอมให้ละไว้ตอนอ่าน เลยเผลอละตอนเขียนได้

🗣 ศัพท์ที่เพิ่งเรียนไป
self · setter
self = object ที่ method กำลังทำงานอยู่ (แฟ้มแผ่นนี้) · setter = method ที่ชื่อลงท้ายด้วย = เช่น slug= ใช้เขียนค่าลงช่อง · กฎ: เรียก setter ภายใน class ต้องมี self. เสมอ · พิมพ์บอก AI ได้ว่า “my callback assigns slug but the column stays nil, did I forget self?”
local variable
ตัวแปรที่มีชีวิตอยู่แค่ใน method เดียว จบ method ก็หาย · Ruby สร้างให้ทุกครั้งที่เห็น ชื่อ = ค่า โดยไม่มีอะไรนำหน้า · ถาม senior ได้ว่า “ทำไม Ruby ไม่เตือนตอนผมเขียน slug = … ใน method แล้วค่าไม่เข้าช่อง”

ใน before_validation เขียนว่า slug ||= title.to_s.parameterize (ไม่มี self.) แล้วสร้างบทความชื่อ "Massaman Curry" จะเกิดอะไร?

ตัวเลือกที่ 3 ถูก — ผลจริง: INSERT ส่ง NULL ลงช่อง slug · oops.slug ได้ nil · ไม่มีอะไรเตือน

ตัวเลือกที่ 1 ผิด เพราะ Ruby ไม่ได้ "หา" slug เลย มันสร้างตัวแปรใหม่ชื่อนั้นแทน · ตัวเลือกที่ 2 ผิด เพราะ Rails ไม่เดาให้ — ค่าที่คำนวณได้อยู่บนกระดาษโน้ตแล้วถูกทิ้ง · ตัวเลือกที่ 4 ผิด เพราะเราไม่ได้ใส่กฎ presence ให้ slug · ถ้าใส่ไว้ถึงจะไม่ยอมบันทึก (อีกเหตุผลที่ควรมีกฎตรวจคู่กัน)

จุดที่ต้องจำ: เขียนค่าลงช่องใน model → self.ชื่อช่อง = … ทุกครั้ง · ลืม = พังเงียบ

ทำอีเมลเป็นตัวเล็กเสมอ — callback หรือ normalizes

ผู้ใช้พิมพ์ " Piti@Example.COM " (มีช่องว่าง ตัวใหญ่ปน) แต่เราอยากเก็บเป็น "piti@example.com" ทุกครั้ง · ไม่งั้นคนเดียวกันจะดูเหมือนสองคน · งานแบบนี้เรียกว่า normalize (ทำให้อยู่ในรูปมาตรฐานเดียวกัน)

ทางที่ 1 — เขียน callback เอง แบบเดียวกับ slug:

# app/models/user.rb
class User < ApplicationRecord
  has_many :posts
  has_many :comments

  before_validation :tidy_email

  private

  def tidy_email
    self.email = email.to_s.strip.downcase
  end
end
wongnok-blog(dev):001> piti = User.create(name: "ปิติ", email: "  Piti@Example.COM "); nil
  TRANSACTION (0.7ms)  BEGIN /*application='WongnokBlog'*/
  User Create (2.0ms)  INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES ('ปิติ', 'piti@example.com', '2026-11-04 11:53:34.169235', '2026-11-04 11:53:34.169235') RETURNING "id" /*application='WongnokBlog'*/
  TRANSACTION (1.5ms)  COMMIT /*application='WongnokBlog'*/
=> nil
wongnok-blog(dev):002> piti.email
=> "piti@example.com"
wongnok-blog(dev):003> User.find_by(email: "PITI@example.com")
  User Load (2.5ms)  SELECT "users".* FROM "users" WHERE "users"."email" = 'PITI@example.com' LIMIT 1 /*application='WongnokBlog'*/
=> nil

ตอนเก็บใช้ได้ (INSERT ส่ง 'piti@example.com') · แต่บรรทัด 003 หาไม่เจอ — SQL ค้นด้วย 'PITI@example.com' ตัวใหญ่ตามที่พิมพ์ ("หาแฟ้มในลิ้นชัก users ที่ช่อง email ตรงกับ PITI@… เป๊ะ ๆ") · callback ทำงานตอนบันทึกเท่านั้น ไม่ได้ช่วยตอนค้นหา · หน้าล็อกอินที่ผู้ใช้พิมพ์ตัวใหญ่จะหาบัญชีตัวเองไม่เจอ

ทางที่ 2 — normalizes คำสั่งสำเร็จรูปของ Rails (มีตั้งแต่ Rails 7.1 · ใช้ได้ใน 8.1.4 ที่ผมตรวจ) แทน callback ทั้งก้อน:

# app/models/user.rb
class User < ApplicationRecord
  has_many :posts
  has_many :comments

  normalizes :email, with: -> email { email.strip.downcase }
end
wongnok-blog(dev):001> chujai = User.create(name: "ชูใจ", email: "  ChuJai@Example.COM "); nil
  TRANSACTION (0.4ms)  BEGIN /*application='WongnokBlog'*/
  User Create (1.6ms)  INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES ('ชูใจ', 'chujai@example.com', '2026-11-04 11:53:38.195125', '2026-11-04 11:53:38.195125') RETURNING "id" /*application='WongnokBlog'*/
  TRANSACTION (2.0ms)  COMMIT /*application='WongnokBlog'*/
=> nil
wongnok-blog(dev):002> chujai.email
=> "chujai@example.com"
wongnok-blog(dev):003> User.find_by(email: "CHUJAI@example.com").name
  User Load (1.3ms)  SELECT "users".* FROM "users" WHERE "users"."email" = 'chujai@example.com' LIMIT 1 /*application='WongnokBlog'*/
=> "ชูใจ"

ดูบรรทัด 003 — เราพิมพ์ "CHUJAI@example.com" แต่ SQL ค้นด้วย 'chujai@example.com' · normalizes แปลงให้ทั้งตอนเก็บและตอนค้น จึงเจอ · เลยแนะนำ normalizes สำหรับงานแปลงค่าช่องเดียว · ส่วน slug ต้องใช้ title (อีกช่องหนึ่ง) มาคำนวณ จึงยังเป็นงานของ callback

🔤 เทียบให้เห็นภาพ — คนจัดแฟ้มตอนเก็บ กับกติกาตัวสะกดของทั้งลิ้นชัก

callback tidy_email เหมือนคนดูแลสต็อกที่แก้ตัวสะกดให้เฉพาะแฟ้มที่กำลังจะเก็บ · ใครยื่นใบเบิกมาว่า "ขอแฟ้มของ PITI@…" เขาก็ค้นตามตัวอักษรที่เขียนมาเป๊ะ ๆ เลยหาไม่เจอ · normalizes คือติดกติกาไว้ที่ลิ้นชัก users ว่า "อีเมลเขียนตัวเล็กเสมอ" — ทั้งแฟ้มที่เข้า และใบเบิกที่ส่งมาค้น ต้องแปลงตามกติกานี้ก่อน

ที่ไม่เหมือนกัน: กติกาบนลิ้นชักจริงใช้กับแฟ้มเก่าที่อยู่ในลิ้นชักแล้วด้วย · normalizes ไม่ไปแก้แฟ้มเก่าในตู้ให้ — แฟ้มที่เก็บไว้ก่อนเพิ่มบรรทัดนี้ ถ้ามีตัวใหญ่ปนก็ยังเป็นแบบเดิม (ผมลองแล้ว และ find_by กลับหาแฟ้มเก่าพวกนั้นไม่เจอ เพราะคำค้นถูกแปลงเป็นตัวเล็ก)

เทียบกับ Go: ในโค้ด Go ที่เรียนมา คุณต้องเรียก strings.ToLower(strings.TrimSpace(email)) เองสองที่ — ก่อนบันทึก และก่อนค้น · ลืมที่ไหน ก็พังที่นั่น (แบบเดียวกับทางที่ 1 ข้างบน)

🗣 ศัพท์ที่เพิ่งเรียนไป
normalize · normalizes
normalize = แปลงค่าให้อยู่รูปมาตรฐาน (ตัดช่องว่าง · ตัวเล็ก) ก่อนเก็บ · normalizes = คำสั่งของ Rails ที่ทำให้ทั้งตอนกำหนดค่าและตอน find_by / where · ถาม senior ได้ว่า “ช่อง email ของเรา normalize ไว้หรือยัง ถ้ายัง ข้อมูลเก่าที่มีตัวใหญ่ปนต้องแก้ด้วยไหม”
lambda (-> x { … })
ก้อนโค้ดที่เก็บใส่ตัวแปรหรือส่งเป็นค่าได้ แล้วค่อยเรียกทีหลัง · เทียบ anonymous function ของ Go · พิมพ์บอก AI ได้ว่า “explain this Ruby lambda syntax: -> email { email.strip.downcase }”

after_save หรือ after_commit — อีเมลหลอกเกิดได้ยังไง

สมมติอยากส่งอีเมลบอกผู้ติดตาม "มีบทความใหม่!" ทุกครั้งที่สร้างบทความ · ดูเผิน ๆ ใส่ after_save ก็น่าจะได้ · แต่จำแผนภาพได้ไหม — after_save ทำงานก่อน COMMIT ตอนนั้นตู้ยังไม่ได้ยืนยันว่าจะรับแฟ้ม

📱 เทียบให้เห็นภาพ — ส่งไลน์บอกลูกค้าก่อนตู้เซ็นรับ

แฟ้มบทความถูกยื่นเข้าช่องตู้ แต่ยังไม่ได้เซ็นรับ · พนักงานคนหนึ่งรีบส่งไลน์บอกผู้ติดตามว่า "บทความใหม่เข้าตู้แล้ว!" (after_save) · วินาทีต่อมาผู้จัดการสั่งยกเลิกทั้งกล่อง (ROLLBACK) แฟ้มถูกดึงออก — ผู้ติดตามได้ข่าวของที่ไม่มีอยู่จริง · ถ้ารอให้ตู้เซ็นรับก่อนค่อยส่ง (after_commit) จะไม่มีวันส่งข่าวหลอก

ที่ไม่เหมือนกัน: ไลน์ยังพิมพ์ขอโทษตามได้ · อีเมลที่ส่งออกไปแล้วดึงกลับไม่ได้ · และ ROLLBACK ย้อนได้เฉพาะของในตู้ (database) — ของที่ออกไปนอกร้านแล้ว ไม่ย้อนตาม (เหมือนแผ่นใสของ --sandbox ในบท 104)

ลองของจริง · ใส่ทั้งสองแบบไว้ใน Post พร้อมกัน (งานจริงยังไม่ส่งอีเมล แค่พิมพ์ข้อความแทน):

# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :user
  has_many :comments

  before_validation :generate_slug
  after_save   :email_too_early                    # ❌ ตัวอย่างที่ผิด
  after_commit :notify_followers, on: :create      # ✅ ตัวอย่างที่ถูก

  private

  def generate_slug
    self.slug ||= title.to_s.parameterize
  end

  def email_too_early
    puts "📧 after_save: ส่งอีเมลเรื่อง #{title} แล้ว"
  end

  def notify_followers
    Rails.logger.info "📬 after_commit: ส่งงานแจ้งผู้ติดตามเรื่อง #{title}"
  end
end

เปิด console ใหม่ · รอบแรก สร้างบทความตามปกติ (ผลจริง):

wongnok-blog(dev):001> somchai = User.find_by(name: "สมชาย"); nil
  User Load (0.8ms)  SELECT "users".* FROM "users" WHERE "users"."name" = 'สมชาย' LIMIT 1 /*application='WongnokBlog'*/
=> nil
wongnok-blog(dev):002> somchai.posts.create(title: "Khao Soi", body: "ข้าวซอย"); nil
  TRANSACTION (1.6ms)  BEGIN /*application='WongnokBlog'*/
  Post Create (3.6ms)  INSERT INTO "posts" ("title", "body", "created_at", "updated_at", "published", "views", "user_id", "slug") VALUES ('Khao Soi', 'ข้าวซอย', '2026-11-04 11:53:52.082532', '2026-11-04 11:53:52.082532', FALSE, 0, 1, 'khao-soi') RETURNING "id" /*application='WongnokBlog'*/
📧 after_save: ส่งอีเมลเรื่อง Khao Soi แล้ว
  TRANSACTION (1.3ms)  COMMIT /*application='WongnokBlog'*/
📬 after_commit: ส่งงานแจ้งผู้ติดตามเรื่อง Khao Soi
=> nil

ทั้งสองข้อความขึ้น · ดูไม่ต่างกัน · ทีนี้ลองให้บทความถูกยกเลิกกลางทาง:

wongnok-blog(dev):003> Post.transaction do
wongnok-blog(dev):004*   somchai.posts.create(title: "Boat Noodles", body: "ก๋วยเตี๋ยวเรือ")
wongnok-blog(dev):005*   raise ActiveRecord::Rollback
wongnok-blog(dev):006*   end

บทความ Boat Noodles จะอยู่ในตู้ไหม? และจะเห็นข้อความ 📧 กับ 📬 ตัวไหนบ้าง?

ผลจริง:

  TRANSACTION (2.2ms)  BEGIN /*application='WongnokBlog'*/
  Post Create (4.9ms)  INSERT INTO "posts" ("title", "body", "created_at", "updated_at", "published", "views", "user_id", "slug") VALUES ('Boat Noodles', 'ก๋วยเตี๋ยวเรือ', '2026-11-04 11:55:22.932559', '2026-11-04 11:55:22.932559', FALSE, 0, 1, 'boat-noodles') RETURNING "id" /*application='WongnokBlog'*/
📧 after_save: ส่งอีเมลเรื่อง Boat Noodles แล้ว
  TRANSACTION (1.3ms)  ROLLBACK /*application='WongnokBlog'*/
=> nil
wongnok-blog(dev):007> Post.where(title: "Boat Noodles").count
  Post Count (3.2ms)  SELECT COUNT(*) FROM "posts" WHERE "posts"."title" = 'Boat Noodles' /*application='WongnokBlog'*/
=> 0
  • 📧 ขึ้น — after_save ทำงานไปแล้วก่อน ROLLBACK · ถ้าตรงนี้ส่งอีเมลจริง ผู้ติดตามได้อีเมลไปแล้ว
  • 📬 ไม่ขึ้น — ไม่มี COMMIT จึงไม่มี after_commit
  • SELECT COUNT(*) FROM "posts" WHERE "posts"."title" = 'Boat Noodles' — "นับว่าลิ้นชัก posts มีแฟ้มชื่อนี้กี่แผ่น" (COUNT = นับ) · ได้ 0 = บทความไม่มีอยู่จริง แต่อีเมลออกไปแล้ว

เทียบกับ Go: คู่มือ GORM บอกลำดับ hook ไว้ว่า BeforeSave → … → AfterSave → แล้วค่อย commit หรือ rollback · ไม่มี hook หลัง commit ให้ — ถ้าส่งอีเมลใน AfterSave ของ GORM ก็เจอปัญหาเดียวกันนี้เป๊ะ ต้องไปส่งหลัง db.Create คืนค่าสำเร็จเอง

🗣 ศัพท์ที่เพิ่งเรียนไป
after_commit (transaction callback)
callback ที่รอให้ฐานข้อมูลยืนยัน (COMMIT) ก่อนค่อยทำ · ถ้า rollback ก็ไม่ทำเลย · ที่สำหรับงานที่ออกไปนอกตู้: ส่งอีเมล · แจ้งเตือน · ฝากงานให้ job · ถาม senior ได้ว่า “callback ที่ส่งอีเมลตัวนี้ ควรย้ายจาก after_save ไป after_commit ไหม”
side effect (ผลข้างเคียง)
สิ่งที่โค้ดทำแล้วมีผลออกไปนอกตัวมัน เช่น ส่งอีเมล · เรียก API ภายนอก · เขียนไฟล์ · rollback ย้อนของพวกนี้ไม่ได้ · พิมพ์บอก AI ได้ว่า “move side effects out of after_save into after_commit and enqueue a job”

โค้ดส่งอีเมลแจ้งผู้ติดตามหลังสร้างบทความใหม่ ควรวางไว้ที่ไหน?

ตัวเลือกที่ 4 ถูก — รอตู้ยืนยันก่อน จะไม่มีวันส่งอีเมลเรื่องที่ไม่มีอยู่จริง · และฝาก job (Day 3) ให้ส่งทีหลัง หน้าเว็บจะได้ไม่ต้องรออีเมลส่งเสร็จ

ตัวเลือกที่ 1 ผิด เพราะยังไม่ได้ตรวจด้วยซ้ำ บทความอาจไม่ผ่าน validation ด้วย · ตัวเลือกที่ 2 คือกับดักของข้อนี้ (ฟังดูถูกที่สุด) — "เขียนลงตู้แล้ว" ไม่ได้แปลว่า "ยืนยันแล้ว" ผลจริงข้างบน 📧 ขึ้นทั้งที่ count ได้ 0 · ตัวเลือกที่ 3 ผิด เพราะ validation มีไว้ตรวจว่าแฟ้มถูกไหม ไม่ใช่ที่ทำงานอื่น (และ valid? เรียกเฉย ๆ ก็ทำงาน ทั้งที่ไม่ได้บันทึก)

จุดที่ต้องจำ: ของที่ออกไปนอกตู้ (อีเมล · API · แจ้งเตือน) → after_commit เสมอ

ทดลองเสร็จ ลบบรรทัด after_save :email_too_early และ method email_too_early ทิ้ง · ให้ Post เหลือ before_validation :generate_slug กับ after_commit :notify_followers, on: :create — หน้าตาเดียวกับสไลด์หน้า 24

callback แบบไหนใช้ได้สบาย แบบไหนเริ่มเจ็บ

สไลด์หน้า 24 แบ่งไว้สองฝั่ง · แปลเป็นภาษาร้าน:

ตัวอย่างทำไม
✅ ใช้ได้สบายสร้าง slug · ทำอีเมลเป็นตัวเล็กเตรียมข้อมูลบนแฟ้มตัวเอง · ไม่ทำแล้วแฟ้มไม่สมบูรณ์ · ไม่ออกไปนอกตู้
⚠️ เริ่มเจ็บยิง API ภายนอก / ส่งอีเมลใน callbackช้า · ถ้าระบบนอกล่ม การบันทึกพังตาม · rollback ย้อนไม่ได้ (หัวข้อที่แล้ว)
⚠️ เริ่มเจ็บcallback ไปแก้ model ตัวอื่น ที่มี callback ต่ออีกโดมิโน: save บทความหนึ่งครั้ง ไปสะกิดผู้ใช้ ผู้ใช้ไปสะกิดอย่างอื่น · ไล่บั๊กยากมาก
⚠️ เริ่มเจ็บsave ทีเดียวมีของเกิด 8 อย่างอ่าน controller เห็นแค่ post.save · ไม่มีใครรู้ว่าจะเกิดอะไรบ้าง · เทสต์ช้าลง (เทสต์คือ Day 4)

หลักง่าย ๆ: callback ควรดูแลแค่แฟ้มของตัวเอง · ถ้างานนั้นต้องคุยกับคนอื่น (model อื่น ระบบอื่น อีเมล) ให้คิดก่อนว่าควรอยู่ใน callback จริงไหม · ส่วนมากคำตอบคือ "ย้ายไปไว้ในไฟล์ของงานนั้นเอง" — หัวข้อถัดไป

business logic ควรอยู่ที่ไหน — ใครในร้านรับผิดชอบงานไหน

business logic คือกฎของธุรกิจเรา เช่น "บทความที่เผยแพร่แล้ว ห้ามเผยแพร่ซ้ำ" หรือ "เผยแพร่แล้วต้องแจ้งผู้ติดตาม" · Rails ไม่ได้บังคับว่าโค้ดพวกนี้ต้องอยู่ไฟล์ไหน — สไลด์หน้า 27 เลยเสนอที่อยู่ 4 แบบ:

ที่อยู่ใส่อะไรในร้านอาหารตัวอย่าง · โฟลเดอร์
modelของที่เป็น "ธรรมชาติ" ของสิ่งนั้นคนดูแลสต็อกรู้เรื่องของแฟ้มตัวเองpost.published · validation · callback แบบสบาย ๆ · app/models/
concernของที่ model หลายตัวใช้ร่วมกันคู่มือกลางที่หลายลิ้นชักหยิบไปใช้Sluggable · app/models/concerns/
service objectงานหลายขั้น ข้ามหลาย modelใบงานที่ผู้จัดการถือไปสั่งหลายแผนกทีละขั้นPublishPost · app/services/
form objectฟอร์มเดียว ที่ข้อมูลกระจายลงหลายตารางใบสมัครสมาชิกใบเดียว แยกเก็บหลายลิ้นชักSignupForm · app/forms/
🍽 เทียบให้เห็นภาพ — งาน "เปิดเมนูใหม่" ของร้าน

การเปิดเมนูใหม่มีหลายขั้น: เช็คว่ายังไม่เคยเปิด → เปลี่ยนป้ายเป็น "พร้อมขาย" → แจ้งลูกค้าประจำ · ถ้ายัดทุกขั้นไว้กับคนดูแลสต็อก (model) เขาจะต้องรู้เรื่องลูกค้าด้วย ทั้งที่หน้าที่เขาคือแฟ้ม · ร้านที่เป็นระบบจะเขียนใบงาน "เปิดเมนูใหม่" (service object) หนึ่งใบ ใครจะเปิดเมนูก็หยิบใบนี้ไปทำตามทีละขั้น

ที่ไม่เหมือนกัน: ร้านจริงมีผังองค์กรชัดว่าใครทำอะไร · Rails ไม่มีผังให้ ทุกทีมตกลงกันเอง · ไปทีมใหม่ ต้องเปิดดูโฟลเดอร์ app/ หรือถาม senior ก่อนว่าทีมนี้วางงานแบบไหน — อย่าเดาจากทีมเก่า

concern — ทวนบท 95 ในแบบของ Rails

บท 95 คุณทำ module Sluggable แล้ว include ไปใส่หลาย class · ใน Rails ทำแบบเดียวกันแต่วางไว้ที่ app/models/concerns/ (โฟลเดอร์นี้มีมาให้แล้วตั้งแต่ rails new) · ตัวอย่างนี้ดูหน้าตาไว้ก่อน ไม่ต้องทำตาม (ผมรันจริงแล้ว):

# app/models/concerns/sluggable.rb
module Sluggable
  extend ActiveSupport::Concern

  included do
    before_validation :generate_slug
  end

  private

  def generate_slug
    self.slug ||= title.to_s.parameterize
  end
end

# app/models/post.rb — แค่ include แทนการเขียนเอง
class Post < ApplicationRecord
  include Sluggable
  …
end

service object — ลองทำ PublishPost จิ๋ว ๆ

งาน "เผยแพร่บทความ" มีหลายขั้น เหมาะกับ service object · ชื่อเป็นกริยา (PublishPost = "เผยแพร่บทความ") ไม่ใช่คำนาม · เพราะมันคือ "งานหนึ่งอย่าง" ไม่ใช่ "ของหนึ่งชิ้น" · สร้างโฟลเดอร์ app/services/ แล้วสร้างไฟล์นี้:

# app/services/publish_post.rb
class PublishPost
  def initialize(post)
    @post = post
  end

  def call
    return false if @post.published

    @post.update!(published: true)
    Rails.logger.info "📣 เผยแพร่เรื่อง #{@post.title} ของ #{@post.user.name} แล้ว"
    true
  end
end

⚠️ สร้างโฟลเดอร์ app/services/ ใหม่ ต้องปิด console แล้วเปิดใหม่ · reload! ไม่พอ — ผมลองสร้างโฟลเดอร์ระหว่างที่ console เปิดอยู่ ได้แบบนี้ทั้งก่อนและหลัง reload!:

(wongnok-blog):2:in '<main>': uninitialized constant PublishPost (NameError)

Rails จดรายชื่อโฟลเดอร์ใต้ app/ ไว้ตอนเปิดแอปเท่านั้น · เปิดใหม่แล้วโฟลเดอร์ใหม่ถูกรวมเข้ามาเอง (ไม่ต้องตั้งค่าอะไร)

เปิด console ใหม่ แล้วเผยแพร่ "Green Curry Chicken" ที่สร้างไว้ตอนหัวข้อ slug (ตอนนี้ยังเป็นร่าง) — ผลจริง:

wongnok-blog(dev):001> curry = Post.find_by(slug: "green-curry-chicken"); nil
  Post Load (1.7ms)  SELECT "posts".* FROM "posts" WHERE "posts"."slug" = 'green-curry-chicken' LIMIT 1 /*application='WongnokBlog'*/
=> nil
wongnok-blog(dev):002> PublishPost.new(curry).call
  TRANSACTION (1.9ms)  BEGIN /*application='WongnokBlog'*/
  Post Update (4.1ms)  UPDATE "posts" SET "updated_at" = '2026-11-04 11:55:35.237323', "published" = TRUE WHERE "posts"."id" = 8 /*application='WongnokBlog'*/
  TRANSACTION (1.7ms)  COMMIT /*application='WongnokBlog'*/
  User Load (1.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1 /*application='WongnokBlog'*/
📣 เผยแพร่เรื่อง Best Green Curry ของ สมชาย แล้ว
=> true
wongnok-blog(dev):003> PublishPost.new(curry).call
=> false

เทียบกับ Go: นี่คือแนวเดียวกับชั้น service ที่คุณแยกจาก handler และ repository ในโปรเจกต์ Go (บท 27) · ต่างกันที่ Go คุณวางโครงนี้เองตั้งแต่วันแรก · Rails ไม่มีโฟลเดอร์นี้มาให้ สร้างเมื่อต้องใช้

form object — แค่รู้จักชื่อไว้: ใช้ตอนฟอร์มหนึ่งหน้าไม่ตรงกับ model ตัวเดียว เช่น ฟอร์มสมัครสมาชิกที่สร้างทั้งผู้ใช้และบทความแรกพร้อมกัน · เป็น class ธรรมดาที่ใช้ validation ได้ทั้งที่ไม่มีตารางของตัวเอง · บทนี้ไม่ลงรายละเอียด

💡 ลำดับที่สไลด์แนะนำ: เขียนใน model ไปก่อน → เมื่อไฟล์ยาวเกินราว ๆ 200 บรรทัด หรือเริ่มมีหลายเรื่องปนกัน ค่อยแยกออก · อย่าแยกตั้งแต่วันแรก — สไลด์อ้างว่าโปรเจกต์ที่มี service object 40 ไฟล์ตั้งแต่สัปดาห์แรก อ่านยากกว่า model อ้วน ๆ ไฟล์เดียว

🗣 ศัพท์ที่เพิ่งเรียนไป
service object
class ธรรมดาที่ทำ "งานหนึ่งอย่าง" ที่มีหลายขั้น · ตั้งชื่อเป็นกริยา (PublishPost · CheckoutOrder) · มักมี method เดียวชื่อ call · อยู่ app/services/ · ถาม senior ได้ว่า “งานนี้ควรอยู่ใน model หรือแยกเป็น service object — ทีมเราใช้เกณฑ์อะไร”
concern · ActiveSupport::Concern
module ที่ model หลายตัว include ไปใช้ร่วมกัน · ใส่ callback หรือ validation ได้ผ่าน included do … end · อยู่ app/models/concerns/ · พิมพ์บอก AI ได้ว่า “extract this slug logic into a Rails concern only if two models need it” — กันมันแยกเร็วเกินเหตุ
fat model · form object
fat model = model ที่อ้วนเพราะมีงานทุกอย่างอยู่ในไฟล์เดียว (ไม่ผิด แค่มีจุดที่ควรแยก) · form object = class สำหรับฟอร์มที่เขียนหลายตาราง · ถาม senior ได้ว่า “model ไหนของเราอ้วนที่สุด และมีแผนจะแยกไหม”

งาน "เผยแพร่บทความ" ต้องทำ: เช็คว่ายังไม่เคยเผยแพร่ → เปลี่ยนสถานะบทความ → บันทึกสถิติของผู้เขียน → แจ้งผู้ติดตาม · ควรวางโค้ดไว้ที่ไหน?

ตัวเลือกที่ 1 ถูก — งานหลายขั้น ข้ามหลาย model (บทความ · ผู้เขียน · ผู้ติดตาม) = ใบงานหนึ่งใบที่ชื่อเป็นกริยา

ตัวเลือกที่ 2 ผิด เพราะ concern ใช้ตอน model หลายตัวมีของเหมือนกัน · ที่นี่มีแค่ Post ที่เผยแพร่ได้ · ตัวเลือกที่ 3 ผิด เพราะเป็นทุกอย่างในตาราง "เริ่มเจ็บ": ไปแก้ model อื่น + แจ้งคนนอกตู้ใน after_save (ก่อน COMMIT) · ตัวเลือกที่ 4 ผิด เพราะไม่ได้มีฟอร์มที่ผู้ใช้กรอกลงหลายตาราง

จุดที่ต้องจำ: ธรรมชาติของแฟ้ม → model · ซ้ำหลาย model → concern · งานหลายขั้นข้าม model → service object · ไม่แน่ใจ → เริ่มที่ model

🎯 ภารกิจ — ให้คอมเมนต์ดูแลตัวเอง (~10 นาที)

ทำในแอป wongnok_blog ของคุณ (ที่มี users / posts / comments และ seeds จากบท 107 · Post มี slug ตามหัวข้อบน ๆ แล้ว) · ใช้ console ธรรมดา ไม่ใช่ --sandbox

  1. เปิด app/models/comment.rb แก้ให้เป็นแบบนี้ (สองบรรทัด belongs_to มีอยู่แล้ว):
    # app/models/comment.rb
    class Comment < ApplicationRecord
      belongs_to :post
      belongs_to :user
    
      before_validation :tidy_body
      after_commit :announce, on: :create
    
      private
    
      def tidy_body
        self.body = body.to_s.strip
      end
    
      def announce
        Rails.logger.info "💬 คอมเมนต์ใหม่ในเรื่อง #{post.title}"
      end
    end
  2. เปิด bin/rails console แล้วพิมพ์: tomyum = Post.find_by(title: "ต้มยำกุ้งน้ำข้น"); mana = User.find_by(name: "มานะ"); nil
  3. สร้างคอมเมนต์ที่มีช่องว่างหัวท้าย: c = tomyum.comments.create(user: mana, body: " อร่อยมาก "); nil แล้วพิมพ์ c.body (tomyum.comments.create = สร้างคอมเมนต์ที่เป็นของบทความต้มยำ · Rails เติม post_id ให้เอง เหมือน somchai.posts.create)
  4. ลองสร้างคอมเมนต์ในกล่องที่ถูกยกเลิก — พิมพ์ทีละบรรทัด กด Enter ทุกบรรทัด (แบบเดียวกับ Boat Noodles ข้างบน):
    Comment.transaction do
      tomyum.comments.create(user: mana, body: "คอมเมนต์ที่จะถูกยกเลิก")
      raise ActiveRecord::Rollback
    end
  5. นับ: tomyum.comments.count
  6. คืนสภาพก่อนไปบทถัดไป — ปิด console (และ server ถ้าเปิดอยู่) แล้ว: ① ลบเฉพาะโค้ดที่บทนี้เพิ่มใน post.rb · user.rb · comment.rb (บรรทัด callback · normalizes · method ที่บทนี้เขียนใต้ private) — validates จากบท 110–111 เก็บไว้ · ② ลบโฟลเดอร์ app/services/ (และ app/models/concerns/sluggable.rb ถ้าทำตาม) · ③ bin/rails db:rollback (รื้อช่อง slug ออก) · ④ ลบไฟล์ db/migrate/…_add_slug_to_posts.rb · ⑤ bin/rails db:reset (ล้างตู้แล้วใส่ seeds ใหม่ — บท 108)
    ลำดับสำคัญ: ต้อง rollback (③) ก่อนลบไฟล์ migration (④) · ลบไฟล์ก่อนแล้ว Rails จะไม่รู้ว่าต้องรื้ออะไร

ก่อนกดเฉลย: ข้อ 3 c.body ได้อะไร · ข้อ 4 จะเห็นบรรทัด 💬 ไหม · ข้อ 5 ได้เลขเท่าไร (seeds ให้ต้มยำกุ้งมีคอมเมนต์อยู่แล้ว 2 อัน) · และถ้าเปลี่ยน after_commit เป็น after_save ข้อ 4 จะต่างไปยังไง?

สิ่งที่ควรเห็น (ผลจริงจากเครื่องที่ใช้ตรวจ · id และเวลาของคุณจะต่าง):

wongnok-blog(dev):002> c = tomyum.comments.create(user: mana, body: "   อร่อยมาก   "); nil
  TRANSACTION (1.5ms)  BEGIN /*application='WongnokBlog'*/
  Comment Create (3.7ms)  INSERT INTO "comments" ("body", "post_id", "user_id", "created_at", "updated_at") VALUES ('อร่อยมาก', 1, 3, '2026-11-04 11:55:58.493625', '2026-11-04 11:55:58.493625') RETURNING "id" /*application='WongnokBlog'*/
  TRANSACTION (1.0ms)  COMMIT /*application='WongnokBlog'*/
💬 คอมเมนต์ใหม่ในเรื่อง ต้มยำกุ้งน้ำข้น
=> nil
wongnok-blog(dev):003> c.body
=> "อร่อยมาก"
wongnok-blog(dev):004> Comment.transaction do
wongnok-blog(dev):005*   tomyum.comments.create(user: mana, body: "คอมเมนต์ที่จะถูกยกเลิก")
wongnok-blog(dev):006*   raise ActiveRecord::Rollback
wongnok-blog(dev):007*   end
  TRANSACTION (2.8ms)  BEGIN /*application='WongnokBlog'*/
  Comment Create (6.6ms)  INSERT INTO "comments" ("body", "post_id", "user_id", "created_at", "updated_at") VALUES ('คอมเมนต์ที่จะถูกยกเลิก', 1, 3, '2026-11-04 11:57:30.098348', '2026-11-04 11:57:30.098348') RETURNING "id" /*application='WongnokBlog'*/
  TRANSACTION (1.2ms)  ROLLBACK /*application='WongnokBlog'*/
=> nil
wongnok-blog(dev):008> tomyum.comments.count
  Comment Count (3.7ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 1 /*application='WongnokBlog'*/
=> 3
  • ข้อ 3 — INSERT ส่ง 'อร่อยมาก' ไม่มีช่องว่าง · 💬 ขึ้นหลัง COMMIT
  • ข้อ 4 — ROLLBACK · ไม่มี 💬 · ข้อ 5 ได้ 3 (2 จาก seeds + 1 จากข้อ 3) — "นับแฟ้มในลิ้นชัก comments ที่เป็นของบทความเลข 1"
  • ถ้าเปลี่ยนเป็น after_save :announce — ผมลองแล้ว ข้อ 4 มี 💬 ขึ้นก่อน ROLLBACK = ประกาศคอมเมนต์ที่ไม่มีอยู่จริง (เรื่องเดียวกับอีเมลหลอก)
  • ข้อ 6 — db:rollback ขึ้น AddSlugToPosts: reverting · -- remove_column(:posts, :slug, :string) · db:reset ขึ้น Dropped database … / Created database … (ชื่อ database ของคุณจะต่างจากของผม) · เช็คด้วย bin/rails runner 'p [User.count, Post.count, Comment.count]' ควรได้ [3, 6, 7] เท่ากับตอนจบบท 107

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

  • c.body ยังมีช่องว่างหัวท้าย ไม่มี error — ลืม self. ใน tidy_body (ผมลองแล้ว ได้ " ลืม self " ออกมาทั้งช่องว่าง)
  • undefined method 'anounce' for an instance of Comment (NoMethodError) — ชื่อหลัง after_commit สะกดไม่ตรงกับชื่อ method · ระวัง: ผมลองแล้ว error ขึ้นหลัง COMMIT — คอมเมนต์ถูกบันทึกไปแล้วทั้งที่จอแดง (count เพิ่มจริง) · Ruby ใบ้ให้ด้วย Did you mean? announce
  • User(#…) expected, got #<User id: 3, name: "มานะ", …> which is an instance of User(#…) (ActiveRecord::AssociationTypeMismatch) หรือข้อ 3 ไม่มี 💬 และช่องว่างยังอยู่ครบ — console ยังถือตัวแปรที่หาไว้ก่อนแก้ไฟล์ · ผมลองแล้ว: พิมพ์ reload! แต่ใช้ tomyum กับ mana ตัวเดิม ได้ error นี้ (Rails เห็นว่า mana เป็นแฟ้ม User รุ่นก่อน reload) · หา mana ใหม่แต่ใช้ tomyum เดิม ได้ช่องว่างครบและไม่มี 💬 · ทางที่ชัวร์คือปิด console เปิดใหม่ แล้วเริ่มจากข้อ 2
  • undefined method 'slug' หลังคืนสภาพ — rollback ช่อง slug ไปแล้ว แต่ยังไม่ได้ลบ generate_slug ออกจาก post.rb · กลับไปทำ ① ให้ครบ

ติดตรงไหน หรือผลของคุณไม่ตรงกับที่เขียนไว้ ถามกลับมาได้เลย พร้อมแปะสิ่งที่จอขึ้นมาทั้งก้อน

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

หน้าสไลด์เขียนของจริงต้องสนใจไหม
24โค้ด self.slug ||= … โดยไม่บอกว่าต้องเพิ่มคอลัมน์ตาราง posts ไม่มีช่อง slug · ไม่ทำ migration ก่อนได้ undefined method 'slug'สนใจ — พิมพ์ตามแล้วพัง
24NotifyFollowersJob.perform_later(id)แอปยังไม่มี job นี้ (Day 3) · ได้ uninitialized constant Post::NotifyFollowersJob หลัง COMMIT · บทนี้ใช้ Rails.logger แทนสนใจ ถ้าพิมพ์ตาม
24title.to_s.parameterizeชื่อเรื่องภาษาไทยได้ slug เป็น ""ควรรู้ ยังไม่ต้องแก้
27PublishPost.call(post)กับโค้ดในบทนี้ได้ undefined method 'call' for class PublishPost · บทนี้เรียก PublishPost.new(post).call · แบบสไลด์ต้องเขียน method ของ class เพิ่มไม่ต้อง — ทั้งสองแบบเจอบ่อย
29Quiz 3 ข้อ 3 (ส่งอีเมลหลังสร้างโพสต์)เฉลยคือ after_commit + job · อยู่ใน quiz ข้อ 3 ของบทนี้ พร้อมผลรันจริง · ข้อ 1–2 ของสไลด์เป็นเรื่องของบท 110–111ไม่ต้อง

อ้างอิง