🔁 ทวนก่อนเริ่ม — คำจากบทก่อน ๆ ที่บทนี้ใช้
- model (บท 106) — คนดูแลสต็อกที่ฝังอยู่ในแฟ้ม ·
Postหนึ่งตัว = แฟ้มบทความหนึ่งแผ่น ที่เดินเข้าลิ้นชักpostsเองได้ - validation (บท 110) — กฎที่คนดูแลสต็อกใช้ตรวจแฟ้มก่อนเก็บ เช่น "ไม่มีชื่อเรื่อง ไม่รับ" ·
save!/update!= ถ้าไม่ผ่านให้ระเบิด - migration (บท 108) — ใบสั่งช่างมาต่อเติมตู้ ·
bin/rails db:migrate= ให้ช่างลงมือ ·db:rollback= รื้องานใบล่าสุดออก - transaction (บท 111) — กล่องคำสั่ง "ทำทั้งหมด หรือไม่ทำเลย" · ใน log เห็นเป็น
BEGIN(เปิดกล่อง) ·COMMIT(ยืนยัน) ·ROLLBACK(ยกเลิกทั้งกล่อง) ||=(บท 94) — "ถ้ายังว่างค่อยใส่" ·before_action :set_post(บท 102) — "ก่อนเข้าเมนู ให้เรียก method นี้ก่อน" ·private(บท 102) — method ข้างใต้ใช้ได้แค่ภายใน class
🧰 ตรวจด้วย 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
อ่านเฉพาะส่วนที่เกี่ยวกับเรื่องนี้:
func (p *Post) BeforeSave(tx *gorm.DB) error— method ของ struct Post (pคือบทความตัวที่กำลังจะถูกบันทึก) · คืนerrorได้ ถ้าคืน error GORM จะยกเลิกการบันทึกif p.Slug == ""… — ถ้ายังไม่มี slug ให้เอาชื่อเรื่องมาเปลี่ยนช่องว่างเป็นขีด แล้วทำเป็นตัวเล็กdb.AutoMigrate(&Post{})— ให้ GORM สร้างตารางจาก struct (บท 27) ·db.Create(&post)— บันทึก · ในจังหวะนี้แหละที่ GORM เรียกBeforeSaveให้เอง เราไม่ได้เรียกเลย- ฝั่ง Go: GORM เดาจากชื่อ method — สะกด
Beforesaveผิดตัวเดียวก็เงียบ ไม่ถูกเรียก (ผมลองแล้ว ได้บรรทัดว่างแทน slug) - ฝั่ง Rails: เราประกาศเองตรง ๆ ว่า "จังหวะ before_validation ให้เรียก
:generate_slug" · ชื่อ method ตั้งอะไรก็ได้ - ใช้ SQLite (ฐานข้อมูลในหน่วยความจำ) แค่ให้ตัวอย่าง Go รันได้ในไฟล์เดียว ไม่เกี่ยวกับแอป Rails
- 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
belongs_to :user·has_many :comments— ของเดิมในไฟล์ (บทความเป็นของผู้ใช้หนึ่งคน มีคอมเมนต์หลายอัน — ละเอียดบท 115) ห้ามลบbefore_validation { … }— แทนที่จะส่งชื่อ method ก็ส่ง block (ก้อนโค้ดใน{ }บท 93) ไปตรง ๆ ได้ · เหมาะกับโค้ดทดลองสั้น ๆ
เปิด 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
; nilท้ายบรรทัด —;คือ "สั่งสองอย่างในบรรทัดเดียว" · อย่างที่สองคือnilเฉย ๆ · console จึงโชว์=> nilแทนการพิมพ์แฟ้มบทความยาวเหยียด · ไม่ได้เปลี่ยนผลอะไร ใช้แบบนี้ทั้งบทsomchai.posts.create(…)— สร้างบทความในชื่อของสมชาย แบบเดียวกับใน seeds บท 107 · Rails เติมช่องuser_idให้เอง (ละเอียดบท 115)- บรรทัดที่ขึ้นต้นด้วย
User Load (2.0ms)·Post Create (3.1ms)— สมุดบันทึกการเบิกของ: ใครไปตู้ ทำอะไร ใช้เวลากี่มิลลิวินาที แล้วตามด้วย SQL ที่ส่งไปจริง SELECT "users".* FROM "users" WHERE "users"."name" = 'สมชาย' LIMIT 1— "ขอทุกช่องจากลิ้นชัก users เฉพาะแผ่นที่ช่อง name เป็น สมชาย เอาแผ่นเดียว" · ป้าย/*application='WongnokBlog'*/ท้ายทุกบรรทัด คือ Rails แปะบอกว่า SQL นี้มาจากแอปไหน ไม่ต้องสนใจINSERT INTO "posts" (…) VALUES (…)— "เก็บแฟ้มใหม่ลงลิ้นชัก posts โดยเขียนช่องพวกนี้ด้วยค่าพวกนี้" ·RETURNING "id"ท้ายคำสั่ง = "เก็บเสร็จแล้วบอกเลขที่แฟ้มกลับมาด้วย" (บท 107) · ตอนนี้ยังไม่มีช่องslug— จะเพิ่มในหัวข้อถัดไปBEGIN…COMMIT— Rails ครอบการบันทึกทุกครั้งด้วย transaction ให้เอง (บท 111) · ที่BEGINโผล่หลังเลข 4 เพราะ Rails ส่งคำนี้ไปบอกฐานข้อมูลตอนจะเขียนจริงเท่านั้น ไม่ต้องสนใจ- จุดที่ต้องดูจริง ๆ: เลข 6 (
after_save) อยู่ก่อนCOMMIT· เลข 7 (after_commit) อยู่หลัง — จำไว้ หัวข้อ after_save หรือ after_commit ข้างล่างใช้เรื่องนี้
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) ==========================
AddSlugToPosts slug:string— ชื่อขึ้นต้นด้วยAdd…To…Rails เลยรู้ว่าจะเพิ่มช่องให้ลิ้นชัก posts · ช่องชื่อslugเก็บข้อความadd_column(:posts, :slug, :string)— ใบสั่งช่างที่ generator เขียนให้ มีบรรทัดเดียว · เลข20261104115254คือวันเวลาที่สร้าง ของคุณจะต่าง
ขั้นที่ 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
before_validation :generate_slug— "ก่อนตรวจแฟ้มทุกครั้ง ให้เรียก methodgenerate_slug" · ส่งชื่อ method เป็น symbol เหมือนbefore_actionprivate—generate_slugเป็นงานภายใน ไม่อยากให้ใครเรียกpost.generate_slugจากข้างนอกtitle.to_s— เอาชื่อเรื่องมา ·.to_sกันกรณี title เป็นnil(ได้""แทน จะได้ไม่พัง).parameterize— method ของ Rails ที่แปลงข้อความเป็นแบบใส่ URL ได้: ตัวเล็กหมด · ช่องว่างเป็นขีด · ตัดเครื่องหมายแปลก ๆ ทิ้งself.slug ||= …— "ถ้าช่อง slug ยังว่าง ค่อยเติม" (บท 94) · คำว่าself.สำคัญมาก หัวข้อถัดไปทั้งหัวข้อพูดเรื่องนี้
⚠️ ถ้าข้ามขั้นที่ 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"
- บรรทัด 002 — ไม่ได้ส่ง slug มาเลย แต่ใน INSERT ช่องสุดท้ายเป็น
'green-curry-chicken'= callback เติมให้ก่อนเข้าตู้ - บรรทัด 004 — เปลี่ยนชื่อเรื่อง แต่ UPDATE ไม่ได้แตะช่อง slug · บรรทัด 005 ยืนยันว่ายังเป็นชื่อเดิม ·
เพราะ
||=เห็นว่า slug มีค่าแล้วเลยปล่อยไว้ — ตั้งใจ: URL ที่คนแชร์ไปแล้วจะได้ไม่เสีย
⚠️ ชื่อเรื่องภาษาไทย ได้ 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"
attr_accessor :slug(บท 95) — สร้าง method อ่านslugและ method เขียนslug=ให้ · Rails ก็สร้างสองตัวนี้ให้ทุกคอลัมน์เหมือนกันslug = "hello"— ไม่มีอะไรนำหน้า Ruby ถือว่าเป็นการตั้งตัวแปรใหม่เสมอ · ไม่ได้เรียกslug=self.slug = "hello"— มีself.นำหน้า = "เรียก methodslug=ของ object ตัวนี้" → ค่าเข้าช่องจริง
คนดูแลสต็อกถือแฟ้มบทความอยู่ และมีกระดาษโน้ตแผ่นเล็กในมือ ·
slug = … คือจดลงกระดาษโน้ต — จบงานก็ขยำทิ้ง แฟ้มไม่ได้อะไรเลย ·
self.slug = … คือเขียนลงช่อง slug บนแฟ้มแผ่นนี้ — แฟ้มเข้าตู้พร้อมค่านั้น
ที่ไม่เหมือนกัน: คนจริงก้มดูก็รู้ว่าเขียนผิดที่ · Ruby ไม่เตือนสักคำ เพราะการสร้างตัวแปรชื่อ slug ถูกกฎทุกอย่าง ·
และการอ่าน (title) ไม่ต้องมี self. เพราะไม่มีตัวแปรชื่อ title ให้สับสน — ต้องระวังแค่ตอนเขียน
เทียบกับ Go: ใน hook ของ GORM คุณเขียน p.Slug = … — มี p. นำหน้าเสมอเพราะ Go บังคับ ·
self. ของ Ruby คือ p. ตัวนั้น ต่างกันที่ Ruby ยอมให้ละไว้ตอนอ่าน เลยเผลอละตอนเขียนได้
self· setterself= 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
.strip— ตัดช่องว่างหัวท้ายทิ้ง ·.downcase— เปลี่ยนเป็นตัวเล็กทั้งหมด · และอย่าลืมself.ตามหัวข้อที่แล้ว
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
normalizes :email— "ช่อง email ต้องผ่านเครื่องแปลงก่อนเสมอ"with: -> email { … }—->สร้าง lambda = ก้อนโค้ดที่เก็บไว้เรียกทีหลัง (ญาติของ block บท 93) · รับค่าเดิมเข้ามาในชื่อemailแล้วคืนค่าใหม่ · เทียบ Go คือfunc(email string) string { … }ที่ส่งเป็นค่าได้- ไม่ต้องเขียน
self.ไม่ต้องมีprivate· ลบbefore_validation :tidy_emailกับ methodtidy_emailทิ้งได้เลย
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
after_commit :notify_followers, on: :create— "หลังตู้ยืนยันแล้ว เรียกnotify_followers· เฉพาะตอนสร้างใหม่" · ไม่ใส่on:จะทำทั้งตอนสร้าง แก้ และลบRails.logger.info "…"— จดลงสมุดบันทึกของแอป (log) แทนputs(บท 93 บอกไว้ว่าแอปจริงใช้ตัวนี้) · ใน console จะเห็นขึ้นจอด้วย- ของจริงใน
notify_followersจะเป็นNotifyFollowersJob.perform_later(id)ตามสไลด์ — แปลว่า "ฝากงานส่งอีเมลไว้ในคิว ให้คนงานเบื้องหลังทำทีหลัง" · job คือเรื่องของ Day 3 · ตอนนี้แอปยังไม่มี job ตัวนี้ ถ้าพิมพ์ตามสไลด์จะได้uninitialized constant Post::NotifyFollowersJob(ผมลองแล้ว)
เปิด 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
Post.transaction do … end— เปิดกล่อง "ทำทั้งหมดหรือไม่ทำเลย" เอง (บท 111) · พิมพ์doแล้ว Enter prompt จะเปลี่ยนเป็น*= รอให้พิมพ์จนถึงendraise ActiveRecord::Rollback— สั่ง "ยกเลิกทั้งกล่อง" แบบเงียบ ๆ (ไม่โชว์ error) · จำลองว่าขั้นถัดไปพัง เช่น ระบบอื่นล่ม
บทความ 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
extend ActiveSupport::Concern— ป้ายบอก Rails ว่า "module นี้เป็น concern" เพื่อปลดล็อกบรรทัดถัดไป (ActiveSupport= ชุดเครื่องมือเสริมที่มากับ Rails ·parameterizeก็มาจากชุดนี้)included do … end— "ตอนมี class ไหนincludeฉัน ให้รันบรรทัดในนี้ใน class นั้น" · callback จึงไปติดที่ Post- ผลจริง:
User.first.posts.create(title: "Mango Sticky Rice", body: "ข้าวเหนียวมะม่วง").slugได้=> "mango-sticky-rice"— เหมือนเขียนใน Post ตรง ๆ - สไลด์แนะนำว่า แยกเป็น concern เมื่อซ้ำตั้งแต่ 2 ที่ขึ้นไป · ตอนนี้มี slug แค่ Post ที่เดียว เขียนใน model ไปก่อนพอ
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
class PublishPost— class Ruby ธรรมดา ไม่มี< ApplicationRecordเพราะไม่ใช่แฟ้มในตู้ ไม่มีตารางของตัวเองinitialize(post)·@post = post— รับบทความที่จะเผยแพร่มาเก็บไว้ (บท 95)return false if @post.published— ขั้น 1: ถ้าเผยแพร่ไปแล้ว จบเลย คืนfalse@post.update!(published: true)— ขั้น 2: เปลี่ยนสถานะ · มี!= ถ้าบันทึกไม่ได้ให้ระเบิด (บท 110)Rails.logger.info …— ขั้น 3: แจ้งผู้ติดตาม (ของจริงฝากให้ job — Day 3) ·@post.user.name= ชื่อผู้เขียน (บท 115 จะอธิบายว่าทำไมเรียกแบบนี้ได้)- ชื่อไฟล์
publish_post.rb↔ ชื่อ classPublishPost— กฎตั้งชื่อเดียวกับบท 99 · Rails หาไฟล์เจอเองจากชื่อ ไม่ต้องrequire
⚠️ สร้างโฟลเดอร์ 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
- บรรทัด 001 — หาบทความด้วย slug ได้แล้ว (ค้นช่อง slug ในลิ้นชัก posts) · ชื่อเรื่องเป็น Best Green Curry เพราะเราเปลี่ยนไว้ แต่ slug ยังเป็นอันเดิม
- บรรทัด 002 —
UPDATE … SET "published" = TRUE= "แก้ช่อง published ของแฟ้มเลข 8 เป็นจริง" · ไม่มี 📬 เพราะafter_commitของเราใส่on: :create(นี่คือการแก้ ไม่ใช่สร้าง) User Load … WHERE "users"."id" = 1 LIMIT 1หลัง COMMIT — มาจาก@post.user.nameในขั้น 3: "ไปหยิบแฟ้มผู้เขียนเลขที่ 1 จากลิ้นชัก users มาดูชื่อ"- บรรทัด 003 — เรียกซ้ำ ได้
falseทันที ไม่ยิง SQL สักบรรทัด · กฎ "ห้ามเผยแพร่ซ้ำ" อยู่ในใบงานใบเดียว อ่านง่าย
เทียบกับ 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
- เปิด
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 - เปิด
bin/rails consoleแล้วพิมพ์:tomyum = Post.find_by(title: "ต้มยำกุ้งน้ำข้น"); mana = User.find_by(name: "มานะ"); nil - สร้างคอมเมนต์ที่มีช่องว่างหัวท้าย:
c = tomyum.comments.create(user: mana, body: " อร่อยมาก "); nilแล้วพิมพ์c.body(tomyum.comments.create= สร้างคอมเมนต์ที่เป็นของบทความต้มยำ · Rails เติมpost_idให้เอง เหมือนsomchai.posts.create) - ลองสร้างคอมเมนต์ในกล่องที่ถูกยกเลิก — พิมพ์ทีละบรรทัด กด Enter ทุกบรรทัด (แบบเดียวกับ Boat Noodles ข้างบน):
Comment.transaction do tomyum.comments.create(user: mana, body: "คอมเมนต์ที่จะถูกยกเลิก") raise ActiveRecord::Rollback end - นับ:
tomyum.comments.count - คืนสภาพก่อนไปบทถัดไป — ปิด 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? announceUser(#…) 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 เปิดใหม่ แล้วเริ่มจากข้อ 2undefined method 'slug'หลังคืนสภาพ — rollback ช่อง slug ไปแล้ว แต่ยังไม่ได้ลบgenerate_slugออกจากpost.rb· กลับไปทำ ① ให้ครบ
ติดตรงไหน หรือผลของคุณไม่ตรงกับที่เขียนไว้ ถามกลับมาได้เลย พร้อมแปะสิ่งที่จอขึ้นมาทั้งก้อน
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง | ต้องสนใจไหม |
|---|---|---|---|
| 24 | โค้ด self.slug ||= … โดยไม่บอกว่าต้องเพิ่มคอลัมน์ | ตาราง posts ไม่มีช่อง slug · ไม่ทำ migration ก่อนได้ undefined method 'slug' | สนใจ — พิมพ์ตามแล้วพัง |
| 24 | NotifyFollowersJob.perform_later(id) | แอปยังไม่มี job นี้ (Day 3) · ได้ uninitialized constant Post::NotifyFollowersJob หลัง COMMIT · บทนี้ใช้ Rails.logger แทน | สนใจ ถ้าพิมพ์ตาม |
| 24 | title.to_s.parameterize | ชื่อเรื่องภาษาไทยได้ slug เป็น "" | ควรรู้ ยังไม่ต้องแก้ |
| 27 | PublishPost.call(post) | กับโค้ดในบทนี้ได้ undefined method 'call' for class PublishPost · บทนี้เรียก PublishPost.new(post).call · แบบสไลด์ต้องเขียน method ของ class เพิ่ม | ไม่ต้อง — ทั้งสองแบบเจอบ่อย |
| 29 | Quiz 3 ข้อ 3 (ส่งอีเมลหลังสร้างโพสต์) | เฉลยคือ after_commit + job · อยู่ใน quiz ข้อ 3 ของบทนี้ พร้อมผลรันจริง · ข้อ 1–2 ของสไลด์เป็นเรื่องของบท 110–111 | ไม่ต้อง |
อ้างอิง
- Rails Guides — Active Record Callbacks § Available Callbacks —
อ่านแค่รายการ Creating an Object กับ Updating an Object · ลำดับตรงกับแผนภาพในบทนี้ (คู่มือมี
around_*เพิ่มมา ข้ามไปก่อนได้) - Rails Guides — Active Record Callbacks § after_commit and after_rollback — ทำไมงานที่ออกไปนอกฐานข้อมูลต้องรอ COMMIT · อ่านถึงหัวข้อ Aliases for after_commit ก็พอ
- Rails API — normalizes —
ดูตัวอย่าง
normalizes :email, with: -> email { email.strip.downcase }และหมายเหตุว่าค่า nil จะไม่ถูกแปลง ถ้าไม่ตั้งapply_to_nil· (ตัวอย่างในหน้านั้นมีบรรทัดinclude ActiveModel::…เพราะเป็น class ธรรมดา · model ที่< ApplicationRecordของเราไม่ต้องใส่) - Rails API — ActiveSupport::Inflector#parameterize — ตัวอย่างการแปลงข้อความเป็น slug · ดูแค่สองบรรทัดตัวอย่างแรก
- Ruby docs — Assignment § Local Variables and Methods —
คู่มือ Ruby อธิบายว่าทำไม
ชื่อ = ค่ากลายเป็นตัวแปร local และต้องใช้self.ถึงจะเรียก method - Rails Guides — Autoloading and Reloading Constants § Custom Namespaces —
อ่านแค่ย่อหน้าแรก ที่บอกว่า
app/servicesไม่ได้สร้างมาให้ แต่ถ้ามีอยู่ Rails จะรวมเข้าไปเอง · ส่วน namespace ที่เหลือข้ามได้ - Rails API — ActiveSupport::Concern —
หน้าตา
extend ActiveSupport::Concernกับincluded do· ตัวอย่างท้ายหน้าเรื่อง dependency ยังไม่ต้องอ่าน - GORM — Hooks —
ลำดับ hook ฝั่ง Go (
BeforeSave→ … →AfterSave→ commit or rollback) · ยืนยันว่า GORM ไม่มี hook หลัง commit