🔁 ทวนก่อนเริ่ม — คำพวกนี้จะโผล่ในบทนี้
- blob · attachment (บท 138) — blob = ใบรับฝาก (แถวในตาราง
active_storage_blobs: ชื่อไฟล์ ขนาด ชนิด และkey= ชื่อของในโกดัง) · attachment = ป้ายว่าใบรับฝากใบนี้เป็นของแฟ้มไหน (ตารางactive_storage_attachments) · ตัวไฟล์จริงอยู่ใน MinIO - direct upload (บท 139) — ตอนกดบันทึก เบราว์เซอร์ขอใบอนุญาตจาก Rails ก่อน แล้วเอาไฟล์ไปฝาก MinIO เอง · ฟอร์มส่งให้ Rails แค่รหัสของไฟล์
- N+1 ·
includes· bullet (บท 114 · บท 125) — เดินไปตู้ทีละแผ่น vs ใบเบิกทีเดียวทั้งตะกร้า · bullet = ผู้ช่วยที่ตะโกนบอกใน log - validation (บท 110) — กฎใน model ที่ต้องผ่านก่อนบันทึก · ไม่ผ่าน = ไม่บันทึก + ข้อความใน
errors - console (บท 107) —
bin/rails consoleคุยกับแอปทีละบรรทัด
🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 18 · libvips 8.18.7 · image_processing 1.14.0 · marcel 1.2.1 · MinIO (image pgsty/silo) · Chrome (สั่งด้วยสคริปต์) — สไลด์และ repo ของอาจารย์เขียน Ruby 4.0.5–4.0.6 · Rails 8.1.3 เรื่องในบทนี้ไม่ต่างกัน
📍 โค้ดเริ่มจาก: ตอนจบบท 138 (มีรูปปก + direct upload) และคืนการทดลองของบท 139 แล้ว (git status --short ว่าง) ·
บทนี้เก็บโค้ดไว้จริง — จบบทจะแก้ 5 ไฟล์: _post.html.erb · posts_controller.rb · config/application.rb · post.rb · _form.html.erb
🖥 เปิดค้างไว้: docker compose up -d (Postgres + MinIO) · bin/dev หนึ่งหน้าต่าง (เว็บที่ localhost:3000 + ตัว build CSS) · อีกหน้าต่างไว้พิมพ์คำสั่ง/เปิด console ·
ผลรันทุกกล่องมาจากเครื่องจริง · เวลา ms · วันเวลา · id · key ของไฟล์ (ตัวอักษรสุ่มยาว ๆ) · ลายเซ็นใน URL · ขนาดไฟล์รูปของคุณ จะไม่ตรงกับในบท
เตรียมของ: บทความที่มีรูปปก 2–3 ชิ้น
ทั้งบทเราดูหน้ารวม /posts · ถ้ายังไม่มีบทความไหนมีรูปปก ให้เปิดหน้าแก้ไขบทความที่อยู่บนสุด 2–3 ชิ้น แล้วเลือกรูป (png · jpg · webp อะไรก็ได้ในเครื่อง) กดบันทึก แบบที่ทำในบท 138 ·
ของผมแนบไว้ 3 ชิ้น: บทความ 30 (cover1.jpg ขนาด 1600×1000 หนัก 618,620 byte) · 29 · 28
ตอนนี้หน้ารวมยังไม่โชว์รูปเลย — บท 138 ใส่รูปปกไว้แค่ในหน้าบทความเดี่ยว (/posts/30) เป็นรูปเต็มขนาด · บทนี้จะเอารูปมาแปะบนการ์ดในหน้ารวม
รูปต้นฉบับใหญ่เกินไปสำหรับการ์ด — variant
รูปที่ผู้ใช้อัปมักใหญ่มาก (รูปจากมือถือกว้างหลายพันพิกเซล หนักหลาย MB) · ถ้าการ์ดในหน้ารวมใช้ไฟล์ต้นฉบับ ผู้ใช้ต้องโหลดรูปใหญ่ 10 รูปเพื่อดูภาพเล็ก ๆ 10 ภาพ · ทางแก้คือทำรูปย่อ — ไฟล์ใหม่ที่เล็กลง ทำจากรูปเดิม แล้วให้การ์ดใช้ไฟล์นั้น
Active Storage ทำรูปย่อให้เองด้วยคำสั่ง variant (แปลว่า "แบบหนึ่งของของชิ้นเดิม") · เราไม่ต้องเก็บไฟล์ย่อเอง ไม่ต้องเพิ่มคอลัมน์ ·
สั่งแค่ใน view ว่า "ขอแบบย่อไม่เกิน 400×300" — ครั้งแรกที่มีคนขอ Rails ย่อแล้วเก็บไว้ใน MinIO · ครั้งต่อไปหยิบอันเดิม
รูปต้นฉบับคือปลาทั้งตัวที่ลูกค้าเอามาฝากไว้ในโกดัง · หน้าบทความเดี่ยวเสิร์ฟทั้งตัวได้ · แต่การ์ดในหน้ารวมเป็นจานเล็ก ต้องการแค่ชิ้นพอดีคำ · ครั้งแรกที่มีโต๊ะสั่ง คนครัวไปหยิบปลาจากโกดังมาหั่น แล้วเอาชิ้นที่หั่นแล้วไปเก็บในโกดังด้วย · โต๊ะต่อไปสั่งเมนูเดิม ไม่ต้องหั่นใหม่ หยิบชิ้นเดิมได้เลย · ปลาทั้งตัวยังอยู่ครบ วันหลังอยากได้ชิ้นขนาดอื่นก็หั่นจากตัวเดิมได้อีก
ที่ไม่เหมือนกัน: ร้านจริงหั่นเตรียมไว้ตั้งแต่ก่อนเปิดร้าน · Rails ไม่หั่นล่วงหน้า รอให้มีคนขอครั้งแรกก่อน — ลูกค้าคนแรกเลยรอนานกว่าคนอื่น (ส่วน Rails รู้ได้ยังไงว่าชิ้นไหนหั่นแล้ว เดี๋ยวเห็นใน log ในหัวข้อ "ครั้งแรกหั่น ครั้งต่อไปหยิบ")
เทียบของที่รู้: ถ้าเคยใช้ <Image> ของ Next.js — มันทำแบบเดียวกัน คือย่อรูปตอนมีคนขอครั้งแรกแล้วเก็บไว้ ·
และอีกเดี๋ยวจะเห็นว่า "สูตรการย่อ" ถูกฝังอยู่ใน URL ของรูป แบบเดียวกับเลขรุ่นบนชื่อไฟล์ CSS (fingerprinting บท 127)
เพิ่มรูปย่อในการ์ด
เปิด app/views/posts/_post.html.erb · ไฟล์นี้มีสองกิ่ง: if full = หน้าบทความเดี่ยว · else = การ์ดในหน้ารวม ·
เพิ่ม 3 บรรทัด (comment · image_tag · บรรทัดว่าง) ใต้ <% else %> พอดี (บรรทัดที่ขึ้นต้น + คือของใหม่ ไม่ต้องพิมพ์ตัว +):
<%# app/views/posts/_post.html.erb (ช่วงกิ่ง else) %>
<% else %>
+ <%# รูปย่อไม่เกิน 400x300: สร้างครั้งแรกที่มีคนขอ แล้วเก็บไว้ใน MinIO ใช้ซ้ำ %>
+ <%= image_tag post.cover.variant(resize_to_limit: [ 400, 300 ]), alt: "", class: "rounded-box self-start" if post.cover.attached? %>
+
<h2 class="card-title">
<%= link_to post.title, post, class: "link link-hover" %>
</h2>
image_tag …— helper ที่สร้างแท็ก<img>(บท 124) · ส่งรูปปกให้มันได้ตรง ๆ แบบที่บท 138 ทำในหน้าเดี่ยวpost.cover.variant(…)— "ขอรูปปกแบบย่อ" · ยังไม่ย่ออะไรตอนนี้ แค่จดสูตรไว้resize_to_limit: [ 400, 300 ]— สูตร: ย่อให้กว้างไม่เกิน 400 และสูงไม่เกิน 300 โดยรักษาสัดส่วน (รูป 1600×1000 ได้ 400×250 ไม่ใช่ 400×300) · รูปที่เล็กกว่านี้อยู่แล้วไม่ถูกขยาย · เว้นวรรคใน[ 400, 300 ]เพราะ rubocop (ตัวตรวจรูปแบบโค้ดที่โปรเจกต์ติดมา) ชอบแบบนี้ สไลด์เขียนชิดก็ทำงานเหมือนกันalt: ""— รูปตกแต่ง ไม่มีคำบรรยาย (โปรแกรมอ่านจอจะข้ามไป) ·rounded-box= มุมมนของ daisyUI (บท 128)self-start— คลาส Tailwind สำคัญ: การ์ดของ daisyUI เรียงของในแนวตั้งแล้วยืดทุกชิ้นให้เต็มความกว้าง · ไม่ใส่ = รูป 400px ถูกยืดเป็น 720px แตก ๆ (วัดจริงใน Chrome) · ใส่แล้วรูปกว้างตามจริง 400px · ถ้าใส่แล้วรูปยังกว้างเต็มการ์ด = ตัว build CSS ในbin/devไม่ได้เปิดอยู่ (คลาส Tailwind ใหม่ต้องถูก build ก่อนถึงจะมีผล · บท 128)if post.cover.attached?— โชว์เฉพาะบทความที่มีรูป · บทความไม่มีรูปไม่ต้องมีแท็กรูปเปล่า ๆ (ifท้ายบรรทัด = "ทำบรรทัดนี้ก็ต่อเมื่อ…")
เซฟแล้วรีเฟรช http://localhost:3000/posts → การ์ด 3 ใบบนสุดมีรูปขนาด 400×250 อยู่เหนือหัวข้อ · ดูว่าแท็กรูปหน้าตาเป็นยังไง (ผลจริง ตัดมาใบแรกใบเดียว):
$ curl -s localhost:3000/posts | grep '<img'
<img alt="" class="rounded-box self-start" src="http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MSwicHVyIjoiYmxvYl9pZCJ9fQ==--43aa658d7525d1cd4db04eae95f0f904e19f481e/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGciLCJyZXNpemVfdG9fbGltaXQiOls0MDAsMzAwXX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==--7a3118b7de5256f591925063f8e4b05374960466/cover1.jpg" />
…
URL ยาวน่ากลัว แต่แบ่งได้เป็นท่อน ๆ คั่นด้วย / · ท่อนตัวอักษรยาว ๆ เป็นข้อความที่ถูกแปลงรหัส (base64 = วิธีเขียนข้อมูลด้วยตัวอักษรที่ใส่ใน URL ได้) — แกะออกมาดูได้ (ผลจริง):
$ echo 'eyJfcmFpbHMiOnsiZGF0YSI6MSwicHVyIjoiYmxvYl9pZCJ9fQ==' | base64 -d
{"_rails":{"data":1,"pur":"blob_id"}}
$ echo 'eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJqcGciLCJyZXNpemVfdG9fbGltaXQiOls0MDAsMzAwXX0sInB1ciI6InZhcmlhdGlvbiJ9fQ==' | base64 -d
{"_rails":{"data":{"format":"jpg","resize_to_limit":[400,300]},"pur":"variation"}}
/rails/active_storage/representations/redirect/— URL ของ Rails เอง ไม่ใช่ของ MinIO · เบราว์เซอร์ขอรูปจาก Rails ก่อน แล้ว Rails ค่อยบอกทางไป MinIO (redirect · บท 139)- ท่อนแรก
{"data":1,"pur":"blob_id"}— "ใบรับฝากเลขที่ 1" (blob id ของรูปปกบทความ 30) - ท่อนที่สอง — สูตรการย่อ: ชนิด jpg · ไม่เกิน 400×300 ·
purย่อมาจาก purpose (ใช้ทำอะไร) --43aa…ต่อท้ายแต่ละท่อน — ลายเซ็น ของ Rails (บท 139) · คนนอกแก้ตัวเลขในท่อนข้างหน้าแล้วลายเซ็นจะไม่ตรง Rails ไม่ยอม · ใครจะขอสูตรแปลก ๆ เองไม่ได้ (เช่นขอย่อเป็น 99999×99999 ให้เซิร์ฟเวอร์ทำงานหนัก)
สูตรอยู่ใน URL → เปลี่ยนสูตร = URL ใหม่ = รูปย่อชิ้นใหม่ · ไม่มีใครต้องตามไปลบรูปย่อแบบเก่า — แนวคิดเดียวกับ fingerprinting และจะกลับมาอีกครั้งในเรื่อง cache (บล็อก F)
- variant · resize_to_limit
- variant = ไฟล์แบบแปลงแล้ว (ย่อ · ครอป · เปลี่ยนชนิด) ที่ Active Storage ทำจากไฟล์ต้นฉบับ ·
resize_to_limit= ย่อให้อยู่ในกรอบ รักษาสัดส่วน ไม่ขยายรูปเล็ก · พิมพ์บอก AI ได้ว่า "show a thumbnail with cover.variant(resize_to_limit: [400, 300]) instead of the original file" - lazy processing (on-demand)
- ทำงานตอนมีคนขอครั้งแรก ไม่ทำล่วงหน้า · ข้อดี: ไม่ทำรูปย่อที่ไม่มีใครดู · ข้อเสีย: คนแรกรอ · ถาม senior ได้ว่า "รูปย่อของเราสร้างตอนอัป หรือตอนมีคนเปิดครั้งแรก — ถ้าคนแรกรอนานควรย้ายไป background job ไหม"
คนหั่นรูปคือ libvips — ต้องติดตั้งในเครื่อง
Rails ไม่ได้ย่อรูปเอง · มันส่งงานต่อให้ gem image_processing ซึ่งเรียกโปรแกรมย่อรูปที่ชื่อ libvips อีกที ·
gem มีในโปรเจกต์แล้วตั้งแต่ rails new (ผลจริง):
$ grep -n -B1 image_processing Gemfile
42-# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
43:gem "image_processing", "~> 1.2"
$ vips --version
vips-8.18.7
- บรรทัด 43 — gem มีอยู่แล้ว ไม่ต้อง
bundle add· บรรทัด 42 เป็น comment ที่ Rails เขียนมาให้ (ชี้ไปเอกสารเรื่องเดียวกับบทนี้) vips --version— ถามว่าเครื่องมี libvips ไหม · ขึ้นเลขรุ่น = มี · ขึ้นcommand not found= ยังไม่มี
libvips ไม่ใช่ gem แต่เป็นโปรแกรมของระบบปฏิบัติการ ต้องติดตั้งแยก: macOS brew install vips · Ubuntu/Debian sudo apt install libvips42 (ชื่อตามคู่มือของ ruby-vips) ·
ส่วนบนเซิร์ฟเวอร์จริง Dockerfile (ไฟล์สูตรสร้างกล่องของแอปตอนเอาขึ้นเซิร์ฟเวอร์ — rails new สร้างให้) มีบรรทัด apt-get install … libvips … อยู่แล้ว
⚠️ ถ้ารูปย่อในหน้ารวมไม่ขึ้นและ log มีคำว่า vips ให้เช็ค vips --version ก่อนอย่างอื่น · (ผมลองอาการ "ไม่มี libvips" จริงไม่ได้ เพราะเครื่องที่ใช้ตรวจมีอยู่แล้ว จึงไม่มีข้อความ error ให้ดู)
รูปปกพา N+1 กลับมา — with_attached_cover
รีเฟรชหน้ารวมแล้วเปิด log/development.log (บท 113) ดูใบสั่ง GET "/posts" · ผลจริง (ตัด comment /*…*/ ท้าย SQL · ตัดบรรทัด Rendered … และ ↳ ส่วนใหญ่ · ย่อแถวที่ซ้ำและท่อนกลาง SQL เป็น … · กล่อง bullet ตัดเหลือ 3 บรรทัด):
Started GET "/posts" for ::1 at 2026-11-10 10:15:07 +0700
Processing by PostsController#index as */*
Post Count (0.7ms) SELECT COUNT(*) FROM "posts"
Post Load (0.8ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT 10 OFFSET 0
Category Load (0.7ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" IN (5, 4, 3, 2, 1)
ActionText::RichText Load (0.9ms) SELECT "action_text_rich_texts".* FROM "action_text_rich_texts" WHERE … "record_id" IN (30, 29, 28, 27, 26, 25, 24, 23, 22, 21)
ActiveStorage::Attachment Load (0.8ms) SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = 30 AND "active_storage_attachments"."record_type" = 'Post' AND "active_storage_attachments"."name" = 'cover' LIMIT 1
↳ app/views/posts/_post.html.erb:25
ActiveStorage::Blob Load (0.8ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = 1 LIMIT 1
↳ app/views/posts/_post.html.erb:25
ActiveStorage::Attachment Load (0.7ms) … "record_id" = 29 …
ActiveStorage::Blob Load (0.7ms) … "id" = 2 …
ActiveStorage::Attachment Load (0.7ms) … "record_id" = 28 …
ActiveStorage::Blob Load (1.3ms) … "id" = 3 …
ActiveStorage::Attachment Load (1.7ms) … "record_id" = 27 …
… (record_id = 26, 25, 24, 23, 22, 21 อีกตัวละบรรทัด)
Completed 200 OK in 154ms (Views: 112.2ms | ActiveRecord: 16.3ms (17 queries, 0 cached) | GC: 28.9ms)
USE eager loading detected
Post => [:cover_attachment]
Add to your query: .includes([:cover_attachment])
- 4 บรรทัดแรก (
Post CountถึงRichText) — ของเดิมก่อนมีรูป ·Post Countมาจาก pagy (นับหน้า · บท 126) ·RichTextคือเนื้อหา rich text ที่with_rich_text_bodyหยิบมาทีเดียว (บท 137) ActiveStorage::Attachment Load … "record_id" = 30— "บทความ 30 มีป้ายรูปปกไหม" · ถามทีละบทความ ครบ 10 ใบ (ทั้งที่ 7 ใบไม่มีรูป ก็ต้องถามถึงจะรู้ว่าไม่มี)ActiveStorage::Blob Load … "id" = 1— มีป้าย → ไปหยิบใบรับฝากมาอีกรอบ · เกิด 3 ครั้งตามจำนวนบทความที่มีรูป↳ _post.html.erb:25— ต้นเหตุคือบรรทัด 24 ที่เราเพิ่งเพิ่ม (เลขใน log ของไฟล์.erbลบหนึ่ง · บท 125)(17 queries, …)— 4 + 10 + 3 = 17 · มีรูปมากขึ้น ตัวเลขยิ่งโต = N+1 ตัวเดิมจากบท 114- กล่อง
USE eager loading detected— bullet ตะโกนบอก ·cover_attachmentคือชื่อจริงของป้ายรูปปกที่has_one_attached :coverสร้างให้เบื้องหลัง
bullet แนะนำ .includes([:cover_attachment]) · แต่นั่นหยิบมาแค่ป้าย ยังต้องไปหยิบใบรับฝากอีกทีละใบ ·
Active Storage มีคำสั่งสำเร็จรูปที่หยิบให้ครบทั้งสองอย่าง ชื่อ with_attached_ + ชื่อไฟล์แนบ — แบบเดียวกับ with_rich_text_body ของบท 137
เปิด app/controllers/posts_controller.rb แก้ 2 บรรทัดใน def index (comment + บรรทัด @pagy) — - คือของเดิม + คือของใหม่:
def index
- # with_rich_text_body = ดึง body ของทั้งหน้ามาในคำสั่งเดียว (ไม่งั้น N+1 ทีละบทความ)
- @pagy, @posts = pagy(Post.includes(:category).with_rich_text_body.recent, limit: 10)
+ # with_rich_text_body / with_attached_cover = ดึง body และรูปปกของทั้งหน้ามาทีเดียว (ไม่งั้น N+1 ทีละบทความ)
+ @pagy, @posts = pagy(Post.includes(:category).with_rich_text_body.with_attached_cover.recent, limit: 10)
end
ของใหม่มีคำเดียว: .with_attached_cover ต่อท้าย with_rich_text_body = "หยิบป้ายรูปปกและใบรับฝากของทั้ง 10 บทความมาพร้อมกัน" ·
รีเฟรชแล้วดู log อีกรอบ (ผลจริง · ผมเปิดรูปย่อในเบราว์เซอร์ไปแล้วรอบหนึ่ง · ตัดมาเฉพาะส่วนรูปปก ต่อจาก 4 บรรทัดเดิม):
ActiveStorage::Attachment Load (0.7ms) SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_type" = 'Post' AND "active_storage_attachments"."name" = 'cover' AND "active_storage_attachments"."record_id" IN (30, 29, 28, 27, 26, 25, 24, 23, 22, 21)
ActiveStorage::Blob Load (0.7ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" IN (3, 2, 1)
ActiveStorage::VariantRecord Load (0.7ms) SELECT "active_storage_variant_records".* FROM "active_storage_variant_records" WHERE "active_storage_variant_records"."blob_id" IN (1, 2, 3)
ActiveStorage::Attachment Load (0.7ms) SELECT … WHERE "active_storage_attachments"."record_type" = 'ActiveStorage::Blob' AND "active_storage_attachments"."name" = 'preview_image' AND … IN (1, 2, 3)
ActiveStorage::Attachment Load (0.7ms) SELECT … WHERE "active_storage_attachments"."record_type" = 'ActiveStorage::VariantRecord' AND "active_storage_attachments"."name" = 'image' AND … IN (1, 2, 3)
ActiveStorage::Blob Load (0.8ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" IN (4, 5, 6)
Completed 200 OK in 44ms (Views: 27.0ms | ActiveRecord: 9.3ms (10 queries, 0 cached) | GC: 4.6ms)
AVOID eager loading detected
ActiveStorage::Blob => [:variant_records, :preview_image_attachment]
Remove from your query: .includes([:variant_records, :preview_image_attachment])
AVOID eager loading detected
ActiveStorage::VariantRecord => [:image_attachment]
Remove from your query: .includes([:image_attachment])
- สองบรรทัดแรก —
IN (30, 29, …)และIN (3, 2, 1)· ป้ายทั้ง 10 ใบ + ใบรับฝากทั้ง 3 ใบ มาในรอบเดียว · N+1 หายแล้ว - แต่มีอีก 4 บรรทัดที่เราไม่ได้สั่ง:
variant_records(สมุดจดรูปย่อ — อธิบายข้างล่าง) ·preview_image(รูปตัวอย่างของไฟล์ที่ไม่ใช่รูป เช่นหน้าแรกของ PDF — ของเราไม่มี) · ป้ายimageของ variant · ใบรับฝากIN (4, 5, 6)ของรูปย่อ AVOID eager loading detected— bullet เตือนกลับด้าน: "หยิบมาแล้วไม่ได้ใช้ เอาออก" · 4 + 6 = 10 query (ถ้าคุณยังไม่เคยเปิดรูปย่อเลยจะได้ 8)
ของเกินมาจากไหน — track_variants
ค่าเริ่มต้นของ Rails จดทุกรูปย่อที่ทำแล้วลงตาราง active_storage_variant_records (เห็นตอนรูปย่อถูกสร้างครั้งแรก มีบรรทัด INSERT INTO "active_storage_variant_records" ใน log) ·
และเพราะมีตารางนี้ with_attached_cover จึงหยิบข้อมูลรูปย่อมาด้วยเผื่อไว้ · หน้ารวมของเราไม่ได้ใช้ (มันแค่สร้าง URL ซึ่งไม่ต้องรู้ว่าย่อแล้วหรือยัง) จึงเป็นของเกิน
ปิดการจดได้ในบรรทัดเดียว · เปิด config/application.rb เพิ่ม 5 บรรทัด (บรรทัดว่าง · comment 3 · ค่าตั้ง 1) ใต้ config.time_zone:
config.time_zone = "Asia/Bangkok"
+
+ # ไม่จดรูปย่อ (variant) ลงตาราง active_storage_variant_records
+ # ถ้าจด (ค่าเริ่มต้น) with_attached_cover จะดึงตารางนั้นมาด้วยอีก 2-4 query ทั้งที่หน้า index ไม่ได้ใช้
+ # แลกกับ: ตอนเปิดรูปย่อ Rails ถาม MinIO ว่ามีไฟล์รูปย่อหรือยัง แทนการถาม DB
+ config.active_storage.track_variants = false
# config.eager_load_paths << Rails.root.join("extras")
config.active_storage.track_variants = false— "ไม่ต้องจดรูปย่อลงตาราง" · ตัวไฟล์รูปย่อยังถูกสร้างและเก็บใน MinIO เหมือนเดิม- comment บรรทัดสาม — ของที่แลกมา: ไม่มีสมุดจด Rails เลยต้องถาม MinIO ตรง ๆ ว่ารูปย่อมีหรือยัง (เดี๋ยวเห็นใน log)
⚠️ ไฟล์ใน config/ ถูกอ่านครั้งเดียวตอนเปิดเซิร์ฟเวอร์ · ต้องหยุด bin/dev (Ctrl+C) แล้วเปิดใหม่ ไม่งั้นตัวเลขไม่เปลี่ยน
รีสตาร์ทแล้วรีเฟรชหน้ารวม (ผลจริง · ตัดเหลือส่วนที่ไปตู้):
Post Count (1.1ms) SELECT COUNT(*) FROM "posts"
Post Load (1.8ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC LIMIT 10 OFFSET 0
Category Load (1.3ms) SELECT "categories".* FROM "categories" WHERE "categories"."id" IN (5, 4, 3, 2, 1)
ActionText::RichText Load (1.3ms) SELECT "action_text_rich_texts".* FROM … "record_id" IN (30, 29, 28, 27, 26, 25, 24, 23, 22, 21)
ActiveStorage::Attachment Load (0.8ms) SELECT "active_storage_attachments".* FROM … "record_id" IN (30, 29, 28, 27, 26, 25, 24, 23, 22, 21)
ActiveStorage::Blob Load (1.0ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" IN (3, 2, 1)
Completed 200 OK in 39ms (Views: 30.0ms | ActiveRecord: 4.0ms (6 queries, 0 cached) | GC: 20.3ms)
17 → 10 → 6 query และไม่มีกล่อง bullet เลย · 6 คือ "ของเดิม 4 + ป้ายรูปปก 1 + ใบรับฝาก 1" — เพิ่มบทความหรือรูปอีกเท่าไรก็ยัง 6
ไม่มี with_attached_cover = คนจัดจานเดินไปตู้ถามทีละแฟ้มว่า "แฟ้มนี้มีบัตรรับฝากรูปไหม" (บท 114 อีกแล้ว) ·
ใส่แล้ว = หัวหน้าเชฟเขียนใบเบิกทีเดียว "เอาบัตรรับฝากของทั้ง 10 แฟ้มมาด้วย" ·
ค่าเริ่มต้นของ Rails ใบเบิกนั้นแถมสมุดจดว่าชิ้นไหนหั่นแล้วมาด้วยทุกครั้ง ทั้งที่จานในหน้ารวมไม่ได้เปิดดู — ปิด track_variants = เลิกใช้สมุดเล่มนั้น
ที่ไม่เหมือนกัน: ในร้านจริง เลิกจดสมุดแล้วก็แค่ไม่มีบันทึก · ใน Rails ของที่แลกมาคือทุกครั้งที่มีคนขอรูปย่อ Rails ต้องไปถามโกดัง (MinIO) ว่ามีชิ้นนั้นหรือยัง แทนการเปิดสมุด · ถามโกดังที่อยู่ข้างร้าน (เครื่องเดียวกัน) เร็วมาก แต่ถ้าโกดังอยู่ไกล (S3 ต่างประเทศ) อาจกลับมาคุ้มที่จะจดสมุด
with_attached_cover(with_attached_*)- scope ที่
has_one_attached :coverสร้างให้ =includesป้าย + ใบรับฝากของไฟล์แนบทีเดียว · ชื่อเปลี่ยนตามชื่อไฟล์แนบ (with_attached_avatar) · พิมพ์บอก AI ได้ว่า "the index shows cover thumbnails; add with_attached_cover to the query and check the log stays at a constant number of queries" - track_variants · AVOID eager loading
- track_variants = จดรูปย่อลงตาราง
active_storage_variant_records(ค่าเริ่มต้น true) · AVOID eager loading = bullet บอกว่าหยิบมาล่วงหน้าแต่ไม่ได้ใช้ · ถาม senior ได้ว่า "เราควรเปิด track_variants ไหม storage ของเราอยู่ไกลแค่ไหน"
ครั้งแรกหั่น ครั้งต่อไปหยิบ — ดูใน log
หลังรีสตาร์ท รูปย่อถูกสร้างใหม่อีกรอบหนึ่ง — ปิดสมุดแล้ว Rails ตั้งชื่อชิ้นย่อในโกดังแบบใหม่ เลยหาชิ้นเดิมไม่เจอ (เกิดครั้งเดียว) · เราเลยได้เห็นจังหวะ "ครั้งแรก" พอดี ·
ใน log หาใบสั่งที่ขึ้นต้น Started GET "/rails/active_storage/representations/redirect/…/cover1.jpg" (ผลจริง · URL ยาวผมย่อเป็น …):
Processing by ActiveStorage::Representations::RedirectController#show as JPEG
ActiveStorage::Blob Load (0.9ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = 1 LIMIT 1
S3 Storage (11.5ms) Checked if file exists at key: variants/6ims6h1zecgfmejk558tkfdd3vpy/6b68a4a4ccbcccc1886efb4cef49776906bf890a781e7937db17ff54b07368ca (no)
S3 Storage (6.9ms) Downloaded file from key: 6ims6h1zecgfmejk558tkfdd3vpy
S3 Storage (3.8ms) Uploaded file to key: variants/6ims6h1zecgfmejk558tkfdd3vpy/6b68a4a4ccbcccc1886efb4cef49776906bf890a781e7937db17ff54b07368ca
S3 Storage (1.0ms) Generated URL for file at key: variants/6ims6h1zecgfmejk558tkfdd3vpy/6b68… (http://localhost:9000/wongnok-dev/variants/6ims…/6b68…?…&X-Amz-Expires=300&…)
Redirected to http://localhost:9000/wongnok-dev/variants/6ims6h1zecgfmejk558tkfdd3vpy/6b68…?…
Completed 302 Found in 46ms (ActiveRecord: 0.6ms (1 query, 0 cached) | GC: 9.4ms)
S3 Storage— คุยกับ MinIO (MinIO พูดภาษาเดียวกับ S3 · บท 138) ·6ims6h…คือ key ของรูปต้นฉบับ (ของคุณเป็นตัวอักษรสุ่มอื่น)Checked if file exists at key: variants/6ims…/6b68… (no)— ① ถามโกดัง "มีชิ้นย่อหรือยัง" → ยังไม่มี · ชื่อชิ้นย่อ =variants/+ key ต้นฉบับ + รหัสของสูตรDownloaded file from key: 6ims…— ② โหลดปลาทั้งตัว (ต้นฉบับ) มาที่ Rails- (ระหว่างบรรทัด ② กับ ③ libvips ย่อรูป — ไม่มีบรรทัด log ของตัวเอง)
Uploaded file to key: variants/…— ③ เอาชิ้นที่หั่นแล้วไปเก็บในโกดังGenerated URL …+Redirected to http://localhost:9000/…+302 Found— ④ ออกบัตรผ่านที่หมดอายุใน 300 วินาที (บท 139) แล้วบอกเบราว์เซอร์ให้ไปรับที่โกดังเอง
รีเฟรชหน้ารวมอีกรอบ · เบราว์เซอร์ขอรูปย่อใบเดิมอีกครั้ง · ใน log ของใบสั่งรูปนั้นจะยังเห็นบรรทัด Downloaded กับ Uploaded ไหม? และน่าจะเร็วหรือช้ากว่าครั้งแรก?
ผลจริง (ใบสั่งรูปเดิม ครั้งที่สอง):
Processing by ActiveStorage::Representations::RedirectController#show as JPEG
ActiveStorage::Blob Load (1.8ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = 1 LIMIT 1
S3 Storage (2.7ms) Checked if file exists at key: variants/6ims6h1zecgfmejk558tkfdd3vpy/6b68a4a4ccbcccc1886efb4cef49776906bf890a781e7937db17ff54b07368ca (yes)
S3 Storage (0.8ms) Generated URL for file at key: variants/6ims6h1zecgfmejk558tkfdd3vpy/6b68… (http://localhost:9000/wongnok-dev/variants/6ims…/6b68…?…)
Redirected to http://localhost:9000/wongnok-dev/variants/6ims6h1zecgfmejk558tkfdd3vpy/6b68…?…
Completed 302 Found in 8ms (ActiveRecord: 1.3ms (1 query, 0 cached) | GC: 0.0ms)
ไม่มี Downloaded/Uploaded แล้ว · (yes) = มีชิ้นหั่นแล้ว → ออกบัตรผ่านแล้วส่งไปเลย · ของผม 46ms → 8ms (ของคุณจะต่าง แต่ครั้งที่สองควรเร็วกว่าชัดเจน) ·
ยังเห็น Checked if file exists ทุกครั้ง — นี่คือ "ของที่แลกมา" ของ track_variants = false
ชิ้นย่อที่เก็บไว้เล็กกว่าต้นฉบับมาก: ของผมต้นฉบับ cover1.jpg 1600×1000 หนัก 618,620 byte → ชิ้นย่อ 400×250 หนัก 17,583 byte (ดึงไฟล์ออกมาวัดจริง) ·
และต้นฉบับยังอยู่ครบ หน้าบทความเดี่ยวยังใช้รูปเต็ม
สไลด์บอกว่า "สร้างครั้งแรกจะช้า → ถ้าไม่อยากให้ผู้ใช้คนแรกรอ ให้สร้างล่วงหน้าใน background job" — background job (งานที่ทำเบื้องหลังโดยไม่ให้ผู้ใช้ยืนรอ) คือเรื่องของบล็อก E บท 142 · ตอนนี้ปล่อยให้คนแรกรอไปก่อน
content_type เชื่อไม่ได้ — ลองด้วยไฟล์ปลอมของเราเอง
เรื่องที่สอง: ตอนนี้ช่องรูปปกรับไฟล์อะไรก็ได้ · สไลด์หน้า 12 ให้โค้ดตรวจมา: ชนิดต้องเป็น PNG/JPEG/WebP และขนาดไม่เกิน 5 MB · ก่อนเชื่อโค้ดในสไลด์ เราลองโจมตีแอปของตัวเองดูก่อน
⚠️ การทดลองทั้งหมดในบทนี้ทำกับแอปในเครื่องของคุณเท่านั้น · ไฟล์ปลอมของเราเป็นแค่ข้อความธรรมดา ไม่มีอันตราย · ห้ามลองอัปไฟล์ปลอมกับเว็บของคนอื่น แม้จะเป็นไฟล์ไม่มีพิษก็ตาม
สร้าง "รูปปลอม" — ไฟล์ข้อความธรรมดา แต่ตั้งชื่อลงท้าย .png · เก็บไว้ในโฟลเดอร์ tmp/ ของโปรเจกต์ (git ไม่สนโฟลเดอร์นี้):
$ printf 'hello this is just text, not an image\n' > tmp/fake.png
printf '…' > ไฟล์ = เขียนข้อความลงไฟล์ · \n = ขึ้นบรรทัดใหม่ · ได้ไฟล์ 38 byte ที่ชื่อบอกว่าเป็นรูป แต่ข้างในเป็นตัวหนังสือ
เปิด app/models/post.rb แล้วพิมพ์โค้ดตามสไลด์หน้า 12 ก่อน (เป็นการทดลอง — อีกเดี๋ยวเราเปลี่ยนทิ้ง) · เพิ่ม 1 บรรทัดใต้ validates :body และ method ใหม่ใต้ private:
validates :body, presence: true
+ validate :cover_is_valid
…
private
def copy_body_to_plain_text
…
end
+
+ def cover_is_valid
+ return unless cover.attached?
+
+ unless cover.content_type.in?(%w[ image/png image/jpeg image/webp ])
+ errors.add(:cover, "รองรับเฉพาะ PNG, JPEG, WebP")
+ end
+
+ if cover.byte_size > 5.megabytes
+ errors.add(:cover, "ไฟล์ต้องไม่เกิน 5 MB")
+ end
+ end
validate :cover_is_valid—validate(ไม่มี s) = "ตรวจด้วย method ที่เราเขียนเอง" · ต่างจากvalidates(มี s) ที่ใช้กฎสำเร็จรูป (บท 110)return unless cover.attached?— "ถ้าไม่มีรูปปก ก็จบ method ตรงนี้เลย" (return= ออกจาก method ·unless= ถ้าไม่) · บทความไม่มีรูปไม่ต้องตรวจอะไรcover.content_type— ชนิดของไฟล์ที่จดไว้ในใบรับฝาก เขียนแบบimage/png(เรียกว่า MIME type — ป้ายชนิดไฟล์มาตรฐานของเว็บ).in?(%w[ … ])— "อยู่ในรายการนี้ไหม" ·%w[ a b ]= รายการคำ["a", "b"]เขียนย่อ (เคยเห็นในsanitizeบท 125)errors.add(:cover, "…")— แปะข้อความผิดไว้ที่ช่อง cover · มีข้อความเมื่อไร = บันทึกไม่ได้cover.byte_size > 5.megabytes— ขนาดไฟล์ (byte) มากกว่า 5 MB ไหม ·5.megabytes= 5,242,880
เปิดหน้าแก้ไขบทความที่ยังไม่มีรูปปก (ของผมคือ /posts/27/edit) · ช่องรูปปกเลือก tmp/fake.png · กดบันทึก · โค้ดของสไลด์ตรวจ content_type อยู่ — คิดว่าจะบันทึกได้ไหม?
บันทึกได้ — หน้าเด้งไปหน้าบทความ ขึ้นแถบเขียว "บันทึกการแก้ไขแล้ว" (ยืนยันใน Chrome) · ใน log (ผลจริง ตัดมาเฉพาะบรรทัดที่เกี่ยว):
Started POST "/rails/active_storage/direct_uploads" for ::1 at 2026-11-10 10:19:39 +0700
Parameters: {"blob" => {"filename" => "fake.png", "content_type" => "image/png", "byte_size" => 38, "checksum" => "i6qWQX4nSMSe4tJLtV293Q=="}, …}
ActiveStorage::Blob Create (2.7ms) INSERT INTO "active_storage_blobs" ("key", "filename", "content_type", "metadata", "service_name", "byte_size", "checksum", "created_at") VALUES ('iqj0qwd65ukk2uns8664ezy8m2lf', 'fake.png', 'image/png', NULL, 'minio', 38, 'i6qWQX4nSMSe4tJLtV293Q==', '2026-11-10 03:19:39.342756') RETURNING "id"
…
Started PATCH "/posts/27" for ::1 at 2026-11-10 10:19:39 +0700
ActiveStorage::Attachment Create (1.3ms) INSERT INTO "active_storage_attachments" ("name", "record_type", "record_id", "blob_id", "created_at") VALUES ('cover', 'Post', 27, 7, '2026-11-10 03:19:39.393646') RETURNING "id"
Completed 303 See Other in 48ms (ActiveRecord: 8.0ms (11 queries, 0 cached) | GC: 7.7ms)
"content_type" => "image/png"ในบรรทัดParameters— เบราว์เซอร์เป็นคนบอก และมันเดาจากนามสกุล.pngล้วน ๆ ไม่ได้เปิดดูข้างใน · (checksumข้าง ๆ คือค่าไว้เช็คว่าไฟล์ไม่เพี้ยนระหว่างส่ง ไม่ได้บอกชนิดไฟล์)INSERT INTO "active_storage_blobs" … 'image/png'— Rails จดลงใบรับฝากตามที่เบราว์เซอร์บอกAttachment Create … 'cover', 'Post', 27+303 See Other— ผ่าน validation · ติดป้ายเข้าบทความ 27 แล้ว
แล้วผลเสียคืออะไร? เปิดหน้ารวม — การ์ดบทความ 27 ไม่มีรูป (ช่องว่าง) · ใบสั่งของรูปย่อใบนั้นใน log (ผลจริง ตัดเหลือ 3 บรรทัดแรกของ error):
Completed 500 Internal Server Error in 8ms (ActiveRecord: 0.6ms (1 query, 0 cached) | GC: 0.2ms)
Vips::Error (VipsForeignLoad: "/var/folders/jc/…/T/ActiveStorage-7-20261110-94944-npebqo.png" is not a known file format
):
/var/folders/…/ActiveStorage-7-….png คือสำเนาชั่วคราวที่ Rails โหลดมาจาก MinIO ให้ libvips หั่น ·
libvips พยายามหั่น "ปลา" ที่เป็นกระดาษ → is not a known file format (ไม่รู้จักไฟล์ชนิดนี้) → 500 (error ฝั่งเซิร์ฟเวอร์) ·
ไฟล์ข้อความของเราไม่มีพิษ แต่แปลว่าไฟล์อะไรก็ตามที่ไม่มีลายเซ็นให้ Rails จับได้ (อย่างข้อความล้วน) ผ่านด่านของสไลด์ได้หมด — และเว็บเราเก็บไว้ให้แล้วด้วยป้าย image/png
ลบรูปปลอมทิ้งด้วย purge
เปิด bin/rails console แล้วลบรูปปกปลอมของบทความ 27 (ใช้เลขบทความของคุณ) · ผลจริง (ตัด comment ท้าย SQL):
wongnok-blog-all-in-one(dev):001> post = Post.find(27); nil
Post Load (0.5ms) SELECT "posts".* FROM "posts" WHERE "posts"."id" = 27 LIMIT 1
=> nil
wongnok-blog-all-in-one(dev):002> post.cover.filename
ActiveStorage::Attachment Load (1.7ms) SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE … "record_id" = 27 … "name" = 'cover' LIMIT 1
ActiveStorage::Blob Load (0.5ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = 7 LIMIT 1
=> #<ActiveStorage::Filename:0x000000012dbb0750 @filename="fake.png">
wongnok-blog-all-in-one(dev):003> post.cover.purge
TRANSACTION (1.7ms) BEGIN
ActiveStorage::Attachment Destroy (3.4ms) DELETE FROM "active_storage_attachments" WHERE "active_storage_attachments"."id" = 7
Post Update (1.2ms) UPDATE "posts" SET "updated_at" = '2026-11-10 03:21:22.332731' WHERE "posts"."id" = 27
TRANSACTION (1.3ms) COMMIT
TRANSACTION (0.7ms) BEGIN
ActiveStorage::Attachment Exists? (1.7ms) SELECT 1 AS one FROM "active_storage_attachments" WHERE "active_storage_attachments"."blob_id" = 7 LIMIT 1
ActiveStorage::Attachment Load (0.8ms) SELECT … "name" = 'preview_image' LIMIT 1
ActiveStorage::Blob Destroy (1.7ms) DELETE FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = 7
TRANSACTION (0.8ms) COMMIT
S3 Storage (16.1ms) Deleted file from key: iqj0qwd65ukk2uns8664ezy8m2lf
S3 Storage (3.2ms) Deleted files by key prefix: variants/iqj0qwd65ukk2uns8664ezy8m2lf/
=> nil
; nilท้ายบรรทัด 001 — แค่ให้ console ไม่พิมพ์บทความทั้งก้อนออกมา (ไม่ใส่ก็ได้)#<ActiveStorage::Filename:0x… @filename="fake.png">— กล่องห่อชื่อไฟล์ · ดูแค่"fake.png"ข้างใน = ใช่รูปปลอมของเรา (เลข0x…เป็นที่อยู่ในหน่วยความจำ ของคุณจะต่าง · บท 95)post.cover.purge— "ลบรูปปกทิ้งให้หมด" · log บอกว่ามันลบ 4 อย่าง ตามลำดับ: ป้าย (Attachment Destroy) → ใบรับฝาก (Blob Destroy) → ตัวไฟล์ใน MinIO (Deleted file from key) → รูปย่อทุกชิ้นของมัน (Deleted files by key prefix: variants/…/)Post Update … "updated_at"— ถอดป้ายรูปปกแล้ว Rails อัปเดตเวลาแก้ล่าสุดของบทความให้ด้วย (บทความ "เปลี่ยน" เพราะรูปปกหายไป)Attachment Exists? … "blob_id" = 7— ก่อนลบใบรับฝาก Rails เช็คว่าไม่มีแฟ้มอื่นใช้ไฟล์เดียวกันอยู่ · บรรทัดpreview_imageถัดมาคือหารูปตัวอย่าง (ของไฟล์ PDF ฯลฯ) ไปลบด้วย — ของเราไม่มี
สไลด์หน้า 13 มีโจทย์ "ลูกค้าโทรมาขอให้ลบรูปที่อัปผิด เราลบ attachment ใน DB แล้ว ต้องเช็คอะไรอีก" — คำตอบคือ 4 บรรทัดข้างบน: ลบแค่ป้ายไม่พอ ไฟล์ยังอยู่ในโกดัง และรูปย่อก็ยังอยู่ ·
purge ทำครบทั้ง 4 ให้
ตรวจจากเนื้อไฟล์ — magic bytes
ไฟล์รูปทุกชนิดมีลายเซ็นประจำตัวอยู่ไม่กี่ byte แรกของไฟล์ ที่โปรแกรมสร้างรูปเขียนไว้เสมอ · เรียกกันว่า magic bytes ·
ดูด้วยตาได้ (ใช้รูปจริงในเครื่องคุณสักรูป ก็อปมาเป็น tmp/real.png) · ผลจริง:
$ head -c 8 tmp/real.png | xxd
00000000: 8950 4e47 0d0a 1a0a .PNG....
$ head -c 8 tmp/fake.png | xxd
00000000: 6865 6c6c 6f20 7468 hello th
$ file tmp/fake.png tmp/real.png
tmp/fake.png: ASCII text
tmp/real.png: PNG image data, 1600 x 1000, 8-bit grayscale, non-interlaced
head -c 8 ไฟล์— อ่าน 8 byte แรก ·| xxd— โชว์ byte เป็นเลขฐานสิบหก (ซ้าย) กับตัวอักษร (ขวา)8950 4e47 …/.PNG....— ลายเซ็นของไฟล์ PNG ทุกไฟล์บนโลก (ตัว.คือ byte ที่พิมพ์ออกมาเป็นตัวอักษรไม่ได้)hello th— ไฟล์ปลอมขึ้นต้นด้วยตัวหนังสือ ไม่มีลายเซ็นของรูปชนิดไหนเลยfile— โปรแกรมที่มากับ macOS/Linux ดูลายเซ็นแล้วบอกชนิด · ไม่สนนามสกุลเลย ไฟล์ปลอมจึงเป็นASCII text(ข้อความธรรมดา) · ส่วน1600 x 1000, 8-bit grayscaleคือรายละเอียดรูปของผม ของคุณจะต่าง
ลูกค้าเอากล่องมาฝาก เขียนฉลากเองว่า "ปลา" (นามสกุล .png · content_type ที่เบราว์เซอร์ส่งมา) · โค้ดของสไลด์คือพนักงานที่อ่านฉลากแล้วเชื่อ ·
วิธีที่ถูกคือแง้มฝากล่องดู — ปลาทุกตัวมีหน้าตาของปลา (magic bytes) ปลอมฉลากได้ แต่ปลอมของข้างในยากกว่ามาก
ที่ไม่เหมือนกัน: เราแง้มดูแค่ปากกล่อง (ไม่กี่ KB แรก) ไม่ได้รื้อทั้งกล่อง · คนร้ายที่ตั้งใจจริงทำไฟล์ที่หัวเป็น PNG ถูกต้องแต่ท้ายไฟล์ซ่อนของอื่นได้ · การตรวจนี้กัน "ของผิดชนิด" ได้ ไม่ได้รับประกันว่าไฟล์ปลอดภัย 100% · OWASP (กลุ่มที่ทำคู่มือความปลอดภัยเว็บที่คนทั้งวงการใช้ — เจออีกใน Part 2) จึงแนะนำให้ทำหลายชั้น เช่นจำกัดขนาด เก็บไฟล์ไว้คนละเครื่องกับแอป และสแกนไวรัสถ้าทำได้
เทียบของที่รู้: บท 37 ของ Go เจอ http.DetectContentType มาแล้ว — ฟังก์ชันที่เดาชนิดจากเนื้อข้อมูลไม่กี่ร้อย byte แรก หลักเดียวกันเป๊ะ ·
ฝั่ง Ruby มี gem ชื่อ Marcel ทำงานนี้ และมากับ Rails อยู่แล้ว (Active Storage ใช้มันเอง) · ลองใน console (ผลจริง):
wongnok-blog-all-in-one(dev):001> Marcel::MimeType.for(File.open("tmp/fake.png"))
=> "application/octet-stream"
wongnok-blog-all-in-one(dev):002> Marcel::MimeType.for(File.open("tmp/fake.png"), name: "fake.png")
=> "image/png"
wongnok-blog-all-in-one(dev):003> Marcel::MimeType.for(File.open("tmp/real.png"))
=> "image/png"
wongnok-blog-all-in-one(dev):004> Marcel::MimeType.for(File.open("tmp/real.png"), name: "real.txt")
=> "image/png"
File.open("tmp/fake.png")— เปิดไฟล์ให้อ่านได้ (ส่งเนื้อให้ Marcel) ·name: "…"— บอกชื่อไฟล์ไปด้วย (ไม่ใส่ก็ได้)- บรรทัด 001 — ให้แค่เนื้อไฟล์ปลอม →
"application/octet-stream"= "ข้อมูลอะไรสักอย่าง ไม่รู้จัก" · ไม่ใช่รูปแน่นอน - บรรทัด 002 — บอกชื่อไฟล์ไปด้วย →
"image/png"· เนื้อไม่มีลายเซ็นให้ดู Marcel เลยถอยไปเชื่อชื่อไฟล์ · นี่แหละสาเหตุที่ Rails เองก็ปล่อยไฟล์ปลอมผ่าน: ตอนแนบไฟล์ Rails เรียก Marcel พร้อมชื่อไฟล์และชนิดที่เบราว์เซอร์บอก - บรรทัด 003–004 — รูปจริงได้
"image/png"เสมอ · แม้ตั้งชื่อเป็น.txtลายเซ็นก็ชนะชื่อ
กฎที่ได้: เรียก Marcel ด้วยเนื้อไฟล์อย่างเดียว ห้ามบอกชื่อ ห้ามบอกชนิดที่เบราว์เซอร์ส่งมา
- content_type · MIME type
- ป้ายชนิดไฟล์แบบ
image/png· ที่มาจากเบราว์เซอร์คือสิ่งที่ client อ้าง (เดาจากนามสกุล หรือพิมพ์เองด้วย curl ก็ได้) ไม่ใช่ความจริง · พิมพ์บอก AI ได้ว่า "never trust the client-provided content_type or file extension; detect the type from the file content" - magic bytes (file signature) · Marcel
- ลายเซ็นไม่กี่ byte แรกของไฟล์ที่บอกชนิดจริง · Marcel = gem ที่ Rails ใช้อ่านลายเซ็นนั้น ·
application/octet-stream= ไม่รู้จัก · ถาม senior ได้ว่า "เราตรวจชนิดไฟล์จาก magic bytes หรือจาก content_type — แล้วใช้ gem อย่าง active_storage_validations ไหม"
โค้ดตรวจไฟล์ตัวจริง — ทีละบรรทัด
ลบโค้ดของสไลด์ออกทั้งหมด (บรรทัด validate :cover_is_valid และ method cover_is_valid) ให้ post.rb กลับเป็นแบบตอนต้นบท ·
แล้วเพิ่มของใหม่ 3 จุด · จุดที่ 1 บนสุดของคลาส (บรรทัดค่าคงที่ + บรรทัดว่าง):
class Post < ApplicationRecord
+ COVER_TYPES = %w[ image/png image/jpeg image/webp ]
+
belongs_to :category
COVER_TYPES — ชื่อขึ้นต้นตัวใหญ่ = ค่าคงที่ (ตั้งครั้งเดียวไม่เปลี่ยน · บท 91) · รายการชนิดที่ยอมรับ เขียนไว้ที่เดียว ใช้ทั้งใน model และในฟอร์ม
จุดที่ 2 ใต้ validates :body, presence: true (comment 1 + validate 1):
validates :body, presence: true
+ # ตรวจเฉพาะตอนแนบรูปใหม่ (attachment ยังไม่ได้บันทึก) — แก้แค่หัวข้อไม่ต้องตรวจรูปเดิมซ้ำ
+ validate :cover_must_be_small_image, if: -> { cover.attached? && cover.attachment.new_record? }
validate :cover_must_be_small_image— ตรวจด้วย method ชื่อนี้ (เขียนในจุดที่ 3)if: -> { … }— ตรวจเฉพาะเมื่อเงื่อนไขในปีกกาเป็นจริง ·-> { }คือก้อนโค้ดที่เก็บไว้รันทีหลัง (lambda · บท 112 · คล้าย arrow function() => …ของ JS)cover.attached?— มีรูปปกไหม ·&&— และcover.attachment.new_record?— ป้ายรูปปกยังไม่เคยบันทึก = เพิ่งแนบรูปใหม่ในรอบนี้ · ถ้าแค่แก้หัวข้อของบทความที่มีรูปเดิมอยู่แล้ว ป้ายเป็นของเก่า → ไม่ต้องตรวจซ้ำ (การตรวจต้องโหลดไฟล์จาก MinIO มีค่าใช้จ่าย)
จุดที่ 3 ใต้ private ต่อจาก method copy_body_to_plain_text (บรรทัดว่างคั่น method เหมือนเดิม):
def copy_body_to_plain_text
self.plain_text = body.to_plain_text
self.description = plain_text
end
+
+ def cover_must_be_small_image
+ # ชนิดไฟล์: ดูจากเนื้อไฟล์จริง (magic bytes ต้นไฟล์) ไม่ใช่ content_type หรือนามสกุลที่เบราว์เซอร์บอกมา
+ real_type = Marcel::MimeType.for(StringIO.new(cover_head))
+ errors.add(:cover, "รองรับเฉพาะ PNG, JPEG, WebP") unless real_type.in?(COVER_TYPES)
+ # byte_size ของ direct upload เชื่อได้: MinIO รับไฟล์เฉพาะเมื่อขนาดตรงกับที่ Rails เซ็นไว้
+ errors.add(:cover, "ไฟล์ต้องไม่เกิน 5 MB") if cover.byte_size > 5.megabytes
+ end
+
+ # 4 KB แรกของไฟล์ (พอให้เห็น magic bytes)
+ def cover_head
+ if cover.byte_size.zero?
+ # ไฟล์ว่าง: ไม่มีอะไรให้อ่าน (ขอช่วงไบต์จากไฟล์ว่างใน MinIO จะ error)
+ ""
+ elsif cover.blob.persisted?
+ # direct upload: ไฟล์อยู่ใน MinIO แล้ว → ดึงมาแค่ช่วงต้นไฟล์
+ cover.blob.download_chunk(0...4.kilobytes)
+ else
+ # แนบไฟล์ตรง ๆ (เช่น ใน test หรือ console): ไฟล์ยังไม่ขึ้น MinIO → อ่านจากไฟล์ในมือ แล้วกรอกลับไปต้นไฟล์
+ io = attachment_changes["cover"].attachable
+ io = io[:io] if io.is_a?(Hash)
+ io.rewind
+ io.read(4.kilobytes).to_s.tap { io.rewind }
+ end
+ end
method แรก cover_must_be_small_image — ตัวตัดสิน:
cover_head— เรียก method ที่สอง ได้ต้นไฟล์ 4 KB เป็นข้อความ (4 KB = 4,096 byte มากพอให้เห็นลายเซ็นทุกชนิดที่เรารับ)StringIO.new(…)— ห่อข้อความให้หน้าตาเหมือนไฟล์ (Marcel อยากได้ของที่ "อ่านได้แบบไฟล์")Marcel::MimeType.for(…)— ส่งแค่เนื้อ ไม่ส่งชื่อ ตามกฎเมื่อกี้ → ได้ชนิดจริงเก็บในreal_typeerrors.add(…) unless real_type.in?(COVER_TYPES)—unless= "ถ้าไม่" · ชนิดจริงไม่อยู่ในรายการ → แปะข้อความผิด · ข้อความเดียวกับของสไลด์- บรรทัดขนาด — เหมือนสไลด์ แค่เขียนบรรทัดเดียว · comment บอกเหตุผลว่าทำไมขนาดเชื่อได้ ทั้งที่ชนิดเชื่อไม่ได้: บท 139 เห็นแล้วว่าใบอนุญาตที่ Rails เซ็นให้เบราว์เซอร์ล็อกขนาดไฟล์ไว้ (
X-Amz-SignedHeaders=content-length;…) ส่งไฟล์ขนาดไม่ตรง MinIO ไม่รับ
method ที่สอง cover_head — ไปเอาต้นไฟล์มา ซึ่งอยู่ได้ 3 ที่ จึงมี 3 กิ่ง:
if cover.byte_size.zero?→""— ไฟล์ว่าง 0 byte: ไม่มีอะไรให้อ่าน ตอบข้อความว่าง (Marcel จะบอก "ไม่รู้จัก" → ถูกปฏิเสธ) · ไม่มีกิ่งนี้ การขอช่วง byte จากไฟล์ว่างใน MinIO จะ errorelsif cover.blob.persisted?—elsif= "ไม่งั้นถ้า…" ·persisted?= ใบรับฝากถูกบันทึกแล้ว = มาทาง direct upload (ใบรับฝากเกิดตอนขอใบอนุญาต และไฟล์อยู่ใน MinIO แล้ว) →download_chunk(0...4.kilobytes)โหลดแค่ช่วง byte ที่ 0 ถึง 4,095 ไม่โหลดทั้งไฟล์ (0...4096สามจุด = ไม่รวมตัวท้าย)else— แนบไฟล์จากโค้ดตรง ๆ (ใน console หรือ test) ไฟล์ยังอยู่ในมือ ยังไม่ขึ้น MinIO · กิ่งนี้หน้าเว็บไม่ได้ใช้ พิมพ์ตามได้เลย ไม่ต้องจำทุกคำ — อ่านทีละบรรทัดได้ว่า:attachment_changes["cover"].attachable— ของที่กำลังจะแนบ (method ภายในของ Active Storage — ยังไม่มีทางทางการให้อ่านไฟล์ก่อนอัป)io = io[:io] if io.is_a?(Hash)— ถ้าแนบมาแบบ{ io: ไฟล์, filename: … }ให้หยิบตัวไฟล์ออกมาio.rewind— กรอกกลับไปต้นไฟล์ (เหมือนกรอเทป) ·read(4.kilobytes)— อ่าน 4 KB.tap { io.rewind }— อ่านเสร็จแล้วกรอกลับอีกรอบ ไม่งั้นตอนอัปจริงจะเริ่มอ่านจากกลางไฟล์
✅ กิ่ง else ผมลองจริงใน console แล้ว: แนบ tmp/fake.png ด้วย post.cover.attach(io: File.open(…), filename: "fake.png") → ได้ ["Cover รองรับเฉพาะ PNG, JPEG, WebP"] และรูปเดิมใน DB ไม่ถูกแทน
ฟอร์ม: ช่วยกรองไฟล์ + ทำช่องเป็นสีแดง
เปิด app/views/posts/_form.html.erb · แก้บรรทัด file_field ของรูปปกเป็น 3 บรรทัด (comment + file_field ที่ยาวจนต้องขึ้นบรรทัดใหม่):
<%# direct_upload: เบราว์เซอร์ส่งไฟล์ขึ้น MinIO (:9000) ตรง ๆ ไม่ผ่าน Rails · ฟอร์มส่งแค่รหัสของไฟล์ %>
- <%= form.file_field :cover, direct_upload: true, class: "file-input w-full mt-2" %>
+ <%# accept แค่ช่วยกรองไฟล์ในหน้าต่างเลือกไฟล์ — ของจริงตรวจที่ model จากเนื้อไฟล์ %>
+ <%= form.file_field :cover, direct_upload: true, accept: Post::COVER_TYPES.join(","),
+ class: ["file-input w-full mt-2", {"file-input-error": post.errors[:cover].any?}] %>
accept: Post::COVER_TYPES.join(",")— ได้accept="image/png,image/jpeg,image/webp"ในแท็ก<input>· หน้าต่างเลือกไฟล์ของเบราว์เซอร์โชว์เฉพาะไฟล์ชนิดนี้ ·Post::= หยิบค่าคงที่จากคลาส Post- comment บอกตรง ๆ ว่า
acceptไม่ใช่ด่านตรวจ — มันดูแค่นามสกุลเหมือนกัน ·fake.pngของเราก็ยังเลือกได้ class: [ …, {"file-input-error": … } ]— ท่าเดียวกับช่องหัวข้อในฟอร์มเดิม (บท 129): มี error ที่ cover → เติมคลาสfile-input-errorให้ช่องเป็นสีแดง
ลองไฟล์ปลอมอีกรอบ
เปิดหน้าแก้ไขบทความ 27 · เลือก tmp/fake.png · กดบันทึก · ผลจริงบนจอ (กล่องแดงเหนือฟอร์ม):
บันทึกไม่ได้ มี 1 จุดที่ต้องแก้:
• Cover รองรับเฉพาะ PNG, JPEG, WebP
และใน log (ตัดมาเฉพาะบรรทัดสำคัญ):
Started POST "/rails/active_storage/direct_uploads" for ::1 at 2026-11-10 10:21:38 +0700
ActiveStorage::Blob Create (3.0ms) INSERT INTO "active_storage_blobs" (…) VALUES ('u17psfgqs35172ueghghvdkulwkj', 'fake.png', 'image/png', NULL, 'minio', 38, …) RETURNING "id"
Completed 200 OK in 13ms (Views: 0.1ms | ActiveRecord: 5.6ms (1 query, 0 cached) | GC: 0.0ms)
Started PATCH "/posts/27" for ::1 at 2026-11-10 10:21:38 +0700
TRANSACTION (0.6ms) ROLLBACK
Completed 422 Unprocessable Content in 24ms (Views: 2.3ms | ActiveRecord: 5.3ms (5 queries, 0 cached) | GC: 3.2ms)
Cover รองรับเฉพาะ…—Coverภาษาอังกฤษนำหน้า เพราะ Rails เติมชื่อช่องให้เอง (แบบเดียวกับTitle can't be blankของ Day 3)ROLLBACK+422— validation ไม่ผ่าน ไม่บันทึก · หน้าแก้ไขถูกวาดใหม่พร้อมกล่องแดง (บท 110)- แต่สังเกตบรรทัดแรก:
Blob Create … 'fake.png'เกิดก่อน PATCH · ใบรับฝากถูกสร้าง และไฟล์ถูกส่งขึ้น MinIO ก่อนที่ model จะได้ตรวจ — จำไว้ หัวข้อถัดไปใช้
ไฟล์รูปจริงที่ใหญ่เกินก็โดนเหมือนกัน — ผมลองรูป PNG จริงหนัก 6,346,163 byte ได้ Cover ไฟล์ต้องไม่เกิน 5 MB (ผลจริง) · รูป png/jpg/webp ขนาดปกติบันทึกได้ตามเดิม
ไฟล์กำพร้า — ฝากแล้วไม่มีใครมารับ
direct upload ส่งไฟล์ขึ้นโกดังตอนกดบันทึก ก่อนที่ Rails จะเห็นฟอร์ม · ถ้า validation ไม่ผ่าน หรือผู้ใช้ปิดหน้าไปกลางทาง
ไฟล์อยู่ใน MinIO แล้ว ใบรับฝากอยู่ใน DB แล้ว แต่ไม่มีป้ายผูกกับบทความไหน · เรียกว่าไฟล์กำพร้า (orphan · Rails เรียก unattached)
นับดูใน console (ผลจริง · หลังจากผมลองไฟล์ปลอม 1 ครั้ง และไฟล์ใหญ่ 1 ครั้ง):
wongnok-blog-all-in-one(dev):001> ActiveStorage::Blob.unattached.count
ActiveStorage::Blob Count (0.5ms) SELECT COUNT(*) FROM "active_storage_blobs" LEFT OUTER JOIN "active_storage_attachments" ON "active_storage_attachments"."blob_id" = "active_storage_blobs"."id" WHERE "active_storage_attachments"."id" IS NULL
=> 2
wongnok-blog-all-in-one(dev):002> ActiveStorage::Blob.unattached.pluck(:filename, :byte_size)
ActiveStorage::Blob Pluck (2.6ms) SELECT "active_storage_blobs"."filename", "active_storage_blobs"."byte_size" FROM … WHERE "active_storage_attachments"."id" IS NULL
=> [["fake.png", 38], ["big.png", 6346163]]
wongnok-blog-all-in-one(dev):003> ActiveStorage::Blob.unattached.map { |b| b.service.exist?(b.key) }
ActiveStorage::Blob Load (2.5ms) SELECT "active_storage_blobs".* FROM … WHERE "active_storage_attachments"."id" IS NULL
S3 Storage (49.9ms) Checked if file exists at key: tdxomee5263zp0xj82lynsfwkyko (yes)
S3 Storage (1.4ms) Checked if file exists at key: w6y2kv8iuzroxbljrd13wci9et72 (yes)
=> [true, true]
unattached— "ใบรับฝากที่ไม่มีป้ายไหนชี้มา" · SQL คือLEFT OUTER JOINตารางป้ายแล้วเอาแถวที่หาป้ายไม่เจอ (… "id" IS NULL)pluck(:filename, :byte_size)— หยิบแค่สองช่อง (บท 113) · เห็นชัดว่าเป็นสองไฟล์ที่ถูกปฏิเสธ — ไฟล์ปลอม 38 byte และไฟล์ใหญ่ 6 MBb.service.exist?(b.key)— ถาม MinIO ว่าไฟล์จริงยังอยู่ไหม →(yes)ทั้งคู่ · ไฟล์ที่เราปฏิเสธไปแล้ว ยังนอนอยู่ในโกดังและกินที่
ไฟล์กำพร้ามาจาก direct upload ที่ถูกปฏิเสธ หรือรูปที่ลากใส่ช่องเนื้อหา rich text แล้วไม่บันทึก · แค่เลือกไฟล์แล้วไม่กดบันทึกไม่เกิด (ลองใน Chrome แล้ว — direct upload ของ Rails เริ่มตอนกดบันทึก)
direct upload คือลูกค้าเอาของไปฝากโกดังเองก่อน แล้วค่อยถือบัตรรับฝากมายื่นที่เคาน์เตอร์ · พนักงานตรวจแล้วบอก "รับไม่ได้" — ลูกค้าเดินกลับบ้าน แต่ของยังวางอยู่ในโกดัง พร้อมบัตรรับฝากที่ไม่มีแฟ้มไหนอ้างถึง · วันละไม่กี่ชิ้นไม่เป็นไร แต่ปีหนึ่งโกดังเต็ม (และ S3 จริงคิดเงินตามพื้นที่)
ที่ไม่เหมือนกัน: โกดังจริงรู้ว่าของชิ้นไหนค้างนาน · MinIO/S3 ไม่รู้เลย ว่าไฟล์ไหนกำพร้า เพราะความรู้ว่า "ของชิ้นนี้เป็นของแฟ้มไหน" (ป้าย) อยู่ใน DB ของเรา ไม่ได้อยู่ในโกดัง · คนที่ไล่หาของค้างได้จึงต้องเป็นแอปของเราเอง
ลบได้เลยไหม — เว้นไว้สองวันก่อน
สไลด์บอก "ต้องมี job ไล่เก็บ" · เอกสาร Rails ให้ตัวอย่างไว้บรรทัดเดียว (ด้านล่าง) · ลองรันเฉพาะส่วนนับต่อใน console เดิม (ผลจริง):
wongnok-blog-all-in-one(dev):004> ActiveStorage::Blob.unattached.where(created_at: ..2.days.ago).count
ActiveStorage::Blob Count (2.7ms) SELECT COUNT(*) FROM "active_storage_blobs" LEFT OUTER JOIN … WHERE "active_storage_attachments"."id" IS NULL AND "active_storage_blobs"."created_at" <= '2026-11-08 03:37:40.973843'
=> 0
# ตัวอย่างในเอกสาร Rails (ยังไม่ต้องรัน): ลบไฟล์กำพร้าที่อายุเกิน 2 วัน
ActiveStorage::Blob.unattached.where(created_at: ..2.days.ago).find_each(&:purge_later)
created_at: ..2.days.ago— "สร้างไว้ตั้งแต่สองวันก่อนลงไป" (..x= ช่วงตั้งแต่อดีตถึง x) · SQL ออกมาเป็น"created_at" <= '2026-11-08 …'(วันนี้ลบสอง · เวลาใน DB เป็นเวลาสากล UTC)=> 0— ไฟล์กำพร้าสองไฟล์ของผมเพิ่งเกิดเมื่อกี้ ยังไม่ถึงสองวัน → บรรทัดของเอกสารจะยังไม่ลบ- ทำไมต้องรอสองวัน — ไฟล์ที่ "ยังไม่มีป้าย" อาจเป็นของคนที่กำลังกรอกฟอร์มอยู่ (เช่นรูปที่ลากใส่เนื้อหาแล้วยังไม่กดบันทึก) · ลบทันทีคือลบของลูกค้าจากมือ
find_each(&:purge_later)— ไล่ใบรับฝากที่เจอทีละใบ (ดึงจาก DB ทีละ 1,000 แถว) แล้วสั่งpurge_laterทุกใบ ·&:purge_later= เขียนย่อของ{ |b| b.purge_later }
บรรทัดนี้ต้องมีคนรันให้ทุกวันโดยอัตโนมัติ — นั่นคืองานตามเวลา (recurring job) ของบท 144 · บทนี้รู้แค่ว่าปัญหามีจริงและหน้าตาของคำตอบเป็นแบบไหน
purge กับ purge_later
ลบไฟล์ได้สองแบบ · purge (ใช้ไปตอนลบรูปปลอม) = ลบเดี๋ยวนี้ แล้วรอจนเสร็จ · purge_later = เขียนใบงาน "ลบไฟล์นี้" แขวนไว้ แล้วไปทำอย่างอื่นต่อเลย ใครว่างค่อยมาทำ ·
ในเครื่องตัวเองไม่ต้องรอสองวัน — ลองแบบ later กับไฟล์กำพร้าทั้งสองเลย (ผลจริง · ตัดบรรทัด SQL ของงานลบเป็น … เพราะเหมือนตอน purge):
wongnok-blog-all-in-one(dev):005> ActiveStorage::Blob.unattached.find_each(&:purge_later)
ActiveStorage::Blob Load (1.0ms) SELECT "active_storage_blobs".* FROM … WHERE "active_storage_attachments"."id" IS NULL ORDER BY "active_storage_blobs"."id" ASC LIMIT 1000
Enqueued ActiveStorage::PurgeJob (Job ID: d5e26f62-8ad8-43e7-9929-b83a5146d5fd) to Async(default) with arguments: #<GlobalID:0x0000000128b1ca00 @uri=#<URI::GID gid://wongnok-blog-all-in-one/ActiveStorage::Blob/13>>
↳ (wongnok-blog-all-in-one):5:in '<main>'
Enqueued ActiveStorage::PurgeJob (Job ID: 00faa663-8627-4d3e-bbcf-0a803d5ade7d) to Async(default) with arguments: #<GlobalID:0x0000000128b15a98 @uri=#<URI::GID gid://wongnok-blog-all-in-one/ActiveStorage::Blob/14>>
↳ (wongnok-blog-all-in-one):5:in '<main>'
=> nil
wongnok-blog-all-in-one(dev):006> ActiveStorage::Blob Load (1.0ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = 13 LIMIT 1
Performing ActiveStorage::PurgeJob (Job ID: d5e26f62-8ad8-43e7-9929-b83a5146d5fd) from Async(default) enqueued at 2026-11-10T03:37:44.992969000Z with arguments: …
…
S3 Storage (3.6ms) Deleted file from key: tdxomee5263zp0xj82lynsfwkyko
S3 Storage (2.5ms) Deleted files by key prefix: variants/tdxomee5263zp0xj82lynsfwkyko/
Performed ActiveStorage::PurgeJob (Job ID: d5e26f62-8ad8-43e7-9929-b83a5146d5fd) from Async(default) in 13.89ms
… (งานของ Blob/14 แบบเดียวกัน)
ActiveStorage::Blob.unattached.count
ActiveStorage::Blob Count (2.0ms) SELECT COUNT(*) FROM "active_storage_blobs" LEFT OUTER JOIN … WHERE "active_storage_attachments"."id" IS NULL
=> 0
Enqueued ActiveStorage::PurgeJob … to Async(default)— "แขวนใบงานลบไฟล์" 2 ใบ ·Job ID= เลขใบงาน (สุ่ม ของคุณจะต่าง) ·↳ (wongnok-blog-all-in-one):5= สั่งมาจาก consolegid://…/ActiveStorage::Blob/13— ที่อยู่ของใบรับฝากเลข 13 เขียนเป็นข้อความ (GlobalID) · ใบงานจดแค่ที่อยู่ ไม่ได้แนบตัวไฟล์หรือข้อมูลทั้งแถวไปด้วย=> nilมาทันที — console ได้ prompt006>คืนก่อนงานลบจะเริ่มPerforming …/Performed …— งานลบเริ่ม-จบทีหลัง เลยโผล่แทรกต่อท้าย prompt 006 · บรรทัดActiveStorage::Blob.unattached.countคือที่ผมพิมพ์ต่อ ได้0= ลบครบ
ตอนนี้ "คนทำใบงาน" (Async) คือเธรดในโปรเซส Rails เดียวกัน — คล้ายสั่ง go func() ใน Go (บท 21) ·
ใบงานอยู่ในหน่วยความจำ ไม่ได้จดลงที่ไหน · บล็อก E (บท 142–143) จะเปลี่ยนเป็นกระดานใบงานจริงที่จดลง Postgres
อีกเรื่องที่ไม่ต้องทำเอง: ลบบทความ → Rails ลบรูปปกให้ เพราะ has_one_attached ตั้งค่าเริ่มต้นไว้เป็น dependent: :purge_later (เอกสาร API ของ Rails) — ตรงกับที่สไลด์หน้า 12 บอก
- orphaned file · unattached blob
- ไฟล์ที่อัปขึ้น storage แล้วแต่ไม่มี record ไหนผูก · ใน Rails หาด้วย
ActiveStorage::Blob.unattached· เกิดบ่อยกับ direct upload · ถาม senior ได้ว่า "โปรเจกต์เรามี job ลบ unattached blob ไหม รันทุกวันหรือเปล่า เว้นกี่วัน" - purge · purge_later
- purge = ลบป้าย + ใบรับฝาก + ไฟล์ + รูปย่อ เดี๋ยวนั้น · purge_later = ส่งเป็นงานเบื้องหลัง (Active Job) คืนผลทันที · พิมพ์บอก AI ได้ว่า "use purge_later in request paths so the user does not wait for storage deletes"
📍 ตรวจก่อนไปต่อ: git status --short ต้องเห็นไฟล์ที่ถูกแก้ 5 ไฟล์พอดี — app/controllers/posts_controller.rb · app/models/post.rb · app/views/posts/_form.html.erb · app/views/posts/_post.html.erb · config/application.rb ·
ใน post.rb ต้องไม่เหลือ cover_is_valid ของสไลด์ · ไฟล์ใน tmp/ ไม่โผล่ (git ไม่สนโฟลเดอร์นี้) · แล้ว commit ได้ เช่น git commit -am "รูปย่อ + ตรวจรูปปกจากเนื้อไฟล์"
🎯 ภารกิจ — ทำไฟล์ปลอมสองแบบ แล้วเก็บกวาดโกดังเอง (~8 นาที)
ทำบนโค้ดตอนจบบท (validation ตัวจริงแล้ว) · ใช้บทความที่ไม่มีรูปปก
- สร้างไฟล์หนัก 6 MB ที่ข้างในเป็นเลข 0 ล้วน (ไม่ใช่รูป) ชื่อลงท้าย .png:
mkfile -n 6m tmp/zero.png(macOS) หรือtruncate -s 6M tmp/zero.png(Linux) · ใช้tmp/fake.pngจากในบทด้วย (ถ้าลบไปแล้วสร้างใหม่ด้วยprintfเดิม) - เปิดหน้าแก้ไขบทความ · เลือก
tmp/zero.png(ในหน้าต่างเลือกไฟล์ เข้าโฟลเดอร์โปรเจกต์ →tmp) · กดบันทึก - ทำซ้ำด้วย
tmp/fake.png - เปิด
bin/rails consoleแล้วพิมพ์ทีละบรรทัด:
(ใช้ActiveStorage::Blob.unattached.pluck(:filename) ActiveStorage::Blob.unattached.find_each(&:purge) ActiveStorage::Blob.unattached.countpurgeไม่ใช่purge_laterเพื่อเห็นผลทันที · ลบหมดไม่เว้นสองวันได้เฉพาะในเครื่องตัวเอง)
ก่อนกดเฉลย: ข้อ 2 กล่องแดงจะมีกี่บรรทัด บรรทัดไหนบ้าง? · ข้อ 4 บรรทัดแรกจะเห็นชื่อไฟล์อะไร?
ข้อ 2 — ผลจริง: 2 จุด เพราะไฟล์ศูนย์ล้วนไม่มีลายเซ็นรูป และใหญ่เกิน 5 MB · validation รันครบทุกข้อ ไม่หยุดที่ข้อแรก:
บันทึกไม่ได้ มี 2 จุดที่ต้องแก้:
• Cover รองรับเฉพาะ PNG, JPEG, WebP
• Cover ไฟล์ต้องไม่เกิน 5 MB
ข้อ 3 — Cover รองรับเฉพาะ PNG, JPEG, WebP บรรทัดเดียว · ข้อ 4 — ผลจริง (ตัดบรรทัด SQL):
wongnok-blog-all-in-one(dev):001> ActiveStorage::Blob.unattached.pluck(:filename)
=> ["zero.png", "fake.png"]
wongnok-blog-all-in-one(dev):002> ActiveStorage::Blob.unattached.find_each(&:purge)
…
ActiveStorage::Blob Destroy (1.3ms) DELETE FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = 11
…
S3 Storage (20.0ms) Deleted file from key: rxqg8puw70j2319aqptw98puznrq
S3 Storage (4.3ms) Deleted files by key prefix: variants/rxqg8puw70j2319aqptw98puznrq/
… (ไฟล์ที่สองแบบเดียวกัน)
=> nil
wongnok-blog-all-in-one(dev):003> ActiveStorage::Blob.unattached.count
=> 0
ไฟล์ที่ถูกปฏิเสธทั้งสองไปค้างอยู่ใน MinIO ระหว่างข้อ 2–3 · purge ลบทั้งใบรับฝากและตัวไฟล์ · บทความไม่ถูกแตะเลย (ไม่มีป้ายให้ลบ)
ถ้าเจออาการนี้ แปลว่า…
- ข้อ 2 บันทึกผ่าน ขึ้นแถบเขียว —
post.rbยังเป็นโค้ดของสไลด์ (ตรวจcontent_type) หรือvalidateพิมพ์ชื่อ method ไม่ตรงกับdef· เปิดpost.rbเทียบกับในบท - ข้อ 4 บรรทัดแรกเห็นชื่อไฟล์มากกว่าสองชื่อ — ปกติ · มีไฟล์กำพร้าจากการทดลองก่อนหน้า (หรือรูปที่ลากใส่เนื้อหาแล้วไม่บันทึก) ·
purgeเก็บหมดเหมือนกัน undefined method 'unattached' for module ActiveStorage::Blobs— พิมพ์Blobsมี s เกิน (ลองแล้ว ได้ข้อความนี้เป๊ะ) · ชื่อที่ถูกคือActiveStorage::Blob· ถ้าได้uninitialized constant Activestorage= พิมพ์ S ตัวเล็ก
ทดสอบความเข้าใจ
(ดัดแปลงจาก Recap case 2 สไลด์หน้า 13) หน้ารวมโชว์รูปย่อของรูปปก และข้อความล้วนของเนื้อหา rich text ในการ์ดทุกใบ · controller เขียน Post.includes(:category).limit(20) · ควรเติมอะไรไม่ให้เกิด N+1?
ตัวเลือกที่ 3 ถูก — ผลจริงในบทนี้: ไม่มี with_attached_cover ได้ 17 query (ป้ายทีละบทความ + ใบรับฝากทีละใบ) · ใส่แล้ว + ปิด track_variants เหลือ 6 · ส่วน with_rich_text_body กันเนื้อหา rich text ทีละบทความ (บท 137)
ตัวเลือกที่ 1 สับสนระหว่างตัวไฟล์ (อยู่ใน MinIO) กับป้าย/ใบรับฝาก (อยู่ใน DB) — N+1 เกิดที่ DB ·
ตัวเลือกที่ 2 — cover ไม่ใช่ชื่อความสัมพันธ์จริง ลองแล้วได้ Association named 'cover' was not found on Post (ชื่อจริงคือ cover_attachment) ·
ตัวเลือกที่ 4 — ย้ายที่เรียกไม่ได้ลดจำนวนครั้งที่ไปตู้ ปัญหาอยู่ที่ใบเบิกไม่ได้บอกล่วงหน้า
จุดที่ต้องจำ: ไฟล์แนบ = ความสัมพันธ์ใน DB เหมือน association อื่น · หน้ารวมที่โชว์ไฟล์แนบต้องมี with_attached_* แล้วดูว่าจำนวน query ใน log ไม่โตตามข้อมูล
ทำไมโค้ดในสไลด์ที่ตรวจ cover.content_type.in?(%w[image/png image/jpeg image/webp]) จึงปล่อยไฟล์ข้อความที่ตั้งชื่อ fake.png ผ่านไปได้?
ตัวเลือกที่ 1 ถูก — log จริง: "content_type" => "image/png" มากับใบขอ direct upload ของเบราว์เซอร์ และ Marcel ที่ได้ชื่อไฟล์ไปด้วยตอบ "image/png" · ให้แค่เนื้อ Marcel ตอบ "application/octet-stream"
ตัวเลือกที่ 2 — validation รันจริง (ตอน PATCH) โค้ดตัวจริงของเราปฏิเสธได้ด้วย 422 · ปัญหาคือข้อมูลที่โค้ดของสไลด์ใช้ตัดสิน ·
ตัวเลือกที่ 3 — content_type ถูกจดลง DB ตั้งแต่ INSERT INTO "active_storage_blobs" ตามที่เบราว์เซอร์บอก MinIO ไม่ได้ยุ่ง ·
ตัวเลือกที่ 4 — validation รันทุกข้อ (ภารกิจได้ 2 ข้อความพร้อมกัน) และในสไลด์ตรวจชนิดก่อนขนาดด้วยซ้ำ
จุดที่ต้องจำ: ชื่อไฟล์ นามสกุล content_type = สิ่งที่ client อ้าง · ชนิดจริงต้องดูจากเนื้อไฟล์ (magic bytes) โดยไม่บอกชื่อไฟล์ให้ตัวตรวจ
ลูกค้าคนที่สองเปิดหน้ารวม หลังจากคนแรกเปิดไปแล้ว (โค้ดตอนจบบท) · ตอนเบราว์เซอร์ขอรูปย่อของบทความ 30 Rails ทำอะไร?
ตัวเลือกที่ 4 ถูก — log ครั้งที่สอง: Checked if file exists at key: variants/… (yes) → Generated URL → 302 · ไม่มี Downloaded/Uploaded · 46ms → 8ms
ตัวเลือกที่ 1 — ไม่จดลงตาราง ≠ ไม่จำ · ชิ้นย่อยังอยู่ใน MinIO Rails แค่ถามโกดังแทนการเปิดสมุด ·
ตัวเลือกที่ 2 — ถ้าทำแบบนั้นก็ไม่ต้องมี variant · ผู้ใช้ได้ไฟล์เล็ก 400×250 จริง ·
ตัวเลือกที่ 3 — เราปิด track_variants แล้ว ตารางนั้นไม่ถูกใช้ และตาราง DB ไม่เคยเก็บตัวไฟล์ เก็บแค่ข้อมูลของไฟล์
จุดที่ต้องจำ: variant ทำครั้งแรกตอนมีคนขอ แล้วเก็บใน storage ใช้ซ้ำ · คนแรกรอ คนต่อไปเร็ว
ผู้ใช้อัปไฟล์ปลอมผ่านฟอร์ม (direct upload) แล้วได้กล่องแดง 422 · ตอนนี้ไฟล์ปลอมนั้นอยู่ที่ไหน?
ตัวเลือกที่ 2 ถูก — ผลจริง: ActiveStorage::Blob.unattached.count → 2 และ exist? ใน MinIO → [true, true] · direct upload ส่งไฟล์และสร้างใบรับฝากก่อนฟอร์มถึง model
ตัวเลือกที่ 1 — ROLLBACK ยกเลิกแค่สิ่งที่ทำใน transaction ของ PATCH · ใบรับฝากถูก COMMIT ไปตั้งแต่ใบสั่ง direct_uploads ก่อนหน้า และ MinIO ไม่รู้จัก transaction ของ Postgres ·
ตัวเลือกที่ 3 — direct upload ไม่ผ่านเซิร์ฟเวอร์ Rails เลย ไฟล์ไปที่ MinIO ตรง ๆ ·
ตัวเลือกที่ 4 — ไม่มีป้าย (Attachment Create ไม่เกิด) บทความยังไม่มีรูปปก
จุดที่ต้องจำ: direct upload + validation ไม่ผ่าน = ไฟล์กำพร้า · ต้องมีงานไล่ unattached ที่อายุเกินกำหนดแล้ว purge_later
ถ้าเปิดสไลด์ดูประกอบ
| หน้า | สไลด์เขียน | ของจริง (Rails 8.1.4 · แอปของคอร์ส) | ต้องสนใจไหม |
|---|---|---|---|
| 12 | validation ตรวจ cover.content_type.in?(…) | ไฟล์ข้อความชื่อ fake.png ผ่าน บันทึกได้ แล้วรูปย่อพัง 500 (ลองแล้ว) · บทนี้ใช้ Marcel อ่านเนื้อไฟล์แทน | สนใจ — ใช้โค้ดในบทนี้ |
| 12 | "คนร้ายอัป shell script แล้วบอกว่าเป็น image/png ได้สบาย" | ลองไฟล์ที่ขึ้นต้น #!/bin/sh ชื่อ .png แล้ว — โค้ดของสไลด์จับได้ เพราะ Marcel รู้จักลายเซ็นนั้น (Rails ตรวจเนื้อซ้ำตอนแนบ) · ที่หลุดคือไฟล์ที่ไม่มีลายเซ็นอะไรเลย อย่างข้อความธรรมดา | ไม่ต้อง — ข้อสรุปเดิมยังถูก: อย่าเชื่อ content_type |
| 12 | return unless cover.attached? แล้วตรวจทุกครั้งที่บันทึก | เราตรวจเฉพาะตอนแนบรูปใหม่ (cover.attachment.new_record?) · แก้หัวข้อแล้วไม่ต้องโหลดไฟล์จาก MinIO ซ้ำ | ไม่ต้อง |
| 12 | ใช้ variant ในการ์ด (ไม่พูดถึงจำนวน query) | ต้องเติม with_attached_cover และปิด track_variants ถึงจะเหลือ 6 query ไม่มีคำเตือน bullet (วัดจริง 17 → 10 → 6) | สนใจ ถ้าดู log |
| 12 | resize_to_limit: [400, 300] | ทำงานเหมือนกัน · โปรเจกต์เขียน [ 400, 300 ] มีเว้นวรรคตามรูปแบบของ rubocop · และต้องมีคลาส self-start ไม่งั้นรูปถูกยืดเป็น 720px | ไม่ต้อง |
| 12 | libvips "เร็วและกิน RAM น้อยกว่า ImageMagick" | เอกสาร Rails ให้ตัวเลข "เร็วได้ถึง 10 เท่า ใช้หน่วยความจำ 1/10" (ผมไม่ได้วัด) | ไม่ต้อง |
อ้างอิง
- Rails Guides — Active Storage Overview § Transforming Images — อ่านถึงก่อนหัวข้อ ImageMagick Security Policy · ยืนยันว่า variant ถูกทำตอนเบราว์เซอร์ขอ URL ครั้งแรก · อ่านต่อ § Lazy vs Immediate Loading สั้น ๆ ได้
- Rails Guides — Active Storage Overview § Requirements — ที่มาของ "ต้องติดตั้ง libvips เอง" และตัวเลข 10 เท่า · อ่านแค่ย่อหน้านี้
- Rails API — has_one_attached —
อ่านย่อหน้าเรื่อง
:dependent(ค่าเริ่มต้น:purge_later) และwith_attached_avatarที่กัน N+1 - Rails Guides — Configuring § config.active_storage.track_variants — ค่าตั้งที่เราปิด · อ่านแค่หัวข้อนี้ (บอกว่าค่าเริ่มต้นเป็น true ตั้งแต่ Rails 6.1)
- Rails Guides — Active Storage Overview § Removing Files และ
§ Purging Unattached Uploads —
purgeกับpurge_later· และบรรทัดunattached.where(created_at: ..2.days.ago)ที่ใช้ในบท - GitHub — rails/marcel — อ่านแค่ README ช่วงตัวอย่าง ที่เทียบการเรียกแบบให้แค่เนื้อ กับแบบให้ชื่อไฟล์/ชนิดที่ประกาศมาด้วย
- OWASP — File Upload Cheat Sheet § File Signature Validation — ตรวจ magic bytes · อ่านต่ออีกหัวข้อ Content-Type Validation ที่บอกว่า content type จาก client ปลอมได้ · ทั้งหน้ามีรายการป้องกันหลายชั้นถ้าอยากอ่านเพิ่ม
- GitHub — libvips/ruby-vips —
อ่านแค่ส่วน Install on linux and macOS: คำสั่ง
apt install libvips42/brew install vips - MDN — MIME types —
ป้าย
image/pngคืออะไร · อ่านถึงหัวข้อ MIME sniffing ก็พอ
สงสัยตรงไหน — เช่น "ทำไมไม่ตรวจไฟล์ในเบราว์เซอร์ด้วย JavaScript ไปเลย" หรือ "S3 ของจริงต้องเก็บกวาดแบบนี้ไหม" — ถามกลับมาได้เลย