🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้ทั้งหมด
resources :posts(บท 101) — บรรทัดเดียวในconfig/routes.rbที่ได้ "เซ็ตเมนูมาตรฐาน" ของแผนกบทความ ·bin/rails routes -c posts= พิมพ์รายการของพนักงานต้อนรับเฉพาะแผนก postsparams(บท 102) — กล่องรวมของทุกอย่างที่ลูกค้าส่งมากับใบสั่ง · ใช้เหมือน Hash · ค่าข้างในเป็นข้อความเสมอprivate(บท 102) — method ที่อยู่ใต้คำนี้ไม่ใช่เมนู route ชี้มาหาไม่ได้ ใช้ได้แค่ใน class- CSRF token / บัตรคิว (บท 103) — ใบสั่งที่เปลี่ยนข้อมูล (POST · PATCH · DELETE) ต้องแนบรหัสที่ได้จากหน้าเว็บของเรา ไม่งั้นได้ 422
post.comments·save!(บท 115 · บท 110) — คอมเมนต์ทั้งหมดของบทความนี้ · บันทึก ถ้าไม่ได้ให้ระเบิด
🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 17 (สไลด์ใช้ Ruby 4.0.6 · Rails 8.1.3 — เรื่องในบทนี้ไม่ต่างกัน)
📦 ข้อมูลตัวอย่าง: ใช้ตาราง users / posts / comments และข้อมูลตัวอย่าง (seeds) ชุดเดียวกับ
บท 107 หัวข้อ "เตรียมตู้ของวันที่ 2" (สมชาย · สมหญิง · มานะ · บทความ 6 ชิ้น · คอมเมนต์ 7 อัน) ·
ผลรันทุกกล่องมาจากเครื่องจริงที่เปิด server พอร์ต 3000 · เวลา ms · เลข 0x… · วันเวลาใน log ของคุณจะไม่ตรงกับของผม ·
ผลในบทรันกับตารางสามลิ้นชักแบบบท 107 เปล่า ๆ — ถ้าแอปของคุณยังมีของจากบท 116
(ช่อง comments_count · counter_cache · touch) บรรทัด INSERT INTO "posts" จะมีช่อง comments_count เพิ่ม
และตอนสร้างคอมเมนต์จะมีบรรทัด UPDATE "posts" ตามมาอีกบรรทัด — ไม่ใช่ error
ทวน — REST และ resources :posts คือ 7 เมนูมาตรฐาน วางข้าง Gin ทีละแถว
REST คือคำที่คุณเจอแล้วในฝั่ง Go (บท 33) · ภาษาคน: ตั้งชื่อ URL เป็นชื่อของ (คำนาม เช่น /posts)
แล้วให้ verb (GET · POST · PATCH · DELETE) บอกว่าจะทำอะไรกับของนั้น · ไม่ตั้ง URL เป็นคำกริยาอย่าง /deletePost
ในร้านเรา REST คือกฎแฟรนไชส์ที่ให้ทุกแผนกรับใบสั่งรูปแบบเดียวกัน: ชื่อแผนก + เลขจาน (ถ้ามี) + อยากทำอะไร ·
"ของหนึ่งชนิดที่มี URL ของตัวเอง" (บทความ · คอมเมนต์) เรียกว่า resource — ที่มาของชื่อคำสั่ง resources
ในบท 101 เราพิมพ์ resources :posts บรรทัดเดียว แล้ว Rails ลงรายการให้พนักงานต้อนรับครบชุด ·
สไลด์หน้า 46 นับได้ 7 route แต่ตอนรัน bin/rails routes ในบท 101 เราเห็น 8 แถว — ไม่มีใครผิด:
$ bin/rails routes -c posts
Prefix Verb URI Pattern Controller#Action
posts GET /posts(.:format) posts#index
POST /posts(.:format) posts#create
new_post GET /posts/new(.:format) posts#new
edit_post GET /posts/:id/edit(.:format) posts#edit
post GET /posts/:id(.:format) posts#show
PATCH /posts/:id(.:format) posts#update
PUT /posts/:id(.:format) posts#update
DELETE /posts/:id(.:format) posts#destroy
ดูคอลัมน์ขวาสุด · PATCH กับ PUT ไปที่ posts#update เมนูเดียวกัน ·
นับเป็น "เมนู" (action) ได้ 7 · นับเป็น "บรรทัดในรายการ" ได้ 8 · เวลาคนพูดว่า "resources ให้ 7 route" เขานับเมนู
นี่คือ 7 เมนูนั้น วางข้างโค้ด Gin ที่คุณเคยเขียนในบท 38 (คอลัมน์ Gin ยกมาจากสไลด์หน้า 46):
| ใบสั่ง | เมนู | ภาษาคน | Gin ที่เคยเขียน |
|---|---|---|---|
GET /posts | index | ดูบทความทั้งหมด | r.GET("/posts", h.List) |
GET /posts/new | new | เปิดฟอร์มเขียนใหม่ | — (API ไม่มีหน้าฟอร์ม) |
POST /posts | create | บันทึกบทความใหม่ | r.POST("/posts", h.Create) |
GET /posts/:id | show | ดูบทความชิ้นเดียว | r.GET("/posts/:id", h.Get) |
GET /posts/:id/edit | edit | เปิดฟอร์มแก้ | — (API ไม่มีหน้าฟอร์ม) |
PATCH /posts/:id (และ PUT) | update | บันทึกที่แก้ | r.PUT("/posts/:id", h.Update) |
DELETE /posts/:id | destroy | ลบ | r.DELETE("/posts/:id", h.Del) |
ฝั่ง Gin มีแค่ 5 เพราะตอนนั้นเราทำ API ที่ตอบเป็น JSON ให้โปรแกรมอื่นอ่าน ไม่มีหน้าฟอร์มให้คนกรอก ·
Rails ทำเว็บที่มีหน้าฟอร์มด้วย เลยมี new กับ edit เพิ่ม
✅ ได้อะไร
ทุกแผนกในร้านใช้ชื่อเมนูชุดเดียวกัน · คนใหม่เข้าทีมเห็น URL /comments/3/edit ก็เดาได้ทันทีว่าไปที่ method edit ของ CommentsController ไม่ต้องเปิดไฟล์
⚠️ เสียอะไร
เปิด config/routes.rb แล้วไม่เห็น URL จริงสักเส้น เห็นแค่ resources :posts · ต้องสั่ง bin/rails routes ดูทุกครั้ง · ฝั่ง Gin เปิด main.go ก็เห็นครบ
- REST · RESTful
- แนวตั้ง URL เป็นชื่อของ (คำนาม) แล้วใช้ verb บอกว่าทำอะไร · route ที่ทำตามแนวนี้เรียกว่า RESTful ·
ถาม senior ได้ว่า "URL
/deletePost?id=3นี่ไม่ RESTful ใช่ไหมครับ ควรเป็นDELETE /posts/3" - resource
- ของหนึ่งชนิดที่มี URL ของตัวเอง (posts · comments) ·
resources :ชื่อสร้างเซ็ตเมนูมาตรฐานของมันให้ · พิมพ์บอก AI ได้ว่า "add RESTful routes for comments using resources"
ที่เหลือของครึ่งแรกคือ 4 ท่าสำหรับวันที่เซ็ตมาตรฐานไม่พอดีกับร้านเรา · ทุกท่าผมรัน bin/rails routes จริงให้ดูผล
ท่าที่ 1 · ตัดเมนูที่ไม่ใช้ทิ้ง — only: กับ except:
ถ้าแอปเรายังไม่มีหน้าลบบทความ ก็ไม่ควรเปิดทาง DELETE /posts/:id ทิ้งไว้ ·
เส้นที่เปิดไว้แต่ไม่ได้ใช้ คือประตูที่ไม่มีคนเฝ้า วันหนึ่งมีคนเผลอเขียน def destroy เพิ่มเข้ามา ประตูนั้นก็ใช้ได้ทันทีโดยไม่มีใครตั้งใจ
# config/routes.rb
resources :posts, only: [:index, :show]
$ bin/rails routes -c posts
Prefix Verb URI Pattern Controller#Action
posts GET /posts(.:format) posts#index
post GET /posts/:id(.:format) posts#show
only: [:index, :show]— "เอาแค่เมนูในรายการนี้" · ผลคือเหลือ 2 เส้นจาก 8 ·[ ]คือ array ของ symbol (บท 92)- เขียน
only:ต่อท้ายด้วยลูกน้ำได้ เพราะเป็น hash ท้าย argument ที่ไม่ต้องใส่ปีกกา (บท 97)
ถ้าอยากได้เกือบครบ ขาดแค่ตัวเดียว ใช้ except: (= "ทุกเมนู ยกเว้น…") จะสั้นกว่า:
resources :posts, except: [:destroy]
$ bin/rails routes -c posts
Prefix Verb URI Pattern Controller#Action
posts GET /posts(.:format) posts#index
POST /posts(.:format) posts#create
new_post GET /posts/new(.:format) posts#new
edit_post GET /posts/:id/edit(.:format) posts#edit
post GET /posts/:id(.:format) posts#show
PATCH /posts/:id(.:format) posts#update
PUT /posts/:id(.:format) posts#update
แถว DELETE หายไป · ตอนนี้ใบสั่ง DELETE /posts/1 จะได้ Routing Error ทันที (ผมถาม router ดูแล้วได้ No route matches "/posts/1" · พนักงานต้อนรับไม่มีบรรทัดนี้ในรายการ — 404 แบบที่ 1 ของบท 101)
ท่าที่ 2 · ซ้อนกัน — คอมเมนต์ของบทความเลขไหน
คอมเมนต์ไม่ได้ลอยอยู่เฉย ๆ มันอยู่ใต้บทความเสมอ (ในตู้ ใบคอมเมนต์มีช่อง post_id — บท 115) ·
เวลาจะเขียนคอมเมนต์ใหม่ URL ก็ควรบอกด้วยว่า "ใต้บทความเลขไหน" · Rails ทำได้ด้วยการวาง resources ซ้อนใน do … end:
# config/routes.rb
resources :posts do
resources :comments
end
$ bin/rails routes -c comments
Prefix Verb URI Pattern Controller#Action
post_comments GET /posts/:post_id/comments(.:format) comments#index
POST /posts/:post_id/comments(.:format) comments#create
new_post_comment GET /posts/:post_id/comments/new(.:format) comments#new
edit_post_comment GET /posts/:post_id/comments/:id/edit(.:format) comments#edit
post_comment GET /posts/:post_id/comments/:id(.:format) comments#show
PATCH /posts/:post_id/comments/:id(.:format) comments#update
PUT /posts/:post_id/comments/:id(.:format) comments#update
DELETE /posts/:post_id/comments/:id(.:format) comments#destroy
ของใหม่ในตารางนี้มีสามอย่าง:
- ทุก URL ขึ้นต้นด้วย
/posts/:post_id/— ช่องว่างที่รอเติมเลขบทความ · ชื่อช่องคือ:post_idไม่ใช่:idเพราะ:idสงวนไว้ให้เลขของคอมเมนต์เอง · ใน controller อ่านด้วยparams[:post_id] - ยังไปแผนก
comments(=CommentsController) ไม่ใช่แผนก posts · การซ้อนเปลี่ยนแค่หน้าตา URL ไม่ได้ย้ายแผนก - ชื่อ route ได้คำว่า
post_นำหน้า —post_comments·post_comment→ helper คือpost_comments_path·post_comment_path(หัวข้อ route helper ด้านล่าง)
ใบคอมเมนต์ในตู้มีช่อง "เป็นของบทความเลขที่ …" อยู่แล้ว (บท 115) ·
nested route คือการพิมพ์ช่องเดียวกันนี้ไว้บนใบสั่งของลูกค้าด้วย:
"ขอเขียนคอมเมนต์ ใต้บทความจานที่ 5" = POST /posts/5/comments ·
พนักงานต้อนรับเห็นเลข 5 ก็จดใส่ params[:post_id] แล้วส่งเข้าแผนกคอมเมนต์
ที่ไม่เหมือนกัน: พนักงานต้อนรับไม่ได้เปิดตู้เช็คว่าบทความเลข 5 มีจริงไหม
หรือคอมเมนต์เลข 9 อยู่ใต้บทความ 5 จริงไหม · เขาแค่จดตัวเลขตามใบสั่ง ·
คนที่ต้องเช็คคือเชฟ (controller) — เช่น Post.find(params[:post_id]) ถ้าไม่เจอได้ 404 (เห็นจริงในภารกิจท้ายบท)
เทียบกับ Go: ใน Gin คุณเขียนเองว่า r.POST("/posts/:post_id/comments", h.CreateComment) แล้วอ่าน c.Param("post_id") ·
Rails ได้ชุดนี้ครบ 8 บรรทัดจากการวาง resources :comments ไว้ใน block ของ posts
กฎ: ซ้อนแค่ชั้นเดียวพอ
ถ้าคอมเมนต์มีการตอบกลับ (replies) อีกชั้น แล้วซ้อนลงไปตรง ๆ จะได้แบบนี้ (ตัดมา 3 แถวจาก 8):
# ❌ ซ้อนสองชั้น
resources :posts do
resources :comments do
resources :replies
end
end
$ bin/rails routes -c replies
Prefix Verb URI Pattern Controller#Action
post_comment_replies GET /posts/:post_id/comments/:comment_id/replies(.:format) replies#index
edit_post_comment_reply GET /posts/:post_id/comments/:comment_id/replies/:id/edit(.:format) replies#edit
post_comment_reply GET /posts/:post_id/comments/:comment_id/replies/:id(.:format) replies#show
URL ยาวเป็นพรืด และทุกครั้งที่สร้างลิงก์ต้องส่งเลขครบสามตัว (บทความ · คอมเมนต์ · การตอบ) ·
คู่มือ Rails เองแนะนำว่า ซ้อนไม่เกิน 1 ชั้น · สไลด์หน้า 47 เรียก /posts/1/comments/2/replies/3 ว่า "สัญญาณว่าออกแบบผิด"
ทางออกคือ: ใช้ URL ซ้อนเฉพาะตอนที่ต้องรู้แม่ (ดูรายการคอมเมนต์ของบทความ · เขียนคอมเมนต์ใหม่ใต้บทความ) ·
ส่วนคอมเมนต์ที่มีเลขของตัวเองแล้ว ใช้ URL สั้น /comments/9 ได้เลย เพราะคอมเมนต์ 9 รู้อยู่แล้วว่าตัวเองอยู่ใต้บทความไหน ·
Rails มีตัวเลือก shallow: true ที่ทำแบบนี้ให้อัตโนมัติ (แค่รู้จักไว้ · ตัวอย่างเต็มอยู่ในคู่มือ Rails ลิงก์แรกของหัวข้ออ้างอิง):
resources :posts do
resources :comments, shallow: true # index · new · create ซ้อน · ที่เหลือใช้ /comments/:id
end
- nested resources
- การวาง
resourcesซ้อนใน block ของอีกตัว ได้ URL แบบ/posts/:post_id/comments· พิมพ์บอก AI ได้ว่า "nest comments under posts in routes.rb, only index and create" - parent id (
:post_id) - เลขของแม่ที่ติดมาใน URL · อ่านด้วย
params[:post_id]· ส่วนparams[:id]คือเลขของลูก · ถาม senior ได้ว่า "action นี้ควรหา comment ผ่าน post.comments.find หรือ Comment.find ตรง ๆ ครับ" (คำตอบส่วนใหญ่คืออย่างแรก เพราะเช็คว่าเป็นของบทความนี้จริง) - shallow nesting
- ซ้อนเฉพาะเส้นที่ยังไม่มี id ของลูก · เส้นที่มี id แล้วใช้ URL สั้น · คุยกับทีมได้ว่า "เส้นนี้ลึกเกิน ใช้ shallow ดีไหม"
ท่าที่ 3 · เมนูพิเศษ — member กับ collection
ร้านเราอยากได้สองหน้าที่ไม่อยู่ในเซ็ตมาตรฐาน: ปุ่ม "เผยแพร่" ของบทความหนึ่งชิ้น (publish) และหน้ารวมบทความที่ยังเป็นร่าง (drafts) · สองอย่างนี้ต่างกันตรงที่ อันแรกต้องรู้ว่าบทความชิ้นไหน (มีเลข id) · อันหลังเป็นเรื่องของทั้งแผนก (ไม่มีเลข id)
# config/routes.rb
resources :posts do
member do
post :publish # เรื่องของบทความชิ้นเดียว → มี :id
end
collection do
get :drafts # เรื่องของทั้งแผนก → ไม่มี :id
end
end
$ bin/rails routes -c posts
Prefix Verb URI Pattern Controller#Action
publish_post POST /posts/:id/publish(.:format) posts#publish
drafts_posts GET /posts/drafts(.:format) posts#drafts
posts GET /posts(.:format) posts#index
POST /posts(.:format) posts#create
new_post GET /posts/new(.:format) posts#new
edit_post GET /posts/:id/edit(.:format) posts#edit
post GET /posts/:id(.:format) posts#show
PATCH /posts/:id(.:format) posts#update
PUT /posts/:id(.:format) posts#update
DELETE /posts/:id(.:format) posts#destroy
member do … end— "เมนูสำหรับบทความชิ้นเดียว" · ได้/posts/:id/publish· ชื่อpublish_post(เอกพจน์)post :publish— คำว่าpostตรงนี้คือ verb POST (เหมือนgetในบท 101) ไม่ใช่ชื่อ model · ที่ใช้ POST เพราะการเผยแพร่คือ "เปลี่ยนข้อมูล"collection do … end— "เมนูสำหรับทั้งแผนก" · ได้/posts/draftsไม่มี:id· ชื่อdrafts_posts(พหูพจน์)- สังเกตว่าสองเส้นใหม่ถูกวางไว้บนสุด เหนือ
/posts/:id· Rails จัดให้เอง/posts/draftsเลยไม่โดน:idแย่งไปเป็นshow(กับดักเรื่องลำดับในบท 101)
member คือคำสั่งพิเศษที่ต้องบอกเลขจาน: "จานที่ 5 ยกขึ้นตู้โชว์หน้าร้านได้แล้ว" (publish) · collection คือคำสั่งกับทั้งแผนก ไม่ต้องบอกเลข: "ขอดูรายการจานที่ยังไม่ได้ขึ้นตู้โชว์" (drafts) · ทั้งสองอย่างคือการเขียนบรรทัดเพิ่มลงในรายการของพนักงานต้อนรับ
ที่ไม่เหมือนกัน: ร้านจริงประกาศเมนูพิเศษแล้วเชฟก็รู้ว่าต้องทำอะไร ·
ใน Rails การเพิ่มบรรทัดใน routes.rb คือแค่จดลงในรายการ · เชฟยังไม่มีสูตร ต้องเขียน def publish กับ def drafts ใน PostsController เอง
ไม่งั้นยิงไปได้ Unknown action (404 แบบที่ 2 ของบท 101)
เทียบกับ Go: ใน Gin ไม่มีคำว่า member/collection · คุณแค่เขียนเพิ่มสองบรรทัด
r.POST("/posts/:id/publish", h.Publish) กับ r.GET("/posts/drafts", h.Drafts) · Rails แยกสองคำนี้ไว้เพื่อให้มันเติม :id ให้ถูกและตั้งชื่อ route ให้เอง
ท่าที่ 4 · ของที่มีชิ้นเดียว — resource (ไม่มี s)
บางอย่างผู้ใช้แต่ละคนมีแค่ชิ้นเดียว เช่น "หน้าโปรไฟล์ของฉัน" · ไม่มีหน้ารวมโปรไฟล์ทุกคน และไม่ต้องใส่เลข id ใน URL เพราะ "ของฉัน" ก็คือของคนที่ login อยู่ (ระบบ login มาใน Day 4) ·
ใน Gin คุณก็แค่เขียน r.GET("/profile", h.ShowProfile) ไม่มี :id · Rails ใช้ resource ที่ไม่มี s:
# config/routes.rb
resource :profile
$ bin/rails routes -c profile
ใบ้: ชื่อ symbol เป็นเอกพจน์ :profile · คิดว่าคอลัมน์ Controller#Action จะเขียนว่า profile#show หรือ profiles#show? และมี index ไหม?
$ bin/rails routes -c profile
Prefix Verb URI Pattern Controller#Action
new_profile GET /profile/new(.:format) profiles#new
edit_profile GET /profile/edit(.:format) profiles#edit
profile GET /profile(.:format) profiles#show
PATCH /profile(.:format) profiles#update
PUT /profile(.:format) profiles#update
DELETE /profile(.:format) profiles#destroy
POST /profile(.:format) profiles#create
URL เป็นเอกพจน์ /profile และไม่มี :id สักเส้น · ไม่มี index (ของชิ้นเดียวไม่มีหน้ารวม) ·
แต่ controller ยังเป็นพหูพจน์ profiles#… = ProfilesController ·
ถ้าคุณสร้างไฟล์ชื่อ profile_controller.rb ตามความรู้สึก จะเจอ error uninitialized constant ProfilesController
- member route · collection route
- member = เมนูเพิ่มของชิ้นเดียว (มี
:id) · collection = เมนูเพิ่มของทั้งกลุ่ม (ไม่มี:id) · พิมพ์บอก AI ได้ว่า "add a member route POST /posts/:id/publish and a collection route GET /posts/drafts" - singular resource (
resource) - ของที่ผู้ใช้มีชิ้นเดียว · ได้ 6 เมนู ไม่มี index ไม่มี id · controller ยังเป็นพหูพจน์ · ถาม senior ได้ว่า "หน้าตั้งค่าบัญชีควรเป็น resource เอกพจน์ไหมครับ"
only:·except:- ตัดเซ็ตเมนูให้เหลือเฉพาะที่ใช้จริง · คุยกับทีมได้ว่า "route ที่ไม่มี action รองรับ ใส่ only ปิดไว้เลยดีไหม"
route helper — ทวนแล้วต่อยอดให้เส้นที่ซ้อน
ในบท 101 เราได้ post_path(42) ที่ประกอบ URL จากชื่อ route ·
พอ route ซ้อนกัน ชื่อ helper ก็ต่อกันตามไปด้วย · ผมลองใน console
(app. ข้างหน้า = ตัวช่วยใน console ที่ทำตัวเหมือน browser — ตัวเดียวกับ app.get ในบท 104 — จึงเรียก helper ของ route ได้ · ใน controller กับ view ไม่ต้องใส่ app.):
wongnok-blog(dev):001> post = Post.find(5)
Post Load (1.4ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 5 LIMIT 1 /*application='WongnokBlog'*/
wongnok-blog(dev):002> app.post_path(post)
=> "/posts/5"
wongnok-blog(dev):003> app.edit_post_path(post)
=> "/posts/5/edit"
wongnok-blog(dev):004> app.post_comments_path(post)
=> "/posts/5/comments"
wongnok-blog(dev):005> app.post_comment_path(post, post.comments.first)
Comment Load (1.5ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 5 ORDER BY "comments"."id" ASC LIMIT 1 /*application='WongnokBlog'*/
=> "/posts/5/comments/5"
wongnok-blog(dev):006> app.polymorphic_path(post)
=> "/posts/5"
(routes ตอนรันคือ resources :posts do resources :comments end · ผมตัดบรรทัดที่ console พิมพ์แฟ้มบทความทั้งแผ่นออกหลังบรรทัด 001)
post_path(post)— ส่งตัวบทความทั้งแผ่นเข้าไปได้ ไม่ต้องส่งpost.id· Rails หยิบเลข id ให้เอง- SQL บรรทัด
Post Loadแปลว่า "ขอแฟ้มทุกช่องจากลิ้นชัก posts เฉพาะแผ่นที่ช่อง id เป็น 5 เอาแผ่นเดียว" post_comments_path(post)— ชื่อแม่ + ชื่อลูก · ต้องส่งบทความเข้าไป เพราะ URL ต้องการเลขบทความpost_comment_path(post, comment)— เส้นที่มีเลขสองตัว ต้องส่งสองอย่าง เรียงแม่ก่อนลูก · SQLComment Load= "ขอคอมเมนต์แผ่นแรกจากลิ้นชัก comments เฉพาะที่เป็นของบทความ 5 เรียงตามเลข id" ได้คอมเมนต์เลข 5polymorphic_path(post)— กลไกเดียวกับที่อยู่เบื้องหลังredirect_to @post(บท 110) · Rails ดูว่าของที่ส่งมาเป็นPost→ เดาว่าต้องใช้post_path→ ได้/posts/5(ตอนredirect_toมันใช้ตัวพี่น้องที่ได้ URL เต็มมีhttp://…นำหน้า แต่หลักการเดาเหมือนกัน)
ประโยชน์เหมือนเดิมกับบท 101: ถ้าวันหนึ่งเปลี่ยน URL จาก /posts เป็น /articles แก้แค่ routes.rb ·
ทุกที่ที่ใช้ post_path ได้ URL ใหม่ทันที ไม่ต้องไล่แก้ข้อความ "/posts" ที่พิมพ์ไว้ 20 ที่
เตรียมยิงใบสั่งจริง — แอปทดลองสำหรับครึ่งหลัง
ครึ่งหลังเราจะส่งข้อมูลเข้า action create แล้วดูว่าอะไรลงตู้บ้าง · ต้องมีสามไฟล์ ·
ถ้าคุณมีไฟล์ชื่อเดียวกันจากบทก่อน ๆ อยู่แล้ว แทนที่ทั้งไฟล์ด้วยอันนี้ (ใช้ git ย้อนกลับได้ทีหลัง)
# config/routes.rb
Rails.application.routes.draw do
resources :posts, only: [:index, :create]
end
<%# app/views/posts/index.html.erb %>
<h1>บทความทั้งหมด</h1>
<ul>
<% @posts.each do |post| %>
<li><%= post.title %> · <%= post.views %> วิว</li>
<% end %>
</ul>
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
@posts = Post.order(:id)
end
def create
@post = Post.new(params[:post])
@post.user = User.first # ชั่วคราว: ยังไม่มีระบบ login
@post.save!
render plain: "บันทึกแล้ว: #{@post.attributes.slice("title", "views", "published", "user_id")}\n"
end
end
index+ view — มีไว้ให้เราไปขอบัตรคิว (CSRF token) เท่านั้น · หน้าที่มี layout จะมีรหัสฝังอยู่ใน<head>(บท 103) ·Post.order(:id)= บทความทั้งหมดเรียงตามเลข id (บท 113)params[:post]— ฟอร์มของ Rails ตั้งชื่อช่องว่าpost[title]·post[body]· Rails จึงรวบเป็นกลุ่มเดียว{"title" => …, "body" => …}ไว้ในparams[:post]Post.new(params[:post])— "สร้างแฟ้มบทความจากทุกช่องในกลุ่มนั้นรวดเดียว" · บรรทัดนี้คือตัวเอกของครึ่งหลัง@post.user = User.first— ใส่ผู้เขียนเป็นสมชาย (คนแรกในตู้) แบบเดียวกับบท 110 · ของจริงคือคนที่ login อยู่ (Day 4)render plain: …— ตอบเป็นข้อความเปล่า ให้ curl เห็นผลทันที (ของจริงจะredirect_to @post) ·@post.attributes= Hash ของทุกช่องบนแฟ้ม ·.slice("title", …)= หยิบเฉพาะช่องที่ระบุมาโชว์
เปิด server (bin/rails server) ทิ้งไว้หนึ่งหน้าต่าง แล้วเปิดอีกหน้าต่างมาขอบัตรคิว ·
บรรทัดนี้ยาว ก็อปไปวางได้เลย หน้าตาเดียวกับบท 103 แค่เก็บรหัสไว้ใช้หลายรอบ:
$ TOKEN=$(curl -s -c jar.txt http://localhost:3000/posts | grep -o 'name="csrf-token" content="[^"]*"' | sed 's/.*content="//;s/"$//')
- เปิดหน้า
/postsเหมือนลูกค้าจริง · เก็บ cookie ลงไฟล์jar.txt(-c) · ตัดเอารหัสใส่ตัวแปรTOKEN - ทุกคำสั่ง POST ต่อจากนี้จะแนบ
-b jar.txt(cookie) และ-H "X-CSRF-Token: $TOKEN"(รหัส) ·-d "post[title]=…"= ส่งช่องหนึ่งช่องเหมือนกรอกฟอร์ม - คำสั่งยาว ผมตัดบรรทัดด้วย
\(= "บรรทัดนี้ยังไม่จบ") พิมพ์รวดเดียวบรรทัดเดียวก็ได้ผลเหมือนกัน
⚠️ เราถือบัตรคิวไปเหมือนลูกค้าจริง ไม่ได้ปิดการตรวจ CSRF · บทความในเน็ตหลายที่แนะนำให้ปิดเพื่อยิง curl ง่าย ๆ — อย่าทำ ·
ปิดแล้วลืมเปิด = เปิดทางให้เว็บอื่นปลอมใบสั่งแทนผู้ใช้ (บท 103) · ถ้าเห็น 422 + Can't verify CSRF token authenticity.
แปลว่าลืม -b jar.txt หรือ -H · หรือเปิดหน้าต่าง terminal ใหม่ (ตัวแปร TOKEN อยู่แค่ในหน้าต่างที่สั่ง หน้าต่างใหม่ $TOKEN ว่าง) → สั่งบรรทัดขอบัตรคิวใหม่ในหน้าต่างนั้น
mass assignment — ลูกค้าแอบเขียนเพิ่มในใบสั่ง
ฟอร์มเขียนบทความของเรามีแค่ 2 ช่อง: ชื่อเรื่องกับเนื้อหา · แต่ ฟอร์มเป็นแค่หน้าตาบนจอ ·
ของที่ถึงร้านจริงคือ request ซึ่งใครจะเติมช่องอะไรลงไปก็ได้ (ด้วย curl หรือเครื่องมือในเบราว์เซอร์)
เช่น post[views]=999999 · post[published]=true
mass assignment คือการเอาของทั้งกลุ่มยัดใส่แฟ้มรวดเดียว อย่าง Post.new(params[:post]) ·
สะดวกมาก · แต่ถ้าไม่จำกัดว่ารับช่องไหน ช่องที่คนร้ายเติมมาก็เข้าตู้ไปด้วย
ใบสั่งของร้านพิมพ์ช่องไว้สองช่อง: "ชื่อเมนู" กับ "รายละเอียด" · ลูกค้าคนหนึ่งเขียนเพิ่มที่ขอบกระดาษว่า "ฉันเป็นผู้จัดการ · ฟรี · ลงบัญชีโต๊ะอื่น" · ถ้าครัวทำตามทุกบรรทัดที่เขียนบนกระดาษ ร้านก็เจ๊ง — นี่คือ mass assignment
ที่ไม่เหมือนกัน: คนในครัวจริงจะสะดุดตากับลายมือแปลก ๆ ที่ขอบกระดาษ ·
โปรแกรมไม่มีทางรู้ว่าช่องไหนมาจากฟอร์มของเรา ช่องไหนคนร้ายเติม — ทุกช่องหน้าตาเหมือนกันหมดใน params ·
เราจึงต้องเขียนบอกเองว่ารับช่องไหน
ขั้นที่ 1 · Rails 8 ไม่ยอมให้ทำแบบนี้ตั้งแต่แรก
ยิงใบสั่งธรรมดา ๆ (ไม่มีช่องแปลก) เข้าโค้ดข้างบน · -o /dev/null -w '%{http_code}\n' = ไม่ต้องโชว์เนื้อหา พิมพ์แค่เลขสถานะ (บท 103):
$ curl -s -o /dev/null -w '%{http_code}\n' -b jar.txt -H "X-CSRF-Token: $TOKEN" \
-X POST http://localhost:3000/posts -d "post[title]=ลาบหมู" -d "post[body]=แซ่บ"
500
log ของ server (ตัดรายชื่อไฟล์ของ Rails ใต้บรรทัดสุดท้ายออก):
Started POST "/posts" for ::1 at 2026-11-04 19:09:56 +0700
Processing by PostsController#create as */*
Parameters: {"post" => {"title" => "ลาบหมู", "body" => "แซ่บ"}}
Completed 500 Internal Server Error in 2ms (ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)
ActiveModel::ForbiddenAttributesError (ActiveModel::ForbiddenAttributesError):
app/controllers/posts_controller.rb:8:in 'PostsController#create'
500— ครัวพัง · ไม่มีอะไรลงตู้ (ไม่มีบรรทัด SQL เลย:0 queries)ForbiddenAttributesErrorแปลว่า "ของต้องห้ามถูกยัดใส่แฟ้ม" · Rails ไม่ยอมให้เอาparamsทั้งก้อนใส่แฟ้มจนกว่าเราจะบอกว่ารับช่องไหนposts_controller.rb:8— บรรทัดที่ 8 นับรวมบรรทัดคอมเมนต์ชื่อไฟล์ด้วย = บรรทัดPost.new(params[:post])
ข่าวดีคือ Rails ปิดประตูนี้ให้แล้ว · ข่าวร้ายคือมีท่าเปิดประตูกลับ ที่โผล่ในคำตอบของ AI และในเน็ตบ่อย ๆ เวลามีคนเจอ error ข้างบนแล้วอยากให้มัน "หายไปเร็ว ๆ"
ขั้นที่ 2 · เปิดประตูด้วย permit! แล้วลองเจาะ
⚠️ permit! (มี !) แปลว่า "รับทุกช่อง ไม่ต้องตรวจ" · ใส่ในบทนี้เพื่อให้เห็นช่องโหว่กับตาเท่านั้น ·
ถ้าเห็นในโค้ดจริงหรือในคำตอบของ AI ให้ถือว่าเป็นบั๊ก · ทำหัวข้อนี้เสร็จแล้วเปลี่ยนกลับตามหัวข้อถัดไปทันที
แก้บรรทัดที่ 8 เป็น:
@post = Post.new(params[:post].permit!)
ใบสั่งธรรมดาผ่านแล้ว · แฟ้มใหม่ได้ views 0 · published false (ค่าตั้งต้นที่ migration ของตาราง posts ตั้งไว้) · ผู้เขียนเป็นสมชาย (user_id 1):
$ curl -s -b jar.txt -H "X-CSRF-Token: $TOKEN" \
-X POST http://localhost:3000/posts -d "post[title]=ลาบหมู" -d "post[body]=แซ่บ"
บันทึกแล้ว: {"title" => "ลาบหมู", "views" => 0, "published" => false, "user_id" => 1}
ทีนี้เป็นคนร้าย · ส่งสองช่องตามฟอร์ม แล้วแอบเติมอีกสามช่อง: ยอดวิว 999999 · เผยแพร่ทันที · และอ้างว่าผู้เขียนคือมานะ (user_id 3)
$ curl -s -b jar.txt -H "X-CSRF-Token: $TOKEN" -X POST http://localhost:3000/posts \
-d "post[title]=ร้านผม" -d "post[body]=อร่อยที่สุดในโลก" \
-d "post[views]=999999" -d "post[published]=true" -d "post[user_id]=3"
สามช่องที่แอบเติม ช่องไหนลงตู้ได้บ้าง? (ดูโค้ด create อีกที — บรรทัดที่ 9 ทำอะไรหลังบรรทัดที่ 8)
บันทึกแล้ว: {"title" => "ร้านผม", "views" => 999999, "published" => true, "user_id" => 1}
log ยืนยันว่าเข้าตู้จริง (ตัดเฉพาะสองบรรทัดที่สำคัญ):
Parameters: {"post" => {"title" => "ร้านผม", "body" => "อร่อยที่สุดในโลก", "views" => "999999", "published" => "true", "user_id" => "3"}}
Post Create (2.2ms) INSERT INTO "posts" ("title", "body", "created_at", "updated_at", "published", "views", "user_id") VALUES ('ร้านผม', 'อร่อยที่สุดในโลก', '2026-11-04 12:09:58.082068', '2026-11-04 12:09:58.082068', TRUE, 999999, 1) RETURNING "id" /*action='create',application='WongnokBlog',controller='posts'*/
INSERT INTO "posts" (…) VALUES (…) แปลว่า "เพิ่มแฟ้มใหม่ลงลิ้นชัก posts โดยช่องในวงเล็บแรกได้ค่าในวงเล็บที่สองตามลำดับ" ·
ช่อง published ได้ TRUE · views ได้ 999999 · RETURNING "id" = "แล้วบอกเลขที่แฟ้มใหม่กลับมาด้วย"
views กับ published เข้าตู้ · บทความที่ควรรอตรวจกลายเป็นเผยแพร่แล้วและยอดวิวสูงสุดในเว็บ ·
user_id ไม่เข้า — ได้ 1 ไม่ใช่ 3 · ไม่ใช่เพราะ Rails กันให้ แต่เพราะบรรทัดที่ 9 @post.user = User.first เขียนทับหลังจากบรรทัดที่ 8 ใส่ 3 ไปแล้ว ·
นี่คือ "รอดเพราะโชค" ไม่ใช่รอดเพราะตั้งใจกัน
อยากเห็นตอนที่โชคไม่ช่วย · ผมเขียน action update ชั่วคราวที่ใช้ท่าเดียวกัน (@post.update!(params[:post].permit!)) ·
ใน update ไม่มีบรรทัดไหนเขียนทับผู้เขียน · ส่งแก้คำผิดในชื่อบทความ 2 (ของสมชาย) พร้อมแอบเติม user_id:
$ curl -s -b jar.txt -H "X-CSRF-Token: $TOKEN" -X PATCH http://localhost:3000/posts/2 \
-d "post[title]=ส้มตำปูปลาร้า (แก้คำผิด)" -d "post[user_id]=3"
แก้แล้ว: {"title" => "ส้มตำปูปลาร้า (แก้คำผิด)", "user_id" => 3}
Post Update (1.3ms) UPDATE "posts" SET "title" = 'ส้มตำปูปลาร้า (แก้คำผิด)', "updated_at" = '2026-11-04 12:10:01.703604', "user_id" = 3 WHERE "posts"."id" = 2 /*action='update',application='WongnokBlog',controller='posts'*/
UPDATE "posts" SET … WHERE "posts"."id" = 2 = "แก้แฟ้มเลข 2 ในลิ้นชัก posts ให้ช่องเหล่านี้เป็นค่าใหม่" ·
"user_id" = 3 อยู่ในนั้น → บทความของสมชายกลายเป็นของมานะ แค่เติมช่องเดียวในใบสั่ง
(ตอนทดลองผมเปลี่ยน only: ใน routes เป็น [:index, :update] ชั่วคราว · ไม่ต้องทำตาม)
สไลด์หน้า 48 อ้างว่าช่องโหว่แบบนี้เคยถูกใช้กับ GitHub ในปี 2012 · ส่วนที่ผมยืนยันได้กับยืนยันไม่ได้ อยู่ในตารางท้ายบท · ไม่ต้องจำเรื่องนี้ จำแค่ว่าช่องโหว่นี้ทำได้จริงด้วย curl คำสั่งเดียว อย่างที่คุณเพิ่งเห็น
เทียบกับ Go: ช่องโหว่เดียวกันเกิดได้ถ้าเอา JSON ของผู้ใช้ใส่ตรงเข้า struct ที่ลง DB · struct Post แบบ GORM ที่มีช่อง Views · UserID พร้อม tag json:
var post Post // ❌ struct ที่ลง DB
json.Unmarshal(body, &post) // body มี views · published · user_id แอบมา
// ผลรันจริง: {ID:0 Title:ร้านผม Body:x Views:999999 Published:true UserID:3}
(ตัดมาสามบรรทัด · ผมรันในไฟล์ main.go เต็มที่มี package main กับ import "encoding/json" · ShouldBindJSON ของ Gin ในบท 39 โดยปกติก็ใช้ encoding/json ตัวนี้ข้างใน)
- mass assignment
- การใส่ค่าหลายช่องให้ object รวดเดียวจาก Hash เช่น
Post.new(hash)·post.update(hash)· ตัวมันเองไม่ผิด ผิดตอนที่ hash มาจากผู้ใช้โดยไม่ได้กรอง · ถาม senior ได้ว่า "action นี้มีช่องโหว่ mass assignment ไหมครับ ผมควรเช็คตรงไหน" ActiveModel::ForbiddenAttributesError- error ที่ Rails โยนเมื่อเอา
paramsที่ยังไม่ได้กรองไปใส่แฟ้ม · ทางแก้ที่ถูกคือ strong parameters (หัวข้อถัดไป) ไม่ใช่permit!· พิมพ์บอก AI ได้ว่า "I got ForbiddenAttributesError — write strong params with params.expect, do not use permit!" permit!- "อนุญาตทุกช่อง" · ปิด error ได้แต่เปิดช่องโหว่กลับมาทั้งหมด · ในการรีวิวโค้ด เจอคำนี้กับ params จากผู้ใช้ ให้ทักทุกครั้ง
strong parameters — รับเฉพาะช่องที่อยู่บนแบบฟอร์ม
ทางแก้คือเขียนรายชื่อช่องที่ยอมรับ ไว้ใน method แยกใต้ private แล้วให้ create เรียกใช้ ·
ช่องที่ไม่อยู่ในรายชื่อจะถูกทิ้งก่อนถึงแฟ้ม · เปลี่ยนไฟล์ controller เป็น:
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
@posts = Post.order(:id)
end
def create
@post = Post.new(post_params)
@post.user = User.first # ชั่วคราว: ยังไม่มีระบบ login
@post.save!
render plain: "บันทึกแล้ว: #{@post.attributes.slice("title", "views", "published", "user_id")}\n"
end
private
def post_params
params.require(:post).permit(:title, :body)
end
end
Post.new(post_params)— เปลี่ยนจากparams[:post]เป็นการเรียก methodpost_paramsที่กรองแล้วparams.require(:post)— "ใบสั่งนี้ต้องมีกลุ่มชื่อpost" · ไม่มีให้หยุดเลย.permit(:title, :body)— "จากกลุ่มนั้น รับแค่titleกับbody" · ที่เหลือทิ้ง- อยู่ใต้
privateเพราะไม่ใช่เมนู ลูกค้าเรียกตรง ๆ ไม่ได้ · ตั้งชื่อชื่อmodel_paramsเป็นธรรมเนียมที่ทุกร้าน Rails ใช้ และใช้ซ้ำได้ทั้งในcreateกับupdate
ยิงคำสั่งเจาะชุดเดิม:
$ curl -s -b jar.txt -H "X-CSRF-Token: $TOKEN" -X POST http://localhost:3000/posts \
-d "post[title]=ร้านผม" -d "post[body]=อร่อยที่สุดในโลก" \
-d "post[views]=999999" -d "post[published]=true" -d "post[user_id]=3"
บันทึกแล้ว: {"title" => "ร้านผม", "views" => 0, "published" => false, "user_id" => 1}
บันทึกได้ (คนร้ายไม่รู้ตัวด้วยซ้ำว่าโดนตัด) แต่ views กลับเป็น 0 · published เป็น false · ใน log มีบรรทัดใหม่ (ตัดท้ายบรรทัดที่ยาวมากออก):
Parameters: {"post" => {"title" => "ร้านผม", "body" => "อร่อยที่สุดในโลก", "views" => "999999", "published" => "true", "user_id" => "3"}}
Unpermitted parameters: :views, :published, :user_id. Context: { controller: PostsController, action: create, … }
Post Create (2.5ms) INSERT INTO "posts" ("title", "body", "created_at", "updated_at", "published", "views", "user_id") VALUES ('ร้านผม', 'อร่อยที่สุดในโลก', '2026-11-04 12:09:59.183341', '2026-11-04 12:09:59.183341', FALSE, 0, 1) RETURNING "id" /*action='create',application='WongnokBlog',controller='posts'*/
Parameters:— ของที่คนร้ายส่งมายังอยู่ครบ 5 ช่อง (log จดตามที่ได้รับ)Unpermitted parameters: :views, :published, :user_id— "สามช่องนี้ไม่อยู่ในรายชื่อ ทิ้งแล้ว" · บรรทัดนี้โผล่เฉพาะตอนพัฒนาในเครื่องกับตอนรันเทส (ค่าตั้งต้นของ Rails) เป็นตัวช่วยจับว่าฟอร์มส่งอะไรเกินมาINSERT … VALUES (…, FALSE, 0, 1)— ค่าที่ลงตู้จริงคือค่าตั้งต้น ไม่ใช่ของคนร้าย
หลักการข้างหลังคือ allowlist ชนะ blocklist — เขียนว่า "ช่องไหนผ่านได้" ไม่ใช่ "ช่องไหนห้ามผ่าน" ·
ถ้าเขียนแบบห้าม วันที่ migration เพิ่มช่อง is_featured (บทความแนะนำ) เข้ามา แล้วลืมเพิ่มในรายการห้าม ช่องนั้นก็เปิดให้คนร้ายทันที ·
แบบ allowlist ช่องใหม่ถูกปิดไว้ก่อนเสมอจนกว่าจะตั้งใจเปิด
strong parameters คือพนักงานต้อนรับที่ถือแบบฟอร์มทางการของร้านไว้ · ลูกค้ายื่นกระดาษอะไรมาก็ตาม พนักงานลอกเฉพาะช่อง "ชื่อเมนู" กับ "รายละเอียด" ลงใบสั่งของร้าน แล้วส่งใบนั้นเข้าครัว · ข้อความ "ฉันเป็นผู้จัดการ · ฟรี" ที่ขอบกระดาษไม่เคยไปถึงครัว
ที่ไม่เหมือนกัน: พนักงานจริงอาจหันไปบอกลูกค้าว่า "ช่องนี้เราไม่รับนะครับ" ·
permit ทิ้งเงียบ ๆ ลูกค้าได้คำตอบ 200 เหมือนสำเร็จทุกอย่าง · คนที่รู้มีแค่เรา ผ่านบรรทัด Unpermitted parameters ใน log ·
และพนักงานคนนี้ไม่ตรวจว่าค่าในช่องถูกไหม (ชื่อเรื่องว่างก็ลอกให้) — เรื่องนั้นเป็นงานของ validation ในบท 110
เทียบกับ Go: ท่าที่ถูกคือแยก struct สำหรับรับ input (เรียกกันว่า DTO) ออกจาก struct ที่ลง DB · struct รับ input มีแค่ช่องที่อยู่บนฟอร์ม:
❌ รับตรงเข้า struct ที่ลง DB
var post Post
json.Unmarshal(body, &post)
// Views:999999 Published:true UserID:3
✅ รับเข้า DTO ก่อน แล้วค่อยย้าย
type CreatePostInput struct {
Title string `json:"title"`
Body string `json:"body"`
}
var in CreatePostInput
json.Unmarshal(body, &in)
post := Post{Title: in.Title, Body: in.Body, UserID: 1}
// Views:0 Published:false UserID:1
(ผลในคอมเมนต์มาจากการรันจริง ด้วย JSON ที่แอบเติม views · published · user_id แบบเดียวกับคนร้าย · encoding/json ทิ้งช่องที่ struct ไม่มีเงียบ ๆ เหมือน permit) ·
struct CreatePostInput ของ Go = รายชื่อใน permit(:title, :body) ของ Rails · ต่างกันแค่ Go ประกาศเป็นชนิดข้อมูล Rails ประกาศเป็นรายชื่อ symbol
- strong parameters
- ระบบกรอง
paramsของ Rails · ต้องประกาศช่องที่รับก่อนเอาไปใส่ model · methodpost_paramsใต้privateคือที่อยู่ประจำ · พิมพ์บอก AI ได้ว่า "add strong parameters for Post allowing only title and body" - allowlist · blocklist
- allowlist = รายชื่อของที่ผ่านได้ (ที่เหลือห้ามหมด) · blocklist = รายชื่อของที่ห้าม (ที่เหลือผ่านหมด) · งานความปลอดภัยใช้ allowlist เสมอ · คุยกับทีมได้ว่า "ตรงนี้เราใช้ allowlist หรือ blocklist ครับ"
- Unpermitted parameters (log)
- บรรทัดใน log ตอนพัฒนาที่บอกว่าช่องไหนโดน
permitทิ้ง · ถ้าเห็นช่องที่ควรผ่าน แปลว่าลืมใส่ในรายชื่อ · ถาม senior ได้ว่า "log ขึ้น Unpermitted parameter: :category_id แปลว่าผมต้องเพิ่มใน permit ใช่ไหมครับ"
params.expect — ท่าของ Rails 8 ที่ตรวจรูปร่างด้วย
require/permit กันช่องเกินได้แล้ว แต่ยังมีรูรั่วอีกแบบ · require(:post) เช็คแค่ว่า "มีของชื่อ post ไหม" ·
ไม่ได้เช็คว่าของนั้นเป็นกลุ่มช่อง (Hash) จริงหรือเปล่า · ถ้าคนป่วนส่ง post มาเป็นคำเดียวหรือเป็นรายการ จะเกิดอะไรขึ้น:
$ curl -s -o /dev/null -w '%{http_code}\n' -b jar.txt -H "X-CSRF-Token: $TOKEN" \
-X POST http://localhost:3000/posts -d "post=hello"
500
Parameters: {"post" => "hello"}
Completed 500 Internal Server Error in 1ms (ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)
NoMethodError (undefined method 'permit' for an instance of String):
app/controllers/posts_controller.rb:17:in 'PostsController#post_params'
app/controllers/posts_controller.rb:8:in 'PostsController#create'
{"post" => "hello"}—postเป็นข้อความเฉย ๆ ไม่ใช่กลุ่ม{title, body}require(:post)เห็นว่ามีpostก็ปล่อยผ่าน · ต่อด้วย.permitกับข้อความ →undefined method 'permit' for an instance of String(ข้อความไม่มี method ชื่อ permit)- บรรทัด 17 คือ
params.require(:post).permit(:title, :body)· บรรทัด 8 คือที่createเรียกpost_params
ข้อมูลไม่ได้เสีย แต่ได้ 500 · ทั้งที่เป็นความผิดของคนส่ง ไม่ใช่บั๊กของเรา ·
Rails 8 มี method ใหม่ params.expect ที่เช็คทั้งรายชื่อช่องและรูปร่างในคำสั่งเดียว · แก้ post_params เป็น:
def post_params
params.expect(post: [:title, :body])
end
- อ่านว่า "คาดหวังว่าจะมี
postที่เป็นกลุ่มช่อง และรับแค่titleกับbody" post: [ … ]— hash ที่ key คือชื่อกลุ่ม ค่าคือรายชื่อช่อง (เหมือนเอาrequireกับpermitมาเขียนรวมกัน)
ผมยิงสามแบบเดียวกันเข้าทั้งสองท่า (ทุกครั้งเปลี่ยนแค่ -d) ได้ status แบบนี้:
ส่งมาแบบไหน (-d …) | ภาษาคน | require/permit | params.expect |
|---|---|---|---|
"post=hello" | post เป็นข้อความคำเดียว | 500 NoMethodError | 400 ParameterMissing |
"post[]=a" | post เป็นรายการ (Array) | 500 NoMethodError | 400 ParameterMissing |
"title=x" | ไม่มี post เลย | 400 ParameterMissing | 400 ParameterMissing |
| ห้าช่องของคนร้ายชุดเดิม | ช่องเกิน 3 ช่อง | 200 · ช่องเกินถูกทิ้ง | 200 · ช่องเกินถูกทิ้ง |
บรรทัดใน log ของ expect ตอนส่ง post=hello:
Parameters: {"post" => "hello"}
Completed 400 Bad Request in 1ms (ActiveRecord: 0.0ms (0 queries, 0 cached) | GC: 0.0ms)
ActionController::ParameterMissing (param is missing or the value is empty or invalid: post):
param is missing or the value is empty or invalid: post = "ช่อง post ไม่มี · ว่าง · หรือรูปร่างไม่ถูก" · Rails ตอบ 400 ให้เอง ไปไม่ถึงบรรทัด save!
400 กับ 500 ต่างกันยังไง ทำไมต้องสน: 400 Bad Request = "ใบสั่งของคุณผิดแบบ" (ความผิดฝั่งคนส่ง) ·
500 Internal Server Error = "ครัวเราพังเอง" (ความผิดฝั่งเรา) · หลายทีมตั้งระบบเตือนให้ปลุกคนเวรเมื่อเห็น 500 เยอะ ๆ ·
ถ้าคนป่วนยิง post=hello มาพันครั้งแล้วได้ 500 พันครั้ง ทีมต้องตื่นมาไล่ log หาบั๊กที่ไม่มีอยู่จริง
require เหมือนพนักงานต้อนรับที่ถามแค่ว่า "มีกระดาษที่เขียนว่า ใบสั่ง ไหม" ·
ลูกค้ายื่นกระดาษที่เขียนคำว่า "hello" คำเดียวมา พนักงานก็รับส่งเข้าครัว · เชฟหาช่องชื่อเมนูไม่เจอ ทำจานตกแตก (500)
expect คือพนักงานที่ดูก่อนว่าเป็นแบบฟอร์มที่มีช่องจริง ๆ · ถ้าไม่ใช่ คืนลูกค้าที่หน้าร้านเลยว่า "ใบสั่งผิดแบบครับ" (400) ครัวไม่ต้องรู้เรื่อง
ที่ไม่เหมือนกัน: การตรวจนี้จริง ๆ เกิดในครัว (ตอน create เรียก post_params) ไม่ใช่หน้าร้าน แค่เกิดก่อนเชฟลงมือ ·
พนักงานจริงที่คืนใบสั่งจะบอกว่าผิดตรงไหน แต่ expect บอกแค่ชื่อกลุ่ม (invalid: post) · และเรื่องช่องเกิน expect ทิ้งเงียบกว่า require/permit อีก —
ผมยิงห้าช่องของคนร้ายเข้าท่า expect แล้วไม่มีบรรทัด Unpermitted parameters ใน log เลย ·
ช่องเกินยังถูกทิ้งเหมือนเดิม แค่ไม่มีใครบอก
เรื่อง AI: สไลด์หน้า 49 เตือนว่า AI มักเขียน require/permit ให้ เพราะเรียนจากโค้ดยุค Rails 6–7 ·
ไม่ผิด กันช่องเกินได้เหมือนกัน แต่ได้ 500 ตอนเจอรูปร่างแปลก · ขอให้ AI ใช้ params.expect ตรง ๆ ได้เลย ·
ส่วน bin/rails generate scaffold ของ Rails 8.1.4 เขียน params.expect ให้แล้ว (ผมเปิดไฟล์แม่แบบของมันดู — บท 118 จะอ่านโค้ดที่มันสร้าง)
params.expect- ท่าของ Rails 8 ที่รวม require + permit และเช็คว่าเป็นกลุ่มช่องจริง · รูปร่างผิดได้ 400 · พิมพ์บอก AI ได้ว่า "use params.expect (Rails 8) instead of require/permit"
ActionController::ParameterMissing· 400 Bad Request- error ที่ Rails แปลงเป็น 400 ให้เองเมื่อใบสั่งไม่มีกลุ่มที่ต้องการหรือรูปร่างไม่ถูก · ไม่ต้องเขียน rescue เอง · ถาม senior ได้ว่า "API นี้ตอบ 500 ตอนส่ง body ผิดรูป ควรเปลี่ยนเป็น params.expect ให้ได้ 400 ไหมครับ"
- 500 Internal Server Error
- "ฝั่ง server พังเอง" · ควรเห็นเฉพาะตอนมีบั๊กจริง · ถ้าคนส่งผิดแล้วได้ 500 คือเราตอบผิดประเภท · คุยกับทีมได้ว่า "error rate 500 ขึ้น เพราะ request ผิดรูปหรือบั๊กจริง"
ภารกิจ — ช่องเขียนคอมเมนต์ใต้บทความ ที่กันคนสวมรอยได้
🎯 nested route + params.expect (~10 นาที)
ต่อจากแอปทดลองข้างบน (ต้องมี posts#index + view ไว้ขอบัตรคิว) · คอมเมนต์ต้องเป็นของ "คนที่ login อยู่" เสมอ ไม่ใช่คนที่ใบสั่งอ้าง
- แก้
config/routes.rbให้มีPOST /posts/:post_id/commentsเส้นเดียว ซ้อนใต้ posts (ใช้only:ทั้งสองชั้น ให้ posts เหลือ index กับ create เหมือนเดิม · ใบ้:only: […]เขียนไว้ก่อนคำว่าdoได้ เช่นresources :posts, only: [...] do) - สั่ง
bin/rails routes -c comments· ควรเห็นแถวเดียว ชื่อpost_commentsไปที่comments#create - สร้างไฟล์
app/controllers/comments_controller.rb· actioncreateให้: หาบทความจากparams[:post_id]· สร้างคอมเมนต์ใต้บทความนั้นด้วยpost.comments.new(comment_params)(new=buildในบท 115) · ใส่ผู้เขียนเป็นUser.last(สมมุติว่าคนที่ login คือมานะ) ·save!· แล้วrender plain:ข้อความบอกว่าใครเขียนอะไรใต้บทความไหน - เขียน
comment_paramsใต้privateด้วยparams.expectให้รับแค่body - ขอบัตรคิวใหม่ (บรรทัด
TOKEN=…) แล้วยิงแบบคนร้ายที่อ้างว่าเป็นสมชาย:$ curl -s -b jar.txt -H "X-CSRF-Token: $TOKEN" -X POST http://localhost:3000/posts/5/comments \ -d "comment[body]=ขอสูตรด้วย" -d "comment[user_id]=1"
ควรเห็น: ข้อความที่บอกว่าผู้เขียนคือ มานะ ไม่ใช่สมชาย · บทความเลข 5 ใน seeds คือ "ผัดกะเพราหมูสับ" (ถ้าของคุณเลขไม่ตรงเพราะเคยเพิ่ม/ลบบทความ ใช้เลขบทความที่มีอยู่จริงในตู้ของคุณแทนได้)
ถ้าเจอ error แปลว่า…
400+param is missing or the value is empty or invalid: comment→ ชื่อกลุ่มใน-dไม่ตรงกับในexpectเช่นส่ง-d "body=…"หรือpost[body]422+Can't verify CSRF token authenticity.→ ลืม-b jar.txt/-Hหรือยิงจากหน้าต่าง terminal ใหม่ที่ยังไม่ได้สั่งบรรทัดTOKEN=…($TOKENว่าง)uninitialized constant CommentsController→ ชื่อไฟล์หรือชื่อ class ไม่ตรงกฎ (ต้องcomments_controller.rb+CommentsControllerพหูพจน์)404+Couldn't find Post with 'id'="999"→ ไม่มีบทความเลขนั้นในตู้ ·Post.findทำงานถูกแล้ว
① routes:
# config/routes.rb
Rails.application.routes.draw do
resources :posts, only: [:index, :create] do
resources :comments, only: [:create]
end
end
$ bin/rails routes -c comments
Prefix Verb URI Pattern Controller#Action
post_comments POST /posts/:post_id/comments(.:format) comments#create
② controller:
# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
def create
post = Post.find(params[:post_id])
comment = post.comments.new(comment_params)
comment.user = User.last # ชั่วคราว: สมมุติว่าคนที่ login คือมานะ
comment.save!
render plain: "คอมเมนต์ใหม่บน #{post.title} โดย #{comment.user.name}: #{comment.body}\n"
end
private
def comment_params
params.expect(comment: [:body])
end
end
params[:post_id]— เลขบทความจาก URL (ไม่ใช่params[:id])post.comments.new(…)— สร้างใบคอมเมนต์ที่ช่องpost_idถูกเติมเป็น 5 ให้แล้ว (บท 115)comment.user = User.last— ผู้เขียนมาจากฝั่งเรา ไม่ได้มาจากใบสั่ง · ต่อให้ไม่มีบรรทัดนี้user_idของคนร้ายก็ไม่ผ่านexpectอยู่ดี
③ ผลรันจริง:
$ curl -s -b jar.txt -H "X-CSRF-Token: $TOKEN" -X POST http://localhost:3000/posts/5/comments \
-d "comment[body]=ขอสูตรด้วย" -d "comment[user_id]=1"
คอมเมนต์ใหม่บน ผัดกะเพราหมูสับ โดย มานะ: ขอสูตรด้วย
log (ตัดเฉพาะบรรทัดสำคัญ):
Parameters: {"comment" => {"body" => "ขอสูตรด้วย", "user_id" => "1"}, "post_id" => "5"}
Post Load (1.1ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 5 LIMIT 1 /*action='create',application='WongnokBlog',controller='comments'*/
User Load (1.2ms) SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT 1 /*action='create',application='WongnokBlog',controller='comments'*/
Comment Create (3.1ms) INSERT INTO "comments" ("body", "post_id", "user_id", "created_at", "updated_at") VALUES ('ขอสูตรด้วย', 5, 3, '2026-11-04 12:10:02.806225', '2026-11-04 12:10:02.806225') RETURNING "id" /*action='create',application='WongnokBlog',controller='comments'*/
"post_id" => "5"อยู่นอกกลุ่มcomment— มาจาก URL ไม่ใช่จากฟอร์มUser Load … ORDER BY "users"."id" DESC LIMIT 1= "ขอผู้ใช้หนึ่งคน เรียงเลข id จากมากไปน้อย" = คนสุดท้าย (มานะ) ·DESC= เรียงจากมากไปน้อยINSERT INTO "comments" … VALUES ('ขอสูตรด้วย', 5, 3, …)— ลงลิ้นชัก comments ด้วย post_id 5 และ user_id 3 (มานะ) ไม่ใช่ 1 ที่คนร้ายอ้าง- ไม่มีบรรทัด
Unpermitted parameters— ท่าexpectทิ้งเงียบ (หัวข้อก่อน)
ทดสอบความเข้าใจ
ข้อ 1 มาจาก Quiz 6 ของสไลด์หน้า 50 · ทุกเฉลยรันพิสูจน์แล้ว
ข้อ 1 · routes.rb มีแค่ resources :posts do resources :comments end · ใบสั่ง DELETE /posts/5/comments/9 ไปที่ไหน และ params มีอะไร?
ตัวเลือกที่ 3 ถูก · ผมถามพนักงานต้อนรับตรง ๆ ด้วย Rails.application.routes.recognize_path (ท่าเดียวกับ quiz ข้อ 4 ของบท 105) วนทีละเส้นครบห้าเส้นของสไลด์ แล้วพิมพ์ผล:
GET /posts/5 => {controller: "posts", action: "show", id: "5"}
GET /posts/5/edit => {controller: "posts", action: "edit", id: "5"}
POST /posts/5/comments => {controller: "comments", action: "create", post_id: "5"}
DELETE /posts/5/comments/9 => {controller: "comments", action: "destroy", post_id: "5", id: "9"}
GET /posts/new => {controller: "posts", action: "new"}
ตัวเลือกที่ 1 ลืมว่า URL ที่มี /comments ไปแผนก comments · ตัวเลือกที่ 2 ไปแผนกถูก แต่สลับเลข — :id เป็นของคอมเมนต์ (ลูก) ส่วนเลขบทความ (แม่) ชื่อ post_id ·
ตัวเลือกที่ 4 ผิดทั้ง verb และแผนก — DELETE ไปเมนู destroy เสมอ
จุดที่ต้องจำ: ใน nested route เลขของแม่ชื่อ :ชื่อแม่_id · เลขของลูกชื่อ :id · ตอบไม่ได้เมื่อไหร่ สั่ง bin/rails routes -c comments ดูได้เลย
ข้อ 2 · บน Rails 8.1 · action create เขียน @post = Post.new(params[:post]) (ไม่มี permit/expect) แล้วผู้ใช้ส่งฟอร์มธรรมดาที่มีแค่ title กับ body · เกิดอะไรขึ้น?
ตัวเลือกที่ 2 ถูก · ผลจริงในหัวข้อ mass assignment ขั้นที่ 1: 500 · log เขียน ActiveModel::ForbiddenAttributesError · 0 queries ·
คู่มือ Rails (Action Controller Overview หัวข้อ Strong Parameters) ก็เขียนตัวอย่างเดียวกันไว้ว่าบรรทัดแบบนี้จะโยน error ตัวนี้
ตัวเลือกที่ 1 คือสิ่งที่สไลด์หน้า 48 และ 50 พูดถึง — เกิดจริงเฉพาะตอนมีคนเติม permit! เข้าไป ·
ตัวเลือกที่ 3 ผิด — Rails ไม่รู้ว่าฟอร์มเรามีช่องอะไร ต้องเขียนรายชื่อเอง ·
ตัวเลือกที่ 4 ผิด — 400 มาจาก expect/require ตอนไม่มีกลุ่ม post ไม่ใช่เพราะลืมเขียน
จุดที่ต้องจำ: Rails 8 ปิดประตู mass assignment ไว้ก่อน · คนที่เปิดกลับคือเราเอง (ด้วย permit!) · ทางแก้ error นี้ที่ถูกคือเขียน strong parameters
ข้อ 3 · post_params เขียน params.require(:post).permit(:title, :body) · คนร้ายส่ง title · body และแอบเติม post[views]=999999 · ผลคืออะไร?
ตัวเลือกที่ 1 ถูก · ผลจริง: บันทึกแล้ว: {"title" => "ร้านผม", "views" => 0, …} และ log มี
Unpermitted parameters: :views, :published, :user_id · SQL INSERT ใส่ views เป็น 0
ตัวเลือกที่ 2 กับ 4 ผิด — permit ไม่ปฏิเสธใบสั่ง แค่ทิ้งช่องเกินแล้วไปต่อ (status 200) ·
ตัวเลือกที่ 3 ผิด — log จดไว้ก็จริง แต่ช่องนั้นไม่ได้ลงตู้ · คำเตือนไม่ใช่การปล่อยผ่าน
จุดที่ต้องจำ: permit = allowlist ที่ทิ้งเงียบ ๆ · คนส่งไม่รู้ตัว · เราดูได้จาก log ตอนพัฒนา
ข้อ 4 · มีคนป่วนส่ง -d "post=hello" (post เป็นข้อความคำเดียว) · ถ้า post_params เขียนด้วย require/permit กับเขียนด้วย params.expect จะได้ status อะไรตามลำดับ?
ตัวเลือกที่ 4 ถูก · ผลจริงในตารางหัวข้อ params.expect: require/permit ได้ 500 + undefined method 'permit' for an instance of String ·
expect ได้ 400 + ParameterMissing
ตัวเลือกที่ 1 ผิดครึ่งเดียว — ท่าเก่าไม่ได้ 400 เพราะ require เห็นว่ามี post ก็ปล่อยผ่าน ·
ตัวเลือกที่ 2 ผิดครึ่งเดียว — expect เช็ครูปร่างก่อน จึงไม่พัง ·
ตัวเลือกที่ 3 ผิด — ทั้งสองท่าไม่ไปถึงบรรทัด save! เลย
จุดที่ต้องจำ: expect = require + permit + เช็ครูปร่าง · ใบสั่งผิดแบบได้ 400 (ความผิดคนส่ง) ไม่ใช่ 500 (ครัวพัง)
ข้อ 3 ปลายเปิดของสไลด์หน้า 50 ("แก้ให้ปลอดภัยแล้ว ยังมีอีก 2 เรื่องที่ควรแก้ · เรื่องหนึ่งเกี่ยวกับ save อีกเรื่องเกี่ยวกับผู้ใช้ที่กรอกผิด") ·
ลองคิดเองก่อน แล้วเทียบกับ action create ในบท 110 หัวข้อ 7 ที่ใช้ if @post.save … else render :new, status: :unprocessable_content ·
โค้ดในสไลด์ไม่ดูผลของ save แล้ว redirect เสมอ แม้บันทึกไม่สำเร็จ
ถามกลับได้เลย
ถ้าคุณเจอ route ที่ไม่รู้ว่าควรเป็น member · collection · หรือ resource ใหม่ (เช่น "กดไลก์บทความ" ควรเป็น POST /posts/:id/like หรือ resources :likes) —
นี่คือคำถามที่ดีมากที่จะเอาไปถาม senior หรือ AI · ส่งไปพร้อมผล bin/rails routes -c ชื่อแผนก คนที่ช่วยจะเห็นภาพทันที
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง | ต้องสนใจไหม |
|---|---|---|---|
| 45–46 | resources 1 บรรทัด = 7 route | bin/rails routes พิมพ์ 8 แถว เพราะ PATCH กับ PUT ไปเมนู update เดียวกัน | ไม่ต้อง — นับเมนูได้ 7 ถูกแล้ว |
| 46 | คอลัมน์ Gin ใช้ r.PUT คู่กับ PATCH ของ Rails | Rails รับทั้ง PATCH และ PUT | ไม่ต้อง |
| 48 | Post.create(params[:post]) → "ทุก field ที่ส่งมา เข้าไปหมด" | Rails 8.1.4 ได้ ForbiddenAttributesError (500) ไม่มีอะไรลงตู้ · ช่องโหว่เปิดจริงเมื่อเติม permit! | รู้ไว้ — quiz ข้อ 2 |
| 48–49 | "field อื่นถูกทิ้ง + log เตือน" | จริงกับ require/permit · ส่วน params.expect ทิ้งเหมือนกันแต่ไม่มี log Unpermitted parameters | สนใจ — ถ้าเปลี่ยนเป็น expect อย่ารอดู log บรรทัดนี้ |
| 48 | GitHub ถูกเจาะ มีนาคม 2012 (Egor Homakov) | ยืนยันได้ว่ามี issue #5228 ใน repo ของ Rails ที่เขาเปิดเรื่อง mass assignment วันที่ 1 มี.ค. 2012 · ส่วนรายละเอียดการเจาะไม่ได้ยืนยัน | ไม่ต้อง |
| 50 ข้อ 2 | ถามว่าโค้ด Post.new(params[:post]) มีปัญหาอะไร | บน Rails 8.1.4 ได้ error ก่อนถึง save (ดูแถวหน้า 48) · ถ้าเปลี่ยนเป็น permit! บรรทัด user_id = current_user.id จะเขียนทับ user_id ได้ แต่ช่องอื่นรั่ว | รู้ไว้ — predict ในหัวข้อ mass assignment |
อ้างอิง
- Rails Guides — Rails Routing from the Outside In · 2.7 Nested Resources — อ่าน 2.7 ต่อด้วย 2.7.1 Limits to Nesting (ประโยค "only nest resources 1 level deep") และ 2.7.2 Shallow Nesting
- Rails Guides — 2.10.1 Adding Member Routes —
อ่าน 2.10.1 กับ 2.10.2 Adding Collection Routes (publish · drafts ของบทนี้) · แล้วย้อนไป
2.5 Singular Resources ที่บอกว่า
resource :geocoderไปGeocodersController(พหูพจน์ — predict ของบทนี้) - Rails Guides — 4.8 Restricting the Routes Created —
อ่านทั้งหัวข้อ เรื่อง
only:/except:· และ 2.9 Creating Paths and URLs from Objects ที่อธิบายว่าส่ง object ตรง ๆ แล้ว Rails เดา helper ให้ (กลไกเดียวกับredirect_to @post) - Rails Guides — Action Controller Overview · 4 Strong Parameters —
อ่านตัวอย่างโค้ดแรกของหัวข้อ 4 (บรรทัด
Person.create(params[:person])ที่โยน ForbiddenAttributesError) แล้วต่อด้วย 4.1.1expectและ 4.1.3permit!(คำเตือนให้ใช้อย่างระวัง) - Rails API — ActionController::Parameters#expect —
อ่านย่อหน้าแรก ที่บอกว่า expect ปลอดภัยกว่า require + permit ซึ่ง "could allow user triggered 500 errors" · แล้วดูตัวอย่าง
user: "hack"(กรณีเดียวกับpost=helloของบทนี้) · ถ้าวันหนึ่งฟอร์มมีช่องเป็นรายการหรือมีฟอร์มย่อยข้างใน หน้านี้มีตัวอย่างวิธีเขียนexpectให้รองรับ - rails/rails issue #5228 — Mass assignment vulnerability — issue ที่ Egor Homakov เปิดเมื่อ 1 มี.ค. 2012 · อ่านแค่ข้อความแรก พอเห็นบรรยากาศสมัยที่ Rails ยังไม่บังคับ strong parameters
- Go — encoding/json · Unmarshal — อ่านย่อหน้าที่พูดถึงการจับคู่ key ของ JSON กับช่องของ struct (ช่องที่ struct ไม่มีถูกข้ามไป) — เหตุผลที่ DTO ใน Go กันช่องเกินได้