บทเรียน 133 · Rails · Day 3

broadcast — คอมเมนต์เด้งขึ้นทุกแท็บ และ Turbo เหมาะกับงานแบบไหน

~22 นาที · ควรผ่าน บทเรียน 132 (Turbo Streams · ฟอร์มคอมเมนต์) และ บทเรียน 112 (callback · after_commit) มาก่อน

บท 132 เราส่งคอมเมนต์แล้วหน้าของเราเปลี่ยนทันที · แต่ถ้าเพื่อนเปิดบทความเดียวกันอยู่อีกเครื่อง เขาจะไม่เห็นอะไรเลยจนกว่าจะกด refresh
อ่านจบบทนี้ คุณจะทำให้คอมเมนต์ใหม่เด้งขึ้นเองในทุกแท็บที่เปิดบทความนั้นอยู่ ด้วยโค้ดแค่ 3 บรรทัด · อ่าน log ออกว่าข้อความวิ่งไปถึงใครบ้าง · รู้ว่าบรรทัดที่ดูง่ายนี้มี "ราคา" อะไร · และตอบได้ว่างานแบบไหนควรใช้ Turbo งานแบบไหนไม่ควร

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

🧰 ตรวจด้วย Ruby 4.0.9 · Rails 8.1.4 · turbo-rails 2.0.23 (Turbo 8.0.23) · PostgreSQL 18 (สไลด์และ repo ของอาจารย์ใช้ Ruby 4.0.5–4.0.6 · Rails 8.1.3 — รุ่นแก้บั๊กในสายเดียวกัน)

🗂 โค้ดเริ่มจาก: แอปหลังจบบท 132 (คอมเมนต์ + ฟอร์มที่ตอบด้วย Turbo Stream · ตามไม่ทันดูบท 132) · บทนี้เพิ่มโค้ดจริง 3 บรรทัดแล้วเก็บไว้ (commit ท้ายภารกิจ) · ส่วนการทดลองอื่น ๆ ในบทให้ git restore ทิ้ง · branch day3-c-done ของอาจารย์มีไว้ตามทันได้ แต่หน้าตาไฟล์ต่างจากบทเรียน (ใช้ i18n และข้อมูลคนละชุด)

ผลทุกก้อนรันจริงแล้วก็อปมา · ตัดบรรทัดที่ไม่เกี่ยวเป็น … และตัดคอมเมนต์ SQL /*application=…*/ ท้ายบรรทัดออก · เลขบทความ · เลขคอมเมนต์ · Job ID · เวลา (ms) · ชื่อช่องยาว ๆ ที่ขึ้นต้น Z2lk · ลายเซ็นหลัง -- ของคุณจะไม่ตรงกับในบท

ปัญหา: แท็บอื่นไม่รู้ว่ามีคอมเมนต์ใหม่

ลองนึกภาพ: คุณเปิดบทความ "ราดหน้าหมูหมัก" ไว้สองแท็บ · ส่งคอมเมนต์จากแท็บซ้าย แท็บซ้ายเปลี่ยนทันที (บท 132) · แต่แท็บขวานิ่งสนิท ต้องกด refresh เองถึงจะเห็น

เหตุผลอยู่ที่วิธีคุยของเว็บปกติ (HTTP): เบราว์เซอร์ต้องถามก่อน server ถึงจะตอบ · ถามหนึ่งครั้งได้คำตอบหนึ่งครั้ง แล้วจบ · server ไม่มีทางวิ่งไปบอกแท็บขวาเองได้ เพราะแท็บขวาไม่ได้ถามอะไรอยู่

สิ่งที่เราอยากได้เรียกว่า real-time — ข้อมูลใหม่วิ่งมาหาเราเองทันทีที่เกิด โดยเราไม่ต้องกดอะไร · ทางแก้คือให้เบราว์เซอร์ต่อสายค้างไว้กับ server ตั้งแต่เปิดหน้า · สายแบบนี้ชื่อ WebSocket · ต่อครั้งเดียวแล้วคุยได้สองทาง ใครมีเรื่องก็พูดได้เลย ไม่ต้องรออีกฝั่งถาม

📢 เทียบให้เห็นภาพ — ใบสั่งทีละใบ vs สายที่ต่อค้างไว้

HTTP ปกติเหมือนใบสั่งอาหาร: โต๊ะยื่นใบสั่ง → ครัวทำ → เสิร์ฟจาน → จบ · ครัวไม่เดินไปหาโต๊ะไหนเองถ้าโต๊ะนั้นไม่ได้สั่ง · โต๊ะขวาจะรู้ว่ามีเมนูใหม่ก็ต่อเมื่อยื่นใบสั่งไปถามเอง (กด refresh)

WebSocket เหมือนร้านที่ติดลำโพงเสียงตามสายไว้ทุกโต๊ะ · สายต่อค้างไว้ตลอดที่ลูกค้านั่งอยู่ · พอครัวมีข่าว ประกาศครั้งเดียว ทุกโต๊ะที่เปิดลำโพงได้ยินพร้อมกัน

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

เทียบกับฝั่ง React ที่เพิ่งเรียน: ใน บท 74 การ "เปิด WebSocket" คือตัวอย่างงานที่ต้องเขียนใน useEffect เอง พร้อม cleanup ตอนปิด · ใน Rails บทนี้เราไม่เขียน JavaScript สักบรรทัด — Turbo เปิดและปิดสายให้เอง

🗣 ศัพท์ชุดแรก
real-time
ข้อมูลใหม่ไปโผล่ที่หน้าจอคนอื่นทันทีโดยเขาไม่ต้องกดอะไร · พิมพ์บอก AI ได้ว่า “make new comments appear in real time for everyone viewing the post”
WebSocket
การเชื่อมต่อที่เปิดค้างไว้ระหว่างเบราว์เซอร์กับ server คุยได้สองทาง · ต่างจาก HTTP ที่ถามหนึ่งครั้งตอบหนึ่งครั้ง · ถาม senior ได้ว่า “ตอนขึ้น server จริง WebSocket ต้องตั้งค่าอะไรเพิ่มไหม”
Action Cable
ส่วนของ Rails ที่จัดการ WebSocket ให้ (รับสาย · จำว่าใครฟังช่องไหน · ส่งข้อความ) · เปิดอยู่ที่ path /cable โดยไม่ต้องแก้ routes · ถาม AI ได้ว่า “how does Action Cable know which browser is listening to which stream?”

สองชิ้นที่ต้องมี — คนประกาศ กับ โต๊ะที่เปิดลำโพง

real-time ใน Rails ต้องมีสองฝั่งเสมอ: ฝั่งที่ประกาศ (เกิดคอมเมนต์ใหม่ → ส่งข่าว) กับฝั่งที่ฟัง (หน้าเว็บที่รอรับข่าว) · ขาดฝั่งไหนก็ไม่ทำงาน · เราจะใส่ทีละฝั่ง แล้วดูผลจริงแต่ละขั้น

ชิ้นที่ 1 — คนประกาศ: broadcasts_to :post ใน model

เปิดไฟล์ app/models/comment.rb แล้วเพิ่ม 2 บรรทัดใต้ belongs_to :post พร้อมบรรทัดว่างคั่นก่อน validates ให้หน้าตาเป็นแบบนี้ (บรรทัดคอมเมนต์ # พิมพ์ตามไว้ด้วย ไฟล์จะได้ตรงกับบทเรียน):

class Comment < ApplicationRecord
  belongs_to :post

  # คอมเมนต์ใหม่เด้งขึ้นทุกแท็บที่เปิดหน้าโพสต์นี้ (ส่งต่อให้ job · หลัง commit)
  broadcasts_to :post

  validates :author_name, presence: true, length: { maximum: 50 }
  validates :body, presence: true, length: { maximum: 1000 }
end

ไม่ต้อง restart server — แก้ไฟล์ใน app/ ตอน development Rails โหลดใหม่ให้เอง

ตอนนี้มีแค่คนประกาศ ยังไม่ได้แตะหน้า show เลย · เปิดบทความเดียวกันสองแท็บ ส่งคอมเมนต์จากแท็บซ้าย — แท็บขวาจะเห็นคอมเมนต์ใหม่ไหม?

ไม่เห็น — ผลจริงจาก Chrome สองแท็บ (บทความเลข 30 · สถานะก่อนและหลังกดส่ง 2.5 วินาที):

before A {"marker":"A","count":"0","n":0,"ids":[],"source":false,"connected":null}
before B {"marker":"B","count":"0","n":0,"ids":[],"source":false,"connected":null}
after  A {"marker":"A","count":"1","n":1,"ids":["comment_1"],"source":false,"connected":null}
after  B {"marker":"B","count":"0","n":0,"ids":[],"source":false,"connected":null}

วิธีอ่าน: count = เลขในตัวนับ · n = จำนวนคอมเมนต์ในกล่อง · ids = id ของคอมเมนต์ในกล่อง · source / connected = หน้านี้มี "ลำโพง" หรือยัง และต่อสายติดไหม (เดี๋ยวรู้จัก) · marker = ป้ายที่เราแปะไว้ในหน้าด้วย JavaScript ก่อนกดส่ง ถ้าหน้าโหลดใหม่ป้ายจะหาย — ยังอยู่ทุกรอบ = ไม่มีแท็บไหนโหลดหน้าใหม่

แท็บซ้าย (A) ได้คอมเมนต์จากคำตอบของฟอร์มตามบท 132 · แท็บขวา (B) นิ่ง · (การทดลองแต่ละรอบในบทนี้ใช้บทความคนละเลข ตัวนับจะได้เริ่มที่ 0 ทุกรอบ)

แต่ใน log ของ server มีการประกาศจริง (ตัดท้ายบรรทัด):

Started POST "/posts/30/comments" for ::1 at …
…
[ActiveJob] [Turbo::Streams::ActionBroadcastJob] […] [ActionCable] Broadcasting to Z2lkOi8vd29uZ25vay1ibG9nLWFsbC1pbi1vbmUvUG9zdC8zMA: "<turbo-stream action=\"append\" target=\"comments\"…

Z2lkOi8v… ที่อ่านไม่ออกคือชื่อช่องของบทความ 30 ที่ถูกแปลงเป็นตัวอักษรชุดหนึ่ง (เดี๋ยวเจออีกในหัวข้อถัดไป) · ครัวประกาศแล้ว แต่ไม่มีโต๊ะไหนเปิดลำโพง · และไม่มี error ทั้งในเบราว์เซอร์และใน log — พังแบบเงียบ ๆ (สไลด์หน้า 53 บอกว่าเป็นจุดที่ AI ลืมบ่อย)

ชิ้นที่ 2 — เปิดลำโพง: turbo_stream_from @post ในหน้า show

เปิด app/views/posts/show.html.erb · หา <section class="mt-10 space-y-4"> (ส่วนความคิดเห็นจากบท 132) แล้วเพิ่ม 2 บรรทัดนี้ (บรรทัดที่สองคือบรรทัดว่าง) เป็นบรรทัดแรกในนั้น:

  <section class="mt-10 space-y-4">
    <%= turbo_stream_from @post %>

    <h2 class="text-xl font-bold">
      ความคิดเห็น (<span id="comment_count"><%= @post.comments.count %></span>)
    </h2>

ดูว่าเบราว์เซอร์ได้อะไรจริง (บทความเลข 23 ของเรา · ของคุณใช้เลขไหนก็ได้):

$ curl -s localhost:3000/posts/23 | grep -B1 -A4 turbo-cable-stream-source
  <section class="mt-10 space-y-4">
    <turbo-cable-stream-source channel="Turbo::StreamsChannel" signed-stream-name="IloybGtPaTh2ZDI5dVoyNXZheTFpYkc5bkxXRnNiQzFwYmkxdmJtVXZVRzl6ZEM4eU13Ig==--5bd6c5ff5bee9fefc0a058276b908435619e744c593415866dc65f0536ff2a9c"></turbo-cable-stream-source>

    <h2 class="text-xl font-bold">
      ความคิดเห็น (<span id="comment_count">1</span>)
    </h2>

ส่งคอมเมนต์จากแท็บซ้ายอีกครั้ง (บทความเลข 29) — ผลจริง:

before A {"marker":"A","count":"0","n":0,"ids":[],"source":true,"connected":true}
before B {"marker":"B","count":"0","n":0,"ids":[],"source":true,"connected":true}
after  A {"marker":"A","count":"1","n":1,"ids":["comment_2"],"source":true,"connected":true}
after  B {"marker":"B","count":"0","n":1,"ids":["comment_2"],"source":true,"connected":true}

แท็บขวา (after B) ได้คอมเมนต์ comment_2 โดยไม่ต้องกดอะไร และ marker ยังเป็น "B" = มาโดยไม่โหลดหน้าใหม่ 🎉 · แต่สังเกต "count":"0" — หัวข้อยังเขียนว่า "ความคิดเห็น (0)" ทั้งที่มีคอมเมนต์ 1 อัน · เพราะ broadcasts_to ประกาศแค่ "append คอมเมนต์" ไม่ได้ประกาศเรื่องตัวนับ · ต้องเพิ่มเอง (หัวข้อถัดไป)

📻 เทียบให้เห็นภาพ — ช่องประกาศของแต่ละเมนู

ร้านมีเสียงตามสายหลายช่อง ช่องละหนึ่งบทความ · broadcasts_to :post คือกฎของครัว: "มีคอมเมนต์ใหม่เมื่อไหร่ ประกาศในช่องของบทความนั้น" · turbo_stream_from @post คือโต๊ะที่หมุนลำโพงไปที่ช่องของบทความที่กำลังดู · ข่าวที่ประกาศเป็นใบสั่งงานแบบเดียวกับบท 132 พนักงานเสิร์ฟ (Turbo) ที่โต๊ะได้ยินแล้วเอาไปจัดจานตามนั้นทันที

ที่ไม่เหมือนกัน: ลำโพงจริงหมุนผิดช่องก็ยังได้ยินเสียงอะไรสักอย่าง · ในเว็บ ถ้าชื่อช่องสองฝั่งไม่ตรงกัน หรือลืมลำโพง ไม่มีเสียงอะไรเลย และไม่มีใครแจ้ง error — ต้องเปิดสองแท็บทดสอบเองถึงจะรู้

🗣 ศัพท์ของสองฝั่ง
broadcast
ส่งข้อความครั้งเดียวไปถึงทุกคนที่ฟังช่องนั้นอยู่ · broadcasts_to = ให้ model ประกาศเองทุกครั้งที่สร้าง/แก้/ลบ · พิมพ์บอก AI ได้ว่า “Comment should broadcast to its post's stream with broadcasts_to :post”
stream (ช่อง) · subscribe
stream คือชื่อช่องที่ใช้ประกาศ (ในที่นี้ = บทความหนึ่งชิ้น) · subscribe คือ "ขอฟังช่องนี้" ซึ่ง turbo_stream_from ทำให้ · ถาม senior ได้ว่า “stream ควรผูกกับ record ไหน ถ้าอยากให้คนละหน้าไม่ได้ยินกัน”
<turbo-cable-stream-source> · signed stream name
แท็กที่ turbo_stream_from สร้าง = ลำโพงในหน้าเว็บ · ชื่อช่องในแท็กมีลายเซ็นกันการปลอม · ใช้ตอนดีบัก: “the page has turbo-cable-stream-source but no connected attribute — why?”

ให้ตัวนับในแท็บอื่นขยับด้วย — บรรทัดที่สาม

กลับไปที่ app/models/comment.rb เพิ่มอีก 2 บรรทัดต่อจาก broadcasts_to :post (ก่อนบรรทัดว่าง) · ไฟล์ทั้งไฟล์จะเป็นแบบนี้ (ตรงกับโค้ดของบทเรียนทุกบรรทัด):

class Comment < ApplicationRecord
  belongs_to :post

  # คอมเมนต์ใหม่เด้งขึ้นทุกแท็บที่เปิดหน้าโพสต์นี้ (ส่งต่อให้ job · หลัง commit)
  broadcasts_to :post
  # ตัวนับในแท็บอื่นต้องเปลี่ยนตามด้วย
  after_create_commit -> { broadcast_update_later_to post, target: "comment_count", html: post.comments.count }

  validates :author_name, presence: true, length: { maximum: 50 }
  validates :body, presence: true, length: { maximum: 1000 }
end

แกะบรรทัดยาวทีละท่อน:

⚠️ html: ใส่ของลงใบสั่งตรง ๆ ไม่ใส่ซองใส (ไม่ escape — ทวน XSS บท 125) · เราลองส่ง html: "<b>ตัวหนา</b>" แล้ว log ได้ <template><b>ตัวหนา</b></template> เป็นแท็กจริง · ตัวเลขจาก count ปลอดภัย แต่ห้ามส่งข้อความที่ผู้ใช้พิมพ์ผ่าน html: — ของแบบนั้นให้ใช้ partial ซึ่ง escape ให้เอง

ทดลองรอบที่สาม (บทความเลข 28) — ผลจริง:

after  A {"marker":"A","count":"1","n":1,"ids":["comment_3"],"source":true,"connected":true}
after  B {"marker":"B","count":"1","n":1,"ids":["comment_3"],"source":true,"connected":true}

แท็บขวา "count":"1" แล้ว — ตัวนับขยับตาม

ไม่ต้องรัน tailwindcss:build ใหม่ (ไม่มีคลาส CSS ใหม่) และไม่มี gem หรือ migration ใหม่

แท็บที่กดส่งเอง ได้ใบสั่งสองใบ — ทำไมคอมเมนต์ไม่ซ้ำ

มีเรื่องแปลกที่ต้องสังเกต: แท็บซ้ายก็เปิดลำโพงอยู่เหมือนกัน · ตอนกดส่ง มันจึงได้ใบสั่ง "append คอมเมนต์" สองใบ — ใบแรกจากคำตอบของฟอร์ม (create.turbo_stream.erb บท 132) ใบที่สองจากเสียงตามสาย

แท็บซ้ายได้ append คอมเมนต์ใบเดียวกันสองรอบ — ในกล่องจะมีคอมเมนต์กี่อัน? แล้วถ้าลบ id="<%= dom_id comment %>" ออกจาก _comment.html.erb ล่ะ?

ผลจริง — เราดักดูว่ามีอะไรถูกใส่เข้ากล่อง #comments บ้าง (appended) เทียบกับของที่เหลือจริง:

after  A {"marker":"A","count":"1","n":1,"ids":["comment_3"],"source":true,"connected":true}
A appended ids: ["comment_3","comment_3"]

บรรทัดล่าง: comment_3 ถูกใส่เข้ากล่องสองรอบจริง · บรรทัดบน: แต่ในกล่องเหลืออันเดียว ("n":1)

พอลบ id ออกจาก partial ชั่วคราว (ใส่บั๊กกลับเพื่อพิสูจน์):

after  A {"marker":"A","count":"1","n":2,"ids":["",""],"source":true,"connected":true}
after  B {"marker":"B","count":"1","n":1,"ids":[""],"source":true,"connected":true}

แท็บซ้าย "n":2 = เห็นคอมเมนต์ซ้ำ 2 อัน (id ว่างทั้งคู่) · แท็บขวาปกติเพราะได้ใบสั่งใบเดียว

เพราะ Turbo มีกฎว่า ถ้าของใหม่มี id ซ้ำกับของที่อยู่ในกล่องแล้ว ให้เอาอันเก่าออกแล้วใส่อันใหม่แทน (เอกสาร Turbo หน้า Streams เขียนไว้ใต้หัวข้อ Append) · ป้ายเลขถ้วย comment_3 จึงช่วยให้ไม่ซ้ำ · ไม่มีป้าย Turbo เทียบไม่ได้ ก็ใส่ทั้งสองอัน
บทเรียน: dom_id ใน partial ไม่ใช่ของประดับ — มันคือสิ่งที่ทำให้ broadcast กับคำตอบฟอร์มอยู่ด้วยกันได้ · อย่าลืม git restore app/views/comments/_comment.html.erb ถ้าลองตาม

อ่าน log ของ server — ข่าววิ่งไปถึงใครบ้าง

ภาพรวมของสิ่งที่เกิดขึ้นตอนกดส่งหนึ่งครั้ง:

แท็บ A (คนกดส่ง) server (Rails) แท็บ B (เปิดดูเฉย ๆ) ─────────────── ────────────── ──────────────────── ① POST ฟอร์ม ────────────▶ บันทึกคอมเมนต์ → COMMIT │ after_create_commit ▼ ② ฝากงาน 2 ชิ้น (job) ◀──── ③ คำตอบของฟอร์ม │ "render _comment แล้วประกาศ" (append · ตัวนับ · │ "ประกาศตัวนับใหม่" ล้างฟอร์ม) ▼ ④ ประกาศในช่องของบทความ 23 ◀──────────── ⑤ ลำโพงแท็บ A ─┴─ ⑤ ลำโพงแท็บ B ─────────────▶ คอมเมนต์ + ตัวนับเปลี่ยน

log จริงของการกดส่งหนึ่งครั้ง (มีสองแท็บเปิดบทความ 23 อยู่ · ส่วนที่ตัดออกแทนด้วย …: บรรทัดที่ไม่เกี่ยว · บรรทัด ↳ · ท้ายบรรทัดที่ยาวเกิน · ชื่อช่องย่อเหลือ Z2lk…UG9zdC8yMw):

Started POST "/posts/23/comments" for ::1 at 2026-11-07 11:01:29 +0700
Processing by CommentsController#create as TURBO_STREAM
  …
  TRANSACTION (1.1ms)  BEGIN
  Comment Create (3.0ms)  INSERT INTO "comments" (…) VALUES ('สวัสดีจากแท็บซ้าย', 'ต้นกล้า', 23, …) RETURNING "id"
  TRANSACTION (1.2ms)  COMMIT
[ActiveJob] Enqueued Turbo::Streams::ActionBroadcastJob (Job ID: 1e8b4d1c-…) to Async(default) with arguments: "Z2lk…UG9zdC8yMw", {action: :append, target: "comments", …
  Comment Count (1.1ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 23
[ActiveJob] Enqueued Turbo::Streams::ActionBroadcastJob (Job ID: 09095142-…) to Async(default) with arguments: "Z2lk…UG9zdC8yMw", {action: :update, target: "comment_count", …
…
[ActiveJob] [Turbo::Streams::ActionBroadcastJob] [1e8b4d1c-…]   Rendered comments/_comment.html.erb (Duration: 0.3ms | GC: 0.0ms)
[ActiveJob] [Turbo::Streams::ActionBroadcastJob] [1e8b4d1c-…] [ActionCable] Broadcasting to Z2lk…UG9zdC8yMw: "<turbo-stream action=\"append\" target=\"comments\">…
…
Turbo::StreamsChannel transmitting "<turbo-stream action=\"append\" target=\"comments\"><template>…<article id=\"comment_8\" …
Turbo::StreamsChannel transmitting "<turbo-stream action=\"append\" target=\"comments\"><template>…<article id=\"comment_8\" …
  Rendering comments/create.turbo_stream.erb
  …
Completed 200 OK in 37ms (Views: 7.1ms | ActiveRecord: 5.9ms (4 queries, 1 cached) | GC: 0.0ms)
…
[ActiveJob] [Turbo::Streams::ActionBroadcastJob] [09095142-…] [ActionCable] Broadcasting to Z2lk…UG9zdC8yMw: "<turbo-stream action=\"update\" target=\"comment_count\"><template>1</template></turbo-stream>"
…
Turbo::StreamsChannel transmitting "<turbo-stream action=\"update\" target=\"comment_count\"><template>1</template></turbo-stream>" (via streamed from Z2lk…UG9zdC8yMw)
Turbo::StreamsChannel transmitting "<turbo-stream action=\"update\" target=\"comment_count\"><template>1</template></turbo-stream>" (via streamed from Z2lk…UG9zdC8yMw)

อ่านทีละกลุ่ม:

✅ วิธีดีบักที่ใช้ได้ตลอด: เห็น Broadcasting to แต่ไม่มี transmitting ตามมา = ประกาศแล้วแต่ไม่มีใครฟัง → ไปดูว่าหน้าเว็บมี turbo_stream_from ไหม และชื่อช่องตรงกันไหม · ตอนเปิดหน้า log ต้องมี Started GET "/cable" [WebSocket] และ Turbo::StreamsChannel is streaming from Z2lk… = ลำโพงต่อสายสำเร็จ

เบื้องหลังที่ Rails ทำให้ — เทียบกับการเขียนเองใน Go

สไลด์หน้า 45 เทียบว่าถ้าทำเรื่องเดียวกันใน Go ต้องเขียนเองหลายชิ้น (คอร์ส Go ของเราไม่ได้ทำ WebSocket — เล่าเป็นภาษาคนพอให้เห็นขนาดงาน):

งานใน Go (เช่นใช้ gorilla/websocket)ใน Rails บทนี้
เปิดทางให้เบราว์เซอร์ต่อสายเขียน endpoint รับ WebSocket เองAction Cable เปิด /cable ให้
จำว่าใครต่อสายอยู่บ้างเขียน hub (สมุดรายชื่อโต๊ะที่ต่อสาย) · register ตอนต่อ · unregister ตอนหลุด · heartbeat เช็คว่ายังอยู่Action Cable ทำให้ (log: is streaming from / stopped streaming from)
รูปแบบข้อความออกแบบเอง (เช่น JSON)ใบสั่ง Turbo Stream (HTML)
ฝั่งเบราว์เซอร์เขียน JS รับข้อความแล้วไปแก้ HTML ในหน้าเองTurbo ทำให้ · เราเขียน JS 0 บรรทัด
มี server หลายเครื่องต้องมี Redis pub/sub ให้ทุกเครื่องได้ยินกันเปลี่ยน adapter ใน config/cable.yml (ด้านล่าง)

แถวสุดท้ายสำคัญ: ถ้าแอปรันบน server สองเครื่อง แท็บ A ต่อสายกับเครื่อง 1 แท็บ B ต่อกับเครื่อง 2 · ประกาศที่เกิดในเครื่อง 1 ต้องมีสถานีกลางส่งต่อให้เครื่อง 2 ด้วย · สถานีกลางนี้เรียกว่า pub/sub (publish = ประกาศ · subscribe = ขอฟัง) · ใน Go นิยมใช้ Redis (บท 32 ใช้ Redis ทำ cache — คนละงานแต่ตัวเดียวกัน)

Rails เรียกสถานีกลางนี้ว่า adapter และตั้งไว้ใน config/cable.yml · เปิดไฟล์จริงในโปรเจกต์ (ไม่ต้องแก้):

# Async adapter only works within the same process, so for manually triggering cable updates from a console,
# and seeing results in the browser, you must do so from the web console (running inside the dev process),
# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view
# to make the web console appear.
development:
  adapter: async

test:
  adapter: test

production:
  adapter: solid_cable
  connects_to:
    database:
      writing: cable
  polling_interval: 0.1.seconds
  …

ผลจริง: เปิดแท็บดูบทความ 26 ไว้ แล้วสร้างคอมเมนต์จาก terminal อีกหน้าต่างด้วย bin/rails runner '…' (คำสั่งที่รันโค้ด Ruby ในแอปครั้งเดียวแล้วจบ เหมือนพิมพ์ใน console หนึ่งบรรทัด = อีก process หนึ่ง แยกจาก server):

B before {"count":"0","n":0}
saved
B after 3s {"count":"0","n":0}       ← ไม่เด้ง
B after reload {"count":"1","n":1}   ← คอมเมนต์อยู่ในฐานข้อมูลจริง กด refresh ถึงเห็น
🏢 เทียบให้เห็นภาพ — สถานีกลางของเสียงตามสาย

async เหมือนร้านสาขาเดียวที่ไมค์ต่อตรงกับลำโพงในร้าน · คนที่พูดจากตึกข้าง ๆ (terminal อีกหน้าต่าง) ไมค์ไม่ได้ต่อกับลำโพงร้านเรา ลูกค้าเลยไม่ได้ยิน · Solid Cable เหมือนร้านหลายสาขาที่ตกลงกันว่าจะเขียนประกาศลงกระดานกลาง แล้วทุกสาขาส่งคนไปดูกระดานทุก 0.1 วินาที เจอข่าวใหม่ก็ประกาศในสาขาตัวเอง

ที่ไม่เหมือนกัน: คนเดินไปดูกระดานทุก 0.1 วินาทีฟังดูเหนื่อยและช้า แต่คอมพิวเตอร์ทำได้สบาย · สไลด์อ้างว่าความหน่วงอยู่ระดับไม่กี่สิบมิลลิวินาทีและพอสำหรับงานส่วนใหญ่ — เราไม่ได้วัดใน production เอง · และ Redis ไม่ได้ "ดูกระดาน" แต่ส่งข่าวต่อทันที — แลกกับต้องมีอีกโปรแกรมให้ดูแล

🗣 ศัพท์ฝั่ง server
pub/sub (publish/subscribe)
รูปแบบที่ฝั่งหนึ่งประกาศในช่อง อีกฝั่งขอฟังช่อง โดยสองฝั่งไม่ต้องรู้จักกัน · ถาม senior ได้ว่า “ตอนนี้ pub/sub ของ Action Cable เราใช้ adapter อะไรใน production”
cable adapter · async · Solid Cable
adapter = สถานีกลางที่ Action Cable ใช้ส่งข่าวข้าม server · async = เก็บในหน่วยความจำ ใช้ได้แค่ process เดียว (development) · Solid Cable = ใช้ตารางในฐานข้อมูล (production ของ Rails 8) · พิมพ์บอก AI ได้ว่า “we use the async cable adapter in development, so broadcasts from bin/rails console won't reach the browser”
hub (ในโลก Go)
โค้ดที่เก็บรายชื่อการเชื่อมต่อทั้งหมดแล้วกระจายข้อความ · Action Cable คือ hub สำเร็จรูป · ใช้อธิบายให้คนสาย Go ฟังว่า “Action Cable is the hub, Turbo is the client”

ราคาของบรรทัดเดียว — ข้อควรระวัง

สไลด์หน้า 46 เตือนว่า broadcasts_to คือ callback ใน model ที่ไปทำเรื่องของคนอื่น · ทวนบท 112: callback ที่ทำเรื่องของตัวเอง (เช่นสร้าง slug) ใช้สบาย · แต่ callback ที่แตะของนอกตัว (ส่งอีเมล · ประกาศ) เริ่มมีผลข้างเคียง · บรรทัดนี้ทำ 2 เรื่องที่ไม่ใช่งานของคนดูแลสต็อก: จัดจาน (render partial ของ view) และประกาศ

ราคาที่ 1 — สร้างข้อมูลเยอะ ๆ = ประกาศเยอะ ๆ

callback ทำงานทุกครั้งที่สร้างคอมเมนต์ ไม่ว่าจะมาจากหน้าเว็บ · seed · หรือสคริปต์นำเข้าข้อมูล · เราลองสร้างคอมเมนต์ 100 อันด้วย bin/rails runner แล้วนับจาก log: มีงานประกาศถูกฝากไว้ 200 ชิ้น (2 ชิ้นต่อคอมเมนต์ — รายการ + ตัวนับ) · สไลด์ยกตัวอย่าง 10,000 แถว = ประกาศ 10,000 ครั้ง (คิดตามสัดส่วนที่วัดได้ ของเราจะเป็น 20,000 งาน เพราะมีบรรทัดตัวนับด้วย)

ทางแก้ตอน seed/import คือห่อด้วย suppressing_turbo_broadcasts = "ช่วงนี้ห้ามประกาศ" · ลองใน bin/rails console (ผลจริง ตัดบางส่วนเป็น …):

wongnok-blog-all-in-one(dev):001> Comment.suppressing_turbo_broadcasts { Post.find(25).comments.create!(author_name: "seed", body: "เงียบ") }; nil
  Post Load (2.0ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = 25 LIMIT 1
  TRANSACTION (0.7ms)  BEGIN
  Comment Create (1.4ms)  INSERT INTO "comments" (…) VALUES ('เงียบ', 'seed', 25, …) RETURNING "id"
  TRANSACTION (0.5ms)  COMMIT
  Comment Count (0.4ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 25
=> nil
wongnok-blog-all-in-one(dev):002> Post.find(25).comments.create!(author_name: "ปกติ", body: "ดัง"); nil
  Post Load (3.0ms)  SELECT "posts".* FROM "posts" WHERE "posts"."id" = 25 LIMIT 1
  TRANSACTION (1.4ms)  BEGIN
  Comment Create (3.6ms)  INSERT INTO "comments" (…) VALUES ('ดัง', 'ปกติ', 25, …) RETURNING "id"
  TRANSACTION (1.7ms)  COMMIT
Enqueued Turbo::Streams::ActionBroadcastJob (Job ID: b1ac1546-…) to Async(default) with arguments: "Z2lk…UG9zdC8yNQ", {action: :append, target: "comments", …
↳ (wongnok-blog-all-in-one):2:in '<main>'
  Comment Count (1.1ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."post_id" = 25
Enqueued Turbo::Streams::ActionBroadcastJob (Job ID: b1b59b6c-…) to Async(default) with arguments: "Z2lk…UG9zdC8yNQ", {action: :update, target: "comment_count", …, html: 2, …
↳ app/models/comment.rb:7:in 'block in <class:Comment>'
=> nil
…

ราคาที่ 2 — model รู้จัก view

ปกติคนดูแลสต็อก (model) ไม่ยุ่งกับการจัดจาน (view) · แต่ broadcasts_to ทำให้ model ไปเรียก partial comments/_comment เอง · ผลที่สไลด์เตือน: เปลี่ยนชื่อ partial แล้ว model พังโดยไม่รู้ตัว · test ของ model ต้อง render HTML ด้วยจึงช้าลง (เรื่อง test อยู่ Day 4)

ทางเลือกที่สไลด์เสนอ: ไม่ใส่ใน model แต่ให้ controller เป็นคนสั่งประกาศ หลังบันทึกสำเร็จ — seed/console ไม่โดนลูกหลง เพราะไม่ได้ผ่าน controller · หน้าตาประมาณนี้ (เราลองเรียกคำสั่งนี้จาก runner แล้วได้ Broadcasting to จริง · บทเรียนไม่ได้เปลี่ยนไปใช้แบบนี้ ไม่ต้องพิมพ์ตาม · สไลด์เขียน broadcast_append_to ซึ่งทำ HTML และประกาศทันทีในคำขอ · ตรงนี้ใช้รุ่น _later_ ให้ส่งต่อให้ job ตามหลักเดียวกับ broadcasts_to):

# ใน CommentsController#create หลัง @comment.save สำเร็จ (ตัวอย่างเท่านั้น)
Turbo::StreamsChannel.broadcast_append_later_to(
  @post, target: "comments", partial: "comments/comment", locals: { comment: @comment }
)

ข้อดี: อ่าน controller แล้วรู้เลยว่าใครสั่งประกาศ · ข้อเสีย: ทุกที่ที่สร้างคอมเมนต์ (เช่นหน้า admin ในอนาคต) ต้องจำใส่เอง · ไม่มีคำตอบเดียว — สไลด์สรุปว่า "ความสะดวกบรรทัดเดียวมีราคาเสมอ แค่ต้องรู้ว่าราคาคืออะไร"

ทำไมต้องหลัง commit — และ later: true ในสไลด์

broadcasts_to และบรรทัดตัวนับของเราใช้ after_*_commit ทั้งหมด · ทวนบท 112: ถ้าประกาศตอน after_save (ก่อน COMMIT) แล้ว transaction ถูกยกเลิก ทุกแท็บจะเห็นคอมเมนต์ที่ไม่มีอยู่จริง — เหมือนส่งไลน์บอกลูกค้าก่อนตู้เซ็นรับ · เราลองสร้างคอมเมนต์ใน transaction แล้วสั่ง raise ActiveRecord::Rollback (คำสั่ง "ยกเลิกทั้งกล่อง" ของ transaction): log มี BEGIN → ROLLBACK และไม่มี Enqueued สักบรรทัด = ไม่ประกาศของที่ถูกยกเลิก ✅

⚠️ สไลด์หน้า 46 และ 52 ให้เขียน broadcasts_to :post, later: true · ใน turbo-rails 2.0.23 ที่เราใช้ broadcasts_to ไม่มีตัวเลือก later: · ใส่แล้วไม่ error แต่ไม่มีผลอะไร (มันใช้ job ให้อยู่แล้วตอนสร้างและแก้ — เห็น Enqueued ใน log ข้างบน) · บทเรียนจึงไม่ใส่ — รายละเอียดท้ายบท

morphing และ data-turbo-permanent — refresh ทั้งหน้าแบบไม่สะดุด

สไลด์หน้า 47 พูดถึงอีกความสามารถหนึ่งของ Turbo 8 (รุ่นที่เราใช้) ซึ่งไม่ได้อยู่ในโค้ดของบทเรียน แต่ควรรู้จัก · ก่อนอื่นต้องรู้จัก page refresh = Turbo โหลดหน้าเดิมซ้ำ · เกิดบ่อยตอนส่งฟอร์มแล้ว server พากลับมาหน้าเดิม (redirect) หรือตอน server ประกาศว่า "ทุกคนโหลดหน้านี้ใหม่" (คำสั่ง broadcasts_refreshes_to — อีกทางเลือกแทน broadcasts_to ไม่ได้ใช้ในบทนี้)

ปกติ Turbo เปลี่ยนทั้ง <body> (บท 130) → หน้าเลื่อนกลับไปบนสุด ช่องที่กำลังพิมพ์หลุด · ถ้าใส่ 2 บรรทัดนี้ใน <head> ของ layout Turbo จะเทียบหน้าเก่ากับหน้าใหม่ทีละชิ้น แล้วแก้เฉพาะจุดที่ต่าง (เรียกว่า morphing):

<meta name="turbo-refresh-method" content="morph">     <%# refresh แบบเทียบแล้วแก้เฉพาะที่ต่าง %>
<meta name="turbo-refresh-scroll" content="preserve">  <%# refresh แล้วไม่เลื่อนกลับบนสุด %>

และถ้ามีชิ้นไหนที่ห้ามแตะเด็ดขาด (เช่นเครื่องเล่นเพลงที่กำลังเล่น) ให้ติด data-turbo-permanent พร้อม id:

<div id="player" data-turbo-permanent>
  <audio controls src="…"></audio>
</div>

เปิดหน้า morph แล้ว · กำลังพิมพ์คอมเมนต์ค้างไว้ (ยังไม่กดส่ง) แล้วหน้าเกิด refresh — ข้อความที่พิมพ์ค้างจะอยู่ไหม?

เราทดลองบนหน้าบทความ (ใส่ meta ชั่วคราว · พิมพ์ในช่องความคิดเห็น · เลื่อนจอลง · สั่ง refresh ด้วย Turbo.session.refresh(location.href) ใน Chrome) — ผลจริง:

แบบข้อความที่พิมพ์ค้างเคอร์เซอร์ยังอยู่ในช่องตำแหน่งเลื่อนจอ
ปกติ (ไม่มี meta)❌ หาย❌ หลุด❌ กลับบนสุด (297 → 0 · ตัวเลขขึ้นกับขนาดหน้าต่าง)
morph + preserve❌ หาย✅ อยู่✅ คงที่ (297)
morph + ติด data-turbo-permanent ที่ช่องพิมพ์ (ตอนทดลองติดด้วย JavaScript ก่อนสั่ง refresh)✅ อยู่✅ อยู่✅ คงที่

morph ทำให้หน้าตรงกับ HTML ใหม่จาก server · HTML ใหม่มีช่องความคิดเห็นว่าง ๆ → morph จึงทำให้ช่องเราว่างตาม (แต่ยังเป็นช่องเดิม เคอร์เซอร์ไม่หลุด) · data-turbo-permanent คือการบอกว่า "ชิ้นนี้ไม่ต้องเทียบ ปล่อยไว้แบบที่ผู้ใช้ทำ" ข้อความจึงรอด · สรุป: morph ช่วยเรื่องตำแหน่งและ focus · ของที่ผู้ใช้กรอกค้างต้องกันเองด้วย permanent

🍽 เทียบให้เห็นภาพ — เก็บโต๊ะใหม่ทั้งโต๊ะ vs เทียบทีละจาน

refresh แบบปกติเหมือนพนักงานยกของบนโต๊ะออกหมดแล้ววางชุดใหม่ · morph เหมือนพนักงานถือรายการใหม่มาเทียบกับโต๊ะทีละจาน จานไหนเหมือนเดิมไม่แตะ จานไหนต่างค่อยเปลี่ยน ลูกค้าแทบไม่รู้สึก · data-turbo-permanent คือป้าย "ของลูกค้า ห้ามเก็บ" ที่วางบนแก้วของเขาเอง

ที่ไม่เหมือนกัน: พนักงานจริงเห็นกระดาษที่ลูกค้าเขียนค้างไว้ก็คงไม่ลบ · morph ไม่รู้ว่าอะไรคือ "ของที่ลูกค้ากำลังทำ" มันทำให้ตรงกับรายการใหม่เป๊ะ ๆ · ต้องติดป้าย permanent เองทุกชิ้นที่อยากให้รอด

ลองเองได้ (ไม่บังคับ): ใส่ 2 บรรทัด <meta> ข้างบนไว้ใต้ <%= yield :head %> ใน app/views/layouts/application.html.erb · พิมพ์ในช่องความคิดเห็น เลื่อนจอลง เปิด DevTools แท็บ Console แล้วพิมพ์ Turbo.session.refresh(location.href) (JavaScript หนึ่งบรรทัด: "Turbo ช่วย refresh หน้านี้" · location.href = URL ของหน้าที่เปิดอยู่) · ดูเสร็จแล้ว git restore app/views/layouts/application.html.erb — โค้ดของบทเรียนไม่ได้ใช้ morph

🗣 ศัพท์ของ refresh
page refresh · morphing
page refresh = Turbo โหลดหน้าเดิมซ้ำ · morphing = แทนที่จะเปลี่ยนทั้ง body ให้เทียบทีละชิ้นแล้วแก้เฉพาะที่ต่าง (Turbo ใช้ library ชื่อ idiomorph) · พิมพ์บอก AI ได้ว่า “enable morphing with turbo-refresh-method=morph and preserve scroll on refresh”
data-turbo-permanent
ติดที่ element ที่มี id เพื่อบอก Turbo ว่าห้ามแตะชิ้นนี้ (ทั้งตอน morph และตอนเปลี่ยนหน้า) · ถาม senior ได้ว่า “ช่องไหนในหน้านี้ควรเป็น permanent ถ้าเปิด morph”

Turbo เหมาะกับงานแบบไหน

หลักง่าย ๆ: Turbo เก่งเมื่อ server เป็นคนตัดสินว่าหน้าจอควรเป็นแบบไหน และการรอ server ตอบสักครู่ไม่เป็นปัญหา · ทุกอย่างที่เราทำมาตั้งแต่บท 130 อยู่ในหลักนี้ — ข้อมูลชุดเดียวอยู่ที่ครัว (ประโยคหลักข้อ 1 ของวัน: ไม่มีสมุดจดเล่มที่สองบนโต๊ะ ก็ไม่มีบั๊ก sync)

✅ เหมาะ (สไลด์หน้า 47)ทำไม
รายการที่เพิ่ม ลบ แก้ (CRUD ทั่วไป)ทุกการเปลี่ยนต้องผ่าน server อยู่แล้ว
ฟอร์มและ validation errorกฎตรวจอยู่ที่ model บน server (บท 132 แทนฟอร์มที่มี error)
แจ้งเตือน · ตัวนับ · สถานะที่เปลี่ยนเป็นครั้งคราวเปลี่ยนไม่บ่อย ส่ง HTML ชิ้นเล็กพอ
แชท · คอมเมนต์ · กระดานงานคือสิ่งที่เพิ่งทำในบทนี้
dashboard ที่อัปเดตทุกไม่กี่วินาทีไม่กี่วินาทีครั้ง = server ตามทัน
❌ ไม่เหมาะ (สไลด์หน้า 47)ทำไม
ลากวาง เรียงลำดับด้วยเมาส์ต้องขยับตามมือทุกเสี้ยววินาที รอ server ไม่ทัน
canvas (พื้นที่วาดรูปในหน้าเว็บ) · แผนที่ · กราฟที่ซูมและลากเลื่อนได้หน้าจอเปลี่ยนตามเมาส์ล้วน ๆ ไม่ใช่ HTML จาก server
เห็น cursor ของคนอื่นขยับแบบ Figmaข้อความถี่มาก ส่งเป็นใบสั่ง HTML หนักเกินไป
แอปที่ต้องทำงานตอนไม่มีเน็ตTurbo ต้องถาม server ทุกครั้ง ไม่มีเน็ต = ไม่มีครัว
ของที่ต้องตอบสนองทุกครั้งที่กดแป้นพิมพ์ โดยไม่รอ serverเช่นตัวนับอักษรตอนพิมพ์ — ไม่ต้องถามครัวเลย

งานแบบตารางที่สอง ถ้าเป็นชิ้นเล็ก ๆ ในหน้า (เช่นตัวนับอักษร · ปุ่มคัดลอก) ใช้ Stimulus = JavaScript ชิ้นเล็กที่ HTML เป็นคนเรียก (บท 134) · ถ้าทั้งแอปเป็นแบบนั้น (เช่นโปรแกรมวาดรูป) เครื่องมืออย่าง React ที่เรียนในฝั่ง FRONT จะเหมาะกว่า

ให้ AI ทำ แล้วตรวจยังไง

ตอนนี้คุณทำเองเป็นแล้ว · ถ้าจะสั่ง AI ทำ real-time ให้ส่วนอื่น ใช้ prompt 4 ส่วน (บท 89 · 121) แบบนี้ — อย่าก็อป later: true จากสไลด์ไปด้วย:

## เป้าหมาย
คอมเมนต์ใหม่เด้งขึ้นทุกแท็บที่เปิดหน้าบทความเดียวกัน · ตัวนับเปลี่ยนตาม

## ข้อกำหนด
- ใช้ broadcasts_to :post ใน app/models/comment.rb
- ใส่ turbo_stream_from @post ใน app/views/posts/show.html.erb
- partial comments/_comment ต้องมี id="<%= dom_id comment %>"

## ห้าม
- ใช้ after_save (ต้องเป็น after_commit)
- ส่งข้อความที่ผู้ใช้พิมพ์ผ่าน html:
- เขียน JavaScript เอง

## เกณฑ์ตรวจรับ
- [ ] เปิดสองหน้าต่างวางข้างกัน พิมพ์หน้าซ้าย หน้าขวาขึ้นเองทั้งคอมเมนต์และตัวนับ
- [ ] หน้าที่กดส่งเองไม่มีคอมเมนต์ซ้ำ

Checklist ตรวจงาน AI (ทำเองได้ทุกข้อ ไม่ต้องเชื่อคำบอกของ AI):

🎯 ภารกิจ: เปิดสองหน้าต่างวางข้างกัน แล้วพิมพ์ (~10 นาที)

  1. ถ้ายังไม่ได้ทำตามบท: แก้ app/models/comment.rb ให้ตรงกับไฟล์เต็มในหัวข้อ "ให้ตัวนับในแท็บอื่นขยับด้วย" และเพิ่ม <%= turbo_stream_from @post %> ในหน้า show ตามหัวข้อ "ชิ้นที่ 2"
  2. เปิด server ด้วย bin/dev (ถ้ายังไม่ได้เปิด)
  3. เปิดสองหน้าต่างเบราว์เซอร์ (ไม่ใช่สองแท็บในหน้าต่างเดียว — จะได้เห็นพร้อมกัน) ไปที่ http://localhost:3000/posts/1 ทั้งคู่ แล้ววางซ้าย–ขวา
  4. พิมพ์ชื่อและความคิดเห็นในหน้าต่างซ้าย กดส่ง · มองหน้าต่างขวา
  5. ดู terminal ที่รัน bin/dev หาบรรทัด Broadcasting to และนับ transmitting
  6. ถ้าทุกอย่างถูก: git add . && git commit -m "hotwire: broadcast คอมเมนต์ทุกแท็บ"

ควรเห็น: หน้าต่างขวามีคอมเมนต์ใหม่ต่อท้าย และเลขใน "ความคิดเห็น (…)" เพิ่มขึ้น 1 เองโดยไม่ต้องกด refresh · หน้าต่างซ้ายมีคอมเมนต์อันเดียว · log มี Broadcasting to 2 บรรทัด (รายการ · ตัวนับ) และแต่ละอันตามด้วย transmitting 2 บรรทัด (สองหน้าต่าง)

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

ก่อน commit ตรวจว่าเปลี่ยนแค่ 2 ไฟล์ 7 บรรทัด (ผลจริงจากโค้ดของบทเรียน):

$ git diff --stat
 app/models/comment.rb         | 5 +++++
 app/views/posts/show.html.erb | 2 ++
 2 files changed, 7 insertions(+)

ถ้าตัวเลขต่าง (เช่นมี layouts/application.html.erb หรือ _comment.html.erb โผล่มา) แปลว่ายังมีของจากการทดลองค้างอยู่ — git restore <ไฟล์นั้น> ก่อน commit · ไม่แน่ใจว่าหน้าต่างขวาขึ้นเพราะ broadcast หรือเพราะเผลอ refresh: ลองพิมพ์อะไรสักตัวในช่องชื่อของหน้าต่างขวาไว้ก่อน แล้วส่งจากหน้าต่างซ้าย · ถ้าตัวอักษรในหน้าต่างขวายังอยู่ แปลว่าหน้าไม่ได้โหลดใหม่ คอมเมนต์มาทางเสียงตามสายจริง (เราลองแล้ว: พิมพ์ค้างในช่องชื่อของแท็บขวา → คอมเมนต์ใหม่โผล่ + ตัวนับเปลี่ยน + ข้อความในช่องชื่อยังอยู่ครบ)

Quiz

1. ส่งคอมเมนต์จากแท็บ A แล้วแท็บ B ไม่ขยับ · ใน log เห็น Broadcasting to Z2lk… แต่ไม่มี transmitting เลย · สาเหตุที่น่าจะเป็นที่สุดคือ?

ตัวเลือกที่ 1 ผิด เพราะเห็น Broadcasting to แปลว่า model ประกาศแล้ว · ตัวเลือกที่ 2 ผิด เพราะ broadcasts_to ใช้ after_*_commit ให้เองอยู่แล้ว และต่อให้ใช้ after_save ข่าวก็ยังถูกส่ง ไม่ได้หาย · ตัวเลือกที่ 4 ผิด เพราะ development โหลดไฟล์ใน app/ ใหม่ให้เอง (เราทดลองทั้งบทโดยไม่ restart) · ตัวเลือกที่ 3 ถูก: transmitting คือการส่งถึงลำโพงแต่ละตัว ไม่มีเลย = ไม่มีลำโพง

จุดที่ต้องจำ: real-time ต้องมีสองฝั่ง — คนประกาศ (model) กับคนฟัง (turbo_stream_from ใน view) · ขาดฝั่งฟังแล้วพังเงียบ

2. (ดัดแปลงจากสไลด์หน้า 51) "เพิ่มคอมเมนต์แล้วหน้าเราเปลี่ยน 3 จุด" (บท 132) กับ "คนอื่นเพิ่มคอมเมนต์แล้วหน้าเราขึ้นเอง" (บทนี้) ใช้ Turbo Stream ทั้งคู่ · ต่างกันตรงไหน?

ตัวเลือกที่ 1 ผิด เพราะทั้งคู่เป็นใบสั่ง <turbo-stream> ที่เป็น HTML เหมือนกัน (เห็นใน log) · ตัวเลือกที่ 3 ผิด เพราะทั้งสองทางใช้คำสั่งได้เหมือนกัน — บทนี้เองก็ broadcast ทั้ง append และ update · ตัวเลือกที่ 4 สลับกัน: แบบที่สองต่างหากที่ต้องมี turbo_stream_from · แบบแรกเป็นคำตอบของฟอร์มจึงไม่ต้องฟังอะไร · ตัวเลือกที่ 2 ถูก: ของข้างในเหมือนกัน ต่างแค่ทางที่มันเดินทางมา

จุดที่ต้องจำ: Turbo Stream คือ "ใบสั่งงาน" · ส่งได้สองทาง — เป็นคำตอบของคำขอ (ถึงคนที่ถามคนเดียว) หรือ broadcast ผ่าน WebSocket (ถึงทุกคนที่ฟังช่องนั้น)

3. ทำไมการประกาศคอมเมนต์ใหม่ต้องเกิดหลัง COMMIT (after_commit) ไม่ใช่ตอน after_save?

ตัวเลือกที่ 2 ผิด เพราะ after_save เกิดก่อน after_commit ไม่ได้ช้ากว่า · ตัวเลือกที่ 3 ผิด เพราะ after_save ใช้กับ model ไหนก็ได้ · ตัวเลือกที่ 4 ผิด เพราะ WebSocket ไม่รู้จักฐานข้อมูลเลย ใครสั่งส่งมันก็ส่ง · ตัวเลือกที่ 1 ถูก: เราลอง ROLLBACK แล้วไม่มีการประกาศ เพราะ after_commit ไม่ทำงานเมื่อกล่องถูกยกเลิก

จุดที่ต้องจำ: เรื่องที่ออกไปนอกร้าน (ประกาศ · อีเมล) ทำหลังตู้เซ็นรับเท่านั้น — ส่งออกไปแล้วดึงกลับไม่ได้

4. งานไหนไม่เหมาะจะทำด้วย Turbo มากที่สุด?

ตัวเลือกที่ 1 เหมาะ — เปลี่ยนเป็นครั้งคราว ข้อมูลอยู่ที่ server (เหมือนคอมเมนต์ในบทนี้) · ตัวเลือกที่ 2 เหมาะ — กฎตรวจอยู่บน server ส่งฟอร์มที่มี error กลับมาแทนที่ (บท 132) · ตัวเลือกที่ 3 เหมาะ — ทุกสองสามวินาที server ตามทัน · ตัวเลือกที่ 4 ไม่เหมาะ: ต้องตอบสนองตามมือทุกเสี้ยววินาที รอ server ไม่ได้ และหน้าจอไม่ได้มาจาก HTML ของ server

จุดที่ต้องจำ: Turbo เหมาะเมื่อ server เป็นคนตัดสินหน้าจอและรอสักครู่ได้ · งานที่ต้องตอบทุกการขยับของมือ ใช้ JavaScript ฝั่งเบราว์เซอร์

💬 ถามกลับได้เลย — เช่น "ถ้าอยากให้คอมเมนต์ใหม่ขึ้นบนสุดแทนต่อท้าย ต้องแก้ตรงไหน" หรือ "ถ้าลบคอมเมนต์ ตัวนับในแท็บอื่นจะลดไหม" (ใบ้: ดูว่า broadcasts_to ทำอะไรตอนลบ และบรรทัดตัวนับของเราเป็น after_create_commit)

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

หน้าสไลด์เขียนของจริงต้องสนใจไหม
46 · 52broadcasts_to :post, later: true ใน turbo-rails 2.0.23 broadcasts_to รับแค่ inserts_by: · target: และตัวเลือกของ partial · later: true ตกไปเป็นตัวเลือกของ partial ซึ่งไม่มีผล · ตอนสร้าง/แก้ใช้ job (*_later_to) ให้อยู่แล้ว (ลองทั้งแบบใส่และไม่ใส่ log ได้ Enqueued เหมือนกัน) สนใจ — อย่าก็อปไปใส่ prompt · หลักการ "ส่งต่อให้ job" ยังถูก แค่มันทำให้เองแล้ว
45Solid Cable — Rails 8 ใช้ตาราง PostgreSQL เป็นช่องทาง pub/sub จริงเฉพาะ production · config/cable.yml ของ repo นี้ development ใช้ async (ในหน่วยความจำ) สนใจ — เป็นเหตุผลที่สร้างคอมเมนต์จาก console แล้วไม่เด้ง
45latency "ไม่กี่สิบมิลลิวินาที"ไม่ได้วัดใน production · config ตั้งให้ดูตารางทุก 0.1 วินาทีไม่ต้อง
45<%= render @post.comments %>ของเราใช้ render @comments (เรียงตามเวลา จากบท 132)ไม่ต้อง
52เกณฑ์ "ตัวนับจำนวน comment อัปเดตพร้อมกัน"broadcasts_to อย่างเดียวไม่ทำ (ผลจริง: ตัวนับแท็บขวาค้าง 0) ต้องเพิ่มบรรทัด after_create_commit เองสนใจ
47morph — "ช่องที่กำลังพิมพ์อยู่ไม่เสีย focus"focus กับตำแหน่งจออยู่จริง แต่ข้อความที่พิมพ์ค้างหาย ถ้าไม่ติด data-turbo-permanent (ผลทดลองในบท)สนใจถ้าจะเปิด morph

อ้างอิง