<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>模組二：WebSocket 服務架構 on Tarragon</title><link>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/</link><description>Recent content in 模組二：WebSocket 服務架構 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 22 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/index.xml" rel="self" type="application/rss+xml"/><item><title>2.1 read pump / write pump 模式</title><link>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/read-write-pump/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/read-write-pump/</guid><description>&lt;p>Read pump / write pump 的核心規則是單一 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> 連線的讀取與寫入必須分成兩個協調的 goroutine。Read pump 擁有讀取權，write pump 擁有寫入權；其他元件不直接操作底層 connection，而是透過 channel 或 method 協作。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 read pump、write pump、hub 的責任&lt;/li>
&lt;li>避免多 goroutine 同時寫同一條 WebSocket connection&lt;/li>
&lt;li>用 send channel 作為 server-to-client 推送邊界&lt;/li>
&lt;li>設計 client unregister 與 close path&lt;/li>
&lt;li>用 fake router 測試 read pump 的行為邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察websocket-是一條長生命週期雙向連線">【觀察】WebSocket 是一條長生命週期雙向連線&lt;/h2>
&lt;p>WebSocket 連線的核心特徵是 client 和 server 都可能主動送訊息。Client 可能送 subscribe、unsubscribe、ping 或 command；server 可能推送 notification、status update 或 error。&lt;/p>
&lt;p>若讀寫責任不分開，程式很容易出現這種結構：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">handleConnection&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">conn&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">websocket&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Conn&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">go&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">msg&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">serverMessages&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">conn&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteJSON&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">msg&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="p">}()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">msg&lt;/span> &lt;span class="nx">ClientMessage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">conn&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ReadJSON&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">msg&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">msg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Action&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;subscribe&amp;#34;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">conn&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteJSON&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ServerMessage&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Type&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;subscribed&amp;#34;&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式的問題是多個路徑可能同時寫 connection：背景 goroutine 寫推送，read loop 裡也直接寫回應。多個 goroutine 同時寫 WebSocket 會讓錯誤、資料交錯與 close path 變得難以推理。&lt;/p>
&lt;h2 id="判讀read-pump-和-write-pump-是-ownership-邊界">【判讀】read pump 和 write pump 是 ownership 邊界&lt;/h2>
&lt;p>Read pump / write pump 的核心價值是 ownership。Read pump 是唯一讀取者，write pump 是唯一寫入者，其他元件只能透過它們的公開邊界互動。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Client&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">id&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">conn&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">websocket&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Conn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">send&lt;/span> &lt;span class="kd">chan&lt;/span> &lt;span class="nx">ServerMessage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>conn&lt;/code> 是底層連線，&lt;code>send&lt;/code> 是 server 要推給 client 的訊息佇列。其他元件不直接呼叫 &lt;code>conn.WriteJSON&lt;/code>，而是把 &lt;code>ServerMessage&lt;/code> 放進 &lt;code>send&lt;/code>。&lt;/p>
&lt;p>責任表：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>元件&lt;/th>
 &lt;th>責任&lt;/th>
 &lt;th>不應做的事&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>read pump&lt;/td>
 &lt;td>讀 client message、交給 router&lt;/td>
 &lt;td>直接寫 WebSocket&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>write pump&lt;/td>
 &lt;td>寫 server message、送 heartbeat、送 close&lt;/td>
 &lt;td>處理 client action&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>hub&lt;/td>
 &lt;td>註冊、取消註冊、廣播&lt;/td>
 &lt;td>直接讀寫 connection&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>router&lt;/td>
 &lt;td>解析 action、呼叫 usecase 或更新訂閱&lt;/td>
 &lt;td>關閉底層 connection&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這個分工讓連線生命週期可以被測試與替換，而不是散在多個 goroutine 裡。&lt;/p>
&lt;h2 id="策略client-型別要表達連線邊界">【策略】Client 型別要表達連線邊界&lt;/h2>
&lt;p>Client 型別的核心責任是封裝單一連線的狀態與輸出佇列。它不應包含整個系統的業務狀態。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Client&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">id&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">conn&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">websocket&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Conn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">send&lt;/span> &lt;span class="kd">chan&lt;/span> &lt;span class="nx">ServerMessage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">mu&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RWMutex&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">subscriptions&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="kd">struct&lt;/span>&lt;span class="p">{}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewClient&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">id&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">conn&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">websocket&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Conn&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">sendBuffer&lt;/span> &lt;span class="kt">int&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Client&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">Client&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">conn&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">conn&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">send&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">ServerMessage&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">sendBuffer&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">subscriptions&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="kd">struct&lt;/span>&lt;span class="p">{}),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>send&lt;/code> 有固定容量，避免慢 client 無限制累積訊息。&lt;code>subscriptions&lt;/code> 屬於這條連線的狀態，若會被多個 goroutine 讀寫，就需要 mutex 或集中到 hub event loop。&lt;/p></description><content:encoded><![CDATA[<p>Read pump / write pump 的核心規則是單一 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 連線的讀取與寫入必須分成兩個協調的 goroutine。Read pump 擁有讀取權，write pump 擁有寫入權；其他元件不直接操作底層 connection，而是透過 channel 或 method 協作。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 read pump、write pump、hub 的責任</li>
<li>避免多 goroutine 同時寫同一條 WebSocket connection</li>
<li>用 send channel 作為 server-to-client 推送邊界</li>
<li>設計 client unregister 與 close path</li>
<li>用 fake router 測試 read pump 的行為邊界</li>
</ol>
<hr>
<h2 id="觀察websocket-是一條長生命週期雙向連線">【觀察】WebSocket 是一條長生命週期雙向連線</h2>
<p>WebSocket 連線的核心特徵是 client 和 server 都可能主動送訊息。Client 可能送 subscribe、unsubscribe、ping 或 command；server 可能推送 notification、status update 或 error。</p>
<p>若讀寫責任不分開，程式很容易出現這種結構：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">handleConnection</span><span class="p">(</span><span class="nx">conn</span> <span class="o">*</span><span class="nx">websocket</span><span class="p">.</span><span class="nx">Conn</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">for</span> <span class="nx">msg</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">serverMessages</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="nx">conn</span><span class="p">.</span><span class="nf">WriteJSON</span><span class="p">(</span><span class="nx">msg</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">}()</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="kd">var</span> <span class="nx">msg</span> <span class="nx">ClientMessage</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">conn</span><span class="p">.</span><span class="nf">ReadJSON</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">msg</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">if</span> <span class="nx">msg</span><span class="p">.</span><span class="nx">Action</span> <span class="o">==</span> <span class="s">&#34;subscribe&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nx">conn</span><span class="p">.</span><span class="nf">WriteJSON</span><span class="p">(</span><span class="nx">ServerMessage</span><span class="p">{</span><span class="nx">Type</span><span class="p">:</span> <span class="s">&#34;subscribed&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式的問題是多個路徑可能同時寫 connection：背景 goroutine 寫推送，read loop 裡也直接寫回應。多個 goroutine 同時寫 WebSocket 會讓錯誤、資料交錯與 close path 變得難以推理。</p>
<h2 id="判讀read-pump-和-write-pump-是-ownership-邊界">【判讀】read pump 和 write pump 是 ownership 邊界</h2>
<p>Read pump / write pump 的核心價值是 ownership。Read pump 是唯一讀取者，write pump 是唯一寫入者，其他元件只能透過它們的公開邊界互動。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">Client</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">id</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">conn</span> <span class="o">*</span><span class="nx">websocket</span><span class="p">.</span><span class="nx">Conn</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">send</span> <span class="kd">chan</span> <span class="nx">ServerMessage</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>conn</code> 是底層連線，<code>send</code> 是 server 要推給 client 的訊息佇列。其他元件不直接呼叫 <code>conn.WriteJSON</code>，而是把 <code>ServerMessage</code> 放進 <code>send</code>。</p>
<p>責任表：</p>
<table>
  <thead>
      <tr>
          <th>元件</th>
          <th>責任</th>
          <th>不應做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>read pump</td>
          <td>讀 client message、交給 router</td>
          <td>直接寫 WebSocket</td>
      </tr>
      <tr>
          <td>write pump</td>
          <td>寫 server message、送 heartbeat、送 close</td>
          <td>處理 client action</td>
      </tr>
      <tr>
          <td>hub</td>
          <td>註冊、取消註冊、廣播</td>
          <td>直接讀寫 connection</td>
      </tr>
      <tr>
          <td>router</td>
          <td>解析 action、呼叫 usecase 或更新訂閱</td>
          <td>關閉底層 connection</td>
      </tr>
  </tbody>
</table>
<p>這個分工讓連線生命週期可以被測試與替換，而不是散在多個 goroutine 裡。</p>
<h2 id="策略client-型別要表達連線邊界">【策略】Client 型別要表達連線邊界</h2>
<p>Client 型別的核心責任是封裝單一連線的狀態與輸出佇列。它不應包含整個系統的業務狀態。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Client</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">id</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">conn</span> <span class="o">*</span><span class="nx">websocket</span><span class="p">.</span><span class="nx">Conn</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">send</span> <span class="kd">chan</span> <span class="nx">ServerMessage</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">mu</span>            <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">subscriptions</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kd">struct</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kd">func</span> <span class="nf">NewClient</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">conn</span> <span class="o">*</span><span class="nx">websocket</span><span class="p">.</span><span class="nx">Conn</span><span class="p">,</span> <span class="nx">sendBuffer</span> <span class="kt">int</span><span class="p">)</span> <span class="o">*</span><span class="nx">Client</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">Client</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">id</span><span class="p">:</span>            <span class="nx">id</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">conn</span><span class="p">:</span>          <span class="nx">conn</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">send</span><span class="p">:</span>          <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">ServerMessage</span><span class="p">,</span> <span class="nx">sendBuffer</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">subscriptions</span><span class="p">:</span> <span class="nb">make</span><span class="p">(</span><span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kd">struct</span><span class="p">{}),</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>send</code> 有固定容量，避免慢 client 無限制累積訊息。<code>subscriptions</code> 屬於這條連線的狀態，若會被多個 goroutine 讀寫，就需要 mutex 或集中到 hub event loop。</p>
<h2 id="執行read-pump-只處理-client-輸入">【執行】read pump 只處理 client 輸入</h2>
<p>Read pump 的核心責任是從 connection 讀訊息、轉成 <code>ClientMessage</code>、交給 router。它不應直接操作所有業務規則。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">MessageRouter</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nf">Route</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">client</span> <span class="o">*</span><span class="nx">Client</span><span class="p">,</span> <span class="nx">message</span> <span class="nx">ClientMessage</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">readPump</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">hub</span> <span class="o">*</span><span class="nx">Hub</span><span class="p">,</span> <span class="nx">router</span> <span class="nx">MessageRouter</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">defer</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">hub</span><span class="p">.</span><span class="nx">unregister</span> <span class="o">&lt;-</span> <span class="nx">c</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="kd">var</span> <span class="nx">message</span> <span class="nx">ClientMessage</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">ReadJSON</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">message</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">router</span><span class="p">.</span><span class="nf">Route</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">c</span><span class="p">,</span> <span class="nx">message</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">            <span class="nx">c</span><span class="p">.</span><span class="nf">TrySend</span><span class="p">(</span><span class="nx">ServerMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">                <span class="nx">Type</span><span class="p">:</span>  <span class="s">&#34;error&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">                <span class="nx">Error</span><span class="p">:</span> <span class="nx">err</span><span class="p">.</span><span class="nf">Error</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="p">})</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Read pump 收到 read error 時退出，並通知 hub unregister。這裡不直接 close <code>send</code>，因為 <code>send</code> 的關閉責任交給 hub 統一處理。</p>
<h2 id="執行write-pump-是唯一寫入者">【執行】write pump 是唯一寫入者</h2>
<p>Write pump 的核心責任是把 <code>send</code> channel 裡的 server message 寫回 WebSocket。所有寫入都集中在這一個 goroutine，能避免 concurrent write。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">writePump</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">message</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">c</span><span class="p">.</span><span class="nx">send</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="nx">_</span> <span class="p">=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">WriteMessage</span><span class="p">(</span><span class="nx">websocket</span><span class="p">.</span><span class="nx">CloseMessage</span><span class="p">,</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">{})</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">WriteJSON</span><span class="p">(</span><span class="nx">message</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>當 <code>send</code> 被關閉時，write pump 送出 close message 並退出。這表示 hub 或 connection manager 是 <code>send</code> 的 owner，write pump 是 receiver。</p>
<p>下一章會把 heartbeat ticker 加進 write pump。原則不變：ping 也是寫入，所以也要由 write pump 統一執行。</p>
<h2 id="策略send-channel-是推送邊界">【策略】send channel 是推送邊界</h2>
<p><code>send</code> channel 的核心意義是把內部事件轉成 client 輸出佇列。其他元件可以嘗試送訊息，但不能直接寫 connection。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">TrySend</span><span class="p">(</span><span class="nx">message</span> <span class="nx">ServerMessage</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">case</span> <span class="nx">c</span><span class="p">.</span><span class="nx">send</span> <span class="o">&lt;-</span> <span class="nx">message</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="k">return</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>TrySend</code> 使用 non-blocking send，表示 client <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 滿時不阻塞呼叫端。Hub 可以根據 <code>false</code> 決定丟棄訊息、取消註冊 client 或記錄 metric。</p>
<p>這個方法把 WebSocket 寫入問題轉成前一模組的 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 問題：滿載時要有明確策略。</p>
<h2 id="執行hub-統一管理-unregister">【執行】hub 統一管理 unregister</h2>
<p>Unregister 的核心目標是讓清理流程只有一個責任中心。Read pump、write pump、heartbeat 都可能發現連線失效，但不要讓每個地方各自 close channel 和 connection。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Hub</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">clients</span>    <span class="kd">map</span><span class="p">[</span><span class="o">*</span><span class="nx">Client</span><span class="p">]</span><span class="kd">struct</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">register</span>   <span class="kd">chan</span> <span class="o">*</span><span class="nx">Client</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">unregister</span> <span class="kd">chan</span> <span class="o">*</span><span class="nx">Client</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">broadcast</span>  <span class="kd">chan</span> <span class="nx">ServerMessage</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="o">*</span><span class="nx">Hub</span><span class="p">)</span> <span class="nf">run</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">case</span> <span class="nx">client</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">h</span><span class="p">.</span><span class="nx">register</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="nx">h</span><span class="p">.</span><span class="nx">clients</span><span class="p">[</span><span class="nx">client</span><span class="p">]</span> <span class="p">=</span> <span class="kd">struct</span><span class="p">{}{}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="k">case</span> <span class="nx">client</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">h</span><span class="p">.</span><span class="nx">unregister</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">clients</span><span class="p">[</span><span class="nx">client</span><span class="p">];</span> <span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">                <span class="nb">delete</span><span class="p">(</span><span class="nx">h</span><span class="p">.</span><span class="nx">clients</span><span class="p">,</span> <span class="nx">client</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">                <span class="nb">close</span><span class="p">(</span><span class="nx">client</span><span class="p">.</span><span class="nx">send</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">                <span class="nx">_</span> <span class="p">=</span> <span class="nx">client</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">Close</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個設計讓 <code>client.send</code> 只會在 hub 中被 close。其他 goroutine 只送 unregister 訊號，不直接關閉資源。</p>
<p>實務上要避免重複 unregister 造成 channel 重複 close。上例透過 <code>clients</code> map 判斷 client 是否仍註冊，讓 unregister 具備 idempotent 行為。</p>
<h2 id="判讀read-pump-結束不代表-write-pump-立刻結束">【判讀】read pump 結束不代表 write pump 立刻結束</h2>
<p>Read pump 與 write pump 的核心關係是協作，不是互相任意關閉。Read pump 發現錯誤後通知 hub；hub 關閉 <code>send</code>；write pump 收到 <code>send</code> 關閉後送 close message 並退出。</p>
<p>流程：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">read error
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">   │
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">   ▼
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">hub.unregister &lt;- client
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">   │
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">   ▼
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">hub closes client.send and conn
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">   │
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">   ▼
</span></span><span class="line"><span class="ln">10</span><span class="cl">write pump exits</span></span></code></pre></div><p>這條路徑讓 close ownership 清楚。若 read pump 同時 close <code>send</code>，hub 也 close <code>send</code>，就會有 double close panic。</p>
<h2 id="測試router-可以用-fake-驗證-read-pump-邊界">【測試】router 可以用 fake 驗證 read pump 邊界</h2>
<p>Read pump 測試的核心目標是確認 client message 會交給 router，而不是在 read pump 裡塞入業務邏輯。完整 WebSocket integration test 可以留到測試模組；這裡先用 router 的小介面讓行為可替換。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">type</span> <span class="nx">fakeRouter</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">messages</span> <span class="p">[]</span><span class="nx">ClientMessage</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">fakeRouter</span><span class="p">)</span> <span class="nf">Route</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">client</span> <span class="o">*</span><span class="nx">Client</span><span class="p">,</span> <span class="nx">message</span> <span class="nx">ClientMessage</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">messages</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">messages</span><span class="p">,</span> <span class="nx">message</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>若測試需要真實 connection，可用 <code>httptest.Server</code> 建立 WebSocket。若只測 router 規則，應直接測 router，不必繞過 network。</p>
<p>Write pump 的測試通常放在 integration test，因為它依賴真實 connection 寫入行為。單元測試則可以集中在 <code>TrySend</code>、router、hub unregister 這些純邊界。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一連線的 read/write ownership；跨節點 hub 與 <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> 互動，會在下列章節延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">Go 進階：跨節點 WebSocket、presence 與重連協定</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 goroutine ownership、channel 與 backpressure；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">Go：goroutine：輕量並發工作</a></li>
<li><a href="/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go：channel：資料傳遞與 backpressure </a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/channel-ownership/" data-link-title="1.1 channel ownership 與關閉責任" data-link-desc="判斷誰能送出、接收與關閉 channel">Go：channel ownership 與關閉責任</a></li>
<li><a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">Go：如何新增一個即時訊息 action</a></li>
<li><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>Read pump / write pump 模式把一條 WebSocket 連線拆成清楚的 ownership：read pump 讀 client message，write pump 寫 server message，hub 統一註冊與清理。<code>send</code> channel 是推送邊界，所有 close path 應收斂到同一個 unregister 流程。這樣長連線才不會因為 concurrent write、double close 或慢 client 而失控。</p>
]]></content:encoded></item><item><title>2.2 heartbeat、deadline 與連線清理</title><link>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/heartbeat-deadline/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/heartbeat-deadline/</guid><description>&lt;p>Heartbeat 的核心目標是讓失效的長連線可以被發現並清理。&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">Deadline&lt;/a> 定義讀寫最多能停滯多久，ping/pong 在沒有業務訊息時確認連線仍然活著，unregister 流程負責釋放連線與訂閱狀態。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 read deadline、write deadline、ping period、pong wait 的角色&lt;/li>
&lt;li>在 read pump 設定 pong handler 與 read limit&lt;/li>
&lt;li>在 write pump 用 ticker 統一送 ping&lt;/li>
&lt;li>讓 heartbeat 失敗進入同一條 unregister 路徑&lt;/li>
&lt;li>測試 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a> 設定與清理流程的邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察長連線可能在沒有錯誤訊息時失效">【觀察】長連線可能在沒有錯誤訊息時失效&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> 長連線的核心風險是失效不一定立刻表現成明確錯誤。Client 可能斷網、瀏覽器休眠、代理中斷、行動網路切換，server 的 read 或 write 可能長時間卡住。&lt;/p>
&lt;p>沒有 heartbeat 的服務可能出現：&lt;/p>
&lt;ul>
&lt;li>client 已離線，但 server 還保留 client。&lt;/li>
&lt;li>訂閱狀態沒有清理，broadcast 仍嘗試推送。&lt;/li>
&lt;li>write pump 卡在慢或失效的 connection。&lt;/li>
&lt;li>goroutine、send &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a>、記憶體逐步累積。&lt;/li>
&lt;/ul>
&lt;p>Heartbeat 的目的是讓失敗可以在合理時間內被觀測並進入清理流程。&lt;/p>
&lt;h2 id="判讀四個時間參數負責不同邊界">【判讀】四個時間參數負責不同邊界&lt;/h2>
&lt;p>Heartbeat 設計的核心是四個時間參數的關係。這些參數是讀寫生命週期的合約。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">const&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">writeWait&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">10&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Second&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">pongWait&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">60&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Second&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">pingPeriod&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">50&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Second&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">maxMessage&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">1&lt;/span> &lt;span class="o">&amp;lt;&amp;lt;&lt;/span> &lt;span class="mi">20&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>參數&lt;/th>
 &lt;th>角色&lt;/th>
 &lt;th>常見關係&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>writeWait&lt;/code>&lt;/td>
 &lt;td>單次寫入最多等待多久&lt;/td>
 &lt;td>小於 &lt;code>pongWait&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>pongWait&lt;/code>&lt;/td>
 &lt;td>多久沒讀到資料就視為失效&lt;/td>
 &lt;td>大於 &lt;code>pingPeriod&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>pingPeriod&lt;/code>&lt;/td>
 &lt;td>多久主動送一次 ping&lt;/td>
 &lt;td>小於 &lt;code>pongWait&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>maxMessage&lt;/code>&lt;/td>
 &lt;td>單筆 client message 大小上限&lt;/td>
 &lt;td>依協定需求設定&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>pingPeriod&lt;/code> 應小於 &lt;code>pongWait&lt;/code>，讓 server 有時間送 ping 並等待 client 回 pong。&lt;code>writeWait&lt;/code> 保護每次寫入，避免 write pump 無限卡住。&lt;/p>
&lt;h2 id="執行read-pump-設定-read-deadline-與-pong-handler">【執行】read pump 設定 read deadline 與 pong handler&lt;/h2>
&lt;p>Read deadline 的核心語意是超過指定時間沒有讀取進展，下一次 read 會失敗。Pong handler 的核心責任是每次收到 pong 時延長 read deadline。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">c&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Client&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">configureRead&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">conn&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">SetReadLimit&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">maxMessage&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">conn&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">SetReadDeadline&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Now&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">pongWait&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">conn&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">SetPongHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">conn&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">SetReadDeadline&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Now&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">pongWait&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Read pump 啟動時先設定：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">c&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Client&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">readPump&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">hub&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Hub&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">router&lt;/span> &lt;span class="nx">MessageRouter&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">hub&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">unregister&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">c&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="p">}()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">configureRead&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">message&lt;/span> &lt;span class="nx">ClientMessage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">conn&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ReadJSON&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">message&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">router&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Route&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">c&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">message&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">TrySend&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nf">errorMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">err&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>ReadJSON&lt;/code> 回錯時，read pump 不需要判斷每一種錯誤都如何清理；它只要退出並通知 hub。錯誤分類可以用於 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a>，但清理路徑應一致。&lt;/p>
&lt;h2 id="執行write-pump-用-ticker-送-ping">【執行】write pump 用 ticker 送 ping&lt;/h2>
&lt;p>Ping 的核心規則是由 write pump 送出，因為 ping 也是 WebSocket write。讓其他 goroutine 直接送 ping 會破壞「write pump 是唯一寫入者」的原則。&lt;/p></description><content:encoded><![CDATA[<p>Heartbeat 的核心目標是讓失效的長連線可以被發現並清理。<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">Deadline</a> 定義讀寫最多能停滯多久，ping/pong 在沒有業務訊息時確認連線仍然活著，unregister 流程負責釋放連線與訂閱狀態。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 read deadline、write deadline、ping period、pong wait 的角色</li>
<li>在 read pump 設定 pong handler 與 read limit</li>
<li>在 write pump 用 ticker 統一送 ping</li>
<li>讓 heartbeat 失敗進入同一條 unregister 路徑</li>
<li>測試 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 設定與清理流程的邊界</li>
</ol>
<hr>
<h2 id="觀察長連線可能在沒有錯誤訊息時失效">【觀察】長連線可能在沒有錯誤訊息時失效</h2>
<p><a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 長連線的核心風險是失效不一定立刻表現成明確錯誤。Client 可能斷網、瀏覽器休眠、代理中斷、行動網路切換，server 的 read 或 write 可能長時間卡住。</p>
<p>沒有 heartbeat 的服務可能出現：</p>
<ul>
<li>client 已離線，但 server 還保留 client。</li>
<li>訂閱狀態沒有清理，broadcast 仍嘗試推送。</li>
<li>write pump 卡在慢或失效的 connection。</li>
<li>goroutine、send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a>、記憶體逐步累積。</li>
</ul>
<p>Heartbeat 的目的是讓失敗可以在合理時間內被觀測並進入清理流程。</p>
<h2 id="判讀四個時間參數負責不同邊界">【判讀】四個時間參數負責不同邊界</h2>
<p>Heartbeat 設計的核心是四個時間參數的關係。這些參數是讀寫生命週期的合約。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">writeWait</span>  <span class="p">=</span> <span class="mi">10</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">pongWait</span>   <span class="p">=</span> <span class="mi">60</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">pingPeriod</span> <span class="p">=</span> <span class="mi">50</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">maxMessage</span> <span class="p">=</span> <span class="mi">1</span> <span class="o">&lt;&lt;</span> <span class="mi">20</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>參數</th>
          <th>角色</th>
          <th>常見關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>writeWait</code></td>
          <td>單次寫入最多等待多久</td>
          <td>小於 <code>pongWait</code></td>
      </tr>
      <tr>
          <td><code>pongWait</code></td>
          <td>多久沒讀到資料就視為失效</td>
          <td>大於 <code>pingPeriod</code></td>
      </tr>
      <tr>
          <td><code>pingPeriod</code></td>
          <td>多久主動送一次 ping</td>
          <td>小於 <code>pongWait</code></td>
      </tr>
      <tr>
          <td><code>maxMessage</code></td>
          <td>單筆 client message 大小上限</td>
          <td>依協定需求設定</td>
      </tr>
  </tbody>
</table>
<p><code>pingPeriod</code> 應小於 <code>pongWait</code>，讓 server 有時間送 ping 並等待 client 回 pong。<code>writeWait</code> 保護每次寫入，避免 write pump 無限卡住。</p>
<h2 id="執行read-pump-設定-read-deadline-與-pong-handler">【執行】read pump 設定 read deadline 與 pong handler</h2>
<p>Read deadline 的核心語意是超過指定時間沒有讀取進展，下一次 read 會失敗。Pong handler 的核心責任是每次收到 pong 時延長 read deadline。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">configureRead</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">SetReadLimit</span><span class="p">(</span><span class="nx">maxMessage</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">SetReadDeadline</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">().</span><span class="nf">Add</span><span class="p">(</span><span class="nx">pongWait</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">SetPongHandler</span><span class="p">(</span><span class="kd">func</span><span class="p">(</span><span class="kt">string</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="k">return</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">SetReadDeadline</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">().</span><span class="nf">Add</span><span class="p">(</span><span class="nx">pongWait</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Read pump 啟動時先設定：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">readPump</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">hub</span> <span class="o">*</span><span class="nx">Hub</span><span class="p">,</span> <span class="nx">router</span> <span class="nx">MessageRouter</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">defer</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">hub</span><span class="p">.</span><span class="nx">unregister</span> <span class="o">&lt;-</span> <span class="nx">c</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">}()</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nf">configureRead</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="kd">var</span> <span class="nx">message</span> <span class="nx">ClientMessage</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">ReadJSON</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">message</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">router</span><span class="p">.</span><span class="nf">Route</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">c</span><span class="p">,</span> <span class="nx">message</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nx">c</span><span class="p">.</span><span class="nf">TrySend</span><span class="p">(</span><span class="nf">errorMessage</span><span class="p">(</span><span class="nx">err</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>ReadJSON</code> 回錯時，read pump 不需要判斷每一種錯誤都如何清理；它只要退出並通知 hub。錯誤分類可以用於 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>，但清理路徑應一致。</p>
<h2 id="執行write-pump-用-ticker-送-ping">【執行】write pump 用 ticker 送 ping</h2>
<p>Ping 的核心規則是由 write pump 送出，因為 ping 也是 WebSocket write。讓其他 goroutine 直接送 ping 會破壞「write pump 是唯一寫入者」的原則。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">writePump</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ticker</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">NewTicker</span><span class="p">(</span><span class="nx">pingPeriod</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">ticker</span><span class="p">.</span><span class="nf">Stop</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">case</span> <span class="nx">message</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">c</span><span class="p">.</span><span class="nx">send</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="nx">_</span> <span class="p">=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">SetWriteDeadline</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">().</span><span class="nf">Add</span><span class="p">(</span><span class="nx">writeWait</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">                <span class="nx">_</span> <span class="p">=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">WriteMessage</span><span class="p">(</span><span class="nx">websocket</span><span class="p">.</span><span class="nx">CloseMessage</span><span class="p">,</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">{})</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">                <span class="k">return</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">WriteJSON</span><span class="p">(</span><span class="nx">message</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">                <span class="k">return</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ticker</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="nx">_</span> <span class="p">=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">SetWriteDeadline</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">().</span><span class="nf">Add</span><span class="p">(</span><span class="nx">writeWait</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">WriteMessage</span><span class="p">(</span><span class="nx">websocket</span><span class="p">.</span><span class="nx">PingMessage</span><span class="p">,</span> <span class="kc">nil</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">                <span class="k">return</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>每次寫入前設定 write deadline。這包含正常訊息、ping、close message；只保護部分寫入會留下卡住路徑。</p>
<h2 id="判讀heartbeat-失敗走共用清理流程">【判讀】heartbeat 失敗走共用清理流程</h2>
<p>Heartbeat 失敗的核心語意是連線不可用。它應該進入和 read error、write error、client disconnect 相同的 unregister 流程，而不是在 ping 錯誤處重寫一套清理。</p>
<p>推薦流程：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">read error / write error / ping error
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">          │
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">          ▼
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">read pump exits or write pump exits
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">          │
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">          ▼
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">hub unregisters client
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">          │
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">          ▼
</span></span><span class="line"><span class="ln">10</span><span class="cl">close send, close conn, remove subscriptions</span></span></code></pre></div><p>實作可以用 hub unregister channel、context cancellation 或 connection manager。重點是所有失效都收斂到同一個 owner。</p>
<h2 id="策略read-pump-和-write-pump-都可能先失敗">【策略】read pump 和 write pump 都可能先失敗</h2>
<p>連線失效的核心不確定性是 read pump 和 write pump 哪個先看到錯誤不可預測。讀不到 pong 可能讓 read pump 先退出；寫 ping 失敗可能讓 write pump 先退出。</p>
<p>因此 unregister 必須可重複呼叫而不出錯：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="o">*</span><span class="nx">Hub</span><span class="p">)</span> <span class="nf">unregisterClient</span><span class="p">(</span><span class="nx">client</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">clients</span><span class="p">[</span><span class="nx">client</span><span class="p">];</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nb">delete</span><span class="p">(</span><span class="nx">h</span><span class="p">.</span><span class="nx">clients</span><span class="p">,</span> <span class="nx">client</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nb">close</span><span class="p">(</span><span class="nx">client</span><span class="p">.</span><span class="nx">send</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">client</span><span class="p">.</span><span class="nx">conn</span><span class="p">.</span><span class="nf">Close</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>用 <code>clients</code> map 判斷 client 是否仍註冊，可以避免重複 close <code>send</code>。這是 WebSocket cleanup 最容易漏掉的細節之一。</p>
<h2 id="策略heartbeat-參數要符合部署環境">【策略】heartbeat 參數要符合部署環境</h2>
<p>Heartbeat 參數的核心取捨是偵測速度與誤判風險。偵測太快會讓短暫網路抖動造成大量斷線；偵測太慢會讓失效連線保留太久。</p>
<p>調整時要考慮：</p>
<ul>
<li>load balancer 或 proxy idle timeout</li>
<li>行動網路與瀏覽器背景分頁行為</li>
<li>server 可接受的失效連線保留時間</li>
<li>ping 對大量連線造成的週期性流量</li>
<li>client 是否會自動重連</li>
</ul>
<p>若基礎設施會在 60 秒 idle 後關閉連線，server 的 ping period 就不能長於這個時間。這是部署環境合約，不是單純 Go 程式碼問題。</p>
<h2 id="測試把時間參數和清理邊界拆開測">【測試】把時間參數和清理邊界拆開測</h2>
<p>Heartbeat 的測試核心是不要用真實分鐘級等待。時間參數可以測設定值關係，清理流程可以測 unregister 是否 idempotent。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestHeartbeatDurations</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">if</span> <span class="nx">pingPeriod</span> <span class="o">&gt;=</span> <span class="nx">pongWait</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;pingPeriod must be smaller than pongWait&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">if</span> <span class="nx">writeWait</span> <span class="o">&gt;=</span> <span class="nx">pongWait</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;writeWait should be smaller than pongWait&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Unregister 測試：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestUnregisterClientIsIdempotent</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">hub</span> <span class="o">:=</span> <span class="nf">NewHub</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">client</span> <span class="o">:=</span> <span class="nf">NewClient</span><span class="p">(</span><span class="s">&#34;c1&#34;</span><span class="p">,</span> <span class="kc">nil</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">hub</span><span class="p">.</span><span class="nx">clients</span><span class="p">[</span><span class="nx">client</span><span class="p">]</span> <span class="p">=</span> <span class="kd">struct</span><span class="p">{}{}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">hub</span><span class="p">.</span><span class="nf">unregisterClient</span><span class="p">(</span><span class="nx">client</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">hub</span><span class="p">.</span><span class="nf">unregisterClient</span><span class="p">(</span><span class="nx">client</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">hub</span><span class="p">.</span><span class="nx">clients</span><span class="p">[</span><span class="nx">client</span><span class="p">];</span> <span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;client should be removed&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>真實 ping/pong 行為適合放在 integration test。單元測試先保證時間合約與 cleanup owner 不會被破壞。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一 WebSocket 連線的存活偵測與 cleanup；client 重連與 load balancer 參數，會在下列章節延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">Go 進階：跨節點 WebSocket、presence 與重連協定</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 read/write pump、time control 與 shutdown；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件</a></li>
<li><a href="/blog/go/03-stdlib/defer-cleanup/" data-link-title="3.8 defer 與資源清理" data-link-desc="用 defer 管理 close、unlock、cleanup 與 panic 邊界">Go：defer 與資源清理</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/time-control/" data-link-title="5.1 時間注入與狀態轉移測試" data-link-desc="讓時間相關邏輯可重現">Go 進階：time control</a></li>
<li><a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Go 進階：graceful shutdown 與 signal handling</a></li>
<li><a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>Heartbeat/deadline 的目的是讓失效連線在可預期時間內被發現並清理。Read deadline 搭配 pong handler 保護讀取端，write deadline 保護每次寫入，ping ticker 由 write pump 統一執行，所有錯誤最後都應進入同一個 unregister 流程。</p>
]]></content:encoded></item><item><title>2.3 訂閱模型與訊息路由</title><link>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/subscription-routing/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/subscription-routing/</guid><description>&lt;p>訂閱模型的核心目標是把 client action 轉成明確的連線狀態與回應訊息。&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> 是長連線，單次 action 失敗通常不應直接關閉連線；router 應把錯誤轉成可理解的 server message。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>設計穩定的 client action envelope&lt;/li>
&lt;li>把 router、handler、usecase 與 client state 分開&lt;/li>
&lt;li>用訂閱集合表達 client 想收到的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a>&lt;/li>
&lt;li>在 broadcast 前檢查訂閱狀態&lt;/li>
&lt;li>測試 action routing、payload validation 與 error response&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察client-message-很容易變成臨時協定">【觀察】client message 很容易變成臨時協定&lt;/h2>
&lt;p>WebSocket action 的核心風險是前後端快速加功能時，訊息格式變成一堆臨時欄位。若 action 名稱依賴按鈕、畫面或短期 UI 狀態，server 很快會累積難以維護的分支。&lt;/p>
&lt;p>不穩定的訊息格式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;button&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;watch&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;tab&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;jobs&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;topic_1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這種訊息描述 UI 發生什麼，不是描述 client 想對服務做什麼。服務端應該接收穩定 action，例如 &lt;code>subscribe_topic&lt;/code>、&lt;code>unsubscribe_topic&lt;/code>、&lt;code>list_subscriptions&lt;/code>。&lt;/p>
&lt;h2 id="判讀action-是-client-intent">【判讀】action 是 client intent&lt;/h2>
&lt;p>Client action 的核心語意是「client 想做什麼」。它不是 domain event，因為它還沒被驗證、授權或套用規則。Domain event 表示已經發生的事，action 表示請求。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">ClientAction&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">const&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">ActionSubscribeTopic&lt;/span> &lt;span class="nx">ClientAction&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;subscribe_topic&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">ActionUnsubscribeTopic&lt;/span> &lt;span class="nx">ClientAction&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;unsubscribe_topic&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">ActionListTopics&lt;/span> &lt;span class="nx">ClientAction&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;list_topics&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">ClientMessage&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">Action&lt;/span> &lt;span class="nx">ClientAction&lt;/span> &lt;span class="s">`json:&amp;#34;action&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">Data&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RawMessage&lt;/span> &lt;span class="s">`json:&amp;#34;data,omitempty&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>外層 envelope 穩定，內層 &lt;code>Data&lt;/code> 依 action 解析。這讓 read pump 可以先解析 envelope，router 再依 action 決定 payload 型別。&lt;/p>
&lt;h2 id="策略router-負責分派不擁有全部規則">【策略】router 負責分派，不擁有全部規則&lt;/h2>
&lt;p>Router 的核心責任是把 action 分派到對應 handler。它應該知道有哪些 action，但不應把訂閱規則、權限檢查、資料查詢全部塞在一個巨大 switch 裡。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Router&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">subscriptions&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">SubscriptionService&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="nx">Router&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Route&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">message&lt;/span> &lt;span class="nx">ClientMessage&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">switch&lt;/span> &lt;span class="nx">message&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Action&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">ActionSubscribeTopic&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">handleSubscribe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">message&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Data&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">ActionUnsubscribeTopic&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">handleUnsubscribe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">message&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Data&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">ActionListTopics&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">handleListTopics&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">default&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;unknown action: %s&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">message&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Action&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>switch&lt;/code> 讓支援的 action 集中可見。真正的訂閱狀態修改可以交給 &lt;code>SubscriptionService&lt;/code> 或 client method，避免 router 變成所有規則的聚集地。&lt;/p>
&lt;h2 id="執行payload-validation-在-action-邊界完成">【執行】payload validation 在 action 邊界完成&lt;/h2>
&lt;p>Payload validation 的核心責任是讓內部服務只收到有效 command。訂閱 topic 至少要檢查 JSON 格式、topic 是否空白、topic 名稱是否符合規則。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">SubscribeTopicRequest&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">Topic&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;topic&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">SubscribeTopicCommand&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">ClientID&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">Topic&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="nx">Router&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">handleSubscribe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">raw&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RawMessage&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">req&lt;/span> &lt;span class="nx">SubscribeTopicRequest&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Unmarshal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">req&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;decode subscribe request: %w&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">topic&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">TrimSpace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">topic&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;topic is required&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="nx">cmd&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">SubscribeTopicCommand&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="nx">ClientID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">client&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ID&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="nx">Topic&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">topic&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">subscriptions&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Subscribe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cmd&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Request struct 是 wire format，command 是內部意圖。兩者分開後，JSON 命名、驗證錯誤與內部服務規則不會混在同一個型別。&lt;/p></description><content:encoded><![CDATA[<p>訂閱模型的核心目標是把 client action 轉成明確的連線狀態與回應訊息。<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 是長連線，單次 action 失敗通常不應直接關閉連線；router 應把錯誤轉成可理解的 server message。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>設計穩定的 client action envelope</li>
<li>把 router、handler、usecase 與 client state 分開</li>
<li>用訂閱集合表達 client 想收到的 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a></li>
<li>在 broadcast 前檢查訂閱狀態</li>
<li>測試 action routing、payload validation 與 error response</li>
</ol>
<hr>
<h2 id="觀察client-message-很容易變成臨時協定">【觀察】client message 很容易變成臨時協定</h2>
<p>WebSocket action 的核心風險是前後端快速加功能時，訊息格式變成一堆臨時欄位。若 action 名稱依賴按鈕、畫面或短期 UI 狀態，server 很快會累積難以維護的分支。</p>
<p>不穩定的訊息格式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;button&#34;</span><span class="p">:</span> <span class="s2">&#34;watch&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;tab&#34;</span><span class="p">:</span> <span class="s2">&#34;jobs&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="s2">&#34;topic_1&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種訊息描述 UI 發生什麼，不是描述 client 想對服務做什麼。服務端應該接收穩定 action，例如 <code>subscribe_topic</code>、<code>unsubscribe_topic</code>、<code>list_subscriptions</code>。</p>
<h2 id="判讀action-是-client-intent">【判讀】action 是 client intent</h2>
<p>Client action 的核心語意是「client 想做什麼」。它不是 domain event，因為它還沒被驗證、授權或套用規則。Domain event 表示已經發生的事，action 表示請求。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">ClientAction</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">ActionSubscribeTopic</span>   <span class="nx">ClientAction</span> <span class="p">=</span> <span class="s">&#34;subscribe_topic&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">ActionUnsubscribeTopic</span> <span class="nx">ClientAction</span> <span class="p">=</span> <span class="s">&#34;unsubscribe_topic&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">ActionListTopics</span>       <span class="nx">ClientAction</span> <span class="p">=</span> <span class="s">&#34;list_topics&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">type</span> <span class="nx">ClientMessage</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">Action</span> <span class="nx">ClientAction</span>    <span class="s">`json:&#34;action&#34;`</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">Data</span>   <span class="nx">json</span><span class="p">.</span><span class="nx">RawMessage</span> <span class="s">`json:&#34;data,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>外層 envelope 穩定，內層 <code>Data</code> 依 action 解析。這讓 read pump 可以先解析 envelope，router 再依 action 決定 payload 型別。</p>
<h2 id="策略router-負責分派不擁有全部規則">【策略】router 負責分派，不擁有全部規則</h2>
<p>Router 的核心責任是把 action 分派到對應 handler。它應該知道有哪些 action，但不應把訂閱規則、權限檢查、資料查詢全部塞在一個巨大 switch 裡。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Router</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">subscriptions</span> <span class="o">*</span><span class="nx">SubscriptionService</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="nx">Router</span><span class="p">)</span> <span class="nf">Route</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">client</span> <span class="o">*</span><span class="nx">Client</span><span class="p">,</span> <span class="nx">message</span> <span class="nx">ClientMessage</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">switch</span> <span class="nx">message</span><span class="p">.</span><span class="nx">Action</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">case</span> <span class="nx">ActionSubscribeTopic</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">return</span> <span class="nx">r</span><span class="p">.</span><span class="nf">handleSubscribe</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">client</span><span class="p">,</span> <span class="nx">message</span><span class="p">.</span><span class="nx">Data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">case</span> <span class="nx">ActionUnsubscribeTopic</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</span> <span class="nx">r</span><span class="p">.</span><span class="nf">handleUnsubscribe</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">client</span><span class="p">,</span> <span class="nx">message</span><span class="p">.</span><span class="nx">Data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">case</span> <span class="nx">ActionListTopics</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">return</span> <span class="nx">r</span><span class="p">.</span><span class="nf">handleListTopics</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">client</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;unknown action: %s&#34;</span><span class="p">,</span> <span class="nx">message</span><span class="p">.</span><span class="nx">Action</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>switch</code> 讓支援的 action 集中可見。真正的訂閱狀態修改可以交給 <code>SubscriptionService</code> 或 client method，避免 router 變成所有規則的聚集地。</p>
<h2 id="執行payload-validation-在-action-邊界完成">【執行】payload validation 在 action 邊界完成</h2>
<p>Payload validation 的核心責任是讓內部服務只收到有效 command。訂閱 topic 至少要檢查 JSON 格式、topic 是否空白、topic 名稱是否符合規則。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">SubscribeTopicRequest</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">Topic</span> <span class="kt">string</span> <span class="s">`json:&#34;topic&#34;`</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="kd">type</span> <span class="nx">SubscribeTopicCommand</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">ClientID</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">Topic</span>    <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="nx">Router</span><span class="p">)</span> <span class="nf">handleSubscribe</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">client</span> <span class="o">*</span><span class="nx">Client</span><span class="p">,</span> <span class="nx">raw</span> <span class="nx">json</span><span class="p">.</span><span class="nx">RawMessage</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="kd">var</span> <span class="nx">req</span> <span class="nx">SubscribeTopicRequest</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">Unmarshal</span><span class="p">(</span><span class="nx">raw</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">req</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;decode subscribe request: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">topic</span> <span class="o">:=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">if</span> <span class="nx">topic</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="k">return</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;topic is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">cmd</span> <span class="o">:=</span> <span class="nx">SubscribeTopicCommand</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="nx">ClientID</span><span class="p">:</span> <span class="nx">client</span><span class="p">.</span><span class="nf">ID</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>    <span class="nx">topic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">return</span> <span class="nx">r</span><span class="p">.</span><span class="nx">subscriptions</span><span class="p">.</span><span class="nf">Subscribe</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">client</span><span class="p">,</span> <span class="nx">cmd</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Request struct 是 wire format，command 是內部意圖。兩者分開後，JSON 命名、驗證錯誤與內部服務規則不會混在同一個型別。</p>
<h2 id="執行訂閱集合是連線狀態">【執行】訂閱集合是連線狀態</h2>
<p>訂閱集合的核心語意是「這個 client 目前想收到哪些 topic」。它可以放在 client 上，也可以由 hub 集中保存；重點是 owner 要明確。</p>
<p>Client owner 版本：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Client</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">id</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">mu</span>            <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">subscriptions</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kd">struct</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">Subscribe</span><span class="p">(</span><span class="nx">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">defer</span> <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">subscriptions</span><span class="p">[</span><span class="nx">topic</span><span class="p">]</span> <span class="p">=</span> <span class="kd">struct</span><span class="p">{}{}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">Unsubscribe</span><span class="p">(</span><span class="nx">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">defer</span> <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nb">delete</span><span class="p">(</span><span class="nx">c</span><span class="p">.</span><span class="nx">subscriptions</span><span class="p">,</span> <span class="nx">topic</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">IsSubscribed</span><span class="p">(</span><span class="nx">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">defer</span> <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="nx">_</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">subscriptions</span><span class="p">[</span><span class="nx">topic</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">return</span> <span class="nx">ok</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>map[string]struct{}</code> 是 Go 常見 set 表示法。若 read pump 修改訂閱，hub broadcast 讀取訂閱，就需要 lock 或把所有訂閱操作集中到 hub event loop。</p>
<h2 id="策略訂閱狀態也需要-copy-boundary">【策略】訂閱狀態也需要 copy boundary</h2>
<p>訂閱列表的核心風險是直接回傳 map 會暴露內部狀態。若需要列出目前訂閱，應回傳 slice 或 map copy。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">Subscriptions</span><span class="p">()</span> <span class="p">[]</span><span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RLock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">RUnlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">topics</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="kt">string</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">c</span><span class="p">.</span><span class="nx">subscriptions</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">for</span> <span class="nx">topic</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">c</span><span class="p">.</span><span class="nx">subscriptions</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">topics</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">topics</span><span class="p">,</span> <span class="nx">topic</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">sort</span><span class="p">.</span><span class="nf">Strings</span><span class="p">(</span><span class="nx">topics</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</span> <span class="nx">topics</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>回傳 sorted slice 讓測試更穩定，也避免呼叫端修改內部 map。排序不是業務必要條件，但對 API response 與測試可讀性有幫助。</p>
<h2 id="執行成功與失敗都應回-server-message">【執行】成功與失敗都應回 server message</h2>
<p>WebSocket action 的核心互動模式是 request-like，但連線不會因單次 action 結束。成功或失敗都應回一筆 server message，讓 client 能更新 UI 或顯示錯誤。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">ServerMessage</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">Type</span>  <span class="kt">string</span> <span class="s">`json:&#34;type&#34;`</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Topic</span> <span class="kt">string</span> <span class="s">`json:&#34;topic,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">Error</span> <span class="kt">string</span> <span class="s">`json:&#34;error,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">s</span> <span class="o">*</span><span class="nx">SubscriptionService</span><span class="p">)</span> <span class="nf">Subscribe</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">client</span> <span class="o">*</span><span class="nx">Client</span><span class="p">,</span> <span class="nx">cmd</span> <span class="nx">SubscribeTopicCommand</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">client</span><span class="p">.</span><span class="nf">Subscribe</span><span class="p">(</span><span class="nx">cmd</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">client</span><span class="p">.</span><span class="nf">TrySend</span><span class="p">(</span><span class="nx">ServerMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>  <span class="s">&#34;topic_subscribed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span> <span class="nx">cmd</span><span class="p">.</span><span class="nx">Topic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="k">return</span> <span class="nx">ErrClientQueueFull</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>若 action 失敗，read pump 或 router wrapper 可以把錯誤轉成 <code>ServerMessage{Type: &quot;error&quot;}</code>。不要只寫 server <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>，因為 client 需要知道該 action 沒有成功。</p>
<h2 id="執行broadcast-前檢查訂閱">【執行】broadcast 前檢查訂閱</h2>
<p>Broadcast 的核心規則是 <a href="/blog/backend/knowledge-cards/producer/" data-link-title="Producer" data-link-desc="說明 producer 如何把工作、事件或資料送入後續處理路徑">producer</a> 只產生 topic 與 message，hub 決定哪些 client 應該收到。訂閱邏輯不應散落在每個 producer 裡。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="o">*</span><span class="nx">Hub</span><span class="p">)</span> <span class="nf">Broadcast</span><span class="p">(</span><span class="nx">topic</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">message</span> <span class="nx">ServerMessage</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">for</span> <span class="nx">client</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">h</span><span class="p">.</span><span class="nx">clients</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">if</span> <span class="p">!</span><span class="nx">client</span><span class="p">.</span><span class="nf">IsSubscribed</span><span class="p">(</span><span class="nx">topic</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">if</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">client</span><span class="p">.</span><span class="nf">TrySend</span><span class="p">(</span><span class="nx">message</span><span class="p">);</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="nx">h</span><span class="p">.</span><span class="nx">unregister</span> <span class="o">&lt;-</span> <span class="nx">client</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式先檢查訂閱，再嘗試送出。若 client 的 send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 滿了，hub 可以 unregister 或採用其他慢 client 策略；下一章會專門處理。</p>
<h2 id="測試router-test-不需要真實-websocket">【測試】router test 不需要真實 WebSocket</h2>
<p>Router 的測試核心是 action 到行為的對應。它不需要真實 WebSocket connection，只需要 fake client 或檢查 client state。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestSubscribeActionAddsTopic</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">client</span> <span class="o">:=</span> <span class="nf">NewTestClient</span><span class="p">(</span><span class="s">&#34;client_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">router</span> <span class="o">:=</span> <span class="nx">Router</span><span class="p">{</span><span class="nx">subscriptions</span><span class="p">:</span> <span class="nf">NewSubscriptionService</span><span class="p">()}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">data</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">RawMessage</span><span class="p">(</span><span class="s">`{&#34;topic&#34;:&#34;alerts&#34;}`</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">err</span> <span class="o">:=</span> <span class="nx">router</span><span class="p">.</span><span class="nf">Route</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">client</span><span class="p">,</span> <span class="nx">ClientMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Action</span><span class="p">:</span> <span class="nx">ActionSubscribeTopic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Data</span><span class="p">:</span>   <span class="nx">data</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;route subscribe: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">client</span><span class="p">.</span><span class="nf">IsSubscribed</span><span class="p">(</span><span class="s">&#34;alerts&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;client should subscribe to alerts&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Payload validation 也應獨立測：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestSubscribeActionRequiresTopic</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">client</span> <span class="o">:=</span> <span class="nf">NewTestClient</span><span class="p">(</span><span class="s">&#34;client_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">router</span> <span class="o">:=</span> <span class="nx">Router</span><span class="p">{</span><span class="nx">subscriptions</span><span class="p">:</span> <span class="nf">NewSubscriptionService</span><span class="p">()}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">err</span> <span class="o">:=</span> <span class="nx">router</span><span class="p">.</span><span class="nf">Route</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">client</span><span class="p">,</span> <span class="nx">ClientMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">Action</span><span class="p">:</span> <span class="nx">ActionSubscribeTopic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Data</span><span class="p">:</span>   <span class="nx">json</span><span class="p">.</span><span class="nf">RawMessage</span><span class="p">(</span><span class="s">`{&#34;topic&#34;:&#34; &#34;}`</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">==</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;empty topic should return error&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>WebSocket integration test 留給「真實 client/server 互動」；router 單元測試先確保協定語意正確。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 action envelope 到 subscription 的路由與 ownership；授權、presence 與跨節點同步，會在下列章節延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">Go 進階：跨節點 WebSocket、presence 與重連協定</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 WebSocket action、event fusion 與 handler boundary；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">Go：如何新增一個即時訊息 action</a></li>
<li><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">Go：如何新增一種 domain event</a></li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">Go：事件融合</a></li>
<li><a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">Go：把 handler 邏輯拆成可測單元</a></li>
<li><a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">Backend：快取與 Redis</a></li>
<li><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>訂閱模型把 client action 轉成連線狀態與 server response。Action 是 client intent，不是 domain event；router 負責分派，payload validation 在邊界完成，訂閱集合要有明確 owner，broadcast 由 hub 統一檢查訂閱。這樣新增 action 或 topic 時，修改範圍會清楚且可測。</p>
]]></content:encoded></item><item><title>2.4 慢客戶端與 send buffer 管理</title><link>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/slow-client/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/slow-client/</guid><description>&lt;p>慢客戶端管理的核心問題是單一 client 的讀取速度可能低於 server 推送速度。若 send &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 沒有上限，慢 client 會把訊息堆在記憶體裡；若 hub 使用 blocking send，慢 client 會拖住所有 client。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨慢 client 對 hub、write pump、記憶體的影響&lt;/li>
&lt;li>用 bounded send channel 限制單一 client 的排隊量&lt;/li>
&lt;li>設計 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> full 時的 drop、disconnect、coalesce 策略&lt;/li>
&lt;li>在必要時用 byte budget 管理大型 payload&lt;/li>
&lt;li>測試 send buffer 滿載與 client unregister 行為&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察慢-client-會把局部問題變成全域問題">【觀察】慢 client 會把局部問題變成全域問題&lt;/h2>
&lt;p>慢 client 的核心風險是它不只影響自己。若 hub broadcast 時對每個 client 使用 blocking send，其中一個 client 的 &lt;code>send&lt;/code> channel 滿了，hub 就可能卡住，其他 client 也收不到訊息。&lt;/p>
&lt;p>反模式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">h&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Hub&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Broadcast&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">message&lt;/span> &lt;span class="nx">ServerMessage&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">client&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">clients&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">client&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">send&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">message&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式看起來保證送達，但實際上把整個 hub 的可用性綁在最慢的 client 上。只要一個 client 不讀，所有 broadcast 都可能停住。&lt;/p>
&lt;h2 id="判讀send-channel-是每個-client-的容量邊界">【判讀】send channel 是每個 client 的容量邊界&lt;/h2>
&lt;p>Send channel 的核心責任是作為單一 client 的輸出佇列。它必須有容量上限，否則 server 會替慢 client 無限制保存訊息。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">const&lt;/span> &lt;span class="nx">sendBufferSize&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">64&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Client&lt;/span> &lt;span class="kd">struct&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">id&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">send&lt;/span> &lt;span class="kd">chan&lt;/span> &lt;span class="nx">ServerMessage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewClient&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">id&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Client&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">Client&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">send&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">ServerMessage&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">sendBufferSize&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Buffer 的目的只是吸收短暫尖峰，不是讓 client 長期落後。若 client 長期消費速度低於推送速度，任何有限 buffer 都會滿。&lt;/p>
&lt;h2 id="策略滿載策略取決於訊息語意">【策略】滿載策略取決於訊息語意&lt;/h2>
&lt;p>慢 client 滿載的核心決策是訊息能不能遺失。不同資料類型需要不同策略。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊息類型&lt;/th>
 &lt;th>常見策略&lt;/th>
 &lt;th>理由&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>即時狀態 snapshot&lt;/td>
 &lt;td>可丟棄舊訊息或 coalesce&lt;/td>
 &lt;td>最新狀態比每個中間狀態重要&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>action result&lt;/td>
 &lt;td>優先送達，滿載時可斷線&lt;/td>
 &lt;td>client 需要知道操作結果&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>診斷 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a> stream&lt;/td>
 &lt;td>可取樣或丟棄&lt;/td>
 &lt;td>資料量大，通常不是唯一真相&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>金流、訂單、稽核事件&lt;/td>
 &lt;td>不應只靠 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a>&lt;/td>
 &lt;td>需要可靠儲存或可重播來源&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>WebSocket send buffer 不應承擔資料可靠性。若訊息不能遺失，可靠性應放在資料庫、queue 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/event-log/" data-link-title="Event Log" data-link-desc="說明事件歷史如何保存、重播與支援跨服務資料重建">event log&lt;/a>，WebSocket 只負責即時通知。&lt;/p>
&lt;h2 id="執行non-blocking-send-保護-hub">【執行】non-blocking send 保護 hub&lt;/h2>
&lt;p>Hub 的核心保護是 broadcast 時不被單一 client 阻塞。&lt;code>TrySend&lt;/code> 可以讓 hub 立即知道該 client 是否已滿載。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">c&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Client&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">TrySend&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">message&lt;/span> &lt;span class="nx">ServerMessage&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">bool&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="k">select&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">send&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">message&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="k">default&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">false&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Hub 可以把滿載 client 送進 unregister：&lt;/p></description><content:encoded><![CDATA[<p>慢客戶端管理的核心問題是單一 client 的讀取速度可能低於 server 推送速度。若 send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 沒有上限，慢 client 會把訊息堆在記憶體裡；若 hub 使用 blocking send，慢 client 會拖住所有 client。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨慢 client 對 hub、write pump、記憶體的影響</li>
<li>用 bounded send channel 限制單一 client 的排隊量</li>
<li>設計 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> full 時的 drop、disconnect、coalesce 策略</li>
<li>在必要時用 byte budget 管理大型 payload</li>
<li>測試 send buffer 滿載與 client unregister 行為</li>
</ol>
<hr>
<h2 id="觀察慢-client-會把局部問題變成全域問題">【觀察】慢 client 會把局部問題變成全域問題</h2>
<p>慢 client 的核心風險是它不只影響自己。若 hub broadcast 時對每個 client 使用 blocking send，其中一個 client 的 <code>send</code> channel 滿了，hub 就可能卡住，其他 client 也收不到訊息。</p>
<p>反模式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="o">*</span><span class="nx">Hub</span><span class="p">)</span> <span class="nf">Broadcast</span><span class="p">(</span><span class="nx">message</span> <span class="nx">ServerMessage</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">for</span> <span class="nx">client</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">h</span><span class="p">.</span><span class="nx">clients</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="nx">client</span><span class="p">.</span><span class="nx">send</span> <span class="o">&lt;-</span> <span class="nx">message</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式看起來保證送達，但實際上把整個 hub 的可用性綁在最慢的 client 上。只要一個 client 不讀，所有 broadcast 都可能停住。</p>
<h2 id="判讀send-channel-是每個-client-的容量邊界">【判讀】send channel 是每個 client 的容量邊界</h2>
<p>Send channel 的核心責任是作為單一 client 的輸出佇列。它必須有容量上限，否則 server 會替慢 client 無限制保存訊息。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">const</span> <span class="nx">sendBufferSize</span> <span class="p">=</span> <span class="mi">64</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">type</span> <span class="nx">Client</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">id</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">send</span> <span class="kd">chan</span> <span class="nx">ServerMessage</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">func</span> <span class="nf">NewClient</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="o">*</span><span class="nx">Client</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="o">&amp;</span><span class="nx">Client</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">id</span><span class="p">:</span>   <span class="nx">id</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">send</span><span class="p">:</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">ServerMessage</span><span class="p">,</span> <span class="nx">sendBufferSize</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Buffer 的目的只是吸收短暫尖峰，不是讓 client 長期落後。若 client 長期消費速度低於推送速度，任何有限 buffer 都會滿。</p>
<h2 id="策略滿載策略取決於訊息語意">【策略】滿載策略取決於訊息語意</h2>
<p>慢 client 滿載的核心決策是訊息能不能遺失。不同資料類型需要不同策略。</p>
<table>
  <thead>
      <tr>
          <th>訊息類型</th>
          <th>常見策略</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>即時狀態 snapshot</td>
          <td>可丟棄舊訊息或 coalesce</td>
          <td>最新狀態比每個中間狀態重要</td>
      </tr>
      <tr>
          <td>action result</td>
          <td>優先送達，滿載時可斷線</td>
          <td>client 需要知道操作結果</td>
      </tr>
      <tr>
          <td>診斷 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> stream</td>
          <td>可取樣或丟棄</td>
          <td>資料量大，通常不是唯一真相</td>
      </tr>
      <tr>
          <td>金流、訂單、稽核事件</td>
          <td>不應只靠 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a></td>
          <td>需要可靠儲存或可重播來源</td>
      </tr>
  </tbody>
</table>
<p>WebSocket send buffer 不應承擔資料可靠性。若訊息不能遺失，可靠性應放在資料庫、queue 或 <a href="/blog/backend/knowledge-cards/event-log/" data-link-title="Event Log" data-link-desc="說明事件歷史如何保存、重播與支援跨服務資料重建">event log</a>，WebSocket 只負責即時通知。</p>
<h2 id="執行non-blocking-send-保護-hub">【執行】non-blocking send 保護 hub</h2>
<p>Hub 的核心保護是 broadcast 時不被單一 client 阻塞。<code>TrySend</code> 可以讓 hub 立即知道該 client 是否已滿載。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">TrySend</span><span class="p">(</span><span class="nx">message</span> <span class="nx">ServerMessage</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">case</span> <span class="nx">c</span><span class="p">.</span><span class="nx">send</span> <span class="o">&lt;-</span> <span class="nx">message</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="k">return</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Hub 可以把滿載 client 送進 unregister：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="o">*</span><span class="nx">Hub</span><span class="p">)</span> <span class="nf">Broadcast</span><span class="p">(</span><span class="nx">topic</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">message</span> <span class="nx">ServerMessage</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">for</span> <span class="nx">client</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">h</span><span class="p">.</span><span class="nx">clients</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">if</span> <span class="p">!</span><span class="nx">client</span><span class="p">.</span><span class="nf">IsSubscribed</span><span class="p">(</span><span class="nx">topic</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="k">continue</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">if</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">client</span><span class="p">.</span><span class="nf">TrySend</span><span class="p">(</span><span class="nx">message</span><span class="p">);</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="nx">h</span><span class="p">.</span><span class="nx">unregister</span> <span class="o">&lt;-</span> <span class="nx">client</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種策略犧牲慢 client，保護整體服務。對即時通知服務來說，讓慢 client 重連並重新取得 snapshot，通常比讓所有 client 等它更合理。</p>
<h2 id="策略drop-newestdrop-oldestdisconnect-是不同語意">【策略】drop newest、drop oldest、disconnect 是不同語意</h2>
<p>Queue full 策略的核心差異是保留哪一筆資料，以及是否繼續維持連線。</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>行為</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>drop newest</td>
          <td>新訊息不進 queue</td>
          <td>舊訊息仍有價值</td>
      </tr>
      <tr>
          <td>drop oldest</td>
          <td>移除舊訊息，保留最新</td>
          <td>狀態型更新</td>
      </tr>
      <tr>
          <td>disconnect</td>
          <td>關閉 client，要求重連</td>
          <td>client 已明顯跟不上</td>
      </tr>
      <tr>
          <td>coalesce</td>
          <td>合併多筆更新成一筆</td>
          <td><a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> 最新狀態可覆蓋</td>
      </tr>
  </tbody>
</table>
<p>Drop oldest 範例：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">TrySendLatest</span><span class="p">(</span><span class="nx">message</span> <span class="nx">ServerMessage</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">case</span> <span class="nx">c</span><span class="p">.</span><span class="nx">send</span> <span class="o">&lt;-</span> <span class="nx">message</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">c</span><span class="p">.</span><span class="nx">send</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">case</span> <span class="nx">c</span><span class="p">.</span><span class="nx">send</span> <span class="o">&lt;-</span> <span class="nx">message</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">return</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式表示「新狀態比舊狀態重要」。它不適合 action result 或不可遺失事件，因為它會主動丟掉尚未送出的舊訊息。</p>
<h2 id="策略byte-budget-比-message-count-更接近記憶體風險">【策略】byte budget 比 message count 更接近記憶體風險</h2>
<p>Message count 的核心限制是每筆訊息大小不同。64 筆小訊息和 64 筆大型 JSON payload 的記憶體成本差很多；當 payload 大小差異明顯時，可以加上 byte budget。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">type</span> <span class="nx">Client</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">send</span>      <span class="kd">chan</span> <span class="nx">ServerMessage</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">sendBytes</span> <span class="kt">int64</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">maxBytes</span>  <span class="kt">int64</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">TrySend</span><span class="p">(</span><span class="nx">message</span> <span class="nx">ServerMessage</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">size</span> <span class="o">:=</span> <span class="nb">int64</span><span class="p">(</span><span class="nx">message</span><span class="p">.</span><span class="nf">Size</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">atomic</span><span class="p">.</span><span class="nf">AddInt64</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">c</span><span class="p">.</span><span class="nx">sendBytes</span><span class="p">,</span> <span class="nx">size</span><span class="p">)</span> <span class="p">&gt;</span> <span class="nx">c</span><span class="p">.</span><span class="nx">maxBytes</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">atomic</span><span class="p">.</span><span class="nf">AddInt64</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">c</span><span class="p">.</span><span class="nx">sendBytes</span><span class="p">,</span> <span class="o">-</span><span class="nx">size</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">case</span> <span class="nx">c</span><span class="p">.</span><span class="nx">send</span> <span class="o">&lt;-</span> <span class="nx">message</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="k">return</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">atomic</span><span class="p">.</span><span class="nf">AddInt64</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">c</span><span class="p">.</span><span class="nx">sendBytes</span><span class="p">,</span> <span class="o">-</span><span class="nx">size</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Write pump 成功取出並寫出訊息後，必須扣回 byte budget：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Client</span><span class="p">)</span> <span class="nf">markSent</span><span class="p">(</span><span class="nx">message</span> <span class="nx">ServerMessage</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">atomic</span><span class="p">.</span><span class="nf">AddInt64</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">c</span><span class="p">.</span><span class="nx">sendBytes</span><span class="p">,</span> <span class="o">-</span><span class="nb">int64</span><span class="p">(</span><span class="nx">message</span><span class="p">.</span><span class="nf">Size</span><span class="p">()))</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Byte budget 更接近記憶體風險，但也更複雜。只有在訊息大小差異大、或服務連線數高時才值得加入；小型服務先用固定 buffer 通常足夠。</p>
<h2 id="判讀write-pump-慢不一定是-client-的錯">【判讀】write pump 慢不一定是 client 的錯</h2>
<p>慢寫入的核心原因可能在 client，也可能在 server。Client 網路慢、瀏覽器停住、行動裝置休眠會造成慢寫；server payload 太大、序列化太慢、單次寫入沒有 <a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 也會造成問題。</p>
<p>排查方向：</p>
<ul>
<li>send buffer 長期接近滿載</li>
<li>write deadline 錯誤增加</li>
<li>單筆 message size 過大</li>
<li>broadcast 頻率超過 client 消費能力</li>
<li>某些 topic 推送量異常高</li>
</ul>
<p>queue full 的歸因應同時檢查 client 與 server 端訊號。若所有 client 都慢，通常是 server 推送量、payload 大小或下游網路策略出問題。</p>
<h2 id="策略滿載要有觀測欄位">【策略】滿載要有觀測欄位</h2>
<p>慢 client 策略的核心要求是可觀測。若系統選擇 drop 或 disconnect，應記錄足夠欄位讓工程師知道原因。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="o">*</span><span class="nx">Hub</span><span class="p">)</span> <span class="nf">handleFullClient</span><span class="p">(</span><span class="nx">client</span> <span class="o">*</span><span class="nx">Client</span><span class="p">,</span> <span class="nx">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">metrics</span><span class="p">.</span><span class="nf">Inc</span><span class="p">(</span><span class="s">&#34;websocket_client_send_full&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">h</span><span class="p">.</span><span class="nx">logger</span><span class="p">.</span><span class="nf">Warn</span><span class="p">(</span><span class="s">&#34;websocket client send buffer full&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="s">&#34;client_id&#34;</span><span class="p">,</span> <span class="nx">client</span><span class="p">.</span><span class="nf">ID</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="s">&#34;topic&#34;</span><span class="p">,</span> <span class="nx">topic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="s">&#34;send_queue_len&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">client</span><span class="p">.</span><span class="nx">send</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="s">&#34;send_queue_cap&#34;</span><span class="p">,</span> <span class="nb">cap</span><span class="p">(</span><span class="nx">client</span><span class="p">.</span><span class="nx">send</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">h</span><span class="p">.</span><span class="nx">unregister</span> <span class="o">&lt;-</span> <span class="nx">client</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Log 用來追單次事件，metric 用來看趨勢。若滿載數量突然增加，可能是某個 topic 推送量上升，也可能是 client 版本或網路環境改變。</p>
<h2 id="測試滿載測試要先填滿-buffer">【測試】滿載測試要先填滿 buffer</h2>
<p>慢 client 測試的核心是直接建立滿載條件。容量為 1 的 channel 加上預先填滿的資料，可以穩定製造 queue full；sleep 只是在等待排程運氣。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestTrySendReturnsFalseWhenBufferFull</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">client</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">Client</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">id</span><span class="p">:</span>   <span class="s">&#34;client_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">send</span><span class="p">:</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">ServerMessage</span><span class="p">,</span> <span class="mi">1</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">client</span><span class="p">.</span><span class="nx">send</span> <span class="o">&lt;-</span> <span class="nx">ServerMessage</span><span class="p">{</span><span class="nx">Type</span><span class="p">:</span> <span class="s">&#34;first&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">client</span><span class="p">.</span><span class="nf">TrySend</span><span class="p">(</span><span class="nx">ServerMessage</span><span class="p">{</span><span class="nx">Type</span><span class="p">:</span> <span class="s">&#34;second&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;TrySend should return false when buffer is full&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Hub unregister 行為也可以測：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestBroadcastUnregistersFullClient</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">hub</span> <span class="o">:=</span> <span class="nf">NewHub</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">client</span> <span class="o">:=</span> <span class="nf">NewTestClient</span><span class="p">(</span><span class="s">&#34;client_1&#34;</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">client</span><span class="p">.</span><span class="nf">Subscribe</span><span class="p">(</span><span class="s">&#34;alerts&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">client</span><span class="p">.</span><span class="nx">send</span> <span class="o">&lt;-</span> <span class="nx">ServerMessage</span><span class="p">{</span><span class="nx">Type</span><span class="p">:</span> <span class="s">&#34;existing&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">hub</span><span class="p">.</span><span class="nx">clients</span><span class="p">[</span><span class="nx">client</span><span class="p">]</span> <span class="p">=</span> <span class="kd">struct</span><span class="p">{}{}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">hub</span><span class="p">.</span><span class="nf">Broadcast</span><span class="p">(</span><span class="s">&#34;alerts&#34;</span><span class="p">,</span> <span class="nx">ServerMessage</span><span class="p">{</span><span class="nx">Type</span><span class="p">:</span> <span class="s">&#34;new&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">case</span> <span class="nx">got</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">hub</span><span class="p">.</span><span class="nx">unregister</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">client</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;unregister client mismatch&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;full client should be unregistered&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這類測試直接驗證服務策略：client 滿載時，hub 不阻塞，而是走指定降級路徑。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一 server 內的慢 client 與 send buffer 邊界；跨節點 <a href="/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out</a> 與持久化同步，會在下列章節延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">Go 進階：跨節點 WebSocket、presence 與重連協定</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 channel <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 、non-blocking send 與 rate limiting；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go：channel：資料傳遞與 backpressure </a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">Go：非阻塞送出與事件丟棄策略</a></li>
<li><a href="/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">Go：rate limiting 與 backpressure </a></li>
<li><a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">Go：bounded worker pool</a></li>
<li><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a></li>
<li><a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">Backend：快取與 Redis</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>慢客戶端是 WebSocket 服務的容量控制問題。每個 client 的 send buffer 必須有上限，hub broadcast 不應被單一 client 阻塞，queue full 策略要符合訊息語意。必要時可加入 byte budget，但更重要的是明確決定 drop、disconnect、coalesce 或可靠儲存，並用 log、metric、測試讓降級行為可見。</p>
]]></content:encoded></item></channel></rss>