🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้
- XSS · escape (บท 103) — คนร้ายใส่
<script>ไว้ในข้อมูล ให้ไปรันในเบราว์เซอร์คนอื่น ·<%= %>กันให้เองด้วยการแปลง<เป็น< - partial ·
render post(บท 123) — แม่พิมพ์_post.html.erbที่หน้า index ใช้ซ้ำทีละบทความ · ในแม่พิมพ์เรียกบทความว่าpost(ไม่มี @) - N+1 ·
includes(บท 114) — ไปตู้ 1 รอบเอารายการ แล้วไปอีกรอบต่อทุกชิ้น · แก้ด้วยใบเบิกที่บอกล่วงหน้าว่า "หยิบของที่ผูกกันมาด้วย" - อ่าน log (บท 113) —
… Load= ไปตู้ 1 รอบ ·↳ ไฟล์:บรรทัด= บรรทัดที่สั่ง (ไฟล์.erbลบหนึ่ง) ·(N queries, M cached)ท้ายใบสั่ง = รวมกี่รอบ
🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 18 · bullet 8.3.1 (สไลด์และ repo ของอาจารย์เขียน Ruby 4.0.5 · Rails 8.1.3 — เรื่องในบทนี้ไม่ต่างกัน)
📍 โค้ดเริ่มจาก: โปรเจกต์ที่ทำเสร็จในบท 120 (หมวด 5 · บทความ 30 ชิ้น) ·
บทนี้ทดลองแล้วคืนทิ้งตอนจบด้วย git restore — ของจริงเราทำกันใน Lab บท 126
🖥 เปิดค้างไว้: bin/dev หนึ่งหน้าต่าง (เว็บอยู่ที่ localhost:3000) · อีกหน้าต่างไว้พิมพ์คำสั่ง ·
ผลรันทุกกล่องมาจากเครื่องจริง · เวลา ms · วันที่ · เลข 0x… · id ของบทความ ของคุณอาจไม่ตรงกับในบท
XSS ทวน 30 วินาที — แล้วทำไมต้องลงลึกอีก
บท 103 เห็นแล้วว่าถ้าชื่อบทความเป็น <script>…</script> แล้วหน้าเว็บวางลงไปตรง ๆ เบราว์เซอร์ของทุกคนที่เปิดหน้านั้นจะรันโปรแกรมของคนร้าย ·
และ <%= %> กันให้แล้วโดยไม่ต้องสั่ง
ที่ต้องลงลึกเพราะ Day 3 เราจะเริ่มให้ AI เขียน view · และมีคำสั้น ๆ ไม่กี่คำที่ปิดระบบกันภัยนี้ได้ ซึ่ง AI (หรือคนที่รีบ) อาจหยิบมาใช้ตอนอยากให้ HTML "โชว์ออกมาสวย ๆ" · บทนี้จะลองของจริงทั้ง 4 แบบในสไลด์หน้า 17 แล้วดูว่าเบราว์เซอร์ได้รับอะไรจริง
สร้างบทความอันตรายของจริงหนึ่งชิ้น
เปิด bin/rails console (บท 107) ในหน้าต่างที่สองแล้วสร้างบทความที่ชื่อเป็นโปรแกรม ·
เราใช้ alert('XSS!') (alert = คำสั่ง JavaScript ที่เด้งกล่องข้อความขึ้นมาบนหน้าเว็บ) — แค่เด้งกล่อง ไม่ทำอะไรเสียหาย ·
คนร้ายจริงจะใส่โปรแกรมขโมยข้อมูลแทน (สไลด์หน้า 17)
wongnok-blog-all-in-one(dev):001> post = Post.create!(title: "<script>alert('XSS!')</script>", description: "บทความทดลอง XSS", category: Category.first)
Category Load (0.5ms) SELECT "categories".* FROM "categories" ORDER BY "categories"."id" ASC LIMIT 1 /*application='WongnokBlogAllInOne'*/
TRANSACTION (0.3ms) BEGIN /*application='WongnokBlogAllInOne'*/
Post Create (0.8ms) INSERT INTO "posts" ("title", "description", "category_id", "created_at", "updated_at") VALUES ('<script>alert(''XSS!'')</script>', 'บทความทดลอง XSS', 1, '2026-11-07 03:44:18.637441', '2026-11-07 03:44:18.637441') RETURNING "id" /*application='WongnokBlogAllInOne'*/
TRANSACTION (0.7ms) COMMIT /*application='WongnokBlogAllInOne'*/
=>
#<Post:0x0000000126ae2118
…
wongnok-blog-all-in-one(dev):002> post.id
=> 31
Post.create!(…)— สร้างแฟ้มใหม่ใส่ลิ้นชักposts(บท 107) ·category: Category.first= ใส่หมวดแรก (อาหารไทย)VALUES ('<script>alert(''XSS!'')…— ฐานข้อมูลเก็บตัวอักษรตามที่พิมพ์ทุกตัว ไม่มีใครกรอง · (''สองตัวคือวิธีที่ SQL เขียน'หนึ่งตัว ไม่ต้องสนใจ)#<Post:0x…แล้วตามด้วย…— console โชว์แฟ้มที่เพิ่งสร้างทีละช่องอีกหลายบรรทัด ผมตัดออกเพราะไม่ใช่เรื่องของบทนี้post.id→31— จดเลขนี้ไว้ · ของคุณอาจเป็นเลขอื่น ถ้าเคยเพิ่มบทความเอง ให้ใช้เลขของคุณแทน 31 ในทุกคำสั่งข้างล่าง
สังเกตว่า Rails ยอมให้บันทึก · ระบบกันภัยของ Rails ไม่ได้อยู่ตอนเก็บ แต่อยู่ตอนโชว์ — ตรงนี้แหละที่บทนี้จะเจาะ
ลอง 4 แบบ — ดูว่าเบราว์เซอร์ได้รับอะไรจริง
หน้า /posts/31 โชว์บทความผ่านแม่พิมพ์ app/views/posts/_post.html.erb (ของเดิมจาก scaffold) · เปิดไฟล์ดูทั้งไฟล์ก่อน — ครึ่งหลังของบทก็ใช้ไฟล์นี้:
<%# app/views/posts/_post.html.erb — เลขบรรทัดอยู่ซ้ายสุด (ไม่ได้อยู่ในไฟล์) %>
1 <div id="<%= dom_id post %>" class="w-full sm:w-auto my-5 space-y-5">
2 <div>
3 <strong class="block font-medium mb-1">Title:</strong>
4 <%= post.title %>
5 </div>
6 <div>
7 <strong class="block font-medium mb-1">Description:</strong>
8 <%= post.description %>
9 </div>
10 <div>
11 <strong class="block font-medium mb-1">Category:</strong>
12 <%= post.category_id %>
13 </div>
14 </div>
- บรรทัด 4
<%= post.title %>— ชื่อบทความ · ครึ่งแรกของบทเราแก้บรรทัดนี้ - บรรทัด 12
<%= post.category_id %>— เลขหมวด · ครึ่งหลัง (N+1) แก้บรรทัดนี้ dom_id postได้ id เช่นpost_31(เรื่องของบท 131) ·class="block font-medium mb-1"ฯลฯ คือคลาส Tailwind (บท 72) ที่ scaffold ใส่มาจัดหน้าตา — ข้ามได้ ไม่เกี่ยวกับบทนี้
เราจะเปลี่ยนบรรทัด 4 ทีละแบบ แล้วดู HTML ที่ได้ด้วย curl (โปรแกรมเปิดหน้าเว็บจากเทอร์มินัล โชว์ HTML เป็นข้อความ — เห็นของที่เบราว์เซอร์ได้รับจริง ก่อนเบราว์เซอร์จะแปลเป็นหน้าจอ · -s = เงียบ ๆ ไม่โชว์แถบความคืบหน้า) ·
HTML ทั้งหน้ายาวมาก ข้างล่างตัดมาเฉพาะสองบรรทัดถัดจากคำว่า Title: · ของคุณให้ไล่หาคำว่า Title: แบบเดียวกัน
แบบ ① <%= post.title %> — ของเดิม
$ curl -s localhost:3000/posts/31
…
<strong class="block font-medium mb-1">Title:</strong>
<script>alert('XSS!')</script>
…
<·>·'— รหัสของ<·>·'(บท 103) · เบราว์เซอร์วาดเป็นตัวอักษร ไม่ตีความเป็นแท็ก- ผลบนจอ: ผู้ใช้เห็นข้อความ
<script>alert('XSS!')</script>ครบทุกตัว · ไม่มีอะไรรัน
แบบ ② <%= raw post.title %>
แก้บรรทัด 4 เป็น <%= raw post.title %> แล้ว curl ใหม่:
$ curl -s localhost:3000/posts/31
…
<strong class="block font-medium mb-1">Title:</strong>
<script>alert('XSS!')</script>
…
<script> ตัวจริงหลุดเข้าไปในหน้าเว็บ · เปิด http://localhost:3000/posts/31 ในเบราว์เซอร์ → กล่อง alert "XSS!" เด้งขึ้นมา (กด OK ปิดได้) ·
(ผมยืนยันด้วย Chrome จริงแล้ว — และเปิดหน้ารวม /posts ก็เด้งเหมือนกัน เพราะใช้แม่พิมพ์เดียวกัน = ทุกคนที่เปิดหน้ารวมโดนหมด)
แบบ ③ <%= post.title.html_safe %>
แก้บรรทัด 4 เป็น <%= post.title.html_safe %> — ผลจริง:
<strong class="block font-medium mb-1">Title:</strong>
<script>alert('XSS!')</script>
เหมือนแบบ ② ทุกตัวอักษร · กล่อง alert เด้งเหมือนกัน (ยืนยันด้วย Chrome แล้ว) ·
เพราะในโค้ดของ Rails เอง raw คือ to_s.html_safe บรรทัดเดียว — สองคำนี้คือของชิ้นเดียวกัน คนละหน้าตา
⚠️ ชื่อ html_safe หลอกตา — มันไม่ได้ทำให้ HTML ปลอดภัย · มันคือการแปะป้ายว่า "ข้อความนี้ปลอดภัยแล้ว ไม่ต้อง escape" ·
Rails เชื่อป้ายนั้นทันที ไม่ตรวจอะไรเลย
แบบ ④ <%= sanitize post.title %>
<%= sanitize post.title %>
sanitize แปลว่า "ฆ่าเชื้อ" · ชื่อบทความคือ <script>alert('XSS!')</script> · คิดว่าบนจอจะเห็นอะไร —
ว่างเปล่า · ตัวหนังสือทั้งก้อนเหมือนแบบ ① · alert เด้ง · หรืออย่างอื่น?
ผลจริงจาก curl:
<strong class="block font-medium mb-1">Title:</strong>
alert('XSS!')
แท็ก <script> ถูกถอดทิ้ง แต่ตัวหนังสือข้างในยังอยู่ · บนจอจึงเห็นคำว่า alert('XSS!') เป็นตัวหนังสือเฉย ๆ ·
ไม่มีอะไรรัน (Chrome ยืนยันว่าตรงชื่อบทความไม่มีแท็ก script เหลือ และกล่องไม่เด้ง)
สรุปทั้ง 4 แบบ (ผลจริงทุกช่อง):
| ใน view | HTML ที่ส่งออกไป | บนจอ | script รันไหม |
|---|---|---|---|
<%= post.title %> | <script>… | ข้อความเต็ม ๆ | ✅ ไม่รัน |
<%= raw post.title %> | <script>… | ว่าง + กล่องเด้ง | ❌ รัน |
<%= post.title.html_safe %> | <script>… | ว่าง + กล่องเด้ง | ❌ รัน |
<%= sanitize post.title %> | alert('XSS!') | ข้อความที่ถอดแท็กแล้ว | ✅ ไม่รัน |
คนจัดจาน (view) เอาทุกอย่างที่ลูกค้าเขียนมาใส่ซองใสก่อนวางบนจาน · ใครดูก็อ่านออกว่าเขียนอะไร แต่มันทำงานไม่ได้ — ใบที่เขียนว่า "จุดประทัด" ก็เป็นแค่กระดาษในซอง ·
raw คือสั่งคนจัดจานว่า "แกะซองออก วางของจริงลงจานเลย" ·
.html_safe คือแปะสติกเกอร์ "ตรวจแล้ว" บนของชิ้นนั้น — คนจัดจานเห็นสติกเกอร์ก็ไม่ใส่ซองให้ ทั้งที่ไม่มีใครตรวจจริง
ที่ไม่เหมือนกัน: คนจัดจานจริงเห็นประทัดก็คงทักว่า "เอ๊ะ แน่ใจเหรอ" · Rails ไม่ทักเลย ไม่มี error ไม่มีคำเตือนใน log — หน้าเว็บโหลดปกติ 200 OK · คนเดียวที่จะจับได้คือคนอ่านโค้ด
เทียบกับ React (บท 71): {post.title} ใน JSX ก็ escape ให้เองแบบเดียวกับ <%= %> ·
ถ้าอยากวาง HTML ดิบ React บังคับให้พิมพ์ชื่อยาว ๆ ว่า dangerouslySetInnerHTML — ตั้งชื่อให้ดูอันตรายโดยตั้งใจ ·
ฝั่ง Rails คำที่ทำหน้าที่นั้นคือ raw กับ html_safe ซึ่งชื่อไม่ได้ฟังดูอันตรายเลย เราจึงต้องจำเอง
- stored XSS (persistent XSS)
- XSS ที่โปรแกรมของคนร้ายถูกเก็บลงฐานข้อมูลแล้วไปรันในเบราว์เซอร์ของทุกคนที่เปิดหน้านั้น (แบบที่เราเพิ่งทำ) · ร้ายแรงเพราะโดนทุกคน ไม่ใช่แค่คนที่กดลิงก์แปลก ๆ · ถาม senior ได้ว่า "ช่องไหนบ้างที่ผู้ใช้พิมพ์เองแล้วเราเอาไปโชว์ให้คนอื่นดู"
raw·html_safe- สองคำที่บอก Rails ว่า "ไม่ต้อง escape ข้อความนี้" ·
raw x=x.to_s.html_safe· พิมพ์บอก AI ได้ว่า "do not use raw or html_safe on user input; keep ERB's default escaping"
sanitize — คัดแท็กตามรายชื่อที่อนุญาต
แล้วถ้าอยากให้ผู้ใช้ทำตัวหนา หรือใส่ลิงก์ได้ล่ะ? (เช่น คำอธิบายร้านที่มี ตัวหนา) ·
<%= %> จะ escape ทุกแท็ก ส่วน raw ปล่อยทุกแท็ก — sanitize คือทางกลาง: เก็บแท็กที่อยู่ในรายชื่อที่อนุญาต ถอดที่เหลือทิ้ง
ลองใน console (helper ใน console = กล่องเครื่องมือชุดเดียวกับที่ view ใช้ ยืมมาลองนอกหน้าเว็บได้) — ผลจริง:
wongnok-blog-all-in-one(dev):001> helper.sanitize('<b>อร่อยมาก</b>')
=> "<b>อร่อยมาก</b>"
wongnok-blog-all-in-one(dev):002> helper.sanitize('<script>alert(1)</script>สวัสดี')
=> "alert(1)สวัสดี"
wongnok-blog-all-in-one(dev):003> helper.sanitize('<a href="/posts" onclick="alert(1)">ร้านอื่น</a>')
=> "<a href=\"/posts\">ร้านอื่น</a>"
- บรรทัด 1 —
<b>(ตัวหนา) อยู่ในรายชื่อ → ผ่านไปทั้งแท็ก - บรรทัด 2 —
<script>ไม่อยู่ในรายชื่อ → ถอดแท็กทิ้ง เหลือแต่ตัวหนังสือ (แบบเดียวกับแบบ ④ เมื่อกี้) - บรรทัด 3 — แท็ก
<a>(ลิงก์) ผ่าน และhref(ที่อยู่ลิงก์) ผ่าน · แต่onclick="…"(คำสั่ง "กดแล้วรันโปรแกรมนี้") ถูกตัดออก ·\"คือวิธีที่ console โชว์"ที่อยู่ในข้อความ ไม่ต้องสนใจ
ลูกค้าส่งของตกแต่งจานมาเอง · คนจัดจานถือรายการ "ใบโหระพา · พริกซอย · ผักชี วางได้" ·
ของที่อยู่ในรายการวางลงจาน · ของนอกรายการ (ประทัด · ไม้ขีด) ดึงออกทิ้ง · กระดาษโน้ตที่ติดมากับประทัดยังวางไว้ให้อ่านได้ — แบบที่ alert(1) ยังเหลือเป็นตัวหนังสือ
ที่ไม่เหมือนกัน: คนจัดจานจริง "ดูออก" ว่าอะไรอันตราย · sanitize ดูไม่ออก มันแค่เทียบกับรายการ ·
ถ้าเราไปเพิ่ม script ลงรายการเอง (sanitize(x, tags: %w[script])) มันก็ปล่อย <script>alert(1)</script> ผ่านทั้งก้อน (ผมลองแล้ว) ·
เอกสารของ Rails เตือนตรง ๆ ว่าให้ใช้รายการเริ่มต้นเท่าที่ทำได้
สไลด์หน้า 17 บอกว่า sanitize "ใช้กับ rich text (บ่าย)" — rich text คือช่องที่ผู้ใช้จัดตัวหนา ใส่ลิงก์ได้ เป็นเรื่องของ Day 3 ช่วงบ่าย (ยังไม่มาในชุดนี้) ·
วันนี้จำแค่ว่า: ไม่ต้องการ HTML จากผู้ใช้ = ใช้ <%= %> ธรรมดา · ต้องการจริง ๆ = sanitize · ไม่มีกรณีไหนที่ควร raw ข้อมูลจากผู้ใช้
- sanitize · sanitizer
- ล้างข้อความ HTML ให้เหลือเฉพาะแท็กและ attribute ที่อนุญาต · ใน Rails คือ helper
sanitize· พิมพ์บอก AI ได้ว่า "if HTML from users is needed, use sanitize with the default allowlist, never raw" - allowlist (safe list)
- รายชื่อของที่อนุญาต — ไม่อยู่ในรายชื่อ = ไม่ผ่าน (ตรงข้ามกับ denylist ที่ห้ามเฉพาะของที่รู้จัก) · ถาม senior ได้ว่า "โปรเจกต์เราแก้ allowlist ของ sanitize ไว้ไหม เพิ่มอะไรเข้าไปบ้าง"
กฎหนึ่งบรรทัด + หาคำอันตรายด้วยคำสั่งเดียว
ค่าเริ่มต้นของ Rails ปลอดภัย — อันตรายเกิดเมื่อเราตั้งใจพิมพ์ raw หรือ html_safe เอง (สไลด์หน้า 17) ·
แปลว่าเวลาตรวจโค้ด (ของเพื่อนหรือของ AI) ไม่ต้องไล่อ่านทุกบรรทัด — ค้นหาสองคำนี้ก่อน
ตอนนี้บรรทัด 4 ของ _post.html.erb ยังเป็น sanitize อยู่ · เปลี่ยนกลับเป็น <%= raw post.title %> สักครู่ แล้วค้นด้วย grep — ผลจริง:
$ grep -rn "raw\|html_safe" app/views
app/views/posts/_post.html.erb:4: <%= raw post.title %>
grep— โปรแกรมค้นคำในไฟล์ เหมือนกด Ctrl+F แต่ค้นทุกไฟล์ในโฟลเดอร์ทีเดียว-r— ค้นเข้าไปในโฟลเดอร์ย่อยทุกชั้น ·-n— บอกเลขบรรทัด (เขียนติดกันเป็น-rn)"raw\|html_safe"— คำที่จะหา ·\|แปลว่า "หรือ" → raw หรือ html_safeapp/views— ค้นที่ไหน- ผล
ไฟล์:บรรทัด:ข้อความ— เจอที่_post.html.erbบรรทัด 4 · (ตรงนี้เลขบรรทัดตรงจริง ไม่ต้องลบหนึ่ง เพราะ grep อ่านไฟล์ตรง ๆ ไม่ใช่ log)
คืนไฟล์เป็นของเดิมแล้วค้นอีกรอบ: git restore app/views/posts/_post.html.erb → grep -rn "raw\|html_safe" app/views → ไม่มีอะไรขึ้นเลย = ไม่เจอ = ดี
⚠️ grep หาตัวอักษร ไม่ได้อ่านโค้ดเป็น — ถ้าค้นทั้งโฟลเดอร์ app ผมได้ผลเกินมา 9 บรรทัดจาก app/assets/tailwind/daisyui.mjs
เพราะคำว่า drawer มี raw ซ่อนอยู่ · เติม -w (เฉพาะคำเต็ม) → grep -rnw "raw\|html_safe" app เหลือแต่บรรทัดที่ใช่จริง ·
ค้นทั้ง app ดีกว่าค้นแค่ app/views เพราะ html_safe ซ่อนใน helper (app/helpers) ได้ด้วย
เจอแล้วทำอะไรต่อ — ถามคำถามเดียวตามสไลด์: "ข้อมูลตรงนี้มาจากไหน"
- มาจากผู้ใช้พิมพ์ (ชื่อบทความ · คอมเมนต์ · ชื่อหมวด) → ลบ
raw/html_safeทิ้ง ถ้าต้องการ HTML จริงใช้sanitize - เป็นข้อความที่เราเขียนเองในโค้ด ไม่มีอะไรจากผู้ใช้ปนเลย → ปลอดภัย แต่ส่วนใหญ่มี helper ที่ไม่ต้องใช้
html_safeอยู่แล้ว (เช่นtag.divใน บท 124)
🧹 ก่อนไปครึ่งหลัง: ลบบทความทดลองทิ้งใน console (บทความชิ้นล่าสุดคือชิ้นที่เราเพิ่งสร้าง):
wongnok-blog-all-in-one(dev):001> Post.last.destroy
Post Load (0.5ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."id" DESC LIMIT 1 /*application='WongnokBlogAllInOne'*/
TRANSACTION (0.3ms) BEGIN /*application='WongnokBlogAllInOne'*/
Post Destroy (0.8ms) DELETE FROM "posts" WHERE "posts"."id" = 31 /*application='WongnokBlogAllInOne'*/
TRANSACTION (0.8ms) COMMIT /*application='WongnokBlogAllInOne'*/
=>
#<Post:0x0000000125c6e0f0
id: 31,
title: "<script>alert('XSS!')</script>",
…
เห็น DELETE … "id" = 31 และ title: "<script>…" = ลบถูกชิ้น · กลับมาเหลือบทความ 30 ชิ้นเท่าเดิม
N+1 ใน view — ทำไมหายากกว่าที่เห็นในบท 114
บท 114 เราเจอ N+1 ในไฟล์ view ไฟล์เดียว ที่ @posts.each กับ post.user.name อยู่ติดกัน · ของจริงในโปรเจกต์มักไม่ง่ายขนาดนั้น
เพราะ การวนลูป อยู่ใน index.html.erb แต่ของที่ไปตู้ไปซ่อนอยู่ในแม่พิมพ์ _post.html.erb อีกไฟล์หนึ่ง
สมมติเพื่อนในทีมเปิด PR (คำขอรวมโค้ด) หนึ่งอัน: "โชว์ชื่อหมวดแทนเลขหมวด" · ทั้ง PR มีแค่นี้ (ผลจริงจาก git diff):
--- a/app/views/posts/_post.html.erb
+++ b/app/views/posts/_post.html.erb
@@ -9,6 +9,6 @@
</div>
<div>
<strong class="block font-medium mb-1">Category:</strong>
- <%= post.category_id %>
+ <%= post.category.name %>
</div>
</div>
- บรรทัดที่ขึ้นต้นด้วย
-= ของเดิมที่ถูกลบ ·+= ของใหม่ที่ใส่แทน · บรรทัดที่ไม่มีเครื่องหมาย = ของรอบ ๆ ที่ไม่ได้เปลี่ยน โชว์ไว้ให้รู้ตำแหน่ง ---+++= ชื่อไฟล์ ·@@ -9,6 +9,6 @@= "ตัดมาตั้งแต่บรรทัด 9 ยาว 6 บรรทัด" — ไม่ต้องสนใจpost.category_id= เลขหมวดบนแฟ้มบทความ (อ่านฟรี ไม่ไปตู้) ·post.category.name= เดินไปลิ้นชัก categories เอาชื่อหมวด (บท 114: ของที่อยู่ลิ้นชักอื่น = ไปตู้)
คนตรวจ PR เห็นแค่นี้ · controller ไม่อยู่ใน diff เลย จึงไม่มีใครเห็นว่า controller เขียน @posts = Post.all ไม่มี includes ·
สไลด์หน้า 18 สรุปสามเหตุผลที่มันหายาก: คนเขียน controller กับ partial อาจเป็นคนละคน คนละ PR · เพิ่มช่องใหม่ใน partial วันหลัง = เพิ่ม N+1 ใหม่โดยไม่รู้ตัว · controller ไม่เปลี่ยน diff จึงดูไม่มีอะไร
ลองทำตาม PR นี้จริง: แก้บรรทัด 12 ของ _post.html.erb จาก post.category_id เป็น post.category.name แล้วรีเฟรชหน้า /posts
มีบทความ 30 ชิ้น อยู่ใน 5 หมวด · controller ยังเป็น Post.all ·
บรรทัด Completed … ใน log จะขึ้น (? queries, ? cached) เท่าไหร่? (ใบ้: บท 114 เห็นแล้วว่า Rails จำคำถามที่ซ้ำเป๊ะ ๆ ได้ภายในใบสั่งเดียว)
ผลจริงจาก log/development.log (ตัดบรรทัดกลางที่ซ้ำ ๆ เป็น …):
Started GET "/posts" for ::1 at 2026-11-07 10:46:02 +0700
Processing by PostsController#index as */*
Rendering layout layouts/application.html.erb
Rendering posts/index.html.erb within layouts/application
Post Exists? (1.1ms) SELECT 1 AS one FROM "posts" LIMIT 1 /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
↳ app/views/posts/index.html.erb:15
Post Load (1.1ms) SELECT "posts".* FROM "posts" /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
↳ app/views/posts/index.html.erb:16
Category Load (1.7ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = 1 LIMIT 1 /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
↳ app/views/posts/_post.html.erb:13
Rendered posts/_post.html.erb (Duration: 14.6ms | GC: 8.1ms)
Category Load (0.9ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = 2 LIMIT 1 /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
↳ app/views/posts/_post.html.erb:13
… (id = 3, 4, 5 เหมือนกัน)
CACHE Category Load (0.3ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" = 1 LIMIT 1
↳ app/views/posts/_post.html.erb:13
… (CACHE วน id 1–5 ไปเรื่อย ๆ อีก 24 ครั้ง)
Rendered posts/index.html.erb within layouts/application (Duration: 53.5ms | GC: 17.1ms)
Rendered layout layouts/application.html.erb (Duration: 54.5ms | GC: 17.1ms)
Completed 200 OK in 57ms (Views: 49.3ms | ActiveRecord: 6.2ms (32 queries, 25 cached) | GC: 17.5ms)
(32 queries, 25 cached) — นับให้ดูทีละกลุ่ม:
Post Exists?1 — มาจาก<% if @posts.any? %>บรรทัด 14 ของ index ("มีบทความสักชิ้นไหม") · log บอก:15→ ลบหนึ่ง = 14Post Load1 — หยิบบทความ 30 แผ่น (@posts.eachบรรทัด 15)Category Load30 — ตัวจริง 5 (หมวด 1–5) +CACHE25 (หมวดเดิมซ้ำ Rails ตอบจากความจำ) · ทุกบรรทัดชี้_post.html.erb:13→ ลบหนึ่ง = บรรทัด 12 =post.category.name
บรรทัดอื่นอ่านผ่านได้: Rendered … = วาดแม่พิมพ์/หน้าเสร็จ · Duration GC Views = ตัวเลขเวลา (ms) เปลี่ยนทุกครั้ง ·
ผมยิงด้วย curl จึงเห็น for ::1 กับ as */* · ถ้าคุณรีเฟรชจากเบราว์เซอร์จะเห็น as HTML แทน ความหมายเดียวกัน
1 + 1 + 30 = 32 · ไปตู้จริง 7 รอบ · ที่เหลือ "cached" · แต่อย่าวางใจ CACHE (บท 114) — ถ้ามี 30 หมวดไม่ซ้ำกัน ก็ไปตู้จริงครบ 30 รอบ
หัวหน้าเชฟ (controller) วางแฟ้มบทความ 30 แผ่นไว้บนถาดหน้าครัว · คนจัดจานหยิบแม่พิมพ์ (_post) มากดทีละจาน ·
แม่พิมพ์รุ่นใหม่มีช่อง "ชื่อหมวด" · แต่บนถาดมีแค่เลขหมวด ไม่มีชื่อ → ทุกจาน คนจัดจานต้องเดินไปตู้ถามชื่อหมวดเอง 30 รอบ ·
หัวหน้าเชฟไม่รู้เรื่องเลย เพราะเขาไม่ได้ดูแม่พิมพ์ — เขาแค่วางถาด
ที่ไม่เหมือนกัน: คนจัดจานจริงเดินไปสองสามรอบก็คงจำได้ว่า "หมวด 1 ชื่ออาหารไทย" แล้วเลิกเดิน · Rails จำได้แค่ภายในใบสั่งเดียว (CACHE) และยังนับว่าถามทุกครั้ง · ใบสั่งใหม่ (ลูกค้าคนถัดไป) ความจำถูกล้าง เริ่มเดินใหม่ทั้งหมด
- N+1 in a partial
- N+1 ที่ตัวลูป (
each/render @posts) อยู่ไฟล์หนึ่ง แต่บรรทัดที่ไปตู้อยู่ในแม่พิมพ์อีกไฟล์ · ดูจาก diff ไม่ออก ต้องดู log · พิมพ์บอก AI ได้ว่า "_post.html.erb calls post.category.name; check whether the controller eager loads :category" - PR (pull request) · diff
- PR = คำขอรวมโค้ดที่เพื่อนตรวจก่อน · diff = รายการบรรทัดที่เปลี่ยน (
-ลบ ·+เพิ่ม) · ของที่ไม่ได้เปลี่ยนจะไม่อยู่ใน diff · ถาม senior ได้ว่า "ตอน review PR ที่แก้ partial ต้องเปิดดู controller ด้วยไหม"
bullet — ผู้ช่วยที่ตะโกนบอกเอง
นับจาก log ได้ผลแน่นอน แต่ต้องตั้งใจดู · สไลด์หน้า 18 แนะนำให้ "ใส่ gem bullet ไว้ตั้งแต่วันแรก" — โปรเจกต์ของคอร์สติดตั้งมาให้แล้ว (ใน Gemfile และเปิดไว้ใน config/environments/development.rb) ·
bullet คอยดูระหว่างหน้าเว็บทำงาน ถ้าเห็นรูปแบบ N+1 มันบอกเราสองที่:
ที่ ① บนหน้าเว็บ — กล่องสีชมพูมุมซ้ายล่าง หัวข้อ Bullet Warnings · คลิกแล้วกางออก (ข้อความจริงจาก Chrome):
Bullet Warnings
user: tuwanon USE eager loading detected Post => [:category] Add to your query: .includes([:category])
ที่ ② ใน log/development.log ต่อท้ายใบสั่งนั้น (ผลจริง · ผมตัด path ยาว ๆ ข้างหน้า app/ ออกเป็น …/):
user: tuwanon
GET /posts
USE eager loading detected
Post => [:category]
Add to your query: .includes([:category])
Call stack
…/app/views/posts/_post.html.erb:13:in '_app_views_posts__post_html_erb__…'
…/app/views/posts/index.html.erb:18:in 'block in _app_views_posts_index_html_erb__…'
…/app/views/posts/index.html.erb:16:in '_app_views_posts_index_html_erb__…'
user: tuwanon— ชื่อผู้ใช้เครื่องของผม · ของคุณจะเป็นชื่อคุณ ·GET /posts= หน้าไหนUSE eager loading detected— "เจอที่ควรหยิบมาล่วงหน้า" (eager loading =includes· บท 114)Post => [:category]— ตอนวนบทความ มีคนเดินไปเอาcategoryทีละชิ้นAdd to your query: .includes([:category])— บอกวิธีแก้มาเลย ([:category]เขียนแบบรายการ ความหมายเดียวกับincludes(:category))Call stack— ไล่ว่าใครเรียกใคร · บรรทัดบนสุดคือต้นเหตุ_post.html.erb:13(ลบหนึ่ง = 12) · ข้างล่างคือที่ที่เรียกแม่พิมพ์นั้น (index.html.erb:18→ บรรทัด 17render post·:16→ บรรทัด 15@posts.each)
(ป้ายตัวเลข ms เล็ก ๆ มุมซ้ายบนของหน้าเว็บคือ gem อีกตัวชื่อ rack-mini-profiler ที่ repo ติดมาด้วย — บทนี้ไม่ใช้ ปล่อยไว้ได้)
bullet คือผู้ช่วยที่ยืนอยู่ข้างตู้ จดว่าใครเดินมาหยิบอะไรกี่รอบ · พอเห็นคนจัดจานเดินมาถามชื่อหมวดซ้ำ ๆ ทีละจาน ก็ตะโกนบอกว่า "รอบหน้าให้หัวหน้าเชฟเบิกหมวดมาพร้อมแฟ้มบทความเลย!" — พร้อมบอกว่าเขียนใบเบิกว่าอะไร
ที่ไม่เหมือนกัน: ผู้ช่วยตัวจริงอยู่ทุกกะ · bullet ทำงานเฉพาะในเครื่องเรา (development) และเห็นเฉพาะหน้าที่เราเปิดจริง · หน้าที่ไม่มีใครเปิดตอนพัฒนา มันก็ไม่รู้ · และมันเดาผิดได้ — เห็นตัวอย่างในภารกิจท้ายบท
- bullet
- gem ที่จับ N+1 ระหว่างพัฒนา แล้วเตือนใน log และบนหน้าเว็บ (สองบรรทัดตั้งค่าใน
development.rb:Bullet.rails_logger = true= เขียนลง log ·Bullet.add_footer = true= แปะกล่องบนหน้าเว็บ) · แปะคำเตือนให้ AI ได้ตรง ๆ ว่า "Bullet says: USE eager loading detected Post => [:category]. Fix it in the controller." - call stack
- รายการ "ใครเรียกใคร" ตอนเกิดเรื่อง เรียงจากจุดเกิดเหตุขึ้นไปหาต้นทาง · อ่านบรรทัดบนสุดที่เป็นไฟล์ของเราก่อน · ถาม senior ได้ว่า "บรรทัดไหนใน stack ที่เป็นโค้ดของเรา"
แก้ที่ controller ไม่ใช่ที่ view
ทำตามที่ bullet บอก — แก้ app/controllers/posts_controller.rb บรรทัดเดียว (ของเดิมจาก scaffold คือ @posts = Post.all) · แม่พิมพ์ไม่ต้องแตะเลย:
# app/controllers/posts_controller.rb — action index
def index
@posts = Post.includes(:category)
end
includes(:category) = ใบเบิกที่เขียนต่อท้ายว่า "หยิบหมวดของทุกแผ่นมาใส่ตะกร้าด้วย" (บท 114) · ชื่อ :category ต้องตรงกับใน model (belongs_to :category) ·
รีเฟรช /posts — log ใหม่ (ผลจริง · ตัดบรรทัด Rendered posts/_post… 30 บรรทัดออก):
Post Exists? (0.6ms) SELECT 1 AS one FROM "posts" LIMIT 1 /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
↳ app/views/posts/index.html.erb:15
Post Load (0.6ms) SELECT "posts".* FROM "posts" /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
↳ app/views/posts/index.html.erb:16
Category Load (0.7ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" IN (1, 2, 3, 4, 5) /*action='index',application='WongnokBlogAllInOne',controller='posts'*/
↳ app/views/posts/index.html.erb:16
…
Completed 200 OK in 23ms (Views: 18.3ms | ActiveRecord: 3.2ms (3 queries, 0 cached) | GC: 5.8ms)
(32 queries, 25 cached)→(3 queries, 0 cached)· หน้าเว็บที่ได้หน้าตาเหมือนเดิม (ผมเทียบ HTML สองรอบแล้ว ต่างกันแค่รหัสกัน CSRF ที่สุ่มใหม่ทุกครั้งอยู่แล้ว กับกล่อง Bullet ที่หายไป)WHERE "categories"."id" IN (1, 2, 3, 4, 5)— เดินไปลิ้นชักหมวดรอบเดียว หยิบมาครบ 5 หมวด (IN= "ตัวใดตัวหนึ่งในวงเล็บ" · บท 114)- ไม่มีบรรทัด
↳ไหนชี้_post.html.erbแล้ว · และกล่อง Bullet Warnings หายไป (ผมเช็คใน HTML แล้ว ไม่มีbullet-footer) - ถ้ามีบทความ 3,000 ชิ้น ก็ยัง 3 query · จำนวนรอบไม่โตตามข้อมูลอีกต่อไป
เทียบกับ React/Next.js: คล้ายเวลา component ลูกแต่ละตัวยิง fetch ของตัวเองใน useEffect (บท 74) — รายการ 30 ชิ้นก็ยิง 30 ครั้ง ·
ทางแก้ก็หลักเดียวกัน: ให้ตัวแม่ดึงข้อมูลครบทีเดียว แล้วส่งลงไปให้ลูก · ฝั่ง Rails ตัวแม่คือ controller
✅ นิสัยที่อยากให้ติดตัว (สไลด์หน้า 18)
- ทุกครั้งที่เติมจุดลงใน partial เช่น
post.category.name→ ไปเปิด controller ดูว่าincludesแล้วหรือยัง - เปิด log ค้างไว้ตอนทำงาน (
bin/devโชว์ log ในหน้าต่างของมันอยู่แล้ว) · ดูบรรทัดCompleted … (N queries)ทุกครั้งที่เปิดหน้าใหม่ - เห็นกล่อง Bullet Warnings โผล่ = หยุดแก้ก่อน อย่าปล่อยค้าง
ให้ AI ช่วย — แต่ตรวจด้วยของสองอย่างที่เพิ่งเรียน
สไลด์ Day 3 จะให้เราสั่ง AI เขียน view บ่อย ๆ (Lab บท 126) · AI เขียน post.category.name ในแม่พิมพ์โดยไม่ไปเติม includes ที่ controller ได้ง่ายมาก เพราะมันไม่เห็น log ของคุณ ·
ใส่ข้อห้ามไว้ใน prompt ตั้งแต่แรก (โครง 4 ส่วนจาก บท 121) เช่นท่อน "ห้าม" กับ "เกณฑ์ตรวจรับ":
ห้าม:
- ใช้ raw หรือ html_safe กับข้อมูลที่ผู้ใช้พิมพ์
- เรียก association ใน partial โดยไม่ includes ที่ controller
เกณฑ์ตรวจรับ:
- grep -rnw "raw\|html_safe" app ไม่เจออะไร
- หน้า /posts ไม่มี Bullet Warnings และ log ขึ้นไม่เกิน 3 queries
แล้วตรวจเองทุกครั้ง อย่าเชื่อคำว่า "เสร็จแล้ว" ของ AI:
- รัน
grep -rnw "raw\|html_safe" app· เจอ → ถาม "ข้อมูลตรงนี้มาจากไหน" - เปิดทุกหน้าที่ AI แก้ · ดูมุมซ้ายล่างว่ามี Bullet Warnings ไหม · ดู
(N queries)ใน log - เจอปัญหา → แปะ log หรือคำเตือนของ bullet ให้ AI ดูตรง ๆ ได้ผลกว่าสั่งลอย ๆ ว่า "ทำให้เร็วขึ้น"
🎯 ภารกิจ — หา N+1 ตัวใหม่ด้วยตัวเอง (~10 นาที)
คราวนี้เป็นหน้าหมวด · เพื่อนอยากให้หน้า /categories โชว์ชื่อบทความในแต่ละหมวดด้วย · เริ่มจากโค้ดสะอาด: git restore . ก่อน (คืนทุกไฟล์ที่ทดลองไว้ข้างบน)
- เปิด
app/views/categories/_category.html.erbเพิ่มก้อนนี้ก่อน</div>บรรทัดสุดท้าย:
(<div> <strong class="block font-medium mb-1">Posts:</strong> <%= category.posts.map(&:title).join(", ") %> </div>category.posts= บทความทุกชิ้นในหมวดนี้ ·map(&:title)= เอาแต่ชื่อ (บท 92) ·join(", ")= ต่อเป็นข้อความเดียวคั่นด้วยจุลภาค) - เปิด
http://localhost:3000/categories· ดูมุมซ้ายล่าง และดู log ของใบสั่งนี้ (หน้าต่างbin/devหรือlog/development.log— หาบรรทัดStarted GET "/categories"แล้วอ่านลงมา) - โดยไม่ดูเฉลย: จดตัวเลข
(N queries)· หาบรรทัดที่ซ้ำ ๆ · ตาม↳ไปหาว่าบรรทัดไหนใน view เป็นต้นเหตุ · แล้วตัดสินใจเองว่าต้องแก้ไฟล์ไหน แก้เป็นอะไร - แก้ · รีเฟรช · นับใหม่ · เสร็จแล้ว
git restore .คืนทุกไฟล์
ก่อนกดเฉลย: ก่อนแก้กี่ query · หลังแก้กี่ query · และคุณแก้ที่ไฟล์ไหน?
ก่อนแก้ (ผลจริง · ตัดบางบรรทัด):
Category Exists? (0.7ms) SELECT 1 AS one FROM "categories" LIMIT 1 …
Category Load (0.6ms) SELECT "categories".* FROM "categories" …
Post Load (0.6ms) SELECT "posts".* FROM "posts" WHERE "posts"."category_id" = 1 …
↳ app/views/categories/_category.html.erb:13:in 'Enumerable#map'
Post Load (0.7ms) SELECT "posts".* FROM "posts" WHERE "posts"."category_id" = 2 …
↳ app/views/categories/_category.html.erb:13:in 'Enumerable#map'
… (category_id = 3, 4, 5 เหมือนกัน)
Completed 200 OK in 16ms (Views: 10.7ms | ActiveRecord: 4.4ms (7 queries, 0 cached) | GC: 0.2ms)
Post Load ซ้ำ 5 บรรทัด ต่างกันแค่เลขหมวด = N+1 · _category.html.erb:13 ลบหนึ่ง = บรรทัด 12 = category.posts.map… ที่เพิ่งเติม ·
(in 'Enumerable#map' = "เกิดระหว่าง map") · bullet ขึ้น USE eager loading detected Category => [:posts] · Add to your query: .includes([:posts])
แก้ที่ controller app/controllers/categories_controller.rb action index:
@categories = Category.includes(:posts)
หลังแก้ (ผลจริง):
Post Load (0.7ms) SELECT "posts".* FROM "posts" WHERE "posts"."category_id" IN (1, 2, 3, 4, 5) …
Completed 200 OK in 12ms (Views: 7.6ms | ActiveRecord: 3.1ms (3 queries, 0 cached) | GC: 0.0ms)
7 → 3 query และกล่อง Bullet Warnings หายไป · ไม่ได้แตะ _category.html.erb เลย
ถ้าเจออาการนี้ แปลว่า…
- หน้า error สีแดง
Association named 'post' was not found on Category; perhaps you misspelled it?— คุณเขียนincludes(:post)· หมวดหนึ่งมีหลายบทความ model จึงเขียนhas_many :posts(มี s) · ชื่อใน includes ต้องตรงกับใน model - ถ้าลองเปลี่ยนไปโชว์แค่จำนวน (
category.posts.size) แล้ว bullet ขึ้นNeed Counter Cache with Active Record size— เป็นคำแนะนำอีกเรื่อง (เก็บตัวเลขจำนวนไว้บนแฟ้มหมวดเลย) ยังไม่ต้องทำตอนนี้ · และถ้า includes แล้วใช้sizeผมได้AVOID eager loading detected("หยิบมาล่วงหน้าแต่ไม่ได้ใช้") กลับมาแทน — bullet เดาผิดได้ ให้เชื่อตัวเลข(N queries)ใน log เป็นหลัก - ตัวเลขไม่ลดเลย — คุณอาจแก้
posts_controller.rbแทนcategories_controller.rb· ดูบรรทัดProcessing by …Controller#indexใน log ว่าใบสั่งนี้เป็นของ controller ไหน
ทดสอบความเข้าใจ
บรรทัดไหนเปิดช่อง XSS ถ้า post.title มาจากที่ผู้ใช้พิมพ์? (ดัดแปลงจาก Quiz A ข้อ 1 สไลด์หน้า 19)
ตัวเลือกที่ 3 ถูก — ผลจริงจาก curl: ทั้งสองแบบส่ง <script> ตัวจริงออกไป และกล่อง alert เด้งใน Chrome · สองคำนี้คือของชิ้นเดียวกัน (raw = to_s.html_safe)
ตัวเลือกที่ 1 กลับด้าน — <%= %> ธรรมดาคือแบบที่ปลอดภัยที่สุด มัน escape ให้ ·
ตัวเลือกที่ 2 ตัวหนังสือ alert('XSS!') ที่เหลืออยู่ไม่ได้รัน เพราะแท็ก script ถูกถอดแล้ว ·
ตัวเลือกที่ 4 ผิดเพราะ Rails escape ให้จนกว่าเราจะสั่งให้หยุด — raw/html_safe คือคำสั่งให้หยุด
จุดที่ต้องจำ: ค่าเริ่มต้นปลอดภัย · อันตรายเกิดเมื่อพิมพ์ raw หรือ html_safe เอง → ค้นสองคำนี้ก่อนเสมอ
ส่งข้อความ <a href="/posts" onclick="alert(1)">ร้านอื่น</a> เข้า sanitize แบบไม่ตั้งค่าอะไรเพิ่ม · ได้อะไรออกมา?
ตัวเลือกที่ 2 ถูก — ผลจริงใน console: "<a href=\"/posts\">ร้านอื่น</a>" · รายชื่อที่อนุญาตมีทั้งแท็กและช่องในแท็ก (attribute) · href อยู่ในรายชื่อ onclick ไม่อยู่
ตัวเลือกที่ 1 ลืมว่าตรวจระดับช่องด้วย ไม่ใช่แค่ระดับแท็ก ·
ตัวเลือกที่ 3 คือสิ่งที่เกิดกับแท็กที่ไม่อยู่ในรายชื่อ เช่น <script> · <a> อยู่ในรายชื่อจึงรอด ·
ตัวเลือกที่ 4 — sanitize ไม่เคย error มันแค่ถอดของที่ไม่อนุญาตออกเงียบ ๆ
จุดที่ต้องจำ: sanitize = เก็บเฉพาะของในรายชื่อ (allowlist) ถอดที่เหลือ · ใช้เมื่อต้องการ HTML จากผู้ใช้จริง ๆ เท่านั้น
partial นี้ (ดัดแปลงจาก Quiz A ข้อ 3 สไลด์หน้า 19) ถูก render @posts เรียกในหน้ารวม · ข้อไหนบอกปัญหาสองข้อได้ถูก?
<h2><%= @post.title %></h2> · <small><%= @post.category.name %></small>
ตัวเลือกที่ 1 ถูก — ① ในหน้ารวม controller วางแค่ @posts (มี s) บนถาด · @post ไม่มีใครวาง = nil → พัง (บท 123: แม่พิมพ์ควรใช้ของที่ยื่นใส่มือ ไม่ควานหาบนถาด) ·
② .category.name ทุกรอบ = เดินไปตู้ทุกบทความ แก้ที่ controller ด้วย includes(:category)
ตัวเลือกที่ 2 — <%= %> escape ให้เสมอ และ partial ใส่แท็กอะไรก็ได้ ·
ตัวเลือกที่ 3 — @post ไม่เกี่ยวกับ XSS และ raw คือตัวเปิดช่อง XSS ไม่ใช่ตัวแก้ ·
ตัวเลือกที่ 4 — partial ขึ้นต้นด้วย _ ไม่ใช่ @ · และ includes ต้องอยู่ที่ใบเบิกใน controller ไม่ใช่ใน view
จุดที่ต้องจำ: partial ใช้ตัวแปรที่ยื่นมา (post) · เติมจุดใน partial แล้วไปเช็ค includes ที่ controller
หน้า /posts 30 บทความ log ขึ้น (32 queries, 25 cached) และมีบรรทัด CACHE Category Load เยอะมาก · เพื่อนบอก "cached ตั้ง 25 ตัว ไม่ได้ไปตู้จริง ปล่อยไว้ได้" · ข้อไหนตอบเพื่อนได้ถูก?
ตัวเลือกที่ 4 ถูก — ผลจริงหลังเติม includes(:category): (3 queries, 0 cached) · ความจำ CACHE อยู่แค่ในใบสั่งเดียว และช่วยเฉพาะตอนหมวดซ้ำ · ถ้าหมวดไม่ซ้ำกันเลยก็เดินจริงทุกรอบ
ตัวเลือกที่ 1 — CACHE ไม่ได้แก้ N+1 แค่บังให้ดูเบาลงในข้อมูลชุดนี้ · ตัวเลือกที่ 2 — partial เรียกลิ้นชักอื่นได้ ปัญหาอยู่ที่ใบเบิกไม่ได้บอกล่วงหน้า แก้ที่ controller โดยไม่ต้องตัดข้อมูลที่ผู้ใช้อยากเห็น · ตัวเลือกที่ 3 — ตัวเลขใน log เชื่อได้ดีอยู่แล้ว (มันแยก cached ให้ดูด้วยซ้ำ) ปิด cache ไม่ได้แก้อะไร
จุดที่ต้องจำ: เห็น … Load ซ้ำ ๆ ต่างกันแค่เลข (มี CACHE หรือไม่ก็ตาม) = N+1 → includes ที่ controller
🧹 ก่อนไปบทถัดไป: git restore . คืนทุกไฟล์ที่ทดลอง · git status ต้องไม่เหลือไฟล์ที่ถูกแก้ ·
ถ้ายังไม่ได้ลบบทความ XSS ให้ลบใน console ด้วย (ข้อมูลใน DB ไม่ถูก git restore คืนให้) · บทความต้องกลับมาเป็น 30 ชิ้น
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง (Rails 8.1.4 · แอปของคอร์ส) | ต้องสนใจไหม |
|---|---|---|---|
| 17 | ตัวอย่างชื่อโพสต์ <script>fetch('/evil?c='+document.cookie)</script> | บทนี้ใช้ alert('XSS!') แทน · ผลเดียวกันคือ script ได้รันในเบราว์เซอร์คนอื่น | ไม่ต้อง — อย่าลองโค้ดขโมยข้อมูลกับเว็บจริง |
| 17 | <%= sanitize post.body %> | แอปของคอร์สไม่มีช่อง body มีแค่ title กับ description · พิมพ์ตามได้ undefined method 'body' for an instance of Post | สนใจ ถ้าพิมพ์ตาม — ใช้ description |
| 17 | อันตรายเกิด "เมื่อพิมพ์ raw หรือ html_safe เท่านั้น" | ถ้าเพิ่ม script ลงรายชื่อของ sanitize เอง (tags: %w[script]) script ก็ผ่าน (ลองแล้ว) | สนใจนิดหนึ่ง — อย่าแก้ allowlist โดยไม่จำเป็น |
| 18 | Post.limit(20) · "21 query → 2 query" | หน้า scaffold ของเรามี @posts.any? ถามเพิ่ม 1 · วัดจริงด้วย limit(20): 22 queries, 15 cached → 3 queries · ไม่ใส่ limit (30 ชิ้น): 32 → 3 | ไม่ต้อง — ทิศเดียวกัน ดูว่าเลขไม่โตตามข้อมูลก็พอ |
| 18 | "ใส่ gem bullet ไว้ตั้งแต่วันแรก" | repo ของคอร์สติดตั้งและเปิดไว้แล้วใน development (log + กล่องบนหน้าเว็บ) ไม่ต้อง bundle add | ไม่ต้อง |
| 19 | ข้อ 3 มี @post.description[0..80] | Ruby ตัดตามตัวอักษร ไม่ใช่ byte · ภาษาไทยไม่พัง (ลองแล้ว) แต่ตัดกลางคำ/ตัดวรรณยุกต์ทิ้งได้ · ตัดข้อความให้สวยใช้ helper ใน บท 124 | ไม่ต้อง |
อ้างอิง
- Rails Guides — Securing Rails Applications § Cross-Site Scripting (XSS) — อ่านถึงหัวข้อย่อย HTML/JavaScript Injection Countermeasures แล้วหยุด · ยืนยันว่า escape ตอนโชว์คือทางป้องกันหลัก
- Rails API — OutputSafetyHelper#raw — อ่านแค่ย่อหน้าเดียว ที่เตือนว่าไม่ควรใช้กับข้อมูลจากผู้ใช้
- Rails API — String#html_safe — ยืนยันว่ามัน "ทำเครื่องหมายว่าเชื่อถือได้" ไม่ได้ทำความสะอาดอะไร · อ่านสั้น ๆ
- Rails API — SanitizeHelper#sanitize —
อ่านกล่อง Warning เรื่องการเพิ่มแท็กลง allowlist และตัวอย่าง option
tags:·attributes: - MDN — Cross-site scripting (XSS) — ภาพรวมฝั่งเบราว์เซอร์ · อ่านส่วนที่พูดถึง stored (persistent) XSS และ output encoding พอ
- Rails Guides — Active Record Query Interface § N + 1 Queries Problem — ทวนบท 114 · อ่านต่อถึง Solution to N + 1 Queries Problem แล้วหยุด
- GitHub — flyerhzm/bullet —
อ่านแค่ส่วน Configuration ดูความหมายของ
Bullet.rails_loggerกับBullet.add_footerที่ repo ของคอร์สเปิดไว้ - Rails Guides — Caching with Rails § SQL Caching —
ที่มาของบรรทัด
CACHE … Load· อ่านแค่ย่อหน้าแรก: ความจำนี้อยู่แค่ช่วงหนึ่ง request