<?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>Go on Tarragon</title><link>https://tarrragon.github.io/blog/tags/go/</link><description>Recent content in Go on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 23 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/go/index.xml" rel="self" type="application/rss+xml"/><item><title>Collector 架構</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/architecture/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/architecture/</guid><description>&lt;p>Collector 是監控資料的接收與處理中心，職責是把 SDK 送來的事件資料轉換成可查詢、可觸發動作的持久化記錄。整條鏈路由五段組成，每段有明確的輸入和輸出，段與段之間用結構化資料傳遞。&lt;/p>
&lt;h2 id="五段處理鏈路">五段處理鏈路&lt;/h2>
&lt;h3 id="第一段http-endpoint-接收">第一段：HTTP endpoint 接收&lt;/h3>
&lt;p>Collector 對外提供一個 HTTP POST endpoint（例如 &lt;code>/v1/events&lt;/code>），接收 SDK 送來的 JSON body。每個 request 可以是單一事件或批次事件陣列。&lt;/p>
&lt;p>Endpoint 的職責只有兩件事：驗證 HTTP 層面的基本條件（Content-Type、body size limit、認證 token），然後把 body 傳給下一段。HTTP 層面的錯誤（413 body too large、401 unauthorized）在這裡回應，不進入後續處理。&lt;/p>
&lt;p>自用工具場景下，Go 的 &lt;code>net/http&lt;/code> 標準庫提供的 HTTP server 已足夠。一個 &lt;code>http.HandleFunc(&amp;quot;/v1/events&amp;quot;, handler)&lt;/code> 加上 &lt;code>json.NewDecoder(r.Body).Decode(&amp;amp;events)&lt;/code> 就完成接收。不需要 framework。&lt;/p>
&lt;h3 id="第二段json-schema-驗證">第二段：JSON Schema 驗證&lt;/h3>
&lt;p>收到的 JSON body 用 JSON Schema 驗證結構正確性 — 必要欄位是否存在、型別是否正確、值是否在合法範圍內。驗證失敗的事件被拒絕並記錄原因，通過的事件進入下一段。&lt;/p>
&lt;p>Schema 驗證是 collector 的品質閘門。沒有驗證的 collector 會累積格式不一致的資料，查詢時需要處理各種邊界條件。驗證在寫入前攔截問題，比寫入後清理成本低。&lt;/p>
&lt;p>驗證的粒度是事件級 — 批次中的一個事件驗證失敗不影響其他事件。回應中標明哪些事件被接受、哪些被拒絕及原因。&lt;/p>
&lt;h3 id="ingestion-回應格式">Ingestion 回應格式&lt;/h3>
&lt;p>回應格式把「接受了幾筆、拒絕了幾筆、拒絕原因」三件事用一套一致的結構表達。SDK 端只需要判斷 status code 就知道怎麼處理 buffer。&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="c1">// 200 OK — 單筆成功或批次全部成功
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;accepted&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1">// 207 Multi-Status — 批次部分失敗
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="c1">&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="nt">&amp;#34;accepted&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1&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="nt">&amp;#34;rejected&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1&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="nt">&amp;#34;errors&amp;#34;&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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;index&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;message&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;missing required field: type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;fields&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&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">10&lt;/span>&lt;span class="cl"> &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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="c1">// 400 Bad Request — 單筆失敗或批次全部失敗
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="c1">&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="nt">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;schema validation failed&amp;#34;&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="nt">&amp;#34;details&amp;#34;&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">17&lt;/span>&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;field&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;message&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;missing required field&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="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="c1">// 503 Service Unavailable — 寫入端暫時不可用
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">{&lt;/span> &lt;span class="nt">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;service temporarily unavailable&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;retry_after&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">5&lt;/span> &lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>設計選擇：207 的 &lt;code>errors&lt;/code> 陣列用 &lt;code>index&lt;/code> 標明失敗事件在原始 batch 中的位置（0-based），SDK 可以用 index 對照原始事件做 debug log。合法事件不因部分失敗而被丟棄 — 部分成功是 batch 收集的核心價值。400 和 207 的差異是「全軍覆沒 vs 部分存活」，SDK 端的處理策略不同：400 直接清 buffer（schema 問題重試也不會過），207 只清成功的部分。&lt;/p>
&lt;h3 id="health-endpoint-回應">Health endpoint 回應&lt;/h3>
&lt;p>Health endpoint 回傳 collector 自身的運行狀態，不包含事件內容。用途是 SDK 端確認 collector 可達、監控腳本定期探測。&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="c1">// GET /health → 200 OK
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&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;status&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ok&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;uptime_seconds&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">3600&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="nt">&amp;#34;total_events&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1234&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="nt">&amp;#34;storage_bytes&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">5242880&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="nt">&amp;#34;version&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;0.1.0&amp;#34;&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>&lt;code>total_events&lt;/code> 和 &lt;code>storage_bytes&lt;/code> 讓監控腳本判斷 collector 的負載趨勢。&lt;code>version&lt;/code> 讓 SDK 確認 collector 版本（schema 不匹配時的第一個 debug 線索）。&lt;/p>
&lt;h3 id="第三段儲存">第三段：儲存&lt;/h3>
&lt;p>通過驗證的事件寫入 Storage Backend。Collector 使用可插拔的 Storage interface — day-one 預設用 SQLite（零依賴、嵌入式），分析需求觸發時切換到 PostgreSQL。具體的 backend 選擇和功能分層見 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇&lt;/a>，可插拔架構見 &lt;a href="https://tarrragon.github.io/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Collector 是監控資料的接收與處理中心，職責是把 SDK 送來的事件資料轉換成可查詢、可觸發動作的持久化記錄。整條鏈路由五段組成，每段有明確的輸入和輸出，段與段之間用結構化資料傳遞。</p>
<h2 id="五段處理鏈路">五段處理鏈路</h2>
<h3 id="第一段http-endpoint-接收">第一段：HTTP endpoint 接收</h3>
<p>Collector 對外提供一個 HTTP POST endpoint（例如 <code>/v1/events</code>），接收 SDK 送來的 JSON body。每個 request 可以是單一事件或批次事件陣列。</p>
<p>Endpoint 的職責只有兩件事：驗證 HTTP 層面的基本條件（Content-Type、body size limit、認證 token），然後把 body 傳給下一段。HTTP 層面的錯誤（413 body too large、401 unauthorized）在這裡回應，不進入後續處理。</p>
<p>自用工具場景下，Go 的 <code>net/http</code> 標準庫提供的 HTTP server 已足夠。一個 <code>http.HandleFunc(&quot;/v1/events&quot;, handler)</code> 加上 <code>json.NewDecoder(r.Body).Decode(&amp;events)</code> 就完成接收。不需要 framework。</p>
<h3 id="第二段json-schema-驗證">第二段：JSON Schema 驗證</h3>
<p>收到的 JSON body 用 JSON Schema 驗證結構正確性 — 必要欄位是否存在、型別是否正確、值是否在合法範圍內。驗證失敗的事件被拒絕並記錄原因，通過的事件進入下一段。</p>
<p>Schema 驗證是 collector 的品質閘門。沒有驗證的 collector 會累積格式不一致的資料，查詢時需要處理各種邊界條件。驗證在寫入前攔截問題，比寫入後清理成本低。</p>
<p>驗證的粒度是事件級 — 批次中的一個事件驗證失敗不影響其他事件。回應中標明哪些事件被接受、哪些被拒絕及原因。</p>
<h3 id="ingestion-回應格式">Ingestion 回應格式</h3>
<p>回應格式把「接受了幾筆、拒絕了幾筆、拒絕原因」三件事用一套一致的結構表達。SDK 端只需要判斷 status code 就知道怎麼處理 buffer。</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="c1">// 200 OK — 單筆成功或批次全部成功
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="p">{</span> <span class="nt">&#34;accepted&#34;</span><span class="p">:</span> <span class="mi">1</span> <span class="p">}</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="c1">// 207 Multi-Status — 批次部分失敗
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="nt">&#34;accepted&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="nt">&#34;rejected&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="nt">&#34;errors&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">{</span> <span class="nt">&#34;index&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&#34;message&#34;</span><span class="p">:</span> <span class="s2">&#34;missing required field: type&#34;</span><span class="p">,</span> <span class="nt">&#34;fields&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;type&#34;</span><span class="p">]</span> <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><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="c1">// 400 Bad Request — 單筆失敗或批次全部失敗
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1"></span><span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="nt">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;schema validation failed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="nt">&#34;details&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">{</span> <span class="nt">&#34;field&#34;</span><span class="p">:</span> <span class="s2">&#34;type&#34;</span><span class="p">,</span> <span class="nt">&#34;message&#34;</span><span class="p">:</span> <span class="s2">&#34;missing required field&#34;</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 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="c1">// 503 Service Unavailable — 寫入端暫時不可用
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="c1"></span><span class="p">{</span> <span class="nt">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;service temporarily unavailable&#34;</span><span class="p">,</span> <span class="nt">&#34;retry_after&#34;</span><span class="p">:</span> <span class="mi">5</span> <span class="p">}</span></span></span></code></pre></div><p>設計選擇：207 的 <code>errors</code> 陣列用 <code>index</code> 標明失敗事件在原始 batch 中的位置（0-based），SDK 可以用 index 對照原始事件做 debug log。合法事件不因部分失敗而被丟棄 — 部分成功是 batch 收集的核心價值。400 和 207 的差異是「全軍覆沒 vs 部分存活」，SDK 端的處理策略不同：400 直接清 buffer（schema 問題重試也不會過），207 只清成功的部分。</p>
<h3 id="health-endpoint-回應">Health endpoint 回應</h3>
<p>Health endpoint 回傳 collector 自身的運行狀態，不包含事件內容。用途是 SDK 端確認 collector 可達、監控腳本定期探測。</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="c1">// GET /health → 200 OK
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;status&#34;</span><span class="p">:</span> <span class="s2">&#34;ok&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;uptime_seconds&#34;</span><span class="p">:</span> <span class="mi">3600</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nt">&#34;total_events&#34;</span><span class="p">:</span> <span class="mi">1234</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nt">&#34;storage_bytes&#34;</span><span class="p">:</span> <span class="mi">5242880</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="nt">&#34;version&#34;</span><span class="p">:</span> <span class="s2">&#34;0.1.0&#34;</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>total_events</code> 和 <code>storage_bytes</code> 讓監控腳本判斷 collector 的負載趨勢。<code>version</code> 讓 SDK 確認 collector 版本（schema 不匹配時的第一個 debug 線索）。</p>
<h3 id="第三段儲存">第三段：儲存</h3>
<p>通過驗證的事件寫入 Storage Backend。Collector 使用可插拔的 Storage interface — day-one 預設用 SQLite（零依賴、嵌入式），分析需求觸發時切換到 PostgreSQL。具體的 backend 選擇和功能分層見 <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a>，可插拔架構見 <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a>。</p>
<h3 id="第四段查詢">第四段：查詢</h3>
<p>儲存的事件透過 CLI 指令或 HTTP 查詢 endpoint 存取。SQLite backend 下用 SQL 查詢；匯出為 JSONL 格式後也可用 <code>grep</code> + <code>jq</code> 做臨時分析。</p>
<p>查詢設計見 <a href="/blog/monitoring/04-collector/query-api/" data-link-title="查詢 API 設計" data-link-desc="CLI grep 友好的 JSONL 結構 &#43; HTTP 查詢 endpoint — 兩種查詢介面各自的適用場景和設計要點">查詢 API 設計</a>。</p>
<h3 id="第五段rule-engine">第五段：Rule engine</h3>
<p>Rule engine 在事件寫入後觸發，檢查事件是否匹配預定義的規則。匹配時執行對應的動作（發通知、寫 summary、觸發 webhook）。</p>
<p>Rule engine 設計見 <a href="/blog/monitoring/04-collector/rule-engine/" data-link-title="Rule engine 設計" data-link-desc="條件 → 動作 → 模板的三段式規則結構 — 讓 collector 從被動儲存變成主動回應">Rule engine 設計</a>。</p>
<h2 id="多獨立-client-併發寫入">多獨立 client 併發寫入</h2>
<p>上述五段鏈路描述的是單一 request 的路徑。實際運行時，多個 SDK 會同時送事件——以下先描述場景，下方<a href="#%e4%b8%a6%e7%99%bc%e5%af%ab%e5%85%a5%e7%ad%96%e7%95%a5">並發寫入策略</a>再詳述 collector 如何處理。</p>
<p>常見部署場景中，多個完全獨立的 SDK 實例同時送事件到同一個 collector——不同 process、不同 app、甚至不同語言的 SDK。這和「一個 app 內的多 thread 併發」不同：每個 SDK 有自己的 buffer 和 HTTP 連線，不共享任何狀態。</p>
<p>SDK 端不需要知道其他 SDK 的存在。每個 SDK 獨立 init、獨立 buffer、獨立 flush、獨立 close。SDK 端的唯一接觸點是 collector 的 HTTP endpoint——併發安全由 storage backend 的併發策略保證（見下方<a href="#%e4%b8%a6%e7%99%bc%e5%af%ab%e5%85%a5%e7%ad%96%e7%95%a5">並發寫入策略</a>），不需要 SDK 端協調。多 client 同時 flush 時的背壓機制見 <a href="/blog/monitoring/04-collector/ingestion-scaling/" data-link-title="Ingestion Scaling" data-link-desc="四層防線應對 ingestion 端的流量擴展 — SDK 取樣、Collector 背壓、水平擴展、Queue 解耦">Ingestion 背壓與流量管控</a>。</p>
<p>例如 CI pipeline 的多個 job 同時送 build 事件，或微服務架構中多個 service 各自送事件到同一個 collector。另一個具體案例是 Claude Code 的 Hook 系統——多個 Hook 同時觸發時，每個 Hook 是獨立的 Python process，各自初始化 SDK、產生事件、flush 到同一個 collector。</p>
<h2 id="並發寫入策略">並發寫入策略</h2>
<p>Go 的 HTTP server 為每個 request 分配一個 goroutine。多個 SDK 同時 flush 時，collector 同時收到多個寫入請求。Storage Backend 的並發能力決定了這些 goroutine 怎麼協調。</p>
<h3 id="sqlite-backend單寫者模型">SQLite Backend：單寫者模型</h3>
<p>SQLite 的 WAL mode 允許一個 writer 和多個 concurrent reader — 讀寫不互相阻塞，但多個 writer 之間是序列化的。Go 端有兩種處理 pattern：</p>
<p><strong>Single-writer goroutine + channel</strong>：所有 <code>Store()</code> 呼叫把事件送進一個 Go channel，由一個專屬的 goroutine 從 channel 讀取並序列寫入 SQLite。HTTP handler 送完 channel 後等待確認（或用 buffered channel 異步）。優點是背壓控制清晰 — channel 滿時 HTTP handler 自然阻塞，可以回 503。缺點是多一層間接。</p>
<p><strong>Busy timeout fallback</strong>：不在 Go 層管序列化，讓 SQLite driver 自己處理。設定 <code>_pragma=busy_timeout(5000)</code>，多個 goroutine 同時呼叫 <code>Store()</code> 時，SQLite 讓等待的 goroutine block 直到寫入鎖釋放（最多 5 秒）。優點是實作簡單（不需要 channel 和額外 goroutine）。缺點是背壓不可控 — goroutine 數量可能累積。</p>
<p>自用工具場景推薦 busy timeout（簡單）、寫入量增長到出現超時錯誤時切換到 channel pattern。</p>
<h3 id="postgresql-backend連線池">PostgreSQL Backend：連線池</h3>
<p>PostgreSQL 透過連線池（<code>database/sql</code> 的 <code>SetMaxOpenConns</code>）支援並行寫入。多個 goroutine 可以同時寫入不同的連線，不需要額外的序列化機制。</p>
<h2 id="go-單一-binary-的設計選擇">Go 單一 binary 的設計選擇</h2>
<p>Collector 用 Go 編譯成單一 binary，不依賴外部 runtime（JVM、Python interpreter、Node.js）。部署是複製一個檔案，啟動是執行一個指令。</p>
<p>這個選擇在自用工具場景下有特定優勢：server 和 collector 在同一台機器上，部署流程是 <code>scp collector user@host:</code> + <code>ssh user@host ./collector</code>。不需要 package manager、不需要 container registry、不需要 orchestration。</p>
<p>Go 的 <code>net/http</code> 標準庫提供 production-ready 的 HTTP server，JSON 處理用標準庫的 <code>encoding/json</code>，SQLite 用 <code>modernc.org/sqlite</code>（pure Go、無 CGO 依賴）。整個 collector 的核心邏輯可以在 500 行以內完成。</p>
<p>具體的部署步驟（systemd service 檔案、啟動參數、設定檔格式）和 Quick Start（從零到第一筆事件出現在 collector）見 monitor repo 的 deployment guide。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>功能分層與 Backend 選擇 → <a href="/blog/monitoring/04-collector/feature-tier-boundary/" data-link-title="功能分層與 Backend 選擇" data-link-desc="SQLite 層和 PostgreSQL 層各自承載哪些功能 — 分界線是查詢模式而非資料量、觸發升級的是功能需求而非規模成長">功能分層與 Backend 選擇</a></li>
<li>可插拔 Storage Backend 架構 → <a href="/blog/monitoring/04-collector/scaling-evolution/" data-link-title="規模演進" data-link-desc="可插拔 Storage Backend 架構 — SQLite 預設、PostgreSQL 觸發切換、時間序列 DB 長期演進">規模演進</a></li>
<li>JSONL 匯出與備份格式 → <a href="/blog/monitoring/04-collector/jsonl-storage/" data-link-title="JSONL 匯出與備份格式" data-link-desc="JSONL 作為匯出和備份格式的設計 — 人類可讀、grep 友好、SQLite 損壞時的重建來源">JSONL 儲存設計</a></li>
<li>查詢 API 的設計 → <a href="/blog/monitoring/04-collector/query-api/" data-link-title="查詢 API 設計" data-link-desc="CLI grep 友好的 JSONL 結構 &#43; HTTP 查詢 endpoint — 兩種查詢介面各自的適用場景和設計要點">查詢 API 設計</a></li>
<li>Rule engine → <a href="/blog/monitoring/04-collector/rule-engine/" data-link-title="Rule engine 設計" data-link-desc="條件 → 動作 → 模板的三段式規則結構 — 讓 collector 從被動儲存變成主動回應">Rule engine 設計</a></li>
<li>背壓與流量管控的基礎概念 → <a href="/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">DevOps 流量管控</a></li>
<li>端到端資料完整性 → <a href="/blog/monitoring/04-collector/data-integrity/" data-link-title="端到端資料完整性" data-link-desc="從 SDK 到 storage 的資料損失地圖 — 每個環節的損失類型、控制策略、完整性指標、被自己 SDK DDoS 的防護">端到端資料完整性</a></li>
<li>Error fingerprint 與去重分群 → <a href="/blog/monitoring/04-collector/error-fingerprint/" data-link-title="Error Fingerprint 與去重分群" data-link-desc="把大量 error 事件歸組成可管理的 issue 列表 — fingerprint 演算法、message normalization、error_groups 表設計、自架方案的務實邊界">Error Fingerprint 與去重分群</a></li>
</ul>
]]></content:encoded></item><item><title>9.1 用 stdlib flag 寫 subcommand CLI</title><link>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/stdlib-flag-subcommands/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/stdlib-flag-subcommands/</guid><description>&lt;p>Subcommand CLI 的核心結構是 &lt;code>&amp;lt;tool&amp;gt; &amp;lt;sub&amp;gt; [flags] [args]&lt;/code>，每層各自承擔獨立決策：dispatcher 決定走到哪個子命令、flag parser 只認該子命令的旗標命名空間、positional args 交給業務邏輯。&lt;code>flag.NewFlagSet&lt;/code> 為每個子命令建立獨立 flag 命名空間，讓三層以內的 CLI 用 stdlib 就能乾淨解析；cobra 的說服點在 tab completion、generated help、hierarchical commands 等&lt;strong>超出 flag 解析本身&lt;/strong>的領域，三層內走 stdlib 成本最低。&lt;/p>
&lt;p>本章以 &lt;code>scripts/mdtools&lt;/code>（blog 自己的 markdown 工具鏈，repo 內檔案）作為 concrete instance。讀者不需要事先熟悉 mdtools — 每段會先講通用 pattern，再用對應 code 示範一種可行實作。&lt;/p>
&lt;h2 id="基礎為什麼需要-flagnewflagset-而非-flagparse">基礎：為什麼需要 &lt;code>flag.NewFlagSet&lt;/code> 而非 &lt;code>flag.Parse()&lt;/code>&lt;/h2>
&lt;p>&lt;code>flag.Parse()&lt;/code> 只解析一次全域 flag set。對只有一個命令的小工具（如 &lt;code>tool --input foo&lt;/code>）夠用；但一旦進入 &lt;code>tool fmt --fix&lt;/code> 這種 &lt;code>&amp;lt;tool&amp;gt; &amp;lt;subcommand&amp;gt; [flags]&lt;/code> 結構，全域 flag set 就擋路：&lt;/p>
&lt;ul>
&lt;li>&lt;code>--fix&lt;/code> 對 &lt;code>fmt&lt;/code> 命令有意義，對 &lt;code>lint&lt;/code> 命令沒有。&lt;/li>
&lt;li>各子命令可能共享 name（例如 &lt;code>--verbose&lt;/code>）但預設值或語意不同。&lt;/li>
&lt;li>help 輸出需要分子命令各自列自己的 flags。&lt;/li>
&lt;/ul>
&lt;p>&lt;code>flag.NewFlagSet&lt;/code> 讓每個子命令擁有&lt;strong>獨立的 flag 命名空間&lt;/strong>：&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="nx">fs&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">flag&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewFlagSet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;fmt&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">flag&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ExitOnError&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">fix&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">fs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Bool&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;fix&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;apply fixes in place&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="nx">check&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">fs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Bool&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;check&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;report-only&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="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">fs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Parse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">args&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// args = os.Args[2:]，已經跳過了子命令本身&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>fs.Parse(args)&lt;/code> 只看傳進去的片段，不碰 &lt;code>os.Args&lt;/code> 全域。這是撐起 subcommand CLI 的核心 API。&lt;/p>
&lt;h2 id="專案-layoutmain--cmd--internal">專案 Layout：main → cmd/ → internal/&lt;/h2>
&lt;p>Go 慣例的 CLI 專案結構是三層，對應三種責任：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">scripts/mdtools/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">├── main.go ← 層 1：dispatcher，只做「看第一個參數分派到哪裡」
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">├── cmd/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">│ ├── fmt.go ← 層 2：每個子命令一個檔案，負責 flag 解析與呼叫 internal
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">│ ├── lint.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">│ ├── cards.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">│ └── migrate.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">└── internal/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> ├── mdfmt/ ← 層 3：純邏輯，不碰 flag、os.Args、os.Exit
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> ├── mdlint/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> └── mdcards/&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>分層的目的是支援每層獨立的測試策略：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>layer 1&lt;/strong>：幾乎不測，因為只是 &lt;code>switch&lt;/code>。&lt;/li>
&lt;li>&lt;strong>layer 2&lt;/strong>：integration test（給定 argv、確認 exit code 與 stdout）。&lt;/li>
&lt;li>&lt;strong>layer 3&lt;/strong>：unit test，純函式輸入輸出。後續模組的所有實作技術 — &lt;a href="https://tarrragon.github.io/blog/go/09-tooling-and-analysis/goldmark-ast-basics/" data-link-title="9.2 第三方 parser 整合：goldmark AST 入門" data-link-desc="用 goldmark 把 markdown 解析成 AST，掌握 ast.Walk visitor 模式、block 與 inline 節點的判讀、byte offset 如何定位到行號">AST 整合&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go/09-tooling-and-analysis/ast-idempotent-rewriting/" data-link-title="9.3 AST 驅動的 idempotent 文字改寫" data-link-desc="用 AST 定位位置、用 line-based 或 byte-level 改寫；設計多條 rule 的執行順序；--check 跟 --fix 如何共用邏輯">idempotent 改寫&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go/09-tooling-and-analysis/cross-file-graph-analysis/" data-link-title="9.4 跨檔案圖分析：從 lint 走到 static analysis" data-link-desc="Single-file 規則用 AST 搞定；跨檔 orphan 偵測、broken link、backlink 完整性需要把整個 repo 建成圖再走訪。用 mdtools cards 為例">graph 分析&lt;/a> — 都落在這層。&lt;/li>
&lt;/ul>
&lt;p>把 &lt;code>os.Exit&lt;/code> / &lt;code>os.Args&lt;/code> / &lt;code>os.Stderr&lt;/code> 都擋在 layer 1-2，layer 3 就能用一般 table-driven test 測，不用起 subprocess。&lt;/p></description><content:encoded><![CDATA[<p>Subcommand CLI 的核心結構是 <code>&lt;tool&gt; &lt;sub&gt; [flags] [args]</code>，每層各自承擔獨立決策：dispatcher 決定走到哪個子命令、flag parser 只認該子命令的旗標命名空間、positional args 交給業務邏輯。<code>flag.NewFlagSet</code> 為每個子命令建立獨立 flag 命名空間，讓三層以內的 CLI 用 stdlib 就能乾淨解析；cobra 的說服點在 tab completion、generated help、hierarchical commands 等<strong>超出 flag 解析本身</strong>的領域，三層內走 stdlib 成本最低。</p>
<p>本章以 <code>scripts/mdtools</code>（blog 自己的 markdown 工具鏈，repo 內檔案）作為 concrete instance。讀者不需要事先熟悉 mdtools — 每段會先講通用 pattern，再用對應 code 示範一種可行實作。</p>
<h2 id="基礎為什麼需要-flagnewflagset-而非-flagparse">基礎：為什麼需要 <code>flag.NewFlagSet</code> 而非 <code>flag.Parse()</code></h2>
<p><code>flag.Parse()</code> 只解析一次全域 flag set。對只有一個命令的小工具（如 <code>tool --input foo</code>）夠用；但一旦進入 <code>tool fmt --fix</code> 這種 <code>&lt;tool&gt; &lt;subcommand&gt; [flags]</code> 結構，全域 flag set 就擋路：</p>
<ul>
<li><code>--fix</code> 對 <code>fmt</code> 命令有意義，對 <code>lint</code> 命令沒有。</li>
<li>各子命令可能共享 name（例如 <code>--verbose</code>）但預設值或語意不同。</li>
<li>help 輸出需要分子命令各自列自己的 flags。</li>
</ul>
<p><code>flag.NewFlagSet</code> 讓每個子命令擁有<strong>獨立的 flag 命名空間</strong>：</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="nx">fs</span> <span class="o">:=</span> <span class="nx">flag</span><span class="p">.</span><span class="nf">NewFlagSet</span><span class="p">(</span><span class="s">&#34;fmt&#34;</span><span class="p">,</span> <span class="nx">flag</span><span class="p">.</span><span class="nx">ExitOnError</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">fix</span> <span class="o">:=</span> <span class="nx">fs</span><span class="p">.</span><span class="nf">Bool</span><span class="p">(</span><span class="s">&#34;fix&#34;</span><span class="p">,</span> <span class="kc">false</span><span class="p">,</span> <span class="s">&#34;apply fixes in place&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">check</span> <span class="o">:=</span> <span class="nx">fs</span><span class="p">.</span><span class="nf">Bool</span><span class="p">(</span><span class="s">&#34;check&#34;</span><span class="p">,</span> <span class="kc">false</span><span class="p">,</span> <span class="s">&#34;report-only&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">_</span> <span class="p">=</span> <span class="nx">fs</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="nx">args</span><span class="p">)</span> <span class="c1">// args = os.Args[2:]，已經跳過了子命令本身</span></span></span></code></pre></div><p><code>fs.Parse(args)</code> 只看傳進去的片段，不碰 <code>os.Args</code> 全域。這是撐起 subcommand CLI 的核心 API。</p>
<h2 id="專案-layoutmain--cmd--internal">專案 Layout：main → cmd/ → internal/</h2>
<p>Go 慣例的 CLI 專案結構是三層，對應三種責任：</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">scripts/mdtools/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── main.go             ← 層 1：dispatcher，只做「看第一個參數分派到哪裡」
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">├── cmd/
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">│   ├── fmt.go          ← 層 2：每個子命令一個檔案，負責 flag 解析與呼叫 internal
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">│   ├── lint.go
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">│   ├── cards.go
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">│   └── migrate.go
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">└── internal/
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    ├── mdfmt/          ← 層 3：純邏輯，不碰 flag、os.Args、os.Exit
</span></span><span class="line"><span class="ln">10</span><span class="cl">    ├── mdlint/
</span></span><span class="line"><span class="ln">11</span><span class="cl">    └── mdcards/</span></span></code></pre></div><p>分層的目的是支援每層獨立的測試策略：</p>
<ul>
<li><strong>layer 1</strong>：幾乎不測，因為只是 <code>switch</code>。</li>
<li><strong>layer 2</strong>：integration test（給定 argv、確認 exit code 與 stdout）。</li>
<li><strong>layer 3</strong>：unit test，純函式輸入輸出。後續模組的所有實作技術 — <a href="/blog/go/09-tooling-and-analysis/goldmark-ast-basics/" data-link-title="9.2 第三方 parser 整合：goldmark AST 入門" data-link-desc="用 goldmark 把 markdown 解析成 AST，掌握 ast.Walk visitor 模式、block 與 inline 節點的判讀、byte offset 如何定位到行號">AST 整合</a>、<a href="/blog/go/09-tooling-and-analysis/ast-idempotent-rewriting/" data-link-title="9.3 AST 驅動的 idempotent 文字改寫" data-link-desc="用 AST 定位位置、用 line-based 或 byte-level 改寫；設計多條 rule 的執行順序；--check 跟 --fix 如何共用邏輯">idempotent 改寫</a>、<a href="/blog/go/09-tooling-and-analysis/cross-file-graph-analysis/" data-link-title="9.4 跨檔案圖分析：從 lint 走到 static analysis" data-link-desc="Single-file 規則用 AST 搞定；跨檔 orphan 偵測、broken link、backlink 完整性需要把整個 repo 建成圖再走訪。用 mdtools cards 為例">graph 分析</a> — 都落在這層。</li>
</ul>
<p>把 <code>os.Exit</code> / <code>os.Args</code> / <code>os.Stderr</code> 都擋在 layer 1-2，layer 3 就能用一般 table-driven test 測，不用起 subprocess。</p>
<h2 id="layer-1maingo-dispatcher">Layer 1：main.go dispatcher</h2>





<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="c1">// scripts/mdtools/main.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">package</span> <span class="nx">main</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="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	<span class="s">&#34;fmt&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="s">&#34;os&#34;</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="s">&#34;blog/scripts/mdtools/cmd&#34;</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></span><span class="line"><span class="ln">11</span><span class="cl"><span class="kd">func</span> <span class="nf">main</span><span class="p">()</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="nb">len</span><span class="p">(</span><span class="nx">os</span><span class="p">.</span><span class="nx">Args</span><span class="p">)</span> <span class="p">&lt;</span> <span class="mi">2</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">		<span class="nf">usage</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">		<span class="nx">os</span><span class="p">.</span><span class="nf">Exit</span><span class="p">(</span><span class="mi">2</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></span><span class="line"><span class="ln">17</span><span class="cl">	<span class="nx">sub</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nx">Args</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">	<span class="nx">args</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nx">Args</span><span class="p">[</span><span class="mi">2</span><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">var</span> <span class="nx">exitCode</span> <span class="kt">int</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">	<span class="k">switch</span> <span class="nx">sub</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">	<span class="k">case</span> <span class="s">&#34;fmt&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">		<span class="nx">exitCode</span> <span class="p">=</span> <span class="nx">cmd</span><span class="p">.</span><span class="nf">Fmt</span><span class="p">(</span><span class="nx">args</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">	<span class="k">case</span> <span class="s">&#34;lint&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">		<span class="nx">exitCode</span> <span class="p">=</span> <span class="nx">cmd</span><span class="p">.</span><span class="nf">Lint</span><span class="p">(</span><span class="nx">args</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">	<span class="k">case</span> <span class="s">&#34;cards&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">		<span class="nx">exitCode</span> <span class="p">=</span> <span class="nx">cmd</span><span class="p">.</span><span class="nf">Cards</span><span class="p">(</span><span class="nx">args</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">	<span class="k">case</span> <span class="s">&#34;migrate&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">		<span class="nx">exitCode</span> <span class="p">=</span> <span class="nx">cmd</span><span class="p">.</span><span class="nf">Migrate</span><span class="p">(</span><span class="nx">args</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">	<span class="k">case</span> <span class="s">&#34;-h&#34;</span><span class="p">,</span> <span class="s">&#34;--help&#34;</span><span class="p">,</span> <span class="s">&#34;help&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">		<span class="nf">usage</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">	<span class="k">case</span> <span class="s">&#34;version&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">		<span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;mdtools 0.1.0-dev&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">	<span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">		<span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprintf</span><span class="p">(</span><span class="nx">os</span><span class="p">.</span><span class="nx">Stderr</span><span class="p">,</span> <span class="s">&#34;unknown subcommand: %q\n\n&#34;</span><span class="p">,</span> <span class="nx">sub</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">		<span class="nf">usage</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">		<span class="nx">exitCode</span> <span class="p">=</span> <span class="mi">2</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">
</span></span><span class="line"><span class="ln">40</span><span class="cl">	<span class="nx">os</span><span class="p">.</span><span class="nf">Exit</span><span class="p">(</span><span class="nx">exitCode</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>注意幾個 pattern：</p>
<ul>
<li><strong>dispatcher 不做 flag 解析</strong>。<code>args := os.Args[2:]</code> 把剩下交給子命令。</li>
<li><strong>每個子命令回傳 <code>int</code>，dispatcher 統一呼叫 <code>os.Exit</code></strong>。這讓子命令本身容易測（不會直接 kill 測試 process）。</li>
<li><strong><code>-h</code> / <code>--help</code> / <code>help</code> 三種寫法都接受</strong>。Unix 慣例。</li>
<li><strong>unknown subcommand 進 exit code 2</strong>，保留 exit 1 給「有違規」的語義。</li>
</ul>
<h2 id="layer-2子命令入口">Layer 2：子命令入口</h2>
<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="c1">// scripts/mdtools/cmd/fmt.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">package</span> <span class="nx">cmd</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="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	<span class="s">&#34;flag&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="s">&#34;fmt&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">	<span class="s">&#34;os&#34;</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="s">&#34;blog/scripts/mdtools/internal/files&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">	<span class="s">&#34;blog/scripts/mdtools/internal/mdfmt&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">	<span class="s">&#34;blog/scripts/mdtools/internal/rules&#34;</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="nf">Fmt</span><span class="p">(</span><span class="nx">args</span> <span class="p">[]</span><span class="kt">string</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">	<span class="nx">fs</span> <span class="o">:=</span> <span class="nx">flag</span><span class="p">.</span><span class="nf">NewFlagSet</span><span class="p">(</span><span class="s">&#34;fmt&#34;</span><span class="p">,</span> <span class="nx">flag</span><span class="p">.</span><span class="nx">ExitOnError</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">	<span class="nx">fix</span> <span class="o">:=</span> <span class="nx">fs</span><span class="p">.</span><span class="nf">Bool</span><span class="p">(</span><span class="s">&#34;fix&#34;</span><span class="p">,</span> <span class="kc">false</span><span class="p">,</span> <span class="s">&#34;apply fixes in place&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">	<span class="nx">check</span> <span class="o">:=</span> <span class="nx">fs</span><span class="p">.</span><span class="nf">Bool</span><span class="p">(</span><span class="s">&#34;check&#34;</span><span class="p">,</span> <span class="kc">false</span><span class="p">,</span> <span class="s">&#34;report-only; non-zero on pending changes&#34;</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">fs</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="nx">args</span><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="k">if</span> <span class="o">*</span><span class="nx">check</span> <span class="o">&amp;&amp;</span> <span class="o">*</span><span class="nx">fix</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">		<span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprintln</span><span class="p">(</span><span class="nx">os</span><span class="p">.</span><span class="nx">Stderr</span><span class="p">,</span> <span class="s">&#34;mdtools fmt: --fix and --check are mutually exclusive&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">		<span class="k">return</span> <span class="mi">2</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="k">if</span> <span class="p">!</span><span class="o">*</span><span class="nx">check</span> <span class="o">&amp;&amp;</span> <span class="p">!</span><span class="o">*</span><span class="nx">fix</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">		<span class="o">*</span><span class="nx">check</span> <span class="p">=</span> <span class="kc">true</span> <span class="c1">// safe default</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">
</span></span><span class="line"><span class="ln">28</span><span class="cl">	<span class="nx">paths</span> <span class="o">:=</span> <span class="nx">fs</span><span class="p">.</span><span class="nf">Args</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">	<span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">paths</span><span class="p">)</span> <span class="o">==</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">		<span class="nx">paths</span> <span class="p">=</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;content&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">
</span></span><span class="line"><span class="ln">33</span><span class="cl">	<span class="nx">cfg</span> <span class="o">:=</span> <span class="nx">rules</span><span class="p">.</span><span class="nf">Default</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">	<span class="nx">mdFiles</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">files</span><span class="p">.</span><span class="nf">WalkMarkdown</span><span class="p">(</span><span class="nx">paths</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">35</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">36</span><span class="cl">		<span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprintf</span><span class="p">(</span><span class="nx">os</span><span class="p">.</span><span class="nx">Stderr</span><span class="p">,</span> <span class="s">&#34;mdtools fmt: walk error: %v\n&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">		<span class="k">return</span> <span class="mi">2</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">
</span></span><span class="line"><span class="ln">40</span><span class="cl">	<span class="nx">changed</span> <span class="o">:=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">path</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">mdFiles</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">		<span class="nx">result</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">mdfmt</span><span class="p">.</span><span class="nf">FormatFile</span><span class="p">(</span><span class="nx">path</span><span class="p">,</span> <span class="nx">cfg</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">43</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">44</span><span class="cl">			<span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprintf</span><span class="p">(</span><span class="nx">os</span><span class="p">.</span><span class="nx">Stderr</span><span class="p">,</span> <span class="s">&#34;mdtools fmt: %s: %v\n&#34;</span><span class="p">,</span> <span class="nx">path</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">45</span><span class="cl">			<span class="k">return</span> <span class="mi">2</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">47</span><span class="cl">		<span class="k">if</span> <span class="p">!</span><span class="nx">result</span><span class="p">.</span><span class="nf">Changed</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">			<span class="k">continue</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">50</span><span class="cl">		<span class="nx">changed</span><span class="o">++</span>
</span></span><span class="line"><span class="ln">51</span><span class="cl">		<span class="k">if</span> <span class="o">*</span><span class="nx">fix</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">52</span><span class="cl">			<span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">WriteFile</span><span class="p">(</span><span class="nx">path</span><span class="p">,</span> <span class="nx">result</span><span class="p">.</span><span class="nx">Fixed</span><span class="p">,</span> <span class="mi">0</span><span class="nx">o644</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">53</span><span class="cl">				<span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprintf</span><span class="p">(</span><span class="nx">os</span><span class="p">.</span><span class="nx">Stderr</span><span class="p">,</span> <span class="s">&#34;write %s: %v\n&#34;</span><span class="p">,</span> <span class="nx">path</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">54</span><span class="cl">				<span class="k">return</span> <span class="mi">2</span>
</span></span><span class="line"><span class="ln">55</span><span class="cl">			<span class="p">}</span>
</span></span><span class="line"><span class="ln">56</span><span class="cl">			<span class="nx">fmt</span><span class="p">.</span><span class="nf">Printf</span><span class="p">(</span><span class="s">&#34;fixed: %s\n&#34;</span><span class="p">,</span> <span class="nx">path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">57</span><span class="cl">		<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">58</span><span class="cl">			<span class="nx">fmt</span><span class="p">.</span><span class="nf">Printf</span><span class="p">(</span><span class="s">&#34;would fix: %s\n&#34;</span><span class="p">,</span> <span class="nx">path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">59</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">60</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">61</span><span class="cl">
</span></span><span class="line"><span class="ln">62</span><span class="cl">	<span class="k">if</span> <span class="o">*</span><span class="nx">check</span> <span class="o">&amp;&amp;</span> <span class="nx">changed</span> <span class="p">&gt;</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">63</span><span class="cl">		<span class="k">return</span> <span class="mi">1</span> <span class="c1">// CI-friendly: exit 1 means &#34;things need fixing&#34;</span>
</span></span><span class="line"><span class="ln">64</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">65</span><span class="cl">	<span class="k">return</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">66</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>要注意幾個設計決策：</p>
<ul>
<li><strong>flag 定義就在入口函式裡</strong>，不抽成 package 常數。每個子命令的 flag 獨立演化。</li>
<li><strong><code>ExitOnError</code></strong> 讓 <code>fs.Parse</code> 遇到不合法 flag 直接 exit — 對 CLI 工具 OK，因為 parse 失敗本來就無法繼續。測試時要用 <code>ContinueOnError</code> 避免殺測試。</li>
<li><strong>positional args 從 <code>fs.Args()</code> 取，不是 <code>os.Args</code></strong>。<code>fs.Parse</code> 會把非 flag 的留在 fs.Args()。</li>
<li><strong>預設值走安全側</strong>（<code>*check = true</code> when neither given）— 防止使用者意外執行破壞性動作。</li>
<li><strong>exit code 分層語意</strong>：0 = 成功、1 = 有違規、2 = 工具本身失敗。CI script 能用 <code>[[ $? -eq 1 ]]</code> 區分。</li>
</ul>
<h2 id="layer-3internal-實作">Layer 3：internal 實作</h2>
<p>Layer 3 是純邏輯，不知道任何 <code>os</code> / <code>flag</code> 的存在。這讓它能被 layer 2 呼叫、被 test 呼叫、也能在未來被其他 binary 或 library 重用：</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="c1">// scripts/mdtools/internal/mdfmt/fixer.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">package</span> <span class="nx">mdfmt</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="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	<span class="s">&#34;bytes&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="s">&#34;os&#34;</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="s">&#34;blog/scripts/mdtools/internal/rules&#34;</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></span><span class="line"><span class="ln">11</span><span class="cl"><span class="kd">type</span> <span class="nx">FixResult</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="nx">Path</span>     <span class="kt">string</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">	<span class="nx">Original</span> <span class="p">[]</span><span class="kt">byte</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">	<span class="nx">Fixed</span>    <span class="p">[]</span><span class="kt">byte</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="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="nx">FixResult</span><span class="p">)</span> <span class="nf">Changed</span><span class="p">()</span> <span class="kt">bool</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="p">!</span><span class="nx">bytes</span><span class="p">.</span><span class="nf">Equal</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Original</span><span class="p">,</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Fixed</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="kd">func</span> <span class="nf">FormatFile</span><span class="p">(</span><span class="nx">path</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">cfg</span> <span class="nx">rules</span><span class="p">.</span><span class="nx">Config</span><span class="p">)</span> <span class="p">(</span><span class="nx">FixResult</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">	<span class="nx">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">ReadFile</span><span class="p">(</span><span class="nx">path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">23</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">24</span><span class="cl">		<span class="k">return</span> <span class="nx">FixResult</span><span class="p">{},</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">	<span class="nx">fixed</span> <span class="o">:=</span> <span class="nf">applyAll</span><span class="p">(</span><span class="nx">data</span><span class="p">,</span> <span class="nx">cfg</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">	<span class="k">return</span> <span class="nx">FixResult</span><span class="p">{</span><span class="nx">Path</span><span class="p">:</span> <span class="nx">path</span><span class="p">,</span> <span class="nx">Original</span><span class="p">:</span> <span class="nx">data</span><span class="p">,</span> <span class="nx">Fixed</span><span class="p">:</span> <span class="nx">fixed</span><span class="p">},</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>FormatFile</code> 回傳 <code>(FixResult, error)</code>，不 <code>os.Exit</code>、不印訊息、不碰全域狀態。Test 可以直接給一個記憶體 <code>[]byte</code> 跑 <code>applyAll</code> 驗結果。</p>
<h2 id="什麼時候該上-cobra">什麼時候該上 cobra</h2>
<p>升級到 cobra 的判準是<strong>stdlib 能處理的負面複雜度已經超過 cobra 的學習成本</strong>。下表列五個實際觸發過團隊升級的訊號，每個都附展開說明。</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>為什麼 stdlib 處理不好</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>命令層級超過 3 層（<code>tool sub1 sub2 sub3 --flag</code>）</td>
          <td>dispatcher 變成多層 nested switch，flag 繼承需要手動維護</td>
      </tr>
      <tr>
          <td>需要自動 shell completion（bash / zsh / fish）</td>
          <td>手寫 completion 腳本成本高；cobra / urfave-cli 有 generator</td>
      </tr>
      <tr>
          <td>需要 markdown / man-page 形式的 help 輸出</td>
          <td>stdlib 只有基本 <code>flag.Usage</code>；cobra 有 <code>doc</code> package 能渲染</td>
      </tr>
      <tr>
          <td>有多個 end-user 要閱讀 help（非開發者）</td>
          <td>stdlib 的 <code>flag.Usage</code> 格式樸素，降低使用者可讀性</td>
      </tr>
      <tr>
          <td>大量共用 flag（&ndash;verbose / &ndash;log-level 每個命令都要）</td>
          <td>cobra 的 PersistentFlags 比手工在每個子命令重複宣告乾淨</td>
      </tr>
  </tbody>
</table>
<p><strong>命令層級超過 3 層</strong>：<code>kubectl get pods</code> 只有兩層還撐得住；到 <code>gh api repos owner/repo/pulls list --limit 10</code> 就是四層（含 <code>api</code> 這個 namespace），dispatcher 裡巢狀 switch 開始難讀。信號：dispatcher 的 switch case 超過十個，或 case 裡面又呼叫另一個 switch。反例：即使只有兩層，若每層未來會繼續加，早上 cobra 可省後來重構。</p>
<p><strong>需要自動 shell completion</strong>：end-user 會反覆打命令、需要 tab 補齊子命令與 flag 名稱時，這功能差很多。手寫 completion 腳本要處理三種 shell 的語法差異，成本高；cobra 一行 <code>cobra.GenBashCompletion</code> 就產生。信號：工具有外部使用者、或團隊已經裝 shell completion。反例：只在 CI 跑、人不會互動輸入。</p>
<p><strong>man-page 形式的 help 輸出</strong>：Unix 社群期待工具有 <code>man tool</code> 級的文件。stdlib 只輸出簡單的 usage 字串，排版樸素；cobra 的 <code>doc</code> package 能生成 markdown / reStructuredText / man。信號：工具要 package 進系統（Homebrew、apt），或對外發佈。反例：公司內部用、README 夠用。</p>
<p><strong>多 end-user 讀 help</strong>：工程師忍受樸素的 <code>-h</code> 輸出，但產品經理、SRE on-call 看不下。cobra 有明確的 long description、example 欄位，排版比 stdlib 好。信號：使用者包含非程式設計角色。反例：user 是同團隊工程師。</p>
<p><strong>大量共用 flag</strong>：<code>--verbose</code>、<code>--log-level</code>、<code>--config</code> 這類 flag 每個子命令都要用。stdlib 要在每個子命令重複 <code>fs.Bool(&quot;verbose&quot;, ...)</code>；cobra 的 PersistentFlags 能繼承到所有 subcommand。信號：重複 flag 超過三個、或要 enforce 某個 flag 在所有 subcommand 都有。反例：flag 在每個子命令語意不同，共用反而製造混淆。</p>
<p>以上五個訊號在 mdtools 都沒命中（內部工具、單層 subcommand、工程師使用者），所以繼續走 stdlib。若未來 mdtools 對外釋出給讀者下載，就值得重新評估。<strong>判讀時機是設計當下，不是感覺「stdlib 開始髒」時</strong> — 髒時通常已經晚。</p>
<h2 id="常見陷阱">常見陷阱</h2>
<h3 id="在-layer-3-直接呼叫-osexit">在 layer 3 直接呼叫 <code>os.Exit</code></h3>
<p>會破壞 test：test runner 呼叫 <code>TestXxx</code> 時，如果 subject code 裡 <code>os.Exit(1)</code>，整個 test process 退出，其他 test 不跑。Layer 3 應回傳 error，讓 layer 2 決定怎麼退出。</p>
<h3 id="用全域-var-fs--flagnewflagset-宣告-flag">用全域 <code>var fs = flag.NewFlagSet(...)</code> 宣告 flag</h3>
<p>每次呼叫會累積狀態（flag 已經被定義過會 panic），並且兩個 test 同時跑會 race。定義 flag 要在函式裡。</p>
<h3 id="忘記-continueonerror-就跑-test">忘記 <code>ContinueOnError</code> 就跑 test</h3>
<p><code>ExitOnError</code> 是 production 預設，但測試時會讓測試 process 整個退出。Table-driven test 要用：</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="nx">fs</span> <span class="o">:=</span> <span class="nx">flag</span><span class="p">.</span><span class="nf">NewFlagSet</span><span class="p">(</span><span class="nx">name</span><span class="p">,</span> <span class="nx">flag</span><span class="p">.</span><span class="nx">ContinueOnError</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">fs</span><span class="p">.</span><span class="nf">SetOutput</span><span class="p">(</span><span class="nx">io</span><span class="p">.</span><span class="nx">Discard</span><span class="p">)</span> <span class="c1">// 測試時不要印 usage 到 stderr</span></span></span></code></pre></div><h3 id="太早抽出所有子命令共用的-flag">太早抽出「所有子命令共用的 flag」</h3>
<p>PersistentFlags 概念在 stdlib 沒有，手動在每個子命令重複 <code>fs.Bool(&quot;verbose&quot;, false, ...)</code> 看似重複但其實可讀。一旦抽成共用 helper，就開始維護一個小框架 — 這時候用 cobra 反而更乾淨。</p>
<h2 id="擴充路徑">擴充路徑</h2>
<ul>
<li><strong>命令太多時分組</strong>：<code>tool fmt check</code>、<code>tool fmt fix</code> 的兩層 subcommand 可以用「每層一個 switch」展開，main → cmd.Fmt → cmd.FmtCheck。mdtools 的 <code>migrate fix-links</code> 就是這個模式（見 <code>cmd/migrate.go</code>）。</li>
<li><strong>共用 config loading</strong>：<code>rules.Default()</code> 這類邏輯放在 internal 裡，每個子命令呼叫；不要每個子命令自己 parse 配置檔。</li>
<li><strong>測試 layer 2</strong>：用 <code>buffer</code> 捕獲 stdout/stderr，傳入自定 args。參考 Go stdlib 的 <code>testing/iotest</code> 跟 <code>bytes.Buffer</code>。</li>
</ul>
<h2 id="下一步">下一步</h2>
<p><a href="/blog/go/09-tooling-and-analysis/goldmark-ast-basics/" data-link-title="9.2 第三方 parser 整合：goldmark AST 入門" data-link-desc="用 goldmark 把 markdown 解析成 AST，掌握 ast.Walk visitor 模式、block 與 inline 節點的判讀、byte offset 如何定位到行號">9.2 goldmark AST 入門</a> 會看 mdtools 怎麼把 markdown 解析成可操作的結構，layer 3 內部怎麼組織 parser 整合。</p>
]]></content:encoded></item><item><title>8.1 Google：大規模微服務與索引服務</title><link>https://tarrragon.github.io/blog/go/08-case-studies/google/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/08-case-studies/google/</guid><description>&lt;p>Google 的官方案例最適合用來理解 Go 的原始定位：這門語言的目標是解決大型工程團隊在多核心、網路、模組化與依賴管理上的問題。Google Core Data Solutions 團隊把原本的單體 C++ 索引堆疊拆成多個微服務，並把多數索引服務改寫成 Go。&lt;/p>
&lt;h2 id="你應該看什麼">你應該看什麼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://go.dev/solutions/google/">Using Go at Google&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://go.dev/solutions/google/coredata">How Google’s Core Data Solutions Team Uses Go&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://go.dev/talks/2012/splash.article">Go at Google: Language Design in the Service of Software Engineering&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼&lt;/h2>
&lt;ol>
&lt;li>Go 很適合大型服務拆分之後的邊界管理。&lt;/li>
&lt;li>built-in concurrency 對高併發索引與資料處理很重要。&lt;/li>
&lt;li>Go 的簡單語法與明確依賴，能讓大團隊維持可讀性。&lt;/li>
&lt;/ol>
&lt;h2 id="可對照的公開原始碼">可對照的公開原始碼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://github.com/kubernetes/kubernetes">kubernetes/kubernetes&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>Kubernetes 不是 Google 內部產品，但它很好地呈現了 Google 文化裡常見的 Go 工程模式：大型 codebase、明確 package 邊界、cmd 入口與大量服務協調。&lt;/p></description><content:encoded><![CDATA[<p>Google 的官方案例最適合用來理解 Go 的原始定位：這門語言的目標是解決大型工程團隊在多核心、網路、模組化與依賴管理上的問題。Google Core Data Solutions 團隊把原本的單體 C++ 索引堆疊拆成多個微服務，並把多數索引服務改寫成 Go。</p>
<h2 id="你應該看什麼">你應該看什麼</h2>
<ul>
<li><a href="https://go.dev/solutions/google/">Using Go at Google</a></li>
<li><a href="https://go.dev/solutions/google/coredata">How Google’s Core Data Solutions Team Uses Go</a></li>
<li><a href="https://go.dev/talks/2012/splash.article">Go at Google: Language Design in the Service of Software Engineering</a></li>
</ul>
<h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼</h2>
<ol>
<li>Go 很適合大型服務拆分之後的邊界管理。</li>
<li>built-in concurrency 對高併發索引與資料處理很重要。</li>
<li>Go 的簡單語法與明確依賴，能讓大團隊維持可讀性。</li>
</ol>
<h2 id="可對照的公開原始碼">可對照的公開原始碼</h2>
<ul>
<li><a href="https://github.com/kubernetes/kubernetes">kubernetes/kubernetes</a></li>
</ul>
<p>Kubernetes 不是 Google 內部產品，但它很好地呈現了 Google 文化裡常見的 Go 工程模式：大型 codebase、明確 package 邊界、cmd 入口與大量服務協調。</p>
]]></content:encoded></item><item><title>0.1 Go 的簡單哲學與認知負擔</title><link>https://tarrragon.github.io/blog/go/00-philosophy/simplicity/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/00-philosophy/simplicity/</guid><description>&lt;p>Go 的核心取捨是降低讀程式的人需要同時記住的事情。它不追求語法表現力最大化，而是追求團隊在多年後仍能快速判讀服務如何啟動、資料如何流動、錯誤如何處理。這些特性之所以重要，是因為它們直接支撐了 Go 在高併發服務、worker、長連線與事件處理場景中的可維護性。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>說明 Go 為什麼偏好顯式控制流程&lt;/li>
&lt;li>看懂入口程式中依賴組裝的意圖&lt;/li>
&lt;li>區分「程式碼短」和「認知負擔低」&lt;/li>
&lt;li>用 Go 的風格閱讀現有服務&lt;/li>
&lt;/ol>
&lt;h2 id="為什麼這章在第零章">為什麼這章在第零章&lt;/h2>
&lt;p>如果工作負載、架構與 runtime 條件已經顯示 Go 是合適選項，接下來就要理解 Go 為什麼會長成現在這種樣子。簡單、顯式、少魔法是讓高併發服務在多人維護時仍能被快速理解的工程策略。&lt;/p>
&lt;hr>
&lt;h2 id="觀察go-程式的入口通常很直">【觀察】Go 程式的入口通常很直&lt;/h2>
&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="nx">events&lt;/span> &lt;span class="o">:=&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">Event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">128&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">notifications&lt;/span> &lt;span class="o">:=&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">Notification&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">128&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>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="nx">repo&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewNotificationRepository&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="nx">worker&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewWorker&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">repo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">events&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">notifications&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">server&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewHTTPServer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">repo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">notifications&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式沒有依賴注入框架，也沒有隱式容器。所有元件的建立順序、依賴關係、資料流向都直接寫在入口。&lt;/p>
&lt;p>這種寫法特別適合服務型應用：當系統需要處理很多並發請求、背景工作或外部事件時，入口越清楚，就越容易確認誰負責什麼、失敗時會停在哪裡。&lt;/p>
&lt;h2 id="判讀簡單是少猜幾件事">【判讀】簡單是少猜幾件事&lt;/h2>
&lt;p>對維護者來說，入口程式的主要工作是回答三個問題：&lt;/p>
&lt;ol>
&lt;li>哪些元件存在？&lt;/li>
&lt;li>元件彼此怎麼連接？&lt;/li>
&lt;li>程式關閉時誰負責停止？&lt;/li>
&lt;/ol>
&lt;p>Go 偏好把這些答案留在表面。當你看到 &lt;code>NewWorker(repo, events, notifications)&lt;/code>，你不需要先理解框架規則，就能知道 worker 依賴 repository，從事件 channel 讀資料，輸出通知到另一個 channel。抽象的責任是讓資料流更容易判讀；抽象若讓讀者需要猜測背後規則，就削弱了 Go 在長期維護上的主要價值。&lt;/p>
&lt;h2 id="策略閱讀-go-程式先找資料流">【策略】閱讀 Go 程式先找資料流&lt;/h2>
&lt;p>讀 Go 應用時，可以用以下順序降低認知負擔：&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>1&lt;/td>
 &lt;td>process 從哪裡開始？&lt;/td>
 &lt;td>入口程式&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>HTTP endpoint 在哪裡註冊？&lt;/td>
 &lt;td>route 註冊處&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>背景工作有哪些？&lt;/td>
 &lt;td>&lt;code>go ...&lt;/code> 呼叫&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>4&lt;/td>
 &lt;td>資料用什麼型別傳遞？&lt;/td>
 &lt;td>model 定義&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>5&lt;/td>
 &lt;td>共享狀態由誰保護？&lt;/td>
 &lt;td>repository 或狀態元件&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這個順序先建立系統地圖，再深入單一函式，避免一開始就陷入細節。&lt;/p>
&lt;h2 id="執行用入口程式畫出資料流">【執行】用入口程式畫出資料流&lt;/h2>
&lt;p>即時通知服務可以先整理成這張資料流：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">file / HTTP / timer ──&amp;gt; events ──&amp;gt; Worker ──&amp;gt; notifications
&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>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> Repository
&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>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> HTTP API&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這張圖比逐行閱讀更重要。它先回答「系統如何運作」，再讓你回到個別函式確認細節。&lt;/p></description><content:encoded><![CDATA[<p>Go 的核心取捨是降低讀程式的人需要同時記住的事情。它不追求語法表現力最大化，而是追求團隊在多年後仍能快速判讀服務如何啟動、資料如何流動、錯誤如何處理。這些特性之所以重要，是因為它們直接支撐了 Go 在高併發服務、worker、長連線與事件處理場景中的可維護性。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>說明 Go 為什麼偏好顯式控制流程</li>
<li>看懂入口程式中依賴組裝的意圖</li>
<li>區分「程式碼短」和「認知負擔低」</li>
<li>用 Go 的風格閱讀現有服務</li>
</ol>
<h2 id="為什麼這章在第零章">為什麼這章在第零章</h2>
<p>如果工作負載、架構與 runtime 條件已經顯示 Go 是合適選項，接下來就要理解 Go 為什麼會長成現在這種樣子。簡單、顯式、少魔法是讓高併發服務在多人維護時仍能被快速理解的工程策略。</p>
<hr>
<h2 id="觀察go-程式的入口通常很直">【觀察】Go 程式的入口通常很直</h2>
<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="nx">events</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">,</span> <span class="mi">128</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">notifications</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Notification</span><span class="p">,</span> <span class="mi">128</span><span class="p">)</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">repo</span> <span class="o">:=</span> <span class="nf">NewNotificationRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">worker</span> <span class="o">:=</span> <span class="nf">NewWorker</span><span class="p">(</span><span class="nx">repo</span><span class="p">,</span> <span class="nx">events</span><span class="p">,</span> <span class="nx">notifications</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nx">server</span> <span class="o">:=</span> <span class="nf">NewHTTPServer</span><span class="p">(</span><span class="nx">repo</span><span class="p">,</span> <span class="nx">notifications</span><span class="p">)</span></span></span></code></pre></div><p>這段程式沒有依賴注入框架，也沒有隱式容器。所有元件的建立順序、依賴關係、資料流向都直接寫在入口。</p>
<p>這種寫法特別適合服務型應用：當系統需要處理很多並發請求、背景工作或外部事件時，入口越清楚，就越容易確認誰負責什麼、失敗時會停在哪裡。</p>
<h2 id="判讀簡單是少猜幾件事">【判讀】簡單是少猜幾件事</h2>
<p>對維護者來說，入口程式的主要工作是回答三個問題：</p>
<ol>
<li>哪些元件存在？</li>
<li>元件彼此怎麼連接？</li>
<li>程式關閉時誰負責停止？</li>
</ol>
<p>Go 偏好把這些答案留在表面。當你看到 <code>NewWorker(repo, events, notifications)</code>，你不需要先理解框架規則，就能知道 worker 依賴 repository，從事件 channel 讀資料，輸出通知到另一個 channel。抽象的責任是讓資料流更容易判讀；抽象若讓讀者需要猜測背後規則，就削弱了 Go 在長期維護上的主要價值。</p>
<h2 id="策略閱讀-go-程式先找資料流">【策略】閱讀 Go 程式先找資料流</h2>
<p>讀 Go 應用時，可以用以下順序降低認知負擔：</p>
<table>
  <thead>
      <tr>
          <th>順序</th>
          <th>問題</th>
          <th>對應檔案</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>process 從哪裡開始？</td>
          <td>入口程式</td>
      </tr>
      <tr>
          <td>2</td>
          <td>HTTP endpoint 在哪裡註冊？</td>
          <td>route 註冊處</td>
      </tr>
      <tr>
          <td>3</td>
          <td>背景工作有哪些？</td>
          <td><code>go ...</code> 呼叫</td>
      </tr>
      <tr>
          <td>4</td>
          <td>資料用什麼型別傳遞？</td>
          <td>model 定義</td>
      </tr>
      <tr>
          <td>5</td>
          <td>共享狀態由誰保護？</td>
          <td>repository 或狀態元件</td>
      </tr>
  </tbody>
</table>
<p>這個順序先建立系統地圖，再深入單一函式，避免一開始就陷入細節。</p>
<h2 id="執行用入口程式畫出資料流">【執行】用入口程式畫出資料流</h2>
<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">file / HTTP / timer ──&gt; events ──&gt; Worker ──&gt; notifications
</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">                                  Repository
</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">                                   HTTP API</span></span></code></pre></div><p>這張圖比逐行閱讀更重要。它先回答「系統如何運作」，再讓你回到個別函式確認細節。</p>
]]></content:encoded></item><item><title>1.1 channel ownership 與關閉責任</title><link>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/channel-ownership/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/channel-ownership/</guid><description>&lt;p>Channel ownership 的核心規則是：能保證不再送出資料的一方，才有資格關閉 channel。建立 channel 的程式碼不一定是 owner；真正的 owner 是掌握 send lifecycle 的元件。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>用 send lifecycle 判斷誰能 close channel&lt;/li>
&lt;li>分辨 sender、receiver、coordinator 的責任&lt;/li>
&lt;li>用 channel direction 表達能力限制&lt;/li>
&lt;li>設計多 sender 的安全關閉流程&lt;/li>
&lt;li>用 context 表達接收端提早停止，而不是關閉不屬於自己的 channel&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察close-channel-的核心風險是責任不清">【觀察】close channel 的核心風險是責任不清&lt;/h2>
&lt;p>Channel 關閉錯誤的核心問題是 ownership 沒定義清楚。接收端想停止讀取時關閉輸入 channel，多個 sender 中任一個 sender 自行 close，共用 channel 被外部任意 close，這些都可能造成 panic 或資料遺失。&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">Consume&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&lt;/span> &lt;span class="kd">chan&lt;/span> &lt;span class="nx">Event&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="nb">close&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&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>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">event&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">input&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="nf">handle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&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>這段程式的問題是 &lt;code>Consume&lt;/code> 只是 receiver，卻關閉了 sender 還可能使用的 channel。只要上游晚一點送資料，就會出現 &lt;code>send on closed channel&lt;/code>。&lt;/p>
&lt;h2 id="判讀close-的語意是不再有新值">【判讀】close 的語意是不再有新值&lt;/h2>
&lt;p>&lt;code>close(ch)&lt;/code> 的核心語意是「這個 channel 不會再收到新值」。它不是取消 goroutine 的通用手段，也不是釋放記憶體的必要動作。&lt;/p>
&lt;p>單一 sender 可以安全 close：&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">Produce&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">items&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="kt">string&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">out&lt;/span> &lt;span class="o">:=&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="kt">string&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&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"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nb">close&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">out&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">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">item&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">items&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="nx">out&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">item&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 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>&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="nx">out&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>這個 goroutine 是唯一 sender，因此它能保證迴圈結束後不再送出。receiver 可以用 &lt;code>range&lt;/code> 讀到 channel 關閉為止：&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="k">for&lt;/span> &lt;span class="nx">item&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nf">Produce&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s">&amp;#34;a&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;b&amp;#34;&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">item&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>receiver 不需要 close &lt;code>out&lt;/code>。接收完資料是 receiver 的狀態，不代表 sender 的生命週期已經結束。&lt;/p>
&lt;h2 id="策略先畫出-sender-和-receiver">【策略】先畫出 sender 和 receiver&lt;/h2>
&lt;p>Channel 設計的核心動作是先列出誰會 send、誰會 receive、誰知道所有 sender 已經結束。這比先決定 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 大小更重要。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>角色&lt;/th>
 &lt;th>能力&lt;/th>
 &lt;th>close 責任&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>single sender&lt;/td>
 &lt;td>送出資料，知道自己何時結束&lt;/td>
 &lt;td>擁有 close 責任&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>receiver&lt;/td>
 &lt;td>接收資料，可能提早停止&lt;/td>
 &lt;td>透過 context 通知停止&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>coordinator&lt;/td>
 &lt;td>等待所有 sender 結束&lt;/td>
 &lt;td>擁有 close 責任&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>external caller&lt;/td>
 &lt;td>持有 channel reference 但不了解生命週期&lt;/td>
 &lt;td>不參與 close 決策&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>如果無法回答「誰知道所有 sender 都結束」，就不應該 close 這個 channel。沒有 close 不一定是 bug；錯誤 close 才是更嚴重的問題。&lt;/p>
&lt;h2 id="執行多-sender-需要-coordinator-關閉">【執行】多 sender 需要 coordinator 關閉&lt;/h2>
&lt;p>多個 goroutine 送往同一個 channel 時，關閉責任必須交給 coordinator。任一 sender 都不能單方面 close，因為其他 sender 可能還在送。&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">Merge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">inputs&lt;/span> &lt;span class="o">...&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">Event&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">out&lt;/span> &lt;span class="o">:=&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">Event&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="kd">var&lt;/span> &lt;span class="nx">wg&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">WaitGroup&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="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">inputs&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">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">inputs&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="nx">input&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">input&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">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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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">for&lt;/span> &lt;span class="nx">event&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">input&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">out&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">event&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;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="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">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Wait&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="nb">close&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">out&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="k">return&lt;/span> &lt;span class="nx">out&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>轉送 goroutine 只負責送資料。另一個 goroutine 等所有 sender 結束後才 close &lt;code>out&lt;/code>。這把「送資料」和「宣告所有資料送完」分成兩個責任。&lt;/p></description><content:encoded><![CDATA[<p>Channel ownership 的核心規則是：能保證不再送出資料的一方，才有資格關閉 channel。建立 channel 的程式碼不一定是 owner；真正的 owner 是掌握 send lifecycle 的元件。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>用 send lifecycle 判斷誰能 close channel</li>
<li>分辨 sender、receiver、coordinator 的責任</li>
<li>用 channel direction 表達能力限制</li>
<li>設計多 sender 的安全關閉流程</li>
<li>用 context 表達接收端提早停止，而不是關閉不屬於自己的 channel</li>
</ol>
<hr>
<h2 id="觀察close-channel-的核心風險是責任不清">【觀察】close channel 的核心風險是責任不清</h2>
<p>Channel 關閉錯誤的核心問題是 ownership 沒定義清楚。接收端想停止讀取時關閉輸入 channel，多個 sender 中任一個 sender 自行 close，共用 channel 被外部任意 close，這些都可能造成 panic 或資料遺失。</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">Consume</span><span class="p">(</span><span class="nx">input</span> <span class="kd">chan</span> <span class="nx">Event</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="nb">close</span><span class="p">(</span><span class="nx">input</span><span class="p">)</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="k">for</span> <span class="nx">event</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">input</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="nf">handle</span><span class="p">(</span><span class="nx">event</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>這段程式的問題是 <code>Consume</code> 只是 receiver，卻關閉了 sender 還可能使用的 channel。只要上游晚一點送資料，就會出現 <code>send on closed channel</code>。</p>
<h2 id="判讀close-的語意是不再有新值">【判讀】close 的語意是不再有新值</h2>
<p><code>close(ch)</code> 的核心語意是「這個 channel 不會再收到新值」。它不是取消 goroutine 的通用手段，也不是釋放記憶體的必要動作。</p>
<p>單一 sender 可以安全 close：</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">Produce</span><span class="p">(</span><span class="nx">items</span> <span class="p">[]</span><span class="kt">string</span><span class="p">)</span> <span class="o">&lt;-</span><span class="kd">chan</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">out</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="kt">string</span><span class="p">)</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="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">defer</span> <span class="nb">close</span><span class="p">(</span><span class="nx">out</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">_</span><span class="p">,</span> <span class="nx">item</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">items</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nx">out</span> <span class="o">&lt;-</span> <span class="nx">item</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="p">}()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">return</span> <span class="nx">out</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 goroutine 是唯一 sender，因此它能保證迴圈結束後不再送出。receiver 可以用 <code>range</code> 讀到 channel 關閉為止：</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="k">for</span> <span class="nx">item</span> <span class="o">:=</span> <span class="k">range</span> <span class="nf">Produce</span><span class="p">([]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;a&#34;</span><span class="p">,</span> <span class="s">&#34;b&#34;</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">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">item</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>receiver 不需要 close <code>out</code>。接收完資料是 receiver 的狀態，不代表 sender 的生命週期已經結束。</p>
<h2 id="策略先畫出-sender-和-receiver">【策略】先畫出 sender 和 receiver</h2>
<p>Channel 設計的核心動作是先列出誰會 send、誰會 receive、誰知道所有 sender 已經結束。這比先決定 <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 大小更重要。</p>
<table>
  <thead>
      <tr>
          <th>角色</th>
          <th>能力</th>
          <th>close 責任</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>single sender</td>
          <td>送出資料，知道自己何時結束</td>
          <td>擁有 close 責任</td>
      </tr>
      <tr>
          <td>receiver</td>
          <td>接收資料，可能提早停止</td>
          <td>透過 context 通知停止</td>
      </tr>
      <tr>
          <td>coordinator</td>
          <td>等待所有 sender 結束</td>
          <td>擁有 close 責任</td>
      </tr>
      <tr>
          <td>external caller</td>
          <td>持有 channel reference 但不了解生命週期</td>
          <td>不參與 close 決策</td>
      </tr>
  </tbody>
</table>
<p>如果無法回答「誰知道所有 sender 都結束」，就不應該 close 這個 channel。沒有 close 不一定是 bug；錯誤 close 才是更嚴重的問題。</p>
<h2 id="執行多-sender-需要-coordinator-關閉">【執行】多 sender 需要 coordinator 關閉</h2>
<p>多個 goroutine 送往同一個 channel 時，關閉責任必須交給 coordinator。任一 sender 都不能單方面 close，因為其他 sender 可能還在送。</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">Merge</span><span class="p">(</span><span class="nx">inputs</span> <span class="o">...&lt;-</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">)</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Event</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">out</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="kd">var</span> <span class="nx">wg</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">WaitGroup</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">wg</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="nx">inputs</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">_</span><span class="p">,</span> <span class="nx">input</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">inputs</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">input</span> <span class="o">:=</span> <span class="nx">input</span>
</span></span><span class="line"><span class="ln"> 8</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"> 9</span><span class="cl">            <span class="k">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="k">for</span> <span class="nx">event</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">input</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">                <span class="nx">out</span> <span class="o">&lt;-</span> <span class="nx">event</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><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">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">wg</span><span class="p">.</span><span class="nf">Wait</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nb">close</span><span class="p">(</span><span class="nx">out</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="k">return</span> <span class="nx">out</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>轉送 goroutine 只負責送資料。另一個 goroutine 等所有 sender 結束後才 close <code>out</code>。這把「送資料」和「宣告所有資料送完」分成兩個責任。</p>
<h2 id="執行接收端提早停止要用-context">【執行】接收端提早停止要用 context</h2>
<p>Receiver 提早停止的核心做法是通知上游停止，而不是關閉輸入 channel。<code>context.Context</code> 是 Go 服務中最常見的停止訊號。</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">Consume</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">input</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">)</span> <span class="kt">error</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</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">ctx</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">case</span> <span class="nx">event</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">input</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</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"> 8</span><span class="cl">                <span class="k">return</span> <span class="kc">nil</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="nf">handle</span><span class="p">(</span><span class="nx">event</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><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>Consume</code> 可以因為 context 取消而退出，也可以因為 input 關閉而退出。它沒有 close <code>input</code>，因為 input 的 send lifecycle 不屬於它。</p>
<p>這個邊界在服務中很重要。HTTP handler、background worker、connection writer 都可能提早退出，但不能任意 close 上游仍可能使用的 channel。</p>
<h2 id="策略channel-direction-把能力寫進型別">【策略】channel direction 把能力寫進型別</h2>
<p>Channel direction 的核心價值是限制函式能做的事。<code>chan&lt;- T</code> 只能 send，<code>&lt;-chan T</code> 只能 receive；這讓 ownership 更容易被讀者看見。</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">StartWorker</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">jobs</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Job</span><span class="p">,</span> <span class="nx">results</span> <span class="kd">chan</span><span class="o">&lt;-</span> <span class="nx">Result</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">case</span> <span class="nx">job</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">jobs</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</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"> 8</span><span class="cl">                <span class="k">return</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="nx">results</span> <span class="o">&lt;-</span> <span class="nf">Process</span><span class="p">(</span><span class="nx">job</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><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>StartWorker</code> 只能從 <code>jobs</code> 收資料，只能往 <code>results</code> 送資料。它不能 close <code>jobs</code>，因為型別上就不是 sender；它是否能 close <code>results</code> 則要看它是不是唯一 sender。</p>
<p>方向限制不會自動解決所有權，但它能減少誤用，也讓 API 比註解更可靠。</p>
<h2 id="判讀done-channel-和-data-channel-分開表達不同語意">【判讀】done channel 和 data channel 分開表達不同語意</h2>
<p>停止訊號的核心語意應該和資料流分開。資料 channel 傳遞值；done channel 或 context 表示停止。把兩者混在一起會讓 close 的語意變模糊。</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">type</span> <span class="nx">Worker</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">jobs</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Job</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">w</span> <span class="nx">Worker</span><span class="p">)</span> <span class="nf">Run</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="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">case</span> <span class="nx">job</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">w</span><span class="p">.</span><span class="nx">jobs</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</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">12</span><span class="cl">                <span class="k">return</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nf">process</span><span class="p">(</span><span class="nx">job</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>jobs</code> 關閉代表沒有更多 job。<code>ctx.Done()</code> 代表上層要求停止。這兩種退出原因不同，分開處理才能在 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>、metric 或測試中看清楚。</p>
<h2 id="測試測試-close-行為要避免靠-sleep">【測試】測試 close 行為要避免靠 sleep</h2>
<p>Channel ownership 的測試目標是確認 sender 結束後會 close、receiver 取消時不會 panic、多 sender 等全部完成才 close。這類測試應使用 channel 同步，不應依賴任意 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">TestProduceClosesOutput</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">output</span> <span class="o">:=</span> <span class="nf">Produce</span><span class="p">([]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;a&#34;</span><span class="p">})</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="k">if</span> <span class="nx">got</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">output</span><span class="p">;</span> <span class="nx">got</span> <span class="o">!=</span> <span class="s">&#34;a&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</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;first value = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="s">&#34;a&#34;</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="nx">_</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">output</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;output should be closed after producer finishes&#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>多 sender 測試可以讀到輸出 channel 關閉為止，確認所有值都收到：</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">TestMergeClosesAfterAllInputsClose</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">a</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">b</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Event</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">a</span> <span class="o">&lt;-</span> <span class="nx">Event</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;a&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">b</span> <span class="o">&lt;-</span> <span class="nx">Event</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;b&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nb">close</span><span class="p">(</span><span class="nx">a</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">b</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="nx">output</span> <span class="o">:=</span> <span class="nf">Merge</span><span class="p">(</span><span class="nx">a</span><span class="p">,</span> <span class="nx">b</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">got</span> <span class="o">:=</span> <span class="kd">map</span><span class="p">[</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">11</span><span class="cl">    <span class="k">for</span> <span class="nx">event</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">output</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">got</span><span class="p">[</span><span class="nx">event</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">got</span><span class="p">[</span><span class="s">&#34;a&#34;</span><span class="p">]</span> <span class="o">||</span> <span class="p">!</span><span class="nx">got</span><span class="p">[</span><span class="s">&#34;b&#34;</span><span class="p">]</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;merge should forward all events before closing&#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>這個測試沒有固定等待時間。它把 channel close 本身當成同步訊號，結果更穩定。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先聚焦單一 Go process 內的 channel close 與 goroutine lifecycle；跨 process 的 <a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack</a>、[<a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> group](/go-advanced/backend/knowledge-cards/consumer-group) 與分散式訊號，會在下列章節再往外延伸：</p>
<ul>
<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/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證流程</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 channel、goroutine 與 select 的協作；如果你要先回看語言教材，可以讀：</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/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件</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>
</ul>
<h2 id="小結">小結</h2>
<p>Channel ownership 的核心是 send lifecycle。唯一 sender 可以在送完後 close；多 sender 需要 coordinator 統一 close；receiver 想停止時應使用 context，而不是關閉輸入 channel。把 sender、receiver、coordinator 分清楚，才能避免 <code>send on closed channel</code>、goroutine leak 與資料流混亂。</p>
]]></content:encoded></item><item><title>1.1 Go 專案結構與 module</title><link>https://tarrragon.github.io/blog/go/01-basics/modules/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/01-basics/modules/</guid><description>&lt;p>Go 專案的邊界通常從 &lt;code>go.mod&lt;/code> 開始。它定義目前程式碼屬於哪個 module、使用哪個 Go 版本，以及依賴哪些外部套件。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>看懂 &lt;code>go.mod&lt;/code> 的三個核心欄位&lt;/li>
&lt;li>理解 module path 與 import path 的關係&lt;/li>
&lt;li>知道為什麼 Go 指令要在 module 根目錄執行&lt;/li>
&lt;li>分辨標準庫與第三方依賴&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察gomod-定義-module">【觀察】&lt;code>go.mod&lt;/code> 定義 module&lt;/h2>
&lt;p>&lt;code>go.mod&lt;/code> 的核心用途是宣告目前 module 的身份、Go 版本與外部依賴。一個 Go 專案通常會在 module 根目錄放 &lt;code>go.mod&lt;/code>：&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="nx">module&lt;/span> &lt;span class="nx">example&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">com&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">notify&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="nx">service&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="k">go&lt;/span> &lt;span class="mf">1.25.1&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="nf">require&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">github&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">com&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">gorilla&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="nx">websocket&lt;/span> &lt;span class="nx">v1&lt;/span>&lt;span class="mf">.5.3&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>這份檔案表達三件事：module 名稱、Go 語言版本、外部依賴。&lt;/p>
&lt;h2 id="判讀module-是-go-編譯與依賴解析的單位">【判讀】module 是 Go 編譯與依賴解析的單位&lt;/h2>
&lt;p>module 的核心規則是：Go 工具鏈以 &lt;code>go.mod&lt;/code> 所在目錄作為依賴解析與 package 掃描的根。Go 工具鏈需要知道「目前這批程式碼」的根在哪裡；&lt;code>go.mod&lt;/code> 就是這個根。&lt;/p>
&lt;p>當你在 module 根目錄執行：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">go &lt;span class="nb">test&lt;/span> ./...&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>./...&lt;/code> 的意思是測試目前 module 底下所有 package。實務上要先找到 &lt;code>go.mod&lt;/code> 所在目錄，再從那裡執行 Go 指令。&lt;/p>
&lt;h2 id="策略先分辨三種-import">【策略】先分辨三種 import&lt;/h2>
&lt;p>閱讀 import 的核心規則是：先分辨能力來源，再決定去哪裡查。讀 Go 檔案時，先把 import 分成三類：&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>標準庫&lt;/td>
 &lt;td>&lt;code>net/http&lt;/code>, &lt;code>context&lt;/code>, &lt;code>encoding/json&lt;/code>&lt;/td>
 &lt;td>Go 內建能力&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>第三方套件&lt;/td>
 &lt;td>&lt;code>github.com/gorilla/websocket&lt;/code>&lt;/td>
 &lt;td>由 &lt;code>go.mod&lt;/code> 管理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>module 內部套件&lt;/td>
 &lt;td>&lt;code>example.com/notify-service/messages&lt;/code>&lt;/td>
 &lt;td>同一個 module 的其他 package&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這個分類會告訴你：問題應該去查標準庫文件、第三方套件文件，還是目前 module 的其他目錄。&lt;/p>
&lt;h2 id="執行用-module-模型閱讀-maingo">【執行】用 module 模型閱讀 &lt;code>main.go&lt;/code>&lt;/h2>
&lt;p>閱讀入口程式 import 的核心方法是：先把 import 依來源分群，再判斷程式依賴哪些能力。&lt;code>main.go&lt;/code> 的 import 可以整理成這樣：&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="kn">import&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="s">&amp;#34;context&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="s">&amp;#34;fmt&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;log/slog&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="s">&amp;#34;net/http&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="s">&amp;#34;os&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="s">&amp;#34;time&amp;#34;&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="s">&amp;#34;example.com/notify-service/messages&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>前面是標準庫，最後一個是專案內部 package。這表示入口程式主要依賴 Go 標準庫，只有日誌訊息常數被拆到內部 &lt;code>messages&lt;/code> package。&lt;/p></description><content:encoded><![CDATA[<p>Go 專案的邊界通常從 <code>go.mod</code> 開始。它定義目前程式碼屬於哪個 module、使用哪個 Go 版本，以及依賴哪些外部套件。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>看懂 <code>go.mod</code> 的三個核心欄位</li>
<li>理解 module path 與 import path 的關係</li>
<li>知道為什麼 Go 指令要在 module 根目錄執行</li>
<li>分辨標準庫與第三方依賴</li>
</ol>
<hr>
<h2 id="觀察gomod-定義-module">【觀察】<code>go.mod</code> 定義 module</h2>
<p><code>go.mod</code> 的核心用途是宣告目前 module 的身份、Go 版本與外部依賴。一個 Go 專案通常會在 module 根目錄放 <code>go.mod</code>：</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="nx">module</span> <span class="nx">example</span><span class="p">.</span><span class="nx">com</span><span class="o">/</span><span class="nx">notify</span><span class="o">-</span><span class="nx">service</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="k">go</span> <span class="mf">1.25.1</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="nf">require</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">github</span><span class="p">.</span><span class="nx">com</span><span class="o">/</span><span class="nx">gorilla</span><span class="o">/</span><span class="nx">websocket</span> <span class="nx">v1</span><span class="mf">.5.3</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>這份檔案表達三件事：module 名稱、Go 語言版本、外部依賴。</p>
<h2 id="判讀module-是-go-編譯與依賴解析的單位">【判讀】module 是 Go 編譯與依賴解析的單位</h2>
<p>module 的核心規則是：Go 工具鏈以 <code>go.mod</code> 所在目錄作為依賴解析與 package 掃描的根。Go 工具鏈需要知道「目前這批程式碼」的根在哪裡；<code>go.mod</code> 就是這個根。</p>
<p>當你在 module 根目錄執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go <span class="nb">test</span> ./...</span></span></code></pre></div><p><code>./...</code> 的意思是測試目前 module 底下所有 package。實務上要先找到 <code>go.mod</code> 所在目錄，再從那裡執行 Go 指令。</p>
<h2 id="策略先分辨三種-import">【策略】先分辨三種 import</h2>
<p>閱讀 import 的核心規則是：先分辨能力來源，再決定去哪裡查。讀 Go 檔案時，先把 import 分成三類：</p>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>例子</th>
          <th>意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>標準庫</td>
          <td><code>net/http</code>, <code>context</code>, <code>encoding/json</code></td>
          <td>Go 內建能力</td>
      </tr>
      <tr>
          <td>第三方套件</td>
          <td><code>github.com/gorilla/websocket</code></td>
          <td>由 <code>go.mod</code> 管理</td>
      </tr>
      <tr>
          <td>module 內部套件</td>
          <td><code>example.com/notify-service/messages</code></td>
          <td>同一個 module 的其他 package</td>
      </tr>
  </tbody>
</table>
<p>這個分類會告訴你：問題應該去查標準庫文件、第三方套件文件，還是目前 module 的其他目錄。</p>
<h2 id="執行用-module-模型閱讀-maingo">【執行】用 module 模型閱讀 <code>main.go</code></h2>
<p>閱讀入口程式 import 的核心方法是：先把 import 依來源分群，再判斷程式依賴哪些能力。<code>main.go</code> 的 import 可以整理成這樣：</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="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="s">&#34;context&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="s">&#34;fmt&#34;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="s">&#34;log/slog&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="s">&#34;net/http&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="s">&#34;os&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="s">&#34;time&#34;</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="s">&#34;example.com/notify-service/messages&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>前面是標準庫，最後一個是專案內部 package。這表示入口程式主要依賴 Go 標準庫，只有日誌訊息常數被拆到內部 <code>messages</code> package。</p>
]]></content:encoded></item><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.1 struct 與 JSON tag</title><link>https://tarrragon.github.io/blog/go/02-types-data/struct-json/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/02-types-data/struct-json/</guid><description>&lt;p>Go 的 struct 用來描述資料形狀：有哪些欄位、欄位是什麼型別、哪些資料應該放在一起。當資料需要存成 JSON 或透過 API 傳輸時，JSON tag 會把 Go 的欄位命名對應到外部格式。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>定義可序列化成 JSON 的 struct&lt;/li>
&lt;li>理解 &lt;code>omitempty&lt;/code> 的 API 語義&lt;/li>
&lt;li>分辨內部欄位命名與外部 JSON 命名&lt;/li>
&lt;li>看懂設定檔、API request 與事件資料模型&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察struct-用欄位集合定義資料形狀">【觀察】struct 用欄位集合定義資料形狀&lt;/h2>
&lt;p>struct 的核心規則是：每個欄位都用名稱和型別描述一部分資料。以下範例用 struct 定義一份應用設定：&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">Config&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">AppName&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;appName&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="nx">Port&lt;/span> &lt;span class="kt">int&lt;/span> &lt;span class="s">`json:&amp;#34;port&amp;#34;`&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">Debug&lt;/span> &lt;span class="kt">bool&lt;/span> &lt;span class="s">`json:&amp;#34;debug&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>這段程式同時回答兩個問題：Go 程式內用哪些欄位處理設定，以及 JSON 檔案裡的欄位名稱是什麼。&lt;/p>
&lt;h2 id="判讀json-tag-是外部資料格式-contract">【判讀】JSON tag 是外部資料格式 contract&lt;/h2>
&lt;p>JSON tag 的核心規則是：Go 欄位名稱服務程式碼可見性，JSON 欄位名稱服務外部資料格式。&lt;code>AppName&lt;/code> 對應 &lt;code>appName&lt;/code> 是兩個命名慣例的交界：&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>Go struct 欄位&lt;/td>
 &lt;td>&lt;code>AppName&lt;/code>&lt;/td>
 &lt;td>exported 欄位必須大寫開頭&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>JSON payload&lt;/td>
 &lt;td>&lt;code>appName&lt;/code>&lt;/td>
 &lt;td>JSON 與 API 常用 camelCase&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>omitempty&lt;/code> 宣告「這個欄位在某些資料情境中不是必要資料」。它是可選欄位的語義標記；欄位為零值時，JSON 序列化會跳過輸出。&lt;/p>
&lt;h2 id="策略先用資料語意決定欄位是否必要">【策略】先用資料語意決定欄位是否必要&lt;/h2>
&lt;p>設計 JSON 資料時，先分辨欄位角色：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>欄位角色&lt;/th>
 &lt;th>tag 策略&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>每筆資料都需要&lt;/td>
 &lt;td>不加 &lt;code>omitempty&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>只有部分情境需要&lt;/td>
 &lt;td>加 &lt;code>omitempty&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>內部使用，不輸出 JSON&lt;/td>
 &lt;td>使用 &lt;code>json:&amp;quot;-&amp;quot;&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>外部名稱需要穩定&lt;/td>
 &lt;td>明確寫 tag，不依賴預設&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這樣資料 contract 會比「把 struct 全部輸出」更清楚。&lt;/p>
&lt;h2 id="執行事件資料建模">【執行】事件資料建模&lt;/h2>
&lt;p>事件資料模型的核心規則是：事件本身必備欄位不使用 &lt;code>omitempty&lt;/code>，事件內容可依類型使用可選欄位。&lt;code>UserEvent&lt;/code> 表示一筆使用者行為事件，可以來自檔案、HTTP API 或 message &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>：&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">UserEvent&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">UserID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;userId&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="nx">Type&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;type&amp;#34;`&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">Timestamp&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span> &lt;span class="s">`json:&amp;#34;timestamp&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">Source&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;source&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">Payload&lt;/span> &lt;span class="nx">Payload&lt;/span> &lt;span class="s">`json:&amp;#34;payload&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;/code>&lt;/pre>&lt;/div>&lt;p>這個 struct 的欄位都沒有 &lt;code>omitempty&lt;/code>，表示它是事件流中的完整資料單位。相比之下，&lt;code>Payload&lt;/code> 可以依事件類型使用 &lt;code>omitempty&lt;/code>，因為不同事件只會填入部分欄位。&lt;/p>
&lt;h2 id="巢狀-struct">巢狀 struct&lt;/h2>
&lt;p>巢狀 struct 的核心規則是：資料本身有層次時，Go 型別也應保留同樣層次。以下設定把 server 相關欄位集中到 &lt;code>ServerConfig&lt;/code>：&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">ServerConfig&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">Host&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;host&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="nx">Port&lt;/span> &lt;span class="kt">int&lt;/span> &lt;span class="s">`json:&amp;#34;port&amp;#34;`&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="kd">type&lt;/span> &lt;span class="nx">Config&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">AppName&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;appName&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">Debug&lt;/span> &lt;span class="kt">bool&lt;/span> &lt;span class="s">`json:&amp;#34;debug&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">Server&lt;/span> &lt;span class="nx">ServerConfig&lt;/span> &lt;span class="s">`json:&amp;#34;server&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>對應 JSON：&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;appName&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;notify&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;debug&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&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;server&amp;#34;&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">5&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;host&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;localhost&amp;#34;&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="nt">&amp;#34;port&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">8080&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>這樣的設計讓資料層次在 Go 程式中也看得見。&lt;/p></description><content:encoded><![CDATA[<p>Go 的 struct 用來描述資料形狀：有哪些欄位、欄位是什麼型別、哪些資料應該放在一起。當資料需要存成 JSON 或透過 API 傳輸時，JSON tag 會把 Go 的欄位命名對應到外部格式。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>定義可序列化成 JSON 的 struct</li>
<li>理解 <code>omitempty</code> 的 API 語義</li>
<li>分辨內部欄位命名與外部 JSON 命名</li>
<li>看懂設定檔、API request 與事件資料模型</li>
</ol>
<hr>
<h2 id="觀察struct-用欄位集合定義資料形狀">【觀察】struct 用欄位集合定義資料形狀</h2>
<p>struct 的核心規則是：每個欄位都用名稱和型別描述一部分資料。以下範例用 struct 定義一份應用設定：</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">Config</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">AppName</span> <span class="kt">string</span> <span class="s">`json:&#34;appName&#34;`</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Port</span>    <span class="kt">int</span>    <span class="s">`json:&#34;port&#34;`</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Debug</span>   <span class="kt">bool</span>   <span class="s">`json:&#34;debug&#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>這段程式同時回答兩個問題：Go 程式內用哪些欄位處理設定，以及 JSON 檔案裡的欄位名稱是什麼。</p>
<h2 id="判讀json-tag-是外部資料格式-contract">【判讀】JSON tag 是外部資料格式 contract</h2>
<p>JSON tag 的核心規則是：Go 欄位名稱服務程式碼可見性，JSON 欄位名稱服務外部資料格式。<code>AppName</code> 對應 <code>appName</code> 是兩個命名慣例的交界：</p>
<table>
  <thead>
      <tr>
          <th>層次</th>
          <th>命名</th>
          <th>原因</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Go struct 欄位</td>
          <td><code>AppName</code></td>
          <td>exported 欄位必須大寫開頭</td>
      </tr>
      <tr>
          <td>JSON payload</td>
          <td><code>appName</code></td>
          <td>JSON 與 API 常用 camelCase</td>
      </tr>
  </tbody>
</table>
<p><code>omitempty</code> 宣告「這個欄位在某些資料情境中不是必要資料」。它是可選欄位的語義標記；欄位為零值時，JSON 序列化會跳過輸出。</p>
<h2 id="策略先用資料語意決定欄位是否必要">【策略】先用資料語意決定欄位是否必要</h2>
<p>設計 JSON 資料時，先分辨欄位角色：</p>
<table>
  <thead>
      <tr>
          <th>欄位角色</th>
          <th>tag 策略</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>每筆資料都需要</td>
          <td>不加 <code>omitempty</code></td>
      </tr>
      <tr>
          <td>只有部分情境需要</td>
          <td>加 <code>omitempty</code></td>
      </tr>
      <tr>
          <td>內部使用，不輸出 JSON</td>
          <td>使用 <code>json:&quot;-&quot;</code></td>
      </tr>
      <tr>
          <td>外部名稱需要穩定</td>
          <td>明確寫 tag，不依賴預設</td>
      </tr>
  </tbody>
</table>
<p>這樣資料 contract 會比「把 struct 全部輸出」更清楚。</p>
<h2 id="執行事件資料建模">【執行】事件資料建模</h2>
<p>事件資料模型的核心規則是：事件本身必備欄位不使用 <code>omitempty</code>，事件內容可依類型使用可選欄位。<code>UserEvent</code> 表示一筆使用者行為事件，可以來自檔案、HTTP API 或 message <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>：</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">UserEvent</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">UserID</span>    <span class="kt">string</span>    <span class="s">`json:&#34;userId&#34;`</span>
</span></span><span class="line"><span class="ln">3</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">4</span><span class="cl">    <span class="nx">Timestamp</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span> <span class="s">`json:&#34;timestamp&#34;`</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">Source</span>    <span class="kt">string</span>    <span class="s">`json:&#34;source&#34;`</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">Payload</span>   <span class="nx">Payload</span>   <span class="s">`json:&#34;payload&#34;`</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 struct 的欄位都沒有 <code>omitempty</code>，表示它是事件流中的完整資料單位。相比之下，<code>Payload</code> 可以依事件類型使用 <code>omitempty</code>，因為不同事件只會填入部分欄位。</p>
<h2 id="巢狀-struct">巢狀 struct</h2>
<p>巢狀 struct 的核心規則是：資料本身有層次時，Go 型別也應保留同樣層次。以下設定把 server 相關欄位集中到 <code>ServerConfig</code>：</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">ServerConfig</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">Host</span> <span class="kt">string</span> <span class="s">`json:&#34;host&#34;`</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Port</span> <span class="kt">int</span>    <span class="s">`json:&#34;port&#34;`</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="kd">type</span> <span class="nx">Config</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">AppName</span> <span class="kt">string</span>       <span class="s">`json:&#34;appName&#34;`</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">Debug</span>   <span class="kt">bool</span>         <span class="s">`json:&#34;debug&#34;`</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">Server</span>  <span class="nx">ServerConfig</span> <span class="s">`json:&#34;server&#34;`</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>對應 JSON：</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;appName&#34;</span><span class="p">:</span> <span class="s2">&#34;notify&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;debug&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;server&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nt">&#34;host&#34;</span><span class="p">:</span> <span class="s2">&#34;localhost&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nt">&#34;port&#34;</span><span class="p">:</span> <span class="mi">8080</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>這樣的設計讓資料層次在 Go 程式中也看得見。</p>
]]></content:encoded></item><item><title>3.1 fmt、strings 與基本文字處理</title><link>https://tarrragon.github.io/blog/go/03-stdlib/fmt-strings/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/03-stdlib/fmt-strings/</guid><description>&lt;p>文字處理的核心規則是：格式化輸出交給 &lt;code>fmt&lt;/code>，字串查找、裁切、替換與組合交給 &lt;code>strings&lt;/code>。本章將用 CLI 輸出、設定值清理與簡單 parser 建立標準庫文字處理基礎。&lt;/p>
&lt;h2 id="fmt-負責格式化">&lt;code>fmt&lt;/code> 負責格式化&lt;/h2>
&lt;p>&lt;code>fmt&lt;/code> 的核心責任是把資料轉成可閱讀的文字。它可以輸出到標準輸出，也可以把格式化結果組成字串，常用於 CLI 訊息、錯誤訊息與簡單除錯。&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="nx">name&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="s">&amp;#34;worker&amp;#34;&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">count&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">3&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Printf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;%s handled %d jobs\n&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">count&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="nx">message&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Sprintf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;%s handled %d jobs&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">count&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>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">message&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>Printf&lt;/code> 會直接輸出，&lt;code>Sprintf&lt;/code> 會回傳字串。這個差異很重要：函式內部如果只是要建立訊息，通常應該用 &lt;code>Sprintf&lt;/code> 回傳字串，而不是直接印出。&lt;/p>
&lt;h2 id="格式動詞描述輸出形狀">格式動詞描述輸出形狀&lt;/h2>
&lt;p>格式動詞的核心作用是告訴 &lt;code>fmt&lt;/code> 如何呈現資料。常見動詞包括 &lt;code>%s&lt;/code> 表示字串，&lt;code>%d&lt;/code> 表示十進位整數，&lt;code>%v&lt;/code> 表示一般值，&lt;code>%+v&lt;/code> 顯示 struct 欄位名稱，&lt;code>%#v&lt;/code> 顯示更接近 Go 語法的表示。&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">User&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">int&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">Name&lt;/span> &lt;span class="kt">string&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">user&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">User&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">7&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">Name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;alice&amp;#34;&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Printf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;%v\n&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// {7 alice}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Printf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;%+v\n&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// {ID:7 Name:alice}&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Printf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;%#v\n&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// main.User{ID:7, Name:&amp;#34;alice&amp;#34;}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>%v&lt;/code> 適合一般輸出，&lt;code>%+v&lt;/code> 適合快速檢查 struct 欄位，&lt;code>%#v&lt;/code> 適合除錯或理解實際型別。正式 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a> 通常應該使用結構化 log，而不是把所有資料塞進格式化字串。&lt;/p>
&lt;h2 id="錯誤訊息要包含可行動資訊">錯誤訊息要包含可行動資訊&lt;/h2>
&lt;p>錯誤訊息的核心原則是描述失敗的操作與關鍵資料。&lt;code>fmt.Errorf&lt;/code> 可以建立帶格式的 error，也可以用 &lt;code>%w&lt;/code> 包裝原始錯誤，保留錯誤鏈。&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">loadUser&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="kt">error&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">if&lt;/span> &lt;span class="nx">id&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">3&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;load user: id is required&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="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="k">return&lt;/span> &lt;span class="kc">nil&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>錯誤訊息是給工程師定位問題的線索。像 &lt;code>&amp;quot;failed&amp;quot;&lt;/code> 這類訊息太籠統，讀者無法知道是哪個操作失敗。&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="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">saveConfig&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">path&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">config&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">2&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;save config %q: %w&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">path&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">3&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>save config&lt;/code>、目標 &lt;code>path&lt;/code> 與原始錯誤。呼叫端可以顯示完整錯誤，也可以用 &lt;code>errors.Is&lt;/code> 或 &lt;code>errors.As&lt;/code> 檢查被包裝的錯誤。&lt;/p>
&lt;h2 id="strings-負責字串操作">&lt;code>strings&lt;/code> 負責字串操作&lt;/h2>
&lt;p>&lt;code>strings&lt;/code> 的核心責任是提供不需要正規表示式的常見字串操作。裁切空白、檢查前後綴、切割、替換、大小寫轉換，都應該先考慮 &lt;code>strings&lt;/code>。&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="nx">raw&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="s">&amp;#34; api,worker,admin &amp;#34;&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">raw&lt;/span> &lt;span class="p">=&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">raw&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>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="nx">parts&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">Split&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">,&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">5&lt;/span>&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">part&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">parts&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&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">part&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式先移除整體前後空白，再用逗號切割，最後清理每個片段。這種處理很常見於環境變數、設定檔與簡單文字輸入。&lt;/p>
&lt;h2 id="查找與判斷應該直接表達意圖">查找與判斷應該直接表達意圖&lt;/h2>
&lt;p>字串判斷的核心原則是使用最貼近意圖的函式。檢查包含關係用 &lt;code>Contains&lt;/code>，檢查開頭用 &lt;code>HasPrefix&lt;/code>，檢查結尾用 &lt;code>HasSuffix&lt;/code>。&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="nx">path&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="s">&amp;#34;/api/users&amp;#34;&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="k">if&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">HasPrefix&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">path&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;/api/&amp;#34;&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">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;api route&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Contains&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">path&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;users&amp;#34;&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">8&lt;/span>&lt;span class="cl"> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;user resource&amp;#34;&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>用 &lt;code>Index&lt;/code> 判斷是否存在也是可行的，但意圖比較間接。&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="k">if&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Index&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">path&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;/api/&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="mi">0&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;api route&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式需要讀者理解 &lt;code>Index&lt;/code> 回傳 &lt;code>0&lt;/code> 表示出現在開頭；&lt;code>HasPrefix&lt;/code> 則直接說出規則。入門階段應優先選擇語意清楚的 API。&lt;/p>
&lt;h2 id="組合字串要看資料量">組合字串要看資料量&lt;/h2>
&lt;p>組合少量字串時，&lt;code>+&lt;/code> 與 &lt;code>fmt.Sprintf&lt;/code> 通常足夠；大量或迴圈內組合字串時，&lt;code>strings.Builder&lt;/code> 更適合。核心判斷是：資料量小時重視可讀性，資料量大時避免反覆建立中間字串。&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="nx">name&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="s">&amp;#34;alice&amp;#34;&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">message&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="s">&amp;#34;hello, &amp;#34;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">name&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">message&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>少量字串串接很直覺，不需要過度設計。當你在迴圈中累積文字，&lt;code>strings.Builder&lt;/code> 能更明確地表達「正在逐步建構一段文字」。&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">var&lt;/span> &lt;span class="nx">builder&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Builder&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="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">name&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s">&amp;#34;alice&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;bob&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;carol&amp;#34;&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">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">builder&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteString&lt;/span>&lt;span class="p">(&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">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">builder&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteString&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">name&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">builder&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteString&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;\n&amp;#34;&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="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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">builder&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">String&lt;/span>&lt;span class="p">())&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>strings.Builder&lt;/code> 不是每次組字串都必須使用。若資料量小、流程簡單，普通串接往往更好讀。&lt;/p>
&lt;h2 id="簡單-parser-可以先用標準庫">簡單 parser 可以先用標準庫&lt;/h2>
&lt;p>簡單文字解析的核心策略是先用清楚的步驟切割資料，再逐步驗證格式。只有當格式本身複雜到難以維護時，才需要引入 parser 或正規表示式。&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">parsePair&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&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">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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">parts&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">SplitN&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;=&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">2&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">if&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">parts&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="mi">2&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="s">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&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;parse pair %q: missing =&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">key&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">parts&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&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="nx">value&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">parts&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">1&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">if&lt;/span> &lt;span class="nx">key&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">10&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&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;parse pair %q: empty key&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&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">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">value&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>SplitN&lt;/code> 限制最多切成兩段，避免 value 裡再次出現 &lt;code>=&lt;/code> 時被過度切割。這個例子也先處理格式錯誤，再回傳正常結果，讓流程保持清楚。&lt;/p></description><content:encoded><![CDATA[<p>文字處理的核心規則是：格式化輸出交給 <code>fmt</code>，字串查找、裁切、替換與組合交給 <code>strings</code>。本章將用 CLI 輸出、設定值清理與簡單 parser 建立標準庫文字處理基礎。</p>
<h2 id="fmt-負責格式化"><code>fmt</code> 負責格式化</h2>
<p><code>fmt</code> 的核心責任是把資料轉成可閱讀的文字。它可以輸出到標準輸出，也可以把格式化結果組成字串，常用於 CLI 訊息、錯誤訊息與簡單除錯。</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="nx">name</span> <span class="o">:=</span> <span class="s">&#34;worker&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">count</span> <span class="o">:=</span> <span class="mi">3</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">fmt</span><span class="p">.</span><span class="nf">Printf</span><span class="p">(</span><span class="s">&#34;%s handled %d jobs\n&#34;</span><span class="p">,</span> <span class="nx">name</span><span class="p">,</span> <span class="nx">count</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">message</span> <span class="o">:=</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Sprintf</span><span class="p">(</span><span class="s">&#34;%s handled %d jobs&#34;</span><span class="p">,</span> <span class="nx">name</span><span class="p">,</span> <span class="nx">count</span><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="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">message</span><span class="p">)</span></span></span></code></pre></div><p><code>Printf</code> 會直接輸出，<code>Sprintf</code> 會回傳字串。這個差異很重要：函式內部如果只是要建立訊息，通常應該用 <code>Sprintf</code> 回傳字串，而不是直接印出。</p>
<h2 id="格式動詞描述輸出形狀">格式動詞描述輸出形狀</h2>
<p>格式動詞的核心作用是告訴 <code>fmt</code> 如何呈現資料。常見動詞包括 <code>%s</code> 表示字串，<code>%d</code> 表示十進位整數，<code>%v</code> 表示一般值，<code>%+v</code> 顯示 struct 欄位名稱，<code>%#v</code> 顯示更接近 Go 語法的表示。</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">User</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">int</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Name</span> <span class="kt">string</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">user</span> <span class="o">:=</span> <span class="nx">User</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="mi">7</span><span class="p">,</span> <span class="nx">Name</span><span class="p">:</span> <span class="s">&#34;alice&#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">fmt</span><span class="p">.</span><span class="nf">Printf</span><span class="p">(</span><span class="s">&#34;%v\n&#34;</span><span class="p">,</span> <span class="nx">user</span><span class="p">)</span>  <span class="c1">// {7 alice}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Printf</span><span class="p">(</span><span class="s">&#34;%+v\n&#34;</span><span class="p">,</span> <span class="nx">user</span><span class="p">)</span> <span class="c1">// {ID:7 Name:alice}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Printf</span><span class="p">(</span><span class="s">&#34;%#v\n&#34;</span><span class="p">,</span> <span class="nx">user</span><span class="p">)</span> <span class="c1">// main.User{ID:7, Name:&#34;alice&#34;}</span></span></span></code></pre></div><p><code>%v</code> 適合一般輸出，<code>%+v</code> 適合快速檢查 struct 欄位，<code>%#v</code> 適合除錯或理解實際型別。正式 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 通常應該使用結構化 log，而不是把所有資料塞進格式化字串。</p>
<h2 id="錯誤訊息要包含可行動資訊">錯誤訊息要包含可行動資訊</h2>
<p>錯誤訊息的核心原則是描述失敗的操作與關鍵資料。<code>fmt.Errorf</code> 可以建立帶格式的 error，也可以用 <code>%w</code> 包裝原始錯誤，保留錯誤鏈。</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">loadUser</span><span class="p">(</span><span class="nx">id</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">2</span><span class="cl">    <span class="k">if</span> <span class="nx">id</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</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;load user: id is required&#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></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>錯誤訊息是給工程師定位問題的線索。像 <code>&quot;failed&quot;</code> 這類訊息太籠統，讀者無法知道是哪個操作失敗。</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="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">saveConfig</span><span class="p">(</span><span class="nx">path</span><span class="p">,</span> <span class="nx">config</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">2</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;save config %q: %w&#34;</span><span class="p">,</span> <span class="nx">path</span><span class="p">,</span> <span class="nx">err</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>這裡的訊息包含操作 <code>save config</code>、目標 <code>path</code> 與原始錯誤。呼叫端可以顯示完整錯誤，也可以用 <code>errors.Is</code> 或 <code>errors.As</code> 檢查被包裝的錯誤。</p>
<h2 id="strings-負責字串操作"><code>strings</code> 負責字串操作</h2>
<p><code>strings</code> 的核心責任是提供不需要正規表示式的常見字串操作。裁切空白、檢查前後綴、切割、替換、大小寫轉換，都應該先考慮 <code>strings</code>。</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="nx">raw</span> <span class="o">:=</span> <span class="s">&#34;  api,worker,admin  &#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">raw</span> <span class="p">=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">raw</span><span class="p">)</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">parts</span> <span class="o">:=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">Split</span><span class="p">(</span><span class="nx">raw</span><span class="p">,</span> <span class="s">&#34;,&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">part</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">parts</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">part</span><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>這段程式先移除整體前後空白，再用逗號切割，最後清理每個片段。這種處理很常見於環境變數、設定檔與簡單文字輸入。</p>
<h2 id="查找與判斷應該直接表達意圖">查找與判斷應該直接表達意圖</h2>
<p>字串判斷的核心原則是使用最貼近意圖的函式。檢查包含關係用 <code>Contains</code>，檢查開頭用 <code>HasPrefix</code>，檢查結尾用 <code>HasSuffix</code>。</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="nx">path</span> <span class="o">:=</span> <span class="s">&#34;/api/users&#34;</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="k">if</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">HasPrefix</span><span class="p">(</span><span class="nx">path</span><span class="p">,</span> <span class="s">&#34;/api/&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;api route&#34;</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></span><span class="line"><span class="ln">7</span><span class="cl"><span class="k">if</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">Contains</span><span class="p">(</span><span class="nx">path</span><span class="p">,</span> <span class="s">&#34;users&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;user resource&#34;</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>Index</code> 判斷是否存在也是可行的，但意圖比較間接。</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="k">if</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">Index</span><span class="p">(</span><span class="nx">path</span><span class="p">,</span> <span class="s">&#34;/api/&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;api route&#34;</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>這段程式需要讀者理解 <code>Index</code> 回傳 <code>0</code> 表示出現在開頭；<code>HasPrefix</code> 則直接說出規則。入門階段應優先選擇語意清楚的 API。</p>
<h2 id="組合字串要看資料量">組合字串要看資料量</h2>
<p>組合少量字串時，<code>+</code> 與 <code>fmt.Sprintf</code> 通常足夠；大量或迴圈內組合字串時，<code>strings.Builder</code> 更適合。核心判斷是：資料量小時重視可讀性，資料量大時避免反覆建立中間字串。</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="nx">name</span> <span class="o">:=</span> <span class="s">&#34;alice&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">message</span> <span class="o">:=</span> <span class="s">&#34;hello, &#34;</span> <span class="o">+</span> <span class="nx">name</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">message</span><span class="p">)</span></span></span></code></pre></div><p>少量字串串接很直覺，不需要過度設計。當你在迴圈中累積文字，<code>strings.Builder</code> 能更明確地表達「正在逐步建構一段文字」。</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">var</span> <span class="nx">builder</span> <span class="nx">strings</span><span class="p">.</span><span class="nx">Builder</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="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">name</span> <span class="o">:=</span> <span class="k">range</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;alice&#34;</span><span class="p">,</span> <span class="s">&#34;bob&#34;</span><span class="p">,</span> <span class="s">&#34;carol&#34;</span><span class="p">}</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">builder</span><span class="p">.</span><span class="nf">WriteString</span><span class="p">(</span><span class="s">&#34;- &#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">builder</span><span class="p">.</span><span class="nf">WriteString</span><span class="p">(</span><span class="nx">name</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">builder</span><span class="p">.</span><span class="nf">WriteString</span><span class="p">(</span><span class="s">&#34;\n&#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></span><span class="line"><span class="ln">9</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Print</span><span class="p">(</span><span class="nx">builder</span><span class="p">.</span><span class="nf">String</span><span class="p">())</span></span></span></code></pre></div><p><code>strings.Builder</code> 不是每次組字串都必須使用。若資料量小、流程簡單，普通串接往往更好讀。</p>
<h2 id="簡單-parser-可以先用標準庫">簡單 parser 可以先用標準庫</h2>
<p>簡單文字解析的核心策略是先用清楚的步驟切割資料，再逐步驗證格式。只有當格式本身複雜到難以維護時，才需要引入 parser 或正規表示式。</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">parsePair</span><span class="p">(</span><span class="nx">input</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="kt">string</span><span class="p">,</span> <span class="kt">string</span><span class="p">,</span> <span class="kt">error</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">parts</span> <span class="o">:=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">SplitN</span><span class="p">(</span><span class="nx">input</span><span class="p">,</span> <span class="s">&#34;=&#34;</span><span class="p">,</span> <span class="mi">2</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="nb">len</span><span class="p">(</span><span class="nx">parts</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">2</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="s">&#34;&#34;</span><span class="p">,</span> <span class="s">&#34;&#34;</span><span class="p">,</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;parse pair %q: missing =&#34;</span><span class="p">,</span> <span class="nx">input</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></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">key</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">parts</span><span class="p">[</span><span class="mi">0</span><span class="p">])</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">value</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">parts</span><span class="p">[</span><span class="mi">1</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">key</span> <span class="o">==</span> <span class="s">&#34;&#34;</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="s">&#34;&#34;</span><span class="p">,</span> <span class="s">&#34;&#34;</span><span class="p">,</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;parse pair %q: empty key&#34;</span><span class="p">,</span> <span class="nx">input</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">return</span> <span class="nx">key</span><span class="p">,</span> <span class="nx">value</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>SplitN</code> 限制最多切成兩段，避免 value 裡再次出現 <code>=</code> 時被過度切割。這個例子也先處理格式錯誤，再回傳正常結果，讓流程保持清楚。</p>
<h2 id="小結">小結</h2>
<p>下一章會進入 <code>time</code>，說明時間點、時間長度與 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 的標準表示方式。</p>
]]></content:encoded></item><item><title>3.1 GC 與 memory limit</title><link>https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/gc-memory-limit/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/gc-memory-limit/</guid><description>&lt;p>GC 與 memory limit 的核心關係是：Go runtime 會根據 heap 成長決定何時執行 GC，而 memory limit 讓 runtime 有一個軟性記憶體目標。Memory limit 不是硬性上限，也不是 leak 修復工具；它是讓 runtime 更早回應記憶體壓力的控制訊號。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解 heap growth、GOGC 與 GC 頻率的關係&lt;/li>
&lt;li>判斷 &lt;code>debug.SetMemoryLimit&lt;/code> 能解決什麼、不能解決什麼&lt;/li>
&lt;li>從環境變數設定服務 memory limit&lt;/li>
&lt;li>用 runtime &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics&lt;/a> 觀察調整效果&lt;/li>
&lt;li>分辨 GC 壓力、長期保留與真正 leak&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察長時間服務的記憶體問題通常是趨勢問題">【觀察】長時間服務的記憶體問題通常是趨勢問題&lt;/h2>
&lt;p>記憶體診斷的核心觀察是趨勢。Heap 是否持續上升、GC 後是否下降、goroutine 是否增加、某個操作後是否留下無法回收的資料，這些都比「現在用了多少 MB」更重要。&lt;/p>
&lt;p>常見現象：&lt;/p>
&lt;ul>
&lt;li>啟動後 heap 穩定在某個區間：通常正常。&lt;/li>
&lt;li>每次高峰後 heap 都能下降：可能是短暫配置。&lt;/li>
&lt;li>GC 後 heap 仍持續上升：可能有長期保留或 leak。&lt;/li>
&lt;li>GC 次數快速增加且 CPU 升高：可能是 allocation 壓力。&lt;/li>
&lt;li>goroutine 與 heap 同時增加：可能是 goroutine leak 或 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>Memory limit 可以幫 runtime 更積極控制 heap，但不能替代趨勢判讀。&lt;/p>
&lt;h2 id="判讀gc-控制的是-heap-成長">【判讀】GC 控制的是 heap 成長&lt;/h2>
&lt;p>Go GC 的核心目標是回收不再被引用的 heap 物件。Runtime 會根據 &lt;code>GOGC&lt;/code> 控制下一次 GC 觸發點。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nv">GOGC&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">100&lt;/span> go run ./cmd/server&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>GOGC=100&lt;/code> 大致表示 heap 在上次 GC 後成長約 100% 時觸發下一次 GC。數字越小，GC 越頻繁，記憶體通常較低但 CPU 成本較高；數字越大，GC 較少，記憶體通常較高但 CPU 成本較低。&lt;/p>
&lt;p>這是取捨，不是調大或調小就一定更好。CPU 緊繃的服務可能不能承受過低 &lt;code>GOGC&lt;/code>；記憶體緊繃的服務可能不能承受過高 &lt;code>GOGC&lt;/code>。&lt;/p>
&lt;h2 id="判讀memory-limit-是-runtime-軟目標">【判讀】memory limit 是 runtime 軟目標&lt;/h2>
&lt;p>&lt;code>debug.SetMemoryLimit&lt;/code> 的核心用途是告訴 Go runtime 希望整體記憶體使用量靠近某個目標。當 runtime 接近目標時，會更積極回收 heap。&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">configureRuntime&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="kd">const&lt;/span> &lt;span class="nx">limit&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">512&lt;/span> &lt;span class="o">&amp;lt;&amp;lt;&lt;/span> &lt;span class="mi">20&lt;/span> &lt;span class="c1">// 512 MiB&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">debug&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">SetMemoryLimit&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">limit&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這不是作業系統層級的硬限制。程式仍可能短暫超過這個值，特別是有大量非 Go heap 記憶體、cgo、mmap、大型 byte slice 尖峰或外部 library 配置時。&lt;/p>
&lt;p>Memory limit 適合容器、桌面常駐服務、背景 worker、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> server 這類需要避免吃掉過多資源的服務。若部署平台已有 memory limit，Go runtime 的 limit 通常應略低於平台限制，留給非 Go heap 與系統開銷。&lt;/p>
&lt;h2 id="執行設定值應來自部署環境">【執行】設定值應來自部署環境&lt;/h2>
&lt;p>Memory limit 的核心配置原則是由部署環境決定，而不是寫死在 library 裡。應用入口可以讀取環境變數，解析後設定 runtime。&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">ConfigureMemoryLimitFromEnv&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"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">raw&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Getenv&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;APP_MEMORY_LIMIT_MB&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="k">if&lt;/span> &lt;span class="nx">raw&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"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">mb&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">strconv&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Atoi&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">raw&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">if&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"> 9&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;parse APP_MEMORY_LIMIT_MB: %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">10&lt;/span>&lt;span class="cl"> &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>&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">mb&lt;/span> &lt;span class="o">&amp;lt;=&lt;/span> &lt;span class="mi">0&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;APP_MEMORY_LIMIT_MB must be positive&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="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">debug&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">SetMemoryLimit&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">int64&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">mb&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;lt;&amp;lt;&lt;/span> &lt;span class="mi">20&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">return&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&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;/p></description><content:encoded><![CDATA[<p>GC 與 memory limit 的核心關係是：Go runtime 會根據 heap 成長決定何時執行 GC，而 memory limit 讓 runtime 有一個軟性記憶體目標。Memory limit 不是硬性上限，也不是 leak 修復工具；它是讓 runtime 更早回應記憶體壓力的控制訊號。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解 heap growth、GOGC 與 GC 頻率的關係</li>
<li>判斷 <code>debug.SetMemoryLimit</code> 能解決什麼、不能解決什麼</li>
<li>從環境變數設定服務 memory limit</li>
<li>用 runtime <a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a> 觀察調整效果</li>
<li>分辨 GC 壓力、長期保留與真正 leak</li>
</ol>
<hr>
<h2 id="觀察長時間服務的記憶體問題通常是趨勢問題">【觀察】長時間服務的記憶體問題通常是趨勢問題</h2>
<p>記憶體診斷的核心觀察是趨勢。Heap 是否持續上升、GC 後是否下降、goroutine 是否增加、某個操作後是否留下無法回收的資料，這些都比「現在用了多少 MB」更重要。</p>
<p>常見現象：</p>
<ul>
<li>啟動後 heap 穩定在某個區間：通常正常。</li>
<li>每次高峰後 heap 都能下降：可能是短暫配置。</li>
<li>GC 後 heap 仍持續上升：可能有長期保留或 leak。</li>
<li>GC 次數快速增加且 CPU 升高：可能是 allocation 壓力。</li>
<li>goroutine 與 heap 同時增加：可能是 goroutine leak 或 send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 堆積。</li>
</ul>
<p>Memory limit 可以幫 runtime 更積極控制 heap，但不能替代趨勢判讀。</p>
<h2 id="判讀gc-控制的是-heap-成長">【判讀】GC 控制的是 heap 成長</h2>
<p>Go GC 的核心目標是回收不再被引用的 heap 物件。Runtime 會根據 <code>GOGC</code> 控制下一次 GC 觸發點。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nv">GOGC</span><span class="o">=</span><span class="m">100</span> go run ./cmd/server</span></span></code></pre></div><p><code>GOGC=100</code> 大致表示 heap 在上次 GC 後成長約 100% 時觸發下一次 GC。數字越小，GC 越頻繁，記憶體通常較低但 CPU 成本較高；數字越大，GC 較少，記憶體通常較高但 CPU 成本較低。</p>
<p>這是取捨，不是調大或調小就一定更好。CPU 緊繃的服務可能不能承受過低 <code>GOGC</code>；記憶體緊繃的服務可能不能承受過高 <code>GOGC</code>。</p>
<h2 id="判讀memory-limit-是-runtime-軟目標">【判讀】memory limit 是 runtime 軟目標</h2>
<p><code>debug.SetMemoryLimit</code> 的核心用途是告訴 Go runtime 希望整體記憶體使用量靠近某個目標。當 runtime 接近目標時，會更積極回收 heap。</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">configureRuntime</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="kd">const</span> <span class="nx">limit</span> <span class="p">=</span> <span class="mi">512</span> <span class="o">&lt;&lt;</span> <span class="mi">20</span> <span class="c1">// 512 MiB</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">debug</span><span class="p">.</span><span class="nf">SetMemoryLimit</span><span class="p">(</span><span class="nx">limit</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這不是作業系統層級的硬限制。程式仍可能短暫超過這個值，特別是有大量非 Go heap 記憶體、cgo、mmap、大型 byte slice 尖峰或外部 library 配置時。</p>
<p>Memory limit 適合容器、桌面常駐服務、背景 worker、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> server 這類需要避免吃掉過多資源的服務。若部署平台已有 memory limit，Go runtime 的 limit 通常應略低於平台限制，留給非 Go heap 與系統開銷。</p>
<h2 id="執行設定值應來自部署環境">【執行】設定值應來自部署環境</h2>
<p>Memory limit 的核心配置原則是由部署環境決定，而不是寫死在 library 裡。應用入口可以讀取環境變數，解析後設定 runtime。</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">ConfigureMemoryLimitFromEnv</span><span class="p">()</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">raw</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">Getenv</span><span class="p">(</span><span class="s">&#34;APP_MEMORY_LIMIT_MB&#34;</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="nx">raw</span> <span class="o">==</span> <span class="s">&#34;&#34;</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">nil</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="nx">mb</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">strconv</span><span class="p">.</span><span class="nf">Atoi</span><span class="p">(</span><span class="nx">raw</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</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"> 9</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;parse APP_MEMORY_LIMIT_MB: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><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></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">if</span> <span class="nx">mb</span> <span class="o">&lt;=</span> <span class="mi">0</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;APP_MEMORY_LIMIT_MB must be positive&#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></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">debug</span><span class="p">.</span><span class="nf">SetMemoryLimit</span><span class="p">(</span><span class="nb">int64</span><span class="p">(</span><span class="nx">mb</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="mi">20</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">nil</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>錯誤應在啟動時明確失敗。服務若用錯誤設定悄悄運行，後續記憶體行為會很難解釋。</p>
<h2 id="策略runtime-metrics-用來看調整是否有效">【策略】runtime metrics 用來看調整是否有效</h2>
<p>Runtime metrics 的核心用途是驗證調整效果。只改 <code>GOGC</code> 或 memory limit，不看 heap 與 GC 趨勢，容易變成憑感覺調參。</p>
<p>簡單方式可以用 <code>runtime.ReadMemStats</code>：</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">ReadHeapAlloc</span><span class="p">()</span> <span class="kt">uint64</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="kd">var</span> <span class="nx">stats</span> <span class="nx">runtime</span><span class="p">.</span><span class="nx">MemStats</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">runtime</span><span class="p">.</span><span class="nf">ReadMemStats</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">stats</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="nx">stats</span><span class="p">.</span><span class="nx">HeapAlloc</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>runtime/metrics</code>：</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">ReadRuntimeSamples</span><span class="p">()</span> <span class="p">[]</span><span class="nx">metrics</span><span class="p">.</span><span class="nx">Sample</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">samples</span> <span class="o">:=</span> <span class="p">[]</span><span class="nx">metrics</span><span class="p">.</span><span class="nx">Sample</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="p">{</span><span class="nx">Name</span><span class="p">:</span> <span class="s">&#34;/memory/classes/heap/objects:bytes&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="p">{</span><span class="nx">Name</span><span class="p">:</span> <span class="s">&#34;/gc/cycles/total:gc-cycles&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="p">{</span><span class="nx">Name</span><span class="p">:</span> <span class="s">&#34;/sched/goroutines:goroutines&#34;</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="nx">metrics</span><span class="p">.</span><span class="nf">Read</span><span class="p">(</span><span class="nx">samples</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">samples</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>觀察時要看趨勢：調整後 heap 峰值是否下降、GC 次數是否合理、CPU 是否上升、goroutine 是否仍持續增加。</p>
<h2 id="判讀memory-limit-不能修正仍被引用的資料">【判讀】memory limit 不能修正仍被引用的資料</h2>
<p>Memory limit 的核心邊界是它只能影響 GC 行為，不能讓仍被引用的物件消失。若程式把資料一直留在 map、slice、cache、goroutine 或 send buffer 裡，GC 不能回收。</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">var</span> <span class="nx">cache</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="kt">byte</span><span class="p">{}</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">func</span> <span class="nf">SavePayload</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">payload</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">cache</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span> <span class="p">=</span> <span class="nx">payload</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>cache</code> 沒有大小限制、<a href="/blog/backend/knowledge-cards/ttl/" data-link-title="TTL" data-link-desc="說明資料過期時間如何影響快取新鮮度、成本與一致性">TTL</a> 或刪除策略，memory limit 只會讓 GC 更常跑，但資料仍被 <code>cache</code> 引用。真正修正是設計 cache 淘汰、分頁、快照大小限制或資料釋放路徑。</p>
<p>因此遇到 heap 持續上升時，下一步是用 pprof 確認誰保留了記憶體。</p>
<h2 id="策略判斷是-gc-壓力還是長期保留">【策略】判斷是 GC 壓力還是長期保留</h2>
<p>記憶體問題的核心分流是：物件被大量配置但很快回收，還是物件被長期保留。</p>
<table>
  <thead>
      <tr>
          <th>現象</th>
          <th>可能問題</th>
          <th>下一步</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>alloc_space</code> 高，<code>inuse_space</code> 不高</td>
          <td>短命配置多，GC 壓力大</td>
          <td>找熱路徑 allocation</td>
      </tr>
      <tr>
          <td><code>inuse_space</code> 持續上升</td>
          <td>長期保留或 leak</td>
          <td>看 heap profile retainers</td>
      </tr>
      <tr>
          <td>goroutine 數量同步上升</td>
          <td>goroutine leak 或 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 堆積</td>
          <td>看 goroutine profile</td>
      </tr>
      <tr>
          <td>GC 次數暴增但 heap 仍高</td>
          <td>memory limit 壓力或資料保留</td>
          <td>檢查 cache/map/buffer</td>
      </tr>
  </tbody>
</table>
<p>這個分流會決定後續工具。GC 參數能緩解壓力，但保留資料要回到資料結構與 lifecycle 修。</p>
<h2 id="測試runtime-設定函式可以獨立測解析">【測試】runtime 設定函式可以獨立測解析</h2>
<p>Runtime 本身不需要在單元測試中反覆調參。應把環境解析邏輯獨立出來，測試輸入與錯誤即可。</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">ParseMemoryLimitMB</span><span class="p">(</span><span class="nx">raw</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="kt">int64</span><span class="p">,</span> <span class="kt">error</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">raw</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">return</span> <span class="mi">0</span><span class="p">,</span> <span class="kc">nil</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="nx">mb</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">strconv</span><span class="p">.</span><span class="nf">Atoi</span><span class="p">(</span><span class="nx">raw</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</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"> 7</span><span class="cl">        <span class="k">return</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;parse memory limit: %w&#34;</span><span class="p">,</span> <span class="nx">err</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">mb</span> <span class="o">&lt;=</span> <span class="mi">0</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="mi">0</span><span class="p">,</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;memory limit must be positive&#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="k">return</span> <span class="nb">int64</span><span class="p">(</span><span class="nx">mb</span><span class="p">)</span> <span class="o">&lt;&lt;</span> <span class="mi">20</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><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">TestParseMemoryLimitMB</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">got</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">ParseMemoryLimitMB</span><span class="p">(</span><span class="s">&#34;512&#34;</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="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</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;parse memory limit: %v&#34;</span><span class="p">,</span> <span class="nx">err</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="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="mi">512</span><span class="o">&lt;&lt;</span><span class="mi">20</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</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;limit = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nb">int64</span><span class="p">(</span><span class="mi">512</span><span class="o">&lt;&lt;</span><span class="mi">20</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="p">}</span></span></span></code></pre></div><p>這讓設定邏輯可測，而不需要在每個測試中真的改 runtime 狀態。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一 Go process 如何判讀 heap、GC 與 memory limit；平台 OOM 與部署合約，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Go 進階：Kubernetes、systemd 與 load balancer 合約</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="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 runtime 壓力、allocation 與 pprof 診斷；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go-advanced/03-runtime-profiling/allocation/" data-link-title="3.4 資料結構與 allocation 壓力" data-link-desc="分析列表、歷史資料與 WebSocket payload 的配置成本">Go：資料結構與 allocation 壓力</a></li>
<li><a href="/blog/go-advanced/03-runtime-profiling/goroutine-leak/" data-link-title="3.3 goroutine leak 偵測" data-link-desc="判斷背景工作與 client pump 是否正確退出">Go：goroutine leak 偵測</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>GC 控制 heap 回收節奏，memory limit 給 runtime 一個記憶體軟目標。合理設定能降低長時間服務的資源風險，但不能修正 cache、map、slice、goroutine 或 buffer 長期持有資料。診斷時先看趨勢，再用 pprof 區分 GC 壓力與長期保留。</p>
]]></content:encoded></item><item><title>4.1 goroutine：輕量並發工作</title><link>https://tarrragon.github.io/blog/go/04-concurrency/goroutine/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/04-concurrency/goroutine/</guid><description>&lt;p>goroutine 是 Go 執行並發工作的基本單位。它的核心用途是讓一段函式和目前流程同時進行，但每個 goroutine 都必須有明確的退出條件，否則長時間程式會累積無法回收的背景工作。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>用 &lt;code>go&lt;/code> 啟動 goroutine&lt;/li>
&lt;li>理解 goroutine 和一般函式呼叫的差異&lt;/li>
&lt;li>判斷哪些工作適合放進 goroutine&lt;/li>
&lt;li>為 goroutine 設計退出條件&lt;/li>
&lt;li>避免 goroutine leak&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察go-關鍵字啟動並發工作">【觀察】go 關鍵字啟動並發工作&lt;/h2>
&lt;p>&lt;code>go&lt;/code> 的核心規則是：在函式呼叫前加上 &lt;code>go&lt;/code>，該函式會在新的 goroutine 中執行，呼叫端不會等待它完成。&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">say&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">message&lt;/span> &lt;span class="kt">string&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&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">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="nf">main&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">6&lt;/span>&lt;span class="cl"> &lt;span class="k">go&lt;/span> &lt;span class="nf">say&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;background&amp;#34;&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="nf">say&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;foreground&amp;#34;&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;/code>&lt;/pre>&lt;/div>&lt;p>這段程式啟動一個背景 goroutine 執行 &lt;code>say(&amp;quot;background&amp;quot;)&lt;/code>，主 goroutine 會繼續執行 &lt;code>say(&amp;quot;foreground&amp;quot;)&lt;/code>。&lt;/p>
&lt;h2 id="判讀goroutine-需要明確完成保證">【判讀】goroutine 需要明確完成保證&lt;/h2>
&lt;p>goroutine 的生命週期規則是：程式不會因為你啟動了 goroutine 就自動等待它完成。&lt;code>main()&lt;/code> 結束時，整個 process 會結束，尚未完成的 goroutine 也會停止。&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">main&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;background&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>主程式太快結束時，背景 goroutine 可能還沒得到執行機會。&lt;/p>
&lt;p>需要等待結果時，應該使用 channel、&lt;code>sync.WaitGroup&lt;/code> 或其他同步機制。&lt;/p>
&lt;h2 id="策略goroutine-適合等待型或獨立型工作">【策略】goroutine 適合等待型或獨立型工作&lt;/h2>
&lt;p>goroutine 使用的核心規則是：只有當工作能和目前流程並發進行，且生命週期可被管理時，才啟動 goroutine。&lt;/p>
&lt;p>適合 goroutine 的工作：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>工作類型&lt;/th>
 &lt;th>原因&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>等待 I/O&lt;/td>
 &lt;td>等檔案、網路、外部程序時不阻塞主流程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>背景 worker&lt;/td>
 &lt;td>從 channel 收 job 並處理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>定時任務&lt;/td>
 &lt;td>定期清理、同步或掃描&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>多個獨立請求&lt;/td>
 &lt;td>可同時發出、再收集結果&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>等待 I/O 的核心訊號是目前流程會花時間等外部回應，例如讀檔、呼叫 HTTP API、等待資料庫查詢或讀取 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/socket/" data-link-title="Socket" data-link-desc="說明 network socket 如何成為 application 與網路之間的資料傳輸邊界">socket&lt;/a>。這類工作放進 goroutine 後，呼叫端可以繼續處理其他事件，但仍然要用 context 或 channel 管理結果與取消。&lt;/p>
&lt;p>背景 worker 的核心訊號是工作來自 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> 或 channel，而且處理時間和 request 生命週期分離。例如使用者送出匯入任務後，server 只先接受任務，後續由 worker 逐筆處理資料。這種 goroutine 通常需要明確的 job channel、錯誤回報與 shutdown 流程。&lt;/p>
&lt;p>定時任務的核心訊號是行為按時間觸發，例如每分鐘清理過期 session、同步外部狀態或刷新快取。這類 goroutine 應使用 ticker 搭配 context，讓服務停止時可以一起退出。&lt;/p>
&lt;p>多個獨立請求的核心訊號是多個工作彼此沒有順序依賴，例如同時查三個外部 API，最後合併結果。這類 goroutine 的重點是收集結果、限制並發數量，並在其中一個工作失敗時決定是否取消其他工作。&lt;/p>
&lt;p>需要先補齊生命週期設計的情境：&lt;/p>
&lt;ul>
&lt;li>只是想讓程式「看起來比較快」&lt;/li>
&lt;li>沒有任何退出條件&lt;/li>
&lt;li>呼叫端需要結果但沒有同步機制&lt;/li>
&lt;li>多個 goroutine 會同時修改共享資料但沒有保護&lt;/li>
&lt;/ul>
&lt;h2 id="執行用-waitgroup-等待一組工作">【執行】用 WaitGroup 等待一組工作&lt;/h2>
&lt;p>&lt;code>sync.WaitGroup&lt;/code> 的核心用途是等待一組 goroutine 完成。&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">main&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="kd">var&lt;/span> &lt;span class="nx">wg&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">WaitGroup&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="p">&amp;lt;&lt;/span> &lt;span class="mi">3&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="o">++&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="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&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">go&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">id&lt;/span> &lt;span class="kt">int&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;worker&amp;#34;&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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">}(&lt;/span>&lt;span class="nx">i&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Wait&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式有三個關鍵：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>動作&lt;/th>
 &lt;th>意義&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>wg.Add(1)&lt;/code>&lt;/td>
 &lt;td>增加一個待完成工作&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>defer wg.Done()&lt;/code>&lt;/td>
 &lt;td>goroutine 結束時標記完成&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>wg.Wait()&lt;/code>&lt;/td>
 &lt;td>等待所有工作完成&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>id&lt;/code> 作為參數傳入 goroutine，可以避免 loop 變數捕捉造成混淆。&lt;/p>
&lt;h2 id="長時間-goroutine-要能停止">長時間 goroutine 要能停止&lt;/h2>
&lt;p>長時間 goroutine 的核心規則是：迴圈中必須等待取消訊號或輸入 channel 關閉。&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">worker&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">jobs&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">Job&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="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">select&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">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">job&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">jobs&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">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">ok&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &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="nf">handle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&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="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>這個 worker 不會無限卡住；上層取消 context 或關閉 jobs channel，它都會退出。&lt;/p></description><content:encoded><![CDATA[<p>goroutine 是 Go 執行並發工作的基本單位。它的核心用途是讓一段函式和目前流程同時進行，但每個 goroutine 都必須有明確的退出條件，否則長時間程式會累積無法回收的背景工作。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>用 <code>go</code> 啟動 goroutine</li>
<li>理解 goroutine 和一般函式呼叫的差異</li>
<li>判斷哪些工作適合放進 goroutine</li>
<li>為 goroutine 設計退出條件</li>
<li>避免 goroutine leak</li>
</ol>
<hr>
<h2 id="觀察go-關鍵字啟動並發工作">【觀察】go 關鍵字啟動並發工作</h2>
<p><code>go</code> 的核心規則是：在函式呼叫前加上 <code>go</code>，該函式會在新的 goroutine 中執行，呼叫端不會等待它完成。</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">say</span><span class="p">(</span><span class="nx">message</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">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">message</span><span class="p">)</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="nf">main</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">go</span> <span class="nf">say</span><span class="p">(</span><span class="s">&#34;background&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nf">say</span><span class="p">(</span><span class="s">&#34;foreground&#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></code></pre></div><p>這段程式啟動一個背景 goroutine 執行 <code>say(&quot;background&quot;)</code>，主 goroutine 會繼續執行 <code>say(&quot;foreground&quot;)</code>。</p>
<h2 id="判讀goroutine-需要明確完成保證">【判讀】goroutine 需要明確完成保證</h2>
<p>goroutine 的生命週期規則是：程式不會因為你啟動了 goroutine 就自動等待它完成。<code>main()</code> 結束時，整個 process 會結束，尚未完成的 goroutine 也會停止。</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">main</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="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;background&#34;</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>主程式太快結束時，背景 goroutine 可能還沒得到執行機會。</p>
<p>需要等待結果時，應該使用 channel、<code>sync.WaitGroup</code> 或其他同步機制。</p>
<h2 id="策略goroutine-適合等待型或獨立型工作">【策略】goroutine 適合等待型或獨立型工作</h2>
<p>goroutine 使用的核心規則是：只有當工作能和目前流程並發進行，且生命週期可被管理時，才啟動 goroutine。</p>
<p>適合 goroutine 的工作：</p>
<table>
  <thead>
      <tr>
          <th>工作類型</th>
          <th>原因</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>等待 I/O</td>
          <td>等檔案、網路、外部程序時不阻塞主流程</td>
      </tr>
      <tr>
          <td>背景 worker</td>
          <td>從 channel 收 job 並處理</td>
      </tr>
      <tr>
          <td>定時任務</td>
          <td>定期清理、同步或掃描</td>
      </tr>
      <tr>
          <td>多個獨立請求</td>
          <td>可同時發出、再收集結果</td>
      </tr>
  </tbody>
</table>
<p>等待 I/O 的核心訊號是目前流程會花時間等外部回應，例如讀檔、呼叫 HTTP API、等待資料庫查詢或讀取 <a href="/blog/backend/knowledge-cards/socket/" data-link-title="Socket" data-link-desc="說明 network socket 如何成為 application 與網路之間的資料傳輸邊界">socket</a>。這類工作放進 goroutine 後，呼叫端可以繼續處理其他事件，但仍然要用 context 或 channel 管理結果與取消。</p>
<p>背景 worker 的核心訊號是工作來自 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 或 channel，而且處理時間和 request 生命週期分離。例如使用者送出匯入任務後，server 只先接受任務，後續由 worker 逐筆處理資料。這種 goroutine 通常需要明確的 job channel、錯誤回報與 shutdown 流程。</p>
<p>定時任務的核心訊號是行為按時間觸發，例如每分鐘清理過期 session、同步外部狀態或刷新快取。這類 goroutine 應使用 ticker 搭配 context，讓服務停止時可以一起退出。</p>
<p>多個獨立請求的核心訊號是多個工作彼此沒有順序依賴，例如同時查三個外部 API，最後合併結果。這類 goroutine 的重點是收集結果、限制並發數量，並在其中一個工作失敗時決定是否取消其他工作。</p>
<p>需要先補齊生命週期設計的情境：</p>
<ul>
<li>只是想讓程式「看起來比較快」</li>
<li>沒有任何退出條件</li>
<li>呼叫端需要結果但沒有同步機制</li>
<li>多個 goroutine 會同時修改共享資料但沒有保護</li>
</ul>
<h2 id="執行用-waitgroup-等待一組工作">【執行】用 WaitGroup 等待一組工作</h2>
<p><code>sync.WaitGroup</code> 的核心用途是等待一組 goroutine 完成。</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">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="kd">var</span> <span class="nx">wg</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">WaitGroup</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="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="mi">3</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">wg</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">go</span> <span class="kd">func</span><span class="p">(</span><span class="nx">id</span> <span class="kt">int</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="k">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;worker&#34;</span><span class="p">,</span> <span class="nx">id</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="p">}(</span><span class="nx">i</span><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></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">wg</span><span class="p">.</span><span class="nf">Wait</span><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>這段程式有三個關鍵：</p>
<table>
  <thead>
      <tr>
          <th>動作</th>
          <th>意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>wg.Add(1)</code></td>
          <td>增加一個待完成工作</td>
      </tr>
      <tr>
          <td><code>defer wg.Done()</code></td>
          <td>goroutine 結束時標記完成</td>
      </tr>
      <tr>
          <td><code>wg.Wait()</code></td>
          <td>等待所有工作完成</td>
      </tr>
  </tbody>
</table>
<p><code>id</code> 作為參數傳入 goroutine，可以避免 loop 變數捕捉造成混淆。</p>
<h2 id="長時間-goroutine-要能停止">長時間 goroutine 要能停止</h2>
<p>長時間 goroutine 的核心規則是：迴圈中必須等待取消訊號或輸入 channel 關閉。</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">worker</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">jobs</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Job</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">case</span> <span class="nx">job</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">jobs</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</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"> 8</span><span class="cl">                <span class="k">return</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="nf">handle</span><span class="p">(</span><span class="nx">job</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><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 worker 不會無限卡住；上層取消 context 或關閉 jobs channel，它都會退出。</p>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="需要結果時要有等待機制">需要結果時要有等待機制</h3>
<p>需要結果或完成保證時，goroutine 應搭配 channel 或 <code>WaitGroup</code>。<code>go doWork()</code> 只負責啟動工作，結果收集與完成等待需要另外設計。</p>
<h3 id="錯誤要有回報路徑">錯誤要有回報路徑</h3>
<p>goroutine 裡的錯誤需要明確回報路徑。需要錯誤結果時，用 channel 傳回：</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="nx">errCh</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="kt">error</span><span class="p">,</span> <span class="mi">1</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="nx">errCh</span> <span class="o">&lt;-</span> <span class="nf">doWork</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></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">errCh</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">7</span><span class="cl">    <span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="長時間工作要有退出條件">長時間工作要有退出條件</h3>
<p>長時間 worker 至少要監聽 context 或 channel close。永遠 <code>for {}</code> 會讓 goroutine 生命週期失去 owner，服務停止時也難以清理。</p>
]]></content:encoded></item><item><title>4.1 事件來源、處理流程與狀態邊界</title><link>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/component-boundaries/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/component-boundaries/</guid><description>&lt;p>事件系統的核心邊界是把「收到訊號」、「轉成事件」、「套用規則」、「更新狀態」與「輸出結果」拆開。每個邊界都應該有自己的型別與測試，否則一個 handler 或 worker 很快就會同時負責協定、驗證、去重、狀態與推送。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 event source、normalizer、processor、repository、publisher 的責任&lt;/li>
&lt;li>用 Go interface 表達元件能力，而不是表達資料夾模板&lt;/li>
&lt;li>把外部格式限制在 adapter 內&lt;/li>
&lt;li>讓狀態更新集中到 repository 或 state owner&lt;/li>
&lt;li>用測試驗證每個邊界是否可替換&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察事件流程容易被寫成一團">【觀察】事件流程容易被寫成一團&lt;/h2>
&lt;p>事件流程膨脹的常見原因是入口程式碼太方便。HTTP handler 可以 decode JSON、驗證欄位、查 map、送通知；worker 也可以讀 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>、判斷重複、更新狀態、寫 &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;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">handleCallback&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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="kd">var&lt;/span> &lt;span class="nx">raw&lt;/span> &lt;span class="nx">CallbackPayload&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">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">raw&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&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"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;missing id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&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">return&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="k">if&lt;/span> &lt;span class="nx">seen&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&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">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusNoContent&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>&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;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">seen&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kc">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">states&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">AccountID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;active&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &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">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">AccountID&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;active&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式的問題是責任混在一起。HTTP 協定、輸入格式、去重策略、狀態更新與推送規則都被綁在同一個函式，任何一項改變都會影響整個入口。&lt;/p>
&lt;h2 id="判讀事件邊界應該按照責任切開">【判讀】事件邊界應該按照責任切開&lt;/h2>
&lt;p>事件邊界的核心規則是每一層只知道自己必須知道的資訊。adapter 知道外部協定，normalizer 知道格式轉換，processor 知道事件規則，repository 知道狀態保存，publisher 知道輸出方式。&lt;/p>
&lt;p>一個可維護的事件流程可以長這樣：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">HTTP / queue / timer
&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> adapter
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> │ raw input
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> normalizer
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> │ DomainEvent
&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"> processor
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> ├── deduper
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> ├── repository
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> └── publisher&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這是依賴方向的要求。外部來源依賴內部事件模型；內部處理流程不依賴外部 raw payload。&lt;/p>
&lt;h2 id="策略先定義內部事件模型">【策略】先定義內部事件模型&lt;/h2>
&lt;p>內部事件模型的核心責任是提供穩定語意。不同來源可以有不同欄位名稱與時間格式，但進入 processor 前都應轉成同一種事件。&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">EventType&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">EventNotificationCreated&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;notification.created&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">EventAccountActivated&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;account.activated&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">EventJobFinished&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;job.finished&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">EventSource&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&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">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">SourceHTTPCallback&lt;/span> &lt;span class="nx">EventSource&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;http_callback&amp;#34;&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">SourceQueue&lt;/span> &lt;span class="nx">EventSource&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;queue&amp;#34;&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">SourceTimer&lt;/span> &lt;span class="nx">EventSource&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;timer&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">DomainEvent&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">18&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">19&lt;/span>&lt;span class="cl"> &lt;span class="nx">Source&lt;/span> &lt;span class="nx">EventSource&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">Type&lt;/span> &lt;span class="nx">EventType&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="nx">SubjectID&lt;/span> &lt;span class="kt">string&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">OccurredAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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">ReceivedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="nx">Payload&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RawMessage&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&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>OccurredAt&lt;/code> 是事件發生時間，&lt;code>ReceivedAt&lt;/code> 是系統收到時間。這兩個欄位要分開，因為外部事件可能延遲送達；去重與排序通常看事件語意時間，操作監控通常看收到時間。&lt;/p>
&lt;h2 id="執行adapter-只負責外部格式">【執行】adapter 只負責外部格式&lt;/h2>
&lt;p>adapter 的核心責任是把外部輸入轉成內部事件或 command。它可以知道 JSON tag、HTTP status、queue &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack&lt;/a>、header，但不應直接修改狀態。&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">CallbackPayload&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">EventID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;event_id&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="nx">AccountID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;account_id&amp;#34;`&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">EventName&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;event_name&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">Timestamp&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;timestamp&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="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">type&lt;/span> &lt;span class="nx">CallbackHandler&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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">processor&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">EventProcessor&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">now&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&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>&lt;/span>&lt;span class="line">&lt;span class="ln">13&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="nx">CallbackHandler&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">ServeHTTP&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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">14&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">payload&lt;/span> &lt;span class="nx">CallbackPayload&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&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">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">payload&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">16&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid_json&amp;#34;&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">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NormalizeCallback&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">payload&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">now&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&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="kc">nil&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="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid_event&amp;#34;&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="k">return&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">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">processor&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Process&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Context&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">event&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">27&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusInternalServerError&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;process_event_failed&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&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">29&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusAccepted&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>handler 的測試應該檢查 HTTP 行為與 normalize 錯誤對應。事件規則的測試不應透過 HTTP handler 才能執行，否則 processor 的變化會被協定細節干擾。&lt;/p></description><content:encoded><![CDATA[<p>事件系統的核心邊界是把「收到訊號」、「轉成事件」、「套用規則」、「更新狀態」與「輸出結果」拆開。每個邊界都應該有自己的型別與測試，否則一個 handler 或 worker 很快就會同時負責協定、驗證、去重、狀態與推送。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 event source、normalizer、processor、repository、publisher 的責任</li>
<li>用 Go interface 表達元件能力，而不是表達資料夾模板</li>
<li>把外部格式限制在 adapter 內</li>
<li>讓狀態更新集中到 repository 或 state owner</li>
<li>用測試驗證每個邊界是否可替換</li>
</ol>
<hr>
<h2 id="觀察事件流程容易被寫成一團">【觀察】事件流程容易被寫成一團</h2>
<p>事件流程膨脹的常見原因是入口程式碼太方便。HTTP handler 可以 decode JSON、驗證欄位、查 map、送通知；worker 也可以讀 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>、判斷重複、更新狀態、寫 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>。短期看起來直接，長期會讓每個入口都複製一套規則。</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">handleCallback</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="kd">var</span> <span class="nx">raw</span> <span class="nx">CallbackPayload</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">json</span><span class="p">.</span><span class="nf">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">raw</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">if</span> <span class="nx">raw</span><span class="p">.</span><span class="nx">ID</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;missing id&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</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">if</span> <span class="nx">seen</span><span class="p">[</span><span class="nx">raw</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusNoContent</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">seen</span><span class="p">[</span><span class="nx">raw</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">states</span><span class="p">[</span><span class="nx">raw</span><span class="p">.</span><span class="nx">AccountID</span><span class="p">]</span> <span class="p">=</span> <span class="s">&#34;active&#34;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nx">hub</span><span class="p">.</span><span class="nf">Broadcast</span><span class="p">(</span><span class="nx">raw</span><span class="p">.</span><span class="nx">AccountID</span><span class="p">,</span> <span class="s">&#34;active&#34;</span><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>這段程式的問題是責任混在一起。HTTP 協定、輸入格式、去重策略、狀態更新與推送規則都被綁在同一個函式，任何一項改變都會影響整個入口。</p>
<h2 id="判讀事件邊界應該按照責任切開">【判讀】事件邊界應該按照責任切開</h2>
<p>事件邊界的核心規則是每一層只知道自己必須知道的資訊。adapter 知道外部協定，normalizer 知道格式轉換，processor 知道事件規則，repository 知道狀態保存，publisher 知道輸出方式。</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">HTTP / queue / timer
</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">    adapter
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        │ raw input
</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">   normalizer
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        │ DomainEvent
</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">   processor
</span></span><span class="line"><span class="ln">11</span><span class="cl">        │
</span></span><span class="line"><span class="ln">12</span><span class="cl">        ├── deduper
</span></span><span class="line"><span class="ln">13</span><span class="cl">        ├── repository
</span></span><span class="line"><span class="ln">14</span><span class="cl">        └── publisher</span></span></code></pre></div><p>這是依賴方向的要求。外部來源依賴內部事件模型；內部處理流程不依賴外部 raw payload。</p>
<h2 id="策略先定義內部事件模型">【策略】先定義內部事件模型</h2>
<p>內部事件模型的核心責任是提供穩定語意。不同來源可以有不同欄位名稱與時間格式，但進入 processor 前都應轉成同一種事件。</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">EventType</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">EventNotificationCreated</span> <span class="nx">EventType</span> <span class="p">=</span> <span class="s">&#34;notification.created&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">EventAccountActivated</span>    <span class="nx">EventType</span> <span class="p">=</span> <span class="s">&#34;account.activated&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">EventJobFinished</span>         <span class="nx">EventType</span> <span class="p">=</span> <span class="s">&#34;job.finished&#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">EventSource</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">SourceHTTPCallback</span> <span class="nx">EventSource</span> <span class="p">=</span> <span class="s">&#34;http_callback&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">SourceQueue</span>        <span class="nx">EventSource</span> <span class="p">=</span> <span class="s">&#34;queue&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">SourceTimer</span>        <span class="nx">EventSource</span> <span class="p">=</span> <span class="s">&#34;timer&#34;</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="kd">type</span> <span class="nx">DomainEvent</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">ID</span>         <span class="kt">string</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">Source</span>     <span class="nx">EventSource</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="nx">Type</span>       <span class="nx">EventType</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">SubjectID</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="nx">OccurredAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="nx">ReceivedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="nx">Payload</span>    <span class="nx">json</span><span class="p">.</span><span class="nx">RawMessage</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>OccurredAt</code> 是事件發生時間，<code>ReceivedAt</code> 是系統收到時間。這兩個欄位要分開，因為外部事件可能延遲送達；去重與排序通常看事件語意時間，操作監控通常看收到時間。</p>
<h2 id="執行adapter-只負責外部格式">【執行】adapter 只負責外部格式</h2>
<p>adapter 的核心責任是把外部輸入轉成內部事件或 command。它可以知道 JSON tag、HTTP status、queue <a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack</a>、header，但不應直接修改狀態。</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">CallbackPayload</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">EventID</span>   <span class="kt">string</span> <span class="s">`json:&#34;event_id&#34;`</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">AccountID</span> <span class="kt">string</span> <span class="s">`json:&#34;account_id&#34;`</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">EventName</span> <span class="kt">string</span> <span class="s">`json:&#34;event_name&#34;`</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">Timestamp</span> <span class="kt">string</span> <span class="s">`json:&#34;timestamp&#34;`</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">type</span> <span class="nx">CallbackHandler</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">processor</span> <span class="o">*</span><span class="nx">EventProcessor</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">now</span>       <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="nx">CallbackHandler</span><span class="p">)</span> <span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="kd">var</span> <span class="nx">payload</span> <span class="nx">CallbackPayload</span>
</span></span><span class="line"><span class="ln">15</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">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">payload</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">16</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">return</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="nx">event</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeCallback</span><span class="p">(</span><span class="nx">payload</span><span class="p">,</span> <span class="nx">h</span><span class="p">.</span><span class="nf">now</span><span class="p">())</span>
</span></span><span class="line"><span class="ln">21</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">22</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_event&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">return</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">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nf">Context</span><span class="p">(),</span> <span class="nx">event</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">27</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusInternalServerError</span><span class="p">,</span> <span class="s">&#34;process_event_failed&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusAccepted</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>handler 的測試應該檢查 HTTP 行為與 normalize 錯誤對應。事件規則的測試不應透過 HTTP handler 才能執行，否則 processor 的變化會被協定細節干擾。</p>
<h2 id="執行normalizer-負責轉換與基本合約">【執行】normalizer 負責轉換與基本合約</h2>
<p>normalizer 的核心責任是把 raw input 轉成 <code>DomainEvent</code>，並拒絕語意不完整的資料。它是外部世界與內部模型的邊界。</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">NormalizeCallback</span><span class="p">(</span><span class="nx">raw</span> <span class="nx">CallbackPayload</span><span class="p">,</span> <span class="nx">receivedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="p">(</span><span class="nx">DomainEvent</span><span class="p">,</span> <span class="kt">error</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">occurredAt</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">RFC3339</span><span class="p">,</span> <span class="nx">raw</span><span class="p">.</span><span class="nx">Timestamp</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="nx">err</span> <span class="o">!=</span> <span class="kc">nil</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="nx">DomainEvent</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;parse timestamp: %w&#34;</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">event</span> <span class="o">:=</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>         <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">raw</span><span class="p">.</span><span class="nx">EventID</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">Source</span><span class="p">:</span>     <span class="nx">SourceHTTPCallback</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="nf">mapCallbackEventName</span><span class="p">(</span><span class="nx">raw</span><span class="p">.</span><span class="nx">EventName</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>  <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">raw</span><span class="p">.</span><span class="nx">AccountID</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">occurredAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span> <span class="nx">receivedAt</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="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">event</span><span class="p">.</span><span class="nf">Validate</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="k">return</span> <span class="nx">DomainEvent</span><span class="p">{},</span> <span class="nx">err</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="k">return</span> <span class="nx">event</span><span class="p">,</span> <span class="kc">nil</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></span><span class="line"><span class="ln">22</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">e</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="nf">Validate</span><span class="p">()</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">ID</span> <span class="o">==</span> <span class="s">&#34;&#34;</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">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;event id is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">Type</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">27</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;event type is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">SubjectID</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">30</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;subject id is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">OccurredAt</span><span class="p">.</span><span class="nf">IsZero</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">33</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;occurred at is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">ReceivedAt</span><span class="p">.</span><span class="nf">IsZero</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">36</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;received at is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>validation 應該保護 envelope 的必要欄位。更細的 payload 規則可以放在特定事件的 normalizer 或 processor，避免 <code>Validate</code> 變成所有事件的巨大規則表。</p>
<h2 id="執行processor-負責事件規則">【執行】processor 負責事件規則</h2>
<p>processor 的核心責任是套用內部事件規則。它可以驗證、去重、更新狀態、寫入事件紀錄、呼叫 publisher，但不應知道 HTTP body 或 queue message 的原始格式。</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">EventRepository</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">Apply</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">event</span> <span class="nx">DomainEvent</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">type</span> <span class="nx">Deduper</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nf">Seen</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">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="p">(</span><span class="kt">bool</span><span class="p">,</span> <span class="kt">error</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></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">type</span> <span class="nx">Publisher</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nf">Publish</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">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</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="kd">type</span> <span class="nx">EventProcessor</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">deduper</span>    <span class="nx">Deduper</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">repository</span> <span class="nx">EventRepository</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">publisher</span>  <span class="nx">Publisher</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></span><span class="line"><span class="ln">19</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">p</span> <span class="o">*</span><span class="nx">EventProcessor</span><span class="p">)</span> <span class="nf">Process</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">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">event</span><span class="p">.</span><span class="nf">Validate</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">21</span><span class="cl">        <span class="k">return</span> <span class="nx">err</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></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="nx">duplicated</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">deduper</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</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">26</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;dedup event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="k">if</span> <span class="nx">duplicated</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</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">33</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;apply event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">
</span></span><span class="line"><span class="ln">36</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">publisher</span><span class="p">.</span><span class="nf">Publish</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</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">37</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;publish event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">
</span></span><span class="line"><span class="ln">40</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 processor 依賴能力介面，不依賴具體實作。Go 的 implicit interface 讓 memory repository、<a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a> repository 或測試 fake 都可以自然接上。</p>
<h2 id="判讀publisher-失敗策略必須明確">【判讀】publisher 失敗策略必須明確</h2>
<p>publisher 的核心問題是「輸出失敗是否影響狀態成功」。即時通知、審計紀錄、外部 <a href="/blog/backend/knowledge-cards/webhook/" data-link-title="Webhook" data-link-desc="說明外部系統回呼事件的接收、驗證與處理邊界">webhook</a> 的可靠性要求不同，不能一律用同一個錯誤策略。</p>
<p>常見策略：</p>
<table>
  <thead>
      <tr>
          <th>輸出類型</th>
          <th>失敗策略</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>即時 UI 推送</td>
          <td>記錄錯誤，可允許狀態已更新</td>
          <td>客戶端可重新查詢最新狀態</td>
      </tr>
      <tr>
          <td>事件紀錄</td>
          <td>失敗時中止流程</td>
          <td>紀錄是不可遺失的資料</td>
      </tr>
      <tr>
          <td>外部 webhook</td>
          <td>寫入 outbox，稍後重試</td>
          <td>下游需要可靠接收</td>
      </tr>
  </tbody>
</table>
<p>若 <code>repository.Apply</code> 成功但 <code>publisher.Publish</code> 失敗，系統必須知道這是可接受的降級，還是需要重試與補償。這個決策應該寫在 processor 或 usecase 的設計裡，不應藏在 publisher implementation。</p>
<h2 id="測試每個邊界分開測">【測試】每個邊界分開測</h2>
<p>事件邊界的測試目標是讓錯誤定位清楚。normalizer 測 raw input 轉換，processor 測規則順序，repository 測狀態一致性，publisher 測輸出協定。</p>
<p>processor fake test 範例：</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">TestProcessorSkipsDuplicateEvent</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">processor</span> <span class="o">:=</span> <span class="nx">EventProcessor</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">deduper</span><span class="p">:</span>    <span class="nx">fakeDeduper</span><span class="p">{</span><span class="nx">duplicated</span><span class="p">:</span> <span class="kc">true</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">repository</span><span class="p">:</span> <span class="o">&amp;</span><span class="nx">fakeRepository</span><span class="p">{},</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">publisher</span><span class="p">:</span>  <span class="o">&amp;</span><span class="nx">fakePublisher</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="nx">err</span> <span class="o">:=</span> <span class="nx">processor</span><span class="p">.</span><span class="nf">Process</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">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>         <span class="s">&#34;evt_1&#34;</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="nx">EventAccountActivated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>  <span class="s">&#34;acct_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</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">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">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;process event: %v&#34;</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="nx">processor</span><span class="p">.</span><span class="nx">repository</span><span class="p">.(</span><span class="o">*</span><span class="nx">fakeRepository</span><span class="p">).</span><span class="nx">applied</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</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;duplicate event should not update repository&#34;</span><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>這種測試不需要 HTTP server。它直接驗證 processor 的規則：重複事件不應更新狀態，也不應送出推送。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一 Go 服務內的事件來源與處理邊界；分散式一致性與 event sourcing，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">Go 進階：資料庫 transaction 與 schema migration</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</a></li>
<li><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫與持久化</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 action、event、repository 與 publisher 的邊界；如果你要先回看語言教材，可以讀：</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/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">Go：逐步遷移到 ports/adapters 架構</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>事件系統的可維護性來自清楚邊界：adapter 處理外部格式，normalizer 建立內部事件，processor 套用規則，repository 擁有狀態，publisher 輸出結果。當每個元件只承擔一種責任時，新增來源、新增事件或替換儲存實作都會變成局部修改。</p>
]]></content:encoded></item><item><title>5.1 時間注入與狀態轉移測試</title><link>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/time-control/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/time-control/</guid><description>&lt;p>時間控制測試的核心原則是把「現在」變成可指定輸入。只要程式邏輯依賴目前時間、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>、ticker 或過期判斷，測試就不應依賴真實等待。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>用 &lt;code>now time.Time&lt;/code> 測試純狀態轉移&lt;/li>
&lt;li>用 &lt;code>func() time.Time&lt;/code> 注入長生命週期元件的時間來源&lt;/li>
&lt;li>用 table-driven test 覆蓋時間邊界&lt;/li>
&lt;li>把 ticker 排程與單次工作拆開測&lt;/li>
&lt;li>避免 &lt;code>time.Sleep&lt;/code> 造成慢且不穩定的測試&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察直接呼叫-timenow-會讓測試失去控制">【觀察】直接呼叫 time.Now 會讓測試失去控制&lt;/h2>
&lt;p>時間相關邏輯的核心問題是同一筆資料在不同時間會得到不同結果。若函式內部直接呼叫 &lt;code>time.Now()&lt;/code>，測試就無法完整控制輸入。&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">Status&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">string&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">if&lt;/span> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">FinishedAt&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">3&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s">&amp;#34;completed&amp;#34;&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="k">if&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Since&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StartedAt&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">&amp;gt;&lt;/span> &lt;span class="mi">5&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">Minute&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="s">&amp;#34;idle&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 class="k">return&lt;/span> &lt;span class="s">&amp;#34;active&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個函式看起來簡單，但測試無法指定「現在剛好是開始後 4 分鐘」或「現在剛好跨過 5 分鐘」。測試只能依賴真實時間，結果慢且不穩定。&lt;/p>
&lt;h2 id="判讀時間是狀態轉移的輸入">【判讀】時間是狀態轉移的輸入&lt;/h2>
&lt;p>時間測試的核心判讀是：如果時間會影響結果，時間就是輸入。把 &lt;code>now&lt;/code> 放進函式簽名，會讓狀態轉移規則變得可測。&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">Job&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">StartedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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">FinishedAt&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">Time&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="kd">func&lt;/span> &lt;span class="nf">Status&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">job&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">string&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">if&lt;/span> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">FinishedAt&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"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s">&amp;#34;completed&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">now&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Sub&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StartedAt&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">&amp;gt;&lt;/span> &lt;span class="mi">5&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">Minute&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="s">&amp;#34;idle&amp;#34;&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;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s">&amp;#34;active&amp;#34;&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>now&lt;/code> 是明確輸入，因此測試可以建立任何時間點。這也讓讀者一眼看出 &lt;code>Status&lt;/code> 看的是 &lt;code>Job&lt;/code> 與目前時間的關係。&lt;/p>
&lt;h2 id="執行用-table-driven-test-描述時間邊界">【執行】用 table-driven test 描述時間邊界&lt;/h2>
&lt;p>時間邊界的核心測試方式是列出切換點前後的案例。狀態通常在某個 duration 前後改變，table-driven test 能讓這些情境集中呈現。&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">TestStatus&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">startedAt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Date&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2026&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">22&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UTC&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">tests&lt;/span> &lt;span class="o">:=&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"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span> &lt;span class="kt">string&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">now&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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">job&lt;/span> &lt;span class="nx">Job&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">want&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &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="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">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;active before idle threshold&amp;#34;&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">now&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">startedAt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">4&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">Minute&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">job&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">StartedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">startedAt&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">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;active&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="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;idle after threshold&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="nx">now&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">startedAt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">6&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">Minute&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="nx">job&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">StartedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">startedAt&lt;/span>&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 class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;idle&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &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="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">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;completed ignores idle threshold&amp;#34;&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="nx">now&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">startedAt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">30&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">Minute&lt;/span>&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 class="nx">job&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="nx">StartedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">startedAt&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="nx">FinishedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nf">ptrTime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">startedAt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2&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">Minute&lt;/span>&lt;span class="p">)),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;completed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">33&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">tests&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">34&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">35&lt;/span>&lt;span class="cl"> &lt;span class="nx">got&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">Status&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">job&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">36&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">want&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">37&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Status() = %q, want %q&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">want&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">38&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">39&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">40&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">41&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>time.Sleep&lt;/code>。案例名稱直接描述時間邊界，失敗時能快速定位是哪個規則壞了。&lt;/p>
&lt;h2 id="策略長生命週期元件用-time-provider">【策略】長生命週期元件用 time provider&lt;/h2>
&lt;p>Time provider 的核心用途是讓元件在多個方法中取得時間，但測試仍能控制時間來源。最輕量的形式是 &lt;code>func() time.Time&lt;/code>。&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">Monitor&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">now&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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="nf">NewMonitor&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">Monitor&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">if&lt;/span> &lt;span class="nx">now&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">now&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Now&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 class="k">return&lt;/span> &lt;span class="nx">Monitor&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">now&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">m&lt;/span> &lt;span class="nx">Monitor&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Snapshot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">string&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="nf">Status&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">m&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">now&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">job&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;/code>&lt;/pre>&lt;/div>&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">TestMonitorSnapshot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">fixedNow&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Date&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2026&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">22&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UTC&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">monitor&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewMonitor&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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="nx">fixedNow&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">got&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">monitor&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Snapshot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">Job&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="nx">StartedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">fixedNow&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">-&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">Minute&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="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">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s">&amp;#34;idle&amp;#34;&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">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;snapshot = %q, want idle&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&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>這比導入大型 clock framework 更輕量，也比在測試裡等待真實時間更可靠。若整個專案有大量時間需求，再考慮統一 clock interface。&lt;/p></description><content:encoded><![CDATA[<p>時間控制測試的核心原則是把「現在」變成可指定輸入。只要程式邏輯依賴目前時間、<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a>、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>、ticker 或過期判斷，測試就不應依賴真實等待。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>用 <code>now time.Time</code> 測試純狀態轉移</li>
<li>用 <code>func() time.Time</code> 注入長生命週期元件的時間來源</li>
<li>用 table-driven test 覆蓋時間邊界</li>
<li>把 ticker 排程與單次工作拆開測</li>
<li>避免 <code>time.Sleep</code> 造成慢且不穩定的測試</li>
</ol>
<hr>
<h2 id="觀察直接呼叫-timenow-會讓測試失去控制">【觀察】直接呼叫 time.Now 會讓測試失去控制</h2>
<p>時間相關邏輯的核心問題是同一筆資料在不同時間會得到不同結果。若函式內部直接呼叫 <code>time.Now()</code>，測試就無法完整控制輸入。</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">Status</span><span class="p">(</span><span class="nx">job</span> <span class="nx">Job</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="k">if</span> <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;completed&#34;</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">time</span><span class="p">.</span><span class="nf">Since</span><span class="p">(</span><span class="nx">job</span><span class="p">.</span><span class="nx">StartedAt</span><span class="p">)</span> <span class="p">&gt;</span> <span class="mi">5</span><span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Minute</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="s">&#34;idle&#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 class="k">return</span> <span class="s">&#34;active&#34;</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個函式看起來簡單，但測試無法指定「現在剛好是開始後 4 分鐘」或「現在剛好跨過 5 分鐘」。測試只能依賴真實時間，結果慢且不穩定。</p>
<h2 id="判讀時間是狀態轉移的輸入">【判讀】時間是狀態轉移的輸入</h2>
<p>時間測試的核心判讀是：如果時間會影響結果，時間就是輸入。把 <code>now</code> 放進函式簽名，會讓狀態轉移規則變得可測。</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">Job</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">StartedAt</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">FinishedAt</span> <span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</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="kd">func</span> <span class="nf">Status</span><span class="p">(</span><span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">,</span> <span class="nx">job</span> <span class="nx">Job</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="o">!=</span> <span class="kc">nil</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="s">&#34;completed&#34;</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="nx">now</span><span class="p">.</span><span class="nf">Sub</span><span class="p">(</span><span class="nx">job</span><span class="p">.</span><span class="nx">StartedAt</span><span class="p">)</span> <span class="p">&gt;</span> <span class="mi">5</span><span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Minute</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="s">&#34;idle&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">return</span> <span class="s">&#34;active&#34;</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>now</code> 是明確輸入，因此測試可以建立任何時間點。這也讓讀者一眼看出 <code>Status</code> 看的是 <code>Job</code> 與目前時間的關係。</p>
<h2 id="執行用-table-driven-test-描述時間邊界">【執行】用 table-driven test 描述時間邊界</h2>
<p>時間邊界的核心測試方式是列出切換點前後的案例。狀態通常在某個 duration 前後改變，table-driven test 能讓這些情境集中呈現。</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">TestStatus</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">startedAt</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</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">tests</span> <span class="o">:=</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 class="nx">name</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">now</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">job</span>  <span class="nx">Job</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">want</span> <span class="kt">string</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="nx">name</span><span class="p">:</span> <span class="s">&#34;active before idle threshold&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="nx">now</span><span class="p">:</span>  <span class="nx">startedAt</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">4</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="nx">job</span><span class="p">:</span>  <span class="nx">Job</span><span class="p">{</span><span class="nx">StartedAt</span><span class="p">:</span> <span class="nx">startedAt</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;active&#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="nx">name</span><span class="p">:</span> <span class="s">&#34;idle after threshold&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="nx">now</span><span class="p">:</span>  <span class="nx">startedAt</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">6</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="nx">job</span><span class="p">:</span>  <span class="nx">Job</span><span class="p">{</span><span class="nx">StartedAt</span><span class="p">:</span> <span class="nx">startedAt</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;idle&#34;</span><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="nx">name</span><span class="p">:</span> <span class="s">&#34;completed ignores idle threshold&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">            <span class="nx">now</span><span class="p">:</span>  <span class="nx">startedAt</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">30</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">            <span class="nx">job</span><span class="p">:</span> <span class="nx">Job</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">                <span class="nx">StartedAt</span><span class="p">:</span>  <span class="nx">startedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">                <span class="nx">FinishedAt</span><span class="p">:</span> <span class="nf">ptrTime</span><span class="p">(</span><span class="nx">startedAt</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)),</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">            <span class="p">},</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">            <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;completed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">
</span></span><span class="line"><span class="ln">33</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</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">35</span><span class="cl">            <span class="nx">got</span> <span class="o">:=</span> <span class="nf">Status</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">now</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">job</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">            <span class="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">37</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;Status() = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試不需要 <code>time.Sleep</code>。案例名稱直接描述時間邊界，失敗時能快速定位是哪個規則壞了。</p>
<h2 id="策略長生命週期元件用-time-provider">【策略】長生命週期元件用 time provider</h2>
<p>Time provider 的核心用途是讓元件在多個方法中取得時間，但測試仍能控制時間來源。最輕量的形式是 <code>func() time.Time</code>。</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">Monitor</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">now</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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="nf">NewMonitor</span><span class="p">(</span><span class="nx">now</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="nx">Monitor</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="nx">now</span> <span class="o">==</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">now</span> <span class="p">=</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Now</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">return</span> <span class="nx">Monitor</span><span class="p">{</span><span class="nx">now</span><span class="p">:</span> <span class="nx">now</span><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></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">m</span> <span class="nx">Monitor</span><span class="p">)</span> <span class="nf">Snapshot</span><span class="p">(</span><span class="nx">job</span> <span class="nx">Job</span><span class="p">)</span> <span class="kt">string</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="nf">Status</span><span class="p">(</span><span class="nx">m</span><span class="p">.</span><span class="nf">now</span><span class="p">(),</span> <span class="nx">job</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><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">TestMonitorSnapshot</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">fixedNow</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">monitor</span> <span class="o">:=</span> <span class="nf">NewMonitor</span><span class="p">(</span><span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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="nx">fixedNow</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="nx">got</span> <span class="o">:=</span> <span class="nx">monitor</span><span class="p">.</span><span class="nf">Snapshot</span><span class="p">(</span><span class="nx">Job</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">StartedAt</span><span class="p">:</span> <span class="nx">fixedNow</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="o">-</span><span class="mi">10</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</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">got</span> <span class="o">!=</span> <span class="s">&#34;idle&#34;</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;snapshot = %q, want idle&#34;</span><span class="p">,</span> <span class="nx">got</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>這比導入大型 clock framework 更輕量，也比在測試裡等待真實時間更可靠。若整個專案有大量時間需求，再考慮統一 clock interface。</p>
<h2 id="判讀ticker-測試要拆排程與工作">【判讀】Ticker 測試要拆排程與工作</h2>
<p>Ticker 的核心問題是它同時包含「何時觸發」與「觸發時做什麼」。測試時應把單次工作抽出來，避免為了測狀態規則而等待 ticker。</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">Worker</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">syncOnce</span> <span class="kd">func</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nx">Context</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">w</span> <span class="nx">Worker</span><span class="p">)</span> <span class="nf">Run</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">interval</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</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">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">interval</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</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"> 8</span><span class="cl">
</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="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</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">ctx</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">13</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">14</span><span class="cl">            <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">w</span><span class="p">.</span><span class="nf">SyncOnce</span><span class="p">(</span><span class="nx">ctx</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">15</span><span class="cl">                <span class="k">return</span> <span class="nx">err</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><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><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="kd">func</span> <span class="p">(</span><span class="nx">w</span> <span class="nx">Worker</span><span class="p">)</span> <span class="nf">SyncOnce</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="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">return</span> <span class="nx">w</span><span class="p">.</span><span class="nf">syncOnce</span><span class="p">(</span><span class="nx">ctx</span><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><code>SyncOnce</code> 可以單獨測規則，<code>Run</code> 只需要少數測試確認 context 取消與 ticker 排程。不要讓每個狀態測試都真的啟動 ticker。</p>
<h2 id="測試run-測試應用-context-控制退出">【測試】Run 測試應用 context 控制退出</h2>
<p>長生命週期 worker 的測試核心是讓退出條件可控。若只想測 context 取消，先取消 context 再呼叫 <code>Run</code>。</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">TestRunStopsWhenContextCanceled</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">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithCancel</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nf">cancel</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">worker</span> <span class="o">:=</span> <span class="nx">Worker</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">syncOnce</span><span class="p">:</span> <span class="kd">func</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</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;syncOnce should not be called&#34;</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="kc">nil</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></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">err</span> <span class="o">:=</span> <span class="nx">worker</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Hour</span><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">errors</span><span class="p">.</span><span class="nf">Is</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Canceled</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</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;Run() error = %v, want context canceled&#34;</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></code></pre></div><p>這個測試不需要等待一小時。<code>time.Hour</code> 只是確保 ticker 不會在測試中自然觸發，真正的退出由 context 控制。</p>
<h2 id="判讀sleep-based-test-應該是例外">【判讀】sleep-based test 應該是例外</h2>
<p>Sleep-based test 的核心問題是慢、不穩定、難以定位。排程、CI 負載與機器速度都可能讓測試偶發失敗。</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">TestStatusWithSleep</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">start</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">time</span><span class="p">.</span><span class="nf">Sleep</span><span class="p">(</span><span class="mi">6</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">got</span> <span class="o">:=</span> <span class="nf">Status</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="nx">Job</span><span class="p">{</span><span class="nx">StartedAt</span><span class="p">:</span> <span class="nx">start</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">got</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種測試不應存在。它拖慢測試套件，仍然不能保證結果穩定。正確做法是直接建構 <code>now</code> 與 <code>StartedAt</code>。</p>
<p>若真的要等待非同步事件，應使用 deadline 與條件重試，而不是固定 sleep；下一章的 integration test 會使用這個原則。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理時間作為輸入的可測性；更完整的 fake clock 與平台 timeout 合約，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">Go 進階：heartbeat、deadline 與連線清理</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 defer、select loop 與 timeout 邊界；如果你要先回看語言教材，可以讀：</p>
<ul>
<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/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</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/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Go 進階：graceful shutdown 與 signal handling</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Go 進階：Kubernetes、systemd 與 load balancer 合約</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>時間控制測試的重點是把時間變成可指定輸入。純邏輯用 <code>now time.Time</code>，長生命週期元件用 <code>func() time.Time</code>，ticker 排程和單次工作分開測。避免 <code>time.Sleep</code>，測試才會快速、穩定且可重現。</p>
]]></content:encoded></item><item><title>5.1 錯誤回傳與早期返回</title><link>https://tarrragon.github.io/blog/go/05-error-testing/errors/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/05-error-testing/errors/</guid><description>&lt;p>Go 把錯誤當成回傳值。這讓失敗路徑直接出現在程式碼裡，也讓呼叫者必須明確決定如何處理失敗。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解 &lt;code>error&lt;/code> 回傳值的設計目的&lt;/li>
&lt;li>用 early return 保持控制流程扁平&lt;/li>
&lt;li>為錯誤加上足夠脈絡&lt;/li>
&lt;li>在 HTTP handler 中對應不同錯誤情境&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察go-錯誤處理很顯式">【觀察】Go 錯誤處理很顯式&lt;/h2>
&lt;p>Go 錯誤處理的核心規則是：可能失敗的函式用 &lt;code>error&lt;/code> 回傳失敗，呼叫者在呼叫點立即處理。常見寫法如下：&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="nx">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ReadFile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;config.json&amp;#34;&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">if&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">3&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&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;/code>&lt;/pre>&lt;/div>&lt;p>這段程式很直接：讀檔可能失敗，失敗就回傳錯誤。&lt;/p>
&lt;p>對剛接觸 Go 的人來說，&lt;code>if err != nil&lt;/code> 可能看起來重複。但這個重複有明確目的：失敗路徑不被隱藏，讀者可以逐步看見每個操作失敗時會發生什麼事。&lt;/p>
&lt;h2 id="判讀錯誤是控制流程的一部分">【判讀】錯誤是控制流程的一部分&lt;/h2>
&lt;p>Go 的錯誤模型把失敗視為控制流程的一部分。很多語言用 exception 讓錯誤跳出目前流程；Go 則偏好讓錯誤留在函式簽名和呼叫點：&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">LoadConfig&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">path&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">Config&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ReadFile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">path&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">if&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"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">Config&lt;/span>&lt;span class="p">{},&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;read config: %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"> 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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">cfg&lt;/span> &lt;span class="nx">Config&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">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">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">cfg&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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">Config&lt;/span>&lt;span class="p">{},&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;parse config: %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">10&lt;/span>&lt;span class="cl"> &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>&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="nf">validateConfig&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">cfg&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">Config&lt;/span>&lt;span class="p">{},&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;validate config: %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="k">return&lt;/span> &lt;span class="nx">cfg&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&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;/p>
&lt;ul>
&lt;li>&lt;code>read config&lt;/code>&lt;/li>
&lt;li>&lt;code>parse config&lt;/code>&lt;/li>
&lt;li>&lt;code>validate config&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>當錯誤出現在 &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="策略用-early-return-避免巢狀">【策略】用 early return 避免巢狀&lt;/h2>
&lt;p>early return 的核心規則是：失敗路徑就地返回，成功路徑保持在左側。不要把成功路徑包在很多層 &lt;code>else&lt;/code> 裡：&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="c1">// 不佳：成功路徑被包在巢狀中&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">Load&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">path&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">Config&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ReadFile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">path&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">if&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"> 5&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">cfg&lt;/span> &lt;span class="nx">Config&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">err&lt;/span> &lt;span class="p">=&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">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">cfg&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">if&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"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">cfg&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span> &lt;span class="k">else&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">Config&lt;/span>&lt;span class="p">{},&lt;/span> &lt;span class="nx">err&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &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 class="k">else&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">Config&lt;/span>&lt;span class="p">{},&lt;/span> &lt;span class="nx">err&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 class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Go 更常用 early return：&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">Load&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">path&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">Config&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ReadFile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">path&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">if&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"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">Config&lt;/span>&lt;span class="p">{},&lt;/span> &lt;span class="nx">err&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">cfg&lt;/span> &lt;span class="nx">Config&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">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">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">cfg&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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">Config&lt;/span>&lt;span class="p">{},&lt;/span> &lt;span class="nx">err&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &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>&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">cfg&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&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>成功路徑保持在左側，失敗路徑就地處理。這是 Go 可讀性的重要風格。&lt;/p>
&lt;h2 id="執行http-handler-中的錯誤路徑">【執行】HTTP handler 中的錯誤路徑&lt;/h2>
&lt;p>邊界層錯誤處理的核心規則是：內部錯誤要轉成呼叫者能理解的回應。HTTP handler 要把 Go 的錯誤轉成 HTTP response：&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">handleCreateUser&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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="kd">var&lt;/span> &lt;span class="nx">req&lt;/span> &lt;span class="nx">CreateUserRequest&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">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">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&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"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeJSONError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid JSON&amp;#34;&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>&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">if&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Name&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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeJSONError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;name is required&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">user&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">createUser&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">req&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">if&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">15&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeJSONError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusInternalServerError&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;create user failed&amp;#34;&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="k">return&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;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeJSON&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusCreated&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span>&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 class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式的層次很清楚：&lt;/p>
&lt;ol>
&lt;li>JSON 格式錯誤 → 400&lt;/li>
&lt;li>欄位驗證錯誤 → 400&lt;/li>
&lt;li>內部建立失敗 → 500&lt;/li>
&lt;li>成功 → 201&lt;/li>
&lt;/ol>
&lt;p>每個錯誤路徑都結束於 &lt;code>return&lt;/code>，後面的成功流程不需要被 &lt;code>else&lt;/code> 包住。&lt;/p></description><content:encoded><![CDATA[<p>Go 把錯誤當成回傳值。這讓失敗路徑直接出現在程式碼裡，也讓呼叫者必須明確決定如何處理失敗。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解 <code>error</code> 回傳值的設計目的</li>
<li>用 early return 保持控制流程扁平</li>
<li>為錯誤加上足夠脈絡</li>
<li>在 HTTP handler 中對應不同錯誤情境</li>
</ol>
<hr>
<h2 id="觀察go-錯誤處理很顯式">【觀察】Go 錯誤處理很顯式</h2>
<p>Go 錯誤處理的核心規則是：可能失敗的函式用 <code>error</code> 回傳失敗，呼叫者在呼叫點立即處理。常見寫法如下：</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="nx">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">ReadFile</span><span class="p">(</span><span class="s">&#34;config.json&#34;</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">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式很直接：讀檔可能失敗，失敗就回傳錯誤。</p>
<p>對剛接觸 Go 的人來說，<code>if err != nil</code> 可能看起來重複。但這個重複有明確目的：失敗路徑不被隱藏，讀者可以逐步看見每個操作失敗時會發生什麼事。</p>
<h2 id="判讀錯誤是控制流程的一部分">【判讀】錯誤是控制流程的一部分</h2>
<p>Go 的錯誤模型把失敗視為控制流程的一部分。很多語言用 exception 讓錯誤跳出目前流程；Go 則偏好讓錯誤留在函式簽名和呼叫點：</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">LoadConfig</span><span class="p">(</span><span class="nx">path</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Config</span><span class="p">,</span> <span class="kt">error</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">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">ReadFile</span><span class="p">(</span><span class="nx">path</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="nx">err</span> <span class="o">!=</span> <span class="kc">nil</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="nx">Config</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;read config: %w&#34;</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="kd">var</span> <span class="nx">cfg</span> <span class="nx">Config</span>
</span></span><span class="line"><span class="ln"> 8</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">data</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">cfg</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"> 9</span><span class="cl">        <span class="k">return</span> <span class="nx">Config</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;parse config: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><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></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="nf">validateConfig</span><span class="p">(</span><span class="nx">cfg</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">Config</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;validate config: %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="k">return</span> <span class="nx">cfg</span><span class="p">,</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>錯誤脈絡的核心規則是：越靠近失敗來源，越應補上「正在做什麼」的資訊。這裡每個錯誤都被加上脈絡：</p>
<ul>
<li><code>read config</code></li>
<li><code>parse config</code></li>
<li><code>validate config</code></li>
</ul>
<p>當錯誤出現在 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 裡時，讀者不只知道失敗了，也知道失敗在哪個階段。</p>
<h2 id="策略用-early-return-避免巢狀">【策略】用 early return 避免巢狀</h2>
<p>early return 的核心規則是：失敗路徑就地返回，成功路徑保持在左側。不要把成功路徑包在很多層 <code>else</code> 裡：</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="c1">// 不佳：成功路徑被包在巢狀中</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">Load</span><span class="p">(</span><span class="nx">path</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Config</span><span class="p">,</span> <span class="kt">error</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">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">ReadFile</span><span class="p">(</span><span class="nx">path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</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"> 5</span><span class="cl">        <span class="kd">var</span> <span class="nx">cfg</span> <span class="nx">Config</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">err</span> <span class="p">=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">Unmarshal</span><span class="p">(</span><span class="nx">data</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">cfg</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</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"> 8</span><span class="cl">            <span class="k">return</span> <span class="nx">cfg</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="p">}</span> <span class="k">else</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">Config</span><span class="p">{},</span> <span class="nx">err</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 class="k">else</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">Config</span><span class="p">{},</span> <span class="nx">err</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="p">}</span></span></span></code></pre></div><p>Go 更常用 early return：</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">Load</span><span class="p">(</span><span class="nx">path</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Config</span><span class="p">,</span> <span class="kt">error</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">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">ReadFile</span><span class="p">(</span><span class="nx">path</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="nx">err</span> <span class="o">!=</span> <span class="kc">nil</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="nx">Config</span><span class="p">{},</span> <span class="nx">err</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">var</span> <span class="nx">cfg</span> <span class="nx">Config</span>
</span></span><span class="line"><span class="ln"> 8</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">data</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">cfg</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"> 9</span><span class="cl">        <span class="k">return</span> <span class="nx">Config</span><span class="p">{},</span> <span class="nx">err</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></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">return</span> <span class="nx">cfg</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>成功路徑保持在左側，失敗路徑就地處理。這是 Go 可讀性的重要風格。</p>
<h2 id="執行http-handler-中的錯誤路徑">【執行】HTTP handler 中的錯誤路徑</h2>
<p>邊界層錯誤處理的核心規則是：內部錯誤要轉成呼叫者能理解的回應。HTTP handler 要把 Go 的錯誤轉成 HTTP response：</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">handleCreateUser</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="kd">var</span> <span class="nx">req</span> <span class="nx">CreateUserRequest</span>
</span></span><span class="line"><span class="ln"> 3</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">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</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"> 4</span><span class="cl">        <span class="nf">writeJSONError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid JSON&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">return</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">if</span> <span class="nx">req</span><span class="p">.</span><span class="nx">Name</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nf">writeJSONError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;name is required&#34;</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></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">user</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">createUser</span><span class="p">(</span><span class="nx">req</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</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">15</span><span class="cl">        <span class="nf">writeJSONError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusInternalServerError</span><span class="p">,</span> <span class="s">&#34;create user failed&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="k">return</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></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nf">writeJSON</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</span><span class="p">,</span> <span class="nx">user</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式的層次很清楚：</p>
<ol>
<li>JSON 格式錯誤 → 400</li>
<li>欄位驗證錯誤 → 400</li>
<li>內部建立失敗 → 500</li>
<li>成功 → 201</li>
</ol>
<p>每個錯誤路徑都結束於 <code>return</code>，後面的成功流程不需要被 <code>else</code> 包住。</p>
<h2 id="錯誤訊息要包含脈絡">錯誤訊息要包含脈絡</h2>
<p>錯誤訊息分層的核心規則是：內部 error 保留診斷脈絡，對外 response 保持穩定且不洩漏內部細節。底層函式應該保留技術脈絡：</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="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;insert user %q: %w&#34;</span><span class="p">,</span> <span class="nx">req</span><span class="p">.</span><span class="nx">Name</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span></span></span></code></pre></div><p>但對外 response 不一定要暴露內部錯誤：</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="nf">writeJSONError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusInternalServerError</span><span class="p">,</span> <span class="s">&#34;create user failed&#34;</span><span class="p">)</span></span></span></code></pre></div><p>這是兩層不同需求：</p>
<table>
  <thead>
      <tr>
          <th>層次</th>
          <th>目標</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>內部 error</td>
          <td>幫工程師定位問題</td>
      </tr>
      <tr>
          <td>對外 response</td>
          <td>給呼叫者穩定、可理解的訊息</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>6.1 graceful shutdown 與 signal handling</title><link>https://tarrragon.github.io/blog/go-advanced/06-production-operations/graceful-shutdown/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/06-production-operations/graceful-shutdown/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Graceful shutdown&lt;/a> 的核心目標是服務收到停止訊號後，不再接受新工作，並給既有工作一段時間完成或清理。Go 服務通常用 signal、root context、&lt;code>http.Server.Shutdown&lt;/code>、worker context 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a> 串起停止流程。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>把 OS signal 轉成 root context 取消&lt;/li>
&lt;li>用 &lt;code>http.Server.Shutdown&lt;/code> 停止接受新 request&lt;/li>
&lt;li>讓 worker、hub、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> pump 觀察同一個停止訊號&lt;/li>
&lt;li>設計 shutdown timeout 與強制退出邊界&lt;/li>
&lt;li>測試 server 與 worker 的停止流程&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察直接結束-process-會留下不確定狀態">【觀察】直接結束 process 會留下不確定狀態&lt;/h2>
&lt;p>Shutdown 的核心風險是停止流程不明確。服務可能正在處理 request、WebSocket client 仍在線、worker 正在寫資料、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> message 尚未 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack&lt;/a>、diagnostics 還以為服務可接流量。&lt;/p>
&lt;p>不完整停止常見後果：&lt;/p>
&lt;ul>
&lt;li>新 request 在服務即將關閉時仍被接受。&lt;/li>
&lt;li>WebSocket client 沒收到 close，server 端 goroutine 殘留。&lt;/li>
&lt;li>背景 worker 寫到一半被中斷。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness&lt;/a> 還是 200，負載平衡器繼續送流量。&lt;/li>
&lt;li>測試結束後留下 goroutine 或開放 port。&lt;/li>
&lt;/ul>
&lt;p>Graceful shutdown 是讓停止策略可預期。&lt;/p>
&lt;h2 id="判讀shutdown-是多階段流程">【判讀】shutdown 是多階段流程&lt;/h2>
&lt;p>Graceful shutdown 的核心流程是先停止接新工作，再讓既有工作收尾，最後釋放資源。&lt;/p>
&lt;p>建議順序：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">receive SIGINT/SIGTERM
&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">cancel root context
&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"> ├── readiness becomes false
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> ├── HTTP server stops accepting new requests
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> ├── workers stop consuming new jobs
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> ├── WebSocket hub unregisters clients
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> └── diagnostics/log records shutdown reason
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">wait within timeout
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> │
&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">process exits&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>不同服務會有不同細節，但核心不變：停止訊號要集中，元件各自完成自己的 cleanup，整體流程要有 timeout。&lt;/p>
&lt;h2 id="執行signal-轉成-root-context">【執行】signal 轉成 root context&lt;/h2>
&lt;p>Signal handling 的核心責任是把作業系統訊號轉成應用程式可理解的取消訊號。Go 1.16 之後可以使用 &lt;code>signal.NotifyContext&lt;/code>。&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">main&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">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">stop&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">signal&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NotifyContext&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Background&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Interrupt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">syscall&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SIGTERM&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">defer&lt;/span> &lt;span class="nf">stop&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>&lt;/span>&lt;span class="line">&lt;span class="ln">5&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="nf">run&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">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">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">log&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatal&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">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>&lt;code>ctx&lt;/code> 是 root context。HTTP server、worker、hub、diagnostics 都應從它派生出自己的 lifecycle，而不是每個元件各自監聽 signal。&lt;/p>
&lt;p>Signal handler 不應放大量清理邏輯。它只負責發出停止意圖；實際清理由各元件在自己的 ownership 邊界內完成。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Graceful shutdown</a> 的核心目標是服務收到停止訊號後，不再接受新工作，並給既有工作一段時間完成或清理。Go 服務通常用 signal、root context、<code>http.Server.Shutdown</code>、worker context 與 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 串起停止流程。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>把 OS signal 轉成 root context 取消</li>
<li>用 <code>http.Server.Shutdown</code> 停止接受新 request</li>
<li>讓 worker、hub、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> pump 觀察同一個停止訊號</li>
<li>設計 shutdown timeout 與強制退出邊界</li>
<li>測試 server 與 worker 的停止流程</li>
</ol>
<hr>
<h2 id="觀察直接結束-process-會留下不確定狀態">【觀察】直接結束 process 會留下不確定狀態</h2>
<p>Shutdown 的核心風險是停止流程不明確。服務可能正在處理 request、WebSocket client 仍在線、worker 正在寫資料、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> message 尚未 <a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack</a>、diagnostics 還以為服務可接流量。</p>
<p>不完整停止常見後果：</p>
<ul>
<li>新 request 在服務即將關閉時仍被接受。</li>
<li>WebSocket client 沒收到 close，server 端 goroutine 殘留。</li>
<li>背景 worker 寫到一半被中斷。</li>
<li><a href="/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness</a> 還是 200，負載平衡器繼續送流量。</li>
<li>測試結束後留下 goroutine 或開放 port。</li>
</ul>
<p>Graceful shutdown 是讓停止策略可預期。</p>
<h2 id="判讀shutdown-是多階段流程">【判讀】shutdown 是多階段流程</h2>
<p>Graceful shutdown 的核心流程是先停止接新工作，再讓既有工作收尾，最後釋放資源。</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">receive SIGINT/SIGTERM
</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">cancel root context
</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">        ├── readiness becomes false
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        ├── HTTP server stops accepting new requests
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        ├── workers stop consuming new jobs
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        ├── WebSocket hub unregisters clients
</span></span><span class="line"><span class="ln">10</span><span class="cl">        └── diagnostics/log records shutdown reason
</span></span><span class="line"><span class="ln">11</span><span class="cl">        │
</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">wait within timeout
</span></span><span class="line"><span class="ln">14</span><span class="cl">        │
</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">process exits</span></span></code></pre></div><p>不同服務會有不同細節，但核心不變：停止訊號要集中，元件各自完成自己的 cleanup，整體流程要有 timeout。</p>
<h2 id="執行signal-轉成-root-context">【執行】signal 轉成 root context</h2>
<p>Signal handling 的核心責任是把作業系統訊號轉成應用程式可理解的取消訊號。Go 1.16 之後可以使用 <code>signal.NotifyContext</code>。</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">main</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">ctx</span><span class="p">,</span> <span class="nx">stop</span> <span class="o">:=</span> <span class="nx">signal</span><span class="p">.</span><span class="nf">NotifyContext</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">os</span><span class="p">.</span><span class="nx">Interrupt</span><span class="p">,</span> <span class="nx">syscall</span><span class="p">.</span><span class="nx">SIGTERM</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="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">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">run</span><span class="p">(</span><span class="nx">ctx</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">6</span><span class="cl">        <span class="nx">log</span><span class="p">.</span><span class="nf">Fatal</span><span class="p">(</span><span class="nx">err</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><code>ctx</code> 是 root context。HTTP server、worker、hub、diagnostics 都應從它派生出自己的 lifecycle，而不是每個元件各自監聽 signal。</p>
<p>Signal handler 不應放大量清理邏輯。它只負責發出停止意圖；實際清理由各元件在自己的 ownership 邊界內完成。</p>
<h2 id="執行http-server-用-shutdown-停止接新-request">【執行】HTTP server 用 Shutdown 停止接新 request</h2>
<p><code>http.Server.Shutdown</code> 的核心行為是停止接受新連線，並等待既有 request 在 timeout 內完成。它比直接 <code>Close</code> 更適合 graceful shutdown。</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">RunHTTPServer</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">handler</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">server</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">http</span><span class="p">.</span><span class="nx">Server</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">Addr</span><span class="p">:</span>    <span class="s">&#34;:8080&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">Handler</span><span class="p">:</span> <span class="nx">handler</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></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">errCh</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="kt">error</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</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"> 9</span><span class="cl">        <span class="nx">errCh</span> <span class="o">&lt;-</span> <span class="nx">server</span><span class="p">.</span><span class="nf">ListenAndServe</span><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></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">shutdownCtx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithTimeout</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="mi">10</span><span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">defer</span> <span class="nf">cancel</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="nx">server</span><span class="p">.</span><span class="nf">Shutdown</span><span class="p">(</span><span class="nx">shutdownCtx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">case</span> <span class="nx">err</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">errCh</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">errors</span><span class="p">.</span><span class="nf">Is</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ErrServerClosed</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="k">return</span> <span class="kc">nil</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="k">return</span> <span class="nx">err</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>Shutdown timeout 是必要邊界。沒有 timeout 的 shutdown 可能永遠等待某個卡住 request；timeout 太短則可能讓合理 request 來不及收尾。</p>
<h2 id="策略readiness-應先變成-false">【策略】readiness 應先變成 false</h2>
<p>Readiness 的核心用途是控制服務是否應接新流量。Shutdown 開始後，readiness 應先變成 false，再停止 server 或等待既有工作。</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">Lifecycle</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">shuttingDown</span> <span class="nx">atomic</span><span class="p">.</span><span class="nx">Bool</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">l</span> <span class="o">*</span><span class="nx">Lifecycle</span><span class="p">)</span> <span class="nf">BeginShutdown</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">l</span><span class="p">.</span><span class="nx">shuttingDown</span><span class="p">.</span><span class="nf">Store</span><span class="p">(</span><span class="kc">true</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></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">l</span> <span class="o">*</span><span class="nx">Lifecycle</span><span class="p">)</span> <span class="nf">Ready</span><span class="p">()</span> <span class="kt">bool</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="p">!</span><span class="nx">l</span><span class="p">.</span><span class="nx">shuttingDown</span><span class="p">.</span><span class="nf">Load</span><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>Signal 收到後：</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="nx">lifecycle</span><span class="p">.</span><span class="nf">BeginShutdown</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nf">cancel</span><span class="p">()</span></span></span></code></pre></div><p>這讓負載平衡器或監控能知道服務不應再接新流量。Process 還活著，但 readiness 已經反映操作狀態。</p>
<h2 id="執行背景工作要觀察-context">【執行】背景工作要觀察 context</h2>
<p>背景 worker 的核心 shutdown 條件是每個 loop 都能觀察停止訊號。Ticker、queue <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a>、WebSocket hub 都應該有退出路徑。</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">RunWorker</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="kt">error</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">time</span><span class="p">.</span><span class="nx">Minute</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="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</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">ctx</span><span class="p">.</span><span class="nf">Err</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">ticker</span><span class="p">.</span><span class="nx">C</span><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="nf">RunOnce</span><span class="p">(</span><span class="nx">ctx</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 class="nx">err</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><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="p">}</span></span></span></code></pre></div><p>若 <code>RunOnce</code> 可能執行很久，也應接收 context。否則外層 loop 看到 cancel，內層 I/O 或計算仍可能卡住。</p>
<h2 id="策略websocket-cleanup-要回到-hub-owner">【策略】WebSocket cleanup 要回到 hub owner</h2>
<p>WebSocket shutdown 的核心原則是讓 hub 或 connection manager 統一清理 client。不要讓 signal handler 直接遍歷各種 connection 並隨意 close。</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">Run</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="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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="nx">h</span><span class="p">.</span><span class="nf">closeAllClients</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="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"> 8</span><span class="cl">            <span class="nx">h</span><span class="p">.</span><span class="nf">registerClient</span><span class="p">(</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">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">10</span><span class="cl">            <span class="nx">h</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">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>closeAllClients</code> 應透過 hub 的既有 owner 邏輯關閉 <code>send</code>、移除訂閱、關閉 connection。這延續前面模組的 ownership 原則。</p>
<h2 id="測試shutdown-測試要觀察明確條件">【測試】shutdown 測試要觀察明確條件</h2>
<p>Shutdown 測試的核心是確認停止訊號能讓元件退出，而不是等待固定時間。</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">TestWorkerStopsOnContextCancel</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">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithCancel</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">done</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="kd">struct</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">go</span> <span class="kd">func</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="nb">close</span><span class="p">(</span><span class="nx">done</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">_</span> <span class="p">=</span> <span class="nf">RunWorker</span><span class="p">(</span><span class="nx">ctx</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="nf">cancel</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">done</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="o">&lt;-</span><span class="nx">time</span><span class="p">.</span><span class="nf">After</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</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;worker did not stop&#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>HTTP server 測試可以啟動 server 後 cancel context，確認 <code>RunHTTPServer</code> 回傳。測試應使用隨機 port 或 <code>httptest.Server</code>，避免固定 port 造成衝突。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理服務內部的 shutdown 順序與 cleanup owner；平台 hook、timeout 與 load balancer 合約，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Go 進階：Kubernetes、systemd 與 load balancer 合約</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 goroutine lifecycle、ticker cleanup 與 platform handoff；如果你要先回看語言教材，可以讀：</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/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/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件</a></li>
<li><a href="/blog/go-advanced/03-runtime-profiling/goroutine-leak/" data-link-title="3.3 goroutine leak 偵測" data-link-desc="判斷背景工作與 client pump 是否正確退出">Go：goroutine leak 偵測</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>Graceful shutdown 是多階段流程：signal 轉成 root context，readiness 先關閉，HTTP server 停止接新 request，worker 和 WebSocket hub 觀察 context 收尾，整體流程受 timeout 保護。停止訊號越集中，元件 ownership 越清楚，服務在部署、測試與本機開發時越不容易留下殘存 goroutine 或未釋放連線。</p>
]]></content:encoded></item><item><title>6.1 如何新增一個即時訊息 action</title><link>https://tarrragon.github.io/blog/go/06-practical/new-websocket-action/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/06-practical/new-websocket-action/</guid><description>&lt;p>新增即時訊息 action 的核心流程是先定義 client 意圖，再把 action 轉成 application command。&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> handler 負責傳輸邊界，domain state 的修改交給 usecase 或 processor。本章用一個簡化的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> subscription action 示範完整路徑。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>用 action type 表達 client intent&lt;/li>
&lt;li>用 request struct 定義 JSON payload 邊界&lt;/li>
&lt;li>把 WebSocket message 轉成 application command&lt;/li>
&lt;li>設計穩定的 response 與 error 格式&lt;/li>
&lt;li>把 router、usecase 與 WebSocket integration test 分層測試&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察action-表達-client-intent">【觀察】action 表達 client intent&lt;/h2>
&lt;p>action 的核心語意是 client 想要系統做什麼。它是 client 和 server 之間的訊息合約，命名應描述行為意圖，而不是 UI 按鈕或 handler 函式名稱。&lt;/p>
&lt;p>例如即時通知服務可能有三種 action：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>action&lt;/th>
 &lt;th>client 意圖&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>subscribe_topic&lt;/code>&lt;/td>
 &lt;td>訂閱某個 topic 的即時通知&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>unsubscribe_topic&lt;/code>&lt;/td>
 &lt;td>取消某個 topic 的訂閱&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>get_snapshot&lt;/code>&lt;/td>
 &lt;td>取得目前狀態快照&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>用字串常數定義 action，可以避免 handler 到處散落 magic string：&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">ActionSubscribeTopic&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">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">ActionUnsubscribeTopic&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">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">ActionGetSnapshot&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;get_snapshot&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>action 名稱應該描述行為意圖。&lt;code>subscribe_topic&lt;/code> 比 &lt;code>ws_subscribe&lt;/code> 更穩定，因為未來同一個 usecase 也可能被 HTTP endpoint 或 background job 呼叫。&lt;/p>
&lt;h2 id="判讀外部訊息先進入-envelope">【判讀】外部訊息先進入 envelope&lt;/h2>
&lt;p>WebSocket message 的核心邊界是 envelope。client 傳來的 JSON 應該先被解析成一個共同外殼，再根據 action 解析 payload。&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">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">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;id&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="nx">Action&lt;/span> &lt;span class="kt">string&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">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">Payload&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;payload&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>&lt;code>ID&lt;/code> 是 client message ID，可用來讓 response 對應原始 request。&lt;code>Action&lt;/code> 決定路由方向。&lt;code>Payload&lt;/code> 使用 &lt;code>json.RawMessage&lt;/code>，讓 router 可以先看 action，再把 payload 解成對應 struct。&lt;/p>
&lt;p>例如 client 可以送出：&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;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;msg_1001&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;action&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;subscribe_topic&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;payload&amp;#34;&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">5&lt;/span>&lt;span class="cl"> &lt;span class="nt">&amp;#34;topic&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;deployments&amp;#34;&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="nt">&amp;#34;includeHistory&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&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>這種 envelope 設計讓新 action 可以共用同一套外層格式。新增 action 時，不需要改整個 WebSocket 讀取流程，只要新增 payload struct 與路由分支。&lt;/p>
&lt;h2 id="策略payload-struct-要表達資料語意">【策略】payload struct 要表達資料語意&lt;/h2>
&lt;p>payload struct 的核心責任是把外部 JSON 轉成明確的 Go 型別。必填欄位、可選欄位與相容性都應該在 struct 與驗證函式中清楚表達。&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="nx">IncludeHistory&lt;/span> &lt;span class="kt">bool&lt;/span> &lt;span class="s">`json:&amp;#34;includeHistory,omitempty&amp;#34;`&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>Topic&lt;/code> 是必填欄位，因為沒有 topic 就無法訂閱。&lt;code>IncludeHistory&lt;/code> 是可選欄位，零值 &lt;code>false&lt;/code> 可以代表「不要求歷史資料」。這裡使用 &lt;code>omitempty&lt;/code> 是在表達：輸出 response 或轉送資料時，這個欄位可以省略；它不是必填資料。&lt;/p>
&lt;p>驗證規則應該靠明確函式完成，讓 router 分支只負責呼叫驗證與轉換：&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">r&lt;/span> &lt;span class="nx">SubscribeTopicRequest&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Validate&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">2&lt;/span>&lt;span class="cl"> &lt;span class="k">if&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">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">)&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">3&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">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="k">return&lt;/span> &lt;span class="kc">nil&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;p>外部資料進入系統後，要先完成解碼與驗證，才轉成 application command。這可以避免 usecase 同時處理 JSON 格式、欄位缺漏與業務規則。&lt;/p></description><content:encoded><![CDATA[<p>新增即時訊息 action 的核心流程是先定義 client 意圖，再把 action 轉成 application command。<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> handler 負責傳輸邊界，domain state 的修改交給 usecase 或 processor。本章用一個簡化的 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> subscription action 示範完整路徑。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>用 action type 表達 client intent</li>
<li>用 request struct 定義 JSON payload 邊界</li>
<li>把 WebSocket message 轉成 application command</li>
<li>設計穩定的 response 與 error 格式</li>
<li>把 router、usecase 與 WebSocket integration test 分層測試</li>
</ol>
<hr>
<h2 id="觀察action-表達-client-intent">【觀察】action 表達 client intent</h2>
<p>action 的核心語意是 client 想要系統做什麼。它是 client 和 server 之間的訊息合約，命名應描述行為意圖，而不是 UI 按鈕或 handler 函式名稱。</p>
<p>例如即時通知服務可能有三種 action：</p>
<table>
  <thead>
      <tr>
          <th>action</th>
          <th>client 意圖</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>subscribe_topic</code></td>
          <td>訂閱某個 topic 的即時通知</td>
      </tr>
      <tr>
          <td><code>unsubscribe_topic</code></td>
          <td>取消某個 topic 的訂閱</td>
      </tr>
      <tr>
          <td><code>get_snapshot</code></td>
          <td>取得目前狀態快照</td>
      </tr>
  </tbody>
</table>
<p>用字串常數定義 action，可以避免 handler 到處散落 magic string：</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">ActionSubscribeTopic</span>   <span class="p">=</span> <span class="s">&#34;subscribe_topic&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">ActionUnsubscribeTopic</span> <span class="p">=</span> <span class="s">&#34;unsubscribe_topic&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">ActionGetSnapshot</span>      <span class="p">=</span> <span class="s">&#34;get_snapshot&#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>action 名稱應該描述行為意圖。<code>subscribe_topic</code> 比 <code>ws_subscribe</code> 更穩定，因為未來同一個 usecase 也可能被 HTTP endpoint 或 background job 呼叫。</p>
<h2 id="判讀外部訊息先進入-envelope">【判讀】外部訊息先進入 envelope</h2>
<p>WebSocket message 的核心邊界是 envelope。client 傳來的 JSON 應該先被解析成一個共同外殼，再根據 action 解析 payload。</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">ClientMessage</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 class="s">`json:&#34;id&#34;`</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Action</span>  <span class="kt">string</span>          <span class="s">`json:&#34;action&#34;`</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Payload</span> <span class="nx">json</span><span class="p">.</span><span class="nx">RawMessage</span> <span class="s">`json:&#34;payload&#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><code>ID</code> 是 client message ID，可用來讓 response 對應原始 request。<code>Action</code> 決定路由方向。<code>Payload</code> 使用 <code>json.RawMessage</code>，讓 router 可以先看 action，再把 payload 解成對應 struct。</p>
<p>例如 client 可以送出：</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;id&#34;</span><span class="p">:</span> <span class="s2">&#34;msg_1001&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;action&#34;</span><span class="p">:</span> <span class="s2">&#34;subscribe_topic&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;payload&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nt">&#34;topic&#34;</span><span class="p">:</span> <span class="s2">&#34;deployments&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nt">&#34;includeHistory&#34;</span><span class="p">:</span> <span class="kc">true</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>這種 envelope 設計讓新 action 可以共用同一套外層格式。新增 action 時，不需要改整個 WebSocket 讀取流程，只要新增 payload struct 與路由分支。</p>
<h2 id="策略payload-struct-要表達資料語意">【策略】payload struct 要表達資料語意</h2>
<p>payload struct 的核心責任是把外部 JSON 轉成明確的 Go 型別。必填欄位、可選欄位與相容性都應該在 struct 與驗證函式中清楚表達。</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="nx">IncludeHistory</span> <span class="kt">bool</span>   <span class="s">`json:&#34;includeHistory,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>Topic</code> 是必填欄位，因為沒有 topic 就無法訂閱。<code>IncludeHistory</code> 是可選欄位，零值 <code>false</code> 可以代表「不要求歷史資料」。這裡使用 <code>omitempty</code> 是在表達：輸出 response 或轉送資料時，這個欄位可以省略；它不是必填資料。</p>
<p>驗證規則應該靠明確函式完成，讓 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">func</span> <span class="p">(</span><span class="nx">r</span> <span class="nx">SubscribeTopicRequest</span><span class="p">)</span> <span class="nf">Validate</span><span class="p">()</span> <span class="kt">error</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">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</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">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">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>外部資料進入系統後，要先完成解碼與驗證，才轉成 application command。這可以避免 usecase 同時處理 JSON 格式、欄位缺漏與業務規則。</p>
<h2 id="執行router-只做解析驗證與轉換">【執行】router 只做解析、驗證與轉換</h2>
<p>message router 的核心責任是把 client message 轉成 application command。router 只處理傳輸邊界，狀態修改與訂閱規則交給 usecase。</p>
<p>先定義 usecase 需要的 command：</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">SubscribeTopicCommand</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">ClientID</span>       <span class="kt">string</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></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">IncludeHistory</span> <span class="kt">bool</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>command 是 application layer 的輸入模型，只描述 usecase 需要的資料。它不需要 JSON tag，因為外部傳輸格式已經停在 request struct。</p>
<p>接著定義 usecase 介面：</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">SubscriptionUsecase</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">SubscribeTopic</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">cmd</span> <span class="nx">SubscribeTopicCommand</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></code></pre></div><p>這個介面小而明確，只描述 router 目前需要的能力。不要一開始就建立大型 <code>Service</code> 介面，把所有 action 都塞進去。</p>
<p>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">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="nx">SubscriptionUsecase</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="nf">NewMessageRouter</span><span class="p">(</span><span class="nx">subscriptions</span> <span class="nx">SubscriptionUsecase</span><span class="p">)</span> <span class="o">*</span><span class="nx">MessageRouter</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="o">&amp;</span><span class="nx">MessageRouter</span><span class="p">{</span><span class="nx">subscriptions</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="p">}</span></span></span></code></pre></div><p>處理入口接收原始 JSON bytes，回傳可序列化的 response：</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">r</span> <span class="o">*</span><span class="nx">MessageRouter</span><span class="p">)</span> <span class="nf">Handle</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">clientID</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">data</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="nx">ServerMessage</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</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"> 3</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">data</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"> 4</span><span class="cl">        <span class="k">return</span> <span class="nf">ErrorMessage</span><span class="p">(</span><span class="s">&#34;&#34;</span><span class="p">,</span> <span class="s">&#34;invalid_json&#34;</span><span class="p">,</span> <span class="s">&#34;message must be valid JSON&#34;</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></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">switch</span> <span class="nx">msg</span><span class="p">.</span><span class="nx">Action</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</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"> 9</span><span class="cl">        <span class="k">return</span> <span class="nx">r</span><span class="p">.</span><span class="nf">handleSubscribeTopic</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">clientID</span><span class="p">,</span> <span class="nx">msg</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="k">return</span> <span class="nf">ErrorMessage</span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span> <span class="s">&#34;unknown_action&#34;</span><span class="p">,</span> <span class="s">&#34;action is not supported&#34;</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><code>Handle</code> 不知道 WebSocket connection 怎麼讀寫，也不處理網路錯誤。這讓 router 可以被普通單元測試覆蓋。</p>
<p><code>subscribe_topic</code> 的分支負責 payload 解碼、驗證與 command 建立：</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">r</span> <span class="o">*</span><span class="nx">MessageRouter</span><span class="p">)</span> <span class="nf">handleSubscribeTopic</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">clientID</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">msg</span> <span class="nx">ClientMessage</span><span class="p">)</span> <span class="nx">ServerMessage</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</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"> 3</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">msg</span><span class="p">.</span><span class="nx">Payload</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"> 4</span><span class="cl">        <span class="k">return</span> <span class="nf">ErrorMessage</span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span> <span class="s">&#34;invalid_payload&#34;</span><span class="p">,</span> <span class="s">&#34;payload must match subscribe_topic schema&#34;</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></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">req</span><span class="p">.</span><span class="nf">Validate</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"> 8</span><span class="cl">        <span class="k">return</span> <span class="nf">ErrorMessage</span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span> <span class="s">&#34;invalid_payload&#34;</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"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</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">12</span><span class="cl">        <span class="nx">ClientID</span><span class="p">:</span>       <span class="nx">clientID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">Topic</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">14</span><span class="cl">        <span class="nx">IncludeHistory</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">IncludeHistory</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></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">subscriptions</span><span class="p">.</span><span class="nf">SubscribeTopic</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">cmd</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">18</span><span class="cl">        <span class="k">return</span> <span class="nf">ErrorMessage</span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span> <span class="s">&#34;subscribe_failed&#34;</span><span class="p">,</span> <span class="s">&#34;topic subscription failed&#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="k">return</span> <span class="nf">OKMessage</span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">ID</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="kt">string</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="s">&#34;topic&#34;</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">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>這段程式保留了清楚的轉換路徑：JSON message -&gt; request struct -&gt; command -&gt; usecase。每一層只處理自己的責任。</p>
<h2 id="判讀response-也需要穩定格式">【判讀】response 也需要穩定格式</h2>
<p>response 格式的核心目標是讓 client 能穩定判斷一個 action 的結果。成功、輸入錯誤與不支援 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">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">ReplyTo</span> <span class="kt">string</span> <span class="s">`json:&#34;replyTo,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">OK</span>      <span class="kt">bool</span>   <span class="s">`json:&#34;ok&#34;`</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Code</span>    <span class="kt">string</span> <span class="s">`json:&#34;code,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">Message</span> <span class="kt">string</span> <span class="s">`json:&#34;message,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">Data</span>    <span class="kt">any</span>    <span class="s">`json:&#34;data,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>成功 response 可以用 helper 建立：</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">OKMessage</span><span class="p">(</span><span class="nx">replyTo</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">data</span> <span class="kt">any</span><span class="p">)</span> <span class="nx">ServerMessage</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">ServerMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="nx">ReplyTo</span><span class="p">:</span> <span class="nx">replyTo</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">OK</span><span class="p">:</span>      <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</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">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>錯誤 response 也應該用 helper 建立：</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">ErrorMessage</span><span class="p">(</span><span class="nx">replyTo</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">code</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">message</span> <span class="kt">string</span><span class="p">)</span> <span class="nx">ServerMessage</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">ServerMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="nx">ReplyTo</span><span class="p">:</span> <span class="nx">replyTo</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">OK</span><span class="p">:</span>      <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="nx">Code</span><span class="p">:</span>    <span class="nx">code</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="nx">Message</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="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>WebSocket action 失敗不一定要關閉連線。JSON 格式錯誤、未知 action 或 payload 驗證失敗，通常可以回一筆 error message，讓 client 修正下一次請求；只有協定嚴重錯誤、授權失效或連線狀態不可恢復時，才考慮關閉連線。</p>
<h2 id="策略websocket-handler-聚焦-connection-io">【策略】WebSocket handler 聚焦 connection I/O</h2>
<p>WebSocket handler 的核心責任是 connection I/O。它可以讀 message、呼叫 router、寫 response；每種 action 的業務規則交給 router 後方的 usecase。</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">handleClientMessage</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">router</span> <span class="o">*</span><span class="nx">MessageRouter</span><span class="p">,</span> <span class="nx">clientID</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">data</span> <span class="p">[]</span><span class="kt">byte</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"> 2</span><span class="cl">    <span class="nx">resp</span> <span class="o">:=</span> <span class="nx">router</span><span class="p">.</span><span class="nf">Handle</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">clientID</span><span class="p">,</span> <span class="nx">data</span><span class="p">)</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">encoded</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">Marshal</span><span class="p">(</span><span class="nx">resp</span><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">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">fallback</span> <span class="o">:=</span> <span class="nf">ErrorMessage</span><span class="p">(</span><span class="s">&#34;&#34;</span><span class="p">,</span> <span class="s">&#34;encode_failed&#34;</span><span class="p">,</span> <span class="s">&#34;response could not be encoded&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">encoded</span><span class="p">,</span> <span class="nx">_</span> <span class="p">=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">Marshal</span><span class="p">(</span><span class="nx">fallback</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="k">return</span> <span class="nx">encoded</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>真實 WebSocket server 會有 read loop、write loop、heartbeat 與 slow client 處理。這些都屬於連線生命週期，應和 action routing 分開維護。</p>
<h2 id="執行router-測試先覆蓋協定行為">【執行】router 測試先覆蓋協定行為</h2>
<p>router 測試的核心目標是確認 message 進入後會產生正確 command 與 response。這類測試不需要啟動真實 WebSocket server。</p>
<p>先建立 fake usecase：</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">fakeSubscriptionUsecase</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">got</span> <span class="nx">SubscribeTopicCommand</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">err</span> <span class="kt">error</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="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="o">*</span><span class="nx">fakeSubscriptionUsecase</span><span class="p">)</span> <span class="nf">SubscribeTopic</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">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"> 7</span><span class="cl">    <span class="k">if</span> <span class="nx">f</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"> 8</span><span class="cl">        <span class="k">return</span> <span class="nx">f</span><span class="p">.</span><span class="nx">err</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="nx">f</span><span class="p">.</span><span class="nx">got</span> <span class="p">=</span> <span class="nx">cmd</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>成功案例測試可以檢查 command 是否正確：</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">TestMessageRouterSubscribeTopic</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">fake</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeSubscriptionUsecase</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="nf">NewMessageRouter</span><span class="p">(</span><span class="nx">fake</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="p">[]</span><span class="nb">byte</span><span class="p">(</span><span class="s">`{
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">        &#34;id&#34;: &#34;msg_1&#34;,
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">        &#34;action&#34;: &#34;subscribe_topic&#34;,
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">        &#34;payload&#34;: {
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">            &#34;topic&#34;: &#34;deployments&#34;,
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">            &#34;includeHistory&#34;: true
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s">        }
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s">    }`</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="nx">resp</span> <span class="o">:=</span> <span class="nx">router</span><span class="p">.</span><span class="nf">Handle</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="s">&#34;client_1&#34;</span><span class="p">,</span> <span class="nx">data</span><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="p">!</span><span class="nx">resp</span><span class="p">.</span><span class="nx">OK</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</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;response OK = false, want true&#34;</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 class="k">if</span> <span class="nx">fake</span><span class="p">.</span><span class="nx">got</span><span class="p">.</span><span class="nx">ClientID</span> <span class="o">!=</span> <span class="s">&#34;client_1&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</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 ID = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">fake</span><span class="p">.</span><span class="nx">got</span><span class="p">.</span><span class="nx">ClientID</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">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">if</span> <span class="nx">fake</span><span class="p">.</span><span class="nx">got</span><span class="p">.</span><span class="nx">Topic</span> <span class="o">!=</span> <span class="s">&#34;deployments&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">23</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;topic = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">fake</span><span class="p">.</span><span class="nx">got</span><span class="p">.</span><span class="nx">Topic</span><span class="p">,</span> <span class="s">&#34;deployments&#34;</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 class="k">if</span> <span class="p">!</span><span class="nx">fake</span><span class="p">.</span><span class="nx">got</span><span class="p">.</span><span class="nx">IncludeHistory</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">26</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;include history = false, want true&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>輸入錯誤案例應該測 response code。錯誤文案可以調整，code 才是較穩定的協定欄位：</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">TestMessageRouterUnknownAction</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">router</span> <span class="o">:=</span> <span class="nf">NewMessageRouter</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">fakeSubscriptionUsecase</span><span class="p">{})</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">resp</span> <span class="o">:=</span> <span class="nx">router</span><span class="p">.</span><span class="nf">Handle</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="s">&#34;client_1&#34;</span><span class="p">,</span> <span class="p">[]</span><span class="nb">byte</span><span class="p">(</span><span class="s">`{
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">        &#34;id&#34;: &#34;msg_1&#34;,
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">        &#34;action&#34;: &#34;missing_action&#34;,
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">        &#34;payload&#34;: {}
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">    }`</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">if</span> <span class="nx">resp</span><span class="p">.</span><span class="nx">OK</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;response OK = true, want false&#34;</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="nx">resp</span><span class="p">.</span><span class="nx">Code</span> <span class="o">!=</span> <span class="s">&#34;unknown_action&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</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;code = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">resp</span><span class="p">.</span><span class="nx">Code</span><span class="p">,</span> <span class="s">&#34;unknown_action&#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></code></pre></div><p>這些測試保護的是 action 協定。未來 WebSocket library、connection manager 或 repository 改變時，router 行為仍然能被快速驗證。</p>
<h2 id="判讀usecase-測試要離開傳輸格式">【判讀】usecase 測試要離開傳輸格式</h2>
<p>usecase 測試的核心目標是驗證行為規則，而不是 JSON 格式。當 router 已經把 message 轉成 command，usecase 測試就應該直接餵 command。</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">TestSubscriptionServiceSubscribeTopic</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">repo</span> <span class="o">:=</span> <span class="nf">NewInMemorySubscriptionRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">service</span> <span class="o">:=</span> <span class="nf">NewSubscriptionService</span><span class="p">(</span><span class="nx">repo</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">cmd</span> <span class="o">:=</span> <span class="nx">SubscribeTopicCommand</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="p">:</span>       <span class="s">&#34;client_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>          <span class="s">&#34;deployments&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">IncludeHistory</span><span class="p">:</span> <span class="kc">true</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">service</span><span class="p">.</span><span class="nf">SubscribeTopic</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">cmd</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">12</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;subscribe topic: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">repo</span><span class="p">.</span><span class="nf">IsSubscribed</span><span class="p">(</span><span class="s">&#34;client_1&#34;</span><span class="p">,</span> <span class="s">&#34;deployments&#34;</span><span class="p">)</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;client should be subscribed&#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>這裡不需要出現 JSON、WebSocket 或 <code>ClientMessage</code>。usecase 只關心訂閱規則與 repository 狀態。</p>
<h2 id="實作檢查清單">實作檢查清單</h2>
<p>新增 action 時，可以依序檢查：</p>
<ol>
<li>action 名稱是否描述 client intent</li>
<li>是否有獨立 request struct</li>
<li>必填欄位是否有驗證</li>
<li>router 是否只做解析、驗證與 command 轉換</li>
<li>usecase 是否不依賴 WebSocket 型別</li>
<li>response 是否有穩定 <code>ok</code>、<code>code</code>、<code>message</code> 格式</li>
<li>錯誤 action 是否回 error message，而不是直接關閉連線</li>
<li>router 測試是否覆蓋成功、未知 action、invalid JSON、invalid payload</li>
<li>usecase 測試是否直接使用 command</li>
</ol>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一handler-只處理傳輸邊界">檢查一：handler 只處理傳輸邊界</h3>
<p>handler 只處理讀寫、編碼與連線狀態，可以讓 HTTP API、CLI 或背景工作共用同一個 usecase。handler 直接改 map、slice 或 repository 時，傳輸協定和業務規則會綁在一起。</p>
<h3 id="檢查二payload-轉成明確-command">檢查二：payload 轉成明確 command</h3>
<p><code>map[string]any</code> 適合短暫承接未知 JSON，不適合傳進 usecase。usecase 應該接收明確 command，讓欄位、型別與驗證規則可讀可測。</p>
<h3 id="檢查三action-失敗和連線失敗分開處理">檢查三：action 失敗和連線失敗分開處理</h3>
<p>單一 action payload 錯誤不代表 WebSocket 連線壞掉。多數 client input error 應該用 error response 表達，避免 client 因小錯誤被斷線。</p>
<h3 id="檢查四router-interface-跟著-usecase-成長">檢查四：router interface 跟著 usecase 成長</h3>
<p>router 依賴的 interface 應該由當下需要的 usecase 定義。過早建立大型 service interface，會讓每個測試都被迫實作不相關方法。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一 server 內的 action routing 與 response contract；完整 WebSocket lifecycle 與跨節點推送，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/02-networking-websocket/" data-link-title="模組二：WebSocket 服務架構" data-link-desc="WebSocket client lifecycle、heartbeat、訂閱路由與慢客戶端管理">Go 進階：WebSocket 服務架構</a></li>
<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>這一章承接的是 action、command 與 handler 邊界；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</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/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</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>
</ul>
]]></content:encoded></item><item><title>7.1 把 handler 邏輯拆成可測單元</title><link>https://tarrragon.github.io/blog/go/07-refactoring/handler-boundary/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/handler-boundary/</guid><description>&lt;p>handler 重構的核心目標是把 transport concern 和 application concern 分開。handler 應處理 request/response，usecase 應處理行為規則，domain 應保存狀態語意。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>辨識 handler 過重的訊號&lt;/li>
&lt;li>把 request DTO 與 command 分開&lt;/li>
&lt;li>把業務規則搬到 usecase&lt;/li>
&lt;li>讓 handler 只做 request/response 轉換&lt;/li>
&lt;li>分開撰寫 usecase test、handler test 與少量 integration test&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察過重-handler-會混合三種責任">【觀察】過重 handler 會混合三種責任&lt;/h2>
&lt;p>handler 過重的核心問題是 transport、application 與 state concern 混在同一個函式。當一個 handler 同時解析 JSON、驗證欄位、檢查重複、修改 map、組 response，它就很難測，也很難重用。&lt;/p>
&lt;p>常見壞味道：&lt;/p>
&lt;ul>
&lt;li>handler 超過一兩個螢幕。&lt;/li>
&lt;li>測試核心規則必須透過 HTTP。&lt;/li>
&lt;li>JSON tag 出現在 domain type 上。&lt;/li>
&lt;li>handler 直接改 repository 的 map 或 slice。&lt;/li>
&lt;li>多個 handler 重複同樣的驗證與錯誤 mapping。&lt;/li>
&lt;li>想新增 CLI、worker 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> action 時，只能複製 handler 內的邏輯。&lt;/li>
&lt;/ul>
&lt;p>以下是一個過重的建立通知 handler：&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">var&lt;/span> &lt;span class="nx">notifications&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="nx">Notification&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">handleCreateNotification&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Method&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MethodPost&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="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;method not allowed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusMethodNotAllowed&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>&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">var&lt;/span> &lt;span class="nx">req&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">ID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;id&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">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">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">Title&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;title&amp;#34;`&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;span class="line">&lt;span class="ln">14&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">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&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">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid json&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&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="k">return&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;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="k">if&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">ID&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&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 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">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;missing required field&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&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">22&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">exists&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">notifications&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">ID&lt;/span>&lt;span class="p">];&lt;/span> &lt;span class="nx">exists&lt;/span> &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 class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;notification already exists&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusConflict&lt;/span>&lt;span class="p">)&lt;/span>
&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>&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;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="nx">notification&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">Notification&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&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">ID&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl"> &lt;span class="nx">Topic&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">32&lt;/span>&lt;span class="cl"> &lt;span class="nx">Title&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">Title&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">33&lt;/span>&lt;span class="cl"> &lt;span class="nx">CreatedAt&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>&lt;/span>&lt;span class="line">&lt;span class="ln">34&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">35&lt;/span>&lt;span class="cl"> &lt;span class="nx">notifications&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">notification&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">notification&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">36&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">37&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Header&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Content-Type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;application/json&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">38&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusCreated&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">39&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewEncoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Encode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">notification&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">40&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式可以跑，但它把太多責任放進 HTTP 邊界。只要要測「重複 ID 不可建立」，就必須走 HTTP；只要要改儲存方式，就必須改 handler。&lt;/p>
&lt;h2 id="判讀先拆-request-dto">【判讀】先拆 request DTO&lt;/h2>
&lt;p>request DTO 的核心責任是描述外部輸入格式。它可以有 JSON tag，但不應直接當成 domain model 或 repository model。&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">createNotificationRequest&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 class="s">`json:&amp;#34;id&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="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"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">Title&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;title&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;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&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">createNotificationRequest&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">validate&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"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">if&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">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">)&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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">ErrInvalidInput&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Field&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">Reason&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;required&amp;#34;&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="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">if&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">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">)&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">12&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">ErrInvalidInput&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Field&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;topic&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">Reason&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;required&amp;#34;&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="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="kc">nil&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;/code>&lt;/pre>&lt;/div>&lt;p>DTO 可以是 unexported，因為它只服務 HTTP handler。JSON tag 也停在 transport layer，不會污染 application command。&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">type&lt;/span> &lt;span class="nx">ErrInvalidInput&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">Field&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">Reason&lt;/span> &lt;span class="kt">string&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="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">e&lt;/span> &lt;span class="nx">ErrInvalidInput&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Error&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kt">string&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">return&lt;/span> &lt;span class="nx">e&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Field&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s">&amp;#34;: &amp;#34;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">e&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Reason&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>這個錯誤型別讓 handler 可以把輸入錯誤轉成 &lt;code>400 Bad Request&lt;/code>，而不必靠字串比對。&lt;/p></description><content:encoded><![CDATA[<p>handler 重構的核心目標是把 transport concern 和 application concern 分開。handler 應處理 request/response，usecase 應處理行為規則，domain 應保存狀態語意。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>辨識 handler 過重的訊號</li>
<li>把 request DTO 與 command 分開</li>
<li>把業務規則搬到 usecase</li>
<li>讓 handler 只做 request/response 轉換</li>
<li>分開撰寫 usecase test、handler test 與少量 integration test</li>
</ol>
<hr>
<h2 id="觀察過重-handler-會混合三種責任">【觀察】過重 handler 會混合三種責任</h2>
<p>handler 過重的核心問題是 transport、application 與 state concern 混在同一個函式。當一個 handler 同時解析 JSON、驗證欄位、檢查重複、修改 map、組 response，它就很難測，也很難重用。</p>
<p>常見壞味道：</p>
<ul>
<li>handler 超過一兩個螢幕。</li>
<li>測試核心規則必須透過 HTTP。</li>
<li>JSON tag 出現在 domain type 上。</li>
<li>handler 直接改 repository 的 map 或 slice。</li>
<li>多個 handler 重複同樣的驗證與錯誤 mapping。</li>
<li>想新增 CLI、worker 或 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> action 時，只能複製 handler 內的邏輯。</li>
</ul>
<p>以下是一個過重的建立通知 handler：</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">var</span> <span class="nx">notifications</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="nx">Notification</span><span class="p">{}</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">func</span> <span class="nf">handleCreateNotification</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</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">if</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Method</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">MethodPost</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;method not allowed&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusMethodNotAllowed</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="kd">var</span> <span class="nx">req</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">ID</span>    <span class="kt">string</span> <span class="s">`json:&#34;id&#34;`</span>
</span></span><span class="line"><span class="ln">11</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">12</span><span class="cl">        <span class="nx">Title</span> <span class="kt">string</span> <span class="s">`json:&#34;title&#34;`</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</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">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</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">15</span><span class="cl">        <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;invalid json&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="k">return</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></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</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">ID</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</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 class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;missing required field&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">return</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></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">exists</span> <span class="o">:=</span> <span class="nx">notifications</span><span class="p">[</span><span class="nx">req</span><span class="p">.</span><span class="nx">ID</span><span class="p">];</span> <span class="nx">exists</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;notification already exists&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusConflict</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="nx">notification</span> <span class="o">:=</span> <span class="nx">Notification</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="nx">req</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">        <span class="nx">Topic</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">32</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="nx">req</span><span class="p">.</span><span class="nx">Title</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">    <span class="nx">notifications</span><span class="p">[</span><span class="nx">notification</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">notification</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">
</span></span><span class="line"><span class="ln">37</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">Header</span><span class="p">().</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Content-Type&#34;</span><span class="p">,</span> <span class="s">&#34;application/json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewEncoder</span><span class="p">(</span><span class="nx">w</span><span class="p">).</span><span class="nf">Encode</span><span class="p">(</span><span class="nx">notification</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式可以跑，但它把太多責任放進 HTTP 邊界。只要要測「重複 ID 不可建立」，就必須走 HTTP；只要要改儲存方式，就必須改 handler。</p>
<h2 id="判讀先拆-request-dto">【判讀】先拆 request DTO</h2>
<p>request DTO 的核心責任是描述外部輸入格式。它可以有 JSON tag，但不應直接當成 domain model 或 repository model。</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">createNotificationRequest</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 class="s">`json:&#34;id&#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&#34;`</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">Title</span> <span class="kt">string</span> <span class="s">`json:&#34;title&#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">r</span> <span class="nx">createNotificationRequest</span><span class="p">)</span> <span class="nf">validate</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="k">if</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</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="nx">ErrInvalidInput</span><span class="p">{</span><span class="nx">Field</span><span class="p">:</span> <span class="s">&#34;id&#34;</span><span class="p">,</span> <span class="nx">Reason</span><span class="p">:</span> <span class="s">&#34;required&#34;</span><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="k">if</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</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">ErrInvalidInput</span><span class="p">{</span><span class="nx">Field</span><span class="p">:</span> <span class="s">&#34;topic&#34;</span><span class="p">,</span> <span class="nx">Reason</span><span class="p">:</span> <span class="s">&#34;required&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>DTO 可以是 unexported，因為它只服務 HTTP handler。JSON tag 也停在 transport layer，不會污染 application command。</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">type</span> <span class="nx">ErrInvalidInput</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">Field</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Reason</span> <span class="kt">string</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="kd">func</span> <span class="p">(</span><span class="nx">e</span> <span class="nx">ErrInvalidInput</span><span class="p">)</span> <span class="nf">Error</span><span class="p">()</span> <span class="kt">string</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="nx">e</span><span class="p">.</span><span class="nx">Field</span> <span class="o">+</span> <span class="s">&#34;: &#34;</span> <span class="o">+</span> <span class="nx">e</span><span class="p">.</span><span class="nx">Reason</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個錯誤型別讓 handler 可以把輸入錯誤轉成 <code>400 Bad Request</code>，而不必靠字串比對。</p>
<h2 id="策略command-表達-usecase-輸入">【策略】command 表達 usecase 輸入</h2>
<p>command 的核心責任是描述 application layer 要執行的行為。它不需要 JSON tag，也不需要知道 request body 來自 HTTP、WebSocket 或 CLI。</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">CreateNotificationCommand</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">Topic</span>     <span class="kt">string</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Title</span>     <span class="kt">string</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">CreatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>handler 負責 DTO -&gt; command 的轉換：</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">r</span> <span class="nx">createNotificationRequest</span><span class="p">)</span> <span class="nf">toCommand</span><span class="p">(</span><span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="nx">CreateNotificationCommand</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">CreateNotificationCommand</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="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">ID</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>     <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Topic</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Title</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">now</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><code>CreatedAt</code> 由 handler 或 usecase 決定都可以，但要一致。若時間是業務規則的一部分，通常由 usecase 注入 clock 會更穩；若只是 request 接收時間，handler 傳入也合理。重點是不要在測試中散落 <code>time.Now()</code>。</p>
<h2 id="執行usecase-保存行為規則">【執行】usecase 保存行為規則</h2>
<p>usecase 的核心責任是處理行為規則與資料能力。重複檢查、儲存、事件發布或狀態轉移應該在 usecase，而不是 handler。</p>
<p>先定義 usecase 需要的 repository：</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">NotificationRepository</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">Save</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">notification</span> <span class="nx">Notification</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="nf">FindByID</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">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>再定義 service：</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">CreateNotificationUsecase</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">repository</span> <span class="nx">NotificationRepository</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="nf">NewCreateNotificationUsecase</span><span class="p">(</span><span class="nx">repository</span> <span class="nx">NotificationRepository</span><span class="p">)</span> <span class="o">*</span><span class="nx">CreateNotificationUsecase</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="o">&amp;</span><span class="nx">CreateNotificationUsecase</span><span class="p">{</span><span class="nx">repository</span><span class="p">:</span> <span class="nx">repository</span><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>執行 command：</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">u</span> <span class="o">*</span><span class="nx">CreateNotificationUsecase</span><span class="p">)</span> <span class="nf">Execute</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">cmd</span> <span class="nx">CreateNotificationCommand</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">error</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">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">cmd</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">return</span> <span class="nx">Notification</span><span class="p">{},</span> <span class="nx">ErrInvalidInput</span><span class="p">{</span><span class="nx">Field</span><span class="p">:</span> <span class="s">&#34;id&#34;</span><span class="p">,</span> <span class="nx">Reason</span><span class="p">:</span> <span class="s">&#34;required&#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">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">cmd</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</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="nx">Notification</span><span class="p">{},</span> <span class="nx">ErrInvalidInput</span><span class="p">{</span><span class="nx">Field</span><span class="p">:</span> <span class="s">&#34;topic&#34;</span><span class="p">,</span> <span class="nx">Reason</span><span class="p">:</span> <span class="s">&#34;required&#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></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">exists</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">u</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">FindByID</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">cmd</span><span class="p">.</span><span class="nx">ID</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 class="nx">Notification</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;find notification: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="nx">exists</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">Notification</span><span class="p">{},</span> <span class="nx">ErrAlreadyExists</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="nx">cmd</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="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">notification</span> <span class="o">:=</span> <span class="nx">Notification</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="nx">cmd</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</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">18</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="nx">cmd</span><span class="p">.</span><span class="nx">Title</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">cmd</span><span class="p">.</span><span class="nx">CreatedAt</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></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">u</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">notification</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">23</span><span class="cl">        <span class="k">return</span> <span class="nx">Notification</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;save notification: %w&#34;</span><span class="p">,</span> <span class="nx">err</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">notification</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>ErrAlreadyExists</code> 可以是明確錯誤型別：</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">ErrAlreadyExists</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="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">e</span> <span class="nx">ErrAlreadyExists</span><span class="p">)</span> <span class="nf">Error</span><span class="p">()</span> <span class="kt">string</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="s">&#34;notification already exists: &#34;</span> <span class="o">+</span> <span class="nx">e</span><span class="p">.</span><span class="nx">ID</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這樣 handler 可以用 <code>errors.As</code> 把它對應到 <code>409 Conflict</code>。</p>
<h2 id="執行handler-只做轉換與-mapping">【執行】handler 只做轉換與 mapping</h2>
<p>重構後 handler 的核心責任是 request -&gt; command、result -&gt; response、error -&gt; HTTP status。它不直接碰 map，也不保存業務規則。</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">NotificationCreator</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">Execute</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">cmd</span> <span class="nx">CreateNotificationCommand</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</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">NotificationHandler</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">creator</span> <span class="nx">NotificationCreator</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">now</span>     <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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">NewNotificationHandler</span><span class="p">(</span><span class="nx">creator</span> <span class="nx">NotificationCreator</span><span class="p">,</span> <span class="nx">now</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="nx">NotificationHandler</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="nx">NotificationHandler</span><span class="p">{</span><span class="nx">creator</span><span class="p">:</span> <span class="nx">creator</span><span class="p">,</span> <span class="nx">now</span><span class="p">:</span> <span class="nx">now</span><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>handler 實作：</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="nx">NotificationHandler</span><span class="p">)</span> <span class="nf">Create</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</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">r</span><span class="p">.</span><span class="nx">Method</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">MethodPost</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusMethodNotAllowed</span><span class="p">,</span> <span class="s">&#34;method_not_allowed&#34;</span><span class="p">,</span> <span class="s">&#34;method not allowed&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</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">var</span> <span class="nx">req</span> <span class="nx">createNotificationRequest</span>
</span></span><span class="line"><span class="ln"> 8</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">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</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"> 9</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_json&#34;</span><span class="p">,</span> <span class="s">&#34;request body must be valid JSON&#34;</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></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">req</span><span class="p">.</span><span class="nf">validate</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="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_input&#34;</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">15</span><span class="cl">        <span class="k">return</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></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">notification</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">creator</span><span class="p">.</span><span class="nf">Execute</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nf">Context</span><span class="p">(),</span> <span class="nx">req</span><span class="p">.</span><span class="nf">toCommand</span><span class="p">(</span><span class="nx">h</span><span class="p">.</span><span class="nf">now</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="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nf">writeUsecaseError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">return</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></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="nf">writeJSON</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</span><span class="p">,</span> <span class="nf">newNotificationResponse</span><span class="p">(</span><span class="nx">notification</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 handler 仍然有 HTTP 協定責任，但核心行為已經搬出去。未來 WebSocket action 或 worker 也可以建立 <code>CreateNotificationCommand</code> 呼叫同一個 usecase。</p>
<h2 id="策略response-struct-是對外-contract">【策略】response struct 是對外 contract</h2>
<p>response struct 的核心責任是描述 HTTP 回應格式。不要直接把 domain model 全部輸出，否則內部欄位會變成外部 API 承諾。</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">notificationResponse</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 class="s">`json:&#34;id&#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&#34;`</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">Title</span>     <span class="kt">string</span>    <span class="s">`json:&#34;title&#34;`</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">CreatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span> <span class="s">`json:&#34;createdAt&#34;`</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">newNotificationResponse</span><span class="p">(</span><span class="nx">notification</span> <span class="nx">Notification</span><span class="p">)</span> <span class="nx">notificationResponse</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="nx">notificationResponse</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">notification</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">Topic</span><span class="p">:</span>     <span class="nx">notification</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="nx">Title</span><span class="p">:</span>     <span class="nx">notification</span><span class="p">.</span><span class="nx">Title</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">notification</span><span class="p">.</span><span class="nx">CreatedAt</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="p">}</span></span></span></code></pre></div><p>error response 也應該穩定：</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">errorResponse</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">Code</span>    <span class="kt">string</span> <span class="s">`json:&#34;code&#34;`</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Message</span> <span class="kt">string</span> <span class="s">`json:&#34;message&#34;`</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="kd">func</span> <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">status</span> <span class="kt">int</span><span class="p">,</span> <span class="nx">code</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">message</span> <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nf">writeJSON</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">status</span><span class="p">,</span> <span class="nx">errorResponse</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Code</span><span class="p">:</span>    <span class="nx">code</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">Message</span><span class="p">:</span> <span class="nx">message</span><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><code>writeJSON</code> 集中 JSON response 寫法：</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">writeJSON</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">status</span> <span class="kt">int</span><span class="p">,</span> <span class="nx">value</span> <span class="kt">any</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">w</span><span class="p">.</span><span class="nf">Header</span><span class="p">().</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Content-Type&#34;</span><span class="p">,</span> <span class="s">&#34;application/json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">status</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewEncoder</span><span class="p">(</span><span class="nx">w</span><span class="p">).</span><span class="nf">Encode</span><span class="p">(</span><span class="nx">value</span><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>這個 helper 可以忽略 encode error，因為 response 已經開始寫出；正式服務通常會記錄 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>。</p>
<h2 id="判讀error-mapping-是-handler-邊界">【判讀】error mapping 是 handler 邊界</h2>
<p>error mapping 的核心責任是把 application error 轉成 HTTP status 與對外 code。usecase 不應知道 HTTP status；handler 不應靠錯誤字串猜狀態。</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">writeUsecaseError</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">err</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="kd">var</span> <span class="nx">invalid</span> <span class="nx">ErrInvalidInput</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">if</span> <span class="nx">errors</span><span class="p">.</span><span class="nf">As</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">invalid</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_input&#34;</span><span class="p">,</span> <span class="nx">invalid</span><span class="p">.</span><span class="nf">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></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">var</span> <span class="nx">alreadyExists</span> <span class="nx">ErrAlreadyExists</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">errors</span><span class="p">.</span><span class="nf">As</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">alreadyExists</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusConflict</span><span class="p">,</span> <span class="s">&#34;already_exists&#34;</span><span class="p">,</span> <span class="s">&#34;notification already exists&#34;</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></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusInternalServerError</span><span class="p">,</span> <span class="s">&#34;internal_error&#34;</span><span class="p">,</span> <span class="s">&#34;internal server error&#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></code></pre></div><p>內部錯誤不要直接回給 client。對外 message 應該穩定且安全；詳細錯誤留給 log 與 error chain。</p>
<h2 id="執行usecase-測試不需要-http">【執行】usecase 測試不需要 HTTP</h2>
<p>usecase 測試的核心目標是驗證行為規則。它應該直接建立 command，使用 fake repository，不需要 <code>httptest</code>。</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">fakeNotificationRepository</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">existing</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">Notification</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">saved</span>    <span class="p">[]</span><span class="nx">Notification</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="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="o">*</span><span class="nx">fakeNotificationRepository</span><span class="p">)</span> <span class="nf">Save</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">notification</span> <span class="nx">Notification</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">f</span><span class="p">.</span><span class="nx">saved</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">f</span><span class="p">.</span><span class="nx">saved</span><span class="p">,</span> <span class="nx">notification</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="kc">nil</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></span><span class="line"><span class="ln">11</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="o">*</span><span class="nx">fakeNotificationRepository</span><span class="p">)</span> <span class="nf">FindByID</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">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">notification</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">f</span><span class="p">.</span><span class="nx">existing</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="k">return</span> <span class="nx">notification</span><span class="p">,</span> <span class="nx">ok</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><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">TestCreateNotificationUsecaseExecute</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">repo</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeNotificationRepository</span><span class="p">{</span><span class="nx">existing</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="nx">Notification</span><span class="p">{}}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">usecase</span> <span class="o">:=</span> <span class="nf">NewCreateNotificationUsecase</span><span class="p">(</span><span class="nx">repo</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">_</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">usecase</span><span class="p">.</span><span class="nf">Execute</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">CreateNotificationCommand</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>     <span class="s">&#34;deployments&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="s">&#34;Deploy finished&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><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="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">12</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;execute usecase: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">repo</span><span class="p">.</span><span class="nx">saved</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</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;saved notifications = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">repo</span><span class="p">.</span><span class="nx">saved</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>這個測試速度快、錯誤定位明確。若失敗，問題在 usecase，不在 HTTP parsing。</p>
<h2 id="執行handler-test-專注-requestresponse">【執行】handler test 專注 request/response</h2>
<p>handler test 的核心目標是驗證 HTTP 協定行為。它應該使用 fake usecase，而不是真 repository。</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">fakeNotificationCreator</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">got</span> <span class="nx">CreateNotificationCommand</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">out</span> <span class="nx">Notification</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">err</span> <span class="kt">error</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">f</span> <span class="o">*</span><span class="nx">fakeNotificationCreator</span><span class="p">)</span> <span class="nf">Execute</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">cmd</span> <span class="nx">CreateNotificationCommand</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">f</span><span class="p">.</span><span class="nx">got</span> <span class="p">=</span> <span class="nx">cmd</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">f</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 class="nx">Notification</span><span class="p">{},</span> <span class="nx">f</span><span class="p">.</span><span class="nx">err</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="k">return</span> <span class="nx">f</span><span class="p">.</span><span class="nx">out</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>測試成功 response：</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">TestNotificationHandlerCreate</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">creator</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeNotificationCreator</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">out</span><span class="p">:</span> <span class="nx">Notification</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="p">:</span>        <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="nx">Topic</span><span class="p">:</span>     <span class="s">&#34;deployments&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="nx">Title</span><span class="p">:</span>     <span class="s">&#34;Deploy finished&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">handler</span> <span class="o">:=</span> <span class="nf">NewNotificationHandler</span><span class="p">(</span><span class="nx">creator</span><span class="p">,</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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="nx">req</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRequest</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">MethodPost</span><span class="p">,</span> <span class="s">&#34;/notifications&#34;</span><span class="p">,</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">NewReader</span><span class="p">(</span><span class="s">`{
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">        &#34;id&#34;: &#34;ntf_1&#34;,
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">        &#34;topic&#34;: &#34;deployments&#34;,
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s">        &#34;title&#34;: &#34;Deploy finished&#34;
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s">    }`</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">rec</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRecorder</span><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">handler</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="nx">rec</span><span class="p">,</span> <span class="nx">req</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="k">if</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">24</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;status = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">if</span> <span class="nx">creator</span><span class="p">.</span><span class="nx">got</span><span class="p">.</span><span class="nx">Topic</span> <span class="o">!=</span> <span class="s">&#34;deployments&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">27</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;topic = %q, want deployments&#34;</span><span class="p">,</span> <span class="nx">creator</span><span class="p">.</span><span class="nx">got</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試確認 handler 能解析 JSON、建立 command、呼叫 usecase、寫出狀態碼。它不測重複 ID 的儲存規則，那已經是 usecase 測試的責任。</p>
<h2 id="策略integration-test-只保留少數端到端路徑">【策略】integration test 只保留少數端到端路徑</h2>
<p>integration test 的核心用途是確認組裝正確，不是覆蓋所有規則。當 usecase 與 handler 都已有單元測試，端到端測試只需要保留代表性成功與失敗路徑。</p>
<p>例如：</p>
<ul>
<li><code>POST /notifications</code> 成功建立。</li>
<li>invalid JSON 回 <code>400</code>。</li>
<li>重複 ID 回 <code>409</code>。</li>
</ul>
<p>不要把所有欄位驗證都只放在 integration test。那會讓測試慢、失敗定位模糊，也讓重構成本升高。</p>
<h2 id="重構步驟">重構步驟</h2>
<p>從過重 handler 重構時，可以按這個順序：</p>
<ol>
<li>先補 handler 現有行為測試，鎖住 status code 與 response body。</li>
<li>抽出 request DTO，但暫時不改行為。</li>
<li>抽出 command 與 usecase，讓 handler 呼叫 usecase。</li>
<li>把 repository 或 map 寫入移到 usecase 後方。</li>
<li>抽出 response struct 與 error mapping helper。</li>
<li>補 usecase 單元測試。</li>
<li>縮減 handler 測試範圍，保留 request/response 行為。</li>
</ol>
<p>每一步都應該讓程式可編譯、測試可跑。不要一次把 handler、repository、package 結構全部搬完。</p>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一抽出真正的行為邊界">檢查一：抽出真正的行為邊界</h3>
<p>如果新函式仍然接收 <code>http.ResponseWriter</code> 和 <code>*http.Request</code>，那只是移動程式碼，還沒有分離 transport concern。</p>
<h3 id="檢查二domain-model-和-response-model-分開">檢查二：domain model 和 response model 分開</h3>
<p>JSON tag 是 transport contract。domain model 若直接承擔對外格式，未來內部欄位調整就會牽動 API 相容性。</p>
<h3 id="檢查三錯誤類型對應-http-回應">檢查三：錯誤類型對應 HTTP 回應</h3>
<p>輸入錯誤、重複資料、權限問題與內部錯誤應該對應不同 status code。錯誤型別與 error mapping helper 可以避免字串判斷。</p>
<h3 id="檢查四分層測試保護不同責任">檢查四：分層測試保護不同責任</h3>
<p>端到端測試重要，但不應是唯一測試。usecase 規則越多，越需要直接測 command 與 fake repository。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 HTTP handler 的轉換邊界；router、middleware 與 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a>，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">Go 進階：逐步遷移到 ports/adapters 架構</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">Go 進階：資料庫 transaction 與 schema migration</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 request DTO、command 與 usecase 分層；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</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/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/06-practical/state-fields/" data-link-title="6.3 如何擴展狀態投影欄位" data-link-desc="更新狀態模型、repository 與 API 輸出">Go：如何擴展狀態投影欄位</a></li>
</ul>
]]></content:encoded></item><item><title>7.1 資料庫 transaction 與 schema migration</title><link>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/database-transactions/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/database-transactions/</guid><description>&lt;p>資料庫整合的核心責任是讓持久化行為符合 application 的狀態規則。Repository port 決定 usecase 需要哪些資料能力；&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">transaction boundary&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/schema-migration/" data-link-title="Schema Migration" data-link-desc="說明資料庫結構如何隨應用程式版本安全演進">schema migration&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/expand-contract/" data-link-title="Expand / Contract" data-link-desc="說明先擴充相容面、再收斂舊路徑的遷移做法">Expand / Contract&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">isolation level&lt;/a> 則決定這些能力在資料庫中如何保持一致。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷 [&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a> boundary](/go-advanced/backend/knowledge-cards/transaction-boundary) 應該放在 repository 還是 usecase&lt;/li>
&lt;li>理解 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration&lt;/a> 為什麼要維持向前相容&lt;/li>
&lt;li>分辨 application validation、constraint 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">isolation level&lt;/a> 的責任&lt;/li>
&lt;li>用 contract test 保護 memory repository 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database&lt;/a> repository 的一致行為&lt;/li>
&lt;li>讓 SQL 細節留在 adapter，讓 domain 規則留在 application&lt;/li>
&lt;/ol>
&lt;h2 id="前置章節">前置章節&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go 入門：如何新增 repository port&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go 入門：狀態管理的安全邊界&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Go 進階：Source of Truth：狀態邊界&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Backend：Source of Truth&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">Backend：Connection Pool&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="後續撰寫方向">後續撰寫方向&lt;/h2>
&lt;ol>
&lt;li>Repository method 如何表達交易語意，讓 SQL 細節留在 adapter。&lt;/li>
&lt;li>一個 usecase 需要多筆寫入同時成功或失敗時，transaction boundary 應放在哪裡。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">Migration&lt;/a> 如何維持向前相容，避免新舊程式版本互相破壞資料。&lt;/li>
&lt;li>Isolation level、unique constraint 與 application-level validation 如何分工。&lt;/li>
&lt;li>Contract test 如何保護 memory repository 與 database repository 的一致行為。&lt;/li>
&lt;/ol>
&lt;h2 id="觀察transaction-是一致性邊界">【觀察】transaction 是一致性邊界&lt;/h2>
&lt;p>transaction 的核心用途是把一組資料庫操作綁成單一一致性單位。判斷重點是：這個 usecase 哪些狀態要一起成功或一起失敗。效能與寫入便利性都應放在一致性需求之後評估。&lt;/p>
&lt;p>例如建立訂單時，可能同時需要：&lt;/p>
&lt;ul>
&lt;li>寫入 order 主表&lt;/li>
&lt;li>寫入 order items&lt;/li>
&lt;li>更新 inventory&lt;/li>
&lt;li>寫入 outbox event&lt;/li>
&lt;/ul>
&lt;p>如果其中一個步驟失敗，整組操作就應回滾，避免 application 狀態和資料庫狀態分裂。&lt;/p>
&lt;h2 id="判讀transaction-boundary-應該跟-usecase-對齊">【判讀】transaction boundary 應該跟 usecase 對齊&lt;/h2>
&lt;p>交易邊界最常見的錯誤，是把 transaction 放得太低或太高。&lt;/p>
&lt;ul>
&lt;li>放太低：repository 各自開 transaction，usecase 層看起來成功，實際上無法保證整體一致。&lt;/li>
&lt;li>放太高：把不需要一致性的讀取、外部 API、長迴圈也包進 transaction，讓連線被占住太久。&lt;/li>
&lt;/ul>
&lt;p>一般原則是：&lt;/p>
&lt;ul>
&lt;li>要維持同一個 domain 不變式的寫入，應放在同一個 transaction。&lt;/li>
&lt;li>可以重試或可補償的外部互動，通常應放在 transaction 之外。&lt;/li>
&lt;/ul>
&lt;h2 id="策略migration-要讓舊版與新版可以共存">【策略】&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">Migration&lt;/a> 要讓舊版與新版可以共存&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/schema-migration/" data-link-title="Schema Migration" data-link-desc="說明資料庫結構如何隨應用程式版本安全演進">schema migration&lt;/a> 的核心是讓部署期間的新舊版本能同時活著。實務上常見的是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/expand-contract/" data-link-title="Expand / Contract" data-link-desc="說明先擴充相容面、再收斂舊路徑的遷移做法">Expand / Contract&lt;/a> 流程：&lt;/p></description><content:encoded><![CDATA[<p>資料庫整合的核心責任是讓持久化行為符合 application 的狀態規則。Repository port 決定 usecase 需要哪些資料能力；<a href="/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">transaction boundary</a>、<a href="/blog/backend/knowledge-cards/schema-migration/" data-link-title="Schema Migration" data-link-desc="說明資料庫結構如何隨應用程式版本安全演進">schema migration</a>、<a href="/blog/backend/knowledge-cards/expand-contract/" data-link-title="Expand / Contract" data-link-desc="說明先擴充相容面、再收斂舊路徑的遷移做法">Expand / Contract</a> 與 <a href="/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">isolation level</a> 則決定這些能力在資料庫中如何保持一致。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷 [<a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> boundary](/go-advanced/backend/knowledge-cards/transaction-boundary) 應該放在 repository 還是 usecase</li>
<li>理解 <a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration</a> 為什麼要維持向前相容</li>
<li>分辨 application validation、constraint 與 <a href="/blog/backend/knowledge-cards/isolation-level/" data-link-title="Isolation Level" data-link-desc="說明資料庫交易隔離級別如何影響並發讀寫結果">isolation level</a> 的責任</li>
<li>用 contract test 保護 memory repository 與 <a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a> repository 的一致行為</li>
<li>讓 SQL 細節留在 adapter，讓 domain 規則留在 application</li>
</ol>
<h2 id="前置章節">前置章節</h2>
<ul>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go 入門：如何新增 repository port</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go 入門：狀態管理的安全邊界</a></li>
<li><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Go 進階：Source of Truth：狀態邊界</a></li>
<li><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Backend：Source of Truth</a></li>
<li><a href="/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">Backend：Connection Pool</a></li>
</ul>
<h2 id="後續撰寫方向">後續撰寫方向</h2>
<ol>
<li>Repository method 如何表達交易語意，讓 SQL 細節留在 adapter。</li>
<li>一個 usecase 需要多筆寫入同時成功或失敗時，transaction boundary 應放在哪裡。</li>
<li><a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">Migration</a> 如何維持向前相容，避免新舊程式版本互相破壞資料。</li>
<li>Isolation level、unique constraint 與 application-level validation 如何分工。</li>
<li>Contract test 如何保護 memory repository 與 database repository 的一致行為。</li>
</ol>
<h2 id="觀察transaction-是一致性邊界">【觀察】transaction 是一致性邊界</h2>
<p>transaction 的核心用途是把一組資料庫操作綁成單一一致性單位。判斷重點是：這個 usecase 哪些狀態要一起成功或一起失敗。效能與寫入便利性都應放在一致性需求之後評估。</p>
<p>例如建立訂單時，可能同時需要：</p>
<ul>
<li>寫入 order 主表</li>
<li>寫入 order items</li>
<li>更新 inventory</li>
<li>寫入 outbox event</li>
</ul>
<p>如果其中一個步驟失敗，整組操作就應回滾，避免 application 狀態和資料庫狀態分裂。</p>
<h2 id="判讀transaction-boundary-應該跟-usecase-對齊">【判讀】transaction boundary 應該跟 usecase 對齊</h2>
<p>交易邊界最常見的錯誤，是把 transaction 放得太低或太高。</p>
<ul>
<li>放太低：repository 各自開 transaction，usecase 層看起來成功，實際上無法保證整體一致。</li>
<li>放太高：把不需要一致性的讀取、外部 API、長迴圈也包進 transaction，讓連線被占住太久。</li>
</ul>
<p>一般原則是：</p>
<ul>
<li>要維持同一個 domain 不變式的寫入，應放在同一個 transaction。</li>
<li>可以重試或可補償的外部互動，通常應放在 transaction 之外。</li>
</ul>
<h2 id="策略migration-要讓舊版與新版可以共存">【策略】<a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">Migration</a> 要讓舊版與新版可以共存</h2>
<p><a href="/blog/backend/knowledge-cards/schema-migration/" data-link-title="Schema Migration" data-link-desc="說明資料庫結構如何隨應用程式版本安全演進">schema migration</a> 的核心是讓部署期間的新舊版本能同時活著。實務上常見的是 <a href="/blog/backend/knowledge-cards/expand-contract/" data-link-title="Expand / Contract" data-link-desc="說明先擴充相容面、再收斂舊路徑的遷移做法">Expand / Contract</a> 流程：</p>
<ol>
<li>先新增欄位、表或索引。</li>
<li>讓新舊程式都能讀寫。</li>
<li>確認流量已切到新版本。</li>
<li>再移除舊欄位或舊邏輯。</li>
</ol>
<p>這樣做的目的，是避免應用版本與資料庫版本在 rolling deploy 時互相踩到。</p>
<h2 id="判讀constraintvalidation-與-isolation-level-各管不同風險">【判讀】constraint、validation 與 isolation level 各管不同風險</h2>
<p>這三者的責任應清楚分工：</p>
<ul>
<li>application validation：在進資料庫前先檢查基本輸入是否合法。</li>
<li>unique / foreign key / check constraint：在資料庫層保底，防止不合法資料落地。</li>
<li>isolation level：處理多交易同時進行時的可見性與衝突問題。</li>
</ul>
<p>如果只靠 application validation，資料庫仍可能被其他路徑寫入不合法資料。如果只靠資料庫 constraint，錯誤回報可能太晚。兩者通常要一起用。</p>
<h2 id="執行contract-test-檢查-repository-語意一致">【執行】contract test 檢查 repository 語意一致</h2>
<p>當你同時有 memory repository 與 database repository 時，測試重點是它們對外暴露的語意是否一致。SQL 細節屬於 database adapter 的內部實作。</p>
<p>通常要測：</p>
<ul>
<li>找不到資料時怎麼回傳</li>
<li>重複寫入時怎麼回傳</li>
<li>transaction 失敗時是否維持一致狀態</li>
<li>欄位驗證與預設值是否相同</li>
</ul>
<p>這類測試可以讓 repository adapter 保持可替換，讓資料庫替換時 usecase 維持穩定。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章不會選定特定資料庫或 ORM。真正的重點是 Go application 如何定義資料一致性責任，讓 SQLite、PostgreSQL 或其他儲存技術都能成為可替換 adapter。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 Go 的 repository port 與狀態邊界；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
<li><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Go 進階：Source of Truth</a></li>
</ul>
]]></content:encoded></item><item><title>模組一：Go 基礎概念</title><link>https://tarrragon.github.io/blog/go/01-basics/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/01-basics/</guid><description>&lt;p>本模組帶你建立閱讀 Go 程式需要的基本模型。重點是理解 module、變數、控制流程、package、檔案拆分、函式、入口程式與 Go tooling 如何組成日常開發流程。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&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;a href="https://tarrragon.github.io/blog/go/01-basics/modules/" data-link-title="1.1 Go 專案結構與 module" data-link-desc="理解 go.mod、module path 與 Go 專案的依賴邊界">1.1&lt;/a>&lt;/td>
 &lt;td>Go 專案結構與 module&lt;/td>
 &lt;td>理解 module 與 import path&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/variables-zero-values/" data-link-title="1.2 變數、零值與短變數宣告" data-link-desc="理解 Go 如何宣告、初始化與使用零值">1.2&lt;/a>&lt;/td>
 &lt;td>變數、零值與短變數宣告&lt;/td>
 &lt;td>理解 Go 如何宣告與初始化資料&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/control-flow/" data-link-title="1.3 控制流程：if、for、switch" data-link-desc="掌握 Go 的條件判斷、迴圈與分支控制">1.3&lt;/a>&lt;/td>
 &lt;td>控制流程：if、for、switch&lt;/td>
 &lt;td>掌握 Go 的基本流程控制&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/packages/" data-link-title="1.4 package、檔案與可見性" data-link-desc="看懂 package main、檔案切分與大小寫可見性">1.4&lt;/a>&lt;/td>
 &lt;td>package、檔案與可見性&lt;/td>
 &lt;td>看懂 &lt;code>package main&lt;/code> 與大小寫可見性&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/growing-files-packages/" data-link-title="1.5 從單檔到多檔案" data-link-desc="理解 Go 程式如何從 main.go 長成多檔案與多 package">1.5&lt;/a>&lt;/td>
 &lt;td>從單檔到多檔案&lt;/td>
 &lt;td>理解 Go 程式如何從 &lt;code>main.go&lt;/code> 長成多檔案與多 package&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/functions-methods/" data-link-title="1.6 函式、方法與 receiver" data-link-desc="區分普通函式、建構函式與帶 receiver 的方法">1.6&lt;/a>&lt;/td>
 &lt;td>函式、方法與 receiver&lt;/td>
 &lt;td>區分函式、建構函式與物件方法&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">1.7&lt;/a>&lt;/td>
 &lt;td>從入口程式看應用啟動流程&lt;/td>
 &lt;td>建立 Go 應用啟動地圖&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/go-tooling-workflow/" data-link-title="1.8 Go tooling 與日常開發流程" data-link-desc="用 go run、go test、go fmt、go mod tidy 建立 Go 專案的基本工作節奏">1.8&lt;/a>&lt;/td>
 &lt;td>Go tooling 與日常開發流程&lt;/td>
 &lt;td>用 &lt;code>go run&lt;/code>、&lt;code>go test&lt;/code>、&lt;code>go fmt&lt;/code>、&lt;code>go mod tidy&lt;/code> 建立基本工作節奏&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;ul>
&lt;li>module 宣告與依賴&lt;/li>
&lt;li>變數、零值與流程控制&lt;/li>
&lt;li>單檔、多檔案與跨 package 呼叫&lt;/li>
&lt;li>入口點與應用啟動&lt;/li>
&lt;li>建構函式與方法&lt;/li>
&lt;li>receiver 與狀態方法&lt;/li>
&lt;li>Go command、format、test、module tidy&lt;/li>
&lt;/ul>
&lt;h2 id="預備知識">預備知識&lt;/h2>
&lt;ul>
&lt;li>基本變數、函式、條件判斷&lt;/li>
&lt;li>知道命令列程式或 HTTP server 的基本概念&lt;/li>
&lt;/ul>
&lt;h2 id="學習時間">學習時間&lt;/h2>
&lt;p>預計 140-170 分鐘&lt;/p></description><content:encoded><![CDATA[<p>本模組帶你建立閱讀 Go 程式需要的基本模型。重點是理解 module、變數、控制流程、package、檔案拆分、函式、入口程式與 Go tooling 如何組成日常開發流程。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go/01-basics/modules/" data-link-title="1.1 Go 專案結構與 module" data-link-desc="理解 go.mod、module path 與 Go 專案的依賴邊界">1.1</a></td>
          <td>Go 專案結構與 module</td>
          <td>理解 module 與 import path</td>
      </tr>
      <tr>
          <td><a href="/blog/go/01-basics/variables-zero-values/" data-link-title="1.2 變數、零值與短變數宣告" data-link-desc="理解 Go 如何宣告、初始化與使用零值">1.2</a></td>
          <td>變數、零值與短變數宣告</td>
          <td>理解 Go 如何宣告與初始化資料</td>
      </tr>
      <tr>
          <td><a href="/blog/go/01-basics/control-flow/" data-link-title="1.3 控制流程：if、for、switch" data-link-desc="掌握 Go 的條件判斷、迴圈與分支控制">1.3</a></td>
          <td>控制流程：if、for、switch</td>
          <td>掌握 Go 的基本流程控制</td>
      </tr>
      <tr>
          <td><a href="/blog/go/01-basics/packages/" data-link-title="1.4 package、檔案與可見性" data-link-desc="看懂 package main、檔案切分與大小寫可見性">1.4</a></td>
          <td>package、檔案與可見性</td>
          <td>看懂 <code>package main</code> 與大小寫可見性</td>
      </tr>
      <tr>
          <td><a href="/blog/go/01-basics/growing-files-packages/" data-link-title="1.5 從單檔到多檔案" data-link-desc="理解 Go 程式如何從 main.go 長成多檔案與多 package">1.5</a></td>
          <td>從單檔到多檔案</td>
          <td>理解 Go 程式如何從 <code>main.go</code> 長成多檔案與多 package</td>
      </tr>
      <tr>
          <td><a href="/blog/go/01-basics/functions-methods/" data-link-title="1.6 函式、方法與 receiver" data-link-desc="區分普通函式、建構函式與帶 receiver 的方法">1.6</a></td>
          <td>函式、方法與 receiver</td>
          <td>區分函式、建構函式與物件方法</td>
      </tr>
      <tr>
          <td><a href="/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">1.7</a></td>
          <td>從入口程式看應用啟動流程</td>
          <td>建立 Go 應用啟動地圖</td>
      </tr>
      <tr>
          <td><a href="/blog/go/01-basics/go-tooling-workflow/" data-link-title="1.8 Go tooling 與日常開發流程" data-link-desc="用 go run、go test、go fmt、go mod tidy 建立 Go 專案的基本工作節奏">1.8</a></td>
          <td>Go tooling 與日常開發流程</td>
          <td>用 <code>go run</code>、<code>go test</code>、<code>go fmt</code>、<code>go mod tidy</code> 建立基本工作節奏</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<ul>
<li>module 宣告與依賴</li>
<li>變數、零值與流程控制</li>
<li>單檔、多檔案與跨 package 呼叫</li>
<li>入口點與應用啟動</li>
<li>建構函式與方法</li>
<li>receiver 與狀態方法</li>
<li>Go command、format、test、module tidy</li>
</ul>
<h2 id="預備知識">預備知識</h2>
<ul>
<li>基本變數、函式、條件判斷</li>
<li>知道命令列程式或 HTTP server 的基本概念</li>
</ul>
<h2 id="學習時間">學習時間</h2>
<p>預計 140-170 分鐘</p>
]]></content:encoded></item><item><title>模組一：進階並發模式</title><link>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/</guid><description>&lt;p>Go 並發設計的核心是明確定義 ownership、生命週期、 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> 與共享狀態邊界。goroutine 很便宜，但失控的 goroutine、關閉錯誤的 channel、無限制堆積的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a>，以及外洩的可變資料都會讓服務難以維護。&lt;/p>
&lt;p>本模組承接入門篇的 goroutine、channel、select、mutex 基礎，進一步處理長時間運行服務會遇到的問題：誰能關閉 channel、worker 如何停止、channel 滿載時怎麼回應、共享 map/slice 如何避免 data race、工作量如何限制、入口速率如何控制。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&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;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/channel-ownership/" data-link-title="1.1 channel ownership 與關閉責任" data-link-desc="判斷誰能送出、接收與關閉 channel">1.1&lt;/a>&lt;/td>
 &lt;td>channel ownership 與關閉責任&lt;/td>
 &lt;td>用 sender lifecycle 判斷誰能 close channel&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">1.2&lt;/a>&lt;/td>
 &lt;td>select loop 的生命週期設計&lt;/td>
 &lt;td>同時處理輸入、ticker、取消與資源釋放&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">1.3&lt;/a>&lt;/td>
 &lt;td>非阻塞送出與事件丟棄策略&lt;/td>
 &lt;td>把 channel 滿載轉成明確服務行為&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/shared-state/" data-link-title="1.4 共享狀態與複製邊界" data-link-desc="用 lock 與 copy 保護長期服務的狀態資料">1.4&lt;/a>&lt;/td>
 &lt;td>共享狀態與複製邊界&lt;/td>
 &lt;td>用 lock、copy 與 owner method 保護可變資料&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/worker-pool/" data-link-title="1.5 bounded worker pool" data-link-desc="限制同時執行的 goroutine 數量，讓背景工作有明確容量邊界">1.5&lt;/a>&lt;/td>
 &lt;td>bounded &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool&lt;/a>&lt;/td>
 &lt;td>限制同時執行的工作量，避免 goroutine 無限制堆積&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/rate-limit/" data-link-title="1.6 rate limiting 與 backpressure " data-link-desc="用本地速率限制與 backpressure 策略保護服務入口與下游依賴">1.6&lt;/a>&lt;/td>
 &lt;td>rate limiting 與 backpressure&lt;/td>
 &lt;td>用本地速率限制保護服務入口與下游依賴&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;p>本模組使用虛構的通知與工作處理服務作為範例。範例會包含背景 worker、事件佇列、即時推送、狀態 repository 與測試 fake。&lt;/p>
&lt;p>範例只用來展示 Go 並發設計方法，不假設讀者正在維護任何特定專案。&lt;/p>
&lt;h2 id="本模組的-go-核心概念">本模組的 Go 核心概念&lt;/h2>
&lt;ul>
&lt;li>用 channel direction 表達 send-only 與 receive-only 能力。&lt;/li>
&lt;li>用 context 作為 goroutine 停止訊號。&lt;/li>
&lt;li>用 select 管理多種輸入與 ticker。&lt;/li>
&lt;li>用 buffered channel 吸收短暫尖峰，但不把 buffer 當成容量規劃替代品。&lt;/li>
&lt;li>用 mutex 保護共享 map/slice。&lt;/li>
&lt;li>用 copy boundary 防止呼叫端修改內部狀態。&lt;/li>
&lt;li>用 worker pool 控制同時執行數。&lt;/li>
&lt;li>用 rate limiter 把過量輸入轉成可預期回應。&lt;/li>
&lt;li>用 race detector 與 focused tests 驗證並發邊界。&lt;/li>
&lt;/ul>
&lt;h2 id="學習重點">學習重點&lt;/h2>
&lt;p>學完本模組後，你應該能判斷：&lt;/p>
&lt;ol>
&lt;li>哪個 goroutine 擁有 channel 的關閉責任&lt;/li>
&lt;li>一個長期 worker 停止時需要釋放哪些資源&lt;/li>
&lt;li>channel 滿載時應該等待、回錯、丟棄還是降級&lt;/li>
&lt;li>map、slice、pointer 何時會洩漏內部狀態&lt;/li>
&lt;li>什麼情況下 mutex 比 channel 更適合表達狀態擁有權&lt;/li>
&lt;li>什麼情況下需要 bounded worker pool&lt;/li>
&lt;li>入口過量時應排隊、限速、拒絕還是降級&lt;/li>
&lt;/ol>
&lt;h2 id="本模組不處理">本模組不處理&lt;/h2>
&lt;p>本模組不討論分散式鎖、actor framework 或高階 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> 系統。這些主題建立在本模組的基礎之上；本模組先把單一 Go process 內的 goroutine、worker、速率與共享資料邊界講清楚。外部 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a> 與分散式流量治理會放在 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Go 並發設計的核心是明確定義 ownership、生命週期、 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 與共享狀態邊界。goroutine 很便宜，但失控的 goroutine、關閉錯誤的 channel、無限制堆積的 <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a>，以及外洩的可變資料都會讓服務難以維護。</p>
<p>本模組承接入門篇的 goroutine、channel、select、mutex 基礎，進一步處理長時間運行服務會遇到的問題：誰能關閉 channel、worker 如何停止、channel 滿載時怎麼回應、共享 map/slice 如何避免 data race、工作量如何限制、入口速率如何控制。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go-advanced/01-concurrency-patterns/channel-ownership/" data-link-title="1.1 channel ownership 與關閉責任" data-link-desc="判斷誰能送出、接收與關閉 channel">1.1</a></td>
          <td>channel ownership 與關閉責任</td>
          <td>用 sender lifecycle 判斷誰能 close channel</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">1.2</a></td>
          <td>select loop 的生命週期設計</td>
          <td>同時處理輸入、ticker、取消與資源釋放</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">1.3</a></td>
          <td>非阻塞送出與事件丟棄策略</td>
          <td>把 channel 滿載轉成明確服務行為</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/01-concurrency-patterns/shared-state/" data-link-title="1.4 共享狀態與複製邊界" data-link-desc="用 lock 與 copy 保護長期服務的狀態資料">1.4</a></td>
          <td>共享狀態與複製邊界</td>
          <td>用 lock、copy 與 owner method 保護可變資料</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/01-concurrency-patterns/worker-pool/" data-link-title="1.5 bounded worker pool" data-link-desc="限制同時執行的 goroutine 數量，讓背景工作有明確容量邊界">1.5</a></td>
          <td>bounded <a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool</a></td>
          <td>限制同時執行的工作量，避免 goroutine 無限制堆積</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/01-concurrency-patterns/rate-limit/" data-link-title="1.6 rate limiting 與 backpressure " data-link-desc="用本地速率限制與 backpressure 策略保護服務入口與下游依賴">1.6</a></td>
          <td>rate limiting 與 backpressure</td>
          <td>用本地速率限制保護服務入口與下游依賴</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<p>本模組使用虛構的通知與工作處理服務作為範例。範例會包含背景 worker、事件佇列、即時推送、狀態 repository 與測試 fake。</p>
<p>範例只用來展示 Go 並發設計方法，不假設讀者正在維護任何特定專案。</p>
<h2 id="本模組的-go-核心概念">本模組的 Go 核心概念</h2>
<ul>
<li>用 channel direction 表達 send-only 與 receive-only 能力。</li>
<li>用 context 作為 goroutine 停止訊號。</li>
<li>用 select 管理多種輸入與 ticker。</li>
<li>用 buffered channel 吸收短暫尖峰，但不把 buffer 當成容量規劃替代品。</li>
<li>用 mutex 保護共享 map/slice。</li>
<li>用 copy boundary 防止呼叫端修改內部狀態。</li>
<li>用 worker pool 控制同時執行數。</li>
<li>用 rate limiter 把過量輸入轉成可預期回應。</li>
<li>用 race detector 與 focused tests 驗證並發邊界。</li>
</ul>
<h2 id="學習重點">學習重點</h2>
<p>學完本模組後，你應該能判斷：</p>
<ol>
<li>哪個 goroutine 擁有 channel 的關閉責任</li>
<li>一個長期 worker 停止時需要釋放哪些資源</li>
<li>channel 滿載時應該等待、回錯、丟棄還是降級</li>
<li>map、slice、pointer 何時會洩漏內部狀態</li>
<li>什麼情況下 mutex 比 channel 更適合表達狀態擁有權</li>
<li>什麼情況下需要 bounded worker pool</li>
<li>入口過量時應排隊、限速、拒絕還是降級</li>
</ol>
<h2 id="本模組不處理">本模組不處理</h2>
<p>本模組不討論分散式鎖、actor framework 或高階 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 系統。這些主題建立在本模組的基礎之上；本模組先把單一 Go process 內的 goroutine、worker、速率與共享資料邊界講清楚。外部 <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> 與分散式流量治理會放在 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a> 與 <a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a>。</p>
<h2 id="先備知識">先備知識</h2>
<ul>
<li><a href="/blog/go/04-concurrency/" data-link-title="模組四：並發模型" data-link-desc="從 goroutine、channel、select 與 RWMutex 理解 Go 並發模型">Go 入門：並發模型</a></li>
<li>知道 goroutine、channel、select、mutex 的基本用法</li>
</ul>
<h2 id="學習時間">學習時間</h2>
<p>預計 4-5 小時</p>
]]></content:encoded></item><item><title>9.2 第三方 parser 整合：goldmark AST 入門</title><link>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/goldmark-ast-basics/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/goldmark-ast-basics/</guid><description>&lt;p>第三方 parser 整合的核心責任是&lt;strong>把外部格式的語法細節封裝成可走訪的結構化樹&lt;/strong>，讓上層業務邏輯脫離字串處理，直接在 AST 節點上判讀。對 markdown 這類格式，成熟 parser（如 goldmark）提供完整 CommonMark 解析、GFM 擴充、位置資訊；上層工具透過 &lt;a href="https://tarrragon.github.io/blog/go/glossary/#ast-walker" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">AST walker&lt;/a> 接住 AST 後再決定要做 lint、&lt;a href="https://tarrragon.github.io/blog/go/09-tooling-and-analysis/ast-idempotent-rewriting/" data-link-title="9.3 AST 驅動的 idempotent 文字改寫" data-link-desc="用 AST 定位位置、用 line-based 或 byte-level 改寫；設計多條 rule 的執行順序；--check 跟 --fix 如何共用邏輯">rewrite&lt;/a>、render 或 &lt;a href="https://tarrragon.github.io/blog/go/09-tooling-and-analysis/cross-file-graph-analysis/" data-link-title="9.4 跨檔案圖分析：從 lint 走到 static analysis" data-link-desc="Single-file 規則用 AST 搞定；跨檔 orphan 偵測、broken link、backlink 完整性需要把整個 repo 建成圖再走訪。用 mdtools cards 為例">graph 分析&lt;/a>。&lt;/p>
&lt;p>Go 的慣例是&lt;strong>封一層薄 wrapper&lt;/strong> — 不讓呼叫端直接看到第三方 API 的完整型別空間，保留未來換 parser 的彈性。加上 Go 的 AST 節點通常區分 &lt;strong>block&lt;/strong> 跟 &lt;strong>inline&lt;/strong> 兩種型別（對應到 CommonMark spec），走訪時需要配合型別判讀，以免呼叫到只存在於 block 節點的 method（&lt;code>Lines()&lt;/code> 就是典型例子，對 inline 節點呼叫會 panic）。&lt;/p>
&lt;p>本章以 &lt;code>scripts/mdtools/internal/astutil&lt;/code> 跟 &lt;code>internal/mdcards/graph.go&lt;/code> 為 concrete instance 示範整合流程。更廣泛的 AST 概念背景在 &lt;a href="https://tarrragon.github.io/blog/posts/%E4%BB%80%E9%BA%BC%E6%98%AF-ast-%E5%BE%9E%E5%AD%97%E4%B8%B2%E5%88%B0%E8%AA%9E%E6%B3%95%E6%A8%B9%E7%9A%84%E8%A6%96%E8%A7%92%E8%BD%89%E6%8F%9B/" data-link-title="什麼是 AST — 從字串到語法樹的視角轉換" data-link-desc="AST 與 regex 的差異判準：規則需要知道文字處在什麼結構中時 regex 就不夠。附 regex 誤判的具體 case。">什麼是 AST&lt;/a>；本章聚焦 Go 層面的整合 pattern。&lt;/p>
&lt;h2 id="為什麼選-goldmark">為什麼選 goldmark&lt;/h2>
&lt;p>Markdown parser 在 Go 有多個選項。選 goldmark 的理由：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Hugo 內建用它&lt;/strong> — 同一個 parser 解析，lint 結果跟 render 結果一定一致。其他 parser 可能判讀差異導致「lint 過了但 Hugo render 壞」的長尾 bug。&lt;/li>
&lt;li>&lt;strong>完整 CommonMark 支援 + GFM 擴充&lt;/strong>。table、strikethrough、task list 都在。&lt;/li>
&lt;li>&lt;strong>AST 節點設計貼近 CommonMark spec&lt;/strong>：心智負擔小，節點型別直接對應 spec 用語。&lt;/li>
&lt;li>&lt;strong>純 Go、零 CGO、穩定&lt;/strong>。build 不會踩奇怪的 C 依賴。&lt;/li>
&lt;/ul>
&lt;p>類似選擇邏輯可套用到其他格式：Go 原始碼用 &lt;code>go/parser&lt;/code>，YAML 用 &lt;code>gopkg.in/yaml.v3&lt;/code>，protobuf 用 &lt;code>google.golang.org/protobuf/encoding/prototext&lt;/code>。&lt;/p>
&lt;h2 id="最小整合parse-一份-markdown">最小整合：parse 一份 markdown&lt;/h2>





&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="c1">// scripts/mdtools/internal/astutil/parser.go&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="kn">package&lt;/span> &lt;span class="nx">astutil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="kn">import&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="s">&amp;#34;github.com/yuin/goldmark&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="s">&amp;#34;github.com/yuin/goldmark/ast&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="s">&amp;#34;github.com/yuin/goldmark/extension&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">	&lt;span class="s">&amp;#34;github.com/yuin/goldmark/parser&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">	&lt;span class="s">&amp;#34;github.com/yuin/goldmark/text&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&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>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Parser&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">13&lt;/span>&lt;span class="cl">	&lt;span class="nx">md&lt;/span> &lt;span class="nx">goldmark&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Markdown&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="kd">func&lt;/span> &lt;span class="nf">NewParser&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Parser&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="nx">md&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">goldmark&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">New&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="nx">goldmark&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WithExtensions&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="nx">extension&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">GFM&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// Table, Strikethrough, Linkify, TaskList&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">		&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">	&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="k">return&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">Parser&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">md&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">md&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">p&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Parser&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Parse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">src&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">byte&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">ast&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Node&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl">	&lt;span class="nx">reader&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">text&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewReader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">src&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="k">return&lt;/span> &lt;span class="nx">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">md&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Parser&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Parse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">reader&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">parser&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WithContext&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">parser&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewContext&lt;/span>&lt;span class="p">()))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&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>Parser&lt;/code> 而不是直接呼叫 &lt;code>goldmark.New(...).Parser().Parse(...)&lt;/code>：&lt;/p></description><content:encoded><![CDATA[<p>第三方 parser 整合的核心責任是<strong>把外部格式的語法細節封裝成可走訪的結構化樹</strong>，讓上層業務邏輯脫離字串處理，直接在 AST 節點上判讀。對 markdown 這類格式，成熟 parser（如 goldmark）提供完整 CommonMark 解析、GFM 擴充、位置資訊；上層工具透過 <a href="/blog/go/glossary/#ast-walker" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">AST walker</a> 接住 AST 後再決定要做 lint、<a href="/blog/go/09-tooling-and-analysis/ast-idempotent-rewriting/" data-link-title="9.3 AST 驅動的 idempotent 文字改寫" data-link-desc="用 AST 定位位置、用 line-based 或 byte-level 改寫；設計多條 rule 的執行順序；--check 跟 --fix 如何共用邏輯">rewrite</a>、render 或 <a href="/blog/go/09-tooling-and-analysis/cross-file-graph-analysis/" data-link-title="9.4 跨檔案圖分析：從 lint 走到 static analysis" data-link-desc="Single-file 規則用 AST 搞定；跨檔 orphan 偵測、broken link、backlink 完整性需要把整個 repo 建成圖再走訪。用 mdtools cards 為例">graph 分析</a>。</p>
<p>Go 的慣例是<strong>封一層薄 wrapper</strong> — 不讓呼叫端直接看到第三方 API 的完整型別空間，保留未來換 parser 的彈性。加上 Go 的 AST 節點通常區分 <strong>block</strong> 跟 <strong>inline</strong> 兩種型別（對應到 CommonMark spec），走訪時需要配合型別判讀，以免呼叫到只存在於 block 節點的 method（<code>Lines()</code> 就是典型例子，對 inline 節點呼叫會 panic）。</p>
<p>本章以 <code>scripts/mdtools/internal/astutil</code> 跟 <code>internal/mdcards/graph.go</code> 為 concrete instance 示範整合流程。更廣泛的 AST 概念背景在 <a href="/blog/posts/%E4%BB%80%E9%BA%BC%E6%98%AF-ast-%E5%BE%9E%E5%AD%97%E4%B8%B2%E5%88%B0%E8%AA%9E%E6%B3%95%E6%A8%B9%E7%9A%84%E8%A6%96%E8%A7%92%E8%BD%89%E6%8F%9B/" data-link-title="什麼是 AST — 從字串到語法樹的視角轉換" data-link-desc="AST 與 regex 的差異判準：規則需要知道文字處在什麼結構中時 regex 就不夠。附 regex 誤判的具體 case。">什麼是 AST</a>；本章聚焦 Go 層面的整合 pattern。</p>
<h2 id="為什麼選-goldmark">為什麼選 goldmark</h2>
<p>Markdown parser 在 Go 有多個選項。選 goldmark 的理由：</p>
<ul>
<li><strong>Hugo 內建用它</strong> — 同一個 parser 解析，lint 結果跟 render 結果一定一致。其他 parser 可能判讀差異導致「lint 過了但 Hugo render 壞」的長尾 bug。</li>
<li><strong>完整 CommonMark 支援 + GFM 擴充</strong>。table、strikethrough、task list 都在。</li>
<li><strong>AST 節點設計貼近 CommonMark spec</strong>：心智負擔小，節點型別直接對應 spec 用語。</li>
<li><strong>純 Go、零 CGO、穩定</strong>。build 不會踩奇怪的 C 依賴。</li>
</ul>
<p>類似選擇邏輯可套用到其他格式：Go 原始碼用 <code>go/parser</code>，YAML 用 <code>gopkg.in/yaml.v3</code>，protobuf 用 <code>google.golang.org/protobuf/encoding/prototext</code>。</p>
<h2 id="最小整合parse-一份-markdown">最小整合：parse 一份 markdown</h2>





<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="c1">// scripts/mdtools/internal/astutil/parser.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">package</span> <span class="nx">astutil</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="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	<span class="s">&#34;github.com/yuin/goldmark&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="s">&#34;github.com/yuin/goldmark/ast&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">	<span class="s">&#34;github.com/yuin/goldmark/extension&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">	<span class="s">&#34;github.com/yuin/goldmark/parser&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">	<span class="s">&#34;github.com/yuin/goldmark/text&#34;</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></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">type</span> <span class="nx">Parser</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">	<span class="nx">md</span> <span class="nx">goldmark</span><span class="p">.</span><span class="nx">Markdown</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="kd">func</span> <span class="nf">NewParser</span><span class="p">()</span> <span class="o">*</span><span class="nx">Parser</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">	<span class="nx">md</span> <span class="o">:=</span> <span class="nx">goldmark</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">		<span class="nx">goldmark</span><span class="p">.</span><span class="nf">WithExtensions</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">			<span class="nx">extension</span><span class="p">.</span><span class="nx">GFM</span><span class="p">,</span> <span class="c1">// Table, Strikethrough, Linkify, TaskList</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="k">return</span> <span class="o">&amp;</span><span class="nx">Parser</span><span class="p">{</span><span class="nx">md</span><span class="p">:</span> <span class="nx">md</span><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></span><span class="line"><span class="ln">25</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">p</span> <span class="o">*</span><span class="nx">Parser</span><span class="p">)</span> <span class="nf">Parse</span><span class="p">(</span><span class="nx">src</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">Node</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">	<span class="nx">reader</span> <span class="o">:=</span> <span class="nx">text</span><span class="p">.</span><span class="nf">NewReader</span><span class="p">(</span><span class="nx">src</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">	<span class="k">return</span> <span class="nx">p</span><span class="p">.</span><span class="nx">md</span><span class="p">.</span><span class="nf">Parser</span><span class="p">().</span><span class="nf">Parse</span><span class="p">(</span><span class="nx">reader</span><span class="p">,</span> <span class="nx">parser</span><span class="p">.</span><span class="nf">WithContext</span><span class="p">(</span><span class="nx">parser</span><span class="p">.</span><span class="nf">NewContext</span><span class="p">()))</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>為什麼包一層 <code>Parser</code> 而不是直接呼叫 <code>goldmark.New(...).Parser().Parse(...)</code>：</p>
<ul>
<li><strong>第三方 API 面積大</strong>，工具只需要其中一部分。封裝讓呼叫端看不到 <code>goldmark.Markdown</code>、<code>parser.NewContext</code> 這些細節。</li>
<li><strong>未來換 parser 成本低</strong>：如果有天換 mistune-for-go 或自寫 parser，呼叫端的 <code>astutil.NewParser().Parse(src)</code> 不用改。</li>
<li><strong>測試替身容易</strong>：unit test 可以 mock <code>Parser</code> interface。</li>
</ul>
<p>三個 struct / package / extension 配置的預設值：</p>
<ul>
<li><strong>Extensions</strong>：<code>extension.GFM</code> 涵蓋 blog 需要的全部；只啟用實際用到的 extension 讓 parser 行為可預測。</li>
<li><strong>Context</strong>：每次 <code>Parse</code> 都建新 context — goldmark context 儲存 parse 狀態，不能跨 parse 共用。</li>
</ul>
<h2 id="ast-節點階層block-跟-inline-的分野">AST 節點階層：Block 跟 Inline 的分野</h2>
<p>goldmark 的 AST 節點有兩大類，型別系統直接區分：</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="c1">// goldmark/ast/ast.go</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">type</span> <span class="nx">NodeType</span> <span class="kt">int</span>
</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">TypeDocument</span> <span class="nx">NodeType</span> <span class="p">=</span> <span class="kc">iota</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">	<span class="nx">TypeBlock</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">	<span class="nx">TypeInline</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p><strong>Block 節點</strong>：段落、heading、list、table、blockquote、fenced code block — 在來源檔案中占據完整的行。這類節點帶有 source line segments，能用 <code>n.Lines()</code> 取得起訖位置。</p>
<p><strong>Inline 節點</strong>：link、emphasis、text、code span、image — 存在於 block 節點內部。Inline 節點<strong>沒有獨立的 line segments</strong>；它們的位置由父 block 管理。</p>
<p>這個區分有個實戰後果。第一次寫 AST 走訪的人經常這樣寫：</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="c1">// WRONG: 對 inline 節點呼叫 Lines() 會 panic</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">ast</span><span class="p">.</span><span class="nf">Walk</span><span class="p">(</span><span class="nx">doc</span><span class="p">,</span> <span class="kd">func</span><span class="p">(</span><span class="nx">n</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">Node</span><span class="p">,</span> <span class="nx">entering</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">(</span><span class="nx">ast</span><span class="p">.</span><span class="nx">WalkStatus</span><span class="p">,</span> <span class="kt">error</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">link</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">n</span><span class="p">.(</span><span class="o">*</span><span class="nx">ast</span><span class="p">.</span><span class="nx">Link</span><span class="p">)</span> <span class="c1">// Link 是 inline</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="k">return</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</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="nx">segs</span> <span class="o">:=</span> <span class="nx">link</span><span class="p">.</span><span class="nf">Lines</span><span class="p">()</span> <span class="c1">// panic: &#34;can not call with inline nodes&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">	<span class="o">...</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><p>Link 節點沒有 Lines()。正確做法是<strong>走上去找最近的 block 節點</strong>：</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="c1">// scripts/mdtools/internal/mdcards/graph.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">nodeLine</span><span class="p">(</span><span class="nx">n</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">Node</span><span class="p">,</span> <span class="nx">src</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="kt">int</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">p</span> <span class="o">:=</span> <span class="nx">n</span><span class="p">;</span> <span class="nx">p</span> <span class="o">!=</span> <span class="kc">nil</span><span class="p">;</span> <span class="nx">p</span> <span class="p">=</span> <span class="nx">p</span><span class="p">.</span><span class="nf">Parent</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">if</span> <span class="nx">p</span><span class="p">.</span><span class="nf">Type</span><span class="p">()</span> <span class="o">!=</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">TypeBlock</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">			<span class="k">continue</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="nx">segs</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nf">Lines</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">		<span class="k">if</span> <span class="nx">segs</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="o">&amp;&amp;</span> <span class="nx">segs</span><span class="p">.</span><span class="nf">Len</span><span class="p">()</span> <span class="p">&gt;</span> <span class="mi">0</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="nf">lineNumber</span><span class="p">(</span><span class="nx">src</span><span class="p">,</span> <span class="nx">segs</span><span class="p">.</span><span class="nf">At</span><span class="p">(</span><span class="mi">0</span><span class="p">).</span><span class="nx">Start</span><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><span class="line"><span class="ln">12</span><span class="cl">	<span class="k">return</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 walk-up-to-block 模式在每個會操作 inline 節點的工具裡都會出現。<strong>初學者的第一個 goldmark panic 幾乎必然是這個</strong>。</p>
<h2 id="astwalk-visitor-模式"><code>ast.Walk</code> visitor 模式</h2>
<p>goldmark 用標準 visitor pattern 走 AST：</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="nx">ast</span><span class="p">.</span><span class="nf">Walk</span><span class="p">(</span><span class="nx">doc</span><span class="p">,</span> <span class="kd">func</span><span class="p">(</span><span class="nx">n</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">Node</span><span class="p">,</span> <span class="nx">entering</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">(</span><span class="nx">ast</span><span class="p">.</span><span class="nx">WalkStatus</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">	<span class="c1">// entering == true：進入節點（DFS 下行）</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">	<span class="c1">// entering == false：離開節點（DFS 回溯）</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">	<span class="k">return</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><p>Walk status 三個常見值：</p>
<ul>
<li><code>ast.WalkContinue</code> — 繼續深度優先走訪。</li>
<li><code>ast.WalkSkipChildren</code> — 跳過子樹，繼續走同層。適合當「處理完整個 Paragraph 就不用再進去找子 Link」。</li>
<li><code>ast.WalkStop</code> — 整個走訪中止。適合「找到第一個就結束」。</li>
</ul>
<p>實戰中幾乎只處理 <code>entering == true</code> 的情境 — DFS 下行足以覆蓋多數規則。<code>entering == false</code> 的 post-order 位置保留給需要聚合子樹資訊的場景（例如計算子樹裡的 link 數量）。</p>
<h2 id="實戰抽出所有-link-節點並計算位置">實戰：抽出所有 Link 節點並計算位置</h2>
<p><code>mdtools cards</code> 要找所有相對連結。這是一個完整的 <code>ast.Walk</code> 應用：</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="c1">// scripts/mdtools/internal/mdcards/graph.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">g</span> <span class="o">*</span><span class="nx">Graph</span><span class="p">)</span> <span class="nf">extractEdges</span><span class="p">(</span><span class="nx">fn</span> <span class="nx">FileNode</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">ast</span><span class="p">.</span><span class="nf">Walk</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">AST</span><span class="p">,</span> <span class="kd">func</span><span class="p">(</span><span class="nx">n</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">Node</span><span class="p">,</span> <span class="nx">entering</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">(</span><span class="nx">ast</span><span class="p">.</span><span class="nx">WalkStatus</span><span class="p">,</span> <span class="kt">error</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">if</span> <span class="p">!</span><span class="nx">entering</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">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</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="nx">link</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">n</span><span class="p">.(</span><span class="o">*</span><span class="nx">ast</span><span class="p">.</span><span class="nx">Link</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</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"> 9</span><span class="cl">			<span class="k">return</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</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="nx">dest</span> <span class="o">:=</span> <span class="nb">string</span><span class="p">(</span><span class="nx">link</span><span class="p">.</span><span class="nx">Destination</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="nf">isExternalOrAnchor</span><span class="p">(</span><span class="nx">dest</span><span class="p">)</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">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</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="nx">target</span> <span class="o">:=</span> <span class="nf">resolveTarget</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">,</span> <span class="nx">dest</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">		<span class="k">if</span> <span class="nx">target</span> <span class="o">==</span> <span class="s">&#34;&#34;</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="nx">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</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="nx">g</span><span class="p">.</span><span class="nx">Edges</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">g</span><span class="p">.</span><span class="nx">Edges</span><span class="p">,</span> <span class="nx">Edge</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">			<span class="nx">SourcePath</span><span class="p">:</span>  <span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">			<span class="nx">SourceLine</span><span class="p">:</span>  <span class="nf">nodeLine</span><span class="p">(</span><span class="nx">n</span><span class="p">,</span> <span class="nx">fn</span><span class="p">.</span><span class="nx">Src</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">			<span class="nx">Destination</span><span class="p">:</span> <span class="nx">dest</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">			<span class="nx">Target</span><span class="p">:</span>      <span class="nx">target</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">			<span class="nx">DisplayText</span><span class="p">:</span> <span class="nb">string</span><span class="p">(</span><span class="nx">link</span><span class="p">.</span><span class="nf">Text</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">Src</span><span class="p">)),</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">		<span class="p">})</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">		<span class="k">return</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">	<span class="p">})</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>關鍵操作：</p>
<ul>
<li><strong>Type assertion 提前 filter</strong>：<code>link, ok := n.(*ast.Link)</code>。不是 Link 就直接 continue，不做無用工。</li>
<li><strong>判讀早退</strong>：<code>isExternalOrAnchor(dest)</code> 先過濾 <code>http://</code> 與 <code>#anchor</code> 這類不屬於 graph 的邊。</li>
<li><strong>對 inline 節點取行號走 walk-up</strong>（上節講的 <code>nodeLine</code>）。</li>
<li><strong>text 要透過 <code>link.Text(fn.Src)</code> 取</strong> — inline 節點的文字儲存為 source 的 byte segment，不是 string。<code>link.Text()</code> 需要帶 src 才能反推。</li>
</ul>
<h2 id="byte-offset-定位到行號">Byte offset 定位到行號</h2>
<p>goldmark 的 source segment 用 byte offset 標註起訖。要轉成 1-based line number：</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="c1">// scripts/mdtools/internal/mdcards/graph.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">lineNumber</span><span class="p">(</span><span class="nx">src</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">,</span> <span class="nx">offset</span> <span class="kt">int</span><span class="p">)</span> <span class="kt">int</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="nx">offset</span> <span class="p">&lt;</span> <span class="mi">0</span> <span class="o">||</span> <span class="nx">offset</span> <span class="p">&gt;</span> <span class="nb">len</span><span class="p">(</span><span class="nx">src</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">return</span> <span class="mi">0</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">line</span> <span class="o">:=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">	<span class="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="nx">offset</span> <span class="o">&amp;&amp;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="nx">src</span><span class="p">);</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">		<span class="k">if</span> <span class="nx">src</span><span class="p">[</span><span class="nx">i</span><span class="p">]</span> <span class="o">==</span> <span class="sc">&#39;\n&#39;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">			<span class="nx">line</span><span class="o">++</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><span class="line"><span class="ln">12</span><span class="cl">	<span class="k">return</span> <span class="nx">line</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>O(offset) scan。對 300-檔 / 每檔 500 行的 blog repo 夠快；若是幾千萬行的 codebase 才需要預建 line-offset table。</p>
<h2 id="textsegment-跟-byte-slice-的對應">text.Segment 跟 byte slice 的對應</h2>
<p>每個 block 節點的 <code>Lines()</code> 回傳 <code>*text.Segments</code>，裡面是多個 <code>text.Segment{Start, Stop int}</code>：</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="c1">// 取段落第一行的原始 byte 內容</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">segs</span> <span class="o">:=</span> <span class="nx">paragraph</span><span class="p">.</span><span class="nf">Lines</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">firstSeg</span> <span class="o">:=</span> <span class="nx">segs</span><span class="p">.</span><span class="nf">At</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">lineBytes</span> <span class="o">:=</span> <span class="nx">src</span><span class="p">[</span><span class="nx">firstSeg</span><span class="p">.</span><span class="nx">Start</span><span class="p">:</span><span class="nx">firstSeg</span><span class="p">.</span><span class="nx">Stop</span><span class="p">]</span></span></span></code></pre></div><p>這個 API 讓你能回頭看原始 source，而不是透過 AST 重新渲染。對 lint 工具（要報告精確位置、甚至 rewrite）很重要。</p>
<h2 id="常見陷阱">常見陷阱</h2>
<h3 id="對-inline-節點呼叫-lines">對 inline 節點呼叫 <code>Lines()</code></h3>
<p>已經講過，補一句：不只 Link，還有 Text、CodeSpan、Emphasis、Image — 凡是 <code>n.Type() == ast.TypeInline</code> 都不能 <code>Lines()</code>。寫 rule 時永遠用 <code>nodeLine</code> helper。</p>
<h3 id="忘記-gfm-extensiontable-節點會少">忘記 GFM extension，Table 節點會少</h3>
<p>預設 <code>goldmark.New()</code> 沒開 GFM。content 裡的表格會被當成普通段落 parse，<code>ast.Walk</code> 根本找不到 <code>*extension.ast.Table</code> 節點。永遠在 <code>goldmark.WithExtensions(extension.GFM)</code>。</p>
<h3 id="用-stringsrc-當作可變字串操作">用 <code>string(src)</code> 當作可變字串操作</h3>
<p>goldmark 預期 src 在 Parse 過程中 <strong>保持不變</strong>。若要改動，應先讀 <code>src</code>、parse、收集位置、<strong>產生新 byte slice</strong>；以不可變輸入 + 新輸出替代 in-place mutation。</p>
<h3 id="astwalk-忘記回傳-continue"><code>ast.Walk</code> 忘記回傳 continue</h3>





<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="nx">ast</span><span class="p">.</span><span class="nf">Walk</span><span class="p">(</span><span class="nx">doc</span><span class="p">,</span> <span class="kd">func</span><span class="p">(</span><span class="nx">n</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">Node</span><span class="p">,</span> <span class="nx">entering</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">(</span><span class="nx">ast</span><span class="p">.</span><span class="nx">WalkStatus</span><span class="p">,</span> <span class="kt">error</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">someCondition</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">		<span class="nf">processNode</span><span class="p">(</span><span class="nx">n</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">		<span class="c1">// 忘記 return，編譯失敗；但加 return 0 會提前終止</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="k">return</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><p>預設要回 <code>ast.WalkContinue, nil</code>。早退用 <code>ast.WalkSkipChildren</code> 或 <code>ast.WalkStop</code>，別用 bare <code>return</code>。</p>
<h2 id="擴充路徑">擴充路徑</h2>
<ul>
<li><strong>解析自己的 Go 原始碼</strong>：改用 <code>go/parser</code> + <code>go/ast</code>。語法樹更複雜（型別、scope、import），但 visitor pattern 本質一樣。參考 gopls 或 stringer 的原始碼。</li>
<li><strong>寫自訂 extension</strong>：goldmark 允許註冊 parse-time 與 render-time 的 extension（自己的 block / inline 語法、或接管某個節點的 render 行為）。但除非你的 markdown 有特殊語法（Hugo shortcode 之類），大多數工具不用走這層。</li>
<li><strong>AST 快照比對測試</strong>：用 <code>go-cmp</code> 比對 <code>ast.Walk</code> 抓出的節點序列；新版 goldmark 升級時能快速發現相容性問題。</li>
</ul>
<h2 id="下一步">下一步</h2>
<p><a href="/blog/go/09-tooling-and-analysis/ast-idempotent-rewriting/" data-link-title="9.3 AST 驅動的 idempotent 文字改寫" data-link-desc="用 AST 定位位置、用 line-based 或 byte-level 改寫；設計多條 rule 的執行順序；--check 跟 --fix 如何共用邏輯">9.3 AST 驅動的 idempotent 文字改寫</a> 會接著看怎麼從「讀 AST」走到「改原檔案」— 這是 <code>mdtools fmt --fix</code> 的核心。</p>
]]></content:encoded></item><item><title>8.2 PayPal：支付平台與 NoSQL / build pipelines</title><link>https://tarrragon.github.io/blog/go/08-case-studies/paypal/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/08-case-studies/paypal/</guid><description>&lt;p>PayPal 的案例很適合拿來理解 Go 在複雜企業系統中的角色。官方案例提到，他們的 NoSQL 與 DB proxy 原本在多執行緒模式下非常複雜，而 Go 的 channels 與 goroutines 幫助團隊把這些條件收斂成更清楚的結構。之後，PayPal 也把 build、test、release pipelines 建在 Go 上。&lt;/p>
&lt;h2 id="你應該看什麼">你應該看什麼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://go.dev/solutions/paypal">PayPal Taps Go to Modernize and Scale&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼&lt;/h2>
&lt;ol>
&lt;li>Go 不只適合對外服務，也適合內部工程平台。&lt;/li>
&lt;li>當系統條件變多時，明確並發模型比隱式 thread 管理更容易維護。&lt;/li>
&lt;li>Go 的價值常常在於讓大系統更容易演進。&lt;/li>
&lt;/ol>
&lt;h2 id="可對照的公開原始碼">可對照的公開原始碼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://github.com/paypal/paypal-rest-api-specifications">paypal/paypal-rest-api-specifications&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/paypal">paypal/github organization&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>PayPal 的 Go 內部系統細節不會完整公開，但它的公開 API spec 與 SDK 生態，能幫你理解大型支付平台如何維持清楚的外部合約。&lt;/p></description><content:encoded><![CDATA[<p>PayPal 的案例很適合拿來理解 Go 在複雜企業系統中的角色。官方案例提到，他們的 NoSQL 與 DB proxy 原本在多執行緒模式下非常複雜，而 Go 的 channels 與 goroutines 幫助團隊把這些條件收斂成更清楚的結構。之後，PayPal 也把 build、test、release pipelines 建在 Go 上。</p>
<h2 id="你應該看什麼">你應該看什麼</h2>
<ul>
<li><a href="https://go.dev/solutions/paypal">PayPal Taps Go to Modernize and Scale</a></li>
</ul>
<h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼</h2>
<ol>
<li>Go 不只適合對外服務，也適合內部工程平台。</li>
<li>當系統條件變多時，明確並發模型比隱式 thread 管理更容易維護。</li>
<li>Go 的價值常常在於讓大系統更容易演進。</li>
</ol>
<h2 id="可對照的公開原始碼">可對照的公開原始碼</h2>
<ul>
<li><a href="https://github.com/paypal/paypal-rest-api-specifications">paypal/paypal-rest-api-specifications</a></li>
<li><a href="https://github.com/paypal">paypal/github organization</a></li>
</ul>
<p>PayPal 的 Go 內部系統細節不會完整公開，但它的公開 API spec 與 SDK 生態，能幫你理解大型支付平台如何維持清楚的外部合約。</p>
]]></content:encoded></item><item><title>0.2 組合優先：小介面與明確依賴</title><link>https://tarrragon.github.io/blog/go/00-philosophy/composition/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/00-philosophy/composition/</guid><description>&lt;p>Go 組合的核心原則是用小型型別與小介面拼出行為。程式需要什麼能力，就依賴那個能力；型別擁有哪些資料，就把資料明確放在 struct 裡。這種設計讓高併發服務更容易拆解責任，也更容易在選型成立時維持邊界清楚。&lt;/p>
&lt;h2 id="為什麼這章在第零章">為什麼這章在第零章&lt;/h2>
&lt;p>當你的工作負載本來就適合 Go 時，真正需要確認的就不只是語法，而是 Go 如何幫你把服務邊界維持清楚。組合優先讓 &lt;code>main()&lt;/code>、constructor、handler、worker 與 repository 的責任能被直接看見，這是 Go 在服務型專案中很重要的可維護性來源。&lt;/p>
&lt;h2 id="組合先描述擁有什麼">組合先描述擁有什麼&lt;/h2>
&lt;p>struct 的核心責任是把一組資料與依賴放在同一個明確邊界內。Go 不用 class inheritance 表達「某個型別繼承另一個型別」，而是用欄位組合出需要的結構。&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">Logger&lt;/span> &lt;span class="kd">interface&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="nf">Info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">message&lt;/span> &lt;span class="kt">string&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="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">Server&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">addr&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">logger&lt;/span> &lt;span class="nx">Logger&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>&lt;code>Server&lt;/code> 擁有一個地址，也依賴一個 logger。這些資訊都在欄位上直接呈現，讀者不需要追蹤隱式容器或父類別初始化順序。&lt;/p>
&lt;p>當依賴變多時，struct 仍然應該只保留這個型別真正需要的依賴。把整個 application container 塞進 struct，通常會讓依賴邊界變模糊。&lt;/p>
&lt;h2 id="小介面先描述需要什麼">小介面先描述需要什麼&lt;/h2>
&lt;p>interface 的核心責任是描述呼叫端需要的行為。Go 的介面通常很小，常見的好介面只有一到三個方法。&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">UserFinder&lt;/span> &lt;span class="kd">interface&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="nf">FindUser&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">id&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">User&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">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">UserHandler&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">finder&lt;/span> &lt;span class="nx">UserFinder&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>&lt;code>UserHandler&lt;/code> 不需要知道資料來自資料庫、快取或遠端 API。它只需要「可以用 id 找使用者」這個能力，因此介面只放 &lt;code>FindUser&lt;/code>。&lt;/p>
&lt;p>介面應由替換、測試或隔離需求推動。只有一個具體型別，而且沒有測試替身或替換需求時，先直接依賴具體型別通常更簡單。過度抽象的代價是把原本簡單的依賴藏成難追蹤的間接關係，反而比直接依賴更難閱讀。&lt;/p>
&lt;h2 id="依賴由外層組裝">依賴由外層組裝&lt;/h2>
&lt;p>Go 應用組裝依賴的核心策略是讓外層建立具體型別，內層只接收自己需要的依賴。常見位置是 &lt;code>main()&lt;/code> 或專門的 constructor。&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">NewUserHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">finder&lt;/span> &lt;span class="nx">UserFinder&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">UserHandler&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">return&lt;/span> &lt;span class="nx">UserHandler&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">finder&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">finder&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="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="nf">main&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"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">db&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewDatabase&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;postgres://localhost/app&amp;#34;&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="nx">repository&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewUserRepository&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">db&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="nx">handler&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewUserHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">repository&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>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">HandleFunc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;/users/&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">handler&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ServeHTTP&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式讓資料流與依賴關係保持可見：repository 依賴 db，handler 依賴 repository 的查詢能力。Go 的組合方式偏好把這些關係寫出來，而不是藏在 framework magic 裡。&lt;/p>
&lt;h2 id="行為可以用-embedding-重用">行為可以用 embedding 重用&lt;/h2>
&lt;p>embedding 的核心用途是把一個型別的欄位或方法提升到外層型別。它是組合工具；若需要表達明確擁有關係，named field 會比繼承式心智模型更清楚。&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">AuditFields&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">CreatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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">UpdatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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="kd">type&lt;/span> &lt;span class="nx">User&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"> 7&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"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">Email&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">AuditFields&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&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>User&lt;/code> 透過 embedding 擁有 &lt;code>CreatedAt&lt;/code> 與 &lt;code>UpdatedAt&lt;/code>。這適合重用資料欄位，但不代表 &lt;code>User&lt;/code> 在概念上「繼承」了 &lt;code>AuditFields&lt;/code> 的完整行為。&lt;/p>
&lt;p>embedding 應該用在語意自然的地方。若提升方法會讓外層型別出現不該公開的能力，明確寫欄位名稱通常更安全。&lt;/p>
&lt;h2 id="組合讓測試替換更自然">組合讓測試替換更自然&lt;/h2>
&lt;p>組合的測試價值是可以替換依賴，而不需要啟動整個系統。只要 production code 依賴小介面，測試就能提供 fake。&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">fakeUserFinder&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">user&lt;/span> &lt;span class="nx">User&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">err&lt;/span> &lt;span class="kt">error&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="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">f&lt;/span> &lt;span class="nx">fakeUserFinder&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">FindUser&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">id&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">f&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"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">User&lt;/span>&lt;span class="p">{},&lt;/span> &lt;span class="nx">f&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">err&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &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">f&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">user&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>測試 handler 時，可以把 &lt;code>fakeUserFinder&lt;/code> 傳進去，專注檢查 HTTP response。這種替換的目標是讓測試只覆蓋當前邊界的行為。&lt;/p></description><content:encoded><![CDATA[<p>Go 組合的核心原則是用小型型別與小介面拼出行為。程式需要什麼能力，就依賴那個能力；型別擁有哪些資料，就把資料明確放在 struct 裡。這種設計讓高併發服務更容易拆解責任，也更容易在選型成立時維持邊界清楚。</p>
<h2 id="為什麼這章在第零章">為什麼這章在第零章</h2>
<p>當你的工作負載本來就適合 Go 時，真正需要確認的就不只是語法，而是 Go 如何幫你把服務邊界維持清楚。組合優先讓 <code>main()</code>、constructor、handler、worker 與 repository 的責任能被直接看見，這是 Go 在服務型專案中很重要的可維護性來源。</p>
<h2 id="組合先描述擁有什麼">組合先描述擁有什麼</h2>
<p>struct 的核心責任是把一組資料與依賴放在同一個明確邊界內。Go 不用 class inheritance 表達「某個型別繼承另一個型別」，而是用欄位組合出需要的結構。</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">Logger</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">Info</span><span class="p">(</span><span class="nx">message</span> <span class="kt">string</span><span class="p">)</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">Server</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">addr</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"> <span class="nx">logger</span> <span class="nx">Logger</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>Server</code> 擁有一個地址，也依賴一個 logger。這些資訊都在欄位上直接呈現，讀者不需要追蹤隱式容器或父類別初始化順序。</p>
<p>當依賴變多時，struct 仍然應該只保留這個型別真正需要的依賴。把整個 application container 塞進 struct，通常會讓依賴邊界變模糊。</p>
<h2 id="小介面先描述需要什麼">小介面先描述需要什麼</h2>
<p>interface 的核心責任是描述呼叫端需要的行為。Go 的介面通常很小，常見的好介面只有一到三個方法。</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">UserFinder</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">FindUser</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">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">User</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</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">UserHandler</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">finder</span> <span class="nx">UserFinder</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>UserHandler</code> 不需要知道資料來自資料庫、快取或遠端 API。它只需要「可以用 id 找使用者」這個能力，因此介面只放 <code>FindUser</code>。</p>
<p>介面應由替換、測試或隔離需求推動。只有一個具體型別，而且沒有測試替身或替換需求時，先直接依賴具體型別通常更簡單。過度抽象的代價是把原本簡單的依賴藏成難追蹤的間接關係，反而比直接依賴更難閱讀。</p>
<h2 id="依賴由外層組裝">依賴由外層組裝</h2>
<p>Go 應用組裝依賴的核心策略是讓外層建立具體型別，內層只接收自己需要的依賴。常見位置是 <code>main()</code> 或專門的 constructor。</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">NewUserHandler</span><span class="p">(</span><span class="nx">finder</span> <span class="nx">UserFinder</span><span class="p">)</span> <span class="nx">UserHandler</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"> <span class="k">return</span> <span class="nx">UserHandler</span><span class="p">{</span><span class="nx">finder</span><span class="p">:</span> <span class="nx">finder</span><span class="p">}</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="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"> <span class="nx">db</span> <span class="o">:=</span> <span class="nf">NewDatabase</span><span class="p">(</span><span class="s">&#34;postgres://localhost/app&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"> <span class="nx">repository</span> <span class="o">:=</span> <span class="nf">NewUserRepository</span><span class="p">(</span><span class="nx">db</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"> <span class="nx">handler</span> <span class="o">:=</span> <span class="nf">NewUserHandler</span><span class="p">(</span><span class="nx">repository</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="nx">http</span><span class="p">.</span><span class="nf">HandleFunc</span><span class="p">(</span><span class="s">&#34;/users/&#34;</span><span class="p">,</span> <span class="nx">handler</span><span class="p">.</span><span class="nx">ServeHTTP</span><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>這段程式讓資料流與依賴關係保持可見：repository 依賴 db，handler 依賴 repository 的查詢能力。Go 的組合方式偏好把這些關係寫出來，而不是藏在 framework magic 裡。</p>
<h2 id="行為可以用-embedding-重用">行為可以用 embedding 重用</h2>
<p>embedding 的核心用途是把一個型別的欄位或方法提升到外層型別。它是組合工具；若需要表達明確擁有關係，named field 會比繼承式心智模型更清楚。</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">AuditFields</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">CreatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"> <span class="nx">UpdatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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="kd">type</span> <span class="nx">User</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"> <span class="nx">ID</span>    <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"> <span class="nx">Email</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"> <span class="nx">AuditFields</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>User</code> 透過 embedding 擁有 <code>CreatedAt</code> 與 <code>UpdatedAt</code>。這適合重用資料欄位，但不代表 <code>User</code> 在概念上「繼承」了 <code>AuditFields</code> 的完整行為。</p>
<p>embedding 應該用在語意自然的地方。若提升方法會讓外層型別出現不該公開的能力，明確寫欄位名稱通常更安全。</p>
<h2 id="組合讓測試替換更自然">組合讓測試替換更自然</h2>
<p>組合的測試價值是可以替換依賴，而不需要啟動整個系統。只要 production code 依賴小介面，測試就能提供 fake。</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">fakeUserFinder</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">user</span> <span class="nx">User</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">err</span>  <span class="kt">error</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="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="nx">fakeUserFinder</span><span class="p">)</span> <span class="nf">FindUser</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">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">User</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"> <span class="k">if</span> <span class="nx">f</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"> 8</span><span class="cl">     <span class="k">return</span> <span class="nx">User</span><span class="p">{},</span> <span class="nx">f</span><span class="p">.</span><span class="nx">err</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">return</span> <span class="nx">f</span><span class="p">.</span><span class="nx">user</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>測試 handler 時，可以把 <code>fakeUserFinder</code> 傳進去，專注檢查 HTTP response。這種替換的目標是讓測試只覆蓋當前邊界的行為。</p>
]]></content:encoded></item><item><title>1.2 select loop 的生命週期設計</title><link>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/select-loop/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/select-loop/</guid><description>&lt;p>&lt;code>select&lt;/code> loop 的核心責任是管理長時間 goroutine 的生命週期。它不只是等待多個 channel 的語法，而是決定元件如何接收輸入、處理定時任務、回應取消、釋放資源與停止。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>拆解 &lt;code>select&lt;/code> loop 中每個 case 的責任&lt;/li>
&lt;li>用 &lt;code>ctx.Done()&lt;/code> 設計一致的退出路徑&lt;/li>
&lt;li>正確建立與停止 ticker&lt;/li>
&lt;li>處理 channel 關閉後的 nil channel pattern&lt;/li>
&lt;li>測試 worker 在事件、ticker、取消下的行為&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察長期-goroutine-通常同時等待多種訊號">【觀察】長期 goroutine 通常同時等待多種訊號&lt;/h2>
&lt;p>長期 goroutine 的核心特徵是它不只處理一種資料。背景 worker 可能同時等待外部事件、定時掃描、清理工作與停止訊號。&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">w&lt;/span> &lt;span class="nx">Worker&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Run&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="kt">error&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">statusTicker&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewTicker&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">statusInterval&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">defer&lt;/span> &lt;span class="nx">statusTicker&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Stop&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">cleanupTicker&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewTicker&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">cleanupInterval&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">defer&lt;/span> &lt;span class="nx">cleanupTicker&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Stop&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="k">select&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">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Err&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">case&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">events&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">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">ok&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="kc">nil&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="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">processEvent&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">event&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">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">statusTicker&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">C&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="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">scanStatus&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&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="k">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">cleanupTicker&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">C&lt;/span>&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 class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">cleanup&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &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="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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 loop 的責任不是「跑一個無限迴圈」，而是定義 worker 活著時能接受哪些訊號，以及停止時要如何退出。&lt;/p>
&lt;h2 id="判讀select-loop-是元件的生命週期表">【判讀】select loop 是元件的生命週期表&lt;/h2>
&lt;p>&lt;code>select&lt;/code> loop 的核心價值是把元件生命週期寫成明確表格。每個 case 都應該能回答：收到什麼訊號、代表什麼意思、下一步做什麼。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>case&lt;/th>
 &lt;th>系統意義&lt;/th>
 &lt;th>下一步&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>ctx.Done()&lt;/code>&lt;/td>
 &lt;td>上層要求停止&lt;/td>
 &lt;td>回傳 context error&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>w.events&lt;/code>&lt;/td>
 &lt;td>收到外部事件&lt;/td>
 &lt;td>套用處理流程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>statusTicker.C&lt;/code>&lt;/td>
 &lt;td>到時間掃描狀態&lt;/td>
 &lt;td>執行週期任務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>cleanupTicker.C&lt;/code>&lt;/td>
 &lt;td>到時間清理暫存資料&lt;/td>
 &lt;td>回收資源&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>若某個 case 的意義說不清楚，通常代表 worker 責任太多，或事件來源還沒有被整理成清楚的 channel。&lt;/p>
&lt;h2 id="策略每個長期-goroutine-先回答四個問題">【策略】每個長期 goroutine 先回答四個問題&lt;/h2>
&lt;p>Select loop 設計的核心檢查是生命週期，而不是語法。寫 loop 前先回答四個問題：&lt;/p>
&lt;ol>
&lt;li>誰能停止它？&lt;/li>
&lt;li>它消費哪些輸入？&lt;/li>
&lt;li>它擁有哪些資源？&lt;/li>
&lt;li>停止時要回報錯誤、靜默退出，還是交給上層判斷？&lt;/li>
&lt;/ol>
&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">type&lt;/span> &lt;span class="nx">Worker&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">events&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">Event&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">statusInterval&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Duration&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">cleanupInterval&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Duration&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">processor&lt;/span> &lt;span class="nx">Processor&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;p>&lt;code>Worker&lt;/code> 消費 &lt;code>events&lt;/code>，擁有兩個 ticker，停止訊號來自 &lt;code>context.Context&lt;/code>。這些資訊應該能從型別與 &lt;code>Run&lt;/code> 方法看出來，而不是藏在任意 goroutine 裡。&lt;/p>
&lt;h2 id="執行ticker-要由使用者停止">【執行】ticker 要由使用者停止&lt;/h2>
&lt;p>Ticker 的核心規則是建立者負責停止。&lt;code>time.NewTicker&lt;/code> 會建立 runtime 資源；不再使用時應呼叫 &lt;code>Stop&lt;/code>。&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">w&lt;/span> &lt;span class="nx">Worker&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Run&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="kt">error&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">ticker&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewTicker&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">interval&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">defer&lt;/span> &lt;span class="nx">ticker&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Stop&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&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"> 6&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Err&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="o">&amp;lt;-&lt;/span>&lt;span class="nx">ticker&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">C&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">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">SyncOnce&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">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 class="nx">err&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;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 class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Ticker 放在 &lt;code>Run&lt;/code> 裡建立，表示它的生命週期和 &lt;code>Run&lt;/code> 一致。&lt;code>defer ticker.Stop()&lt;/code> 讓 worker 不論因為 context、錯誤或 channel 關閉退出，都能釋放 ticker。&lt;/p>
&lt;p>如果 ticker 由外部傳入，外部就應該負責停止。擁有權要一致，否則測試和 shutdown 都會變得模糊。&lt;/p>
&lt;h2 id="執行處理已關閉-channel-要避免忙等">【執行】處理已關閉 channel 要避免忙等&lt;/h2>
&lt;p>已關閉 channel 的核心行為是讀取會立即回傳零值與 &lt;code>ok=false&lt;/code>。在 &lt;code>select&lt;/code> loop 裡，如果不處理這件事，loop 可能一直選到同一個已關閉 channel。&lt;/p>
&lt;p>當一個輸入關閉後，還要繼續處理其他輸入，可以把它設成 nil：&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">w&lt;/span> &lt;span class="nx">Worker&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Run&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="kt">error&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">events&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">events&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">alerts&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">alerts&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="k">for&lt;/span> &lt;span class="nx">events&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nx">alerts&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"> 6&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Err&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">event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">events&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">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">ok&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">events&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kc">nil&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">continue&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;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">processEvent&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">event&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="k">case&lt;/span> &lt;span class="nx">alert&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">alerts&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="k">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">ok&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="nx">alerts&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kc">nil&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">continue&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 class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">processAlert&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">alert&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Nil channel 在 &lt;code>select&lt;/code> 中永遠不會 ready。這讓 worker 能在某個來源關閉後繼續處理其他來源，而不是忙等或提早退出。&lt;/p></description><content:encoded><![CDATA[<p><code>select</code> loop 的核心責任是管理長時間 goroutine 的生命週期。它不只是等待多個 channel 的語法，而是決定元件如何接收輸入、處理定時任務、回應取消、釋放資源與停止。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>拆解 <code>select</code> loop 中每個 case 的責任</li>
<li>用 <code>ctx.Done()</code> 設計一致的退出路徑</li>
<li>正確建立與停止 ticker</li>
<li>處理 channel 關閉後的 nil channel pattern</li>
<li>測試 worker 在事件、ticker、取消下的行為</li>
</ol>
<hr>
<h2 id="觀察長期-goroutine-通常同時等待多種訊號">【觀察】長期 goroutine 通常同時等待多種訊號</h2>
<p>長期 goroutine 的核心特徵是它不只處理一種資料。背景 worker 可能同時等待外部事件、定時掃描、清理工作與停止訊號。</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">w</span> <span class="nx">Worker</span><span class="p">)</span> <span class="nf">Run</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="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">statusTicker</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">w</span><span class="p">.</span><span class="nx">statusInterval</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">statusTicker</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="nx">cleanupTicker</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">w</span><span class="p">.</span><span class="nx">cleanupInterval</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="nx">cleanupTicker</span><span class="p">.</span><span class="nf">Stop</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</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="nx">ctx</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">case</span> <span class="nx">event</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">w</span><span class="p">.</span><span class="nx">events</span><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="kc">nil</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="nx">w</span><span class="p">.</span><span class="nf">processEvent</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">)</span>
</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">statusTicker</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">w</span><span class="p">.</span><span class="nf">scanStatus</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">cleanupTicker</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="nx">w</span><span class="p">.</span><span class="nf">cleanup</span><span class="p">(</span><span class="nx">ctx</span><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>這個 loop 的責任不是「跑一個無限迴圈」，而是定義 worker 活著時能接受哪些訊號，以及停止時要如何退出。</p>
<h2 id="判讀select-loop-是元件的生命週期表">【判讀】select loop 是元件的生命週期表</h2>
<p><code>select</code> loop 的核心價值是把元件生命週期寫成明確表格。每個 case 都應該能回答：收到什麼訊號、代表什麼意思、下一步做什麼。</p>
<table>
  <thead>
      <tr>
          <th>case</th>
          <th>系統意義</th>
          <th>下一步</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ctx.Done()</code></td>
          <td>上層要求停止</td>
          <td>回傳 context error</td>
      </tr>
      <tr>
          <td><code>w.events</code></td>
          <td>收到外部事件</td>
          <td>套用處理流程</td>
      </tr>
      <tr>
          <td><code>statusTicker.C</code></td>
          <td>到時間掃描狀態</td>
          <td>執行週期任務</td>
      </tr>
      <tr>
          <td><code>cleanupTicker.C</code></td>
          <td>到時間清理暫存資料</td>
          <td>回收資源</td>
      </tr>
  </tbody>
</table>
<p>若某個 case 的意義說不清楚，通常代表 worker 責任太多，或事件來源還沒有被整理成清楚的 channel。</p>
<h2 id="策略每個長期-goroutine-先回答四個問題">【策略】每個長期 goroutine 先回答四個問題</h2>
<p>Select loop 設計的核心檢查是生命週期，而不是語法。寫 loop 前先回答四個問題：</p>
<ol>
<li>誰能停止它？</li>
<li>它消費哪些輸入？</li>
<li>它擁有哪些資源？</li>
<li>停止時要回報錯誤、靜默退出，還是交給上層判斷？</li>
</ol>
<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">type</span> <span class="nx">Worker</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">events</span>          <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Event</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">statusInterval</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">cleanupInterval</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">processor</span>       <span class="nx">Processor</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>Worker</code> 消費 <code>events</code>，擁有兩個 ticker，停止訊號來自 <code>context.Context</code>。這些資訊應該能從型別與 <code>Run</code> 方法看出來，而不是藏在任意 goroutine 裡。</p>
<h2 id="執行ticker-要由使用者停止">【執行】ticker 要由使用者停止</h2>
<p>Ticker 的核心規則是建立者負責停止。<code>time.NewTicker</code> 會建立 runtime 資源；不再使用時應呼叫 <code>Stop</code>。</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">w</span> <span class="nx">Worker</span><span class="p">)</span> <span class="nf">Run</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="kt">error</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">w</span><span class="p">.</span><span class="nx">interval</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="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</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">ctx</span><span class="p">.</span><span class="nf">Err</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">ticker</span><span class="p">.</span><span class="nx">C</span><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="nx">w</span><span class="p">.</span><span class="nf">SyncOnce</span><span class="p">(</span><span class="nx">ctx</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 class="nx">err</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><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="p">}</span></span></span></code></pre></div><p>Ticker 放在 <code>Run</code> 裡建立，表示它的生命週期和 <code>Run</code> 一致。<code>defer ticker.Stop()</code> 讓 worker 不論因為 context、錯誤或 channel 關閉退出，都能釋放 ticker。</p>
<p>如果 ticker 由外部傳入，外部就應該負責停止。擁有權要一致，否則測試和 shutdown 都會變得模糊。</p>
<h2 id="執行處理已關閉-channel-要避免忙等">【執行】處理已關閉 channel 要避免忙等</h2>
<p>已關閉 channel 的核心行為是讀取會立即回傳零值與 <code>ok=false</code>。在 <code>select</code> loop 裡，如果不處理這件事，loop 可能一直選到同一個已關閉 channel。</p>
<p>當一個輸入關閉後，還要繼續處理其他輸入，可以把它設成 nil：</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">w</span> <span class="nx">Worker</span><span class="p">)</span> <span class="nf">Run</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="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">events</span> <span class="o">:=</span> <span class="nx">w</span><span class="p">.</span><span class="nx">events</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">alerts</span> <span class="o">:=</span> <span class="nx">w</span><span class="p">.</span><span class="nx">alerts</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="nx">events</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="o">||</span> <span class="nx">alerts</span> <span class="o">!=</span> <span class="kc">nil</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="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</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">ctx</span><span class="p">.</span><span class="nf">Err</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">event</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">events</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</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">11</span><span class="cl">                <span class="nx">events</span> <span class="p">=</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">                <span class="k">continue</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nx">w</span><span class="p">.</span><span class="nf">processEvent</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</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">alert</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">alerts</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">16</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">17</span><span class="cl">                <span class="nx">alerts</span> <span class="p">=</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">                <span class="k">continue</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="nx">w</span><span class="p">.</span><span class="nf">processAlert</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">alert</span><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></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Nil channel 在 <code>select</code> 中永遠不會 ready。這讓 worker 能在某個來源關閉後繼續處理其他來源，而不是忙等或提早退出。</p>
<h2 id="判讀default-case-會改變-loop-的語意">【判讀】default case 會改變 loop 的語意</h2>
<p><code>default</code> 的核心效果是讓 <code>select</code> 不等待。這在非阻塞送出很有用，但在長期 worker 的主 loop 中要小心，因為它可能造成 busy loop。</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="k">for</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="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">case</span> <span class="nx">event</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">events</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nf">process</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nf">cleanup</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="p">}</span></span></span></code></pre></div><p>當沒有事件時，這個 loop 會不停執行 <code>cleanup()</code>，可能吃滿 CPU。週期任務應該用 ticker 表達，不應用 <code>default</code> 假裝閒置時執行。</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="nx">cleanupTicker</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">time</span><span class="p">.</span><span class="nx">Minute</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="nx">cleanupTicker</span><span class="p">.</span><span class="nf">Stop</span><span class="p">()</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="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">case</span> <span class="nx">event</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">events</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nf">process</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">cleanupTicker</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nf">cleanup</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>Ticker 讓頻率明確，也讓測試可以透過可控時間或手動觸發 channel 驗證行為。</p>
<h2 id="策略長工作要移出主要-loop">【策略】長工作要移出主要 loop</h2>
<p>Select loop 的核心風險是某個 case 裡的工作太久，導致其他訊號無法被處理。若 <code>processEvent</code> 可能執行很久，worker 在這段期間就不會回應 context 或 ticker。</p>
<p>可選策略：</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>適用情境</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>case 內同步執行</td>
          <td>工作短、需要順序處理</td>
          <td>慢事件會阻塞整個 loop</td>
      </tr>
      <tr>
          <td>啟動 bounded <a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool</a></td>
          <td>工作可並行、需要限制併發</td>
          <td>需要排隊與 shutdown 設計</td>
      </tr>
      <tr>
          <td>送入另一個 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a></td>
          <td>入口要快速接收</td>
          <td>需要 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 策略</td>
      </tr>
  </tbody>
</table>
<p>長工作需要 bounded worker pool、另一個 queue 或明確的同步策略。無限制地在 case 裡 <code>go process(event)</code> 只會把排隊問題從 channel 轉成 goroutine 堆積，並讓 shutdown 和錯誤回報更難處理。</p>
<h2 id="測試把單次工作抽成方法">【測試】把單次工作抽成方法</h2>
<p>Select loop 的測試核心是避免所有邏輯都只能透過無限迴圈測。把單次工作抽成 <code>SyncOnce</code>、<code>ProcessOne</code> 或 <code>CleanupOnce</code>，可以讓規則測試和 lifecycle 測試分開。</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">w</span> <span class="nx">Worker</span><span class="p">)</span> <span class="nf">SyncOnce</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="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">w</span><span class="p">.</span><span class="nx">processor</span><span class="p">.</span><span class="nf">Sync</span><span class="p">(</span><span class="nx">ctx</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><code>Run</code> 只負責排程：</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="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">2</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">w</span><span class="p">.</span><span class="nf">SyncOnce</span><span class="p">(</span><span class="nx">ctx</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">3</span><span class="cl">        <span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">}</span></span></span></code></pre></div><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">TestSyncOnceCallsProcessor</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">processor</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeProcessor</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">worker</span> <span class="o">:=</span> <span class="nx">Worker</span><span class="p">{</span><span class="nx">processor</span><span class="p">:</span> <span class="nx">processor</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">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">worker</span><span class="p">.</span><span class="nf">SyncOnce</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">err</span> <span class="o">!=</span> <span class="kc">nil</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;sync once: %v&#34;</span><span class="p">,</span> <span class="nx">err</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="k">if</span> <span class="p">!</span><span class="nx">processor</span><span class="p">.</span><span class="nx">called</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</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;processor should be called&#34;</span><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>Lifecycle 測試則只確認 context 取消能讓 <code>Run</code> 退出：</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">TestRunStopsOnContextCancel</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">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithCancel</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nf">cancel</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">worker</span> <span class="o">:=</span> <span class="nx">Worker</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">events</span><span class="p">:</span>          <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">statusInterval</span><span class="p">:</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Hour</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">cleanupInterval</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Hour</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">worker</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">ctx</span><span class="p">);</span> <span class="p">!</span><span class="nx">errors</span><span class="p">.</span><span class="nf">Is</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Canceled</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</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;run error = %v, want context canceled&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種拆法讓測試不需要等待真實 ticker，也不需要在無限 loop 裡猜時間。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先把長生命週期 goroutine 的停止、輸入與排空講清楚；更完整的 worker 協調與平台排程責任，會在下列章節再往外延伸：</p>
<ul>
<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/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Go 進階：graceful shutdown 與 signal handling</a></li>
<li><a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證流程</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 goroutine、channel 與 shutdown loop；如果你要先回看語言教材，可以讀：</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/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/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Go：graceful shutdown 與 signal handling</a></li>
</ul>
<h2 id="小結">小結</h2>
<p><code>select</code> loop 是長期 goroutine 的生命週期表。好的 loop 會明確處理 context 取消、輸入 channel、ticker、資源釋放與 channel 關閉。避免在主 loop 中濫用 <code>default</code> 或無限制開 goroutine，才能讓服務在高流量、錯誤與 shutdown 情境下保持可預測。</p>
]]></content:encoded></item><item><title>1.2 變數、零值與短變數宣告</title><link>https://tarrragon.github.io/blog/go/01-basics/variables-zero-values/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/01-basics/variables-zero-values/</guid><description>&lt;p>Go 變數宣告的核心規則是：每個變數都有明確型別，未指定初始值時會得到該型別的零值。本章將說明 &lt;code>var&lt;/code>、&lt;code>:=&lt;/code>、型別推斷與零值如何讓 Go 程式保持可預測。&lt;/p>
&lt;h2 id="變數一定有型別">變數一定有型別&lt;/h2>
&lt;p>Go 的變數一定屬於某個明確型別。這個型別可以由程式直接寫出，也可以由編譯器根據右側的初始值推斷出來；但推斷完成後，變數的型別就固定了。&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">var&lt;/span> &lt;span class="nx">name&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;api&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">port&lt;/span> &lt;span class="kt">int&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">8080&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="nx">enabled&lt;/span> &lt;span class="o">:=&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="nx">timeout&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">30&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>name&lt;/code> 的型別是 &lt;code>string&lt;/code>，&lt;code>port&lt;/code> 的型別是 &lt;code>int&lt;/code>，&lt;code>enabled&lt;/code> 與 &lt;code>timeout&lt;/code> 則由右側初始值推斷型別。Go 允許省略重複資訊，但不允許變數在執行期間任意改變型別。&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="nx">count&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">10&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">count&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">20&lt;/span> &lt;span class="c1">// 可以，仍然是 int&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">// count = &amp;#34;20&amp;#34; // 編譯錯誤：string 不能指派給 int&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個規則讓 Go 程式在閱讀時更穩定：看到一個變數後，讀者可以相信它的型別不會在後面突然變成另一種資料。&lt;/p>
&lt;h2 id="零值讓變數可立即使用">零值讓變數可立即使用&lt;/h2>
&lt;p>零值是 Go 對「尚未明確指定初始值」的標準答案。每個型別都有自己的零值：數字是 &lt;code>0&lt;/code>，字串是空字串，布林是 &lt;code>false&lt;/code>，指標、slice、map、function、interface 與 channel 是 &lt;code>nil&lt;/code>。&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">var&lt;/span> &lt;span class="nx">retryCount&lt;/span> &lt;span class="kt">int&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">title&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="kd">var&lt;/span> &lt;span class="nx">debug&lt;/span> &lt;span class="kt">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">tags&lt;/span> &lt;span class="p">[]&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>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">retryCount&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// 0&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">title&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// &amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">debug&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// false&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tags&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="kc">nil&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// true&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>零值是型別的預設狀態。這是 Go 設計中很重要的精神：讓資料結構在最少初始化下仍然有合理行為。&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">Counter&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">value&lt;/span> &lt;span class="kt">int&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">c&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Counter&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">n&lt;/span> &lt;span class="kt">int&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"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">value&lt;/span> &lt;span class="o">+=&lt;/span> &lt;span class="nx">n&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">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">c&lt;/span> &lt;span class="nx">Counter&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Value&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kt">int&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">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">value&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&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>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">counter&lt;/span> &lt;span class="nx">Counter&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">counter&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">counter&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Value&lt;/span>&lt;span class="p">())&lt;/span> &lt;span class="c1">// 3&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>Counter&lt;/code> 沒有建構函式也能使用，因為 &lt;code>value&lt;/code> 的零值是 &lt;code>0&lt;/code>。當型別可以靠零值進入可用狀態時，使用者需要記住的初始化規則就會少很多。&lt;/p>
&lt;h2 id="var-適合宣告意圖">&lt;code>var&lt;/code> 適合宣告意圖&lt;/h2>
&lt;p>&lt;code>var&lt;/code> 的主要用途是清楚宣告變數的存在、型別或零值意義。當你需要讓讀者知道「這個變數稍後才會被賦值」或「零值本身有意義」時，&lt;code>var&lt;/code> 比 &lt;code>:=&lt;/code> 更清楚。&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">var&lt;/span> &lt;span class="nx">userID&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="k">if&lt;/span> &lt;span class="nx">fromHeader&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">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">userID&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">fromHeader&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 class="k">else&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">userID&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">fromCookie&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>這裡的 &lt;code>userID&lt;/code> 需要經過條件判斷才會得到值，因此先用 &lt;code>var&lt;/code> 宣告變數，再在不同分支指派。讀者看到 &lt;code>var userID string&lt;/code>，可以理解這是一個稍後會被填入的字串。&lt;/p>
&lt;p>&lt;code>var&lt;/code> 也適合用在 package 層級，因為 package 層級不能使用 &lt;code>:=&lt;/code>。&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">var&lt;/span> &lt;span class="nx">defaultPort&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">8080&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">serviceName&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;worker&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>package 層級變數會增加全域狀態，使用前要先確認它是否真的代表整個 package 的共同狀態；如果只是函式內部暫存資料，應該放回函式裡。&lt;/p>
&lt;h2 id="-適合區域初始化">&lt;code>:=&lt;/code> 適合區域初始化&lt;/h2>
&lt;p>短變數宣告 &lt;code>:=&lt;/code> 的主要用途是在函式內同時宣告與初始化變數。當右側初始值已經清楚表達型別時，&lt;code>:=&lt;/code> 可以讓程式更精簡。&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">greeting&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">name&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">string&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">message&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="s">&amp;#34;hello, &amp;#34;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">name&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">return&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>message&lt;/code> 的型別可以從字串串接結果推斷，所以不需要寫成 &lt;code>var message string = ...&lt;/code>。這種省略是移除讀者已經能從右側看懂的重複資訊。&lt;/p>
&lt;p>短變數宣告只能用在函式內。以下寫法不能放在 package 層級：&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="c1">// package 層級不允許：&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">// port := 8080&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>:=&lt;/code> 也要求左側至少有一個新變數。這個規則會影響常見的錯誤處理寫法。&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="nx">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">loadFile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;config.json&amp;#34;&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">if&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">3&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&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">config&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">parseConfig&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// config 是新變數，err 是重新指派&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">if&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">8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&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>:=&lt;/code> 是合法的，因為 &lt;code>config&lt;/code> 是新變數；同時 &lt;code>err&lt;/code> 會被重新指派。這種寫法在 Go 很常見，但要留意不要在內層區塊意外宣告出新的同名變數。&lt;/p>
&lt;h2 id="型別推斷不等於放棄型別">型別推斷不等於放棄型別&lt;/h2>
&lt;p>型別推斷的核心作用是減少重複，不是讓變數變成動態型別。Go 只在編譯期根據右側表達式推斷型別，推斷後仍然遵守靜態型別規則。&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="nx">limit&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">100&lt;/span> &lt;span class="c1">// int&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">ratio&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mf">0.75&lt;/span> &lt;span class="c1">// float64&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">label&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="s">&amp;#34;active&amp;#34;&lt;/span> &lt;span class="c1">// string&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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">var&lt;/span> &lt;span class="nx">timeoutSeconds&lt;/span> &lt;span class="kt">int64&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">30&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="kd">var&lt;/span> &lt;span class="nx">threshold&lt;/span> &lt;span class="kt">float32&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mf">0.8&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這裡如果只寫 &lt;code>timeoutSeconds := 30&lt;/code>，通常會得到 &lt;code>int&lt;/code>；如果 API、資料庫欄位或二進位格式需要 &lt;code>int64&lt;/code>，明確宣告型別會比事後轉型更好讀。&lt;/p>
&lt;h2 id="指標slice-與-map-的零值差異">指標、slice 與 map 的零值差異&lt;/h2>
&lt;p>零值的共同規則是「未初始化時有定義好的狀態」，但不同型別的零值可用程度不同。指標的零值是 &lt;code>nil&lt;/code>，使用前需要確認是否指向有效資料；slice 的零值可以安全讀長度與 append；map 的零值可以讀取但不能寫入。&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">var&lt;/span> &lt;span class="nx">names&lt;/span> &lt;span class="p">[]&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 class="nx">names&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">names&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;alice&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">names&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="c1">// 1&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">var&lt;/span> &lt;span class="nx">scores&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="kt">int&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">scores&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">&amp;#34;alice&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="c1">// 0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1">// scores[&amp;#34;alice&amp;#34;] = 10 // panic: assignment to entry in nil map&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>slice 的零值能直接 &lt;code>append&lt;/code>，所以很多函式可以回傳 &lt;code>nil&lt;/code> slice 表示沒有資料；呼叫端仍然可以用 &lt;code>len&lt;/code> 與 &lt;code>range&lt;/code> 處理。map 要寫入前必須先用 &lt;code>make&lt;/code> 初始化。&lt;/p></description><content:encoded><![CDATA[<p>Go 變數宣告的核心規則是：每個變數都有明確型別，未指定初始值時會得到該型別的零值。本章將說明 <code>var</code>、<code>:=</code>、型別推斷與零值如何讓 Go 程式保持可預測。</p>
<h2 id="變數一定有型別">變數一定有型別</h2>
<p>Go 的變數一定屬於某個明確型別。這個型別可以由程式直接寫出，也可以由編譯器根據右側的初始值推斷出來；但推斷完成後，變數的型別就固定了。</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">var</span> <span class="nx">name</span> <span class="kt">string</span> <span class="p">=</span> <span class="s">&#34;api&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">var</span> <span class="nx">port</span> <span class="kt">int</span> <span class="p">=</span> <span class="mi">8080</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">enabled</span> <span class="o">:=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">timeout</span> <span class="o">:=</span> <span class="mi">30</span></span></span></code></pre></div><p><code>name</code> 的型別是 <code>string</code>，<code>port</code> 的型別是 <code>int</code>，<code>enabled</code> 與 <code>timeout</code> 則由右側初始值推斷型別。Go 允許省略重複資訊，但不允許變數在執行期間任意改變型別。</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="nx">count</span> <span class="o">:=</span> <span class="mi">10</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">count</span> <span class="p">=</span> <span class="mi">20</span>      <span class="c1">// 可以，仍然是 int</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1">// count = &#34;20&#34; // 編譯錯誤：string 不能指派給 int</span></span></span></code></pre></div><p>這個規則讓 Go 程式在閱讀時更穩定：看到一個變數後，讀者可以相信它的型別不會在後面突然變成另一種資料。</p>
<h2 id="零值讓變數可立即使用">零值讓變數可立即使用</h2>
<p>零值是 Go 對「尚未明確指定初始值」的標準答案。每個型別都有自己的零值：數字是 <code>0</code>，字串是空字串，布林是 <code>false</code>，指標、slice、map、function、interface 與 channel 是 <code>nil</code>。</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">var</span> <span class="nx">retryCount</span> <span class="kt">int</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">var</span> <span class="nx">title</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">var</span> <span class="nx">debug</span> <span class="kt">bool</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="kd">var</span> <span class="nx">tags</span> <span class="p">[]</span><span class="kt">string</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">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">retryCount</span><span class="p">)</span> <span class="c1">// 0</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">title</span><span class="p">)</span>      <span class="c1">// &#34;&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">debug</span><span class="p">)</span>      <span class="c1">// false</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">tags</span> <span class="o">==</span> <span class="kc">nil</span><span class="p">)</span> <span class="c1">// true</span></span></span></code></pre></div><p>零值是型別的預設狀態。這是 Go 設計中很重要的精神：讓資料結構在最少初始化下仍然有合理行為。</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">Counter</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">value</span> <span class="kt">int</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">Counter</span><span class="p">)</span> <span class="nf">Add</span><span class="p">(</span><span class="nx">n</span> <span class="kt">int</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">value</span> <span class="o">+=</span> <span class="nx">n</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">func</span> <span class="p">(</span><span class="nx">c</span> <span class="nx">Counter</span><span class="p">)</span> <span class="nf">Value</span><span class="p">()</span> <span class="kt">int</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">c</span><span class="p">.</span><span class="nx">value</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="kd">var</span> <span class="nx">counter</span> <span class="nx">Counter</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="nx">counter</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">3</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">counter</span><span class="p">.</span><span class="nf">Value</span><span class="p">())</span> <span class="c1">// 3</span></span></span></code></pre></div><p><code>Counter</code> 沒有建構函式也能使用，因為 <code>value</code> 的零值是 <code>0</code>。當型別可以靠零值進入可用狀態時，使用者需要記住的初始化規則就會少很多。</p>
<h2 id="var-適合宣告意圖"><code>var</code> 適合宣告意圖</h2>
<p><code>var</code> 的主要用途是清楚宣告變數的存在、型別或零值意義。當你需要讓讀者知道「這個變數稍後才會被賦值」或「零值本身有意義」時，<code>var</code> 比 <code>:=</code> 更清楚。</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">var</span> <span class="nx">userID</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="k">if</span> <span class="nx">fromHeader</span> <span class="o">!=</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">userID</span> <span class="p">=</span> <span class="nx">fromHeader</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">userID</span> <span class="p">=</span> <span class="nx">fromCookie</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這裡的 <code>userID</code> 需要經過條件判斷才會得到值，因此先用 <code>var</code> 宣告變數，再在不同分支指派。讀者看到 <code>var userID string</code>，可以理解這是一個稍後會被填入的字串。</p>
<p><code>var</code> 也適合用在 package 層級，因為 package 層級不能使用 <code>:=</code>。</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">var</span> <span class="nx">defaultPort</span> <span class="p">=</span> <span class="mi">8080</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">var</span> <span class="nx">serviceName</span> <span class="p">=</span> <span class="s">&#34;worker&#34;</span></span></span></code></pre></div><p>package 層級變數會增加全域狀態，使用前要先確認它是否真的代表整個 package 的共同狀態；如果只是函式內部暫存資料，應該放回函式裡。</p>
<h2 id="-適合區域初始化"><code>:=</code> 適合區域初始化</h2>
<p>短變數宣告 <code>:=</code> 的主要用途是在函式內同時宣告與初始化變數。當右側初始值已經清楚表達型別時，<code>:=</code> 可以讓程式更精簡。</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">greeting</span><span class="p">(</span><span class="nx">name</span> <span class="kt">string</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">message</span> <span class="o">:=</span> <span class="s">&#34;hello, &#34;</span> <span class="o">+</span> <span class="nx">name</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">return</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></code></pre></div><p><code>message</code> 的型別可以從字串串接結果推斷，所以不需要寫成 <code>var message string = ...</code>。這種省略是移除讀者已經能從右側看懂的重複資訊。</p>
<p>短變數宣告只能用在函式內。以下寫法不能放在 package 層級：</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="c1">// package 層級不允許：</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// port := 8080</span></span></span></code></pre></div><p><code>:=</code> 也要求左側至少有一個新變數。這個規則會影響常見的錯誤處理寫法。</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="nx">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">loadFile</span><span class="p">(</span><span class="s">&#34;config.json&#34;</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">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">return</span> <span class="nx">err</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">config</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">parseConfig</span><span class="p">(</span><span class="nx">data</span><span class="p">)</span> <span class="c1">// config 是新變數，err 是重新指派</span>
</span></span><span class="line"><span class="ln">7</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">8</span><span class="cl">    <span class="k">return</span> <span class="nx">err</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>:=</code> 是合法的，因為 <code>config</code> 是新變數；同時 <code>err</code> 會被重新指派。這種寫法在 Go 很常見，但要留意不要在內層區塊意外宣告出新的同名變數。</p>
<h2 id="型別推斷不等於放棄型別">型別推斷不等於放棄型別</h2>
<p>型別推斷的核心作用是減少重複，不是讓變數變成動態型別。Go 只在編譯期根據右側表達式推斷型別，推斷後仍然遵守靜態型別規則。</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="nx">limit</span> <span class="o">:=</span> <span class="mi">100</span>       <span class="c1">// int</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">ratio</span> <span class="o">:=</span> <span class="mf">0.75</span>      <span class="c1">// float64</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">label</span> <span class="o">:=</span> <span class="s">&#34;active&#34;</span>  <span class="c1">// string</span></span></span></code></pre></div><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">var</span> <span class="nx">timeoutSeconds</span> <span class="kt">int64</span> <span class="p">=</span> <span class="mi">30</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">var</span> <span class="nx">threshold</span> <span class="kt">float32</span> <span class="p">=</span> <span class="mf">0.8</span></span></span></code></pre></div><p>這裡如果只寫 <code>timeoutSeconds := 30</code>，通常會得到 <code>int</code>；如果 API、資料庫欄位或二進位格式需要 <code>int64</code>，明確宣告型別會比事後轉型更好讀。</p>
<h2 id="指標slice-與-map-的零值差異">指標、slice 與 map 的零值差異</h2>
<p>零值的共同規則是「未初始化時有定義好的狀態」，但不同型別的零值可用程度不同。指標的零值是 <code>nil</code>，使用前需要確認是否指向有效資料；slice 的零值可以安全讀長度與 append；map 的零值可以讀取但不能寫入。</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">var</span> <span class="nx">names</span> <span class="p">[]</span><span class="kt">string</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">names</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">names</span><span class="p">,</span> <span class="s">&#34;alice&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="nx">names</span><span class="p">))</span> <span class="c1">// 1</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">var</span> <span class="nx">scores</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">int</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">scores</span><span class="p">[</span><span class="s">&#34;alice&#34;</span><span class="p">])</span> <span class="c1">// 0</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">// scores[&#34;alice&#34;] = 10      // panic: assignment to entry in nil map</span></span></span></code></pre></div><p>slice 的零值能直接 <code>append</code>，所以很多函式可以回傳 <code>nil</code> slice 表示沒有資料；呼叫端仍然可以用 <code>len</code> 與 <code>range</code> 處理。map 要寫入前必須先用 <code>make</code> 初始化。</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="nx">scores</span> <span class="o">:=</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="kt">int</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">scores</span><span class="p">[</span><span class="s">&#34;alice&#34;</span><span class="p">]</span> <span class="p">=</span> <span class="mi">10</span></span></span></code></pre></div><p>這個差異是零值判讀的核心陷阱：<code>nil</code> slice 通常容易處理，<code>nil</code> map 則需要先初始化才能寫入。</p>
<h2 id="命名要服務讀者">命名要服務讀者</h2>
<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="k">for</span> <span class="nx">i</span><span class="p">,</span> <span class="nx">item</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">items</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">i</span><span class="p">,</span> <span class="nx">item</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><code>i</code> 在很短的迴圈內代表 index，讀者可以立即理解。相反地，跨越多個段落使用的變數應該使用更完整的名稱。</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="nx">requestTimeout</span> <span class="o">:=</span> <span class="mi">5</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">2</span><span class="cl"><span class="nx">maxRetryCount</span> <span class="o">:=</span> <span class="mi">3</span></span></span></code></pre></div><p>Go 程式常使用短名稱，但短名稱不是目標本身。好的名稱應該讓讀者不必回頭搜尋變數來源，就能理解這個值現在代表什麼。</p>
<h2 id="下一章">下一章</h2>
<p>下一章會進入控制流程，說明 Go 如何用少量語法表達條件、迴圈與多分支判斷。</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.2 slice 與 map</title><link>https://tarrragon.github.io/blog/go/02-types-data/slices-maps/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/02-types-data/slices-maps/</guid><description>&lt;p>slice 和 map 是 Go 最常用的集合型別。slice 表達有順序的資料列表，map 表達 key-value 查詢表。理解它們的行為，是寫出可靠 Go 程式的基本功。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>建立與操作 slice&lt;/li>
&lt;li>理解 slice 的長度、容量與 append&lt;/li>
&lt;li>建立與操作 map&lt;/li>
&lt;li>判斷何時使用 slice，何時使用 map&lt;/li>
&lt;li>避免 nil slice、nil map 與共享底層資料的常見問題&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察slice-表達有順序的資料">【觀察】slice 表達有順序的資料&lt;/h2>
&lt;p>slice 是 Go 中用來表示有順序元素列表的集合型別。以下範例建立一個 &lt;code>[]string&lt;/code>，並依照索引順序走訪：&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="nx">names&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s">&amp;#34;Alice&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;Bob&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;Carol&amp;#34;&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>&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">i&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">name&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">names&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">i&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">name&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;/code>&lt;/pre>&lt;/div>&lt;p>slice 的常見操作是讀取元素、取得長度、用 &lt;code>append&lt;/code> 增加元素：&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="nx">names&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">names&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;Dave&amp;#34;&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">first&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">names&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&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">count&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">names&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>append&lt;/code> 的核心規則是：它會回傳 append 後的 slice，呼叫端必須接回結果。&lt;code>len(names)&lt;/code> 取得元素數量；&lt;code>append&lt;/code> 可能重用原底層 array，也可能配置新底層 array：&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="nx">names&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">names&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;Eve&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="判讀slice-是對底層-array-的視窗">【判讀】slice 是對底層 array 的視窗&lt;/h2>
&lt;p>slice 的核心模型是「指向底層 array 的視窗」，不是 array 本身。它比較像一個描述底層 array 區段的 header，包含：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">pointer -&amp;gt; 底層 array
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">len -&amp;gt; 目前看得到幾個元素
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">cap -&amp;gt; 從起點到底層 array 結尾還有多少容量&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>長度與容量分別描述「目前元素數」與「不重新配置時還能擴張多少」。以下範例可以觀察 &lt;code>len&lt;/code> 和 &lt;code>cap&lt;/code> 的變化：&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="nx">items&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">3&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">items&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="nb">cap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">items&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="c1">// 0 3&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="nx">items&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">items&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">items&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="nb">cap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">items&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="c1">// 1 3&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>append&lt;/code> 超過容量時，Go 可能會配置新的底層 array：&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="nx">items&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">items&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">20&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">30&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">40&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">items&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="nb">cap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">items&lt;/span>&lt;span class="p">))&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這就是 &lt;code>append&lt;/code> 必須接回原變數的原因：append 後的 slice 可能已經指向新的底層資料。&lt;/p>
&lt;h2 id="策略用-slice-保存順序用-map-做查詢">【策略】用 slice 保存順序，用 map 做查詢&lt;/h2>
&lt;p>選擇集合型別的核心規則是：在意順序用 slice，需要 key-value 查詢用 map。如果你在意資料順序，用 slice：&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="nx">tasks&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s">&amp;#34;read&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;write&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;test&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果你要用 key 快速查資料，用 map：&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="nx">scores&lt;/span> &lt;span class="o">:=&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="kt">int&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="s">&amp;#34;Alice&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">90&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="s">&amp;#34;Bob&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">85&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>讀取 map：&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="nx">score&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">scores&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">&amp;#34;Alice&amp;#34;&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">if&lt;/span> &lt;span class="nx">ok&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">score&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>map 讀取的核心規則是：需要分辨「不存在」和「零值」時，必須使用 &lt;code>value, ok&lt;/code>。key 不存在時，map 會回傳 value type 的零值：&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="nx">score&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">scores&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">&amp;#34;Unknown&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="c1">// 0&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果不檢查 &lt;code>ok&lt;/code>，你無法分辨「不存在」和「存在但分數是 0」。&lt;/p>
&lt;h2 id="執行nil-slice-與-nil-map-的差異">【執行】nil slice 與 nil map 的差異&lt;/h2>
&lt;p>nil slice 和 nil map 的核心差異是：nil slice 可以 append，nil map 不能寫入。nil slice 可以 append：&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">var&lt;/span> &lt;span class="nx">names&lt;/span> &lt;span class="p">[]&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 class="nx">names&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">names&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;Alice&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>nil map 不能直接寫入：&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">var&lt;/span> &lt;span class="nx">scores&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="kt">int&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">scores&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">&amp;#34;Alice&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">90&lt;/span> &lt;span class="c1">// panic&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>map 寫入前必須先初始化：&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="nx">scores&lt;/span> &lt;span class="o">:=&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="kt">int&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">scores&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">&amp;#34;Alice&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">90&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>或用 literal：&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="nx">scores&lt;/span> &lt;span class="o">:=&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="kt">int&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="s">&amp;#34;Alice&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">90&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="slice-和-map-的常見組合">slice 和 map 的常見組合&lt;/h2>
&lt;h3 id="用-slice-保存輸出順序">用 slice 保存輸出順序&lt;/h3>
&lt;p>map 的迭代順序不保證穩定；如果輸出順序重要，必須額外用 slice 保存或排序 key：&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="k">for&lt;/span> &lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">score&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">scores&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">score&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>先整理 key：&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="nx">names&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">scores&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">name&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">scores&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">names&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">names&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">name&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="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="nx">sort&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Strings&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">names&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>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">name&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">names&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">scores&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">name&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="用-map-當-set">用 map 當 set&lt;/h3>
&lt;p>Go 沒有內建 set；需要集合語義時，常用 &lt;code>map[string]struct{}&lt;/code> 表示「某個 key 是否存在」：&lt;/p></description><content:encoded><![CDATA[<p>slice 和 map 是 Go 最常用的集合型別。slice 表達有順序的資料列表，map 表達 key-value 查詢表。理解它們的行為，是寫出可靠 Go 程式的基本功。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>建立與操作 slice</li>
<li>理解 slice 的長度、容量與 append</li>
<li>建立與操作 map</li>
<li>判斷何時使用 slice，何時使用 map</li>
<li>避免 nil slice、nil map 與共享底層資料的常見問題</li>
</ol>
<hr>
<h2 id="觀察slice-表達有順序的資料">【觀察】slice 表達有順序的資料</h2>
<p>slice 是 Go 中用來表示有順序元素列表的集合型別。以下範例建立一個 <code>[]string</code>，並依照索引順序走訪：</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="nx">names</span> <span class="o">:=</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;Alice&#34;</span><span class="p">,</span> <span class="s">&#34;Bob&#34;</span><span class="p">,</span> <span class="s">&#34;Carol&#34;</span><span class="p">}</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="k">for</span> <span class="nx">i</span><span class="p">,</span> <span class="nx">name</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">names</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">i</span><span class="p">,</span> <span class="nx">name</span><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>slice 的常見操作是讀取元素、取得長度、用 <code>append</code> 增加元素：</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="nx">names</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">names</span><span class="p">,</span> <span class="s">&#34;Dave&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">first</span> <span class="o">:=</span> <span class="nx">names</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">count</span> <span class="o">:=</span> <span class="nb">len</span><span class="p">(</span><span class="nx">names</span><span class="p">)</span></span></span></code></pre></div><p><code>append</code> 的核心規則是：它會回傳 append 後的 slice，呼叫端必須接回結果。<code>len(names)</code> 取得元素數量；<code>append</code> 可能重用原底層 array，也可能配置新底層 array：</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="nx">names</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">names</span><span class="p">,</span> <span class="s">&#34;Eve&#34;</span><span class="p">)</span></span></span></code></pre></div><h2 id="判讀slice-是對底層-array-的視窗">【判讀】slice 是對底層 array 的視窗</h2>
<p>slice 的核心模型是「指向底層 array 的視窗」，不是 array 本身。它比較像一個描述底層 array 區段的 header，包含：</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">pointer -&gt; 底層 array
</span></span><span class="line"><span class="ln">2</span><span class="cl">len     -&gt; 目前看得到幾個元素
</span></span><span class="line"><span class="ln">3</span><span class="cl">cap     -&gt; 從起點到底層 array 結尾還有多少容量</span></span></code></pre></div><p>長度與容量分別描述「目前元素數」與「不重新配置時還能擴張多少」。以下範例可以觀察 <code>len</code> 和 <code>cap</code> 的變化：</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="nx">items</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="kt">int</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">3</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="nx">items</span><span class="p">),</span> <span class="nb">cap</span><span class="p">(</span><span class="nx">items</span><span class="p">))</span> <span class="c1">// 0 3</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">items</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">items</span><span class="p">,</span> <span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="nx">items</span><span class="p">),</span> <span class="nb">cap</span><span class="p">(</span><span class="nx">items</span><span class="p">))</span> <span class="c1">// 1 3</span></span></span></code></pre></div><p><code>append</code> 超過容量時，Go 可能會配置新的底層 array：</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="nx">items</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">items</span><span class="p">,</span> <span class="mi">20</span><span class="p">,</span> <span class="mi">30</span><span class="p">,</span> <span class="mi">40</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="nx">items</span><span class="p">),</span> <span class="nb">cap</span><span class="p">(</span><span class="nx">items</span><span class="p">))</span></span></span></code></pre></div><p>這就是 <code>append</code> 必須接回原變數的原因：append 後的 slice 可能已經指向新的底層資料。</p>
<h2 id="策略用-slice-保存順序用-map-做查詢">【策略】用 slice 保存順序，用 map 做查詢</h2>
<p>選擇集合型別的核心規則是：在意順序用 slice，需要 key-value 查詢用 map。如果你在意資料順序，用 slice：</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="nx">tasks</span> <span class="o">:=</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;read&#34;</span><span class="p">,</span> <span class="s">&#34;write&#34;</span><span class="p">,</span> <span class="s">&#34;test&#34;</span><span class="p">}</span></span></span></code></pre></div><p>如果你要用 key 快速查資料，用 map：</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="nx">scores</span> <span class="o">:=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">int</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s">&#34;Alice&#34;</span><span class="p">:</span> <span class="mi">90</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s">&#34;Bob&#34;</span><span class="p">:</span>   <span class="mi">85</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>讀取 map：</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="nx">score</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">scores</span><span class="p">[</span><span class="s">&#34;Alice&#34;</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">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">score</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>map 讀取的核心規則是：需要分辨「不存在」和「零值」時，必須使用 <code>value, ok</code>。key 不存在時，map 會回傳 value type 的零值：</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="nx">score</span> <span class="o">:=</span> <span class="nx">scores</span><span class="p">[</span><span class="s">&#34;Unknown&#34;</span><span class="p">]</span> <span class="c1">// 0</span></span></span></code></pre></div><p>如果不檢查 <code>ok</code>，你無法分辨「不存在」和「存在但分數是 0」。</p>
<h2 id="執行nil-slice-與-nil-map-的差異">【執行】nil slice 與 nil map 的差異</h2>
<p>nil slice 和 nil map 的核心差異是：nil slice 可以 append，nil map 不能寫入。nil slice 可以 append：</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">var</span> <span class="nx">names</span> <span class="p">[]</span><span class="kt">string</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">names</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">names</span><span class="p">,</span> <span class="s">&#34;Alice&#34;</span><span class="p">)</span></span></span></code></pre></div><p>nil map 不能直接寫入：</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">var</span> <span class="nx">scores</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">int</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">scores</span><span class="p">[</span><span class="s">&#34;Alice&#34;</span><span class="p">]</span> <span class="p">=</span> <span class="mi">90</span> <span class="c1">// panic</span></span></span></code></pre></div><p>map 寫入前必須先初始化：</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="nx">scores</span> <span class="o">:=</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="kt">int</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">scores</span><span class="p">[</span><span class="s">&#34;Alice&#34;</span><span class="p">]</span> <span class="p">=</span> <span class="mi">90</span></span></span></code></pre></div><p>或用 literal：</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="nx">scores</span> <span class="o">:=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">int</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s">&#34;Alice&#34;</span><span class="p">:</span> <span class="mi">90</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><h2 id="slice-和-map-的常見組合">slice 和 map 的常見組合</h2>
<h3 id="用-slice-保存輸出順序">用 slice 保存輸出順序</h3>
<p>map 的迭代順序不保證穩定；如果輸出順序重要，必須額外用 slice 保存或排序 key：</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="k">for</span> <span class="nx">name</span><span class="p">,</span> <span class="nx">score</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">scores</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">name</span><span class="p">,</span> <span class="nx">score</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>先整理 key：</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="nx">names</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">scores</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">name</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">scores</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">names</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">names</span><span class="p">,</span> <span class="nx">name</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="nx">sort</span><span class="p">.</span><span class="nf">Strings</span><span class="p">(</span><span class="nx">names</span><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">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">name</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">names</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">name</span><span class="p">,</span> <span class="nx">scores</span><span class="p">[</span><span class="nx">name</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><h3 id="用-map-當-set">用 map 當 set</h3>
<p>Go 沒有內建 set；需要集合語義時，常用 <code>map[string]struct{}</code> 表示「某個 key 是否存在」：</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="nx">seen</span> <span class="o">:=</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">2</span><span class="cl"><span class="nx">seen</span><span class="p">[</span><span class="s">&#34;Alice&#34;</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">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</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">seen</span><span class="p">[</span><span class="s">&#34;Alice&#34;</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">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;already seen&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>如果想更直觀，也可以用 <code>map[string]bool</code>：</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="nx">seen</span> <span class="o">:=</span> <span class="kd">map</span><span class="p">[</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">2</span><span class="cl">    <span class="s">&#34;Alice&#34;</span><span class="p">:</span> <span class="kc">true</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><h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一接住-append-回傳值">檢查一：接住 append 回傳值</h3>





<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="nb">append</span><span class="p">(</span><span class="nx">names</span><span class="p">,</span> <span class="s">&#34;Alice&#34;</span><span class="p">)</span> <span class="c1">// 編譯錯誤：append 結果未使用</span></span></span></code></pre></div><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="nx">names</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">names</span><span class="p">,</span> <span class="s">&#34;Alice&#34;</span><span class="p">)</span></span></span></code></pre></div><h3 id="檢查二寫入前初始化-map">檢查二：寫入前初始化 map</h3>





<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">var</span> <span class="nx">m</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">int</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">m</span><span class="p">[</span><span class="s">&#34;x&#34;</span><span class="p">]</span> <span class="p">=</span> <span class="mi">1</span> <span class="c1">// panic</span></span></span></code></pre></div><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="nx">m</span> <span class="o">:=</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="kt">int</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">m</span><span class="p">[</span><span class="s">&#34;x&#34;</span><span class="p">]</span> <span class="p">=</span> <span class="mi">1</span></span></span></code></pre></div><h3 id="檢查三需要順序時先排序-key">檢查三：需要順序時先排序 key</h3>
<p>map 的順序不能拿來做穩定輸出、測試 snapshot 或 UI 排序。需要順序就額外維護 slice。</p>
]]></content:encoded></item><item><title>3.2 pprof 基礎診斷流程</title><link>https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/pprof/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/pprof/</guid><description>&lt;p>pprof 的核心用途是用實際執行資料定位效能問題。它能協助觀察 heap、goroutine、CPU、block、mutex 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/trace/" data-link-title="Trace" data-link-desc="說明 trace 如何重建跨服務請求的路徑、耗時與依賴關係">trace&lt;/a>，讓工程師從「感覺哪裡慢」改成「依 profile 判斷哪裡有壓力」。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>安全地條件啟用 pprof endpoint&lt;/li>
&lt;li>判斷 heap、goroutine、CPU、block、mutex、trace 各自回答什麼問題&lt;/li>
&lt;li>用 &lt;code>go tool pprof&lt;/code> 取得 profile 並閱讀 &lt;code>top&lt;/code>&lt;/li>
&lt;li>區分 &lt;code>inuse_space&lt;/code> 與 &lt;code>alloc_space&lt;/code>&lt;/li>
&lt;li>把 profile 結果連回程式設計邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察效能問題需要先問對問題">【觀察】效能問題需要先問對問題&lt;/h2>
&lt;p>pprof 診斷的核心起點是先確認你要回答哪個問題。不同 profile 回答不同問題，拿錯工具會讓分析變成猜測。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>問題&lt;/th>
 &lt;th>優先 profile&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>記憶體持續上升&lt;/td>
 &lt;td>heap &lt;code>inuse_space&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>GC 壓力高、配置很多&lt;/td>
 &lt;td>heap &lt;code>alloc_space&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>goroutine 數量持續增加&lt;/td>
 &lt;td>goroutine profile&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CPU 使用率高&lt;/td>
 &lt;td>CPU profile&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>goroutine 常卡在 channel 或 syscall&lt;/td>
 &lt;td>goroutine / trace&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>mutex 等待嚴重&lt;/td>
 &lt;td>mutex profile&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>channel/send/receive 阻塞多&lt;/td>
 &lt;td>block profile&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Profile 不是一次全抓就會自動給答案。先問清楚問題，再抓對應資料，分析成本會低很多。&lt;/p>
&lt;h2 id="判讀pprof-endpoint-是受控診斷入口">【判讀】pprof endpoint 是受控診斷入口&lt;/h2>
&lt;p>pprof endpoint 的核心安全責任是受控地暴露診斷資訊。它可能包含 goroutine stack、函式名稱、路徑、記憶體配置模式與部分請求脈絡；正式服務應把 &lt;code>/debug/pprof/&lt;/code> 放在明確啟用、內部網路或驗證保護之後。&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="kn">import&lt;/span> &lt;span class="nx">_&lt;/span> &lt;span class="s">&amp;#34;net/http/pprof&amp;#34;&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">func&lt;/span> &lt;span class="nf">RegisterDebugEndpoints&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">mux&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ServeMux&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">4&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Getenv&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;APP_PPROF&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s">&amp;#34;1&amp;#34;&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>&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="nx">mux&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Handle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;/debug/pprof/&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">DefaultServeMux&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>實務上還可以只綁定 localhost、掛在內部管理 port、加上身份驗證，或只在開發與診斷環境啟用。重點是 pprof 要受控，而不是跟公開 API 一起裸露。&lt;/p>
&lt;h2 id="執行heap-profile-看記憶體保留與配置壓力">【執行】heap profile 看記憶體保留與配置壓力&lt;/h2>
&lt;p>Heap profile 的核心問題是「哪些物件佔用或配置了記憶體」。當服務記憶體持續上升時，heap profile 是第一個常用工具。&lt;/p>
&lt;p>看目前仍被保留的記憶體：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">go tool pprof http://localhost:8080/debug/pprof/heap&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>進入 pprof 後：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">(pprof) top&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>inuse_space&lt;/code> 代表目前仍被保留的記憶體，適合分析 leak、cache、map、slice、send &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a>、長期持有資料。&lt;/p>
&lt;p>看累積配置量：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">go tool pprof -alloc_space http://localhost:8080/debug/pprof/heap&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>alloc_space&lt;/code> 代表累積配置量，適合分析 JSON marshal、slice append、短命 object、熱路徑反覆配置造成的 GC 壓力。&lt;/p>
&lt;h2 id="判讀heap-profile-要連回資料結構">【判讀】heap profile 要連回資料結構&lt;/h2>
&lt;p>Heap profile 的核心解讀是問「誰持有資料」或「誰反覆配置」。看到某個函式在 top 裡，下一步要回到資料結構與生命週期。&lt;/p>
&lt;p>常見對應：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>profile 現象&lt;/th>
 &lt;th>可能設計問題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>map 持續佔用&lt;/td>
 &lt;td>cache 沒有淘汰或 key 無限制成長&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>slice/history 佔用高&lt;/td>
 &lt;td>history 無上限或 list 回傳太大&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>JSON marshal alloc 高&lt;/td>
 &lt;td>高頻推送每個 client 重複 marshal&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>bytes.Buffer 配置高&lt;/td>
 &lt;td>熱路徑重複建立 buffer&lt;/td>
 &lt;/tr>
 &lt;tr>
 &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> message 佔用高&lt;/td>
 &lt;td>send buffer 滿載或慢 client&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Profile 給的是線索，不是最終修正。修正要回到資料模型、copy boundary、buffer policy 或 cache policy。&lt;/p>
&lt;h2 id="執行goroutine-profile-看存活與卡住路徑">【執行】goroutine profile 看存活與卡住路徑&lt;/h2>
&lt;p>Goroutine profile 的核心問題是「哪些 goroutine 還活著，以及它們卡在哪裡」。它常用來診斷 goroutine leak、channel 等待、鎖等待與 network read 阻塞。&lt;/p></description><content:encoded><![CDATA[<p>pprof 的核心用途是用實際執行資料定位效能問題。它能協助觀察 heap、goroutine、CPU、block、mutex 與 <a href="/blog/backend/knowledge-cards/trace/" data-link-title="Trace" data-link-desc="說明 trace 如何重建跨服務請求的路徑、耗時與依賴關係">trace</a>，讓工程師從「感覺哪裡慢」改成「依 profile 判斷哪裡有壓力」。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>安全地條件啟用 pprof endpoint</li>
<li>判斷 heap、goroutine、CPU、block、mutex、trace 各自回答什麼問題</li>
<li>用 <code>go tool pprof</code> 取得 profile 並閱讀 <code>top</code></li>
<li>區分 <code>inuse_space</code> 與 <code>alloc_space</code></li>
<li>把 profile 結果連回程式設計邊界</li>
</ol>
<hr>
<h2 id="觀察效能問題需要先問對問題">【觀察】效能問題需要先問對問題</h2>
<p>pprof 診斷的核心起點是先確認你要回答哪個問題。不同 profile 回答不同問題，拿錯工具會讓分析變成猜測。</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>優先 profile</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>記憶體持續上升</td>
          <td>heap <code>inuse_space</code></td>
      </tr>
      <tr>
          <td>GC 壓力高、配置很多</td>
          <td>heap <code>alloc_space</code></td>
      </tr>
      <tr>
          <td>goroutine 數量持續增加</td>
          <td>goroutine profile</td>
      </tr>
      <tr>
          <td>CPU 使用率高</td>
          <td>CPU profile</td>
      </tr>
      <tr>
          <td>goroutine 常卡在 channel 或 syscall</td>
          <td>goroutine / trace</td>
      </tr>
      <tr>
          <td>mutex 等待嚴重</td>
          <td>mutex profile</td>
      </tr>
      <tr>
          <td>channel/send/receive 阻塞多</td>
          <td>block profile</td>
      </tr>
  </tbody>
</table>
<p>Profile 不是一次全抓就會自動給答案。先問清楚問題，再抓對應資料，分析成本會低很多。</p>
<h2 id="判讀pprof-endpoint-是受控診斷入口">【判讀】pprof endpoint 是受控診斷入口</h2>
<p>pprof endpoint 的核心安全責任是受控地暴露診斷資訊。它可能包含 goroutine stack、函式名稱、路徑、記憶體配置模式與部分請求脈絡；正式服務應把 <code>/debug/pprof/</code> 放在明確啟用、內部網路或驗證保護之後。</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="kn">import</span> <span class="nx">_</span> <span class="s">&#34;net/http/pprof&#34;</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">func</span> <span class="nf">RegisterDebugEndpoints</span><span class="p">(</span><span class="nx">mux</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">ServeMux</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">if</span> <span class="nx">os</span><span class="p">.</span><span class="nf">Getenv</span><span class="p">(</span><span class="s">&#34;APP_PPROF&#34;</span><span class="p">)</span> <span class="o">!=</span> <span class="s">&#34;1&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="k">return</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="nx">mux</span><span class="p">.</span><span class="nf">Handle</span><span class="p">(</span><span class="s">&#34;/debug/pprof/&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">DefaultServeMux</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>實務上還可以只綁定 localhost、掛在內部管理 port、加上身份驗證，或只在開發與診斷環境啟用。重點是 pprof 要受控，而不是跟公開 API 一起裸露。</p>
<h2 id="執行heap-profile-看記憶體保留與配置壓力">【執行】heap profile 看記憶體保留與配置壓力</h2>
<p>Heap profile 的核心問題是「哪些物件佔用或配置了記憶體」。當服務記憶體持續上升時，heap profile 是第一個常用工具。</p>
<p>看目前仍被保留的記憶體：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go tool pprof http://localhost:8080/debug/pprof/heap</span></span></code></pre></div><p>進入 pprof 後：</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">(pprof) top</span></span></code></pre></div><p><code>inuse_space</code> 代表目前仍被保留的記憶體，適合分析 leak、cache、map、slice、send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a>、長期持有資料。</p>
<p>看累積配置量：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go tool pprof -alloc_space http://localhost:8080/debug/pprof/heap</span></span></code></pre></div><p><code>alloc_space</code> 代表累積配置量，適合分析 JSON marshal、slice append、短命 object、熱路徑反覆配置造成的 GC 壓力。</p>
<h2 id="判讀heap-profile-要連回資料結構">【判讀】heap profile 要連回資料結構</h2>
<p>Heap profile 的核心解讀是問「誰持有資料」或「誰反覆配置」。看到某個函式在 top 裡，下一步要回到資料結構與生命週期。</p>
<p>常見對應：</p>
<table>
  <thead>
      <tr>
          <th>profile 現象</th>
          <th>可能設計問題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>map 持續佔用</td>
          <td>cache 沒有淘汰或 key 無限制成長</td>
      </tr>
      <tr>
          <td>slice/history 佔用高</td>
          <td>history 無上限或 list 回傳太大</td>
      </tr>
      <tr>
          <td>JSON marshal alloc 高</td>
          <td>高頻推送每個 client 重複 marshal</td>
      </tr>
      <tr>
          <td>bytes.Buffer 配置高</td>
          <td>熱路徑重複建立 buffer</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">websocket</a> message 佔用高</td>
          <td>send buffer 滿載或慢 client</td>
      </tr>
  </tbody>
</table>
<p>Profile 給的是線索，不是最終修正。修正要回到資料模型、copy boundary、buffer policy 或 cache policy。</p>
<h2 id="執行goroutine-profile-看存活與卡住路徑">【執行】goroutine profile 看存活與卡住路徑</h2>
<p>Goroutine profile 的核心問題是「哪些 goroutine 還活著，以及它們卡在哪裡」。它常用來診斷 goroutine leak、channel 等待、鎖等待與 network read 阻塞。</p>
<p>互動模式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go tool pprof http://localhost:8080/debug/pprof/goroutine</span></span></code></pre></div><p>文字 stack：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">curl <span class="s2">&#34;http://localhost:8080/debug/pprof/goroutine?debug=2&#34;</span></span></span></code></pre></div><p>若大量 goroutine 卡在同一個 channel receive、send、network read、ticker loop，通常代表某個退出條件、close path、<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 或 unregister 設計有問題。</p>
<p>Goroutine profile 不只看數量。少量但卡在錯誤位置的 goroutine，也可能代表資源沒有被釋放。</p>
<h2 id="執行cpu-profile-看熱路徑">【執行】CPU profile 看熱路徑</h2>
<p>CPU profile 的核心問題是「程式把 CPU 時間花在哪裡」。它需要採樣一段時間，適合分析 CPU 使用率高或 request latency 異常。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go tool pprof <span class="s2">&#34;http://localhost:8080/debug/pprof/profile?seconds=30&#34;</span></span></span></code></pre></div><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">(pprof) top
</span></span><span class="line"><span class="ln">2</span><span class="cl">(pprof) list Encode</span></span></code></pre></div><p>CPU profile 要搭配流量情境解讀。低流量時抓到的 profile 可能沒有代表性；高流量時則要注意診斷本身也會造成額外負擔。</p>
<p>若 top 顯示大量時間花在 JSON encode、sort、lock、regex 或 compression，下一步應回到對應熱路徑，判斷是否可以減少工作、快取結果、改資料結構或降低呼叫頻率。</p>
<h2 id="策略block-與-mutex-profile-需要先啟用取樣">【策略】block 與 mutex profile 需要先啟用取樣</h2>
<p>Block/mutex profile 的核心用途是分析等待，而不是分析 CPU 計算。它們通常需要在程式中設定取樣比例。</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">ConfigureBlockingProfiles</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">runtime</span><span class="p">.</span><span class="nf">SetBlockProfileRate</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">runtime</span><span class="p">.</span><span class="nf">SetMutexProfileFraction</span><span class="p">(</span><span class="mi">5</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Block profile 看 goroutine 在同步原語上阻塞的時間，例如 channel send/receive、select、mutex。Mutex profile 看鎖競爭。</p>
<p>啟用取樣有成本，不一定要常駐開最高強度。診斷時可以條件啟用，或在壓測環境中使用。</p>
<h2 id="執行trace-看排程與延遲">【執行】trace 看排程與延遲</h2>
<p>Trace 的核心用途是觀察 goroutine 排程、network block、syscall、GC pause 與延遲事件。它比單一 profile 更完整，但也更重。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">curl -o trace.out <span class="s2">&#34;http://localhost:8080/debug/pprof/trace?seconds=5&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">go tool trace trace.out</span></span></code></pre></div><p>Trace 適合用在你已經知道有延遲問題，但 heap、CPU、goroutine profile 都不足以解釋時。它能顯示 goroutine 何時 runnable、何時 blocked、何時被排程。</p>
<p>Trace 檔案可能很大，不適合長時間收集。通常先抓短時間，確認問題窗口後再精準分析。</p>
<h2 id="策略診斷流程要先保留現場">【策略】診斷流程要先保留現場</h2>
<p>pprof 診斷的核心流程是先保留現場，再改程式。若你先重啟服務或調參，可能會清掉最重要的證據。</p>
<p>建議流程：</p>
<ol>
<li>記錄當下流量、版本、操作、時間區間。</li>
<li>讀 runtime <a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a>：heap、GC、goroutine、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 長度。</li>
<li>依問題抓 profile：heap、goroutine、CPU 或 trace。</li>
<li>用 profile 找出函式與 stack pattern。</li>
<li>回到程式碼確認資料結構、goroutine lifecycle 或 hot path。</li>
<li>修改後用相同情境再抓一次 profile 驗證。</li>
</ol>
<p>這個流程能避免「看到 top 第一名就改」的衝動。Profile 需要和情境一起讀，才不會誤判。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一服務內的 profile 讀法；商用 APM 與分散式 tracing，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Go 進階：Observability pipeline、metrics 與 tracing</a></li>
<li><a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend：可觀測性平台</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 goroutine、allocation 與 runtime metrics；如果你要先回看語言教材，可以讀：</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/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>pprof 是診斷工具，不是公開 API。Heap profile 看保留與配置，goroutine profile 看存活與卡住路徑，CPU profile 看熱點，block/mutex profile 看等待，trace 看排程與延遲。好的診斷流程會先問對問題、抓對 profile，再把結果連回資料結構、goroutine lifecycle 與服務行為。</p>
]]></content:encoded></item><item><title>3.2 time：時間與 duration</title><link>https://tarrragon.github.io/blog/go/03-stdlib/time/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/03-stdlib/time/</guid><description>&lt;p>時間處理的核心規則是：時間點使用 &lt;code>time.Time&lt;/code>，時間長度使用 &lt;code>time.Duration&lt;/code>。本章將說明 now、parse、format、duration、timer 與 ticker 的基本用法。&lt;/p>
&lt;h2 id="timetime-表示時間點">&lt;code>time.Time&lt;/code> 表示時間點&lt;/h2>
&lt;p>&lt;code>time.Time&lt;/code> 的核心意義是一個具體時間點。它可以代表現在、某個解析出來的時間、資料庫中的時間戳，或 API 回傳的建立時間。&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="nx">now&lt;/span> &lt;span class="o">:=&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>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>time.Now()&lt;/code> 會取得目前時間。它很方便，但也會讓測試變得不穩定；需要可測試的邏輯時，通常會把時間來源包成參數或介面。&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">isExpired&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">deadline&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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">return&lt;/span> &lt;span class="nx">now&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">After&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">deadline&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個函式不自己呼叫 &lt;code>time.Now()&lt;/code>，而是由呼叫端傳入現在時間。測試時就能提供固定時間點，避免測試結果受執行時間影響。&lt;/p>
&lt;h2 id="timeduration-表示時間長度">&lt;code>time.Duration&lt;/code> 表示時間長度&lt;/h2>
&lt;p>&lt;code>time.Duration&lt;/code> 的核心意義是一段時間長度，不是某個時間點。它常用於 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>、interval、重試等待與效能測量。&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="nx">timeout&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">5&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">2&lt;/span>&lt;span class="cl">&lt;span class="nx">interval&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">200&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">Millisecond&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">timeout&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">interval&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>time.Second&lt;/code>、&lt;code>time.Millisecond&lt;/code> 這些常數本身是 &lt;code>Duration&lt;/code>，可以用乘法組出可讀的時間長度。這比直接寫奈秒數清楚很多。&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="c1">// 可讀性差：&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">timeout&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Duration&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">5000000000&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>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">// 可讀性好：&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">timeout&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">5&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;/code>&lt;/pre>&lt;/div>&lt;p>直接寫數字會讓讀者無法立即看出單位。Go 的時間 API 以奈秒為底層單位，但程式碼應該使用明確單位表達意圖。&lt;/p>
&lt;h2 id="時間加減要區分時間點與長度">時間加減要區分時間點與長度&lt;/h2>
&lt;p>時間運算的核心規則是：時間點加上 duration 會得到另一個時間點，兩個時間點相減會得到 duration。&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="nx">start&lt;/span> &lt;span class="o">:=&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>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nx">deadline&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">start&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">30&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 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">elapsed&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Since&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">start&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>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">deadline&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">elapsed&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>Add&lt;/code> 適合計算截止時間，&lt;code>Since&lt;/code> 適合計算從某個時間點到現在經過多久。&lt;code>time.Until(deadline)&lt;/code> 則可以計算距離某個未來時間還有多久。&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="nx">remaining&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Until&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">deadline&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">if&lt;/span> &lt;span class="nx">remaining&lt;/span> &lt;span class="o">&amp;lt;=&lt;/span> &lt;span class="mi">0&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;expired&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這些 API 讓程式直接表達時間語意，而不是把時間轉成數字後自行相減。&lt;/p>
&lt;h2 id="parse-與-format-使用-layout">parse 與 format 使用 layout&lt;/h2>
&lt;p>Go 時間格式化的核心規則是使用固定參考時間 &lt;code>2006-01-02 15:04:05&lt;/code> 作為 layout。layout 用這個參考時間的各個部分代表輸出形狀。&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="nx">now&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Date&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2026&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">22&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">9&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">30&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UTC&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>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Format&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;2006-01-02&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Format&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;2006-01-02 15:04:05&amp;#34;&lt;/span>&lt;span class="p">))&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果你想輸出年月日，就在 layout 裡寫 &lt;code>2006-01-02&lt;/code>；如果想輸出 24 小時制時間，就寫 &lt;code>15:04:05&lt;/code>。這和許多語言使用 &lt;code>YYYY-MM-DD&lt;/code> 的方式不同，是 Go 時間 API 最容易混淆的地方。&lt;/p>
&lt;p>解析時間也使用同一套 layout。&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="nx">input&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="s">&amp;#34;2026-04-22 09:30:00&amp;#34;&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="nx">createdAt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Parse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;2006-01-02 15:04:05&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&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">if&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">5&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">createdAt&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>time.Parse&lt;/code> 預設會以 UTC 解讀不含時區的時間。若資料代表某個本地時區，應使用 &lt;code>time.ParseInLocation&lt;/code>。&lt;/p>
&lt;h2 id="timer-表示一次性的未來事件">timer 表示一次性的未來事件&lt;/h2>
&lt;p>&lt;code>time.Timer&lt;/code> 的核心用途是在一段時間後發出一次訊號。它常用於 timeout、延遲執行與 select 控制。&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="nx">timer&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewTimer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2&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 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="nx">timer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Stop&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>&lt;/span>&lt;span class="line">&lt;span class="ln">4&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">5&lt;/span>&lt;span class="cl">&lt;span class="k">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">timer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">C&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;timeout&amp;#34;&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>timer.C&lt;/code> 是一個 channel，到時間後會收到一個時間值。若 timer 可能提前不再使用，應呼叫 &lt;code>Stop&lt;/code> 釋放資源。&lt;/p>
&lt;p>在很多簡單情境中，&lt;code>time.After&lt;/code> 更短。&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="k">select&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">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">After&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2&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 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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;timeout&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>time.After&lt;/code> 適合一次性的簡單 timeout；在高頻迴圈或需要取消、重設的情境中，&lt;code>time.NewTimer&lt;/code> 通常比較適合。&lt;/p>
&lt;h2 id="ticker-表示週期性事件">ticker 表示週期性事件&lt;/h2>
&lt;p>&lt;code>time.Ticker&lt;/code> 的核心用途是固定間隔發出訊號。它常用於定期清理、輪詢、健康檢查與背景工作。&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="nx">ticker&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewTicker&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&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 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="nx">ticker&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Stop&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>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="p">&amp;lt;&lt;/span> &lt;span class="mi">3&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="o">++&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="o">&amp;lt;-&lt;/span>&lt;span class="nx">ticker&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">C&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;tick&amp;#34;&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>ticker.C&lt;/code> 每隔指定 duration 會收到一次訊號。只要不再使用 ticker，就應呼叫 &lt;code>Stop&lt;/code>，避免背景資源持續運作。&lt;/p>
&lt;p>週期性工作要有明確的退出條件。實務上常搭配 &lt;code>context.Context&lt;/code>。&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">run&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="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">ticker&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewTicker&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&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 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">defer&lt;/span> &lt;span class="nx">ticker&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Stop&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&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"> 6&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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>&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="o">&amp;lt;-&lt;/span>&lt;span class="nx">ticker&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">C&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;work&amp;#34;&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="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>這段程式每秒執行一次工作，直到 context 被取消。時間控制與生命週期控制分開後，程式會比較容易測試與關閉。&lt;/p>
&lt;h2 id="小結">小結&lt;/h2>
&lt;p>下一章會進入 &lt;code>os&lt;/code> 與 &lt;code>io&lt;/code>，說明檔案、輸入輸出與 streaming API 的共同抽象。&lt;/p></description><content:encoded><![CDATA[<p>時間處理的核心規則是：時間點使用 <code>time.Time</code>，時間長度使用 <code>time.Duration</code>。本章將說明 now、parse、format、duration、timer 與 ticker 的基本用法。</p>
<h2 id="timetime-表示時間點"><code>time.Time</code> 表示時間點</h2>
<p><code>time.Time</code> 的核心意義是一個具體時間點。它可以代表現在、某個解析出來的時間、資料庫中的時間戳，或 API 回傳的建立時間。</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="nx">now</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">now</span><span class="p">)</span></span></span></code></pre></div><p><code>time.Now()</code> 會取得目前時間。它很方便，但也會讓測試變得不穩定；需要可測試的邏輯時，通常會把時間來源包成參數或介面。</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">isExpired</span><span class="p">(</span><span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">,</span> <span class="nx">deadline</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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">return</span> <span class="nx">now</span><span class="p">.</span><span class="nf">After</span><span class="p">(</span><span class="nx">deadline</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>這個函式不自己呼叫 <code>time.Now()</code>，而是由呼叫端傳入現在時間。測試時就能提供固定時間點，避免測試結果受執行時間影響。</p>
<h2 id="timeduration-表示時間長度"><code>time.Duration</code> 表示時間長度</h2>
<p><code>time.Duration</code> 的核心意義是一段時間長度，不是某個時間點。它常用於 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>、interval、重試等待與效能測量。</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="nx">timeout</span> <span class="o">:=</span> <span class="mi">5</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">2</span><span class="cl"><span class="nx">interval</span> <span class="o">:=</span> <span class="mi">200</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Millisecond</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">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">timeout</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">interval</span><span class="p">)</span></span></span></code></pre></div><p><code>time.Second</code>、<code>time.Millisecond</code> 這些常數本身是 <code>Duration</code>，可以用乘法組出可讀的時間長度。這比直接寫奈秒數清楚很多。</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="c1">// 可讀性差：</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">timeout</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Duration</span><span class="p">(</span><span class="mi">5000000000</span><span class="p">)</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="c1">// 可讀性好：</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">timeout</span> <span class="o">:=</span> <span class="mi">5</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span></span></span></code></pre></div><p>直接寫數字會讓讀者無法立即看出單位。Go 的時間 API 以奈秒為底層單位，但程式碼應該使用明確單位表達意圖。</p>
<h2 id="時間加減要區分時間點與長度">時間加減要區分時間點與長度</h2>
<p>時間運算的核心規則是：時間點加上 duration 會得到另一個時間點，兩個時間點相減會得到 duration。</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="nx">start</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">deadline</span> <span class="o">:=</span> <span class="nx">start</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">30</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">elapsed</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Since</span><span class="p">(</span><span class="nx">start</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">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">deadline</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">elapsed</span><span class="p">)</span></span></span></code></pre></div><p><code>Add</code> 適合計算截止時間，<code>Since</code> 適合計算從某個時間點到現在經過多久。<code>time.Until(deadline)</code> 則可以計算距離某個未來時間還有多久。</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="nx">remaining</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Until</span><span class="p">(</span><span class="nx">deadline</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">remaining</span> <span class="o">&lt;=</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;expired&#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></code></pre></div><p>這些 API 讓程式直接表達時間語意，而不是把時間轉成數字後自行相減。</p>
<h2 id="parse-與-format-使用-layout">parse 與 format 使用 layout</h2>
<p>Go 時間格式化的核心規則是使用固定參考時間 <code>2006-01-02 15:04:05</code> 作為 layout。layout 用這個參考時間的各個部分代表輸出形狀。</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="nx">now</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">9</span><span class="p">,</span> <span class="mi">30</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</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="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">now</span><span class="p">.</span><span class="nf">Format</span><span class="p">(</span><span class="s">&#34;2006-01-02&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">now</span><span class="p">.</span><span class="nf">Format</span><span class="p">(</span><span class="s">&#34;2006-01-02 15:04:05&#34;</span><span class="p">))</span></span></span></code></pre></div><p>如果你想輸出年月日，就在 layout 裡寫 <code>2006-01-02</code>；如果想輸出 24 小時制時間，就寫 <code>15:04:05</code>。這和許多語言使用 <code>YYYY-MM-DD</code> 的方式不同，是 Go 時間 API 最容易混淆的地方。</p>
<p>解析時間也使用同一套 layout。</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="nx">input</span> <span class="o">:=</span> <span class="s">&#34;2026-04-22 09:30:00&#34;</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="nx">createdAt</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="s">&#34;2006-01-02 15:04:05&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</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">5</span><span class="cl">    <span class="k">return</span> <span class="nx">err</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="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">createdAt</span><span class="p">)</span></span></span></code></pre></div><p><code>time.Parse</code> 預設會以 UTC 解讀不含時區的時間。若資料代表某個本地時區，應使用 <code>time.ParseInLocation</code>。</p>
<h2 id="timer-表示一次性的未來事件">timer 表示一次性的未來事件</h2>
<p><code>time.Timer</code> 的核心用途是在一段時間後發出一次訊號。它常用於 timeout、延遲執行與 select 控制。</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="nx">timer</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">NewTimer</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</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="nx">timer</span><span class="p">.</span><span class="nf">Stop</span><span class="p">()</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">case</span> <span class="o">&lt;-</span><span class="nx">timer</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;timeout&#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></code></pre></div><p><code>timer.C</code> 是一個 channel，到時間後會收到一個時間值。若 timer 可能提前不再使用，應呼叫 <code>Stop</code> 釋放資源。</p>
<p>在很多簡單情境中，<code>time.After</code> 更短。</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">case</span> <span class="o">&lt;-</span><span class="nx">time</span><span class="p">.</span><span class="nf">After</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;timeout&#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></code></pre></div><p><code>time.After</code> 適合一次性的簡單 timeout；在高頻迴圈或需要取消、重設的情境中，<code>time.NewTimer</code> 通常比較適合。</p>
<h2 id="ticker-表示週期性事件">ticker 表示週期性事件</h2>
<p><code>time.Ticker</code> 的核心用途是固定間隔發出訊號。它常用於定期清理、輪詢、健康檢查與背景工作。</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="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="mi">1</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</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="nx">ticker</span><span class="p">.</span><span class="nf">Stop</span><span class="p">()</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="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="mi">3</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="o">&lt;-</span><span class="nx">ticker</span><span class="p">.</span><span class="nx">C</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;tick&#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></code></pre></div><p><code>ticker.C</code> 每隔指定 duration 會收到一次訊號。只要不再使用 ticker，就應呼叫 <code>Stop</code>，避免背景資源持續運作。</p>
<p>週期性工作要有明確的退出條件。實務上常搭配 <code>context.Context</code>。</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">run</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="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="mi">1</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</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="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="k">return</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">ticker</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;work&#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><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式每秒執行一次工作，直到 context 被取消。時間控制與生命週期控制分開後，程式會比較容易測試與關閉。</p>
<h2 id="小結">小結</h2>
<p>下一章會進入 <code>os</code> 與 <code>io</code>，說明檔案、輸入輸出與 streaming API 的共同抽象。</p>
]]></content:encoded></item><item><title>4.2 channel：資料傳遞與 backpressure</title><link>https://tarrragon.github.io/blog/go/04-concurrency/channel/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/04-concurrency/channel/</guid><description>&lt;p>channel 是 Go 用來在 goroutine 之間傳遞資料的同步工具。它的核心意義是建立資料流邊界：誰送出資料、誰接收資料、當接收端跟不上時送出端如何被阻擋或丟棄。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>建立與使用 channel&lt;/li>
&lt;li>看懂 channel 的方向與資料型別&lt;/li>
&lt;li>理解 buffered channel 的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> 意義&lt;/li>
&lt;li>分辨 blocking send 與 non-blocking send&lt;/li>
&lt;li>用 channel 畫出資料流&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察channel-連接送出端與接收端">【觀察】channel 連接送出端與接收端&lt;/h2>
&lt;p>channel 的核心規則是：送出端用 &lt;code>&amp;lt;-&lt;/code> 把值放入 channel，接收端用 &lt;code>&amp;lt;-&lt;/code> 從 channel 取出值。以下範例建立一個傳遞 &lt;code>string&lt;/code> 的 channel：&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="nx">messages&lt;/span> &lt;span class="o">:=&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="kt">string&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>&lt;/span>&lt;span class="line">&lt;span class="ln">3&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">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">messages&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="s">&amp;#34;hello&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;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="nx">msg&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">messages&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>make(chan string)&lt;/code> 建立只能傳 &lt;code>string&lt;/code> 的 channel。&lt;code>messages &amp;lt;- &amp;quot;hello&amp;quot;&lt;/code> 是送出，&lt;code>msg := &amp;lt;-messages&lt;/code> 是接收。&lt;/p>
&lt;h2 id="判讀channel-是同步點不只是佇列">【判讀】channel 是同步點，不只是佇列&lt;/h2>
&lt;p>unbuffered channel 的核心規則是：送出和接收必須同時準備好，資料才會通過。這表示 channel 也是同步點。&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="nx">ch&lt;/span> &lt;span class="o">:=&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="kt">int&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>&lt;/span>&lt;span class="line">&lt;span class="ln">3&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">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">ch&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="mi">1&lt;/span> &lt;span class="c1">// 等到有人接收才會繼續&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>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="nx">value&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ch&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">value&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>buffered channel 的核心規則是：&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 未滿時送出不會阻塞，buffer 滿時送出會阻塞。&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="nx">jobs&lt;/span> &lt;span class="o">:=&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">Job&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&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">jobs&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>buffer 大小不是隨便的數字。它代表系統允許累積多少尚未處理的工作；接收端處理速度跟不上時，buffer 會逐漸填滿，最後形成 backpressure 。&lt;/p>
&lt;h2 id="策略用方向限制表達所有權">【策略】用方向限制表達所有權&lt;/h2>
&lt;p>channel direction 的核心規則是：函式簽名應限制自己只需要的能力。Go 可以用 channel direction 表達函式只讀或只寫：&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">producer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">out&lt;/span> &lt;span class="kd">chan&lt;/span>&lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">Job&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">out&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;1&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="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="nf">consumer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">in&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">Job&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">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">in&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="nf">handle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>chan&amp;lt;- Job&lt;/code> 表示只能送出，&lt;code>&amp;lt;-chan Job&lt;/code> 表示只能接收。這是 API 層的保護：&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/producer/" data-link-title="Producer" data-link-desc="說明 producer 如何把工作、事件或資料送入後續處理路徑">producer&lt;/a> 不能讀取 channel，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a> 不能寫入 channel。&lt;/p>
&lt;h2 id="執行non-blocking-send-的取捨">【執行】non-blocking send 的取捨&lt;/h2>
&lt;p>non-blocking send 的核心規則是：送不出去時立即走 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fallback/" data-link-title="Fallback" data-link-desc="說明主要路徑失敗時使用替代結果或替代流程的設計責任">fallback&lt;/a>，不等待接收端。它適合「寧可丟棄或記錄，也不要卡住呼叫端」的情境。&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="k">select&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">case&lt;/span> &lt;span class="nx">jobs&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">job&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">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;job queued&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">job&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">4&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">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Warn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;job queue full&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">job&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">6&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;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a> 或回傳明確錯誤。若資料不能丟，就不要用 default；讓送出端阻塞或回傳「系統忙碌」會更誠實。&lt;/p>
&lt;h2 id="關閉-channel">關閉 channel&lt;/h2>
&lt;p>關閉 channel 的核心規則是：由送出端關閉，表示不會再有新資料。接收端可以用 &lt;code>range&lt;/code> 讀到 channel 關閉：&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">producer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">out&lt;/span> &lt;span class="kd">chan&lt;/span>&lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="kt">int&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="nb">close&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">out&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">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="p">&amp;lt;&lt;/span> &lt;span class="mi">3&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="o">++&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">out&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">i&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="kd">func&lt;/span> &lt;span class="nf">consumer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">in&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="kt">int&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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">value&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">in&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">value&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="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;/code>&lt;/pre>&lt;/div>&lt;p>接收端不應關閉自己沒有所有權的 channel；否則送出端可能在送資料時遇到 panic。&lt;/p></description><content:encoded><![CDATA[<p>channel 是 Go 用來在 goroutine 之間傳遞資料的同步工具。它的核心意義是建立資料流邊界：誰送出資料、誰接收資料、當接收端跟不上時送出端如何被阻擋或丟棄。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>建立與使用 channel</li>
<li>看懂 channel 的方向與資料型別</li>
<li>理解 buffered channel 的 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 意義</li>
<li>分辨 blocking send 與 non-blocking send</li>
<li>用 channel 畫出資料流</li>
</ol>
<hr>
<h2 id="觀察channel-連接送出端與接收端">【觀察】channel 連接送出端與接收端</h2>
<p>channel 的核心規則是：送出端用 <code>&lt;-</code> 把值放入 channel，接收端用 <code>&lt;-</code> 從 channel 取出值。以下範例建立一個傳遞 <code>string</code> 的 channel：</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="nx">messages</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="kt">string</span><span class="p">)</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="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">messages</span> <span class="o">&lt;-</span> <span class="s">&#34;hello&#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="nx">msg</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">messages</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">msg</span><span class="p">)</span></span></span></code></pre></div><p><code>make(chan string)</code> 建立只能傳 <code>string</code> 的 channel。<code>messages &lt;- &quot;hello&quot;</code> 是送出，<code>msg := &lt;-messages</code> 是接收。</p>
<h2 id="判讀channel-是同步點不只是佇列">【判讀】channel 是同步點，不只是佇列</h2>
<p>unbuffered channel 的核心規則是：送出和接收必須同時準備好，資料才會通過。這表示 channel 也是同步點。</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="nx">ch</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="kt">int</span><span class="p">)</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="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">ch</span> <span class="o">&lt;-</span> <span class="mi">1</span> <span class="c1">// 等到有人接收才會繼續</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="nx">value</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">ch</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">value</span><span class="p">)</span></span></span></code></pre></div><p>buffered channel 的核心規則是：<a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 未滿時送出不會阻塞，buffer 滿時送出會阻塞。</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="nx">jobs</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Job</span><span class="p">,</span> <span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">jobs</span> <span class="o">&lt;-</span> <span class="nx">Job</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;1&#34;</span><span class="p">}</span></span></span></code></pre></div><p>buffer 大小不是隨便的數字。它代表系統允許累積多少尚未處理的工作；接收端處理速度跟不上時，buffer 會逐漸填滿，最後形成 backpressure 。</p>
<h2 id="策略用方向限制表達所有權">【策略】用方向限制表達所有權</h2>
<p>channel direction 的核心規則是：函式簽名應限制自己只需要的能力。Go 可以用 channel direction 表達函式只讀或只寫：</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">producer</span><span class="p">(</span><span class="nx">out</span> <span class="kd">chan</span><span class="o">&lt;-</span> <span class="nx">Job</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">out</span> <span class="o">&lt;-</span> <span class="nx">Job</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;1&#34;</span><span class="p">}</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="nf">consumer</span><span class="p">(</span><span class="nx">in</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Job</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">job</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">in</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nf">handle</span><span class="p">(</span><span class="nx">job</span><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>chan&lt;- Job</code> 表示只能送出，<code>&lt;-chan Job</code> 表示只能接收。這是 API 層的保護：<a href="/blog/backend/knowledge-cards/producer/" data-link-title="Producer" data-link-desc="說明 producer 如何把工作、事件或資料送入後續處理路徑">producer</a> 不能讀取 channel，<a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 不能寫入 channel。</p>
<h2 id="執行non-blocking-send-的取捨">【執行】non-blocking send 的取捨</h2>
<p>non-blocking send 的核心規則是：送不出去時立即走 <a href="/blog/backend/knowledge-cards/fallback/" data-link-title="Fallback" data-link-desc="說明主要路徑失敗時使用替代結果或替代流程的設計責任">fallback</a>，不等待接收端。它適合「寧可丟棄或記錄，也不要卡住呼叫端」的情境。</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">case</span> <span class="nx">jobs</span> <span class="o">&lt;-</span> <span class="nx">job</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;job queued&#34;</span><span class="p">,</span> <span class="s">&#34;id&#34;</span><span class="p">,</span> <span class="nx">job</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">logger</span><span class="p">.</span><span class="nf">Warn</span><span class="p">(</span><span class="s">&#34;job queue full&#34;</span><span class="p">,</span> <span class="s">&#34;id&#34;</span><span class="p">,</span> <span class="nx">job</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個策略的代價是資料可能被丟棄，所以必須記錄 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 或回傳明確錯誤。若資料不能丟，就不要用 default；讓送出端阻塞或回傳「系統忙碌」會更誠實。</p>
<h2 id="關閉-channel">關閉 channel</h2>
<p>關閉 channel 的核心規則是：由送出端關閉，表示不會再有新資料。接收端可以用 <code>range</code> 讀到 channel 關閉：</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">producer</span><span class="p">(</span><span class="nx">out</span> <span class="kd">chan</span><span class="o">&lt;-</span> <span class="kt">int</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="nb">close</span><span class="p">(</span><span class="nx">out</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">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="mi">3</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">out</span> <span class="o">&lt;-</span> <span class="nx">i</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="kd">func</span> <span class="nf">consumer</span><span class="p">(</span><span class="nx">in</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="kt">int</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="nx">value</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">in</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">value</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>接收端不應關閉自己沒有所有權的 channel；否則送出端可能在送資料時遇到 panic。</p>
]]></content:encoded></item><item><title>4.2 事件去重與語義鍵設計</title><link>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/dedup-key/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/dedup-key/</guid><description>&lt;p>事件去重的核心規則是用領域語意判斷「哪兩筆事件代表同一件事」。原始 payload、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/request-id/" data-link-title="Request ID" data-link-desc="說明單次 request 的識別碼如何支援 log 搜尋與問題定位">request ID&lt;/a>、收到時間和重試次數常常每次都不同，直接拿來比對會讓去重失效。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 event ID 去重與 domain key 去重的差異&lt;/li>
&lt;li>用 subject、event type、source group 與時間窗口設計 &lt;code>DedupKey&lt;/code>&lt;/li>
&lt;li>避免把不穩定欄位放進去重鍵&lt;/li>
&lt;li>設計去重表的過期與清理策略&lt;/li>
&lt;li>用 table-driven test 驗證去重邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察重複事件不一定長得一樣">【觀察】重複事件不一定長得一樣&lt;/h2>
&lt;p>重複事件的核心困難是外觀可能不同。HTTP callback 可能每次都有新的 request ID，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> message 可能因 retry 改變 delivery tag，timer 可能在下一輪掃描再次產生類似事件。&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;request_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;req_1001&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;event_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;provider_7788&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;account_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;acct_1&amp;#34;&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="nt">&amp;#34;event_name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;activated&amp;#34;&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="nt">&amp;#34;timestamp&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2026-04-22T10:00:03Z&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;/code>&lt;/pre>&lt;/div>




&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;request_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;req_1002&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;event_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;provider_7788_retry&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;account_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;acct_1&amp;#34;&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="nt">&amp;#34;event_name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;activated&amp;#34;&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="nt">&amp;#34;timestamp&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2026-04-22T10:00:05Z&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;/code>&lt;/pre>&lt;/div>&lt;p>如果直接比對整包 JSON，這兩筆不同；如果從 domain 看，它們可能都是「同一個 account 在同一小段時間內變成 active」。&lt;/p>
&lt;h2 id="判讀去重鍵是語意決策">【判讀】去重鍵是語意決策&lt;/h2>
&lt;p>去重鍵的核心責任是把「相同事件」的定義寫進型別。它不是單純把 payload 做 hash；hash 只能回答 bytes 是否相同，不能回答領域事件是否相同。&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">DedupKey&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">SubjectID&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">Type&lt;/span> &lt;span class="nx">EventType&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">SourceSet&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">Window&lt;/span> &lt;span class="kt">int64&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;p>這個 key 表示：同一個 subject、同一種 event type、同一組來源語意、落在同一個時間窗口的事件，視為同一件事。&lt;/p>
&lt;p>&lt;code>SourceSet&lt;/code> 不一定等於原始來源名稱。多個來源若只是同一件事的不同傳輸管道，可以映射到同一個 source set；若兩個來源代表不同權威資料，則應分開。&lt;/p>
&lt;h2 id="策略先選擇去重層級">【策略】先選擇去重層級&lt;/h2>
&lt;p>去重層級的核心選擇是 event ID、domain key 或兩者並用。不同層級解決的問題不同。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>去重方式&lt;/th>
 &lt;th>判斷依據&lt;/th>
 &lt;th>適用情境&lt;/th>
 &lt;th>風險&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>event ID&lt;/td>
 &lt;td>外部或內部 event ID 相同&lt;/td>
 &lt;td>上游提供穩定唯一 ID&lt;/td>
 &lt;td>上游 retry 可能換 ID&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>domain key&lt;/td>
 &lt;td>subject、type、時間窗口相同&lt;/td>
 &lt;td>多來源可能描述同一件事&lt;/td>
 &lt;td>key 設太粗會誤殺事件&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>兩者並用&lt;/td>
 &lt;td>event ID 先判斷，再用 domain key 補強&lt;/td>
 &lt;td>上游 ID 大多可信但不完全穩定&lt;/td>
 &lt;td>實作與測試較複雜&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>小型服務可以先使用 domain key。若上游提供可靠 event ID，則 event ID 可以成為第一層快速去重，domain key 作為跨來源重複的保護。&lt;/p>
&lt;h2 id="執行用內部事件建立-dedupkey">【執行】用內部事件建立 DedupKey&lt;/h2>
&lt;p>&lt;code>DedupKey&lt;/code> 應該建立在 &lt;code>DomainEvent&lt;/code> 上，而不是 raw input 上。這能讓 HTTP、queue、timer 進來的同類事件共用去重規則。&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">NewDedupKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span> &lt;span class="nx">DomainEvent&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">window&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Duration&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">DedupKey&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">return&lt;/span> &lt;span class="nx">DedupKey&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">SubjectID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SubjectID&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">Type&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&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="nx">SourceSet&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nf">sourceSet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Source&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">Window&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">OccurredAt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">UnixNano&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="nb">int64&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">window&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="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">sourceSet&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">source&lt;/span> &lt;span class="nx">EventSource&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">string&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">switch&lt;/span> &lt;span class="nx">source&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">case&lt;/span> &lt;span class="nx">SourceHTTPCallback&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">SourceQueue&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="s">&amp;#34;external_delivery&amp;#34;&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">case&lt;/span> &lt;span class="nx">SourceTimer&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="k">return&lt;/span> &lt;span class="s">&amp;#34;internal_scan&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&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">17&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nb">string&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">source&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="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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>OccurredAt&lt;/code> 通常比 &lt;code>ReceivedAt&lt;/code> 更適合事件語意去重。兩筆 retry 可能收到時間不同，但實際描述的發生時間相近；若使用收到時間，系統忙碌或網路延遲就會改變去重結果。&lt;/p>
&lt;h2 id="判讀哪些欄位不該放進-key">【判讀】哪些欄位不該放進 key&lt;/h2>
&lt;p>去重鍵的核心限制是不能包含每次都會變的欄位。這類欄位適合用於追蹤、除錯或觀測，不適合用於判斷是否同一事件。&lt;/p>
&lt;p>不適合放進 key 的欄位：&lt;/p>
&lt;ul>
&lt;li>&lt;code>request_id&lt;/code>：每次 request 都可能不同。&lt;/li>
&lt;li>&lt;code>received_at&lt;/code>：取決於系統接收時間，不一定是事件語意。&lt;/li>
&lt;li>&lt;code>delivery_attempt&lt;/code>：重試次數本身就是重複事件的證據。&lt;/li>
&lt;li>raw payload hash：欄位順序、metadata 或非語意欄位可能改變。&lt;/li>
&lt;li>client IP、瀏覽器識別字串：代表傳輸脈絡，不代表事件本身。&lt;/li>
&lt;/ul>
&lt;p>適合放進 key 的欄位：&lt;/p></description><content:encoded><![CDATA[<p>事件去重的核心規則是用領域語意判斷「哪兩筆事件代表同一件事」。原始 payload、<a href="/blog/backend/knowledge-cards/request-id/" data-link-title="Request ID" data-link-desc="說明單次 request 的識別碼如何支援 log 搜尋與問題定位">request ID</a>、收到時間和重試次數常常每次都不同，直接拿來比對會讓去重失效。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 event ID 去重與 domain key 去重的差異</li>
<li>用 subject、event type、source group 與時間窗口設計 <code>DedupKey</code></li>
<li>避免把不穩定欄位放進去重鍵</li>
<li>設計去重表的過期與清理策略</li>
<li>用 table-driven test 驗證去重邊界</li>
</ol>
<hr>
<h2 id="觀察重複事件不一定長得一樣">【觀察】重複事件不一定長得一樣</h2>
<p>重複事件的核心困難是外觀可能不同。HTTP callback 可能每次都有新的 request ID，<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> message 可能因 retry 改變 delivery tag，timer 可能在下一輪掃描再次產生類似事件。</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;request_id&#34;</span><span class="p">:</span> <span class="s2">&#34;req_1001&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;event_id&#34;</span><span class="p">:</span> <span class="s2">&#34;provider_7788&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;account_id&#34;</span><span class="p">:</span> <span class="s2">&#34;acct_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nt">&#34;event_name&#34;</span><span class="p">:</span> <span class="s2">&#34;activated&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nt">&#34;timestamp&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-04-22T10:00:03Z&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div>




<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;request_id&#34;</span><span class="p">:</span> <span class="s2">&#34;req_1002&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;event_id&#34;</span><span class="p">:</span> <span class="s2">&#34;provider_7788_retry&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;account_id&#34;</span><span class="p">:</span> <span class="s2">&#34;acct_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nt">&#34;event_name&#34;</span><span class="p">:</span> <span class="s2">&#34;activated&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nt">&#34;timestamp&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-04-22T10:00:05Z&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>如果直接比對整包 JSON，這兩筆不同；如果從 domain 看，它們可能都是「同一個 account 在同一小段時間內變成 active」。</p>
<h2 id="判讀去重鍵是語意決策">【判讀】去重鍵是語意決策</h2>
<p>去重鍵的核心責任是把「相同事件」的定義寫進型別。它不是單純把 payload 做 hash；hash 只能回答 bytes 是否相同，不能回答領域事件是否相同。</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">DedupKey</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">SubjectID</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Type</span>      <span class="nx">EventType</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">SourceSet</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">Window</span>    <span class="kt">int64</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 key 表示：同一個 subject、同一種 event type、同一組來源語意、落在同一個時間窗口的事件，視為同一件事。</p>
<p><code>SourceSet</code> 不一定等於原始來源名稱。多個來源若只是同一件事的不同傳輸管道，可以映射到同一個 source set；若兩個來源代表不同權威資料，則應分開。</p>
<h2 id="策略先選擇去重層級">【策略】先選擇去重層級</h2>
<p>去重層級的核心選擇是 event ID、domain key 或兩者並用。不同層級解決的問題不同。</p>
<table>
  <thead>
      <tr>
          <th>去重方式</th>
          <th>判斷依據</th>
          <th>適用情境</th>
          <th>風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>event ID</td>
          <td>外部或內部 event ID 相同</td>
          <td>上游提供穩定唯一 ID</td>
          <td>上游 retry 可能換 ID</td>
      </tr>
      <tr>
          <td>domain key</td>
          <td>subject、type、時間窗口相同</td>
          <td>多來源可能描述同一件事</td>
          <td>key 設太粗會誤殺事件</td>
      </tr>
      <tr>
          <td>兩者並用</td>
          <td>event ID 先判斷，再用 domain key 補強</td>
          <td>上游 ID 大多可信但不完全穩定</td>
          <td>實作與測試較複雜</td>
      </tr>
  </tbody>
</table>
<p>小型服務可以先使用 domain key。若上游提供可靠 event ID，則 event ID 可以成為第一層快速去重，domain key 作為跨來源重複的保護。</p>
<h2 id="執行用內部事件建立-dedupkey">【執行】用內部事件建立 DedupKey</h2>
<p><code>DedupKey</code> 應該建立在 <code>DomainEvent</code> 上，而不是 raw input 上。這能讓 HTTP、queue、timer 進來的同類事件共用去重規則。</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">NewDedupKey</span><span class="p">(</span><span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">,</span> <span class="nx">window</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">)</span> <span class="nx">DedupKey</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">return</span> <span class="nx">DedupKey</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>      <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">SourceSet</span><span class="p">:</span> <span class="nf">sourceSet</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">Source</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">Window</span><span class="p">:</span>    <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</span><span class="p">.</span><span class="nf">UnixNano</span><span class="p">()</span> <span class="o">/</span> <span class="nb">int64</span><span class="p">(</span><span class="nx">window</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><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">sourceSet</span><span class="p">(</span><span class="nx">source</span> <span class="nx">EventSource</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">switch</span> <span class="nx">source</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">case</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">SourceQueue</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="s">&#34;external_delivery&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">case</span> <span class="nx">SourceTimer</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="s">&#34;internal_scan&#34;</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="nb">string</span><span class="p">(</span><span class="nx">source</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 class="p">}</span></span></span></code></pre></div><p><code>OccurredAt</code> 通常比 <code>ReceivedAt</code> 更適合事件語意去重。兩筆 retry 可能收到時間不同，但實際描述的發生時間相近；若使用收到時間，系統忙碌或網路延遲就會改變去重結果。</p>
<h2 id="判讀哪些欄位不該放進-key">【判讀】哪些欄位不該放進 key</h2>
<p>去重鍵的核心限制是不能包含每次都會變的欄位。這類欄位適合用於追蹤、除錯或觀測，不適合用於判斷是否同一事件。</p>
<p>不適合放進 key 的欄位：</p>
<ul>
<li><code>request_id</code>：每次 request 都可能不同。</li>
<li><code>received_at</code>：取決於系統接收時間，不一定是事件語意。</li>
<li><code>delivery_attempt</code>：重試次數本身就是重複事件的證據。</li>
<li>raw payload hash：欄位順序、metadata 或非語意欄位可能改變。</li>
<li>client IP、瀏覽器識別字串：代表傳輸脈絡，不代表事件本身。</li>
</ul>
<p>適合放進 key 的欄位：</p>
<ul>
<li>subject ID：事件作用的對象。</li>
<li>event type：發生了什麼事。</li>
<li>source set：資料權威或來源語意。</li>
<li>occurred time window：同一事件可接受的時間範圍。</li>
</ul>
<h2 id="策略時間窗口是取捨">【策略】時間窗口是取捨</h2>
<p>時間窗口的核心作用是容忍短時間內的重送。窗口越短，越不容易誤殺不同事件；窗口越長，越能吸收延遲與 retry。</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">defaultDedupWindow</span> <span class="p">=</span> <span class="mi">30</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span></span></span></code></pre></div><p>窗口大小應該依事件語意決定：</p>
<table>
  <thead>
      <tr>
          <th>事件類型</th>
          <th>可用窗口</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>account activated</td>
          <td>1-5 分鐘</td>
          <td>同一 account 短時間重複啟用通常是 retry</td>
      </tr>
      <tr>
          <td>notification created</td>
          <td>不一定適合時間窗口</td>
          <td>使用者可能短時間建立多筆通知</td>
      </tr>
      <tr>
          <td>job finished</td>
          <td>30 秒-2 分鐘</td>
          <td>job 完成事件通常只應發生一次</td>
      </tr>
      <tr>
          <td>heartbeat received</td>
          <td>不應去重成單一事件</td>
          <td>heartbeat 本身就是週期訊號</td>
      </tr>
  </tbody>
</table>
<p>時間窗口不是萬用答案。若事件本身允許短時間內多次發生，就需要更細的 subject 或 event ID，而不是把窗口調小到碰運氣。</p>
<h2 id="執行deduper-要保護共享-map">【執行】Deduper 要保護共享 map</h2>
<p>in-memory deduper 的核心責任是記住近期看過的 key，並在多 goroutine 下保持安全。只要 processor 可能同時處理事件，就需要 mutex 或單一 goroutine 擁有去重表。</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">Deduper</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">mu</span>      <span class="nx">sync</span><span class="p">.</span><span class="nx">Mutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">seen</span>    <span class="kd">map</span><span class="p">[</span><span class="nx">DedupKey</span><span class="p">]</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">window</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">expires</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</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">NewDeduper</span><span class="p">(</span><span class="nx">window</span><span class="p">,</span> <span class="nx">expires</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">)</span> <span class="o">*</span><span class="nx">Deduper</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">Deduper</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">seen</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="nx">DedupKey</span><span class="p">]</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">window</span><span class="p">:</span>  <span class="nx">window</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">expires</span><span class="p">:</span> <span class="nx">expires</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <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="kd">func</span> <span class="p">(</span><span class="nx">d</span> <span class="o">*</span><span class="nx">Deduper</span><span class="p">)</span> <span class="nf">Seen</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">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="p">(</span><span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nx">d</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">18</span><span class="cl">    <span class="k">defer</span> <span class="nx">d</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">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="nx">key</span> <span class="o">:=</span> <span class="nf">NewDedupKey</span><span class="p">(</span><span class="nx">event</span><span class="p">,</span> <span class="nx">d</span><span class="p">.</span><span class="nx">window</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</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">d</span><span class="p">.</span><span class="nx">seen</span><span class="p">[</span><span class="nx">key</span><span class="p">];</span> <span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="k">return</span> <span class="kc">true</span><span class="p">,</span> <span class="kc">nil</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></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="nx">d</span><span class="p">.</span><span class="nx">seen</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="p">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ReceivedAt</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">return</span> <span class="kc">false</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>ctx</code> 在 memory 實作中可能用不到，但保留在 port 上能讓未來改成 Redis、資料庫或遠端服務時支援取消與逾時。</p>
<h2 id="執行去重表必須清理">【執行】去重表必須清理</h2>
<p>去重表的核心風險是無限制成長。只要把 key 放進 map，就必須定義 key 何時過期。</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">d</span> <span class="o">*</span><span class="nx">Deduper</span><span class="p">)</span> <span class="nf">Cleanup</span><span class="p">(</span><span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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">d</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"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">d</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"> 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="nx">key</span><span class="p">,</span> <span class="nx">seenAt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">d</span><span class="p">.</span><span class="nx">seen</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">if</span> <span class="nx">now</span><span class="p">.</span><span class="nf">Sub</span><span class="p">(</span><span class="nx">seenAt</span><span class="p">)</span> <span class="p">&gt;</span> <span class="nx">d</span><span class="p">.</span><span class="nx">expires</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nb">delete</span><span class="p">(</span><span class="nx">d</span><span class="p">.</span><span class="nx">seen</span><span class="p">,</span> <span class="nx">key</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="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>expires</code> 通常應該大於 <code>window</code>。窗口決定兩筆事件是否可能被視為相同，過期時間決定 key 在記憶體中保留多久；兩者不是同一個概念。</p>
<h2 id="測試用-table-driven-test-固定語意">【測試】用 table-driven test 固定語意</h2>
<p>去重測試的核心目標是把「什麼算相同」寫成案例。這比只測 map 是否有資料更重要。</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">TestDedupKey</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">base</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</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">tests</span> <span class="o">:=</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 class="nx">name</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">a</span>    <span class="nx">DomainEvent</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">b</span>    <span class="nx">DomainEvent</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">same</span> <span class="kt">bool</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="nx">name</span><span class="p">:</span> <span class="s">&#34;same subject type and window&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="nx">a</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="nx">b</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceQueue</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">5</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">)},</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nx">same</span><span class="p">:</span> <span class="kc">true</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="nx">name</span><span class="p">:</span> <span class="s">&#34;different subject&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="nx">a</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="nx">b</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_2&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="nx">same</span><span class="p">:</span> <span class="kc">false</span><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="nx">name</span><span class="p">:</span> <span class="s">&#34;outside window&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">            <span class="nx">a</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">            <span class="nx">b</span><span class="p">:</span> <span class="nx">DomainEvent</span><span class="p">{</span><span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span> <span class="nx">Type</span><span class="p">:</span> <span class="nx">EventAccountActivated</span><span class="p">,</span> <span class="nx">Source</span><span class="p">:</span> <span class="nx">SourceHTTPCallback</span><span class="p">,</span> <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">base</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)},</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">            <span class="nx">same</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</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">32</span><span class="cl">            <span class="nx">got</span> <span class="o">:=</span> <span class="nf">NewDedupKey</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">a</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)</span> <span class="o">==</span> <span class="nf">NewDedupKey</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">b</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">            <span class="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">same</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">34</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;same key = %v, want %v&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">same</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試把來源融合、subject 差異與時間窗口都明確化。未來調整 key 時，測試會提醒你正在改變事件語意，而不只是改一個 struct。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一服務內的事件去重語意；跨節點一致性與 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> store，會在下列章節再往外延伸：</p>
<ul>
<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/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫與持久化</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 event normalization、processor 與 source priority；如果你要先回看語言教材，可以讀：</p>
<ul>
<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/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/07-refactoring/dedup-refactor/" data-link-title="7.3 事件去重邏輯的重構策略" data-link-desc="保留語義鍵並降低重複流程">Go：事件去重邏輯的重構策略</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>事件去重是領域語意設計，不是 payload 比對。好的 <code>DedupKey</code> 會使用 subject、event type、source set 與合適的 occurred time window，並避免 request ID、收到時間與 raw payload hash 這類不穩定欄位。去重表還必須有清理策略，否則事件系統會用記憶體 leak 換取短期正確性。</p>
]]></content:encoded></item><item><title>5.2 testing 基礎</title><link>https://tarrragon.github.io/blog/go/05-error-testing/testing-basics/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/05-error-testing/testing-basics/</guid><description>&lt;p>Go 測試的核心規則是：測試檔以 &lt;code>_test.go&lt;/code> 結尾，測試函式以 &lt;code>Test&lt;/code> 開頭並接收 &lt;code>*testing.T&lt;/code>。本章將說明如何建立第一個單元測試、檢查結果與回報失敗。&lt;/p>
&lt;h2 id="測試是同一個-package-的行為說明">測試是同一個 package 的行為說明&lt;/h2>
&lt;p>Go 測試的核心目標是驗證可觀察行為。測試用輸入、輸出與錯誤條件說明這段程式應該如何運作。&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">NormalizeName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">string&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">input&lt;/span> &lt;span class="p">=&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">input&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">return&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ToLower&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個函式的可觀察行為是：移除前後空白，並轉成小寫。測試應該檢查這個行為，而不是檢查函式內部是否真的先呼叫 &lt;code>TrimSpace&lt;/code> 再呼叫 &lt;code>ToLower&lt;/code>。&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">TestNormalizeName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">got&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NormalizeName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34; Alice &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="nx">want&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="s">&amp;#34;alice&amp;#34;&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="k">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">want&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">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;NormalizeName() = %q, want %q&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&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="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>這就是最小的 Go 單元測試：準備輸入，呼叫函式，比對結果，失敗時回報清楚訊息。&lt;/p>
&lt;h2 id="測試檔命名有固定規則">測試檔命名有固定規則&lt;/h2>
&lt;p>Go 測試檔的核心規則是檔名必須以 &lt;code>_test.go&lt;/code> 結尾。測試函式必須以 &lt;code>Test&lt;/code> 開頭，接收一個 &lt;code>*testing.T&lt;/code> 參數，且沒有回傳值。&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="c1">// normalize_test.go&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">TestNormalizeName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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="c1">// ...&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>go test&lt;/code> 會自動找到這些檔案與函式。測試檔可以和被測程式放在同一個 package，也可以使用 &lt;code>package xxx_test&lt;/code> 建立外部測試 package。&lt;/p>
&lt;p>同 package 測試可以存取未匯出的函式與型別，外部測試只能使用匯出的 API。入門階段可以先用同 package 測試，等到需要從使用者視角驗證 public API 時，再使用外部測試。&lt;/p>
&lt;h2 id="失敗訊息要說明-got-與-want">失敗訊息要說明 got 與 want&lt;/h2>
&lt;p>測試失敗訊息的核心責任是幫助讀者快速定位差異。Go 社群常用 &lt;code>got&lt;/code> 與 &lt;code>want&lt;/code> 表示實際結果與預期結果。&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="k">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">want&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">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;NormalizeName() = %q, want %q&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個訊息包含函式名稱、實際結果與預期結果。當測試失敗時，讀者不需要再打開測試檔猜哪個值錯了。&lt;/p>
&lt;p>&lt;code>t.Fatal&lt;/code> 與 &lt;code>t.Fatalf&lt;/code> 會立刻中止目前測試；&lt;code>t.Error&lt;/code> 與 &lt;code>t.Errorf&lt;/code> 會記錄失敗但繼續執行。若後續檢查依賴目前結果，使用 &lt;code>Fatalf&lt;/code> 比較安全。&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="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">ParsePort&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;8080&amp;#34;&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">if&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">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;ParsePort() error = %v&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">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="k">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="mi">8080&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="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;ParsePort() = %d, want %d&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">8080&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;/code>&lt;/pre>&lt;/div>&lt;p>如果解析已經失敗，後面再檢查數值沒有意義，所以先用 &lt;code>Fatalf&lt;/code> 結束測試。&lt;/p>
&lt;h2 id="測試錯誤要明確檢查錯誤存在">測試錯誤要明確檢查錯誤存在&lt;/h2>
&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">ParsePort&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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">port&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">strconv&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Atoi&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&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">if&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"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&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;parse port %q: %w&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&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"> 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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">port&lt;/span> &lt;span class="o">&amp;lt;=&lt;/span> &lt;span class="mi">0&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="mi">0&lt;/span>&lt;span class="p">,&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;port must be positive&amp;#34;&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&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="nx">port&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&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>測試成功情境時，應確認沒有錯誤；測試失敗情境時，應確認錯誤確實發生。&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">TestParsePortInvalid&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">ParsePort&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;abc&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="k">if&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">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;ParsePort() error = nil, want error&amp;#34;&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;/code>&lt;/pre>&lt;/div>&lt;p>若程式使用 sentinel error 或可辨識的錯誤型別，可以再用 &lt;code>errors.Is&lt;/code> 或 &lt;code>errors.As&lt;/code> 檢查錯誤種類。不要只比對完整錯誤字串，除非錯誤訊息本身就是公開合約。&lt;/p>
&lt;h2 id="helper-函式可以降低重複">helper 函式可以降低重複&lt;/h2>
&lt;p>測試 helper 的核心用途是隱藏準備資料的細節，而不是隱藏真正的驗證邏輯。helper 應該讓測試主體更接近「這個行為應該成立」。&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">mustParsePort&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">int&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">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Helper&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">port&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">ParsePort&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&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">if&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"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;ParsePort(%q) error = %v&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&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"> 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="k">return&lt;/span> &lt;span class="nx">port&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&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>t.Helper()&lt;/code> 會讓失敗行號指向呼叫 helper 的測試，而不是 helper 內部。這能讓測試失敗時更快找到真正的案例位置。&lt;/p>
&lt;p>helper 不應該把測試意圖藏起來。若 helper 名稱太抽象，或讀者必須跳進 helper 才知道測試在驗證什麼，這個 helper 可能反而降低可讀性。&lt;/p>
&lt;h2 id="測試要避免依賴不穩定環境">測試要避免依賴不穩定環境&lt;/h2>
&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">IsExpired&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">deadline&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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">return&lt;/span> &lt;span class="nx">now&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">After&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">deadline&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個函式把 &lt;code>now&lt;/code> 當成參數，因此測試可以傳入固定時間。&lt;/p></description><content:encoded><![CDATA[<p>Go 測試的核心規則是：測試檔以 <code>_test.go</code> 結尾，測試函式以 <code>Test</code> 開頭並接收 <code>*testing.T</code>。本章將說明如何建立第一個單元測試、檢查結果與回報失敗。</p>
<h2 id="測試是同一個-package-的行為說明">測試是同一個 package 的行為說明</h2>
<p>Go 測試的核心目標是驗證可觀察行為。測試用輸入、輸出與錯誤條件說明這段程式應該如何運作。</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">NormalizeName</span><span class="p">(</span><span class="nx">input</span> <span class="kt">string</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">input</span> <span class="p">=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">return</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">ToLower</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個函式的可觀察行為是：移除前後空白，並轉成小寫。測試應該檢查這個行為，而不是檢查函式內部是否真的先呼叫 <code>TrimSpace</code> 再呼叫 <code>ToLower</code>。</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">TestNormalizeName</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">got</span> <span class="o">:=</span> <span class="nf">NormalizeName</span><span class="p">(</span><span class="s">&#34;  Alice  &#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">want</span> <span class="o">:=</span> <span class="s">&#34;alice&#34;</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">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">want</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;NormalizeName() = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">want</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>這就是最小的 Go 單元測試：準備輸入，呼叫函式，比對結果，失敗時回報清楚訊息。</p>
<h2 id="測試檔命名有固定規則">測試檔命名有固定規則</h2>
<p>Go 測試檔的核心規則是檔名必須以 <code>_test.go</code> 結尾。測試函式必須以 <code>Test</code> 開頭，接收一個 <code>*testing.T</code> 參數，且沒有回傳值。</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="c1">// normalize_test.go</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">func</span> <span class="nf">TestNormalizeName</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">3</span><span class="cl">    <span class="c1">// ...</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>go test</code> 會自動找到這些檔案與函式。測試檔可以和被測程式放在同一個 package，也可以使用 <code>package xxx_test</code> 建立外部測試 package。</p>
<p>同 package 測試可以存取未匯出的函式與型別，外部測試只能使用匯出的 API。入門階段可以先用同 package 測試，等到需要從使用者視角驗證 public API 時，再使用外部測試。</p>
<h2 id="失敗訊息要說明-got-與-want">失敗訊息要說明 got 與 want</h2>
<p>測試失敗訊息的核心責任是幫助讀者快速定位差異。Go 社群常用 <code>got</code> 與 <code>want</code> 表示實際結果與預期結果。</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="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">want</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</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;NormalizeName() = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">want</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>這個訊息包含函式名稱、實際結果與預期結果。當測試失敗時，讀者不需要再打開測試檔猜哪個值錯了。</p>
<p><code>t.Fatal</code> 與 <code>t.Fatalf</code> 會立刻中止目前測試；<code>t.Error</code> 與 <code>t.Errorf</code> 會記錄失敗但繼續執行。若後續檢查依賴目前結果，使用 <code>Fatalf</code> 比較安全。</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="nx">got</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">ParsePort</span><span class="p">(</span><span class="s">&#34;8080&#34;</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">err</span> <span class="o">!=</span> <span class="kc">nil</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;ParsePort() error = %v&#34;</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="mi">8080</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</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;ParsePort() = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="mi">8080</span><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>Fatalf</code> 結束測試。</p>
<h2 id="測試錯誤要明確檢查錯誤存在">測試錯誤要明確檢查錯誤存在</h2>
<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">ParsePort</span><span class="p">(</span><span class="nx">input</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="kt">int</span><span class="p">,</span> <span class="kt">error</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">port</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">strconv</span><span class="p">.</span><span class="nf">Atoi</span><span class="p">(</span><span class="nx">input</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="nx">err</span> <span class="o">!=</span> <span class="kc">nil</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="mi">0</span><span class="p">,</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;parse port %q: %w&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="nx">port</span> <span class="o">&lt;=</span> <span class="mi">0</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="mi">0</span><span class="p">,</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;port must be positive&#34;</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">return</span> <span class="nx">port</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><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">TestParsePortInvalid</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">_</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">ParsePort</span><span class="p">(</span><span class="s">&#34;abc&#34;</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="nx">err</span> <span class="o">==</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</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;ParsePort() error = nil, want error&#34;</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></code></pre></div><p>若程式使用 sentinel error 或可辨識的錯誤型別，可以再用 <code>errors.Is</code> 或 <code>errors.As</code> 檢查錯誤種類。不要只比對完整錯誤字串，除非錯誤訊息本身就是公開合約。</p>
<h2 id="helper-函式可以降低重複">helper 函式可以降低重複</h2>
<p>測試 helper 的核心用途是隱藏準備資料的細節，而不是隱藏真正的驗證邏輯。helper 應該讓測試主體更接近「這個行為應該成立」。</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">mustParsePort</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="nx">input</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Helper</span><span class="p">()</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">port</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">ParsePort</span><span class="p">(</span><span class="nx">input</span><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">err</span> <span class="o">!=</span> <span class="kc">nil</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;ParsePort(%q) error = %v&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="nx">port</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>t.Helper()</code> 會讓失敗行號指向呼叫 helper 的測試，而不是 helper 內部。這能讓測試失敗時更快找到真正的案例位置。</p>
<p>helper 不應該把測試意圖藏起來。若 helper 名稱太抽象，或讀者必須跳進 helper 才知道測試在驗證什麼，這個 helper 可能反而降低可讀性。</p>
<h2 id="測試要避免依賴不穩定環境">測試要避免依賴不穩定環境</h2>
<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">IsExpired</span><span class="p">(</span><span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">,</span> <span class="nx">deadline</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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">return</span> <span class="nx">now</span><span class="p">.</span><span class="nf">After</span><span class="p">(</span><span class="nx">deadline</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>這個函式把 <code>now</code> 當成參數，因此測試可以傳入固定時間。</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">TestIsExpired</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">now</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">deadline</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">9</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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">if</span> <span class="p">!</span><span class="nf">IsExpired</span><span class="p">(</span><span class="nx">now</span><span class="p">,</span> <span class="nx">deadline</span><span class="p">)</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;IsExpired() = false, want true&#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>測試要在合適層級控制變因。單元測試優先控制依賴，整合測試才使用真實檔案、網路或服務。</p>
<h2 id="下一章">下一章</h2>
<p>下一章會介紹 table-driven test，說明如何用同一個測試流程整理多組案例。</p>
]]></content:encoded></item><item><title>5.2 WebSocket integration test</title><link>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/websocket-integration/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/websocket-integration/</guid><description>&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> integration test 的核心目標是驗證 client 與 server 透過真實連線互動後，協定行為是否正確。它比單元測試慢，但能覆蓋 HTTP upgrade、read/write pump、router、server message、push flow 與 cleanup。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>用 &lt;code>httptest.Server&lt;/code> 建立真實 WebSocket 測試入口&lt;/li>
&lt;li>將 &lt;code>http://&lt;/code> 測試 URL 轉成 &lt;code>ws://&lt;/code>&lt;/li>
&lt;li>用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a> 避免 read/write 永久卡住&lt;/li>
&lt;li>驗證 subscribe、push、error response 與 cleanup&lt;/li>
&lt;li>分辨 integration test 與 unit test 的責任邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察websocket-的錯誤常出現在元件交界">【觀察】WebSocket 的錯誤常出現在元件交界&lt;/h2>
&lt;p>WebSocket 測試的核心困難是很多錯誤不在單一函式裡。Router 單元測試可能通過，但真實連線仍可能因為 upgrade path、read pump、write pump、send &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 或 unregister 流程出錯。&lt;/p>
&lt;p>Integration test 適合驗證這些交界：&lt;/p>
&lt;ul>
&lt;li>client 能否成功 dial 到 &lt;code>/ws&lt;/code>&lt;/li>
&lt;li>server 是否接受 client action&lt;/li>
&lt;li>subscribe 後是否收到 acknowledgement&lt;/li>
&lt;li>server broadcast 是否能推到 client&lt;/li>
&lt;li>client 關閉後 hub 是否清理連線&lt;/li>
&lt;li>錯誤 action 是否回 error message 而不是斷線&lt;/li>
&lt;/ul>
&lt;p>這些不是每個單元測試都該覆蓋的內容。Integration test 的價值在於證明多個元件能透過真實協定協作。&lt;/p>
&lt;h2 id="判讀integration-test-補的是協作信心">【判讀】integration test 補的是協作信心&lt;/h2>
&lt;p>Integration test 的核心責任是覆蓋協定流程，不是取代所有規則測試。Router validation、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> normalization、dedup key、state transition 應主要用單元測試；WebSocket integration test 只挑關鍵端到端流程。&lt;/p>
&lt;p>建議分工：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>測試類型&lt;/th>
 &lt;th>負責內容&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>unit test&lt;/td>
 &lt;td>router、payload validation、subscription state、TrySend&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>integration test&lt;/td>
 &lt;td>dial、upgrade、read/write pump、server response、cleanup&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>race test&lt;/td>
 &lt;td>hub、client state、repository 的並發存取&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>如果每個 validation case 都啟動 WebSocket server，測試會變慢且失敗定位不清楚。Integration test 應少量、關鍵、穩定。&lt;/p>
&lt;h2 id="執行用-httptestserver-建立真實入口">【執行】用 httptest.Server 建立真實入口&lt;/h2>
&lt;p>WebSocket integration test 的核心起點是 &lt;code>httptest.Server&lt;/code>。它提供真實 HTTP server，不需要手動管理 port。&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">TestWebSocketSubscribe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">server&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">httptest&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewServer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nf">newRouter&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">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Cleanup&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">server&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Close&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">wsURL&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="s">&amp;#34;ws&amp;#34;&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">TrimPrefix&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">server&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">URL&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;http&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s">&amp;#34;/ws&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">conn&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&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">DefaultDialer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Dial&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">wsURL&lt;/span>&lt;span class="p">,&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"> 8&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="kc">nil&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="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;dial websocket: %v&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">10&lt;/span>&lt;span class="cl"> &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">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Cleanup&lt;/span>&lt;span class="p">(&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">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&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">Close&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="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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>httptest.NewServer&lt;/code> 產生的是 &lt;code>http://127.0.0.1:port&lt;/code>，WebSocket client 需要 &lt;code>ws://127.0.0.1:port/ws&lt;/code>，所以常用字串轉換。&lt;/p>
&lt;p>若 handler 需要 hub、router、fake repository，應在測試中明確組裝。這讓 integration test 的依賴可控。&lt;/p>
&lt;h2 id="策略測試-helper-應封裝連線樣板">【策略】測試 helper 應封裝連線樣板&lt;/h2>
&lt;p>Integration test 的核心樣板很多：建立 server、轉 URL、dial、設定 cleanup。可以用 helper 降低重複，但不要把協定斷言藏起來。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> integration test 的核心目標是驗證 client 與 server 透過真實連線互動後，協定行為是否正確。它比單元測試慢，但能覆蓋 HTTP upgrade、read/write pump、router、server message、push flow 與 cleanup。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>用 <code>httptest.Server</code> 建立真實 WebSocket 測試入口</li>
<li>將 <code>http://</code> 測試 URL 轉成 <code>ws://</code></li>
<li>用 <a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 避免 read/write 永久卡住</li>
<li>驗證 subscribe、push、error response 與 cleanup</li>
<li>分辨 integration test 與 unit test 的責任邊界</li>
</ol>
<hr>
<h2 id="觀察websocket-的錯誤常出現在元件交界">【觀察】WebSocket 的錯誤常出現在元件交界</h2>
<p>WebSocket 測試的核心困難是很多錯誤不在單一函式裡。Router 單元測試可能通過，但真實連線仍可能因為 upgrade path、read pump、write pump、send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 或 unregister 流程出錯。</p>
<p>Integration test 適合驗證這些交界：</p>
<ul>
<li>client 能否成功 dial 到 <code>/ws</code></li>
<li>server 是否接受 client action</li>
<li>subscribe 後是否收到 acknowledgement</li>
<li>server broadcast 是否能推到 client</li>
<li>client 關閉後 hub 是否清理連線</li>
<li>錯誤 action 是否回 error message 而不是斷線</li>
</ul>
<p>這些不是每個單元測試都該覆蓋的內容。Integration test 的價值在於證明多個元件能透過真實協定協作。</p>
<h2 id="判讀integration-test-補的是協作信心">【判讀】integration test 補的是協作信心</h2>
<p>Integration test 的核心責任是覆蓋協定流程，不是取代所有規則測試。Router validation、<a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> normalization、dedup key、state transition 應主要用單元測試；WebSocket integration test 只挑關鍵端到端流程。</p>
<p>建議分工：</p>
<table>
  <thead>
      <tr>
          <th>測試類型</th>
          <th>負責內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>unit test</td>
          <td>router、payload validation、subscription state、TrySend</td>
      </tr>
      <tr>
          <td>integration test</td>
          <td>dial、upgrade、read/write pump、server response、cleanup</td>
      </tr>
      <tr>
          <td>race test</td>
          <td>hub、client state、repository 的並發存取</td>
      </tr>
  </tbody>
</table>
<p>如果每個 validation case 都啟動 WebSocket server，測試會變慢且失敗定位不清楚。Integration test 應少量、關鍵、穩定。</p>
<h2 id="執行用-httptestserver-建立真實入口">【執行】用 httptest.Server 建立真實入口</h2>
<p>WebSocket integration test 的核心起點是 <code>httptest.Server</code>。它提供真實 HTTP server，不需要手動管理 port。</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">TestWebSocketSubscribe</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">server</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewServer</span><span class="p">(</span><span class="nf">newRouter</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">Cleanup</span><span class="p">(</span><span class="nx">server</span><span class="p">.</span><span class="nx">Close</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">wsURL</span> <span class="o">:=</span> <span class="s">&#34;ws&#34;</span> <span class="o">+</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimPrefix</span><span class="p">(</span><span class="nx">server</span><span class="p">.</span><span class="nx">URL</span><span class="p">,</span> <span class="s">&#34;http&#34;</span><span class="p">)</span> <span class="o">+</span> <span class="s">&#34;/ws&#34;</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="nx">conn</span><span class="p">,</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">websocket</span><span class="p">.</span><span class="nx">DefaultDialer</span><span class="p">.</span><span class="nf">Dial</span><span class="p">(</span><span class="nx">wsURL</span><span class="p">,</span> <span class="kc">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</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"> 9</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;dial websocket: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><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="nx">t</span><span class="p">.</span><span class="nf">Cleanup</span><span class="p">(</span><span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">_</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">13</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>httptest.NewServer</code> 產生的是 <code>http://127.0.0.1:port</code>，WebSocket client 需要 <code>ws://127.0.0.1:port/ws</code>，所以常用字串轉換。</p>
<p>若 handler 需要 hub、router、fake repository，應在測試中明確組裝。這讓 integration test 的依賴可控。</p>
<h2 id="策略測試-helper-應封裝連線樣板">【策略】測試 helper 應封裝連線樣板</h2>
<p>Integration test 的核心樣板很多：建立 server、轉 URL、dial、設定 cleanup。可以用 helper 降低重複，但不要把協定斷言藏起來。</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">newTestWebSocket</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="nx">handler</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span><span class="p">)</span> <span class="p">(</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="o">*</span><span class="nx">httptest</span><span class="p">.</span><span class="nx">Server</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">t</span><span class="p">.</span><span class="nf">Helper</span><span class="p">()</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">server</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewServer</span><span class="p">(</span><span class="nx">handler</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Cleanup</span><span class="p">(</span><span class="nx">server</span><span class="p">.</span><span class="nx">Close</span><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="nx">wsURL</span> <span class="o">:=</span> <span class="s">&#34;ws&#34;</span> <span class="o">+</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimPrefix</span><span class="p">(</span><span class="nx">server</span><span class="p">.</span><span class="nx">URL</span><span class="p">,</span> <span class="s">&#34;http&#34;</span><span class="p">)</span> <span class="o">+</span> <span class="s">&#34;/ws&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">conn</span><span class="p">,</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">websocket</span><span class="p">.</span><span class="nx">DefaultDialer</span><span class="p">.</span><span class="nf">Dial</span><span class="p">(</span><span class="nx">wsURL</span><span class="p">,</span> <span class="kc">nil</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">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;dial websocket: %v&#34;</span><span class="p">,</span> <span class="nx">err</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="nx">t</span><span class="p">.</span><span class="nf">Cleanup</span><span class="p">(</span><span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">_</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">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">return</span> <span class="nx">conn</span><span class="p">,</span> <span class="nx">server</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Helper 負責重複 setup。測試本文仍應清楚寫出「送什麼 message、期待什麼 response」。</p>
<h2 id="執行action-測試要檢查協定語意">【執行】action 測試要檢查協定語意</h2>
<p>Action 測試的核心流程是送 client message、讀 server message、檢查協定欄位。</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">TestSubscribeActionReturnsAcknowledgement</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">conn</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nf">newTestWebSocket</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nf">newRouter</span><span class="p">())</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">request</span> <span class="o">:=</span> <span class="nx">ClientMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</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"> 6</span><span class="cl">        <span class="nx">Data</span><span class="p">:</span> <span class="nf">mustJSON</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">SubscribeTopicRequest</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nx">Topic</span><span class="p">:</span> <span class="s">&#34;alerts&#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="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</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">WriteJSON</span><span class="p">(</span><span class="nx">request</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">12</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;write 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">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">response</span> <span class="o">:=</span> <span class="nf">readServerMessage</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">conn</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">if</span> <span class="nx">response</span><span class="p">.</span><span class="nx">Type</span> <span class="o">!=</span> <span class="s">&#34;topic_subscribed&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</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;response type = %q, want topic_subscribed&#34;</span><span class="p">,</span> <span class="nx">response</span><span class="p">.</span><span class="nx">Type</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 class="k">if</span> <span class="nx">response</span><span class="p">.</span><span class="nx">Topic</span> <span class="o">!=</span> <span class="s">&#34;alerts&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</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;response topic = %q, want alerts&#34;</span><span class="p">,</span> <span class="nx">response</span><span class="p">.</span><span class="nx">Topic</span><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>這個測試檢查的是協定語意，不只是連線沒有斷。Subscribe 的成功條件是 server 明確回覆訂閱成功。</p>
<h2 id="執行每次讀取前設定-deadline">【執行】每次讀取前設定 deadline</h2>
<p>WebSocket integration test 的核心風險是永久卡住。每次等待 server message 前，都應設定 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="nf">readServerMessage</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="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">ServerMessage</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Helper</span><span class="p">()</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="k">if</span> <span class="nx">err</span> <span class="o">:=</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="mi">2</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</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"> 5</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;set read deadline: %v&#34;</span><span class="p">,</span> <span class="nx">err</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">var</span> <span class="nx">response</span> <span class="nx">ServerMessage</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="nx">conn</span><span class="p">.</span><span class="nf">ReadJSON</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">response</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="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;read server message: %v&#34;</span><span class="p">,</span> <span class="nx">err</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="k">return</span> <span class="nx">response</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Deadline 是測試保護。若 server 沒有送出預期訊息，測試會在合理時間內失敗，而不是卡住整個測試套件。</p>
<p><a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">Timeout</a> 不應過短。CI 可能比本機慢，測試應給足合理緩衝，但仍要能快速暴露失敗。</p>
<h2 id="執行推送測試要先建立可觀察觸發點">【執行】推送測試要先建立可觀察觸發點</h2>
<p>Server push 的核心測試流程是先讓 client 訂閱 topic，再從 server 端觸發 broadcast，最後讀取 client 收到的 message。</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">TestSubscribedClientReceivesBroadcast</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="k">go</span> <span class="nx">hub</span><span class="p">.</span><span class="nf">Run</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">conn</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nf">newTestWebSocket</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nf">newRouterWithHub</span><span class="p">(</span><span class="nx">hub</span><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="nf">writeClientMessage</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">conn</span><span class="p">,</span> <span class="nx">ClientMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</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"> 9</span><span class="cl">        <span class="nx">Data</span><span class="p">:</span>   <span class="nf">mustJSON</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">SubscribeTopicRequest</span><span class="p">{</span><span class="nx">Topic</span><span class="p">:</span> <span class="s">&#34;alerts&#34;</span><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="nx">_</span> <span class="p">=</span> <span class="nf">readServerMessage</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">conn</span><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="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></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>  <span class="s">&#34;notification&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span> <span class="s">&#34;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></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">pushed</span> <span class="o">:=</span> <span class="nf">readServerMessage</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">conn</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">pushed</span><span class="p">.</span><span class="nx">Type</span> <span class="o">!=</span> <span class="s">&#34;notification&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</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;pushed type = %q, want notification&#34;</span><span class="p">,</span> <span class="nx">pushed</span><span class="p">.</span><span class="nx">Type</span><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>這個測試證明 subscribe state、hub broadcast、write pump 能透過真實 connection 協作。若只想測 <code>Broadcast</code> 是否檢查 topic，應寫 hub unit test，不必走 WebSocket。</p>
<h2 id="策略非同步清理用-eventually不用固定-sleep">【策略】非同步清理用 eventually，不用固定 sleep</h2>
<p>連線清理測試的核心問題是 cleanup 通常非同步發生。測試應等待可觀察條件，而不是固定 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">eventually</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="nx">timeout</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">,</span> <span class="nx">condition</span> <span class="kd">func</span><span class="p">()</span> <span class="kt">bool</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">t</span><span class="p">.</span><span class="nf">Helper</span><span class="p">()</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">deadline</span> <span class="o">:=</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">timeout</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">for</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">().</span><span class="nf">Before</span><span class="p">(</span><span class="nx">deadline</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">if</span> <span class="nf">condition</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="k">return</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">time</span><span class="p">.</span><span class="nf">Sleep</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">Millisecond</span><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></span><span class="line"><span class="ln">12</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;condition was not met within %s&#34;</span><span class="p">,</span> <span class="nx">timeout</span><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>使用方式：</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">TestClientIsRemovedAfterClose</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">conn</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nf">newTestWebSocket</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nf">newRouterWithHub</span><span class="p">(</span><span class="nx">hub</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="nf">eventually</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">,</span> <span class="kd">func</span><span class="p">()</span> <span class="kt">bool</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="nx">hub</span><span class="p">.</span><span class="nf">ClientCount</span><span class="p">()</span> <span class="o">==</span> <span class="mi">1</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="nx">_</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">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nf">eventually</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">,</span> <span class="kd">func</span><span class="p">()</span> <span class="kt">bool</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">hub</span><span class="p">.</span><span class="nf">ClientCount</span><span class="p">()</span> <span class="o">==</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>eventually</code> 不是任意等待；它等待具體條件。失敗時，測試會指出 cleanup 沒發生，而不是把時間耗掉後仍然不清楚原因。</p>
<h2 id="判讀error-action-應測協定不只測-log">【判讀】error action 應測協定，不只測 log</h2>
<p>WebSocket action 失敗的核心語意是單次 action 失敗，不一定代表連線失敗。Integration test 應確認 server 回 error message，並且連線仍可繼續使用。</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">TestUnknownActionReturnsErrorMessage</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">conn</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nf">newTestWebSocket</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nf">newRouter</span><span class="p">())</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="nf">writeClientMessage</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">conn</span><span class="p">,</span> <span class="nx">ClientMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">Action</span><span class="p">:</span> <span class="s">&#34;unknown_action&#34;</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="nx">response</span> <span class="o">:=</span> <span class="nf">readServerMessage</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">conn</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">response</span><span class="p">.</span><span class="nx">Type</span> <span class="o">!=</span> <span class="s">&#34;error&#34;</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;response type = %q, want error&#34;</span><span class="p">,</span> <span class="nx">response</span><span class="p">.</span><span class="nx">Type</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>若設計上 unknown action 應直接關閉連線，也應明確測出 close 行為。重點是協定行為要可驗證，不要只依賴 server <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一 Go server 內的 WebSocket 協定協作；跨節點 <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>這一章承接的是 WebSocket handler、pump 與 heartbeat；如果你要先回看語言教材，可以讀：</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-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">Go：read/write pump 模式</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">Go：heartbeat、deadline 與連線清理</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/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">Go 進階：CI、fuzz、load test 與 chaos testing</a></li>
<li><a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證流程</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>WebSocket integration test 應少量覆蓋關鍵端到端協定：dial、送 action、收 response、server push、錯誤回應與 cleanup。測試要使用真實 <code>httptest.Server</code>，每次 read 前設定 deadline，等待非同步清理時使用 <code>eventually</code>。單元測試負責大量規則，integration test 負責證明真實連線能把規則串起來。</p>
]]></content:encoded></item><item><title>6.2 如何新增一種 domain event</title><link>https://tarrragon.github.io/blog/go/06-practical/new-event-type/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/06-practical/new-event-type/</guid><description>&lt;p>新增 domain event 的核心流程是先定義事件語意，再決定哪些外部來源可以轉成這個事件。事件是系統內部對「發生了什麼」的穩定合約，常數清單只是其中一種表達方式。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷 event type 是否代表穩定 domain fact&lt;/li>
&lt;li>用 &lt;code>DomainEvent&lt;/code> envelope 承接不同外部來源&lt;/li>
&lt;li>把 raw input 轉成內部事件，而不是直接更新狀態&lt;/li>
&lt;li>設計 validation 與 dedup key&lt;/li>
&lt;li>把 normalize、processor 與 repository 測試分開&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察event-type-代表已經發生的事">【觀察】event type 代表已經發生的事&lt;/h2>
&lt;p>domain event 的核心語意是「某件對系統有意義的事已經發生」。command 表達意圖，event 表達事實；兩者的命名與處理責任要分開。&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>&lt;code>subscribe_topic&lt;/code>&lt;/td>
 &lt;td>command/action&lt;/td>
 &lt;td>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;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>notification.created&lt;/code>&lt;/td>
 &lt;td>event&lt;/td>
 &lt;td>一筆通知已建立&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>job.started&lt;/code>&lt;/td>
 &lt;td>event&lt;/td>
 &lt;td>一個 job 已開始&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>account.status_changed&lt;/code>&lt;/td>
 &lt;td>event&lt;/td>
 &lt;td>account 狀態已改變&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>event type 應該用過去式或事實語氣。&lt;code>notification.created&lt;/code> 比 &lt;code>notification.create&lt;/code> 更能表達事件語意。這個命名差異會影響後續設計：事件處理器應該處理已發生事實，授權與意圖判斷則留在 command/usecase。&lt;/p>
&lt;p>用 typed constant 可以集中 event type：&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">EventType&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">EventNotificationCreated&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;notification.created&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">EventJobStarted&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;job.started&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">EventAccountStatusChanged&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;account.status_changed&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;/code>&lt;/pre>&lt;/div>&lt;p>若 event type 只是為了對應外部欄位名稱，還沒有內部語意，應先把外部 raw input normalize，再判斷它是否真的代表一個穩定事實。&lt;/p>
&lt;h2 id="判讀domainevent-是內部事件合約">【判讀】DomainEvent 是內部事件合約&lt;/h2>
&lt;p>&lt;code>DomainEvent&lt;/code> 的核心責任是提供統一 envelope，讓不同來源的事件進入系統後使用同一種語意模型。HTTP callback、client action、timer 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> message 都可以被 adapter 轉成 &lt;code>DomainEvent&lt;/code>。&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">EventSource&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">SourceClientCommand&lt;/span> &lt;span class="nx">EventSource&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;client_command&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">SourceHTTPCallback&lt;/span> &lt;span class="nx">EventSource&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;http_callback&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">SourceTimer&lt;/span> &lt;span class="nx">EventSource&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;timer&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">SubjectKind&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&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">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">SubjectNotification&lt;/span> &lt;span class="nx">SubjectKind&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;notification&amp;#34;&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">SubjectJob&lt;/span> &lt;span class="nx">SubjectKind&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;job&amp;#34;&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">SubjectAccount&lt;/span> &lt;span class="nx">SubjectKind&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;account&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">DomainEvent&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">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;id&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="nx">Source&lt;/span> &lt;span class="nx">EventSource&lt;/span> &lt;span class="s">`json:&amp;#34;source&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">Type&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="s">`json:&amp;#34;type&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="nx">SubjectID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;subjectId&amp;#34;`&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">SubjectKind&lt;/span> &lt;span class="nx">SubjectKind&lt;/span> &lt;span class="s">`json:&amp;#34;subjectKind&amp;#34;`&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">CorrelationID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;correlationId,omitempty&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="nx">CausationID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;causationId,omitempty&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="nx">OccurredAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span> &lt;span class="s">`json:&amp;#34;occurredAt&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="nx">ReceivedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span> &lt;span class="s">`json:&amp;#34;receivedAt&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl"> &lt;span class="nx">SchemaVersion&lt;/span> &lt;span class="kt">int&lt;/span> &lt;span class="s">`json:&amp;#34;schemaVersion&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl"> &lt;span class="nx">Payload&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;payload,omitempty&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&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>SubjectID&lt;/code> 和 &lt;code>SubjectKind&lt;/code> 用來描述事件作用在哪個對象上。&lt;code>OccurredAt&lt;/code> 表示事件實際發生時間，&lt;code>ReceivedAt&lt;/code> 表示系統收到事件的時間。這兩個時間不能混用，因為外部事件可能延遲送達。&lt;/p>
&lt;p>&lt;code>CorrelationID&lt;/code> 用來串起同一個使用者操作或 request 造成的一串事件。&lt;code>CausationID&lt;/code> 用來記錄這筆事件是由哪個 command 或事件引起。初期可以先保留欄位，不必一開始就建立完整 tracing 系統。&lt;/p>
&lt;h2 id="策略payload-是補充資料">【策略】payload 是補充資料&lt;/h2>
&lt;p>event envelope 的核心語意應該放在固定欄位。&lt;code>Payload&lt;/code> 適合存事件特有資料，事件分類、主體、時間與來源則應維持在第一層欄位。&lt;/p>
&lt;p>例如通知建立事件的 payload 可以是：&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">NotificationCreatedPayload&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="nx">Title&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;title&amp;#34;`&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;/code>&lt;/pre>&lt;/div>&lt;p>建立 event 時，把穩定欄位放在 envelope：&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">NewNotificationCreatedEvent&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">notificationID&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">payload&lt;/span> &lt;span class="nx">NotificationCreatedPayload&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">now&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">DomainEvent&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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">data&lt;/span>&lt;span class="p">,&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">Marshal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">payload&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">if&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"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">DomainEvent&lt;/span>&lt;span class="p">{},&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;marshal notification payload: %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"> 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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">DomainEvent&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="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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">Source&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">SourceClientCommand&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">Type&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">EventNotificationCreated&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">SubjectID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">notificationID&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">SubjectKind&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">SubjectNotification&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">OccurredAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">now&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">ReceivedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">now&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">SchemaVersion&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1&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="nx">Payload&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">17&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個函式把事件建立規則集中起來。未來若要補 schema version、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/correlation-id/" data-link-title="Correlation ID" data-link-desc="說明跨事件或跨服務的關聯識別碼如何支援排障">correlation ID&lt;/a> 或預設時間，也不需要在每個呼叫端重複組 struct。&lt;/p></description><content:encoded><![CDATA[<p>新增 domain event 的核心流程是先定義事件語意，再決定哪些外部來源可以轉成這個事件。事件是系統內部對「發生了什麼」的穩定合約，常數清單只是其中一種表達方式。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷 event type 是否代表穩定 domain fact</li>
<li>用 <code>DomainEvent</code> envelope 承接不同外部來源</li>
<li>把 raw input 轉成內部事件，而不是直接更新狀態</li>
<li>設計 validation 與 dedup key</li>
<li>把 normalize、processor 與 repository 測試分開</li>
</ol>
<hr>
<h2 id="觀察event-type-代表已經發生的事">【觀察】event type 代表已經發生的事</h2>
<p>domain event 的核心語意是「某件對系統有意義的事已經發生」。command 表達意圖，event 表達事實；兩者的命名與處理責任要分開。</p>
<p>例如：</p>
<table>
  <thead>
      <tr>
          <th>名稱</th>
          <th>類型</th>
          <th>語意</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>subscribe_topic</code></td>
          <td>command/action</td>
          <td>client 想訂閱 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a></td>
      </tr>
      <tr>
          <td><code>notification.created</code></td>
          <td>event</td>
          <td>一筆通知已建立</td>
      </tr>
      <tr>
          <td><code>job.started</code></td>
          <td>event</td>
          <td>一個 job 已開始</td>
      </tr>
      <tr>
          <td><code>account.status_changed</code></td>
          <td>event</td>
          <td>account 狀態已改變</td>
      </tr>
  </tbody>
</table>
<p>event type 應該用過去式或事實語氣。<code>notification.created</code> 比 <code>notification.create</code> 更能表達事件語意。這個命名差異會影響後續設計：事件處理器應該處理已發生事實，授權與意圖判斷則留在 command/usecase。</p>
<p>用 typed constant 可以集中 event type：</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">EventType</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">EventNotificationCreated</span> <span class="nx">EventType</span> <span class="p">=</span> <span class="s">&#34;notification.created&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">EventJobStarted</span>          <span class="nx">EventType</span> <span class="p">=</span> <span class="s">&#34;job.started&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">EventAccountStatusChanged</span> <span class="nx">EventType</span> <span class="p">=</span> <span class="s">&#34;account.status_changed&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>若 event type 只是為了對應外部欄位名稱，還沒有內部語意，應先把外部 raw input normalize，再判斷它是否真的代表一個穩定事實。</p>
<h2 id="判讀domainevent-是內部事件合約">【判讀】DomainEvent 是內部事件合約</h2>
<p><code>DomainEvent</code> 的核心責任是提供統一 envelope，讓不同來源的事件進入系統後使用同一種語意模型。HTTP callback、client action、timer 或 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> message 都可以被 adapter 轉成 <code>DomainEvent</code>。</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">EventSource</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">SourceClientCommand</span> <span class="nx">EventSource</span> <span class="p">=</span> <span class="s">&#34;client_command&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">SourceHTTPCallback</span>  <span class="nx">EventSource</span> <span class="p">=</span> <span class="s">&#34;http_callback&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">SourceTimer</span>         <span class="nx">EventSource</span> <span class="p">=</span> <span class="s">&#34;timer&#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">SubjectKind</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">SubjectNotification</span> <span class="nx">SubjectKind</span> <span class="p">=</span> <span class="s">&#34;notification&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">SubjectJob</span>          <span class="nx">SubjectKind</span> <span class="p">=</span> <span class="s">&#34;job&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">SubjectAccount</span>      <span class="nx">SubjectKind</span> <span class="p">=</span> <span class="s">&#34;account&#34;</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="kd">type</span> <span class="nx">DomainEvent</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">ID</span>            <span class="kt">string</span>          <span class="s">`json:&#34;id&#34;`</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">Source</span>        <span class="nx">EventSource</span>     <span class="s">`json:&#34;source&#34;`</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="nx">Type</span>          <span class="nx">EventType</span>       <span class="s">`json:&#34;type&#34;`</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">SubjectID</span>     <span class="kt">string</span>          <span class="s">`json:&#34;subjectId&#34;`</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="nx">SubjectKind</span>   <span class="nx">SubjectKind</span>     <span class="s">`json:&#34;subjectKind&#34;`</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="nx">CorrelationID</span> <span class="kt">string</span>          <span class="s">`json:&#34;correlationId,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="nx">CausationID</span>   <span class="kt">string</span>          <span class="s">`json:&#34;causationId,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="nx">OccurredAt</span>    <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>       <span class="s">`json:&#34;occurredAt&#34;`</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="nx">ReceivedAt</span>    <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>       <span class="s">`json:&#34;receivedAt&#34;`</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="nx">SchemaVersion</span> <span class="kt">int</span>             <span class="s">`json:&#34;schemaVersion&#34;`</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="nx">Payload</span>       <span class="nx">json</span><span class="p">.</span><span class="nx">RawMessage</span> <span class="s">`json:&#34;payload,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>SubjectID</code> 和 <code>SubjectKind</code> 用來描述事件作用在哪個對象上。<code>OccurredAt</code> 表示事件實際發生時間，<code>ReceivedAt</code> 表示系統收到事件的時間。這兩個時間不能混用，因為外部事件可能延遲送達。</p>
<p><code>CorrelationID</code> 用來串起同一個使用者操作或 request 造成的一串事件。<code>CausationID</code> 用來記錄這筆事件是由哪個 command 或事件引起。初期可以先保留欄位，不必一開始就建立完整 tracing 系統。</p>
<h2 id="策略payload-是補充資料">【策略】payload 是補充資料</h2>
<p>event envelope 的核心語意應該放在固定欄位。<code>Payload</code> 適合存事件特有資料，事件分類、主體、時間與來源則應維持在第一層欄位。</p>
<p>例如通知建立事件的 payload 可以是：</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">NotificationCreatedPayload</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="nx">Title</span> <span class="kt">string</span> <span class="s">`json:&#34;title&#34;`</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>建立 event 時，把穩定欄位放在 envelope：</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">NewNotificationCreatedEvent</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">notificationID</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">payload</span> <span class="nx">NotificationCreatedPayload</span><span class="p">,</span> <span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="p">(</span><span class="nx">DomainEvent</span><span class="p">,</span> <span class="kt">error</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">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">Marshal</span><span class="p">(</span><span class="nx">payload</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="nx">err</span> <span class="o">!=</span> <span class="kc">nil</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="nx">DomainEvent</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;marshal notification payload: %w&#34;</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">return</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</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"> 9</span><span class="cl">        <span class="nx">Source</span><span class="p">:</span>        <span class="nx">SourceClientCommand</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="nx">EventNotificationCreated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>     <span class="nx">notificationID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">SubjectKind</span><span class="p">:</span>   <span class="nx">SubjectNotification</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span>    <span class="nx">now</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span>    <span class="nx">now</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">SchemaVersion</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">Payload</span><span class="p">:</span>       <span class="nx">data</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">},</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個函式把事件建立規則集中起來。未來若要補 schema version、<a href="/blog/backend/knowledge-cards/correlation-id/" data-link-title="Correlation ID" data-link-desc="說明跨事件或跨服務的關聯識別碼如何支援排障">correlation ID</a> 或預設時間，也不需要在每個呼叫端重複組 struct。</p>
<h2 id="執行adapter-負責-raw-input-轉換">【執行】adapter 負責 raw input 轉換</h2>
<p>adapter 的核心責任是把外部格式轉成內部事件。repository 更新交給 processor 或 usecase，raw payload 也應先轉成內部穩定模型再進入 domain layer。</p>
<p>假設外部 HTTP callback 長這樣：</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">RawNotificationCallback</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">EventID</span>        <span class="kt">string</span> <span class="s">`json:&#34;event_id&#34;`</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">NotificationID</span> <span class="kt">string</span> <span class="s">`json:&#34;notification_id&#34;`</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">EventName</span>      <span class="kt">string</span> <span class="s">`json:&#34;event_name&#34;`</span>
</span></span><span class="line"><span class="ln">5</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">6</span><span class="cl">    <span class="nx">Title</span>          <span class="kt">string</span> <span class="s">`json:&#34;title&#34;`</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nx">Timestamp</span>      <span class="kt">string</span> <span class="s">`json:&#34;timestamp&#34;`</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>normalize 函式可以把它轉成 <code>DomainEvent</code>：</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">NormalizeNotificationCallback</span><span class="p">(</span><span class="nx">raw</span> <span class="nx">RawNotificationCallback</span><span class="p">,</span> <span class="nx">receivedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="p">(</span><span class="nx">DomainEvent</span><span class="p">,</span> <span class="kt">error</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">occurredAt</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">RFC3339</span><span class="p">,</span> <span class="nx">raw</span><span class="p">.</span><span class="nx">Timestamp</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="nx">err</span> <span class="o">!=</span> <span class="kc">nil</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="nx">DomainEvent</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;parse callback timestamp: %w&#34;</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">payload</span> <span class="o">:=</span> <span class="nx">NotificationCreatedPayload</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span> <span class="nx">raw</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">Title</span><span class="p">:</span> <span class="nx">raw</span><span class="p">.</span><span class="nx">Title</span><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></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">Marshal</span><span class="p">(</span><span class="nx">payload</span><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="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 class="nx">DomainEvent</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;marshal callback payload: %w&#34;</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></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nx">event</span> <span class="o">:=</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>            <span class="nx">raw</span><span class="p">.</span><span class="nx">EventID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="nx">Source</span><span class="p">:</span>        <span class="nx">SourceHTTPCallback</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>          <span class="nx">EventNotificationCreated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>     <span class="nx">raw</span><span class="p">.</span><span class="nx">NotificationID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="nx">SubjectKind</span><span class="p">:</span>   <span class="nx">SubjectNotification</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span>    <span class="nx">occurredAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span>    <span class="nx">receivedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="nx">SchemaVersion</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="nx">Payload</span><span class="p">:</span>       <span class="nx">data</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">event</span><span class="p">.</span><span class="nf">Validate</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">30</span><span class="cl">        <span class="k">return</span> <span class="nx">DomainEvent</span><span class="p">{},</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">
</span></span><span class="line"><span class="ln">33</span><span class="cl">    <span class="k">return</span> <span class="nx">event</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式把外部欄位名稱、時間格式與 payload 組裝限制在 adapter 內。下游 processor 只需要理解 <code>DomainEvent</code>，不需要知道 callback 原始格式。</p>
<h2 id="判讀validation-保護事件合約">【判讀】validation 保護事件合約</h2>
<p>event validation 的核心目標是確保事件語意完整。缺少 ID、type、subject 或時間的 event 進入 processor 後，狀態更新與去重都會失去依據。</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">e</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="nf">Validate</span><span class="p">()</span> <span class="kt">error</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">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</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;event id is required&#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">e</span><span class="p">.</span><span class="nx">Type</span> <span class="o">==</span> <span class="s">&#34;&#34;</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="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;event type is required&#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="k">if</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</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="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;subject id is required&#34;</span><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="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">SubjectKind</span> <span class="o">==</span> <span class="s">&#34;&#34;</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">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;subject kind is required&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">OccurredAt</span><span class="p">.</span><span class="nf">IsZero</span><span class="p">()</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="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;occurred at is required&#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="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">ReceivedAt</span><span class="p">.</span><span class="nf">IsZero</span><span class="p">()</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;received at 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 class="k">if</span> <span class="nx">e</span><span class="p">.</span><span class="nx">SchemaVersion</span> <span class="o">&lt;=</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</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;schema version must be positive&#34;</span><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="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>validation 應該檢查事件 envelope 的基本合約。更細的 payload 規則可以在 normalize 或 processor 中處理，依資料來源與用途決定。</p>
<h2 id="策略去重鍵應建立在-domain-語意上">【策略】去重鍵應建立在 domain 語意上</h2>
<p>event dedup 的核心規則是使用語意鍵。不同來源可能用不同格式描述同一件事，但只要 subject、type 與時間窗口相同，就可能是重複事件。</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">DedupKey</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">SubjectKind</span> <span class="nx">SubjectKind</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">SubjectID</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">Type</span>        <span class="nx">EventType</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">Window</span>      <span class="kt">int64</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">NewDedupKey</span><span class="p">(</span><span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">,</span> <span class="nx">window</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">)</span> <span class="nx">DedupKey</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="nx">DedupKey</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">SubjectKind</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectKind</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>   <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>        <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">Window</span><span class="p">:</span>      <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</span><span class="p">.</span><span class="nf">UnixNano</span><span class="p">()</span> <span class="o">/</span> <span class="nb">int64</span><span class="p">(</span><span class="nx">window</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="p">}</span></span></span></code></pre></div><p>去重鍵應排除 <code>ReceivedAt</code>、raw metadata 或 <a href="/blog/backend/knowledge-cards/request-id/" data-link-title="Request ID" data-link-desc="說明單次 request 的識別碼如何支援 log 搜尋與問題定位">request ID</a> 這類每次都可能不同的欄位。那些欄位適合記錄觀測資訊，不適合作為「是否同一件事」的判斷依據。</p>
<p>若事件 ID 由可靠上游產生，可以優先用 event ID 去重。若上游 ID 不穩定，才需要 domain dedup key。這個選擇應該寫成明確規則，讓 map key 的組成方式對應可理解的去重語意。</p>
<h2 id="執行processor-負責套用事件規則">【執行】processor 負責套用事件規則</h2>
<p>event processor 的核心責任是驗證、去重、更新狀態與發布結果。processor 不負責讀 HTTP request，也不負責解析 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> message。</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">EventRepository</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">Apply</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">event</span> <span class="nx">DomainEvent</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">type</span> <span class="nx">EventPublisher</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nf">Publish</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">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</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">Deduper</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nf">Seen</span><span class="p">(</span><span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">bool</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>processor 可以依賴這些小介面：</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">EventProcessor</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">repository</span> <span class="nx">EventRepository</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">publisher</span>  <span class="nx">EventPublisher</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">deduper</span>    <span class="nx">Deduper</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="nf">NewEventProcessor</span><span class="p">(</span><span class="nx">repository</span> <span class="nx">EventRepository</span><span class="p">,</span> <span class="nx">publisher</span> <span class="nx">EventPublisher</span><span class="p">,</span> <span class="nx">deduper</span> <span class="nx">Deduper</span><span class="p">)</span> <span class="o">*</span><span class="nx">EventProcessor</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="o">&amp;</span><span class="nx">EventProcessor</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">repository</span><span class="p">:</span> <span class="nx">repository</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">publisher</span><span class="p">:</span>  <span class="nx">publisher</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">deduper</span><span class="p">:</span>    <span class="nx">deduper</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>處理流程保持短而明確：</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">p</span> <span class="o">*</span><span class="nx">EventProcessor</span><span class="p">)</span> <span class="nf">Process</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">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</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">err</span> <span class="o">:=</span> <span class="nx">event</span><span class="p">.</span><span class="nf">Validate</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"> 3</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;validate event: %w&#34;</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="nx">p</span><span class="p">.</span><span class="nx">deduper</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">event</span><span class="p">)</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><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">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</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 class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;apply event: %w&#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="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">publisher</span><span class="p">.</span><span class="nf">Publish</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</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">15</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;publish event: %w&#34;</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 processor 不知道資料來自哪裡，也不知道 repository 是 memory map 還是資料庫。這種邊界讓新增 event source 時不需要重寫狀態規則。</p>
<h2 id="執行normalize-測試要固定外部輸入">【執行】normalize 測試要固定外部輸入</h2>
<p>normalize 測試的核心目標是確認 raw input 會被轉成正確內部事件。這類測試應該固定時間，避免測試依賴真實現在時間。</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">TestNormalizeNotificationCallback</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">receivedAt</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">raw</span> <span class="o">:=</span> <span class="nx">RawNotificationCallback</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">EventID</span><span class="p">:</span>        <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">NotificationID</span><span class="p">:</span> <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">EventName</span><span class="p">:</span>      <span class="s">&#34;notification_created&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>          <span class="s">&#34;deployments&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>          <span class="s">&#34;Deploy finished&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">Timestamp</span><span class="p">:</span>      <span class="s">&#34;2026-04-22T10:00:00Z&#34;</span><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></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">event</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeNotificationCallback</span><span class="p">(</span><span class="nx">raw</span><span class="p">,</span> <span class="nx">receivedAt</span><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="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</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;normalize callback: %v&#34;</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></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">if</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span> <span class="o">!=</span> <span class="nx">EventNotificationCreated</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</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;event type = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">,</span> <span class="nx">EventNotificationCreated</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="k">if</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span> <span class="o">!=</span> <span class="s">&#34;ntf_1&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</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;subject ID = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">,</span> <span class="s">&#34;ntf_1&#34;</span><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="k">if</span> <span class="p">!</span><span class="nx">event</span><span class="p">.</span><span class="nx">ReceivedAt</span><span class="p">.</span><span class="nf">Equal</span><span class="p">(</span><span class="nx">receivedAt</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">24</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;received at = %v, want %v&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ReceivedAt</span><span class="p">,</span> <span class="nx">receivedAt</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試不需要 repository，也不需要 publisher。它只保護 adapter 的轉換規則。</p>
<h2 id="執行processor-測試要隔離外部來源">【執行】processor 測試要隔離外部來源</h2>
<p>processor 測試的核心目標是確認事件規則被正確套用。測試應該直接建立 <code>DomainEvent</code>，讓 HTTP 或 WebSocket 解析留在 adapter 測試。</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">fakeEventRepository</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">applied</span> <span class="p">[]</span><span class="nx">DomainEvent</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">f</span> <span class="o">*</span><span class="nx">fakeEventRepository</span><span class="p">)</span> <span class="nf">Apply</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">event</span> <span class="nx">DomainEvent</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">f</span><span class="p">.</span><span class="nx">applied</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">f</span><span class="p">.</span><span class="nx">applied</span><span class="p">,</span> <span class="nx">event</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><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">type</span> <span class="nx">fakeEventPublisher</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">published</span> <span class="p">[]</span><span class="nx">DomainEvent</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">f</span> <span class="o">*</span><span class="nx">fakeEventPublisher</span><span class="p">)</span> <span class="nf">Publish</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">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">f</span><span class="p">.</span><span class="nx">published</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">f</span><span class="p">.</span><span class="nx">published</span><span class="p">,</span> <span class="nx">event</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">nil</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></span><span class="line"><span class="ln">19</span><span class="cl"><span class="kd">type</span> <span class="nx">neverSeenDeduper</span> <span class="kd">struct</span><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="kd">func</span> <span class="p">(</span><span class="nx">neverSeenDeduper</span><span class="p">)</span> <span class="nf">Seen</span><span class="p">(</span><span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>成功案例可以確認 repository 與 publisher 都被呼叫：</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">TestEventProcessorProcess</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">repo</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeEventRepository</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">publisher</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeEventPublisher</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">processor</span> <span class="o">:=</span> <span class="nf">NewEventProcessor</span><span class="p">(</span><span class="nx">repo</span><span class="p">,</span> <span class="nx">publisher</span><span class="p">,</span> <span class="nx">neverSeenDeduper</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">event</span> <span class="o">:=</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>            <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Source</span><span class="p">:</span>        <span class="nx">SourceHTTPCallback</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>          <span class="nx">EventNotificationCreated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>     <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">SubjectKind</span><span class="p">:</span>   <span class="nx">SubjectNotification</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span>    <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span>    <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">SchemaVersion</span><span class="p">:</span> <span class="mi">1</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></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">processor</span><span class="p">.</span><span class="nf">Process</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">event</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">18</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;process event: %v&#34;</span><span class="p">,</span> <span class="nx">err</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="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">repo</span><span class="p">.</span><span class="nx">applied</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</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;applied events = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">repo</span><span class="p">.</span><span class="nx">applied</span><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="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">publisher</span><span class="p">.</span><span class="nx">published</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</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;published events = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">publisher</span><span class="p">.</span><span class="nx">published</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <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>processor 測試使用 fake repository、publisher 或 deduper，就能隔離事件規則。真實 message <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> 或資料庫屬於 adapter integration test。</p>
<h2 id="實作檢查清單">實作檢查清單</h2>
<p>新增 domain event 時，可以依序檢查：</p>
<ol>
<li>event type 是否描述已經發生的事</li>
<li>event type 是否是內部穩定語意，而不是外部欄位名稱</li>
<li>envelope 是否包含 source、type、subject、occurred/received time</li>
<li>payload 是否只放事件特有補充資料</li>
<li>raw input 是否先經過 adapter normalize</li>
<li>adapter 是否不直接更新 repository</li>
<li>validation 是否保護事件基本合約</li>
<li>dedup key 是否建立在 domain 語意上</li>
<li>normalize、processor、repository 是否分開測試</li>
</ol>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一command-和-event-分開命名">檢查一：command 和 event 分開命名</h3>
<p><code>create_notification</code> 是想做某件事，<code>notification.created</code> 是某件事已發生。把兩者混在一起，會讓 processor 不清楚自己是在判斷授權、執行行為，還是在套用事實。</p>
<h3 id="檢查二raw-payload-停在-adapter">檢查二：raw payload 停在 adapter</h3>
<p>raw payload 會帶有外部來源格式、命名與缺漏。domain layer 應該面對內部穩定模型，外部格式應該停在 adapter。</p>
<h3 id="檢查三穩定欄位放在-envelope">檢查三：穩定欄位放在 envelope</h3>
<p>如果 type、subject、time 都藏在 payload，processor、deduper、repository 都必須解析 payload 才能做事。這會讓事件系統難測，也難以演進 schema。</p>
<h3 id="檢查四事件順序使用發生時間">檢查四：事件順序使用發生時間</h3>
<p><code>ReceivedAt</code> 是系統看到事件的時間，不一定是事件發生時間。狀態轉移通常應該優先看 <code>OccurredAt</code>，再根據延遲與來源可靠度設計補償規則。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 domain event 的定義、轉換與發布；event store 與 broker 傳遞，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">Go 進階：多來源 event 融合</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</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="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 event envelope、normalize 與 processor；如果你要先回看語言教材，可以讀：</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-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/07-refactoring/dedup-refactor/" data-link-title="7.3 事件去重邏輯的重構策略" data-link-desc="保留語義鍵並降低重複流程">Go：事件去重邏輯的重構策略</a></li>
</ul>
]]></content:encoded></item><item><title>6.2 健康檢查與診斷 endpoint</title><link>https://tarrragon.github.io/blog/go-advanced/06-production-operations/health-diagnostics/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/06-production-operations/health-diagnostics/</guid><description>&lt;p>健康檢查與診斷 endpoint 的核心差異是使用者與風險不同。&lt;code>/health&lt;/code> 給監控或負載平衡器判斷 process 是否活著，&lt;code>/ready&lt;/code> 判斷是否應接流量，&lt;code>/debug/...&lt;/code> 則給工程師排查問題且必須限制存取。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 health、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness&lt;/a>、diagnostics 的語意&lt;/li>
&lt;li>設計快速穩定的 &lt;code>/health&lt;/code>&lt;/li>
&lt;li>用 &lt;code>/ready&lt;/code> 控制是否接新流量&lt;/li>
&lt;li>條件啟用 pprof、runtime stats 等診斷入口&lt;/li>
&lt;li>測試 status code 與 JSON response 合約&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察所有狀態都塞進-health-會讓監控失真">【觀察】所有狀態都塞進 health 會讓監控失真&lt;/h2>
&lt;p>Health endpoint 的核心風險是語意混亂。若 &lt;code>/health&lt;/code> 同時檢查 process、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>、外部 API、cache、背景同步，任何依賴短暫波動都可能讓服務被判定死亡。&lt;/p>
&lt;p>問題範例：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">/health
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> ├── process alive?
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> ├── database reachable?
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> ├── queue lag small?
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> ├── external API reachable?
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> └── background sync fresh?&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這些問題不應全部塞進同一個 endpoint。Process 活著、可接流量、依賴降級、工程診斷，是不同操作訊號。&lt;/p>
&lt;h2 id="判讀healthreadydiagnostics-回答不同問題">【判讀】health、ready、diagnostics 回答不同問題&lt;/h2>
&lt;p>操作 endpoint 的核心設計是每個 endpoint 只回答一個問題。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Endpoint&lt;/th>
 &lt;th>使用者&lt;/th>
 &lt;th>回答的問題&lt;/th>
 &lt;th>失敗影響&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>/health&lt;/code>&lt;/td>
 &lt;td>process monitor&lt;/td>
 &lt;td>process 是否基本活著&lt;/td>
 &lt;td>可能重啟 process&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>/ready&lt;/code>&lt;/td>
 &lt;td>load balancer&lt;/td>
 &lt;td>是否應接新流量&lt;/td>
 &lt;td>暫停導流&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>/debug/...&lt;/code>&lt;/td>
 &lt;td>工程師&lt;/td>
 &lt;td>服務內部狀態如何&lt;/td>
 &lt;td>不應公開&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>/metrics&lt;/code>&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics&lt;/a> collector&lt;/td>
 &lt;td>可聚合監控資料&lt;/td>
 &lt;td>監控缺資料&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這樣切分後，某個外部依賴故障不一定要讓 process 被重啟；服務可能只是不 ready，或處於 degraded 狀態。&lt;/p>
&lt;h2 id="執行health-endpoint-應簡單快速">【執行】health endpoint 應簡單快速&lt;/h2>
&lt;p>Health endpoint 的核心責任是快速回答 process 是否能處理基本 HTTP request。它應該簡單、快速、穩定。&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">HandleHealth&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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">if&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Method&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MethodGet&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">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;method not allowed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusMethodNotAllowed&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>&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Header&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Content-Type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;application/json&amp;#34;&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="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusOK&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="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Write&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="nb">byte&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">`{&amp;#34;status&amp;#34;:&amp;#34;ok&amp;#34;}`&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>/health&lt;/code> 不應執行昂貴查詢，也不應依賴大量下游服務。若健康檢查本身很慢，監控會把診斷工具變成新問題。&lt;/p>
&lt;h2 id="執行readiness-控制是否接流量">【執行】readiness 控制是否接流量&lt;/h2>
&lt;p>Readiness 的核心責任是回答「服務現在是否應該接新流量」。它可以檢查啟動狀態、必要依賴、shutdown 狀態。&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">Readiness&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">ready&lt;/span> &lt;span class="nx">atomic&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Bool&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">shuttingDown&lt;/span> &lt;span class="nx">atomic&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Bool&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="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Readiness&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Ready&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"> 7&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">ready&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Load&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">shuttingDown&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Load&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">HandleReady&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">readiness&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Readiness&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">HandlerFunc&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="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Header&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Content-Type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;application/json&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">readiness&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Ready&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">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusServiceUnavailable&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="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Write&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="nb">byte&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">`{&amp;#34;status&amp;#34;:&amp;#34;not_ready&amp;#34;}`&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">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusOK&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Write&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="nb">byte&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">`{&amp;#34;status&amp;#34;:&amp;#34;ready&amp;#34;}`&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="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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>服務啟動尚未完成、必要背景同步尚未就緒、或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown&lt;/a> 已開始時，readiness 應回 &lt;code>503&lt;/code>。Process 仍然活著，但不應接新流量。&lt;/p>
&lt;h2 id="策略dependency-check-依照監控語意分層">【策略】dependency check 依照監控語意分層&lt;/h2>
&lt;p>依賴檢查的核心判斷是故障是否代表 process 應重啟。Database 暫時不可用不一定代表 process 壞掉；重啟可能無法修復，反而造成更多負載。&lt;/p></description><content:encoded><![CDATA[<p>健康檢查與診斷 endpoint 的核心差異是使用者與風險不同。<code>/health</code> 給監控或負載平衡器判斷 process 是否活著，<code>/ready</code> 判斷是否應接流量，<code>/debug/...</code> 則給工程師排查問題且必須限制存取。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 health、<a href="/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness</a>、diagnostics 的語意</li>
<li>設計快速穩定的 <code>/health</code></li>
<li>用 <code>/ready</code> 控制是否接新流量</li>
<li>條件啟用 pprof、runtime stats 等診斷入口</li>
<li>測試 status code 與 JSON response 合約</li>
</ol>
<hr>
<h2 id="觀察所有狀態都塞進-health-會讓監控失真">【觀察】所有狀態都塞進 health 會讓監控失真</h2>
<p>Health endpoint 的核心風險是語意混亂。若 <code>/health</code> 同時檢查 process、<a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a>、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>、外部 API、cache、背景同步，任何依賴短暫波動都可能讓服務被判定死亡。</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">/health
</span></span><span class="line"><span class="ln">2</span><span class="cl">  ├── process alive?
</span></span><span class="line"><span class="ln">3</span><span class="cl">  ├── database reachable?
</span></span><span class="line"><span class="ln">4</span><span class="cl">  ├── queue lag small?
</span></span><span class="line"><span class="ln">5</span><span class="cl">  ├── external API reachable?
</span></span><span class="line"><span class="ln">6</span><span class="cl">  └── background sync fresh?</span></span></code></pre></div><p>這些問題不應全部塞進同一個 endpoint。Process 活著、可接流量、依賴降級、工程診斷，是不同操作訊號。</p>
<h2 id="判讀healthreadydiagnostics-回答不同問題">【判讀】health、ready、diagnostics 回答不同問題</h2>
<p>操作 endpoint 的核心設計是每個 endpoint 只回答一個問題。</p>
<table>
  <thead>
      <tr>
          <th>Endpoint</th>
          <th>使用者</th>
          <th>回答的問題</th>
          <th>失敗影響</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>/health</code></td>
          <td>process monitor</td>
          <td>process 是否基本活著</td>
          <td>可能重啟 process</td>
      </tr>
      <tr>
          <td><code>/ready</code></td>
          <td>load balancer</td>
          <td>是否應接新流量</td>
          <td>暫停導流</td>
      </tr>
      <tr>
          <td><code>/debug/...</code></td>
          <td>工程師</td>
          <td>服務內部狀態如何</td>
          <td>不應公開</td>
      </tr>
      <tr>
          <td><code>/metrics</code></td>
          <td><a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a> collector</td>
          <td>可聚合監控資料</td>
          <td>監控缺資料</td>
      </tr>
  </tbody>
</table>
<p>這樣切分後，某個外部依賴故障不一定要讓 process 被重啟；服務可能只是不 ready，或處於 degraded 狀態。</p>
<h2 id="執行health-endpoint-應簡單快速">【執行】health endpoint 應簡單快速</h2>
<p>Health endpoint 的核心責任是快速回答 process 是否能處理基本 HTTP request。它應該簡單、快速、穩定。</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">HandleHealth</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</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">r</span><span class="p">.</span><span class="nx">Method</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">MethodGet</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;method not allowed&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusMethodNotAllowed</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</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="nx">w</span><span class="p">.</span><span class="nf">Header</span><span class="p">().</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Content-Type&#34;</span><span class="p">,</span> <span class="s">&#34;application/json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusOK</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">_</span><span class="p">,</span> <span class="nx">_</span> <span class="p">=</span> <span class="nx">w</span><span class="p">.</span><span class="nf">Write</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="s">`{&#34;status&#34;:&#34;ok&#34;}`</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>/health</code> 不應執行昂貴查詢，也不應依賴大量下游服務。若健康檢查本身很慢，監控會把診斷工具變成新問題。</p>
<h2 id="執行readiness-控制是否接流量">【執行】readiness 控制是否接流量</h2>
<p>Readiness 的核心責任是回答「服務現在是否應該接新流量」。它可以檢查啟動狀態、必要依賴、shutdown 狀態。</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">Readiness</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">ready</span>        <span class="nx">atomic</span><span class="p">.</span><span class="nx">Bool</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">shuttingDown</span> <span class="nx">atomic</span><span class="p">.</span><span class="nx">Bool</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="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">Readiness</span><span class="p">)</span> <span class="nf">Ready</span><span class="p">()</span> <span class="kt">bool</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="nx">r</span><span class="p">.</span><span class="nx">ready</span><span class="p">.</span><span class="nf">Load</span><span class="p">()</span> <span class="o">&amp;&amp;</span> <span class="p">!</span><span class="nx">r</span><span class="p">.</span><span class="nx">shuttingDown</span><span class="p">.</span><span class="nf">Load</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">HandleReady</span><span class="p">(</span><span class="nx">readiness</span> <span class="o">*</span><span class="nx">Readiness</span><span class="p">)</span> <span class="nx">http</span><span class="p">.</span><span class="nx">HandlerFunc</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="kd">func</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">w</span><span class="p">.</span><span class="nf">Header</span><span class="p">().</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Content-Type&#34;</span><span class="p">,</span> <span class="s">&#34;application/json&#34;</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">if</span> <span class="p">!</span><span class="nx">readiness</span><span class="p">.</span><span class="nf">Ready</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">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusServiceUnavailable</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">            <span class="nx">_</span><span class="p">,</span> <span class="nx">_</span> <span class="p">=</span> <span class="nx">w</span><span class="p">.</span><span class="nf">Write</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="s">`{&#34;status&#34;:&#34;not_ready&#34;}`</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">            <span class="k">return</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="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusOK</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="nx">_</span><span class="p">,</span> <span class="nx">_</span> <span class="p">=</span> <span class="nx">w</span><span class="p">.</span><span class="nf">Write</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="s">`{&#34;status&#34;:&#34;ready&#34;}`</span><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>服務啟動尚未完成、必要背景同步尚未就緒、或 <a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown</a> 已開始時，readiness 應回 <code>503</code>。Process 仍然活著，但不應接新流量。</p>
<h2 id="策略dependency-check-依照監控語意分層">【策略】dependency check 依照監控語意分層</h2>
<p>依賴檢查的核心判斷是故障是否代表 process 應重啟。Database 暫時不可用不一定代表 process 壞掉；重啟可能無法修復，反而造成更多負載。</p>
<p>建議分層：</p>
<ul>
<li><code>/health</code>：只確認 process alive。</li>
<li><code>/ready</code>：確認必要依賴是否足以接新流量。</li>
<li><code>/diagnostics/dependencies</code>：提供工程師查看細節。</li>
</ul>
<p>診斷 response 可以包含穩定欄位：</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;status&#34;</span><span class="p">:</span> <span class="s2">&#34;degraded&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;dependencies&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nt">&#34;database&#34;</span><span class="p">:</span> <span class="s2">&#34;ok&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nt">&#34;queue&#34;</span><span class="p">:</span> <span class="s2">&#34;lagging&#34;</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>監控應依賴 status code 與穩定欄位，工程師再用 body 細節診斷問題。自由文字可以輔助閱讀，但不應成為監控規則的依據。</p>
<h2 id="執行diagnostics-endpoint-要條件啟用">【執行】diagnostics endpoint 要條件啟用</h2>
<p>Diagnostics endpoint 的核心用途是提供工程師排查問題的資料。pprof、runtime metrics、internal queue length、goroutine count 都屬於這類。</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">RegisterDiagnostics</span><span class="p">(</span><span class="nx">mux</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">ServeMux</span><span class="p">,</span> <span class="nx">enabled</span> <span class="kt">bool</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="p">!</span><span class="nx">enabled</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="nx">mux</span><span class="p">.</span><span class="nf">HandleFunc</span><span class="p">(</span><span class="s">&#34;/debug/runtime&#34;</span><span class="p">,</span> <span class="nx">HandleRuntimeStats</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></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">func</span> <span class="nf">HandleRuntimeStats</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="kd">var</span> <span class="nx">stats</span> <span class="nx">runtime</span><span class="p">.</span><span class="nx">MemStats</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">runtime</span><span class="p">.</span><span class="nf">ReadMemStats</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">stats</span><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="nx">response</span> <span class="o">:=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">any</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="s">&#34;heap_alloc&#34;</span><span class="p">:</span>  <span class="nx">stats</span><span class="p">.</span><span class="nx">HeapAlloc</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="s">&#34;num_gc&#34;</span><span class="p">:</span>      <span class="nx">stats</span><span class="p">.</span><span class="nx">NumGC</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="s">&#34;goroutines&#34;</span><span class="p">:</span>  <span class="nx">runtime</span><span class="p">.</span><span class="nf">NumGoroutine</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></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewEncoder</span><span class="p">(</span><span class="nx">w</span><span class="p">).</span><span class="nf">Encode</span><span class="p">(</span><span class="nx">response</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Diagnostics 可能揭露內部狀態、記憶體資訊、goroutine 數量、路徑與部署細節，不應公開給一般使用者。若需要長期保留，至少應限制在內網、管理 port、認證或防火牆後。</p>
<h2 id="判讀status-code-是監控合約">【判讀】status code 是監控合約</h2>
<p>健康檢查的核心合約是 status code。監控系統通常先看 HTTP code 與 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>，不會理解複雜 body。</p>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>200 OK</code></td>
          <td>符合該 endpoint 的健康條件</td>
      </tr>
      <tr>
          <td><code>503 Service Unavailable</code></td>
          <td>暫時不可用或不應接流量</td>
      </tr>
      <tr>
          <td><code>405 Method Not Allowed</code></td>
          <td>呼叫方式錯誤</td>
      </tr>
      <tr>
          <td>timeout</td>
          <td>endpoint 無法在預期時間內回應</td>
      </tr>
  </tbody>
</table>
<p>Body 可以提供人類可讀資訊，但不應讓監控依賴自由文字。若要機器讀取，使用穩定 JSON 欄位，例如 <code>status</code>、<code>reason</code>、<code>dependencies</code>。</p>
<h2 id="測試endpoint-測試要鎖定-status-code">【測試】endpoint 測試要鎖定 status code</h2>
<p>Endpoint 測試的核心是驗證 status code 與穩定 JSON 欄位，而不是完整自由文字。</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">TestReadyReturnsUnavailableWhenShuttingDown</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">readiness</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">Readiness</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">readiness</span><span class="p">.</span><span class="nx">ready</span><span class="p">.</span><span class="nf">Store</span><span class="p">(</span><span class="kc">true</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">readiness</span><span class="p">.</span><span class="nx">shuttingDown</span><span class="p">.</span><span class="nf">Store</span><span class="p">(</span><span class="kc">true</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">req</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRequest</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">MethodGet</span><span class="p">,</span> <span class="s">&#34;/ready&#34;</span><span class="p">,</span> <span class="kc">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">rec</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRecorder</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="nf">HandleReady</span><span class="p">(</span><span class="nx">readiness</span><span class="p">).</span><span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">rec</span><span class="p">,</span> <span class="nx">req</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusServiceUnavailable</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</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;status = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusServiceUnavailable</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Diagnostics endpoint 也應測 gate 關閉時不註冊或回 404，避免診斷入口不小心暴露。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 health、readiness 與 diagnostics 的語意切分；Prometheus、OpenTelemetry 與平台設定，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Go 進階：Observability pipeline、metrics 與 tracing</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 pprof、runtime metrics 與 deploy readiness；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go-advanced/03-runtime-profiling/pprof/" data-link-title="3.2 pprof 基礎診斷流程" data-link-desc="用 pprof endpoint 診斷 heap、goroutine 與 CPU 問題">Go：pprof 基礎診斷流程</a></li>
<li><a href="/blog/go-advanced/03-runtime-profiling/gc-memory-limit/" data-link-title="3.1 GC 與 memory limit" data-link-desc="理解 debug.SetMemoryLimit 在長時間服務中的用途">Go：GC 與 memory limit</a></li>
<li><a href="/blog/go/03-stdlib/slog/" data-link-title="3.6 log/slog：結構化日誌" data-link-desc="用 key-value log 設計可查詢、可過濾的程式訊號">Go：結構化日誌</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/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Go 進階：Kubernetes、systemd 與 load balancer 合約</a></li>
<li><a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend：可觀測性平台</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><code>/health</code>、<code>/ready</code>、diagnostics endpoint 解決不同問題。Health 檢查 process 基本可用性，readiness 控制是否接新流量，diagnostics 支援工程排查且應限制存取。Status code 是監控合約，JSON body 是補充細節；把這些訊號混在一起會讓操作判斷與安全邊界都變模糊。</p>
]]></content:encoded></item><item><title>7.2 Durable queue、outbox 與 idempotency</title><link>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/outbox-idempotency/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/outbox-idempotency/</guid><description>&lt;p>跨 process 事件傳遞的核心責任是讓事件在失敗、重試與重複投遞下仍維持可預期語意。Channel 只能處理單一 process 內的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> ；[durable &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>](/go-advanced/backend/knowledge-cards/durable-queue)、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> store 才能處理服務重啟、網路失敗與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a> 重試。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解 outbox 為什麼能避免半成功&lt;/li>
&lt;li>分辨 domain dedup key 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> key 的用途&lt;/li>
&lt;li>設計可重入的 consumer / processor&lt;/li>
&lt;li>用 retry、DLQ 與回補流程處理失敗事件&lt;/li>
&lt;li>把事件可靠性寫進資料結構，讓規則可以被程式與測試驗證&lt;/li>
&lt;/ol>
&lt;h2 id="前置章節">前置章節&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">Go 進階：非阻塞送出與事件丟棄策略&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">Go 進階：事件去重與語義鍵設計&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">Go 進階：多來源 event 融合&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">Backend：Ack / Nack&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/retry-policy/" data-link-title="Retry Policy" data-link-desc="說明重試策略如何區分暫時性錯誤、永久錯誤與副作用風險">Backend：Retry Policy&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">Backend：Dead-Letter Queue&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">Backend：Consumer Lag&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="後續撰寫方向">後續撰寫方向&lt;/h2>
&lt;ol>
&lt;li>Outbox 如何避免「狀態已寫入，但事件沒送出」的半成功。&lt;/li>
&lt;li>Idempotency key 如何和 domain dedup key 分工。&lt;/li>
&lt;li>Consumer retry、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/poison-message/" data-link-title="Poison Message" data-link-desc="說明特定訊息內容如何穩定造成 consumer 失敗">poison message&lt;/a> 如何設計處理流程。&lt;/li>
&lt;li>At-least-once delivery 下，processor 如何保持可重入。&lt;/li>
&lt;li>Queue lag、retry count、dead-letter count 應如何進入 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a> 與 metric。&lt;/li>
&lt;/ol>
&lt;h2 id="觀察outbox-是把資料與事件綁在同一個-transaction">【觀察】outbox 是把資料與事件綁在同一個 transaction&lt;/h2>
&lt;p>outbox 的核心概念是：先把業務狀態與待發事件一起寫進資料庫，再由獨立 publisher 把 outbox 內容送到 queue 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a>。這樣即使 process 在寫完資料後當機，也不會丟掉事件。&lt;/p>
&lt;p>典型流程是：&lt;/p>
&lt;ol>
&lt;li>usecase 開 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a>。&lt;/li>
&lt;li>寫入 domain data。&lt;/li>
&lt;li>寫入 outbox record。&lt;/li>
&lt;li>commit。&lt;/li>
&lt;li>background publisher 讀出未送出的 outbox。&lt;/li>
&lt;li>成功後把 outbox 標成已送出。&lt;/li>
&lt;/ol>
&lt;p>這個模型的重點是讓「至少會被發現並補送」成為可能。它承認跨 process 傳遞很難保證絕對只送一次，所以後續還要搭配 idempotency。&lt;/p>
&lt;h2 id="判讀idempotency-是跨-process-的必要邊界">【判讀】idempotency 是跨 process 的必要邊界&lt;/h2>
&lt;p>只要事件可能重送，consumer 就要能承受重複訊息。idempotent processor 的核心是讓同一筆事件重複進來時，結果仍然穩定。&lt;/p></description><content:encoded><![CDATA[<p>跨 process 事件傳遞的核心責任是讓事件在失敗、重試與重複投遞下仍維持可預期語意。Channel 只能處理單一 process 內的 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> ；[durable <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>](/go-advanced/backend/knowledge-cards/durable-queue)、<a href="/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox</a> 與 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> store 才能處理服務重啟、網路失敗與 <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 重試。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解 outbox 為什麼能避免半成功</li>
<li>分辨 domain dedup key 與 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> key 的用途</li>
<li>設計可重入的 consumer / processor</li>
<li>用 retry、DLQ 與回補流程處理失敗事件</li>
<li>把事件可靠性寫進資料結構，讓規則可以被程式與測試驗證</li>
</ol>
<h2 id="前置章節">前置章節</h2>
<ul>
<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/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">Go 進階：事件去重與語義鍵設計</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 進階：多來源 event 融合</a></li>
<li><a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">Backend：Ack / Nack</a></li>
<li><a href="/blog/backend/knowledge-cards/retry-policy/" data-link-title="Retry Policy" data-link-desc="說明重試策略如何區分暫時性錯誤、永久錯誤與副作用風險">Backend：Retry Policy</a></li>
<li><a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">Backend：Dead-Letter Queue</a></li>
<li><a href="/blog/backend/knowledge-cards/consumer-lag/" data-link-title="Consumer Lag" data-link-desc="說明 consumer lag 如何反映訊息堆積、處理能力與容量風險">Backend：Consumer Lag</a></li>
</ul>
<h2 id="後續撰寫方向">後續撰寫方向</h2>
<ol>
<li>Outbox 如何避免「狀態已寫入，但事件沒送出」的半成功。</li>
<li>Idempotency key 如何和 domain dedup key 分工。</li>
<li>Consumer retry、<a href="/blog/backend/knowledge-cards/dead-letter-queue/" data-link-title="Dead-Letter Queue" data-link-desc="說明 dead-letter queue 如何隔離多次處理失敗的訊息">dead-letter queue</a> 與 <a href="/blog/backend/knowledge-cards/poison-message/" data-link-title="Poison Message" data-link-desc="說明特定訊息內容如何穩定造成 consumer 失敗">poison message</a> 如何設計處理流程。</li>
<li>At-least-once delivery 下，processor 如何保持可重入。</li>
<li>Queue lag、retry count、dead-letter count 應如何進入 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 與 metric。</li>
</ol>
<h2 id="觀察outbox-是把資料與事件綁在同一個-transaction">【觀察】outbox 是把資料與事件綁在同一個 transaction</h2>
<p>outbox 的核心概念是：先把業務狀態與待發事件一起寫進資料庫，再由獨立 publisher 把 outbox 內容送到 queue 或 <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a>。這樣即使 process 在寫完資料後當機，也不會丟掉事件。</p>
<p>典型流程是：</p>
<ol>
<li>usecase 開 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a>。</li>
<li>寫入 domain data。</li>
<li>寫入 outbox record。</li>
<li>commit。</li>
<li>background publisher 讀出未送出的 outbox。</li>
<li>成功後把 outbox 標成已送出。</li>
</ol>
<p>這個模型的重點是讓「至少會被發現並補送」成為可能。它承認跨 process 傳遞很難保證絕對只送一次，所以後續還要搭配 idempotency。</p>
<h2 id="判讀idempotency-是跨-process-的必要邊界">【判讀】idempotency 是跨 process 的必要邊界</h2>
<p>只要事件可能重送，consumer 就要能承受重複訊息。idempotent processor 的核心是讓同一筆事件重複進來時，結果仍然穩定。</p>
<p>常見做法包括：</p>
<ul>
<li>用 event ID 記錄已處理過的訊息</li>
<li>用 domain key 去重，讓同一個業務操作不會重複套用</li>
<li>用狀態機檢查 transition 是否已發生</li>
</ul>
<h2 id="策略dlq-是流程的一部分">【策略】DLQ 是流程的一部分</h2>
<p>當事件重試失敗，dead-letter queue 要變成可處理的操作流程。你要知道：</p>
<ul>
<li>為什麼失敗</li>
<li>要重試幾次</li>
<li>什麼錯誤可以直接放棄</li>
<li>什麼錯誤需要人工回補</li>
</ul>
<p>如果沒有這些規則，DLQ 只會變成看不完的黑洞。</p>
<h2 id="執行可重入-processor-的基本形式">【執行】可重入 processor 的基本形式</h2>
<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">p</span> <span class="o">*</span><span class="nx">Processor</span><span class="p">)</span> <span class="nf">Handle</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">evt</span> <span class="nx">Event</span><span class="p">)</span> <span class="kt">error</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">p</span><span class="p">.</span><span class="nx">store</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">evt</span><span class="p">.</span><span class="nx">ID</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">return</span> <span class="kc">nil</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="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">store</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">evt</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"> 7</span><span class="cl">        <span class="k">return</span> <span class="nx">err</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">return</span> <span class="nx">p</span><span class="p">.</span><span class="nx">store</span><span class="p">.</span><span class="nf">MarkSeen</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">evt</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="p">}</span></span></span></code></pre></div><p>實際實作時，<code>Seen</code> 與 <code>MarkSeen</code> 通常要跟業務狀態放在同一個一致性邊界裡，避免競態。</p>
<h2 id="延伸queue-lag-與-retry-需要被觀測">【延伸】queue lag 與 retry 需要被觀測</h2>
<p>只要有 durable queue，就一定會有 backlog、retry 與 failure pattern。這些訊號應進入 log 與 metric，讓工程師知道是 <a href="/blog/backend/knowledge-cards/producer/" data-link-title="Producer" data-link-desc="說明 producer 如何把工作、事件或資料送入後續處理路徑">producer</a> 變慢、consumer 壞掉，還是下游依賴正在抖動。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章不追求 exactly-once 的口號。教材重點會放在 Go 服務如何承認 at-least-once 的現實，並用 idempotent processor、outbox 與可觀測欄位降低風險。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接 Go 的事件邊界與非阻塞送出；如果你要先回看語言教材，可以讀：</p>
<ul>
<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/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">Go 進階：事件去重與語義鍵設計</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 進階：多來源 event 融合</a></li>
</ul>
]]></content:encoded></item><item><title>7.2 用 interface 隔離外部依賴</title><link>https://tarrragon.github.io/blog/go/07-refactoring/interface-boundary/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/interface-boundary/</guid><description>&lt;p>interface 邊界重構的核心規則是由使用端定義需要的能力。介面的目的是讓 usecase 不依賴外部技術細節。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>辨識哪些依賴值得用 interface 隔離&lt;/li>
&lt;li>讓 interface 由使用端定義&lt;/li>
&lt;li>設計小而穩定的 port&lt;/li>
&lt;li>分辨 fake test 與 contract test&lt;/li>
&lt;li>避免過早抽象與巨大 interface&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察interface-是依賴邊界">【觀察】interface 是依賴邊界&lt;/h2>
&lt;p>interface 重構的核心目標是讓高層邏輯只依賴需要的能力。Go 的 interface 讓呼叫端不必知道具體實作。&lt;/p>
&lt;p>過重依賴常見在這些地方：&lt;/p>
&lt;ul>
&lt;li>usecase 直接依賴 &lt;code>*sql.DB&lt;/code>。&lt;/li>
&lt;li>handler 直接依賴 concrete service，測試很難替換。&lt;/li>
&lt;li>background worker 直接呼叫外部 API client。&lt;/li>
&lt;li>processor 直接知道 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> hub。&lt;/li>
&lt;li>測試為了建一個 usecase，必須初始化真資料庫、真檔案或真網路。&lt;/li>
&lt;/ul>
&lt;p>interface 的價值是讓 usecase 可以說：「我只需要儲存 notification」、「我只需要 append event」、「我只需要 publish message」。至於能力怎麼實作，是 adapter 的責任。&lt;/p>
&lt;h2 id="判讀先辨識外部依賴">【判讀】先辨識外部依賴&lt;/h2>
&lt;p>外部依賴的核心特徵是慢、不穩、難測或帶有技術細節。這些依賴通常適合被 interface 隔離。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>依賴&lt;/th>
 &lt;th>隔離原因&lt;/th>
 &lt;th>可能 interface&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>clock&lt;/td>
 &lt;td>測試需要固定時間&lt;/td>
 &lt;td>&lt;code>Clock&lt;/code> 或 &lt;code>func() time.Time&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>repository&lt;/td>
 &lt;td>儲存技術可替換&lt;/td>
 &lt;td>&lt;code>NotificationRepository&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>[event &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a>](/go/backend/knowledge-cards/event-log)&lt;/td>
 &lt;td>記錄實作可替換&lt;/td>
 &lt;td>&lt;code>EventLog&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>publisher&lt;/td>
 &lt;td>WebSocket、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>、log 都可能是輸出&lt;/td>
 &lt;td>&lt;code>Publisher&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>external client&lt;/td>
 &lt;td>網路失敗與測試替身&lt;/td>
 &lt;td>&lt;code>NotificationSource&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>command runner&lt;/td>
 &lt;td>外部程序慢且不穩&lt;/td>
 &lt;td>&lt;code>CommandRunner&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>不是所有型別都需要 interface。純資料 struct、簡單 helper、沒有替換需求的內部物件，通常先保持 concrete type 更清楚。&lt;/p>
&lt;h2 id="策略interface-放在使用端">【策略】interface 放在使用端&lt;/h2>
&lt;p>interface 位置的核心規則是：誰需要這個能力，誰定義 interface。這會讓 interface 保持小，也避免 implementation 暴露太多方法。&lt;/p>
&lt;p>usecase 需要儲存通知，就在 usecase 附近定義：&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">NotificationRepository&lt;/span> &lt;span class="kd">interface&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="nf">Save&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">notification&lt;/span> &lt;span class="nx">Notification&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nf">FindByID&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">id&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">Notification&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">bool&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">4&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>in-memory adapter 只要方法集合符合，就自然實作這個 interface：&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">InMemoryNotificationRepository&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">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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">notifications&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="nx">Notification&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="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">InMemoryNotificationRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Save&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">notification&lt;/span> &lt;span class="nx">Notification&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Lock&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">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Unlock&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="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">notifications&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">notification&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">notification&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="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&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>&lt;/span>&lt;span class="line">&lt;span class="ln">13&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="o">*&lt;/span>&lt;span class="nx">InMemoryNotificationRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">FindByID&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">id&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">Notification&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">bool&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RLock&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="k">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RUnlock&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="nx">notification&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">notifications&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">17&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">notification&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&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>InMemoryNotificationRepository&lt;/code> 不需要宣告自己 implements 了什麼。Go 的 implicit interface 讓實作端保持乾淨。&lt;/p>
&lt;h2 id="執行用小-port-隔離-event-log">【執行】用小 port 隔離 event log&lt;/h2>
&lt;p>event log port 的核心語意是「可以記錄已發生的事件」。usecase 或 processor 不需要知道事件記錄到 memory、檔案還是資料庫。&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">EventLog&lt;/span> &lt;span class="kd">interface&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="nf">Append&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">event&lt;/span> &lt;span class="nx">DomainEvent&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&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;/code>&lt;/pre>&lt;/div>&lt;p>processor 依賴這個 port：&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">EventProcessor&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">eventLog&lt;/span> &lt;span class="nx">EventLog&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="nf">NewEventProcessor&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">eventLog&lt;/span> &lt;span class="nx">EventLog&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">EventProcessor&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="o">&amp;amp;&lt;/span>&lt;span class="nx">EventProcessor&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">eventLog&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">eventLog&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="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">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">p&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">EventProcessor&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Process&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">event&lt;/span> &lt;span class="nx">DomainEvent&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">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">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Validate&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 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;validate event: %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">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">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">eventLog&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Append&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">event&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="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;append event log: %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">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="k">return&lt;/span> &lt;span class="kc">nil&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>這個 interface 很小，但它已經足夠讓 processor 測試脫離真正儲存實作。&lt;/p></description><content:encoded><![CDATA[<p>interface 邊界重構的核心規則是由使用端定義需要的能力。介面的目的是讓 usecase 不依賴外部技術細節。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>辨識哪些依賴值得用 interface 隔離</li>
<li>讓 interface 由使用端定義</li>
<li>設計小而穩定的 port</li>
<li>分辨 fake test 與 contract test</li>
<li>避免過早抽象與巨大 interface</li>
</ol>
<hr>
<h2 id="觀察interface-是依賴邊界">【觀察】interface 是依賴邊界</h2>
<p>interface 重構的核心目標是讓高層邏輯只依賴需要的能力。Go 的 interface 讓呼叫端不必知道具體實作。</p>
<p>過重依賴常見在這些地方：</p>
<ul>
<li>usecase 直接依賴 <code>*sql.DB</code>。</li>
<li>handler 直接依賴 concrete service，測試很難替換。</li>
<li>background worker 直接呼叫外部 API client。</li>
<li>processor 直接知道 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> hub。</li>
<li>測試為了建一個 usecase，必須初始化真資料庫、真檔案或真網路。</li>
</ul>
<p>interface 的價值是讓 usecase 可以說：「我只需要儲存 notification」、「我只需要 append event」、「我只需要 publish message」。至於能力怎麼實作，是 adapter 的責任。</p>
<h2 id="判讀先辨識外部依賴">【判讀】先辨識外部依賴</h2>
<p>外部依賴的核心特徵是慢、不穩、難測或帶有技術細節。這些依賴通常適合被 interface 隔離。</p>
<table>
  <thead>
      <tr>
          <th>依賴</th>
          <th>隔離原因</th>
          <th>可能 interface</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>clock</td>
          <td>測試需要固定時間</td>
          <td><code>Clock</code> 或 <code>func() time.Time</code></td>
      </tr>
      <tr>
          <td>repository</td>
          <td>儲存技術可替換</td>
          <td><code>NotificationRepository</code></td>
      </tr>
      <tr>
          <td>[event <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>](/go/backend/knowledge-cards/event-log)</td>
          <td>記錄實作可替換</td>
          <td><code>EventLog</code></td>
      </tr>
      <tr>
          <td>publisher</td>
          <td>WebSocket、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>、log 都可能是輸出</td>
          <td><code>Publisher</code></td>
      </tr>
      <tr>
          <td>external client</td>
          <td>網路失敗與測試替身</td>
          <td><code>NotificationSource</code></td>
      </tr>
      <tr>
          <td>command runner</td>
          <td>外部程序慢且不穩</td>
          <td><code>CommandRunner</code></td>
      </tr>
  </tbody>
</table>
<p>不是所有型別都需要 interface。純資料 struct、簡單 helper、沒有替換需求的內部物件，通常先保持 concrete type 更清楚。</p>
<h2 id="策略interface-放在使用端">【策略】interface 放在使用端</h2>
<p>interface 位置的核心規則是：誰需要這個能力，誰定義 interface。這會讓 interface 保持小，也避免 implementation 暴露太多方法。</p>
<p>usecase 需要儲存通知，就在 usecase 附近定義：</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">NotificationRepository</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">Save</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">notification</span> <span class="nx">Notification</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="nf">FindByID</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">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>in-memory adapter 只要方法集合符合，就自然實作這個 interface：</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">InMemoryNotificationRepository</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">mu</span>            <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">notifications</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">Notification</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="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">InMemoryNotificationRepository</span><span class="p">)</span> <span class="nf">Save</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">notification</span> <span class="nx">Notification</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">r</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"> 8</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</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"> 9</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">notifications</span><span class="p">[</span><span class="nx">notification</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">notification</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</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="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">InMemoryNotificationRepository</span><span class="p">)</span> <span class="nf">FindByID</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">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">r</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">15</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</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">16</span><span class="cl">    <span class="nx">notification</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">notifications</span><span class="p">[</span><span class="nx">id</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="nx">notification</span><span class="p">,</span> <span class="nx">ok</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>InMemoryNotificationRepository</code> 不需要宣告自己 implements 了什麼。Go 的 implicit interface 讓實作端保持乾淨。</p>
<h2 id="執行用小-port-隔離-event-log">【執行】用小 port 隔離 event log</h2>
<p>event log port 的核心語意是「可以記錄已發生的事件」。usecase 或 processor 不需要知道事件記錄到 memory、檔案還是資料庫。</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">EventLog</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">Append</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">event</span> <span class="nx">DomainEvent</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></code></pre></div><p>processor 依賴這個 port：</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">EventProcessor</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">eventLog</span> <span class="nx">EventLog</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="nf">NewEventProcessor</span><span class="p">(</span><span class="nx">eventLog</span> <span class="nx">EventLog</span><span class="p">)</span> <span class="o">*</span><span class="nx">EventProcessor</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="o">&amp;</span><span class="nx">EventProcessor</span><span class="p">{</span><span class="nx">eventLog</span><span class="p">:</span> <span class="nx">eventLog</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></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">p</span> <span class="o">*</span><span class="nx">EventProcessor</span><span class="p">)</span> <span class="nf">Process</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">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span> <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="nx">event</span><span class="p">.</span><span class="nf">Validate</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 class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;validate event: %w&#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 class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">eventLog</span><span class="p">.</span><span class="nf">Append</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</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 class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;append event log: %w&#34;</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="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>這個 interface 很小，但它已經足夠讓 processor 測試脫離真正儲存實作。</p>
<h2 id="執行publisher-port-隔離輸出技術">【執行】publisher port 隔離輸出技術</h2>
<p>publisher port 的核心語意是「把結果送出去」。即時推送可以用 WebSocket，非同步流程可以用 queue，測試可以用 recording fake。</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">Publisher</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">Publish</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">event</span> <span class="nx">DomainEvent</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></code></pre></div><p>processor 可以同時依賴 event log 與 publisher：</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">RecordingProcessor</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">eventLog</span>  <span class="nx">EventLog</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">publisher</span> <span class="nx">Publisher</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="kd">func</span> <span class="p">(</span><span class="nx">p</span> <span class="o">*</span><span class="nx">RecordingProcessor</span><span class="p">)</span> <span class="nf">Process</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">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">eventLog</span><span class="p">.</span><span class="nf">Append</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</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"> 8</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;append event: %w&#34;</span><span class="p">,</span> <span class="nx">err</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="nx">p</span><span class="p">.</span><span class="nx">publisher</span><span class="p">.</span><span class="nf">Publish</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</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 class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;publish event: %w&#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 class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這裡的 processor 不知道輸出是 WebSocket 還是 message queue。這就是 interface 邊界的目的。</p>
<h2 id="策略clock-可以用函式不一定要-interface">【策略】clock 可以用函式，不一定要 interface</h2>
<p>時間依賴的核心問題是測試需要固定現在時間。最小解法通常是注入函式，而不是建立完整 interface。</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">Clock</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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">NotificationService</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">now</span> <span class="nx">Clock</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="nf">NewNotificationService</span><span class="p">(</span><span class="nx">now</span> <span class="nx">Clock</span><span class="p">)</span> <span class="o">*</span><span class="nx">NotificationService</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="o">&amp;</span><span class="nx">NotificationService</span><span class="p">{</span><span class="nx">now</span><span class="p">:</span> <span class="nx">now</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>使用時：</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">s</span> <span class="o">*</span><span class="nx">NotificationService</span><span class="p">)</span> <span class="nf">NewNotification</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="nx">Notification</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">Notification</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="nx">id</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</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">5</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">s</span><span class="p">.</span><span class="nf">now</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>測試時傳固定時間：</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="nx">fixedNow</span> <span class="o">:=</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</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 class="nx">service</span> <span class="o">:=</span> <span class="nf">NewNotificationService</span><span class="p">(</span><span class="nx">fixedNow</span><span class="p">)</span></span></span></code></pre></div><p>若只需要「現在時間」，函式比 <code>Clock interface { Now() time.Time }</code> 更簡單。Go 的抽象不一定要用 interface。</p>
<h2 id="判讀小-interface-比大-interface-更穩定">【判讀】小 interface 比大 interface 更穩定</h2>
<p>小 interface 的核心好處是測試替身容易寫，使用端只知道自己需要的能力。巨大 interface 會把不相關 usecase 綁在一起。</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">type</span> <span class="nx">ApplicationService</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">CreateNotification</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">cmd</span> <span class="nx">CreateNotificationCommand</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="nf">ListNotifications</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">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="p">([]</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nf">AppendEvent</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">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nf">Publish</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">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nf">RunSync</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="kt">error</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><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">type</span> <span class="nx">NotificationCreator</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">Create</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">cmd</span> <span class="nx">CreateNotificationCommand</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">type</span> <span class="nx">EventLog</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nf">Append</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">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</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">Publisher</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nf">Publish</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">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>不同呼叫端依賴不同能力。handler 只依賴 creator，processor 只依賴 event log 與 publisher，worker 只依賴 source 與 processor。</p>
<h2 id="執行fake-test-驗證使用端行為">【執行】fake test 驗證使用端行為</h2>
<p>fake test 的核心目標是測使用端怎麼使用依賴。fake 可以很小，只實作測試需要的行為。</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">fakeEventLog</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">appended</span> <span class="p">[]</span><span class="nx">DomainEvent</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">err</span>      <span class="kt">error</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="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="o">*</span><span class="nx">fakeEventLog</span><span class="p">)</span> <span class="nf">Append</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">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="nx">f</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"> 8</span><span class="cl">        <span class="k">return</span> <span class="nx">f</span><span class="p">.</span><span class="nx">err</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="nx">f</span><span class="p">.</span><span class="nx">appended</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">f</span><span class="p">.</span><span class="nx">appended</span><span class="p">,</span> <span class="nx">event</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">nil</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>測 processor：</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">TestEventProcessorAppendsEvent</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">eventLog</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeEventLog</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">processor</span> <span class="o">:=</span> <span class="nf">NewEventProcessor</span><span class="p">(</span><span class="nx">eventLog</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">event</span> <span class="o">:=</span> <span class="nf">validDomainEvent</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">processor</span><span class="p">.</span><span class="nf">Process</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">event</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"> 7</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;process event: %v&#34;</span><span class="p">,</span> <span class="nx">err</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="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">eventLog</span><span class="p">.</span><span class="nx">appended</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</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;appended events = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">eventLog</span><span class="p">.</span><span class="nx">appended</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>這個測試不關心 event log 如何保存資料。它只驗證 processor 在正確情境下呼叫了 port。</p>
<h2 id="執行contract-test-驗證-adapter-行為">【執行】contract test 驗證 adapter 行為</h2>
<p>contract test 的核心目標是讓不同 adapter 都符合 port 行為。這類測試測 implementation，不測 usecase。</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">runEventLogContract</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="nx">newLog</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">EventLogWithList</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">t</span><span class="p">.</span><span class="nf">Helper</span><span class="p">()</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">log</span> <span class="o">:=</span> <span class="nf">newLog</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">event</span> <span class="o">:=</span> <span class="nf">validDomainEvent</span><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">err</span> <span class="o">:=</span> <span class="nx">log</span><span class="p">.</span><span class="nf">Append</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">event</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"> 8</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;append event: %v&#34;</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">events</span> <span class="o">:=</span> <span class="nx">log</span><span class="p">.</span><span class="nf">List</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="nb">len</span><span class="p">(</span><span class="nx">events</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</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;events = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">events</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">if</span> <span class="nx">events</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">ID</span> <span class="o">!=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ID</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;event ID = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">events</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">ID</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ID</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><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">type</span> <span class="nx">EventLogWithList</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">EventLog</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="nf">List</span><span class="p">()</span> <span class="p">[]</span><span class="nx">DomainEvent</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>List</code> 不一定屬於 production port，它可以是測試用輔助介面。若未來有 SQLite event log，也可以跑同一套 contract test。</p>
<h2 id="策略避免過早抽象">【策略】避免過早抽象</h2>
<p>避免過早抽象的核心判斷是：沒有替換、測試或技術隔離需求時，先用 concrete type。interface 不是越多越好。</p>
<p>先不要抽 interface 的情境：</p>
<ul>
<li>只有一個 implementation。</li>
<li>測試不需要 fake。</li>
<li>concrete type 很小，直接使用更清楚。</li>
<li>interface 只是完整複製 concrete type 的所有方法。</li>
<li>邊界還不穩，方法很快會變。</li>
</ul>
<p>可以抽 interface 的情境：</p>
<ul>
<li>usecase 不應依賴技術細節。</li>
<li>測試需要替換慢或不穩的依賴。</li>
<li>同一個能力有多種 implementation。</li>
<li>依賴跨越 package 邊界，使用端只需要小部分能力。</li>
</ul>
<p>重構時可以先寫 concrete type，等第二個使用端或測試壓力出現，再抽出使用端 interface。</p>
<h2 id="重構步驟">重構步驟</h2>
<p>把 concrete dependency 改成 interface 時，可以按這個順序：</p>
<ol>
<li>找出使用端真正呼叫的方法。</li>
<li>在使用端附近定義小 interface。</li>
<li>把 struct 欄位型別從 concrete type 改成 interface。</li>
<li>確認現有 concrete type 自然符合 interface。</li>
<li>在測試中建立 fake。</li>
<li>為 adapter 補 contract test。</li>
<li>移除不再需要的直接依賴。</li>
</ol>
<p>不要先設計一個完美的全域介面。從使用端需要的最小方法開始，介面會更穩。</p>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一interface-由使用端定義">檢查一：interface 由使用端定義</h3>
<p>implementation 定義的 interface 往往暴露太多方法。使用端定義 interface，才能只依賴自己真正需要的能力。</p>
<h3 id="檢查二有替換需求再建立-interface">檢查二：有替換需求再建立 interface</h3>
<p><code>Foo</code> 搭配 <code>FooInterface</code> 不是 Go 的慣例。interface 應該來自使用需求，而不是來自型別存在。</p>
<h3 id="檢查三fake-服務當前測試行為">檢查三：fake 服務當前測試行為</h3>
<p>fake 是測試工具，不是真 adapter。它只需要支援測試情境，不需要重建資料庫、網路或完整狀態機。</p>
<h3 id="檢查四公開-interface-需要穩定承諾">檢查四：公開 interface 需要穩定承諾</h3>
<p>一旦 interface 被多個 package 依賴，修改成本會提高。邊界還在探索時，保持 unexported 或使用 concrete type 更務實。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 interface 如何讓使用端依賴能力；全專案 DI 框架與 mock generator，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">Go 入門：interface：用行為定義依賴</a></li>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go 入門：testing 基礎</a></li>
<li><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go 進階：composition root 與依賴組裝</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 usecase、adapter 與測試替身邊界；如果你要先回看語言教材，可以讀：</p>
<ul>
<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/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
<li><a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">Go：逐步遷移到 ports/adapters 架構</a></li>
</ul>
]]></content:encoded></item><item><title>模組二：WebSocket 服務架構</title><link>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/</guid><description>&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> 服務的核心難點是連線建立後的長生命週期管理。HTTP upgrade 只是入口；每個 client 都有讀取、寫入、心跳、訂閱、推送佇列與清理流程。任何一個邊界不清楚，都可能造成 goroutine leak、concurrent write、慢 client 拖垮服務或訂閱狀態不一致。&lt;/p>
&lt;p>本模組承接模組一的並發主題：read pump / write pump 對應 goroutine ownership，heartbeat 對應 select loop 與 ticker，send &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 對應 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a>，訂閱集合對應共享狀態與 copy boundary。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&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;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">2.1&lt;/a>&lt;/td>
 &lt;td>read pump / write pump 模式&lt;/td>
 &lt;td>讓單一連線的讀取、寫入與清理責任可推理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">2.2&lt;/a>&lt;/td>
 &lt;td>heartbeat、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a> 與連線清理&lt;/td>
 &lt;td>用 ping/pong、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a> 與統一 unregister 偵測失效連線&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/subscription-routing/" data-link-title="2.3 訂閱模型與訊息路由" data-link-desc="將 client action 對應到主題訂閱狀態">2.3&lt;/a>&lt;/td>
 &lt;td>訂閱模型與訊息路由&lt;/td>
 &lt;td>把 client action 轉成可測的 command 與訂閱狀態&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/slow-client/" data-link-title="2.4 慢客戶端與 send buffer 管理" data-link-desc="控制推送佇列與記憶體風險">2.4&lt;/a>&lt;/td>
 &lt;td>慢客戶端與 send &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 管理&lt;/td>
 &lt;td>用 bounded buffer、drop policy 與 byte budget 控制容量風險&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;p>本模組使用虛構的即時通知服務作為範例。Client 可以訂閱 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a>，server 會依 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> 推送 notification、status update 或 error message。&lt;/p>
&lt;p>範例只用來展示 Go &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> 服務設計，不假設讀者正在維護任何特定專案。&lt;/p>
&lt;h2 id="本模組的-go-核心概念">本模組的 Go 核心概念&lt;/h2>
&lt;ul>
&lt;li>用一個 read pump 負責 client 輸入。&lt;/li>
&lt;li>用一個 write pump 負責所有 WebSocket 寫入。&lt;/li>
&lt;li>用 channel 作為 client send &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>。&lt;/li>
&lt;li>用 context、done channel 或 hub unregister 管理連線生命週期。&lt;/li>
&lt;li>用 ticker 實作 heartbeat，但由 write pump 統一寫 ping。&lt;/li>
&lt;li>用 mutex 或 hub event loop 保護訂閱狀態。&lt;/li>
&lt;li>用 non-blocking send 保護 hub 不被慢 client 卡住。&lt;/li>
&lt;/ul>
&lt;h2 id="學習重點">學習重點&lt;/h2>
&lt;p>學完本模組後，你應該能判斷：&lt;/p>
&lt;ol>
&lt;li>哪個 goroutine 可以讀 WebSocket，哪個 goroutine 可以寫 WebSocket&lt;/li>
&lt;li>read pump、write pump、hub unregister 之間如何協作&lt;/li>
&lt;li>heartbeat 失敗後應該走哪一條清理路徑&lt;/li>
&lt;li>client action 應該在 router、usecase 還是 hub 裡處理&lt;/li>
&lt;li>send buffer 滿載時應該丟棄、斷線、回錯或改用可靠儲存&lt;/li>
&lt;/ol>
&lt;h2 id="章節粒度說明">章節粒度說明&lt;/h2>
&lt;p>WebSocket 章節依照連線生命週期拆分。Read/write pump、heartbeat、subscription routing、slow client 是四個不同責任；它們常在同一個 hub 或 client type 中互相呼叫，但教學上應分開建立模型。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 服務的核心難點是連線建立後的長生命週期管理。HTTP upgrade 只是入口；每個 client 都有讀取、寫入、心跳、訂閱、推送佇列與清理流程。任何一個邊界不清楚，都可能造成 goroutine leak、concurrent write、慢 client 拖垮服務或訂閱狀態不一致。</p>
<p>本模組承接模組一的並發主題：read pump / write pump 對應 goroutine ownership，heartbeat 對應 select loop 與 ticker，send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 對應 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a>，訂閱集合對應共享狀態與 copy boundary。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">2.1</a></td>
          <td>read pump / write pump 模式</td>
          <td>讓單一連線的讀取、寫入與清理責任可推理</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">2.2</a></td>
          <td>heartbeat、<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 與連線清理</td>
          <td>用 ping/pong、<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 與統一 unregister 偵測失效連線</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/02-networking-websocket/subscription-routing/" data-link-title="2.3 訂閱模型與訊息路由" data-link-desc="將 client action 對應到主題訂閱狀態">2.3</a></td>
          <td>訂閱模型與訊息路由</td>
          <td>把 client action 轉成可測的 command 與訂閱狀態</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/02-networking-websocket/slow-client/" data-link-title="2.4 慢客戶端與 send buffer 管理" data-link-desc="控制推送佇列與記憶體風險">2.4</a></td>
          <td>慢客戶端與 send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 管理</td>
          <td>用 bounded buffer、drop policy 與 byte budget 控制容量風險</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<p>本模組使用虛構的即時通知服務作為範例。Client 可以訂閱 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a>，server 會依 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> 推送 notification、status update 或 error message。</p>
<p>範例只用來展示 Go <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 服務設計，不假設讀者正在維護任何特定專案。</p>
<h2 id="本模組的-go-核心概念">本模組的 Go 核心概念</h2>
<ul>
<li>用一個 read pump 負責 client 輸入。</li>
<li>用一個 write pump 負責所有 WebSocket 寫入。</li>
<li>用 channel 作為 client send <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>。</li>
<li>用 context、done channel 或 hub unregister 管理連線生命週期。</li>
<li>用 ticker 實作 heartbeat，但由 write pump 統一寫 ping。</li>
<li>用 mutex 或 hub event loop 保護訂閱狀態。</li>
<li>用 non-blocking send 保護 hub 不被慢 client 卡住。</li>
</ul>
<h2 id="學習重點">學習重點</h2>
<p>學完本模組後，你應該能判斷：</p>
<ol>
<li>哪個 goroutine 可以讀 WebSocket，哪個 goroutine 可以寫 WebSocket</li>
<li>read pump、write pump、hub unregister 之間如何協作</li>
<li>heartbeat 失敗後應該走哪一條清理路徑</li>
<li>client action 應該在 router、usecase 還是 hub 裡處理</li>
<li>send buffer 滿載時應該丟棄、斷線、回錯或改用可靠儲存</li>
</ol>
<h2 id="章節粒度說明">章節粒度說明</h2>
<p>WebSocket 章節依照連線生命週期拆分。Read/write pump、heartbeat、subscription routing、slow client 是四個不同責任；它們常在同一個 hub 或 client type 中互相呼叫，但教學上應分開建立模型。</p>
<p>如果只想處理單一問題，可以這樣查：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>優先閱讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>concurrent write 或讀寫責任混亂</td>
          <td><a href="/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">read pump / write pump 模式</a></td>
      </tr>
      <tr>
          <td>連線失效沒有被清理</td>
          <td><a href="/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">heartbeat、deadline 與連線清理</a></td>
      </tr>
      <tr>
          <td>action、payload、訂閱狀態混在一起</td>
          <td><a href="/blog/go-advanced/02-networking-websocket/subscription-routing/" data-link-title="2.3 訂閱模型與訊息路由" data-link-desc="將 client action 對應到主題訂閱狀態">訂閱模型與訊息路由</a></td>
      </tr>
      <tr>
          <td>慢 client 拖垮 hub</td>
          <td><a href="/blog/go-advanced/02-networking-websocket/slow-client/" data-link-title="2.4 慢客戶端與 send buffer 管理" data-link-desc="控制推送佇列與記憶體風險">慢客戶端與 send buffer 管理</a></td>
      </tr>
  </tbody>
</table>
<h2 id="本模組不處理">本模組不處理</h2>
<p>本模組不討論 WebSocket 壓縮、水平擴展、跨節點廣播或完整身份驗證。這些都是實務重要主題，但必須先建立單一 Go process 內的連線生命週期與容量邊界；後續可接 <a href="/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">跨節點 WebSocket、presence 與重連協定</a>。</p>
<h2 id="先備知識">先備知識</h2>
<ul>
<li><a href="/blog/go-advanced/01-concurrency-patterns/" data-link-title="模組一：進階並發模式" data-link-desc="channel ownership、select loop、非阻塞送出、共享狀態、worker pool 與 rate limiting">模組一：進階並發模式</a></li>
<li><a href="/blog/go/03-stdlib/http-handler/" data-link-title="3.5 net/http 與 handler 設計" data-link-desc="用 net/http 建立健康檢查、API endpoint 與清楚的 handler 邊界">Go 入門：標準庫 HTTP</a></li>
<li>知道 goroutine、channel、select、context 的基本用法</li>
</ul>
<h2 id="學習時間">學習時間</h2>
<p>預計 3-4 小時</p>
]]></content:encoded></item><item><title>模組二：型別、資料與介面</title><link>https://tarrragon.github.io/blog/go/02-types-data/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/02-types-data/</guid><description>&lt;p>Go 的型別系統不追求複雜，而是讓資料形狀、行為需求與程式邊界能被清楚看見。本模組用一般資料處理、設定檔、API message 與狀態資料說明資料如何被定義、序列化、傳遞、組合、泛型化與保護。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&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;a href="https://tarrragon.github.io/blog/go/02-types-data/struct-json/" data-link-title="2.1 struct 與 JSON tag" data-link-desc="理解 Go struct 如何表達資料形狀，並透過 JSON tag 對應外部格式">2.1&lt;/a>&lt;/td>
 &lt;td>struct 與 JSON tag&lt;/td>
 &lt;td>用 struct 定義 API schema&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/02-types-data/slices-maps/" data-link-title="2.2 slice 與 map" data-link-desc="掌握 Go 最常用的集合型別：slice 與 map">2.2&lt;/a>&lt;/td>
 &lt;td>slice 與 map&lt;/td>
 &lt;td>掌握 Go 最常用的集合型別&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">2.3&lt;/a>&lt;/td>
 &lt;td>interface：用行為定義依賴&lt;/td>
 &lt;td>用小介面降低耦合&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/02-types-data/constants/" data-link-title="2.4 常數與 typed string" data-link-desc="管理狀態值、事件類型與協定字串">2.4&lt;/a>&lt;/td>
 &lt;td>常數與 typed string&lt;/td>
 &lt;td>管理狀態值與訊息類型&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/02-types-data/pointers-copy/" data-link-title="2.5 指標與資料複製邊界" data-link-desc="理解指標、slice 與共享狀態的防護策略">2.5&lt;/a>&lt;/td>
 &lt;td>指標與資料複製邊界&lt;/td>
 &lt;td>避免外部修改共享狀態&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/02-types-data/embedding-composition/" data-link-title="2.6 struct embedding 與組合式設計" data-link-desc="理解 Go 的 embedding、方法提升與組合邊界">2.6&lt;/a>&lt;/td>
 &lt;td>struct embedding 與組合式設計&lt;/td>
 &lt;td>分辨欄位提升、方法提升與依賴組合&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/02-types-data/generics-basics/" data-link-title="2.7 generics 入門：型別參數與約束" data-link-desc="用最小範圍理解 Go generics 的適用場景">2.7&lt;/a>&lt;/td>
 &lt;td>generics 入門：型別參數與約束&lt;/td>
 &lt;td>在重複資料結構與 helper 中使用最小泛型&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;ul>
&lt;li>設定檔與資料列 model&lt;/li>
&lt;li>API request/response model&lt;/li>
&lt;li>狀態資料 model&lt;/li>
&lt;li>查詢與寫入介面&lt;/li>
&lt;li>embedding 與小型泛型 helper&lt;/li>
&lt;/ul>
&lt;h2 id="學習時間">學習時間&lt;/h2>
&lt;p>預計 130-170 分鐘&lt;/p></description><content:encoded><![CDATA[<p>Go 的型別系統不追求複雜，而是讓資料形狀、行為需求與程式邊界能被清楚看見。本模組用一般資料處理、設定檔、API message 與狀態資料說明資料如何被定義、序列化、傳遞、組合、泛型化與保護。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go/02-types-data/struct-json/" data-link-title="2.1 struct 與 JSON tag" data-link-desc="理解 Go struct 如何表達資料形狀，並透過 JSON tag 對應外部格式">2.1</a></td>
          <td>struct 與 JSON tag</td>
          <td>用 struct 定義 API schema</td>
      </tr>
      <tr>
          <td><a href="/blog/go/02-types-data/slices-maps/" data-link-title="2.2 slice 與 map" data-link-desc="掌握 Go 最常用的集合型別：slice 與 map">2.2</a></td>
          <td>slice 與 map</td>
          <td>掌握 Go 最常用的集合型別</td>
      </tr>
      <tr>
          <td><a href="/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">2.3</a></td>
          <td>interface：用行為定義依賴</td>
          <td>用小介面降低耦合</td>
      </tr>
      <tr>
          <td><a href="/blog/go/02-types-data/constants/" data-link-title="2.4 常數與 typed string" data-link-desc="管理狀態值、事件類型與協定字串">2.4</a></td>
          <td>常數與 typed string</td>
          <td>管理狀態值與訊息類型</td>
      </tr>
      <tr>
          <td><a href="/blog/go/02-types-data/pointers-copy/" data-link-title="2.5 指標與資料複製邊界" data-link-desc="理解指標、slice 與共享狀態的防護策略">2.5</a></td>
          <td>指標與資料複製邊界</td>
          <td>避免外部修改共享狀態</td>
      </tr>
      <tr>
          <td><a href="/blog/go/02-types-data/embedding-composition/" data-link-title="2.6 struct embedding 與組合式設計" data-link-desc="理解 Go 的 embedding、方法提升與組合邊界">2.6</a></td>
          <td>struct embedding 與組合式設計</td>
          <td>分辨欄位提升、方法提升與依賴組合</td>
      </tr>
      <tr>
          <td><a href="/blog/go/02-types-data/generics-basics/" data-link-title="2.7 generics 入門：型別參數與約束" data-link-desc="用最小範圍理解 Go generics 的適用場景">2.7</a></td>
          <td>generics 入門：型別參數與約束</td>
          <td>在重複資料結構與 helper 中使用最小泛型</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<ul>
<li>設定檔與資料列 model</li>
<li>API request/response model</li>
<li>狀態資料 model</li>
<li>查詢與寫入介面</li>
<li>embedding 與小型泛型 helper</li>
</ul>
<h2 id="學習時間">學習時間</h2>
<p>預計 130-170 分鐘</p>
]]></content:encoded></item><item><title>9.3 AST 驅動的 idempotent 文字改寫</title><link>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/ast-idempotent-rewriting/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/ast-idempotent-rewriting/</guid><description>&lt;p>AST 驅動文字改寫的核心契約是 &lt;strong>&lt;a href="https://tarrragon.github.io/blog/go/glossary/#idempotent-%e6%96%87%e5%ad%97%e6%94%b9%e5%af%ab" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">idempotent&lt;/a>&lt;/strong>：對同一輸入跑一次或十次結果相同。這個契約讓工具能安全地接到 &lt;a href="https://tarrragon.github.io/blog/go/glossary/#pre-commit-hook-%e5%ae%9a%e4%bd%8d" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">pre-commit hook&lt;/a>（每次 commit 都跑不會累積漂移）、能分段除錯（改一條 rule 不會破壞其他 rule 的輸出）、能用 &lt;code>--check&lt;/code> 跟 &lt;code>--fix&lt;/code> 共用同一套邏輯（差別只在要不要寫檔）。&lt;code>gofmt&lt;/code>、&lt;code>prettier&lt;/code>、&lt;code>ruff fix&lt;/code> 這類工具在工程界立信譽的基礎就是冪等。&lt;/p>
&lt;p>設計一個冪等的改寫流水線有三個配合層：&lt;strong>策略選擇&lt;/strong>（AST round-trip / byte surgical / 混合）、&lt;strong>rule 鏈順序&lt;/strong>（每條 rule 的輸出要是下一條的合法輸入）、&lt;strong>context 重算紀律&lt;/strong>（行數變動後索引要重建）。本章依序展開這三層，並以 &lt;code>mdtools fmt --fix&lt;/code> 的 &lt;code>applyAll&lt;/code> 為 concrete instance。&lt;/p>
&lt;h2 id="改檔的三種策略">改檔的三種策略&lt;/h2>
&lt;p>把 AST 資訊轉成檔案修改有三條路：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>策略&lt;/th>
 &lt;th>做法&lt;/th>
 &lt;th>優點&lt;/th>
 &lt;th>缺點&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>AST 重新 serialize&lt;/td>
 &lt;td>parse → 在 AST 上改 → 用 renderer 寫回 markdown&lt;/td>
 &lt;td>概念乾淨；不會遺漏結構&lt;/td>
 &lt;td>goldmark 的 renderer &lt;strong>不保證 round-trip 精確&lt;/strong>；diff 會爆炸&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>位置導向 byte 改寫&lt;/td>
 &lt;td>用 AST 找違規節點的 offset，外科手術式字串編輯&lt;/td>
 &lt;td>diff 只影響違規處，保留原格式細節&lt;/td>
 &lt;td>byte offset 要嚴謹管理；規則越多越囉嗦&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>混合：line-based + AST-guided&lt;/td>
 &lt;td>行內能解決的（空行、trailing newline）用逐行處理；複雜的（URL 縮短、結構重構）用 AST 找位置&lt;/td>
 &lt;td>取兩者之長；簡單規則簡單寫&lt;/td>
 &lt;td>要明確劃分哪條 rule 用哪種策略&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>mdtools 選第三條。純 AST round-trip 在長尾場景（nested fence、特殊 escape）會產出跟 source 不等價的 markdown；純 byte-offset 讓 MD047（trailing newline）這類微瑣事變得囉嗦。混合讓每條 rule 在它最自然的層級解決。&lt;/p>
&lt;p>設計原則：&lt;strong>能用 line-based 就用，AST 只在真的需要語意判讀時才上&lt;/strong>。&lt;/p>
&lt;h2 id="rule-鏈的結構">Rule 鏈的結構&lt;/h2>
&lt;p>&lt;code>mdtools fmt&lt;/code> 的 &lt;code>applyAll&lt;/code> 是條流水線：&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="c1">// scripts/mdtools/internal/mdfmt/fixer.go&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">applyAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">data&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">byte&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cfg&lt;/span> &lt;span class="nx">rules&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Config&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">byte&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">lines&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">splitLines&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"> 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="c1">// MD026 — 標題結尾標點，line-preserving&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">if&lt;/span> &lt;span class="nx">cfg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Headings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ForbidTrailingPunct&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="nx">ctx&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">AnalyzeLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&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="nx">lines&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nf">FixHeadingTrailingPunct&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&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">cfg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Headings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ForbiddenTrailingPunct&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">	&lt;span class="c1">// MD022 — 標題前後空行，line-count changing&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">cfg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Headings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RequireBlankLines&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">ctx&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">AnalyzeLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&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">lines&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nf">FixHeadingBlankLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ctx&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>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">	&lt;span class="c1">// MD031 — fenced code block 前後空行&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">if&lt;/span> &lt;span class="nx">cfg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">CodeBlocks&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RequireBlankLinesAround&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="nx">ctx&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">AnalyzeLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&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 class="nx">lines&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nf">FixFencedCodeBlankLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ctx&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">	&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>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">	&lt;span class="c1">// MD032 — 列表前後空行&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">	&lt;span class="nx">ctx&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">AnalyzeLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&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 class="nx">lines&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nf">FixListBlankLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ctx&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl">	&lt;span class="c1">// MD060 — 表格對齊&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl">	&lt;span class="nx">ctx&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nf">AnalyzeLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl">	&lt;span class="nx">lines&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nf">FixTables&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&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">cfg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Tables&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl">	&lt;span class="c1">// MD034 — 裸 URL 縮短&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl">	&lt;span class="nx">ctx&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nf">AnalyzeLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">33&lt;/span>&lt;span class="cl">	&lt;span class="nx">lines&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nf">FixBareURLs&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&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">cfg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">URLs&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">34&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">35&lt;/span>&lt;span class="cl">	&lt;span class="nx">out&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">joinLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">lines&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">36&lt;/span>&lt;span class="cl">	&lt;span class="nx">out&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nf">EnsureTrailingNewline&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">out&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// MD047&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">37&lt;/span>&lt;span class="cl">	&lt;span class="k">return&lt;/span> &lt;span class="nx">out&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">38&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;strong>設計決策&lt;/strong>值得拆開看。&lt;/p>
&lt;h3 id="順序決定結果">順序決定結果&lt;/h3>
&lt;p>Rule 順序有明確的依賴判準：&lt;strong>每條 rule 的輸出應該是下一條 rule 的合法輸入&lt;/strong>。&lt;/p></description><content:encoded><![CDATA[<p>AST 驅動文字改寫的核心契約是 <strong><a href="/blog/go/glossary/#idempotent-%e6%96%87%e5%ad%97%e6%94%b9%e5%af%ab" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">idempotent</a></strong>：對同一輸入跑一次或十次結果相同。這個契約讓工具能安全地接到 <a href="/blog/go/glossary/#pre-commit-hook-%e5%ae%9a%e4%bd%8d" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">pre-commit hook</a>（每次 commit 都跑不會累積漂移）、能分段除錯（改一條 rule 不會破壞其他 rule 的輸出）、能用 <code>--check</code> 跟 <code>--fix</code> 共用同一套邏輯（差別只在要不要寫檔）。<code>gofmt</code>、<code>prettier</code>、<code>ruff fix</code> 這類工具在工程界立信譽的基礎就是冪等。</p>
<p>設計一個冪等的改寫流水線有三個配合層：<strong>策略選擇</strong>（AST round-trip / byte surgical / 混合）、<strong>rule 鏈順序</strong>（每條 rule 的輸出要是下一條的合法輸入）、<strong>context 重算紀律</strong>（行數變動後索引要重建）。本章依序展開這三層，並以 <code>mdtools fmt --fix</code> 的 <code>applyAll</code> 為 concrete instance。</p>
<h2 id="改檔的三種策略">改檔的三種策略</h2>
<p>把 AST 資訊轉成檔案修改有三條路：</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>做法</th>
          <th>優點</th>
          <th>缺點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>AST 重新 serialize</td>
          <td>parse → 在 AST 上改 → 用 renderer 寫回 markdown</td>
          <td>概念乾淨；不會遺漏結構</td>
          <td>goldmark 的 renderer <strong>不保證 round-trip 精確</strong>；diff 會爆炸</td>
      </tr>
      <tr>
          <td>位置導向 byte 改寫</td>
          <td>用 AST 找違規節點的 offset，外科手術式字串編輯</td>
          <td>diff 只影響違規處，保留原格式細節</td>
          <td>byte offset 要嚴謹管理；規則越多越囉嗦</td>
      </tr>
      <tr>
          <td>混合：line-based + AST-guided</td>
          <td>行內能解決的（空行、trailing newline）用逐行處理；複雜的（URL 縮短、結構重構）用 AST 找位置</td>
          <td>取兩者之長；簡單規則簡單寫</td>
          <td>要明確劃分哪條 rule 用哪種策略</td>
      </tr>
  </tbody>
</table>
<p>mdtools 選第三條。純 AST round-trip 在長尾場景（nested fence、特殊 escape）會產出跟 source 不等價的 markdown；純 byte-offset 讓 MD047（trailing newline）這類微瑣事變得囉嗦。混合讓每條 rule 在它最自然的層級解決。</p>
<p>設計原則：<strong>能用 line-based 就用，AST 只在真的需要語意判讀時才上</strong>。</p>
<h2 id="rule-鏈的結構">Rule 鏈的結構</h2>
<p><code>mdtools fmt</code> 的 <code>applyAll</code> 是條流水線：</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="c1">// scripts/mdtools/internal/mdfmt/fixer.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">applyAll</span><span class="p">(</span><span class="nx">data</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">,</span> <span class="nx">cfg</span> <span class="nx">rules</span><span class="p">.</span><span class="nx">Config</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"> 3</span><span class="cl">	<span class="nx">lines</span> <span class="o">:=</span> <span class="nf">splitLines</span><span class="p">(</span><span class="nx">data</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="c1">// MD026 — 標題結尾標點，line-preserving</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="k">if</span> <span class="nx">cfg</span><span class="p">.</span><span class="nx">Headings</span><span class="p">.</span><span class="nx">ForbidTrailingPunct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">		<span class="nx">ctx</span> <span class="o">:=</span> <span class="nf">AnalyzeLines</span><span class="p">(</span><span class="nx">lines</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">		<span class="nx">lines</span> <span class="p">=</span> <span class="nf">FixHeadingTrailingPunct</span><span class="p">(</span><span class="nx">lines</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">,</span> <span class="nx">cfg</span><span class="p">.</span><span class="nx">Headings</span><span class="p">.</span><span class="nx">ForbiddenTrailingPunct</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></span><span class="line"><span class="ln">11</span><span class="cl">	<span class="c1">// MD022 — 標題前後空行，line-count changing</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">	<span class="k">if</span> <span class="nx">cfg</span><span class="p">.</span><span class="nx">Headings</span><span class="p">.</span><span class="nx">RequireBlankLines</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">		<span class="nx">ctx</span> <span class="o">:=</span> <span class="nf">AnalyzeLines</span><span class="p">(</span><span class="nx">lines</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">		<span class="nx">lines</span> <span class="p">=</span> <span class="nf">FixHeadingBlankLines</span><span class="p">(</span><span class="nx">lines</span><span class="p">,</span> <span class="nx">ctx</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></span><span class="line"><span class="ln">17</span><span class="cl">	<span class="c1">// MD031 — fenced code block 前後空行</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">	<span class="k">if</span> <span class="nx">cfg</span><span class="p">.</span><span class="nx">CodeBlocks</span><span class="p">.</span><span class="nx">RequireBlankLinesAround</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">		<span class="nx">ctx</span> <span class="o">:=</span> <span class="nf">AnalyzeLines</span><span class="p">(</span><span class="nx">lines</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">		<span class="nx">lines</span> <span class="p">=</span> <span class="nf">FixFencedCodeBlankLines</span><span class="p">(</span><span class="nx">lines</span><span class="p">,</span> <span class="nx">ctx</span><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></span><span class="line"><span class="ln">23</span><span class="cl">	<span class="c1">// MD032 — 列表前後空行</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">	<span class="nx">ctx</span> <span class="o">:=</span> <span class="nf">AnalyzeLines</span><span class="p">(</span><span class="nx">lines</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">	<span class="nx">lines</span> <span class="p">=</span> <span class="nf">FixListBlankLines</span><span class="p">(</span><span class="nx">lines</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">
</span></span><span class="line"><span class="ln">27</span><span class="cl">	<span class="c1">// MD060 — 表格對齊</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">	<span class="nx">ctx</span> <span class="p">=</span> <span class="nf">AnalyzeLines</span><span class="p">(</span><span class="nx">lines</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">	<span class="nx">lines</span> <span class="p">=</span> <span class="nf">FixTables</span><span class="p">(</span><span class="nx">lines</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">,</span> <span class="nx">cfg</span><span class="p">.</span><span class="nx">Tables</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">
</span></span><span class="line"><span class="ln">31</span><span class="cl">	<span class="c1">// MD034 — 裸 URL 縮短</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">	<span class="nx">ctx</span> <span class="p">=</span> <span class="nf">AnalyzeLines</span><span class="p">(</span><span class="nx">lines</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">	<span class="nx">lines</span> <span class="p">=</span> <span class="nf">FixBareURLs</span><span class="p">(</span><span class="nx">lines</span><span class="p">,</span> <span class="nx">ctx</span><span class="p">,</span> <span class="nx">cfg</span><span class="p">.</span><span class="nx">URLs</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">
</span></span><span class="line"><span class="ln">35</span><span class="cl">	<span class="nx">out</span> <span class="o">:=</span> <span class="nf">joinLines</span><span class="p">(</span><span class="nx">lines</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">	<span class="nx">out</span> <span class="p">=</span> <span class="nf">EnsureTrailingNewline</span><span class="p">(</span><span class="nx">out</span><span class="p">)</span> <span class="c1">// MD047</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">	<span class="k">return</span> <span class="nx">out</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>幾個<strong>設計決策</strong>值得拆開看。</p>
<h3 id="順序決定結果">順序決定結果</h3>
<p>Rule 順序有明確的依賴判準：<strong>每條 rule 的輸出應該是下一條 rule 的合法輸入</strong>。</p>
<ul>
<li>MD026 先跑：它改標題內容、不改行數，後面 rule 的行號不會位移。</li>
<li>MD022 / MD031 / MD032 緊接著：這些都 insert blank lines，會改行數；但它們彼此之間不衝突（heading ≠ fence ≠ list）。</li>
<li>MD060 表格對齊在 URL 縮短之前：讓表格先成為可解析結構，URL rule 才能正確判斷「這個 URL 在表格 cell 內」。</li>
<li>MD034 URL 縮短最後：URL 變短會讓表格欄寬變化；但因為 MD060 已經做過對齊，後續工具會再跑一次 fmt &ndash;fix 重新對齊。這個「跑兩次才穩定」的特性是可接受的，因為 fmt &ndash;fix 本來就冪等。</li>
<li>MD047 trailing newline 在 byte 層做，最後一步。</li>
</ul>
<h3 id="每條-rule-重新-analyze-context">每條 rule 重新 analyze context</h3>
<p><code>AnalyzeLines(lines)</code> 在每個會變行數的 rule 之前重跑。為什麼：</p>
<ul>
<li>上一條 rule 可能把 fence 或 front matter 位置推後。</li>
<li>Context 裡的 <code>Skip[]</code>、<code>FenceOpen[]</code>、<code>FenceClose[]</code> 都是按行索引儲存。</li>
<li>行數改變 → 索引失效 → 必須重算。</li>
</ul>
<p>成本是 O(N)，對 500-行檔案微秒級。在整體 pipeline 中可忽略。</p>
<h3 id="line-based-rule-本體範例">Line-based rule 本體範例</h3>
<p>以 MD022（標題前後空行）為例：</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="c1">// scripts/mdtools/internal/mdfmt/rules.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">FixHeadingBlankLines</span><span class="p">(</span><span class="nx">lines</span> <span class="p">[]</span><span class="kt">string</span><span class="p">,</span> <span class="nx">ctx</span> <span class="nx">LineContext</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"> 3</span><span class="cl">	<span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">lines</span><span class="p">)</span> <span class="o">==</span> <span class="mi">0</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="nx">lines</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">out</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">lines</span><span class="p">)</span><span class="o">+</span><span class="mi">8</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">	<span class="k">for</span> <span class="nx">i</span><span class="p">,</span> <span class="nx">line</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">lines</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">		<span class="nx">isHdr</span> <span class="o">:=</span> <span class="p">!</span><span class="nx">ctx</span><span class="p">.</span><span class="nx">Skip</span><span class="p">[</span><span class="nx">i</span><span class="p">]</span> <span class="o">&amp;&amp;</span> <span class="nf">isHeadingLine</span><span class="p">(</span><span class="nx">line</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">if</span> <span class="nx">isHdr</span> <span class="o">&amp;&amp;</span> <span class="nb">len</span><span class="p">(</span><span class="nx">out</span><span class="p">)</span> <span class="p">&gt;</span> <span class="mi">0</span> <span class="o">&amp;&amp;</span> <span class="p">!</span><span class="nf">isBlank</span><span class="p">(</span><span class="nx">out</span><span class="p">[</span><span class="nb">len</span><span class="p">(</span><span class="nx">out</span><span class="p">)</span><span class="o">-</span><span class="mi">1</span><span class="p">])</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">			<span class="nx">out</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">out</span><span class="p">,</span> <span class="s">&#34;&#34;</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="nx">out</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">out</span><span class="p">,</span> <span class="nx">line</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">		<span class="k">if</span> <span class="nx">isHdr</span> <span class="o">&amp;&amp;</span> <span class="nx">i</span><span class="o">+</span><span class="mi">1</span> <span class="p">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="nx">lines</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="p">!</span><span class="nf">isBlank</span><span class="p">(</span><span class="nx">lines</span><span class="p">[</span><span class="nx">i</span><span class="o">+</span><span class="mi">1</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">out</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">out</span><span class="p">,</span> <span class="s">&#34;&#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><span class="line"><span class="ln">18</span><span class="cl">	<span class="k">return</span> <span class="nx">out</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>關鍵 idempotent 技巧：</p>
<ul>
<li><strong>判斷「上一行不是 blank 就插」而非「永遠插」</strong>。已經插過 blank 的情況下，第二次跑會看到 blank，跳過，結果相同。</li>
<li><strong>out 是新 slice</strong>，不改動原 lines。函式純粹。</li>
<li><strong>look-ahead 看原 lines</strong>，避免剛插的 blank 讓邏輯誤判下一輪。</li>
</ul>
<h2 id="ast-guided-rule-範例md034-url-縮短">AST-guided rule 範例：MD034 URL 縮短</h2>
<p>這條 rule 用 AST 找「哪些 text 是 link 之外的」，行內用 regex + mask 處理：</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="c1">// scripts/mdtools/internal/mdfmt/urls.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">rewriteBareURLsInLine</span><span class="p">(</span><span class="nx">line</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">cfg</span> <span class="nx">rules</span><span class="p">.</span><span class="nx">URLRules</span><span class="p">,</span> <span class="nx">idPatterns</span> <span class="p">[]</span><span class="o">*</span><span class="nx">regexp</span><span class="p">.</span><span class="nx">Regexp</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="nx">masked</span> <span class="o">:=</span> <span class="nf">collectMaskedRanges</span><span class="p">(</span><span class="nx">line</span><span class="p">)</span> <span class="c1">// [...](/go/09-tooling-and-analysis/ast-idempotent-rewriting/...) / &lt;...&gt; / `...` 的位置</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="nx">matches</span> <span class="o">:=</span> <span class="nx">bareURLRe</span><span class="p">.</span><span class="nf">FindAllStringIndex</span><span class="p">(</span><span class="nx">line</span><span class="p">,</span> <span class="o">-</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="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">matches</span><span class="p">)</span> <span class="o">==</span> <span class="mi">0</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="nx">line</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="kd">var</span> <span class="nx">b</span> <span class="nx">strings</span><span class="p">.</span><span class="nx">Builder</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">	<span class="nx">cursor</span> <span class="o">:=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">m</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">matches</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">		<span class="nx">start</span><span class="p">,</span> <span class="nx">end</span> <span class="o">:=</span> <span class="nx">m</span><span class="p">[</span><span class="mi">0</span><span class="p">],</span> <span class="nx">m</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">		<span class="nx">end</span> <span class="p">=</span> <span class="nf">trimURLTrailPunct</span><span class="p">(</span><span class="nx">line</span><span class="p">,</span> <span class="nx">start</span><span class="p">,</span> <span class="nx">end</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">		<span class="nx">b</span><span class="p">.</span><span class="nf">WriteString</span><span class="p">(</span><span class="nx">line</span><span class="p">[</span><span class="nx">cursor</span><span class="p">:</span><span class="nx">start</span><span class="p">])</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">		<span class="k">if</span> <span class="nf">inMasked</span><span class="p">(</span><span class="nx">masked</span><span class="p">,</span> <span class="nx">start</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">b</span><span class="p">.</span><span class="nf">WriteString</span><span class="p">(</span><span class="nx">line</span><span class="p">[</span><span class="nx">start</span><span class="p">:</span><span class="nx">end</span><span class="p">])</span> <span class="c1">// 已在 link / code span / 角括號內</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">		<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">			<span class="nx">rawURL</span> <span class="o">:=</span> <span class="nx">line</span><span class="p">[</span><span class="nx">start</span><span class="p">:</span><span class="nx">end</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">			<span class="nx">display</span> <span class="o">:=</span> <span class="nf">shortenURL</span><span class="p">(</span><span class="nx">rawURL</span><span class="p">,</span> <span class="nx">cfg</span><span class="p">,</span> <span class="nx">idPatterns</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">			<span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprintf</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">b</span><span class="p">,</span> <span class="s">&#34;[%s](/go/09-tooling-and-analysis/ast-idempotent-rewriting/%s)&#34;</span><span class="p">,</span> <span class="nx">display</span><span class="p">,</span> <span class="nx">rawURL</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="nx">cursor</span> <span class="p">=</span> <span class="nx">end</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="nx">b</span><span class="p">.</span><span class="nf">WriteString</span><span class="p">(</span><span class="nx">line</span><span class="p">[</span><span class="nx">cursor</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">b</span><span class="p">.</span><span class="nf">String</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這裡的 <strong>混合精神</strong>：</p>
<ul>
<li>AST 不直接改檔，只提供「這行的哪些 byte 範圍是 existing link / code span」的判讀（實際上這段用了 regex 模擬；真正嚴謹時會改用 AST 定位）。</li>
<li>真的改寫走字串層級，保留原格式。</li>
<li>已在 link 內的 URL 不再包第二層 — <code>inMasked</code> 檢查防止 double-wrap，這也是 <strong>idempotent 關鍵</strong>：第二次跑，所有 URL 都已經在 masked range 裡，跳過。</li>
</ul>
<h2 id="--check-跟---fix-共用邏輯"><code>--check</code> 跟 <code>--fix</code> 共用邏輯</h2>
<p>一個常見反 pattern 是：<code>check</code> 模式重寫一次邏輯「看會不會改」，<code>fix</code> 模式真的改。兩套邏輯一旦漂移，誤報或漏報就出現。</p>
<p>正確做法是<strong>共用同一個 FormatFile，然後比對結果</strong>：</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="c1">// scripts/mdtools/internal/mdfmt/fixer.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">FormatFile</span><span class="p">(</span><span class="nx">path</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">cfg</span> <span class="nx">rules</span><span class="p">.</span><span class="nx">Config</span><span class="p">)</span> <span class="p">(</span><span class="nx">FixResult</span><span class="p">,</span> <span class="kt">error</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">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">ReadFile</span><span class="p">(</span><span class="nx">path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</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"> 5</span><span class="cl">		<span class="k">return</span> <span class="nx">FixResult</span><span class="p">{},</span> <span class="nx">err</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="nx">fixed</span> <span class="o">:=</span> <span class="nf">applyAll</span><span class="p">(</span><span class="nx">data</span><span class="p">,</span> <span class="nx">cfg</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">FixResult</span><span class="p">{</span><span class="nx">Path</span><span class="p">:</span> <span class="nx">path</span><span class="p">,</span> <span class="nx">Original</span><span class="p">:</span> <span class="nx">data</span><span class="p">,</span> <span class="nx">Fixed</span><span class="p">:</span> <span class="nx">fixed</span><span class="p">},</span> <span class="kc">nil</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></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">// check / fix 都呼叫 FormatFile，只差在怎麼處理結果</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="nx">FixResult</span><span class="p">)</span> <span class="nf">Changed</span><span class="p">()</span> <span class="kt">bool</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="p">!</span><span class="nx">bytes</span><span class="p">.</span><span class="nf">Equal</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Original</span><span class="p">,</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Fixed</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><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="c1">// cmd/fmt.go 簡化版</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="nx">result</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">mdfmt</span><span class="p">.</span><span class="nf">FormatFile</span><span class="p">(</span><span class="nx">path</span><span class="p">,</span> <span class="nx">cfg</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">result</span><span class="p">.</span><span class="nf">Changed</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">return</span> <span class="c1">// 沒改動，跳過</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="k">if</span> <span class="o">*</span><span class="nx">fix</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">	<span class="nx">os</span><span class="p">.</span><span class="nf">WriteFile</span><span class="p">(</span><span class="nx">path</span><span class="p">,</span> <span class="nx">result</span><span class="p">.</span><span class="nx">Fixed</span><span class="p">,</span> <span class="mi">0</span><span class="nx">o644</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">	<span class="nx">fmt</span><span class="p">.</span><span class="nf">Printf</span><span class="p">(</span><span class="s">&#34;would fix: %s\n&#34;</span><span class="p">,</span> <span class="nx">path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>check 跟 fix 跑一模一樣的 rule chain，只是其中一個寫檔、另一個回報</strong>。這個結構讓兩個模式的行為<strong>保證一致</strong>。</p>
<h2 id="idempotent-的驗證方式">Idempotent 的驗證方式</h2>
<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">TestFormatIdempotent</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">inputs</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">filepath</span><span class="p">.</span><span class="nf">Glob</span><span class="p">(</span><span class="s">&#34;testdata/*.md&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="nx">cfg</span> <span class="o">:=</span> <span class="nx">rules</span><span class="p">.</span><span class="nf">Default</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">in</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">inputs</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">		<span class="nx">data</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">ReadFile</span><span class="p">(</span><span class="nx">in</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">		<span class="nx">once</span> <span class="o">:=</span> <span class="nf">applyAll</span><span class="p">(</span><span class="nx">data</span><span class="p">,</span> <span class="nx">cfg</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">		<span class="nx">twice</span> <span class="o">:=</span> <span class="nf">applyAll</span><span class="p">(</span><span class="nx">once</span><span class="p">,</span> <span class="nx">cfg</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">		<span class="k">if</span> <span class="p">!</span><span class="nx">bytes</span><span class="p">.</span><span class="nf">Equal</span><span class="p">(</span><span class="nx">once</span><span class="p">,</span> <span class="nx">twice</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">t</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;%s: not idempotent (applied twice != once)&#34;</span><span class="p">,</span> <span class="nx">in</span><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><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>生產環境的 pre-commit hook 本質上每次 commit 都在驗證冪等：</p>
<ol>
<li>作者寫 commit → <code>fmt --fix</code> 跑過 → re-stage</li>
<li>如果邏輯不冪等，下次作者改同檔案，可能又會被改回/改去</li>
<li>使用者很快會發現「為什麼這個工具一直來回改我的檔案」</li>
</ol>
<p>冪等是 pre-commit 的信譽基礎。</p>
<h2 id="常見陷阱">常見陷阱</h2>
<h3 id="rule-之間互相抵消">Rule 之間互相抵消</h3>





<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">Rule A: 移除行尾空白
</span></span><span class="line"><span class="ln">2</span><span class="cl">Rule B: 把每個 heading 後面補空格到 60 欄</span></span></code></pre></div><p>A 跟 B 串起來會永遠改來改去。寫 rule 時要想「其他 rule 會對我的輸出做什麼」。</p>
<h3 id="讀-src-跟改-src-用不同的-byte-slice">讀 src 跟改 src 用不同的 byte slice</h3>
<p>在迴圈中一邊掃 <code>data</code>、一邊 append 到 <code>out</code>，中間忘了切換視角。建議永遠遵循 <code>(原 lines, 新 out)</code> 兩個名字，迴圈體只 look-back 到 <code>out[len(out)-1]</code> 或 look-ahead 到 <code>lines[i+1]</code>，絕不在同一時段既讀又寫同一 slice。</p>
<h3 id="trailing-newline-的邊界">Trailing newline 的邊界</h3>





<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="nx">bytes</span><span class="p">.</span><span class="nf">TrimRight</span><span class="p">(</span><span class="nx">data</span><span class="p">,</span> <span class="s">&#34;\r\n&#34;</span><span class="p">)</span>      <span class="c1">// 去掉全部</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">return</span> <span class="nb">append</span><span class="p">(</span><span class="nx">data</span><span class="p">,</span> <span class="sc">&#39;\n&#39;</span><span class="p">)</span>           <span class="c1">// 加一個</span></span></span></code></pre></div><p>空檔案要特別處理 — 加了 <code>\n</code> 就變非空。mdtools 的作法是：</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="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">data</span><span class="p">)</span> <span class="o">==</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">	<span class="k">return</span> <span class="nx">data</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>空檔保持空。</p>
<h3 id="regex-based-url-偵測的邊界">Regex-based URL 偵測的邊界</h3>
<p>Go 的 RE2 沒有 lookbehind，無法用 regex 直接寫「URL 不在 <code>](</code> 後面」。解法是先掃 mask（link span、angle bracket、code span），再跑 URL regex，match 結果對照 mask 決定是否替換。<code>collectMaskedRanges</code> 就是這個模式。</p>
<h2 id="擴充路徑">擴充路徑</h2>
<ul>
<li><strong>Rule dry-run diff</strong>：把每條 rule 單獨跑一遍，輸出每條 rule 改了哪幾個檔案。debug 為什麼某檔案被改時用得到。</li>
<li><strong>Configurable rule disabling</strong>：把 rule 開關改成 front matter 級別（<code>mdtools-disable: MD026</code>），讓個別檔案能 opt-out。</li>
<li><strong>Rule 可程式化插入</strong>：把 <code>applyAll</code> 改成「讀 config → 產生 rule list → iterate」，讓新 rule 不用改 fixer.go 而是註冊進來。</li>
</ul>
<h2 id="下一步">下一步</h2>
<p><a href="/blog/go/09-tooling-and-analysis/cross-file-graph-analysis/" data-link-title="9.4 跨檔案圖分析：從 lint 走到 static analysis" data-link-desc="Single-file 規則用 AST 搞定；跨檔 orphan 偵測、broken link、backlink 完整性需要把整個 repo 建成圖再走訪。用 mdtools cards 為例">9.4 跨檔案圖分析</a> 離開 single-file 世界，看 <code>mdtools cards</code> 怎麼建整個 repo 的 link graph 跑反向查詢。</p>
]]></content:encoded></item><item><title>8.3 Dropbox：從 Python 遷移到 Go</title><link>https://tarrragon.github.io/blog/go/08-case-studies/dropbox/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/08-case-studies/dropbox/</guid><description>&lt;p>Dropbox 的案例是最典型的「性能關鍵服務遷移」故事之一。官方案例直接寫到，他們把 performance-critical backends 從 Python 轉到 Go，以獲得更好的 concurrency support 與更快的執行速度。&lt;/p>
&lt;h2 id="你應該看什麼">你應該看什麼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://go.dev/solutions/dropbox">Dropbox - Open sourcing our Go libraries&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼&lt;/h2>
&lt;ol>
&lt;li>Go 很常被選在 Python 已經不夠用的後端邊界。&lt;/li>
&lt;li>併發支援通常是遷移的重要原因之一。&lt;/li>
&lt;li>遷移通常先把性能最敏感的部分換成 Go，逐步擴展。&lt;/li>
&lt;/ol>
&lt;h2 id="可對照的公開原始碼">可對照的公開原始碼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://github.com/dropbox/godropbox">dropbox/godropbox&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/dropbox/dropbox-api-spec">dropbox/dropbox-api-spec&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>Dropbox 的公開 Go libraries 與 API spec 很適合對照閱讀。你會看到一個大公司如何把 Go 用在可重用工具與服務邊界上。&lt;/p></description><content:encoded><![CDATA[<p>Dropbox 的案例是最典型的「性能關鍵服務遷移」故事之一。官方案例直接寫到，他們把 performance-critical backends 從 Python 轉到 Go，以獲得更好的 concurrency support 與更快的執行速度。</p>
<h2 id="你應該看什麼">你應該看什麼</h2>
<ul>
<li><a href="https://go.dev/solutions/dropbox">Dropbox - Open sourcing our Go libraries</a></li>
</ul>
<h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼</h2>
<ol>
<li>Go 很常被選在 Python 已經不夠用的後端邊界。</li>
<li>併發支援通常是遷移的重要原因之一。</li>
<li>遷移通常先把性能最敏感的部分換成 Go，逐步擴展。</li>
</ol>
<h2 id="可對照的公開原始碼">可對照的公開原始碼</h2>
<ul>
<li><a href="https://github.com/dropbox/godropbox">dropbox/godropbox</a></li>
<li><a href="https://github.com/dropbox/dropbox-api-spec">dropbox/dropbox-api-spec</a></li>
</ul>
<p>Dropbox 的公開 Go libraries 與 API spec 很適合對照閱讀。你會看到一個大公司如何把 Go 用在可重用工具與服務邊界上。</p>
]]></content:encoded></item><item><title>0.3 錯誤處理：把失敗路徑寫出來</title><link>https://tarrragon.github.io/blog/go/00-philosophy/error-thinking/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/00-philosophy/error-thinking/</guid><description>&lt;p>Go 錯誤處理的核心原則是把失敗路徑明確寫在程式流程中。&lt;code>if err != nil&lt;/code> 看起來重複，但它讓每一步可能失敗的地方都可見，也讓讀者能直接知道失敗時程式會怎麼結束。對需要長時間運行、並發處理、背景工作或即時請求回應的服務來說，這種顯式失敗路徑比隱式例外更容易維護。&lt;/p>
&lt;h2 id="為什麼這章在第零章">為什麼這章在第零章&lt;/h2>
&lt;p>如果你的場景已經把 Go 推向服務型系統，錯誤處理就是維持服務穩定的核心能力。這一章要先建立的是：Go 會要求你把失敗說清楚，因為在高併發或長時間運行的情境下，模糊的失敗行為會比清楚的錯誤訊息更難排查。&lt;/p>
&lt;h2 id="error-是普通回傳值">error 是普通回傳值&lt;/h2>
&lt;p>Go 的 &lt;code>error&lt;/code> 是一個普通介面值。函式若可能失敗，通常會把錯誤放在最後一個回傳值。&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">LoadConfig&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">path&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">Config&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ReadFile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">path&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">if&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"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">Config&lt;/span>&lt;span class="p">{},&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;read config %q: %w&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">path&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"> 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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">config&lt;/span> &lt;span class="nx">Config&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">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">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">config&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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">Config&lt;/span>&lt;span class="p">{},&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;parse config %q: %w&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">path&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">10&lt;/span>&lt;span class="cl"> &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>&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">config&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&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>這段程式有兩個失敗點：讀檔失敗與 JSON 解析失敗。每個失敗點都立刻處理並回傳，正常流程則留在函式底部。&lt;/p>
&lt;h2 id="早期返回讓正常流程清楚">早期返回讓正常流程清楚&lt;/h2>
&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">CreateUser&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">email&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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">email&lt;/span> &lt;span class="p">=&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">email&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">if&lt;/span> &lt;span class="nx">email&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"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">User&lt;/span>&lt;span class="p">{},&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;email is required&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Contains&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">email&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;@&amp;#34;&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"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">User&lt;/span>&lt;span class="p">{},&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;invalid email&amp;#34;&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&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="nx">User&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Email&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">email&lt;/span>&lt;span class="p">},&lt;/span> &lt;span class="kc">nil&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>這個函式先處理空字串與格式錯誤，最後才建立使用者。讀者不需要進入深層巢狀條件，就能知道哪些資料不能通過。&lt;/p>
&lt;h2 id="包裝錯誤要補上操作脈絡">包裝錯誤要補上操作脈絡&lt;/h2>
&lt;p>錯誤包裝的核心責任是保留原始錯誤，同時補上當前操作脈絡。&lt;code>fmt.Errorf&lt;/code> 搭配 &lt;code>%w&lt;/code> 可以建立錯誤鏈。&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="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">repository&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Save&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">user&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">2&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;save user %q: %w&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Email&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">3&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>save user &amp;quot;alice@example.com&amp;quot;&lt;/code> 是當前操作脈絡，原始錯誤則被 &lt;code>%w&lt;/code> 保留下來。呼叫端可以印出完整錯誤，也可以用 &lt;code>errors.Is&lt;/code> 或 &lt;code>errors.As&lt;/code> 檢查特定錯誤。&lt;/p>
&lt;p>錯誤包裝應提供新的操作脈絡。&lt;code>fmt.Errorf(&amp;quot;failed: %w&amp;quot;, err)&lt;/code> 這類包裝沒有資訊量；好的錯誤訊息應該回答「做什麼失敗」與「關鍵資料是什麼」。&lt;/p>
&lt;h2 id="http-handler-要把錯誤轉成協定語意">HTTP handler 要把錯誤轉成協定語意&lt;/h2>
&lt;p>HTTP handler 的錯誤處理核心是把內部錯誤轉成明確 status code。輸入格式錯誤通常是 &lt;code>400&lt;/code>，找不到資料是 &lt;code>404&lt;/code>，不支援的方法是 &lt;code>405&lt;/code>，未預期內部錯誤才是 &lt;code>500&lt;/code>。&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">handleCreateUser&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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="kd">var&lt;/span> &lt;span class="nx">req&lt;/span> &lt;span class="nx">createUserRequest&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">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">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&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"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid json&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&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>&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="nx">user&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">CreateUser&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">Email&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">if&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">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&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>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeJSON&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusCreated&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&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;/code>&lt;/pre>&lt;/div>&lt;p>handler 的錯誤對應應反映協定語意。錯誤來自呼叫端輸入時，回 &lt;code>400&lt;/code> 才能讓 client 知道應該修正 request；未預期內部錯誤才應進入 &lt;code>500&lt;/code>。&lt;/p>
&lt;h2 id="log-應該放在有處理責任的位置">log 應該放在有處理責任的位置&lt;/h2>
&lt;p>錯誤記錄的核心規則是誰負責處理錯誤，誰才記錄錯誤。底層函式通常回傳錯誤，上層邊界再決定要 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a>、重試、轉成 HTTP response 或讓程式結束。&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">run&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"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">config&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">LoadConfig&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;config.json&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="k">if&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"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nf">StartServer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">config&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">main&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">11&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="nf">run&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">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">log&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatal&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">13&lt;/span>&lt;span class="cl"> &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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>LoadConfig&lt;/code> 回傳錯誤比直接 &lt;code>log.Fatal&lt;/code> 更符合責任邊界，因為它不知道呼叫端是否想重試、使用預設值或結束程式。&lt;code>main&lt;/code> 是 process 邊界，才適合決定失敗時結束。&lt;/p>
&lt;h2 id="小結">小結&lt;/h2>
&lt;p>Go 錯誤處理的價值是讓失敗路徑可讀、可測、可追蹤。每個 &lt;code>if err != nil&lt;/code> 都是一個明確的決策點：是否補脈絡、是否轉成協定狀態、是否記錄、是否終止流程。這種顯式設計是 Go 長期維護性的核心之一。&lt;/p></description><content:encoded><![CDATA[<p>Go 錯誤處理的核心原則是把失敗路徑明確寫在程式流程中。<code>if err != nil</code> 看起來重複，但它讓每一步可能失敗的地方都可見，也讓讀者能直接知道失敗時程式會怎麼結束。對需要長時間運行、並發處理、背景工作或即時請求回應的服務來說，這種顯式失敗路徑比隱式例外更容易維護。</p>
<h2 id="為什麼這章在第零章">為什麼這章在第零章</h2>
<p>如果你的場景已經把 Go 推向服務型系統，錯誤處理就是維持服務穩定的核心能力。這一章要先建立的是：Go 會要求你把失敗說清楚，因為在高併發或長時間運行的情境下，模糊的失敗行為會比清楚的錯誤訊息更難排查。</p>
<h2 id="error-是普通回傳值">error 是普通回傳值</h2>
<p>Go 的 <code>error</code> 是一個普通介面值。函式若可能失敗，通常會把錯誤放在最後一個回傳值。</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">LoadConfig</span><span class="p">(</span><span class="nx">path</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Config</span><span class="p">,</span> <span class="kt">error</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">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">ReadFile</span><span class="p">(</span><span class="nx">path</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="nx">err</span> <span class="o">!=</span> <span class="kc">nil</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="nx">Config</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;read config %q: %w&#34;</span><span class="p">,</span> <span class="nx">path</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln"> 7</span><span class="cl"> <span class="kd">var</span> <span class="nx">config</span> <span class="nx">Config</span>
</span></span><span class="line"><span class="ln"> 8</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">data</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">config</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"> 9</span><span class="cl">     <span class="k">return</span> <span class="nx">Config</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;parse config %q: %w&#34;</span><span class="p">,</span> <span class="nx">path</span><span class="p">,</span> <span class="nx">err</span><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></span><span class="line"><span class="ln">12</span><span class="cl"> <span class="k">return</span> <span class="nx">config</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式有兩個失敗點：讀檔失敗與 JSON 解析失敗。每個失敗點都立刻處理並回傳，正常流程則留在函式底部。</p>
<h2 id="早期返回讓正常流程清楚">早期返回讓正常流程清楚</h2>
<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">CreateUser</span><span class="p">(</span><span class="nx">email</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">User</span><span class="p">,</span> <span class="kt">error</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">email</span> <span class="p">=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">email</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="nx">email</span> <span class="o">==</span> <span class="s">&#34;&#34;</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="nx">User</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;email is required&#34;</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></span><span class="line"><span class="ln"> 7</span><span class="cl"> <span class="k">if</span> <span class="p">!</span><span class="nx">strings</span><span class="p">.</span><span class="nf">Contains</span><span class="p">(</span><span class="nx">email</span><span class="p">,</span> <span class="s">&#34;@&#34;</span><span class="p">)</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">User</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;invalid email&#34;</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></span><span class="line"><span class="ln">11</span><span class="cl"> <span class="k">return</span> <span class="nx">User</span><span class="p">{</span><span class="nx">Email</span><span class="p">:</span> <span class="nx">email</span><span class="p">},</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個函式先處理空字串與格式錯誤，最後才建立使用者。讀者不需要進入深層巢狀條件，就能知道哪些資料不能通過。</p>
<h2 id="包裝錯誤要補上操作脈絡">包裝錯誤要補上操作脈絡</h2>
<p>錯誤包裝的核心責任是保留原始錯誤，同時補上當前操作脈絡。<code>fmt.Errorf</code> 搭配 <code>%w</code> 可以建立錯誤鏈。</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="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repository</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">user</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">2</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;save user %q: %w&#34;</span><span class="p">,</span> <span class="nx">user</span><span class="p">.</span><span class="nx">Email</span><span class="p">,</span> <span class="nx">err</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><code>save user &quot;alice@example.com&quot;</code> 是當前操作脈絡，原始錯誤則被 <code>%w</code> 保留下來。呼叫端可以印出完整錯誤，也可以用 <code>errors.Is</code> 或 <code>errors.As</code> 檢查特定錯誤。</p>
<p>錯誤包裝應提供新的操作脈絡。<code>fmt.Errorf(&quot;failed: %w&quot;, err)</code> 這類包裝沒有資訊量；好的錯誤訊息應該回答「做什麼失敗」與「關鍵資料是什麼」。</p>
<h2 id="http-handler-要把錯誤轉成協定語意">HTTP handler 要把錯誤轉成協定語意</h2>
<p>HTTP handler 的錯誤處理核心是把內部錯誤轉成明確 status code。輸入格式錯誤通常是 <code>400</code>，找不到資料是 <code>404</code>，不支援的方法是 <code>405</code>，未預期內部錯誤才是 <code>500</code>。</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">handleCreateUser</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"> <span class="kd">var</span> <span class="nx">req</span> <span class="nx">createUserRequest</span>
</span></span><span class="line"><span class="ln"> 3</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">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</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"> 4</span><span class="cl">  <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;invalid json&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="k">return</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="nx">user</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">CreateUser</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">Email</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">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">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">err</span><span class="p">.</span><span class="nf">Error</span><span class="p">(),</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</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></span><span class="line"><span class="ln">14</span><span class="cl"> <span class="nf">writeJSON</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</span><span class="p">,</span> <span class="nx">user</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>handler 的錯誤對應應反映協定語意。錯誤來自呼叫端輸入時，回 <code>400</code> 才能讓 client 知道應該修正 request；未預期內部錯誤才應進入 <code>500</code>。</p>
<h2 id="log-應該放在有處理責任的位置">log 應該放在有處理責任的位置</h2>
<p>錯誤記錄的核心規則是誰負責處理錯誤，誰才記錄錯誤。底層函式通常回傳錯誤，上層邊界再決定要 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>、重試、轉成 HTTP response 或讓程式結束。</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">run</span><span class="p">()</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"> <span class="nx">config</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">LoadConfig</span><span class="p">(</span><span class="s">&#34;config.json&#34;</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="nx">err</span> <span class="o">!=</span> <span class="kc">nil</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="nx">err</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">return</span> <span class="nf">StartServer</span><span class="p">(</span><span class="nx">config</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">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"> <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">run</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">12</span><span class="cl">  <span class="nx">log</span><span class="p">.</span><span class="nf">Fatal</span><span class="p">(</span><span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"> <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>LoadConfig</code> 回傳錯誤比直接 <code>log.Fatal</code> 更符合責任邊界，因為它不知道呼叫端是否想重試、使用預設值或結束程式。<code>main</code> 是 process 邊界，才適合決定失敗時結束。</p>
<h2 id="小結">小結</h2>
<p>Go 錯誤處理的價值是讓失敗路徑可讀、可測、可追蹤。每個 <code>if err != nil</code> 都是一個明確的決策點：是否補脈絡、是否轉成協定狀態、是否記錄、是否終止流程。這種顯式設計是 Go 長期維護性的核心之一。</p>
]]></content:encoded></item><item><title>1.3 非阻塞送出與事件丟棄策略</title><link>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/non-blocking-send/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/non-blocking-send/</guid><description>&lt;p>非阻塞送出的核心取捨是用明確降級換取呼叫端可用性。當 channel 滿載時，程式可以等待、回錯、丟棄、覆蓋或轉交可靠儲存；選擇哪一個是服務語意，不是 &lt;code>select&lt;/code> 語法偏好。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 blocking send 與 non-blocking send 的服務語意&lt;/li>
&lt;li>為 HTTP、worker、即時推送設計不同滿載策略&lt;/li>
&lt;li>判斷哪些事件可以丟、哪些不能丟&lt;/li>
&lt;li>為 drop 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> full 建立 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a>/metric&lt;/li>
&lt;li>測試 channel 滿載時的行為&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察channel-滿載是容量訊號">【觀察】channel 滿載是容量訊號&lt;/h2>
&lt;p>Channel 滿載的核心意義是下游處理速度跟不上上游輸入速度。這可能是短暫尖峰，也可能是系統長期容量不足。&lt;/p>
&lt;p>最直接的 send 會接受 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> ：&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="nx">events&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">event&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果 &lt;code>events&lt;/code> 沒有 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a>，或 buffer 已滿，sender 會等待 receiver。這能保留資料，但也可能讓 HTTP handler、connection writer 或其他 goroutine 卡住。&lt;/p>
&lt;p>對批次 worker 來說，等待可能合理；對使用者 request 來說，無限等待通常會變成 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a> 或 goroutine 堆積。&lt;/p>
&lt;h2 id="判讀blocking-send-表示願意等待">【判讀】blocking send 表示願意等待&lt;/h2>
&lt;p>Blocking send 的核心語意是 sender 接受下游 backpressure 。資料不會被丟掉，但 sender 的生命週期會被 receiver 影響。&lt;/p>
&lt;p>有 context 的 blocking send：&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">Enqueue&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">events&lt;/span> &lt;span class="kd">chan&lt;/span>&lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span> &lt;span class="nx">Event&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">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">events&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">event&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">nil&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">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Err&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="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>這種寫法仍然願意等待，但不會無限等待。若 request 被取消或 timeout，send 也會停止。&lt;/p>
&lt;p>Blocking send 適合資料不能丟、上游能等待、且等待時間受 context 控制的情境。若沒有 context，blocking send 在服務入口通常風險較高。&lt;/p>
&lt;h2 id="判讀non-blocking-send-表示立即選擇替代路徑">【判讀】non-blocking send 表示立即選擇替代路徑&lt;/h2>
&lt;p>Non-blocking send 的核心語意是「能送就送，不能送就立刻走其他策略」。Go 常用 &lt;code>select&lt;/code> 加 &lt;code>default&lt;/code> 表達。&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">var&lt;/span> &lt;span class="nx">ErrQueueFull&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">errors&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">New&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;event queue is full&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">TryEnqueue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">events&lt;/span> &lt;span class="kd">chan&lt;/span>&lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span> &lt;span class="nx">Event&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"> 4&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"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">events&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">event&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">nil&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">default&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">ErrQueueFull&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式不會等待 receiver。當 buffer 滿載時，呼叫端會立刻拿到 &lt;code>ErrQueueFull&lt;/code>，並可以決定回 HTTP 錯誤、記錄 drop、或改走其他儲存。&lt;/p>
&lt;p>Non-blocking send 不是比較進階的寫法。它只是把 backpressure 從「等待」改成「立即決策」。&lt;/p>
&lt;h2 id="策略先定義事件的保留等級">【策略】先定義事件的保留等級&lt;/h2>
&lt;p>滿載策略的核心判斷是資料語意。每種事件都應先定義保留等級：必須保存、可降級、可覆蓋、可取樣，或可延後處理。這個等級決定 channel 滿載時要等待、回錯、丟棄、覆蓋或轉交可靠儲存。&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>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/audit-log/" data-link-title="Audit Log" data-link-desc="說明高風險操作如何留下可追溯、可稽核的紀錄">audit log&lt;/a>&lt;/td>
 &lt;td>不應直接丟，應寫可靠儲存或回錯&lt;/td>
 &lt;td>資料遺失會破壞稽核&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>UI 即時提示&lt;/td>
 &lt;td>可丟棄或覆蓋&lt;/td>
 &lt;td>使用者可重新查詢狀態&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>狀態轉移事件&lt;/td>
 &lt;td>通常不應丟&lt;/td>
 &lt;td>會造成 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth&lt;/a> 不一致&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics&lt;/a> sample&lt;/td>
 &lt;td>可取樣或丟棄&lt;/td>
 &lt;td>趨勢比單筆資料重要&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>background refresh&lt;/td>
 &lt;td>可跳過本輪&lt;/td>
 &lt;td>下次仍可重新計算&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這個表格的重點是要求每種事件都要有明確策略。若團隊只說「channel 滿了就 default」，通常代表資料語意還沒有想清楚。&lt;/p>
&lt;h2 id="執行http-入口要把滿載轉成狀態碼">【執行】HTTP 入口要把滿載轉成狀態碼&lt;/h2>
&lt;p>HTTP 入口的核心責任是把內部滿載轉成呼叫端能理解的結果。不要讓 request 一直等到 timeout，也不要把未接受的事件回成成功。&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">EventHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">events&lt;/span> &lt;span class="kd">chan&lt;/span>&lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">HandlerFunc&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">return&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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">event&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Header&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;X-Request-ID&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&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="nf">TryEnqueue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">events&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&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"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">errors&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Is&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">err&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ErrQueueFull&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Header&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Retry-After&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;5&amp;#34;&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="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;event queue is full&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusServiceUnavailable&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>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &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">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;event enqueue failed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusInternalServerError&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>&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;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusAccepted&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>202 Accepted&lt;/code> 表示事件已被服務接受進入後續處理。&lt;code>503 Service Unavailable&lt;/code> 表示服務目前無法接受更多事件，呼叫端可以稍後重試。&lt;/p></description><content:encoded><![CDATA[<p>非阻塞送出的核心取捨是用明確降級換取呼叫端可用性。當 channel 滿載時，程式可以等待、回錯、丟棄、覆蓋或轉交可靠儲存；選擇哪一個是服務語意，不是 <code>select</code> 語法偏好。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 blocking send 與 non-blocking send 的服務語意</li>
<li>為 HTTP、worker、即時推送設計不同滿載策略</li>
<li>判斷哪些事件可以丟、哪些不能丟</li>
<li>為 drop 與 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> full 建立 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>/metric</li>
<li>測試 channel 滿載時的行為</li>
</ol>
<hr>
<h2 id="觀察channel-滿載是容量訊號">【觀察】channel 滿載是容量訊號</h2>
<p>Channel 滿載的核心意義是下游處理速度跟不上上游輸入速度。這可能是短暫尖峰，也可能是系統長期容量不足。</p>
<p>最直接的 send 會接受 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> ：</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="nx">events</span> <span class="o">&lt;-</span> <span class="nx">event</span></span></span></code></pre></div><p>如果 <code>events</code> 沒有 <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a>，或 buffer 已滿，sender 會等待 receiver。這能保留資料，但也可能讓 HTTP handler、connection writer 或其他 goroutine 卡住。</p>
<p>對批次 worker 來說，等待可能合理；對使用者 request 來說，無限等待通常會變成 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 或 goroutine 堆積。</p>
<h2 id="判讀blocking-send-表示願意等待">【判讀】blocking send 表示願意等待</h2>
<p>Blocking send 的核心語意是 sender 接受下游 backpressure 。資料不會被丟掉，但 sender 的生命週期會被 receiver 影響。</p>
<p>有 context 的 blocking send：</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">Enqueue</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">events</span> <span class="kd">chan</span><span class="o">&lt;-</span> <span class="nx">Event</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">Event</span><span class="p">)</span> <span class="kt">error</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">events</span> <span class="o">&lt;-</span> <span class="nx">event</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">nil</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</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="nx">ctx</span><span class="p">.</span><span class="nf">Err</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>這種寫法仍然願意等待，但不會無限等待。若 request 被取消或 timeout，send 也會停止。</p>
<p>Blocking send 適合資料不能丟、上游能等待、且等待時間受 context 控制的情境。若沒有 context，blocking send 在服務入口通常風險較高。</p>
<h2 id="判讀non-blocking-send-表示立即選擇替代路徑">【判讀】non-blocking send 表示立即選擇替代路徑</h2>
<p>Non-blocking send 的核心語意是「能送就送，不能送就立刻走其他策略」。Go 常用 <code>select</code> 加 <code>default</code> 表達。</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">var</span> <span class="nx">ErrQueueFull</span> <span class="p">=</span> <span class="nx">errors</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="s">&#34;event queue is full&#34;</span><span class="p">)</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">func</span> <span class="nf">TryEnqueue</span><span class="p">(</span><span class="nx">events</span> <span class="kd">chan</span><span class="o">&lt;-</span> <span class="nx">Event</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">Event</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">case</span> <span class="nx">events</span> <span class="o">&lt;-</span> <span class="nx">event</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">nil</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">default</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">ErrQueueFull</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></code></pre></div><p>這段程式不會等待 receiver。當 buffer 滿載時，呼叫端會立刻拿到 <code>ErrQueueFull</code>，並可以決定回 HTTP 錯誤、記錄 drop、或改走其他儲存。</p>
<p>Non-blocking send 不是比較進階的寫法。它只是把 backpressure 從「等待」改成「立即決策」。</p>
<h2 id="策略先定義事件的保留等級">【策略】先定義事件的保留等級</h2>
<p>滿載策略的核心判斷是資料語意。每種事件都應先定義保留等級：必須保存、可降級、可覆蓋、可取樣，或可延後處理。這個等級決定 channel 滿載時要等待、回錯、丟棄、覆蓋或轉交可靠儲存。</p>
<table>
  <thead>
      <tr>
          <th>事件類型</th>
          <th>建議策略</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/audit-log/" data-link-title="Audit Log" data-link-desc="說明高風險操作如何留下可追溯、可稽核的紀錄">audit log</a></td>
          <td>不應直接丟，應寫可靠儲存或回錯</td>
          <td>資料遺失會破壞稽核</td>
      </tr>
      <tr>
          <td>UI 即時提示</td>
          <td>可丟棄或覆蓋</td>
          <td>使用者可重新查詢狀態</td>
      </tr>
      <tr>
          <td>狀態轉移事件</td>
          <td>通常不應丟</td>
          <td>會造成 <a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth</a> 不一致</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a> sample</td>
          <td>可取樣或丟棄</td>
          <td>趨勢比單筆資料重要</td>
      </tr>
      <tr>
          <td>background refresh</td>
          <td>可跳過本輪</td>
          <td>下次仍可重新計算</td>
      </tr>
  </tbody>
</table>
<p>這個表格的重點是要求每種事件都要有明確策略。若團隊只說「channel 滿了就 default」，通常代表資料語意還沒有想清楚。</p>
<h2 id="執行http-入口要把滿載轉成狀態碼">【執行】HTTP 入口要把滿載轉成狀態碼</h2>
<p>HTTP 入口的核心責任是把內部滿載轉成呼叫端能理解的結果。不要讓 request 一直等到 timeout，也不要把未接受的事件回成成功。</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">EventHandler</span><span class="p">(</span><span class="nx">events</span> <span class="kd">chan</span><span class="o">&lt;-</span> <span class="nx">Event</span><span class="p">)</span> <span class="nx">http</span><span class="p">.</span><span class="nx">HandlerFunc</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">return</span> <span class="kd">func</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</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">event</span> <span class="o">:=</span> <span class="nx">Event</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Header</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;X-Request-ID&#34;</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">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">TryEnqueue</span><span class="p">(</span><span class="nx">events</span><span class="p">,</span> <span class="nx">event</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"> 6</span><span class="cl">            <span class="k">if</span> <span class="nx">errors</span><span class="p">.</span><span class="nf">Is</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">ErrQueueFull</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">w</span><span class="p">.</span><span class="nf">Header</span><span class="p">().</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Retry-After&#34;</span><span class="p">,</span> <span class="s">&#34;5&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;event queue is full&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusServiceUnavailable</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">                <span class="k">return</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="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;event enqueue failed&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusInternalServerError</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusAccepted</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>202 Accepted</code> 表示事件已被服務接受進入後續處理。<code>503 Service Unavailable</code> 表示服務目前無法接受更多事件，呼叫端可以稍後重試。</p>
<p>若事件不能丟，HTTP handler 應該回錯或寫入可靠儲存，不應假裝成功。</p>
<h2 id="執行即時推送可以選擇-drop-或-disconnect">【執行】即時推送可以選擇 drop 或 disconnect</h2>
<p>即時推送的核心問題是慢 client 不能拖住整個服務。若某個連線的 send buffer 滿了，服務要決定丟掉該訊息、丟掉舊訊息，還是關閉連線。</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">Message</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">TrySend</span><span class="p">(</span><span class="nx">message</span> <span class="nx">Message</span><span class="p">)</span> <span class="kt">bool</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">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"> 8</span><span class="cl">        <span class="k">return</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">default</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="kc">false</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>呼叫端可以根據 <code>false</code> 決定策略：</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="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">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;client_send_dropped&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">logger</span><span class="p">.</span><span class="nf">Warn</span><span class="p">(</span><span class="s">&#34;drop client message&#34;</span><span class="p">,</span> <span class="s">&#34;reason&#34;</span><span class="p">,</span> <span class="s">&#34;send_buffer_full&#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></code></pre></div><p>對狀態型 UI 來說，丟掉中間更新可能可以接受，因為下一次 snapshot 會補上最新狀態。對逐筆不可遺失訊息來說，應改用可靠佇列或明確斷線重連協定。</p>
<h2 id="策略buffer-只能吸收短暫尖峰">【策略】buffer 只能吸收短暫尖峰</h2>
<p>Buffer 的核心作用是平滑短時間流量差，不是解決長期處理能力不足。把 channel buffer 調大，只是延後滿載時間，也可能增加記憶體與延遲。</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="nx">events</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">,</span> <span class="mi">1024</span><span class="p">)</span></span></span></code></pre></div><p>設計 buffer 時至少要考慮：</p>
<ul>
<li>單筆事件大小</li>
<li><a href="/blog/backend/knowledge-cards/producer/" data-link-title="Producer" data-link-desc="說明 producer 如何把工作、事件或資料送入後續處理路徑">producer</a> 峰值速度</li>
<li><a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 穩定處理速度</li>
<li>允許排隊延遲</li>
<li>滿載時的回應策略</li>
</ul>
<p>若 producer 每秒 1000 筆、consumer 每秒 100 筆，任何有限 buffer 都會滿。這時要改善 consumer 能力、增加 worker、做取樣、回錯或使用可靠 queue，而不是只調大數字。</p>
<h2 id="策略丟棄一定要可觀測">【策略】丟棄一定要可觀測</h2>
<p>Drop strategy 的核心要求是可觀測。只要系統選擇丟棄或降級，就應該留下 metric 或 structured log，否則資料遺失會變成隱性 bug。</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">TryEnqueueWithMetrics</span><span class="p">(</span><span class="nx">events</span> <span class="kd">chan</span><span class="o">&lt;-</span> <span class="nx">Event</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">Event</span><span class="p">,</span> <span class="nx">logger</span> <span class="o">*</span><span class="nx">slog</span><span class="p">.</span><span class="nx">Logger</span><span class="p">)</span> <span class="kt">error</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">events</span> <span class="o">&lt;-</span> <span class="nx">event</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 4</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;event_enqueue_success&#34;</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="kc">nil</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</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;event_enqueue_dropped&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">logger</span><span class="p">.</span><span class="nf">Warn</span><span class="p">(</span><span class="s">&#34;drop event&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="s">&#34;reason&#34;</span><span class="p">,</span> <span class="s">&#34;queue_full&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="s">&#34;event_type&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="s">&#34;subject_id&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</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">return</span> <span class="nx">ErrQueueFull</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="p">}</span></span></span></code></pre></div><p>Log 適合保留單次事件脈絡，metric 適合觀察趨勢。若 drop rate 升高，代表服務正在降級；這應該能被監控看見。</p>
<h2 id="測試滿載行為要直接測">【測試】滿載行為要直接測</h2>
<p>Non-blocking send 的測試核心是先讓 channel 滿載，再確認函式立刻回錯。不要用 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">TestTryEnqueueReturnsQueueFull</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">events</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">events</span> <span class="o">&lt;-</span> <span class="nx">Event</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;already_full&#34;</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="nf">TryEnqueue</span><span class="p">(</span><span class="nx">events</span><span class="p">,</span> <span class="nx">Event</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;next&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">errors</span><span class="p">.</span><span class="nf">Is</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">ErrQueueFull</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">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;error = %v, want ErrQueueFull&#34;</span><span class="p">,</span> <span class="nx">err</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="p">}</span></span></span></code></pre></div><p>Blocking send with context 也可以測：</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">TestEnqueueStopsWhenContextCanceled</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">events</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithCancel</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nf">cancel</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">err</span> <span class="o">:=</span> <span class="nf">Enqueue</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">events</span><span class="p">,</span> <span class="nx">Event</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;evt_1&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">errors</span><span class="p">.</span><span class="nf">Is</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Canceled</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</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;error = %v, want context canceled&#34;</span><span class="p">,</span> <span class="nx">err</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="p">}</span></span></span></code></pre></div><p>這些測試把滿載和取消變成可重現條件，不需要依賴時間推測。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一 process 內的滿載處理策略；當訊息需要持久化、重試或跨 process 傳遞時，會在下列章節再往外延伸：</p>
<ul>
<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/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 channel backpressure 、worker capacity 與事件丟棄策略；如果你要先回看語言教材，可以讀：</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/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件</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/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">Go：多來源 event 融合</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>非阻塞送出是服務策略，不是語法技巧。Channel 滿載時，系統必須明確選擇等待、回錯、丟棄、覆蓋或轉交可靠儲存。選擇之前先定義事件的保留等級，選擇之後補上 log、metric 與測試，才能讓 backpressure 成為可管理的服務行為。</p>
]]></content:encoded></item><item><title>1.3 控制流程：if、for、switch</title><link>https://tarrragon.github.io/blog/go/01-basics/control-flow/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/01-basics/control-flow/</guid><description>&lt;p>Go 控制流程的核心規則是：語法少但語意明確；&lt;code>if&lt;/code> 處理條件分支，&lt;code>for&lt;/code> 是唯一迴圈語法，&lt;code>switch&lt;/code> 用於多分支判斷。本章將建立閱讀 Go 流程控制的基本模型。&lt;/p>
&lt;h2 id="if-表達條件與提前返回">&lt;code>if&lt;/code> 表達條件與提前返回&lt;/h2>
&lt;p>&lt;code>if&lt;/code> 的核心責任是根據條件決定程式是否進入某段邏輯。Go 的 &lt;code>if&lt;/code> 條件不需要小括號，但區塊大括號是必要語法。&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="k">if&lt;/span> &lt;span class="nx">age&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="mi">18&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;adult&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Go 不會把數字、字串或指標自動當成布林值。條件必須是明確的 &lt;code>bool&lt;/code> 表達式。&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="nx">count&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">3&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="k">if&lt;/span> &lt;span class="nx">count&lt;/span> &lt;span class="p">&amp;gt;&lt;/span> &lt;span class="mi">0&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;has items&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1">// if count {&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="c1">// fmt.Println(&amp;#34;invalid&amp;#34;)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="c1">// }&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個規則讓條件判斷更直接：讀者不需要猜某個非布林值在條件中會被如何轉換。&lt;/p>
&lt;h2 id="if-可以包含短宣告">&lt;code>if&lt;/code> 可以包含短宣告&lt;/h2>
&lt;p>&lt;code>if&lt;/code> 的短宣告用來把只屬於這個判斷的暫存變數限制在區塊內。這讓錯誤處理與查找結果的作用範圍更清楚。&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="k">if&lt;/span> &lt;span class="nx">value&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">cache&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">&amp;#34;user:1&amp;#34;&lt;/span>&lt;span class="p">];&lt;/span> &lt;span class="nx">ok&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;cache hit:&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">value&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>value&lt;/code> 與 &lt;code>ok&lt;/code> 只存在於 &lt;code>if&lt;/code> 與對應的 &lt;code>else&lt;/code> 區塊內。這種寫法適合處理 map 查找、型別轉換、函式呼叫錯誤等短生命週期資料。&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="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">saveProfile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">profile&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">2&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&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;/code>&lt;/pre>&lt;/div>&lt;p>這裡的 &lt;code>err&lt;/code> 只用來判斷 &lt;code>saveProfile&lt;/code> 是否失敗，離開 &lt;code>if&lt;/code> 後就不再需要。短宣告可以降低變數留在外層範圍造成的干擾。&lt;/p>
&lt;h2 id="提前返回讓主流程靠左">提前返回讓主流程靠左&lt;/h2>
&lt;p>Go 常用提前返回處理失敗或特殊情況。核心原則是先處理不能繼續的狀態，讓正常流程留在較少縮排的位置。&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">normalizeEmail&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&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 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">input&lt;/span> &lt;span class="p">=&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">input&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">if&lt;/span> &lt;span class="nx">input&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"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&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;email is required&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Contains&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;@&amp;#34;&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"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&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;invalid email&amp;#34;&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&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="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ToLower&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="kc">nil&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>這段程式先排除空字串與格式錯誤，最後才回傳正常結果。讀者可以依序看到「不能接受什麼」以及「通過檢查後會得到什麼」。&lt;/p>
&lt;p>提前返回不是要求每個條件都拆開。當兩個條件代表同一個規則時，可以合併成一個判斷；當條件代表不同失敗原因時，拆開通常比較清楚。&lt;/p>
&lt;h2 id="for-是唯一迴圈語法">&lt;code>for&lt;/code> 是唯一迴圈語法&lt;/h2>
&lt;p>Go 的迴圈只有 &lt;code>for&lt;/code>。它可以表達傳統計數迴圈、條件迴圈、無限迴圈與 range 迴圈。&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="k">for&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="p">&amp;lt;&lt;/span> &lt;span class="mi">3&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="o">++&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">i&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這是傳統的三段式迴圈：初始化、條件、迭代後處理。它適合需要 index、固定次數或精準控制遞增方式的場景。&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="nx">remaining&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">3&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">remaining&lt;/span> &lt;span class="p">&amp;gt;&lt;/span> &lt;span class="mi">0&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">remaining&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">remaining&lt;/span>&lt;span class="o">--&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>for&lt;/code> 就是其他語言常見的 while 迴圈。Go 不另外提供 &lt;code>while&lt;/code>，因為 &lt;code>for 條件&lt;/code> 已經能表達同一件事。&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="k">for&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;polling&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="k">break&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;/code>&lt;/pre>&lt;/div>&lt;p>沒有條件的 &lt;code>for&lt;/code> 是無限迴圈，通常會搭配 &lt;code>break&lt;/code>、&lt;code>return&lt;/code>、&lt;code>context&lt;/code> 或 channel 退出。無限迴圈要讓退出條件清楚可見，否則很容易讓讀者無法判斷生命週期。&lt;/p>
&lt;h2 id="range-用來走訪集合">&lt;code>range&lt;/code> 用來走訪集合&lt;/h2>
&lt;p>&lt;code>range&lt;/code> 的核心用途是逐一走訪陣列、slice、map、字串與 channel。它會依資料型別產生不同的索引或值。&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="nx">names&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s">&amp;#34;alice&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;bob&amp;#34;&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>&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">i&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">name&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">names&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">i&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">name&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;/code>&lt;/pre>&lt;/div>&lt;p>走訪 slice 時，第一個值是 index，第二個值是元素副本。若不需要 index，可以用 &lt;code>_&lt;/code> 忽略。&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="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">name&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">names&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">name&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>走訪 map 時，順序沒有保證。這是語言刻意設計的結果，避免程式誤以為 map 有穩定順序。&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="nx">scores&lt;/span> &lt;span class="o">:=&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="kt">int&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="s">&amp;#34;alice&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">90&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="s">&amp;#34;bob&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">80&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="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="k">for&lt;/span> &lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">score&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">scores&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">score&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;/code>&lt;/pre>&lt;/div>&lt;p>如果輸出順序重要，應該先取出 key、排序，再依序讀取 map。&lt;/p>
&lt;h2 id="break-與-continue-控制迴圈節奏">&lt;code>break&lt;/code> 與 &lt;code>continue&lt;/code> 控制迴圈節奏&lt;/h2>
&lt;p>&lt;code>break&lt;/code> 的核心作用是結束目前迴圈，&lt;code>continue&lt;/code> 的核心作用是跳過本次迭代並進入下一輪。它們應該用來表達清楚的流程轉折，而不是補救過度複雜的迴圈。&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="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">line&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">lines&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">if&lt;/span> &lt;span class="nx">line&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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">continue&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="k">if&lt;/span> &lt;span class="nx">line&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;STOP&amp;#34;&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">break&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">line&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式忽略空行，遇到 &lt;code>STOP&lt;/code> 停止，其他行則輸出。條件都放在處理邏輯前方，讀者可以先理解哪些資料不進入主流程。&lt;/p>
&lt;p>當迴圈內的 &lt;code>break&lt;/code>、&lt;code>continue&lt;/code>、巢狀條件太多時，通常代表應該把部分邏輯抽成函式，讓每個函式只負責一層判斷。&lt;/p>
&lt;h2 id="switch-表達多分支判斷">&lt;code>switch&lt;/code> 表達多分支判斷&lt;/h2>
&lt;p>&lt;code>switch&lt;/code> 的核心責任是把同一個概念的多種可能集中呈現。Go 的 &lt;code>switch&lt;/code> 預設不會自動落入下一個 &lt;code>case&lt;/code>，所以大多數情況不需要寫 &lt;code>break&lt;/code>。&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="k">switch&lt;/span> &lt;span class="nx">method&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">case&lt;/span> &lt;span class="s">&amp;#34;GET&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;read&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="k">case&lt;/span> &lt;span class="s">&amp;#34;POST&amp;#34;&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;create&amp;#34;&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">case&lt;/span> &lt;span class="s">&amp;#34;DELETE&amp;#34;&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;delete&amp;#34;&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">default&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;unsupported&amp;#34;&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每個 &lt;code>case&lt;/code> 預設只執行自己的區塊。若真的需要落入下一個 case，Go 提供 &lt;code>fallthrough&lt;/code>，但日常程式很少需要它。&lt;/p></description><content:encoded><![CDATA[<p>Go 控制流程的核心規則是：語法少但語意明確；<code>if</code> 處理條件分支，<code>for</code> 是唯一迴圈語法，<code>switch</code> 用於多分支判斷。本章將建立閱讀 Go 流程控制的基本模型。</p>
<h2 id="if-表達條件與提前返回"><code>if</code> 表達條件與提前返回</h2>
<p><code>if</code> 的核心責任是根據條件決定程式是否進入某段邏輯。Go 的 <code>if</code> 條件不需要小括號，但區塊大括號是必要語法。</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="k">if</span> <span class="nx">age</span> <span class="o">&gt;=</span> <span class="mi">18</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;adult&#34;</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>Go 不會把數字、字串或指標自動當成布林值。條件必須是明確的 <code>bool</code> 表達式。</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="nx">count</span> <span class="o">:=</span> <span class="mi">3</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="k">if</span> <span class="nx">count</span> <span class="p">&gt;</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;has items&#34;</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></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1">// if count {</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1">//     fmt.Println(&#34;invalid&#34;)</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="c1">// }</span></span></span></code></pre></div><p>這個規則讓條件判斷更直接：讀者不需要猜某個非布林值在條件中會被如何轉換。</p>
<h2 id="if-可以包含短宣告"><code>if</code> 可以包含短宣告</h2>
<p><code>if</code> 的短宣告用來把只屬於這個判斷的暫存變數限制在區塊內。這讓錯誤處理與查找結果的作用範圍更清楚。</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="k">if</span> <span class="nx">value</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">cache</span><span class="p">[</span><span class="s">&#34;user:1&#34;</span><span class="p">];</span> <span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;cache hit:&#34;</span><span class="p">,</span> <span class="nx">value</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><code>value</code> 與 <code>ok</code> 只存在於 <code>if</code> 與對應的 <code>else</code> 區塊內。這種寫法適合處理 map 查找、型別轉換、函式呼叫錯誤等短生命週期資料。</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="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">saveProfile</span><span class="p">(</span><span class="nx">profile</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">2</span><span class="cl">    <span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這裡的 <code>err</code> 只用來判斷 <code>saveProfile</code> 是否失敗，離開 <code>if</code> 後就不再需要。短宣告可以降低變數留在外層範圍造成的干擾。</p>
<h2 id="提前返回讓主流程靠左">提前返回讓主流程靠左</h2>
<p>Go 常用提前返回處理失敗或特殊情況。核心原則是先處理不能繼續的狀態，讓正常流程留在較少縮排的位置。</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">normalizeEmail</span><span class="p">(</span><span class="nx">input</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="kt">string</span><span class="p">,</span> <span class="kt">error</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">input</span> <span class="p">=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">input</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="nx">input</span> <span class="o">==</span> <span class="s">&#34;&#34;</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="s">&#34;&#34;</span><span class="p">,</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;email is required&#34;</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></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">strings</span><span class="p">.</span><span class="nf">Contains</span><span class="p">(</span><span class="nx">input</span><span class="p">,</span> <span class="s">&#34;@&#34;</span><span class="p">)</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="s">&#34;&#34;</span><span class="p">,</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;invalid email&#34;</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">return</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">ToLower</span><span class="p">(</span><span class="nx">input</span><span class="p">),</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式先排除空字串與格式錯誤，最後才回傳正常結果。讀者可以依序看到「不能接受什麼」以及「通過檢查後會得到什麼」。</p>
<p>提前返回不是要求每個條件都拆開。當兩個條件代表同一個規則時，可以合併成一個判斷；當條件代表不同失敗原因時，拆開通常比較清楚。</p>
<h2 id="for-是唯一迴圈語法"><code>for</code> 是唯一迴圈語法</h2>
<p>Go 的迴圈只有 <code>for</code>。它可以表達傳統計數迴圈、條件迴圈、無限迴圈與 range 迴圈。</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="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="mi">3</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">i</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>這是傳統的三段式迴圈：初始化、條件、迭代後處理。它適合需要 index、固定次數或精準控制遞增方式的場景。</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="nx">remaining</span> <span class="o">:=</span> <span class="mi">3</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">for</span> <span class="nx">remaining</span> <span class="p">&gt;</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">remaining</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">remaining</span><span class="o">--</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>for</code> 就是其他語言常見的 while 迴圈。Go 不另外提供 <code>while</code>，因為 <code>for 條件</code> 已經能表達同一件事。</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="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;polling&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">break</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>沒有條件的 <code>for</code> 是無限迴圈，通常會搭配 <code>break</code>、<code>return</code>、<code>context</code> 或 channel 退出。無限迴圈要讓退出條件清楚可見，否則很容易讓讀者無法判斷生命週期。</p>
<h2 id="range-用來走訪集合"><code>range</code> 用來走訪集合</h2>
<p><code>range</code> 的核心用途是逐一走訪陣列、slice、map、字串與 channel。它會依資料型別產生不同的索引或值。</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="nx">names</span> <span class="o">:=</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;alice&#34;</span><span class="p">,</span> <span class="s">&#34;bob&#34;</span><span class="p">}</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="k">for</span> <span class="nx">i</span><span class="p">,</span> <span class="nx">name</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">names</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">i</span><span class="p">,</span> <span class="nx">name</span><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>走訪 slice 時，第一個值是 index，第二個值是元素副本。若不需要 index，可以用 <code>_</code> 忽略。</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="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">name</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">names</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">name</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>走訪 map 時，順序沒有保證。這是語言刻意設計的結果，避免程式誤以為 map 有穩定順序。</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="nx">scores</span> <span class="o">:=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">int</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s">&#34;alice&#34;</span><span class="p">:</span> <span class="mi">90</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s">&#34;bob&#34;</span><span class="p">:</span>   <span class="mi">80</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></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">for</span> <span class="nx">name</span><span class="p">,</span> <span class="nx">score</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">scores</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">name</span><span class="p">,</span> <span class="nx">score</span><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>如果輸出順序重要，應該先取出 key、排序，再依序讀取 map。</p>
<h2 id="break-與-continue-控制迴圈節奏"><code>break</code> 與 <code>continue</code> 控制迴圈節奏</h2>
<p><code>break</code> 的核心作用是結束目前迴圈，<code>continue</code> 的核心作用是跳過本次迭代並進入下一輪。它們應該用來表達清楚的流程轉折，而不是補救過度複雜的迴圈。</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="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">line</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">lines</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">line</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">continue</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="k">if</span> <span class="nx">line</span> <span class="o">==</span> <span class="s">&#34;STOP&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">break</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="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">line</span><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>這段程式忽略空行，遇到 <code>STOP</code> 停止，其他行則輸出。條件都放在處理邏輯前方，讀者可以先理解哪些資料不進入主流程。</p>
<p>當迴圈內的 <code>break</code>、<code>continue</code>、巢狀條件太多時，通常代表應該把部分邏輯抽成函式，讓每個函式只負責一層判斷。</p>
<h2 id="switch-表達多分支判斷"><code>switch</code> 表達多分支判斷</h2>
<p><code>switch</code> 的核心責任是把同一個概念的多種可能集中呈現。Go 的 <code>switch</code> 預設不會自動落入下一個 <code>case</code>，所以大多數情況不需要寫 <code>break</code>。</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="k">switch</span> <span class="nx">method</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">case</span> <span class="s">&#34;GET&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;read&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="k">case</span> <span class="s">&#34;POST&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;create&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="k">case</span> <span class="s">&#34;DELETE&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;delete&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;unsupported&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>每個 <code>case</code> 預設只執行自己的區塊。若真的需要落入下一個 case，Go 提供 <code>fallthrough</code>，但日常程式很少需要它。</p>
<p><code>switch</code> 也可以不帶目標值，用來取代一長串 <code>if else</code>。</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="k">switch</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">case</span> <span class="nx">score</span> <span class="o">&gt;=</span> <span class="mi">90</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;A&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="k">case</span> <span class="nx">score</span> <span class="o">&gt;=</span> <span class="mi">80</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;B&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="k">case</span> <span class="nx">score</span> <span class="o">&gt;=</span> <span class="mi">70</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;C&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;D&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種寫法適合條件都在描述同一個分類規則時使用。若每個條件都在處理不同概念，拆成多個 <code>if</code> 或不同函式通常更清楚。</p>
<h2 id="下一章">下一章</h2>
<p>下一章會回到 package 與檔案組織，說明 Go 如何用 package 建立程式邊界。</p>
]]></content:encoded></item><item><title>2.3 interface：用行為定義依賴</title><link>https://tarrragon.github.io/blog/go/02-types-data/interfaces/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/02-types-data/interfaces/</guid><description>&lt;p>Go 的 interface 描述的是行為，不是繼承關係。你不需要在 concrete type 上宣告「我實作了某個 interface」；只要方法集合符合，Go 就視為實作。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解 implicit interface 的設計精神&lt;/li>
&lt;li>寫出小而精準的 interface&lt;/li>
&lt;li>避免把 concrete type 暴露給不需要的呼叫者&lt;/li>
&lt;li>用 interface 改善測試與依賴邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察interface-只描述需要的行為">【觀察】interface 只描述需要的行為&lt;/h2>
&lt;p>interface 的核心規則是：只描述呼叫者需要的行為，不描述實作者的完整身份。假設有一個函式要把訊息寫到某個目的地；它不需要知道目的地是檔案、記憶體 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a>，還是網路連線，只需要知道對方能 &lt;code>Write&lt;/code>。&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">Writer&lt;/span> &lt;span class="kd">interface&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="nf">Write&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">p&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">byte&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">n&lt;/span> &lt;span class="kt">int&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&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">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="nf">WriteMessage&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">Writer&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">message&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">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Write&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="nb">byte&lt;/span>&lt;span class="p">(&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">7&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&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>這個 interface 很小，只描述一個行為：寫入 bytes。&lt;/p>
&lt;h2 id="判讀go-interface-是由使用者定義的需求">【判讀】Go interface 是由使用者定義的需求&lt;/h2>
&lt;p>在 Go 裡，interface 常由「使用者」定義，而不是由「實作者」定義。&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="c1">// Go 不需要這種宣告&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">File&lt;/span> &lt;span class="nx">implements&lt;/span> &lt;span class="nx">Writer&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>implicit interface 的核心規則是：只要型別有相同方法，就符合 interface，不需要顯式宣告實作關係。&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">MemoryWriter&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">data&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">byte&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">m&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">MemoryWriter&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Write&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">p&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">byte&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">m&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">m&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">p&lt;/span>&lt;span class="o">...&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">return&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">p&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="kc">nil&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>&lt;code>MemoryWriter&lt;/code> 沒有提到 &lt;code>Writer&lt;/code>，但它已經符合 &lt;code>Writer&lt;/code>。&lt;/p>
&lt;h2 id="策略interface-越小依賴越清楚">【策略】interface 越小，依賴越清楚&lt;/h2>
&lt;p>小 interface 的核心規則是：interface 應由使用端需要的最小行為組成。Go 常見的好 interface 很小：&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">Reader&lt;/span> &lt;span class="kd">interface&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="nf">Read&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">p&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">byte&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">n&lt;/span> &lt;span class="kt">int&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&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"> 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">Writer&lt;/span> &lt;span class="kd">interface&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="nf">Write&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">p&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">byte&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">n&lt;/span> &lt;span class="kt">int&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&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"> 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">Closer&lt;/span> &lt;span class="kd">interface&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="nf">Close&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kt">error&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>小 interface 的好處是：&lt;/p>
&lt;ul>
&lt;li>呼叫者只依賴自己真正需要的行為&lt;/li>
&lt;li>測試替身容易寫&lt;/li>
&lt;li>concrete type 可以在不同情境中被重用&lt;/li>
&lt;li>未來改內部結構時，外部影響較小&lt;/li>
&lt;/ul>
&lt;p>反例是把太多方法塞進一個 interface：&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">UserService&lt;/span> &lt;span class="kd">interface&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="nf">CreateUser&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nf">UpdateUser&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nf">DeleteUser&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>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nf">FindUser&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">User&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="nf">ListUsers&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">([]&lt;/span>&lt;span class="nx">User&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">7&lt;/span>&lt;span class="cl"> &lt;span class="nf">ExportUsers&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">([]&lt;/span>&lt;span class="kt">byte&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">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>如果某個函式只需要查詢 user，卻依賴整個 &lt;code>UserService&lt;/code>，它就知道太多了。&lt;/p>
&lt;h2 id="執行為查詢需求設計小介面">【執行】為查詢需求設計小介面&lt;/h2>
&lt;p>依賴邊界的核心規則是：使用端只依賴自己需要的方法。假設一個 HTTP handler 只需要查詢使用者名稱：&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">UserLookup&lt;/span> &lt;span class="kd">interface&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="nf">FindName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">userID&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&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">bool&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="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">Handler&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">users&lt;/span> &lt;span class="nx">UserLookup&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">func&lt;/span> &lt;span class="nf">NewHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">users&lt;/span> &lt;span class="nx">UserLookup&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Handler&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="o">&amp;amp;&lt;/span>&lt;span class="nx">Handler&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">users&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">users&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 handler 不知道 user 是存在 map、資料庫、檔案，還是測試假物件裡。它只知道自己需要 &lt;code>FindName&lt;/code>。&lt;/p>
&lt;p>測試時可以寫一個很小的 fake：&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">fakeUsers&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="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">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">f&lt;/span> &lt;span class="nx">fakeUsers&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">FindName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">userID&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&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">bool&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">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">f&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">userID&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">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&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;p>這就是 Go interface 最實用的地方：它讓依賴變小，讓測試變簡單。&lt;/p>
&lt;h2 id="何時先保留-concrete-type">何時先保留 concrete type&lt;/h2>
&lt;p>interface 的使用邊界是：替換需求或測試替身需求清楚時，再抽出小介面。以下情境通常適合先保留 concrete type：&lt;/p>
&lt;ul>
&lt;li>只有一個 concrete type，而且沒有測試替身需求&lt;/li>
&lt;li>interface 只是完整複製 concrete type 的所有方法&lt;/li>
&lt;li>你還不確定呼叫者真正需要哪些行為&lt;/li>
&lt;/ul>
&lt;p>Go 的常見做法是：先寫 concrete type，等使用端出現明確需求，再抽小 interface。&lt;/p></description><content:encoded><![CDATA[<p>Go 的 interface 描述的是行為，不是繼承關係。你不需要在 concrete type 上宣告「我實作了某個 interface」；只要方法集合符合，Go 就視為實作。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解 implicit interface 的設計精神</li>
<li>寫出小而精準的 interface</li>
<li>避免把 concrete type 暴露給不需要的呼叫者</li>
<li>用 interface 改善測試與依賴邊界</li>
</ol>
<hr>
<h2 id="觀察interface-只描述需要的行為">【觀察】interface 只描述需要的行為</h2>
<p>interface 的核心規則是：只描述呼叫者需要的行為，不描述實作者的完整身份。假設有一個函式要把訊息寫到某個目的地；它不需要知道目的地是檔案、記憶體 <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a>，還是網路連線，只需要知道對方能 <code>Write</code>。</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">Writer</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">Write</span><span class="p">(</span><span class="nx">p</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">(</span><span class="nx">n</span> <span class="kt">int</span><span class="p">,</span> <span class="nx">err</span> <span class="kt">error</span><span class="p">)</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="nf">WriteMessage</span><span class="p">(</span><span class="nx">w</span> <span class="nx">Writer</span><span class="p">,</span> <span class="nx">message</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">6</span><span class="cl">    <span class="nx">_</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">w</span><span class="p">.</span><span class="nf">Write</span><span class="p">([]</span><span class="nb">byte</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="nx">err</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 interface 很小，只描述一個行為：寫入 bytes。</p>
<h2 id="判讀go-interface-是由使用者定義的需求">【判讀】Go interface 是由使用者定義的需求</h2>
<p>在 Go 裡，interface 常由「使用者」定義，而不是由「實作者」定義。</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="c1">// Go 不需要這種宣告</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">type</span> <span class="nx">File</span> <span class="nx">implements</span> <span class="nx">Writer</span></span></span></code></pre></div><p>implicit interface 的核心規則是：只要型別有相同方法，就符合 interface，不需要顯式宣告實作關係。</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">MemoryWriter</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">data</span> <span class="p">[]</span><span class="kt">byte</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">m</span> <span class="o">*</span><span class="nx">MemoryWriter</span><span class="p">)</span> <span class="nf">Write</span><span class="p">(</span><span class="nx">p</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">(</span><span class="kt">int</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">m</span><span class="p">.</span><span class="nx">data</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">m</span><span class="p">.</span><span class="nx">data</span><span class="p">,</span> <span class="nx">p</span><span class="o">...</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="nb">len</span><span class="p">(</span><span class="nx">p</span><span class="p">),</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><code>MemoryWriter</code> 沒有提到 <code>Writer</code>，但它已經符合 <code>Writer</code>。</p>
<h2 id="策略interface-越小依賴越清楚">【策略】interface 越小，依賴越清楚</h2>
<p>小 interface 的核心規則是：interface 應由使用端需要的最小行為組成。Go 常見的好 interface 很小：</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">Reader</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">Read</span><span class="p">(</span><span class="nx">p</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">(</span><span class="nx">n</span> <span class="kt">int</span><span class="p">,</span> <span class="nx">err</span> <span class="kt">error</span><span class="p">)</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">Writer</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nf">Write</span><span class="p">(</span><span class="nx">p</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">(</span><span class="nx">n</span> <span class="kt">int</span><span class="p">,</span> <span class="nx">err</span> <span class="kt">error</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></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">type</span> <span class="nx">Closer</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nf">Close</span><span class="p">()</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>小 interface 的好處是：</p>
<ul>
<li>呼叫者只依賴自己真正需要的行為</li>
<li>測試替身容易寫</li>
<li>concrete type 可以在不同情境中被重用</li>
<li>未來改內部結構時，外部影響較小</li>
</ul>
<p>反例是把太多方法塞進一個 interface：</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">UserService</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">CreateUser</span><span class="p">(</span><span class="nx">User</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="nf">UpdateUser</span><span class="p">(</span><span class="nx">User</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nf">DeleteUser</span><span class="p">(</span><span class="kt">string</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nf">FindUser</span><span class="p">(</span><span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">User</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="nf">ListUsers</span><span class="p">()</span> <span class="p">([]</span><span class="nx">User</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nf">ExportUsers</span><span class="p">()</span> <span class="p">([]</span><span class="kt">byte</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="p">}</span></span></span></code></pre></div><p>如果某個函式只需要查詢 user，卻依賴整個 <code>UserService</code>，它就知道太多了。</p>
<h2 id="執行為查詢需求設計小介面">【執行】為查詢需求設計小介面</h2>
<p>依賴邊界的核心規則是：使用端只依賴自己需要的方法。假設一個 HTTP handler 只需要查詢使用者名稱：</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">UserLookup</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">FindName</span><span class="p">(</span><span class="nx">userID</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</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"> 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">Handler</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">users</span> <span class="nx">UserLookup</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">func</span> <span class="nf">NewHandler</span><span class="p">(</span><span class="nx">users</span> <span class="nx">UserLookup</span><span class="p">)</span> <span class="o">*</span><span class="nx">Handler</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="o">&amp;</span><span class="nx">Handler</span><span class="p">{</span><span class="nx">users</span><span class="p">:</span> <span class="nx">users</span><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>這個 handler 不知道 user 是存在 map、資料庫、檔案，還是測試假物件裡。它只知道自己需要 <code>FindName</code>。</p>
<p>測試時可以寫一個很小的 fake：</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">fakeUsers</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</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">func</span> <span class="p">(</span><span class="nx">f</span> <span class="nx">fakeUsers</span><span class="p">)</span> <span class="nf">FindName</span><span class="p">(</span><span class="nx">userID</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="kt">string</span><span class="p">,</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">name</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">f</span><span class="p">[</span><span class="nx">userID</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">name</span><span class="p">,</span> <span class="nx">ok</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這就是 Go interface 最實用的地方：它讓依賴變小，讓測試變簡單。</p>
<h2 id="何時先保留-concrete-type">何時先保留 concrete type</h2>
<p>interface 的使用邊界是：替換需求或測試替身需求清楚時，再抽出小介面。以下情境通常適合先保留 concrete type：</p>
<ul>
<li>只有一個 concrete type，而且沒有測試替身需求</li>
<li>interface 只是完整複製 concrete type 的所有方法</li>
<li>你還不確定呼叫者真正需要哪些行為</li>
</ul>
<p>Go 的常見做法是：先寫 concrete type，等使用端出現明確需求，再抽小 interface。</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>3.3 goroutine leak 偵測</title><link>https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/goroutine-leak/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/goroutine-leak/</guid><description>&lt;p>Goroutine leak 偵測的核心目標是確認已經沒有存在價值的 goroutine 能被停止。它通常是生命週期問題：誰取消、誰 close、誰解除 I/O 阻塞、誰停止 ticker。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨合理長期 goroutine 與 goroutine leak&lt;/li>
&lt;li>用 context、done channel、connection close 設計退出路徑&lt;/li>
&lt;li>用 pprof goroutine profile 判讀卡住 stack&lt;/li>
&lt;li>測試 worker、ticker、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> pump 是否能退出&lt;/li>
&lt;li>從 leak pattern 回到 ownership 修正&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察goroutine-leak-是生命週期沒有結束">【觀察】goroutine leak 是生命週期沒有結束&lt;/h2>
&lt;p>Goroutine leak 的核心定義是某個 goroutine 已經沒有存在價值，卻仍然活著。它可能卡在 channel receive、channel send、network read、ticker、mutex 或永遠不會觸發的 select case。&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">StartWorker&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">jobs&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">Job&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">job&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">jobs&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="nf">process&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&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 class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 worker 只有在 &lt;code>jobs&lt;/code> 被關閉時才會退出。若呼叫端永遠不關閉 &lt;code>jobs&lt;/code>，而 worker 也沒有 context，這個 goroutine 可能永久存在。&lt;/p>
&lt;p>長期存在不一定是 leak。HTTP server accept loop、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics&lt;/a> exporter、background scheduler 都可能合理存在；問題是它們是否有明確停止條件，且 shutdown 時是否真的會走到。&lt;/p>
&lt;h2 id="判讀每個-goroutine-都要有退出原因">【判讀】每個 goroutine 都要有退出原因&lt;/h2>
&lt;p>Goroutine lifecycle 的核心檢查是每個 goroutine 都能回答三個問題：&lt;/p>
&lt;ol>
&lt;li>誰要求它停止？&lt;/li>
&lt;li>它如果卡在 channel 或 I/O，如何被喚醒？&lt;/li>
&lt;li>它停止後如何讓測試或上層知道？&lt;/li>
&lt;/ol>
&lt;p>若三題任一題答不出來，就有 leak 風險。&lt;/p>
&lt;p>例如 worker 應該有 context：&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">RunWorker&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">jobs&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">Job&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="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">select&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">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">job&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">jobs&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">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">ok&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &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="nf">process&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&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="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>這個 worker 有兩條退出路徑：上層取消 context，或 jobs channel 被關閉。這比只依賴 channel close 更容易整合到服務 shutdown。&lt;/p>
&lt;h2 id="策略io-阻塞需要-deadline-或-close">【策略】I/O 阻塞需要 deadline 或 close&lt;/h2>
&lt;p>I/O goroutine 的核心風險是 context 本身不一定能打斷底層阻塞呼叫。WebSocket read、TCP read、file watcher、外部 API call 都要確認是否支援 context、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a> 或 close。&lt;/p>
&lt;p>WebSocket 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="k">for&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="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"> 8&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"> 9&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">10&lt;/span>&lt;span class="cl"> &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">_&lt;/span> &lt;span class="p">=&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>&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>若 &lt;code>ReadJSON&lt;/code> 卡住，context 取消不一定直接讓它返回。實務上需要 read deadline、connection close 或 heartbeat 讓 read pump 有機會退出。&lt;/p>
&lt;h2 id="執行done-channel-讓測試能觀察退出">【執行】done channel 讓測試能觀察退出&lt;/h2>
&lt;p>測試 goroutine 是否退出的核心問題是需要可觀察訊號。&lt;code>done&lt;/code> channel 可以在 goroutine 結束時 close。&lt;/p></description><content:encoded><![CDATA[<p>Goroutine leak 偵測的核心目標是確認已經沒有存在價值的 goroutine 能被停止。它通常是生命週期問題：誰取消、誰 close、誰解除 I/O 阻塞、誰停止 ticker。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨合理長期 goroutine 與 goroutine leak</li>
<li>用 context、done channel、connection close 設計退出路徑</li>
<li>用 pprof goroutine profile 判讀卡住 stack</li>
<li>測試 worker、ticker、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> pump 是否能退出</li>
<li>從 leak pattern 回到 ownership 修正</li>
</ol>
<hr>
<h2 id="觀察goroutine-leak-是生命週期沒有結束">【觀察】goroutine leak 是生命週期沒有結束</h2>
<p>Goroutine leak 的核心定義是某個 goroutine 已經沒有存在價值，卻仍然活著。它可能卡在 channel receive、channel send、network read、ticker、mutex 或永遠不會觸發的 select case。</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">StartWorker</span><span class="p">(</span><span class="nx">jobs</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Job</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">job</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">jobs</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">            <span class="nf">process</span><span class="p">(</span><span class="nx">job</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 class="p">}</span></span></span></code></pre></div><p>這個 worker 只有在 <code>jobs</code> 被關閉時才會退出。若呼叫端永遠不關閉 <code>jobs</code>，而 worker 也沒有 context，這個 goroutine 可能永久存在。</p>
<p>長期存在不一定是 leak。HTTP server accept loop、<a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a> exporter、background scheduler 都可能合理存在；問題是它們是否有明確停止條件，且 shutdown 時是否真的會走到。</p>
<h2 id="判讀每個-goroutine-都要有退出原因">【判讀】每個 goroutine 都要有退出原因</h2>
<p>Goroutine lifecycle 的核心檢查是每個 goroutine 都能回答三個問題：</p>
<ol>
<li>誰要求它停止？</li>
<li>它如果卡在 channel 或 I/O，如何被喚醒？</li>
<li>它停止後如何讓測試或上層知道？</li>
</ol>
<p>若三題任一題答不出來，就有 leak 風險。</p>
<p>例如 worker 應該有 context：</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">RunWorker</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">jobs</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Job</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">case</span> <span class="nx">job</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">jobs</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</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"> 8</span><span class="cl">                <span class="k">return</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="nf">process</span><span class="p">(</span><span class="nx">job</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><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 worker 有兩條退出路徑：上層取消 context，或 jobs channel 被關閉。這比只依賴 channel close 更容易整合到服務 shutdown。</p>
<h2 id="策略io-阻塞需要-deadline-或-close">【策略】I/O 阻塞需要 deadline 或 close</h2>
<p>I/O goroutine 的核心風險是 context 本身不一定能打斷底層阻塞呼叫。WebSocket read、TCP read、file watcher、外部 API call 都要確認是否支援 context、<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 或 close。</p>
<p>WebSocket 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="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</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"> 8</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"> 9</span><span class="cl">            <span class="k">return</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="nx">_</span> <span class="p">=</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></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>ReadJSON</code> 卡住，context 取消不一定直接讓它返回。實務上需要 read deadline、connection close 或 heartbeat 讓 read pump 有機會退出。</p>
<h2 id="執行done-channel-讓測試能觀察退出">【執行】done channel 讓測試能觀察退出</h2>
<p>測試 goroutine 是否退出的核心問題是需要可觀察訊號。<code>done</code> channel 可以在 goroutine 結束時 close。</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">RunWorker</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">jobs</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Job</span><span class="p">,</span> <span class="nx">done</span> <span class="kd">chan</span><span class="o">&lt;-</span> <span class="kd">struct</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="nb">close</span><span class="p">(</span><span class="nx">done</span><span class="p">)</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="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">case</span> <span class="nx">job</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">jobs</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="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="nf">process</span><span class="p">(</span><span class="nx">job</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <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="p">}</span></span></span></code></pre></div><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">TestRunWorkerStops</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">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithCancel</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">jobs</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Job</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">done</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</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="k">go</span> <span class="nf">RunWorker</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">jobs</span><span class="p">,</span> <span class="nx">done</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nf">cancel</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">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">done</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="o">&lt;-</span><span class="nx">time</span><span class="p">.</span><span class="nf">After</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">12</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;worker did not stop&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">Timeout</a> 是測試保護，不是功能本身。真正的退出訊號是 <code>done</code> 被關閉。</p>
<h2 id="執行ticker-必須停止">【執行】ticker 必須停止</h2>
<p>Ticker leak 的核心原因是建立 ticker 後沒有呼叫 <code>Stop</code>。Ticker 會持有 runtime 資源；長時間服務若反覆建立不停止，會累積不必要成本。</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">RunCleanup</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="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">time</span><span class="p">.</span><span class="nx">Minute</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="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="k">return</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">ticker</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="nf">cleanup</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><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>defer ticker.Stop()</code> 應緊跟在成功建立 ticker 後。這樣不管函式因 context、錯誤或 channel 關閉退出，ticker 都會被停止。</p>
<p><code>time.After</code> 在一次性 timeout 很方便，但在高頻迴圈裡反覆建立 timer 可能造成額外配置。需要重複觸發時，優先使用 <code>Ticker</code> 或可重設的 <code>Timer</code> 並明確停止。</p>
<h2 id="判讀pprof-goroutine-profile-看-stack-pattern">【判讀】pprof goroutine profile 看 stack pattern</h2>
<p>Goroutine profile 的核心價值是顯示 goroutine stack。當 goroutine 數量持續上升時，先看它們卡在哪裡。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">curl <span class="s2">&#34;http://localhost:8080/debug/pprof/goroutine?debug=2&#34;</span></span></span></code></pre></div><p>常見 pattern：</p>
<table>
  <thead>
      <tr>
          <th>stack 類型</th>
          <th>可能原因</th>
          <th>回到哪個邊界</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>channel receive</td>
          <td>上游不會再送，也沒 close/context</td>
          <td>channel ownership</td>
      </tr>
      <tr>
          <td>channel send</td>
          <td>下游不再接收或 <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 滿</td>
          <td><a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> / unregister</td>
      </tr>
      <tr>
          <td>network read</td>
          <td>沒有 deadline 或 connection 未 close</td>
          <td>heartbeat / I/O lifecycle</td>
      </tr>
      <tr>
          <td>ticker loop</td>
          <td>context 沒接上或 ticker 未 stop</td>
          <td>select loop lifecycle</td>
      </tr>
      <tr>
          <td>mutex lock</td>
          <td>鎖競爭或死鎖</td>
          <td>shared state owner</td>
      </tr>
  </tbody>
</table>
<p>看到 stack 後，下一步是回到對應 lifecycle 設計：誰負責停止，誰負責釋放阻塞點。</p>
<h2 id="策略websocket-pump-leak-要看-readwriteunregister-三方">【策略】WebSocket pump leak 要看 read/write/unregister 三方</h2>
<p>WebSocket goroutine leak 的核心常見原因是 read pump、write pump、hub unregister 沒有形成閉環。</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 pump error 或 connection close
</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
</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">        ├── close client.send
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        └── close 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>若 hub 沒有 close <code>send</code>，write pump 可能一直等。若 connection 沒有 close，read pump 可能卡在 read。若 unregister 不是 idempotent，重複 close 可能 panic。</p>
<p>Goroutine profile 若顯示大量 goroutine 卡在 <code>writePump</code> 的 send receive，通常要檢查 <code>client.send</code> 是否會被 close。若卡在 <code>ReadJSON</code>，要檢查 read deadline、heartbeat 與 connection close。</p>
<h2 id="測試用-goroutine-數量做粗略回歸檢查">【測試】用 goroutine 數量做粗略回歸檢查</h2>
<p>Goroutine 數量測試的核心用途是粗略檢查是否有明顯 leak。它不是精準證明，因為 Go runtime 與測試環境本身也會有 goroutine。</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">TestNoObviousGoroutineLeak</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">before</span> <span class="o">:=</span> <span class="nx">runtime</span><span class="p">.</span><span class="nf">NumGoroutine</span><span class="p">()</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">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithCancel</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">done</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</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="k">go</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="k">defer</span> <span class="nb">close</span><span class="p">(</span><span class="nx">done</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nf">RunWorker</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Job</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nf">cancel</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">done</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="o">&lt;-</span><span class="nx">time</span><span class="p">.</span><span class="nf">After</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</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;worker did not stop&#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></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nf">eventually</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">,</span> <span class="kd">func</span><span class="p">()</span> <span class="kt">bool</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="nx">runtime</span><span class="p">.</span><span class="nf">NumGoroutine</span><span class="p">()</span> <span class="o">&lt;=</span> <span class="nx">before</span><span class="o">+</span><span class="mi">2</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>這類測試要留緩衝，避免因 runtime 或其他測試 goroutine 造成假失敗。更可靠的測試仍是等待明確 <code>done</code> 訊號。</p>
<h2 id="判讀goroutine-leak-修正要改停止路徑">【判讀】goroutine leak 修正要改停止路徑</h2>
<p>Goroutine leak 的核心修正是補上停止路徑。</p>
<p>常見修正：</p>
<ul>
<li>加入 <code>ctx.Done()</code> case。</li>
<li>關閉由自己擁有的 output channel。</li>
<li>由 coordinator 等 sender 完成再 close。</li>
<li>對 network read/write 設定 deadline。</li>
<li>shutdown 時 close connection。</li>
<li>ticker 建立後 <code>defer Stop()</code>。</li>
<li>hub unregister 時 close send channel。</li>
</ul>
<p>修正後要用測試證明退出路徑真的會發生，再用 pprof 或 goroutine count 驗證趨勢。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 goroutine 的啟動、停止與阻塞邊界；更完整的 worker 全域治理，會在下列章節再往外延伸：</p>
<ul>
<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/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">Go 進階：bounded worker pool</a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">Go 進階：select loop 的生命週期設計</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 goroutine lifecycle、channel 與 shutdown；如果你要先回看語言教材，可以讀：</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/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>Goroutine leak 是生命週期問題。每個長期 goroutine 都應知道誰能停止它、如何解除阻塞、如何讓測試觀察退出。Context、done channel、deadline、connection close、ticker stop 與 hub unregister 是主要工具。pprof goroutine profile 則用來確認還活著的 goroutine 卡在哪個邊界。</p>
]]></content:encoded></item><item><title>3.3 os/io：檔案與輸入輸出</title><link>https://tarrragon.github.io/blog/go/03-stdlib/files-io/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/03-stdlib/files-io/</guid><description>&lt;p>Go I/O 的核心規則是：資料來源抽象成 &lt;code>io.Reader&lt;/code>，資料目的地抽象成 &lt;code>io.Writer&lt;/code>。本章將從檔案讀寫開始，建立 &lt;code>os&lt;/code>、&lt;code>io&lt;/code> 與 streaming API 的基本模型。&lt;/p>
&lt;h2 id="檔案操作從-os-開始">檔案操作從 &lt;code>os&lt;/code> 開始&lt;/h2>
&lt;p>&lt;code>os&lt;/code> package 的核心責任是處理作業系統層級的資源，例如檔案、目錄、環境變數與 process 相關資訊。入門階段最常用的是讀檔、寫檔與建立檔案。&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="nx">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ReadFile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;config.json&amp;#34;&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">if&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">3&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">string&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>os.ReadFile&lt;/code> 會一次把整個檔案讀進記憶體，適合設定檔、小型文字檔與測試資料。若檔案可能很大，就應改用 streaming 方式逐步讀取。&lt;/p>
&lt;p>寫入小檔案也可以使用 &lt;code>os.WriteFile&lt;/code>。&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="nx">data&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nb">byte&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;name=demo\n&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln">3&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">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteFile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;app.env&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mo">0644&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">4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&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>0644&lt;/code> 是檔案權限。它表示檔案擁有者可讀寫，其他人可讀。權限是 Unix 檔案權限慣例。&lt;/p>
&lt;h2 id="開啟檔案後要關閉">開啟檔案後要關閉&lt;/h2>
&lt;p>檔案是作業系統資源，開啟後應在不使用時關閉。Go 常用 &lt;code>defer file.Close()&lt;/code> 放在成功開啟檔案後，確保函式結束時釋放資源。&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="nx">file&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Open&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;data.txt&amp;#34;&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">if&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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&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="k">defer&lt;/span> &lt;span class="nx">file&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Close&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="nx">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">io&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ReadAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">file&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">if&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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&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>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">string&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>defer&lt;/code> 應該放在確認 &lt;code>err == nil&lt;/code> 之後，因為開啟失敗時 &lt;code>file&lt;/code> 可能是 &lt;code>nil&lt;/code>。這是 Go I/O 程式很重要的基本順序：先檢查錯誤，再使用資源。&lt;/p>
&lt;h2 id="ioreader-表示可讀來源">&lt;code>io.Reader&lt;/code> 表示可讀來源&lt;/h2>
&lt;p>&lt;code>io.Reader&lt;/code> 的核心意義是「可以讀出 bytes 的來源」。檔案、網路連線、HTTP request body、字串 reader 都可以是 reader。&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">countBytes&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="nx">io&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Reader&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">io&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ReadAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&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">if&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">4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&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>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="kc">nil&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>這個函式不關心資料來自檔案、記憶體或網路，只要求呼叫端提供一個 &lt;code>io.Reader&lt;/code>。這就是 Go 介面設計的典型風格：用小介面描述能力，而不是描述具體來源。&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="nx">count&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">countBytes&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewReader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;hello&amp;#34;&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">if&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">3&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">count&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>strings.NewReader&lt;/code> 可以把字串包成 reader，常用於測試與範例。因為函式依賴 &lt;code>io.Reader&lt;/code>，測試時不需要真的建立檔案。&lt;/p>
&lt;h2 id="iowriter-表示可寫目的地">&lt;code>io.Writer&lt;/code> 表示可寫目的地&lt;/h2>
&lt;p>&lt;code>io.Writer&lt;/code> 的核心意義是「可以接收 bytes 的目的地」。檔案、網路連線、HTTP response、記憶體 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 都可以是 writer。&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">writeGreeting&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">io&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Writer&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">name&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">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fprintf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;hello, %s\n&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">name&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">return&lt;/span> &lt;span class="nx">err&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;/code>&lt;/pre>&lt;/div>&lt;p>這個函式不決定輸出位置，只決定輸出內容。呼叫端可以把內容寫到標準輸出、檔案或 buffer。&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">var&lt;/span> &lt;span class="nx">buffer&lt;/span> &lt;span class="nx">bytes&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Buffer&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="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">writeGreeting&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">buffer&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;alice&amp;#34;&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">4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&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>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">buffer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">String&lt;/span>&lt;span class="p">())&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>bytes.Buffer&lt;/code> 同時實作 reader 與 writer，適合用來累積輸出或測試寫入結果。&lt;/p>
&lt;h2 id="streaming-適合大資料或長連線">streaming 適合大資料或長連線&lt;/h2>
&lt;p>streaming 的核心策略是分段處理資料，而不是一次把全部資料載入記憶體。當檔案很大、資料來自網路，或你只需要逐步轉送資料時，streaming 會比 &lt;code>ReadAll&lt;/code> 更適合。&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">copyFile&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">dstPath&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">srcPath&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"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">src&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Open&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">srcPath&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">if&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"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&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="k">defer&lt;/span> &lt;span class="nx">src&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Close&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="nx">dst&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Create&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">dstPath&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">if&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">10&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &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">defer&lt;/span> &lt;span class="nx">dst&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Close&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>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">io&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Copy&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">dst&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">src&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="k">return&lt;/span> &lt;span class="nx">err&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>io.Copy&lt;/code> 從 reader 讀資料並寫到 writer。這段程式沒有手動配置完整檔案大小的 byte slice，因此可以處理比記憶體更大的檔案。&lt;/p>
&lt;h2 id="bufioscanner-適合逐行讀取">&lt;code>bufio.Scanner&lt;/code> 適合逐行讀取&lt;/h2>
&lt;p>逐行處理文字的核心工具是 &lt;code>bufio.Scanner&lt;/code>。它會把 reader 切成一個個 token，預設 token 是一行文字。&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">printLines&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="nx">io&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Reader&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">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">scanner&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">bufio&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewScanner&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&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">scanner&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Scan&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">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">scanner&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Text&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>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">scanner&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Err&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>scanner.Scan()&lt;/code> 每次成功讀到一行就回傳 &lt;code>true&lt;/code>，讀完或遇到錯誤時回傳 &lt;code>false&lt;/code>。迴圈結束後要檢查 &lt;code>scanner.Err()&lt;/code>，因為讀取錯誤不會在迴圈內直接回傳。&lt;/p>
&lt;p>&lt;code>Scanner&lt;/code> 適合一般文字行，但它有預設 token 大小限制。若要處理非常長的行或大型二進位資料，應改用 &lt;code>bufio.Reader&lt;/code> 或其他 streaming API。&lt;/p></description><content:encoded><![CDATA[<p>Go I/O 的核心規則是：資料來源抽象成 <code>io.Reader</code>，資料目的地抽象成 <code>io.Writer</code>。本章將從檔案讀寫開始，建立 <code>os</code>、<code>io</code> 與 streaming API 的基本模型。</p>
<h2 id="檔案操作從-os-開始">檔案操作從 <code>os</code> 開始</h2>
<p><code>os</code> package 的核心責任是處理作業系統層級的資源，例如檔案、目錄、環境變數與 process 相關資訊。入門階段最常用的是讀檔、寫檔與建立檔案。</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="nx">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">ReadFile</span><span class="p">(</span><span class="s">&#34;config.json&#34;</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">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">return</span> <span class="nx">err</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">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nb">string</span><span class="p">(</span><span class="nx">data</span><span class="p">))</span></span></span></code></pre></div><p><code>os.ReadFile</code> 會一次把整個檔案讀進記憶體，適合設定檔、小型文字檔與測試資料。若檔案可能很大，就應改用 streaming 方式逐步讀取。</p>
<p>寫入小檔案也可以使用 <code>os.WriteFile</code>。</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="nx">data</span> <span class="o">:=</span> <span class="p">[]</span><span class="nb">byte</span><span class="p">(</span><span class="s">&#34;name=demo\n&#34;</span><span class="p">)</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="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">WriteFile</span><span class="p">(</span><span class="s">&#34;app.env&#34;</span><span class="p">,</span> <span class="nx">data</span><span class="p">,</span> <span class="mo">0644</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">4</span><span class="cl">    <span class="k">return</span> <span class="nx">err</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>0644</code> 是檔案權限。它表示檔案擁有者可讀寫，其他人可讀。權限是 Unix 檔案權限慣例。</p>
<h2 id="開啟檔案後要關閉">開啟檔案後要關閉</h2>
<p>檔案是作業系統資源，開啟後應在不使用時關閉。Go 常用 <code>defer file.Close()</code> 放在成功開啟檔案後，確保函式結束時釋放資源。</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="nx">file</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">Open</span><span class="p">(</span><span class="s">&#34;data.txt&#34;</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">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">return</span> <span class="nx">err</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">defer</span> <span class="nx">file</span><span class="p">.</span><span class="nf">Close</span><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="nx">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">io</span><span class="p">.</span><span class="nf">ReadAll</span><span class="p">(</span><span class="nx">file</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</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"> 9</span><span class="cl">    <span class="k">return</span> <span class="nx">err</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></span><span class="line"><span class="ln">12</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nb">string</span><span class="p">(</span><span class="nx">data</span><span class="p">))</span></span></span></code></pre></div><p><code>defer</code> 應該放在確認 <code>err == nil</code> 之後，因為開啟失敗時 <code>file</code> 可能是 <code>nil</code>。這是 Go I/O 程式很重要的基本順序：先檢查錯誤，再使用資源。</p>
<h2 id="ioreader-表示可讀來源"><code>io.Reader</code> 表示可讀來源</h2>
<p><code>io.Reader</code> 的核心意義是「可以讀出 bytes 的來源」。檔案、網路連線、HTTP request body、字串 reader 都可以是 reader。</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">countBytes</span><span class="p">(</span><span class="nx">r</span> <span class="nx">io</span><span class="p">.</span><span class="nx">Reader</span><span class="p">)</span> <span class="p">(</span><span class="kt">int</span><span class="p">,</span> <span class="kt">error</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">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">io</span><span class="p">.</span><span class="nf">ReadAll</span><span class="p">(</span><span class="nx">r</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="nx">err</span> <span class="o">!=</span> <span class="kc">nil</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="mi">0</span><span class="p">,</span> <span class="nx">err</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">return</span> <span class="nb">len</span><span class="p">(</span><span class="nx">data</span><span class="p">),</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>這個函式不關心資料來自檔案、記憶體或網路，只要求呼叫端提供一個 <code>io.Reader</code>。這就是 Go 介面設計的典型風格：用小介面描述能力，而不是描述具體來源。</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="nx">count</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">countBytes</span><span class="p">(</span><span class="nx">strings</span><span class="p">.</span><span class="nf">NewReader</span><span class="p">(</span><span class="s">&#34;hello&#34;</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">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">return</span> <span class="nx">err</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">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">count</span><span class="p">)</span></span></span></code></pre></div><p><code>strings.NewReader</code> 可以把字串包成 reader，常用於測試與範例。因為函式依賴 <code>io.Reader</code>，測試時不需要真的建立檔案。</p>
<h2 id="iowriter-表示可寫目的地"><code>io.Writer</code> 表示可寫目的地</h2>
<p><code>io.Writer</code> 的核心意義是「可以接收 bytes 的目的地」。檔案、網路連線、HTTP response、記憶體 <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 都可以是 writer。</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">writeGreeting</span><span class="p">(</span><span class="nx">w</span> <span class="nx">io</span><span class="p">.</span><span class="nx">Writer</span><span class="p">,</span> <span class="nx">name</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">2</span><span class="cl">    <span class="nx">_</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprintf</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;hello, %s\n&#34;</span><span class="p">,</span> <span class="nx">name</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個函式不決定輸出位置，只決定輸出內容。呼叫端可以把內容寫到標準輸出、檔案或 buffer。</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">var</span> <span class="nx">buffer</span> <span class="nx">bytes</span><span class="p">.</span><span class="nx">Buffer</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="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">writeGreeting</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">buffer</span><span class="p">,</span> <span class="s">&#34;alice&#34;</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">4</span><span class="cl">    <span class="k">return</span> <span class="nx">err</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="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">buffer</span><span class="p">.</span><span class="nf">String</span><span class="p">())</span></span></span></code></pre></div><p><code>bytes.Buffer</code> 同時實作 reader 與 writer，適合用來累積輸出或測試寫入結果。</p>
<h2 id="streaming-適合大資料或長連線">streaming 適合大資料或長連線</h2>
<p>streaming 的核心策略是分段處理資料，而不是一次把全部資料載入記憶體。當檔案很大、資料來自網路，或你只需要逐步轉送資料時，streaming 會比 <code>ReadAll</code> 更適合。</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">copyFile</span><span class="p">(</span><span class="nx">dstPath</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">srcPath</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"> 2</span><span class="cl">    <span class="nx">src</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">Open</span><span class="p">(</span><span class="nx">srcPath</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="nx">err</span> <span class="o">!=</span> <span class="kc">nil</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="nx">err</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="k">defer</span> <span class="nx">src</span><span class="p">.</span><span class="nf">Close</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">dst</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="nx">dstPath</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">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 class="nx">err</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="k">defer</span> <span class="nx">dst</span><span class="p">.</span><span class="nf">Close</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="nx">_</span><span class="p">,</span> <span class="nx">err</span> <span class="p">=</span> <span class="nx">io</span><span class="p">.</span><span class="nf">Copy</span><span class="p">(</span><span class="nx">dst</span><span class="p">,</span> <span class="nx">src</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="nx">err</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>io.Copy</code> 從 reader 讀資料並寫到 writer。這段程式沒有手動配置完整檔案大小的 byte slice，因此可以處理比記憶體更大的檔案。</p>
<h2 id="bufioscanner-適合逐行讀取"><code>bufio.Scanner</code> 適合逐行讀取</h2>
<p>逐行處理文字的核心工具是 <code>bufio.Scanner</code>。它會把 reader 切成一個個 token，預設 token 是一行文字。</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">printLines</span><span class="p">(</span><span class="nx">r</span> <span class="nx">io</span><span class="p">.</span><span class="nx">Reader</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">scanner</span> <span class="o">:=</span> <span class="nx">bufio</span><span class="p">.</span><span class="nf">NewScanner</span><span class="p">(</span><span class="nx">r</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">scanner</span><span class="p">.</span><span class="nf">Scan</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">scanner</span><span class="p">.</span><span class="nf">Text</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></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">return</span> <span class="nx">scanner</span><span class="p">.</span><span class="nf">Err</span><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>scanner.Scan()</code> 每次成功讀到一行就回傳 <code>true</code>，讀完或遇到錯誤時回傳 <code>false</code>。迴圈結束後要檢查 <code>scanner.Err()</code>，因為讀取錯誤不會在迴圈內直接回傳。</p>
<p><code>Scanner</code> 適合一般文字行，但它有預設 token 大小限制。若要處理非常長的行或大型二進位資料，應改用 <code>bufio.Reader</code> 或其他 streaming API。</p>
<h2 id="小結">小結</h2>
<p>下一章會進入 JSON，說明 Go 如何把 struct 與外部資料格式互相轉換。</p>
]]></content:encoded></item><item><title>4.3 select：同時等待多種事件</title><link>https://tarrragon.github.io/blog/go/04-concurrency/select/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/04-concurrency/select/</guid><description>&lt;p>&lt;code>select&lt;/code> 是 Go 用來同時等待多個 channel 操作的控制結構。它的核心用途是讓一個 goroutine 同時處理資料輸入、取消訊號、timer/ticker 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fallback/" data-link-title="Fallback" data-link-desc="說明主要路徑失敗時使用替代結果或替代流程的設計責任">fallback&lt;/a> 行為。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解 &lt;code>select&lt;/code> 的基本語法&lt;/li>
&lt;li>同時等待多個 channel&lt;/li>
&lt;li>用 &lt;code>ctx.Done()&lt;/code> 停止事件迴圈&lt;/li>
&lt;li>用 ticker 建立定時工作&lt;/li>
&lt;li>理解 &lt;code>default&lt;/code> 的 non-blocking 行為&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察select-同時等待多個-channel">【觀察】select 同時等待多個 channel&lt;/h2>
&lt;p>&lt;code>select&lt;/code> 的核心規則是：多個 case 中哪個 channel 先 ready，就執行哪個 case。以下範例同時等待 job 和取消訊號：&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">worker&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">jobs&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">Job&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="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">select&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">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">job&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">jobs&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="nf">handle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&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 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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 worker 不需要先固定等待 jobs，也不需要用輪詢檢查 context。&lt;code>select&lt;/code> 會同時等待兩者。&lt;/p>
&lt;h2 id="判讀select-loop-是長期-goroutine-的生命週期中心">【判讀】select loop 是長期 goroutine 的生命週期中心&lt;/h2>
&lt;p>長期 goroutine 的核心規則是：事件迴圈必須同時處理工作來源與退出訊號。只讀工作 channel 而不讀 &lt;code>ctx.Done()&lt;/code>，goroutine 可能無法按上層要求停止。&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">worker&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">jobs&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">Job&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="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">select&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">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">job&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">jobs&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">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">ok&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &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="nf">handle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&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="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>&lt;code>ok == false&lt;/code> 表示 channel 已關閉。這讓 worker 在「上層取消」和「工作來源結束」兩種情境都能退出。&lt;/p>
&lt;h2 id="策略ticker-case-要有-stop">【策略】ticker case 要有 Stop&lt;/h2>
&lt;p>ticker 的核心規則是：建立 ticker 後要呼叫 &lt;code>Stop()&lt;/code>，避免不再使用時仍保留 runtime 資源。&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">cleanupLoop&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="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">ticker&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewTicker&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Minute&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">defer&lt;/span> &lt;span class="nx">ticker&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Stop&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&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"> 6&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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>&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="o">&amp;lt;-&lt;/span>&lt;span class="nx">ticker&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">C&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="nf">cleanup&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="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>這種模式常用於定期清理、同步、掃描或報表輸出。&lt;/p>
&lt;h2 id="執行default-建立-non-blocking-select">【執行】default 建立 non-blocking select&lt;/h2>
&lt;p>&lt;code>default&lt;/code> 的核心規則是：沒有任何 channel ready 時，立即執行 default。這會讓 &lt;code>select&lt;/code> 不阻塞。&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="k">select&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">case&lt;/span> &lt;span class="nx">jobs&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">job&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">return&lt;/span> &lt;span class="kc">nil&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">default&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">ErrQueueFull&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;p>這段程式嘗試把 job 放進 channel；如果 channel 不能立即接收，就回傳 &lt;code>ErrQueueFull&lt;/code>。這適合保護呼叫端不要被背景佇列卡住。&lt;/p>
&lt;p>不適合用 default 的情境是你其實需要等待結果：&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="k">select&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">case&lt;/span> &lt;span class="nx">result&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">results&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">return&lt;/span> &lt;span class="nx">result&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">default&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">Result&lt;/span>&lt;span class="p">{}&lt;/span> &lt;span class="c1">// 可能過早返回&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;p>若結果是必要的，應該等待或設定 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>，而不是直接 default。&lt;/p>
&lt;h2 id="timeout-pattern">timeout pattern&lt;/h2>
&lt;p>timeout 的核心規則是：需要等待但不能無限等待時，用 &lt;code>time.After&lt;/code> 或 context timeout。&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="k">select&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">case&lt;/span> &lt;span class="nx">result&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">results&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">return&lt;/span> &lt;span class="nx">result&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&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">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">After&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2&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 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">Result&lt;/span>&lt;span class="p">{},&lt;/span> &lt;span class="nx">errors&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">New&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;timeout&amp;#34;&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;/code>&lt;/pre>&lt;/div>&lt;p>在較大的系統中，通常更偏好 &lt;code>context.WithTimeout&lt;/code>，讓 timeout 可以沿呼叫鏈傳遞。&lt;/p></description><content:encoded><![CDATA[<p><code>select</code> 是 Go 用來同時等待多個 channel 操作的控制結構。它的核心用途是讓一個 goroutine 同時處理資料輸入、取消訊號、timer/ticker 與 <a href="/blog/backend/knowledge-cards/fallback/" data-link-title="Fallback" data-link-desc="說明主要路徑失敗時使用替代結果或替代流程的設計責任">fallback</a> 行為。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解 <code>select</code> 的基本語法</li>
<li>同時等待多個 channel</li>
<li>用 <code>ctx.Done()</code> 停止事件迴圈</li>
<li>用 ticker 建立定時工作</li>
<li>理解 <code>default</code> 的 non-blocking 行為</li>
</ol>
<hr>
<h2 id="觀察select-同時等待多個-channel">【觀察】select 同時等待多個 channel</h2>
<p><code>select</code> 的核心規則是：多個 case 中哪個 channel 先 ready，就執行哪個 case。以下範例同時等待 job 和取消訊號：</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">worker</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">jobs</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Job</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">case</span> <span class="nx">job</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">jobs</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nf">handle</span><span class="p">(</span><span class="nx">job</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="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 worker 不需要先固定等待 jobs，也不需要用輪詢檢查 context。<code>select</code> 會同時等待兩者。</p>
<h2 id="判讀select-loop-是長期-goroutine-的生命週期中心">【判讀】select loop 是長期 goroutine 的生命週期中心</h2>
<p>長期 goroutine 的核心規則是：事件迴圈必須同時處理工作來源與退出訊號。只讀工作 channel 而不讀 <code>ctx.Done()</code>，goroutine 可能無法按上層要求停止。</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">worker</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">jobs</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Job</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">case</span> <span class="nx">job</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">jobs</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</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"> 8</span><span class="cl">                <span class="k">return</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="nf">handle</span><span class="p">(</span><span class="nx">job</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><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>ok == false</code> 表示 channel 已關閉。這讓 worker 在「上層取消」和「工作來源結束」兩種情境都能退出。</p>
<h2 id="策略ticker-case-要有-stop">【策略】ticker case 要有 Stop</h2>
<p>ticker 的核心規則是：建立 ticker 後要呼叫 <code>Stop()</code>，避免不再使用時仍保留 runtime 資源。</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">cleanupLoop</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="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">time</span><span class="p">.</span><span class="nx">Minute</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="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="k">return</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">ticker</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="nf">cleanup</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><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種模式常用於定期清理、同步、掃描或報表輸出。</p>
<h2 id="執行default-建立-non-blocking-select">【執行】default 建立 non-blocking select</h2>
<p><code>default</code> 的核心規則是：沒有任何 channel ready 時，立即執行 default。這會讓 <code>select</code> 不阻塞。</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">case</span> <span class="nx">jobs</span> <span class="o">&lt;-</span> <span class="nx">job</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">default</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">ErrQueueFull</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式嘗試把 job 放進 channel；如果 channel 不能立即接收，就回傳 <code>ErrQueueFull</code>。這適合保護呼叫端不要被背景佇列卡住。</p>
<p>不適合用 default 的情境是你其實需要等待結果：</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">case</span> <span class="nx">result</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">results</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">return</span> <span class="nx">result</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">default</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">Result</span><span class="p">{}</span> <span class="c1">// 可能過早返回</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>若結果是必要的，應該等待或設定 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>，而不是直接 default。</p>
<h2 id="timeout-pattern">timeout pattern</h2>
<p>timeout 的核心規則是：需要等待但不能無限等待時，用 <code>time.After</code> 或 context timeout。</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">case</span> <span class="nx">result</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">results</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">return</span> <span class="nx">result</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">case</span> <span class="o">&lt;-</span><span class="nx">time</span><span class="p">.</span><span class="nf">After</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</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">Result</span><span class="p">{},</span> <span class="nx">errors</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="s">&#34;timeout&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>在較大的系統中，通常更偏好 <code>context.WithTimeout</code>，讓 timeout 可以沿呼叫鏈傳遞。</p>
]]></content:encoded></item><item><title>4.3 Source of Truth：狀態邊界</title><link>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/source-of-truth/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/source-of-truth/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of truth&lt;/a> 的核心原則是系統中只有一個地方負責判定目前狀態。其他元件可以請求更新、讀取快照或訂閱變化，但不能各自保存一份會被當成真相的資料。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷狀態真相應該由哪個元件擁有&lt;/li>
&lt;li>把狀態轉移集中在 repository 或 state owner&lt;/li>
&lt;li>同步更新 current state 與 history&lt;/li>
&lt;li>用 copy boundary 保護 slice、map、pointer&lt;/li>
&lt;li>分辨 internal state、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a> 與 response view&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察狀態分散會讓系統失去真相">【觀察】狀態分散會讓系統失去真相&lt;/h2>
&lt;p>狀態分散的核心風險是每個元件都以為自己看到的是最新資料。handler 可能有 map，worker 可能有 cache，publisher 可能有最後推送狀態；當三者不一致時，系統很難回答「現在到底是什麼狀態」。&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">var&lt;/span> &lt;span class="nx">handlerStates&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="kt">string&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="kd">var&lt;/span> &lt;span class="nx">workerStates&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="kt">string&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="kd">var&lt;/span> &lt;span class="nx">publisherLastSent&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="kt">string&lt;/span>&lt;span class="p">{}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這三份資料可能都叫做 state，但只有一份應該是 source of truth。其他資料如果存在，應該明確標示為 cache、projection 或 delivery record，不能被當成狀態判斷依據。&lt;/p>
&lt;h2 id="判讀source-of-truth-是寫入權責">【判讀】source of truth 是寫入權責&lt;/h2>
&lt;p>source of truth 的核心不是「資料存在哪裡」，而是「誰有權決定狀態如何轉移」。memory map、SQLite、PostgreSQL、Redis 都可以承擔儲存；真正的邊界是所有寫入都經過同一組規則。&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">AccountStatus&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">AccountPending&lt;/span> &lt;span class="nx">AccountStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;pending&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">AccountActive&lt;/span> &lt;span class="nx">AccountStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;active&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">AccountBlocked&lt;/span> &lt;span class="nx">AccountStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;blocked&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">AccountState&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">ID&lt;/span> &lt;span class="kt">string&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">Status&lt;/span> &lt;span class="nx">AccountStatus&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">UpdatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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>&lt;code>AccountState&lt;/code> 是 domain 狀態，不是 HTTP response。它應該表達系統內部真正需要維護的資料，而不是直接迎合某個 API 的輸出格式。&lt;/p>
&lt;h2 id="策略用明確方法集中狀態轉移">【策略】用明確方法集中狀態轉移&lt;/h2>
&lt;p>狀態轉移的核心規則是呼叫端不能直接改欄位。外部元件應該送進事件或 command，由 state owner 決定是否合法、如何更新、是否記錄 history。&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">StateRepository&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">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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">records&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="nx">AccountRecord&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="kd">type&lt;/span> &lt;span class="nx">AccountRecord&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">Current&lt;/span> &lt;span class="nx">AccountState&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">History&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">AccountState&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewStateRepository&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">StateRepository&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="o">&amp;amp;&lt;/span>&lt;span class="nx">StateRepository&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">records&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="nx">AccountRecord&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 class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>repository 擁有 &lt;code>records&lt;/code> map。其他元件不應取得這個 map 的 reference，也不應繞過 repository 修改狀態。&lt;/p>
&lt;h2 id="執行apply-把事件轉成狀態變化">【執行】Apply 把事件轉成狀態變化&lt;/h2>
&lt;p>&lt;code>Apply&lt;/code> 的核心責任是把 domain event 套用到 state。它是事件系統與狀態系統的交界。&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">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">StateRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Apply&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">event&lt;/span> &lt;span class="nx">DomainEvent&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"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Lock&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">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Unlock&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">record&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">records&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SubjectID&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">next&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">transition&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Current&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&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">if&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"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Current&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">next&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">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">History&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">History&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">next&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">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">records&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SubjectID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">record&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&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>這段程式在同一個 lock 內更新 current 與 history。讀者可以相信目前狀態與歷史紀錄來自同一筆事件，不會出現 current 已更新但 history 漏記的情境。&lt;/p>
&lt;p>&lt;code>transition&lt;/code> 可以是純函式：&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">transition&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">current&lt;/span> &lt;span class="nx">AccountState&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span> &lt;span class="nx">DomainEvent&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">AccountState&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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">switch&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&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">EventAccountActivated&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="nx">AccountState&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="nx">ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SubjectID&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">Status&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">AccountActive&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="nx">UpdatedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">OccurredAt&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 class="kc">nil&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">default&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">AccountState&lt;/span>&lt;span class="p">{},&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;unsupported event type: %s&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&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="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;/code>&lt;/pre>&lt;/div>&lt;p>純函式讓狀態規則更容易測試。repository 負責 concurrency 與保存，transition 負責 domain 規則。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of truth</a> 的核心原則是系統中只有一個地方負責判定目前狀態。其他元件可以請求更新、讀取快照或訂閱變化，但不能各自保存一份會被當成真相的資料。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷狀態真相應該由哪個元件擁有</li>
<li>把狀態轉移集中在 repository 或 state owner</li>
<li>同步更新 current state 與 history</li>
<li>用 copy boundary 保護 slice、map、pointer</li>
<li>分辨 internal state、<a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a> 與 response view</li>
</ol>
<hr>
<h2 id="觀察狀態分散會讓系統失去真相">【觀察】狀態分散會讓系統失去真相</h2>
<p>狀態分散的核心風險是每個元件都以為自己看到的是最新資料。handler 可能有 map，worker 可能有 cache，publisher 可能有最後推送狀態；當三者不一致時，系統很難回答「現在到底是什麼狀態」。</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">var</span> <span class="nx">handlerStates</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="kt">string</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">var</span> <span class="nx">workerStates</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="kt">string</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">var</span> <span class="nx">publisherLastSent</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="kt">string</span><span class="p">{}</span></span></span></code></pre></div><p>這三份資料可能都叫做 state，但只有一份應該是 source of truth。其他資料如果存在，應該明確標示為 cache、projection 或 delivery record，不能被當成狀態判斷依據。</p>
<h2 id="判讀source-of-truth-是寫入權責">【判讀】source of truth 是寫入權責</h2>
<p>source of truth 的核心不是「資料存在哪裡」，而是「誰有權決定狀態如何轉移」。memory map、SQLite、PostgreSQL、Redis 都可以承擔儲存；真正的邊界是所有寫入都經過同一組規則。</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">AccountStatus</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">AccountPending</span> <span class="nx">AccountStatus</span> <span class="p">=</span> <span class="s">&#34;pending&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">AccountActive</span>  <span class="nx">AccountStatus</span> <span class="p">=</span> <span class="s">&#34;active&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">AccountBlocked</span> <span class="nx">AccountStatus</span> <span class="p">=</span> <span class="s">&#34;blocked&#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">AccountState</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">ID</span>        <span class="kt">string</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">Status</span>    <span class="nx">AccountStatus</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">UpdatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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>AccountState</code> 是 domain 狀態，不是 HTTP response。它應該表達系統內部真正需要維護的資料，而不是直接迎合某個 API 的輸出格式。</p>
<h2 id="策略用明確方法集中狀態轉移">【策略】用明確方法集中狀態轉移</h2>
<p>狀態轉移的核心規則是呼叫端不能直接改欄位。外部元件應該送進事件或 command，由 state owner 決定是否合法、如何更新、是否記錄 history。</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">StateRepository</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">mu</span>      <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">records</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">AccountRecord</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="kd">type</span> <span class="nx">AccountRecord</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">Current</span> <span class="nx">AccountState</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">History</span> <span class="p">[]</span><span class="nx">AccountState</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></span><span class="line"><span class="ln">11</span><span class="cl"><span class="kd">func</span> <span class="nf">NewStateRepository</span><span class="p">()</span> <span class="o">*</span><span class="nx">StateRepository</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="o">&amp;</span><span class="nx">StateRepository</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">records</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="nx">AccountRecord</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="p">}</span></span></span></code></pre></div><p>repository 擁有 <code>records</code> map。其他元件不應取得這個 map 的 reference，也不應繞過 repository 修改狀態。</p>
<h2 id="執行apply-把事件轉成狀態變化">【執行】Apply 把事件轉成狀態變化</h2>
<p><code>Apply</code> 的核心責任是把 domain event 套用到 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="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">StateRepository</span><span class="p">)</span> <span class="nf">Apply</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">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</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"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</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"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">record</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">records</span><span class="p">[</span><span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">next</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">transition</span><span class="p">(</span><span class="nx">record</span><span class="p">.</span><span class="nx">Current</span><span class="p">,</span> <span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</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"> 8</span><span class="cl">        <span class="k">return</span> <span class="nx">err</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">record</span><span class="p">.</span><span class="nx">Current</span> <span class="p">=</span> <span class="nx">next</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">record</span><span class="p">.</span><span class="nx">History</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">record</span><span class="p">.</span><span class="nx">History</span><span class="p">,</span> <span class="nx">next</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">records</span><span class="p">[</span><span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">record</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式在同一個 lock 內更新 current 與 history。讀者可以相信目前狀態與歷史紀錄來自同一筆事件，不會出現 current 已更新但 history 漏記的情境。</p>
<p><code>transition</code> 可以是純函式：</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">transition</span><span class="p">(</span><span class="nx">current</span> <span class="nx">AccountState</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="p">(</span><span class="nx">AccountState</span><span class="p">,</span> <span class="kt">error</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">switch</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</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">EventAccountActivated</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="nx">AccountState</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="nx">ID</span><span class="p">:</span>        <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="nx">Status</span><span class="p">:</span>    <span class="nx">AccountActive</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nx">UpdatedAt</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="p">},</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">default</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">AccountState</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;unsupported event type: %s&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</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>純函式讓狀態規則更容易測試。repository 負責 concurrency 與保存，transition 負責 domain 規則。</p>
<h2 id="判讀currenthistoryprojection-是不同資料">【判讀】current、history、projection 是不同資料</h2>
<p>狀態資料的核心分類是 internal state、history 與 projection。三者用途不同，不應混成同一個 struct 到處傳。</p>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>角色</th>
          <th>範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>internal state</td>
          <td>系統判斷真相的資料</td>
          <td><code>AccountState</code></td>
      </tr>
      <tr>
          <td>history</td>
          <td>狀態變化紀錄</td>
          <td><code>[]AccountState</code></td>
      </tr>
      <tr>
          <td>projection</td>
          <td>查詢或 UI 需要的讀取模型</td>
          <td><code>AccountSummary</code></td>
      </tr>
      <tr>
          <td>response view</td>
          <td>特定 API 的輸出格式</td>
          <td><code>accountResponse</code></td>
      </tr>
  </tbody>
</table>
<p>projection 可以從 state 與 history 組出來，但 projection 不應反過來成為狀態真相。API 需要新增欄位時，優先新增 response view 或 projection，不要直接污染 internal state。</p>
<h2 id="執行查詢要回傳-copy">【執行】查詢要回傳 copy</h2>
<p>copy boundary 的核心目標是防止呼叫端修改 repository 內部資料。Go 的 slice、map、pointer 都可能讓內部狀態外洩。</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">r</span> <span class="o">*</span><span class="nx">StateRepository</span><span class="p">)</span> <span class="nf">Current</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">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">AccountState</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</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">r</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">r</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">record</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">records</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</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"> 7</span><span class="cl">        <span class="k">return</span> <span class="nx">AccountState</span><span class="p">{},</span> <span class="kc">false</span><span class="p">,</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><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">return</span> <span class="nx">record</span><span class="p">.</span><span class="nx">Current</span><span class="p">,</span> <span class="kc">true</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>AccountState</code> 目前只有值型別欄位，直接回傳值即可。history 是 slice，必須複製：</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">r</span> <span class="o">*</span><span class="nx">StateRepository</span><span class="p">)</span> <span class="nf">History</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">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">([]</span><span class="nx">AccountState</span><span class="p">,</span> <span class="kt">error</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">r</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">r</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">history</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">records</span><span class="p">[</span><span class="nx">id</span><span class="p">].</span><span class="nx">History</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">result</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="nx">AccountState</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">history</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nb">copy</span><span class="p">(</span><span class="nx">result</span><span class="p">,</span> <span class="nx">history</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">result</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>若 state 內含 map、slice 或 pointer，還需要 deep copy。copy 有成本，但它是狀態邊界的保護；資料量大時應用分頁或 projection，不應直接暴露內部 slice。</p>
<h2 id="策略projection-讓查詢需求不污染狀態">【策略】projection 讓查詢需求不污染狀態</h2>
<p>projection 的核心用途是服務讀取需求。列表頁、儀表板、即時推送可能需要不同欄位，這些需求不應全部塞進 domain 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">type</span> <span class="nx">AccountSummary</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">Status</span>          <span class="nx">AccountStatus</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">LastChangedAt</span>   <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">HistoryCount</span>    <span class="kt">int</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">r</span> <span class="o">*</span><span class="nx">StateRepository</span><span class="p">)</span> <span class="nf">Summary</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">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">AccountSummary</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</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">r</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">10</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</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">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">record</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">records</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="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">AccountSummary</span><span class="p">{},</span> <span class="kc">false</span><span class="p">,</span> <span class="kc">nil</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">return</span> <span class="nx">AccountSummary</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>            <span class="nx">record</span><span class="p">.</span><span class="nx">Current</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="nx">Status</span><span class="p">:</span>        <span class="nx">record</span><span class="p">.</span><span class="nx">Current</span><span class="p">.</span><span class="nx">Status</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">LastChangedAt</span><span class="p">:</span> <span class="nx">record</span><span class="p">.</span><span class="nx">Current</span><span class="p">.</span><span class="nx">UpdatedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="nx">HistoryCount</span><span class="p">:</span>  <span class="nb">len</span><span class="p">(</span><span class="nx">record</span><span class="p">.</span><span class="nx">History</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">},</span> <span class="kc">true</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>projection 可以由 repository 即時計算，也可以由背景 worker 預先維護。選擇哪一種取決於讀取量、資料量與一致性要求；小型服務先即時計算通常更容易理解。</p>
<h2 id="判讀mutex-與單一-goroutine-都能成為-state-owner">【判讀】mutex 與單一 goroutine 都能成為 state owner</h2>
<p>狀態擁有權的核心要求是同一時間只有受控路徑能修改資料。mutex 是常見選擇，單一 goroutine 擁有 state 也是 Go 常見模式。</p>
<p>mutex 版本適合直接方法呼叫：</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="nx">repository</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><span class="p">)</span></span></span></code></pre></div><p>單一 goroutine 版本適合事件流：</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">stateCommand</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">event</span> <span class="nx">DomainEvent</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">reply</span> <span class="kd">chan</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>兩者都可以正確。選擇 mutex 時要小心 copy boundary；選擇 goroutine owner 時要設計 shutdown、reply channel 與 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a>。不要為了使用 channel 而使用 channel，狀態模型簡單時 mutex 通常更直接。</p>
<h2 id="測試狀態測試要覆蓋轉移與外洩">【測試】狀態測試要覆蓋轉移與外洩</h2>
<p>狀態邊界的測試目標是確認轉移一致、history 同步、呼叫端不能修改內部資料。</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">TestHistoryReturnsCopy</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">repo</span> <span class="o">:=</span> <span class="nf">NewStateRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">event</span> <span class="o">:=</span> <span class="nx">DomainEvent</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="p">:</span>         <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>       <span class="nx">EventAccountActivated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>  <span class="s">&#34;acct_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Apply</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">event</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">12</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;apply event: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">history</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">History</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="s">&#34;acct_1&#34;</span><span class="p">)</span>
</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="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</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;history: %v&#34;</span><span class="p">,</span> <span class="nx">err</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 class="nx">history</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">AccountBlocked</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">again</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">History</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="s">&#34;acct_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</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">23</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;history again: %v&#34;</span><span class="p">,</span> <span class="nx">err</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 class="k">if</span> <span class="nx">again</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">Status</span> <span class="o">!=</span> <span class="nx">AccountActive</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">26</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;repository state was modified through returned history&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試檢查的是邊界，不只是結果值。對 Go 服務來說，防止 slice/map 外洩是狀態設計的重要一環。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一服務內誰有寫入權責；資料庫 <a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration</a> 與 CQRS，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">Go 進階：資料庫 transaction 與 schema migration</a></li>
<li><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫與持久化</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 repository、state owner 與 projection 的邊界；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/06-practical/state-fields/" data-link-title="6.3 如何擴展狀態投影欄位" data-link-desc="更新狀態模型、repository 與 API 輸出">Go：如何擴展狀態投影欄位</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
<li><a href="/blog/go/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">Go：以 domain 重新整理 package</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>Source of truth 是寫入權責，不是某個特定資料庫。狀態轉移應集中在 repository 或 state owner，current 與 history 要在同一邊界更新，查詢要回傳 copy 或 projection。當狀態真相清楚時，handler、worker、publisher 都能保持簡單，系統也能更容易加入資料庫或新的讀取模型。</p>
]]></content:encoded></item><item><title>5.3 race condition 檢查</title><link>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/race-check/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/race-check/</guid><description>&lt;p>Race detector 的核心作用是找出測試執行期間發生的 data race。它能指出未同步讀寫同一份記憶體的位置，但不能取代 ownership、mutex、channel 與狀態邊界設計。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 data race 與一般邏輯競爭&lt;/li>
&lt;li>用 &lt;code>go test -race ./...&lt;/code> 檢查並發路徑&lt;/li>
&lt;li>寫出能觸發共享狀態讀寫的測試&lt;/li>
&lt;li>依 race report 找到讀寫來源&lt;/li>
&lt;li>選擇 mutex、channel owner 或 atomic 修正同步邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察並發-bug-常常不會穩定重現">【觀察】並發 bug 常常不會穩定重現&lt;/h2>
&lt;p>Data race 的核心問題是測試可能偶爾通過、偶爾失敗，也可能完全不失敗但資料已經不安全。單次執行結果正確，不代表沒有未同步讀寫。&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">var&lt;/span> &lt;span class="nx">count&lt;/span> &lt;span class="kt">int&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">func&lt;/span> &lt;span class="nf">increment&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">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">count&lt;/span>&lt;span class="o">++&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>count++&lt;/code> 不是原子操作。它包含讀取、加一、寫回。多個 goroutine 同時執行時，可能互相覆蓋結果，也可能被 race detector 偵測到未同步讀寫。&lt;/p>
&lt;h2 id="判讀data-race-是未同步的並發讀寫">【判讀】data race 是未同步的並發讀寫&lt;/h2>
&lt;p>Data race 的核心定義是至少兩個 goroutine 同時存取同一份記憶體，其中至少一個是寫入，而且沒有同步保護。&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">TestIncrementRace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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="kd">var&lt;/span> &lt;span class="nx">wg&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">WaitGroup&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="p">&amp;lt;&lt;/span> &lt;span class="mi">100&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="o">++&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="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&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">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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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="nf">increment&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="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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Wait&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>一般 &lt;code>go test&lt;/code> 不一定會失敗。&lt;code>go test -race&lt;/code> 會在 runtime 偵測這類未同步讀寫，並輸出讀取與寫入發生的位置。&lt;/p>
&lt;h2 id="執行用-go-test--race-跑到相關路徑">【執行】用 go test -race 跑到相關路徑&lt;/h2>
&lt;p>Race detector 的核心限制是只能檢查實際執行到的程式路徑。沒有被測試覆蓋的 goroutine、handler、repository 或 broadcast path，不會被它發現。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">go &lt;span class="nb">test&lt;/span> -race ./...&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個指令會用 race detector 跑所有 package 的測試。它會比一般測試慢，但對含有 goroutine、共享 map、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> hub、background worker 的服務非常重要。&lt;/p>
&lt;p>若專案很大，可以先針對相關 package：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">go &lt;span class="nb">test&lt;/span> -race ./internal/websocket ./internal/storage ./internal/worker&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>範圍縮小能讓日常執行更快，但合併前仍應跑完整路徑。&lt;/p>
&lt;h2 id="策略併發測試要讓共享狀態真的被同時讀寫">【策略】併發測試要讓共享狀態真的被同時讀寫&lt;/h2>
&lt;p>Race detector 的核心前提是測試要製造相關路徑。只建立 repository 卻不並發讀寫，race detector 沒有機會回報。&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">TestRepositoryConcurrentAccess&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">repo&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewUserRepository&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">ctx&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Background&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">wg&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">WaitGroup&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">for&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="p">&amp;lt;&lt;/span> &lt;span class="mi">100&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="o">++&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="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">i&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&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">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">10&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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">id&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Sprintf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;user_%d&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">i&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">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">repo&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Save&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">User&lt;/span>&lt;span class="p">{&lt;/span>&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">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">repo&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Find&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">id&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 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>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Wait&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個測試的主要斷言在「讓 race detector 執行共享 map 的讀寫路徑」。若 repository 忘記加 lock，&lt;code>-race&lt;/code> 會指出問題。&lt;/p>
&lt;h2 id="執行websocket-hub-也需要-race-path">【執行】WebSocket hub 也需要 race path&lt;/h2>
&lt;p>WebSocket hub 的核心並發風險是 client 註冊、取消註冊、訂閱變更與 broadcast 可能同時發生。測試應讓這些路徑交錯執行。&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">TestHubConcurrentBroadcastAndUnregister&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">hub&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewHub&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">clients&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">make&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 class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">100&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="p">&amp;lt;&lt;/span> &lt;span class="mi">100&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="o">++&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">client&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewTestClient&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Sprintf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;client_%d&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="mi">8&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="nx">client&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Subscribe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;alerts&amp;#34;&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="nx">hub&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">clients&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">client&lt;/span>&lt;span class="p">]&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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">clients&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">clients&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">10&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">wg&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">WaitGroup&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">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2&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>&lt;/span>&lt;span class="line">&lt;span class="ln">15&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">16&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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">for&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="p">&amp;lt;&lt;/span> &lt;span class="mi">100&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="o">++&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="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="s">&amp;#34;alerts&amp;#34;&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;notification&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 class="p">}()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&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">23&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&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">clients&lt;/span> &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 class="nx">hub&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">unregisterClient&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">26&lt;/span>&lt;span class="cl"> &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;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Wait&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個測試是否需要 lock，取決於 hub 的設計。如果 hub 保證所有操作都在單一 event loop 中執行，測試就應該透過 channel 操作，而不是直接呼叫未同步方法。測試要符合 ownership 設計，不應製造不被 API 允許的並發。&lt;/p></description><content:encoded><![CDATA[<p>Race detector 的核心作用是找出測試執行期間發生的 data race。它能指出未同步讀寫同一份記憶體的位置，但不能取代 ownership、mutex、channel 與狀態邊界設計。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 data race 與一般邏輯競爭</li>
<li>用 <code>go test -race ./...</code> 檢查並發路徑</li>
<li>寫出能觸發共享狀態讀寫的測試</li>
<li>依 race report 找到讀寫來源</li>
<li>選擇 mutex、channel owner 或 atomic 修正同步邊界</li>
</ol>
<hr>
<h2 id="觀察並發-bug-常常不會穩定重現">【觀察】並發 bug 常常不會穩定重現</h2>
<p>Data race 的核心問題是測試可能偶爾通過、偶爾失敗，也可能完全不失敗但資料已經不安全。單次執行結果正確，不代表沒有未同步讀寫。</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">var</span> <span class="nx">count</span> <span class="kt">int</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">func</span> <span class="nf">increment</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">count</span><span class="o">++</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>count++</code> 不是原子操作。它包含讀取、加一、寫回。多個 goroutine 同時執行時，可能互相覆蓋結果，也可能被 race detector 偵測到未同步讀寫。</p>
<h2 id="判讀data-race-是未同步的並發讀寫">【判讀】data race 是未同步的並發讀寫</h2>
<p>Data race 的核心定義是至少兩個 goroutine 同時存取同一份記憶體，其中至少一個是寫入，而且沒有同步保護。</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">TestIncrementRace</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="kd">var</span> <span class="nx">wg</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">WaitGroup</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="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="mi">100</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">wg</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</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"> 7</span><span class="cl">            <span class="k">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="nf">increment</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="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">wg</span><span class="p">.</span><span class="nf">Wait</span><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>go test</code> 不一定會失敗。<code>go test -race</code> 會在 runtime 偵測這類未同步讀寫，並輸出讀取與寫入發生的位置。</p>
<h2 id="執行用-go-test--race-跑到相關路徑">【執行】用 go test -race 跑到相關路徑</h2>
<p>Race detector 的核心限制是只能檢查實際執行到的程式路徑。沒有被測試覆蓋的 goroutine、handler、repository 或 broadcast path，不會被它發現。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go <span class="nb">test</span> -race ./...</span></span></code></pre></div><p>這個指令會用 race detector 跑所有 package 的測試。它會比一般測試慢，但對含有 goroutine、共享 map、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> hub、background worker 的服務非常重要。</p>
<p>若專案很大，可以先針對相關 package：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go <span class="nb">test</span> -race ./internal/websocket ./internal/storage ./internal/worker</span></span></code></pre></div><p>範圍縮小能讓日常執行更快，但合併前仍應跑完整路徑。</p>
<h2 id="策略併發測試要讓共享狀態真的被同時讀寫">【策略】併發測試要讓共享狀態真的被同時讀寫</h2>
<p>Race detector 的核心前提是測試要製造相關路徑。只建立 repository 卻不並發讀寫，race detector 沒有機會回報。</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">TestRepositoryConcurrentAccess</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">repo</span> <span class="o">:=</span> <span class="nf">NewUserRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">ctx</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">Background</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="kd">var</span> <span class="nx">wg</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">WaitGroup</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="mi">100</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">i</span> <span class="o">:=</span> <span class="nx">i</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">wg</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</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">10</span><span class="cl">            <span class="k">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="nx">id</span> <span class="o">:=</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Sprintf</span><span class="p">(</span><span class="s">&#34;user_%d&#34;</span><span class="p">,</span> <span class="nx">i</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="nx">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">User</span><span class="p">{</span><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">_</span><span class="p">,</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Find</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">id</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="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="nx">wg</span><span class="p">.</span><span class="nf">Wait</span><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>這個測試的主要斷言在「讓 race detector 執行共享 map 的讀寫路徑」。若 repository 忘記加 lock，<code>-race</code> 會指出問題。</p>
<h2 id="執行websocket-hub-也需要-race-path">【執行】WebSocket hub 也需要 race path</h2>
<p>WebSocket hub 的核心並發風險是 client 註冊、取消註冊、訂閱變更與 broadcast 可能同時發生。測試應讓這些路徑交錯執行。</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">TestHubConcurrentBroadcastAndUnregister</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">clients</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="o">*</span><span class="nx">Client</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">100</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="nx">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="mi">100</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">client</span> <span class="o">:=</span> <span class="nf">NewTestClient</span><span class="p">(</span><span class="nx">fmt</span><span class="p">.</span><span class="nf">Sprintf</span><span class="p">(</span><span class="s">&#34;client_%d&#34;</span><span class="p">,</span> <span class="nx">i</span><span class="p">),</span> <span class="mi">8</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</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"> 8</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"> 9</span><span class="cl">        <span class="nx">clients</span> <span class="p">=</span> <span class="nb">append</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">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="kd">var</span> <span class="nx">wg</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">WaitGroup</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">wg</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</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">16</span><span class="cl">        <span class="k">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="mi">100</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</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;notification&#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 class="p">}()</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">
</span></span><span class="line"><span class="ln">22</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">23</span><span class="cl">        <span class="k">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">client</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">clients</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</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">26</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="p">}()</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="nx">wg</span><span class="p">.</span><span class="nf">Wait</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試是否需要 lock，取決於 hub 的設計。如果 hub 保證所有操作都在單一 event loop 中執行，測試就應該透過 channel 操作，而不是直接呼叫未同步方法。測試要符合 ownership 設計，不應製造不被 API 允許的並發。</p>
<h2 id="判讀race-report-要看讀寫兩端">【判讀】race report 要看讀寫兩端</h2>
<p>Race report 的核心資訊是兩個位置：一端讀或寫，另一端寫。修正時不要只看最後一行，要找出是哪個共享資料缺少同步。</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">WARNING: DATA RACE
</span></span><span class="line"><span class="ln">2</span><span class="cl">Read at 0x...
</span></span><span class="line"><span class="ln">3</span><span class="cl">  example.com/app.(*UserRepository).Find()
</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">Previous write at 0x...
</span></span><span class="line"><span class="ln">6</span><span class="cl">  example.com/app.(*UserRepository).Save()</span></span></code></pre></div><p>這表示 <code>Find</code> 和 <code>Save</code> 同時碰到同一份資料，且缺少同步。修正方向是在 repository owner 補上 mutex、channel ownership 或其他同步邊界。</p>
<h2 id="策略修正方式要對應狀態形狀">【策略】修正方式要對應狀態形狀</h2>
<p>修正 data race 的核心選擇是建立正確同步邊界。常見方法有 mutex、channel owner、atomic。</p>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>適用情境</th>
          <th>注意事項</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>mutex</td>
          <td>多方法讀寫同一份 map/slice/state</td>
          <td>lock 要保護完整不變式</td>
      </tr>
      <tr>
          <td>channel owner</td>
          <td>狀態修改可集中成事件 loop</td>
          <td>要設計 reply、shutdown、<a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a></td>
      </tr>
      <tr>
          <td>atomic</td>
          <td>單一數值 counter 或 flag</td>
          <td>不適合複雜狀態</td>
      </tr>
  </tbody>
</table>
<p>Mutex 範例：</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">Counter</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">mu</span>    <span class="nx">sync</span><span class="p">.</span><span class="nx">Mutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">value</span> <span class="kt">int</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="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Counter</span><span class="p">)</span> <span class="nf">Inc</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">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"> 8</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"> 9</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">value</span><span class="o">++</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></span><span class="line"><span class="ln">12</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">Counter</span><span class="p">)</span> <span class="nf">Value</span><span class="p">()</span> <span class="kt">int</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</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">14</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">15</span><span class="cl">    <span class="k">return</span> <span class="nx">c</span><span class="p">.</span><span class="nx">value</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>鎖應該屬於擁有狀態的型別，並保護一個清楚的不變條件。只為了讓 race detector 安靜而到處加鎖，會讓 ownership 分散，後續仍然難以判斷資料一致性。</p>
<h2 id="判讀race-free-不代表行為正確">【判讀】race-free 不代表行為正確</h2>
<p>Race detector 的核心邊界是它只找 data race，不保證並發邏輯正確。沒有 data race 的程式仍可能 deadlock、漏訊息、順序錯誤、重複 close 或違反資料語意。</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">case</span> <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 class="p">:</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="c1">// drop</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式可能沒有 data race，但「<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> full 時丟訊息」是否正確是服務語意問題。Race detector 不會告訴你該丟、該斷線、還是該寫可靠 queue。</p>
<p>因此並發測試要分成兩層：</p>
<ul>
<li>用 <code>go test -race</code> 找未同步記憶體存取。</li>
<li>用行為測試檢查 channel close、queue full、context cancel、cleanup、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>。</li>
</ul>
<h2 id="測試把-race-check-納入固定流程">【測試】把 race check 納入固定流程</h2>
<p>Race check 的核心價值來自重複執行。只在出事後手動跑，效果有限。</p>
<p>建議流程：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go <span class="nb">test</span> ./...
</span></span><span class="line"><span class="ln">2</span><span class="cl">go <span class="nb">test</span> -race ./...</span></span></code></pre></div><p>日常開發可以先跑相關 package，提交前或 CI 跑完整 race suite。若 race suite 太慢，至少讓含有 hub、repository、worker、client state 的 package 固定跑 <code>-race</code>。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理共享 state、channel ownership 與 goroutine lifecycle 的 race 風險；lock-free 與完整 memory model，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/01-concurrency-patterns/shared-state/" data-link-title="1.4 共享狀態與複製邊界" data-link-desc="用 lock 與 copy 保護長期服務的狀態資料">Go 進階：共享狀態與複製邊界</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-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">Go 進階：select loop 的生命週期設計</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是共享狀態、channel ownership 與 lifecycle；如果你要先回看語言教材，可以讀：</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/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
</ul>
<h2 id="小結">小結</h2>
<p><code>go test -race</code> 是 Go 並發服務的基本安全網，但它只檢查測試執行到的 data race。你仍然需要設計清楚的 state owner、lock boundary、channel ownership 與行為測試。Race-free 不是正確性的全部；它只是可靠性的第一層檢查。</p>
]]></content:encoded></item><item><title>5.3 table-driven test</title><link>https://tarrragon.github.io/blog/go/05-error-testing/table-driven-test/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/05-error-testing/table-driven-test/</guid><description>&lt;p>table-driven test 的核心規則是：同一個行為的多組案例放進表格，測試流程只寫一次。本章將說明如何設計案例欄位、命名子測試，並避免把太多不同行為塞進同一張表。&lt;/p>
&lt;h2 id="table-driven-test-解決重複案例">table-driven test 解決重複案例&lt;/h2>
&lt;p>table-driven test 的核心目標是把「案例資料」和「測試流程」分開。當同一個函式需要測多組輸入與預期結果時，表格能讓案例集中呈現，測試流程只保留一次。&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">NormalizeName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">string&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">input&lt;/span> &lt;span class="p">=&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">input&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">return&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ToLower&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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">TestNormalizeName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">tests&lt;/span> &lt;span class="o">:=&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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span> &lt;span class="kt">string&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">input&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">want&lt;/span> &lt;span class="kt">string&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 class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;lowercase&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;alice&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;alice&amp;#34;&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 class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;trim spaces&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34; Alice &amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;alice&amp;#34;&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="p">{&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;empty&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&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">10&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">tests&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">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">got&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NormalizeName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">input&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="k">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">want&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="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;NormalizeName(%q) = %q, want %q&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">input&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">want&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="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="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 class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>表格中的每一列是一個案例，迴圈中的程式是共同驗證流程。新增案例時，只要新增一列，不必複製整個測試函式。&lt;/p>
&lt;h2 id="案例欄位要對應行為">案例欄位要對應行為&lt;/h2>
&lt;p>測試表格欄位的核心原則是只放描述案例所需的資料。常見欄位包括 &lt;code>name&lt;/code>、輸入值、預期輸出與是否預期錯誤。&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="nx">tests&lt;/span> &lt;span class="o">:=&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"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&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">input&lt;/span> &lt;span class="kt">string&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">want&lt;/span> &lt;span class="kt">int&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">wantErr&lt;/span> &lt;span class="kt">bool&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 class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;valid&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;8080&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">8080&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 class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;not number&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;abc&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">wantErr&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&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="p">{&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;zero&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;0&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">wantErr&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>wantErr&lt;/code> 表示這個案例預期出錯。它比把錯誤訊息塞進 &lt;code>want&lt;/code> 更清楚，因為成功結果與錯誤結果是兩種不同觀察。&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="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">tests&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">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">ParsePort&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">input&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">if&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">wantErr&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">if&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"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;ParsePort(%q) error = nil, want error&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">input&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="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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&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="kc">nil&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">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;ParsePort(%q) error = %v&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">input&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">13&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">want&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="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;ParsePort(%q) = %d, want %d&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">input&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">want&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="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="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;/code>&lt;/pre>&lt;/div>&lt;p>錯誤案例先處理並 &lt;code>return&lt;/code>，成功案例再繼續檢查輸出。這讓測試流程和函式行為一樣清楚：失敗時不應再比較正常結果。&lt;/p>
&lt;h2 id="trun-讓案例有名字">&lt;code>t.Run&lt;/code> 讓案例有名字&lt;/h2>
&lt;p>&lt;code>t.Run&lt;/code> 的核心作用是建立子測試，讓每個案例在測試輸出中有獨立名稱。當某個案例失敗時，工程師可以直接看到是哪一列資料出問題。&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="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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="c1">// case assertion&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;/code>&lt;/pre>&lt;/div>&lt;p>案例名稱應該描述情境，而不是描述編號。&lt;code>&amp;quot;empty input&amp;quot;&lt;/code>、&lt;code>&amp;quot;negative port&amp;quot;&lt;/code>、&lt;code>&amp;quot;trim spaces&amp;quot;&lt;/code> 比 &lt;code>&amp;quot;case 1&amp;quot;&lt;/code> 更有定位價值。&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="nx">tests&lt;/span> &lt;span class="o">:=&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">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&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">input&lt;/span> &lt;span class="kt">string&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">want&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="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 class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;trim spaces&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34; Alice &amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;alice&amp;#34;&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="p">{&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;preserve hyphen&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;Mary-Jane&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;mary-jane&amp;#34;&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;/code>&lt;/pre>&lt;/div>&lt;p>當測試失敗時，名稱會出現在 &lt;code>go test&lt;/code> 輸出中。好的案例名稱能讓讀者先理解失敗情境，再去看 got/want 差異。&lt;/p>
&lt;h2 id="表格應集中在單一行為">表格應集中在單一行為&lt;/h2>
&lt;p>table-driven test 的邊界是「同一個測試流程是否能自然描述所有案例」。如果某些案例需要完全不同的準備、執行或驗證方式，通常應該拆成不同測試。&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">TestParsePort&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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="c1">// 測 ParsePort 的輸入輸出規則&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="nf">TestLoadConfig&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">6&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 測 LoadConfig 的檔案讀取與解析流程&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>把不同行為硬塞進同一張表，會讓欄位越來越多，最後出現大量只在少數案例使用的欄位。這種表格看起來少了重複，實際上讓讀者更難理解每個案例。&lt;/p>
&lt;p>好的表格應該短而集中。若你需要在測試迴圈裡寫很多 &lt;code>if tt.someMode&lt;/code>，這通常是拆分測試的訊號。&lt;/p>
&lt;h2 id="比較複雜資料時使用合適工具">比較複雜資料時使用合適工具&lt;/h2>
&lt;p>比較結果的核心原則是選擇能清楚表達差異的方式。基本型別可以直接用 &lt;code>!=&lt;/code>，slice、map、struct 則常用 &lt;code>reflect.DeepEqual&lt;/code> 或專門的比較工具。&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">SplitCSV&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&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">if&lt;/span> &lt;span class="nx">input&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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&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">parts&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">Split&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&lt;/span>&lt;span class="p">,&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">parts&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="nx">parts&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">i&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&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">parts&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">i&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&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="nx">parts&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>測試 slice 時，不能直接用 &lt;code>got != want&lt;/code>。&lt;/p></description><content:encoded><![CDATA[<p>table-driven test 的核心規則是：同一個行為的多組案例放進表格，測試流程只寫一次。本章將說明如何設計案例欄位、命名子測試，並避免把太多不同行為塞進同一張表。</p>
<h2 id="table-driven-test-解決重複案例">table-driven test 解決重複案例</h2>
<p>table-driven test 的核心目標是把「案例資料」和「測試流程」分開。當同一個函式需要測多組輸入與預期結果時，表格能讓案例集中呈現，測試流程只保留一次。</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">NormalizeName</span><span class="p">(</span><span class="nx">input</span> <span class="kt">string</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">input</span> <span class="p">=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">return</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">ToLower</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><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">TestNormalizeName</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">tests</span> <span class="o">:=</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">name</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">input</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">want</span>  <span class="kt">string</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 class="nx">name</span><span class="p">:</span> <span class="s">&#34;lowercase&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;alice&#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;alice&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;trim spaces&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;  Alice  &#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;alice&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;empty&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;&#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;&#34;</span><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></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</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">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</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">14</span><span class="cl">            <span class="nx">got</span> <span class="o">:=</span> <span class="nf">NormalizeName</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">input</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">got</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</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;NormalizeName(%q) = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">input</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</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><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></code></pre></div><p>表格中的每一列是一個案例，迴圈中的程式是共同驗證流程。新增案例時，只要新增一列，不必複製整個測試函式。</p>
<h2 id="案例欄位要對應行為">案例欄位要對應行為</h2>
<p>測試表格欄位的核心原則是只放描述案例所需的資料。常見欄位包括 <code>name</code>、輸入值、預期輸出與是否預期錯誤。</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="nx">tests</span> <span class="o">:=</span> <span class="p">[]</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">name</span>    <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">input</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">want</span>    <span class="kt">int</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">wantErr</span> <span class="kt">bool</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 class="nx">name</span><span class="p">:</span> <span class="s">&#34;valid&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;8080&#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="mi">8080</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;not number&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;abc&#34;</span><span class="p">,</span> <span class="nx">wantErr</span><span class="p">:</span> <span class="kc">true</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;zero&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;0&#34;</span><span class="p">,</span> <span class="nx">wantErr</span><span class="p">:</span> <span class="kc">true</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>wantErr</code> 表示這個案例預期出錯。它比把錯誤訊息塞進 <code>want</code> 更清楚，因為成功結果與錯誤結果是兩種不同觀察。</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="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</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"> 3</span><span class="cl">        <span class="nx">got</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">ParsePort</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">input</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">if</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">wantErr</span> <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">err</span> <span class="o">==</span> <span class="kc">nil</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;ParsePort(%q) error = nil, want error&#34;</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">input</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="k">return</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></span><span class="line"><span class="ln">11</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">12</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;ParsePort(%q) error = %v&#34;</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">input</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</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;ParsePort(%q) = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">input</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</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><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>錯誤案例先處理並 <code>return</code>，成功案例再繼續檢查輸出。這讓測試流程和函式行為一樣清楚：失敗時不應再比較正常結果。</p>
<h2 id="trun-讓案例有名字"><code>t.Run</code> 讓案例有名字</h2>
<p><code>t.Run</code> 的核心作用是建立子測試，讓每個案例在測試輸出中有獨立名稱。當某個案例失敗時，工程師可以直接看到是哪一列資料出問題。</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="nx">t</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</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="c1">// case assertion</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><p>案例名稱應該描述情境，而不是描述編號。<code>&quot;empty input&quot;</code>、<code>&quot;negative port&quot;</code>、<code>&quot;trim spaces&quot;</code> 比 <code>&quot;case 1&quot;</code> 更有定位價值。</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="nx">tests</span> <span class="o">:=</span> <span class="p">[]</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">name</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">input</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">want</span>  <span class="kt">string</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 class="nx">name</span><span class="p">:</span> <span class="s">&#34;trim spaces&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;  Alice  &#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;alice&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;preserve hyphen&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;Mary-Jane&#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;mary-jane&#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></code></pre></div><p>當測試失敗時，名稱會出現在 <code>go test</code> 輸出中。好的案例名稱能讓讀者先理解失敗情境，再去看 got/want 差異。</p>
<h2 id="表格應集中在單一行為">表格應集中在單一行為</h2>
<p>table-driven test 的邊界是「同一個測試流程是否能自然描述所有案例」。如果某些案例需要完全不同的準備、執行或驗證方式，通常應該拆成不同測試。</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">TestParsePort</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="c1">// 測 ParsePort 的輸入輸出規則</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="nf">TestLoadConfig</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">6</span><span class="cl">    <span class="c1">// 測 LoadConfig 的檔案讀取與解析流程</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>把不同行為硬塞進同一張表，會讓欄位越來越多，最後出現大量只在少數案例使用的欄位。這種表格看起來少了重複，實際上讓讀者更難理解每個案例。</p>
<p>好的表格應該短而集中。若你需要在測試迴圈裡寫很多 <code>if tt.someMode</code>，這通常是拆分測試的訊號。</p>
<h2 id="比較複雜資料時使用合適工具">比較複雜資料時使用合適工具</h2>
<p>比較結果的核心原則是選擇能清楚表達差異的方式。基本型別可以直接用 <code>!=</code>，slice、map、struct 則常用 <code>reflect.DeepEqual</code> 或專門的比較工具。</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">SplitCSV</span><span class="p">(</span><span class="nx">input</span> <span class="kt">string</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="k">if</span> <span class="nx">input</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</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">parts</span> <span class="o">:=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">Split</span><span class="p">(</span><span class="nx">input</span><span class="p">,</span> <span class="s">&#34;,&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">parts</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">parts</span><span class="p">[</span><span class="nx">i</span><span class="p">]</span> <span class="p">=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">parts</span><span class="p">[</span><span class="nx">i</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">return</span> <span class="nx">parts</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>測試 slice 時，不能直接用 <code>got != want</code>。</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">TestSplitCSV</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">tests</span> <span class="o">:=</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">name</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">input</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">want</span>  <span class="p">[]</span><span class="kt">string</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 class="nx">name</span><span class="p">:</span> <span class="s">&#34;empty&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;&#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="kc">nil</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;two values&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;a, b&#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;a&#34;</span><span class="p">,</span> <span class="s">&#34;b&#34;</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</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">13</span><span class="cl">            <span class="nx">got</span> <span class="o">:=</span> <span class="nf">SplitCSV</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">input</span><span class="p">)</span>
</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">reflect</span><span class="p">.</span><span class="nf">DeepEqual</span><span class="p">(</span><span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</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;SplitCSV(%q) = %#v, want %#v&#34;</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">input</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</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><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><code>reflect.DeepEqual</code> 適合入門與標準庫範例。大型專案可能使用第三方比較工具產生更好的 diff，但核心原則不變：失敗訊息要讓差異容易看懂。</p>
<h2 id="下一章">下一章</h2>
<p>下一章會把測試方法套到 HTTP handler，說明如何不用啟動真實 server 也能驗證請求與回應。</p>
]]></content:encoded></item><item><title>6.3 如何擴展狀態投影欄位</title><link>https://tarrragon.github.io/blog/go/06-practical/state-fields/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/06-practical/state-fields/</guid><description>&lt;p>擴展狀態投影欄位的核心流程是先確認欄位屬於 domain state、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model&lt;/a> 還是 response view。欄位加在哪一層，會決定寫入規則、相容性與測試方式。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 domain state、read model 與 response view&lt;/li>
&lt;li>判斷新欄位的零值是否有語意&lt;/li>
&lt;li>把狀態轉移集中在 repository 或 state owner&lt;/li>
&lt;li>用 copy boundary 保護內部 slice/map&lt;/li>
&lt;li>測試 state transition、repository copy 與 JSON response&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察先判斷欄位屬於哪一層">【觀察】先判斷欄位屬於哪一層&lt;/h2>
&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>domain state&lt;/td>
 &lt;td>影響業務規則與狀態轉移&lt;/td>
 &lt;td>job 是否 running、failed、completed&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a>/read model&lt;/td>
 &lt;td>方便查詢、列表或即時顯示&lt;/td>
 &lt;td>最近更新時間、目前進度百分比&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>response view&lt;/td>
 &lt;td>只影響對外輸出格式&lt;/td>
 &lt;td>顯示文字、前端用 badge 顏色&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>例如「job 狀態」是 domain state，因為它會影響是否能重試、取消或完成。相反地，「狀態顯示文字」通常是 response view，因為它只是把內部狀態轉成 client 更容易顯示的文字。&lt;/p>
&lt;p>本章使用一個簡化的 job 狀態投影作為範例。事件進入系統後，repository 會把事件套用成目前 projection，再由 response layer 輸出給 client。&lt;/p>
&lt;h2 id="判讀domain-state-要用明確型別">【判讀】domain state 要用明確型別&lt;/h2>
&lt;p>domain state 的核心規則是用型別表達可用狀態，而不是讓任意字串在系統裡流動。當欄位會影響規則時，應該優先考慮 typed constant。&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">JobStatus&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">JobStatusPending&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;pending&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">JobStatusRunning&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;running&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">JobStatusSucceeded&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;succeeded&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="nx">JobStatusFailed&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;failed&amp;#34;&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>接著定義狀態投影：&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">JobProjection&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">Status&lt;/span> &lt;span class="nx">JobStatus&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">Progress&lt;/span> &lt;span class="kt">int&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">UpdatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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">FinishedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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>&lt;code>Status&lt;/code> 是 domain state。&lt;code>Progress&lt;/code> 可以是 read model 欄位，代表目前顯示用進度。&lt;code>FinishedAt&lt;/code> 需要進一步判斷：如果完成時間會影響重試、保留時間或排序，它就不只是 response 欄位。&lt;/p>
&lt;p>零值也要有語意。&lt;code>FinishedAt&lt;/code> 的零值可以代表「尚未完成」，但這個語意必須被程式明確處理；若零值會造成混淆，可以改用 pointer：&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">JobProjection&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">Status&lt;/span> &lt;span class="nx">JobStatus&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">Progress&lt;/span> &lt;span class="kt">int&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">UpdatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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">FinishedAt&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">Time&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>pointer 在這裡的用途是區分「沒有值」和「有一個零值」。時間、數字與 bool 欄位最常遇到這個問題。&lt;/p>
&lt;h2 id="策略狀態轉移要集中在同一個入口">【策略】狀態轉移要集中在同一個入口&lt;/h2>
&lt;p>狀態轉移的核心規則是所有寫入都經過同一組方法。handler、worker 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> router 應把狀態變更交給 repository 或 state owner，而不是自行修改 map 或 projection 欄位。&lt;/p>
&lt;p>先定義內部 event：&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">JobEvent&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">JobID&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">Type&lt;/span> &lt;span class="kt">string&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">Progress&lt;/span> &lt;span class="kt">int&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">OccurredAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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;p>repository 可以集中套用事件：&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">JobRepository&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">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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">jobs&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="nx">JobProjection&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="kd">func&lt;/span> &lt;span class="nf">NewJobRepository&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">JobRepository&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">return&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">JobRepository&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="nx">jobs&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="nx">JobProjection&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="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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>Apply&lt;/code> 是狀態寫入入口：&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">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">JobRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Apply&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span> &lt;span class="nx">JobEvent&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"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Lock&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">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Unlock&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">jobs&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">JobID&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">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">JobID&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">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UpdatedAt&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">OccurredAt&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="k">switch&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&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">case&lt;/span> &lt;span class="s">&amp;#34;job.started&amp;#34;&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">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Status&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">JobStatusRunning&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">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Progress&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">0&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">case&lt;/span> &lt;span class="s">&amp;#34;job.progressed&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">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Progress&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Progress&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="s">&amp;#34;job.succeeded&amp;#34;&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="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Status&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">JobStatusSucceeded&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Progress&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">100&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">finishedAt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">OccurredAt&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">FinishedAt&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">finishedAt&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="s">&amp;#34;job.failed&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Status&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">JobStatusFailed&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">finishedAt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">OccurredAt&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">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">FinishedAt&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">finishedAt&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&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">25&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;unsupported job event type %q&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">jobs&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">JobID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">job&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式讓狀態轉移規則集中在 repository。未來如果要禁止 failed job 重新變成 running，或要求 progress 不可倒退，可以在同一個入口加規則與測試。&lt;/p></description><content:encoded><![CDATA[<p>擴展狀態投影欄位的核心流程是先確認欄位屬於 domain state、<a href="/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model</a> 還是 response view。欄位加在哪一層，會決定寫入規則、相容性與測試方式。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 domain state、read model 與 response view</li>
<li>判斷新欄位的零值是否有語意</li>
<li>把狀態轉移集中在 repository 或 state owner</li>
<li>用 copy boundary 保護內部 slice/map</li>
<li>測試 state transition、repository copy 與 JSON response</li>
</ol>
<hr>
<h2 id="觀察先判斷欄位屬於哪一層">【觀察】先判斷欄位屬於哪一層</h2>
<p>狀態欄位的核心問題是「這個欄位代表哪一種資料責任」。同一個欄位放在不同層，代表不同寫入規則與相容性承諾。</p>
<table>
  <thead>
      <tr>
          <th>層次</th>
          <th>意義</th>
          <th>範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>domain state</td>
          <td>影響業務規則與狀態轉移</td>
          <td>job 是否 running、failed、completed</td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a>/read model</td>
          <td>方便查詢、列表或即時顯示</td>
          <td>最近更新時間、目前進度百分比</td>
      </tr>
      <tr>
          <td>response view</td>
          <td>只影響對外輸出格式</td>
          <td>顯示文字、前端用 badge 顏色</td>
      </tr>
  </tbody>
</table>
<p>例如「job 狀態」是 domain state，因為它會影響是否能重試、取消或完成。相反地，「狀態顯示文字」通常是 response view，因為它只是把內部狀態轉成 client 更容易顯示的文字。</p>
<p>本章使用一個簡化的 job 狀態投影作為範例。事件進入系統後，repository 會把事件套用成目前 projection，再由 response layer 輸出給 client。</p>
<h2 id="判讀domain-state-要用明確型別">【判讀】domain state 要用明確型別</h2>
<p>domain state 的核心規則是用型別表達可用狀態，而不是讓任意字串在系統裡流動。當欄位會影響規則時，應該優先考慮 typed constant。</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">JobStatus</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">JobStatusPending</span>   <span class="nx">JobStatus</span> <span class="p">=</span> <span class="s">&#34;pending&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">JobStatusRunning</span>   <span class="nx">JobStatus</span> <span class="p">=</span> <span class="s">&#34;running&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">JobStatusSucceeded</span> <span class="nx">JobStatus</span> <span class="p">=</span> <span class="s">&#34;succeeded&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nx">JobStatusFailed</span>    <span class="nx">JobStatus</span> <span class="p">=</span> <span class="s">&#34;failed&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><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">type</span> <span class="nx">JobProjection</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">Status</span>     <span class="nx">JobStatus</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Progress</span>   <span class="kt">int</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">UpdatedAt</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">FinishedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>Status</code> 是 domain state。<code>Progress</code> 可以是 read model 欄位，代表目前顯示用進度。<code>FinishedAt</code> 需要進一步判斷：如果完成時間會影響重試、保留時間或排序，它就不只是 response 欄位。</p>
<p>零值也要有語意。<code>FinishedAt</code> 的零值可以代表「尚未完成」，但這個語意必須被程式明確處理；若零值會造成混淆，可以改用 pointer：</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">JobProjection</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">Status</span>     <span class="nx">JobStatus</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Progress</span>   <span class="kt">int</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">UpdatedAt</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">FinishedAt</span> <span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>pointer 在這裡的用途是區分「沒有值」和「有一個零值」。時間、數字與 bool 欄位最常遇到這個問題。</p>
<h2 id="策略狀態轉移要集中在同一個入口">【策略】狀態轉移要集中在同一個入口</h2>
<p>狀態轉移的核心規則是所有寫入都經過同一組方法。handler、worker 或 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> router 應把狀態變更交給 repository 或 state owner，而不是自行修改 map 或 projection 欄位。</p>
<p>先定義內部 event：</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">JobEvent</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">JobID</span>      <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Type</span>       <span class="kt">string</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Progress</span>   <span class="kt">int</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">OccurredAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>repository 可以集中套用事件：</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">JobRepository</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">mu</span>   <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">jobs</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">JobProjection</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="kd">func</span> <span class="nf">NewJobRepository</span><span class="p">()</span> <span class="o">*</span><span class="nx">JobRepository</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="o">&amp;</span><span class="nx">JobRepository</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">jobs</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="nx">JobProjection</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="p">}</span></span></span></code></pre></div><p><code>Apply</code> 是狀態寫入入口：</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">r</span> <span class="o">*</span><span class="nx">JobRepository</span><span class="p">)</span> <span class="nf">Apply</span><span class="p">(</span><span class="nx">event</span> <span class="nx">JobEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</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"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</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"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">job</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">jobs</span><span class="p">[</span><span class="nx">event</span><span class="p">.</span><span class="nx">JobID</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">job</span><span class="p">.</span><span class="nx">ID</span> <span class="p">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">JobID</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">job</span><span class="p">.</span><span class="nx">UpdatedAt</span> <span class="p">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</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">switch</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">case</span> <span class="s">&#34;job.started&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">JobStatusRunning</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">job</span><span class="p">.</span><span class="nx">Progress</span> <span class="p">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">case</span> <span class="s">&#34;job.progressed&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">job</span><span class="p">.</span><span class="nx">Progress</span> <span class="p">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Progress</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">case</span> <span class="s">&#34;job.succeeded&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">JobStatusSucceeded</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">job</span><span class="p">.</span><span class="nx">Progress</span> <span class="p">=</span> <span class="mi">100</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">finishedAt</span> <span class="o">:=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="p">=</span> <span class="o">&amp;</span><span class="nx">finishedAt</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">case</span> <span class="s">&#34;job.failed&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">JobStatusFailed</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="nx">finishedAt</span> <span class="o">:=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="p">=</span> <span class="o">&amp;</span><span class="nx">finishedAt</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">25</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;unsupported job event type %q&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">jobs</span><span class="p">[</span><span class="nx">event</span><span class="p">.</span><span class="nx">JobID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">job</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式讓狀態轉移規則集中在 repository。未來如果要禁止 failed job 重新變成 running，或要求 progress 不可倒退，可以在同一個入口加規則與測試。</p>
<h2 id="判讀read-model-可以為查詢服務">【判讀】read model 可以為查詢服務</h2>
<p>read model 的核心用途是讓查詢與顯示更直接。它不一定等同完整 domain state，而是為某種讀取需求整理出的投影。</p>
<p>例如列表頁可能只需要 summary：</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">JobSummary</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">Status</span>    <span class="nx">JobStatus</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Progress</span>  <span class="kt">int</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">UpdatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>repository 可以提供查詢方法：</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">r</span> <span class="o">*</span><span class="nx">JobRepository</span><span class="p">)</span> <span class="nf">ListSummaries</span><span class="p">()</span> <span class="p">[]</span><span class="nx">JobSummary</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</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">r</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">result</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="nx">JobSummary</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">r</span><span class="p">.</span><span class="nx">jobs</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">_</span><span class="p">,</span> <span class="nx">job</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">r</span><span class="p">.</span><span class="nx">jobs</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">result</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">result</span><span class="p">,</span> <span class="nx">JobSummary</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="nx">ID</span><span class="p">:</span>        <span class="nx">job</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="nx">Status</span><span class="p">:</span>    <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="nx">Progress</span><span class="p">:</span>  <span class="nx">job</span><span class="p">.</span><span class="nx">Progress</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="nx">UpdatedAt</span><span class="p">:</span> <span class="nx">job</span><span class="p">.</span><span class="nx">UpdatedAt</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><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">return</span> <span class="nx">result</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個方法回傳新 slice，而不是暴露 repository 內部資料。若 read model 包含 slice、map 或 pointer，也要確認呼叫端不能修改內部狀態。</p>
<h2 id="執行讀取方法要保護-copy-boundary">【執行】讀取方法要保護 copy boundary</h2>
<p>copy boundary 的核心目標是避免外部呼叫者修改 repository 內部資料。Go 的 slice、map、pointer 都可能讓內部狀態外洩。</p>
<p>單筆查詢可以回傳值與 bool：</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">r</span> <span class="o">*</span><span class="nx">JobRepository</span><span class="p">)</span> <span class="nf">Get</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">JobProjection</span><span class="p">,</span> <span class="kt">bool</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">r</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">r</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">job</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">jobs</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</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"> 7</span><span class="cl">        <span class="k">return</span> <span class="nx">JobProjection</span><span class="p">{},</span> <span class="kc">false</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">return</span> <span class="nf">cloneJobProjection</span><span class="p">(</span><span class="nx">job</span><span class="p">),</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>若 struct 內含 pointer，clone 函式要複製 pointer 指向的值：</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">cloneJobProjection</span><span class="p">(</span><span class="nx">job</span> <span class="nx">JobProjection</span><span class="p">)</span> <span class="nx">JobProjection</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">cloned</span> <span class="o">:=</span> <span class="nx">job</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">if</span> <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">finishedAt</span> <span class="o">:=</span> <span class="o">*</span><span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="nx">cloned</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="p">=</span> <span class="o">&amp;</span><span class="nx">finishedAt</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="k">return</span> <span class="nx">cloned</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 clone 看似瑣碎，但它保護了 repository 的擁有權。呼叫端拿到資料後，即使修改 <code>FinishedAt</code> 指向的時間，也不會影響 repository 內部狀態。</p>
<h2 id="策略response-view-負責對外格式">【策略】response view 負責對外格式</h2>
<p>response view 的核心責任是把內部狀態轉成外部 contract。JSON tag、<code>omitempty</code>、顯示文字與相容性都應該在 response struct 中處理，讓 domain model 保持在業務狀態語意上。</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">JobResponse</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 class="s">`json:&#34;id&#34;`</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Status</span>      <span class="nx">JobStatus</span>  <span class="s">`json:&#34;status&#34;`</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Progress</span>    <span class="kt">int</span>        <span class="s">`json:&#34;progress&#34;`</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">UpdatedAt</span>   <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>  <span class="s">`json:&#34;updatedAt&#34;`</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">FinishedAt</span>  <span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span> <span class="s">`json:&#34;finishedAt,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nx">DisplayText</span> <span class="kt">string</span>     <span class="s">`json:&#34;displayText,omitempty&#34;`</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>轉換函式可以集中 response 規則：</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">NewJobResponse</span><span class="p">(</span><span class="nx">job</span> <span class="nx">JobProjection</span><span class="p">)</span> <span class="nx">JobResponse</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">return</span> <span class="nx">JobResponse</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="nx">job</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">Status</span><span class="p">:</span>      <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">Progress</span><span class="p">:</span>    <span class="nx">job</span><span class="p">.</span><span class="nx">Progress</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">UpdatedAt</span><span class="p">:</span>   <span class="nx">job</span><span class="p">.</span><span class="nx">UpdatedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">FinishedAt</span><span class="p">:</span>  <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">DisplayText</span><span class="p">:</span> <span class="nf">displayText</span><span class="p">(</span><span class="nx">job</span><span class="p">.</span><span class="nx">Status</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="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">func</span> <span class="nf">displayText</span><span class="p">(</span><span class="nx">status</span> <span class="nx">JobStatus</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">switch</span> <span class="nx">status</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">JobStatusPending</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="s">&#34;Waiting&#34;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">case</span> <span class="nx">JobStatusRunning</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="s">&#34;Running&#34;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">case</span> <span class="nx">JobStatusSucceeded</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="s">&#34;Completed&#34;</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">case</span> <span class="nx">JobStatusFailed</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;Failed&#34;</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;Unknown&#34;</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 class="p">}</span></span></span></code></pre></div><p><code>DisplayText</code> 是 response view，負責呈現用文字。若未來前端改文案，response 轉換函式可以調整，repository 狀態轉移規則應保持穩定。</p>
<h2 id="判讀omitempty-是相容性語意">【判讀】<code>omitempty</code> 是相容性語意</h2>
<p><code>omitempty</code> 的核心語意是欄位在某些情境中可以不存在。它在對外 contract 中表示「這個欄位可能不存在」，而不是只為了縮短 JSON。</p>
<p>例如 <code>FinishedAt</code> 只有 job 完成或失敗後才有值：</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="nx">FinishedAt</span> <span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span> <span class="s">`json:&#34;finishedAt,omitempty&#34;`</span></span></span></code></pre></div><p>舊 client 如果不知道 <code>finishedAt</code>，通常會忽略新欄位。新 client 如果需要這個欄位，也必須處理它不存在的情況。</p>
<p>必填欄位的 JSON contract 應保持穩定輸出。<code>id</code> 或 <code>status</code> 這類欄位消失會讓 client 無法理解資料，因此它們屬於必要欄位，應維持固定輸出。</p>
<h2 id="執行state-transition-測試要鎖定規則">【執行】state transition 測試要鎖定規則</h2>
<p>state transition 測試的核心目標是確認事件會產生正確狀態。這類測試不需要 HTTP，也不需要 WebSocket。</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">TestJobRepositoryApplySucceeded</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">repo</span> <span class="o">:=</span> <span class="nf">NewJobRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">finishedAt</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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">repo</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">JobEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">JobID</span><span class="p">:</span>      <span class="s">&#34;job_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>       <span class="s">&#34;job.succeeded&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">finishedAt</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;apply event: %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="nx">job</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;job_1&#34;</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="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="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;job should exist&#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="k">if</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span> <span class="o">!=</span> <span class="nx">JobStatusSucceeded</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">19</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;status = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span><span class="p">,</span> <span class="nx">JobStatusSucceeded</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="k">if</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Progress</span> <span class="o">!=</span> <span class="mi">100</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</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;progress = %d, want 100&#34;</span><span class="p">,</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Progress</span><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="k">if</span> <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="o">==</span> <span class="kc">nil</span> <span class="o">||</span> <span class="p">!</span><span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span><span class="p">.</span><span class="nf">Equal</span><span class="p">(</span><span class="nx">finishedAt</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</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;finished at = %v, want %v&#34;</span><span class="p">,</span> <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span><span class="p">,</span> <span class="nx">finishedAt</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <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>這個測試保護的是狀態規則，而不是輸出 JSON 格式。</p>
<h2 id="執行copy-boundary-測試要嘗試修改回傳值">【執行】copy boundary 測試要嘗試修改回傳值</h2>
<p>copy boundary 測試的核心目標是證明呼叫端不能透過回傳資料改到 repository 內部狀態。</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">TestJobRepositoryGetReturnsCopy</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">repo</span> <span class="o">:=</span> <span class="nf">NewJobRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">finishedAt</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">JobEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">JobID</span><span class="p">:</span>      <span class="s">&#34;job_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>       <span class="s">&#34;job.succeeded&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">finishedAt</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">job</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;job_1&#34;</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="p">!</span><span class="nx">ok</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;job should exist&#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></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">changed</span> <span class="o">:=</span> <span class="nx">finishedAt</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Hour</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="p">=</span> <span class="o">&amp;</span><span class="nx">changed</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">again</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;job_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">if</span> <span class="nx">again</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="o">==</span> <span class="kc">nil</span> <span class="o">||</span> <span class="p">!</span><span class="nx">again</span><span class="p">.</span><span class="nx">FinishedAt</span><span class="p">.</span><span class="nf">Equal</span><span class="p">(</span><span class="nx">finishedAt</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</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;repository state was modified through returned value&#34;</span><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>這種測試對 map、slice、pointer 特別重要。值型別欄位通常不需要額外 clone，但一旦 struct 包含可變參照，就要測邊界。</p>
<h2 id="執行response-json-測試要檢查-contract">【執行】response JSON 測試要檢查 contract</h2>
<p>response 測試的核心目標是確認對外 JSON 欄位符合 contract。測試應該解析 JSON 或檢查欄位存在性，而不是只比對整段字串。</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">TestJobResponseOmitsFinishedAtWhenNil</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">response</span> <span class="o">:=</span> <span class="nf">NewJobResponse</span><span class="p">(</span><span class="nx">JobProjection</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;job_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">Status</span><span class="p">:</span>    <span class="nx">JobStatusRunning</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">Progress</span><span class="p">:</span>  <span class="mi">40</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">UpdatedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">Marshal</span><span class="p">(</span><span class="nx">response</span><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;marshal response: %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="kd">var</span> <span class="nx">body</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">any</span>
</span></span><span class="line"><span class="ln">15</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">data</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">body</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">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;unmarshal response: %v&#34;</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln">19</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">body</span><span class="p">[</span><span class="s">&#34;finishedAt&#34;</span><span class="p">];</span> <span class="nx">ok</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</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;finishedAt should be omitted when nil&#34;</span><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>這個測試是測對外承諾的欄位語意。</p>
<h2 id="實作檢查清單">實作檢查清單</h2>
<p>擴展狀態投影欄位時，可以依序檢查：</p>
<ol>
<li>欄位屬於 domain state、read model 還是 response view</li>
<li>零值是否有明確語意</li>
<li>是否需要 typed constant</li>
<li>寫入是否集中在 repository 或 state owner</li>
<li>handler、router、worker 是否沒有直接修改內部 map/slice</li>
<li>查詢是否回傳 copy</li>
<li>response 是否使用正確 JSON tag</li>
<li><code>omitempty</code> 是否真的代表可選欄位</li>
<li>測試是否分成 state transition、copy boundary、response JSON</li>
</ol>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一顯示欄位放在-response-view">檢查一：顯示欄位放在 response view</h3>
<p>顯示文字、顏色或前端 badge 通常是 response view。只有影響業務規則的欄位，才需要進入 domain state。</p>
<h3 id="檢查二handler-透過狀態入口修改-projection">檢查二：handler 透過狀態入口修改 projection</h3>
<p>handler 透過 repository 或 state owner 修改 projection，可以讓狀態規則集中。handler 直接改 projection 時，新增第二個入口容易漏掉同一套規則。</p>
<h3 id="檢查三回傳資料保護-copy-boundary">檢查三：回傳資料保護 copy boundary</h3>
<p>只要呼叫端能修改 repository 內部資料，狀態邊界就失效。回傳值時要檢查是否需要 clone。</p>
<h3 id="檢查四omitempty-對應可選欄位">檢查四：<code>omitempty</code> 對應可選欄位</h3>
<p>必填欄位加上 <code>omitempty</code> 會讓 response contract 變模糊。欄位是否可省略，應由資料語意決定，而不是由欄位零值方便性決定。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理狀態欄位如何影響 response contract；資料庫 <a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration</a> 與前端相容性策略，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Go 進階：Source of Truth：狀態邊界</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">Go 進階：資料庫 transaction 與 schema migration</a></li>
<li><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫與持久化</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 repository、event 與 response view 的邊界；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</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/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
<li><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Go 進階：Source of Truth</a></li>
</ul>
]]></content:encoded></item><item><title>6.3 結構化日誌欄位設計</title><link>https://tarrragon.github.io/blog/go-advanced/06-production-operations/log-fields/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/06-production-operations/log-fields/</guid><description>&lt;p>結構化日誌欄位的核心目標是讓 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a> 可查詢、可聚合、可追蹤。Message 給人讀，欄位給系統查；重要資訊應放在穩定欄位，不應只藏在自由文字裡。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>設計穩定 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log-schema/" data-link-title="Log Schema" data-link-desc="說明結構化 log 欄位如何支援搜尋、關聯與事故排查">log schema&lt;/a>&lt;/li>
&lt;li>用 &lt;code>layer&lt;/code>、&lt;code>request_id&lt;/code>、&lt;code>event_type&lt;/code>、&lt;code>reason&lt;/code> 支援查詢&lt;/li>
&lt;li>區分 message 與 structured fields 的責任&lt;/li>
&lt;li>避免重複記錄同一個錯誤&lt;/li>
&lt;li>避免把敏感資料寫進 log&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察自由文字-log-很難查詢">【觀察】自由文字 log 很難查詢&lt;/h2>
&lt;p>Log 設計的核心問題是事故發生時需要快速查詢。若所有資訊都在 message 裡，查詢只能依賴模糊字串。&lt;/p>
&lt;p>不穩定 log：&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="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;event accepted for user 123 request abc&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這行給人看可以，但系統很難穩定查 &lt;code>request_id=abc&lt;/code> 或 &lt;code>user_id=123&lt;/code>。不同工程師改字句後，查詢就可能失效。&lt;/p>
&lt;p>結構化 log：&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="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;event accepted&amp;#34;&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="s">&amp;#34;layer&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;http&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="s">&amp;#34;request_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">requestID&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="s">&amp;#34;user_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">userID&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="s">&amp;#34;event_type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&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;/code>&lt;/pre>&lt;/div>&lt;p>Message 描述發生什麼事，欄位提供可查詢資料。這是 log schema 的基本分工。&lt;/p>
&lt;h2 id="判讀log-schema-是查詢合約">【判讀】log schema 是查詢合約&lt;/h2>
&lt;p>Log schema 的核心規則是欄位名稱與值集合要穩定。&lt;code>request_id&lt;/code>、&lt;code>requestID&lt;/code>、&lt;code>rid&lt;/code> 混用會讓查詢與儀表板變得困難。&lt;/p>
&lt;p>常用欄位：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>欄位&lt;/th>
 &lt;th>用途&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>layer&lt;/code>&lt;/td>
 &lt;td>問題發生在哪個系統層&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>request_id&lt;/code>&lt;/td>
 &lt;td>串起單次 HTTP request&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>event_id&lt;/code>&lt;/td>
 &lt;td>串起事件處理流程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>event_type&lt;/code>&lt;/td>
 &lt;td>聚合某類 domain event&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>client_id&lt;/code>&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> client 行為&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>topic&lt;/code>&lt;/td>
 &lt;td>查訂閱或推送範圍&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>reason&lt;/code>&lt;/td>
 &lt;td>聚合失敗原因&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>error&lt;/code>&lt;/td>
 &lt;td>保存錯誤文字&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>欄位不需要很多，但要一致。穩定欄位能讓除錯從「讀一堆文字」變成「查一組條件」。&lt;/p>
&lt;h2 id="執行layer-表示發生位置">【執行】layer 表示發生位置&lt;/h2>
&lt;p>&lt;code>layer&lt;/code> 的核心用途是標示 log 來自哪個系統層，協助工程師快速縮小問題範圍。&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="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Warn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;queue full&amp;#34;&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="s">&amp;#34;layer&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;worker&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="s">&amp;#34;queue&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;events&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="s">&amp;#34;reason&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;buffer_full&amp;#34;&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;/code>&lt;/pre>&lt;/div>&lt;p>常見 layer：&lt;/p>
&lt;ul>
&lt;li>&lt;code>http&lt;/code>&lt;/li>
&lt;li>&lt;code>websocket&lt;/code>&lt;/li>
&lt;li>&lt;code>worker&lt;/code>&lt;/li>
&lt;li>&lt;code>repository&lt;/code>&lt;/li>
&lt;li>&lt;code>runtime&lt;/code>&lt;/li>
&lt;li>&lt;code>diagnostics&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>名稱不需要多，但應穩定。若 &lt;code>worker&lt;/code>、&lt;code>background&lt;/code>、&lt;code>job_runner&lt;/code> 混用，查詢就會變麻煩。&lt;/p>
&lt;h2 id="策略correlation-id-串起一次流程">【策略】correlation ID 串起一次流程&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/correlation-id/" data-link-title="Correlation ID" data-link-desc="說明跨事件或跨服務的關聯識別碼如何支援排障">Correlation ID&lt;/a> 的核心目標是把同一次請求或同一個事件流串起來。HTTP request 常用 &lt;code>request_id&lt;/code>，背景事件可以用 &lt;code>event_id&lt;/code> 或 &lt;code>trace_id&lt;/code>。&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">WithRequestLog&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">logger&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">slog&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Logger&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">slog&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Logger&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">requestID&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Header&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;X-Request-ID&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="k">if&lt;/span> &lt;span class="nx">requestID&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">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">requestID&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">uuid&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewString&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>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">With&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;request_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">requestID&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;/code>&lt;/pre>&lt;/div>&lt;p>後續 handler、service、repository 都使用帶有 &lt;code>request_id&lt;/code> 的 logger。查詢單次流程時，不需要靠時間範圍猜哪些 log 相關。&lt;/p>
&lt;p>Correlation ID 不應包含敏感資料。它是追蹤用識別碼，不是使用者資料容器。&lt;/p>
&lt;h2 id="執行reason-欄位讓失敗可統計">【執行】reason 欄位讓失敗可統計&lt;/h2>
&lt;p>&lt;code>reason&lt;/code> 的核心用途是把錯誤原因變成可聚合分類。Message 可以給人讀，reason 給查詢與統計使用。&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="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Warn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;reject event&amp;#34;&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="s">&amp;#34;layer&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;http&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="s">&amp;#34;reason&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid_payload&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="s">&amp;#34;event_type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&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;/code>&lt;/pre>&lt;/div>&lt;p>穩定 reason 可以回答「最近一小時最多的拒絕原因是什麼」。如果原因只寫在 message 中，查詢會依賴模糊字串比對。&lt;/p>
&lt;p>Reason 值應像 enum 一樣維持小集合，例如：&lt;/p>
&lt;ul>
&lt;li>&lt;code>invalid_payload&lt;/code>&lt;/li>
&lt;li>&lt;code>queue_full&lt;/code>&lt;/li>
&lt;li>&lt;code>permission_denied&lt;/code>&lt;/li>
&lt;li>&lt;code>timeout&lt;/code>&lt;/li>
&lt;li>&lt;code>client_disconnected&lt;/code>&lt;/li>
&lt;li>&lt;code>dependency_unavailable&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>&lt;code>reason&lt;/code> 應維持小集合分類，完整錯誤應放在 &lt;code>error&lt;/code> 欄位。這樣監控可以穩定聚合原因，工程師仍能從錯誤欄位取得診斷細節。&lt;/p>
&lt;h2 id="判讀錯誤只在負責處理的邊界記一次">【判讀】錯誤只在負責處理的邊界記一次&lt;/h2>
&lt;p>錯誤日誌的核心風險是同一個錯誤被每一層都記一次。這會放大噪音，讓事故時很難看出真正的失敗點。&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="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;repository failed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;error&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">2&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;save notification: %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;/code>&lt;/pre>&lt;/div>&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="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;request failed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;error&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;/code>&lt;/pre>&lt;/div>&lt;p>較清楚的做法是底層 wrap error，上層在決定 response 或重試策略的邊界記錄一次：&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="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">service&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Create&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">cmd&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">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Warn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;create notification failed&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="s">&amp;#34;layer&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;http&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="s">&amp;#34;reason&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nf">reasonOf&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">5&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;error&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">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="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&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">8&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">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>底層若有必要補充脈絡，優先透過 error wrapping 或 structured error，而不是每層都 &lt;code>Error&lt;/code> log。&lt;/p></description><content:encoded><![CDATA[<p>結構化日誌欄位的核心目標是讓 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 可查詢、可聚合、可追蹤。Message 給人讀，欄位給系統查；重要資訊應放在穩定欄位，不應只藏在自由文字裡。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>設計穩定 <a href="/blog/backend/knowledge-cards/log-schema/" data-link-title="Log Schema" data-link-desc="說明結構化 log 欄位如何支援搜尋、關聯與事故排查">log schema</a></li>
<li>用 <code>layer</code>、<code>request_id</code>、<code>event_type</code>、<code>reason</code> 支援查詢</li>
<li>區分 message 與 structured fields 的責任</li>
<li>避免重複記錄同一個錯誤</li>
<li>避免把敏感資料寫進 log</li>
</ol>
<hr>
<h2 id="觀察自由文字-log-很難查詢">【觀察】自由文字 log 很難查詢</h2>
<p>Log 設計的核心問題是事故發生時需要快速查詢。若所有資訊都在 message 裡，查詢只能依賴模糊字串。</p>
<p>不穩定 log：</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="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;event accepted for user 123 request abc&#34;</span><span class="p">)</span></span></span></code></pre></div><p>這行給人看可以，但系統很難穩定查 <code>request_id=abc</code> 或 <code>user_id=123</code>。不同工程師改字句後，查詢就可能失效。</p>
<p>結構化 log：</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="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;event accepted&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s">&#34;layer&#34;</span><span class="p">,</span> <span class="s">&#34;http&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s">&#34;request_id&#34;</span><span class="p">,</span> <span class="nx">requestID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="s">&#34;user_id&#34;</span><span class="p">,</span> <span class="nx">userID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="s">&#34;event_type&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>Message 描述發生什麼事，欄位提供可查詢資料。這是 log schema 的基本分工。</p>
<h2 id="判讀log-schema-是查詢合約">【判讀】log schema 是查詢合約</h2>
<p>Log schema 的核心規則是欄位名稱與值集合要穩定。<code>request_id</code>、<code>requestID</code>、<code>rid</code> 混用會讓查詢與儀表板變得困難。</p>
<p>常用欄位：</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>用途</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>layer</code></td>
          <td>問題發生在哪個系統層</td>
      </tr>
      <tr>
          <td><code>request_id</code></td>
          <td>串起單次 HTTP request</td>
      </tr>
      <tr>
          <td><code>event_id</code></td>
          <td>串起事件處理流程</td>
      </tr>
      <tr>
          <td><code>event_type</code></td>
          <td>聚合某類 domain event</td>
      </tr>
      <tr>
          <td><code>client_id</code></td>
          <td>查 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> client 行為</td>
      </tr>
      <tr>
          <td><code>topic</code></td>
          <td>查訂閱或推送範圍</td>
      </tr>
      <tr>
          <td><code>reason</code></td>
          <td>聚合失敗原因</td>
      </tr>
      <tr>
          <td><code>error</code></td>
          <td>保存錯誤文字</td>
      </tr>
  </tbody>
</table>
<p>欄位不需要很多，但要一致。穩定欄位能讓除錯從「讀一堆文字」變成「查一組條件」。</p>
<h2 id="執行layer-表示發生位置">【執行】layer 表示發生位置</h2>
<p><code>layer</code> 的核心用途是標示 log 來自哪個系統層，協助工程師快速縮小問題範圍。</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="nx">logger</span><span class="p">.</span><span class="nf">Warn</span><span class="p">(</span><span class="s">&#34;queue full&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s">&#34;layer&#34;</span><span class="p">,</span> <span class="s">&#34;worker&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s">&#34;queue&#34;</span><span class="p">,</span> <span class="s">&#34;events&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="s">&#34;reason&#34;</span><span class="p">,</span> <span class="s">&#34;buffer_full&#34;</span><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>常見 layer：</p>
<ul>
<li><code>http</code></li>
<li><code>websocket</code></li>
<li><code>worker</code></li>
<li><code>repository</code></li>
<li><code>runtime</code></li>
<li><code>diagnostics</code></li>
</ul>
<p>名稱不需要多，但應穩定。若 <code>worker</code>、<code>background</code>、<code>job_runner</code> 混用，查詢就會變麻煩。</p>
<h2 id="策略correlation-id-串起一次流程">【策略】correlation ID 串起一次流程</h2>
<p><a href="/blog/backend/knowledge-cards/correlation-id/" data-link-title="Correlation ID" data-link-desc="說明跨事件或跨服務的關聯識別碼如何支援排障">Correlation ID</a> 的核心目標是把同一次請求或同一個事件流串起來。HTTP request 常用 <code>request_id</code>，背景事件可以用 <code>event_id</code> 或 <code>trace_id</code>。</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">WithRequestLog</span><span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">,</span> <span class="nx">logger</span> <span class="o">*</span><span class="nx">slog</span><span class="p">.</span><span class="nx">Logger</span><span class="p">)</span> <span class="o">*</span><span class="nx">slog</span><span class="p">.</span><span class="nx">Logger</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">requestID</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Header</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;X-Request-ID&#34;</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="nx">requestID</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">requestID</span> <span class="p">=</span> <span class="nx">uuid</span><span class="p">.</span><span class="nf">NewString</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></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">return</span> <span class="nx">logger</span><span class="p">.</span><span class="nf">With</span><span class="p">(</span><span class="s">&#34;request_id&#34;</span><span class="p">,</span> <span class="nx">requestID</span><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>後續 handler、service、repository 都使用帶有 <code>request_id</code> 的 logger。查詢單次流程時，不需要靠時間範圍猜哪些 log 相關。</p>
<p>Correlation ID 不應包含敏感資料。它是追蹤用識別碼，不是使用者資料容器。</p>
<h2 id="執行reason-欄位讓失敗可統計">【執行】reason 欄位讓失敗可統計</h2>
<p><code>reason</code> 的核心用途是把錯誤原因變成可聚合分類。Message 可以給人讀，reason 給查詢與統計使用。</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="nx">logger</span><span class="p">.</span><span class="nf">Warn</span><span class="p">(</span><span class="s">&#34;reject event&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s">&#34;layer&#34;</span><span class="p">,</span> <span class="s">&#34;http&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s">&#34;reason&#34;</span><span class="p">,</span> <span class="s">&#34;invalid_payload&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="s">&#34;event_type&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><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>穩定 reason 可以回答「最近一小時最多的拒絕原因是什麼」。如果原因只寫在 message 中，查詢會依賴模糊字串比對。</p>
<p>Reason 值應像 enum 一樣維持小集合，例如：</p>
<ul>
<li><code>invalid_payload</code></li>
<li><code>queue_full</code></li>
<li><code>permission_denied</code></li>
<li><code>timeout</code></li>
<li><code>client_disconnected</code></li>
<li><code>dependency_unavailable</code></li>
</ul>
<p><code>reason</code> 應維持小集合分類，完整錯誤應放在 <code>error</code> 欄位。這樣監控可以穩定聚合原因，工程師仍能從錯誤欄位取得診斷細節。</p>
<h2 id="判讀錯誤只在負責處理的邊界記一次">【判讀】錯誤只在負責處理的邊界記一次</h2>
<p>錯誤日誌的核心風險是同一個錯誤被每一層都記一次。這會放大噪音，讓事故時很難看出真正的失敗點。</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="nx">logger</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="s">&#34;repository failed&#34;</span><span class="p">,</span> <span class="s">&#34;error&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</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;save notification: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span></span></span></code></pre></div><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="nx">logger</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="s">&#34;request failed&#34;</span><span class="p">,</span> <span class="s">&#34;error&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span></span></span></code></pre></div><p>較清楚的做法是底層 wrap error，上層在決定 response 或重試策略的邊界記錄一次：</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="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">service</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">cmd</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">2</span><span class="cl">    <span class="nx">logger</span><span class="p">.</span><span class="nf">Warn</span><span class="p">(</span><span class="s">&#34;create notification failed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="s">&#34;layer&#34;</span><span class="p">,</span> <span class="s">&#34;http&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="s">&#34;reason&#34;</span><span class="p">,</span> <span class="nf">reasonOf</span><span class="p">(</span><span class="nx">err</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="s">&#34;error&#34;</span><span class="p">,</span> <span class="nx">err</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="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="k">return</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>底層若有必要補充脈絡，優先透過 error wrapping 或 structured error，而不是每層都 <code>Error</code> log。</p>
<h2 id="策略敏感資料不進-log">【策略】敏感資料不進 log</h2>
<p>Log 欄位設計的核心安全邊界是只記錄診斷必要資料。token、密碼、完整 cookie、完整個資與機密 payload 都屬於應排除資料；結構化 log 很容易被集中保存與搜尋，敏感資料一旦進入 log，清理成本很高。</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="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;user login&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s">&#34;user_id&#34;</span><span class="p">,</span> <span class="nx">user</span><span class="p">.</span><span class="nx">ID</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>應排除：</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="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;user login&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s">&#34;password&#34;</span><span class="p">,</span> <span class="nx">password</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s">&#34;token&#34;</span><span class="p">,</span> <span class="nx">token</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>若需要診斷 payload，可記錄長度、hash、欄位是否存在，而不是完整內容。</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="nx">logger</span><span class="p">.</span><span class="nf">Debug</span><span class="p">(</span><span class="s">&#34;payload received&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s">&#34;payload_bytes&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">body</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s">&#34;payload_sha256&#34;</span><span class="p">,</span> <span class="nf">checksum</span><span class="p">(</span><span class="nx">body</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>所有會被收集或保存的 log 都應遵守同一套資料保護規則。Debug log 也會進入檔案、集中式 log 或診斷封包，因此不能把它當成敏感資料的例外通道。</p>
<h2 id="測試log-欄位可以用-handler-驗證">【測試】log 欄位可以用 handler 驗證</h2>
<p>Log schema 的測試核心是確認重要欄位存在，避免未來重構時消失。</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">TestLogAttrsForEvent</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">event</span> <span class="o">:=</span> <span class="nx">DomainEvent</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;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>      <span class="s">&#34;notification.created&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;notification_1&#34;</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="nx">attrs</span> <span class="o">:=</span> <span class="nf">LogAttrsForEvent</span><span class="p">(</span><span class="nx">event</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">if</span> <span class="p">!</span><span class="nf">hasAttr</span><span class="p">(</span><span class="nx">attrs</span><span class="p">,</span> <span class="s">&#34;event_id&#34;</span><span class="p">,</span> <span class="s">&#34;evt_1&#34;</span><span class="p">)</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;event_id attr missing&#34;</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="nf">hasAttr</span><span class="p">(</span><span class="nx">attrs</span><span class="p">,</span> <span class="s">&#34;event_type&#34;</span><span class="p">,</span> <span class="s">&#34;notification.created&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</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;event_type attr missing&#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></code></pre></div><p>不需要測整行 log 字串。測穩定欄位即可，message 文字可以保留一定調整空間。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 Go 服務內部的 structured log schema；集中式平台、欄位標準與隱私治理，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Go 進階：Observability pipeline、metrics 與 tracing</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 structured recording、<a href="/blog/backend/knowledge-cards/event-log/" data-link-title="Event Log" data-link-desc="說明事件歷史如何保存、重播與支援跨服務資料重建">event log</a> 與 observability pipeline；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/structured-recording/" data-link-title="6.5 如何新增結構化記錄欄位" data-link-desc="區分 operational log、domain event log 與狀態資料">Go：如何新增結構化記錄欄位</a></li>
<li><a href="/blog/go/03-stdlib/slog/" data-link-title="3.6 log/slog：結構化日誌" data-link-desc="用 key-value log 設計可查詢、可過濾的程式訊號">Go：結構化日誌</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/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Go：Observability pipeline、metrics 與 tracing</a></li>
<li><a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend：可觀測性平台</a></li>
<li><a href="/blog/go/03-stdlib/slog/" data-link-title="3.6 log/slog：結構化日誌" data-link-desc="用 key-value log 設計可查詢、可過濾的程式訊號">Go 入門：log/slog</a></li>
<li><a href="/blog/go/06-practical/structured-recording/" data-link-title="6.5 如何新增結構化記錄欄位" data-link-desc="區分 operational log、domain event log 與狀態資料">Go 入門：如何新增結構化記錄欄位</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>結構化日誌的價值在於穩定欄位：<code>layer</code> 定位層級，<code>request_id</code> 串起請求，<code>event_id</code> 串起事件，<code>event_type</code> 支援聚合，<code>reason</code> 支援失敗分類。Message 給人讀，欄位給系統查。好的 log schema 能讓除錯從猜測變成查詢，同時避免敏感資料外洩與錯誤重複記錄。</p>
]]></content:encoded></item><item><title>7.3 事件去重邏輯的重構策略</title><link>https://tarrragon.github.io/blog/go/07-refactoring/dedup-refactor/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/dedup-refactor/</guid><description>&lt;p>事件去重重構的核心目標是把語義鍵、時間窗口與來源優先順序整理成可測規則。本章用一般事件處理流程說明如何降低重複邏輯，同時保留事件合併的判斷依據。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>辨識 raw payload 去重的風險&lt;/li>
&lt;li>用 domain dedup key 表達同一件事&lt;/li>
&lt;li>把去重邏輯抽成 &lt;code>Deduper&lt;/code>&lt;/li>
&lt;li>設計時間窗口與 cleanup&lt;/li>
&lt;li>測試同窗口、跨窗口、不同來源與過期清理&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察重複事件通常先散落在入口層">【觀察】重複事件通常先散落在入口層&lt;/h2>
&lt;p>去重邏輯重構的核心觸發點是多個入口開始各自判斷「這筆事件看過了嗎」。HTTP callback、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a>、background worker 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> action 都可能收到同一件事，若每個入口各自去重，規則很快會不一致。&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">var&lt;/span> &lt;span class="nx">seenHTTPEvents&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="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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">handleCallback&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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"> 4&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">raw&lt;/span> &lt;span class="nx">RawNotificationCallback&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">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">raw&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">key&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">NotificationID&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s">&amp;#34;:&amp;#34;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">EventName&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s">&amp;#34;:&amp;#34;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">raw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Timestamp&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">if&lt;/span> &lt;span class="nx">seenHTTPEvents&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">key&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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusOK&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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &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">seenHTTPEvents&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kc">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="c1">// update state...&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;/code>&lt;/pre>&lt;/div>&lt;p>worker 裡又有另一套：&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">var&lt;/span> &lt;span class="nx">seenWorkerEvents&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="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">handleWorkerUpdate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">update&lt;/span> &lt;span class="nx">RawNotificationUpdate&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"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">key&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">update&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&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">if&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">seenWorkerEvents&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">];&lt;/span> &lt;span class="nx">ok&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>&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="nx">seenWorkerEvents&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">]&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>&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="c1">// update state...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這兩段程式都在去重，但依據不同。一個用 notification ID、event name、timestamp；另一個用 raw event ID。當兩個來源描述同一件 domain event 時，它們無法互相辨識。&lt;/p>
&lt;h2 id="判讀raw-payload-不適合當去重依據">【判讀】raw payload 不適合當去重依據&lt;/h2>
&lt;p>raw payload 去重的核心問題是來源格式不是 domain 語意。不同來源可能使用不同欄位名稱、timestamp 精度、metadata 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/request-id/" data-link-title="Request ID" data-link-desc="說明單次 request 的識別碼如何支援 log 搜尋與問題定位">request ID&lt;/a>，但仍然描述同一件事。&lt;/p>
&lt;p>容易造成誤判的欄位：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>欄位&lt;/th>
 &lt;th>問題&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>request ID&lt;/td>
 &lt;td>每次重送都可能不同&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>received timestamp&lt;/td>
 &lt;td>取決於系統收到時間，不是發生時間&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>raw payload hash&lt;/td>
 &lt;td>欄位順序或 metadata 變化會改變 hash&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>source-specific ID&lt;/td>
 &lt;td>不同來源可能沒有共同 ID&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>debug metadata&lt;/td>
 &lt;td>不代表事件語意&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>去重應該發生在 normalized &lt;code>DomainEvent&lt;/code> 上，而不是 raw HTTP body、queue message 或 worker update 上。&lt;/p>
&lt;h2 id="策略domain-dedup-key-表達同一件事">【策略】domain dedup key 表達同一件事&lt;/h2>
&lt;p>domain dedup key 的核心責任是回答「哪兩筆事件應該被視為同一件 domain fact」。常見欄位是 subject kind、subject ID、event type 與時間窗口。&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">DedupKey&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">SubjectKind&lt;/span> &lt;span class="nx">SubjectKind&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">SubjectID&lt;/span> &lt;span class="kt">string&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">EventType&lt;/span> &lt;span class="nx">EventType&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">Window&lt;/span> &lt;span class="kt">int64&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">NewDedupKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span> &lt;span class="nx">DomainEvent&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">window&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Duration&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">DedupKey&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="nx">DedupKey&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">SubjectKind&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SubjectKind&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">SubjectID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SubjectID&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">EventType&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&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">Window&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">OccurredAt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">UnixNano&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="nb">int64&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">window&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 class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 key 不包含 &lt;code>Source&lt;/code>，因為不同來源可能送來同一件事。是否包含 source 是一個 domain 決策：如果不同來源代表不同事實，就包含；如果不同來源只是同一事實的不同通道，就不要包含。&lt;/p>
&lt;p>時間窗口是容忍來源時間差的折衷。窗口太小會漏掉重複事件，窗口太大可能合併兩件獨立事件。&lt;/p>
&lt;h2 id="執行抽出-deduper">【執行】抽出 Deduper&lt;/h2>
&lt;p>&lt;code>Deduper&lt;/code> 的核心責任是保存已看過的 key，並回報目前事件是否重複。它不應知道 HTTP、WebSocket 或 queue，也不應更新狀態。&lt;/p></description><content:encoded><![CDATA[<p>事件去重重構的核心目標是把語義鍵、時間窗口與來源優先順序整理成可測規則。本章用一般事件處理流程說明如何降低重複邏輯，同時保留事件合併的判斷依據。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>辨識 raw payload 去重的風險</li>
<li>用 domain dedup key 表達同一件事</li>
<li>把去重邏輯抽成 <code>Deduper</code></li>
<li>設計時間窗口與 cleanup</li>
<li>測試同窗口、跨窗口、不同來源與過期清理</li>
</ol>
<hr>
<h2 id="觀察重複事件通常先散落在入口層">【觀察】重複事件通常先散落在入口層</h2>
<p>去重邏輯重構的核心觸發點是多個入口開始各自判斷「這筆事件看過了嗎」。HTTP callback、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a>、background worker 或 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> action 都可能收到同一件事，若每個入口各自去重，規則很快會不一致。</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">var</span> <span class="nx">seenHTTPEvents</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="kt">bool</span><span class="p">{}</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">func</span> <span class="nf">handleCallback</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="kd">var</span> <span class="nx">raw</span> <span class="nx">RawNotificationCallback</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">json</span><span class="p">.</span><span class="nf">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">raw</span><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="nx">key</span> <span class="o">:=</span> <span class="nx">raw</span><span class="p">.</span><span class="nx">NotificationID</span> <span class="o">+</span> <span class="s">&#34;:&#34;</span> <span class="o">+</span> <span class="nx">raw</span><span class="p">.</span><span class="nx">EventName</span> <span class="o">+</span> <span class="s">&#34;:&#34;</span> <span class="o">+</span> <span class="nx">raw</span><span class="p">.</span><span class="nx">Timestamp</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="nx">seenHTTPEvents</span><span class="p">[</span><span class="nx">key</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">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusOK</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="nx">seenHTTPEvents</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="p">=</span> <span class="kc">true</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="c1">// update state...</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>worker 裡又有另一套：</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">var</span> <span class="nx">seenWorkerEvents</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="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">{}</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">func</span> <span class="nf">handleWorkerUpdate</span><span class="p">(</span><span class="nx">update</span> <span class="nx">RawNotificationUpdate</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">key</span> <span class="o">:=</span> <span class="nx">update</span><span class="p">.</span><span class="nx">ID</span>
</span></span><span class="line"><span class="ln"> 5</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">seenWorkerEvents</span><span class="p">[</span><span class="nx">key</span><span class="p">];</span> <span class="nx">ok</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 class="nx">seenWorkerEvents</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="p">=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</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="c1">// update state...</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這兩段程式都在去重，但依據不同。一個用 notification ID、event name、timestamp；另一個用 raw event ID。當兩個來源描述同一件 domain event 時，它們無法互相辨識。</p>
<h2 id="判讀raw-payload-不適合當去重依據">【判讀】raw payload 不適合當去重依據</h2>
<p>raw payload 去重的核心問題是來源格式不是 domain 語意。不同來源可能使用不同欄位名稱、timestamp 精度、metadata 或 <a href="/blog/backend/knowledge-cards/request-id/" data-link-title="Request ID" data-link-desc="說明單次 request 的識別碼如何支援 log 搜尋與問題定位">request ID</a>，但仍然描述同一件事。</p>
<p>容易造成誤判的欄位：</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>問題</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>request ID</td>
          <td>每次重送都可能不同</td>
      </tr>
      <tr>
          <td>received timestamp</td>
          <td>取決於系統收到時間，不是發生時間</td>
      </tr>
      <tr>
          <td>raw payload hash</td>
          <td>欄位順序或 metadata 變化會改變 hash</td>
      </tr>
      <tr>
          <td>source-specific ID</td>
          <td>不同來源可能沒有共同 ID</td>
      </tr>
      <tr>
          <td>debug metadata</td>
          <td>不代表事件語意</td>
      </tr>
  </tbody>
</table>
<p>去重應該發生在 normalized <code>DomainEvent</code> 上，而不是 raw HTTP body、queue message 或 worker update 上。</p>
<h2 id="策略domain-dedup-key-表達同一件事">【策略】domain dedup key 表達同一件事</h2>
<p>domain dedup key 的核心責任是回答「哪兩筆事件應該被視為同一件 domain fact」。常見欄位是 subject kind、subject ID、event type 與時間窗口。</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">DedupKey</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">SubjectKind</span> <span class="nx">SubjectKind</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">SubjectID</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">EventType</span>   <span class="nx">EventType</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">Window</span>      <span class="kt">int64</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">NewDedupKey</span><span class="p">(</span><span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">,</span> <span class="nx">window</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">)</span> <span class="nx">DedupKey</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="nx">DedupKey</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">SubjectKind</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectKind</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>   <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">EventType</span><span class="p">:</span>   <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">Window</span><span class="p">:</span>      <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</span><span class="p">.</span><span class="nf">UnixNano</span><span class="p">()</span> <span class="o">/</span> <span class="nb">int64</span><span class="p">(</span><span class="nx">window</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="p">}</span></span></span></code></pre></div><p>這個 key 不包含 <code>Source</code>，因為不同來源可能送來同一件事。是否包含 source 是一個 domain 決策：如果不同來源代表不同事實，就包含；如果不同來源只是同一事實的不同通道，就不要包含。</p>
<p>時間窗口是容忍來源時間差的折衷。窗口太小會漏掉重複事件，窗口太大可能合併兩件獨立事件。</p>
<h2 id="執行抽出-deduper">【執行】抽出 Deduper</h2>
<p><code>Deduper</code> 的核心責任是保存已看過的 key，並回報目前事件是否重複。它不應知道 HTTP、WebSocket 或 queue，也不應更新狀態。</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">Deduper</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">mu</span>      <span class="nx">sync</span><span class="p">.</span><span class="nx">Mutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">seen</span>    <span class="kd">map</span><span class="p">[</span><span class="nx">DedupKey</span><span class="p">]</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">window</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">expires</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</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">NewDeduper</span><span class="p">(</span><span class="nx">window</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">,</span> <span class="nx">expires</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">)</span> <span class="o">*</span><span class="nx">Deduper</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">Deduper</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">seen</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="nx">DedupKey</span><span class="p">]</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">window</span><span class="p">:</span>  <span class="nx">window</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">expires</span><span class="p">:</span> <span class="nx">expires</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>Seen</code> 判斷是否看過：</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">d</span> <span class="o">*</span><span class="nx">Deduper</span><span class="p">)</span> <span class="nf">Seen</span><span class="p">(</span><span class="nx">event</span> <span class="nx">DomainEvent</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="nx">d</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"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">d</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"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">key</span> <span class="o">:=</span> <span class="nf">NewDedupKey</span><span class="p">(</span><span class="nx">event</span><span class="p">,</span> <span class="nx">d</span><span class="p">.</span><span class="nx">window</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</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">d</span><span class="p">.</span><span class="nx">seen</span><span class="p">[</span><span class="nx">key</span><span class="p">];</span> <span class="nx">ok</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">true</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="nx">d</span><span class="p">.</span><span class="nx">seen</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="p">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ReceivedAt</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></code></pre></div><p>這裡用 <code>ReceivedAt</code> 作為清理基準，因為清理是系統內部記憶體管理問題；去重 key 則用 <code>OccurredAt</code>，因為那是事件發生語意。兩個時間各有用途，不應混用。</p>
<h2 id="執行processor-使用-deduper">【執行】processor 使用 Deduper</h2>
<p>重構後的核心方向是讓所有來源先 normalize 成 <code>DomainEvent</code>，再交給同一個 processor 去重與套用規則。</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">EventProcessor</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">deduper</span>    <span class="o">*</span><span class="nx">Deduper</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">repository</span> <span class="nx">EventRepository</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">publisher</span>  <span class="nx">Publisher</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">p</span> <span class="o">*</span><span class="nx">EventProcessor</span><span class="p">)</span> <span class="nf">Process</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">event</span> <span class="nx">DomainEvent</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="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">event</span><span class="p">.</span><span class="nf">Validate</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"> 9</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;validate event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><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></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">if</span> <span class="nx">p</span><span class="p">.</span><span class="nx">deduper</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">event</span><span class="p">)</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="kc">nil</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">p</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</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="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;apply event: %w&#34;</span><span class="p">,</span> <span class="nx">err</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="k">return</span> <span class="nx">p</span><span class="p">.</span><span class="nx">publisher</span><span class="p">.</span><span class="nf">Publish</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><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>這個位置比 handler 或 worker 更適合去重，因為 processor 已經面對 normalized domain event。新增事件來源時，只要它走同一個 processor，就自然共用同一套去重規則。</p>
<h2 id="策略來源優先順序要顯式化">【策略】來源優先順序要顯式化</h2>
<p>來源優先順序的核心問題是重複事件不一定完全相同。有些來源即時但資料少，有些來源延遲但資料完整。若需要合併資料，就要把 priority rule 寫成可測規則。</p>
<p>先定義 priority：</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">SourcePriority</span><span class="p">(</span><span class="nx">source</span> <span class="nx">EventSource</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">switch</span> <span class="nx">source</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">SourceHTTPCallback</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="mi">100</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">case</span> <span class="nx">SourceClientCommand</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="mi">80</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">case</span> <span class="nx">SourceTimer</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="mi">50</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">default</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="mi">0</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>若 deduper 只需要判斷 seen，就不處理 priority。若系統需要「較高 priority 事件可以取代較低 priority 事件」，應把 deduper 改成更明確的 result：</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">DedupDecision</span> <span class="kt">int</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">DedupAccept</span> <span class="nx">DedupDecision</span> <span class="p">=</span> <span class="kc">iota</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">DedupDrop</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">DedupReplace</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>不要把 priority 規則藏在 <code>if</code> 裡。它是 domain policy，應該可以被直接測試。</p>
<h2 id="執行cleanup-防止去重表無限成長">【執行】cleanup 防止去重表無限成長</h2>
<p>cleanup 的核心責任是移除過期 key，防止去重表變成記憶體 leak。只要 <code>seen</code> 是 map，就必須設計生命週期。</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">d</span> <span class="o">*</span><span class="nx">Deduper</span><span class="p">)</span> <span class="nf">Cleanup</span><span class="p">(</span><span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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">d</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"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">d</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"> 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="nx">key</span><span class="p">,</span> <span class="nx">seenAt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">d</span><span class="p">.</span><span class="nx">seen</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">if</span> <span class="nx">now</span><span class="p">.</span><span class="nf">Sub</span><span class="p">(</span><span class="nx">seenAt</span><span class="p">)</span> <span class="p">&gt;</span> <span class="nx">d</span><span class="p">.</span><span class="nx">expires</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nb">delete</span><span class="p">(</span><span class="nx">d</span><span class="p">.</span><span class="nx">seen</span><span class="p">,</span> <span class="nx">key</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="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>cleanup 可以由 background worker 定期呼叫：</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">RunDeduperCleanup</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">deduper</span> <span class="o">*</span><span class="nx">Deduper</span><span class="p">,</span> <span class="nx">interval</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">,</span> <span class="nx">now</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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">interval</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="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="k">return</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">ticker</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="nx">deduper</span><span class="p">.</span><span class="nf">Cleanup</span><span class="p">(</span><span class="nf">now</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><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這裡注入 <code>now</code> 是為了測試。清理策略不應依賴測試中的真實等待。</p>
<h2 id="執行同窗口去重測試">【執行】同窗口去重測試</h2>
<p>同窗口測試的核心目標是確認兩筆語意相同、時間接近的事件會共用 key。</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">TestDeduperSeenSameWindow</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">deduper</span> <span class="o">:=</span> <span class="nf">NewDeduper</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Hour</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">base</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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">first</span> <span class="o">:=</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>          <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>        <span class="nx">EventNotificationCreated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">SubjectKind</span><span class="p">:</span> <span class="nx">SubjectNotification</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>   <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span>  <span class="nx">base</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span>  <span class="nx">base</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="nx">second</span> <span class="o">:=</span> <span class="nx">first</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">second</span><span class="p">.</span><span class="nx">ID</span> <span class="p">=</span> <span class="s">&#34;evt_2&#34;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">second</span><span class="p">.</span><span class="nx">ReceivedAt</span> <span class="p">=</span> <span class="nx">base</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">5</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><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">if</span> <span class="nx">deduper</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">first</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</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;first event should not be duplicate&#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 class="k">if</span> <span class="p">!</span><span class="nx">deduper</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">second</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</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;second event in same window should be duplicate&#34;</span><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>這個測試刻意讓 ID 不同，證明去重依賴 domain key。</p>
<h2 id="執行跨窗口不去重測試">【執行】跨窗口不去重測試</h2>
<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">TestDeduperSeenDifferentWindow</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">deduper</span> <span class="o">:=</span> <span class="nf">NewDeduper</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Hour</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">base</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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">first</span> <span class="o">:=</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>          <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>        <span class="nx">EventNotificationCreated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">SubjectKind</span><span class="p">:</span> <span class="nx">SubjectNotification</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>   <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span>  <span class="nx">base</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span>  <span class="nx">base</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="nx">second</span> <span class="o">:=</span> <span class="nx">first</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">second</span><span class="p">.</span><span class="nx">ID</span> <span class="p">=</span> <span class="s">&#34;evt_2&#34;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">second</span><span class="p">.</span><span class="nx">OccurredAt</span> <span class="p">=</span> <span class="nx">base</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">second</span><span class="p">.</span><span class="nx">ReceivedAt</span> <span class="p">=</span> <span class="nx">base</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">deduper</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">first</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">deduper</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">second</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</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;event in different window should not be duplicate&#34;</span><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>窗口大小是一個業務取捨，測試可以讓這個取捨變成明確規格。</p>
<h2 id="執行cleanup-測試不應-sleep">【執行】cleanup 測試不應 sleep</h2>
<p>cleanup 測試的核心目標是確認過期 key 會被移除。測試應直接傳入時間，不要真的等待過期。</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">TestDeduperCleanup</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">deduper</span> <span class="o">:=</span> <span class="nf">NewDeduper</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">base</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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">event</span> <span class="o">:=</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>          <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>        <span class="nx">EventNotificationCreated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">SubjectKind</span><span class="p">:</span> <span class="nx">SubjectNotification</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>   <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span>  <span class="nx">base</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span>  <span class="nx">base</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="nx">_</span> <span class="p">=</span> <span class="nx">deduper</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">deduper</span><span class="p">.</span><span class="nf">Cleanup</span><span class="p">(</span><span class="nx">base</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><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">if</span> <span class="nx">deduper</span><span class="p">.</span><span class="nf">Seen</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</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;event should be accepted after cleanup&#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 class="p">}</span></span></span></code></pre></div><p>這個測試能快速完成，也不受機器速度影響。</p>
<h2 id="重構步驟">重構步驟</h2>
<p>把散落的去重邏輯收斂到 <code>Deduper</code> 時，可以按這個順序：</p>
<ol>
<li>先列出所有入口目前的去重 key。</li>
<li>找出它們真正想表達的 domain 語意。</li>
<li>建立 <code>DedupKey</code>，使用 subject、event type 與時間窗口。</li>
<li>把 raw input 先 normalize 成 <code>DomainEvent</code>。</li>
<li>在 processor 中呼叫 <code>Deduper.Seen</code>。</li>
<li>移除 handler、worker 內的重複 map。</li>
<li>補同窗口、跨窗口、不同來源與 cleanup 測試。</li>
</ol>
<p>不要一開始就把所有事件融合規則做完。先把「是否看過」集中，再處理 priority 或 replace policy。</p>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一去重鍵使用-domain-語意">檢查一：去重鍵使用 domain 語意</h3>
<p>payload hash 對格式變化太敏感。欄位順序、metadata 或 timestamp 精度改變，都會讓同一件事看起來不同。</p>
<h3 id="檢查二事件順序使用-occurredat">檢查二：事件順序使用 OccurredAt</h3>
<p><code>ReceivedAt</code> 是系統收到時間。事件是否同一件事，通常應看 <code>OccurredAt</code> 與 subject 語意。</p>
<h3 id="檢查三去重表需要-cleanup">檢查三：去重表需要 cleanup</h3>
<p>任何「看過的 key」map 都會成長。沒有 cleanup 的 deduper 會在長時間服務中累積記憶體壓力。</p>
<h3 id="檢查四來源-priority-需要測試">檢查四：來源 priority 需要測試</h3>
<p>若不同來源資料完整度不同，priority 是 domain policy。它應該有明確函式與測試，而不是散落在 processor 的條件判斷裡。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一服務內的去重規則如何集中；分散式去重與 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> store，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</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="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 event normalization、processor 與 source priority；如果你要先回看語言教材，可以讀：</p>
<ul>
<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/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</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/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
</ul>
]]></content:encoded></item><item><title>7.3 跨節點 WebSocket、presence 與重連協定</title><link>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/cross-node-websocket/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/cross-node-websocket/</guid><description>&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> 的核心責任是把連線狀態、訂閱狀態與推送路徑從單一記憶體 hub 延伸到多台 server。單一 process 內的 read pump、write pump、heartbeat 與 slow client 策略仍然有效，但跨節點後還需要 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a>、presence store、重連協定與授權邊界。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解單節點 hub 為什麼不夠&lt;/li>
&lt;li>看懂 presence store 與 broker 在系統中的角色&lt;/li>
&lt;li>設計 reconnect 後的補資料流程&lt;/li>
&lt;li>分辨訂閱路由、連線管理與授權邊界&lt;/li>
&lt;li>讓多台 server 在語意上看起來像同一個訊息系統&lt;/li>
&lt;/ol>
&lt;h2 id="前置章節">前置章節&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">Go 進階：read pump / write pump 模式&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">Go 進階：heartbeat、deadline 與連線清理&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/subscription-routing/" data-link-title="2.3 訂閱模型與訊息路由" data-link-desc="將 client action 對應到主題訂閱狀態">Go 進階：訂閱模型與訊息路由&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/slow-client/" data-link-title="2.4 慢客戶端與 send buffer 管理" data-link-desc="控制推送佇列與記憶體風險">Go 進階：慢客戶端與 send buffer 管理&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="後續撰寫方向">後續撰寫方向&lt;/h2>
&lt;ol>
&lt;li>多台 server 如何知道某個 &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>Presence store 如何記錄 client online、offline 與最後活動時間。&lt;/li>
&lt;li>Broker &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out&lt;/a> 如何和每個節點本地 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;li>Client reconnect 如何使用 cursor、last event ID 或 snapshot 補資料。&lt;/li>
&lt;li>Topic ACL 與 subscription &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/authorization/" data-link-title="Authorization" data-link-desc="說明授權如何判斷誰能對哪些資源執行哪些操作">authorization&lt;/a> 應放在 router、usecase 還是 gateway。&lt;/li>
&lt;/ol>
&lt;h2 id="觀察跨節點-websocket-的核心問題是狀態協調">【觀察】跨節點 WebSocket 的核心問題是狀態協調&lt;/h2>
&lt;p>WebSocket 協定解決的是單一連線的雙向通訊，但跨節點之後，真正麻煩的是狀態分散在多台 server。某個 client 可能連到 A 節點，但它關注的 topic 事件卻從 B 節點產生，這時就需要能夠路由、轉送與補資料。&lt;/p>
&lt;p>所以跨節點 WebSocket 的問題不只是「能不能推送」，而是：&lt;/p>
&lt;ul>
&lt;li>這個 client 現在在哪台 server&lt;/li>
&lt;li>它訂閱了哪些 topic&lt;/li>
&lt;li>推送失敗後要不要重送&lt;/li>
&lt;li>重新連線後要從哪裡補回遺漏事件&lt;/li>
&lt;/ul>
&lt;h2 id="判讀presence-store-是操作查詢">【判讀】presence store 是操作查詢&lt;/h2>
&lt;p>presence store 的用途是讓系統知道某個 client 或節點目前大概在線上還是離線。它通常是操作性資料，不一定是業務真相。&lt;/p>
&lt;p>常見欄位包括：&lt;/p>
&lt;ul>
&lt;li>client ID&lt;/li>
&lt;li>node ID&lt;/li>
&lt;li>connected at&lt;/li>
&lt;li>last seen&lt;/li>
&lt;li>subscription keys&lt;/li>
&lt;/ul>
&lt;p>這類資料要允許過期與清理，因為斷線、網路抖動與 crash 都可能讓狀態暫時不準。&lt;/p>
&lt;h2 id="策略reconnect-一定要有補資料設計">【策略】reconnect 一定要有補資料設計&lt;/h2>
&lt;p>只靠重新連上 WebSocket 並不能保證使用者不漏訊息。當連線中斷時，常見的補資料方式有：&lt;/p>
&lt;ul>
&lt;li>last event ID&lt;/li>
&lt;li>cursor / &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset&lt;/a>&lt;/li>
&lt;li>snapshot + delta&lt;/li>
&lt;/ul>
&lt;p>選哪一種，取決於你的事件是否可排序、是否可回放，以及業務能容忍多大的缺口。&lt;/p>
&lt;h2 id="執行推送路徑通常要分三層">【執行】推送路徑通常要分三層&lt;/h2>
&lt;p>跨節點場景下，推送路徑常見會分成：&lt;/p>
&lt;ol>
&lt;li>事件產生端把訊息交給 broker 或 routing layer。&lt;/li>
&lt;li>節點收到後，交給本機 hub / connection manager。&lt;/li>
&lt;li>write pump 再把訊息送到單一 client。&lt;/li>
&lt;/ol>
&lt;p>這樣可以維持單一寫入者原則，避免多個 goroutine 同時寫 WebSocket。&lt;/p></description><content:encoded><![CDATA[<p>跨節點 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 的核心責任是把連線狀態、訂閱狀態與推送路徑從單一記憶體 hub 延伸到多台 server。單一 process 內的 read pump、write pump、heartbeat 與 slow client 策略仍然有效，但跨節點後還需要 <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a>、presence store、重連協定與授權邊界。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解單節點 hub 為什麼不夠</li>
<li>看懂 presence store 與 broker 在系統中的角色</li>
<li>設計 reconnect 後的補資料流程</li>
<li>分辨訂閱路由、連線管理與授權邊界</li>
<li>讓多台 server 在語意上看起來像同一個訊息系統</li>
</ol>
<h2 id="前置章節">前置章節</h2>
<ul>
<li><a href="/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">Go 進階：read pump / write pump 模式</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">Go 進階：heartbeat、deadline 與連線清理</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/subscription-routing/" data-link-title="2.3 訂閱模型與訊息路由" data-link-desc="將 client action 對應到主題訂閱狀態">Go 進階：訂閱模型與訊息路由</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/slow-client/" data-link-title="2.4 慢客戶端與 send buffer 管理" data-link-desc="控制推送佇列與記憶體風險">Go 進階：慢客戶端與 send buffer 管理</a></li>
</ul>
<h2 id="後續撰寫方向">後續撰寫方向</h2>
<ol>
<li>多台 server 如何知道某個 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> 的訂閱者在哪些節點。</li>
<li>Presence store 如何記錄 client online、offline 與最後活動時間。</li>
<li>Broker <a href="/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out</a> 如何和每個節點本地 send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 策略銜接。</li>
<li>Client reconnect 如何使用 cursor、last event ID 或 snapshot 補資料。</li>
<li>Topic ACL 與 subscription <a href="/blog/backend/knowledge-cards/authorization/" data-link-title="Authorization" data-link-desc="說明授權如何判斷誰能對哪些資源執行哪些操作">authorization</a> 應放在 router、usecase 還是 gateway。</li>
</ol>
<h2 id="觀察跨節點-websocket-的核心問題是狀態協調">【觀察】跨節點 WebSocket 的核心問題是狀態協調</h2>
<p>WebSocket 協定解決的是單一連線的雙向通訊，但跨節點之後，真正麻煩的是狀態分散在多台 server。某個 client 可能連到 A 節點，但它關注的 topic 事件卻從 B 節點產生，這時就需要能夠路由、轉送與補資料。</p>
<p>所以跨節點 WebSocket 的問題不只是「能不能推送」，而是：</p>
<ul>
<li>這個 client 現在在哪台 server</li>
<li>它訂閱了哪些 topic</li>
<li>推送失敗後要不要重送</li>
<li>重新連線後要從哪裡補回遺漏事件</li>
</ul>
<h2 id="判讀presence-store-是操作查詢">【判讀】presence store 是操作查詢</h2>
<p>presence store 的用途是讓系統知道某個 client 或節點目前大概在線上還是離線。它通常是操作性資料，不一定是業務真相。</p>
<p>常見欄位包括：</p>
<ul>
<li>client ID</li>
<li>node ID</li>
<li>connected at</li>
<li>last seen</li>
<li>subscription keys</li>
</ul>
<p>這類資料要允許過期與清理，因為斷線、網路抖動與 crash 都可能讓狀態暫時不準。</p>
<h2 id="策略reconnect-一定要有補資料設計">【策略】reconnect 一定要有補資料設計</h2>
<p>只靠重新連上 WebSocket 並不能保證使用者不漏訊息。當連線中斷時，常見的補資料方式有：</p>
<ul>
<li>last event ID</li>
<li>cursor / <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a></li>
<li>snapshot + delta</li>
</ul>
<p>選哪一種，取決於你的事件是否可排序、是否可回放，以及業務能容忍多大的缺口。</p>
<h2 id="執行推送路徑通常要分三層">【執行】推送路徑通常要分三層</h2>
<p>跨節點場景下，推送路徑常見會分成：</p>
<ol>
<li>事件產生端把訊息交給 broker 或 routing layer。</li>
<li>節點收到後，交給本機 hub / connection manager。</li>
<li>write pump 再把訊息送到單一 client。</li>
</ol>
<p>這樣可以維持單一寫入者原則，避免多個 goroutine 同時寫 WebSocket。</p>
<h2 id="延伸授權應該在進入路由前就處理">【延伸】授權應該在進入路由前就處理</h2>
<p>Topic ACL 要在訂閱建立時就確認這個 client 是否有資格加入。這能減少不必要的 fan-out 與敏感資料外流。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章不會選定特定 broker 或 presence <a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a>。重點是先讓跨節點責任可見，再依服務需求選擇 Redis、NATS、Kafka、PostgreSQL 或其他基礎設施。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 WebSocket 連線架構與事件路由；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">Go 進階：read/write pump 模式</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">Go 進階：heartbeat、deadline 與連線清理</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/subscription-routing/" data-link-title="2.3 訂閱模型與訊息路由" data-link-desc="將 client action 對應到主題訂閱狀態">Go 進階：訂閱模型與訊息路由</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/slow-client/" data-link-title="2.4 慢客戶端與 send buffer 管理" data-link-desc="控制推送佇列與記憶體風險">Go 進階：慢客戶端與 send buffer 管理</a></li>
</ul>
]]></content:encoded></item><item><title>模組三：Runtime 與效能診斷</title><link>https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/</guid><description>&lt;p>Runtime 診斷的核心目標是用資料判斷服務壓力來源。Go 服務長時間運行後，問題常出現在 heap 成長、GC 壓力、goroutine 數量、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 堆積、JSON 配置與共享狀態保留；診斷流程應先看趨勢，再用 profile 定位來源。&lt;/p>
&lt;p>本模組承接前面的並發、WebSocket 與測試可靠性：如果 goroutine lifecycle、send buffer、repository copy boundary 沒設計好，runtime 訊號會在 heap profile、goroutine profile、CPU profile 或 allocation profile 中反映出來。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&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;a href="https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/gc-memory-limit/" data-link-title="3.1 GC 與 memory limit" data-link-desc="理解 debug.SetMemoryLimit 在長時間服務中的用途">3.1&lt;/a>&lt;/td>
 &lt;td>GC 與 memory limit&lt;/td>
 &lt;td>理解 heap、GOGC、memory limit 與 runtime &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics&lt;/a> 的關係&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/pprof/" data-link-title="3.2 pprof 基礎診斷流程" data-link-desc="用 pprof endpoint 診斷 heap、goroutine 與 CPU 問題">3.2&lt;/a>&lt;/td>
 &lt;td>pprof 基礎診斷流程&lt;/td>
 &lt;td>用 heap、goroutine、CPU、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/trace/" data-link-title="Trace" data-link-desc="說明 trace 如何重建跨服務請求的路徑、耗時與依賴關係">trace&lt;/a> profile 定位壓力來源&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/goroutine-leak/" data-link-title="3.3 goroutine leak 偵測" data-link-desc="判斷背景工作與 client pump 是否正確退出">3.3&lt;/a>&lt;/td>
 &lt;td>goroutine leak 偵測&lt;/td>
 &lt;td>從 stack pattern 回到 context、close、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a> 與 ticker lifecycle&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/allocation/" data-link-title="3.4 資料結構與 allocation 壓力" data-link-desc="分析列表、歷史資料與 WebSocket payload 的配置成本">3.4&lt;/a>&lt;/td>
 &lt;td>資料結構與 allocation 壓力&lt;/td>
 &lt;td>區分必要 copy、安全邊界與可優化熱路徑配置&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;p>本模組使用虛構的即時通知服務作為範例。範例包含 WebSocket client lifecycle、background worker、repository list、JSON push payload 與 cache。&lt;/p>
&lt;p>範例只用來展示 Go runtime 診斷方法，不假設讀者正在維護任何特定專案。&lt;/p>
&lt;h2 id="本模組的-go-核心概念">本模組的 Go 核心概念&lt;/h2>
&lt;ul>
&lt;li>用 &lt;code>runtime.ReadMemStats&lt;/code> 或 &lt;code>runtime/metrics&lt;/code> 觀察 heap、GC 與 goroutine 趨勢。&lt;/li>
&lt;li>用 &lt;code>debug.SetMemoryLimit&lt;/code> 給 runtime 軟性記憶體目標。&lt;/li>
&lt;li>用 pprof 分析 heap、goroutine、CPU、block、mutex 與 trace。&lt;/li>
&lt;li>用 goroutine profile 找出卡在 channel、network read、ticker、mutex 的路徑。&lt;/li>
&lt;li>用 &lt;code>alloc_space&lt;/code> 與 &lt;code>inuse_space&lt;/code> 區分配置壓力與保留記憶體。&lt;/li>
&lt;li>用資料結構設計降低不必要 allocation，但保留必要 copy boundary。&lt;/li>
&lt;/ul>
&lt;h2 id="學習重點">學習重點&lt;/h2>
&lt;p>學完本模組後，你應該能判斷：&lt;/p>
&lt;ol>
&lt;li>記憶體問題是 GC 壓力、長期保留，還是短暫尖峰&lt;/li>
&lt;li>什麼情境適合調整 memory limit，什麼情境應該找 leak&lt;/li>
&lt;li>heap、goroutine、CPU、trace 各自回答什麼問題&lt;/li>
&lt;li>goroutine leak 應回到哪個 lifecycle 邊界修&lt;/li>
&lt;li>allocation 優化何時值得做，何時會破壞安全邊界&lt;/li>
&lt;/ol>
&lt;h2 id="本模組不處理">本模組不處理&lt;/h2>
&lt;p>本模組不討論分散式 tracing 平台、完整監控系統或雲端特定 profiler。這些工具可以接在本模組之後；本模組先建立 Go runtime 原生訊號與 pprof 的診斷思路。後續可接 &lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Observability pipeline、metrics 與 tracing&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Runtime 診斷的核心目標是用資料判斷服務壓力來源。Go 服務長時間運行後，問題常出現在 heap 成長、GC 壓力、goroutine 數量、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 堆積、JSON 配置與共享狀態保留；診斷流程應先看趨勢，再用 profile 定位來源。</p>
<p>本模組承接前面的並發、WebSocket 與測試可靠性：如果 goroutine lifecycle、send buffer、repository copy boundary 沒設計好，runtime 訊號會在 heap profile、goroutine profile、CPU profile 或 allocation profile 中反映出來。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go-advanced/03-runtime-profiling/gc-memory-limit/" data-link-title="3.1 GC 與 memory limit" data-link-desc="理解 debug.SetMemoryLimit 在長時間服務中的用途">3.1</a></td>
          <td>GC 與 memory limit</td>
          <td>理解 heap、GOGC、memory limit 與 runtime <a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a> 的關係</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/03-runtime-profiling/pprof/" data-link-title="3.2 pprof 基礎診斷流程" data-link-desc="用 pprof endpoint 診斷 heap、goroutine 與 CPU 問題">3.2</a></td>
          <td>pprof 基礎診斷流程</td>
          <td>用 heap、goroutine、CPU、<a href="/blog/backend/knowledge-cards/trace/" data-link-title="Trace" data-link-desc="說明 trace 如何重建跨服務請求的路徑、耗時與依賴關係">trace</a> profile 定位壓力來源</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/03-runtime-profiling/goroutine-leak/" data-link-title="3.3 goroutine leak 偵測" data-link-desc="判斷背景工作與 client pump 是否正確退出">3.3</a></td>
          <td>goroutine leak 偵測</td>
          <td>從 stack pattern 回到 context、close、<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 與 ticker lifecycle</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/03-runtime-profiling/allocation/" data-link-title="3.4 資料結構與 allocation 壓力" data-link-desc="分析列表、歷史資料與 WebSocket payload 的配置成本">3.4</a></td>
          <td>資料結構與 allocation 壓力</td>
          <td>區分必要 copy、安全邊界與可優化熱路徑配置</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<p>本模組使用虛構的即時通知服務作為範例。範例包含 WebSocket client lifecycle、background worker、repository list、JSON push payload 與 cache。</p>
<p>範例只用來展示 Go runtime 診斷方法，不假設讀者正在維護任何特定專案。</p>
<h2 id="本模組的-go-核心概念">本模組的 Go 核心概念</h2>
<ul>
<li>用 <code>runtime.ReadMemStats</code> 或 <code>runtime/metrics</code> 觀察 heap、GC 與 goroutine 趨勢。</li>
<li>用 <code>debug.SetMemoryLimit</code> 給 runtime 軟性記憶體目標。</li>
<li>用 pprof 分析 heap、goroutine、CPU、block、mutex 與 trace。</li>
<li>用 goroutine profile 找出卡在 channel、network read、ticker、mutex 的路徑。</li>
<li>用 <code>alloc_space</code> 與 <code>inuse_space</code> 區分配置壓力與保留記憶體。</li>
<li>用資料結構設計降低不必要 allocation，但保留必要 copy boundary。</li>
</ul>
<h2 id="學習重點">學習重點</h2>
<p>學完本模組後，你應該能判斷：</p>
<ol>
<li>記憶體問題是 GC 壓力、長期保留，還是短暫尖峰</li>
<li>什麼情境適合調整 memory limit，什麼情境應該找 leak</li>
<li>heap、goroutine、CPU、trace 各自回答什麼問題</li>
<li>goroutine leak 應回到哪個 lifecycle 邊界修</li>
<li>allocation 優化何時值得做，何時會破壞安全邊界</li>
</ol>
<h2 id="本模組不處理">本模組不處理</h2>
<p>本模組不討論分散式 tracing 平台、完整監控系統或雲端特定 profiler。這些工具可以接在本模組之後；本模組先建立 Go runtime 原生訊號與 pprof 的診斷思路。後續可接 <a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Observability pipeline、metrics 與 tracing</a>。</p>
<h2 id="學習時間">學習時間</h2>
<p>預計 3-4 小時</p>
]]></content:encoded></item><item><title>模組三：標準庫實戰</title><link>https://tarrragon.github.io/blog/go/03-stdlib/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/03-stdlib/</guid><description>&lt;p>Go 的標準庫是理解 Go 精神的重要入口。它偏好清楚的小 API、明確錯誤處理與組合式設計。本模組從常見任務出發：格式化輸出、時間處理、JSON 編解碼、HTTP handler、結構化日誌、生命週期控制、資源清理與設定讀取。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&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;a href="https://tarrragon.github.io/blog/go/03-stdlib/fmt-strings/" data-link-title="3.1 fmt、strings 與基本文字處理" data-link-desc="處理格式化輸出、字串清理、切割與組合">3.1&lt;/a>&lt;/td>
 &lt;td>fmt、strings 與基本文字處理&lt;/td>
 &lt;td>處理輸出、格式化與字串轉換&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/03-stdlib/time/" data-link-title="3.2 time：時間與 duration" data-link-desc="表達時間點、時間差、timer、ticker 與 timeout">3.2&lt;/a>&lt;/td>
 &lt;td>time：時間與 duration&lt;/td>
 &lt;td>表達時間點、時間差與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/03-stdlib/files-io/" data-link-title="3.3 os/io：檔案與輸入輸出" data-link-desc="讀寫檔案，理解 io.Reader 與 io.Writer">3.3&lt;/a>&lt;/td>
 &lt;td>os/io：檔案與輸入輸出&lt;/td>
 &lt;td>讀寫檔案與理解 &lt;code>io.Reader&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/03-stdlib/json/" data-link-title="3.4 encoding/json：資料交換" data-link-desc="用 encoding/json 在 struct、檔案與 HTTP 之間交換資料">3.4&lt;/a>&lt;/td>
 &lt;td>encoding/json：資料交換&lt;/td>
 &lt;td>在檔案、API 與訊息中使用 JSON&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/03-stdlib/http-handler/" data-link-title="3.5 net/http 與 handler 設計" data-link-desc="用 net/http 建立健康檢查、API endpoint 與清楚的 handler 邊界">3.5&lt;/a>&lt;/td>
 &lt;td>net/http 與 handler 設計&lt;/td>
 &lt;td>用 &lt;code>http.HandlerFunc&lt;/code> 理解 Go 的組合風格&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/03-stdlib/slog/" data-link-title="3.6 log/slog：結構化日誌" data-link-desc="用 key-value log 設計可查詢、可過濾的程式訊號">3.6&lt;/a>&lt;/td>
 &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>/slog：結構化日誌&lt;/td>
 &lt;td>用欄位支援除錯與 grep&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/03-stdlib/context/" data-link-title="3.7 context：取消、逾時與生命週期" data-link-desc="用 context 傳遞取消、逾時與請求生命週期">3.7&lt;/a>&lt;/td>
 &lt;td>context：取消、逾時與生命週期&lt;/td>
 &lt;td>讓長時間工作可以停止&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/03-stdlib/defer-cleanup/" data-link-title="3.8 defer 與資源清理" data-link-desc="用 defer 管理 close、unlock、cleanup 與 panic 邊界">3.8&lt;/a>&lt;/td>
 &lt;td>defer 與資源清理&lt;/td>
 &lt;td>用 &lt;code>defer&lt;/code> 管理 close、unlock、recover 邊界&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/03-stdlib/config-flags-env/" data-link-title="3.9 flag、os/env 與設定邊界" data-link-desc="用標準庫讀取設定，並把外部輸入轉成 config struct">3.9&lt;/a>&lt;/td>
 &lt;td>flag、os/env 與設定邊界&lt;/td>
 &lt;td>用標準庫讀取設定，並把設定轉成 config struct&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/03-stdlib/service-support/" data-link-title="3.10 標準庫如何支撐服務型 Go" data-link-desc="把 context、net/http、log/slog、defer 與 time 連成服務底座">3.10&lt;/a>&lt;/td>
 &lt;td>標準庫如何支撐服務型 Go&lt;/td>
 &lt;td>把 &lt;code>context&lt;/code>、&lt;code>net/http&lt;/code> 與 &lt;code>log/slog&lt;/code> 串成底座&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;ul>
&lt;li>格式化輸出與錯誤訊息&lt;/li>
&lt;li>字串處理與時間處理&lt;/li>
&lt;li>檔案讀寫與 I/O 介面&lt;/li>
&lt;li>JSON 檔案與 API 資料&lt;/li>
&lt;li>HTTP handler&lt;/li>
&lt;li>結構化日誌欄位&lt;/li>
&lt;li>資源清理與基本設定讀取&lt;/li>
&lt;/ul>
&lt;h2 id="學習時間">學習時間&lt;/h2>
&lt;p>預計 3-4 小時&lt;/p></description><content:encoded><![CDATA[<p>Go 的標準庫是理解 Go 精神的重要入口。它偏好清楚的小 API、明確錯誤處理與組合式設計。本模組從常見任務出發：格式化輸出、時間處理、JSON 編解碼、HTTP handler、結構化日誌、生命週期控制、資源清理與設定讀取。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go/03-stdlib/fmt-strings/" data-link-title="3.1 fmt、strings 與基本文字處理" data-link-desc="處理格式化輸出、字串清理、切割與組合">3.1</a></td>
          <td>fmt、strings 與基本文字處理</td>
          <td>處理輸出、格式化與字串轉換</td>
      </tr>
      <tr>
          <td><a href="/blog/go/03-stdlib/time/" data-link-title="3.2 time：時間與 duration" data-link-desc="表達時間點、時間差、timer、ticker 與 timeout">3.2</a></td>
          <td>time：時間與 duration</td>
          <td>表達時間點、時間差與 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a></td>
      </tr>
      <tr>
          <td><a href="/blog/go/03-stdlib/files-io/" data-link-title="3.3 os/io：檔案與輸入輸出" data-link-desc="讀寫檔案，理解 io.Reader 與 io.Writer">3.3</a></td>
          <td>os/io：檔案與輸入輸出</td>
          <td>讀寫檔案與理解 <code>io.Reader</code></td>
      </tr>
      <tr>
          <td><a href="/blog/go/03-stdlib/json/" data-link-title="3.4 encoding/json：資料交換" data-link-desc="用 encoding/json 在 struct、檔案與 HTTP 之間交換資料">3.4</a></td>
          <td>encoding/json：資料交換</td>
          <td>在檔案、API 與訊息中使用 JSON</td>
      </tr>
      <tr>
          <td><a href="/blog/go/03-stdlib/http-handler/" data-link-title="3.5 net/http 與 handler 設計" data-link-desc="用 net/http 建立健康檢查、API endpoint 與清楚的 handler 邊界">3.5</a></td>
          <td>net/http 與 handler 設計</td>
          <td>用 <code>http.HandlerFunc</code> 理解 Go 的組合風格</td>
      </tr>
      <tr>
          <td><a href="/blog/go/03-stdlib/slog/" data-link-title="3.6 log/slog：結構化日誌" data-link-desc="用 key-value log 設計可查詢、可過濾的程式訊號">3.6</a></td>
          <td><a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>/slog：結構化日誌</td>
          <td>用欄位支援除錯與 grep</td>
      </tr>
      <tr>
          <td><a href="/blog/go/03-stdlib/context/" data-link-title="3.7 context：取消、逾時與生命週期" data-link-desc="用 context 傳遞取消、逾時與請求生命週期">3.7</a></td>
          <td>context：取消、逾時與生命週期</td>
          <td>讓長時間工作可以停止</td>
      </tr>
      <tr>
          <td><a href="/blog/go/03-stdlib/defer-cleanup/" data-link-title="3.8 defer 與資源清理" data-link-desc="用 defer 管理 close、unlock、cleanup 與 panic 邊界">3.8</a></td>
          <td>defer 與資源清理</td>
          <td>用 <code>defer</code> 管理 close、unlock、recover 邊界</td>
      </tr>
      <tr>
          <td><a href="/blog/go/03-stdlib/config-flags-env/" data-link-title="3.9 flag、os/env 與設定邊界" data-link-desc="用標準庫讀取設定，並把外部輸入轉成 config struct">3.9</a></td>
          <td>flag、os/env 與設定邊界</td>
          <td>用標準庫讀取設定，並把設定轉成 config struct</td>
      </tr>
      <tr>
          <td><a href="/blog/go/03-stdlib/service-support/" data-link-title="3.10 標準庫如何支撐服務型 Go" data-link-desc="把 context、net/http、log/slog、defer 與 time 連成服務底座">3.10</a></td>
          <td>標準庫如何支撐服務型 Go</td>
          <td>把 <code>context</code>、<code>net/http</code> 與 <code>log/slog</code> 串成底座</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<ul>
<li>格式化輸出與錯誤訊息</li>
<li>字串處理與時間處理</li>
<li>檔案讀寫與 I/O 介面</li>
<li>JSON 檔案與 API 資料</li>
<li>HTTP handler</li>
<li>結構化日誌欄位</li>
<li>資源清理與基本設定讀取</li>
</ul>
<h2 id="學習時間">學習時間</h2>
<p>預計 3-4 小時</p>
]]></content:encoded></item><item><title>Go 平台適配</title><link>https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/go-platform/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/go-platform/</guid><description>&lt;p>Go 的 monitoring SDK 和其他平台 SDK 的定位不同。JS / Flutter / Python SDK 是 client-side 的事件上報工具，Go SDK 更常用在 server-side — 包括 collector 本身的自身監控。Go 的 goroutine 並行模型、signal handling 機制和 HTTP server 的 graceful shutdown 是 Go 環境中的三個核心適配問題。&lt;/p>
&lt;h2 id="graceful-shutdown">Graceful shutdown&lt;/h2>
&lt;p>Go 程式收到 SIGTERM 或 SIGINT 時需要在退出前完成清理：flush 剩餘的 buffer、關閉網路連線、寫入最後的 lifecycle 事件。&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="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">stop&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">signal&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NotifyContext&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Background&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">syscall&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SIGTERM&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">syscall&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SIGINT&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="nf">stop&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>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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="c1">// signal received, start graceful shutdown&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">monitor&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Close&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WithTimeout&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Background&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="mi">5&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 class="p">))&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>graceful shutdown 的時間窗口由部署環境決定。Kubernetes 的預設 terminationGracePeriodSeconds 是 30 秒，Docker 的 stop timeout 是 10 秒。SDK 的 Close 方法接受 context 讓呼叫端控制超時。&lt;/p>
&lt;h3 id="http-server-的-shutdown-順序">HTTP server 的 shutdown 順序&lt;/h3>
&lt;p>如果 Go 程式同時是 HTTP server 和 monitoring SDK 的使用者，shutdown 順序需要正確：&lt;/p>
&lt;ol>
&lt;li>停止接受新連線（&lt;code>server.Shutdown(ctx)&lt;/code>）&lt;/li>
&lt;li>等待進行中的請求完成&lt;/li>
&lt;li>flush 監控 buffer（&lt;code>monitor.Close(ctx)&lt;/code>）&lt;/li>
&lt;li>關閉 log 和其他資源&lt;/li>
&lt;/ol>
&lt;p>如果先 close monitor 再 shutdown server，進行中的請求產生的事件會在 monitor 已關閉後嘗試送出，被靜默丟棄。&lt;/p>
&lt;h2 id="signal-handling">Signal handling&lt;/h2>
&lt;p>Go 的 &lt;code>signal.Notify&lt;/code> 和 &lt;code>signal.NotifyContext&lt;/code> 是接收 OS signal 的標準方式。SDK 在 init 時不應該自己註冊 signal handler — 這會和應用程式的 signal handling 衝突（Go 的 signal handler 是先到先得，後註冊的覆蓋先註冊的）。&lt;/p>
&lt;p>SDK 端的適配方式是提供 &lt;code>Close&lt;/code> 方法讓應用程式在自己的 signal handler 中呼叫，而非 SDK 內部攔截 signal。應用程式控制 shutdown 流程，SDK 只負責在被告知關閉時 flush 和清理。&lt;/p>
&lt;h3 id="panic-recovery">panic recovery&lt;/h3>
&lt;p>Go 的 panic 會終止當前 goroutine。如果 panic 發生在 main goroutine 且沒有 recover，程式直接退出，SDK 的 buffer 中的事件遺失。&lt;/p>
&lt;p>SDK 可以提供 &lt;code>monitor.RecoverAndReport()&lt;/code> 讓開發者在 goroutine 的入口用 &lt;code>defer monitor.RecoverAndReport()&lt;/code> 攔截 panic，記錄 error 事件後再 re-panic（保持原有的 crash 行為）。&lt;/p>
&lt;p>HTTP handler 的 panic 可以用 middleware 攔截：&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">monitorMiddleware&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">next&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Handler&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Handler&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">return&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">HandlerFunc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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">defer&lt;/span> &lt;span class="nx">monitor&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RecoverAndReport&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">next&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ServeHTTP&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&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;/code>&lt;/pre>&lt;/div>&lt;h2 id="http-server-自身監控">HTTP server 自身監控&lt;/h2>
&lt;p>Go 常用來寫 collector 本身。Collector 需要監控自己的健康狀態 — 請求處理速率、錯誤率、goroutine 數量、記憶體使用量。&lt;/p></description><content:encoded><![CDATA[<p>Go 的 monitoring SDK 和其他平台 SDK 的定位不同。JS / Flutter / Python SDK 是 client-side 的事件上報工具，Go SDK 更常用在 server-side — 包括 collector 本身的自身監控。Go 的 goroutine 並行模型、signal handling 機制和 HTTP server 的 graceful shutdown 是 Go 環境中的三個核心適配問題。</p>
<h2 id="graceful-shutdown">Graceful shutdown</h2>
<p>Go 程式收到 SIGTERM 或 SIGINT 時需要在退出前完成清理：flush 剩餘的 buffer、關閉網路連線、寫入最後的 lifecycle 事件。</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="nx">ctx</span><span class="p">,</span> <span class="nx">stop</span> <span class="o">:=</span> <span class="nx">signal</span><span class="p">.</span><span class="nf">NotifyContext</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">syscall</span><span class="p">.</span><span class="nx">SIGTERM</span><span class="p">,</span> <span class="nx">syscall</span><span class="p">.</span><span class="nx">SIGINT</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="nf">stop</span><span class="p">()</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="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1">// signal received, start graceful shutdown</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nx">monitor</span><span class="p">.</span><span class="nf">Close</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">WithTimeout</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="mi">5</span><span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">))</span></span></span></code></pre></div><p>graceful shutdown 的時間窗口由部署環境決定。Kubernetes 的預設 terminationGracePeriodSeconds 是 30 秒，Docker 的 stop timeout 是 10 秒。SDK 的 Close 方法接受 context 讓呼叫端控制超時。</p>
<h3 id="http-server-的-shutdown-順序">HTTP server 的 shutdown 順序</h3>
<p>如果 Go 程式同時是 HTTP server 和 monitoring SDK 的使用者，shutdown 順序需要正確：</p>
<ol>
<li>停止接受新連線（<code>server.Shutdown(ctx)</code>）</li>
<li>等待進行中的請求完成</li>
<li>flush 監控 buffer（<code>monitor.Close(ctx)</code>）</li>
<li>關閉 log 和其他資源</li>
</ol>
<p>如果先 close monitor 再 shutdown server，進行中的請求產生的事件會在 monitor 已關閉後嘗試送出，被靜默丟棄。</p>
<h2 id="signal-handling">Signal handling</h2>
<p>Go 的 <code>signal.Notify</code> 和 <code>signal.NotifyContext</code> 是接收 OS signal 的標準方式。SDK 在 init 時不應該自己註冊 signal handler — 這會和應用程式的 signal handling 衝突（Go 的 signal handler 是先到先得，後註冊的覆蓋先註冊的）。</p>
<p>SDK 端的適配方式是提供 <code>Close</code> 方法讓應用程式在自己的 signal handler 中呼叫，而非 SDK 內部攔截 signal。應用程式控制 shutdown 流程，SDK 只負責在被告知關閉時 flush 和清理。</p>
<h3 id="panic-recovery">panic recovery</h3>
<p>Go 的 panic 會終止當前 goroutine。如果 panic 發生在 main goroutine 且沒有 recover，程式直接退出，SDK 的 buffer 中的事件遺失。</p>
<p>SDK 可以提供 <code>monitor.RecoverAndReport()</code> 讓開發者在 goroutine 的入口用 <code>defer monitor.RecoverAndReport()</code> 攔截 panic，記錄 error 事件後再 re-panic（保持原有的 crash 行為）。</p>
<p>HTTP handler 的 panic 可以用 middleware 攔截：</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">monitorMiddleware</span><span class="p">(</span><span class="nx">next</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span><span class="p">)</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">http</span><span class="p">.</span><span class="nf">HandlerFunc</span><span class="p">(</span><span class="kd">func</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</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">defer</span> <span class="nx">monitor</span><span class="p">.</span><span class="nf">RecoverAndReport</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">next</span><span class="p">.</span><span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">r</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></code></pre></div><h2 id="http-server-自身監控">HTTP server 自身監控</h2>
<p>Go 常用來寫 collector 本身。Collector 需要監控自己的健康狀態 — 請求處理速率、錯誤率、goroutine 數量、記憶體使用量。</p>
<p>Collector 的自身監控和接收外部事件是兩個獨立的管線。自身監控的 metric 可以寫入獨立的 JSONL 檔案（和外部事件分開），或透過 Go 的 <code>expvar</code> / <code>runtime.ReadMemStats</code> 暴露為 HTTP endpoint。</p>
<p>自身監控的關鍵指標：</p>
<ul>
<li><code>collector.events.received</code>：每秒收到的事件數</li>
<li><code>collector.events.invalid</code>：schema 驗證失敗的事件數</li>
<li><code>collector.storage.write_duration_ms</code>：寫入 JSONL 的耗時</li>
<li><code>collector.goroutines</code>：goroutine 數量（洩漏偵測）</li>
<li><code>collector.memory.alloc_mb</code>：記憶體使用量</li>
</ul>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>跨平台 timestamp 一致性 → <a href="/blog/monitoring/05-platform-adaptation/cross-platform-timestamp/" data-link-title="跨平台 timestamp 一致性" data-link-desc="時區、精度、clock drift — 不同平台產生的 timestamp 在 collector 端需要能正確比對和排序">跨平台 timestamp 一致性</a></li>
<li>Collector 的架構設計 → <a href="/blog/monitoring/04-collector/" data-link-title="模組四：Collector 設計" data-link-desc="收 → 驗 → 存 → 查 → 觸發的完整鏈路 — Go 單一 binary、可插拔 Storage Backend、rule engine">模組四 Collector 設計</a></li>
<li>SDK 公開 API 的 Close 方法 → <a href="/blog/monitoring/03-sdk-design/public-api/" data-link-title="SDK 公開 API 設計" data-link-desc="init / event / error / metric / flush / close 六個方法構成 SDK 的完整生命週期 — 跨平台共用相同 API 介面">模組三 SDK 公開 API</a></li>
</ul>
]]></content:encoded></item><item><title>模組四：Collector 設計</title><link>https://tarrragon.github.io/blog/monitoring/04-collector/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/monitoring/04-collector/</guid><description>&lt;p>回答「收到的事件怎麼處理」。挑戰在 collector 端，不在 SDK 端。&lt;/p>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Collector 架構（HTTP endpoint → JSON Schema 驗證 → 儲存 → 查詢 → rule engine）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> JSONL 匯出與備份格式（匯出格式、gzip 壓縮、備份保留）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 查詢 API 設計（CLI grep 友好 vs HTTP 查詢 endpoint）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Rule engine 設計（條件 → 動作 → 模板）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 規模演進：可插拔 Storage Backend（SQLite 預設 / PostgreSQL 觸發）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 功能分層與 Backend 選擇（SQLite 層 vs PostgreSQL 層的功能邊界）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> SQLite Backend 效能基準（寫入吞吐 / 查詢延遲 / 資源消耗的量化預期）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Ingestion Scaling（四層防線 — SDK 取樣 → Collector 背壓 → 水平擴展 → Queue 解耦）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 查詢消費模式（Debug / Alerting / 產品決策 / 安全審計 / 效能監控）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> DevOps Dashboard 設計&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Developer Dashboard 設計&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 中台 Dashboard 設計&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Container 部署設計（SQLite 在 container 中的 I/O 考量、volume mount、graceful shutdown）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 讀寫分離與查詢擴展（讀寫競爭辨識、Read Replica、預聚合、CQRS 判讀訊號）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 端到端資料完整性（資料損失地圖、完整性指標、被自己 SDK DDoS 的防護）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Error Fingerprint 與去重分群（fingerprint 演算法、message normalization、error_groups 表）&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">backend 01 資料庫&lt;/a>：PostgreSQL backend 的資料庫設計、&lt;a href="https://tarrragon.github.io/blog/backend/01-database/state-ownership-query-boundary/" data-link-title="1.8 State Ownership 與 Query Boundary" data-link-desc="正式狀態 vs 派生狀態的責任分層、CQRS / event sourcing / materialized view、四種 query 邊界">State Ownership 與 Query Boundary&lt;/a>&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/observability-query-design/" data-link-title="4.23 觀測查詢設計" data-link-desc="把觀測資料的讀取路徑當系統設計問題處理：三種查詢模式、storage tiering、pre-aggregation 與資源治理">backend 04 觀測查詢設計&lt;/a>：觀測領域的讀取路徑設計、CQRS 特化應用&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">backend 09 效能容量&lt;/a>：高併發寫入 / 大資料查詢的效能挑戰&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">DevOps 流量管控&lt;/a>：背壓、rate limit、熔斷的基礎概念&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/devops/07-burst-traffic/" data-link-title="模組七：突發流量應對" data-link-desc="行銷活動或新聞曝光帶來 10x-100x 流量時怎麼撐 — 突發分類、降級策略、queue 緩衝、規模分級應對">DevOps 突發流量&lt;/a>：突發流量分類、降級策略、queue 緩衝&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/infra/air-gapped/air-gapped-monitoring/" data-link-title="斷網環境的監控與可觀測性" data-link-desc="Self-hosted 監控（Prometheus &amp;#43; Grafana）、離線 log 收集（Loki / ELK）、不能 phone home 的告警、NTP 時間同步">斷網環境的監控&lt;/a>：Collector 在斷網環境的部署方式——endpoint 改指 self-hosted backend、SDK 的 offline buffer 更重要&lt;/li>
&lt;li>實作 repo：tarrragon/monitor 的 collector/ + docs/challenges/（撞牆記錄）&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「收到的事件怎麼處理」。挑戰在 collector 端，不在 SDK 端。</p>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> Collector 架構（HTTP endpoint → JSON Schema 驗證 → 儲存 → 查詢 → rule engine）</li>
<li><input checked="" disabled="" type="checkbox"> JSONL 匯出與備份格式（匯出格式、gzip 壓縮、備份保留）</li>
<li><input checked="" disabled="" type="checkbox"> 查詢 API 設計（CLI grep 友好 vs HTTP 查詢 endpoint）</li>
<li><input checked="" disabled="" type="checkbox"> Rule engine 設計（條件 → 動作 → 模板）</li>
<li><input checked="" disabled="" type="checkbox"> 規模演進：可插拔 Storage Backend（SQLite 預設 / PostgreSQL 觸發）</li>
<li><input checked="" disabled="" type="checkbox"> 功能分層與 Backend 選擇（SQLite 層 vs PostgreSQL 層的功能邊界）</li>
<li><input checked="" disabled="" type="checkbox"> SQLite Backend 效能基準（寫入吞吐 / 查詢延遲 / 資源消耗的量化預期）</li>
<li><input checked="" disabled="" type="checkbox"> Ingestion Scaling（四層防線 — SDK 取樣 → Collector 背壓 → 水平擴展 → Queue 解耦）</li>
<li><input checked="" disabled="" type="checkbox"> 查詢消費模式（Debug / Alerting / 產品決策 / 安全審計 / 效能監控）</li>
<li><input checked="" disabled="" type="checkbox"> DevOps Dashboard 設計</li>
<li><input checked="" disabled="" type="checkbox"> Developer Dashboard 設計</li>
<li><input checked="" disabled="" type="checkbox"> 中台 Dashboard 設計</li>
<li><input checked="" disabled="" type="checkbox"> Container 部署設計（SQLite 在 container 中的 I/O 考量、volume mount、graceful shutdown）</li>
<li><input checked="" disabled="" type="checkbox"> 讀寫分離與查詢擴展（讀寫競爭辨識、Read Replica、預聚合、CQRS 判讀訊號）</li>
<li><input checked="" disabled="" type="checkbox"> 端到端資料完整性（資料損失地圖、完整性指標、被自己 SDK DDoS 的防護）</li>
<li><input checked="" disabled="" type="checkbox"> Error Fingerprint 與去重分群（fingerprint 演算法、message normalization、error_groups 表）</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">backend 01 資料庫</a>：PostgreSQL backend 的資料庫設計、<a href="/blog/backend/01-database/state-ownership-query-boundary/" data-link-title="1.8 State Ownership 與 Query Boundary" data-link-desc="正式狀態 vs 派生狀態的責任分層、CQRS / event sourcing / materialized view、四種 query 邊界">State Ownership 與 Query Boundary</a></li>
<li>→ <a href="/blog/backend/04-observability/observability-query-design/" data-link-title="4.23 觀測查詢設計" data-link-desc="把觀測資料的讀取路徑當系統設計問題處理：三種查詢模式、storage tiering、pre-aggregation 與資源治理">backend 04 觀測查詢設計</a>：觀測領域的讀取路徑設計、CQRS 特化應用</li>
<li>→ <a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">backend 09 效能容量</a>：高併發寫入 / 大資料查詢的效能挑戰</li>
<li>→ <a href="/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">DevOps 流量管控</a>：背壓、rate limit、熔斷的基礎概念</li>
<li>→ <a href="/blog/devops/07-burst-traffic/" data-link-title="模組七：突發流量應對" data-link-desc="行銷活動或新聞曝光帶來 10x-100x 流量時怎麼撐 — 突發分類、降級策略、queue 緩衝、規模分級應對">DevOps 突發流量</a>：突發流量分類、降級策略、queue 緩衝</li>
<li>→ <a href="/blog/infra/air-gapped/air-gapped-monitoring/" data-link-title="斷網環境的監控與可觀測性" data-link-desc="Self-hosted 監控（Prometheus &#43; Grafana）、離線 log 收集（Loki / ELK）、不能 phone home 的告警、NTP 時間同步">斷網環境的監控</a>：Collector 在斷網環境的部署方式——endpoint 改指 self-hosted backend、SDK 的 offline buffer 更重要</li>
<li>實作 repo：tarrragon/monitor 的 collector/ + docs/challenges/（撞牆記錄）</li>
</ul>
]]></content:encoded></item><item><title>9.4 跨檔案圖分析：從 lint 走到 static analysis</title><link>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/cross-file-graph-analysis/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/cross-file-graph-analysis/</guid><description>&lt;p>跨檔案靜態分析的核心責任是&lt;strong>把整個 repo 結構化成可查詢的 &lt;a href="https://tarrragon.github.io/blog/go/glossary/#%e8%b7%a8%e6%aa%94%e6%a1%88-link-graph" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">link graph&lt;/a>&lt;/strong>，讓「這張卡片有沒有被引用」「這個連結指的目標存在嗎」「這個 section 是否被孤立」這類反向/跨檔問題能在 O(1) 或 O(log n) 的 graph lookup 內回答，而不是每次查詢都重 parse 全部檔案。圖的節點是檔案、邊是檔案間的連結 / 引用 / 依賴關係；一次 parse（用 &lt;a href="https://tarrragon.github.io/blog/go/glossary/#ast-walker" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">AST walker&lt;/a> 掃過）之後，所有跨檔 query 都在 in-memory map 上做。&lt;/p>
&lt;p>這類分析的典型觸發點是需求已經離開 single-file 層：&lt;strong>orphan 偵測&lt;/strong>（某個檔案是否被引用）、&lt;strong>backlink 完整性&lt;/strong>（連結目標是否存在）、&lt;strong>dependency cycle 檢測&lt;/strong>（import graph 是否有環）、&lt;strong>unused export 偵測&lt;/strong>（某個 symbol 是否被使用）。每個都是圖論問題，需要先把 repo 結構化，單檔 walker 看不見跨檔 edge。本章以 &lt;code>mdtools cards&lt;/code>（L1 連結有效性、L2 orphan 卡片、L4 卡片 K4 合規）作為 concrete instance。&lt;/p>
&lt;h2 id="為什麼要預先建圖而非每次-lint-都現查">為什麼要預先建圖而非每次 lint 都現查&lt;/h2>
&lt;p>直覺會說：對每個 link，直接 &lt;code>os.Stat(target)&lt;/code> 看存在不存在，就能驗證 L1。&lt;/p>
&lt;p>這個做法在 100 個檔案、每檔 10 個 link 時 OK — 1000 次 stat、每次 &amp;lt; 1ms，總計 1 秒內。但一旦要做 L2 「每張卡片至少被一篇正文引用」，問題就變成：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">for each card file C:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> for each other file F:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> parse F
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> for each link L in F:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> if L points to C: mark C as referenced&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>N² parse，每次 parse 又走 AST。1000 檔 × 1000 檔 = 100 萬次 parse，每次 50ms，總計 14 小時。&lt;/p>
&lt;p>解法是 &lt;strong>parse 一次、存下所有 edge、在圖上查&lt;/strong>。Parse 是 O(N) 一次；所有後續 query 都在 in-memory map 上做，microseconds。&lt;/p>
&lt;h2 id="graph-的資料結構">Graph 的資料結構&lt;/h2>





&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="c1">// scripts/mdtools/internal/mdcards/graph.go&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">Edge&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"> 3&lt;/span>&lt;span class="cl">	&lt;span class="nx">SourcePath&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="c1">// 包含連結的檔案&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">SourceLine&lt;/span> &lt;span class="kt">int&lt;/span> &lt;span class="c1">// 連結所在行&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">Destination&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="c1">// link 目的地（原文）&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">Target&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="c1">// 解析後的檔案路徑（可能不存在）&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">DisplayText&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="c1">// 顯示文字（反釣魚用）&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">type&lt;/span> &lt;span class="nx">FileNode&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">11&lt;/span>&lt;span class="cl">	&lt;span class="nx">Path&lt;/span> &lt;span class="kt">string&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">AST&lt;/span> &lt;span class="nx">ast&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Node&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">Src&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">byte&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="kd">type&lt;/span> &lt;span class="nx">Graph&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">17&lt;/span>&lt;span class="cl">	&lt;span class="nx">Files&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">FileNode&lt;/span> &lt;span class="c1">// 所有 .md 檔&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">	&lt;span class="nx">Edges&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">Edge&lt;/span> &lt;span class="c1">// 所有相對連結&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">	&lt;span class="nx">byPath&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="o">*&lt;/span>&lt;span class="nx">FileNode&lt;/span> &lt;span class="c1">// path → FileNode&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">	&lt;span class="nx">inbound&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="kt">int&lt;/span> &lt;span class="c1">// target path → edge indices&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">outbound&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="kt">int&lt;/span> &lt;span class="c1">// source path → edge indices&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&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;/p></description><content:encoded><![CDATA[<p>跨檔案靜態分析的核心責任是<strong>把整個 repo 結構化成可查詢的 <a href="/blog/go/glossary/#%e8%b7%a8%e6%aa%94%e6%a1%88-link-graph" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">link graph</a></strong>，讓「這張卡片有沒有被引用」「這個連結指的目標存在嗎」「這個 section 是否被孤立」這類反向/跨檔問題能在 O(1) 或 O(log n) 的 graph lookup 內回答，而不是每次查詢都重 parse 全部檔案。圖的節點是檔案、邊是檔案間的連結 / 引用 / 依賴關係；一次 parse（用 <a href="/blog/go/glossary/#ast-walker" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">AST walker</a> 掃過）之後，所有跨檔 query 都在 in-memory map 上做。</p>
<p>這類分析的典型觸發點是需求已經離開 single-file 層：<strong>orphan 偵測</strong>（某個檔案是否被引用）、<strong>backlink 完整性</strong>（連結目標是否存在）、<strong>dependency cycle 檢測</strong>（import graph 是否有環）、<strong>unused export 偵測</strong>（某個 symbol 是否被使用）。每個都是圖論問題，需要先把 repo 結構化，單檔 walker 看不見跨檔 edge。本章以 <code>mdtools cards</code>（L1 連結有效性、L2 orphan 卡片、L4 卡片 K4 合規）作為 concrete instance。</p>
<h2 id="為什麼要預先建圖而非每次-lint-都現查">為什麼要預先建圖而非每次 lint 都現查</h2>
<p>直覺會說：對每個 link，直接 <code>os.Stat(target)</code> 看存在不存在，就能驗證 L1。</p>
<p>這個做法在 100 個檔案、每檔 10 個 link 時 OK — 1000 次 stat、每次 &lt; 1ms，總計 1 秒內。但一旦要做 L2 「每張卡片至少被一篇正文引用」，問題就變成：</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">for each card file C:
</span></span><span class="line"><span class="ln">2</span><span class="cl">  for each other file F:
</span></span><span class="line"><span class="ln">3</span><span class="cl">    parse F
</span></span><span class="line"><span class="ln">4</span><span class="cl">    for each link L in F:
</span></span><span class="line"><span class="ln">5</span><span class="cl">      if L points to C: mark C as referenced</span></span></code></pre></div><p>N² parse，每次 parse 又走 AST。1000 檔 × 1000 檔 = 100 萬次 parse，每次 50ms，總計 14 小時。</p>
<p>解法是 <strong>parse 一次、存下所有 edge、在圖上查</strong>。Parse 是 O(N) 一次；所有後續 query 都在 in-memory map 上做，microseconds。</p>
<h2 id="graph-的資料結構">Graph 的資料結構</h2>





<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="c1">// scripts/mdtools/internal/mdcards/graph.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">type</span> <span class="nx">Edge</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">SourcePath</span>  <span class="kt">string</span> <span class="c1">// 包含連結的檔案</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="nx">SourceLine</span>  <span class="kt">int</span>    <span class="c1">// 連結所在行</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	<span class="nx">Destination</span> <span class="kt">string</span> <span class="c1">// link 目的地（原文）</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">	<span class="nx">Target</span>      <span class="kt">string</span> <span class="c1">// 解析後的檔案路徑（可能不存在）</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">	<span class="nx">DisplayText</span> <span class="kt">string</span> <span class="c1">// 顯示文字（反釣魚用）</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">type</span> <span class="nx">FileNode</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">	<span class="nx">Path</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">	<span class="nx">AST</span>  <span class="nx">ast</span><span class="p">.</span><span class="nx">Node</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">	<span class="nx">Src</span>  <span class="p">[]</span><span class="kt">byte</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="kd">type</span> <span class="nx">Graph</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">	<span class="nx">Files</span> <span class="p">[]</span><span class="nx">FileNode</span>  <span class="c1">// 所有 .md 檔</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">	<span class="nx">Edges</span> <span class="p">[]</span><span class="nx">Edge</span>      <span class="c1">// 所有相對連結</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="nx">byPath</span>   <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="o">*</span><span class="nx">FileNode</span> <span class="c1">// path → FileNode</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">	<span class="nx">inbound</span>  <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">][]</span><span class="kt">int</span>     <span class="c1">// target path → edge indices</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">	<span class="nx">outbound</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">][]</span><span class="kt">int</span>     <span class="c1">// source path → edge indices</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>設計重點：</p>
<ul>
<li><strong>FileNode 保留 AST + src</strong>：後面 L4 檢查（卡片首段是否有鄰卡連結）需要重讀 AST，不想再 parse 一次。</li>
<li><strong>Edge 用 slice 儲存，index 走 map</strong>：比起直接用 <code>map[string][]Edge</code>，這個 layout allocation 少、GC 友善，也容易 sort 輸出。</li>
<li><strong>inbound / outbound 都預先建</strong>：L1 靠 outbound，L2 靠 inbound。一次 parse 把兩邊都填好。</li>
</ul>
<h2 id="parse-pipeline兩段式確保指標穩定">Parse pipeline：兩段式確保指標穩定</h2>





<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="c1">// scripts/mdtools/internal/mdcards/graph.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">BuildGraph</span><span class="p">(</span><span class="nx">roots</span> <span class="p">[]</span><span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="nx">Graph</span><span class="p">,</span> <span class="kt">error</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">mdFiles</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">files</span><span class="p">.</span><span class="nf">WalkMarkdown</span><span class="p">(</span><span class="nx">roots</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</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"> 5</span><span class="cl">		<span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">err</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="nx">parser</span> <span class="o">:=</span> <span class="nx">astutil</span><span class="p">.</span><span class="nf">NewParser</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">	<span class="nx">g</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">Graph</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">		<span class="nx">byPath</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="o">*</span><span class="nx">FileNode</span><span class="p">{},</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">		<span class="nx">inbound</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="kt">int</span><span class="p">{},</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">		<span class="nx">outbound</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="kt">int</span><span class="p">{},</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">	<span class="c1">// 第一段：parse 全部檔案，填 g.Files</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">path</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">mdFiles</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">		<span class="nx">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">ReadFile</span><span class="p">(</span><span class="nx">path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</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">19</span><span class="cl">			<span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">err</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="nx">doc</span> <span class="o">:=</span> <span class="nx">parser</span><span class="p">.</span><span class="nf">Parse</span><span class="p">(</span><span class="nx">data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">		<span class="nx">node</span> <span class="o">:=</span> <span class="nx">FileNode</span><span class="p">{</span><span class="nx">Path</span><span class="p">:</span> <span class="nx">path</span><span class="p">,</span> <span class="nx">AST</span><span class="p">:</span> <span class="nx">doc</span><span class="p">,</span> <span class="nx">Src</span><span class="p">:</span> <span class="nx">data</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">		<span class="nx">g</span><span class="p">.</span><span class="nx">Files</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">g</span><span class="p">.</span><span class="nx">Files</span><span class="p">,</span> <span class="nx">node</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">		<span class="nx">g</span><span class="p">.</span><span class="nx">byPath</span><span class="p">[</span><span class="nx">path</span><span class="p">]</span> <span class="p">=</span> <span class="o">&amp;</span><span class="nx">g</span><span class="p">.</span><span class="nx">Files</span><span class="p">[</span><span class="nb">len</span><span class="p">(</span><span class="nx">g</span><span class="p">.</span><span class="nx">Files</span><span class="p">)</span><span class="o">-</span><span class="mi">1</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">
</span></span><span class="line"><span class="ln">27</span><span class="cl">	<span class="c1">// 第二段：抽出 edge（此時 g.Files 大小已固定，指標穩定）</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">node</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">g</span><span class="p">.</span><span class="nx">Files</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">		<span class="nx">g</span><span class="p">.</span><span class="nf">extractEdges</span><span class="p">(</span><span class="nx">node</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">
</span></span><span class="line"><span class="ln">32</span><span class="cl">	<span class="k">return</span> <span class="nx">g</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>兩段式的理由</strong>：第一段邊 parse 邊 append，slice 可能重新分配 underlying array，之前取的 <code>*FileNode</code> 指標會失效。第一段收斂後才取指標，保證穩定。這是 Go slice 常見的事故。</p>
<p>如果用 <code>[]*FileNode</code>（指標 slice）就沒這問題，但對這個情境 <code>[]FileNode</code> 空間效率較好。兩種都 OK，選一種就要注意對應的陷阱。</p>
<h2 id="l1連結有效性outbound-走訪">L1：連結有效性（outbound 走訪）</h2>
<p>最簡單的 graph query：對每個 edge，檢查 target 是否存在。</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="c1">// scripts/mdtools/internal/mdcards/l1.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">checkL1LinkValidity</span><span class="p">(</span><span class="nx">g</span> <span class="o">*</span><span class="nx">Graph</span><span class="p">)</span> <span class="p">[]</span><span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="kd">var</span> <span class="nx">out</span> <span class="p">[]</span><span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">edge</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">g</span><span class="p">.</span><span class="nx">Edges</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">		<span class="k">if</span> <span class="nf">TargetExists</span><span class="p">(</span><span class="nx">edge</span><span class="p">.</span><span class="nx">Target</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">continue</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="nx">out</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">out</span><span class="p">,</span> <span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">			<span class="nx">Path</span><span class="p">:</span>  <span class="nx">edge</span><span class="p">.</span><span class="nx">SourcePath</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">			<span class="nx">Line</span><span class="p">:</span>  <span class="nx">edge</span><span class="p">.</span><span class="nx">SourceLine</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">			<span class="nx">Rule</span><span class="p">:</span>  <span class="s">&#34;L1-broken-link&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">			<span class="nx">Level</span><span class="p">:</span> <span class="nx">report</span><span class="p">.</span><span class="nx">LevelError</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">			<span class="nx">Message</span><span class="p">:</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Sprintf</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">				<span class="s">&#34;broken link %q: target not found&#34;</span><span class="p">,</span> <span class="nx">edge</span><span class="p">.</span><span class="nx">Destination</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><span class="line"><span class="ln">18</span><span class="cl">	<span class="k">return</span> <span class="nx">out</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>TargetExists</code> 試兩個候選路徑 — <code>{target}.md</code> 跟 <code>{target}/_index.md</code>，因為 Hugo 的 URL routing 對 content page 跟 section page 一視同仁：</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">TargetExists</span><span class="p">(</span><span class="nx">target</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">2</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">cand</span> <span class="o">:=</span> <span class="k">range</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span><span class="nx">target</span> <span class="o">+</span> <span class="s">&#34;.md&#34;</span><span class="p">,</span> <span class="nx">filepath</span><span class="p">.</span><span class="nf">Join</span><span class="p">(</span><span class="nx">target</span><span class="p">,</span> <span class="s">&#34;_index.md&#34;</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">if</span> <span class="nx">info</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">os</span><span class="p">.</span><span class="nf">Stat</span><span class="p">(</span><span class="nx">cand</span><span class="p">);</span> <span class="nx">err</span> <span class="o">==</span> <span class="kc">nil</span> <span class="o">&amp;&amp;</span> <span class="p">!</span><span class="nx">info</span><span class="p">.</span><span class="nf">IsDir</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">return</span> <span class="kc">true</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 class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h2 id="l2orphan-偵測inbound-反查">L2：orphan 偵測（inbound 反查）</h2>
<p>「每張卡片至少被一篇非卡片文章引用」— 這是反向查詢：對每張卡片，看它的 <code>inbound</code> 有沒有來自「非卡片檔」的 edge。</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="c1">// scripts/mdtools/internal/mdcards/l2.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">checkL2Orphans</span><span class="p">(</span><span class="nx">g</span> <span class="o">*</span><span class="nx">Graph</span><span class="p">,</span> <span class="nx">cardsRoot</span> <span class="kt">string</span><span class="p">)</span> <span class="p">[]</span><span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="nx">inboundNonCard</span> <span class="o">:=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">int</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">edge</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">g</span><span class="p">.</span><span class="nx">Edges</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">		<span class="nx">targetMd</span> <span class="o">:=</span> <span class="nx">edge</span><span class="p">.</span><span class="nx">Target</span> <span class="o">+</span> <span class="s">&#34;.md&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">		<span class="k">if</span> <span class="p">!</span><span class="nf">isCardPath</span><span class="p">(</span><span class="nx">targetMd</span><span class="p">,</span> <span class="nx">cardsRoot</span><span class="p">)</span> <span class="o">||</span> <span class="nf">isSectionIndex</span><span class="p">(</span><span class="nx">targetMd</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">			<span class="k">continue</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="nf">isCardPath</span><span class="p">(</span><span class="nx">edge</span><span class="p">.</span><span class="nx">SourcePath</span><span class="p">,</span> <span class="nx">cardsRoot</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">			<span class="k">continue</span> <span class="c1">// card-to-card 不算</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="nx">inboundNonCard</span><span class="p">[</span><span class="nx">targetMd</span><span class="p">]</span><span class="o">++</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">	<span class="kd">var</span> <span class="nx">out</span> <span class="p">[]</span><span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">fn</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">g</span><span class="p">.</span><span class="nx">Files</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="p">!</span><span class="nf">isCardPath</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">,</span> <span class="nx">cardsRoot</span><span class="p">)</span> <span class="o">||</span> <span class="nf">isSectionIndex</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">			<span class="k">continue</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="k">if</span> <span class="nx">inboundNonCard</span><span class="p">[</span><span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">]</span> <span class="p">&gt;</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">			<span class="k">continue</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="nx">out</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">out</span><span class="p">,</span> <span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">			<span class="nx">Path</span><span class="p">:</span>    <span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">			<span class="nx">Rule</span><span class="p">:</span>    <span class="s">&#34;L2-orphan-card&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">			<span class="nx">Level</span><span class="p">:</span>   <span class="nx">report</span><span class="p">.</span><span class="nx">LevelWarn</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">			<span class="nx">Message</span><span class="p">:</span> <span class="s">&#34;card has no inbound link from non-card content&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">		<span class="p">})</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">	<span class="k">return</span> <span class="nx">out</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>設計取捨</strong>：</p>
<ul>
<li><strong>Card-to-card 不算 inbound</strong>：依 spec，卡片應被「教學文章」引用證明使用場景；卡片互連只證明概念網路，不證明實用。</li>
<li><strong>Warn 而非 error</strong>：orphan 是<strong>內容覆蓋率訊號</strong>，不是格式錯誤；新卡片剛建時不該被擋 commit。</li>
<li><strong>_index.md 排除</strong>：section page 不是 card。</li>
</ul>
<h2 id="l4ast-跟-graph-混用">L4：AST 跟 Graph 混用</h2>
<p>這條規則複雜：「卡片的首段跟『概念位置』section 都要各有至少一個鄰卡連結」。要用 graph 抓候選，再用 AST 精確判讀在哪個 paragraph / section 內。</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="c1">// scripts/mdtools/internal/mdcards/l4.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">checkL4K4Structure</span><span class="p">(</span><span class="nx">g</span> <span class="o">*</span><span class="nx">Graph</span><span class="p">,</span> <span class="nx">cardsRoot</span><span class="p">,</span> <span class="nx">conceptHeadingTitle</span> <span class="kt">string</span><span class="p">)</span> <span class="p">[]</span><span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">	<span class="kd">var</span> <span class="nx">out</span> <span class="p">[]</span><span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">fn</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">g</span><span class="p">.</span><span class="nx">Files</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">		<span class="k">if</span> <span class="p">!</span><span class="nf">isCardPath</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">,</span> <span class="nx">cardsRoot</span><span class="p">)</span> <span class="o">||</span> <span class="nf">isSectionIndex</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">Path</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">continue</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="nx">firstPara</span> <span class="o">:=</span> <span class="nf">firstBodyParagraph</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">AST</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">		<span class="k">if</span> <span class="p">!</span><span class="nf">subtreeHasCardLink</span><span class="p">(</span><span class="nx">firstPara</span><span class="p">,</span> <span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">,</span> <span class="nx">cardsRoot</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">			<span class="nx">out</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">out</span><span class="p">,</span> <span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">				<span class="nx">Path</span><span class="p">:</span>    <span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">				<span class="nx">Rule</span><span class="p">:</span>    <span class="s">&#34;L4-first-paragraph-no-card-link&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">				<span class="nx">Level</span><span class="p">:</span>   <span class="nx">report</span><span class="p">.</span><span class="nx">LevelWarn</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">				<span class="nx">Message</span><span class="p">:</span> <span class="s">&#34;opening paragraph should link to an adjacent card&#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><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">		<span class="nx">section</span> <span class="o">:=</span> <span class="nf">headingSection</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">AST</span><span class="p">,</span> <span class="nx">conceptHeadingTitle</span><span class="p">,</span> <span class="nx">fn</span><span class="p">.</span><span class="nx">Src</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">		<span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">section</span><span class="p">)</span> <span class="o">==</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">			<span class="nx">out</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">out</span><span class="p">,</span> <span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">				<span class="nx">Path</span><span class="p">:</span>    <span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">				<span class="nx">Rule</span><span class="p">:</span>    <span class="s">&#34;L4-missing-concept-position&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">				<span class="nx">Level</span><span class="p">:</span>   <span class="nx">report</span><span class="p">.</span><span class="nx">LevelWarn</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">				<span class="nx">Message</span><span class="p">:</span> <span class="s">&#34;card missing 概念位置 section&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">			<span class="p">})</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">			<span class="k">continue</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">		<span class="k">if</span> <span class="p">!</span><span class="nf">sectionHasCardLink</span><span class="p">(</span><span class="nx">section</span><span class="p">,</span> <span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">,</span> <span class="nx">cardsRoot</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">			<span class="nx">out</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">out</span><span class="p">,</span> <span class="nx">report</span><span class="p">.</span><span class="nx">Violation</span><span class="p">{</span><span class="o">...</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">		<span class="p">}</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">	<span class="p">}</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">	<span class="k">return</span> <span class="nx">out</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>兩個 helper 展示 graph 跟 AST 的交界：</p>
<ul>
<li><code>firstBodyParagraph(doc)</code> 用 AST 走第一個 Paragraph 節點（document 的第一個 top-level child）。</li>
<li><code>subtreeHasCardLink(node, sourcePath, cardsRoot)</code> 用 <code>ast.Walk</code> 在該節點下找所有 Link，再用 <code>resolveTarget</code> 判斷是不是指向卡片。</li>
</ul>
<p><strong>為什麼用 AST 而不是行號範圍</strong>：Hugo content 的卡片結構多樣，首段可能跨多行；用 <code>paragraph.Lines()</code> 也能拿到 byte range，但還要處理 list item、table row 這類邊界。直接走 AST 子樹是最穩定的做法。</p>
<h2 id="反向索引的設計擴充slug-based-啟發式">反向索引的設計擴充：slug-based 啟發式</h2>
<p><code>mdtools migrate fix-links</code> 要處理「broken link 但作者其實想連到某個存在的檔案，只是路徑寫錯」。這需要<strong>額外一個 slug 反向索引</strong>：</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="c1">// scripts/mdtools/internal/mdmigrate/fixlinks.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kd">func</span> <span class="nf">buildSlugIndexes</span><span class="p">(</span><span class="nx">g</span> <span class="o">*</span><span class="nx">mdcards</span><span class="p">.</span><span class="nx">Graph</span><span class="p">)</span> <span class="p">(</span><span class="nx">primary</span><span class="p">,</span> <span class="nx">normalized</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">][]</span><span class="kt">string</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">primary</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="kt">string</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">	<span class="nx">normalized</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="kt">string</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">	<span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">fn</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">g</span><span class="p">.</span><span class="nx">Files</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">		<span class="nx">base</span> <span class="o">:=</span> <span class="nx">filepath</span><span class="p">.</span><span class="nf">Base</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">		<span class="kd">var</span> <span class="nx">slug</span><span class="p">,</span> <span class="nx">target</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">		<span class="k">if</span> <span class="nx">base</span> <span class="o">==</span> <span class="s">&#34;_index.md&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">			<span class="nx">parent</span> <span class="o">:=</span> <span class="nx">filepath</span><span class="p">.</span><span class="nf">Dir</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">			<span class="nx">slug</span> <span class="p">=</span> <span class="nx">filepath</span><span class="p">.</span><span class="nf">Base</span><span class="p">(</span><span class="nx">parent</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">			<span class="nx">target</span> <span class="p">=</span> <span class="nx">parent</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">		<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">			<span class="nx">slug</span> <span class="p">=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSuffix</span><span class="p">(</span><span class="nx">base</span><span class="p">,</span> <span class="s">&#34;.md&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">			<span class="nx">target</span> <span class="p">=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSuffix</span><span class="p">(</span><span class="nx">fn</span><span class="p">.</span><span class="nx">Path</span><span class="p">,</span> <span class="s">&#34;.md&#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="nx">primary</span><span class="p">[</span><span class="nx">slug</span><span class="p">]</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">primary</span><span class="p">[</span><span class="nx">slug</span><span class="p">],</span> <span class="nx">target</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">norm</span> <span class="o">:=</span> <span class="nx">sectionPrefixRe</span><span class="p">.</span><span class="nf">ReplaceAllString</span><span class="p">(</span><span class="nx">slug</span><span class="p">,</span> <span class="s">&#34;&#34;</span><span class="p">);</span> <span class="nx">norm</span> <span class="o">!=</span> <span class="nx">slug</span> <span class="o">&amp;&amp;</span> <span class="nx">norm</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="nx">normalized</span><span class="p">[</span><span class="nx">norm</span><span class="p">]</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">normalized</span><span class="p">[</span><span class="nx">norm</span><span class="p">],</span> <span class="nx">target</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="k">return</span> <span class="nx">primary</span><span class="p">,</span> <span class="nx">normalized</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>查詢時走多層啟發式：</p>
<ol>
<li><strong>精確 slug 命中</strong>（<code>broker</code> → <code>content/backend/knowledge-cards/broker</code>）</li>
<li><strong>數字前綴 normalized 命中</strong>（<code>03-cpython-internals</code> 找不到 → 試 <code>cpython-internals</code> → 命中 <code>04-cpython-internals</code>）</li>
<li><strong>卡片優先</strong>（多個 candidate 時選 <code>knowledge-cards/</code> 下的）</li>
<li><strong>同頂層子目錄優先</strong>（source 在 <code>content/go/...</code> 時選 <code>content/go/</code> 下的 candidate）</li>
</ol>
<p>這四層啟發式把 143 個 multi-candidate 收斂到 0 個 ambiguous。<strong>啟發式的層層疊加是 static analysis 工具常見 pattern</strong> — 寫過 linter、LSP、refactoring tool 的人都會在類似的決策樹花時間。</p>
<h2 id="parse-成本的實務控制">Parse 成本的實務控制</h2>
<p>跨檔分析一次要 parse 幾百個檔案。幾個優化：</p>
<ul>
<li><strong>只 parse 一次</strong>：Graph 建好後 L1 / L2 / L4 共用。呼叫端不該重建 Graph。</li>
<li><strong>concurrent parse（選擇性）</strong>：<code>mdtools</code> 目前單執行緒 parse ~400 檔案 &lt; 1 秒，沒必要並發。若檔案過萬，用 <code>golang.org/x/sync/errgroup</code> + worker pool fan out。</li>
<li><strong>避免記憶體持有</strong>：Graph 的 <code>FileNode.Src</code> 跟 <code>AST</code> 都持有 reference。如果 GC 壓力敏感，做完 L1-L4 後顯式 <code>g = nil</code> 或分段釋放。blog 這個規模沒必要。</li>
</ul>
<h2 id="常見陷阱">常見陷阱</h2>
<h3 id="slice-append-失效的指標">Slice append 失效的指標</h3>
<p>上面提過 — 邊 append 邊取指標會炸。BuildGraph 的兩段式是標準修法。</p>
<h3 id="filepathrel-在-root-外的-panic">filepath.Rel 在 root 外的 panic</h3>





<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="nx">rel</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">filepath</span><span class="p">.</span><span class="nf">Rel</span><span class="p">(</span><span class="s">&#34;/foo&#34;</span><span class="p">,</span> <span class="s">&#34;/bar&#34;</span><span class="p">)</span>  <span class="c1">// 不會 panic，回傳 &#34;../bar&#34;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">rel</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">filepath</span><span class="p">.</span><span class="nf">Rel</span><span class="p">(</span><span class="s">&#34;foo&#34;</span><span class="p">,</span> <span class="s">&#34;../bar&#34;</span><span class="p">)</span> <span class="c1">// err != nil</span></span></span></code></pre></div><p>跨 repo root 的 rel 路徑會回 error。graph 的 target resolution 要接住這個 error。</p>
<h3 id="symlink-的走訪">Symlink 的走訪</h3>
<p><code>filepath.WalkDir</code> 預設<strong>不跟隨 symlink</strong>。這對 blog repo OK（沒 symlink）；但對其他 repo（例如 monorepo 有 symlink 指到 sibling package）要用 <code>filepath.Walk</code> 或自己實作。mdtools 不處理這個情境。</p>
<h3 id="hugo-的-url-路由細節">Hugo 的 URL 路由細節</h3>
<p><code>../broker/</code> 從 <code>content/backend/knowledge-cards/_index.md</code> 出發，Hugo 解析成 <code>content/backend/knowledge-cards/broker</code>（也就是 sibling card 的 URL）。從 <code>content/backend/knowledge-cards/acme-automation.md</code> 出發，同樣的 <code>../broker/</code> 卻解析成 <code>content/backend/broker</code>（錯誤，broker 不在 backend 直屬）。這是因為 Hugo 把<strong>內容頁 URL dir 當成「slug 的 URL 資料夾」</strong>，不是「檔案所在的資料夾」。做 target resolution 時要注意這一點，參考 <code>resolveTarget()</code> 的實作。</p>
<h2 id="擴充路徑">擴充路徑</h2>
<ul>
<li><strong>Ingest commit history</strong>：把 git commit 的時序資訊加進 graph，抓「這張卡片連結了一個之前存在但被刪掉的檔案」。需要整合 <code>go-git</code>。</li>
<li><strong>Parallel parse</strong>：大型 monorepo 的 lint 可用 worker pool 並行 parse。用 channel 把 parse 結果丟回 main，注意 goldmark context 不跨 goroutine。</li>
<li><strong>Graph visualization</strong>：把 graph 輸出成 Graphviz DOT 或 Mermaid，給作者看「這張卡片的 backlinks 是什麼」。有助於規劃內容修訂。</li>
</ul>
<h2 id="下一步">下一步</h2>
<p><a href="/blog/go/09-tooling-and-analysis/tool-decision-tripwire/" data-link-title="9.5 工具決策：regex 到 AST、Python 到 Go 的 tripwire" data-link-desc="什麼訊號代表工具該升級到下一個層次；用 WRAP 框架做語言與實作層的技術決策；延遲決策的成本">9.5 工具決策的 tripwire</a> 跳出實作，看「什麼時候該從 regex 升級到 AST、什麼時候該從 Python 換到 Go」的決策方法。</p>
]]></content:encoded></item><item><title>0.4 什麼時候選 Go</title><link>https://tarrragon.github.io/blog/go/00-philosophy/selecting-go/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/00-philosophy/selecting-go/</guid><description>&lt;p>選擇 Go 的核心判斷是工作場景是否需要長時間運行、明確邊界、穩定併發與簡單部署。這一章用工程條件判斷 Go 是否適合目前問題；若工作更依賴框架模板、快速表單 CRUD、動態行為或大量 runtime magic，其他語言或框架可能更符合需求。&lt;/p>
&lt;p>選型文章的目標是建立判斷路徑。讀者未來面對的可能是 API service、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> server、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> worker、內部工具或資料處理流程；同一個語言在不同工作負載下會有不同價值。先理解判斷條件，再進入語法細節，才不會把「我會寫 Go」誤解成「所有問題都該用 Go」。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷哪些工作負載適合 Go&lt;/li>
&lt;li>看出哪些系統型態特別適合 Go&lt;/li>
&lt;li>區分 Go 的強項與不適合硬上的場景&lt;/li>
&lt;li>用工程條件取代語言偏好來做選型&lt;/li>
&lt;li>為後續語法與實作章節建立正確的閱讀順序&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察先看工作負載再看語言">【觀察】先看工作負載，再看語言&lt;/h2>
&lt;p>Go 最常被選中的場景，是需要穩定處理大量服務型工作的系統。這些工作通常包含等待外部 I/O、管理長生命週期、持續處理背景任務或維持清楚服務邊界：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>工作型態&lt;/th>
 &lt;th>為什麼適合 Go&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>高併發 I/O&lt;/td>
 &lt;td>goroutine 成本低，適合大量等待型工作&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>長連線服務&lt;/td>
 &lt;td>容易管理生命週期、取消與資源清理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>背景 worker&lt;/td>
 &lt;td>可以把工作拆成小單位並持續處理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>事件處理&lt;/td>
 &lt;td>channel、select 與明確邊界很適合事件流&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>API service&lt;/td>
 &lt;td>標準庫直接支撐 HTTP、context、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>如果工作本質就是一堆等待外部 I/O 的操作，Go 往往比把整個問題放進單線程迴圈更自然。&lt;/p>
&lt;h3 id="高併發-io先看服務是否長時間等待外部回應">高併發 I/O：先看服務是否長時間等待外部回應&lt;/h3>
&lt;p>高併發 I/O 的核心特徵是「同時有很多工作在等待網路、檔案、資料庫或外部 API」。判斷時可以先看 request 的時間花在哪裡：如果大部分時間都在等下游回應，CPU 計算只占一小段，這就是等待型工作。&lt;/p>
&lt;p>接近真實網路服務的例子包括：&lt;/p>
&lt;ul>
&lt;li>API gateway 同時轉送請求到多個下游服務&lt;/li>
&lt;li>價格比較網站同時查詢多個供應商 API&lt;/li>
&lt;li>檔案上傳服務同時處理大量 client 的上傳進度&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/webhook/" data-link-title="Webhook" data-link-desc="說明外部系統回呼事件的接收、驗證與處理邊界">webhook&lt;/a> receiver 同時接收付款、物流、通知平台的 callback&lt;/li>
&lt;/ul>
&lt;p>這類服務的主要工程問題是「同時有很多等待中的工作，而且每個工作都需要容量邊界」。Go 的 goroutine 可以讓每個等待中的 request 有清楚的執行單位，&lt;code>context&lt;/code> 可以控制 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a> 與取消，channel 或 semaphore 可以限制同時打到下游的數量。Go 的價值在於讓等待、取消與容量控制都變成明確程式結構。&lt;/p>
&lt;h3 id="長連線服務先看-client-是否會持續留在線上">長連線服務：先看 client 是否會持續留在線上&lt;/h3>
&lt;p>長連線服務的核心特徵是「client 連上來之後不會立刻結束」。判斷時可以看連線是否需要維持數分鐘、數小時，甚至整個工作階段；只要 server 要持續追蹤 client 狀態，就會遇到生命週期管理問題。&lt;/p>
&lt;p>接近真實網路服務的例子包括：&lt;/p>
&lt;ul>
&lt;li>即時聊天室與客服對話&lt;/li>
&lt;li>線上協作文件的多人編輯狀態&lt;/li>
&lt;li>股票、運動比分或遊戲狀態的即時推送&lt;/li>
&lt;li>後台任務進度頁面的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> / &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/sse/" data-link-title="Server-Sent Events (SSE)" data-link-desc="說明 SSE 如何透過 HTTP 長連線向 client 單向推送事件">SSE&lt;/a> 更新&lt;/li>
&lt;/ul>
&lt;p>這類服務的主要工程問題是「連線會斷、client 會變慢、server 要清理資源」。Go 很適合把 read loop、write loop、heartbeat、subscription、shutdown 拆成不同 goroutine 與 channel 邊界。當連線失效時，&lt;code>context&lt;/code>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a> 與 unregister 流程可以把清理責任收斂到同一個地方。&lt;/p>
&lt;h3 id="背景-worker先看工作是否不適合卡住-request">背景 worker：先看工作是否不適合卡住 request&lt;/h3>
&lt;p>背景 worker 的核心特徵是「工作需要持續處理，並且適合從使用者 request 的等待時間中拆出來」。判斷時可以看某個操作是否需要重試、排程、批次處理，或等待外部系統完成。&lt;/p>
&lt;p>接近真實網路服務的例子包括：&lt;/p>
&lt;ul>
&lt;li>寄送 email、簡訊或推播通知&lt;/li>
&lt;li>影片轉檔、圖片壓縮與報表產生&lt;/li>
&lt;li>每晚同步 CRM、金流或庫存資料&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> message 並更新內部狀態&lt;/li>
&lt;/ul>
&lt;p>這類服務的主要工程問題是「工作要能開始、停止、重試、記錄錯誤並控制速率」。Go 的 &lt;code>Run(ctx)&lt;/code>、ticker、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool&lt;/a>、channel &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> 與 structured &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a> 可以把 worker 生命週期寫清楚。Go 的好處是讓背景流程仍然可取消、可觀測、可測試，而不只是把工作丟到背景。&lt;/p>
&lt;h3 id="事件處理先看系統是否圍繞已發生的事流動">事件處理：先看系統是否圍繞已發生的事流動&lt;/h3>
&lt;p>事件處理的核心特徵是「系統收到某件已發生的事，再依規則更新狀態或觸發後續行為」。判斷時可以看資料是否常以 &lt;code>created&lt;/code>、&lt;code>updated&lt;/code>、&lt;code>failed&lt;/code>、&lt;code>completed&lt;/code> 這類事實形式流動。&lt;/p></description><content:encoded><![CDATA[<p>選擇 Go 的核心判斷是工作場景是否需要長時間運行、明確邊界、穩定併發與簡單部署。這一章用工程條件判斷 Go 是否適合目前問題；若工作更依賴框架模板、快速表單 CRUD、動態行為或大量 runtime magic，其他語言或框架可能更符合需求。</p>
<p>選型文章的目標是建立判斷路徑。讀者未來面對的可能是 API service、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> server、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> worker、內部工具或資料處理流程；同一個語言在不同工作負載下會有不同價值。先理解判斷條件，再進入語法細節，才不會把「我會寫 Go」誤解成「所有問題都該用 Go」。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷哪些工作負載適合 Go</li>
<li>看出哪些系統型態特別適合 Go</li>
<li>區分 Go 的強項與不適合硬上的場景</li>
<li>用工程條件取代語言偏好來做選型</li>
<li>為後續語法與實作章節建立正確的閱讀順序</li>
</ol>
<hr>
<h2 id="觀察先看工作負載再看語言">【觀察】先看工作負載，再看語言</h2>
<p>Go 最常被選中的場景，是需要穩定處理大量服務型工作的系統。這些工作通常包含等待外部 I/O、管理長生命週期、持續處理背景任務或維持清楚服務邊界：</p>
<table>
  <thead>
      <tr>
          <th>工作型態</th>
          <th>為什麼適合 Go</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>高併發 I/O</td>
          <td>goroutine 成本低，適合大量等待型工作</td>
      </tr>
      <tr>
          <td>長連線服務</td>
          <td>容易管理生命週期、取消與資源清理</td>
      </tr>
      <tr>
          <td>背景 worker</td>
          <td>可以把工作拆成小單位並持續處理</td>
      </tr>
      <tr>
          <td>事件處理</td>
          <td>channel、select 與明確邊界很適合事件流</td>
      </tr>
      <tr>
          <td>API service</td>
          <td>標準庫直接支撐 HTTP、context、<a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 與 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a></td>
      </tr>
  </tbody>
</table>
<p>如果工作本質就是一堆等待外部 I/O 的操作，Go 往往比把整個問題放進單線程迴圈更自然。</p>
<h3 id="高併發-io先看服務是否長時間等待外部回應">高併發 I/O：先看服務是否長時間等待外部回應</h3>
<p>高併發 I/O 的核心特徵是「同時有很多工作在等待網路、檔案、資料庫或外部 API」。判斷時可以先看 request 的時間花在哪裡：如果大部分時間都在等下游回應，CPU 計算只占一小段，這就是等待型工作。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>API gateway 同時轉送請求到多個下游服務</li>
<li>價格比較網站同時查詢多個供應商 API</li>
<li>檔案上傳服務同時處理大量 client 的上傳進度</li>
<li><a href="/blog/backend/knowledge-cards/webhook/" data-link-title="Webhook" data-link-desc="說明外部系統回呼事件的接收、驗證與處理邊界">webhook</a> receiver 同時接收付款、物流、通知平台的 callback</li>
</ul>
<p>這類服務的主要工程問題是「同時有很多等待中的工作，而且每個工作都需要容量邊界」。Go 的 goroutine 可以讓每個等待中的 request 有清楚的執行單位，<code>context</code> 可以控制 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 與取消，channel 或 semaphore 可以限制同時打到下游的數量。Go 的價值在於讓等待、取消與容量控制都變成明確程式結構。</p>
<h3 id="長連線服務先看-client-是否會持續留在線上">長連線服務：先看 client 是否會持續留在線上</h3>
<p>長連線服務的核心特徵是「client 連上來之後不會立刻結束」。判斷時可以看連線是否需要維持數分鐘、數小時，甚至整個工作階段；只要 server 要持續追蹤 client 狀態，就會遇到生命週期管理問題。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>即時聊天室與客服對話</li>
<li>線上協作文件的多人編輯狀態</li>
<li>股票、運動比分或遊戲狀態的即時推送</li>
<li>後台任務進度頁面的 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> / <a href="/blog/backend/knowledge-cards/sse/" data-link-title="Server-Sent Events (SSE)" data-link-desc="說明 SSE 如何透過 HTTP 長連線向 client 單向推送事件">SSE</a> 更新</li>
</ul>
<p>這類服務的主要工程問題是「連線會斷、client 會變慢、server 要清理資源」。Go 很適合把 read loop、write loop、heartbeat、subscription、shutdown 拆成不同 goroutine 與 channel 邊界。當連線失效時，<code>context</code>、<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 與 unregister 流程可以把清理責任收斂到同一個地方。</p>
<h3 id="背景-worker先看工作是否不適合卡住-request">背景 worker：先看工作是否不適合卡住 request</h3>
<p>背景 worker 的核心特徵是「工作需要持續處理，並且適合從使用者 request 的等待時間中拆出來」。判斷時可以看某個操作是否需要重試、排程、批次處理，或等待外部系統完成。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>寄送 email、簡訊或推播通知</li>
<li>影片轉檔、圖片壓縮與報表產生</li>
<li>每晚同步 CRM、金流或庫存資料</li>
<li>消費 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> message 並更新內部狀態</li>
</ul>
<p>這類服務的主要工程問題是「工作要能開始、停止、重試、記錄錯誤並控制速率」。Go 的 <code>Run(ctx)</code>、ticker、<a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool</a>、channel <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 與 structured <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 可以把 worker 生命週期寫清楚。Go 的好處是讓背景流程仍然可取消、可觀測、可測試，而不只是把工作丟到背景。</p>
<h3 id="事件處理先看系統是否圍繞已發生的事流動">事件處理：先看系統是否圍繞已發生的事流動</h3>
<p>事件處理的核心特徵是「系統收到某件已發生的事，再依規則更新狀態或觸發後續行為」。判斷時可以看資料是否常以 <code>created</code>、<code>updated</code>、<code>failed</code>、<code>completed</code> 這類事實形式流動。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>訂單付款成功後更新訂單狀態並發送通知</li>
<li>使用者註冊完成後建立歡迎流程與分析事件</li>
<li>CI job 狀態改變後推送到 <a href="/blog/backend/knowledge-cards/dashboard/" data-link-title="Dashboard" data-link-desc="說明 dashboard 如何把關鍵訊號組成可判讀的服務狀態畫面">dashboard</a></li>
<li>IoT 裝置上報 sensor reading 後觸發告警</li>
</ul>
<p>這類服務的主要工程問題是「事件來源多、順序可能不同、重複事件需要處理」。Go 的型別可以定義穩定 event envelope，channel 或 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> adapter 可以把來源收斂到 processor，processor 再集中處理 validation、dedup、state transition 與 publish。Go 的價值在於讓事件流的每一段責任清楚可測。</p>
<h3 id="api-service先看服務是否需要清楚的-request-邊界">API service：先看服務是否需要清楚的 request 邊界</h3>
<p>API service 的核心特徵是「外部 client 用明確 request 取得資料或要求系統執行動作」。判斷時可以看服務是否需要穩定路由、輸入驗證、timeout、error response、<a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 與 <a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a>。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>手機 App 的會員、訂單、通知 API</li>
<li>SaaS 產品提供給客戶整合的 public API</li>
<li>內部微服務之間的 HTTP/gRPC API</li>
<li><a href="/blog/backend/knowledge-cards/dashboard/" data-link-title="Dashboard" data-link-desc="說明 dashboard 如何把關鍵訊號組成可判讀的服務狀態畫面">dashboard</a> 查詢目前狀態與操作後端任務的 API</li>
</ul>
<p>這類服務的主要工程問題是「request 進來後要有清楚邊界」。Go 標準庫的 <code>net/http</code>、<code>context</code>、<code>encoding/json</code>、<code>log/slog</code> 與 testing package 已經提供服務骨架需要的基本能力。當 API 邊界清楚時，handler 可以專注在傳輸格式，usecase 處理行為規則，repository 或 external client 處理資料依賴。</p>
<p>例如一個通知服務需要同時處理三件事：接收 HTTP callback、把事件放進背景處理流程、再把結果推送給已訂閱的 client。這個服務的主要成本通常在於同時等待網路、管理 client 連線、控制 queue 滿載與清理失效資源；單次計算反而只是其中一小部分。Go 的 goroutine、channel、context 與標準庫 HTTP 可以把這些生命週期寫成明確的程式結構。</p>
<p>相反地，一個只需要三個表單頁面、幾個後台列表和現成權限模板的內部管理系統，主要成本可能在 UI、表單驗證、ORM convention 與後台 scaffolding。這種工作也能用 Go 完成，但選型時應先問：「主要成本是在服務生命週期，還是在框架已經提供的業務頁面組裝？」這個問題比語言效能更接近真正瓶頸。</p>
<h2 id="判讀架構邊界是否清楚">【判讀】架構邊界是否清楚</h2>
<p>Go 特別適合邊界清楚的後端服務。當一個系統可以自然拆成輸入、協調、狀態、輸出幾層時，Go 的 struct、interface、package 與明確依賴會讓責任更容易看見。</p>
<p>例如以下這類系統通常是 Go 的好候選：</p>
<ul>
<li><a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 即時服務</li>
<li>notification service</li>
<li>queue <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a></li>
<li>log / event pipeline</li>
<li>需要清楚 ports/adapters 的 backend service</li>
</ul>
<p>產品若高度依賴框架提供的大量現成功能，或核心價值在於快速拼接大量業務頁面與模板，選型時應把框架生態列為主要條件。這類情境可以先評估 Python、Ruby、JavaScript/TypeScript 或其他更貼近既有生態的方案。</p>
<p>判斷架構邊界時，可以先畫出資料如何通過系統：</p>
<ol>
<li>外部請求或事件從哪裡進來</li>
<li>哪一層負責驗證與轉換</li>
<li>哪一層負責狀態轉移或業務規則</li>
<li>哪一層負責回應、推送或記錄</li>
</ol>
<p>如果這四個問題能自然拆成幾個責任清楚的元件，Go 會讓這些邊界很容易被程式碼表達。handler 可以處理傳輸格式，usecase 可以處理行為規則，repository 或 state owner 可以處理狀態，publisher 或 response layer 可以處理輸出。這種設計不需要大型框架先定義所有路徑，Go 的簡單型別與小介面就能支撐。</p>
<p>如果團隊還在探索商業流程，連資料模型、頁面流程與權限規則都會每天改，框架的 convention 可能更重要。這時候語言選型的重點是「顯式設計的成本是否值得現在承擔」。Go 會鼓勵你把邊界寫清楚；當邊界本身仍在頻繁變動，這種清楚有時會變成前置設計成本。</p>
<h2 id="策略runtime-與部署條件也是選型的一部分">【策略】runtime 與部署條件也是選型的一部分</h2>
<p>Go 的優勢不只是語法，還包括 runtime 與部署形態：</p>
<ul>
<li>單一 binary，部署流程簡單</li>
<li>啟動速度快，適合 container 與短週期交付</li>
<li>標準庫完整，很多服務不需要先找一堆框架</li>
<li>可讀性高，長期維護成本較容易控制</li>
</ul>
<p>如果你的團隊很在意：</p>
<ul>
<li>記憶體用量</li>
<li>啟動時間</li>
<li>觀測與除錯</li>
<li>服務在高流量下的穩定性</li>
</ul>
<p>那 Go 的工程價值就會很明顯。</p>
<p>部署條件會影響語言價值，因為服務最終要在開發機之外的環境長期運行。假設一個團隊要把多個小服務放進 container，每個服務都需要 <a href="/blog/backend/knowledge-cards/health-check-liveness/" data-link-title="Liveness" data-link-desc="說明平台如何判斷 process 是否仍然存活，以及何時應重啟">health check</a>、timeout、structured log、<a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown</a> 與固定資源限制。Go 的單一 binary 和標準庫讓這些能力可以用相對少的外部依賴完成；服務啟動、部署與回滾也比較容易被平台工程師理解。</p>
<p>另一個常見例子是 CLI 工具或 sidecar service。這類程式常被放進 CI、Kubernetes job、systemd service 或部署腳本中。Go 編譯後的 binary 可以降低 runtime 安裝與版本衝突問題。這是交付形態優勢：當程式要在很多環境中穩定啟動，少一層 runtime 依賴就是一個可觀的工程收益。</p>
<h2 id="執行哪些情況要先評估其他方案">【執行】哪些情況要先評估其他方案</h2>
<p>選型的執行規則是先確認主要瓶頸，再決定語言。以下情境通常應先評估其他語言、框架或平台能力：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>原因</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>極度偏 CRUD 模板系統</td>
          <td>框架生態可能比語言特性更重要</td>
      </tr>
      <tr>
          <td>大量動態行為與 runtime 配置</td>
          <td>Go 會要求更多顯式設計</td>
      </tr>
      <tr>
          <td>團隊主要目標是快速試錯</td>
          <td>Go 的工程紀律可能比腳本型語言更有前置成本</td>
      </tr>
      <tr>
          <td>主要瓶頸在前端整合流程</td>
          <td>主要解法在前端工具鏈、元件生態與產品流程</td>
      </tr>
  </tbody>
</table>
<p>Go 可以處理其中部分情境，但它的工程價值未必對準主要瓶頸。當主要成本在框架生態、動態流程或前端整合時，下一步應先比較那些領域更成熟的工具。</p>
<h3 id="極度偏-crud-模板系統先看頁面是否圍繞資料表轉">極度偏 CRUD 模板系統：先看頁面是否圍繞資料表轉</h3>
<p>CRUD 模板系統的核心特徵是「大部分功能都在新增、查詢、修改、刪除資料」。判斷時可以先看產品畫面：如果主要頁面都是列表、篩選、表單、詳情頁、權限設定與匯出報表，系統很可能偏 CRUD。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>電商平台的商品、訂單、會員、優惠券後台</li>
<li>餐廳訂位系統的店家、桌位、時段、訂單管理</li>
<li>客服工單系統的 ticket 列表、狀態修改、負責人指派</li>
<li>活動報名系統的活動、票種、參加者、付款狀態管理</li>
</ul>
<p>這類系統的主要工作通常在「快速產生穩定後台功能」。框架如果已經提供 <a href="/blog/backend/knowledge-cards/authentication/" data-link-title="Authentication" data-link-desc="說明系統如何確認呼叫者身份">authentication</a>、<a href="/blog/backend/knowledge-cards/authorization/" data-link-title="Authorization" data-link-desc="說明授權如何判斷誰能對哪些資源執行哪些操作">authorization</a>、ORM、form validation、admin scaffolding、pagination 與 search，團隊會先從框架生態獲得產能。Go 仍然可以負責其中的高流量 API、背景同步、付款 callback 或報表生成，但整個後台產品的主體未必需要先用 Go 開始。</p>
<p>判斷問題可以這樣問：如果拿掉後台表單、列表與權限頁，系統還剩下什麼核心工程問題？如果答案很少，框架模板可能就是主要能力。</p>
<h3 id="大量動態行為與-runtime-配置先看規則是否由使用者定義">大量動態行為與 runtime 配置：先看規則是否由使用者定義</h3>
<p>動態行為系統的核心特徵是「行為在執行期間由設定、腳本、規則或使用者操作改變」。判斷時可以先觀察：工程師是否常常需要讓非工程使用者自己新增欄位、調整流程、改驗證規則或配置通知條件。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>表單建置器：使用者可以自己新增欄位、驗證規則與送出後動作</li>
<li>工作流系統：管理者可以設定「訂單超過金額就送審」、「狀態改變就寄信」</li>
<li>CMS：編輯可以建立不同內容模型、欄位與發布流程</li>
<li>行銷自動化工具：使用者可以用條件組合出不同受眾與觸發規則</li>
</ul>
<p>這類系統的主要工程問題在於「如何安全表達動態規則」。Go 的靜態型別會鼓勵你把資料結構與行為先定義清楚；這對穩定服務很有價值，但對高度動態的產品，可能需要額外設計 rule engine、schema registry、plugin boundary 或 DSL。若產品核心就是讓使用者自由配置流程，選型時應把動態模型能力列為主要評估項目。</p>
<p>Go 的合理位置通常在規則執行引擎、事件處理器或高併發 delivery service。管理介面、規則編輯器與 schema 設計器則可能更依賴前端與動態框架生態。</p>
<h3 id="團隊主要目標是快速試錯先看需求是否每天改方向">團隊主要目標是快速試錯：先看需求是否每天改方向</h3>
<p>快速試錯情境的核心特徵是「產品問題尚未被驗證」。判斷時可以觀察需求文件與會議紀錄：如果資料模型、頁面流程、定價方式、權限規則與使用者角色都還在頻繁改，團隊此時最需要的是降低改方向的成本。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>新創產品的第一版 MVP</li>
<li>內部工具的概念驗證</li>
<li>尚未確定商業模式的 marketplace</li>
<li>正在測試轉換率的報名、購買或 onboarding 流程</li>
</ul>
<p>這類階段的主要問題是「學到使用者真正需要什麼」。腳本型語言、full-stack framework、低程式碼工具或現成 SaaS 可能更適合先驗證流程。Go 的型別、錯誤處理與顯式邊界會提高長期可維護性，但在問題尚未穩定時，過早建立完整邊界可能會讓每次方向調整都需要較多工程變更。</p>
<p>Go 仍然可以在試錯產品中出現，但通常適合放在已經確定會留下的部分，例如 <a href="/blog/backend/knowledge-cards/webhook/" data-link-title="Webhook" data-link-desc="說明外部系統回呼事件的接收、驗證與處理邊界">webhook</a> receiver、背景任務、匯入匯出服務或需要穩定運行的小型 API。產品流程本身可以先用更容易改動的工具探索。</p>
<h3 id="主要瓶頸在前端整合流程先看使用者價值是否發生在介面">主要瓶頸在前端整合流程：先看使用者價值是否發生在介面</h3>
<p>前端整合型產品的核心特徵是「使用者價值主要發生在互動介面」。判斷時可以先看產品成功條件：如果最重要的是頁面轉換率、互動動畫、表單體驗、SEO、設計系統、第三方前端 SDK 或多裝置呈現，後端語言通常只是整體解法的一部分。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>行銷 landing page 與 A/B testing 流程</li>
<li>電商結帳頁、購物車、折扣碼與付款 UI</li>
<li>內容網站、文件站、會員訂閱頁</li>
<li>需要大量拖拉、預覽、即時編輯的設計工具</li>
</ul>
<p>這類系統的主要工程問題在於前端狀態、元件設計、瀏覽器限制、SEO、分析追蹤與使用者流程。Go 可以提供穩定 API、授權、訂單狀態或事件接收，但選型時要先確認後端 runtime 是否真的在解主要瓶頸。若瓶頸集中在 UI 與產品流程，Next.js、Remix、Nuxt 或其他前端框架生態可能是更直接的評估起點。</p>
<p>判斷問題可以這樣問：使用者感受到的主要差異來自 server 的併發能力，還是來自畫面反應、表單流程、SEO 與第三方整合？如果主要差異在後者，Go 可以是後端配角，不一定是產品主體的第一個選型決策。</p>
<p>選型可以用三個問題收斂：</p>
<ol>
<li><strong>主要瓶頸是什麼？</strong> 如果瓶頸是大量 I/O、長連線、背景處理、部署穩定性，Go 值得優先評估；如果瓶頸是 UI scaffolding、資料後台或快速試錯，框架生態可能更關鍵。</li>
<li><strong>邊界是否已經清楚？</strong> 如果輸入、規則、狀態與輸出能被穩定拆開，Go 的顯式設計會帶來可讀性；如果流程每天改，先用 convention 強的工具驗證產品可能更合理。</li>
<li><strong>團隊要優化短期探索還是長期維護？</strong> Go 會把錯誤處理、型別、依賴與生命週期攤開寫清楚；這對長期服務是優點，對一次性探索則可能顯得偏重。</li>
</ol>
<p>因此，選 Go 的結論應該長得像工程判斷。例如：「這個服務會維持上千條長連線，需要明確 timeout、<a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 與 <a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">shutdown</a> 所以 Go 是好候選。」或是：「這個系統主要是後台 CRUD 和權限頁面，框架產能比 runtime 特性更重要，所以先評估 Django、Rails 或 Next.js 生態。」這樣的句子能讓團隊看見選型依據，也能在條件改變時重新評估。</p>
]]></content:encoded></item><item><title>8.4 Microsoft：雲端基礎設施的一部分</title><link>https://tarrragon.github.io/blog/go/08-case-studies/microsoft/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/08-case-studies/microsoft/</guid><description>&lt;p>Microsoft 的官方案例文字不長，但方向很清楚：Go 被用來支撐雲端基礎設施的一部分。這類案例的重點通常在平台層、支援工具與雲端服務周邊。&lt;/p>
&lt;h2 id="你應該看什麼">你應該看什麼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://go.dev/solutions/microsoft">How Microsoft Embraces Go&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼&lt;/h2>
&lt;ol>
&lt;li>Go 很適合平台與基礎設施工具。&lt;/li>
&lt;li>雲端工程很重視部署單純性與長期可維護性。&lt;/li>
&lt;li>Go 常被放在內部治理、雲端元件與自動化流程中。&lt;/li>
&lt;/ol>
&lt;h2 id="可對照的公開原始碼">可對照的公開原始碼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://github.com/Microsoft/cobalt">Microsoft/cobalt&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/azure/osdu-infrastructure">azure/osdu-infrastructure&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>這些公開 repo 可以用來理解 Microsoft 生態裡的雲端基礎設施與自動化工作方式。即使它們不一定只講一件產品，仍很適合對照 Go 的平台語言角色。&lt;/p></description><content:encoded><![CDATA[<p>Microsoft 的官方案例文字不長，但方向很清楚：Go 被用來支撐雲端基礎設施的一部分。這類案例的重點通常在平台層、支援工具與雲端服務周邊。</p>
<h2 id="你應該看什麼">你應該看什麼</h2>
<ul>
<li><a href="https://go.dev/solutions/microsoft">How Microsoft Embraces Go</a></li>
</ul>
<h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼</h2>
<ol>
<li>Go 很適合平台與基礎設施工具。</li>
<li>雲端工程很重視部署單純性與長期可維護性。</li>
<li>Go 常被放在內部治理、雲端元件與自動化流程中。</li>
</ol>
<h2 id="可對照的公開原始碼">可對照的公開原始碼</h2>
<ul>
<li><a href="https://github.com/Microsoft/cobalt">Microsoft/cobalt</a></li>
<li><a href="https://github.com/azure/osdu-infrastructure">azure/osdu-infrastructure</a></li>
</ul>
<p>這些公開 repo 可以用來理解 Microsoft 生態裡的雲端基礎設施與自動化工作方式。即使它們不一定只講一件產品，仍很適合對照 Go 的平台語言角色。</p>
]]></content:encoded></item><item><title>1.4 package、檔案與可見性</title><link>https://tarrragon.github.io/blog/go/01-basics/packages/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/01-basics/packages/</guid><description>&lt;p>Go 用 package 組織程式碼。package 不只是資料夾名稱，而是 API 邊界：哪些名稱能被其他 package 使用，哪些名稱只在內部可見，都由 package 與命名共同決定。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解 &lt;code>package main&lt;/code> 和一般 package 的差異&lt;/li>
&lt;li>看懂同一個 package 如何拆成多個檔案&lt;/li>
&lt;li>用大小寫判斷 exported 與 unexported 名稱&lt;/li>
&lt;li>設計不暴露過多細節的 package API&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察每個-go-檔案都從-package-開始">【觀察】每個 Go 檔案都從 package 開始&lt;/h2>
&lt;p>package 宣告的核心規則是：每個 Go 檔案都必須先宣告自己屬於哪個 package。可執行程式使用 &lt;code>package main&lt;/code>，例如：&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="kn">package&lt;/span> &lt;span class="nx">main&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="kn">import&lt;/span> &lt;span class="s">&amp;#34;fmt&amp;#34;&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="nf">main&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">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;hello&amp;#34;&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>第一行 &lt;code>package main&lt;/code> 表示這個檔案屬於 &lt;code>main&lt;/code> package。Go 編譯器會尋找 &lt;code>main&lt;/code> package 裡的 &lt;code>main()&lt;/code> 函式，將它編譯成可執行程式。&lt;/p>
&lt;p>一般 package 的核心規則是：它不負責啟動程式，而是提供型別、函式或方法給其他 package 使用。可被其他程式引用的工具 package 通常會使用自己的 package 名稱：&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="kn">package&lt;/span> &lt;span class="nx">config&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">Config&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">Port&lt;/span> &lt;span class="kt">int&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>這個 package 不會自己啟動程式，而是提供型別、函式或方法給其他 package 使用。&lt;/p>
&lt;h2 id="判讀package-是一組共同編譯的檔案">【判讀】package 是一組共同編譯的檔案&lt;/h2>
&lt;p>package 的編譯單位是一組同 package 檔案。同一個資料夾中的 Go 檔案通常必須宣告同一個 package；這些檔案會被一起編譯，也可以直接互相使用彼此的 unexported 名稱。&lt;/p>
&lt;p>例如：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">config/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">├── config.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">├── defaults.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">└── validate.go&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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="kn">package&lt;/span> &lt;span class="nx">config&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這表示 &lt;code>config.go&lt;/code> 裡的函式可以直接呼叫 &lt;code>validate.go&lt;/code> 裡的小工具函式，即使那個工具函式沒有 exported。&lt;/p>
&lt;h2 id="策略用大小寫控制-api-邊界">【策略】用大小寫控制 API 邊界&lt;/h2>
&lt;p>Go 可見性的核心規則是：大寫開頭 exported，小寫開頭 unexported。Go 沒有 &lt;code>public&lt;/code>、&lt;code>private&lt;/code> 關鍵字，而是用命名大小寫決定可見性：&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>&lt;code>Config&lt;/code>&lt;/td>
 &lt;td>exported&lt;/td>
 &lt;td>其他 package 可使用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Load&lt;/code>&lt;/td>
 &lt;td>exported&lt;/td>
 &lt;td>其他 package 可呼叫&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>defaultPort&lt;/code>&lt;/td>
 &lt;td>unexported&lt;/td>
 &lt;td>只在目前 package 內可用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>validatePath&lt;/code>&lt;/td>
 &lt;td>unexported&lt;/td>
 &lt;td>內部實作細節&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&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="kn">package&lt;/span> &lt;span class="nx">config&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">Config&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">Port&lt;/span> &lt;span class="kt">int&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="kd">const&lt;/span> &lt;span class="nx">defaultPort&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">8080&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">func&lt;/span> &lt;span class="nf">Load&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">path&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">Config&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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">10&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">path&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">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">Config&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Port&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">defaultPort&lt;/span>&lt;span class="p">},&lt;/span> &lt;span class="kc">nil&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">return&lt;/span> &lt;span class="nf">readConfig&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">path&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="kd">func&lt;/span> &lt;span class="nf">readConfig&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">path&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">Config&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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">17&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 內部解析邏輯&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">Config&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Port&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">defaultPort&lt;/span>&lt;span class="p">},&lt;/span> &lt;span class="kc">nil&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;/code>&lt;/pre>&lt;/div>&lt;p>其他 package 可以使用 &lt;code>config.Config&lt;/code> 和 &lt;code>config.Load&lt;/code>，但不能直接使用 &lt;code>config.defaultPort&lt;/code> 或 &lt;code>config.readConfig&lt;/code>。&lt;/p>
&lt;h2 id="執行把檔案切分成認知單位">【執行】把檔案切分成認知單位&lt;/h2>
&lt;p>檔案切分的核心規則是：依照讀者理解程式的方式分組，而不是機械地一個型別一個檔案。Go 不要求「一個型別一個檔案」。&lt;/p>
&lt;p>例如設定讀取 package 可以這樣切：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">config/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">├── config.go # Config 型別與 Load 入口
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">├── defaults.go # 預設值
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">├── validate.go # 驗證規則
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">└── config_test.go # 測試&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這樣切分的好處是：&lt;/p>
&lt;ul>
&lt;li>&lt;code>config.go&lt;/code> 作為 package 入口，讀者先看這裡&lt;/li>
&lt;li>&lt;code>defaults.go&lt;/code> 集中預設值，不和解析流程混在一起&lt;/li>
&lt;li>&lt;code>validate.go&lt;/code> 集中驗證規則，方便測試&lt;/li>
&lt;li>unexported helper 留在 package 內，不污染外部 API&lt;/li>
&lt;/ul>
&lt;h2 id="設計檢查">設計檢查&lt;/h2>
&lt;h3 id="檢查一控制-exported-api">檢查一：控制 exported API&lt;/h3>
&lt;p>如果你把所有型別、函式、常數都用大寫開頭，其他 package 就會開始依賴你的內部細節。未來你想重構時，會發現很多名稱都不能改。&lt;/p></description><content:encoded><![CDATA[<p>Go 用 package 組織程式碼。package 不只是資料夾名稱，而是 API 邊界：哪些名稱能被其他 package 使用，哪些名稱只在內部可見，都由 package 與命名共同決定。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解 <code>package main</code> 和一般 package 的差異</li>
<li>看懂同一個 package 如何拆成多個檔案</li>
<li>用大小寫判斷 exported 與 unexported 名稱</li>
<li>設計不暴露過多細節的 package API</li>
</ol>
<hr>
<h2 id="觀察每個-go-檔案都從-package-開始">【觀察】每個 Go 檔案都從 package 開始</h2>
<p>package 宣告的核心規則是：每個 Go 檔案都必須先宣告自己屬於哪個 package。可執行程式使用 <code>package main</code>，例如：</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="kn">package</span> <span class="nx">main</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="kn">import</span> <span class="s">&#34;fmt&#34;</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="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;hello&#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></code></pre></div><p>第一行 <code>package main</code> 表示這個檔案屬於 <code>main</code> package。Go 編譯器會尋找 <code>main</code> package 裡的 <code>main()</code> 函式，將它編譯成可執行程式。</p>
<p>一般 package 的核心規則是：它不負責啟動程式，而是提供型別、函式或方法給其他 package 使用。可被其他程式引用的工具 package 通常會使用自己的 package 名稱：</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="kn">package</span> <span class="nx">config</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">Config</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">Port</span> <span class="kt">int</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 package 不會自己啟動程式，而是提供型別、函式或方法給其他 package 使用。</p>
<h2 id="判讀package-是一組共同編譯的檔案">【判讀】package 是一組共同編譯的檔案</h2>
<p>package 的編譯單位是一組同 package 檔案。同一個資料夾中的 Go 檔案通常必須宣告同一個 package；這些檔案會被一起編譯，也可以直接互相使用彼此的 unexported 名稱。</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">config/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── config.go
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── defaults.go
</span></span><span class="line"><span class="ln">4</span><span class="cl">└── validate.go</span></span></code></pre></div><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="kn">package</span> <span class="nx">config</span></span></span></code></pre></div><p>這表示 <code>config.go</code> 裡的函式可以直接呼叫 <code>validate.go</code> 裡的小工具函式，即使那個工具函式沒有 exported。</p>
<h2 id="策略用大小寫控制-api-邊界">【策略】用大小寫控制 API 邊界</h2>
<p>Go 可見性的核心規則是：大寫開頭 exported，小寫開頭 unexported。Go 沒有 <code>public</code>、<code>private</code> 關鍵字，而是用命名大小寫決定可見性：</p>
<table>
  <thead>
      <tr>
          <th>名稱</th>
          <th>可見性</th>
          <th>意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>Config</code></td>
          <td>exported</td>
          <td>其他 package 可使用</td>
      </tr>
      <tr>
          <td><code>Load</code></td>
          <td>exported</td>
          <td>其他 package 可呼叫</td>
      </tr>
      <tr>
          <td><code>defaultPort</code></td>
          <td>unexported</td>
          <td>只在目前 package 內可用</td>
      </tr>
      <tr>
          <td><code>validatePath</code></td>
          <td>unexported</td>
          <td>內部實作細節</td>
      </tr>
  </tbody>
</table>
<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="kn">package</span> <span class="nx">config</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">Config</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">Port</span> <span class="kt">int</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">const</span> <span class="nx">defaultPort</span> <span class="p">=</span> <span class="mi">8080</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">func</span> <span class="nf">Load</span><span class="p">(</span><span class="nx">path</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Config</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <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">path</span> <span class="o">==</span> <span class="s">&#34;&#34;</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="nx">Config</span><span class="p">{</span><span class="nx">Port</span><span class="p">:</span> <span class="nx">defaultPort</span><span class="p">},</span> <span class="kc">nil</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">return</span> <span class="nf">readConfig</span><span class="p">(</span><span class="nx">path</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="kd">func</span> <span class="nf">readConfig</span><span class="p">(</span><span class="nx">path</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Config</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="c1">// 內部解析邏輯</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">return</span> <span class="nx">Config</span><span class="p">{</span><span class="nx">Port</span><span class="p">:</span> <span class="nx">defaultPort</span><span class="p">},</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>其他 package 可以使用 <code>config.Config</code> 和 <code>config.Load</code>，但不能直接使用 <code>config.defaultPort</code> 或 <code>config.readConfig</code>。</p>
<h2 id="執行把檔案切分成認知單位">【執行】把檔案切分成認知單位</h2>
<p>檔案切分的核心規則是：依照讀者理解程式的方式分組，而不是機械地一個型別一個檔案。Go 不要求「一個型別一個檔案」。</p>
<p>例如設定讀取 package 可以這樣切：</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">config/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── config.go       # Config 型別與 Load 入口
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── defaults.go     # 預設值
</span></span><span class="line"><span class="ln">4</span><span class="cl">├── validate.go     # 驗證規則
</span></span><span class="line"><span class="ln">5</span><span class="cl">└── config_test.go  # 測試</span></span></code></pre></div><p>這樣切分的好處是：</p>
<ul>
<li><code>config.go</code> 作為 package 入口，讀者先看這裡</li>
<li><code>defaults.go</code> 集中預設值，不和解析流程混在一起</li>
<li><code>validate.go</code> 集中驗證規則，方便測試</li>
<li>unexported helper 留在 package 內，不污染外部 API</li>
</ul>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一控制-exported-api">檢查一：控制 exported API</h3>
<p>如果你把所有型別、函式、常數都用大寫開頭，其他 package 就會開始依賴你的內部細節。未來你想重構時，會發現很多名稱都不能改。</p>
<h3 id="檢查二package-名稱表達責任">檢查二：package 名稱表達責任</h3>
<p><code>utils</code>、<code>common</code>、<code>helpers</code> 這類名稱常讓 package 變成雜物間。Go 更偏好用資料或能力命名，例如 <code>config</code>、<code>auth</code>、<code>metrics</code>、<code>parser</code>。</p>
<h3 id="檢查三檔案切分服務閱讀">檢查三：檔案切分服務閱讀</h3>
<p>過度切分會讓讀者一直跳檔案。Go 的檔案可以稍微長一點，只要同一個檔案仍然圍繞同一組概念。</p>
]]></content:encoded></item><item><title>1.4 共享狀態與複製邊界</title><link>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/shared-state/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/shared-state/</guid><description>&lt;p>共享狀態的核心規則是同一份可變資料若會被多個 goroutine 存取，就必須有明確 owner 與保護邊界。Map 需要同步，slice 回傳前通常要 copy，可變指標不能隨意暴露，修改行為應集中在擁有狀態的型別內。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷哪個型別擁有共享狀態&lt;/li>
&lt;li>用 &lt;code>sync.RWMutex&lt;/code> 保護 map 與 slice&lt;/li>
&lt;li>避免回傳內部 map、slice、pointer&lt;/li>
&lt;li>分辨 shallow copy 與 deep copy 的邊界&lt;/li>
&lt;li>用測試與 race detector 驗證共享狀態安全性&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察共享狀態風險通常延遲出現">【觀察】共享狀態風險通常延遲出現&lt;/h2>
&lt;p>共享狀態的核心風險是錯誤可能只在特定併發時序下出現。單元測試可能通過，本地手動操作也正常，但高流量下會出現 data race、map panic、狀態被外部修改或歷史資料被覆蓋。&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">type&lt;/span> &lt;span class="nx">Store&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">users&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="nx">User&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">s&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Store&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Save&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">user&lt;/span> &lt;span class="nx">User&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"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">user&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">user&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">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">s&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Store&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Users&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="nx">User&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">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個型別有兩個問題：map 沒有同步保護，且 &lt;code>Users&lt;/code> 直接暴露內部 map。呼叫端拿到 map 後可以繞過 &lt;code>Store&lt;/code> 修改資料。&lt;/p>
&lt;h2 id="判讀mutex-保護的是狀態不變式">【判讀】mutex 保護的是狀態不變式&lt;/h2>
&lt;p>Mutex 的核心責任不是讓程式「不會同時跑」，而是保護某一組資料的不變式。只要讀寫同一份可變資料，就應該由同一個 owner 控制 lock。&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">UserRepository&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">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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">users&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="nx">User&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="kd">func&lt;/span> &lt;span class="nf">NewUserRepository&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">UserRepository&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">return&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">UserRepository&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="nx">users&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="nx">User&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="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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>UserRepository&lt;/code> 是 &lt;code>users&lt;/code> map 的 owner。外部程式不應持有 &lt;code>users&lt;/code> 的 reference，也不應知道它用 map、資料庫或其他結構保存。&lt;/p>
&lt;h2 id="執行所有讀寫都經過-owner-method">【執行】所有讀寫都經過 owner method&lt;/h2>
&lt;p>共享 map 的核心規則是所有讀寫都經過同一組方法。寫入使用 &lt;code>Lock&lt;/code>，讀取使用 &lt;code>RLock&lt;/code>。&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">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">UserRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Save&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">user&lt;/span> &lt;span class="nx">User&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"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Lock&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">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Unlock&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">user&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">user&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">nil&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">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">UserRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Find&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">id&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">bool&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RLock&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">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RUnlock&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>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">user&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&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">14&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">user&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>context.Context&lt;/code> 在 memory repository 裡可能用不到，但保留在 method signature 可以讓未來改成資料庫或遠端儲存時支援取消。這是 repository port 常見的演進邊界。&lt;/p>
&lt;h2 id="判讀回傳內部-map-會破壞-lock">【判讀】回傳內部 map 會破壞 lock&lt;/h2>
&lt;p>回傳 map 的核心風險是鎖只保護到方法結束。方法回傳後，呼叫端拿到的仍然是同一份 map，任何修改都會繞過 owner。&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">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">UserRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">UnsafeUsers&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="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="nx">User&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">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RLock&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">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RUnlock&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>&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">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&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;p>這段程式看起來有加鎖，但鎖釋放後外部仍能修改內部 map：&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="nx">users&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">repo&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">UnsafeUsers&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&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="nb">delete&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">users&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;user_1&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>安全做法是回傳 copy：&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">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">UserRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Users&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="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="nx">User&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RLock&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">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RUnlock&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">result&lt;/span> &lt;span class="o">:=&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="nx">User&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&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">for&lt;/span> &lt;span class="nx">id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&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="nx">result&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">id&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">user&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 class="k">return&lt;/span> &lt;span class="nx">result&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&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>result&lt;/code>，不會影響 repository 內部狀態。&lt;/p>
&lt;h2 id="判讀slice-copy-保護的是底層-array">【判讀】slice copy 保護的是底層 array&lt;/h2>
&lt;p>Slice 的核心風險是 slice header 會被複製，但底層 array 可能共享。直接回傳 slice 會讓呼叫端修改 owner 的內部資料。&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">RecentEvents&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">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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">events&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">Event&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="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">RecentEvents&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Append&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">event&lt;/span> &lt;span class="nx">Event&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Lock&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">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Unlock&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>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">events&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">events&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>安全的 list method：&lt;/p></description><content:encoded><![CDATA[<p>共享狀態的核心規則是同一份可變資料若會被多個 goroutine 存取，就必須有明確 owner 與保護邊界。Map 需要同步，slice 回傳前通常要 copy，可變指標不能隨意暴露，修改行為應集中在擁有狀態的型別內。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷哪個型別擁有共享狀態</li>
<li>用 <code>sync.RWMutex</code> 保護 map 與 slice</li>
<li>避免回傳內部 map、slice、pointer</li>
<li>分辨 shallow copy 與 deep copy 的邊界</li>
<li>用測試與 race detector 驗證共享狀態安全性</li>
</ol>
<hr>
<h2 id="觀察共享狀態風險通常延遲出現">【觀察】共享狀態風險通常延遲出現</h2>
<p>共享狀態的核心風險是錯誤可能只在特定併發時序下出現。單元測試可能通過，本地手動操作也正常，但高流量下會出現 data race、map panic、狀態被外部修改或歷史資料被覆蓋。</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">type</span> <span class="nx">Store</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">users</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">User</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">s</span> <span class="o">*</span><span class="nx">Store</span><span class="p">)</span> <span class="nf">Save</span><span class="p">(</span><span class="nx">user</span> <span class="nx">User</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">s</span><span class="p">.</span><span class="nx">users</span><span class="p">[</span><span class="nx">user</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">user</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">func</span> <span class="p">(</span><span class="nx">s</span> <span class="o">*</span><span class="nx">Store</span><span class="p">)</span> <span class="nf">Users</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="nx">User</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">s</span><span class="p">.</span><span class="nx">users</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個型別有兩個問題：map 沒有同步保護，且 <code>Users</code> 直接暴露內部 map。呼叫端拿到 map 後可以繞過 <code>Store</code> 修改資料。</p>
<h2 id="判讀mutex-保護的是狀態不變式">【判讀】mutex 保護的是狀態不變式</h2>
<p>Mutex 的核心責任不是讓程式「不會同時跑」，而是保護某一組資料的不變式。只要讀寫同一份可變資料，就應該由同一個 owner 控制 lock。</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">UserRepository</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">mu</span>    <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">users</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">User</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="kd">func</span> <span class="nf">NewUserRepository</span><span class="p">()</span> <span class="o">*</span><span class="nx">UserRepository</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="o">&amp;</span><span class="nx">UserRepository</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">users</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="nx">User</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="p">}</span></span></span></code></pre></div><p><code>UserRepository</code> 是 <code>users</code> map 的 owner。外部程式不應持有 <code>users</code> 的 reference，也不應知道它用 map、資料庫或其他結構保存。</p>
<h2 id="執行所有讀寫都經過-owner-method">【執行】所有讀寫都經過 owner method</h2>
<p>共享 map 的核心規則是所有讀寫都經過同一組方法。寫入使用 <code>Lock</code>，讀取使用 <code>RLock</code>。</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">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">Save</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">user</span> <span class="nx">User</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</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"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</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"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">users</span><span class="p">[</span><span class="nx">user</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">user</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</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">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">Find</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">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">User</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">r</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">11</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</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">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">user</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">users</span><span class="p">[</span><span class="nx">id</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">user</span><span class="p">,</span> <span class="nx">ok</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>context.Context</code> 在 memory repository 裡可能用不到，但保留在 method signature 可以讓未來改成資料庫或遠端儲存時支援取消。這是 repository port 常見的演進邊界。</p>
<h2 id="判讀回傳內部-map-會破壞-lock">【判讀】回傳內部 map 會破壞 lock</h2>
<p>回傳 map 的核心風險是鎖只保護到方法結束。方法回傳後，呼叫端拿到的仍然是同一份 map，任何修改都會繞過 owner。</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">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">UnsafeUsers</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="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">User</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">r</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">r</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="k">return</span> <span class="nx">r</span><span class="p">.</span><span class="nx">users</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式看起來有加鎖，但鎖釋放後外部仍能修改內部 map：</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="nx">users</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">UnsafeUsers</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">delete</span><span class="p">(</span><span class="nx">users</span><span class="p">,</span> <span class="s">&#34;user_1&#34;</span><span class="p">)</span></span></span></code></pre></div><p>安全做法是回傳 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">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">Users</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="p">(</span><span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">User</span><span class="p">,</span> <span class="kt">error</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">r</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">r</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">result</span> <span class="o">:=</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="nx">User</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">users</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">id</span><span class="p">,</span> <span class="nx">user</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">r</span><span class="p">.</span><span class="nx">users</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">result</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span> <span class="p">=</span> <span class="nx">user</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">return</span> <span class="nx">result</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>呼叫端可以自由修改 <code>result</code>，不會影響 repository 內部狀態。</p>
<h2 id="判讀slice-copy-保護的是底層-array">【判讀】slice copy 保護的是底層 array</h2>
<p>Slice 的核心風險是 slice header 會被複製，但底層 array 可能共享。直接回傳 slice 會讓呼叫端修改 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">RecentEvents</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">mu</span>     <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">events</span> <span class="p">[]</span><span class="nx">Event</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="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">RecentEvents</span><span class="p">)</span> <span class="nf">Append</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">event</span> <span class="nx">Event</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">r</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"> 8</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</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"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">events</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">events</span><span class="p">,</span> <span class="nx">event</span><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>安全的 list method：</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">r</span> <span class="o">*</span><span class="nx">RecentEvents</span><span class="p">)</span> <span class="nf">List</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="p">[]</span><span class="nx">Event</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">r</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">r</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">result</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="nx">Event</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">events</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nb">copy</span><span class="p">(</span><span class="nx">result</span><span class="p">,</span> <span class="nx">r</span><span class="p">.</span><span class="nx">events</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="nx">result</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>copy</code> 建立新的底層 array。呼叫端對 <code>result</code> 排序、截斷、append 或修改元素，不會改到 <code>r.events</code>。</p>
<h2 id="策略值型別可以-shallow-copy可變欄位需要-deep-copy">【策略】值型別可以 shallow copy，可變欄位需要 deep copy</h2>
<p>Copy boundary 的核心判斷是資料裡是否還包含可變 reference。若 struct 只有 string、int、time.Time 這類值型別，shallow copy 通常足夠；若 struct 包含 map、slice 或 pointer，就要考慮 deep copy。</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">type</span> <span class="nx">Event</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">Type</span>      <span class="kt">string</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">CreatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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>Event</code> 放在 slice 裡，用 <code>copy</code> 複製 slice 通常足夠。</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">type</span> <span class="nx">Event</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">Type</span>     <span class="kt">string</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Metadata</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">string</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這時只 copy slice 不夠，因為每個 <code>Event.Metadata</code> 仍然指向同一份 map。需要 clone：</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">CloneEvent</span><span class="p">(</span><span class="nx">event</span> <span class="nx">Event</span><span class="p">)</span> <span class="nx">Event</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">cloned</span> <span class="o">:=</span> <span class="nx">event</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">if</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Metadata</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">cloned</span><span class="p">.</span><span class="nx">Metadata</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="kt">string</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">Metadata</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">for</span> <span class="nx">key</span><span class="p">,</span> <span class="nx">value</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Metadata</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="nx">cloned</span><span class="p">.</span><span class="nx">Metadata</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="p">=</span> <span class="nx">value</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><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="nx">cloned</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>是否需要 deep copy 取決於 API 承諾。如果呼叫端不應修改 repository 內部資料，就要複製所有可變 reference。</p>
<h2 id="判讀回傳-pointer-要代表明確修改權">【判讀】回傳 pointer 要代表明確修改權</h2>
<p>Pointer 回傳的核心語意是呼叫端取得同一份資料的參照。若資料屬於共享狀態，回傳 pointer 通常會破壞 owner 邊界。</p>
<p>容易誤解的 API：</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">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">FindPointer</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">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="nx">User</span><span class="p">,</span> <span class="kt">bool</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">r</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">r</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">user</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">users</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</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"> 7</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="kc">false</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">return</span> <span class="o">&amp;</span><span class="nx">user</span><span class="p">,</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式回傳的是區域變數 <code>user</code> 的指標，不是 map 內部資料的可修改入口。呼叫端修改這個 pointer，不會保存回 repository。API 看起來像能修改，實際不能，語意不清楚。</p>
<p>更清楚的做法是回傳 value，並提供明確 update method：</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">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">UpdateEmail</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">id</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">email</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="kt">bool</span><span class="p">,</span> <span class="kt">error</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">r</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"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</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"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">user</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">users</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</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"> 7</span><span class="cl">        <span class="k">return</span> <span class="kc">false</span><span class="p">,</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><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="nx">user</span><span class="p">.</span><span class="nx">Email</span> <span class="p">=</span> <span class="nx">email</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">users</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span> <span class="p">=</span> <span class="nx">user</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">return</span> <span class="kc">true</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>修改行為集中在 repository 內，lock、驗證與狀態一致性也留在同一個地方。</p>
<h2 id="策略mutex-和-channel-owner-要按資料形狀選擇">【策略】mutex 和 channel owner 要按資料形狀選擇</h2>
<p>狀態保護的核心選擇是 mutex owner 或 goroutine owner。兩者都符合 Go 的精神，差異在資料存取模式。</p>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>適用情境</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>mutex owner</td>
          <td>多個方法需要同步讀寫狀態</td>
          <td>要維護 lock 與 copy boundary</td>
      </tr>
      <tr>
          <td>goroutine owner</td>
          <td>所有修改都能表示成訊息</td>
          <td>要設計 command、reply、shutdown</td>
      </tr>
  </tbody>
</table>
<p>Mutex 版本：</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="nx">repo</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">user</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">user</span><span class="p">,</span> <span class="nx">ok</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Find</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">id</span><span class="p">)</span></span></span></code></pre></div><p>Goroutine 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">command</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">kind</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">user</span>  <span class="nx">User</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">reply</span> <span class="kd">chan</span> <span class="nx">result</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>不要為了避免 mutex 而把簡單狀態硬改成複雜訊息系統。也不要在需要嚴格順序與單一事件流時到處加 lock。選擇應該來自資料形狀與讀寫模式。</p>
<h2 id="測試copy-boundary-要用外部修改驗證">【測試】copy boundary 要用外部修改驗證</h2>
<p>Copy boundary 的測試核心是呼叫 getter 後修改回傳值，再確認 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">func</span> <span class="nf">TestUsersReturnsCopy</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">repo</span> <span class="o">:=</span> <span class="nf">NewUserRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">ctx</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">Background</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">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">User</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;user_1&#34;</span><span class="p">,</span> <span class="nx">Email</span><span class="p">:</span> <span class="s">&#34;a@example.com&#34;</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"> 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;save user: %v&#34;</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">users</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Users</span><span class="p">(</span><span class="nx">ctx</span><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;users: %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 class="nb">delete</span><span class="p">(</span><span class="nx">users</span><span class="p">,</span> <span class="s">&#34;user_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">_</span><span class="p">,</span> <span class="nx">ok</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Find</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="s">&#34;user_1&#34;</span><span class="p">)</span>
</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="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</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;find user: %v&#34;</span><span class="p">,</span> <span class="nx">err</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 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">20</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;repository should not be modified through returned map&#34;</span><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>Slice 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="nf">TestEventListReturnsCopy</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">events</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">RecentEvents</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">ctx</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">Background</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">events</span><span class="p">.</span><span class="nf">Append</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">Event</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;evt_1&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">got</span> <span class="o">:=</span> <span class="nx">events</span><span class="p">.</span><span class="nf">List</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">got</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">ID</span> <span class="p">=</span> <span class="s">&#34;changed&#34;</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="nx">again</span> <span class="o">:=</span> <span class="nx">events</span><span class="p">.</span><span class="nf">List</span><span class="p">(</span><span class="nx">ctx</span><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">again</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">ID</span> <span class="o">!=</span> <span class="s">&#34;evt_1&#34;</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;internal event was modified through returned slice&#34;</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>這類測試能直接防止未來有人為了「省 copy」而破壞狀態邊界。</p>
<h2 id="測試race-detector-驗證同步邊界">【測試】race detector 驗證同步邊界</h2>
<p>Race detector 的核心用途是找出未同步的共享記憶體存取。對含有 goroutine、map、slice、repository 的測試，應定期執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go <span class="nb">test</span> -race ./...</span></span></code></pre></div><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">TestRepositoryConcurrentAccess</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">repo</span> <span class="o">:=</span> <span class="nf">NewUserRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">ctx</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">Background</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="kd">var</span> <span class="nx">wg</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">WaitGroup</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="mi">100</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">i</span> <span class="o">:=</span> <span class="nx">i</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">wg</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</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">10</span><span class="cl">            <span class="k">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="nx">id</span> <span class="o">:=</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Sprintf</span><span class="p">(</span><span class="s">&#34;user_%d&#34;</span><span class="p">,</span> <span class="nx">i</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="nx">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">User</span><span class="p">{</span><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">_</span><span class="p">,</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Find</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">id</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="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">wg</span><span class="p">.</span><span class="nf">Wait</span><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>-race</code> 可以檢查 repository 方法是否真的包住共享 map。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先聚焦單一 Go process 內的共享狀態保護；更外層的資料庫交易、快取一致性與資料複製邊界，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫與持久化</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/go/02-types-data/pointers-copy/" data-link-title="2.5 指標與資料複製邊界" data-link-desc="理解指標、slice 與共享狀態的防護策略">Go 入門：指標與資料複製邊界</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 repository、copy boundary 與 state owner；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/06-practical/state-fields/" data-link-title="6.3 如何擴展狀態投影欄位" data-link-desc="更新狀態模型、repository 與 API 輸出">Go：如何擴展狀態投影欄位</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>共享狀態的安全邊界由 owner、lock、copy 與明確修改方法組成。Map/slice 讀寫要經過同一個 owner；getter 不應暴露內部可變資料；含 map、slice、pointer 的 struct 要考慮 deep copy；修改行為應集中在方法內。這些規則能讓長時間運行的 Go 服務避開 data race、外部突變與難以重現的狀態錯誤。</p>
]]></content:encoded></item><item><title>2.4 常數與 typed string</title><link>https://tarrragon.github.io/blog/go/02-types-data/constants/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/02-types-data/constants/</guid><description>&lt;p>常數讓程式中的固定值有名稱。typed string 則讓一組字串值形成語意邊界，避免任意字串到處流動。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>用 &lt;code>const&lt;/code> 定義固定值&lt;/li>
&lt;li>理解 untyped constant 與 typed constant 的差異&lt;/li>
&lt;li>用 typed string 表達狀態與事件類型&lt;/li>
&lt;li>集中管理協定字串與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a> message&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察字串散落會增加維護成本">【觀察】字串散落會增加維護成本&lt;/h2>
&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="k">if&lt;/span> &lt;span class="nx">status&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;active&amp;#34;&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="c1">// ...&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="k">if&lt;/span> &lt;span class="nx">eventType&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;user.created&amp;#34;&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="c1">// ...&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="nx">log&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;user created&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>短期看起來很直接，但問題是：&lt;/p>
&lt;ul>
&lt;li>拼字錯誤不容易被發現&lt;/li>
&lt;li>修改字串時要全專案搜尋&lt;/li>
&lt;li>無法從型別看出哪些值是合法的&lt;/li>
&lt;li>不同概念可能共用同一種 &lt;code>string&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>常數可以先解決命名與集中管理問題。&lt;/p>
&lt;h2 id="判讀const-是把意圖寫進名稱">【判讀】const 是把意圖寫進名稱&lt;/h2>
&lt;p>&lt;code>const&lt;/code> 的核心用途是把固定值的意圖寫進名稱。Go 的常數宣告如下：&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">DefaultPort&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">8080&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="kd">const&lt;/span> &lt;span class="nx">EventUserCreated&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;user.created&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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="k">if&lt;/span> &lt;span class="nx">eventType&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="nx">EventUserCreated&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="c1">// ...&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;/code>&lt;/pre>&lt;/div>&lt;p>這比直接看到 &lt;code>&amp;quot;user.created&amp;quot;&lt;/code> 更清楚，因為名稱說明了這個字串在系統中的角色。&lt;/p>
&lt;h2 id="策略用-typed-string-區分概念">【策略】用 typed string 區分概念&lt;/h2>
&lt;p>typed string 的核心用途是用型別區分不同語意的字串。當多組資料底層都是字串，但語意不同，可以定義不同型別：&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">TaskStatus&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">TaskStatusPending&lt;/span> &lt;span class="nx">TaskStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;pending&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">TaskStatusRunning&lt;/span> &lt;span class="nx">TaskStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;running&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">TaskStatusDone&lt;/span> &lt;span class="nx">TaskStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;done&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="nx">TaskStatusFailed&lt;/span> &lt;span class="nx">TaskStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;failed&amp;#34;&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>函式簽名可以明確要求 &lt;code>TaskStatus&lt;/code>：&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">CanRetry&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">status&lt;/span> &lt;span class="nx">TaskStatus&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">return&lt;/span> &lt;span class="nx">status&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="nx">TaskStatusFailed&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;/code>&lt;/pre>&lt;/div>&lt;p>這不會讓 Go 變成 enum 語言，但能讓 API 更清楚。讀者看到 &lt;code>TaskStatus&lt;/code>，就知道這不是任意字串。&lt;/p>
&lt;h2 id="執行事件類型與-action-常數">【執行】事件類型與 action 常數&lt;/h2>
&lt;p>事件類型的核心規則是：同一組事件值應集中在同一個 typed string 型別下。事件驅動程式常需要管理事件類型：&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">EventType&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">EventUserCreated&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;user.created&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">EventUserUpdated&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;user.updated&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">EventUserDeleted&lt;/span> &lt;span class="nx">EventType&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;user.deleted&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;/code>&lt;/pre>&lt;/div>&lt;p>API 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">Action&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">ActionSubscribe&lt;/span> &lt;span class="nx">Action&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;subscribe&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">ActionUnsubscribe&lt;/span> &lt;span class="nx">Action&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;unsubscribe&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">ActionPing&lt;/span> &lt;span class="nx">Action&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;ping&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;/code>&lt;/pre>&lt;/div>&lt;p>處理時，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">func&lt;/span> &lt;span class="nf">HandleAction&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">action&lt;/span> &lt;span class="nx">Action&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"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">switch&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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">ActionSubscribe&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">nil&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">case&lt;/span> &lt;span class="nx">ActionUnsubscribe&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">nil&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">ActionPing&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="kc">nil&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">default&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">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">action&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="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;/code>&lt;/pre>&lt;/div>&lt;h2 id="log-message-也適合集中">log message 也適合集中&lt;/h2>
&lt;p>log message 的核心規則是：會被 grep、監控或文件引用的訊息應保持穩定。這類 message 可以用常數集中：&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">LogServerStarted&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;server started&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="nx">LogEventDropped&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;event dropped&amp;#34;&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">LogInvalidAction&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;invalid action&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>這樣做的價值是讓 log 訊號穩定。當 log 是除錯入口時，穩定字串就是系統 contract 的一部分。&lt;/p>
&lt;h2 id="常見取捨">常見取捨&lt;/h2>
&lt;h3 id="不必把所有字串都變常數">不必把所有字串都變常數&lt;/h3>
&lt;p>只出現一次、沒有協定意義、不需要搜尋的文字，可以直接寫在原處。過度常數化會讓讀者一直跳檔案。&lt;/p>
&lt;h3 id="常數名稱要說明清楚概念">常數名稱要說明清楚概念&lt;/h3>
&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">const&lt;/span> &lt;span class="nx">StringActive&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;active&amp;#34;&lt;/span> &lt;span class="c1">// 不佳&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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">const&lt;/span> &lt;span class="nx">TaskStatusActive&lt;/span> &lt;span class="nx">TaskStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;active&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div></description><content:encoded><![CDATA[<p>常數讓程式中的固定值有名稱。typed string 則讓一組字串值形成語意邊界，避免任意字串到處流動。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>用 <code>const</code> 定義固定值</li>
<li>理解 untyped constant 與 typed constant 的差異</li>
<li>用 typed string 表達狀態與事件類型</li>
<li>集中管理協定字串與 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> message</li>
</ol>
<hr>
<h2 id="觀察字串散落會增加維護成本">【觀察】字串散落會增加維護成本</h2>
<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="k">if</span> <span class="nx">status</span> <span class="o">==</span> <span class="s">&#34;active&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="c1">// ...</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="k">if</span> <span class="nx">eventType</span> <span class="o">==</span> <span class="s">&#34;user.created&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="c1">// ...</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="nx">log</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="s">&#34;user created&#34;</span><span class="p">)</span></span></span></code></pre></div><p>短期看起來很直接，但問題是：</p>
<ul>
<li>拼字錯誤不容易被發現</li>
<li>修改字串時要全專案搜尋</li>
<li>無法從型別看出哪些值是合法的</li>
<li>不同概念可能共用同一種 <code>string</code></li>
</ul>
<p>常數可以先解決命名與集中管理問題。</p>
<h2 id="判讀const-是把意圖寫進名稱">【判讀】const 是把意圖寫進名稱</h2>
<p><code>const</code> 的核心用途是把固定值的意圖寫進名稱。Go 的常數宣告如下：</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">DefaultPort</span> <span class="p">=</span> <span class="mi">8080</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">const</span> <span class="nx">EventUserCreated</span> <span class="p">=</span> <span class="s">&#34;user.created&#34;</span></span></span></code></pre></div><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="k">if</span> <span class="nx">eventType</span> <span class="o">==</span> <span class="nx">EventUserCreated</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="c1">// ...</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這比直接看到 <code>&quot;user.created&quot;</code> 更清楚，因為名稱說明了這個字串在系統中的角色。</p>
<h2 id="策略用-typed-string-區分概念">【策略】用 typed string 區分概念</h2>
<p>typed string 的核心用途是用型別區分不同語意的字串。當多組資料底層都是字串，但語意不同，可以定義不同型別：</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">TaskStatus</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">TaskStatusPending</span> <span class="nx">TaskStatus</span> <span class="p">=</span> <span class="s">&#34;pending&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">TaskStatusRunning</span> <span class="nx">TaskStatus</span> <span class="p">=</span> <span class="s">&#34;running&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">TaskStatusDone</span>    <span class="nx">TaskStatus</span> <span class="p">=</span> <span class="s">&#34;done&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nx">TaskStatusFailed</span>  <span class="nx">TaskStatus</span> <span class="p">=</span> <span class="s">&#34;failed&#34;</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>TaskStatus</code>：</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">CanRetry</span><span class="p">(</span><span class="nx">status</span> <span class="nx">TaskStatus</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">return</span> <span class="nx">status</span> <span class="o">==</span> <span class="nx">TaskStatusFailed</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這不會讓 Go 變成 enum 語言，但能讓 API 更清楚。讀者看到 <code>TaskStatus</code>，就知道這不是任意字串。</p>
<h2 id="執行事件類型與-action-常數">【執行】事件類型與 action 常數</h2>
<p>事件類型的核心規則是：同一組事件值應集中在同一個 typed string 型別下。事件驅動程式常需要管理事件類型：</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">EventType</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">EventUserCreated</span> <span class="nx">EventType</span> <span class="p">=</span> <span class="s">&#34;user.created&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">EventUserUpdated</span> <span class="nx">EventType</span> <span class="p">=</span> <span class="s">&#34;user.updated&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">EventUserDeleted</span> <span class="nx">EventType</span> <span class="p">=</span> <span class="s">&#34;user.deleted&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>API 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">Action</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">ActionSubscribe</span>   <span class="nx">Action</span> <span class="p">=</span> <span class="s">&#34;subscribe&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">ActionUnsubscribe</span> <span class="nx">Action</span> <span class="p">=</span> <span class="s">&#34;unsubscribe&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">ActionPing</span>        <span class="nx">Action</span> <span class="p">=</span> <span class="s">&#34;ping&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>處理時，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">func</span> <span class="nf">HandleAction</span><span class="p">(</span><span class="nx">action</span> <span class="nx">Action</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">switch</span> <span class="nx">action</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">ActionSubscribe</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">nil</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">case</span> <span class="nx">ActionUnsubscribe</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">nil</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">case</span> <span class="nx">ActionPing</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="kc">nil</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">default</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">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">action</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><h2 id="log-message-也適合集中">log message 也適合集中</h2>
<p>log message 的核心規則是：會被 grep、監控或文件引用的訊息應保持穩定。這類 message 可以用常數集中：</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">LogServerStarted</span> <span class="p">=</span> <span class="s">&#34;server started&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">LogEventDropped</span>  <span class="p">=</span> <span class="s">&#34;event dropped&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">LogInvalidAction</span> <span class="p">=</span> <span class="s">&#34;invalid action&#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>這樣做的價值是讓 log 訊號穩定。當 log 是除錯入口時，穩定字串就是系統 contract 的一部分。</p>
<h2 id="常見取捨">常見取捨</h2>
<h3 id="不必把所有字串都變常數">不必把所有字串都變常數</h3>
<p>只出現一次、沒有協定意義、不需要搜尋的文字，可以直接寫在原處。過度常數化會讓讀者一直跳檔案。</p>
<h3 id="常數名稱要說明清楚概念">常數名稱要說明清楚概念</h3>
<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">const</span> <span class="nx">StringActive</span> <span class="p">=</span> <span class="s">&#34;active&#34;</span> <span class="c1">// 不佳</span></span></span></code></pre></div><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">const</span> <span class="nx">TaskStatusActive</span> <span class="nx">TaskStatus</span> <span class="p">=</span> <span class="s">&#34;active&#34;</span></span></span></code></pre></div>]]></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><item><title>3.4 encoding/json：資料交換</title><link>https://tarrragon.github.io/blog/go/03-stdlib/json/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/03-stdlib/json/</guid><description>&lt;p>&lt;code>encoding/json&lt;/code> 是 Go 標準庫中負責 JSON 編碼與解碼的 package。它的核心用途是把 Go struct 轉成 JSON，或把 JSON 轉回 Go struct，讓程式能和設定檔、HTTP API、message &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> 等外部格式交換資料。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>用 &lt;code>json.Unmarshal&lt;/code> 解析 JSON bytes&lt;/li>
&lt;li>用 &lt;code>json.Marshal&lt;/code> 輸出 JSON bytes&lt;/li>
&lt;li>用 &lt;code>json.NewDecoder&lt;/code> 解析 stream&lt;/li>
&lt;li>用 &lt;code>json.NewEncoder&lt;/code> 寫出 response&lt;/li>
&lt;li>正確處理 JSON 解析錯誤&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察json-解碼是外部資料進入-go-型別的邊界">【觀察】JSON 解碼是外部資料進入 Go 型別的邊界&lt;/h2>
&lt;p>JSON 解碼的核心規則是：外部資料必須先進入明確的 Go struct，後續程式才應依賴型別欄位。以下範例把設定檔 JSON 解析成 &lt;code>Config&lt;/code>：&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">Config&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">AppName&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;appName&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="nx">Port&lt;/span> &lt;span class="kt">int&lt;/span> &lt;span class="s">`json:&amp;#34;port&amp;#34;`&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">Debug&lt;/span> &lt;span class="kt">bool&lt;/span> &lt;span class="s">`json:&amp;#34;debug&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;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">LoadConfig&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">data&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">byte&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">Config&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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"> 8&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">cfg&lt;/span> &lt;span class="nx">Config&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">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">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">cfg&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">10&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">Config&lt;/span>&lt;span class="p">{},&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;parse config JSON: %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">11&lt;/span>&lt;span class="cl"> &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">cfg&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&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>&lt;code>json.Unmarshal&lt;/code> 需要接收 pointer，因為它要把解析結果寫入 &lt;code>cfg&lt;/code>。若傳入 &lt;code>cfg&lt;/code> 而不是 &lt;code>&amp;amp;cfg&lt;/code>，解碼結果無法寫回呼叫端變數。&lt;/p>
&lt;h2 id="判讀json-tag-是解碼與編碼的欄位對照表">【判讀】JSON tag 是解碼與編碼的欄位對照表&lt;/h2>
&lt;p>JSON tag 的核心規則是：Go 欄位名稱和 JSON 欄位名稱可以不同，但必須在 struct tag 中明確對應。&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">User&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 class="s">`json:&amp;#34;id&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="nx">Name&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;name&amp;#34;`&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">CreatedAt&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;createdAt&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>這個 struct 對應的 JSON 是：&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;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;u_1&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;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Alice&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;createdAt&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2026-04-22T10:00:00Z&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>Go 欄位必須 exported，&lt;code>encoding/json&lt;/code> 才能讀寫。小寫開頭欄位是 unexported，JSON package 不會填入。&lt;/p>
&lt;h2 id="策略bytes-用-marshalunmarshalstream-用-encoderdecoder">【策略】bytes 用 Marshal/Unmarshal，stream 用 Encoder/Decoder&lt;/h2>
&lt;p>JSON API 選擇的核心規則是：資料已經在記憶體中用 &lt;code>Marshal&lt;/code> / &lt;code>Unmarshal&lt;/code>，資料來自 stream 用 &lt;code>Encoder&lt;/code> / &lt;code>Decoder&lt;/code>。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情境&lt;/th>
 &lt;th>適合 API&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>[]byte&lt;/code> 解析成 struct&lt;/td>
 &lt;td>&lt;code>json.Unmarshal&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>struct 轉成 &lt;code>[]byte&lt;/code>&lt;/td>
 &lt;td>&lt;code>json.Marshal&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>從 &lt;code>io.Reader&lt;/code> 讀 JSON&lt;/td>
 &lt;td>&lt;code>json.NewDecoder&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>寫 JSON 到 &lt;code>io.Writer&lt;/code>&lt;/td>
 &lt;td>&lt;code>json.NewEncoder&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>HTTP request body 是 stream，適合用 decoder：&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">decodeCreateUser&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">CreateUserRequest&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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="kd">var&lt;/span> &lt;span class="nx">req&lt;/span> &lt;span class="nx">CreateUserRequest&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">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">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&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">4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">CreateUserRequest&lt;/span>&lt;span class="p">{},&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 request JSON: %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">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="k">return&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&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>HTTP response writer 也是 stream，適合用 encoder：&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">writeJSON&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">status&lt;/span> &lt;span class="kt">int&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">data&lt;/span> &lt;span class="kt">any&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">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Header&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Content-Type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;application/json&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="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">status&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">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewEncoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Encode&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">5&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="執行在-http-handler-中處理-json">【執行】在 HTTP handler 中處理 JSON&lt;/h2>
&lt;p>HTTP JSON handler 的核心規則是：解析錯誤屬於 client input 問題，通常回 400；內部處理錯誤屬於 server 問題，通常回 500。&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">CreateUserRequest&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">Name&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;name&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">CreateUserResponse&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">ID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;id&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="nx">Name&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;name&amp;#34;`&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">handleCreateUser&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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">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">CreateUserRequest&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">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&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="nf">writeJSON&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&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="kt">string&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="s">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;invalid JSON&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="k">return&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;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Name&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">20&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeJSON&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&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="kt">string&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;error&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;name is required&amp;#34;&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="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="k">return&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="nx">resp&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">CreateUserResponse&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="nx">ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;u_1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl"> &lt;span class="nx">Name&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">Name&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeJSON&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusCreated&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">resp&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 handler 把 JSON 邊界處理清楚：先解碼，再驗證，再執行核心邏輯，最後輸出 JSON。&lt;/p></description><content:encoded><![CDATA[<p><code>encoding/json</code> 是 Go 標準庫中負責 JSON 編碼與解碼的 package。它的核心用途是把 Go struct 轉成 JSON，或把 JSON 轉回 Go struct，讓程式能和設定檔、HTTP API、message <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 等外部格式交換資料。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>用 <code>json.Unmarshal</code> 解析 JSON bytes</li>
<li>用 <code>json.Marshal</code> 輸出 JSON bytes</li>
<li>用 <code>json.NewDecoder</code> 解析 stream</li>
<li>用 <code>json.NewEncoder</code> 寫出 response</li>
<li>正確處理 JSON 解析錯誤</li>
</ol>
<hr>
<h2 id="觀察json-解碼是外部資料進入-go-型別的邊界">【觀察】JSON 解碼是外部資料進入 Go 型別的邊界</h2>
<p>JSON 解碼的核心規則是：外部資料必須先進入明確的 Go struct，後續程式才應依賴型別欄位。以下範例把設定檔 JSON 解析成 <code>Config</code>：</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">Config</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">AppName</span> <span class="kt">string</span> <span class="s">`json:&#34;appName&#34;`</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Port</span>    <span class="kt">int</span>    <span class="s">`json:&#34;port&#34;`</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">Debug</span>   <span class="kt">bool</span>   <span class="s">`json:&#34;debug&#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="nf">LoadConfig</span><span class="p">(</span><span class="nx">data</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">(</span><span class="nx">Config</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="kd">var</span> <span class="nx">cfg</span> <span class="nx">Config</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="nx">json</span><span class="p">.</span><span class="nf">Unmarshal</span><span class="p">(</span><span class="nx">data</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">cfg</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 class="nx">Config</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;parse config JSON: %w&#34;</span><span class="p">,</span> <span class="nx">err</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="k">return</span> <span class="nx">cfg</span><span class="p">,</span> <span class="kc">nil</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>json.Unmarshal</code> 需要接收 pointer，因為它要把解析結果寫入 <code>cfg</code>。若傳入 <code>cfg</code> 而不是 <code>&amp;cfg</code>，解碼結果無法寫回呼叫端變數。</p>
<h2 id="判讀json-tag-是解碼與編碼的欄位對照表">【判讀】JSON tag 是解碼與編碼的欄位對照表</h2>
<p>JSON tag 的核心規則是：Go 欄位名稱和 JSON 欄位名稱可以不同，但必須在 struct tag 中明確對應。</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">User</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 class="s">`json:&#34;id&#34;`</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Name</span>      <span class="kt">string</span> <span class="s">`json:&#34;name&#34;`</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">CreatedAt</span> <span class="kt">string</span> <span class="s">`json:&#34;createdAt&#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>這個 struct 對應的 JSON 是：</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;id&#34;</span><span class="p">:</span> <span class="s2">&#34;u_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;Alice&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;createdAt&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-04-22T10:00:00Z&#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>Go 欄位必須 exported，<code>encoding/json</code> 才能讀寫。小寫開頭欄位是 unexported，JSON package 不會填入。</p>
<h2 id="策略bytes-用-marshalunmarshalstream-用-encoderdecoder">【策略】bytes 用 Marshal/Unmarshal，stream 用 Encoder/Decoder</h2>
<p>JSON API 選擇的核心規則是：資料已經在記憶體中用 <code>Marshal</code> / <code>Unmarshal</code>，資料來自 stream 用 <code>Encoder</code> / <code>Decoder</code>。</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>適合 API</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>[]byte</code> 解析成 struct</td>
          <td><code>json.Unmarshal</code></td>
      </tr>
      <tr>
          <td>struct 轉成 <code>[]byte</code></td>
          <td><code>json.Marshal</code></td>
      </tr>
      <tr>
          <td>從 <code>io.Reader</code> 讀 JSON</td>
          <td><code>json.NewDecoder</code></td>
      </tr>
      <tr>
          <td>寫 JSON 到 <code>io.Writer</code></td>
          <td><code>json.NewEncoder</code></td>
      </tr>
  </tbody>
</table>
<p>HTTP request body 是 stream，適合用 decoder：</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">decodeCreateUser</span><span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">(</span><span class="nx">CreateUserRequest</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="kd">var</span> <span class="nx">req</span> <span class="nx">CreateUserRequest</span>
</span></span><span class="line"><span class="ln">3</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">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</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">4</span><span class="cl">        <span class="k">return</span> <span class="nx">CreateUserRequest</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;decode request JSON: %w&#34;</span><span class="p">,</span> <span class="nx">err</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="k">return</span> <span class="nx">req</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>HTTP response writer 也是 stream，適合用 encoder：</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">writeJSON</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">status</span> <span class="kt">int</span><span class="p">,</span> <span class="nx">data</span> <span class="kt">any</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">w</span><span class="p">.</span><span class="nf">Header</span><span class="p">().</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Content-Type&#34;</span><span class="p">,</span> <span class="s">&#34;application/json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">status</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewEncoder</span><span class="p">(</span><span class="nx">w</span><span class="p">).</span><span class="nf">Encode</span><span class="p">(</span><span class="nx">data</span><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><h2 id="執行在-http-handler-中處理-json">【執行】在 HTTP handler 中處理 JSON</h2>
<p>HTTP JSON handler 的核心規則是：解析錯誤屬於 client input 問題，通常回 400；內部處理錯誤屬於 server 問題，通常回 500。</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">CreateUserRequest</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">Name</span> <span class="kt">string</span> <span class="s">`json:&#34;name&#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">CreateUserResponse</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">ID</span>   <span class="kt">string</span> <span class="s">`json:&#34;id&#34;`</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">Name</span> <span class="kt">string</span> <span class="s">`json:&#34;name&#34;`</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">handleCreateUser</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</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">CreateUserRequest</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">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</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="nf">writeJSON</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</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="kt">string</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="s">&#34;error&#34;</span><span class="p">:</span> <span class="s">&#34;invalid JSON&#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="k">return</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></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="nx">req</span><span class="p">.</span><span class="nx">Name</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nf">writeJSON</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</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="kt">string</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">            <span class="s">&#34;error&#34;</span><span class="p">:</span> <span class="s">&#34;name is required&#34;</span><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="k">return</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="nx">resp</span> <span class="o">:=</span> <span class="nx">CreateUserResponse</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>   <span class="s">&#34;u_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">        <span class="nx">Name</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">Name</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="nf">writeJSON</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</span><span class="p">,</span> <span class="nx">resp</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 handler 把 JSON 邊界處理清楚：先解碼，再驗證，再執行核心邏輯，最後輸出 JSON。</p>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="忘記傳-pointer-給-unmarshal">忘記傳 pointer 給 Unmarshal</h3>
<p><code>json.Unmarshal</code> 必須把結果寫進目標值，所以目標要傳 pointer：</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">var</span> <span class="nx">cfg</span> <span class="nx">Config</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><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">data</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">cfg</span><span class="p">)</span></span></span></code></pre></div><h3 id="忽略-decode-錯誤">忽略 Decode 錯誤</h3>
<p>JSON 來自外部輸入，解析錯誤是正常情境。忽略錯誤會讓後續程式拿到零值 struct，造成更難追蹤的 bug。</p>
<h3 id="把內部錯誤直接回給外部">把內部錯誤直接回給外部</h3>
<p>對外 response 應該穩定且安全；內部錯誤細節留在 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 或 error chain 裡，不直接暴露給使用者。</p>
]]></content:encoded></item><item><title>3.4 資料結構與 allocation 壓力</title><link>https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/allocation/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/allocation/</guid><description>&lt;p>Allocation 分析的核心目標是區分必要的安全複製與可優化的重複配置。Go 服務中很多配置來自 slice 成長、map/list 複製、JSON marshal、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 建立與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> payload；優化前要先確認配置是否位於熱路徑，且不能破壞狀態邊界。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解 allocation 如何增加 GC 壓力&lt;/li>
&lt;li>分辨必要 copy boundary 與不必要重複配置&lt;/li>
&lt;li>用預配置降低 slice 成長成本&lt;/li>
&lt;li>判斷 JSON marshal 與 WebSocket payload 的重用邊界&lt;/li>
&lt;li>用 pprof 的 &lt;code>alloc_space&lt;/code> 與 &lt;code>inuse_space&lt;/code> 決定優化方向&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察allocation-壓力會放大-gc-成本">【觀察】allocation 壓力會放大 GC 成本&lt;/h2>
&lt;p>Allocation 的核心影響是增加 heap 成長速度，進而增加 GC 工作量。即使物件很快被回收，大量短命配置仍可能造成 CPU 與 latency 壓力。&lt;/p>
&lt;p>常見熱路徑：&lt;/p>
&lt;ul>
&lt;li>每次 WebSocket broadcast 都對每個 client 重新 marshal。&lt;/li>
&lt;li>每次 API list 都建立大型 slice。&lt;/li>
&lt;li>每次 repository 查詢都 copy 大型 map。&lt;/li>
&lt;li>每次 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a> 都組大量臨時欄位。&lt;/li>
&lt;li>每次 encode 都建立新的 &lt;code>bytes.Buffer&lt;/code>。&lt;/li>
&lt;/ul>
&lt;p>不是所有 allocation 都要消除。診斷重點是找出高頻、可避免、且不破壞邊界的配置。&lt;/p>
&lt;h2 id="判讀預配置解決的是成長成本">【判讀】預配置解決的是成長成本&lt;/h2>
&lt;p>Slice 預配置的核心用途是讓底層 array 成長符合預期。若結果長度可預估，應用 &lt;code>make&lt;/code> 設定容量。&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">BuildNames&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">users&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&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="kd">var&lt;/span> &lt;span class="nx">names&lt;/span> &lt;span class="p">[]&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="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">users&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">names&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">names&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Name&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="k">return&lt;/span> &lt;span class="nx">names&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>預配置：&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">BuildNames&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">users&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&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">names&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">users&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">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">users&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">names&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">names&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Name&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="k">return&lt;/span> &lt;span class="nx">names&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>這不是微優化。若這段程式在高頻 list API、background &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a> 或 broadcast path 中執行，預配置可以減少反覆擴容與 copy。&lt;/p>
&lt;h2 id="判讀copy-boundary-是必要成本">【判讀】copy boundary 是必要成本&lt;/h2>
&lt;p>安全複製的核心目的是保護內部可變狀態。Repository 回傳資料時 copy slice 或 map，會增加 allocation，但能避免外部突變與 data race。&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">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">UserRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">ListUsers&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="p">([]&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RLock&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">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RUnlock&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">users&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&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">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&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="nx">users&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">users&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&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 class="k">return&lt;/span> &lt;span class="nx">users&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 allocation 是狀態邊界的成本。優化前要先確認它是否真的是瓶頸，不能只因為 profile 看到配置就移除 copy。&lt;/p>
&lt;p>若列表很大且讀取頻繁，應考慮分頁、projection、snapshot cache 或只回傳必要欄位。不要為了省配置而直接暴露內部 map。&lt;/p>
&lt;h2 id="策略大型-list-優先改資料形狀">【策略】大型 list 優先改資料形狀&lt;/h2>
&lt;p>大型 list allocation 的核心問題常常是 API 一次回太多資料。若每次請求都複製整個 repository，配置與延遲都會隨資料量線性成長。&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>分頁&lt;/td>
 &lt;td>使用者只需要部分資料&lt;/td>
 &lt;td>API 需要 cursor 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>projection&lt;/td>
 &lt;td>只需要摘要欄位&lt;/td>
 &lt;td>要維護讀取模型&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>snapshot cache&lt;/td>
 &lt;td>讀多寫少&lt;/td>
 &lt;td>要處理快取失效&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>incremental update&lt;/td>
 &lt;td>WebSocket 推送最新變化&lt;/td>
 &lt;td>client 要能合併狀態&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>優化資料形狀通常比取消 copy 更安全。Copy boundary 保護正確性，資料形狀決定每次 copy 的成本。&lt;/p></description><content:encoded><![CDATA[<p>Allocation 分析的核心目標是區分必要的安全複製與可優化的重複配置。Go 服務中很多配置來自 slice 成長、map/list 複製、JSON marshal、<a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 建立與 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> payload；優化前要先確認配置是否位於熱路徑，且不能破壞狀態邊界。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解 allocation 如何增加 GC 壓力</li>
<li>分辨必要 copy boundary 與不必要重複配置</li>
<li>用預配置降低 slice 成長成本</li>
<li>判斷 JSON marshal 與 WebSocket payload 的重用邊界</li>
<li>用 pprof 的 <code>alloc_space</code> 與 <code>inuse_space</code> 決定優化方向</li>
</ol>
<hr>
<h2 id="觀察allocation-壓力會放大-gc-成本">【觀察】allocation 壓力會放大 GC 成本</h2>
<p>Allocation 的核心影響是增加 heap 成長速度，進而增加 GC 工作量。即使物件很快被回收，大量短命配置仍可能造成 CPU 與 latency 壓力。</p>
<p>常見熱路徑：</p>
<ul>
<li>每次 WebSocket broadcast 都對每個 client 重新 marshal。</li>
<li>每次 API list 都建立大型 slice。</li>
<li>每次 repository 查詢都 copy 大型 map。</li>
<li>每次 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 都組大量臨時欄位。</li>
<li>每次 encode 都建立新的 <code>bytes.Buffer</code>。</li>
</ul>
<p>不是所有 allocation 都要消除。診斷重點是找出高頻、可避免、且不破壞邊界的配置。</p>
<h2 id="判讀預配置解決的是成長成本">【判讀】預配置解決的是成長成本</h2>
<p>Slice 預配置的核心用途是讓底層 array 成長符合預期。若結果長度可預估，應用 <code>make</code> 設定容量。</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">BuildNames</span><span class="p">(</span><span class="nx">users</span> <span class="p">[]</span><span class="nx">User</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="kd">var</span> <span class="nx">names</span> <span class="p">[]</span><span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">user</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">users</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">names</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">names</span><span class="p">,</span> <span class="nx">user</span><span class="p">.</span><span class="nx">Name</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="k">return</span> <span class="nx">names</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><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">BuildNames</span><span class="p">(</span><span class="nx">users</span> <span class="p">[]</span><span class="nx">User</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">names</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">users</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">_</span><span class="p">,</span> <span class="nx">user</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">users</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">names</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">names</span><span class="p">,</span> <span class="nx">user</span><span class="p">.</span><span class="nx">Name</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="k">return</span> <span class="nx">names</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這不是微優化。若這段程式在高頻 list API、background <a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a> 或 broadcast path 中執行，預配置可以減少反覆擴容與 copy。</p>
<h2 id="判讀copy-boundary-是必要成本">【判讀】copy boundary 是必要成本</h2>
<p>安全複製的核心目的是保護內部可變狀態。Repository 回傳資料時 copy slice 或 map，會增加 allocation，但能避免外部突變與 data race。</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">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">ListUsers</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="p">([]</span><span class="nx">User</span><span class="p">,</span> <span class="kt">error</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">r</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">r</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">users</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="nx">User</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">r</span><span class="p">.</span><span class="nx">users</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">_</span><span class="p">,</span> <span class="nx">user</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">r</span><span class="p">.</span><span class="nx">users</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">users</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">users</span><span class="p">,</span> <span class="nx">user</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">return</span> <span class="nx">users</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 allocation 是狀態邊界的成本。優化前要先確認它是否真的是瓶頸，不能只因為 profile 看到配置就移除 copy。</p>
<p>若列表很大且讀取頻繁，應考慮分頁、projection、snapshot cache 或只回傳必要欄位。不要為了省配置而直接暴露內部 map。</p>
<h2 id="策略大型-list-優先改資料形狀">【策略】大型 list 優先改資料形狀</h2>
<p>大型 list allocation 的核心問題常常是 API 一次回太多資料。若每次請求都複製整個 repository，配置與延遲都會隨資料量線性成長。</p>
<p>可選策略：</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>適用情境</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>分頁</td>
          <td>使用者只需要部分資料</td>
          <td>API 需要 cursor 或 <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a></td>
      </tr>
      <tr>
          <td>projection</td>
          <td>只需要摘要欄位</td>
          <td>要維護讀取模型</td>
      </tr>
      <tr>
          <td>snapshot cache</td>
          <td>讀多寫少</td>
          <td>要處理快取失效</td>
      </tr>
      <tr>
          <td>incremental update</td>
          <td>WebSocket 推送最新變化</td>
          <td>client 要能合併狀態</td>
      </tr>
  </tbody>
</table>
<p>優化資料形狀通常比取消 copy 更安全。Copy boundary 保護正確性，資料形狀決定每次 copy 的成本。</p>
<h2 id="執行json-marshal-是-websocket-常見配置來源">【執行】JSON marshal 是 WebSocket 常見配置來源</h2>
<p>JSON 序列化的核心成本是把 Go 資料結構轉成 bytes。高頻 WebSocket 推送若對每個 client 反覆 marshal 同一份 message，會造成大量短命配置。</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="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">client</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">clients</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">payload</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">Marshal</span><span class="p">(</span><span class="nx">message</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="nx">err</span> <span class="o">!=</span> <span class="kc">nil</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="nx">err</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="nf">SendBytes</span><span class="p">(</span><span class="nx">payload</span><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>同一份 message 可以先 marshal 一次：</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="nx">payload</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">Marshal</span><span class="p">(</span><span class="nx">message</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">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">return</span> <span class="nx">err</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="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">client</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">clients</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nx">client</span><span class="p">.</span><span class="nf">SendBytes</span><span class="p">(</span><span class="nx">payload</span><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>payload</code> 被視為只讀。Send path 不應修改傳入的 bytes；若某個 client 需要修改，就應在該 client 邊界 copy，而不是讓共享 payload 被改動。</p>
<h2 id="判讀bytes-重用要先定義所有權">【判讀】bytes 重用要先定義所有權</h2>
<p>Bytes 重用的核心風險是共享 slice 被修改。<code>[]byte</code> 是可變資料，傳給多個 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">type</span> <span class="nx">EncodedMessage</span> <span class="p">[]</span><span class="kt">byte</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">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">SendEncoded</span><span class="p">(</span><span class="nx">message</span> <span class="nx">EncodedMessage</span><span class="p">)</span> <span class="kt">bool</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="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">5</span><span class="cl">        <span class="nx">Encoded</span><span class="p">:</span> <span class="nx">message</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>這不能從型別上完全禁止修改，但能讓 API 語意更清楚。真正保護仍靠 ownership 規則、測試與 code review。</p>
<p>若無法保證下游不修改，就必須 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="nf">CloneBytes</span><span class="p">(</span><span class="nx">input</span> <span class="p">[]</span><span class="kt">byte</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">2</span><span class="cl">    <span class="nx">output</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="kt">byte</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">input</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nb">copy</span><span class="p">(</span><span class="nx">output</span><span class="p">,</span> <span class="nx">input</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="nx">output</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>效能優化不能建立在模糊的可變資料共享上。</p>
<h2 id="策略syncpool-只適合已證明的熱路徑">【策略】sync.Pool 只適合已證明的熱路徑</h2>
<p><code>sync.Pool</code> 的核心用途是複用高頻、短命、可重建的暫存物件。它可以降低配置，但會增加所有權複雜度。</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">var</span> <span class="nx">bufferPool</span> <span class="p">=</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">Pool</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">New</span><span class="p">:</span> <span class="kd">func</span><span class="p">()</span> <span class="kt">any</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">return</span> <span class="nb">new</span><span class="p">(</span><span class="nx">bytes</span><span class="p">.</span><span class="nx">Buffer</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="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="nf">Encode</span><span class="p">(</span><span class="nx">value</span> <span class="kt">any</span><span class="p">)</span> <span class="p">([]</span><span class="kt">byte</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">buf</span> <span class="o">:=</span> <span class="nx">bufferPool</span><span class="p">.</span><span class="nf">Get</span><span class="p">().(</span><span class="o">*</span><span class="nx">bytes</span><span class="p">.</span><span class="nx">Buffer</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">defer</span> <span class="nx">bufferPool</span><span class="p">.</span><span class="nf">Put</span><span class="p">(</span><span class="nx">buf</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">buf</span><span class="p">.</span><span class="nf">Reset</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</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">NewEncoder</span><span class="p">(</span><span class="nx">buf</span><span class="p">).</span><span class="nf">Encode</span><span class="p">(</span><span class="nx">value</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="kc">nil</span><span class="p">,</span> <span class="nx">err</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">output</span> <span class="o">:=</span> <span class="nb">append</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="kc">nil</span><span class="p">),</span> <span class="nx">buf</span><span class="p">.</span><span class="nf">Bytes</span><span class="p">()</span><span class="o">...</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="nx">output</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這裡仍然 copy 出 <code>output</code>，因為 <code>buf</code> 會被放回 pool。若直接回傳 <code>buf.Bytes()</code>，呼叫端拿到的 slice 可能在 pool 重用後被覆寫。</p>
<p>不要一開始就使用 <code>sync.Pool</code>。先用 pprof 證明配置是瓶頸，再評估 pool 是否值得承擔額外複雜度。</p>
<h2 id="判讀inuse-與-alloc-回答不同問題">【判讀】inuse 與 alloc 回答不同問題</h2>
<p>Heap profile 的核心判讀是分清 <code>inuse_space</code> 與 <code>alloc_space</code>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go tool pprof http://localhost:8080/debug/pprof/heap
</span></span><span class="line"><span class="ln">2</span><span class="cl">go tool pprof -alloc_space http://localhost:8080/debug/pprof/heap</span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>指標</th>
          <th>問題</th>
          <th>常見修正</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>inuse_space</code> 高</td>
          <td>現在誰保留記憶體</td>
          <td>cache 淘汰、釋放引用、限制 buffer</td>
      </tr>
      <tr>
          <td><code>alloc_space</code> 高</td>
          <td>誰累積配置最多</td>
          <td>預配置、重用、減少 marshal、改資料形狀</td>
      </tr>
  </tbody>
</table>
<p>若 <code>alloc_space</code> 高但 <code>inuse_space</code> 不高，代表配置很多但大多被回收，問題可能是 GC 壓力。若 <code>inuse_space</code> 持續上升，代表資料被長期保留，應檢查 cache、map、slice、goroutine reference 或 send buffer。</p>
<h2 id="策略allocation-優化要保留正確性邊界">【策略】allocation 優化要保留正確性邊界</h2>
<p>Allocation 優化的核心底線是不能破壞狀態安全。以下做法通常不可接受：</p>
<ul>
<li>為了省 copy，直接回傳 repository 內部 map。</li>
<li>為了省 bytes，讓多個 client 共享可修改 payload。</li>
<li>為了省 allocation，把 buffer 放回 pool 後仍回傳其底層 slice。</li>
<li>為了少建立 struct，把 request DTO 和 domain state 混用。</li>
</ul>
<p>較安全的優化順序：</p>
<ol>
<li>用 pprof 確認熱點。</li>
<li>預配置已知大小的 slice/map。</li>
<li>減少重複 marshal。</li>
<li>改 API 資料形狀，例如分頁或 projection。</li>
<li>最後才考慮 <code>sync.Pool</code>。</li>
</ol>
<p>這個順序先處理低風險、高可讀性的改動，再處理高複雜度工具。</p>
<h2 id="測試優化後要補邊界測試">【測試】優化後要補邊界測試</h2>
<p>Allocation 優化的測試核心是確保共享資料沒有被外部修改。若你重用 bytes、snapshot 或 pooled buffer，要補測試保護 ownership。</p>
<p>例如 repository list 仍要回傳 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="nf">TestListUsersReturnsCopy</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">repo</span> <span class="o">:=</span> <span class="nf">NewUserRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">ctx</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">User</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;user_1&#34;</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">users</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">ListUsers</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</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"> 8</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;list users: %v&#34;</span><span class="p">,</span> <span class="nx">err</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="nx">users</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">ID</span> <span class="p">=</span> <span class="s">&#34;changed&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">again</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">ListUsers</span><span class="p">(</span><span class="nx">ctx</span><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="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</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;list users again: %v&#34;</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="k">if</span> <span class="nx">again</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">ID</span> <span class="o">!=</span> <span class="s">&#34;user_1&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</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;repository data was modified through returned slice&#34;</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 class="p">}</span></span></span></code></pre></div><p>這種測試能防止未來為了省 allocation 而移除必要 copy。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理熱路徑上的配置與資料形狀；更大範圍的序列化與 payload 策略，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go/02-types-data/struct-json/" data-link-title="2.1 struct 與 JSON tag" data-link-desc="理解 Go struct 如何表達資料形狀，並透過 JSON tag 對應外部格式">Go 入門：struct 與 JSON tag</a></li>
<li><a href="/blog/go/02-types-data/slices-maps/" data-link-title="2.2 slice 與 map" data-link-desc="掌握 Go 最常用的集合型別：slice 與 map">Go 入門：slice 與 map</a></li>
<li><a href="/blog/go/02-types-data/pointers-copy/" data-link-title="2.5 指標與資料複製邊界" data-link-desc="理解指標、slice 與共享狀態的防護策略">Go 入門：指標與資料複製邊界</a></li>
<li><a href="/blog/go-advanced/03-runtime-profiling/pprof/" data-link-title="3.2 pprof 基礎診斷流程" data-link-desc="用 pprof endpoint 診斷 heap、goroutine 與 CPU 問題">Go 進階：pprof 基礎診斷流程</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 copy boundary、JSON 與 runtime profile；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/state-fields/" data-link-title="6.3 如何擴展狀態投影欄位" data-link-desc="更新狀態模型、repository 與 API 輸出">Go：如何擴展狀態投影欄位</a></li>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</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/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>Allocation 優化要先判斷配置是否必要。保護狀態的 copy 是合理成本，高頻熱路徑的重複配置才是優先目標。JSON marshal、slice 成長、map/list 複製與 buffer 建立都是常見來源；用 pprof 區分 <code>inuse_space</code> 與 <code>alloc_space</code> 後，再決定預配置、分頁、projection、payload 重用或 <code>sync.Pool</code>。</p>
]]></content:encoded></item><item><title>4.4 sync.RWMutex：保護共享狀態</title><link>https://tarrragon.github.io/blog/go/04-concurrency/rwmutex/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/04-concurrency/rwmutex/</guid><description>&lt;p>&lt;code>sync.RWMutex&lt;/code> 是 Go 用來保護共享狀態的讀寫鎖。它的核心用途是允許多個讀取者同時讀取，但寫入者必須獨占資料，避免 goroutine 同時讀寫 map、slice 或 struct 時產生資料競爭。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解 data race 的風險&lt;/li>
&lt;li>區分 &lt;code>Mutex&lt;/code> 與 &lt;code>RWMutex&lt;/code>&lt;/li>
&lt;li>用 &lt;code>RLock&lt;/code> / &lt;code>RUnlock&lt;/code> 保護讀取&lt;/li>
&lt;li>用 &lt;code>Lock&lt;/code> / &lt;code>Unlock&lt;/code> 保護寫入&lt;/li>
&lt;li>避免回傳內部 map 或 slice 破壞鎖邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察共享-map-不能被多個-goroutine-無保護地讀寫">【觀察】共享 map 不能被多個 goroutine 無保護地讀寫&lt;/h2>
&lt;p>共享狀態的核心規則是：只要多個 goroutine 可能同時讀寫同一份資料，就必須用同步機制保護。以下程式同時讀寫 map，存在 data race：&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">UserRepository&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">users&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="nx">User&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="o">*&lt;/span>&lt;span class="nx">UserRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Set&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">user&lt;/span> &lt;span class="nx">User&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"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">id&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">user&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">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">UserRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Get&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="p">(&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">bool&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">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">user&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&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="k">return&lt;/span> &lt;span class="nx">user&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&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>如果 &lt;code>Set&lt;/code> 和 &lt;code>Get&lt;/code> 從不同 goroutine 同時執行，map 可能被同時讀寫。Go 的 map 不保證這種情境安全。&lt;/p>
&lt;h2 id="判讀rwmutex-區分讀取與寫入">【判讀】RWMutex 區分讀取與寫入&lt;/h2>
&lt;p>&lt;code>RWMutex&lt;/code> 的核心規則是：讀取使用 &lt;code>RLock&lt;/code>，寫入使用 &lt;code>Lock&lt;/code>；多個讀取可並行，寫入會排他。&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">UserRepository&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">users&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="nx">User&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">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"> 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="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">UserRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Set&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">user&lt;/span> &lt;span class="nx">User&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Lock&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">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Unlock&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="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">id&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">user&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&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>&lt;/span>&lt;span class="line">&lt;span class="ln">12&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="o">*&lt;/span>&lt;span class="nx">UserRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Get&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="p">(&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">bool&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">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RLock&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">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RUnlock&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">user&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&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">16&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">user&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&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>Set&lt;/code> 修改 map，所以用 &lt;code>Lock&lt;/code>。&lt;code>Get&lt;/code> 只讀 map，所以用 &lt;code>RLock&lt;/code>。&lt;/p>
&lt;h2 id="策略鎖保護的是資料不變式">【策略】鎖保護的是資料不變式&lt;/h2>
&lt;p>鎖範圍的核心規則是：鎖要包住所有需要一致觀察或一致修改的資料。鎖的邊界應涵蓋完整不變式，慢速 I/O、網路呼叫與和共享資料無關的計算則應放在鎖外。&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">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">UserRepository&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">user&lt;/span> &lt;span class="nx">User&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">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Lock&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">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Unlock&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>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">user&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">user&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">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">count&lt;/span>&lt;span class="o">++&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>如果 &lt;code>users&lt;/code> 和 &lt;code>count&lt;/code> 分開鎖，讀者可能看到 map 已更新但 count 還沒更新的中間狀態。&lt;/p>
&lt;h2 id="執行回傳資料時要保留-copy-boundary">【執行】回傳資料時要保留 copy boundary&lt;/h2>
&lt;p>鎖邊界的核心規則是：鎖只能保護鎖內操作；回傳內部 map 會讓呼叫者在鎖外修改資料，破壞 repository 對狀態的控制權。&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">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">UserRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Users&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="nx">User&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">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RLock&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">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RUnlock&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="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&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;/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">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">UserRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Users&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="nx">User&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">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RLock&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">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">RUnlock&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">result&lt;/span> &lt;span class="o">:=&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="nx">User&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&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">for&lt;/span> &lt;span class="nx">id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&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="nx">result&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">id&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">user&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 class="k">return&lt;/span> &lt;span class="nx">result&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&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>UserRepository&lt;/code> 修改內部狀態。&lt;/p>
&lt;h2 id="mutex-還是-rwmutex">Mutex 還是 RWMutex？&lt;/h2>
&lt;p>選擇鎖的核心規則是：讀多寫少且讀操作可並行時用 &lt;code>RWMutex&lt;/code>；不確定時先用 &lt;code>Mutex&lt;/code>，設計更簡單。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>鎖&lt;/th>
 &lt;th>適合情境&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>sync.Mutex&lt;/code>&lt;/td>
 &lt;td>狀態小、讀寫都簡單、沒有明顯讀多寫少&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>sync.RWMutex&lt;/code>&lt;/td>
 &lt;td>讀取頻繁、寫入較少、讀操作可安全並行&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>sync.Mutex&lt;/code> 的核心優勢是簡單。若狀態很小、讀寫都很快，或讀寫比例尚未明確，先使用 &lt;code>Mutex&lt;/code> 通常更容易維護。它讓每次存取都走同一條鎖路徑，讀者也比較容易確認資料何時被保護。&lt;/p>
&lt;p>&lt;code>sync.RWMutex&lt;/code> 的核心優勢是讀多寫少時可以讓多個讀取並行。它適合像 in-memory cache、狀態查詢 repository 或連線註冊表這類讀取頻繁的資料結構。使用它時，寫入仍然要用 &lt;code>Lock&lt;/code>，因為 &lt;code>RLock&lt;/code> 只適合保護純讀取。&lt;/p>
&lt;p>鎖選擇的判斷重點是資料不變式與讀寫比例。若讀取本身會組裝複雜資料、需要複製大型 map，或很快就會呼叫外部 I/O，&lt;code>RWMutex&lt;/code> 帶來的並行讀取收益可能被複雜度抵消。&lt;/p>
&lt;h2 id="替代方案什麼時候不用-rwmutex">替代方案：什麼時候不用 RWMutex&lt;/h2>
&lt;p>&lt;code>RWMutex&lt;/code> 不是共享狀態保護的唯一選擇。三類替代方案各有適用條件：&lt;/p></description><content:encoded><![CDATA[<p><code>sync.RWMutex</code> 是 Go 用來保護共享狀態的讀寫鎖。它的核心用途是允許多個讀取者同時讀取，但寫入者必須獨占資料，避免 goroutine 同時讀寫 map、slice 或 struct 時產生資料競爭。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解 data race 的風險</li>
<li>區分 <code>Mutex</code> 與 <code>RWMutex</code></li>
<li>用 <code>RLock</code> / <code>RUnlock</code> 保護讀取</li>
<li>用 <code>Lock</code> / <code>Unlock</code> 保護寫入</li>
<li>避免回傳內部 map 或 slice 破壞鎖邊界</li>
</ol>
<hr>
<h2 id="觀察共享-map-不能被多個-goroutine-無保護地讀寫">【觀察】共享 map 不能被多個 goroutine 無保護地讀寫</h2>
<p>共享狀態的核心規則是：只要多個 goroutine 可能同時讀寫同一份資料，就必須用同步機制保護。以下程式同時讀寫 map，存在 data race：</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">UserRepository</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">users</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">User</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">UserRepository</span><span class="p">)</span> <span class="nf">Set</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">user</span> <span class="nx">User</span><span class="p">)</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">users</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span> <span class="p">=</span> <span class="nx">user</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">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">Get</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">User</span><span class="p">,</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">user</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">users</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="k">return</span> <span class="nx">user</span><span class="p">,</span> <span class="nx">ok</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>如果 <code>Set</code> 和 <code>Get</code> 從不同 goroutine 同時執行，map 可能被同時讀寫。Go 的 map 不保證這種情境安全。</p>
<h2 id="判讀rwmutex-區分讀取與寫入">【判讀】RWMutex 區分讀取與寫入</h2>
<p><code>RWMutex</code> 的核心規則是：讀取使用 <code>RLock</code>，寫入使用 <code>Lock</code>；多個讀取可並行，寫入會排他。</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">UserRepository</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">users</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">User</span>
</span></span><span class="line"><span class="ln"> 3</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"> 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="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">Set</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">user</span> <span class="nx">User</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">r</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"> 8</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</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"> 9</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">users</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span> <span class="p">=</span> <span class="nx">user</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></span><span class="line"><span class="ln">12</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">UserRepository</span><span class="p">)</span> <span class="nf">Get</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">User</span><span class="p">,</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">r</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">14</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</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">15</span><span class="cl">    <span class="nx">user</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">users</span><span class="p">[</span><span class="nx">id</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="nx">user</span><span class="p">,</span> <span class="nx">ok</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>Set</code> 修改 map，所以用 <code>Lock</code>。<code>Get</code> 只讀 map，所以用 <code>RLock</code>。</p>
<h2 id="策略鎖保護的是資料不變式">【策略】鎖保護的是資料不變式</h2>
<p>鎖範圍的核心規則是：鎖要包住所有需要一致觀察或一致修改的資料。鎖的邊界應涵蓋完整不變式，慢速 I/O、網路呼叫與和共享資料無關的計算則應放在鎖外。</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">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">Add</span><span class="p">(</span><span class="nx">user</span> <span class="nx">User</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">r</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">3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</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">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">users</span><span class="p">[</span><span class="nx">user</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">user</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">count</span><span class="o">++</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>如果 <code>users</code> 和 <code>count</code> 分開鎖，讀者可能看到 map 已更新但 count 還沒更新的中間狀態。</p>
<h2 id="執行回傳資料時要保留-copy-boundary">【執行】回傳資料時要保留 copy boundary</h2>
<p>鎖邊界的核心規則是：鎖只能保護鎖內操作；回傳內部 map 會讓呼叫者在鎖外修改資料，破壞 repository 對狀態的控制權。</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">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">Users</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="nx">User</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">r</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">r</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 class="k">return</span> <span class="nx">r</span><span class="p">.</span><span class="nx">users</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><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">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">Users</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="nx">User</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</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">r</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">result</span> <span class="o">:=</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="nx">User</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">users</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">id</span><span class="p">,</span> <span class="nx">user</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">r</span><span class="p">.</span><span class="nx">users</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">result</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span> <span class="p">=</span> <span class="nx">user</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">return</span> <span class="nx">result</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>呼叫者拿到的是複本，不能繞過 <code>UserRepository</code> 修改內部狀態。</p>
<h2 id="mutex-還是-rwmutex">Mutex 還是 RWMutex？</h2>
<p>選擇鎖的核心規則是：讀多寫少且讀操作可並行時用 <code>RWMutex</code>；不確定時先用 <code>Mutex</code>，設計更簡單。</p>
<table>
  <thead>
      <tr>
          <th>鎖</th>
          <th>適合情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>sync.Mutex</code></td>
          <td>狀態小、讀寫都簡單、沒有明顯讀多寫少</td>
      </tr>
      <tr>
          <td><code>sync.RWMutex</code></td>
          <td>讀取頻繁、寫入較少、讀操作可安全並行</td>
      </tr>
  </tbody>
</table>
<p><code>sync.Mutex</code> 的核心優勢是簡單。若狀態很小、讀寫都很快，或讀寫比例尚未明確，先使用 <code>Mutex</code> 通常更容易維護。它讓每次存取都走同一條鎖路徑，讀者也比較容易確認資料何時被保護。</p>
<p><code>sync.RWMutex</code> 的核心優勢是讀多寫少時可以讓多個讀取並行。它適合像 in-memory cache、狀態查詢 repository 或連線註冊表這類讀取頻繁的資料結構。使用它時，寫入仍然要用 <code>Lock</code>，因為 <code>RLock</code> 只適合保護純讀取。</p>
<p>鎖選擇的判斷重點是資料不變式與讀寫比例。若讀取本身會組裝複雜資料、需要複製大型 map，或很快就會呼叫外部 I/O，<code>RWMutex</code> 帶來的並行讀取收益可能被複雜度抵消。</p>
<h2 id="替代方案什麼時候不用-rwmutex">替代方案：什麼時候不用 RWMutex</h2>
<p><code>RWMutex</code> 不是共享狀態保護的唯一選擇。三類替代方案各有適用條件：</p>
<table>
  <thead>
      <tr>
          <th>方案</th>
          <th>適用情境</th>
          <th>跟 RWMutex 對比</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>sync.Map</code></td>
          <td>key 集合大、entries 異步增減、讀寫分散在不同 key</td>
          <td>內建讀寫並行、無全域鎖；但語意不同（無 size、無 range 一致性）</td>
      </tr>
      <tr>
          <td><code>sync/atomic</code></td>
          <td>單一純量（counter、flag、pointer）</td>
          <td>無鎖、最快；但只能保護單一值、不能保護結構不變式</td>
      </tr>
      <tr>
          <td>Channel-based coordination</td>
          <td>狀態由單一 owner goroutine 持有、其他 goroutine 透過 channel 傳訊息</td>
          <td>用 ownership 取代 sharing；適合 producer / consumer pattern、見 <a href="../channel/">4.2 channel</a></td>
      </tr>
  </tbody>
</table>
<p>判別準則：</p>
<ul>
<li>保護<strong>多欄位不變式</strong>（如 <code>users</code> + <code>count</code> 同步）→ <code>RWMutex</code> 或 <code>Mutex</code></li>
<li>保護<strong>單一純量</strong>且操作可表達為 atomic op（CAS、increment）→ <code>sync/atomic</code></li>
<li>保護<strong>大量獨立 key</strong> 且無跨 key 不變式 → <code>sync.Map</code></li>
<li>狀態可由<strong>單一 owner</strong> 持有、外部用訊息驅動 → channel-based、見 <a href="../channel/">4.2</a> / <a href="../backpressure/">4.5 backpressure</a></li>
</ul>
<p>選錯方案的代價：用 <code>sync/atomic</code> 保護需要不變式的多欄位 → silent atomicity violation；用 <code>sync.Map</code> 期待 range 一致性 → 拿到 inconsistent snapshot；用 channel 處理需要嚴格 ordering 的 fan-in → 順序錯亂。</p>
<h2 id="rwmutex-不解的問題">RWMutex 不解的問題</h2>
<p><code>RWMutex</code> 解的是 <strong>data race</strong>（多 goroutine 同時讀寫同一份資料的 visible race）。下列問題<strong>不在 <code>RWMutex</code> 防護範圍</strong>、必須由其他機制處理：</p>
<table>
  <thead>
      <tr>
          <th>不防的問題</th>
          <th>為什麼不解</th>
          <th>該用什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Deadlock</td>
          <td>多把鎖的鎖順序不一致、<code>RWMutex</code> 沒有偵測能力</td>
          <td>鎖排序協議、<code>go test -race</code> 並非 deadlock detector</td>
      </tr>
      <tr>
          <td>Starvation</td>
          <td>RWMutex 設計上 reader 多時 writer 可能長期等不到（Go 實作有部分 fairness 保護）</td>
          <td>量測 lock 等待時間、讀多時切 channel-based 或 sharded 鎖</td>
      </tr>
      <tr>
          <td>Lock contention scaling</td>
          <td>goroutine 增多時、單把鎖的競爭成本可能 dominate；<code>RWMutex</code> 多核 scalability 弱</td>
          <td>sharded lock、sync.Map、無鎖結構</td>
      </tr>
      <tr>
          <td>Context cancellation</td>
          <td>reader 已經 hold RLock 時、context 取消不會強制釋放；reader 必須主動 check ctx</td>
          <td>lock 內快進快出、長操作放鎖外、check ctx</td>
      </tr>
      <tr>
          <td>Atomicity violation</td>
          <td>把多步操作拆到多次 Lock/Unlock 中間、其他 goroutine 可能看到中間狀態</td>
          <td>拉大鎖範圍、或改 transaction-like API</td>
      </tr>
      <tr>
          <td>Memory ordering（跨鎖）</td>
          <td>RWMutex 只保證鎖內 happens-before、跨鎖讀寫的 ordering 沒保證</td>
          <td>用 channel 傳遞 ordering、或 atomic load/store</td>
      </tr>
  </tbody>
</table>
<p>判讀訊號：</p>
<ul>
<li><code>go test -race</code> pass、production 仍偶發資料異常 → 可能 atomicity violation 或 ordering bug、不是 data race</li>
<li>多核 CPU 加倍但 throughput 不增 → lock contention dominate、考慮 shard</li>
<li>p99 latency 在高 concurrency 下爆炸 → reader 排隊或 starvation、查 lock 等待 metric</li>
<li>shutdown 時 goroutine 不退 → reader hold RLock + 未 check ctx、補 context 檢查</li>
</ul>
<h2 id="context-dependencescale-改變策略">Context dependence：scale 改變策略</h2>
<p><code>RWMutex</code> 的有效性會隨 deployment 條件變化：</p>
<ul>
<li><strong>Map 大小</strong>：copy 成本隨 entries 線性增長、1k entries 廉價、1M entries 每次 copy 都是 GC pressure 來源；大 map 改 <code>sync.Map</code> 或 sharded</li>
<li><strong>讀寫比例</strong>：90% 讀以下、<code>RWMutex</code> 收益不顯著、<code>Mutex</code> 簡單；讀寫接近時 RWMutex 的內部 atomic 操作成本可能反而比 Mutex 慢</li>
<li><strong>Goroutine 數量</strong>：少（&lt; 10）時 contention 微、多（&gt; 1000）時 RWMutex 不適合、要 shard 或換 lock-free 結構</li>
<li><strong>持鎖時間</strong>：鎖內 microsecond 級 OK、毫秒級會堆隊；鎖內絕不做 I/O / 網路呼叫</li>
</ul>
<h2 id="選擇-rwmutex-前先問四件事">選擇 RWMutex 前先問四件事</h2>
<p><code>RWMutex</code> 只解 data race subset——不解 deadlock / starvation / atomicity violation / context cancellation / 多核 contention scaling。狀態可表達為 atomic op、單 owner channel、或大量獨立 key 時、<code>sync/atomic</code> / channel-based / <code>sync.Map</code> 通常更合適。選擇前先問：「不變式跨幾個欄位？讀寫比例？goroutine 數量？持鎖時間？」</p>
]]></content:encoded></item><item><title>4.4 多來源 event 融合</title><link>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/event-fusion/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/event-fusion/</guid><description>&lt;p>事件融合的核心目標是讓不同來源的同類事件進入同一套內部規則。HTTP callback、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> message、timer scan 與檔案 reader 都只是輸入方式；進入 processor 前，它們應該被轉成一致的 &lt;code>DomainEvent&lt;/code>。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨來源差異與 domain 規則差異&lt;/li>
&lt;li>為不同來源設計 adapter 與 normalize&lt;/li>
&lt;li>用 channel 或直接呼叫收斂事件入口&lt;/li>
&lt;li>為突發流量設計 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> 策略&lt;/li>
&lt;li>決定錯誤應回給上游、重試、丟棄或記錄&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察來源增加後規則容易分裂">【觀察】來源增加後規則容易分裂&lt;/h2>
&lt;p>事件來源增加的核心風險是每個來源各自實作一套處理規則。HTTP handler 有一套 validation，queue &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a> 有一套 retry 判斷，timer worker 又有一套狀態更新；最後同一種 domain event 在不同入口產生不同結果。&lt;/p>
&lt;p>反模式示意：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">HTTP callback ──&amp;gt; validate A ──&amp;gt; update state A
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">queue message ──&amp;gt; validate B ──&amp;gt; update state B
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">timer scan ──&amp;gt; validate C ──&amp;gt; update state C&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這種結構的問題是 domain 規則分裂。新增來源時，應該新增 adapter，不應複製 processor。&lt;/p>
&lt;h2 id="判讀來源差異應限制在-adapter">【判讀】來源差異應限制在 adapter&lt;/h2>
&lt;p>事件融合的核心原則是來源差異停在 adapter 與 normalizer。來源可以有不同 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/authentication/" data-link-title="Authentication" data-link-desc="說明系統如何確認呼叫者身份">authentication&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack&lt;/a>、HTTP status、payload 格式與重試語意；但轉成 &lt;code>DomainEvent&lt;/code> 後，processor 應該面對一致模型。&lt;/p>
&lt;p>目標結構：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">HTTP callback ─┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">queue message ─┼─&amp;gt; normalize ─&amp;gt; DomainEvent ─&amp;gt; processor
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">timer scan ─┘&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個結構讓新增來源變成局部擴充。你新增一個 adapter 與 normalize test，而不是複製 validation、dedup、repository update 與 publish 邏輯。&lt;/p>
&lt;h2 id="策略先定義每個來源的責任">【策略】先定義每個來源的責任&lt;/h2>
&lt;p>來源設計的核心動作是明確寫出每個 adapter 對上游的承諾。不同來源的錯誤回應方式不同，但進入 processor 的事件語意應一致。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>來源&lt;/th>
 &lt;th>adapter 責任&lt;/th>
 &lt;th>失敗回應&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>HTTP callback&lt;/td>
 &lt;td>decode JSON、驗證簽章、normalize&lt;/td>
 &lt;td>回 4xx/5xx&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>queue consumer&lt;/td>
 &lt;td>decode message、控制 ack/nack、normalize&lt;/td>
 &lt;td>ack、nack 或 retry&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>timer scan&lt;/td>
 &lt;td>讀取本地狀態、產生內部事件&lt;/td>
 &lt;td>記錄錯誤或下次再掃&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>file reader&lt;/td>
 &lt;td>讀取增量資料、normalize&lt;/td>
 &lt;td>記錄 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset&lt;/a> 或停下&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>表格是設計工具。若某一列寫不清楚，代表 adapter 與 processor 的邊界還不清楚。&lt;/p>
&lt;h2 id="執行http-adapter-轉成-domainevent">【執行】HTTP adapter 轉成 DomainEvent&lt;/h2>
&lt;p>HTTP adapter 的核心責任是處理 HTTP 協定與外部 payload。它可以回應 status code，但不應直接決定狀態如何更新。&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">HTTPEventHandler&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">processor&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">EventProcessor&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">now&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">h&lt;/span> &lt;span class="nx">HTTPEventHandler&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">ServeHTTP&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">raw&lt;/span> &lt;span class="nx">RawHTTPEvent&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">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">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">raw&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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid_json&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NormalizeHTTPEvent&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">raw&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">now&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">if&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">15&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid_event&amp;#34;&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="k">return&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;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&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">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">processor&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Process&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Context&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">event&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">20&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusServiceUnavailable&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;event_not_accepted&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&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">22&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusAccepted&lt;/span>&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 class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>StatusAccepted&lt;/code> 表示事件已被系統接收，不一定表示所有下游推送都完成。若 API 語意要求同步完成，就需要在文件與測試中明確定義成功條件。&lt;/p></description><content:encoded><![CDATA[<p>事件融合的核心目標是讓不同來源的同類事件進入同一套內部規則。HTTP callback、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> message、timer scan 與檔案 reader 都只是輸入方式；進入 processor 前，它們應該被轉成一致的 <code>DomainEvent</code>。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨來源差異與 domain 規則差異</li>
<li>為不同來源設計 adapter 與 normalize</li>
<li>用 channel 或直接呼叫收斂事件入口</li>
<li>為突發流量設計 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 策略</li>
<li>決定錯誤應回給上游、重試、丟棄或記錄</li>
</ol>
<hr>
<h2 id="觀察來源增加後規則容易分裂">【觀察】來源增加後規則容易分裂</h2>
<p>事件來源增加的核心風險是每個來源各自實作一套處理規則。HTTP handler 有一套 validation，queue <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 有一套 retry 判斷，timer worker 又有一套狀態更新；最後同一種 domain event 在不同入口產生不同結果。</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">HTTP callback ──&gt; validate A ──&gt; update state A
</span></span><span class="line"><span class="ln">2</span><span class="cl">queue message ──&gt; validate B ──&gt; update state B
</span></span><span class="line"><span class="ln">3</span><span class="cl">timer scan    ──&gt; validate C ──&gt; update state C</span></span></code></pre></div><p>這種結構的問題是 domain 規則分裂。新增來源時，應該新增 adapter，不應複製 processor。</p>
<h2 id="判讀來源差異應限制在-adapter">【判讀】來源差異應限制在 adapter</h2>
<p>事件融合的核心原則是來源差異停在 adapter 與 normalizer。來源可以有不同 <a href="/blog/backend/knowledge-cards/authentication/" data-link-title="Authentication" data-link-desc="說明系統如何確認呼叫者身份">authentication</a>、<a href="/blog/backend/knowledge-cards/ack-nack/" data-link-title="Ack / Nack" data-link-desc="說明 consumer 如何向 broker 回報訊息處理結果">ack</a>、HTTP status、payload 格式與重試語意；但轉成 <code>DomainEvent</code> 後，processor 應該面對一致模型。</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">HTTP callback ─┐
</span></span><span class="line"><span class="ln">2</span><span class="cl">queue message ─┼─&gt; normalize ─&gt; DomainEvent ─&gt; processor
</span></span><span class="line"><span class="ln">3</span><span class="cl">timer scan    ─┘</span></span></code></pre></div><p>這個結構讓新增來源變成局部擴充。你新增一個 adapter 與 normalize test，而不是複製 validation、dedup、repository update 與 publish 邏輯。</p>
<h2 id="策略先定義每個來源的責任">【策略】先定義每個來源的責任</h2>
<p>來源設計的核心動作是明確寫出每個 adapter 對上游的承諾。不同來源的錯誤回應方式不同，但進入 processor 的事件語意應一致。</p>
<table>
  <thead>
      <tr>
          <th>來源</th>
          <th>adapter 責任</th>
          <th>失敗回應</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>HTTP callback</td>
          <td>decode JSON、驗證簽章、normalize</td>
          <td>回 4xx/5xx</td>
      </tr>
      <tr>
          <td>queue consumer</td>
          <td>decode message、控制 ack/nack、normalize</td>
          <td>ack、nack 或 retry</td>
      </tr>
      <tr>
          <td>timer scan</td>
          <td>讀取本地狀態、產生內部事件</td>
          <td>記錄錯誤或下次再掃</td>
      </tr>
      <tr>
          <td>file reader</td>
          <td>讀取增量資料、normalize</td>
          <td>記錄 <a href="/blog/backend/knowledge-cards/offset/" data-link-title="Offset" data-link-desc="說明 consumer 在事件流中的讀取位置與重放基準">offset</a> 或停下</td>
      </tr>
  </tbody>
</table>
<p>表格是設計工具。若某一列寫不清楚，代表 adapter 與 processor 的邊界還不清楚。</p>
<h2 id="執行http-adapter-轉成-domainevent">【執行】HTTP adapter 轉成 DomainEvent</h2>
<p>HTTP adapter 的核心責任是處理 HTTP 協定與外部 payload。它可以回應 status code，但不應直接決定狀態如何更新。</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">HTTPEventHandler</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">processor</span> <span class="o">*</span><span class="nx">EventProcessor</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">now</span>       <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="nx">HTTPEventHandler</span><span class="p">)</span> <span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="kd">var</span> <span class="nx">raw</span> <span class="nx">RawHTTPEvent</span>
</span></span><span class="line"><span class="ln"> 8</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">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">raw</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"> 9</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_json&#34;</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></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">event</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeHTTPEvent</span><span class="p">(</span><span class="nx">raw</span><span class="p">,</span> <span class="nx">h</span><span class="p">.</span><span class="nf">now</span><span class="p">())</span>
</span></span><span class="line"><span class="ln">14</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">15</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_event&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="k">return</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></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">h</span><span class="p">.</span><span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nf">Context</span><span class="p">(),</span> <span class="nx">event</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="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusServiceUnavailable</span><span class="p">,</span> <span class="s">&#34;event_not_accepted&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">return</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></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusAccepted</span><span class="p">)</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>StatusAccepted</code> 表示事件已被系統接收，不一定表示所有下游推送都完成。若 API 語意要求同步完成，就需要在文件與測試中明確定義成功條件。</p>
<h2 id="執行queue-adapter-控制-acknack">【執行】queue adapter 控制 ack/nack</h2>
<p>queue adapter 的核心責任是把 message lifecycle 對應到 processor 結果。processor 不應知道 ack、nack 或 delivery tag。</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">QueueMessage</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">Body</span>        <span class="p">[]</span><span class="kt">byte</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Ack</span>         <span class="kd">func</span><span class="p">()</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">Nack</span>        <span class="kd">func</span><span class="p">(</span><span class="nx">requeue</span> <span class="kt">bool</span><span class="p">)</span> <span class="kt">error</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">type</span> <span class="nx">QueueConsumer</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="nx">processor</span> <span class="o">*</span><span class="nx">EventProcessor</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">now</span>       <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="nx">QueueConsumer</span><span class="p">)</span> <span class="nf">Handle</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">msg</span> <span class="nx">QueueMessage</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">event</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeQueueMessage</span><span class="p">(</span><span class="nx">msg</span><span class="p">.</span><span class="nx">Body</span><span class="p">,</span> <span class="nx">c</span><span class="p">.</span><span class="nf">now</span><span class="p">())</span>
</span></span><span class="line"><span class="ln">14</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">15</span><span class="cl">        <span class="k">return</span> <span class="nx">msg</span><span class="p">.</span><span class="nf">Nack</span><span class="p">(</span><span class="kc">false</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></span><span class="line"><span class="ln">18</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">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</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">19</span><span class="cl">        <span class="k">return</span> <span class="nx">msg</span><span class="p">.</span><span class="nf">Nack</span><span class="p">(</span><span class="kc">true</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></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">return</span> <span class="nx">msg</span><span class="p">.</span><span class="nf">Ack</span><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>這段程式把 queue 的重試決策留在 adapter。對 processor 來說，事件只是一筆 <code>DomainEvent</code>；對 queue 來說，錯誤需要轉成 ack/nack 策略。</p>
<h2 id="策略共用-channel-需要-backpressure">【策略】共用 channel 需要 backpressure</h2>
<p>共用 channel 的核心用途是把多個來源收斂到同一個處理 loop。它不是必要架構，但在多來源、突發流量或單一 worker 順序處理時很有用。</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="nx">events</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">DomainEvent</span><span class="p">,</span> <span class="mi">1024</span><span class="p">)</span></span></span></code></pre></div><p>channel 一旦有容量限制，就必須設計滿載策略。沒有滿載策略的 channel 只會把問題延後到 goroutine 堆積或 request 卡住。</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">EnqueueEvent</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">events</span> <span class="kd">chan</span><span class="o">&lt;-</span> <span class="nx">DomainEvent</span><span class="p">,</span> <span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</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">events</span> <span class="o">&lt;-</span> <span class="nx">event</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">nil</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</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="nx">ctx</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">default</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">ErrEventQueueFull</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></code></pre></div><p>HTTP handler 遇到 <code>ErrEventQueueFull</code> 可以回 <code>503</code>。queue consumer 可以 nack 並 <a href="/blog/backend/knowledge-cards/requeue/" data-link-title="Requeue" data-link-desc="說明處理失敗的訊息重新排回 queue 時的風險與控制條件">requeue</a>。timer scan 可以跳過本輪。不同來源的上游回應不同，但進入 channel 的事件模型相同。</p>
<h2 id="執行processor-loop-擁有消費節奏">【執行】processor loop 擁有消費節奏</h2>
<p>processor loop 的核心責任是決定事件如何被消費與停止。它應該接受 context，並在 shutdown 時停止讀取新事件。</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">EventLoop</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">processor</span> <span class="o">*</span><span class="nx">EventProcessor</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">events</span>    <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">DomainEvent</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">logger</span>    <span class="o">*</span><span class="nx">slog</span><span class="p">.</span><span class="nx">Logger</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">l</span> <span class="nx">EventLoop</span><span class="p">)</span> <span class="nf">Run</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="kt">error</span> <span class="p">{</span>
</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</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="nx">ctx</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">case</span> <span class="nx">event</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">l</span><span class="p">.</span><span class="nx">events</span><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">l</span><span class="p">.</span><span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</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">l</span><span class="p">.</span><span class="nx">logger</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="s">&#34;process event failed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">                    <span class="s">&#34;event_type&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">                    <span class="s">&#34;subject_id&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">                    <span class="s">&#34;error&#34;</span><span class="p">,</span> <span class="nx">err</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 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>正式實作還要處理 channel close。若事件來源會關閉 channel，讀取時應使用 <code>event, ok := &lt;-l.events</code>；若 channel 由長生命週期服務持有，通常由 context 控制 shutdown。</p>
<h2 id="判讀錯誤策略要依來源與資料語意決定">【判讀】錯誤策略要依來源與資料語意決定</h2>
<p>錯誤策略的核心問題是「失敗後誰能重送，重送是否安全」。HTTP、queue、timer 的答案不同。</p>
<table>
  <thead>
      <tr>
          <th>錯誤位置</th>
          <th>HTTP callback</th>
          <th>queue message</th>
          <th>timer scan</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>decode 失敗</td>
          <td>400，不重試</td>
          <td>nack(false) 或 dead-letter</td>
          <td>記錄錯誤</td>
      </tr>
      <tr>
          <td>normalize 失敗</td>
          <td>400，不重試</td>
          <td>nack(false) 或 dead-letter</td>
          <td>記錄錯誤</td>
      </tr>
      <tr>
          <td>processor 暫時失敗</td>
          <td>503，可重試</td>
          <td>nack(true)</td>
          <td>下次再掃</td>
      </tr>
      <tr>
          <td>duplicate event</td>
          <td>202 或 204</td>
          <td>ack</td>
          <td>忽略</td>
      </tr>
      <tr>
          <td>publisher 失敗</td>
          <td>視語意而定</td>
          <td>視語意而定</td>
          <td>視語意而定</td>
      </tr>
  </tbody>
</table>
<p>錯誤策略不能只看技術來源，也要看資料語意。若事件已經成功更新狀態但即時推送失敗，HTTP 是否要回錯取決於 API 是否承諾推送已完成。</p>
<h2 id="策略觀測欄位要跨來源一致">【策略】觀測欄位要跨來源一致</h2>
<p>事件融合後的 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 與 metric 也應使用共同欄位。這讓你能跨 HTTP、queue、timer 比較同一類事件的行為。</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">LogAttrsForEvent</span><span class="p">(</span><span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="p">[]</span><span class="nx">slog</span><span class="p">.</span><span class="nx">Attr</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">return</span> <span class="p">[]</span><span class="nx">slog</span><span class="p">.</span><span class="nx">Attr</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">slog</span><span class="p">.</span><span class="nf">String</span><span class="p">(</span><span class="s">&#34;event_id&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ID</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">slog</span><span class="p">.</span><span class="nf">String</span><span class="p">(</span><span class="s">&#34;event_type&#34;</span><span class="p">,</span> <span class="nb">string</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">)),</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">slog</span><span class="p">.</span><span class="nf">String</span><span class="p">(</span><span class="s">&#34;event_source&#34;</span><span class="p">,</span> <span class="nb">string</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">Source</span><span class="p">)),</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">slog</span><span class="p">.</span><span class="nf">String</span><span class="p">(</span><span class="s">&#34;subject_id&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">slog</span><span class="p">.</span><span class="nf">Time</span><span class="p">(</span><span class="s">&#34;occurred_at&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">slog</span><span class="p">.</span><span class="nf">Time</span><span class="p">(</span><span class="s">&#34;received_at&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ReceivedAt</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="p">}</span></span></span></code></pre></div><p>adapter 可以額外記錄 HTTP path、queue name 或 timer name，但共同欄位應該來自 <code>DomainEvent</code>。這樣排查問題時，讀者不用先知道事件從哪個來源進來。</p>
<h2 id="測試融合測試要驗證同類事件走同一規則">【測試】融合測試要驗證同類事件走同一規則</h2>
<p>多來源測試的核心目標是確認不同 adapter 產生同一種 <code>DomainEvent</code>，並且 processor 對它們套用同一組規則。</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">TestHTTPAndQueueNormalizeToSameDomainEvent</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">receivedAt</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</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">httpEvent</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeHTTPEvent</span><span class="p">(</span><span class="nx">RawHTTPEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">EventID</span><span class="p">:</span>   <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">AccountID</span><span class="p">:</span> <span class="s">&#34;acct_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">EventName</span><span class="p">:</span> <span class="s">&#34;activated&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Timestamp</span><span class="p">:</span> <span class="s">&#34;2026-04-22T10:00:00Z&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">},</span> <span class="nx">receivedAt</span><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;normalize http event: %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="nx">queueEvent</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeQueueMessage</span><span class="p">([]</span><span class="nb">byte</span><span class="p">(</span><span class="s">`{
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">        &#34;id&#34;:&#34;evt_1&#34;,
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">        &#34;subject&#34;:&#34;acct_1&#34;,
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s">        &#34;type&#34;:&#34;account.activated&#34;,
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s">        &#34;occurred_at&#34;:&#34;2026-04-22T10:00:00Z&#34;
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="s">    }`</span><span class="p">),</span> <span class="nx">receivedAt</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</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">21</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;normalize queue event: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><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></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">if</span> <span class="nx">httpEvent</span><span class="p">.</span><span class="nx">Type</span> <span class="o">!=</span> <span class="nx">queueEvent</span><span class="p">.</span><span class="nx">Type</span> <span class="o">||</span> <span class="nx">httpEvent</span><span class="p">.</span><span class="nx">SubjectID</span> <span class="o">!=</span> <span class="nx">queueEvent</span><span class="p">.</span><span class="nx">SubjectID</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</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;sources should normalize to same domain semantics&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <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>這個測試不是要求兩個 event 完全相同。<code>Source</code> 可以不同；重點是 domain semantics 一致，processor 才能共用規則。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理多來源事件如何在單一服務內融合；queue driver、outbox 與 tracing，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Go 進階：Observability pipeline、metrics 與 tracing</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="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是多來源 adapter、normalize 與 processor 的路線；如果你要先回看語言教材，可以讀：</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-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</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/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>事件融合的核心是把來源差異限制在 adapter 與 normalizer，讓 processor 只面對一致的 <code>DomainEvent</code>。HTTP、queue、timer 可以有不同的 backpressure 與錯誤回應，但不應複製 domain 規則。當來源增加時，系統應該增加 adapter，而不是增加另一套狀態更新流程。</p>
]]></content:encoded></item><item><title>5.4 HTTP handler 測試</title><link>https://tarrragon.github.io/blog/go/05-error-testing/http-handler-test/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/05-error-testing/http-handler-test/</guid><description>&lt;p>HTTP handler 測試的核心規則是不用啟動真實 server，也能驗證 request 進入 handler 後產生的 response。&lt;code>net/http/httptest&lt;/code> 提供 request builder 與 response recorder，讓 handler 可以像普通函式一樣被測試。&lt;/p>
&lt;h2 id="httptest-把-http-測試變成函式呼叫">&lt;code>httptest&lt;/code> 把 HTTP 測試變成函式呼叫&lt;/h2>
&lt;p>&lt;code>httptest&lt;/code> 的核心用途是建立測試用 request 與 response writer。handler 本來就是 &lt;code>func(http.ResponseWriter, *http.Request)&lt;/code>，所以測試可以直接呼叫 handler。&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">handleHealth&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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">if&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Method&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MethodGet&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">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;method not allowed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusMethodNotAllowed&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>&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>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Header&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Content-Type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;application/json&amp;#34;&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fprint&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">`{&amp;#34;status&amp;#34;:&amp;#34;ok&amp;#34;}`&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 handler 可以不用啟動 port，也不用發出真實網路請求。&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">TestHandleHealth&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">req&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">httptest&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewRequest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MethodGet&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;/health&amp;#34;&lt;/span>&lt;span class="p">,&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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">rec&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">httptest&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewRecorder&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nf">handleHealth&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">rec&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">req&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">res&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">rec&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Result&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">defer&lt;/span> &lt;span class="nx">res&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Close&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>&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">res&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusCode&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusOK&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">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;status = %d, want %d&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">res&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusCode&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusOK&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>&lt;code>httptest.NewRequest&lt;/code> 建立 request，&lt;code>httptest.NewRecorder&lt;/code> 記錄 response。測試直接呼叫 &lt;code>handleHealth(rec, req)&lt;/code>，再檢查 recorder 產生的結果。&lt;/p>
&lt;h2 id="status-code-是第一個行為合約">status code 是第一個行為合約&lt;/h2>
&lt;p>HTTP response 的核心合約通常先看 status code。成功、輸入錯誤、方法不允許與伺服器錯誤，都應該有明確狀態碼。&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">TestHandleHealthMethodNotAllowed&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">req&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">httptest&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewRequest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MethodPost&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;/health&amp;#34;&lt;/span>&lt;span class="p">,&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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">rec&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">httptest&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewRecorder&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nf">handleHealth&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">rec&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">req&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">rec&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Code&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusMethodNotAllowed&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="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;status = %d, want %d&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">rec&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Code&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusMethodNotAllowed&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="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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>rec.Code&lt;/code> 可以直接取得 handler 寫出的狀態碼。若 handler 沒有呼叫 &lt;code>WriteHeader&lt;/code>，但有寫 body，狀態碼通常會是 &lt;code>200&lt;/code>。&lt;/p>
&lt;p>測試狀態碼時，不要只檢查 body 字串。body 可能改文案，但 status code 才是呼叫端最依賴的協定訊號。&lt;/p>
&lt;h2 id="body-檢查要符合輸出格式">body 檢查要符合輸出格式&lt;/h2>
&lt;p>response body 的核心檢查方式應該配合輸出格式。純文字可以比對字串；JSON 應該解析成 struct 或 map 後再比對欄位。&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">TestHandleHealthBody&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">req&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">httptest&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewRequest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MethodGet&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;/health&amp;#34;&lt;/span>&lt;span class="p">,&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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">rec&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">httptest&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewRecorder&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nf">handleHealth&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">rec&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">req&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">body&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="nx">Status&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;status&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&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">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">rec&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">body&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">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;decode response body: %v&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">13&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">body&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Status&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s">&amp;#34;ok&amp;#34;&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="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;status field = %q, want %q&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">body&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Status&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;ok&amp;#34;&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="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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>解析 JSON 後檢查欄位，比直接比對 &lt;code>{&amp;quot;status&amp;quot;:&amp;quot;ok&amp;quot;}&lt;/code> 更穩定。JSON 欄位順序、空白與換行不應該讓測試失敗。&lt;/p>
&lt;h2 id="request-body-可以用-stringsnewreader">request body 可以用 &lt;code>strings.NewReader&lt;/code>&lt;/h2>
&lt;p>測試 JSON request 的核心做法是把字串或 bytes 包成 reader。handler 看到的是 &lt;code>io.Reader&lt;/code>，不需要知道資料來自檔案、網路或測試字串。&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">TestHandleCreateUser&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">body&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">NewReader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">`{&amp;#34;name&amp;#34;:&amp;#34;Alice&amp;#34;,&amp;#34;email&amp;#34;:&amp;#34;alice@example.com&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="nx">req&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">httptest&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewRequest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MethodPost&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;/users&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">body&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">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Header&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Content-Type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;application/json&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">rec&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">httptest&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewRecorder&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="nx">handler&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">newCreateUserHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">fakeUserCreator&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">handler&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ServeHTTP&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">rec&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">req&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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">rec&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Code&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusCreated&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">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;status = %d, want %d&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">rec&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Code&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusCreated&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="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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>strings.NewReader&lt;/code> 讓測試資料留在測試檔中，適合小型 JSON。若 request 很大或要重複使用，可以把測試資料放在 &lt;code>testdata&lt;/code> 目錄。&lt;/p></description><content:encoded><![CDATA[<p>HTTP handler 測試的核心規則是不用啟動真實 server，也能驗證 request 進入 handler 後產生的 response。<code>net/http/httptest</code> 提供 request builder 與 response recorder，讓 handler 可以像普通函式一樣被測試。</p>
<h2 id="httptest-把-http-測試變成函式呼叫"><code>httptest</code> 把 HTTP 測試變成函式呼叫</h2>
<p><code>httptest</code> 的核心用途是建立測試用 request 與 response writer。handler 本來就是 <code>func(http.ResponseWriter, *http.Request)</code>，所以測試可以直接呼叫 handler。</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">handleHealth</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</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">r</span><span class="p">.</span><span class="nx">Method</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">MethodGet</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;method not allowed&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusMethodNotAllowed</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="k">return</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="nx">w</span><span class="p">.</span><span class="nf">Header</span><span class="p">().</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Content-Type&#34;</span><span class="p">,</span> <span class="s">&#34;application/json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprint</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">`{&#34;status&#34;:&#34;ok&#34;}`</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>這個 handler 可以不用啟動 port，也不用發出真實網路請求。</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">TestHandleHealth</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">req</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRequest</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">MethodGet</span><span class="p">,</span> <span class="s">&#34;/health&#34;</span><span class="p">,</span> <span class="kc">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">rec</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRecorder</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="nf">handleHealth</span><span class="p">(</span><span class="nx">rec</span><span class="p">,</span> <span class="nx">req</span><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="nx">res</span> <span class="o">:=</span> <span class="nx">rec</span><span class="p">.</span><span class="nf">Result</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">defer</span> <span class="nx">res</span><span class="p">.</span><span class="nx">Body</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></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="nx">res</span><span class="p">.</span><span class="nx">StatusCode</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusOK</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;status = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">res</span><span class="p">.</span><span class="nx">StatusCode</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusOK</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><code>httptest.NewRequest</code> 建立 request，<code>httptest.NewRecorder</code> 記錄 response。測試直接呼叫 <code>handleHealth(rec, req)</code>，再檢查 recorder 產生的結果。</p>
<h2 id="status-code-是第一個行為合約">status code 是第一個行為合約</h2>
<p>HTTP response 的核心合約通常先看 status code。成功、輸入錯誤、方法不允許與伺服器錯誤，都應該有明確狀態碼。</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">TestHandleHealthMethodNotAllowed</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">req</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRequest</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">MethodPost</span><span class="p">,</span> <span class="s">&#34;/health&#34;</span><span class="p">,</span> <span class="kc">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">rec</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRecorder</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="nf">handleHealth</span><span class="p">(</span><span class="nx">rec</span><span class="p">,</span> <span class="nx">req</span><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">rec</span><span class="p">.</span><span class="nx">Code</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusMethodNotAllowed</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</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;status = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusMethodNotAllowed</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="p">}</span></span></span></code></pre></div><p><code>rec.Code</code> 可以直接取得 handler 寫出的狀態碼。若 handler 沒有呼叫 <code>WriteHeader</code>，但有寫 body，狀態碼通常會是 <code>200</code>。</p>
<p>測試狀態碼時，不要只檢查 body 字串。body 可能改文案，但 status code 才是呼叫端最依賴的協定訊號。</p>
<h2 id="body-檢查要符合輸出格式">body 檢查要符合輸出格式</h2>
<p>response body 的核心檢查方式應該配合輸出格式。純文字可以比對字串；JSON 應該解析成 struct 或 map 後再比對欄位。</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">TestHandleHealthBody</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">req</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRequest</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">MethodGet</span><span class="p">,</span> <span class="s">&#34;/health&#34;</span><span class="p">,</span> <span class="kc">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">rec</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRecorder</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="nf">handleHealth</span><span class="p">(</span><span class="nx">rec</span><span class="p">,</span> <span class="nx">req</span><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">var</span> <span class="nx">body</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="nx">Status</span> <span class="kt">string</span> <span class="s">`json:&#34;status&#34;`</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></span><span class="line"><span class="ln">11</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">NewDecoder</span><span class="p">(</span><span class="nx">rec</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">body</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">12</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;decode response body: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">if</span> <span class="nx">body</span><span class="p">.</span><span class="nx">Status</span> <span class="o">!=</span> <span class="s">&#34;ok&#34;</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;status field = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">body</span><span class="p">.</span><span class="nx">Status</span><span class="p">,</span> <span class="s">&#34;ok&#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>解析 JSON 後檢查欄位，比直接比對 <code>{&quot;status&quot;:&quot;ok&quot;}</code> 更穩定。JSON 欄位順序、空白與換行不應該讓測試失敗。</p>
<h2 id="request-body-可以用-stringsnewreader">request body 可以用 <code>strings.NewReader</code></h2>
<p>測試 JSON request 的核心做法是把字串或 bytes 包成 reader。handler 看到的是 <code>io.Reader</code>，不需要知道資料來自檔案、網路或測試字串。</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">TestHandleCreateUser</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">body</span> <span class="o">:=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">NewReader</span><span class="p">(</span><span class="s">`{&#34;name&#34;:&#34;Alice&#34;,&#34;email&#34;:&#34;alice@example.com&#34;}`</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">req</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRequest</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">MethodPost</span><span class="p">,</span> <span class="s">&#34;/users&#34;</span><span class="p">,</span> <span class="nx">body</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">req</span><span class="p">.</span><span class="nx">Header</span><span class="p">.</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Content-Type&#34;</span><span class="p">,</span> <span class="s">&#34;application/json&#34;</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">rec</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRecorder</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">handler</span> <span class="o">:=</span> <span class="nf">newCreateUserHandler</span><span class="p">(</span><span class="nx">fakeUserCreator</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="nx">handler</span><span class="p">.</span><span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">rec</span><span class="p">,</span> <span class="nx">req</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</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;status = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>strings.NewReader</code> 讓測試資料留在測試檔中，適合小型 JSON。若 request 很大或要重複使用，可以把測試資料放在 <code>testdata</code> 目錄。</p>
<h2 id="依賴應該用-fake-隔離">依賴應該用 fake 隔離</h2>
<p>handler 測試的核心邊界是 HTTP 行為，不是資料庫或外部服務。若 handler 需要呼叫內部服務，可以提供 fake 實作，讓測試專注於 request/response。</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">fakeUserCreator</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">err</span> <span class="kt">error</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="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="nx">fakeUserCreator</span><span class="p">)</span> <span class="nf">CreateUser</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">name</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">email</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="kt">string</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="nx">f</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"> 8</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;&#34;</span><span class="p">,</span> <span class="nx">f</span><span class="p">.</span><span class="nx">err</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">return</span> <span class="nx">f</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>成功案例可以讓 fake 回傳 id，失敗案例可以讓 fake 回傳錯誤。這樣測試可以分別驗證 <code>201 Created</code> 與 <code>500 Internal Server Error</code>。</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">TestHandleCreateUserServiceError</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">req</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRequest</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">MethodPost</span><span class="p">,</span> <span class="s">&#34;/users&#34;</span><span class="p">,</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">NewReader</span><span class="p">(</span><span class="s">`{&#34;name&#34;:&#34;Alice&#34;,&#34;email&#34;:&#34;alice@example.com&#34;}`</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">rec</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRecorder</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">handler</span> <span class="o">:=</span> <span class="nf">newCreateUserHandler</span><span class="p">(</span><span class="nx">fakeUserCreator</span><span class="p">{</span><span class="nx">err</span><span class="p">:</span> <span class="nx">errors</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="s">&#34;database unavailable&#34;</span><span class="p">)})</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">handler</span><span class="p">.</span><span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">rec</span><span class="p">,</span> <span class="nx">req</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">if</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusInternalServerError</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</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;status = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusInternalServerError</span><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>這是 handler 單元測試。資料庫連線、<a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration</a>、真實網路等行為應該放在更高層級的整合測試處理。</p>
<h2 id="下一章">下一章</h2>
<p>下一章會處理時間注入，說明如何避免測試依賴真實現在時間。</p>
]]></content:encoded></item><item><title>5.4 table-driven test 的設計邊界</title><link>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/table-tests/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/table-tests/</guid><description>&lt;p>Table-driven test 的核心邊界是每張表只描述一個行為維度。它能降低重複並清楚列出案例，但不適合把多種 setup、多種執行方式與多種斷言硬塞進同一個測試。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷什麼行為適合 table-driven test&lt;/li>
&lt;li>設計欄位少、意圖清楚的測試表&lt;/li>
&lt;li>發現 table test 膨脹成迷你框架的訊號&lt;/li>
&lt;li>拆分 validation、repository error、integration flow&lt;/li>
&lt;li>寫出能定位失敗情境的子測試名稱&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察table-driven-test-很容易被濫用">【觀察】table-driven test 很容易被濫用&lt;/h2>
&lt;p>Table-driven test 的核心風險是「減少重複」被誤解成「所有案例都塞進一張表」。當表格開始同時控制 HTTP method、request body、repository 狀態、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> client、expected &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a>、expected event，測試就會變成難懂的迷你框架。&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="nx">tests&lt;/span> &lt;span class="o">:=&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"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&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">method&lt;/span> &lt;span class="kt">string&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">body&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">setupRepo&lt;/span> &lt;span class="kt">bool&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">setupClient&lt;/span> &lt;span class="kt">bool&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">queueFull&lt;/span> &lt;span class="kt">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">wantStatus&lt;/span> &lt;span class="kt">int&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">wantMessage&lt;/span> &lt;span class="kt">string&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">wantEvent&lt;/span> &lt;span class="kt">bool&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">wantLog&lt;/span> &lt;span class="kt">bool&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="c1">// many unrelated cases&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;/code>&lt;/pre>&lt;/div>&lt;p>這種表格看似統一，實際上混合了 HTTP validation、repository error、client &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> full、event emission、log assertion。讀者必須同時理解多個系統層，才能看懂單一案例。&lt;/p>
&lt;h2 id="判讀好表格描述同一個行為維度">【判讀】好表格描述同一個行為維度&lt;/h2>
&lt;p>好的 table-driven test 的核心特徵是所有案例共享相同 setup、相同執行方式、相同斷言方式。表格只改變資料，不改變測試流程。&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">TestNormalizeTopic&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">tests&lt;/span> &lt;span class="o">:=&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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span> &lt;span class="kt">string&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">input&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">want&lt;/span> &lt;span class="kt">string&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 class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;trim spaces&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34; alerts &amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;alerts&amp;#34;&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 class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;lowercase&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;ALERTS&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;alerts&amp;#34;&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="p">{&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;empty&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&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">10&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">tests&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">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">got&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NormalizeTopic&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">input&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="k">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">want&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="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;NormalizeTopic(%q) = %q, want %q&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">input&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">want&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="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="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 class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這張表只測 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> normalization。每個案例都只有 input 和 want，失敗時也能立刻看出是哪個 normalization 規則壞了。&lt;/p>
&lt;h2 id="策略表格欄位越多越要懷疑測試邊界">【策略】表格欄位越多，越要懷疑測試邊界&lt;/h2>
&lt;p>Table 欄位的核心警訊是大量欄位只被少數案例使用。這通常表示不同測試目的被合併在一起。&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>很多 &lt;code>setupX bool&lt;/code>&lt;/td>
 &lt;td>setup 不一致&lt;/td>
 &lt;td>拆成不同測試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>很多 &lt;code>wantX bool&lt;/code>&lt;/td>
 &lt;td>斷言目標不一致&lt;/td>
 &lt;td>拆成不同測試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>loop 內大量 &lt;code>if tt...&lt;/code>&lt;/td>
 &lt;td>測試流程不一致&lt;/td>
 &lt;td>拆表或改成具名測試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>案例名稱很長仍說不清&lt;/td>
 &lt;td>行為維度太多&lt;/td>
 &lt;td>回到單一行為&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>helper 隱藏主要斷言&lt;/td>
 &lt;td>可讀性下降&lt;/td>
 &lt;td>讓斷言留在測試本文&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>表格不是越通用越好。測試的第一責任是讓失敗可定位，不是消除所有重複。&lt;/p>
&lt;h2 id="執行validation-適合-table-test">【執行】validation 適合 table test&lt;/h2>
&lt;p>Validation 的核心特徵是輸入和輸出形狀一致，因此很適合 table-driven test。&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">TestValidateSubscribeRequest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">tests&lt;/span> &lt;span class="o">:=&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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span> &lt;span class="kt">string&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">request&lt;/span> &lt;span class="nx">SubscribeTopicRequest&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">wantErr&lt;/span> &lt;span class="kt">bool&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;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;valid topic&amp;#34;&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="nx">request&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">SubscribeTopicRequest&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;alerts&amp;#34;&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">wantErr&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">false&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="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="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;empty topic&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">request&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">SubscribeTopicRequest&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">:&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">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">wantErr&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&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;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;blank topic&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="nx">request&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">SubscribeTopicRequest&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">:&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">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">wantErr&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">tests&lt;/span> &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 class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">26&lt;/span>&lt;span class="cl"> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">ValidateSubscribeRequest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">request&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="k">if&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 class="o">!=&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">wantErr&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;error = %v, wantErr %v&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 class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">wantErr&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&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 是否有效。它不測 WebSocket connection、不測 hub、不測 repository，因此案例可以保持簡潔。&lt;/p></description><content:encoded><![CDATA[<p>Table-driven test 的核心邊界是每張表只描述一個行為維度。它能降低重複並清楚列出案例，但不適合把多種 setup、多種執行方式與多種斷言硬塞進同一個測試。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷什麼行為適合 table-driven test</li>
<li>設計欄位少、意圖清楚的測試表</li>
<li>發現 table test 膨脹成迷你框架的訊號</li>
<li>拆分 validation、repository error、integration flow</li>
<li>寫出能定位失敗情境的子測試名稱</li>
</ol>
<hr>
<h2 id="觀察table-driven-test-很容易被濫用">【觀察】table-driven test 很容易被濫用</h2>
<p>Table-driven test 的核心風險是「減少重複」被誤解成「所有案例都塞進一張表」。當表格開始同時控制 HTTP method、request body、repository 狀態、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> client、expected <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>、expected event，測試就會變成難懂的迷你框架。</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="nx">tests</span> <span class="o">:=</span> <span class="p">[]</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">name</span>          <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">method</span>        <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">body</span>          <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">setupRepo</span>     <span class="kt">bool</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">setupClient</span>   <span class="kt">bool</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">queueFull</span>     <span class="kt">bool</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">wantStatus</span>    <span class="kt">int</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">wantMessage</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">wantEvent</span>     <span class="kt">bool</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">wantLog</span>       <span class="kt">bool</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="c1">// many unrelated cases</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種表格看似統一，實際上混合了 HTTP validation、repository error、client <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> full、event emission、log assertion。讀者必須同時理解多個系統層，才能看懂單一案例。</p>
<h2 id="判讀好表格描述同一個行為維度">【判讀】好表格描述同一個行為維度</h2>
<p>好的 table-driven test 的核心特徵是所有案例共享相同 setup、相同執行方式、相同斷言方式。表格只改變資料，不改變測試流程。</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">TestNormalizeTopic</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">tests</span> <span class="o">:=</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">name</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">input</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">want</span>  <span class="kt">string</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 class="nx">name</span><span class="p">:</span> <span class="s">&#34;trim spaces&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34; alerts &#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;alerts&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;lowercase&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;ALERTS&#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;alerts&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;empty&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;&#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;&#34;</span><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></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</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">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</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">14</span><span class="cl">            <span class="nx">got</span> <span class="o">:=</span> <span class="nf">NormalizeTopic</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">input</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">got</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</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;NormalizeTopic(%q) = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">input</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</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><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></code></pre></div><p>這張表只測 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> normalization。每個案例都只有 input 和 want，失敗時也能立刻看出是哪個 normalization 規則壞了。</p>
<h2 id="策略表格欄位越多越要懷疑測試邊界">【策略】表格欄位越多，越要懷疑測試邊界</h2>
<p>Table 欄位的核心警訊是大量欄位只被少數案例使用。這通常表示不同測試目的被合併在一起。</p>
<p>拆分判斷：</p>
<table>
  <thead>
      <tr>
          <th>現象</th>
          <th>問題</th>
          <th>建議</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>很多 <code>setupX bool</code></td>
          <td>setup 不一致</td>
          <td>拆成不同測試</td>
      </tr>
      <tr>
          <td>很多 <code>wantX bool</code></td>
          <td>斷言目標不一致</td>
          <td>拆成不同測試</td>
      </tr>
      <tr>
          <td>loop 內大量 <code>if tt...</code></td>
          <td>測試流程不一致</td>
          <td>拆表或改成具名測試</td>
      </tr>
      <tr>
          <td>案例名稱很長仍說不清</td>
          <td>行為維度太多</td>
          <td>回到單一行為</td>
      </tr>
      <tr>
          <td>helper 隱藏主要斷言</td>
          <td>可讀性下降</td>
          <td>讓斷言留在測試本文</td>
      </tr>
  </tbody>
</table>
<p>表格不是越通用越好。測試的第一責任是讓失敗可定位，不是消除所有重複。</p>
<h2 id="執行validation-適合-table-test">【執行】validation 適合 table test</h2>
<p>Validation 的核心特徵是輸入和輸出形狀一致，因此很適合 table-driven test。</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">TestValidateSubscribeRequest</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">tests</span> <span class="o">:=</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">name</span>    <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">request</span> <span class="nx">SubscribeTopicRequest</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">wantErr</span> <span class="kt">bool</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><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="nx">name</span><span class="p">:</span>    <span class="s">&#34;valid topic&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="nx">request</span><span class="p">:</span> <span class="nx">SubscribeTopicRequest</span><span class="p">{</span><span class="nx">Topic</span><span class="p">:</span> <span class="s">&#34;alerts&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="nx">wantErr</span><span class="p">:</span> <span class="kc">false</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><span class="line"><span class="ln">13</span><span class="cl">            <span class="nx">name</span><span class="p">:</span>    <span class="s">&#34;empty topic&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nx">request</span><span class="p">:</span> <span class="nx">SubscribeTopicRequest</span><span class="p">{</span><span class="nx">Topic</span><span class="p">:</span> <span class="s">&#34;&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="nx">wantErr</span><span class="p">:</span> <span class="kc">true</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><span class="line"><span class="ln">18</span><span class="cl">            <span class="nx">name</span><span class="p">:</span>    <span class="s">&#34;blank topic&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="nx">request</span><span class="p">:</span> <span class="nx">SubscribeTopicRequest</span><span class="p">{</span><span class="nx">Topic</span><span class="p">:</span> <span class="s">&#34;   &#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="nx">wantErr</span><span class="p">:</span> <span class="kc">true</span><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></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</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">26</span><span class="cl">            <span class="nx">err</span> <span class="o">:=</span> <span class="nf">ValidateSubscribeRequest</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">request</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span><span class="p">)</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">wantErr</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">28</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;error = %v, wantErr %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">wantErr</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這張表只問一件事：request 是否有效。它不測 WebSocket connection、不測 hub、不測 repository，因此案例可以保持簡潔。</p>
<h2 id="執行狀態轉移也適合-table-test">【執行】狀態轉移也適合 table test</h2>
<p>狀態轉移的核心特徵是輸入狀態、事件、期待輸出狀態。只要流程一致，就適合 table-driven test。</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">TestJobTransition</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">tests</span> <span class="o">:=</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">name</span>    <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">current</span> <span class="nx">JobStatus</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">event</span>   <span class="nx">EventType</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">want</span>    <span class="nx">JobStatus</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">wantErr</span> <span class="kt">bool</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="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="nx">name</span><span class="p">:</span>    <span class="s">&#34;pending starts&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="nx">current</span><span class="p">:</span> <span class="nx">JobPending</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="nx">event</span><span class="p">:</span>   <span class="nx">EventJobStarted</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="nx">want</span><span class="p">:</span>    <span class="nx">JobRunning</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="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">            <span class="nx">name</span><span class="p">:</span>    <span class="s">&#34;running finishes&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">            <span class="nx">current</span><span class="p">:</span> <span class="nx">JobRunning</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="nx">event</span><span class="p">:</span>   <span class="nx">EventJobFinished</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="nx">want</span><span class="p">:</span>    <span class="nx">JobSucceeded</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="nx">name</span><span class="p">:</span>    <span class="s">&#34;finished cannot start again&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">            <span class="nx">current</span><span class="p">:</span> <span class="nx">JobSucceeded</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">            <span class="nx">event</span><span class="p">:</span>   <span class="nx">EventJobStarted</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">            <span class="nx">wantErr</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</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">31</span><span class="cl">            <span class="nx">got</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">Transition</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">current</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span><span class="p">)</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">wantErr</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">33</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;error = %v, wantErr %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">wantErr</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">35</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="o">&amp;&amp;</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">36</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;status = %s, want %s&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這張表的欄位都服務同一個行為：job status transition。若未來要測 repository 寫入失敗，應另開測試，不要塞進這張表。</p>
<h2 id="判讀不同-setup-應拆成不同測試">【判讀】不同 setup 應拆成不同測試</h2>
<p>測試拆分的核心原則是 setup 不同，通常就不是同一張表。HTTP validation、repository error、client queue full 都需要不同環境。</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">TestSubscribeValidation</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="c1">// 只測 request validation</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="nf">TestSubscribeAddsTopic</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"> 6</span><span class="cl">    <span class="c1">// 只測成功訂閱後 client state</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">func</span> <span class="nf">TestSubscribeReturnsErrorWhenClientQueueFull</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">10</span><span class="cl">    <span class="c1">// 只測 send buffer 滿載時的錯誤語意</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這些測試可能有少量重複，但每個測試的失敗原因更清楚。測試重複一點可以接受；測試意圖混在一起會讓維護成本更高。</p>
<h2 id="策略helper-只包樣板不包判斷">【策略】helper 只包樣板，不包判斷</h2>
<p>Test helper 的核心責任是降低重複 setup，不應隱藏主要斷言。讀者應能在測試本文看到這個測試到底在驗證什麼。</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">mustJSON</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="nx">value</span> <span class="kt">any</span><span class="p">)</span> <span class="nx">json</span><span class="p">.</span><span class="nx">RawMessage</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Helper</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">Marshal</span><span class="p">(</span><span class="nx">value</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</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">5</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;marshal json: %v&#34;</span><span class="p">,</span> <span class="nx">err</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="k">return</span> <span class="nx">data</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><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">assertSubscribeScenario</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="nx">tt</span> <span class="nx">subscribeScenario</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="c1">// setup HTTP, setup WebSocket, setup repository,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="c1">// execute action, check response, check logs, check events</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>後者把測試主要邏輯藏進 helper。表格看起來短，但讀者必須跳到 helper 才知道每個欄位如何影響流程。</p>
<h2 id="執行子測試名稱要描述情境">【執行】子測試名稱要描述情境</h2>
<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="nx">tests</span> <span class="o">:=</span> <span class="p">[]</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">name</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="c1">// ...</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 class="nx">name</span><span class="p">:</span> <span class="s">&#34;missing topic&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;unknown action&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;queue full returns unavailable&#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></code></pre></div><p><code>go test</code> 輸出會包含 <code>TestValidateSubscribeRequest/missing_topic</code> 這類資訊。當 CI 失敗時，讀者能先知道哪個情境壞了，再看 got/want 差異。</p>
<p>命名應該描述輸入情境或規則，不需要寫成完整句子，也不要只寫 <code>case 1</code>。</p>
<h2 id="測試table-test-本身也要保持可讀">【測試】table test 本身也要保持可讀</h2>
<p>Table-driven test 的核心完成標準是讀者能掃過表格就理解規則。若必須讀整個 loop 才懂欄位意義，表格設計就不夠清楚。</p>
<p>自檢問題：</p>
<ul>
<li>這張表是否只測一個行為？</li>
<li>每個欄位是否幾乎每個案例都用得到？</li>
<li>測試 loop 裡是否有大量條件分支？</li>
<li>子測試名稱是否能定位失敗情境？</li>
<li>got/want 斷言是否直接留在測試本文？</li>
</ul>
<p>任一題答否，先考慮拆測試，而不是加更多欄位。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一行為的多組資料案例；property-based、fuzz 與 snapshot 測試，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">Go 進階：CI、fuzz、load test 與 chaos testing</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 test case 設計、handler boundary 與 command 驗證；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go：testing 基礎</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/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/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證流程</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>Table-driven test 適合同一個行為的多組資料，不適合混合多種 setup 與斷言。欄位膨脹、loop 裡大量 <code>if tt...</code>、helper 隱藏主要判斷，都是拆表訊號。好的測試表讓案例更清楚，而不是把測試變成迷你框架。</p>
]]></content:encoded></item><item><title>6.4 如何新增背景工作流程</title><link>https://tarrragon.github.io/blog/go/06-practical/new-background-worker/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/06-practical/new-background-worker/</guid><description>&lt;p>新增背景工作流程的核心規則是先定義生命週期，再定義資料流。worker 是有 context、輸入、輸出、錯誤處理與 shutdown 協定的長期元件。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷一段工作是否適合做成 worker&lt;/li>
&lt;li>用 &lt;code>Run(ctx)&lt;/code> 設計 worker 生命週期&lt;/li>
&lt;li>用 channel 和 ticker 表達資料流與週期性工作&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、shutdown 與錯誤記錄&lt;/li>
&lt;li>分開測試 &lt;code>SyncOnce&lt;/code>、&lt;code>Run(ctx)&lt;/code> 與 channel 行為&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察worker-是有生命週期的元件">【觀察】worker 是有生命週期的元件&lt;/h2>
&lt;p>worker 的核心定義是長時間運行、可被啟動、可被取消、會消費輸入或定期執行工作的元件。任意程式碼包進 &lt;code>go func()&lt;/code> 只能產生背景 goroutine，還需要生命週期協定才會成為可維護的 worker。&lt;/p>
&lt;p>適合做成 worker 的工作通常有三種：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>工作類型&lt;/th>
 &lt;th>範例&lt;/th>
 &lt;th>worker 責任&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>queue &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a>&lt;/td>
 &lt;td>從 channel 讀取外部事件&lt;/td>
 &lt;td>驗證、轉送 processor&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>periodic task&lt;/td>
 &lt;td>每 30 秒同步一次外部狀態&lt;/td>
 &lt;td>產生 command 或 event&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>cleanup task&lt;/td>
 &lt;td>定期清理過期資料&lt;/td>
 &lt;td>呼叫 repository 或 usecase 的清理方法&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>本章使用「通知同步 worker」作為範例。它定期向外部來源取得通知更新，轉成 domain event，再交給 &lt;code>EventProcessor&lt;/code> 處理。&lt;/p>
&lt;h2 id="判讀worker-責任要先寫清楚">【判讀】worker 責任要先寫清楚&lt;/h2>
&lt;p>worker 責任的核心問題是它消費什麼、產生什麼、交給誰處理。worker 應聚焦在資料取得、格式轉換與轉交處理器，業務規則、狀態更新與 client 推送要留給對應的 usecase 或 processor。&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">type&lt;/span> &lt;span class="nx">NotificationSource&lt;/span> &lt;span class="kd">interface&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="nf">FetchUpdates&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="p">([]&lt;/span>&lt;span class="nx">RawNotificationUpdate&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"> 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">RawNotificationUpdate&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">ID&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">NotificationID&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="nx">Topic&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">Title&lt;/span> &lt;span class="kt">string&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">OccurredAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>再定義 worker 會呼叫的處理器：&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">EventProcessor&lt;/span> &lt;span class="kd">interface&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="nf">Process&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">event&lt;/span> &lt;span class="nx">DomainEvent&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&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;/code>&lt;/pre>&lt;/div>&lt;p>worker 的責任是把外部更新取回來、normalize 成 &lt;code>DomainEvent&lt;/code>、交給 processor。repository 寫入與推送規則仍然留在 processor 或 usecase 裡。&lt;/p>
&lt;h2 id="策略把單次工作獨立成-synconce">【策略】把單次工作獨立成 &lt;code>SyncOnce&lt;/code>&lt;/h2>
&lt;p>worker 的核心設計技巧是把「單次工作」和「長時間迴圈」分開。&lt;code>SyncOnce&lt;/code> 負責做一次同步，&lt;code>Run(ctx)&lt;/code> 負責週期性呼叫它。&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">SyncWorker&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">source&lt;/span> &lt;span class="nx">NotificationSource&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">processor&lt;/span> &lt;span class="nx">EventProcessor&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">logger&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">slog&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Logger&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewSyncWorker&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">source&lt;/span> &lt;span class="nx">NotificationSource&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">processor&lt;/span> &lt;span class="nx">EventProcessor&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">logger&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">slog&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Logger&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">SyncWorker&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="o">&amp;amp;&lt;/span>&lt;span class="nx">SyncWorker&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="nx">source&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">source&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">processor&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">processor&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">logger&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">logger&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>&lt;code>SyncOnce&lt;/code> 可以像普通函式一樣測試：&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">w&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">SyncWorker&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">SyncOnce&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="kt">error&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">updates&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">source&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">FetchUpdates&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&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">if&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"> 4&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;fetch notification updates: %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"> 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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">update&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">updates&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="nx">event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NormalizeNotificationUpdate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">update&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&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="kc">nil&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">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Warn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;skip invalid notification update&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">update&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;error&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">11&lt;/span>&lt;span class="cl"> &lt;span class="k">continue&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>&lt;/span>&lt;span class="line">&lt;span class="ln">14&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">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">processor&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Process&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">event&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">15&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;process notification update %s: %w&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">update&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&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">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;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&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>time.Now()&lt;/code> 先展示基本寫法；如果測試需要固定時間，可以把 clock 注入進 worker。時間注入會在後面測試章節更完整處理。&lt;/p>
&lt;h2 id="執行runctx-管理長時間生命週期">【執行】&lt;code>Run(ctx)&lt;/code> 管理長時間生命週期&lt;/h2>
&lt;p>&lt;code>Run(ctx)&lt;/code> 的核心責任是等待 ticker、呼叫單次工作、尊重取消訊號。它應該在 context 被取消時退出，並釋放 ticker。&lt;/p></description><content:encoded><![CDATA[<p>新增背景工作流程的核心規則是先定義生命週期，再定義資料流。worker 是有 context、輸入、輸出、錯誤處理與 shutdown 協定的長期元件。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷一段工作是否適合做成 worker</li>
<li>用 <code>Run(ctx)</code> 設計 worker 生命週期</li>
<li>用 channel 和 ticker 表達資料流與週期性工作</li>
<li>處理 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> full、shutdown 與錯誤記錄</li>
<li>分開測試 <code>SyncOnce</code>、<code>Run(ctx)</code> 與 channel 行為</li>
</ol>
<hr>
<h2 id="觀察worker-是有生命週期的元件">【觀察】worker 是有生命週期的元件</h2>
<p>worker 的核心定義是長時間運行、可被啟動、可被取消、會消費輸入或定期執行工作的元件。任意程式碼包進 <code>go func()</code> 只能產生背景 goroutine，還需要生命週期協定才會成為可維護的 worker。</p>
<p>適合做成 worker 的工作通常有三種：</p>
<table>
  <thead>
      <tr>
          <th>工作類型</th>
          <th>範例</th>
          <th>worker 責任</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>queue <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a></td>
          <td>從 channel 讀取外部事件</td>
          <td>驗證、轉送 processor</td>
      </tr>
      <tr>
          <td>periodic task</td>
          <td>每 30 秒同步一次外部狀態</td>
          <td>產生 command 或 event</td>
      </tr>
      <tr>
          <td>cleanup task</td>
          <td>定期清理過期資料</td>
          <td>呼叫 repository 或 usecase 的清理方法</td>
      </tr>
  </tbody>
</table>
<p>本章使用「通知同步 worker」作為範例。它定期向外部來源取得通知更新，轉成 domain event，再交給 <code>EventProcessor</code> 處理。</p>
<h2 id="判讀worker-責任要先寫清楚">【判讀】worker 責任要先寫清楚</h2>
<p>worker 責任的核心問題是它消費什麼、產生什麼、交給誰處理。worker 應聚焦在資料取得、格式轉換與轉交處理器，業務規則、狀態更新與 client 推送要留給對應的 usecase 或 processor。</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">type</span> <span class="nx">NotificationSource</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">FetchUpdates</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="p">([]</span><span class="nx">RawNotificationUpdate</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</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">RawNotificationUpdate</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">ID</span>             <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">NotificationID</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">Topic</span>          <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">Title</span>          <span class="kt">string</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">OccurredAt</span>     <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>再定義 worker 會呼叫的處理器：</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">EventProcessor</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">Process</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">event</span> <span class="nx">DomainEvent</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></code></pre></div><p>worker 的責任是把外部更新取回來、normalize 成 <code>DomainEvent</code>、交給 processor。repository 寫入與推送規則仍然留在 processor 或 usecase 裡。</p>
<h2 id="策略把單次工作獨立成-synconce">【策略】把單次工作獨立成 <code>SyncOnce</code></h2>
<p>worker 的核心設計技巧是把「單次工作」和「長時間迴圈」分開。<code>SyncOnce</code> 負責做一次同步，<code>Run(ctx)</code> 負責週期性呼叫它。</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">SyncWorker</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">source</span>    <span class="nx">NotificationSource</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">processor</span> <span class="nx">EventProcessor</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">logger</span>    <span class="o">*</span><span class="nx">slog</span><span class="p">.</span><span class="nx">Logger</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="nf">NewSyncWorker</span><span class="p">(</span><span class="nx">source</span> <span class="nx">NotificationSource</span><span class="p">,</span> <span class="nx">processor</span> <span class="nx">EventProcessor</span><span class="p">,</span> <span class="nx">logger</span> <span class="o">*</span><span class="nx">slog</span><span class="p">.</span><span class="nx">Logger</span><span class="p">)</span> <span class="o">*</span><span class="nx">SyncWorker</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="o">&amp;</span><span class="nx">SyncWorker</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">source</span><span class="p">:</span>    <span class="nx">source</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">processor</span><span class="p">:</span> <span class="nx">processor</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">logger</span><span class="p">:</span>    <span class="nx">logger</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><code>SyncOnce</code> 可以像普通函式一樣測試：</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">w</span> <span class="o">*</span><span class="nx">SyncWorker</span><span class="p">)</span> <span class="nf">SyncOnce</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="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">updates</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">w</span><span class="p">.</span><span class="nx">source</span><span class="p">.</span><span class="nf">FetchUpdates</span><span class="p">(</span><span class="nx">ctx</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="nx">err</span> <span class="o">!=</span> <span class="kc">nil</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="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;fetch notification updates: %w&#34;</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">update</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">updates</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">event</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeNotificationUpdate</span><span class="p">(</span><span class="nx">update</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</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">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">w</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;skip invalid notification update&#34;</span><span class="p">,</span> <span class="s">&#34;id&#34;</span><span class="p">,</span> <span class="nx">update</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span> <span class="s">&#34;error&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="k">continue</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="nx">err</span> <span class="o">:=</span> <span class="nx">w</span><span class="p">.</span><span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</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">15</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;process notification update %s: %w&#34;</span><span class="p">,</span> <span class="nx">update</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span> <span class="nx">err</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><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這裡的 <code>time.Now()</code> 先展示基本寫法；如果測試需要固定時間，可以把 clock 注入進 worker。時間注入會在後面測試章節更完整處理。</p>
<h2 id="執行runctx-管理長時間生命週期">【執行】<code>Run(ctx)</code> 管理長時間生命週期</h2>
<p><code>Run(ctx)</code> 的核心責任是等待 ticker、呼叫單次工作、尊重取消訊號。它應該在 context 被取消時退出，並釋放 ticker。</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">w</span> <span class="o">*</span><span class="nx">SyncWorker</span><span class="p">)</span> <span class="nf">Run</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">interval</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">)</span> <span class="kt">error</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">interval</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="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</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">ctx</span><span class="p">.</span><span class="nf">Err</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">ticker</span><span class="p">.</span><span class="nx">C</span><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="nx">w</span><span class="p">.</span><span class="nf">SyncOnce</span><span class="p">(</span><span class="nx">ctx</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="nx">w</span><span class="p">.</span><span class="nx">logger</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="s">&#34;sync worker failed&#34;</span><span class="p">,</span> <span class="s">&#34;error&#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 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="p">}</span></span></span></code></pre></div><p>這個版本把單次同步錯誤記錄下來，但不讓 worker 退出。這是策略選擇：若外部來源短暫失敗，worker 可以等待下一輪；若錯誤代表設定失效或授權失效，則可以選擇 return error 讓上層重啟或停止服務。</p>
<p>worker 錯誤策略應該明確。暫時性錯誤通常要記錄後等待下一輪；致命設定錯誤則可以回傳給上層，讓服務決定重啟或停止。</p>
<h2 id="判讀channel-worker-要設計-backpressure">【判讀】channel worker 要設計 backpressure</h2>
<p>channel worker 的核心問題是接收端跟不上時要怎麼辦。<a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 大小、blocking send、non-blocking send 都是在回答 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 策略。</p>
<p>假設外部 HTTP callback 會把 raw update 送進 worker queue：</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">QueueWorker</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">updates</span>   <span class="kd">chan</span> <span class="nx">RawNotificationUpdate</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">processor</span> <span class="nx">EventProcessor</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">logger</span>    <span class="o">*</span><span class="nx">slog</span><span class="p">.</span><span class="nx">Logger</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="nf">NewQueueWorker</span><span class="p">(</span><span class="nx">processor</span> <span class="nx">EventProcessor</span><span class="p">,</span> <span class="nx">logger</span> <span class="o">*</span><span class="nx">slog</span><span class="p">.</span><span class="nx">Logger</span><span class="p">,</span> <span class="nx">bufferSize</span> <span class="kt">int</span><span class="p">)</span> <span class="o">*</span><span class="nx">QueueWorker</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="o">&amp;</span><span class="nx">QueueWorker</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">updates</span><span class="p">:</span>   <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">RawNotificationUpdate</span><span class="p">,</span> <span class="nx">bufferSize</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">processor</span><span class="p">:</span> <span class="nx">processor</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">logger</span><span class="p">:</span>    <span class="nx">logger</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>送入 queue 可以選擇 blocking 或 non-blocking。若呼叫端不能被背景處理拖慢，可以用 non-blocking send 並回傳錯誤：</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">var</span> <span class="nx">ErrQueueFull</span> <span class="p">=</span> <span class="nx">errors</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="s">&#34;notification update queue full&#34;</span><span class="p">)</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">func</span> <span class="p">(</span><span class="nx">w</span> <span class="o">*</span><span class="nx">QueueWorker</span><span class="p">)</span> <span class="nf">Enqueue</span><span class="p">(</span><span class="nx">update</span> <span class="nx">RawNotificationUpdate</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">case</span> <span class="nx">w</span><span class="p">.</span><span class="nx">updates</span> <span class="o">&lt;-</span> <span class="nx">update</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">nil</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">default</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">ErrQueueFull</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></code></pre></div><p>這個設計很誠實：queue 滿了就是系統忙碌。上層可以記錄 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>、回 <code>503</code>，或告訴 client 稍後重試。</p>
<h2 id="執行queue-worker-要同時監聽輸入與取消">【執行】queue worker 要同時監聽輸入與取消</h2>
<p>queue worker 的核心生命週期是等待 update 或 context cancel。<code>Run(ctx)</code> 裡應用 <code>select</code> 同時處理這兩件事。</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">w</span> <span class="o">*</span><span class="nx">QueueWorker</span><span class="p">)</span> <span class="nf">Run</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="kt">error</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</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">ctx</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">case</span> <span class="nx">update</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">w</span><span class="p">.</span><span class="nx">updates</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</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"> 8</span><span class="cl">                <span class="k">return</span> <span class="kc">nil</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="nx">w</span><span class="p">.</span><span class="nf">handleUpdate</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">update</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="nx">w</span><span class="p">.</span><span class="nx">logger</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="s">&#34;handle notification update failed&#34;</span><span class="p">,</span> <span class="s">&#34;id&#34;</span><span class="p">,</span> <span class="nx">update</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span> <span class="s">&#34;error&#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 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="p">}</span></span></span></code></pre></div><p><code>handleUpdate</code> 負責單筆資料轉換與處理：</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">w</span> <span class="o">*</span><span class="nx">QueueWorker</span><span class="p">)</span> <span class="nf">handleUpdate</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">update</span> <span class="nx">RawNotificationUpdate</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">event</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeNotificationUpdate</span><span class="p">(</span><span class="nx">update</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</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="nx">err</span> <span class="o">!=</span> <span class="kc">nil</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="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;normalize update: %w&#34;</span><span class="p">,</span> <span class="nx">err</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="k">return</span> <span class="nx">w</span><span class="p">.</span><span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><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>這裡仍然遵守同一條邊界：worker 不直接改 repository，只把事件交給 processor。</p>
<h2 id="策略shutdown-是否-drain-queue-要先決定">【策略】shutdown 是否 drain queue 要先決定</h2>
<p>shutdown 的核心決策是取消時要立刻停止，還是處理完 queue 中既有資料。兩種策略都合理，但語意不同。</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>做法</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>立即停止</td>
          <td>收到 <code>ctx.Done()</code> 就 return</td>
          <td>即時通知、可重試資料</td>
      </tr>
      <tr>
          <td>drain queue</td>
          <td>停止接收新資料，處理已排隊資料</td>
          <td>不可輕易丟棄的資料</td>
      </tr>
  </tbody>
</table>
<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="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">ctx</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span></span></span></code></pre></div><p>drain queue 則需要另一個停止接收的協定，例如由擁有送出端的一方關閉 channel，再讓 worker range 到 channel 關閉。channel close 的所有權要留在送出端，因為送出端最清楚是否還會送資料。</p>
<p>這裡的核心區分是「取消 context」和「關閉 channel」代表不同訊號。context 表示這件工作該停了；channel close 表示不會再有新資料。兩者可以搭配，但語意不同。</p>
<h2 id="判讀worker-使用服務生命週期-context">【判讀】worker 使用服務生命週期 context</h2>
<p>worker context 的核心規則是：長時間 worker 使用服務生命週期 context，單次工作可以另外接收 request context。某個 HTTP request 的 context 只適合控制該次請求；request 結束後 context 會被取消。</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">w</span> <span class="o">*</span><span class="nx">QueueWorker</span><span class="p">)</span> <span class="nf">Enqueue</span><span class="p">(</span><span class="nx">update</span> <span class="nx">RawNotificationUpdate</span><span class="p">)</span> <span class="kt">error</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">w</span><span class="p">.</span><span class="nx">updates</span> <span class="o">&lt;-</span> <span class="nx">update</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">nil</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="nx">ErrQueueFull</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>Enqueue</code> 不把 request context 存起來。真正處理 update 時，worker 使用自己的 <code>Run(ctx)</code> context 控制生命週期。</p>
<p>若某筆 update 需要保留 <a href="/blog/backend/knowledge-cards/request-id/" data-link-title="Request ID" data-link-desc="說明單次 request 的識別碼如何支援 log 搜尋與問題定位">request ID</a> 或 <a href="/blog/backend/knowledge-cards/correlation-id/" data-link-title="Correlation ID" data-link-desc="說明跨事件或跨服務的關聯識別碼如何支援排障">correlation ID</a>，應把它放進明確欄位，而不是依賴 context value 在背景工作中長期存在。</p>
<h2 id="執行synconce-測試要隔離時間與外部來源">【執行】<code>SyncOnce</code> 測試要隔離時間與外部來源</h2>
<p><code>SyncOnce</code> 測試的核心目標是確認單次工作會把外部資料交給 processor。測試重點放在單次同步，不需要等待真實 ticker。</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">fakeNotificationSource</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">updates</span> <span class="p">[]</span><span class="nx">RawNotificationUpdate</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">err</span>     <span class="kt">error</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="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="nx">fakeNotificationSource</span><span class="p">)</span> <span class="nf">FetchUpdates</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="p">([]</span><span class="nx">RawNotificationUpdate</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="nx">f</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"> 8</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span><span class="p">,</span> <span class="nx">f</span><span class="p">.</span><span class="nx">err</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">return</span> <span class="nx">f</span><span class="p">.</span><span class="nx">updates</span><span class="p">,</span> <span class="kc">nil</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="kd">type</span> <span class="nx">recordingProcessor</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">events</span> <span class="p">[]</span><span class="nx">DomainEvent</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="kd">func</span> <span class="p">(</span><span class="nx">p</span> <span class="o">*</span><span class="nx">recordingProcessor</span><span class="p">)</span> <span class="nf">Process</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">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">p</span><span class="p">.</span><span class="nx">events</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">p</span><span class="p">.</span><span class="nx">events</span><span class="p">,</span> <span class="nx">event</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">nil</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><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">TestSyncWorkerSyncOnce</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">source</span> <span class="o">:=</span> <span class="nx">fakeNotificationSource</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">updates</span><span class="p">:</span> <span class="p">[]</span><span class="nx">RawNotificationUpdate</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="nx">ID</span><span class="p">:</span>             <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">                <span class="nx">NotificationID</span><span class="p">:</span> <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">                <span class="nx">Topic</span><span class="p">:</span>          <span class="s">&#34;deployments&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                <span class="nx">Title</span><span class="p">:</span>          <span class="s">&#34;Deploy finished&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">                <span class="nx">OccurredAt</span><span class="p">:</span>     <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><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><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="nx">processor</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">recordingProcessor</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">worker</span> <span class="o">:=</span> <span class="nf">NewSyncWorker</span><span class="p">(</span><span class="nx">source</span><span class="p">,</span> <span class="nx">processor</span><span class="p">,</span> <span class="nx">slog</span><span class="p">.</span><span class="nf">Default</span><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">worker</span><span class="p">.</span><span class="nf">SyncOnce</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">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">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;sync once: %v&#34;</span><span class="p">,</span> <span class="nx">err</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="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">processor</span><span class="p">.</span><span class="nx">events</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</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;processed events = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">processor</span><span class="p">.</span><span class="nx">events</span><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>這個測試不需要 goroutine。先把單次工作測清楚，再測長時間生命週期。</p>
<h2 id="執行runctx-測試要能快速取消">【執行】<code>Run(ctx)</code> 測試要能快速取消</h2>
<p><code>Run(ctx)</code> 測試的核心目標是確認 worker 會尊重取消訊號。測試應該使用已取消或很短的 context，讓測試快速結束。</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">TestSyncWorkerRunStopsWhenContextCanceled</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">source</span> <span class="o">:=</span> <span class="nx">fakeNotificationSource</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">processor</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">recordingProcessor</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">worker</span> <span class="o">:=</span> <span class="nf">NewSyncWorker</span><span class="p">(</span><span class="nx">source</span><span class="p">,</span> <span class="nx">processor</span><span class="p">,</span> <span class="nx">slog</span><span class="p">.</span><span class="nf">Default</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">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithCancel</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nf">cancel</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="nx">err</span> <span class="o">:=</span> <span class="nx">worker</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Hour</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">errors</span><span class="p">.</span><span class="nf">Is</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Canceled</span><span class="p">)</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;error = %v, want context.Canceled&#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 class="p">}</span></span></span></code></pre></div><p>這個測試用 <code>time.Hour</code> 當 interval，因為 context 已經取消，<code>Run</code> 應該立刻退出，不需要等 ticker。</p>
<h2 id="執行queue-full-測試要固定-buffer">【執行】queue full 測試要固定 buffer</h2>
<p>queue full 測試的核心目標是確認 backpressure 策略。buffer 設成 1，先塞滿，再確認第二次 enqueue 回錯。</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">TestQueueWorkerEnqueueFull</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">worker</span> <span class="o">:=</span> <span class="nf">NewQueueWorker</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">recordingProcessor</span><span class="p">{},</span> <span class="nx">slog</span><span class="p">.</span><span class="nf">Default</span><span class="p">(),</span> <span class="mi">1</span><span class="p">)</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">err</span> <span class="o">:=</span> <span class="nx">worker</span><span class="p">.</span><span class="nf">Enqueue</span><span class="p">(</span><span class="nx">RawNotificationUpdate</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;evt_1&#34;</span><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">err</span> <span class="o">!=</span> <span class="kc">nil</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;first enqueue: %v&#34;</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">err</span> <span class="p">=</span> <span class="nx">worker</span><span class="p">.</span><span class="nf">Enqueue</span><span class="p">(</span><span class="nx">RawNotificationUpdate</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;evt_2&#34;</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">errors</span><span class="p">.</span><span class="nf">Is</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">ErrQueueFull</span><span class="p">)</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;error = %v, want ErrQueueFull&#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 class="p">}</span></span></span></code></pre></div><p>這個測試不啟動 worker，所以 channel 裡第一筆資料不會被消費，第二筆必然遇到 full。這比用 sleep 製造滿載狀態穩定。</p>
<h2 id="實作檢查清單">實作檢查清單</h2>
<p>新增 background worker 時，可以依序檢查：</p>
<ol>
<li>worker 責任是否明確：消費什麼，產生什麼，交給誰</li>
<li>是否有 <code>Run(ctx)</code> 作為生命週期入口</li>
<li>單次工作是否拆成 <code>SyncOnce</code> 或 <code>handleUpdate</code></li>
<li>worker 是否尊重 <code>ctx.Done()</code></li>
<li>ticker 是否 <code>defer Stop()</code></li>
<li>channel buffer 是否有明確 backpressure 策略</li>
<li>queue full 是否回錯或記錄，而不是靜默丟棄</li>
<li>worker 是否呼叫 usecase/processor，而不是直接改 repository</li>
<li>測試是否避免真實長時間 sleep</li>
</ol>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一goroutine-要有停止條件">檢查一：goroutine 要有停止條件</h3>
<p>長時間 goroutine 需要 context、channel close 或其他退出條件。缺少停止條件時，服務運行越久，越容易累積難以診斷的資源問題。</p>
<h3 id="檢查二worker-透過-processor-或-usecase-修改狀態">檢查二：worker 透過 processor 或 usecase 修改狀態</h3>
<p>worker 透過 processor 或 usecase 修改狀態，可以讓背景流程和即時流程共用同一套規則。worker 直接改 repository 時，狀態規則容易分散。</p>
<h3 id="檢查三queue-full-要有明確策略">檢查三：queue full 要有明確策略</h3>
<p>資料可以丟時，應明確記錄 log 或 metric；資料需要保留時，應 blocking 或回錯。queue full 策略明確，後續追蹤才有依據。</p>
<h3 id="檢查四測試隔離真實時間">檢查四：測試隔離真實時間</h3>
<p>測試優先測 <code>SyncOnce</code>，再用已取消 context 測 <code>Run</code> 的退出行為。真實 ticker 等待數秒會讓測試慢且不穩。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一背景工作如何啟動、停止與回報；cron、queue、retry 與 outbox，會在下列章節再往外延伸：</p>
<ul>
<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/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">Go 進階：select loop 的生命週期設計</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</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="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 goroutine 生命週期與 event processing；如果你要先回看語言教材，可以讀：</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/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件</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/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">Go：如何新增一種 domain event</a></li>
</ul>
]]></content:encoded></item><item><title>6.4 版本偵測與 feature gate</title><link>https://tarrragon.github.io/blog/go-advanced/06-production-operations/feature-gate/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/06-production-operations/feature-gate/</guid><description>&lt;p>Feature gate 的核心目標是在外部能力、部署環境或版本不同時，讓服務保留可預期行為。它明確管理功能何時啟用、關閉時如何降級、錯誤時如何回報。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>用 config struct 集中載入 feature gate&lt;/li>
&lt;li>把外部版本偵測轉成 capability&lt;/li>
&lt;li>為 gate 關閉時定義降級、回錯或延後處理策略&lt;/li>
&lt;li>避免在程式各處直接讀環境變數&lt;/li>
&lt;li>同時測試 feature 開與關兩條路徑&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察新功能上線需要可控行為">【觀察】新功能上線需要可控行為&lt;/h2>
&lt;p>Feature gate 的核心需求來自生產環境差異。新功能可能只在部分部署環境可用，外部依賴可能版本不同，某些診斷入口只應在內網啟用，某些即時能力需要先灰度。&lt;/p>
&lt;p>沒有 gate 時常見問題：&lt;/p>
&lt;ul>
&lt;li>新功能只能一次性全開或全關。&lt;/li>
&lt;li>部署環境不支援時服務直接失敗。&lt;/li>
&lt;li>測試只能覆蓋預設路徑。&lt;/li>
&lt;li>問題發生時無法快速降級。&lt;/li>
&lt;li>程式各處用環境變數判斷，行為難以推理。&lt;/li>
&lt;/ul>
&lt;p>Feature gate 的目的是讓行為決策集中、可測、可回滾。&lt;/p>
&lt;h2 id="判讀feature-gate-是行為合約">【判讀】feature gate 是行為合約&lt;/h2>
&lt;p>Feature gate 的核心語意是控制某段行為是否啟用，以及未啟用時系統要做什麼。它不只是 &lt;code>if&lt;/code>，而是一個操作合約。&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">Features&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">RealtimePush&lt;/span> &lt;span class="kt">bool&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">Diagnostics&lt;/span> &lt;span class="kt">bool&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">Pprof&lt;/span> &lt;span class="kt">bool&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>RealtimePush&lt;/code> 比 &lt;code>NewCode&lt;/code> 更能長期維護；&lt;code>Diagnostics&lt;/code> 比 &lt;code>DebugStuff&lt;/code> 更清楚。&lt;/p>
&lt;p>Gate 應在應用啟動時集中載入，再傳給需要的元件。不要在程式各處反覆直接讀環境變數，否則測試與推理都會變困難。&lt;/p>
&lt;h2 id="執行集中載入-feature-config">【執行】集中載入 feature config&lt;/h2>
&lt;p>Feature config 的核心責任是把環境變數、設定檔或啟動參數轉成明確資料。&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">LoadFeaturesFromEnv&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">Features&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">return&lt;/span> &lt;span class="nx">Features&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">RealtimePush&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Getenv&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;FEATURE_REALTIME_PUSH&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;1&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="nx">Diagnostics&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Getenv&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;APP_DIAGNOSTICS&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;1&amp;#34;&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="nx">Pprof&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Getenv&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;APP_PPROF&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;1&amp;#34;&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>組裝時傳入元件：&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">main&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">features&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">LoadFeaturesFromEnv&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">mux&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewServeMux&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="nf">RegisterDiagnostics&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">mux&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">features&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Diagnostics&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">publisher&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewPublisher&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">PublisherConfig&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="nx">RealtimeEnabled&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">features&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RealtimePush&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="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">publisher&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>這樣功能測試可以直接建構 &lt;code>Features&lt;/code>，不必依賴全域環境變數。環境變數解析只需要在 &lt;code>LoadFeaturesFromEnv&lt;/code> 的測試中覆蓋。&lt;/p>
&lt;h2 id="判讀版本偵測要轉成能力">【判讀】版本偵測要轉成能力&lt;/h2>
&lt;p>版本偵測的核心原則是不要讓整個程式到處比較版本字串。應把外部版本轉成 capability，內部只判斷能力。&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">Capabilities&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">SupportsStreaming&lt;/span> &lt;span class="kt">bool&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">SupportsMetadata&lt;/span> &lt;span class="kt">bool&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="kd">func&lt;/span> &lt;span class="nf">DetectCapabilities&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">version&lt;/span> &lt;span class="nx">semver&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Version&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">Capabilities&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">return&lt;/span> &lt;span class="nx">Capabilities&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="nx">SupportsStreaming&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">version&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">GTE&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">semver&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">MustParse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;2.0.0&amp;#34;&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="nx">SupportsMetadata&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">version&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">GTE&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">semver&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">MustParse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;2.1.0&amp;#34;&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="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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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="k">if&lt;/span> &lt;span class="nx">caps&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SupportsStreaming&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">return&lt;/span> &lt;span class="nf">useStreaming&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&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="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="k">return&lt;/span> &lt;span class="nf">usePolling&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這比到處寫 &lt;code>if version &amp;gt;= ...&lt;/code> 更清楚，也更容易測試。版本字串是外部事實，capability 是內部行為判斷。&lt;/p>
&lt;h2 id="策略gate-關閉時要有降級策略">【策略】gate 關閉時要有降級策略&lt;/h2>
&lt;p>Feature gate 的核心問題是關閉時要做什麼。常見策略包括降級、回錯、隱藏入口、排程稍後處理。&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>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fallback/" data-link-title="Fallback" data-link-desc="說明主要路徑失敗時使用替代結果或替代流程的設計責任">fallback&lt;/a>&lt;/td>
 &lt;td>使用舊流程&lt;/td>
 &lt;td>新能力只是效率改善&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>reject&lt;/td>
 &lt;td>回明確錯誤&lt;/td>
 &lt;td>功能沒有安全替代方案&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>hide&lt;/td>
 &lt;td>不註冊 endpoint 或不顯示入口&lt;/td>
 &lt;td>使用者不應看到該功能&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>store for later&lt;/td>
 &lt;td>先保存，稍後處理&lt;/td>
 &lt;td>即時能力暫不可用但資料不能丟&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&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">p&lt;/span> &lt;span class="nx">Publisher&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Publish&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">event&lt;/span> &lt;span class="nx">DomainEvent&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">2&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">realtimeEnabled&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">return&lt;/span> &lt;span class="nx">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">realtime&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Publish&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">event&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="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="k">return&lt;/span> &lt;span class="nx">p&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">repository&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">SaveForLater&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">event&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>降級策略要符合資料語意。不能即時送出不代表可以直接丟掉重要事件。&lt;/p>
&lt;h2 id="執行http-endpoint-可用-gate-控制註冊或行為">【執行】HTTP endpoint 可用 gate 控制註冊或行為&lt;/h2>
&lt;p>HTTP feature gate 的核心選擇是「不註冊 endpoint」或「註冊但回明確錯誤」。兩者語意不同。&lt;/p>
&lt;p>不註冊 endpoint：&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="k">if&lt;/span> &lt;span class="nx">features&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Diagnostics&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="nf">RegisterDiagnostics&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">mux&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">true&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>適合診斷入口、內部工具或不希望使用者看見的功能。&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">HandleRealtimeExport&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">features&lt;/span> &lt;span class="nx">Features&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">HandlerFunc&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">return&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">features&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RealtimePush&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">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;realtime export is disabled&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusNotImplemented&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>&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="nf">startRealtimeExport&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&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="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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>適合公開 API，讓呼叫端知道功能存在但目前不可用。&lt;/p>
&lt;h2 id="策略gate-不應散落成巢狀-if">【策略】gate 不應散落成巢狀 if&lt;/h2>
&lt;p>Feature gate 的核心維護風險是判斷散落在多層呼叫中，最後沒人知道功能到底何時啟用。&lt;/p></description><content:encoded><![CDATA[<p>Feature gate 的核心目標是在外部能力、部署環境或版本不同時，讓服務保留可預期行為。它明確管理功能何時啟用、關閉時如何降級、錯誤時如何回報。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>用 config struct 集中載入 feature gate</li>
<li>把外部版本偵測轉成 capability</li>
<li>為 gate 關閉時定義降級、回錯或延後處理策略</li>
<li>避免在程式各處直接讀環境變數</li>
<li>同時測試 feature 開與關兩條路徑</li>
</ol>
<hr>
<h2 id="觀察新功能上線需要可控行為">【觀察】新功能上線需要可控行為</h2>
<p>Feature gate 的核心需求來自生產環境差異。新功能可能只在部分部署環境可用，外部依賴可能版本不同，某些診斷入口只應在內網啟用，某些即時能力需要先灰度。</p>
<p>沒有 gate 時常見問題：</p>
<ul>
<li>新功能只能一次性全開或全關。</li>
<li>部署環境不支援時服務直接失敗。</li>
<li>測試只能覆蓋預設路徑。</li>
<li>問題發生時無法快速降級。</li>
<li>程式各處用環境變數判斷，行為難以推理。</li>
</ul>
<p>Feature gate 的目的是讓行為決策集中、可測、可回滾。</p>
<h2 id="判讀feature-gate-是行為合約">【判讀】feature gate 是行為合約</h2>
<p>Feature gate 的核心語意是控制某段行為是否啟用，以及未啟用時系統要做什麼。它不只是 <code>if</code>，而是一個操作合約。</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">Features</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">RealtimePush</span> <span class="kt">bool</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Diagnostics</span>  <span class="kt">bool</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Pprof</span>        <span class="kt">bool</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>RealtimePush</code> 比 <code>NewCode</code> 更能長期維護；<code>Diagnostics</code> 比 <code>DebugStuff</code> 更清楚。</p>
<p>Gate 應在應用啟動時集中載入，再傳給需要的元件。不要在程式各處反覆直接讀環境變數，否則測試與推理都會變困難。</p>
<h2 id="執行集中載入-feature-config">【執行】集中載入 feature config</h2>
<p>Feature config 的核心責任是把環境變數、設定檔或啟動參數轉成明確資料。</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">LoadFeaturesFromEnv</span><span class="p">()</span> <span class="nx">Features</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">Features</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="nx">RealtimePush</span><span class="p">:</span> <span class="nx">os</span><span class="p">.</span><span class="nf">Getenv</span><span class="p">(</span><span class="s">&#34;FEATURE_REALTIME_PUSH&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">Diagnostics</span><span class="p">:</span>  <span class="nx">os</span><span class="p">.</span><span class="nf">Getenv</span><span class="p">(</span><span class="s">&#34;APP_DIAGNOSTICS&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="nx">Pprof</span><span class="p">:</span>        <span class="nx">os</span><span class="p">.</span><span class="nf">Getenv</span><span class="p">(</span><span class="s">&#34;APP_PPROF&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;1&#34;</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>組裝時傳入元件：</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">main</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">features</span> <span class="o">:=</span> <span class="nf">LoadFeaturesFromEnv</span><span class="p">()</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">mux</span> <span class="o">:=</span> <span class="nx">http</span><span class="p">.</span><span class="nf">NewServeMux</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nf">RegisterDiagnostics</span><span class="p">(</span><span class="nx">mux</span><span class="p">,</span> <span class="nx">features</span><span class="p">.</span><span class="nx">Diagnostics</span><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="nx">publisher</span> <span class="o">:=</span> <span class="nf">NewPublisher</span><span class="p">(</span><span class="nx">PublisherConfig</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">RealtimeEnabled</span><span class="p">:</span> <span class="nx">features</span><span class="p">.</span><span class="nx">RealtimePush</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">publisher</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這樣功能測試可以直接建構 <code>Features</code>，不必依賴全域環境變數。環境變數解析只需要在 <code>LoadFeaturesFromEnv</code> 的測試中覆蓋。</p>
<h2 id="判讀版本偵測要轉成能力">【判讀】版本偵測要轉成能力</h2>
<p>版本偵測的核心原則是不要讓整個程式到處比較版本字串。應把外部版本轉成 capability，內部只判斷能力。</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">Capabilities</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">SupportsStreaming</span> <span class="kt">bool</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">SupportsMetadata</span>  <span class="kt">bool</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="kd">func</span> <span class="nf">DetectCapabilities</span><span class="p">(</span><span class="nx">version</span> <span class="nx">semver</span><span class="p">.</span><span class="nx">Version</span><span class="p">)</span> <span class="nx">Capabilities</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="nx">Capabilities</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">SupportsStreaming</span><span class="p">:</span> <span class="nx">version</span><span class="p">.</span><span class="nf">GTE</span><span class="p">(</span><span class="nx">semver</span><span class="p">.</span><span class="nf">MustParse</span><span class="p">(</span><span class="s">&#34;2.0.0&#34;</span><span class="p">)),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">SupportsMetadata</span><span class="p">:</span>  <span class="nx">version</span><span class="p">.</span><span class="nf">GTE</span><span class="p">(</span><span class="nx">semver</span><span class="p">.</span><span class="nf">MustParse</span><span class="p">(</span><span class="s">&#34;2.1.0&#34;</span><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>內部程式應寫成：</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="k">if</span> <span class="nx">caps</span><span class="p">.</span><span class="nx">SupportsStreaming</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nf">useStreaming</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</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="k">return</span> <span class="nf">usePolling</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span></span></span></code></pre></div><p>這比到處寫 <code>if version &gt;= ...</code> 更清楚，也更容易測試。版本字串是外部事實，capability 是內部行為判斷。</p>
<h2 id="策略gate-關閉時要有降級策略">【策略】gate 關閉時要有降級策略</h2>
<p>Feature gate 的核心問題是關閉時要做什麼。常見策略包括降級、回錯、隱藏入口、排程稍後處理。</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>行為</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/fallback/" data-link-title="Fallback" data-link-desc="說明主要路徑失敗時使用替代結果或替代流程的設計責任">fallback</a></td>
          <td>使用舊流程</td>
          <td>新能力只是效率改善</td>
      </tr>
      <tr>
          <td>reject</td>
          <td>回明確錯誤</td>
          <td>功能沒有安全替代方案</td>
      </tr>
      <tr>
          <td>hide</td>
          <td>不註冊 endpoint 或不顯示入口</td>
          <td>使用者不應看到該功能</td>
      </tr>
      <tr>
          <td>store for later</td>
          <td>先保存，稍後處理</td>
          <td>即時能力暫不可用但資料不能丟</td>
      </tr>
  </tbody>
</table>
<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">p</span> <span class="nx">Publisher</span><span class="p">)</span> <span class="nf">Publish</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">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</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">p</span><span class="p">.</span><span class="nx">realtimeEnabled</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">return</span> <span class="nx">p</span><span class="p">.</span><span class="nx">realtime</span><span class="p">.</span><span class="nf">Publish</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</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></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="k">return</span> <span class="nx">p</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">SaveForLater</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</span><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>降級策略要符合資料語意。不能即時送出不代表可以直接丟掉重要事件。</p>
<h2 id="執行http-endpoint-可用-gate-控制註冊或行為">【執行】HTTP endpoint 可用 gate 控制註冊或行為</h2>
<p>HTTP feature gate 的核心選擇是「不註冊 endpoint」或「註冊但回明確錯誤」。兩者語意不同。</p>
<p>不註冊 endpoint：</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="k">if</span> <span class="nx">features</span><span class="p">.</span><span class="nx">Diagnostics</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nf">RegisterDiagnostics</span><span class="p">(</span><span class="nx">mux</span><span class="p">,</span> <span class="kc">true</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>適合診斷入口、內部工具或不希望使用者看見的功能。</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">HandleRealtimeExport</span><span class="p">(</span><span class="nx">features</span> <span class="nx">Features</span><span class="p">)</span> <span class="nx">http</span><span class="p">.</span><span class="nx">HandlerFunc</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">return</span> <span class="kd">func</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</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">if</span> <span class="p">!</span><span class="nx">features</span><span class="p">.</span><span class="nx">RealtimePush</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;realtime export is disabled&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusNotImplemented</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="k">return</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="nf">startRealtimeExport</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">r</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="p">}</span></span></span></code></pre></div><p>適合公開 API，讓呼叫端知道功能存在但目前不可用。</p>
<h2 id="策略gate-不應散落成巢狀-if">【策略】gate 不應散落成巢狀 if</h2>
<p>Feature gate 的核心維護風險是判斷散落在多層呼叫中，最後沒人知道功能到底何時啟用。</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="k">if</span> <span class="nx">os</span><span class="p">.</span><span class="nf">Getenv</span><span class="p">(</span><span class="s">&#34;FEATURE_REALTIME_PUSH&#34;</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;1&#34;</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">version</span> <span class="o">&gt;=</span> <span class="s">&#34;2.0.0&#34;</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="nx">user</span><span class="p">.</span><span class="nx">Enabled</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">            <span class="c1">// ...</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 class="p">}</span></span></span></code></pre></div><p>較清楚的做法是先組出 decision：</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">RealtimeDecision</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">Enabled</span> <span class="kt">bool</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Reason</span>  <span class="kt">string</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="kd">func</span> <span class="nf">DecideRealtime</span><span class="p">(</span><span class="nx">features</span> <span class="nx">Features</span><span class="p">,</span> <span class="nx">caps</span> <span class="nx">Capabilities</span><span class="p">)</span> <span class="nx">RealtimeDecision</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">features</span><span class="p">.</span><span class="nx">RealtimePush</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">RealtimeDecision</span><span class="p">{</span><span class="nx">Enabled</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="nx">Reason</span><span class="p">:</span> <span class="s">&#34;feature_disabled&#34;</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="p">!</span><span class="nx">caps</span><span class="p">.</span><span class="nx">SupportsStreaming</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="nx">RealtimeDecision</span><span class="p">{</span><span class="nx">Enabled</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="nx">Reason</span><span class="p">:</span> <span class="s">&#34;streaming_not_supported&#34;</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">return</span> <span class="nx">RealtimeDecision</span><span class="p">{</span><span class="nx">Enabled</span><span class="p">:</span> <span class="kc">true</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Decision 物件讓 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>、測試與錯誤回應都能使用相同 reason。</p>
<h2 id="執行log-要記錄-gate-decision">【執行】log 要記錄 gate decision</h2>
<p>Feature gate 的核心操作需求是知道功能為何啟用或關閉。當 gate 影響行為時，應記錄穩定 reason。</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="nx">decision</span> <span class="o">:=</span> <span class="nf">DecideRealtime</span><span class="p">(</span><span class="nx">features</span><span class="p">,</span> <span class="nx">caps</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;realtime decision&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s">&#34;feature&#34;</span><span class="p">,</span> <span class="s">&#34;realtime_push&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="s">&#34;enabled&#34;</span><span class="p">,</span> <span class="nx">decision</span><span class="p">.</span><span class="nx">Enabled</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="s">&#34;reason&#34;</span><span class="p">,</span> <span class="nx">decision</span><span class="p">.</span><span class="nx">Reason</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>這能回答「功能為什麼沒有走即時推送」這類問題。Reason 應是小集合，不要塞完整錯誤字串。</p>
<h2 id="測試開與關兩條路徑都要測">【測試】開與關兩條路徑都要測</h2>
<p>Feature gate 測試的核心規則是同時測啟用與停用路徑。只測預設值很容易讓另一條路徑壞掉。</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">TestHandleRealtimeExportFeatureDisabled</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">req</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRequest</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">MethodPost</span><span class="p">,</span> <span class="s">&#34;/export&#34;</span><span class="p">,</span> <span class="kc">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">rec</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRecorder</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">handler</span> <span class="o">:=</span> <span class="nf">HandleRealtimeExport</span><span class="p">(</span><span class="nx">Features</span><span class="p">{</span><span class="nx">RealtimePush</span><span class="p">:</span> <span class="kc">false</span><span class="p">})</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">handler</span><span class="p">.</span><span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">rec</span><span class="p">,</span> <span class="nx">req</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">if</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusNotImplemented</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</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;status = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusNotImplemented</span><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>啟用路徑：</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">TestDecideRealtimeEnabled</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">decision</span> <span class="o">:=</span> <span class="nf">DecideRealtime</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">Features</span><span class="p">{</span><span class="nx">RealtimePush</span><span class="p">:</span> <span class="kc">true</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">Capabilities</span><span class="p">{</span><span class="nx">SupportsStreaming</span><span class="p">:</span> <span class="kc">true</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></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">decision</span><span class="p">.</span><span class="nx">Enabled</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</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;realtime should be enabled, reason %q&#34;</span><span class="p">,</span> <span class="nx">decision</span><span class="p">.</span><span class="nx">Reason</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="p">}</span></span></span></code></pre></div><p>環境變數解析應單獨測 <code>LoadFeaturesFromEnv</code>。功能測試應直接傳入 <code>Features</code>，不要依賴全域環境狀態。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理服務內部的 gate 行為邊界；遠端 <a href="/blog/backend/knowledge-cards/feature-flag/" data-link-title="Feature Flag" data-link-desc="說明如何用可動態開關控制功能曝光與風險">feature flag</a> 平台與灰度流程，會在下列章節再往外延伸：</p>
<ul>
<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="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 composition root、handler boundary 與 runtime gate；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go：composition root 與依賴組裝</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/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go：testing 基礎</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Go 進階：Kubernetes、systemd 與 load balancer 合約</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>Feature gate 是生產操作工具，也是程式設計邊界。好的 gate 會集中載入、轉成 capability、定義降級策略、輸出穩定 reason，並同時測試開與關兩條路徑。它控制的是行為合約，不只是把新程式碼藏在 <code>if</code> 後面。</p>
]]></content:encoded></item><item><title>7.4 Observability pipeline、metrics 與 tracing</title><link>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/observability-pipeline/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/observability-pipeline/</guid><description>&lt;p>Observability pipeline 的核心責任是把服務訊號整理成可查詢、可聚合、可關聯的診斷資料。&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log-schema/" data-link-title="Log Schema" data-link-desc="說明結構化 log 欄位如何支援搜尋、關聯與事故排查">Log schema&lt;/a> 描述單次事件，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics&lt;/a> 描述趨勢，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/trace-context/" data-link-title="Trace Context" data-link-desc="說明跨服務 request 如何用 trace context 串起路徑與耗時">trace context&lt;/a> 描述跨元件路徑，profile 描述 runtime 成本；它們的責任不同，但應使用一致的識別欄位串起來。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a>、metric、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/trace/" data-link-title="Trace" data-link-desc="說明 trace 如何重建跨服務請求的路徑、耗時與依賴關係">trace&lt;/a> 與 profile 各自回答什麼問題&lt;/li>
&lt;li>設計穩定的 correlation 欄位&lt;/li>
&lt;li>讓 Go 服務輸出適合聚合的診斷訊號&lt;/li>
&lt;li>在產生端控制敏感資料進入觀測管線&lt;/li>
&lt;li>了解 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dashboard/" data-link-title="Dashboard" data-link-desc="說明 dashboard 如何把關鍵訊號組成可判讀的服務狀態畫面">dashboard&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/alert/" data-link-title="Alert" data-link-desc="說明 alert 如何把需要處理的服務症狀轉成可行動通知">alert&lt;/a> 為什麼需要依賴穩定欄位&lt;/li>
&lt;/ol>
&lt;h2 id="前置章節">前置章節&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/03-stdlib/slog/" data-link-title="3.6 log/slog：結構化日誌" data-link-desc="用 key-value log 設計可查詢、可過濾的程式訊號">Go 入門：log/slog：結構化日誌&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/pprof/" data-link-title="3.2 pprof 基礎診斷流程" data-link-desc="用 pprof endpoint 診斷 heap、goroutine 與 CPU 問題">Go 進階：pprof 基礎診斷流程&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">Go 進階：結構化日誌欄位設計&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">Go 進階：健康檢查與診斷 endpoint&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/sli-slo/" data-link-title="SLI / SLO" data-link-desc="說明服務品質指標與服務品質目標如何連接產品承諾">Backend：SLI / SLO&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/metric-cardinality/" data-link-title="Metric Cardinality" data-link-desc="說明 metric label 組合數量如何影響觀測成本與查詢穩定性">Backend：Metric Cardinality&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/alert-runbook/" data-link-title="Alert Runbook" data-link-desc="說明告警如何連到可執行的排障與恢復流程">Backend：Alert Runbook&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="後續撰寫方向">後續撰寫方向&lt;/h2>
&lt;ol>
&lt;li>Log、metric、trace、profile 分別回答哪些問題。&lt;/li>
&lt;li>&lt;code>request_id&lt;/code>、&lt;code>event_id&lt;/code>、&lt;code>trace_id&lt;/code>、&lt;code>span_id&lt;/code> 與 &lt;code>correlation_id&lt;/code> 如何分工。&lt;/li>
&lt;li>OpenTelemetry 導入時，Go 程式碼應保留哪些清楚邊界。&lt;/li>
&lt;li>Sensitive data policy 如何套用到 log、trace attribute 與 error event。&lt;/li>
&lt;li>Dashboard 與 alert 應依賴穩定欄位，讓查詢與告警規則可以被重複執行。&lt;/li>
&lt;/ol>
&lt;h2 id="觀察診斷資料要先可關聯再談漂亮">【觀察】診斷資料要先可關聯，再談漂亮&lt;/h2>
&lt;p>Observability pipeline 的第一個要求是關聯能力。Log、metric、trace 的格式可以各自精緻，但欄位需要對齊，才能把同一筆請求、同一個事件、同一條 goroutine 路徑串起來。&lt;/p>
&lt;p>通常會先建立幾個穩定欄位：&lt;/p>
&lt;ul>
&lt;li>request_id&lt;/li>
&lt;li>event_id&lt;/li>
&lt;li>trace_id&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/span/" data-link-title="Span" data-link-desc="說明 trace 中一段工作如何記錄耗時、狀態與關聯">span&lt;/a>_id&lt;/li>
&lt;li>user_id 或 tenant_id&lt;/li>
&lt;/ul>
&lt;h2 id="判讀不同訊號回答不同問題">【判讀】不同訊號回答不同問題&lt;/h2>
&lt;ul>
&lt;li>log：這次發生了什麼。&lt;/li>
&lt;li>metric：這類事件發生得多不多、快不快、慢不慢。&lt;/li>
&lt;li>trace：它在多個元件之間怎麼走。&lt;/li>
&lt;li>profile：CPU、記憶體、goroutine 與等待成本落在哪裡。&lt;/li>
&lt;/ul>
&lt;p>如果某個問題要靠自由文字 log 去猜，通常代表欄位設計還不夠穩。&lt;/p>
&lt;h2 id="策略敏感資料要在產生端就攔住">【策略】敏感資料要在產生端就攔住&lt;/h2>
&lt;p>敏感資料政策應在產生端執行。Go 服務應該在輸出 log 或 trace attribute 前就決定哪些資訊可以外送。&lt;/p>
&lt;p>常見要注意的資料有：&lt;/p>
&lt;ul>
&lt;li>token&lt;/li>
&lt;li>email&lt;/li>
&lt;li>身分證號&lt;/li>
&lt;li>raw payload&lt;/li>
&lt;li>內部路徑與配置&lt;/li>
&lt;/ul>
&lt;h2 id="執行結構化-log-是-pipeline-的起點">【執行】結構化 log 是 pipeline 的起點&lt;/h2>
&lt;p>當 Go 服務使用結構化 log 時，最重要的是欄位穩定與語意清楚。這些 log 後面可能會被：&lt;/p>
&lt;ul>
&lt;li>集中式 log system 搜尋&lt;/li>
&lt;li>metric extraction 轉成趨勢指標&lt;/li>
&lt;li>alert rule 用來偵測異常&lt;/li>
&lt;/ul>
&lt;p>所以 log 欄位要維持穩定命名，分類資訊要放在結構化欄位裡。&lt;/p></description><content:encoded><![CDATA[<p>Observability pipeline 的核心責任是把服務訊號整理成可查詢、可聚合、可關聯的診斷資料。<a href="/blog/backend/knowledge-cards/log-schema/" data-link-title="Log Schema" data-link-desc="說明結構化 log 欄位如何支援搜尋、關聯與事故排查">Log schema</a> 描述單次事件，<a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a> 描述趨勢，<a href="/blog/backend/knowledge-cards/trace-context/" data-link-title="Trace Context" data-link-desc="說明跨服務 request 如何用 trace context 串起路徑與耗時">trace context</a> 描述跨元件路徑，profile 描述 runtime 成本；它們的責任不同，但應使用一致的識別欄位串起來。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>、metric、<a href="/blog/backend/knowledge-cards/trace/" data-link-title="Trace" data-link-desc="說明 trace 如何重建跨服務請求的路徑、耗時與依賴關係">trace</a> 與 profile 各自回答什麼問題</li>
<li>設計穩定的 correlation 欄位</li>
<li>讓 Go 服務輸出適合聚合的診斷訊號</li>
<li>在產生端控制敏感資料進入觀測管線</li>
<li>了解 <a href="/blog/backend/knowledge-cards/dashboard/" data-link-title="Dashboard" data-link-desc="說明 dashboard 如何把關鍵訊號組成可判讀的服務狀態畫面">dashboard</a> 與 <a href="/blog/backend/knowledge-cards/alert/" data-link-title="Alert" data-link-desc="說明 alert 如何把需要處理的服務症狀轉成可行動通知">alert</a> 為什麼需要依賴穩定欄位</li>
</ol>
<h2 id="前置章節">前置章節</h2>
<ul>
<li><a href="/blog/go/03-stdlib/slog/" data-link-title="3.6 log/slog：結構化日誌" data-link-desc="用 key-value log 設計可查詢、可過濾的程式訊號">Go 入門：log/slog：結構化日誌</a></li>
<li><a href="/blog/go-advanced/03-runtime-profiling/pprof/" data-link-title="3.2 pprof 基礎診斷流程" data-link-desc="用 pprof endpoint 診斷 heap、goroutine 與 CPU 問題">Go 進階：pprof 基礎診斷流程</a></li>
<li><a href="/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">Go 進階：結構化日誌欄位設計</a></li>
<li><a href="/blog/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">Go 進階：健康檢查與診斷 endpoint</a></li>
<li><a href="/blog/backend/knowledge-cards/sli-slo/" data-link-title="SLI / SLO" data-link-desc="說明服務品質指標與服務品質目標如何連接產品承諾">Backend：SLI / SLO</a></li>
<li><a href="/blog/backend/knowledge-cards/metric-cardinality/" data-link-title="Metric Cardinality" data-link-desc="說明 metric label 組合數量如何影響觀測成本與查詢穩定性">Backend：Metric Cardinality</a></li>
<li><a href="/blog/backend/knowledge-cards/alert-runbook/" data-link-title="Alert Runbook" data-link-desc="說明告警如何連到可執行的排障與恢復流程">Backend：Alert Runbook</a></li>
</ul>
<h2 id="後續撰寫方向">後續撰寫方向</h2>
<ol>
<li>Log、metric、trace、profile 分別回答哪些問題。</li>
<li><code>request_id</code>、<code>event_id</code>、<code>trace_id</code>、<code>span_id</code> 與 <code>correlation_id</code> 如何分工。</li>
<li>OpenTelemetry 導入時，Go 程式碼應保留哪些清楚邊界。</li>
<li>Sensitive data policy 如何套用到 log、trace attribute 與 error event。</li>
<li>Dashboard 與 alert 應依賴穩定欄位，讓查詢與告警規則可以被重複執行。</li>
</ol>
<h2 id="觀察診斷資料要先可關聯再談漂亮">【觀察】診斷資料要先可關聯，再談漂亮</h2>
<p>Observability pipeline 的第一個要求是關聯能力。Log、metric、trace 的格式可以各自精緻，但欄位需要對齊，才能把同一筆請求、同一個事件、同一條 goroutine 路徑串起來。</p>
<p>通常會先建立幾個穩定欄位：</p>
<ul>
<li>request_id</li>
<li>event_id</li>
<li>trace_id</li>
<li><a href="/blog/backend/knowledge-cards/span/" data-link-title="Span" data-link-desc="說明 trace 中一段工作如何記錄耗時、狀態與關聯">span</a>_id</li>
<li>user_id 或 tenant_id</li>
</ul>
<h2 id="判讀不同訊號回答不同問題">【判讀】不同訊號回答不同問題</h2>
<ul>
<li>log：這次發生了什麼。</li>
<li>metric：這類事件發生得多不多、快不快、慢不慢。</li>
<li>trace：它在多個元件之間怎麼走。</li>
<li>profile：CPU、記憶體、goroutine 與等待成本落在哪裡。</li>
</ul>
<p>如果某個問題要靠自由文字 log 去猜，通常代表欄位設計還不夠穩。</p>
<h2 id="策略敏感資料要在產生端就攔住">【策略】敏感資料要在產生端就攔住</h2>
<p>敏感資料政策應在產生端執行。Go 服務應該在輸出 log 或 trace attribute 前就決定哪些資訊可以外送。</p>
<p>常見要注意的資料有：</p>
<ul>
<li>token</li>
<li>email</li>
<li>身分證號</li>
<li>raw payload</li>
<li>內部路徑與配置</li>
</ul>
<h2 id="執行結構化-log-是-pipeline-的起點">【執行】結構化 log 是 pipeline 的起點</h2>
<p>當 Go 服務使用結構化 log 時，最重要的是欄位穩定與語意清楚。這些 log 後面可能會被：</p>
<ul>
<li>集中式 log system 搜尋</li>
<li>metric extraction 轉成趨勢指標</li>
<li>alert rule 用來偵測異常</li>
</ul>
<p>所以 log 欄位要維持穩定命名，分類資訊要放在結構化欄位裡。</p>
<h2 id="延伸診斷和容量規劃要串在一起">【延伸】診斷和容量規劃要串在一起</h2>
<p>觀測資料不只是事後排障，也會反過來影響容量規劃與 release 判斷。當你看到 goroutine 數、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> lag、DB latency 或 retry rate 持續變高，就代表系統邊界已經開始吃緊。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章不會綁定特定 observability SaaS。教材重點會放在 Go 服務如何輸出穩定訊號，讓不同收集平台都能使用。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 Go 的結構化日誌與 runtime 診斷；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/03-stdlib/slog/" data-link-title="3.6 log/slog：結構化日誌" data-link-desc="用 key-value log 設計可查詢、可過濾的程式訊號">Go：結構化日誌</a></li>
<li><a href="/blog/go-advanced/03-runtime-profiling/pprof/" data-link-title="3.2 pprof 基礎診斷流程" data-link-desc="用 pprof endpoint 診斷 heap、goroutine 與 CPU 問題">Go 進階：pprof 基礎診斷流程</a></li>
<li><a href="/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">Go 進階：結構化日誌欄位設計</a></li>
<li><a href="/blog/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">Go 進階：健康檢查與診斷 endpoint</a></li>
</ul>
]]></content:encoded></item><item><title>7.4 狀態管理的安全邊界</title><link>https://tarrragon.github.io/blog/go/07-refactoring/state-boundary/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/state-boundary/</guid><description>&lt;p>狀態管理重構的核心目標是集中寫入、保護 map、回傳複製資料，並避免讓 handler、背景工作或即時連線直接操作內部狀態。本章用一般 repository 範例說明如何建立安全邊界。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>辨識共享狀態外洩的程式碼壞味道&lt;/li>
&lt;li>用 repository 或 state owner 集中寫入&lt;/li>
&lt;li>用 &lt;code>sync.RWMutex&lt;/code> 保護 map、slice 與狀態不變式&lt;/li>
&lt;li>用 copy boundary 防止呼叫端修改內部資料&lt;/li>
&lt;li>用行為測試與 &lt;code>go test -race&lt;/code> 驗證並發狀態&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察共享狀態外洩會讓規則分散">【觀察】共享狀態外洩會讓規則分散&lt;/h2>
&lt;p>共享狀態外洩的核心問題是多個元件可以繞過同一套規則直接修改資料。當 handler、worker、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> client manager 都能改同一個 map，狀態不一致與 data race 會變得很難追蹤。&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">type&lt;/span> &lt;span class="nx">Server&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">jobs&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="nx">JobProjection&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">s&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Server&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">handleJobStarted&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">id&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">URL&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Query&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;id&amp;#34;&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="nx">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">jobs&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">id&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">JobProjection&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="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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">Status&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">JobStatusRunning&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">UpdatedAt&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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &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">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusAccepted&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">s&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Server&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">handleJobList&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewEncoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Encode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">jobs&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式有三個問題：handler 直接改 map，map 沒有 lock，查詢直接輸出內部資料。只要另一個 goroutine 同時讀寫 &lt;code>jobs&lt;/code>，就可能產生 data race。&lt;/p>
&lt;h2 id="判讀state-owner-是唯一寫入入口">【判讀】state owner 是唯一寫入入口&lt;/h2>
&lt;p>state owner 的核心責任是擁有資料與狀態轉移規則。它可以叫 repository、store、manager；名稱不是重點，重點是所有寫入都經過同一組方法。&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">JobRepository&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">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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">jobs&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="nx">JobProjection&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="kd">func&lt;/span> &lt;span class="nf">NewJobRepository&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">JobRepository&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">return&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">JobRepository&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="nx">jobs&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="nx">JobProjection&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="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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>handler 不再直接改 map，而是呼叫 repository 方法：&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">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">JobRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">MarkRunning&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">now&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">if&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">id&lt;/span>&lt;span class="p">)&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"> 3&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;job id is required&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="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">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Lock&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">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Unlock&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">jobs&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">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">id&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">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Status&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">JobStatusRunning&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">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UpdatedAt&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">now&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">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">jobs&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">id&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">job&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="kc">nil&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;/code>&lt;/pre>&lt;/div>&lt;p>這個方法把「running 狀態怎麼寫入」集中起來。未來如果 running 只能從 pending 轉移，規則也加在這裡。&lt;/p>
&lt;h2 id="策略鎖保護的是不變式">【策略】鎖保護的是不變式&lt;/h2>
&lt;p>lock 的核心責任是保護完整狀態不變式，不只是保護某一行 map assignment。若一次狀態轉移要同時更新 current、history、updated time，就要在同一把鎖內完成。&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">JobRecord&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">Current&lt;/span> &lt;span class="nx">JobProjection&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">History&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">JobProjection&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="kd">type&lt;/span> &lt;span class="nx">JobRepository&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">7&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">8&lt;/span>&lt;span class="cl"> &lt;span class="nx">records&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="nx">JobRecord&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>寫入時同時更新 summary 與 history：&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">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">JobRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Apply&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span> &lt;span class="nx">JobEvent&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"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Lock&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">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Unlock&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">record&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">records&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">JobID&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">next&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Current&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">next&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">JobID&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">next&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UpdatedAt&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">OccurredAt&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="k">switch&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&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="s">&amp;#34;job.started&amp;#34;&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">next&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Status&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">JobStatusRunning&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">case&lt;/span> &lt;span class="s">&amp;#34;job.succeeded&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">next&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Status&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">JobStatusSucceeded&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">next&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Progress&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">100&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="s">&amp;#34;job.failed&amp;#34;&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="nx">next&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Status&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">JobStatusFailed&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">default&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="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;unsupported job event type %q&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&lt;/span>&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 class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="nx">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Current&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">next&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">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">History&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">History&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">next&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="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">records&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">JobID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">record&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式讓 current 與 history 保持一致。若分散在不同 handler 或不同鎖裡，就可能留下「current 已更新但 history 沒有記錄」的中間狀態。&lt;/p></description><content:encoded><![CDATA[<p>狀態管理重構的核心目標是集中寫入、保護 map、回傳複製資料，並避免讓 handler、背景工作或即時連線直接操作內部狀態。本章用一般 repository 範例說明如何建立安全邊界。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>辨識共享狀態外洩的程式碼壞味道</li>
<li>用 repository 或 state owner 集中寫入</li>
<li>用 <code>sync.RWMutex</code> 保護 map、slice 與狀態不變式</li>
<li>用 copy boundary 防止呼叫端修改內部資料</li>
<li>用行為測試與 <code>go test -race</code> 驗證並發狀態</li>
</ol>
<hr>
<h2 id="觀察共享狀態外洩會讓規則分散">【觀察】共享狀態外洩會讓規則分散</h2>
<p>共享狀態外洩的核心問題是多個元件可以繞過同一套規則直接修改資料。當 handler、worker、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> client manager 都能改同一個 map，狀態不一致與 data race 會變得很難追蹤。</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">type</span> <span class="nx">Server</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">jobs</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">JobProjection</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">s</span> <span class="o">*</span><span class="nx">Server</span><span class="p">)</span> <span class="nf">handleJobStarted</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">id</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">URL</span><span class="p">.</span><span class="nf">Query</span><span class="p">().</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;id&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">s</span><span class="p">.</span><span class="nx">jobs</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span> <span class="p">=</span> <span class="nx">JobProjection</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</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"> 9</span><span class="cl">        <span class="nx">Status</span><span class="p">:</span>    <span class="nx">JobStatusRunning</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">UpdatedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</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="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusAccepted</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</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">Server</span><span class="p">)</span> <span class="nf">handleJobList</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewEncoder</span><span class="p">(</span><span class="nx">w</span><span class="p">).</span><span class="nf">Encode</span><span class="p">(</span><span class="nx">s</span><span class="p">.</span><span class="nx">jobs</span><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>這段程式有三個問題：handler 直接改 map，map 沒有 lock，查詢直接輸出內部資料。只要另一個 goroutine 同時讀寫 <code>jobs</code>，就可能產生 data race。</p>
<h2 id="判讀state-owner-是唯一寫入入口">【判讀】state owner 是唯一寫入入口</h2>
<p>state owner 的核心責任是擁有資料與狀態轉移規則。它可以叫 repository、store、manager；名稱不是重點，重點是所有寫入都經過同一組方法。</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">JobRepository</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">mu</span>   <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">jobs</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">JobProjection</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="kd">func</span> <span class="nf">NewJobRepository</span><span class="p">()</span> <span class="o">*</span><span class="nx">JobRepository</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="o">&amp;</span><span class="nx">JobRepository</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">jobs</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="nx">JobProjection</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="p">}</span></span></span></code></pre></div><p>handler 不再直接改 map，而是呼叫 repository 方法：</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">r</span> <span class="o">*</span><span class="nx">JobRepository</span><span class="p">)</span> <span class="nf">MarkRunning</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="kt">error</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">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">id</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</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;job id is required&#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></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">r</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"> 7</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</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"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">job</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">jobs</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">job</span><span class="p">.</span><span class="nx">ID</span> <span class="p">=</span> <span class="nx">id</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">JobStatusRunning</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">job</span><span class="p">.</span><span class="nx">UpdatedAt</span> <span class="p">=</span> <span class="nx">now</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">jobs</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span> <span class="p">=</span> <span class="nx">job</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個方法把「running 狀態怎麼寫入」集中起來。未來如果 running 只能從 pending 轉移，規則也加在這裡。</p>
<h2 id="策略鎖保護的是不變式">【策略】鎖保護的是不變式</h2>
<p>lock 的核心責任是保護完整狀態不變式，不只是保護某一行 map assignment。若一次狀態轉移要同時更新 current、history、updated time，就要在同一把鎖內完成。</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">JobRecord</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">Current</span> <span class="nx">JobProjection</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">History</span> <span class="p">[]</span><span class="nx">JobProjection</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="kd">type</span> <span class="nx">JobRepository</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</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">8</span><span class="cl">    <span class="nx">records</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">JobRecord</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>寫入時同時更新 summary 與 history：</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">r</span> <span class="o">*</span><span class="nx">JobRepository</span><span class="p">)</span> <span class="nf">Apply</span><span class="p">(</span><span class="nx">event</span> <span class="nx">JobEvent</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</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"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</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"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">record</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">records</span><span class="p">[</span><span class="nx">event</span><span class="p">.</span><span class="nx">JobID</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">next</span> <span class="o">:=</span> <span class="nx">record</span><span class="p">.</span><span class="nx">Current</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">next</span><span class="p">.</span><span class="nx">ID</span> <span class="p">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">JobID</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">next</span><span class="p">.</span><span class="nx">UpdatedAt</span> <span class="p">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">OccurredAt</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">switch</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</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="s">&#34;job.started&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">next</span><span class="p">.</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">JobStatusRunning</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">case</span> <span class="s">&#34;job.succeeded&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">next</span><span class="p">.</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">JobStatusSucceeded</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">next</span><span class="p">.</span><span class="nx">Progress</span> <span class="p">=</span> <span class="mi">100</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">case</span> <span class="s">&#34;job.failed&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">next</span><span class="p">.</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">JobStatusFailed</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">default</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="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;unsupported job event type %q&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Type</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></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="nx">record</span><span class="p">.</span><span class="nx">Current</span> <span class="p">=</span> <span class="nx">next</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="nx">record</span><span class="p">.</span><span class="nx">History</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">record</span><span class="p">.</span><span class="nx">History</span><span class="p">,</span> <span class="nx">next</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">records</span><span class="p">[</span><span class="nx">event</span><span class="p">.</span><span class="nx">JobID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">record</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式讓 current 與 history 保持一致。若分散在不同 handler 或不同鎖裡，就可能留下「current 已更新但 history 沒有記錄」的中間狀態。</p>
<h2 id="執行讀取要回傳-copy">【執行】讀取要回傳 copy</h2>
<p>copy boundary 的核心目標是避免呼叫端拿到內部可變資料。鎖只保護鎖內操作；一旦把內部 slice 或 pointer 回傳出去，呼叫端就可以在鎖外修改資料。</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">r</span> <span class="o">*</span><span class="nx">JobRepository</span><span class="p">)</span> <span class="nf">Get</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">JobProjection</span><span class="p">,</span> <span class="kt">bool</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">r</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">r</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">record</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">records</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</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"> 7</span><span class="cl">        <span class="k">return</span> <span class="nx">JobProjection</span><span class="p">{},</span> <span class="kc">false</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">return</span> <span class="nf">cloneJobProjection</span><span class="p">(</span><span class="nx">record</span><span class="p">.</span><span class="nx">Current</span><span class="p">),</span> <span class="kc">true</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>history 查詢：</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">r</span> <span class="o">*</span><span class="nx">JobRepository</span><span class="p">)</span> <span class="nf">History</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">[]</span><span class="nx">JobProjection</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">r</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">r</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">history</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">records</span><span class="p">[</span><span class="nx">id</span><span class="p">].</span><span class="nx">History</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">result</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="nx">JobProjection</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">history</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">for</span> <span class="nx">i</span><span class="p">,</span> <span class="nx">item</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">history</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">result</span><span class="p">[</span><span class="nx">i</span><span class="p">]</span> <span class="p">=</span> <span class="nf">cloneJobProjection</span><span class="p">(</span><span class="nx">item</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">return</span> <span class="nx">result</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>clone 函式處理 pointer 欄位：</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">cloneJobProjection</span><span class="p">(</span><span class="nx">job</span> <span class="nx">JobProjection</span><span class="p">)</span> <span class="nx">JobProjection</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">cloned</span> <span class="o">:=</span> <span class="nx">job</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">if</span> <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">finishedAt</span> <span class="o">:=</span> <span class="o">*</span><span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="nx">cloned</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="p">=</span> <span class="o">&amp;</span><span class="nx">finishedAt</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="k">return</span> <span class="nx">cloned</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>如果 struct 未來新增 slice、map 或 pointer 欄位，clone 函式也要跟著更新。這是資料擁有權邊界的一部分。</p>
<h2 id="判讀state-和-projection-要分清楚">【判讀】state 和 projection 要分清楚</h2>
<p>state/<a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a> 分離的核心原因是寫入規則與讀取需求不同。domain state 保存規則，projection 服務查詢與顯示。</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">JobState</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">Status</span>    <span class="nx">JobStatus</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">UpdatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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">type</span> <span class="nx">JobProjection</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="nx">ID</span>          <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">Status</span>      <span class="nx">JobStatus</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">Progress</span>    <span class="kt">int</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">UpdatedAt</span>   <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">DisplayText</span> <span class="kt">string</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>DisplayText</code> 不應參與狀態轉移，它是 response 或 <a href="/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model</a> 的資料。若把顯示文字混進核心 state，前端文案改動就會牽動業務規則測試。</p>
<p>重構時不一定要一次拆出兩個 struct。可以先在程式碼中標記哪些欄位是 state，哪些欄位是 projection；等壓力變大，再正式拆型別。</p>
<h2 id="策略handler-只請求狀態更新">【策略】handler 只請求狀態更新</h2>
<p>handler 的核心責任是把 HTTP request 轉成狀態更新請求，而不是自己修改狀態。</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">type</span> <span class="nx">JobStarter</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">MarkRunning</span><span class="p">(</span><span class="nx">id</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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">type</span> <span class="nx">JobHandler</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">jobs</span> <span class="nx">JobStarter</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">now</span>  <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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">h</span> <span class="nx">JobHandler</span><span class="p">)</span> <span class="nf">Start</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">id</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">URL</span><span class="p">.</span><span class="nf">Query</span><span class="p">().</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;id&#34;</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">err</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">jobs</span><span class="p">.</span><span class="nf">MarkRunning</span><span class="p">(</span><span class="nx">id</span><span class="p">,</span> <span class="nx">h</span><span class="p">.</span><span class="nf">now</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="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;start job&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</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 class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusAccepted</span><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>handler 不知道 repository 內部用 map、slice、mutex 還是資料庫。它只知道「可以把 job 標記為 running」。</p>
<h2 id="策略為未來資料庫保留邊界但不提前綁死">【策略】為未來資料庫保留邊界，但不提前綁死</h2>
<p><a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a>-ready 邊界的核心是 context、error 與一致性語意，不是提早引入 ORM。memory repository 可以先存在，但方法簽名可以保留未來 I/O 的可能。</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">JobRepositoryPort</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">Apply</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">event</span> <span class="nx">JobEvent</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="nf">Get</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">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">JobProjection</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>memory implementation 可以忽略 context：</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">r</span> <span class="o">*</span><span class="nx">JobRepository</span><span class="p">)</span> <span class="nf">Get</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">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">JobProjection</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</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">r</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">r</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">record</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">records</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</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"> 7</span><span class="cl">        <span class="k">return</span> <span class="nx">JobProjection</span><span class="p">{},</span> <span class="kc">false</span><span class="p">,</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><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="nf">cloneJobProjection</span><span class="p">(</span><span class="nx">record</span><span class="p">.</span><span class="nx">Current</span><span class="p">),</span> <span class="kc">true</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>未來換成資料庫時，context 可以傳給 query；error 可以包上資料庫錯誤。<a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 則等到一個 usecase 真的需要多筆寫入一致性時再設計。</p>
<h2 id="執行state-transition-測試鎖定規則">【執行】state transition 測試鎖定規則</h2>
<p>state transition 測試的核心目標是確認事件會產生正確狀態與 history。這類測試不需要 HTTP，也不需要 goroutine。</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">TestJobRepositoryApplyRecordsHistory</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">repo</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">JobRepository</span><span class="p">{</span><span class="nx">records</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="nx">JobRecord</span><span class="p">)}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">startedAt</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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">repo</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">JobEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">JobID</span><span class="p">:</span>      <span class="s">&#34;job_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>       <span class="s">&#34;job.started&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">startedAt</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;apply event: %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="nx">job</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;job_1&#34;</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="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="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;job should exist&#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="k">if</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span> <span class="o">!=</span> <span class="nx">JobStatusRunning</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">19</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;status = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span><span class="p">,</span> <span class="nx">JobStatusRunning</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></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="nx">history</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">History</span><span class="p">(</span><span class="s">&#34;job_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">history</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">24</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;history length = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">history</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試鎖定的是狀態規則，而不是鎖本身。</p>
<h2 id="執行copy-boundary-測試要嘗試破壞資料">【執行】copy boundary 測試要嘗試破壞資料</h2>
<p>copy boundary 測試的核心目標是證明呼叫端拿到的資料不能修改 repository 內部狀態。</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">TestJobRepositoryHistoryReturnsCopy</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">repo</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">JobRepository</span><span class="p">{</span><span class="nx">records</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="nx">JobRecord</span><span class="p">)}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">occurredAt</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">JobEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">JobID</span><span class="p">:</span>      <span class="s">&#34;job_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>       <span class="s">&#34;job.started&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">occurredAt</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">history</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">History</span><span class="p">(</span><span class="s">&#34;job_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">history</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">JobStatusFailed</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="nx">again</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">History</span><span class="p">(</span><span class="s">&#34;job_1&#34;</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">again</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">Status</span> <span class="o">!=</span> <span class="nx">JobStatusRunning</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;repository history was modified through returned slice&#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>這種測試比只看程式碼更可靠。它直接模擬呼叫端拿到資料後做了危險操作。</p>
<h2 id="執行並發測試配合-race-detector">【執行】並發測試配合 race detector</h2>
<p>並發測試的核心目標是讓 race detector 執行到共享狀態路徑。測試本身可以只檢查不 panic 或基本結果，真正的 data race 由 <code>go test -race</code> 回報。</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">TestJobRepositoryConcurrentAccess</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">repo</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">JobRepository</span><span class="p">{</span><span class="nx">records</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="nx">JobRecord</span><span class="p">)}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="kd">var</span> <span class="nx">wg</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">WaitGroup</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="nx">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="mi">100</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">wg</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">go</span> <span class="kd">func</span><span class="p">(</span><span class="nx">i</span> <span class="kt">int</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="k">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</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="nx">id</span> <span class="o">:=</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Sprintf</span><span class="p">(</span><span class="s">&#34;job_%d&#34;</span><span class="p">,</span> <span class="nx">i</span><span class="o">%</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="nx">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">JobEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">                <span class="nx">JobID</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">Type</span><span class="p">:</span>       <span class="s">&#34;job.started&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">                <span class="nx">OccurredAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">i</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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="nx">_</span><span class="p">,</span> <span class="nx">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="nx">id</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">            <span class="nx">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">History</span><span class="p">(</span><span class="nx">id</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="p">}(</span><span class="nx">i</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">wg</span><span class="p">.</span><span class="nf">Wait</span><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>執行：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go <span class="nb">test</span> -race ./...</span></span></code></pre></div><p>race detector 只能檢查測試實際跑到的路徑。若並發讀寫沒有被測試覆蓋，它也不會發現問題。</p>
<h2 id="重構步驟">重構步驟</h2>
<p>從共享狀態外洩重構到安全邊界，可以按這個順序：</p>
<ol>
<li>找出所有直接讀寫 map、slice 或 projection 的地方。</li>
<li>建立 state owner 或 repository。</li>
<li>把最常用的寫入流程搬成方法。</li>
<li>在方法內加入 lock，保護完整不變式。</li>
<li>把讀取方法改成回傳 copy。</li>
<li>讓 handler、worker、publisher 改呼叫方法，不直接碰資料。</li>
<li>補 state transition 與 copy boundary 測試。</li>
<li>補並發測試並執行 <code>go test -race ./...</code>。</li>
</ol>
<p>不要一開始就重寫所有狀態模型。先把寫入集中，再逐步整理 state/projection 與資料庫邊界。</p>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一加鎖後仍要保護回傳資料">檢查一：加鎖後仍要保護回傳資料</h3>
<p>鎖只保護鎖內操作。回傳內部 map 或 slice 後，呼叫端可以在鎖外修改資料，狀態邊界仍然失效。</p>
<h3 id="檢查二讀取鎖只保護讀取">檢查二：讀取鎖只保護讀取</h3>
<p><code>RLock</code> 只適合讀取。只要會修改 map、slice、pointer 指向的值或 struct 欄位，就必須使用 <code>Lock</code>。</p>
<h3 id="檢查三狀態副本需要明確-owner">檢查三：狀態副本需要明確 owner</h3>
<p>多份狀態副本會造成 <a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth</a> 混亂。handler 應該請求同一個 state owner 更新或查詢。</p>
<h3 id="檢查四持久化替換跟著需求前進">檢查四：持久化替換跟著需求前進</h3>
<p>狀態邊界是程式碼架構的責任；資料庫只負責持久化。把 memory repository 換成 ORM 只解決「資料存在哪裡」，沒有解決「誰有權利寫、怎麼寫才一致」。</p>
<p>引入資料庫後，清楚的寫入方法、交易語意、copy/DTO 邊界與測試仍要留在程式碼設計中。這些規則決定狀態如何被修改，不能交給資料庫連線本身代勞。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 state owner、lock boundary 與 copy boundary；資料庫 transaction 與分散式一致性，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">Go 進階：資料庫 transaction 與 schema migration</a></li>
<li><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Go 進階：Source of Truth：狀態邊界</a></li>
<li><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫與持久化</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 repository、read model 與 shared state 的邊界；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/06-practical/state-fields/" data-link-title="6.3 如何擴展狀態投影欄位" data-link-desc="更新狀態模型、repository 與 API 輸出">Go：如何擴展狀態投影欄位</a></li>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">Go：以 domain 重新整理 package</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>狀態管理重構的重點是建立資料擁有者。寫入集中在 repository 或 state owner，lock 保護完整不變式，讀取回傳 copy，handler 和 worker 只請求狀態更新。當狀態邊界清楚時，race detector 才有意義，未來換成資料庫也只是 adapter 變化，不會改變核心狀態規則。</p>
]]></content:encoded></item><item><title>模組四：並發模型</title><link>https://tarrragon.github.io/blog/go/04-concurrency/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/04-concurrency/</guid><description>&lt;p>Go 的並發不是只會寫 &lt;code>go func()&lt;/code>。Go 的並發模型包含工作如何啟動、資料如何傳遞、取消如何傳播、共享狀態如何保護。本模組從語言機制出發，再延伸到 worker、事件處理與網路服務情境。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&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;a href="https://tarrragon.github.io/blog/go/04-concurrency/concurrency-model/" data-link-title="4.0 Go 並發模型總覽" data-link-desc="先理解 goroutine、OS thread 與 runtime 排程，再看高併發應用怎麼設計">4.0&lt;/a>&lt;/td>
 &lt;td>Go 並發模型總覽&lt;/td>
 &lt;td>看懂 goroutine、thread 與 runtime 的關係&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">4.1&lt;/a>&lt;/td>
 &lt;td>goroutine：輕量並發工作&lt;/td>
 &lt;td>啟動並發工作並設計退出條件&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">4.2&lt;/a>&lt;/td>
 &lt;td>channel：資料傳遞與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a>&lt;/td>
 &lt;td>用 channel 在 goroutine 之間傳遞資料&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">4.3&lt;/a>&lt;/td>
 &lt;td>select：同時等待多種事件&lt;/td>
 &lt;td>實作 event loop&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/rwmutex/" data-link-title="4.4 sync.RWMutex：保護共享狀態" data-link-desc="用讀寫鎖保護共享狀態">4.4&lt;/a>&lt;/td>
 &lt;td>sync.RWMutex：保護共享狀態&lt;/td>
 &lt;td>安全讀寫共享資料&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/backpressure/" data-link-title="4.5 高併發控制與 backpressure " data-link-desc="用 bounded concurrency、backpressure 與 cancellation 控制 goroutine 的成長">4.5&lt;/a>&lt;/td>
 &lt;td>高併發控制與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a>&lt;/td>
 &lt;td>用 bounded concurrency 與 cancellation 控制壓力&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool&lt;/a> 與背景工作&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/producer/" data-link-title="Producer" data-link-desc="說明 producer 如何把工作、事件或資料送入後續處理路徑">producer&lt;/a> / &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a> 資料流&lt;/li>
&lt;li>ticker 與取消訊號&lt;/li>
&lt;li>共享狀態的讀寫鎖&lt;/li>
&lt;li>非阻塞送出與 backpressure&lt;/li>
&lt;/ul>
&lt;h2 id="學習時間">學習時間&lt;/h2>
&lt;p>預計 90-120 分鐘&lt;/p></description><content:encoded><![CDATA[<p>Go 的並發不是只會寫 <code>go func()</code>。Go 的並發模型包含工作如何啟動、資料如何傳遞、取消如何傳播、共享狀態如何保護。本模組從語言機制出發，再延伸到 worker、事件處理與網路服務情境。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go/04-concurrency/concurrency-model/" data-link-title="4.0 Go 並發模型總覽" data-link-desc="先理解 goroutine、OS thread 與 runtime 排程，再看高併發應用怎麼設計">4.0</a></td>
          <td>Go 並發模型總覽</td>
          <td>看懂 goroutine、thread 與 runtime 的關係</td>
      </tr>
      <tr>
          <td><a href="/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">4.1</a></td>
          <td>goroutine：輕量並發工作</td>
          <td>啟動並發工作並設計退出條件</td>
      </tr>
      <tr>
          <td><a href="/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">4.2</a></td>
          <td>channel：資料傳遞與 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a></td>
          <td>用 channel 在 goroutine 之間傳遞資料</td>
      </tr>
      <tr>
          <td><a href="/blog/go/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">4.3</a></td>
          <td>select：同時等待多種事件</td>
          <td>實作 event loop</td>
      </tr>
      <tr>
          <td><a href="/blog/go/04-concurrency/rwmutex/" data-link-title="4.4 sync.RWMutex：保護共享狀態" data-link-desc="用讀寫鎖保護共享狀態">4.4</a></td>
          <td>sync.RWMutex：保護共享狀態</td>
          <td>安全讀寫共享資料</td>
      </tr>
      <tr>
          <td><a href="/blog/go/04-concurrency/backpressure/" data-link-title="4.5 高併發控制與 backpressure " data-link-desc="用 bounded concurrency、backpressure 與 cancellation 控制 goroutine 的成長">4.5</a></td>
          <td>高併發控制與 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a></td>
          <td>用 bounded concurrency 與 cancellation 控制壓力</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<ul>
<li><a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool</a> 與背景工作</li>
<li><a href="/blog/backend/knowledge-cards/producer/" data-link-title="Producer" data-link-desc="說明 producer 如何把工作、事件或資料送入後續處理路徑">producer</a> / <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 資料流</li>
<li>ticker 與取消訊號</li>
<li>共享狀態的讀寫鎖</li>
<li>非阻塞送出與 backpressure</li>
</ul>
<h2 id="學習時間">學習時間</h2>
<p>預計 90-120 分鐘</p>
]]></content:encoded></item><item><title>模組四：架構邊界與事件系統</title><link>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/</guid><description>&lt;p>架構邊界的核心目標是讓每個元件只承擔一種責任。事件來源負責接收外部訊號，normalize 階段負責轉成內部事件，processor 負責套用規則，repository 負責保存狀態真相，publisher 負責把結果送出去。&lt;/p>
&lt;p>事件驅動不是把所有東西都丟進 channel。Go 的事件系統需要明確的型別、清楚的擁有者、可測的狀態轉移，以及能在多來源輸入下維持一致的處理流程。&lt;/p>
&lt;p>本模組承接入門篇的 practical 與 refactoring：前面學會新增事件、建立 repository port、拆 handler、整理 domain package；這裡進一步處理「系統開始變大後，事件與狀態如何不失控」。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&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;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">4.1&lt;/a>&lt;/td>
 &lt;td>事件來源、處理流程與狀態邊界&lt;/td>
 &lt;td>用邊界拆開 reader、normalizer、processor、repository、publisher&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">4.2&lt;/a>&lt;/td>
 &lt;td>事件去重與語義鍵設計&lt;/td>
 &lt;td>用 domain key、時間窗口與清理策略管理重複事件&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">4.3&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of Truth&lt;/a>：狀態邊界&lt;/td>
 &lt;td>集中狀態轉移、保護可變資料、設計 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">4.4&lt;/a>&lt;/td>
 &lt;td>多來源 event 融合&lt;/td>
 &lt;td>把 HTTP、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>、timer 等來源收斂到同一套 domain event 流程&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;p>本模組使用虛構的通知與工作處理服務作為範例。服務可能從 HTTP callback、queue message、timer 或檔案 reader 收到事件，最後更新內部狀態並推送通知。&lt;/p>
&lt;p>範例只用來展示 Go 的設計方法，不假設讀者正在維護任何特定專案。&lt;/p>
&lt;h2 id="本模組的-go-核心概念">本模組的 Go 核心概念&lt;/h2>
&lt;ul>
&lt;li>用 struct 定義穩定的內部事件模型。&lt;/li>
&lt;li>用 interface 表達 reader、repository、publisher 這類能力。&lt;/li>
&lt;li>用 context 傳遞 request lifecycle、取消與逾時。&lt;/li>
&lt;li>用 mutex 或單一 goroutine 保護共享狀態。&lt;/li>
&lt;li>用 package 邊界限制 adapter、application、domain 的依賴方向。&lt;/li>
&lt;li>用 table-driven test 驗證 normalize、dedup 與狀態轉移。&lt;/li>
&lt;/ul>
&lt;h2 id="學習重點">學習重點&lt;/h2>
&lt;p>學完本模組後，你應該能判斷：&lt;/p>
&lt;ol>
&lt;li>外部訊號是否應該轉成 domain event&lt;/li>
&lt;li>去重應該使用哪些欄位，哪些欄位不應進入 key&lt;/li>
&lt;li>狀態真相應該由哪個元件擁有&lt;/li>
&lt;li>新事件來源應該新增 adapter，還是修改 processor&lt;/li>
&lt;li>ports/adapters 與 event-driven service 如何在 Go 中自然結合&lt;/li>
&lt;/ol>
&lt;h2 id="章節粒度說明">章節粒度說明&lt;/h2>
&lt;p>本模組的四章分別處理事件系統的四個核心面向，不建議硬拆成更小的孤立段落。事件來源、去重、狀態真相與多來源融合會互相影響；拆得太碎會讓讀者看不到一筆事件如何從外部輸入走到狀態更新與推送。&lt;/p>
&lt;p>閱讀時可以把四章視為一條路線：&lt;/p>
&lt;ol>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">事件來源、處理流程與狀態邊界&lt;/a>：先建立元件分工。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">事件去重與語義鍵設計&lt;/a>：再定義「同一事件」的語意。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">Source of Truth：狀態邊界&lt;/a>：接著決定誰能改狀態。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">多來源 event 融合&lt;/a>：最後處理 HTTP、queue、timer 等多入口協作。&lt;/li>
&lt;/ol>
&lt;h2 id="本模組不處理">本模組不處理&lt;/h2>
&lt;p>本模組不實作完整 message queue、分散式 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a> 或 event sourcing 平台。這些主題需要更多基礎設施與操作細節；本模組先聚焦 Go 程式內部如何建立清楚的事件與狀態邊界。後續可接 &lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">資料庫 transaction 與 schema migration&lt;/a> 以及 &lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Durable queue、outbox 與 idempotency&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>架構邊界的核心目標是讓每個元件只承擔一種責任。事件來源負責接收外部訊號，normalize 階段負責轉成內部事件，processor 負責套用規則，repository 負責保存狀態真相，publisher 負責把結果送出去。</p>
<p>事件驅動不是把所有東西都丟進 channel。Go 的事件系統需要明確的型別、清楚的擁有者、可測的狀態轉移，以及能在多來源輸入下維持一致的處理流程。</p>
<p>本模組承接入門篇的 practical 與 refactoring：前面學會新增事件、建立 repository port、拆 handler、整理 domain package；這裡進一步處理「系統開始變大後，事件與狀態如何不失控」。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">4.1</a></td>
          <td>事件來源、處理流程與狀態邊界</td>
          <td>用邊界拆開 reader、normalizer、processor、repository、publisher</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">4.2</a></td>
          <td>事件去重與語義鍵設計</td>
          <td>用 domain key、時間窗口與清理策略管理重複事件</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">4.3</a></td>
          <td><a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of Truth</a>：狀態邊界</td>
          <td>集中狀態轉移、保護可變資料、設計 <a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a></td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">4.4</a></td>
          <td>多來源 event 融合</td>
          <td>把 HTTP、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>、timer 等來源收斂到同一套 domain event 流程</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<p>本模組使用虛構的通知與工作處理服務作為範例。服務可能從 HTTP callback、queue message、timer 或檔案 reader 收到事件，最後更新內部狀態並推送通知。</p>
<p>範例只用來展示 Go 的設計方法，不假設讀者正在維護任何特定專案。</p>
<h2 id="本模組的-go-核心概念">本模組的 Go 核心概念</h2>
<ul>
<li>用 struct 定義穩定的內部事件模型。</li>
<li>用 interface 表達 reader、repository、publisher 這類能力。</li>
<li>用 context 傳遞 request lifecycle、取消與逾時。</li>
<li>用 mutex 或單一 goroutine 保護共享狀態。</li>
<li>用 package 邊界限制 adapter、application、domain 的依賴方向。</li>
<li>用 table-driven test 驗證 normalize、dedup 與狀態轉移。</li>
</ul>
<h2 id="學習重點">學習重點</h2>
<p>學完本模組後，你應該能判斷：</p>
<ol>
<li>外部訊號是否應該轉成 domain event</li>
<li>去重應該使用哪些欄位，哪些欄位不應進入 key</li>
<li>狀態真相應該由哪個元件擁有</li>
<li>新事件來源應該新增 adapter，還是修改 processor</li>
<li>ports/adapters 與 event-driven service 如何在 Go 中自然結合</li>
</ol>
<h2 id="章節粒度說明">章節粒度說明</h2>
<p>本模組的四章分別處理事件系統的四個核心面向，不建議硬拆成更小的孤立段落。事件來源、去重、狀態真相與多來源融合會互相影響；拆得太碎會讓讀者看不到一筆事件如何從外部輸入走到狀態更新與推送。</p>
<p>閱讀時可以把四章視為一條路線：</p>
<ol>
<li><a href="/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">事件來源、處理流程與狀態邊界</a>：先建立元件分工。</li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">事件去重與語義鍵設計</a>：再定義「同一事件」的語意。</li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">Source of Truth：狀態邊界</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 與外部事件來源">多來源 event 融合</a>：最後處理 HTTP、queue、timer 等多入口協作。</li>
</ol>
<h2 id="本模組不處理">本模組不處理</h2>
<p>本模組不實作完整 message queue、分散式 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 或 event sourcing 平台。這些主題需要更多基礎設施與操作細節；本模組先聚焦 Go 程式內部如何建立清楚的事件與狀態邊界。後續可接 <a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">資料庫 transaction 與 schema migration</a> 以及 <a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Durable queue、outbox 與 idempotency</a>。</p>
<h2 id="學習時間">學習時間</h2>
<p>預計 3-4 小時</p>
]]></content:encoded></item><item><title>go vs push vs pushReplacement 的 UX 語意表</title><link>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/go-push-semantics/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/go-push-semantics/</guid><description>&lt;p>&lt;code>go&lt;/code>、&lt;code>push&lt;/code>、&lt;code>pushReplacement&lt;/code> 三種導航方法改變導航堆疊的方式不同，直接影響使用者按 back 時的行為。選擇哪種方法的依據是使用者的操作意圖 — 使用者期望按 back 時回到哪裡。&lt;/p>
&lt;h2 id="語意對照表">語意對照表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>方法&lt;/th>
 &lt;th>堆疊行為&lt;/th>
 &lt;th>按 back 回到&lt;/th>
 &lt;th>使用者意圖&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>go(path)&lt;/code>&lt;/td>
 &lt;td>替換整個堆疊&lt;/td>
 &lt;td>無（離開 app）&lt;/td>
 &lt;td>切換到另一個工作區&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>push(path)&lt;/code>&lt;/td>
 &lt;td>推入堆疊頂端&lt;/td>
 &lt;td>前一個畫面&lt;/td>
 &lt;td>暫時離開，做完回來&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>pushReplacement&lt;/code>&lt;/td>
 &lt;td>替換堆疊頂端&lt;/td>
 &lt;td>更早的畫面&lt;/td>
 &lt;td>流程中的下一步（不可回退）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="go切換工作區">go：切換工作區&lt;/h2>
&lt;p>&lt;code>go&lt;/code> 把整個導航堆疊替換成新的路徑。使用者按 back 不會回到操作前的畫面，因為堆疊已經被替換。&lt;/p>
&lt;p>適合場景：&lt;/p>
&lt;ul>
&lt;li>登入成功後到首頁（使用者不應該按 back 回到登入畫面）&lt;/li>
&lt;li>登出後到登入畫面（使用者不應該按 back 回到需要認證的畫面）&lt;/li>
&lt;li>從 onboarding 到主畫面（onboarding 完成後不需要回去）&lt;/li>
&lt;/ul>
&lt;p>誤用 &lt;code>go&lt;/code> 的後果：使用者期望按 back 回到前一個畫面但堆疊已空，按 back 直接離開 app。app_tunnel 修復時選擇 &lt;code>push('/enrollment')&lt;/code> 而非 &lt;code>go('/enrollment')&lt;/code>，讓使用者配對完成後能按 back 回首頁（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/missing-enrollment-entry-point/" data-link-title="U.C4 首頁缺配對入口按鈕、導航流未完整列出" data-link-desc="Flutter app 首頁只有 Connect Terminal 按鈕、沒有 Enroll Device 入口 — 使用者首次使用時找不到配對功能。根因是導航流設計只考慮了日常操作（UC-02 連線）、遺漏了首次操作（UC-01 配對）的入口">U.C4&lt;/a>）。&lt;/p>
&lt;h2 id="push暫時離開做完回來">push：暫時離開，做完回來&lt;/h2>
&lt;p>&lt;code>push&lt;/code> 在堆疊頂端加入新畫面。使用者按 back 回到前一個畫面。&lt;/p>
&lt;p>適合場景：&lt;/p>
&lt;ul>
&lt;li>從列表到詳細頁（看完回到列表）&lt;/li>
&lt;li>從首頁到配對畫面（配對完回首頁）&lt;/li>
&lt;li>從任何畫面到設定頁（改完設定回原畫面）&lt;/li>
&lt;/ul>
&lt;p>&lt;code>push&lt;/code> 是最常用的導航方法，因為多數導航都是「暫時去另一個畫面做事，做完回來」的模式。&lt;/p>
&lt;h2 id="pushreplacement流程中前進">pushReplacement：流程中前進&lt;/h2>
&lt;p>&lt;code>pushReplacement&lt;/code> 用新畫面替換堆疊頂端。堆疊深度不變，按 back 回到替換前畫面的前一個畫面（跳過被替換的畫面）。&lt;/p>
&lt;p>適合場景：&lt;/p>
&lt;ul>
&lt;li>步驟式流程：步驟 1 → pushReplacement 步驟 2 → pushReplacement 步驟 3。使用者在步驟 3 按 back 回到流程開始前的畫面，不會回到步驟 2 或 1。&lt;/li>
&lt;li>結果頁替換搜尋頁：搜尋結果替換搜尋條件頁，使用者按 back 回到搜尋前的畫面。&lt;/li>
&lt;/ul>
&lt;p>pushReplacement 的語意是「這一步完成後使用者不需要回到這裡」。用於不可回退的流程步驟。&lt;/p>
&lt;h2 id="選擇決策流程">選擇決策流程&lt;/h2>
&lt;p>對每個導航操作問一個問題：&lt;strong>使用者按 back 時，期望回到哪裡？&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>回到前一個畫面 → &lt;code>push&lt;/code>&lt;/li>
&lt;li>離開 app 或回到 app 的根畫面 → &lt;code>go&lt;/code>&lt;/li>
&lt;li>跳過當前畫面，回到更早的畫面 → &lt;code>pushReplacement&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>這個決策應該在 UX 設計階段做，記錄在畫面狀態矩陣的「退出路徑」欄中。開發者實作時對照矩陣選擇正確的導航方法。&lt;/p>
&lt;h2 id="常見誤用">常見誤用&lt;/h2>
&lt;h3 id="用-go-做應該用-push-的導航">用 go 做應該用 push 的導航&lt;/h3>
&lt;p>「首頁 → 配對畫面」如果用 &lt;code>go&lt;/code>，使用者配對完成後按 back 離開 app 而非回到首頁。使用者期望的是「配對完成回首頁」（push 行為）。&lt;/p>
&lt;h3 id="用-push-做應該用-go-的導航">用 push 做應該用 go 的導航&lt;/h3>
&lt;p>「登入 → 首頁」如果用 &lt;code>push&lt;/code>，使用者在首頁按 back 回到登入畫面。使用者已經登入，不應該看到登入畫面。&lt;/p>
&lt;h3 id="用-push-做應該用-pushreplacement-的導航">用 push 做應該用 pushReplacement 的導航&lt;/h3>
&lt;p>步驟式流程中「步驟 1 → 步驟 2」如果用 &lt;code>push&lt;/code>，使用者在步驟 2 按 back 回到步驟 1。如果步驟 1 的操作不可逆（已經提交了資料），回到步驟 1 沒有意義。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>Flutter GoRouter 的完整導航 API → &lt;a href="https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/flutter-gorouter/" data-link-title="Flutter GoRouter 導航設計" data-link-desc="GoRouter 的路由定義、導航 API（go / push / pushReplacement）、redirect 機制和 ShellRoute 的使用場景">Flutter GoRouter 導航設計&lt;/a>&lt;/li>
&lt;li>導航模式分類 → &lt;a href="https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/mobile-navigation-taxonomy/" data-link-title="Mobile 導航模式分類" data-link-desc="Push/pop stack / declarative router / tab bar / drawer — 四種 mobile 導航模式各自的適用場景和使用者心理模型">Mobile 導航模式分類&lt;/a>&lt;/li>
&lt;li>路由可達性檢查 → &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">ux-design 模組一 路由可達性&lt;/a>&lt;/li>
&lt;li>導航路徑的自動化測試 → &lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">testing 模組四 自動化 UI 驗證&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p><code>go</code>、<code>push</code>、<code>pushReplacement</code> 三種導航方法改變導航堆疊的方式不同，直接影響使用者按 back 時的行為。選擇哪種方法的依據是使用者的操作意圖 — 使用者期望按 back 時回到哪裡。</p>
<h2 id="語意對照表">語意對照表</h2>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>堆疊行為</th>
          <th>按 back 回到</th>
          <th>使用者意圖</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>go(path)</code></td>
          <td>替換整個堆疊</td>
          <td>無（離開 app）</td>
          <td>切換到另一個工作區</td>
      </tr>
      <tr>
          <td><code>push(path)</code></td>
          <td>推入堆疊頂端</td>
          <td>前一個畫面</td>
          <td>暫時離開，做完回來</td>
      </tr>
      <tr>
          <td><code>pushReplacement</code></td>
          <td>替換堆疊頂端</td>
          <td>更早的畫面</td>
          <td>流程中的下一步（不可回退）</td>
      </tr>
  </tbody>
</table>
<h2 id="go切換工作區">go：切換工作區</h2>
<p><code>go</code> 把整個導航堆疊替換成新的路徑。使用者按 back 不會回到操作前的畫面，因為堆疊已經被替換。</p>
<p>適合場景：</p>
<ul>
<li>登入成功後到首頁（使用者不應該按 back 回到登入畫面）</li>
<li>登出後到登入畫面（使用者不應該按 back 回到需要認證的畫面）</li>
<li>從 onboarding 到主畫面（onboarding 完成後不需要回去）</li>
</ul>
<p>誤用 <code>go</code> 的後果：使用者期望按 back 回到前一個畫面但堆疊已空，按 back 直接離開 app。app_tunnel 修復時選擇 <code>push('/enrollment')</code> 而非 <code>go('/enrollment')</code>，讓使用者配對完成後能按 back 回首頁（<a href="/blog/ux-design/cases/missing-enrollment-entry-point/" data-link-title="U.C4 首頁缺配對入口按鈕、導航流未完整列出" data-link-desc="Flutter app 首頁只有 Connect Terminal 按鈕、沒有 Enroll Device 入口 — 使用者首次使用時找不到配對功能。根因是導航流設計只考慮了日常操作（UC-02 連線）、遺漏了首次操作（UC-01 配對）的入口">U.C4</a>）。</p>
<h2 id="push暫時離開做完回來">push：暫時離開，做完回來</h2>
<p><code>push</code> 在堆疊頂端加入新畫面。使用者按 back 回到前一個畫面。</p>
<p>適合場景：</p>
<ul>
<li>從列表到詳細頁（看完回到列表）</li>
<li>從首頁到配對畫面（配對完回首頁）</li>
<li>從任何畫面到設定頁（改完設定回原畫面）</li>
</ul>
<p><code>push</code> 是最常用的導航方法，因為多數導航都是「暫時去另一個畫面做事，做完回來」的模式。</p>
<h2 id="pushreplacement流程中前進">pushReplacement：流程中前進</h2>
<p><code>pushReplacement</code> 用新畫面替換堆疊頂端。堆疊深度不變，按 back 回到替換前畫面的前一個畫面（跳過被替換的畫面）。</p>
<p>適合場景：</p>
<ul>
<li>步驟式流程：步驟 1 → pushReplacement 步驟 2 → pushReplacement 步驟 3。使用者在步驟 3 按 back 回到流程開始前的畫面，不會回到步驟 2 或 1。</li>
<li>結果頁替換搜尋頁：搜尋結果替換搜尋條件頁，使用者按 back 回到搜尋前的畫面。</li>
</ul>
<p>pushReplacement 的語意是「這一步完成後使用者不需要回到這裡」。用於不可回退的流程步驟。</p>
<h2 id="選擇決策流程">選擇決策流程</h2>
<p>對每個導航操作問一個問題：<strong>使用者按 back 時，期望回到哪裡？</strong></p>
<ul>
<li>回到前一個畫面 → <code>push</code></li>
<li>離開 app 或回到 app 的根畫面 → <code>go</code></li>
<li>跳過當前畫面，回到更早的畫面 → <code>pushReplacement</code></li>
</ul>
<p>這個決策應該在 UX 設計階段做，記錄在畫面狀態矩陣的「退出路徑」欄中。開發者實作時對照矩陣選擇正確的導航方法。</p>
<h2 id="常見誤用">常見誤用</h2>
<h3 id="用-go-做應該用-push-的導航">用 go 做應該用 push 的導航</h3>
<p>「首頁 → 配對畫面」如果用 <code>go</code>，使用者配對完成後按 back 離開 app 而非回到首頁。使用者期望的是「配對完成回首頁」（push 行為）。</p>
<h3 id="用-push-做應該用-go-的導航">用 push 做應該用 go 的導航</h3>
<p>「登入 → 首頁」如果用 <code>push</code>，使用者在首頁按 back 回到登入畫面。使用者已經登入，不應該看到登入畫面。</p>
<h3 id="用-push-做應該用-pushreplacement-的導航">用 push 做應該用 pushReplacement 的導航</h3>
<p>步驟式流程中「步驟 1 → 步驟 2」如果用 <code>push</code>，使用者在步驟 2 按 back 回到步驟 1。如果步驟 1 的操作不可逆（已經提交了資料），回到步驟 1 沒有意義。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Flutter GoRouter 的完整導航 API → <a href="/blog/ux-design/05-navigation-patterns/flutter-gorouter/" data-link-title="Flutter GoRouter 導航設計" data-link-desc="GoRouter 的路由定義、導航 API（go / push / pushReplacement）、redirect 機制和 ShellRoute 的使用場景">Flutter GoRouter 導航設計</a></li>
<li>導航模式分類 → <a href="/blog/ux-design/05-navigation-patterns/mobile-navigation-taxonomy/" data-link-title="Mobile 導航模式分類" data-link-desc="Push/pop stack / declarative router / tab bar / drawer — 四種 mobile 導航模式各自的適用場景和使用者心理模型">Mobile 導航模式分類</a></li>
<li>路由可達性檢查 → <a href="/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">ux-design 模組一 路由可達性</a></li>
<li>導航路徑的自動化測試 → <a href="/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">testing 模組四 自動化 UI 驗證</a></li>
</ul>
]]></content:encoded></item><item><title>9.5 工具決策：regex 到 AST、Python 到 Go 的 tripwire</title><link>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/tool-decision-tripwire/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/tool-decision-tripwire/</guid><description>&lt;p>工具決策的核心責任是&lt;strong>用事前約定的條件決定何時升級，取代事後的模糊直覺&lt;/strong>。&lt;a href="https://tarrragon.github.io/blog/go/glossary/#tripwire-%e6%b1%ba%e7%ad%96" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">Tripwire 決策&lt;/a>（預設觸發條件）的設計：在某個可量測訊號命中時，主動重新評估現有工具是否夠用、或該升級到下一個層級。這個約定避開兩個常見失敗 —「太早升級」（把 shell 一行解決的事過度工程化）跟「太晚升級」（regex 每週出狀況卻忍著不升，信譽破產）。&lt;/p>
&lt;p>9.1–9.4 講了怎麼寫 Go 工具。這一章退一步，看&lt;strong>什麼時候該寫這個工具、什麼時候該升級既有工具&lt;/strong>。決策錯了，寫得多好都沒用。本章介紹 tripwire 決策法、WRAP 框架在技術決策的套用、以及用 blog 工具鏈選型作為 concrete instance。&lt;/p>
&lt;h2 id="為什麼需要-tripwire">為什麼需要 tripwire&lt;/h2>
&lt;p>「什麼時候升級」本身是決策。如果不做預設，會發生兩件事：&lt;/p>
&lt;p>&lt;strong>太早升級&lt;/strong>：每次問「該不該升」的時候都說「升吧反正不會錯」。結果工具複雜度爆炸，維護成本拖慢產品開發。&lt;/p>
&lt;p>&lt;strong>太晚升級&lt;/strong>：每次都說「regex 再撐一下就好」。結果工具的誤判累積，作者開始手動 override、skip lint、加例外，工具信譽破產。&lt;/p>
&lt;p>Tripwire 是&lt;strong>事前約定&lt;/strong>：「當以下條件之一命中，就重新評估是否升級」。這把「該不該升」從&lt;strong>臨時直覺&lt;/strong>變成&lt;strong>有根據的再評估&lt;/strong>。&lt;/p>
&lt;p>這個概念在 Chip 與 Dan Heath 的《Decisive》裡有詳細討論 — tripwire 的要點是&lt;strong>用事前的明確條件，取代事後的模糊直覺&lt;/strong>。&lt;/p>
&lt;h2 id="wrap-框架套用到工具決策">WRAP 框架套用到工具決策&lt;/h2>
&lt;p>WRAP = Widen options / Reality-test / Attain distance / Prepare to be wrong。對應到技術決策：&lt;/p>
&lt;p>&lt;strong>Widen options&lt;/strong>：不要只在「Go 還是 Python」之間選。多選項至少要有：&lt;/p>
&lt;ul>
&lt;li>現有工具撐著（regex + shell）&lt;/li>
&lt;li>半自動化（Python + regex，50 行腳本）&lt;/li>
&lt;li>自訂工具（Python / Go + 適當 parser）&lt;/li>
&lt;li>買服務（買現成 linter SaaS）&lt;/li>
&lt;li>不解決（接受這個問題）&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Reality-test&lt;/strong>：用數字跟樣本驗證假設。「regex 夠用」是假設，數字可能說「每 100 個 match 裡 15 個誤判」。&lt;/p>
&lt;p>&lt;strong>Attain distance&lt;/strong>：退一步看，如果這個工具三年後可能被捨棄，現在投入多少才合理。&lt;/p>
&lt;p>&lt;strong>Prepare to be wrong&lt;/strong>：先設 tripwire，萬一決策錯了也能及時 pivot，不會沉沒到底。&lt;/p>
&lt;p>blog 的工具鏈決策用這個框架跑過一次，結論是 Go + goldmark。過程紀錄在 &lt;a href="https://tarrragon.github.io/blog/posts/mdtoolsgo--goldmark-%E7%9A%84-markdown-%E5%B7%A5%E5%85%B7%E9%8F%88%E8%A8%AD%E8%A8%88/" data-link-title="mdtools：Go &amp;#43; goldmark 的 markdown 工具鏈設計" data-link-desc="mdtools 的架構決策：選 Go &amp;#43; goldmark 的理由（與 Hugo 同源保證 lint↔render 等價）、單 binary 多子命令設計、pre-commit 整合、規則開啟紀律。">mdtools 設計&lt;/a>，這裡只提煉可複用的決策 pattern。&lt;/p>
&lt;h2 id="三個實戰-tripwire">三個實戰 tripwire&lt;/h2>
&lt;p>以下三組 tripwire 對多數內部工具都適用。遇到其中一個命中時，該花一小時重新評估現有工具是否夠用。&lt;/p>
&lt;h3 id="tripwire-1從-shell-one-liner-升級到腳本">Tripwire 1：從 shell one-liner 升級到腳本&lt;/h3>
&lt;p>&lt;strong>訊號&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>同樣的 shell 指令在三個以上地方重複貼過&lt;/li>
&lt;li>指令超過 3 個 pipe 或巢狀 subshell&lt;/li>
&lt;li>指令的行為要根據環境（CI vs local）分支&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>升級方向&lt;/strong>：寫成 20-50 行 Python 或 Bash script，放進 &lt;code>scripts/&lt;/code>。&lt;/p>
&lt;p>&lt;strong>反例&lt;/strong>：每次寫新 shell 命令都起腳本檔。常用的一行 &lt;code>grep&lt;/code> 不需要變 script。&lt;/p>
&lt;h3 id="tripwire-2從-regex-升級到-parser--ast">Tripwire 2：從 regex 升級到 parser / AST&lt;/h3>
&lt;p>&lt;strong>訊號&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>Regex 需要「上下文判斷」（這個 match 在 code block 內嗎？在 HTML tag 內嗎？）&lt;/li>
&lt;li>規則要處理嵌套結構（表格內的 link、code block 內的 heading）&lt;/li>
&lt;li>誤報率超過 1% 或每週出現&lt;/li>
&lt;li>新規則要知道「父節點」「子節點」（MD024 siblings_only 就是這類）&lt;/li>
&lt;li>跨檔案的 graph 需求出現（backlink 分析、broken link 偵測）&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>升級方向&lt;/strong>：引入該格式的官方 parser（markdown → goldmark；YAML → &lt;code>gopkg.in/yaml.v3&lt;/code>；Go → &lt;code>go/parser&lt;/code>）。&lt;/p></description><content:encoded><![CDATA[<p>工具決策的核心責任是<strong>用事前約定的條件決定何時升級，取代事後的模糊直覺</strong>。<a href="/blog/go/glossary/#tripwire-%e6%b1%ba%e7%ad%96" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">Tripwire 決策</a>（預設觸發條件）的設計：在某個可量測訊號命中時，主動重新評估現有工具是否夠用、或該升級到下一個層級。這個約定避開兩個常見失敗 —「太早升級」（把 shell 一行解決的事過度工程化）跟「太晚升級」（regex 每週出狀況卻忍著不升，信譽破產）。</p>
<p>9.1–9.4 講了怎麼寫 Go 工具。這一章退一步，看<strong>什麼時候該寫這個工具、什麼時候該升級既有工具</strong>。決策錯了，寫得多好都沒用。本章介紹 tripwire 決策法、WRAP 框架在技術決策的套用、以及用 blog 工具鏈選型作為 concrete instance。</p>
<h2 id="為什麼需要-tripwire">為什麼需要 tripwire</h2>
<p>「什麼時候升級」本身是決策。如果不做預設，會發生兩件事：</p>
<p><strong>太早升級</strong>：每次問「該不該升」的時候都說「升吧反正不會錯」。結果工具複雜度爆炸，維護成本拖慢產品開發。</p>
<p><strong>太晚升級</strong>：每次都說「regex 再撐一下就好」。結果工具的誤判累積，作者開始手動 override、skip lint、加例外，工具信譽破產。</p>
<p>Tripwire 是<strong>事前約定</strong>：「當以下條件之一命中，就重新評估是否升級」。這把「該不該升」從<strong>臨時直覺</strong>變成<strong>有根據的再評估</strong>。</p>
<p>這個概念在 Chip 與 Dan Heath 的《Decisive》裡有詳細討論 — tripwire 的要點是<strong>用事前的明確條件，取代事後的模糊直覺</strong>。</p>
<h2 id="wrap-框架套用到工具決策">WRAP 框架套用到工具決策</h2>
<p>WRAP = Widen options / Reality-test / Attain distance / Prepare to be wrong。對應到技術決策：</p>
<p><strong>Widen options</strong>：不要只在「Go 還是 Python」之間選。多選項至少要有：</p>
<ul>
<li>現有工具撐著（regex + shell）</li>
<li>半自動化（Python + regex，50 行腳本）</li>
<li>自訂工具（Python / Go + 適當 parser）</li>
<li>買服務（買現成 linter SaaS）</li>
<li>不解決（接受這個問題）</li>
</ul>
<p><strong>Reality-test</strong>：用數字跟樣本驗證假設。「regex 夠用」是假設，數字可能說「每 100 個 match 裡 15 個誤判」。</p>
<p><strong>Attain distance</strong>：退一步看，如果這個工具三年後可能被捨棄，現在投入多少才合理。</p>
<p><strong>Prepare to be wrong</strong>：先設 tripwire，萬一決策錯了也能及時 pivot，不會沉沒到底。</p>
<p>blog 的工具鏈決策用這個框架跑過一次，結論是 Go + goldmark。過程紀錄在 <a href="/blog/posts/mdtoolsgo--goldmark-%E7%9A%84-markdown-%E5%B7%A5%E5%85%B7%E9%8F%88%E8%A8%AD%E8%A8%88/" data-link-title="mdtools：Go &#43; goldmark 的 markdown 工具鏈設計" data-link-desc="mdtools 的架構決策：選 Go &#43; goldmark 的理由（與 Hugo 同源保證 lint↔render 等價）、單 binary 多子命令設計、pre-commit 整合、規則開啟紀律。">mdtools 設計</a>，這裡只提煉可複用的決策 pattern。</p>
<h2 id="三個實戰-tripwire">三個實戰 tripwire</h2>
<p>以下三組 tripwire 對多數內部工具都適用。遇到其中一個命中時，該花一小時重新評估現有工具是否夠用。</p>
<h3 id="tripwire-1從-shell-one-liner-升級到腳本">Tripwire 1：從 shell one-liner 升級到腳本</h3>
<p><strong>訊號</strong>：</p>
<ul>
<li>同樣的 shell 指令在三個以上地方重複貼過</li>
<li>指令超過 3 個 pipe 或巢狀 subshell</li>
<li>指令的行為要根據環境（CI vs local）分支</li>
</ul>
<p><strong>升級方向</strong>：寫成 20-50 行 Python 或 Bash script，放進 <code>scripts/</code>。</p>
<p><strong>反例</strong>：每次寫新 shell 命令都起腳本檔。常用的一行 <code>grep</code> 不需要變 script。</p>
<h3 id="tripwire-2從-regex-升級到-parser--ast">Tripwire 2：從 regex 升級到 parser / AST</h3>
<p><strong>訊號</strong>：</p>
<ul>
<li>Regex 需要「上下文判斷」（這個 match 在 code block 內嗎？在 HTML tag 內嗎？）</li>
<li>規則要處理嵌套結構（表格內的 link、code block 內的 heading）</li>
<li>誤報率超過 1% 或每週出現</li>
<li>新規則要知道「父節點」「子節點」（MD024 siblings_only 就是這類）</li>
<li>跨檔案的 graph 需求出現（backlink 分析、broken link 偵測）</li>
</ul>
<p><strong>升級方向</strong>：引入該格式的官方 parser（markdown → goldmark；YAML → <code>gopkg.in/yaml.v3</code>；Go → <code>go/parser</code>）。</p>
<p><strong>反例</strong>：簡單的「每行開頭是 <code>#</code> 就當 heading」這類規則，regex 永遠夠用。不要為了「學 AST」硬上。</p>
<h3 id="tripwire-3從腳本語言升級到-go">Tripwire 3：從腳本語言升級到 Go</h3>
<p><strong>訊號</strong>：</p>
<ul>
<li>需要 parse 有官方 Go 實作的格式（goldmark、go/ast、protobuf 等）</li>
<li>需要跨平台分發單一 binary</li>
<li>Python / Node 的啟動時間在 pre-commit 的 accumulated cost 已感</li>
<li>要整合到 Go 生態系（產生 Go 程式碼、讀 Go 原始碼）</li>
<li>團隊主要語言是 Go，Python 腳本的維護者變成單一瓶頸</li>
</ul>
<p><strong>升級方向</strong>：Go。</p>
<p><strong>反例</strong>：臨時的資料轉換、一次性的 data migration、快速 prototyping — Python 永遠比 Go 快動筆。</p>
<h2 id="實戰紀錄blog-的三層升級">實戰紀錄：blog 的三層升級</h2>
<p>blog 的 markdown 品質工具鏈在一個 session 內走完三層升級。把這個時間線攤開當案例。</p>
<h3 id="layer-0沒工具靠-markdownlint-ide-extension">Layer 0：沒工具，靠 markdownlint IDE extension</h3>
<p><strong>狀況</strong>：IDE 裝 markdownlint extension，作者寫稿時看到 yellow underline 手動改。</p>
<p><strong>出現什麼 tripwire</strong>：內容規模長大後，reviewer 收到 PR 發現 20 個 MD026 違規，手動改成 cognitive burden。更糟的是紅隊教材有平行結構（13 個案例各有 <code>### 弱點環節</code>），被 MD024 誤判為重複，作者開始 ignore 警告。</p>
<p><strong>升級驅動</strong>：Tripwire 2 命中（規則需要父標題上下文，siblings_only 規則 IDE 沒有）。決策：升級到自訂工具。</p>
<h3 id="layer-1-候選python--regex">Layer 1 候選：Python + regex</h3>
<p><strong>狀況</strong>：50 行 Python 腳本，逐行 match。</p>
<p><strong>為什麼沒選</strong>：兩個跨檔需求已經浮現 — 卡片雙向完整性、L1 link 驗證。這是 graph 需求，regex 做不到 (Tripwire 2 的後半段命中)。加上 blog 本身用 Hugo（Go 寫的，markdown 由 goldmark parse），用 Python 的 markdown parser 會有 render 跟 lint 判讀不一致的長尾風險。</p>
<p>這個評估花了約 15 分鐘，記錄在決策文件裡 — 重點是<strong>評估本身有 artefact 可追溯</strong>。</p>
<h3 id="layer-2go--goldmark">Layer 2：Go + goldmark</h3>
<p><strong>狀況</strong>：選 Go，因為 (a) goldmark 是 Hugo 的 parser，lint 結果跟 render 必然一致；(b) 跨檔 graph 分析用 Go struct 乾淨；(c) 單一 binary 方便接 pre-commit hook 跟 CI，不用擔心 Python 環境。</p>
<p><strong>如何驗證決策正確</strong>：看三個月後的狀態 — 工具有沒有被 bypass？新規則加起來順不順？CI 有沒有反覆失敗？作者有沒有開始覺得工具阻礙產出？這些訊號都沒出現，表示決策有效。若有出現，就是 Tripwire 3 的反向觸發（「該降級回 Python」或「該拆成多個專門工具」），又要重新評估。</p>
<h2 id="延遲決策的具體成本">延遲決策的具體成本</h2>
<p>常見反論：「不急，等真的需要再升」。問題是<strong>延遲本身有成本</strong>：</p>
<ul>
<li><strong>Technical debt 複利</strong>：regex 工具越長越大，每條新 rule 都變難，最後要重寫時要一次 migration 所有 rule。</li>
<li><strong>誤報侵蝕信譽</strong>：使用者每週看到工具報錯、檢查後發現是誤判，開始忽略工具。信譽一旦壞，再好的工具也沒用。</li>
<li><strong>Option value 流失</strong>：跨檔分析、graph 視覺化、CI 整合這些 downstream feature 都要在 AST 基礎上才能做；延遲升級等於延遲 feature 路徑。</li>
<li><strong>機會成本複利</strong>：每週花 30 分鐘手動改 lint 誤判，一年累積 26 小時 — 比升級工具的 8 小時多 3 倍。</li>
</ul>
<p>時間視角變長，升級的 NPV 幾乎永遠正。<strong>延遲不是零成本的預設，是要主動合理化的選擇</strong>。</p>
<h2 id="為什麼-blog-不走先-python-再-go的雙階段">為什麼 blog 不走「先 Python 再 Go」的雙階段</h2>
<p>有個常見建議：「先用 Python 快速做出雛形，驗證概念後再用 Go 重寫」。這個建議對<strong>不確定需求</strong>的情境有效（「我們不知道要什麼工具」），對<strong>已知需求</strong>的情境是浪費。</p>
<p>blog 的狀況是已知：</p>
<ul>
<li>要 markdown lint + 跨檔 graph + pre-commit + CI — 四個需求都很明確</li>
<li>目標語言（Go）已經確定（Hugo 生態、單一 binary 需求）</li>
<li>第三方 parser 選擇（goldmark）已經是最優解</li>
</ul>
<p>在這些條件下，寫 Python 原型的唯一價值是「學 AST 概念」。但同樣學習也能直接用 Go + goldmark 完成。花兩倍時間寫兩遍只為了「先驗證」，邏輯不成立。</p>
<p>判準：<strong>需求越明確、目標語言越確定，雙階段越浪費；需求越模糊、選型還在評估，雙階段越值得</strong>。</p>
<h2 id="決策的副作用artefact">決策的副作用：artefact</h2>
<p>不管最終選什麼，決策過程本身要留下 artefact。blog 案例裡的 artefact：</p>
<ul>
<li><a href="/blog/posts/mdtoolsgo--goldmark-%E7%9A%84-markdown-%E5%B7%A5%E5%85%B7%E9%8F%88%E8%A8%AD%E8%A8%88/" data-link-title="mdtools：Go &#43; goldmark 的 markdown 工具鏈設計" data-link-desc="mdtools 的架構決策：選 Go &#43; goldmark 的理由（與 Hugo 同源保證 lint↔render 等價）、單 binary 多子命令設計、pre-commit 整合、規則開啟紀律。">mdtools 設計紀錄</a>：為什麼是 Go、為什麼是 goldmark、tripwire 怎麼設的</li>
<li><a href="/blog/posts/%E4%BB%80%E9%BA%BC%E6%98%AF-ast-%E5%BE%9E%E5%AD%97%E4%B8%B2%E5%88%B0%E8%AA%9E%E6%B3%95%E6%A8%B9%E7%9A%84%E8%A6%96%E8%A7%92%E8%BD%89%E6%8F%9B/" data-link-title="什麼是 AST — 從字串到語法樹的視角轉換" data-link-desc="AST 與 regex 的差異判準：規則需要知道文字處在什麼結構中時 regex 就不夠。附 regex 誤判的具體 case。">什麼是 AST</a>：AST vs regex 的概念說明</li>
<li><a href="/blog/posts/blog-markdown-%E5%AF%AB%E4%BD%9C%E8%A6%8F%E7%AF%84%E8%88%87-mdtools-%E6%AA%A2%E6%9F%A5/" data-link-title="Blog Markdown 寫作規範與 mdtools 檢查" data-link-desc="本 blog 的 Markdown 排版規範權威契約。涵蓋 H1 禁用、MD024 siblings_only、反釣魚 TLD 校驗、卡片雙向完整性、front matter schema；改規則時要與 scripts/mdtools 實作同步。">markdown 寫作規範</a>：工具要滿足的契約</li>
</ul>
<p>三個文件加起來約 900 行，寫作時間不到半天。</p>
<p><strong>為什麼留 artefact 比決策本身更重要</strong>：</p>
<ul>
<li>半年後同樣問題再浮現時，不會重跑一遍評估</li>
<li>新加入的協作者能快速跟上決策脈絡</li>
<li>Tripwire 條件寫下來才能被驗證（「三個月後有沒有命中？」）</li>
<li>反面證據出現時（例如發現 goldmark 有 bug），有清楚的位置記錄 revised decision</li>
</ul>
<p>沒 artefact 的決策基本上等於沒做過。<strong>決策是動作，artefact 才是沉澱</strong>。</p>
<h2 id="常見陷阱">常見陷阱</h2>
<h3 id="把-tripwire-設太低">把 tripwire 設太低</h3>
<p>「每週誤判一次就升級」實務上等於「每週升級」。Tripwire 要設在<strong>真的造成信譽或產出瓶頸</strong>的位置。</p>
<h3 id="把-tripwire-設太高">把 tripwire 設太高</h3>
<p>「等到 50% 誤判才升級」就太晚了 — 信譽早就垮了。合理範圍是 1-5% 誤判，或每週一次以上。</p>
<h3 id="用-tripwire-取代日常-review">用 tripwire 取代日常 review</h3>
<p>Tripwire 是「提醒重新評估」，不是「自動升級」。命中時要花時間評估，可能發現「還不該升，因為還有 X 原因」。Tripwire 是重新思考的觸發，不是自動化決策。</p>
<h3 id="忽視已命中的-tripwire">忽視已命中的 tripwire</h3>
<p>「這個誤判已經出現第四週了，但我還是覺得先不要升」— 這是在告訴自己原本的 tripwire 設錯了，不是在等更好的時機。重新評估 tripwire 本身，不是 ignore。</p>
<h2 id="擴充路徑">擴充路徑</h2>
<ul>
<li><strong>Decision log 範本</strong>：把團隊的決策過程寫成 template，讓下次不用從零開始</li>
<li><strong>Post-mortem of decisions</strong>：決策後三個月回頭看，把「當時怎麼想」跟「現在怎麼看」對照</li>
<li><strong>Pre-mortem 技巧</strong>：決策前假設「三個月後這決定被推翻，最可能的原因是什麼」，當成補充 tripwire</li>
</ul>
<h2 id="下一步">下一步</h2>
<p><a href="/blog/go/09-tooling-and-analysis/pre-commit-and-ci/" data-link-title="9.6 Pre-commit hook 與 CI 整合" data-link-desc="工具寫完只是起點；接到 pre-commit hook 跟 CI 才真正守住品質。Re-staging、dry-run vs apply、不能繞過的邊界">9.6 pre-commit hook 與 CI 整合</a> 回到工程落地，看工具怎麼從 binary 變成 commit 與 CI 流程裡的執行體。</p>
]]></content:encoded></item><item><title>0.5 Go 和其他並發語言的差異</title><link>https://tarrragon.github.io/blog/go/00-philosophy/concurrency-language-position/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/00-philosophy/concurrency-language-position/</guid><description>&lt;p>Go 在並發語言中的核心定位是「用較低語言複雜度寫出可部署、可維護的高併發服務」。現代語言大多能處理並發；Go 的特色在於 goroutine、channel、context、標準庫與單一 binary 共同形成一套服務工程模型。&lt;/p>
&lt;p>語言比較的核心判斷是「哪一種並發模型會讓目前服務更容易寫清楚、部署簡單、長期維護」。Java、C#、Rust、Node.js、Python async、Erlang/Elixir 都能處理並發；這一章要比較的是它們各自把並發、生命週期與服務交付放在哪一種工程模型裡。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>區分「能並發」和「適合某種並發服務」的差異&lt;/li>
&lt;li>看懂 Go 的 goroutine 模型和 thread pool、async/await、actor model 的工程差異&lt;/li>
&lt;li>判斷 Go 與 Java/C#、Rust、Node.js、Python async、Erlang/Elixir 的選型邊界&lt;/li>
&lt;li>用工作負載、團隊維護成本與部署形態來比較語言&lt;/li>
&lt;li>把語言比較轉回工程問題，形成可檢查的選型依據&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察現代語言大多有並發能力">【觀察】現代語言大多有並發能力&lt;/h2>
&lt;p>並發能力已經是現代後端語言的基本能力。Java 有 thread pool、virtual threads 與成熟框架；C# 有 Task 與 async/await；Rust 有 async runtime 與底層控制；Node.js 和 Python 有事件迴圈與 async 生態；Erlang/Elixir 有 actor 與 supervision tree。&lt;/p>
&lt;p>因此，Go 的選型問題應該聚焦在「哪一種並發模型符合目前工作負載」。更有用的問題是：&lt;/p>
&lt;ol>
&lt;li>這個服務主要是大量等待 I/O，還是大量 CPU 計算？&lt;/li>
&lt;li>團隊希望用同步風格寫流程，還是接受 async callback / async function 傳播？&lt;/li>
&lt;li>服務是否需要大量長生命週期工作單元？&lt;/li>
&lt;li>部署是否重視單一 binary、啟動速度與少量 runtime 依賴？&lt;/li>
&lt;li>團隊是否更重視語言簡單度、企業框架、底層控制或容錯模型？&lt;/li>
&lt;/ol>
&lt;p>這些問題會把語言比較轉成工程比較。語言本身只是工具，工作負載與團隊約束才是選型依據。&lt;/p>
&lt;h2 id="判讀go-的差異是服務工程模型">【判讀】Go 的差異是服務工程模型&lt;/h2>
&lt;p>Go 的並發模型把「工作單位」表達成 goroutine，把「取消與逾時」表達成 context，把「協調訊號」表達成 channel 或同步原語。這讓大量等待型工作可以長得像普通函式流程。&lt;/p>
&lt;p>典型 Go 服務會長成這樣：&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">handle&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">request&lt;/span> &lt;span class="nx">Request&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"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">result&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&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">Fetch&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">request&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"> 3&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="kc">nil&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="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;fetch data: %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"> 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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&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">repository&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Save&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">result&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"> 8&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;save result: %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"> 9&lt;/span>&lt;span class="cl"> &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>&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="kc">nil&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>這段程式沒有展示 goroutine，但它已經承接 Go 並發服務的核心語意：每個 request 有自己的 context，外部 I/O 接受取消，錯誤沿著呼叫鏈回傳。當這段流程被 HTTP handler、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool&lt;/a> 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a> 呼叫時，生命週期仍然清楚。&lt;/p>
&lt;p>Go 的優勢通常出現在三個地方：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>面向&lt;/th>
 &lt;th>Go 的工程特性&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>並發工作單位&lt;/td>
 &lt;td>goroutine 成本低，適合大量等待型工作&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>生命週期控制&lt;/td>
 &lt;td>&lt;code>context&lt;/code> 讓 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>、cancel、request-scoped value 有共同傳遞方式&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>服務交付&lt;/td>
 &lt;td>編譯成單一 binary，container、CLI、sidecar 與小型服務部署簡單&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表只是索引。下面幾節會把 Go 放到不同語言模型旁邊比較，重點是辨識每種模型適合的服務形狀。&lt;/p>
&lt;h2 id="判讀go-vs-java--c輕量服務模型與成熟平台模型">【判讀】Go vs Java / C#：輕量服務模型與成熟平台模型&lt;/h2>
&lt;p>Java 與 C# 的核心優勢是成熟平台、企業框架、完整工具鏈與大型組織生態。當系統需要完整 ORM、生態整合、企業身份驗證、複雜業務框架、長期平台治理時，Java / C# 經常是穩定選擇。&lt;/p>
&lt;p>接近真實網路服務的例子包括：&lt;/p>
&lt;ul>
&lt;li>大型銀行或保險系統，需要完整交易、稽核、權限與企業整合&lt;/li>
&lt;li>企業內部 ERP、CRM、供應鏈系統，需要成熟框架與長期治理&lt;/li>
&lt;li>使用 Spring、ASP.NET、Entity Framework 等框架已經形成團隊標準的組織&lt;/li>
&lt;/ul>
&lt;p>Go 的差異在於服務模型更輕。當服務主要是 HTTP/gRPC API、background worker、gateway、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> server、CLI 或基礎設施元件時，Go 可以用較少框架建立清楚邊界。程式啟動、部署、容器化和交接通常也比較直接。&lt;/p>
&lt;p>判斷問題可以這樣問：這個系統的主要價值在成熟企業平台與框架整合，還是在小型服務、簡單部署、清楚並發生命週期？前者常偏向 Java/C# 生態，後者常讓 Go 更有吸引力。&lt;/p>
&lt;h2 id="判讀go-vs-rust服務工程與底層控制">【判讀】Go vs Rust：服務工程與底層控制&lt;/h2>
&lt;p>Rust 的核心優勢是記憶體安全、零成本抽象、所有權模型與底層控制。當系統需要精細控制記憶體、避免 GC pause、處理高效能底層元件或安全敏感邊界時，Rust 的能力很強。&lt;/p></description><content:encoded><![CDATA[<p>Go 在並發語言中的核心定位是「用較低語言複雜度寫出可部署、可維護的高併發服務」。現代語言大多能處理並發；Go 的特色在於 goroutine、channel、context、標準庫與單一 binary 共同形成一套服務工程模型。</p>
<p>語言比較的核心判斷是「哪一種並發模型會讓目前服務更容易寫清楚、部署簡單、長期維護」。Java、C#、Rust、Node.js、Python async、Erlang/Elixir 都能處理並發；這一章要比較的是它們各自把並發、生命週期與服務交付放在哪一種工程模型裡。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>區分「能並發」和「適合某種並發服務」的差異</li>
<li>看懂 Go 的 goroutine 模型和 thread pool、async/await、actor model 的工程差異</li>
<li>判斷 Go 與 Java/C#、Rust、Node.js、Python async、Erlang/Elixir 的選型邊界</li>
<li>用工作負載、團隊維護成本與部署形態來比較語言</li>
<li>把語言比較轉回工程問題，形成可檢查的選型依據</li>
</ol>
<hr>
<h2 id="觀察現代語言大多有並發能力">【觀察】現代語言大多有並發能力</h2>
<p>並發能力已經是現代後端語言的基本能力。Java 有 thread pool、virtual threads 與成熟框架；C# 有 Task 與 async/await；Rust 有 async runtime 與底層控制；Node.js 和 Python 有事件迴圈與 async 生態；Erlang/Elixir 有 actor 與 supervision tree。</p>
<p>因此，Go 的選型問題應該聚焦在「哪一種並發模型符合目前工作負載」。更有用的問題是：</p>
<ol>
<li>這個服務主要是大量等待 I/O，還是大量 CPU 計算？</li>
<li>團隊希望用同步風格寫流程，還是接受 async callback / async function 傳播？</li>
<li>服務是否需要大量長生命週期工作單元？</li>
<li>部署是否重視單一 binary、啟動速度與少量 runtime 依賴？</li>
<li>團隊是否更重視語言簡單度、企業框架、底層控制或容錯模型？</li>
</ol>
<p>這些問題會把語言比較轉成工程比較。語言本身只是工具，工作負載與團隊約束才是選型依據。</p>
<h2 id="判讀go-的差異是服務工程模型">【判讀】Go 的差異是服務工程模型</h2>
<p>Go 的並發模型把「工作單位」表達成 goroutine，把「取消與逾時」表達成 context，把「協調訊號」表達成 channel 或同步原語。這讓大量等待型工作可以長得像普通函式流程。</p>
<p>典型 Go 服務會長成這樣：</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">handle</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">request</span> <span class="nx">Request</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">result</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">client</span><span class="p">.</span><span class="nf">Fetch</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">request</span><span class="p">.</span><span class="nx">ID</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="nx">err</span> <span class="o">!=</span> <span class="kc">nil</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="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;fetch data: %w&#34;</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repository</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">result</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"> 8</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;save result: %w&#34;</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式沒有展示 goroutine，但它已經承接 Go 並發服務的核心語意：每個 request 有自己的 context，外部 I/O 接受取消，錯誤沿著呼叫鏈回傳。當這段流程被 HTTP handler、<a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool</a> 或 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 呼叫時，生命週期仍然清楚。</p>
<p>Go 的優勢通常出現在三個地方：</p>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>Go 的工程特性</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>並發工作單位</td>
          <td>goroutine 成本低，適合大量等待型工作</td>
      </tr>
      <tr>
          <td>生命週期控制</td>
          <td><code>context</code> 讓 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>、cancel、request-scoped value 有共同傳遞方式</td>
      </tr>
      <tr>
          <td>服務交付</td>
          <td>編譯成單一 binary，container、CLI、sidecar 與小型服務部署簡單</td>
      </tr>
  </tbody>
</table>
<p>這張表只是索引。下面幾節會把 Go 放到不同語言模型旁邊比較，重點是辨識每種模型適合的服務形狀。</p>
<h2 id="判讀go-vs-java--c輕量服務模型與成熟平台模型">【判讀】Go vs Java / C#：輕量服務模型與成熟平台模型</h2>
<p>Java 與 C# 的核心優勢是成熟平台、企業框架、完整工具鏈與大型組織生態。當系統需要完整 ORM、生態整合、企業身份驗證、複雜業務框架、長期平台治理時，Java / C# 經常是穩定選擇。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>大型銀行或保險系統，需要完整交易、稽核、權限與企業整合</li>
<li>企業內部 ERP、CRM、供應鏈系統，需要成熟框架與長期治理</li>
<li>使用 Spring、ASP.NET、Entity Framework 等框架已經形成團隊標準的組織</li>
</ul>
<p>Go 的差異在於服務模型更輕。當服務主要是 HTTP/gRPC API、background worker、gateway、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> server、CLI 或基礎設施元件時，Go 可以用較少框架建立清楚邊界。程式啟動、部署、容器化和交接通常也比較直接。</p>
<p>判斷問題可以這樣問：這個系統的主要價值在成熟企業平台與框架整合，還是在小型服務、簡單部署、清楚並發生命週期？前者常偏向 Java/C# 生態，後者常讓 Go 更有吸引力。</p>
<h2 id="判讀go-vs-rust服務工程與底層控制">【判讀】Go vs Rust：服務工程與底層控制</h2>
<p>Rust 的核心優勢是記憶體安全、零成本抽象、所有權模型與底層控制。當系統需要精細控制記憶體、避免 GC pause、處理高效能底層元件或安全敏感邊界時，Rust 的能力很強。</p>
<p>接近真實網路服務與系統元件的例子包括：</p>
<ul>
<li>高效能 proxy、資料處理引擎或邊緣運算元件</li>
<li>需要控制記憶體配置與延遲尖峰的低層服務</li>
<li>瀏覽器、資料庫、區塊鏈節點、嵌入式或安全敏感元件</li>
</ul>
<p>Go 的差異在於它把 GC、簡單型別、顯式錯誤處理和 goroutine 組成服務工程預設值。團隊通常可以更快建立 HTTP service、worker、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 或內部平台工具。Go 會讓你接受 runtime 管理記憶體，換取較低心智負擔與較快服務交付。</p>
<p>判斷問題可以這樣問：主要風險是記憶體控制與極致效能，還是服務生命週期、部署、可讀性與交付速度？前者常讓 Rust 更合理，後者常讓 Go 更直接。</p>
<h2 id="判讀go-vs-nodejs--python-async同步風格與事件迴圈模型">【判讀】Go vs Node.js / Python async：同步風格與事件迴圈模型</h2>
<p>Node.js 與 Python async 的核心優勢是事件迴圈模型、豐富應用生態與快速產品整合。當服務以 I/O 為主，且團隊已經在 JavaScript、TypeScript 或 Python 生態中累積大量工具，async/await 可以建立高產能工作流。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>以 Next.js、Remix、FastAPI、Django 或 Flask 為核心的產品服務</li>
<li>需要快速串接 SaaS API、資料處理腳本、內容管理與前端整合的系統</li>
<li>團隊主要技能集中在 JavaScript/TypeScript 或 Python 的新產品</li>
</ul>
<p>Go 的差異在於 goroutine 讓等待型流程看起來更接近普通同步程式。當一個 request 需要呼叫多個下游、寫入狀態、處理 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>、再把錯誤回傳，Go 通常能把控制流程維持在直線式函式中。多核心 CPU 使用、長時間 worker、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 連線與 <a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">shutdown</a> 流程也能用同一套 goroutine/context 模型處理。</p>
<p>判斷問題可以這樣問：主要價值在前端/資料/腳本生態和快速整合，還是在長時間服務、清楚生命週期與單一部署產物？前者常偏向 Node.js 或 Python async 生態，後者常讓 Go 更自然。</p>
<h2 id="判讀go-vs-erlang--elixir通用服務與-actor-容錯模型">【判讀】Go vs Erlang / Elixir：通用服務與 actor 容錯模型</h2>
<p>Erlang / Elixir 的核心優勢是 actor model、supervision tree、熱更新文化與分散式容錯思想。當系統需要大量獨立 actor、強調隔離、復原和訊息傳遞時，BEAM 生態有非常成熟的模型。</p>
<p>接近真實網路服務的例子包括：</p>
<ul>
<li>即時通訊與 presence 系統</li>
<li>大量獨立 session、room、process 的通訊服務</li>
<li>需要 supervision tree 管理故障復原的長時間系統</li>
</ul>
<p>Go 的差異在於它更像通用後端與基礎設施語言。你可以用 goroutine 和 channel 建立 actor-like 結構，但 Go 的標準模型更偏向明確組裝：handler、worker、repository、publisher、context cancellation、<a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown</a>。這讓 Go 在一般 API service、worker、CLI、gateway、sidecar、平台工具中更容易被多數後端團隊採用。</p>
<p>判斷問題可以這樣問：系統核心是否需要 actor supervision 與 fault-tolerant messaging 作為主要模型？如果答案是肯定的，Erlang / Elixir 值得認真評估；如果系統是一般後端服務與平台元件，Go 的採用門檻與部署模型通常更直接。</p>
<h2 id="策略用比較軸選語言">【策略】用比較軸選語言</h2>
<p>語言比較應該回到可觀察的工程條件。下面這張表可以當成選型索引：</p>
<table>
  <thead>
      <tr>
          <th>條件</th>
          <th>更常見的候選方向</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>大量 I/O、長連線、worker、簡單部署</td>
          <td>Go</td>
      </tr>
      <tr>
          <td>大型企業框架、成熟平台治理、完整商業系統生態</td>
          <td>Java / C#</td>
      </tr>
      <tr>
          <td>記憶體控制、底層效能、安全敏感元件</td>
          <td>Rust</td>
      </tr>
      <tr>
          <td>前端整合、SaaS 串接、資料腳本、產品快速整合</td>
          <td>Node.js / Python async</td>
      </tr>
      <tr>
          <td>actor、supervision、分散式容錯模型</td>
          <td>Erlang / Elixir</td>
      </tr>
  </tbody>
</table>
<p>這張表的用途是建立第一輪比較方向。實際選型還要看團隊經驗、既有系統、部署平台、觀測工具、人才供給與維護週期。</p>
<p>若一個服務需要同時支援 HTTP API、背景 worker、<a href="/blog/backend/knowledge-cards/webhook/" data-link-title="Webhook" data-link-desc="說明外部系統回呼事件的接收、驗證與處理邊界">Webhook</a> callback、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 推送與簡單容器部署，Go 的整體組合很強。若一個產品主要依賴企業框架、動態產品流程、底層控制或 actor 容錯，其他語言可能更貼近主要問題。</p>
<h2 id="執行把語言比較寫成工程判斷">【執行】把語言比較寫成工程判斷</h2>
<p>好的語言比較結論應該包含工作負載、主要風險與取捨。語言名稱是結論，前面的工程條件才是判斷依據。</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">這個服務主要是 [webhook](/backend/knowledge-cards/webhook/) receiver、[queue](/backend/knowledge-cards/queue/) [consumer](/backend/knowledge-cards/consumer/) 與 [WebSocket](/backend/knowledge-cards/websocket/) 推送。
</span></span><span class="line"><span class="ln">2</span><span class="cl">主要風險是大量 I/O、[timeout](/backend/knowledge-cards/timeout/)、[backpressure](/go/backend/knowledge-cards/backpressure/) 與 [graceful shutdown](/backend/knowledge-cards/graceful-shutdown/)。
</span></span><span class="line"><span class="ln">3</span><span class="cl">Go 的 goroutine/context 模型和單一 binary 部署符合這些條件，所以 Go 是好候選。</span></span></code></pre></div><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">這個系統主要是企業內部資料管理、權限、報表與工作流。
</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">Java / C# 生態可能比 Go 更貼近主問題。</span></span></code></pre></div><p>語言選型的核心輸出是「為什麼這個工作負載適合某個模型」。當比較句能說清楚工作負載、風險與取捨，團隊未來也能在條件改變時重新評估。</p>
<h2 id="和本模組的關係">和本模組的關係</h2>
<p>這一章承接 <a href="/blog/go/00-philosophy/selecting-go/" data-link-title="0.4 什麼時候選 Go" data-link-desc="用選型條件判斷 Go 是否適合高併發服務、背景工作與長連線場景">0.4 什麼時候選 Go</a>。0.4 先判斷工作場景是否適合 Go；0.5 再把 Go 放到其他並發語言旁邊，理解它的工程定位。</p>
<p>讀完本章後，可以回到：</p>
<ul>
<li><a href="/blog/go/00-philosophy/simplicity/" data-link-title="0.1 Go 的簡單哲學與認知負擔" data-link-desc="理解 Go 為什麼偏好顯式、直線流程與少量語法">Go 的簡單哲學與認知負擔</a></li>
<li><a href="/blog/go/00-philosophy/composition/" data-link-title="0.2 組合優先：小介面與明確依賴" data-link-desc="用小介面與 struct 組合取代大型繼承結構">組合優先：小介面與明確依賴</a></li>
<li><a href="/blog/go/04-concurrency/" data-link-title="模組四：並發模型" data-link-desc="從 goroutine、channel、select 與 RWMutex 理解 Go 並發模型">Go 並發模型</a></li>
</ul>
]]></content:encoded></item><item><title>4.5 高併發控制與 backpressure</title><link>https://tarrragon.github.io/blog/go/04-concurrency/backpressure/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/04-concurrency/backpressure/</guid><description>&lt;p>這一章處理的是一個比「會不會開 goroutine」更重要的問題：當系統真的進入高併發狀態時，怎麼讓工作量保持可控。Go 很容易啟動大量並發工作，但如果沒有邊界，goroutine、channel、下游連線與記憶體都會一起膨脹。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解 bounded concurrency 的用途&lt;/li>
&lt;li>用 semaphore 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool&lt;/a> 限制同時工作數&lt;/li>
&lt;li>看懂 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> 為什麼能保護下游&lt;/li>
&lt;li>在併發流程中保留 cancellation 與 &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;li>辨認什麼時候該拒絕新工作&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察高併發需要容量邊界">【觀察】高併發需要容量邊界&lt;/h2>
&lt;p>goroutine 很便宜，但每個工作仍會消耗下游連線、記憶體、排隊時間與錯誤處理能力。當所有工作都直接丟進 &lt;code>go func()&lt;/code>，被放大的通常是：&lt;/p>
&lt;ul>
&lt;li>連線數&lt;/li>
&lt;li>記憶體&lt;/li>
&lt;li>排隊延遲&lt;/li>
&lt;li>下游壓力&lt;/li>
&lt;li>故障面積&lt;/li>
&lt;/ul>
&lt;p>高併發設計的第一原則是「可控」。系統需要知道同時有多少工作在跑、多少工作在排隊、滿載時如何回應。&lt;/p>
&lt;h2 id="判讀bounded-concurrency-是基本保護">【判讀】bounded concurrency 是基本保護&lt;/h2>
&lt;p>bounded concurrency 的核心規則是：同一時間只允許有限數量的工作進行。這可以用 worker pool、semaphore 或排隊系統達成。&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="nx">sem&lt;/span> &lt;span class="o">:=&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="kd">struct&lt;/span>&lt;span class="p">{},&lt;/span> &lt;span class="mi">16&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>&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">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">job&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">jobs&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">sem&lt;/span> &lt;span class="o">&amp;lt;-&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"> 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="k">go&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&lt;/span> &lt;span class="nx">Job&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"> 7&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 class="o">&amp;lt;-&lt;/span>&lt;span class="nx">sem&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="nf">process&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&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="p">}(&lt;/span>&lt;span class="nx">job&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式限制同時只有 16 個工作在執行。當工作量暴增時，新的工作會自然排隊，而不是把整台機器一次推爆。&lt;/p>
&lt;h2 id="策略backpressure-保護的是下游">【策略】backpressure 保護的是下游&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> 的核心規則是：當系統處理不過來時，不要無限累積工作。這可以表現成：&lt;/p>
&lt;ul>
&lt;li>channel 滿了就阻塞&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> 有上限&lt;/li>
&lt;li>goroutine pool 有上限&lt;/li>
&lt;li>佇列滿時直接拒絕請求&lt;/li>
&lt;/ul>
&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>、event &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a> 或 background worker 如果沒有 backpressure ，輸入端一快，下游就會被放大成連鎖問題。&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="k">select&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">case&lt;/span> &lt;span class="nx">jobs&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">job&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="c1">// accepted&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">default&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">ErrQueueFull&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;p>這種寫法的重點是明確表達滿載策略：系統在某些壓力下會拒絕新工作，因為保護整體健康比接住所有請求更重要。&lt;/p>
&lt;h2 id="執行cancellation-與-timeout-不能少">【執行】cancellation 與 timeout 不能少&lt;/h2>
&lt;p>bounded concurrency 只控制數量，不能解決卡死工作。每個工作都應該保留取消訊號與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>，否則即使數量受限，資源也會被慢工作一直占著。&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="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cancel&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WithTimeout&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">parent&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">3&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 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="nf">cancel&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>&lt;/span>&lt;span class="line">&lt;span class="ln">4&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="nf">doWork&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">job&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">5&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&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;p>這樣可以讓每一筆工作都有自己的時間邊界，避免整體系統因單一慢點而拖垮。&lt;/p>
&lt;h2 id="判讀拒絕工作也是容量策略">【判讀】拒絕工作也是容量策略&lt;/h2>
&lt;p>拒絕新工作是保護容量邊界的一種策略。當以下條件成立時，拒絕通常比勉強接受更合理：&lt;/p>
&lt;ul>
&lt;li>queue 已滿&lt;/li>
&lt;li>下游連線池耗盡&lt;/li>
&lt;li>timeout 已明顯增加&lt;/li>
&lt;li>系統已進入明顯積壓&lt;/li>
&lt;/ul>
&lt;p>這時候回傳 &lt;code>429&lt;/code>、&lt;code>503&lt;/code> 或 domain-level rejection，往往比讓請求默默堆積更健康。&lt;/p></description><content:encoded><![CDATA[<p>這一章處理的是一個比「會不會開 goroutine」更重要的問題：當系統真的進入高併發狀態時，怎麼讓工作量保持可控。Go 很容易啟動大量並發工作，但如果沒有邊界，goroutine、channel、下游連線與記憶體都會一起膨脹。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解 bounded concurrency 的用途</li>
<li>用 semaphore 或 <a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool</a> 限制同時工作數</li>
<li>看懂 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 為什麼能保護下游</li>
<li>在併發流程中保留 cancellation 與 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a></li>
<li>辨認什麼時候該拒絕新工作</li>
</ol>
<hr>
<h2 id="觀察高併發需要容量邊界">【觀察】高併發需要容量邊界</h2>
<p>goroutine 很便宜，但每個工作仍會消耗下游連線、記憶體、排隊時間與錯誤處理能力。當所有工作都直接丟進 <code>go func()</code>，被放大的通常是：</p>
<ul>
<li>連線數</li>
<li>記憶體</li>
<li>排隊延遲</li>
<li>下游壓力</li>
<li>故障面積</li>
</ul>
<p>高併發設計的第一原則是「可控」。系統需要知道同時有多少工作在跑、多少工作在排隊、滿載時如何回應。</p>
<h2 id="判讀bounded-concurrency-是基本保護">【判讀】bounded concurrency 是基本保護</h2>
<p>bounded concurrency 的核心規則是：同一時間只允許有限數量的工作進行。這可以用 worker pool、semaphore 或排隊系統達成。</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="nx">sem</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="kd">struct</span><span class="p">{},</span> <span class="mi">16</span><span class="p">)</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="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">job</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">jobs</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">sem</span> <span class="o">&lt;-</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="k">go</span> <span class="kd">func</span><span class="p">(</span><span class="nx">job</span> <span class="nx">Job</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">defer</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span> <span class="o">&lt;-</span><span class="nx">sem</span> <span class="p">}()</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nf">process</span><span class="p">(</span><span class="nx">job</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}(</span><span class="nx">job</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式限制同時只有 16 個工作在執行。當工作量暴增時，新的工作會自然排隊，而不是把整台機器一次推爆。</p>
<h2 id="策略backpressure-保護的是下游">【策略】backpressure 保護的是下游</h2>
<p><a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 的核心規則是：當系統處理不過來時，不要無限累積工作。這可以表現成：</p>
<ul>
<li>channel 滿了就阻塞</li>
<li><a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 有上限</li>
<li>goroutine pool 有上限</li>
<li>佇列滿時直接拒絕請求</li>
</ul>
<p>例如 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a>、event <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 或 background worker 如果沒有 backpressure ，輸入端一快，下游就會被放大成連鎖問題。</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">case</span> <span class="nx">jobs</span> <span class="o">&lt;-</span> <span class="nx">job</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="c1">// accepted</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="k">default</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">ErrQueueFull</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種寫法的重點是明確表達滿載策略：系統在某些壓力下會拒絕新工作，因為保護整體健康比接住所有請求更重要。</p>
<h2 id="執行cancellation-與-timeout-不能少">【執行】cancellation 與 timeout 不能少</h2>
<p>bounded concurrency 只控制數量，不能解決卡死工作。每個工作都應該保留取消訊號與 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>，否則即使數量受限，資源也會被慢工作一直占著。</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="nx">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithTimeout</span><span class="p">(</span><span class="nx">parent</span><span class="p">,</span> <span class="mi">3</span><span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</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="nf">cancel</span><span class="p">()</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="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">doWork</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">job</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">5</span><span class="cl">    <span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這樣可以讓每一筆工作都有自己的時間邊界，避免整體系統因單一慢點而拖垮。</p>
<h2 id="判讀拒絕工作也是容量策略">【判讀】拒絕工作也是容量策略</h2>
<p>拒絕新工作是保護容量邊界的一種策略。當以下條件成立時，拒絕通常比勉強接受更合理：</p>
<ul>
<li>queue 已滿</li>
<li>下游連線池耗盡</li>
<li>timeout 已明顯增加</li>
<li>系統已進入明顯積壓</li>
</ul>
<p>這時候回傳 <code>429</code>、<code>503</code> 或 domain-level rejection，往往比讓請求默默堆積更健康。</p>
]]></content:encoded></item><item><title>8.5 Twitch：直播與聊天室系統</title><link>https://tarrragon.github.io/blog/go/08-case-studies/twitch/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/08-case-studies/twitch/</guid><description>&lt;p>Twitch 的案例幾乎就是 Go 教材裡高併發與即時系統的縮影。官方說法很直接：Go 被用在很多 busiest systems，上下文是 live video 與 chat，重點是 simplicity、safety、performance 與 readability。&lt;/p>
&lt;h2 id="你應該看什麼">你應該看什麼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://go.dev/solutions/twitch">Twitch - Go’s march to low latency GC&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼&lt;/h2>
&lt;ol>
&lt;li>Go 很適合低延遲、高事件量的即時系統。&lt;/li>
&lt;li>直播與聊天室會大量依賴長連線與狀態協調。&lt;/li>
&lt;li>可讀性在高壓力服務中仍然重要，因為維護者需要快速定位問題。&lt;/li>
&lt;/ol>
&lt;h2 id="可對照的公開原始碼">可對照的公開原始碼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://go.dev/solutions/case-studies">Go case studies page&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>Twitch 的核心系統原始碼不是公開教學重點，所以這一章更適合把官方案例本身當成第一手材料，再回到你的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a>、channel 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> 章節對照。&lt;/p></description><content:encoded><![CDATA[<p>Twitch 的案例幾乎就是 Go 教材裡高併發與即時系統的縮影。官方說法很直接：Go 被用在很多 busiest systems，上下文是 live video 與 chat，重點是 simplicity、safety、performance 與 readability。</p>
<h2 id="你應該看什麼">你應該看什麼</h2>
<ul>
<li><a href="https://go.dev/solutions/twitch">Twitch - Go’s march to low latency GC</a></li>
</ul>
<h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼</h2>
<ol>
<li>Go 很適合低延遲、高事件量的即時系統。</li>
<li>直播與聊天室會大量依賴長連線與狀態協調。</li>
<li>可讀性在高壓力服務中仍然重要，因為維護者需要快速定位問題。</li>
</ol>
<h2 id="可對照的公開原始碼">可對照的公開原始碼</h2>
<ul>
<li><a href="https://go.dev/solutions/case-studies">Go case studies page</a></li>
</ul>
<p>Twitch 的核心系統原始碼不是公開教學重點，所以這一章更適合把官方案例本身當成第一手材料，再回到你的 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a>、channel 與 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 章節對照。</p>
]]></content:encoded></item><item><title>1.5 bounded worker pool</title><link>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/worker-pool/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/worker-pool/</guid><description>&lt;p>bounded &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool&lt;/a> 的核心責任是限制同時執行的工作量。goroutine 很便宜，但工作本身可能佔用 CPU、memory、file descriptor、外部 API quota 或資料庫連線；worker pool 讓容量限制成為程式設計的一部分。&lt;/p>
&lt;h2 id="預計補充內容">預計補充內容&lt;/h2>
&lt;p>這些工作量邊界會在下列章節展開：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">Go 入門：goroutine：背景工作與服務生命週期&lt;/a>：先理解 goroutine 的啟動和結束方式，才知道 worker pool 為什麼要限制並發數。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go 入門：channel：事件流與 backpressure &lt;/a>：job channel 的容量和阻塞行為，會直接影響 pool 的整體策略。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">Go 進階：select loop 的生命週期設計&lt;/a>：worker 的停止、排空與關閉，通常都要回到 select loop 來說明。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞&lt;/a>：真正跨 process 的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a>、retry 與 dead-letter 行為屬於這裡。&lt;/li>
&lt;/ul>
&lt;h2 id="本章不處理">本章不處理&lt;/h2>
&lt;p>本章先把單一 process 內的工作量上限、停止與排空講清楚；跨 process 的 consumer 與 retry 機制，會放在 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞&lt;/a>。&lt;/p>
&lt;h2 id="與-backend-教材的分工">與 Backend 教材的分工&lt;/h2>
&lt;p>本章只處理單一 Go process 內的工作量限制。跨 process 的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a> &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">partition&lt;/a>、[dead-letter &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>](/go-advanced/backend/knowledge-cards/dead-letter-queue) 與重試政策會放在 &lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞&lt;/a>。&lt;/p>
&lt;h2 id="和-go-教材的關係">和 Go 教材的關係&lt;/h2>
&lt;p>這一章承接的是 goroutine、channel 與 select loop；如果你要先回看語言教材，可以讀：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">Go：goroutine：輕量並發工作&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go：channel：資料傳遞與 backpressure &lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">Go：select：同時等待多種事件&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>bounded <a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool</a> 的核心責任是限制同時執行的工作量。goroutine 很便宜，但工作本身可能佔用 CPU、memory、file descriptor、外部 API quota 或資料庫連線；worker pool 讓容量限制成為程式設計的一部分。</p>
<h2 id="預計補充內容">預計補充內容</h2>
<p>這些工作量邊界會在下列章節展開：</p>
<ul>
<li><a href="/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">Go 入門：goroutine：背景工作與服務生命週期</a>：先理解 goroutine 的啟動和結束方式，才知道 worker pool 為什麼要限制並發數。</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>：job channel 的容量和阻塞行為，會直接影響 pool 的整體策略。</li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">Go 進階：select loop 的生命週期設計</a>：worker 的停止、排空與關閉，通常都要回到 select loop 來說明。</li>
<li><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a>：真正跨 process 的 <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a>、retry 與 dead-letter 行為屬於這裡。</li>
</ul>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先把單一 process 內的工作量上限、停止與排空講清楚；跨 process 的 consumer 與 retry 機制，會放在 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a>。</p>
<h2 id="與-backend-教材的分工">與 Backend 教材的分工</h2>
<p>本章只處理單一 Go process 內的工作量限制。跨 process 的 <a href="/blog/backend/knowledge-cards/consumer-group/" data-link-title="Consumer Group" data-link-desc="說明一組 consumer 如何共同分攤 stream 或 topic 的處理責任">consumer group</a>、<a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> <a href="/blog/backend/knowledge-cards/partition/" data-link-title="Partition" data-link-desc="說明事件流如何切分成多個可並行處理的有序片段">partition</a>、[dead-letter <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>](/go-advanced/backend/knowledge-cards/dead-letter-queue) 與重試政策會放在 <a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a>。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 goroutine、channel 與 select loop；如果你要先回看語言教材，可以讀：</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/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">Go：select：同時等待多種事件</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
</ul>
]]></content:encoded></item><item><title>1.5 從單檔到多檔案</title><link>https://tarrragon.github.io/blog/go/01-basics/growing-files-packages/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/01-basics/growing-files-packages/</guid><description>&lt;p>Go 程式變大的第一個拆分單位通常是檔案，不是架構。學習與開發常從一個 &lt;code>main.go&lt;/code> 開始，等到入口程式太長，再把相關函式拆到同一個 package 的其他檔案；只有當某組概念需要形成獨立 API 邊界時，才搬到新的資料夾成為新的 package。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷何時保留單一 &lt;code>main.go&lt;/code>&lt;/li>
&lt;li>理解同 package 多檔案如何互相呼叫&lt;/li>
&lt;li>分辨「拆檔案」和「拆 package」的差異&lt;/li>
&lt;li>看懂跨 package 呼叫、exported 名稱與 import path 的關係&lt;/li>
&lt;li>避免過早把小程式拆成複雜架構&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察單檔是合理起點">【觀察】單檔是合理起點&lt;/h2>
&lt;p>單一 &lt;code>main.go&lt;/code> 的核心價值是降低初期理解成本。程式還小的時候，把入口、設定、簡單函式放在同一個檔案，通常比一開始拆成多個資料夾更容易閱讀。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">notify/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">├── go.mod
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">└── main.go&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>一個最小 HTTP 服務可以先長這樣：&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="kn">package&lt;/span> &lt;span class="nx">main&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="kn">import&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="s">&amp;#34;fmt&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="s">&amp;#34;net/http&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="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">main&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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">HandleFunc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;/health&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">healthHandler&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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&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">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ListenAndServe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;:8080&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&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">12&lt;/span>&lt;span class="cl"> &lt;span class="nb">panic&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">13&lt;/span>&lt;span class="cl"> &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="kd">func&lt;/span> &lt;span class="nf">healthHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fprintln&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;ok&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個階段不需要急著建立 &lt;code>handler&lt;/code>、&lt;code>service&lt;/code> 或 &lt;code>domain&lt;/code> 資料夾。讀者一眼能看懂程式如何啟動，比形式上的分層更重要。&lt;/p>
&lt;h2 id="判讀maingo-膨脹時先拆同-package-多檔案">【判讀】main.go 膨脹時，先拆同 package 多檔案&lt;/h2>
&lt;p>同 package 多檔案的核心規則是：同一個資料夾、同一個 package 名稱的 Go 檔案會被一起編譯，彼此可以直接呼叫，不需要 import。&lt;/p>
&lt;p>當 &lt;code>main.go&lt;/code> 開始同時包含設定、handler、資料型別與啟動流程，可以先拆成這樣：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">notify/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">├── go.mod
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">├── main.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">├── config.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">├── server.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">└── message.go&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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="kn">package&lt;/span> &lt;span class="nx">main&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>因此 &lt;code>main.go&lt;/code> 可以直接呼叫 &lt;code>loadConfig()&lt;/code>：&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">main&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">cfg&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">loadConfig&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">server&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">newServer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">cfg&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>&lt;/span>&lt;span class="line">&lt;span class="ln">5&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">server&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ListenAndServe&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">6&lt;/span>&lt;span class="cl"> &lt;span class="nb">panic&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">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>&lt;code>config.go&lt;/code> 可以提供這個函式：&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="kn">package&lt;/span> &lt;span class="nx">main&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">config&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">Port&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">loadConfig&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">config&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">config&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Port&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;:8080&amp;#34;&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這是同 package 內的檔案切分。&lt;code>loadConfig&lt;/code> 即使用小寫開頭，&lt;code>main.go&lt;/code> 也可以呼叫，因為它們都屬於 &lt;code>package main&lt;/code>。&lt;/p>
&lt;h2 id="策略先拆檔案再拆-package">【策略】先拆檔案，再拆 package&lt;/h2>
&lt;p>拆分的核心判斷是：檔案用來降低閱讀負擔，package 用來建立 API 邊界。這兩者成本不同，不應混在一起。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>拆分方式&lt;/th>
 &lt;th>使用時機&lt;/th>
 &lt;th>呼叫方式&lt;/th>
 &lt;th>成本&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>同 package 多檔案&lt;/td>
 &lt;td>檔案太長、概念需要分段&lt;/td>
 &lt;td>直接呼叫&lt;/td>
 &lt;td>成本低，沒有新 API 邊界&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>新 package&lt;/td>
 &lt;td>概念可獨立、需要被其他 package 使用&lt;/td>
 &lt;td>import 後呼叫 exported 名稱&lt;/td>
 &lt;td>成本較高，需要設計 API&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>例如 &lt;code>message.go&lt;/code> 只是放一些內部型別時，可以留在 &lt;code>package main&lt;/code>：&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="kn">package&lt;/span> &lt;span class="nx">main&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">message&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">Title&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">Body&lt;/span> &lt;span class="kt">string&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;p>如果 notification 概念開始有自己的建構規則、驗證規則與測試需求，就可以搬成獨立 package：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">notify/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">├── go.mod
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">├── main.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">└── notification/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> ├── notification.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> └── validate.go&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>此時 &lt;code>notification&lt;/code> package 要明確決定哪些名稱是對外 API。&lt;/p>
&lt;h2 id="執行跨-package-呼叫需要-import-與-exported-名稱">【執行】跨 package 呼叫需要 import 與 exported 名稱&lt;/h2>
&lt;p>跨 package 呼叫的核心規則是：其他 package 只能使用大寫開頭的 exported 名稱，並且必須透過 import path 引入。&lt;/p>
&lt;p>假設 module path 是：&lt;/p></description><content:encoded><![CDATA[<p>Go 程式變大的第一個拆分單位通常是檔案，不是架構。學習與開發常從一個 <code>main.go</code> 開始，等到入口程式太長，再把相關函式拆到同一個 package 的其他檔案；只有當某組概念需要形成獨立 API 邊界時，才搬到新的資料夾成為新的 package。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷何時保留單一 <code>main.go</code></li>
<li>理解同 package 多檔案如何互相呼叫</li>
<li>分辨「拆檔案」和「拆 package」的差異</li>
<li>看懂跨 package 呼叫、exported 名稱與 import path 的關係</li>
<li>避免過早把小程式拆成複雜架構</li>
</ol>
<hr>
<h2 id="觀察單檔是合理起點">【觀察】單檔是合理起點</h2>
<p>單一 <code>main.go</code> 的核心價值是降低初期理解成本。程式還小的時候，把入口、設定、簡單函式放在同一個檔案，通常比一開始拆成多個資料夾更容易閱讀。</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">notify/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln">3</span><span class="cl">└── main.go</span></span></code></pre></div><p>一個最小 HTTP 服務可以先長這樣：</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="kn">package</span> <span class="nx">main</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="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="s">&#34;fmt&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="s">&#34;net/http&#34;</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">main</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">http</span><span class="p">.</span><span class="nf">HandleFunc</span><span class="p">(</span><span class="s">&#34;/health&#34;</span><span class="p">,</span> <span class="nx">healthHandler</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">http</span><span class="p">.</span><span class="nf">ListenAndServe</span><span class="p">(</span><span class="s">&#34;:8080&#34;</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">12</span><span class="cl">        <span class="nb">panic</span><span class="p">(</span><span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <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="kd">func</span> <span class="nf">healthHandler</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprintln</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;ok&#34;</span><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>這個階段不需要急著建立 <code>handler</code>、<code>service</code> 或 <code>domain</code> 資料夾。讀者一眼能看懂程式如何啟動，比形式上的分層更重要。</p>
<h2 id="判讀maingo-膨脹時先拆同-package-多檔案">【判讀】main.go 膨脹時，先拆同 package 多檔案</h2>
<p>同 package 多檔案的核心規則是：同一個資料夾、同一個 package 名稱的 Go 檔案會被一起編譯，彼此可以直接呼叫，不需要 import。</p>
<p>當 <code>main.go</code> 開始同時包含設定、handler、資料型別與啟動流程，可以先拆成這樣：</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">notify/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── main.go
</span></span><span class="line"><span class="ln">4</span><span class="cl">├── config.go
</span></span><span class="line"><span class="ln">5</span><span class="cl">├── server.go
</span></span><span class="line"><span class="ln">6</span><span class="cl">└── message.go</span></span></code></pre></div><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="kn">package</span> <span class="nx">main</span></span></span></code></pre></div><p>因此 <code>main.go</code> 可以直接呼叫 <code>loadConfig()</code>：</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">main</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">cfg</span> <span class="o">:=</span> <span class="nf">loadConfig</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">server</span> <span class="o">:=</span> <span class="nf">newServer</span><span class="p">(</span><span class="nx">cfg</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">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">server</span><span class="p">.</span><span class="nf">ListenAndServe</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">6</span><span class="cl">        <span class="nb">panic</span><span class="p">(</span><span class="nx">err</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><code>config.go</code> 可以提供這個函式：</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="kn">package</span> <span class="nx">main</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">config</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">Port</span> <span class="kt">string</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="nf">loadConfig</span><span class="p">()</span> <span class="nx">config</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">config</span><span class="p">{</span><span class="nx">Port</span><span class="p">:</span> <span class="s">&#34;:8080&#34;</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>這是同 package 內的檔案切分。<code>loadConfig</code> 即使用小寫開頭，<code>main.go</code> 也可以呼叫，因為它們都屬於 <code>package main</code>。</p>
<h2 id="策略先拆檔案再拆-package">【策略】先拆檔案，再拆 package</h2>
<p>拆分的核心判斷是：檔案用來降低閱讀負擔，package 用來建立 API 邊界。這兩者成本不同，不應混在一起。</p>
<table>
  <thead>
      <tr>
          <th>拆分方式</th>
          <th>使用時機</th>
          <th>呼叫方式</th>
          <th>成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>同 package 多檔案</td>
          <td>檔案太長、概念需要分段</td>
          <td>直接呼叫</td>
          <td>成本低，沒有新 API 邊界</td>
      </tr>
      <tr>
          <td>新 package</td>
          <td>概念可獨立、需要被其他 package 使用</td>
          <td>import 後呼叫 exported 名稱</td>
          <td>成本較高，需要設計 API</td>
      </tr>
  </tbody>
</table>
<p>例如 <code>message.go</code> 只是放一些內部型別時，可以留在 <code>package main</code>：</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="kn">package</span> <span class="nx">main</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">message</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">Title</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">Body</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>如果 notification 概念開始有自己的建構規則、驗證規則與測試需求，就可以搬成獨立 package：</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">notify/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── main.go
</span></span><span class="line"><span class="ln">4</span><span class="cl">└── notification/
</span></span><span class="line"><span class="ln">5</span><span class="cl">    ├── notification.go
</span></span><span class="line"><span class="ln">6</span><span class="cl">    └── validate.go</span></span></code></pre></div><p>此時 <code>notification</code> package 要明確決定哪些名稱是對外 API。</p>
<h2 id="執行跨-package-呼叫需要-import-與-exported-名稱">【執行】跨 package 呼叫需要 import 與 exported 名稱</h2>
<p>跨 package 呼叫的核心規則是：其他 package 只能使用大寫開頭的 exported 名稱，並且必須透過 import path 引入。</p>
<p>假設 module path 是：</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="nx">module</span> <span class="nx">example</span><span class="p">.</span><span class="nx">com</span><span class="o">/</span><span class="nx">notify</span></span></span></code></pre></div><p><code>notification/notification.go</code> 可以這樣寫：</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="kn">package</span> <span class="nx">notification</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="kn">import</span> <span class="s">&#34;strings&#34;</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">Notification</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">Title</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">Body</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="nf">New</span><span class="p">(</span><span class="nx">title</span><span class="p">,</span> <span class="nx">body</span> <span class="kt">string</span><span class="p">)</span> <span class="nx">Notification</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="nx">Notification</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">title</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">Body</span><span class="p">:</span>  <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">body</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="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="kd">func</span> <span class="nf">isEmpty</span><span class="p">(</span><span class="nx">n</span> <span class="nx">Notification</span><span class="p">)</span> <span class="kt">bool</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">n</span><span class="p">.</span><span class="nx">Title</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="o">&amp;&amp;</span> <span class="nx">n</span><span class="p">.</span><span class="nx">Body</span> <span class="o">==</span> <span class="s">&#34;&#34;</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>main.go</code> 要使用這個 package，必須 import：</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="kn">package</span> <span class="nx">main</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="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="s">&#34;fmt&#34;</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="s">&#34;example.com/notify/notification&#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">func</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">n</span> <span class="o">:=</span> <span class="nx">notification</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="s">&#34;Deploy finished&#34;</span><span class="p">,</span> <span class="s">&#34;Version 1.2.0 is live&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">n</span><span class="p">.</span><span class="nx">Title</span><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><code>notification.New</code> 和 <code>notification.Notification</code> 可以被外部使用，因為它們是大寫開頭。<code>isEmpty</code> 不能被 <code>main.go</code> 呼叫，因為它是 package 內部實作細節。</p>
<h2 id="判讀import-cycle-是依賴方向錯了">【判讀】import cycle 是依賴方向錯了</h2>
<p>import cycle 的核心意義是兩個 package 互相依賴，Go 會直接拒絕編譯。Go 工具鏈透過這個限制強迫你把依賴方向想清楚。</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">notify/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── main.go
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── handler/
</span></span><span class="line"><span class="ln">4</span><span class="cl">│   └── handler.go
</span></span><span class="line"><span class="ln">5</span><span class="cl">└── notification/
</span></span><span class="line"><span class="ln">6</span><span class="cl">    └── notification.go</span></span></code></pre></div><p>如果 <code>handler</code> import <code>notification</code>，同時 <code>notification</code> 又 import <code>handler</code>，就會形成循環依賴。</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">handler -&gt; notification -&gt; handler</span></span></code></pre></div><p>修正的核心做法是讓低層概念不要依賴高層協定。<code>notification</code> 應該描述通知資料與規則，不應知道 HTTP handler；handler 可以把 HTTP request 轉成 notification command 或 value。</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">handler -&gt; notification</span></span></code></pre></div><p>這個方向比「互相知道」更容易測試，也更容易重構。</p>
<h2 id="常見拆分路線">常見拆分路線</h2>
<p>Go 服務常見的成長路線是漸進式的。每一步都應該解決當下的閱讀、測試或依賴問題，而不是為了看起來正式。</p>
<h3 id="階段一單一檔案">階段一：單一檔案</h3>





<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">notify/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln">3</span><span class="cl">└── main.go</span></span></code></pre></div><p>適合小工具、實驗程式、剛開始的服務雛形。</p>
<h3 id="階段二同-package-多檔案">階段二：同 package 多檔案</h3>





<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">notify/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── main.go
</span></span><span class="line"><span class="ln">4</span><span class="cl">├── config.go
</span></span><span class="line"><span class="ln">5</span><span class="cl">├── server.go
</span></span><span class="line"><span class="ln">6</span><span class="cl">└── notification.go</span></span></code></pre></div><p>適合 <code>main.go</code> 開始太長，但概念還沒有明確 API 邊界的階段。</p>
<h3 id="階段三多-package">階段三：多 package</h3>





<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">notify/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">├── main.go
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">├── config/
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">│   └── config.go
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">├── notification/
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">│   ├── notification.go
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">│   └── validate.go
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">└── transport/
</span></span><span class="line"><span class="ln">10</span><span class="cl">    └── http.go</span></span></code></pre></div><p>適合設定、通知規則、HTTP transport 已經能清楚分成不同責任的階段。</p>
<h3 id="階段四服務邊界更清楚">階段四：服務邊界更清楚</h3>





<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">notify/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── cmd/
</span></span><span class="line"><span class="ln">4</span><span class="cl">│   └── notify-server/
</span></span><span class="line"><span class="ln">5</span><span class="cl">│       └── main.go
</span></span><span class="line"><span class="ln">6</span><span class="cl">└── internal/
</span></span><span class="line"><span class="ln">7</span><span class="cl">    ├── notification/
</span></span><span class="line"><span class="ln">8</span><span class="cl">    ├── transport/
</span></span><span class="line"><span class="ln">9</span><span class="cl">    └── storage/</span></span></code></pre></div><p>適合服務已經有明確部署入口、內部 package 不想被外部 module 引用的階段。這是程式長大後的選擇。</p>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一檔案切分跟著-go-package-模型">檢查一：檔案切分跟著 Go package 模型</h3>
<p>Go 的檔案不是 class。把每個 struct 都拆成一個檔案，通常只會增加跳轉成本，不會讓設計更清楚。</p>
<h3 id="檢查二資料夾跟著邊界成長">檢查二：資料夾跟著邊界成長</h3>
<p>程式還小時就建立 <code>domain</code>、<code>application</code>、<code>infrastructure</code>，會讓讀者先學資料夾，再學行為本身。Go 更適合先讓程式跑起來，再根據壓力拆邊界。</p>
<h3 id="檢查三exported-名稱代表公開承諾">檢查三：exported 名稱代表公開承諾</h3>
<p>exported 名稱就是 package 對外承諾。還不確定會被外部使用的型別與函式，先保持 unexported，等 API 真的穩定再開放。</p>
]]></content:encoded></item><item><title>2.5 指標與資料複製邊界</title><link>https://tarrragon.github.io/blog/go/02-types-data/pointers-copy/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/02-types-data/pointers-copy/</guid><description>&lt;p>Go 的指標讓函式可以操作原本的資料，而不是資料複本。這很有效率，也很危險：當多個地方共享同一份資料時，你需要明確決定誰可以修改，誰只能讀取。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解值傳遞與指標傳遞的差異&lt;/li>
&lt;li>判斷何時使用 pointer&lt;/li>
&lt;li>理解 slice、map 本身已經帶有共享底層資料的特性&lt;/li>
&lt;li>用 copy 保護資料邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察go-預設是值傳遞">【觀察】Go 預設是值傳遞&lt;/h2>
&lt;p>值傳遞的核心規則是：函式收到的是參數值的複本，修改複本不會改到呼叫端原值。以下範例中，&lt;code>Rename&lt;/code> 修改的是複本：&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">User&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">Name&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="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="nf">Rename&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">u&lt;/span> &lt;span class="nx">User&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"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">u&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Name&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;Bob&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">func&lt;/span> &lt;span class="nf">main&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">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">user&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">User&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;Alice&amp;#34;&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="nf">Rename&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">user&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">user&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Name&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// Alice&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>&lt;code>Rename&lt;/code> 修改的是複本，不是 &lt;code>main&lt;/code> 裡的 &lt;code>user&lt;/code>。&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">Rename&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">u&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">User&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">u&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Name&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;Bob&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">func&lt;/span> &lt;span class="nf">main&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">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">user&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">User&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;Alice&amp;#34;&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="nf">Rename&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">user&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">user&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Name&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1">// Bob&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&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>&amp;amp;user&lt;/code> 取得位址，&lt;code>*User&lt;/code> 表示指向 &lt;code>User&lt;/code> 的指標。&lt;/p>
&lt;h2 id="判讀pointer-表示共享修改權">【判讀】pointer 表示共享修改權&lt;/h2>
&lt;p>pointer 的核心語意是共享修改權，不只是效能工具。它表示被呼叫者可能看到或修改原本那份資料。&lt;/p>
&lt;p>適合使用 pointer 的情境：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>情境&lt;/th>
 &lt;th>原因&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>方法需要修改 receiver&lt;/td>
 &lt;td>例如 &lt;code>Counter.Inc()&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>struct 很大，複製成本高&lt;/td>
 &lt;td>避免每次呼叫都複製大量資料&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>需要表示 optional object&lt;/td>
 &lt;td>&lt;code>nil&lt;/code> 可表示不存在&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>多個方法共享同一份狀態&lt;/td>
 &lt;td>例如 repository、server、cache&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>不適合濫用 pointer 的情境：&lt;/p>
&lt;ul>
&lt;li>小型不可變資料，例如 &lt;code>time.Time&lt;/code> 常直接值傳遞&lt;/li>
&lt;li>只是為了「看起來像物件導向」&lt;/li>
&lt;li>不希望呼叫者能修改內部資料&lt;/li>
&lt;/ul>
&lt;h2 id="策略slice-和-map-要特別小心">【策略】slice 和 map 要特別小心&lt;/h2>
&lt;p>slice 和 map 的核心風險是：即使參數不是 pointer，也會共享底層資料。&lt;/p>
&lt;h3 id="slice-共享底層陣列">slice 共享底層陣列&lt;/h3>
&lt;p>slice 參數會複製 slice header，但 header 指向同一個底層 array；因此函式內修改元素，外面會看見。&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">Modify&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">items&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&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">items&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;changed&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">func&lt;/span> &lt;span class="nf">main&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">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">names&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s">&amp;#34;Alice&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;Bob&amp;#34;&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="nf">Modify&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">names&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">names&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="c1">// changed&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="map-本身就是-reference-like">map 本身就是 reference-like&lt;/h3>
&lt;p>map 傳入函式後，函式可以修改同一份 map。這是很多共享狀態 bug 的來源。&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">Modify&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">m&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="kt">int&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">m&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">&amp;#34;count&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="mi">10&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="nf">main&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">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">values&lt;/span> &lt;span class="o">:=&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="kt">int&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s">&amp;#34;count&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1&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="nf">Modify&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">values&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">values&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">&amp;#34;count&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span> &lt;span class="c1">// 10&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="執行回傳資料時建立-copy-邊界">【執行】回傳資料時建立 copy 邊界&lt;/h2>
&lt;p>copy 邊界的核心規則是：不希望外部修改內部狀態時，不要直接回傳內部 map、slice 或 pointer。假設 &lt;code>UserRepository&lt;/code> 內部保存一組使用者：&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">User&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">Name&lt;/span> &lt;span class="kt">string&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="kd">type&lt;/span> &lt;span class="nx">UserRepository&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">7&lt;/span>&lt;span class="cl"> &lt;span class="nx">users&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="nx">User&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>直接回傳 map 會把內部狀態暴露給呼叫者：&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">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">UserRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Users&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="nx">User&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">return&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&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;/code>&lt;/pre>&lt;/div>&lt;p>呼叫者就可以繞過 &lt;code>UserRepository&lt;/code> 修改內部資料：&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="nx">users&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">repo&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Users&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">users&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">User&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">Name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;Changed&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">UserRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Users&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="nx">User&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">result&lt;/span> &lt;span class="o">:=&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="nx">User&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&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">id&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&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">result&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">id&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">user&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="k">return&lt;/span> &lt;span class="nx">result&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>回傳 slice 時也要複製 slice：&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">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">UserRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">ListUsers&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">User&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">result&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="nx">User&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&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">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">users&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">result&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">result&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">user&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="k">return&lt;/span> &lt;span class="nx">result&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>這樣呼叫者可以自由排序、append 或修改回傳資料，不會影響 repository 內部狀態。&lt;/p>
&lt;h2 id="深層複製與淺層複製">深層複製與淺層複製&lt;/h2>
&lt;p>深層複製的核心規則是：struct 裡面若含 slice、map 或 pointer，只複製 struct 本身仍會共享內部資料。以下 &lt;code>Profile&lt;/code> 包含 slice：&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">Profile&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">Name&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">Tags&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&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;/code>&lt;/pre>&lt;/div>&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="nx">copyProfile&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">profile&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>copyProfile.Tags&lt;/code> 和 &lt;code>profile.Tags&lt;/code> 仍然指向同一個底層 array。若要保護邊界，需要複製 slice：&lt;/p></description><content:encoded><![CDATA[<p>Go 的指標讓函式可以操作原本的資料，而不是資料複本。這很有效率，也很危險：當多個地方共享同一份資料時，你需要明確決定誰可以修改，誰只能讀取。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解值傳遞與指標傳遞的差異</li>
<li>判斷何時使用 pointer</li>
<li>理解 slice、map 本身已經帶有共享底層資料的特性</li>
<li>用 copy 保護資料邊界</li>
</ol>
<hr>
<h2 id="觀察go-預設是值傳遞">【觀察】Go 預設是值傳遞</h2>
<p>值傳遞的核心規則是：函式收到的是參數值的複本，修改複本不會改到呼叫端原值。以下範例中，<code>Rename</code> 修改的是複本：</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">User</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">Name</span> <span class="kt">string</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="nf">Rename</span><span class="p">(</span><span class="nx">u</span> <span class="nx">User</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">u</span><span class="p">.</span><span class="nx">Name</span> <span class="p">=</span> <span class="s">&#34;Bob&#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">func</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">user</span> <span class="o">:=</span> <span class="nx">User</span><span class="p">{</span><span class="nx">Name</span><span class="p">:</span> <span class="s">&#34;Alice&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nf">Rename</span><span class="p">(</span><span class="nx">user</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">user</span><span class="p">.</span><span class="nx">Name</span><span class="p">)</span> <span class="c1">// Alice</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>Rename</code> 修改的是複本，不是 <code>main</code> 裡的 <code>user</code>。</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">Rename</span><span class="p">(</span><span class="nx">u</span> <span class="o">*</span><span class="nx">User</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">u</span><span class="p">.</span><span class="nx">Name</span> <span class="p">=</span> <span class="s">&#34;Bob&#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">func</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">user</span> <span class="o">:=</span> <span class="nx">User</span><span class="p">{</span><span class="nx">Name</span><span class="p">:</span> <span class="s">&#34;Alice&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nf">Rename</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">user</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">user</span><span class="p">.</span><span class="nx">Name</span><span class="p">)</span> <span class="c1">// Bob</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>&amp;user</code> 取得位址，<code>*User</code> 表示指向 <code>User</code> 的指標。</p>
<h2 id="判讀pointer-表示共享修改權">【判讀】pointer 表示共享修改權</h2>
<p>pointer 的核心語意是共享修改權，不只是效能工具。它表示被呼叫者可能看到或修改原本那份資料。</p>
<p>適合使用 pointer 的情境：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>原因</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>方法需要修改 receiver</td>
          <td>例如 <code>Counter.Inc()</code></td>
      </tr>
      <tr>
          <td>struct 很大，複製成本高</td>
          <td>避免每次呼叫都複製大量資料</td>
      </tr>
      <tr>
          <td>需要表示 optional object</td>
          <td><code>nil</code> 可表示不存在</td>
      </tr>
      <tr>
          <td>多個方法共享同一份狀態</td>
          <td>例如 repository、server、cache</td>
      </tr>
  </tbody>
</table>
<p>不適合濫用 pointer 的情境：</p>
<ul>
<li>小型不可變資料，例如 <code>time.Time</code> 常直接值傳遞</li>
<li>只是為了「看起來像物件導向」</li>
<li>不希望呼叫者能修改內部資料</li>
</ul>
<h2 id="策略slice-和-map-要特別小心">【策略】slice 和 map 要特別小心</h2>
<p>slice 和 map 的核心風險是：即使參數不是 pointer，也會共享底層資料。</p>
<h3 id="slice-共享底層陣列">slice 共享底層陣列</h3>
<p>slice 參數會複製 slice header，但 header 指向同一個底層 array；因此函式內修改元素，外面會看見。</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">Modify</span><span class="p">(</span><span class="nx">items</span> <span class="p">[]</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">items</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="p">=</span> <span class="s">&#34;changed&#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">func</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">names</span> <span class="o">:=</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;Alice&#34;</span><span class="p">,</span> <span class="s">&#34;Bob&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nf">Modify</span><span class="p">(</span><span class="nx">names</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">names</span><span class="p">[</span><span class="mi">0</span><span class="p">])</span> <span class="c1">// changed</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="map-本身就是-reference-like">map 本身就是 reference-like</h3>
<p>map 傳入函式後，函式可以修改同一份 map。這是很多共享狀態 bug 的來源。</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">Modify</span><span class="p">(</span><span class="nx">m</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">int</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">m</span><span class="p">[</span><span class="s">&#34;count&#34;</span><span class="p">]</span> <span class="p">=</span> <span class="mi">10</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="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">values</span> <span class="o">:=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">int</span><span class="p">{</span><span class="s">&#34;count&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nf">Modify</span><span class="p">(</span><span class="nx">values</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">values</span><span class="p">[</span><span class="s">&#34;count&#34;</span><span class="p">])</span> <span class="c1">// 10</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h2 id="執行回傳資料時建立-copy-邊界">【執行】回傳資料時建立 copy 邊界</h2>
<p>copy 邊界的核心規則是：不希望外部修改內部狀態時，不要直接回傳內部 map、slice 或 pointer。假設 <code>UserRepository</code> 內部保存一組使用者：</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">User</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">Name</span> <span class="kt">string</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="kd">type</span> <span class="nx">UserRepository</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nx">users</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">User</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>直接回傳 map 會把內部狀態暴露給呼叫者：</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">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">Users</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="nx">User</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">r</span><span class="p">.</span><span class="nx">users</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>呼叫者就可以繞過 <code>UserRepository</code> 修改內部資料：</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="nx">users</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Users</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">users</span><span class="p">[</span><span class="s">&#34;1&#34;</span><span class="p">]</span> <span class="p">=</span> <span class="nx">User</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;1&#34;</span><span class="p">,</span> <span class="nx">Name</span><span class="p">:</span> <span class="s">&#34;Changed&#34;</span><span class="p">}</span></span></span></code></pre></div><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">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">Users</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="nx">User</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">result</span> <span class="o">:=</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="nx">User</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">users</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">id</span><span class="p">,</span> <span class="nx">user</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">r</span><span class="p">.</span><span class="nx">users</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">result</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span> <span class="p">=</span> <span class="nx">user</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="k">return</span> <span class="nx">result</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>回傳 slice 時也要複製 slice：</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">r</span> <span class="o">*</span><span class="nx">UserRepository</span><span class="p">)</span> <span class="nf">ListUsers</span><span class="p">()</span> <span class="p">[]</span><span class="nx">User</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">result</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="nx">User</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">r</span><span class="p">.</span><span class="nx">users</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">_</span><span class="p">,</span> <span class="nx">user</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">r</span><span class="p">.</span><span class="nx">users</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">result</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">result</span><span class="p">,</span> <span class="nx">user</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="k">return</span> <span class="nx">result</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這樣呼叫者可以自由排序、append 或修改回傳資料，不會影響 repository 內部狀態。</p>
<h2 id="深層複製與淺層複製">深層複製與淺層複製</h2>
<p>深層複製的核心規則是：struct 裡面若含 slice、map 或 pointer，只複製 struct 本身仍會共享內部資料。以下 <code>Profile</code> 包含 slice：</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">Profile</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">Name</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Tags</span> <span class="p">[]</span><span class="kt">string</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><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="nx">copyProfile</span> <span class="o">:=</span> <span class="nx">profile</span></span></span></code></pre></div><p><code>copyProfile.Tags</code> 和 <code>profile.Tags</code> 仍然指向同一個底層 array。若要保護邊界，需要複製 slice：</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">CloneProfile</span><span class="p">(</span><span class="nx">p</span> <span class="nx">Profile</span><span class="p">)</span> <span class="nx">Profile</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">p</span><span class="p">.</span><span class="nx">Tags</span> <span class="p">=</span> <span class="nb">append</span><span class="p">([]</span><span class="nb">string</span><span class="p">(</span><span class="kc">nil</span><span class="p">),</span> <span class="nx">p</span><span class="p">.</span><span class="nx">Tags</span><span class="o">...</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">return</span> <span class="nx">p</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種 copy 邊界在共享狀態、快取、API response、測試資料中都很重要。</p>
]]></content:encoded></item><item><title>3.5 net/http 與 handler 設計</title><link>https://tarrragon.github.io/blog/go/03-stdlib/http-handler/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/03-stdlib/http-handler/</guid><description>&lt;p>Go 的 &lt;code>net/http&lt;/code> 把 HTTP endpoint 簡化成一個核心模型：handler 接收 request，然後寫出 response。後端服務可以有複雜的資料庫、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>、背景工作或即時連線，但 HTTP 入口本身應該先保持清楚。&lt;/p>
&lt;h2 id="handler-是-http-邊界">handler 是 HTTP 邊界&lt;/h2>
&lt;p>HTTP handler 的核心責任是處理協定邊界。它應該讀取 request、驗證輸入、呼叫內部邏輯，最後寫出 status code、header 與 body。&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">handleHealth&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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">if&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Method&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MethodGet&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">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;method not allowed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusMethodNotAllowed&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>&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Header&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Content-Type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;application/json&amp;#34;&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="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusOK&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fprint&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">`{&amp;#34;status&amp;#34;:&amp;#34;ok&amp;#34;}`&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 handler 只處理健康檢查：確認 HTTP method，設定回應格式，寫出 JSON。它沒有讀取資料庫，也沒有啟動背景工作，因為健康檢查的責任就是讓呼叫者知道服務是否能回應。&lt;/p>
&lt;p>handler 可以呼叫內部服務，但不應該把所有業務規則都塞在 HTTP 層。HTTP 層越薄，測試越容易，未來改成 CLI、queue &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a> 或其他入口時也比較不會重寫核心邏輯。&lt;/p>
&lt;h2 id="httphandlerfunc-是函式轉接器">&lt;code>http.HandlerFunc&lt;/code> 是函式轉接器&lt;/h2>
&lt;p>&lt;code>http.HandlerFunc&lt;/code> 的核心意義是讓普通函式符合 &lt;code>http.Handler&lt;/code> 介面。只要函式形狀是 &lt;code>func(http.ResponseWriter, *http.Request)&lt;/code>，就能成為 HTTP handler。&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">hello&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fprint&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;hello&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="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="nf">main&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">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">HandleFunc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;/hello&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">hello&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="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ListenAndServe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;:8080&amp;#34;&lt;/span>&lt;span class="p">,&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">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>&lt;code>http.HandleFunc&lt;/code> 會把 &lt;code>hello&lt;/code> 轉成 handler 並註冊到預設 mux。小範例可以這樣寫，但實際應用通常會建立自己的 &lt;code>ServeMux&lt;/code>，避免全域註冊讓測試與組裝變得不清楚。&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">newRouter&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Handler&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">mux&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewServeMux&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">mux&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">HandleFunc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;/health&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">handleHealth&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">mux&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">HandleFunc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;/users&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">handleUsers&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">mux&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;p>回傳 &lt;code>http.Handler&lt;/code> 可以隱藏路由實作，呼叫端只需要知道這是一個可被 server 使用的 handler。&lt;/p>
&lt;h2 id="servemux-負責路由分派">&lt;code>ServeMux&lt;/code> 負責路由分派&lt;/h2>
&lt;p>&lt;code>ServeMux&lt;/code> 的核心責任是把 request path 對應到 handler。標準庫的 &lt;code>http.NewServeMux&lt;/code> 足以建立許多小型服務與教學範例。&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">main&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">mux&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewServeMux&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">mux&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">HandleFunc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;GET /health&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">handleHealth&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">mux&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">HandleFunc&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;POST /users&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">handleCreateUser&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">server&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Server&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="nx">Addr&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;:8080&amp;#34;&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="nx">Handler&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">mux&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&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">server&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ListenAndServe&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">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">log&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatal&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">13&lt;/span>&lt;span class="cl"> &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;/code>&lt;/pre>&lt;/div>&lt;p>新版 Go 的 &lt;code>ServeMux&lt;/code> 支援在 pattern 裡寫 HTTP method，例如 &lt;code>GET /health&lt;/code>。這能讓 method 與 path 在註冊處一起呈現。&lt;/p>
&lt;p>若你的專案需要 middleware group、path parameter 或更完整的路由功能，可以使用第三方 router。入門階段先理解標準庫模型，會更容易看懂任何 router 的抽象。&lt;/p>
&lt;h2 id="request-讀取要有明確限制">request 讀取要有明確限制&lt;/h2>
&lt;p>讀取 request 的核心原則是只接受你預期的內容。handler 應該檢查 method、content type、body 大小與 JSON 格式，避免把任意輸入直接交給內部邏輯。&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">createUserRequest&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">Name&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;name&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="nx">Email&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;email&amp;#34;`&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="kd">func&lt;/span> &lt;span class="nf">handleCreateUser&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Method&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MethodPost&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="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;method not allowed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusMethodNotAllowed&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>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Close&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">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">MaxBytesReader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&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 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>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">req&lt;/span> &lt;span class="nx">createUserRequest&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&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">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&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">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid json&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&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>&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="k">if&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Email&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">22&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;email is required&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&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="k">return&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="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusCreated&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>&lt;code>http.MaxBytesReader&lt;/code> 限制 body 大小，避免大型輸入消耗過多記憶體。&lt;code>json.Decoder&lt;/code> 解析 body，失敗時回傳 &lt;code>400 Bad Request&lt;/code>。欄位驗證通過後，handler 才進入真正的建立流程。&lt;/p></description><content:encoded><![CDATA[<p>Go 的 <code>net/http</code> 把 HTTP endpoint 簡化成一個核心模型：handler 接收 request，然後寫出 response。後端服務可以有複雜的資料庫、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>、背景工作或即時連線，但 HTTP 入口本身應該先保持清楚。</p>
<h2 id="handler-是-http-邊界">handler 是 HTTP 邊界</h2>
<p>HTTP handler 的核心責任是處理協定邊界。它應該讀取 request、驗證輸入、呼叫內部邏輯，最後寫出 status code、header 與 body。</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">handleHealth</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</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">r</span><span class="p">.</span><span class="nx">Method</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">MethodGet</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;method not allowed&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusMethodNotAllowed</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</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="nx">w</span><span class="p">.</span><span class="nf">Header</span><span class="p">().</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Content-Type&#34;</span><span class="p">,</span> <span class="s">&#34;application/json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusOK</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprint</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">`{&#34;status&#34;:&#34;ok&#34;}`</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 handler 只處理健康檢查：確認 HTTP method，設定回應格式，寫出 JSON。它沒有讀取資料庫，也沒有啟動背景工作，因為健康檢查的責任就是讓呼叫者知道服務是否能回應。</p>
<p>handler 可以呼叫內部服務，但不應該把所有業務規則都塞在 HTTP 層。HTTP 層越薄，測試越容易，未來改成 CLI、queue <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 或其他入口時也比較不會重寫核心邏輯。</p>
<h2 id="httphandlerfunc-是函式轉接器"><code>http.HandlerFunc</code> 是函式轉接器</h2>
<p><code>http.HandlerFunc</code> 的核心意義是讓普通函式符合 <code>http.Handler</code> 介面。只要函式形狀是 <code>func(http.ResponseWriter, *http.Request)</code>，就能成為 HTTP handler。</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">hello</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</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">fmt</span><span class="p">.</span><span class="nf">Fprint</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;hello&#34;</span><span class="p">)</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="nf">main</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">http</span><span class="p">.</span><span class="nf">HandleFunc</span><span class="p">(</span><span class="s">&#34;/hello&#34;</span><span class="p">,</span> <span class="nx">hello</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nx">http</span><span class="p">.</span><span class="nf">ListenAndServe</span><span class="p">(</span><span class="s">&#34;:8080&#34;</span><span class="p">,</span> <span class="kc">nil</span><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>http.HandleFunc</code> 會把 <code>hello</code> 轉成 handler 並註冊到預設 mux。小範例可以這樣寫，但實際應用通常會建立自己的 <code>ServeMux</code>，避免全域註冊讓測試與組裝變得不清楚。</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">newRouter</span><span class="p">()</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">mux</span> <span class="o">:=</span> <span class="nx">http</span><span class="p">.</span><span class="nf">NewServeMux</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">mux</span><span class="p">.</span><span class="nf">HandleFunc</span><span class="p">(</span><span class="s">&#34;/health&#34;</span><span class="p">,</span> <span class="nx">handleHealth</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">mux</span><span class="p">.</span><span class="nf">HandleFunc</span><span class="p">(</span><span class="s">&#34;/users&#34;</span><span class="p">,</span> <span class="nx">handleUsers</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">mux</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>回傳 <code>http.Handler</code> 可以隱藏路由實作，呼叫端只需要知道這是一個可被 server 使用的 handler。</p>
<h2 id="servemux-負責路由分派"><code>ServeMux</code> 負責路由分派</h2>
<p><code>ServeMux</code> 的核心責任是把 request path 對應到 handler。標準庫的 <code>http.NewServeMux</code> 足以建立許多小型服務與教學範例。</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">main</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">mux</span> <span class="o">:=</span> <span class="nx">http</span><span class="p">.</span><span class="nf">NewServeMux</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">mux</span><span class="p">.</span><span class="nf">HandleFunc</span><span class="p">(</span><span class="s">&#34;GET /health&#34;</span><span class="p">,</span> <span class="nx">handleHealth</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">mux</span><span class="p">.</span><span class="nf">HandleFunc</span><span class="p">(</span><span class="s">&#34;POST /users&#34;</span><span class="p">,</span> <span class="nx">handleCreateUser</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">server</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">http</span><span class="p">.</span><span class="nx">Server</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Addr</span><span class="p">:</span>    <span class="s">&#34;:8080&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Handler</span><span class="p">:</span> <span class="nx">mux</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">server</span><span class="p">.</span><span class="nf">ListenAndServe</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">12</span><span class="cl">        <span class="nx">log</span><span class="p">.</span><span class="nf">Fatal</span><span class="p">(</span><span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>新版 Go 的 <code>ServeMux</code> 支援在 pattern 裡寫 HTTP method，例如 <code>GET /health</code>。這能讓 method 與 path 在註冊處一起呈現。</p>
<p>若你的專案需要 middleware group、path parameter 或更完整的路由功能，可以使用第三方 router。入門階段先理解標準庫模型，會更容易看懂任何 router 的抽象。</p>
<h2 id="request-讀取要有明確限制">request 讀取要有明確限制</h2>
<p>讀取 request 的核心原則是只接受你預期的內容。handler 應該檢查 method、content type、body 大小與 JSON 格式，避免把任意輸入直接交給內部邏輯。</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">createUserRequest</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">Name</span>  <span class="kt">string</span> <span class="s">`json:&#34;name&#34;`</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Email</span> <span class="kt">string</span> <span class="s">`json:&#34;email&#34;`</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="kd">func</span> <span class="nf">handleCreateUser</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Method</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">MethodPost</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;method not allowed&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusMethodNotAllowed</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">return</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></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">.</span><span class="nf">Close</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">Body</span> <span class="p">=</span> <span class="nx">http</span><span class="p">.</span><span class="nf">MaxBytesReader</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">,</span> <span class="mi">1</span><span class="o">&lt;&lt;</span><span class="mi">20</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="kd">var</span> <span class="nx">req</span> <span class="nx">createUserRequest</span>
</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">json</span><span class="p">.</span><span class="nf">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</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">17</span><span class="cl">        <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;invalid json&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="k">return</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="k">if</span> <span class="nx">req</span><span class="p">.</span><span class="nx">Email</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;email is required&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">return</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="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</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><code>http.MaxBytesReader</code> 限制 body 大小，避免大型輸入消耗過多記憶體。<code>json.Decoder</code> 解析 body，失敗時回傳 <code>400 Bad Request</code>。欄位驗證通過後，handler 才進入真正的建立流程。</p>
<p>這段範例省略了資料儲存，因為本章重點是 HTTP 邊界。實務上通常會把建立使用者的規則放到 service 函式，handler 只負責轉換 request 與 response。</p>
<h2 id="response-要先決定狀態碼">response 要先決定狀態碼</h2>
<p>寫 response 的核心規則是先決定 status code，再寫 header 與 body。只要 body 開始寫出，Go 就會送出預設或目前設定的 status code。</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">writeJSON</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">status</span> <span class="kt">int</span><span class="p">,</span> <span class="nx">value</span> <span class="kt">any</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">w</span><span class="p">.</span><span class="nf">Header</span><span class="p">().</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Content-Type&#34;</span><span class="p">,</span> <span class="s">&#34;application/json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">status</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">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewEncoder</span><span class="p">(</span><span class="nx">w</span><span class="p">).</span><span class="nf">Encode</span><span class="p">(</span><span class="nx">value</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">6</span><span class="cl">        <span class="c1">// response 已經開始寫出，這裡通常只能記錄錯誤。</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">        <span class="nx">log</span><span class="p">.</span><span class="nf">Printf</span><span class="p">(</span><span class="s">&#34;write json response: %v&#34;</span><span class="p">,</span> <span class="nx">err</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="p">}</span></span></span></code></pre></div><p><code>WriteHeader</code> 應該在 <code>Encode</code> 之前呼叫。若先寫 body，再呼叫 <code>WriteHeader</code>，狀態碼可能已經固定為 <code>200 OK</code>。</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="nf">writeJSON</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</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="kt">string</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s">&#34;id&#34;</span><span class="p">:</span> <span class="s">&#34;user_123&#34;</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>小型範例可以直接在 handler 裡寫 response；當多個 handler 都要輸出 JSON 時，抽出 <code>writeJSON</code> 這類 helper 可以減少重複。</p>
<h2 id="handler-可以依賴介面">handler 可以依賴介面</h2>
<p>handler 依賴介面的核心好處是測試與替換更容易。HTTP 層不需要知道資料來自資料庫、記憶體或遠端 API，只需要知道它可以呼叫某個能力。</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">UserCreator</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">CreateUser</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">name</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">email</span> <span class="kt">string</span><span class="p">)</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"> 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">UserHandler</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">creator</span> <span class="nx">UserCreator</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">func</span> <span class="p">(</span><span class="nx">h</span> <span class="nx">UserHandler</span><span class="p">)</span> <span class="nf">Create</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="c1">// 解析與驗證 request 後：</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">id</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">creator</span><span class="p">.</span><span class="nf">CreateUser</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nf">Context</span><span class="p">(),</span> <span class="s">&#34;alice&#34;</span><span class="p">,</span> <span class="s">&#34;alice@example.com&#34;</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">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="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;create user&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusInternalServerError</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="nf">writeJSON</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</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="kt">string</span><span class="p">{</span><span class="s">&#34;id&#34;</span><span class="p">:</span> <span class="nx">id</span><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>這裡的 <code>UserHandler</code> 不知道使用者如何被建立，只知道有一個 <code>UserCreator</code>。測試時可以提供假的 creator，正式環境再接上真正實作。</p>
<p>介面不需要一開始就為所有東西建立。當 handler 真的需要隔離外部依賴，或測試需要替換依賴時，再抽出小介面會更自然。</p>
<h2 id="小結">小結</h2>
<p>下一章會回到 logging，說明如何用 <code>slog</code> 讓服務輸出可搜尋、可關聯的結構化資訊。</p>
]]></content:encoded></item><item><title>5.5 時間注入與 deterministic test</title><link>https://tarrragon.github.io/blog/go/05-error-testing/time-injection/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/05-error-testing/time-injection/</guid><description>&lt;p>時間注入的核心目標是讓測試可以控制「現在時間」。只要函式內部直接呼叫 &lt;code>time.Now()&lt;/code>，測試結果就可能受執行時間影響；把時間來源改成參數或小介面後，測試就能重現固定情境。&lt;/p>
&lt;h2 id="真實時間會讓測試不穩定">真實時間會讓測試不穩定&lt;/h2>
&lt;p>測試不穩定的核心原因是輸入不完全由測試控制。時間是最常見的隱性輸入，因為 &lt;code>time.Now()&lt;/code> 每次呼叫都會得到不同結果。&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">IsExpired&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">deadline&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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">return&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">After&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">deadline&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個函式看起來只有一個參數，但實際上還依賴目前時間。測試如果用 &lt;code>time.Now().Add(...)&lt;/code> 組 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a>，可能因為執行延遲、時區或邊界條件而變得脆弱。&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">IsExpired&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">deadline&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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">return&lt;/span> &lt;span class="nx">now&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">After&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">deadline&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>函式的所有重要輸入都變成參數後，測試可以完全控制情境。&lt;/p>
&lt;h2 id="參數注入適合純邏輯">參數注入適合純邏輯&lt;/h2>
&lt;p>參數注入的核心用途是處理單次計算。當函式只是判斷過期、計算剩餘時間或產生 timestamp，直接把 &lt;code>now time.Time&lt;/code> 傳入通常最簡單。&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">Remaining&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">deadline&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Duration&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">if&lt;/span> &lt;span class="nx">now&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">After&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">deadline&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">return&lt;/span> &lt;span class="mi">0&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="k">return&lt;/span> &lt;span class="nx">deadline&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Sub&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&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;/code>&lt;/pre>&lt;/div>&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">TestRemaining&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">now&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Date&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2026&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">22&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UTC&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">deadline&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Date&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2026&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">22&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">5&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UTC&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">got&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">Remaining&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">deadline&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">want&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">5&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">Minute&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">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">want&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="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Remaining() = %v, want %v&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&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="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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個測試不會因為今天是哪一天、測試跑得快或慢而改變結果。參數注入也讓函式更容易理解，因為時間依賴直接出現在函式簽名中。&lt;/p>
&lt;h2 id="provider-函式適合需要多次取時間的元件">provider 函式適合需要多次取時間的元件&lt;/h2>
&lt;p>時間 provider 的核心用途是讓長生命週期元件可以取得目前時間，但測試仍能替換時間來源。最簡單的 provider 是 &lt;code>func() time.Time&lt;/code>。&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">TokenGenerator&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">now&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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="nf">NewTokenGenerator&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">TokenGenerator&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="nx">TokenGenerator&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">now&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="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">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">g&lt;/span> &lt;span class="nx">TokenGenerator&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">NewToken&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">userID&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">Token&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">Token&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">UserID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">userID&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">CreatedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">g&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">now&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="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;/code>&lt;/pre>&lt;/div>&lt;p>正式環境可以傳入 &lt;code>time.Now&lt;/code>。&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="nx">generator&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewTokenGenerator&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Now&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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">TestTokenGenerator&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">fixedNow&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Date&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2026&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">22&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UTC&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">generator&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewTokenGenerator&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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="nx">fixedNow&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">token&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">generator&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewToken&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;user_123&amp;#34;&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">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">token&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">CreatedAt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Equal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">fixedNow&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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;CreatedAt = %v, want %v&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">token&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">CreatedAt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">fixedNow&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="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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>func() time.Time&lt;/code> 比完整介面更輕量，適合只需要目前時間的情境。若元件還需要 timer、ticker 或 sleep，才需要更完整的 clock abstraction。&lt;/p>
&lt;h2 id="duration-測試應控制時間">duration 測試應控制時間&lt;/h2>
&lt;p>測試 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a> 的核心原則是驗證邏輯，不是讓測試真的睡很久。&lt;code>time.Sleep&lt;/code> 會讓測試慢，也會讓測試受排程影響。&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">RetryDelay&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">attempt&lt;/span> &lt;span class="kt">int&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Duration&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">if&lt;/span> &lt;span class="nx">attempt&lt;/span> &lt;span class="o">&amp;lt;=&lt;/span> &lt;span class="mi">0&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">return&lt;/span> &lt;span class="mi">0&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="k">return&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Duration&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">attempt&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="mi">100&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">Millisecond&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>這種邏輯應該直接測回傳的 duration。&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">TestRetryDelay&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">got&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">RetryDelay&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&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">want&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">300&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">Millisecond&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="k">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">want&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">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;RetryDelay() = %v, want %v&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&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="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>若真的要測等待行為，應把等待機制包成可替換依賴，讓測試使用 fake sleeper，而不是呼叫真實 &lt;code>time.Sleep&lt;/code>。&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">Sleeper&lt;/span> &lt;span class="kd">interface&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="nf">Sleep&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Duration&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="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">realSleeper&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>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">realSleeper&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Sleep&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">d&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Duration&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">8&lt;/span>&lt;span class="cl"> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Sleep&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">d&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這種抽象只有在等待行為本身需要測試時才值得加入。不要為了形式而把所有 &lt;code>time&lt;/code> API 都包起來。&lt;/p>
&lt;h2 id="時區要明確">時區要明確&lt;/h2>
&lt;p>時間測試的核心規則是使用明確時區。測試資料若依賴本機時區，可能在不同開發機或 CI 環境得到不同結果。&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="nx">createdAt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Date&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2026&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">22&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UTC&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>使用 &lt;code>time.UTC&lt;/code> 能讓測試在不同環境保持一致。若功能本來就和特定時區有關，應用 &lt;code>time.LoadLocation&lt;/code> 明確載入。&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="nx">loc&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">LoadLocation&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Asia/Taipei&amp;#34;&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">if&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">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;load location: %v&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">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">localTime&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Date&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2026&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">22&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">18&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">loc&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>不要讓測試默默依賴 &lt;code>time.Local&lt;/code>，除非測試目的就是驗證本機時區設定。&lt;/p>
&lt;h2 id="下一章">下一章&lt;/h2>
&lt;p>下一章會進入並發行為測試，說明如何驗證 goroutine、channel 與共享狀態。&lt;/p>
&lt;h2 id="延伸閱讀">延伸閱讀&lt;/h2>
&lt;p>本章處理入門測試中的時間依賴。若要測長時間 worker、ticker 排程、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> cleanup 或 deadline，可以接著閱讀 &lt;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/time-control/" data-link-title="5.1 時間注入與狀態轉移測試" data-link-desc="讓時間相關邏輯可重現">Go 進階：時間注入與狀態轉移測試&lt;/a>；若 timeout 來自部署平台或 load balancer，則閱讀 &lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Go 進階：Kubernetes、systemd 與 load balancer 合約&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>時間注入的核心目標是讓測試可以控制「現在時間」。只要函式內部直接呼叫 <code>time.Now()</code>，測試結果就可能受執行時間影響；把時間來源改成參數或小介面後，測試就能重現固定情境。</p>
<h2 id="真實時間會讓測試不穩定">真實時間會讓測試不穩定</h2>
<p>測試不穩定的核心原因是輸入不完全由測試控制。時間是最常見的隱性輸入，因為 <code>time.Now()</code> 每次呼叫都會得到不同結果。</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">IsExpired</span><span class="p">(</span><span class="nx">deadline</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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">return</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">().</span><span class="nf">After</span><span class="p">(</span><span class="nx">deadline</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>這個函式看起來只有一個參數，但實際上還依賴目前時間。測試如果用 <code>time.Now().Add(...)</code> 組 <a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a>，可能因為執行延遲、時區或邊界條件而變得脆弱。</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">IsExpired</span><span class="p">(</span><span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">,</span> <span class="nx">deadline</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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">return</span> <span class="nx">now</span><span class="p">.</span><span class="nf">After</span><span class="p">(</span><span class="nx">deadline</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>函式的所有重要輸入都變成參數後，測試可以完全控制情境。</p>
<h2 id="參數注入適合純邏輯">參數注入適合純邏輯</h2>
<p>參數注入的核心用途是處理單次計算。當函式只是判斷過期、計算剩餘時間或產生 timestamp，直接把 <code>now time.Time</code> 傳入通常最簡單。</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">Remaining</span><span class="p">(</span><span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">,</span> <span class="nx">deadline</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</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">now</span><span class="p">.</span><span class="nf">After</span><span class="p">(</span><span class="nx">deadline</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">return</span> <span class="mi">0</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">return</span> <span class="nx">deadline</span><span class="p">.</span><span class="nf">Sub</span><span class="p">(</span><span class="nx">now</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><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">TestRemaining</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">now</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">deadline</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">5</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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">got</span> <span class="o">:=</span> <span class="nf">Remaining</span><span class="p">(</span><span class="nx">now</span><span class="p">,</span> <span class="nx">deadline</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">want</span> <span class="o">:=</span> <span class="mi">5</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</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">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">want</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</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;Remaining() = %v, want %v&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">want</span><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>這個測試不會因為今天是哪一天、測試跑得快或慢而改變結果。參數注入也讓函式更容易理解，因為時間依賴直接出現在函式簽名中。</p>
<h2 id="provider-函式適合需要多次取時間的元件">provider 函式適合需要多次取時間的元件</h2>
<p>時間 provider 的核心用途是讓長生命週期元件可以取得目前時間，但測試仍能替換時間來源。最簡單的 provider 是 <code>func() time.Time</code>。</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">TokenGenerator</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">now</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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="nf">NewTokenGenerator</span><span class="p">(</span><span class="nx">now</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="nx">TokenGenerator</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="nx">TokenGenerator</span><span class="p">{</span><span class="nx">now</span><span class="p">:</span> <span class="nx">now</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></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">g</span> <span class="nx">TokenGenerator</span><span class="p">)</span> <span class="nf">NewToken</span><span class="p">(</span><span class="nx">userID</span> <span class="kt">string</span><span class="p">)</span> <span class="nx">Token</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">Token</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">UserID</span><span class="p">:</span>    <span class="nx">userID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">g</span><span class="p">.</span><span class="nf">now</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>正式環境可以傳入 <code>time.Now</code>。</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="nx">generator</span> <span class="o">:=</span> <span class="nf">NewTokenGenerator</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Now</span><span class="p">)</span></span></span></code></pre></div><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">TestTokenGenerator</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">fixedNow</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">generator</span> <span class="o">:=</span> <span class="nf">NewTokenGenerator</span><span class="p">(</span><span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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="nx">fixedNow</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="nx">token</span> <span class="o">:=</span> <span class="nx">generator</span><span class="p">.</span><span class="nf">NewToken</span><span class="p">(</span><span class="s">&#34;user_123&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">token</span><span class="p">.</span><span class="nx">CreatedAt</span><span class="p">.</span><span class="nf">Equal</span><span class="p">(</span><span class="nx">fixedNow</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">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;CreatedAt = %v, want %v&#34;</span><span class="p">,</span> <span class="nx">token</span><span class="p">.</span><span class="nx">CreatedAt</span><span class="p">,</span> <span class="nx">fixedNow</span><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><code>func() time.Time</code> 比完整介面更輕量，適合只需要目前時間的情境。若元件還需要 timer、ticker 或 sleep，才需要更完整的 clock abstraction。</p>
<h2 id="duration-測試應控制時間">duration 測試應控制時間</h2>
<p>測試 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 的核心原則是驗證邏輯，不是讓測試真的睡很久。<code>time.Sleep</code> 會讓測試慢，也會讓測試受排程影響。</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">RetryDelay</span><span class="p">(</span><span class="nx">attempt</span> <span class="kt">int</span><span class="p">)</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</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">attempt</span> <span class="o">&lt;=</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">return</span> <span class="mi">0</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="k">return</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Duration</span><span class="p">(</span><span class="nx">attempt</span><span class="p">)</span> <span class="o">*</span> <span class="mi">100</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Millisecond</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種邏輯應該直接測回傳的 duration。</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">TestRetryDelay</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">got</span> <span class="o">:=</span> <span class="nf">RetryDelay</span><span class="p">(</span><span class="mi">3</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">want</span> <span class="o">:=</span> <span class="mi">300</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Millisecond</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">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">want</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;RetryDelay() = %v, want %v&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">want</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>若真的要測等待行為，應把等待機制包成可替換依賴，讓測試使用 fake sleeper，而不是呼叫真實 <code>time.Sleep</code>。</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">Sleeper</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">Sleep</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">)</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">realSleeper</span> <span class="kd">struct</span><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">realSleeper</span><span class="p">)</span> <span class="nf">Sleep</span><span class="p">(</span><span class="nx">d</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="nx">time</span><span class="p">.</span><span class="nf">Sleep</span><span class="p">(</span><span class="nx">d</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>time</code> API 都包起來。</p>
<h2 id="時區要明確">時區要明確</h2>
<p>時間測試的核心規則是使用明確時區。測試資料若依賴本機時區，可能在不同開發機或 CI 環境得到不同結果。</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="nx">createdAt</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span></span></span></code></pre></div><p>使用 <code>time.UTC</code> 能讓測試在不同環境保持一致。若功能本來就和特定時區有關，應用 <code>time.LoadLocation</code> 明確載入。</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="nx">loc</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">LoadLocation</span><span class="p">(</span><span class="s">&#34;Asia/Taipei&#34;</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">err</span> <span class="o">!=</span> <span class="kc">nil</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;load location: %v&#34;</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nx">localTime</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">18</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">loc</span><span class="p">)</span></span></span></code></pre></div><p>不要讓測試默默依賴 <code>time.Local</code>，除非測試目的就是驗證本機時區設定。</p>
<h2 id="下一章">下一章</h2>
<p>下一章會進入並發行為測試，說明如何驗證 goroutine、channel 與共享狀態。</p>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>本章處理入門測試中的時間依賴。若要測長時間 worker、ticker 排程、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> cleanup 或 deadline，可以接著閱讀 <a href="/blog/go-advanced/05-testing-reliability/time-control/" data-link-title="5.1 時間注入與狀態轉移測試" data-link-desc="讓時間相關邏輯可重現">Go 進階：時間注入與狀態轉移測試</a>；若 timeout 來自部署平台或 load balancer，則閱讀 <a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Go 進階：Kubernetes、systemd 與 load balancer 合約</a>。</p>
]]></content:encoded></item><item><title>6.5 如何新增結構化記錄欄位</title><link>https://tarrragon.github.io/blog/go/06-practical/structured-recording/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/06-practical/structured-recording/</guid><description>&lt;p>新增結構化記錄欄位的核心規則是先判斷這筆資訊是給工程師除錯、給系統重播，還是給使用者查詢。不同用途對應不同記錄邊界，資料應依用途進入 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a>、&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> 或 repository。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 structured log、domain event log 與 state repository&lt;/li>
&lt;li>設計穩定的 log 欄位名稱&lt;/li>
&lt;li>判斷哪些資料不應寫進 log&lt;/li>
&lt;li>用 &lt;code>EventLog.Append&lt;/code> 表達事件記錄邊界&lt;/li>
&lt;li>測試穩定欄位，而不是測自由文字&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察先判斷記錄用途">【觀察】先判斷記錄用途&lt;/h2>
&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>structured log&lt;/td>
 &lt;td>操作診斷、除錯、聚合查詢&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> full、event rejected、worker failed&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>domain event log&lt;/td>
 &lt;td>記錄已發生事實、audit、replay&lt;/td>
 &lt;td>&lt;code>notification.created&lt;/code>、&lt;code>job.failed&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>state repository&lt;/td>
 &lt;td>查詢目前狀態或投影&lt;/td>
 &lt;td>job current status、notification summary&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>structured log 服務操作診斷，event log 保存 normalized fact，state repository 回答目前狀態。先分清楚用途，才知道欄位該放哪裡。這個用途判斷比選擇哪個 logging package 更關鍵 — 工具決定怎麼寫，用途決定寫什麼、放哪裡。&lt;/p>
&lt;h2 id="判讀structured-log-是操作訊號">【判讀】structured log 是操作訊號&lt;/h2>
&lt;p>structured log 的核心用途是讓工程師知道系統正在發生什麼，並且能用欄位查詢。它應該記錄操作訊號，而不是完整業務資料。&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="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Info&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="s">&amp;#34;event accepted&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="s">&amp;#34;layer&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;adapter&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="s">&amp;#34;event_type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">string&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&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="s">&amp;#34;event_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&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">6&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;subject_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SubjectID&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="s">&amp;#34;correlation_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">CorrelationID&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>message&lt;/code> 給人讀，欄位給查詢工具使用。若未來要查某種事件是否大量進入系統，&lt;code>event_type&lt;/code> 欄位比文字搜尋更可靠。&lt;/p>
&lt;p>常見 log 欄位可以先定義成 helper，避免不同地方拼出不同名稱：&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">LogAttrsForEvent&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span> &lt;span class="nx">DomainEvent&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">any&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">return&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">any&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="s">&amp;#34;event_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&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"> 4&lt;/span>&lt;span class="cl"> &lt;span class="s">&amp;#34;event_type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">string&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&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="s">&amp;#34;subject_kind&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">string&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SubjectKind&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="s">&amp;#34;subject_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SubjectID&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="s">&amp;#34;correlation_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">CorrelationID&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="s">&amp;#34;schema_version&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SchemaVersion&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="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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;event accepted&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nf">LogAttrsForEvent&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">...&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 helper 保護的是 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log-schema/" data-link-title="Log Schema" data-link-desc="說明結構化 log 欄位如何支援搜尋、關聯與事故排查">log schema&lt;/a>。欄位名稱穩定，查詢與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dashboard/" data-link-title="Dashboard" data-link-desc="說明 dashboard 如何把關鍵訊號組成可判讀的服務狀態畫面">dashboard&lt;/a> 才能穩定。&lt;/p>
&lt;h2 id="策略reason-欄位要像-enum">【策略】reason 欄位要像 enum&lt;/h2>
&lt;p>&lt;code>reason&lt;/code> 的核心語意是可聚合的原因分類。它應使用小集合穩定值；完整錯誤訊息則放在 &lt;code>error&lt;/code> 欄位協助診斷。&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">ReasonInvalidPayload&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;invalid_payload&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="nx">ReasonQueueFull&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;queue_full&amp;#34;&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">ReasonDuplicateEvent&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;duplicate_event&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">ReasonTimeout&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;timeout&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="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Warn&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="s">&amp;#34;event rejected&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="s">&amp;#34;layer&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;adapter&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="s">&amp;#34;reason&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ReasonInvalidPayload&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="s">&amp;#34;event_type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">string&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Type&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="s">&amp;#34;error&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">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>&lt;code>reason&lt;/code> 用來統計，&lt;code>error&lt;/code> 用來診斷，message 用來讓人快速理解。這三者不要混成一個大字串。&lt;/p>
&lt;h2 id="判讀event-log-記錄-normalized-fact">【判讀】event log 記錄 normalized fact&lt;/h2>
&lt;p>domain event log 的核心責任是保存已正規化的 domain event。它記錄的是系統承認的事實；raw request、debug log 與目前狀態分別屬於不同記錄邊界。&lt;/p>
&lt;p>先定義 port：&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">EventLog&lt;/span> &lt;span class="kd">interface&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="nf">Append&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">event&lt;/span> &lt;span class="nx">DomainEvent&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&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;/code>&lt;/pre>&lt;/div>&lt;p>memory implementation 可以先這樣寫：&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">InMemoryEventLog&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">mu&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Mutex&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">events&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="nx">DomainEvent&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="kd">func&lt;/span> &lt;span class="nf">NewInMemoryEventLog&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">InMemoryEventLog&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">return&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">InMemoryEventLog&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="p">(&lt;/span>&lt;span class="nx">l&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">InMemoryEventLog&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Append&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">event&lt;/span> &lt;span class="nx">DomainEvent&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="nx">l&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Lock&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">defer&lt;/span> &lt;span class="nx">l&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">mu&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Unlock&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>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">l&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">events&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">l&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">events&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nf">cloneDomainEvent&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&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="k">return&lt;/span> &lt;span class="kc">nil&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>event log 應該保存 &lt;code>DomainEvent&lt;/code> envelope 中的穩定欄位，例如 event ID、type、subject、schema version、occurred/received time。它不需要保存 adapter 的 raw input，除非你已經明確設計 raw &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/audit-log/" data-link-title="Audit Log" data-link-desc="說明高風險操作如何留下可追溯、可稽核的紀錄">audit log&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>新增結構化記錄欄位的核心規則是先判斷這筆資訊是給工程師除錯、給系統重播，還是給使用者查詢。不同用途對應不同記錄邊界，資料應依用途進入 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>、<a href="/blog/backend/knowledge-cards/event-log/" data-link-title="Event Log" data-link-desc="說明事件歷史如何保存、重播與支援跨服務資料重建">event log</a> 或 repository。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 structured log、domain event log 與 state repository</li>
<li>設計穩定的 log 欄位名稱</li>
<li>判斷哪些資料不應寫進 log</li>
<li>用 <code>EventLog.Append</code> 表達事件記錄邊界</li>
<li>測試穩定欄位，而不是測自由文字</li>
</ol>
<hr>
<h2 id="觀察先判斷記錄用途">【觀察】先判斷記錄用途</h2>
<p>記錄邊界的核心問題是資料要服務誰。工程師除錯、系統重播、使用者查詢是三種不同用途，對應三種不同儲存與格式責任。</p>
<table>
  <thead>
      <tr>
          <th>記錄類型</th>
          <th>用途</th>
          <th>範例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>structured log</td>
          <td>操作診斷、除錯、聚合查詢</td>
          <td><a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> full、event rejected、worker failed</td>
      </tr>
      <tr>
          <td>domain event log</td>
          <td>記錄已發生事實、audit、replay</td>
          <td><code>notification.created</code>、<code>job.failed</code></td>
      </tr>
      <tr>
          <td>state repository</td>
          <td>查詢目前狀態或投影</td>
          <td>job current status、notification summary</td>
      </tr>
  </tbody>
</table>
<p>structured log 服務操作診斷，event log 保存 normalized fact，state repository 回答目前狀態。先分清楚用途，才知道欄位該放哪裡。這個用途判斷比選擇哪個 logging package 更關鍵 — 工具決定怎麼寫，用途決定寫什麼、放哪裡。</p>
<h2 id="判讀structured-log-是操作訊號">【判讀】structured log 是操作訊號</h2>
<p>structured log 的核心用途是讓工程師知道系統正在發生什麼，並且能用欄位查詢。它應該記錄操作訊號，而不是完整業務資料。</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="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s">&#34;event accepted&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s">&#34;layer&#34;</span><span class="p">,</span> <span class="s">&#34;adapter&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="s">&#34;event_type&#34;</span><span class="p">,</span> <span class="nb">string</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="s">&#34;event_id&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="s">&#34;subject_id&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="s">&#34;correlation_id&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">CorrelationID</span><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>message</code> 給人讀，欄位給查詢工具使用。若未來要查某種事件是否大量進入系統，<code>event_type</code> 欄位比文字搜尋更可靠。</p>
<p>常見 log 欄位可以先定義成 helper，避免不同地方拼出不同名稱：</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">LogAttrsForEvent</span><span class="p">(</span><span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="p">[]</span><span class="kt">any</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">return</span> <span class="p">[]</span><span class="kt">any</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="s">&#34;event_id&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="s">&#34;event_type&#34;</span><span class="p">,</span> <span class="nb">string</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="s">&#34;subject_kind&#34;</span><span class="p">,</span> <span class="nb">string</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">SubjectKind</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="s">&#34;subject_id&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="s">&#34;correlation_id&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">CorrelationID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="s">&#34;schema_version&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">SchemaVersion</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="p">}</span></span></span></code></pre></div><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="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;event accepted&#34;</span><span class="p">,</span> <span class="nf">LogAttrsForEvent</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span><span class="o">...</span><span class="p">)</span></span></span></code></pre></div><p>這個 helper 保護的是 <a href="/blog/backend/knowledge-cards/log-schema/" data-link-title="Log Schema" data-link-desc="說明結構化 log 欄位如何支援搜尋、關聯與事故排查">log schema</a>。欄位名稱穩定，查詢與 <a href="/blog/backend/knowledge-cards/dashboard/" data-link-title="Dashboard" data-link-desc="說明 dashboard 如何把關鍵訊號組成可判讀的服務狀態畫面">dashboard</a> 才能穩定。</p>
<h2 id="策略reason-欄位要像-enum">【策略】reason 欄位要像 enum</h2>
<p><code>reason</code> 的核心語意是可聚合的原因分類。它應使用小集合穩定值；完整錯誤訊息則放在 <code>error</code> 欄位協助診斷。</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">ReasonInvalidPayload</span> <span class="p">=</span> <span class="s">&#34;invalid_payload&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">ReasonQueueFull</span>      <span class="p">=</span> <span class="s">&#34;queue_full&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">ReasonDuplicateEvent</span> <span class="p">=</span> <span class="s">&#34;duplicate_event&#34;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">ReasonTimeout</span>        <span class="p">=</span> <span class="s">&#34;timeout&#34;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><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="nx">logger</span><span class="p">.</span><span class="nf">Warn</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s">&#34;event rejected&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s">&#34;layer&#34;</span><span class="p">,</span> <span class="s">&#34;adapter&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="s">&#34;reason&#34;</span><span class="p">,</span> <span class="nx">ReasonInvalidPayload</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="s">&#34;event_type&#34;</span><span class="p">,</span> <span class="nb">string</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">Type</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="s">&#34;error&#34;</span><span class="p">,</span> <span class="nx">err</span><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><code>reason</code> 用來統計，<code>error</code> 用來診斷，message 用來讓人快速理解。這三者不要混成一個大字串。</p>
<h2 id="判讀event-log-記錄-normalized-fact">【判讀】event log 記錄 normalized fact</h2>
<p>domain event log 的核心責任是保存已正規化的 domain event。它記錄的是系統承認的事實；raw request、debug log 與目前狀態分別屬於不同記錄邊界。</p>
<p>先定義 port：</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">EventLog</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">Append</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">event</span> <span class="nx">DomainEvent</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></code></pre></div><p>memory implementation 可以先這樣寫：</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">InMemoryEventLog</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">mu</span>     <span class="nx">sync</span><span class="p">.</span><span class="nx">Mutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">events</span> <span class="p">[]</span><span class="nx">DomainEvent</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="kd">func</span> <span class="nf">NewInMemoryEventLog</span><span class="p">()</span> <span class="o">*</span><span class="nx">InMemoryEventLog</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="o">&amp;</span><span class="nx">InMemoryEventLog</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="p">(</span><span class="nx">l</span> <span class="o">*</span><span class="nx">InMemoryEventLog</span><span class="p">)</span> <span class="nf">Append</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">event</span> <span class="nx">DomainEvent</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="nx">l</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">12</span><span class="cl">    <span class="k">defer</span> <span class="nx">l</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">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">l</span><span class="p">.</span><span class="nx">events</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">l</span><span class="p">.</span><span class="nx">events</span><span class="p">,</span> <span class="nf">cloneDomainEvent</span><span class="p">(</span><span class="nx">event</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">nil</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>event log 應該保存 <code>DomainEvent</code> envelope 中的穩定欄位，例如 event ID、type、subject、schema version、occurred/received time。它不需要保存 adapter 的 raw input，除非你已經明確設計 raw <a href="/blog/backend/knowledge-cards/audit-log/" data-link-title="Audit Log" data-link-desc="說明高風險操作如何留下可追溯、可稽核的紀錄">audit log</a>。</p>
<h2 id="執行event-log-要保護-copy-boundary">【執行】event log 要保護 copy boundary</h2>
<p>event log 的核心資料也是內部狀態。若 event 包含 slice、map 或 <code>json.RawMessage</code>，append 與讀取時都要避免外部修改內部資料。</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">cloneDomainEvent</span><span class="p">(</span><span class="nx">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="nx">DomainEvent</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">cloned</span> <span class="o">:=</span> <span class="nx">event</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">if</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Payload</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">cloned</span><span class="p">.</span><span class="nx">Payload</span> <span class="p">=</span> <span class="nb">append</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="kc">nil</span><span class="p">),</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Payload</span><span class="o">...</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="k">return</span> <span class="nx">cloned</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><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">l</span> <span class="o">*</span><span class="nx">InMemoryEventLog</span><span class="p">)</span> <span class="nf">List</span><span class="p">()</span> <span class="p">[]</span><span class="nx">DomainEvent</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">l</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"> 3</span><span class="cl">    <span class="k">defer</span> <span class="nx">l</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"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">result</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="nx">DomainEvent</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">l</span><span class="p">.</span><span class="nx">events</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">i</span><span class="p">,</span> <span class="nx">event</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">l</span><span class="p">.</span><span class="nx">events</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">result</span><span class="p">[</span><span class="nx">i</span><span class="p">]</span> <span class="p">=</span> <span class="nf">cloneDomainEvent</span><span class="p">(</span><span class="nx">event</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">return</span> <span class="nx">result</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這裡展示的是教學用記錄邊界。真正 event store 還需要持久化、排序、[schema <a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration</a>](/go/backend/knowledge-cards/schema-migration)、重播策略與交易語意。</p>
<h2 id="策略state-repository-保存目前狀態">【策略】state repository 保存目前狀態</h2>
<p>state repository 的核心責任是回答目前狀態。它可以由 event 更新，但用途不同於保存所有歷史事實的 event log。</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">type</span> <span class="nx">JobRepository</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">Apply</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">event</span> <span class="nx">DomainEvent</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="nf">Get</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">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">JobProjection</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>event log 和 state repository 可以在 processor 中各自被呼叫：</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">RecordingEventProcessor</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">eventLog</span>   <span class="nx">EventLog</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">repository</span> <span class="nx">JobRepository</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">logger</span>     <span class="o">*</span><span class="nx">slog</span><span class="p">.</span><span class="nx">Logger</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">p</span> <span class="o">*</span><span class="nx">RecordingEventProcessor</span><span class="p">)</span> <span class="nf">Process</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">event</span> <span class="nx">DomainEvent</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="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">p</span><span class="p">.</span><span class="nx">eventLog</span><span class="p">.</span><span class="nf">Append</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</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"> 9</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;append event log: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><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></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">p</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</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;apply state projection: %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">p</span><span class="p">.</span><span class="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;event processed&#34;</span><span class="p">,</span> <span class="nf">LogAttrsForEvent</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span><span class="o">...</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">nil</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式展示三種記錄邊界：event log 保存事實，repository 更新目前狀態，structured log 記錄操作訊號。</p>
<h2 id="判讀記錄位置要跟錯誤發生層一致">【判讀】記錄位置要跟錯誤發生層一致</h2>
<p>記錄位置的核心規則是在哪一層能提供最多上下文，就在哪一層記錄。同一個錯誤通常選擇一個主要層次記錄，避免 log 被重複訊號淹沒。</p>
<p>常見位置：</p>
<table>
  <thead>
      <tr>
          <th>發生位置</th>
          <th>應記錄內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>adapter</td>
          <td>raw input decode/normalize 失敗</td>
      </tr>
      <tr>
          <td>router/usecase</td>
          <td>command 被拒絕、權限不足、狀態不允許</td>
      </tr>
      <tr>
          <td>processor</td>
          <td>event validation、dedup、<a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a> apply 結果</td>
      </tr>
      <tr>
          <td>worker</td>
          <td>queue full、外部來源失敗、重試結果</td>
      </tr>
  </tbody>
</table>
<p>例如 adapter 解碼失敗：</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="nx">logger</span><span class="p">.</span><span class="nf">Warn</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s">&#34;callback rejected&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s">&#34;layer&#34;</span><span class="p">,</span> <span class="s">&#34;adapter&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="s">&#34;reason&#34;</span><span class="p">,</span> <span class="nx">ReasonInvalidPayload</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="s">&#34;payload_bytes&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">body</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>這裡記錄 payload 大小即可診斷資料是否異常；完整 payload 可能包含敏感資料或過大內容。</p>
<h2 id="策略敏感資料預設不進-log">【策略】敏感資料預設不進 log</h2>
<p>敏感資料邊界的核心規則是 log 會被保存、轉發與搜尋，所以 token、password、完整 payload、完整個資應排除在 log 之外。</p>
<p>可以記錄：</p>
<ul>
<li>ID 或 opaque identifier</li>
<li>payload byte length</li>
<li>schema version</li>
<li>欄位是否存在</li>
<li>hash 或 checksum</li>
</ul>
<p>不應記錄：</p>
<ul>
<li>password</li>
<li>access token</li>
<li>cookie</li>
<li>完整 request body</li>
<li>完整 personal data</li>
</ul>
<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="nx">logger</span><span class="p">.</span><span class="nf">Debug</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s">&#34;payload received&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s">&#34;payload_bytes&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">body</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="s">&#34;payload_sha256&#34;</span><span class="p">,</span> <span class="nf">sha256Hex</span><span class="p">(</span><span class="nx">body</span><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>debug log 也需要遵守同樣規則；只要可能被集中收集，就要先控制敏感資料。</p>
<h2 id="執行log-helper-測試只測穩定欄位">【執行】log helper 測試只測穩定欄位</h2>
<p>log helper 測試的核心目標是保護欄位名稱與值。log message 文案是給人讀的內容，通常保留調整空間。</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">TestLogAttrsForEvent</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">event</span> <span class="o">:=</span> <span class="nx">DomainEvent</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;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>          <span class="nx">EventNotificationCreated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">SubjectKind</span><span class="p">:</span>   <span class="nx">SubjectNotification</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>     <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">CorrelationID</span><span class="p">:</span> <span class="s">&#34;corr_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">SchemaVersion</span><span class="p">:</span> <span class="mi">1</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">attrs</span> <span class="o">:=</span> <span class="nf">LogAttrsForEvent</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">got</span> <span class="o">:=</span> <span class="nf">attrsToMap</span><span class="p">(</span><span class="nx">attrs</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">if</span> <span class="nx">got</span><span class="p">[</span><span class="s">&#34;event_id&#34;</span><span class="p">]</span> <span class="o">!=</span> <span class="s">&#34;evt_1&#34;</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;event_id = %v, want evt_1&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">[</span><span class="s">&#34;event_id&#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="k">if</span> <span class="nx">got</span><span class="p">[</span><span class="s">&#34;event_type&#34;</span><span class="p">]</span> <span class="o">!=</span> <span class="nb">string</span><span class="p">(</span><span class="nx">EventNotificationCreated</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</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;event_type = %v, want %s&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">[</span><span class="s">&#34;event_type&#34;</span><span class="p">],</span> <span class="nx">EventNotificationCreated</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></code></pre></div><p>測試輔助函式可以把 key-value slice 轉成 map：</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">attrsToMap</span><span class="p">(</span><span class="nx">attrs</span> <span class="p">[]</span><span class="kt">any</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="kt">any</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">result</span> <span class="o">:=</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="kt">any</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">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span><span class="o">+</span><span class="mi">1</span> <span class="p">&lt;</span> <span class="nb">len</span><span class="p">(</span><span class="nx">attrs</span><span class="p">);</span> <span class="nx">i</span> <span class="o">+=</span> <span class="mi">2</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">key</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">attrs</span><span class="p">[</span><span class="nx">i</span><span class="p">].(</span><span class="kt">string</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</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"> 6</span><span class="cl">            <span class="k">continue</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="nx">result</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="p">=</span> <span class="nx">attrs</span><span class="p">[</span><span class="nx">i</span><span class="o">+</span><span class="mi">1</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">return</span> <span class="nx">result</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試直接檢查 helper 輸出，不需要真的寫 log 或解析 logger output。</p>
<h2 id="執行event-log-測試要保護-append-與-copy">【執行】event log 測試要保護 append 與 copy</h2>
<p>event log 測試的核心目標是確認事件被 append，且外部無法透過原始 payload 或回傳值修改內部紀錄。</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">TestInMemoryEventLogAppendCopiesPayload</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">log</span> <span class="o">:=</span> <span class="nf">NewInMemoryEventLog</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">payload</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;deployments&#34;}`</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">event</span> <span class="o">:=</span> <span class="nx">DomainEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>            <span class="s">&#34;evt_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>          <span class="nx">EventNotificationCreated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">SubjectKind</span><span class="p">:</span>   <span class="nx">SubjectNotification</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span>     <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">OccurredAt</span><span class="p">:</span>    <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">ReceivedAt</span><span class="p">:</span>    <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">SchemaVersion</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">Payload</span><span class="p">:</span>       <span class="nx">payload</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="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">log</span><span class="p">.</span><span class="nf">Append</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">event</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">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;append event: %v&#34;</span><span class="p">,</span> <span class="nx">err</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="nx">payload</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="p">=</span> <span class="sc">&#39;[&#39;</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="nx">events</span> <span class="o">:=</span> <span class="nx">log</span><span class="p">.</span><span class="nf">List</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="k">if</span> <span class="nb">string</span><span class="p">(</span><span class="nx">events</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">Payload</span><span class="p">)</span> <span class="o">!=</span> <span class="s">`{&#34;topic&#34;:&#34;deployments&#34;}`</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">24</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;payload was modified through original slice&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>json.RawMessage</code> 本質是 <code>[]byte</code>，所以需要 copy。這類細節很容易被忽略，測試可以把邊界固定下來。</p>
<h2 id="實作檢查清單">實作檢查清單</h2>
<p>新增結構化記錄欄位時，可以依序檢查：</p>
<ol>
<li>這筆資料是給除錯、重播，還是查詢</li>
<li>structured log 是否只保存操作訊號與安全欄位</li>
<li>event log 是否保存 normalized domain event</li>
<li>state repository 是否只保存目前 projection</li>
<li>log 欄位名稱是否穩定</li>
<li><code>reason</code> 是否是小集合分類</li>
<li>是否避免完整 payload 與敏感資料</li>
<li>event log 是否保護 copy boundary</li>
<li>測試是否檢查穩定欄位，而不是自由文字</li>
</ol>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一log-服務操作診斷">檢查一：log 服務操作診斷</h3>
<p>log 是操作診斷訊號，不是穩定查詢 API。需要使用者查詢的目前狀態，應該進 repository 或 <a href="/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model</a>。</p>
<h3 id="檢查二event-log-保存-normalized-fact">檢查二：event log 保存 normalized fact</h3>
<p>event log 記錄的是 normalized fact。若把暫時性錯誤、debug 訊息與 raw payload 全塞進 event log，重播與 audit 會變得不可信。</p>
<h3 id="檢查三欄位名稱維持一致">檢查三：欄位名稱維持一致</h3>
<p><code>event_id</code>、<code>eventID</code>、<code>id</code> 混用會讓查詢失效。欄位 schema 要像 API 一樣維持穩定。</p>
<h3 id="檢查四完整-payload-需要明確策略">檢查四：完整 payload 需要明確策略</h3>
<p>完整 payload 可能包含敏感資料，也可能非常大。除非有明確安全與保存策略，否則只記錄大小、hash、ID 與必要欄位。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 log、event log 與 repository 的分工；集中式 log 平台與可重播事件系統，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Go 進階：Observability pipeline、metrics 與 tracing</a></li>
<li><a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend：可觀測性平台</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 event log、state repository 與 log schema；如果你要先回看語言教材，可以讀：</p>
<ul>
<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/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">Go：結構化日誌欄位設計</a></li>
</ul>
]]></content:encoded></item><item><title>7.5 Kubernetes、systemd 與 load balancer 合約</title><link>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/deployment-contracts/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/deployment-contracts/</guid><description>&lt;p>部署平台合約的核心責任是讓 Go 服務的生命週期和外部調度系統對齊。程式內部需要清楚的 context、shutdown &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/health-check-liveness/" data-link-title="Liveness" data-link-desc="說明平台如何判斷 process 是否仍然存活，以及何時應重啟">health / liveness&lt;/a> 與 memory limit；Kubernetes、systemd、load balancer 或雲端平台則決定這些訊號何時被觸發與如何被解讀。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解 shutdown、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness&lt;/a> 與 connection draining 的順序&lt;/li>
&lt;li>看懂平台 timeout 對 Go server 的影響&lt;/li>
&lt;li>分辨 health 與 readiness 的不同責任&lt;/li>
&lt;li>把 memory limit 與 Go runtime 的資源管理接在一起&lt;/li>
&lt;li>讓部署平台和程式彼此遵守同一份合約&lt;/li>
&lt;/ol>
&lt;h2 id="前置章節">前置章節&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/gc-memory-limit/" data-link-title="3.1 GC 與 memory limit" data-link-desc="理解 debug.SetMemoryLimit 在長時間服務中的用途">Go 進階：GC 與 memory limit&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Go 進階：graceful shutdown 與 signal handling&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">Go 進階：健康檢查與診斷 endpoint&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Backend：Graceful Shutdown&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/failover/" data-link-title="Failover" data-link-desc="說明主要服務或節點失效時如何切換到備援能力">Backend：Failover&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="後續撰寫方向">後續撰寫方向&lt;/h2>
&lt;ol>
&lt;li>SIGTERM、shutdown timeout、readiness false 與 connection draining 的順序。&lt;/li>
&lt;li>Kubernetes &lt;code>terminationGracePeriodSeconds&lt;/code> 與 Go &lt;code>http.Server.Shutdown&lt;/code> 如何配合。&lt;/li>
&lt;li>Load balancer idle timeout 如何影響 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> heartbeat 參數。&lt;/li>
&lt;li>Container memory limit、Go memory limit 與 OOM killer 之間的關係。&lt;/li>
&lt;li>systemd restart policy 與 health endpoint 的責任分工。&lt;/li>
&lt;/ol>
&lt;h2 id="觀察平台會主動改變服務生命週期">【觀察】平台會主動改變服務生命週期&lt;/h2>
&lt;p>Go 程式不會在真空裡執行。Kubernetes、systemd、load balancer、container runtime 都會影響服務何時接新請求、何時開始收尾、何時被強制終止。這表示程式不只要「能跑」，還要能跟平台協調。&lt;/p>
&lt;p>常見的生命週期訊號有：&lt;/p>
&lt;ul>
&lt;li>SIGTERM&lt;/li>
&lt;li>readiness false&lt;/li>
&lt;li>HTTP shutdown&lt;/li>
&lt;li>connection draining&lt;/li>
&lt;li>memory pressure&lt;/li>
&lt;/ul>
&lt;h2 id="判讀health-與-readiness-有不同合約">【判讀】health 與 readiness 有不同合約&lt;/h2>
&lt;p>health 通常表示服務自己還活著，readiness 則表示它是否適合接新流量。&lt;/p>
&lt;ul>
&lt;li>health 可以用來讓平台知道 process 還活著。&lt;/li>
&lt;li>readiness 可以用來讓 load balancer 停止送新請求。&lt;/li>
&lt;/ul>
&lt;p>如果兩者混在一起，部署時就容易出現「服務還沒收尾就被塞新流量」或「其實還能接流量卻被誤判下線」的問題。&lt;/p>
&lt;h2 id="策略shutdown-應該是可預期流程">【策略】shutdown 應該是可預期流程&lt;/h2>
&lt;p>典型的 shutdown 順序是：&lt;/p>
&lt;ol>
&lt;li>接收到停止訊號。&lt;/li>
&lt;li>先把 readiness 關掉。&lt;/li>
&lt;li>停止接新流量。&lt;/li>
&lt;li>讓現有 request / worker / websocket 收尾。&lt;/li>
&lt;li>超時後強制結束。&lt;/li>
&lt;/ol>
&lt;p>這個順序能讓平台有時間把流量移走，也讓應用有時間清理資源。&lt;/p>
&lt;h2 id="執行資源限制要和-runtime-觀念一起看">【執行】資源限制要和 runtime 觀念一起看&lt;/h2>
&lt;p>container memory limit 不只是部署平台的事，也會影響 Go runtime 的行為。當可用記憶體變少時，應用更需要控制：&lt;/p>
&lt;ul>
&lt;li>goroutine 數量&lt;/li>
&lt;li>&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;li>cache 體積&lt;/li>
&lt;li>in-memory &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> 長度&lt;/li>
&lt;/ul>
&lt;p>如果這些沒有限制，平台的 OOM killer 可能會比你的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown&lt;/a> 先來。&lt;/p></description><content:encoded><![CDATA[<p>部署平台合約的核心責任是讓 Go 服務的生命週期和外部調度系統對齊。程式內部需要清楚的 context、shutdown <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>、<a href="/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness</a>、<a href="/blog/backend/knowledge-cards/health-check-liveness/" data-link-title="Liveness" data-link-desc="說明平台如何判斷 process 是否仍然存活，以及何時應重啟">health / liveness</a> 與 memory limit；Kubernetes、systemd、load balancer 或雲端平台則決定這些訊號何時被觸發與如何被解讀。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解 shutdown、<a href="/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness</a> 與 connection draining 的順序</li>
<li>看懂平台 timeout 對 Go server 的影響</li>
<li>分辨 health 與 readiness 的不同責任</li>
<li>把 memory limit 與 Go runtime 的資源管理接在一起</li>
<li>讓部署平台和程式彼此遵守同一份合約</li>
</ol>
<h2 id="前置章節">前置章節</h2>
<ul>
<li><a href="/blog/go-advanced/03-runtime-profiling/gc-memory-limit/" data-link-title="3.1 GC 與 memory limit" data-link-desc="理解 debug.SetMemoryLimit 在長時間服務中的用途">Go 進階：GC 與 memory limit</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/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">Go 進階：健康檢查與診斷 endpoint</a></li>
<li><a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Backend：Graceful Shutdown</a></li>
<li><a href="/blog/backend/knowledge-cards/failover/" data-link-title="Failover" data-link-desc="說明主要服務或節點失效時如何切換到備援能力">Backend：Failover</a></li>
</ul>
<h2 id="後續撰寫方向">後續撰寫方向</h2>
<ol>
<li>SIGTERM、shutdown timeout、readiness false 與 connection draining 的順序。</li>
<li>Kubernetes <code>terminationGracePeriodSeconds</code> 與 Go <code>http.Server.Shutdown</code> 如何配合。</li>
<li>Load balancer idle timeout 如何影響 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> heartbeat 參數。</li>
<li>Container memory limit、Go memory limit 與 OOM killer 之間的關係。</li>
<li>systemd restart policy 與 health endpoint 的責任分工。</li>
</ol>
<h2 id="觀察平台會主動改變服務生命週期">【觀察】平台會主動改變服務生命週期</h2>
<p>Go 程式不會在真空裡執行。Kubernetes、systemd、load balancer、container runtime 都會影響服務何時接新請求、何時開始收尾、何時被強制終止。這表示程式不只要「能跑」，還要能跟平台協調。</p>
<p>常見的生命週期訊號有：</p>
<ul>
<li>SIGTERM</li>
<li>readiness false</li>
<li>HTTP shutdown</li>
<li>connection draining</li>
<li>memory pressure</li>
</ul>
<h2 id="判讀health-與-readiness-有不同合約">【判讀】health 與 readiness 有不同合約</h2>
<p>health 通常表示服務自己還活著，readiness 則表示它是否適合接新流量。</p>
<ul>
<li>health 可以用來讓平台知道 process 還活著。</li>
<li>readiness 可以用來讓 load balancer 停止送新請求。</li>
</ul>
<p>如果兩者混在一起，部署時就容易出現「服務還沒收尾就被塞新流量」或「其實還能接流量卻被誤判下線」的問題。</p>
<h2 id="策略shutdown-應該是可預期流程">【策略】shutdown 應該是可預期流程</h2>
<p>典型的 shutdown 順序是：</p>
<ol>
<li>接收到停止訊號。</li>
<li>先把 readiness 關掉。</li>
<li>停止接新流量。</li>
<li>讓現有 request / worker / websocket 收尾。</li>
<li>超時後強制結束。</li>
</ol>
<p>這個順序能讓平台有時間把流量移走，也讓應用有時間清理資源。</p>
<h2 id="執行資源限制要和-runtime-觀念一起看">【執行】資源限制要和 runtime 觀念一起看</h2>
<p>container memory limit 不只是部署平台的事，也會影響 Go runtime 的行為。當可用記憶體變少時，應用更需要控制：</p>
<ul>
<li>goroutine 數量</li>
<li><a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 大小</li>
<li>cache 體積</li>
<li>in-memory <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 長度</li>
</ul>
<p>如果這些沒有限制，平台的 OOM killer 可能會比你的 <a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown</a> 先來。</p>
<h2 id="延伸平台合約要被測試">【延伸】平台合約要被測試</h2>
<p>部署平台合約需要在測試或預備環境驗證。至少要確認：</p>
<ul>
<li>shutdown 時 request 是否停止接入</li>
<li>worker 是否有機會收尾</li>
<li>WebSocket 是否有 close path</li>
<li>health 與 readiness 是否分工清楚</li>
</ul>
<h2 id="本章不處理">本章不處理</h2>
<p>本章不會完整教 Kubernetes 或 systemd 操作。重點是讓 Go 程式設計能清楚暴露平台需要的生命週期訊號。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 Go 的 shutdown 與 runtime 限制；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go-advanced/03-runtime-profiling/gc-memory-limit/" data-link-title="3.1 GC 與 memory limit" data-link-desc="理解 debug.SetMemoryLimit 在長時間服務中的用途">Go 進階：GC 與 memory limit</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/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">Go 進階：健康檢查與診斷 endpoint</a></li>
</ul>
]]></content:encoded></item><item><title>7.5 以 domain 重新整理 package</title><link>https://tarrragon.github.io/blog/go/07-refactoring/domain-packages/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/domain-packages/</guid><description>&lt;p>以 domain 重新整理 package 的核心目標是讓程式結構反映業務語意，而不是只反映技術元件。當系統開始有 account、job、event、workflow 這些不同概念時，平面檔案會讓邊界越來越難看見。&lt;/p>
&lt;p>Go package 是語意邊界，不只是檔案分類。好的 package 名稱應該讓讀者知道這裡負責哪一組概念；如果只能命名成 &lt;code>utils&lt;/code>、&lt;code>common&lt;/code> 或 &lt;code>helpers&lt;/code>，通常代表邊界還沒有想清楚。&lt;/p>
&lt;p>這一章承接入門篇的「單檔到多檔案」路線。平面多檔案是 Go 程式自然長大的中間階段；只有當檔案切分已經無法表達業務邊界時，才需要把概念搬成更清楚的 domain package。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷何時該從平面多檔案拆出 package&lt;/li>
&lt;li>用業務語意命名 package&lt;/li>
&lt;li>依照純型別、純規則、usecase/repository 的順序搬移&lt;/li>
&lt;li>避免 import cycle&lt;/li>
&lt;li>用 type alias 與測試降低搬移風險&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察平面多檔案是自然成長階段">【觀察】平面多檔案是自然成長階段&lt;/h2>
&lt;p>平面 package 的核心價值是初期簡單。服務還小時，&lt;code>main.go&lt;/code>、&lt;code>models.go&lt;/code>、&lt;code>handlers.go&lt;/code>、&lt;code>repository.go&lt;/code> 放在同一層，常常比一開始切十幾個資料夾更容易理解。&lt;/p>
&lt;p>常見中間階段：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">notify/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">├── go.mod
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">├── main.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">├── models.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">├── handlers.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">├── repository.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">├── events.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">└── worker.go&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個結構不是問題本身。真正的問題通常出現在概念開始混在一起：HTTP request struct、domain state、event type、repository model 都放在 &lt;code>models.go&lt;/code>；handler、worker、processor 都直接引用同一批可變資料。&lt;/p>
&lt;h2 id="判讀拆-package-的訊號是語意邊界變模糊">【判讀】拆 package 的訊號是語意邊界變模糊&lt;/h2>
&lt;p>拆 package 的核心判斷是讀者是否能從結構看出概念邊界。若只是檔案變多，先拆檔案即可；若業務概念混在一起，才需要拆 package。&lt;/p>
&lt;p>適合拆 package 的訊號：&lt;/p>
&lt;ul>
&lt;li>&lt;code>models.go&lt;/code> 同時包含 request DTO、domain state、response view。&lt;/li>
&lt;li>新增功能時不知道型別該放哪個檔案。&lt;/li>
&lt;li>event、job、account 規則互相 import 或互相修改。&lt;/li>
&lt;li>測試一個 domain 規則必須初始化 handler 或 server。&lt;/li>
&lt;li>package 內 unexported helper 太多，讀者很難判斷哪些屬於哪個概念。&lt;/li>
&lt;/ul>
&lt;p>不一定要拆 package 的情境：&lt;/p>
&lt;ul>
&lt;li>檔案只是稍長，但仍圍繞同一個概念。&lt;/li>
&lt;li>只有單一 main package 的小工具。&lt;/li>
&lt;li>邊界還不穩，拆完很可能馬上搬回來。&lt;/li>
&lt;li>只是為了符合某個目錄模板。&lt;/li>
&lt;/ul>
&lt;h2 id="策略package-名稱要表達業務概念">【策略】package 名稱要表達業務概念&lt;/h2>
&lt;p>domain package 的核心要求是名稱要讓讀者知道這裡負責哪組概念。&lt;code>job&lt;/code>、&lt;code>event&lt;/code>、&lt;code>account&lt;/code>、&lt;code>workflow&lt;/code> 比 &lt;code>common&lt;/code>、&lt;code>types&lt;/code>、&lt;code>utils&lt;/code> 更有語意。&lt;/p>
&lt;p>一個可能的拆分：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">notify/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">├── go.mod
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">├── main.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">├── domain/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">│ ├── account/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">│ ├── job/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">│ ├── event/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">│ └── workflow/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">├── transport/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">│ └── http/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">└── storage/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> └── memory/&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這是示範語意方向的參考結構。小型服務也可以先只拆：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">notify/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">├── main.go
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">├── notification/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">└── httpapi/&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Go package 不需要層數多才算成熟。好的 package 是讓 import 讀起來自然：&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="kn">import&lt;/span> &lt;span class="s">&amp;#34;example.com/notify/domain/job&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果 package 名稱只能叫 &lt;code>misc&lt;/code> 或 &lt;code>helpers&lt;/code>，代表邊界還沒有清楚。&lt;/p>
&lt;h2 id="執行先搬純型別">【執行】先搬純型別&lt;/h2>
&lt;p>搬移 package 的核心順序是先搬依賴最少的東西。純型別通常最安全，因為它不呼叫外部元件。&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="c1">// models.go&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="kn">package&lt;/span> &lt;span class="nx">main&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">JobStatus&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">JobStatusPending&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;pending&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">JobStatusRunning&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;running&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">JobStatusSucceeded&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;succeeded&amp;#34;&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">JobStatusFailed&lt;/span> &lt;span class="nx">JobStatus&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s">&amp;#34;failed&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="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="kd">type&lt;/span> &lt;span class="nx">JobProjection&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">14&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">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">Status&lt;/span> &lt;span class="nx">JobStatus&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">UpdatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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;/p></description><content:encoded><![CDATA[<p>以 domain 重新整理 package 的核心目標是讓程式結構反映業務語意，而不是只反映技術元件。當系統開始有 account、job、event、workflow 這些不同概念時，平面檔案會讓邊界越來越難看見。</p>
<p>Go package 是語意邊界，不只是檔案分類。好的 package 名稱應該讓讀者知道這裡負責哪一組概念；如果只能命名成 <code>utils</code>、<code>common</code> 或 <code>helpers</code>，通常代表邊界還沒有想清楚。</p>
<p>這一章承接入門篇的「單檔到多檔案」路線。平面多檔案是 Go 程式自然長大的中間階段；只有當檔案切分已經無法表達業務邊界時，才需要把概念搬成更清楚的 domain package。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷何時該從平面多檔案拆出 package</li>
<li>用業務語意命名 package</li>
<li>依照純型別、純規則、usecase/repository 的順序搬移</li>
<li>避免 import cycle</li>
<li>用 type alias 與測試降低搬移風險</li>
</ol>
<hr>
<h2 id="觀察平面多檔案是自然成長階段">【觀察】平面多檔案是自然成長階段</h2>
<p>平面 package 的核心價值是初期簡單。服務還小時，<code>main.go</code>、<code>models.go</code>、<code>handlers.go</code>、<code>repository.go</code> 放在同一層，常常比一開始切十幾個資料夾更容易理解。</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">notify/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── main.go
</span></span><span class="line"><span class="ln">4</span><span class="cl">├── models.go
</span></span><span class="line"><span class="ln">5</span><span class="cl">├── handlers.go
</span></span><span class="line"><span class="ln">6</span><span class="cl">├── repository.go
</span></span><span class="line"><span class="ln">7</span><span class="cl">├── events.go
</span></span><span class="line"><span class="ln">8</span><span class="cl">└── worker.go</span></span></code></pre></div><p>這個結構不是問題本身。真正的問題通常出現在概念開始混在一起：HTTP request struct、domain state、event type、repository model 都放在 <code>models.go</code>；handler、worker、processor 都直接引用同一批可變資料。</p>
<h2 id="判讀拆-package-的訊號是語意邊界變模糊">【判讀】拆 package 的訊號是語意邊界變模糊</h2>
<p>拆 package 的核心判斷是讀者是否能從結構看出概念邊界。若只是檔案變多，先拆檔案即可；若業務概念混在一起，才需要拆 package。</p>
<p>適合拆 package 的訊號：</p>
<ul>
<li><code>models.go</code> 同時包含 request DTO、domain state、response view。</li>
<li>新增功能時不知道型別該放哪個檔案。</li>
<li>event、job、account 規則互相 import 或互相修改。</li>
<li>測試一個 domain 規則必須初始化 handler 或 server。</li>
<li>package 內 unexported helper 太多，讀者很難判斷哪些屬於哪個概念。</li>
</ul>
<p>不一定要拆 package 的情境：</p>
<ul>
<li>檔案只是稍長，但仍圍繞同一個概念。</li>
<li>只有單一 main package 的小工具。</li>
<li>邊界還不穩，拆完很可能馬上搬回來。</li>
<li>只是為了符合某個目錄模板。</li>
</ul>
<h2 id="策略package-名稱要表達業務概念">【策略】package 名稱要表達業務概念</h2>
<p>domain package 的核心要求是名稱要讓讀者知道這裡負責哪組概念。<code>job</code>、<code>event</code>、<code>account</code>、<code>workflow</code> 比 <code>common</code>、<code>types</code>、<code>utils</code> 更有語意。</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">notify/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">├── main.go
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">├── domain/
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">│   ├── account/
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">│   ├── job/
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">│   ├── event/
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">│   └── workflow/
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">├── transport/
</span></span><span class="line"><span class="ln">10</span><span class="cl">│   └── http/
</span></span><span class="line"><span class="ln">11</span><span class="cl">└── storage/
</span></span><span class="line"><span class="ln">12</span><span class="cl">    └── memory/</span></span></code></pre></div><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">notify/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── main.go
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── notification/
</span></span><span class="line"><span class="ln">4</span><span class="cl">└── httpapi/</span></span></code></pre></div><p>Go package 不需要層數多才算成熟。好的 package 是讓 import 讀起來自然：</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="kn">import</span> <span class="s">&#34;example.com/notify/domain/job&#34;</span></span></span></code></pre></div><p>如果 package 名稱只能叫 <code>misc</code> 或 <code>helpers</code>，代表邊界還沒有清楚。</p>
<h2 id="執行先搬純型別">【執行】先搬純型別</h2>
<p>搬移 package 的核心順序是先搬依賴最少的東西。純型別通常最安全，因為它不呼叫外部元件。</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="c1">// models.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">package</span> <span class="nx">main</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="kd">type</span> <span class="nx">JobStatus</span> <span class="kt">string</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="kd">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">JobStatusPending</span>   <span class="nx">JobStatus</span> <span class="p">=</span> <span class="s">&#34;pending&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">JobStatusRunning</span>   <span class="nx">JobStatus</span> <span class="p">=</span> <span class="s">&#34;running&#34;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">JobStatusSucceeded</span> <span class="nx">JobStatus</span> <span class="p">=</span> <span class="s">&#34;succeeded&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">JobStatusFailed</span>    <span class="nx">JobStatus</span> <span class="p">=</span> <span class="s">&#34;failed&#34;</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="kd">type</span> <span class="nx">JobProjection</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">ID</span>        <span class="kt">string</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">Status</span>    <span class="nx">JobStatus</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">UpdatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><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="c1">// domain/job/job.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">package</span> <span class="nx">job</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="kn">import</span> <span class="s">&#34;time&#34;</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="kd">type</span> <span class="nx">Status</span> <span class="kt">string</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">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">StatusPending</span>   <span class="nx">Status</span> <span class="p">=</span> <span class="s">&#34;pending&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">StatusRunning</span>   <span class="nx">Status</span> <span class="p">=</span> <span class="s">&#34;running&#34;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">StatusSucceeded</span> <span class="nx">Status</span> <span class="p">=</span> <span class="s">&#34;succeeded&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">StatusFailed</span>    <span class="nx">Status</span> <span class="p">=</span> <span class="s">&#34;failed&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="kd">type</span> <span class="nx">Projection</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="nx">ID</span>        <span class="kt">string</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nx">Status</span>    <span class="nx">Status</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">UpdatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><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="kn">import</span> <span class="s">&#34;example.com/notify/domain/job&#34;</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">var</span> <span class="nx">projection</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Projection</span></span></span></code></pre></div><p>package 名稱已經提供語境，所以型別不必再叫 <code>JobProjection</code>。在 <code>job</code> package 裡叫 <code>Projection</code> 就夠清楚。</p>
<h2 id="策略用-type-alias-過渡">【策略】用 type alias 過渡</h2>
<p>type alias 的核心用途是降低搬移風險。若一次改完所有 import 太大，可以先在舊位置保留 alias，讓既有程式逐步遷移。</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="c1">// models.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">package</span> <span class="nx">main</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="kn">import</span> <span class="s">&#34;example.com/notify/domain/job&#34;</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="kd">type</span> <span class="nx">JobStatus</span> <span class="p">=</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Status</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kd">type</span> <span class="nx">JobProjection</span> <span class="p">=</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Projection</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">const</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">JobStatusPending</span>   <span class="p">=</span> <span class="nx">job</span><span class="p">.</span><span class="nx">StatusPending</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">JobStatusRunning</span>   <span class="p">=</span> <span class="nx">job</span><span class="p">.</span><span class="nx">StatusRunning</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">JobStatusSucceeded</span> <span class="p">=</span> <span class="nx">job</span><span class="p">.</span><span class="nx">StatusSucceeded</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">JobStatusFailed</span>    <span class="p">=</span> <span class="nx">job</span><span class="p">.</span><span class="nx">StatusFailed</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">)</span></span></span></code></pre></div><p>這是過渡工具。等呼叫端逐步改成直接 import <code>domain/job</code>，再移除 alias。</p>
<p>type alias 適合降低大型搬移風險，但不要讓新舊命名長期並存，否則讀者會不知道哪個才是正式 API。</p>
<h2 id="執行再搬純規則">【執行】再搬純規則</h2>
<p>純規則的核心特徵是輸入值、回傳值，不依賴 handler、repository 或外部 I/O。這類函式也適合早期搬入 domain package。</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="c1">// domain/job/transition.go</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">package</span> <span class="nx">job</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="kn">import</span> <span class="s">&#34;fmt&#34;</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="kd">func</span> <span class="nf">CanTransition</span><span class="p">(</span><span class="nx">from</span> <span class="nx">Status</span><span class="p">,</span> <span class="nx">to</span> <span class="nx">Status</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">switch</span> <span class="nx">from</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">case</span> <span class="nx">StatusPending</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="nx">to</span> <span class="o">==</span> <span class="nx">StatusRunning</span> <span class="o">||</span> <span class="nx">to</span> <span class="o">==</span> <span class="nx">StatusFailed</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">case</span> <span class="nx">StatusRunning</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="nx">to</span> <span class="o">==</span> <span class="nx">StatusSucceeded</span> <span class="o">||</span> <span class="nx">to</span> <span class="o">==</span> <span class="nx">StatusFailed</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">default</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="kc">false</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="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="kd">func</span> <span class="nf">Transition</span><span class="p">(</span><span class="nx">current</span> <span class="nx">Projection</span><span class="p">,</span> <span class="nx">next</span> <span class="nx">Status</span><span class="p">)</span> <span class="p">(</span><span class="nx">Projection</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nf">CanTransition</span><span class="p">(</span><span class="nx">current</span><span class="p">.</span><span class="nx">Status</span><span class="p">,</span> <span class="nx">next</span><span class="p">)</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="nx">Projection</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;invalid job status transition: %s -&gt; %s&#34;</span><span class="p">,</span> <span class="nx">current</span><span class="p">.</span><span class="nx">Status</span><span class="p">,</span> <span class="nx">next</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="nx">current</span><span class="p">.</span><span class="nx">Status</span> <span class="p">=</span> <span class="nx">next</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">return</span> <span class="nx">current</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這些規則不應 import HTTP package，也不應知道 repository。它們是 domain 的穩定核心。</p>
<h2 id="判讀domain-不依賴-adapter">【判讀】domain 不依賴 adapter</h2>
<p>避免 import cycle 的核心規則是低層 domain 不依賴高層 adapter。domain 可以被 HTTP、worker、repository 使用；但 domain 不應 import 這些外部層。</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">domain/job -&gt; transport/http -&gt; domain/job</span></span></code></pre></div><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">transport/http -&gt; application -&gt; domain/job
</span></span><span class="line"><span class="ln">2</span><span class="cl">storage/memory -&gt; domain/job</span></span></code></pre></div><p>如果 <code>domain/job</code> 需要知道 HTTP request struct，代表 request DTO 沒有停在 transport layer。應把 HTTP request 轉成 command 或 domain value，再交給下層。</p>
<h2 id="執行最後搬-repositoryusecase">【執行】最後搬 repository/usecase</h2>
<p>repository 和 usecase 的核心特徵是它們開始協調多個概念，所以搬移時要更謹慎。通常先搬 domain 型別與規則，再處理 application layer。</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">notify/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── domain/
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">│   ├── job/
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">│   └── event/
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">├── application/
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">│   ├── command.go
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">│   └── processor.go
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">├── transport/
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">│   └── http/
</span></span><span class="line"><span class="ln">10</span><span class="cl">└── storage/
</span></span><span class="line"><span class="ln">11</span><span class="cl">    └── memory/</span></span></code></pre></div><p>application 可以協調 domain：</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="kn">package</span> <span class="nx">application</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="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="s">&#34;context&#34;</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="s">&#34;example.com/notify/domain/event&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="s">&#34;example.com/notify/domain/job&#34;</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">type</span> <span class="nx">JobRepository</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nf">Apply</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">projection</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Projection</span><span class="p">)</span> <span class="kt">error</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">type</span> <span class="nx">Processor</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">jobs</span> <span class="nx">JobRepository</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></span><span class="line"><span class="ln">18</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">p</span> <span class="o">*</span><span class="nx">Processor</span><span class="p">)</span> <span class="nf">Process</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">e</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Event</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">projection</span> <span class="o">:=</span> <span class="nx">job</span><span class="p">.</span><span class="nx">Projection</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>     <span class="nx">e</span><span class="p">.</span><span class="nx">SubjectID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="nx">Status</span><span class="p">:</span> <span class="nx">job</span><span class="p">.</span><span class="nx">StatusRunning</span><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="k">return</span> <span class="nx">p</span><span class="p">.</span><span class="nx">jobs</span><span class="p">.</span><span class="nf">Apply</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">projection</span><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>application 可以依賴多個 domain package，因為它負責協調 usecase。domain package 之間若互相依賴太多，通常代表邊界切得不對。</p>
<h2 id="策略每次只搬一個邊界">【策略】每次只搬一個邊界</h2>
<p>package 重構的核心風險是 import 修改範圍太大。每次只搬一個語意邊界，測試通過後再搬下一個。</p>
<p>建議順序：</p>
<ol>
<li>搬 <code>domain/job</code> 純型別。</li>
<li>搬 <code>domain/job</code> 純規則。</li>
<li>修正使用端 import。</li>
<li>搬 <code>domain/event</code> 純型別。</li>
<li>搬 event validation/normalize helper。</li>
<li>搬 application processor。</li>
<li>搬 adapter implementation。</li>
</ol>
<p>不要同時搬 job、event、repository、handler。一次搬太多會讓失敗原因難以定位。</p>
<h2 id="執行測試保護搬移">【執行】測試保護搬移</h2>
<p>package 搬移的核心驗證是行為不變。搬檔案本身不是功能變更，所以測試應該確認原本行為仍然存在。</p>
<p>domain 規則測試：</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">TestJobCanTransition</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="p">!</span><span class="nx">job</span><span class="p">.</span><span class="nf">CanTransition</span><span class="p">(</span><span class="nx">job</span><span class="p">.</span><span class="nx">StatusPending</span><span class="p">,</span> <span class="nx">job</span><span class="p">.</span><span class="nx">StatusRunning</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">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;pending should transition to running&#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">job</span><span class="p">.</span><span class="nf">CanTransition</span><span class="p">(</span><span class="nx">job</span><span class="p">.</span><span class="nx">StatusSucceeded</span><span class="p">,</span> <span class="nx">job</span><span class="p">.</span><span class="nx">StatusRunning</span><span class="p">)</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;succeeded should not transition to running&#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>application 測試：</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">TestProcessorAppliesJobProjection</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">repo</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeJobRepository</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">processor</span> <span class="o">:=</span> <span class="nx">application</span><span class="p">.</span><span class="nf">NewProcessor</span><span class="p">(</span><span class="nx">repo</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">processor</span><span class="p">.</span><span class="nf">Process</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">event</span><span class="p">.</span><span class="nx">Event</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">SubjectID</span><span class="p">:</span> <span class="s">&#34;job_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>      <span class="nx">event</span><span class="p">.</span><span class="nx">JobStarted</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;process event: %v&#34;</span><span class="p">,</span> <span class="nx">err</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">if</span> <span class="nx">repo</span><span class="p">.</span><span class="nx">got</span><span class="p">.</span><span class="nx">ID</span> <span class="o">!=</span> <span class="s">&#34;job_1&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</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;job ID = %q, want job_1&#34;</span><span class="p">,</span> <span class="nx">repo</span><span class="p">.</span><span class="nx">got</span><span class="p">.</span><span class="nx">ID</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>測試不需要關心檔案搬到哪裡，它只確認 package API 與行為仍然正確。</p>
<h2 id="重構步驟">重構步驟</h2>
<p>從平面 package 重構成 domain package，可以按這個順序：</p>
<ol>
<li>列出現有檔案中的概念：request、response、domain state、event、repository、worker。</li>
<li>找出最穩定的 domain 名稱，例如 <code>job</code>、<code>event</code>、<code>account</code>。</li>
<li>先建立一個 domain package，不要一次建立整棵架構。</li>
<li>搬純型別與 typed constant。</li>
<li>用 type alias 過渡大型呼叫端。</li>
<li>搬純規則與測試。</li>
<li>修正 import，避免 domain 依賴 adapter。</li>
<li>測試通過後，再搬下一個 domain。</li>
</ol>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一目錄跟著概念壓力成長">檢查一：目錄跟著概念壓力成長</h3>
<p>服務還小時，一次建立 <code>domain/application/infrastructure/interfaces</code> 可能只會增加跳轉成本。先拆最痛的語意邊界。</p>
<h3 id="檢查二package-名稱表達-domain-概念">檢查二：package 名稱表達 domain 概念</h3>
<p><code>models</code>、<code>types</code>、<code>helpers</code> 通常不夠好。它們說明了程式碼形狀，沒有說明業務語意。</p>
<h3 id="檢查三domain-package-保持技術無關">檢查三：domain package 保持技術無關</h3>
<p>domain 應保存業務語意，不應知道傳輸協定。若 domain import adapter，依賴方向已經反了。</p>
<h3 id="檢查四搬移和行為變更分開">檢查四：搬移和行為變更分開</h3>
<p>package 重構應先保持行為不變。若同時改規則與搬檔案，測試失敗時很難判斷是搬移錯誤還是行為改動。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 package 結構如何反映 domain 語意；更大型的 module 拆分與 monorepo 策略，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go/01-basics/growing-files-packages/" data-link-title="1.5 從單檔到多檔案" data-link-desc="理解 Go 程式如何從 main.go 長成多檔案與多 package">Go 入門：從單檔到多檔案</a></li>
<li><a href="/blog/go/01-basics/modules/" data-link-title="1.1 Go 專案結構與 module" data-link-desc="理解 go.mod、module path 與 Go 專案的依賴邊界">Go 入門：Go 專案結構與 module</a></li>
<li><a href="/blog/go/00-philosophy/composition/" data-link-title="0.2 組合優先：小介面與明確依賴" data-link-desc="用小介面與 struct 組合取代大型繼承結構">Go 入門：composition 優先：小介面與明確依賴</a></li>
<li><a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">Go 進階：逐步遷移到 ports/adapters 架構</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 type、rule 與 package 邊界；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</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>
</ul>
]]></content:encoded></item><item><title>模組五：測試與可靠性</title><link>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/</guid><description>&lt;p>並發服務測試的核心目標是讓時間、連線、goroutine、共享狀態與錯誤路徑變得可重現。只測 happy path 不足以保護長時間運行的 Go 服務；真正需要測的是取消、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> full、cleanup、data race 與協定邊界。&lt;/p>
&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> 與架構邊界：時間注入讓狀態轉移可測，WebSocket integration test 驗證真實連線互動，race detector 檢查共享狀態，table-driven test 幫助案例保持清楚。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&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;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/time-control/" data-link-title="5.1 時間注入與狀態轉移測試" data-link-desc="讓時間相關邏輯可重現">5.1&lt;/a>&lt;/td>
 &lt;td>時間注入與狀態轉移測試&lt;/td>
 &lt;td>不用 sleep 也能測 timeout、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a> 與狀態轉移&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/websocket-integration/" data-link-title="5.2 WebSocket integration test" data-link-desc="驗證 client/server 實際互動">5.2&lt;/a>&lt;/td>
 &lt;td>WebSocket integration test&lt;/td>
 &lt;td>用真實 test server 驗證 client/server 協定&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/race-check/" data-link-title="5.3 race condition 檢查" data-link-desc="用 go test -race 找資料競爭">5.3&lt;/a>&lt;/td>
 &lt;td>race condition 檢查&lt;/td>
 &lt;td>用 &lt;code>go test -race&lt;/code> 搭配併發測試找資料競爭&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/table-tests/" data-link-title="5.4 table-driven test 的設計邊界" data-link-desc="避免測試資料混雜太多概念">5.4&lt;/a>&lt;/td>
 &lt;td>table-driven test 的設計邊界&lt;/td>
 &lt;td>讓測試表只描述單一行為維度&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;p>本模組使用虛構的即時通知服務作為範例。範例包含 job 狀態轉移、WebSocket subscribe flow、client cleanup、repository concurrent access 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> normalization。&lt;/p>
&lt;p>範例只用來展示 Go 測試設計，不假設讀者正在維護任何特定專案。&lt;/p>
&lt;h2 id="本模組的-go-核心概念">本模組的 Go 核心概念&lt;/h2>
&lt;ul>
&lt;li>用 &lt;code>now time.Time&lt;/code> 或 &lt;code>func() time.Time&lt;/code> 控制時間。&lt;/li>
&lt;li>用 &lt;code>httptest.Server&lt;/code> 建立真實 WebSocket integration test。&lt;/li>
&lt;li>用 read/write deadline 避免測試永久卡住。&lt;/li>
&lt;li>用 &lt;code>eventually&lt;/code> helper 等待非同步清理，而不是固定 sleep。&lt;/li>
&lt;li>用 &lt;code>go test -race ./...&lt;/code> 檢查執行到的 data race。&lt;/li>
&lt;li>用小而清楚的 table-driven test 表達同一個行為的多組案例。&lt;/li>
&lt;/ul>
&lt;h2 id="學習重點">學習重點&lt;/h2>
&lt;p>學完本模組後，你應該能判斷：&lt;/p>
&lt;ol>
&lt;li>哪些邏輯應該用純函式測，哪些需要 integration test&lt;/li>
&lt;li>測試裡的時間應該如何注入，而不是等待真實時間&lt;/li>
&lt;li>WebSocket 測試如何避免永久卡住&lt;/li>
&lt;li>race detector 能找什麼，不能證明什麼&lt;/li>
&lt;li>table-driven test 何時該拆成多個測試&lt;/li>
&lt;/ol>
&lt;h2 id="本模組不處理">本模組不處理&lt;/h2>
&lt;p>本模組不建立完整測試框架，也不討論大型 CI 平台、壓力測試或 chaos testing。這些主題很重要，但本模組先聚焦單一 Go 服務內最常見、最容易失控的可靠性測試；後續可接 &lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">CI、fuzz、load test 與 chaos testing&lt;/a>。&lt;/p>
&lt;h2 id="學習時間">學習時間&lt;/h2>
&lt;p>預計 3-4 小時&lt;/p></description><content:encoded><![CDATA[<p>並發服務測試的核心目標是讓時間、連線、goroutine、共享狀態與錯誤路徑變得可重現。只測 happy path 不足以保護長時間運行的 Go 服務；真正需要測的是取消、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> full、cleanup、data race 與協定邊界。</p>
<p>本模組承接前面的並發、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 與架構邊界：時間注入讓狀態轉移可測，WebSocket integration test 驗證真實連線互動，race detector 檢查共享狀態，table-driven test 幫助案例保持清楚。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go-advanced/05-testing-reliability/time-control/" data-link-title="5.1 時間注入與狀態轉移測試" data-link-desc="讓時間相關邏輯可重現">5.1</a></td>
          <td>時間注入與狀態轉移測試</td>
          <td>不用 sleep 也能測 timeout、<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 與狀態轉移</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/05-testing-reliability/websocket-integration/" data-link-title="5.2 WebSocket integration test" data-link-desc="驗證 client/server 實際互動">5.2</a></td>
          <td>WebSocket integration test</td>
          <td>用真實 test server 驗證 client/server 協定</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/05-testing-reliability/race-check/" data-link-title="5.3 race condition 檢查" data-link-desc="用 go test -race 找資料競爭">5.3</a></td>
          <td>race condition 檢查</td>
          <td>用 <code>go test -race</code> 搭配併發測試找資料競爭</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/05-testing-reliability/table-tests/" data-link-title="5.4 table-driven test 的設計邊界" data-link-desc="避免測試資料混雜太多概念">5.4</a></td>
          <td>table-driven test 的設計邊界</td>
          <td>讓測試表只描述單一行為維度</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<p>本模組使用虛構的即時通知服務作為範例。範例包含 job 狀態轉移、WebSocket subscribe flow、client cleanup、repository concurrent access 與 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> normalization。</p>
<p>範例只用來展示 Go 測試設計，不假設讀者正在維護任何特定專案。</p>
<h2 id="本模組的-go-核心概念">本模組的 Go 核心概念</h2>
<ul>
<li>用 <code>now time.Time</code> 或 <code>func() time.Time</code> 控制時間。</li>
<li>用 <code>httptest.Server</code> 建立真實 WebSocket integration test。</li>
<li>用 read/write deadline 避免測試永久卡住。</li>
<li>用 <code>eventually</code> helper 等待非同步清理，而不是固定 sleep。</li>
<li>用 <code>go test -race ./...</code> 檢查執行到的 data race。</li>
<li>用小而清楚的 table-driven test 表達同一個行為的多組案例。</li>
</ul>
<h2 id="學習重點">學習重點</h2>
<p>學完本模組後，你應該能判斷：</p>
<ol>
<li>哪些邏輯應該用純函式測，哪些需要 integration test</li>
<li>測試裡的時間應該如何注入，而不是等待真實時間</li>
<li>WebSocket 測試如何避免永久卡住</li>
<li>race detector 能找什麼，不能證明什麼</li>
<li>table-driven test 何時該拆成多個測試</li>
</ol>
<h2 id="本模組不處理">本模組不處理</h2>
<p>本模組不建立完整測試框架，也不討論大型 CI 平台、壓力測試或 chaos testing。這些主題很重要，但本模組先聚焦單一 Go 服務內最常見、最容易失控的可靠性測試；後續可接 <a href="/blog/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">CI、fuzz、load test 與 chaos testing</a>。</p>
<h2 id="學習時間">學習時間</h2>
<p>預計 3-4 小時</p>
]]></content:encoded></item><item><title>模組五：錯誤處理與測試</title><link>https://tarrragon.github.io/blog/go/05-error-testing/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/05-error-testing/</guid><description>&lt;p>Go 的錯誤處理偏向顯式：錯誤是回傳值，呼叫者要直接面對。Go 的測試也偏向直接：建立輸入、執行函式、檢查輸出。本模組把錯誤處理、單元測試、table-driven test、HTTP 測試與並發測試串成一組可落地的驗證方法。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&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;a href="https://tarrragon.github.io/blog/go/05-error-testing/errors/" data-link-title="5.1 錯誤回傳與早期返回" data-link-desc="寫出可追蹤的失敗路徑">5.1&lt;/a>&lt;/td>
 &lt;td>錯誤回傳與早期返回&lt;/td>
 &lt;td>寫出可追蹤的失敗路徑&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">5.2&lt;/a>&lt;/td>
 &lt;td>testing 基礎&lt;/td>
 &lt;td>用 &lt;code>testing&lt;/code> package 驗證函式行為&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/table-driven-test/" data-link-title="5.3 table-driven test" data-link-desc="用表格整理多組輸入、預期輸出與錯誤情境">5.3&lt;/a>&lt;/td>
 &lt;td>table-driven test&lt;/td>
 &lt;td>用表格整理多組輸入輸出&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/http-handler-test/" data-link-title="5.4 HTTP handler 測試" data-link-desc="用 httptest 驗證 request 與 response">5.4&lt;/a>&lt;/td>
 &lt;td>HTTP handler 測試&lt;/td>
 &lt;td>用 &lt;code>httptest&lt;/code> 驗證 request/response&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/time-injection/" data-link-title="5.5 時間注入與 deterministic test" data-link-desc="用 time provider 避免測試依賴真實時間">5.5&lt;/a>&lt;/td>
 &lt;td>時間注入與 deterministic test&lt;/td>
 &lt;td>避免測試依賴真實時間&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/concurrency-test/" data-link-title="5.6 並發行為測試" data-link-desc="測試 channel、goroutine 與狀態更新">5.6&lt;/a>&lt;/td>
 &lt;td>並發行為測試&lt;/td>
 &lt;td>測試 channel、goroutine 與狀態更新&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/service-reliability/" data-link-title="5.7 錯誤處理與測試在高併發服務中的角色" data-link-desc="把錯誤路徑、測試保護與並發行為放進服務可靠性觀點">5.7&lt;/a>&lt;/td>
 &lt;td>錯誤處理與測試在高併發服務中的角色&lt;/td>
 &lt;td>把錯誤路徑與並發邊界納入可靠性觀點&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;ul>
&lt;li>函式單元測試&lt;/li>
&lt;li>table-driven test&lt;/li>
&lt;li>HTTP handler 測試&lt;/li>
&lt;li>時間相關測試&lt;/li>
&lt;li>channel 與 goroutine 測試&lt;/li>
&lt;/ul>
&lt;h2 id="學習時間">學習時間&lt;/h2>
&lt;p>預計 2 小時&lt;/p></description><content:encoded><![CDATA[<p>Go 的錯誤處理偏向顯式：錯誤是回傳值，呼叫者要直接面對。Go 的測試也偏向直接：建立輸入、執行函式、檢查輸出。本模組把錯誤處理、單元測試、table-driven test、HTTP 測試與並發測試串成一組可落地的驗證方法。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go/05-error-testing/errors/" data-link-title="5.1 錯誤回傳與早期返回" data-link-desc="寫出可追蹤的失敗路徑">5.1</a></td>
          <td>錯誤回傳與早期返回</td>
          <td>寫出可追蹤的失敗路徑</td>
      </tr>
      <tr>
          <td><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">5.2</a></td>
          <td>testing 基礎</td>
          <td>用 <code>testing</code> package 驗證函式行為</td>
      </tr>
      <tr>
          <td><a href="/blog/go/05-error-testing/table-driven-test/" data-link-title="5.3 table-driven test" data-link-desc="用表格整理多組輸入、預期輸出與錯誤情境">5.3</a></td>
          <td>table-driven test</td>
          <td>用表格整理多組輸入輸出</td>
      </tr>
      <tr>
          <td><a href="/blog/go/05-error-testing/http-handler-test/" data-link-title="5.4 HTTP handler 測試" data-link-desc="用 httptest 驗證 request 與 response">5.4</a></td>
          <td>HTTP handler 測試</td>
          <td>用 <code>httptest</code> 驗證 request/response</td>
      </tr>
      <tr>
          <td><a href="/blog/go/05-error-testing/time-injection/" data-link-title="5.5 時間注入與 deterministic test" data-link-desc="用 time provider 避免測試依賴真實時間">5.5</a></td>
          <td>時間注入與 deterministic test</td>
          <td>避免測試依賴真實時間</td>
      </tr>
      <tr>
          <td><a href="/blog/go/05-error-testing/concurrency-test/" data-link-title="5.6 並發行為測試" data-link-desc="測試 channel、goroutine 與狀態更新">5.6</a></td>
          <td>並發行為測試</td>
          <td>測試 channel、goroutine 與狀態更新</td>
      </tr>
      <tr>
          <td><a href="/blog/go/05-error-testing/service-reliability/" data-link-title="5.7 錯誤處理與測試在高併發服務中的角色" data-link-desc="把錯誤路徑、測試保護與並發行為放進服務可靠性觀點">5.7</a></td>
          <td>錯誤處理與測試在高併發服務中的角色</td>
          <td>把錯誤路徑與並發邊界納入可靠性觀點</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<ul>
<li>函式單元測試</li>
<li>table-driven test</li>
<li>HTTP handler 測試</li>
<li>時間相關測試</li>
<li>channel 與 goroutine 測試</li>
</ul>
<h2 id="學習時間">學習時間</h2>
<p>預計 2 小時</p>
]]></content:encoded></item><item><title>9.6 Pre-commit hook 與 CI 整合</title><link>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/pre-commit-and-ci/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/pre-commit-and-ci/</guid><description>&lt;p>工具落地的核心責任是&lt;strong>讓檢查在對的時機自動執行&lt;/strong>，把紀律從「勤勞的人手動跑」轉移到「每次 commit / push 都跑」的基礎設施。&lt;a href="https://tarrragon.github.io/blog/go/glossary/#pre-commit-hook-%e5%ae%9a%e4%bd%8d" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">Pre-commit hook&lt;/a> 守本機開發、CI 守共享 branch；兩者互補、一起把規則失敗成本壓到秒級可回饋，避免 bug 漏到 production。這個模式對 &lt;a href="https://tarrragon.github.io/blog/go/glossary/#idempotent-%e6%96%87%e5%ad%97%e6%94%b9%e5%af%ab" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">idempotent&lt;/a> 工具特別重要 — hook 每次 commit 都會跑，非冪等的工具會累積漂移、讓作者反覆看到「為什麼這個檔案又被改了」的困惑。&lt;/p>
&lt;p>工具一旦從 CLI 進入 hook / CI，就有幾個容易出狀況的邊界：&lt;strong>哪些 check 該放 hook&lt;/strong>（快、本地可執行）、&lt;strong>哪些該放 CI&lt;/strong>（慢、需要乾淨環境）、&lt;strong>hook 改了檔怎麼 re-stage&lt;/strong>、&lt;strong>&amp;ndash;no-verify 的邊界&lt;/strong>怎麼約定、&lt;strong>CI strict mode 跟 local dev 的差異&lt;/strong>怎麼處理。本章展開這些問題，並以 &lt;code>.githooks/pre-commit&lt;/code> + &lt;code>.github/workflows/md-check.yml&lt;/code> 作為 concrete instance。&lt;/p>
&lt;h2 id="pre-commit-hook-能做什麼不該做什麼">Pre-commit hook 能做什麼、不該做什麼&lt;/h2>
&lt;p>&lt;strong>能做&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>讀 staged 檔案，跑 lint / fmt&lt;/li>
&lt;li>自動修正格式違規、&lt;code>git add&lt;/code> re-stage&lt;/li>
&lt;li>擋下 lint error 的 commit&lt;/li>
&lt;li>跑跨檔分析（cards）&lt;/li>
&lt;li>執行 build（確保程式碼能編譯）&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>不該做&lt;/strong>：&lt;/p>
&lt;ul>
&lt;li>執行完整 test suite（太慢，交給 CI）&lt;/li>
&lt;li>執行 e2e 或需要網路的操作（脆弱，commit 不該依賴外部）&lt;/li>
&lt;li>修改未 stage 的檔案（會造成 working tree 混亂）&lt;/li>
&lt;li>執行超過幾秒的任務（心流殺手）&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>原則&lt;/strong>：pre-commit 是&lt;strong>快速守門員&lt;/strong>，不是&lt;strong>完整驗證器&lt;/strong>。該做的 checks 要在秒級完成；更慢的驗證交給 CI。&lt;/p>
&lt;h2 id="makefile-作為-hook-與-ci-的共同介面">Makefile 作為 hook 與 CI 的共同介面&lt;/h2>
&lt;p>有個常被忽略的 pattern：&lt;strong>hook 跟 CI 都透過 Makefile 呼叫工具&lt;/strong>，不直接呼叫 binary。這讓三方共用同一套指令。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-makefile" data-lang="makefile">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c"># Makefile
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c">&lt;/span>&lt;span class="nv">MDTOOLS_SRC&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">$(&lt;/span>shell find scripts/mdtools -type f -name &lt;span class="s1">&amp;#39;*.go&amp;#39;&lt;/span> 2&amp;gt;/dev/null&lt;span class="k">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="nv">MDTOOLS_MOD&lt;/span> &lt;span class="o">:=&lt;/span> scripts/mdtools/go.mod scripts/mdtools/go.sum
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="nv">MDTOOLS_BIN&lt;/span> &lt;span class="o">:=&lt;/span> bin/mdtools
&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="nf">.PHONY&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="n">build&lt;/span> &lt;span class="n">check&lt;/span> &lt;span class="n">fix&lt;/span> &lt;span class="n">lint&lt;/span> &lt;span class="n">cards&lt;/span> &lt;span class="n">install&lt;/span>-&lt;span class="n">hooks&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="nf">build&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">$(&lt;/span>&lt;span class="nv">MDTOOLS_BIN&lt;/span>&lt;span class="k">)&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="nf">$(MDTOOLS_BIN)&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="k">$(&lt;/span>&lt;span class="nv">MDTOOLS_SRC&lt;/span>&lt;span class="k">)&lt;/span> &lt;span class="k">$(&lt;/span>&lt;span class="nv">MDTOOLS_MOD&lt;/span>&lt;span class="k">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">	@mkdir -p bin
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">	@cd scripts/mdtools &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> go build -o ../../&lt;span class="k">$(&lt;/span>MDTOOLS_BIN&lt;span class="k">)&lt;/span> .
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="nf">check&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="n">build&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">	@./&lt;span class="k">$(&lt;/span>MDTOOLS_BIN&lt;span class="k">)&lt;/span> fmt --check content/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">	@./&lt;span class="k">$(&lt;/span>MDTOOLS_BIN&lt;span class="k">)&lt;/span> lint content/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">	@./&lt;span class="k">$(&lt;/span>MDTOOLS_BIN&lt;span class="k">)&lt;/span> cards content/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="nf">install-hooks&lt;/span>&lt;span class="o">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">	@git config core.hooksPath .githooks
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這樣：&lt;/p>
&lt;ul>
&lt;li>開發者本機：&lt;code>make check&lt;/code> 手動驗一次&lt;/li>
&lt;li>Pre-commit：hook 呼叫 &lt;code>./bin/mdtools ...&lt;/code>（或透過 Makefile target）&lt;/li>
&lt;li>CI：workflow 跑 &lt;code>make check&lt;/code>&lt;/li>
&lt;li>所有人看到的失敗訊息格式一致&lt;/li>
&lt;/ul>
&lt;p>Make 的依賴 timestamp 機制也剛好解決「binary 什麼時候重 build」— &lt;code>MDTOOLS_BIN&lt;/code> 依賴 &lt;code>MDTOOLS_SRC&lt;/code>，source 新於 binary 才重 build。&lt;/p></description><content:encoded><![CDATA[<p>工具落地的核心責任是<strong>讓檢查在對的時機自動執行</strong>，把紀律從「勤勞的人手動跑」轉移到「每次 commit / push 都跑」的基礎設施。<a href="/blog/go/glossary/#pre-commit-hook-%e5%ae%9a%e4%bd%8d" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">Pre-commit hook</a> 守本機開發、CI 守共享 branch；兩者互補、一起把規則失敗成本壓到秒級可回饋，避免 bug 漏到 production。這個模式對 <a href="/blog/go/glossary/#idempotent-%e6%96%87%e5%ad%97%e6%94%b9%e5%af%ab" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">idempotent</a> 工具特別重要 — hook 每次 commit 都會跑，非冪等的工具會累積漂移、讓作者反覆看到「為什麼這個檔案又被改了」的困惑。</p>
<p>工具一旦從 CLI 進入 hook / CI，就有幾個容易出狀況的邊界：<strong>哪些 check 該放 hook</strong>（快、本地可執行）、<strong>哪些該放 CI</strong>（慢、需要乾淨環境）、<strong>hook 改了檔怎麼 re-stage</strong>、<strong>&ndash;no-verify 的邊界</strong>怎麼約定、<strong>CI strict mode 跟 local dev 的差異</strong>怎麼處理。本章展開這些問題，並以 <code>.githooks/pre-commit</code> + <code>.github/workflows/md-check.yml</code> 作為 concrete instance。</p>
<h2 id="pre-commit-hook-能做什麼不該做什麼">Pre-commit hook 能做什麼、不該做什麼</h2>
<p><strong>能做</strong>：</p>
<ul>
<li>讀 staged 檔案，跑 lint / fmt</li>
<li>自動修正格式違規、<code>git add</code> re-stage</li>
<li>擋下 lint error 的 commit</li>
<li>跑跨檔分析（cards）</li>
<li>執行 build（確保程式碼能編譯）</li>
</ul>
<p><strong>不該做</strong>：</p>
<ul>
<li>執行完整 test suite（太慢，交給 CI）</li>
<li>執行 e2e 或需要網路的操作（脆弱，commit 不該依賴外部）</li>
<li>修改未 stage 的檔案（會造成 working tree 混亂）</li>
<li>執行超過幾秒的任務（心流殺手）</li>
</ul>
<p><strong>原則</strong>：pre-commit 是<strong>快速守門員</strong>，不是<strong>完整驗證器</strong>。該做的 checks 要在秒級完成；更慢的驗證交給 CI。</p>
<h2 id="makefile-作為-hook-與-ci-的共同介面">Makefile 作為 hook 與 CI 的共同介面</h2>
<p>有個常被忽略的 pattern：<strong>hook 跟 CI 都透過 Makefile 呼叫工具</strong>，不直接呼叫 binary。這讓三方共用同一套指令。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-makefile" data-lang="makefile"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># Makefile
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c"></span><span class="nv">MDTOOLS_SRC</span> <span class="o">:=</span> <span class="k">$(</span>shell find scripts/mdtools -type f -name <span class="s1">&#39;*.go&#39;</span> 2&gt;/dev/null<span class="k">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nv">MDTOOLS_MOD</span> <span class="o">:=</span> scripts/mdtools/go.mod scripts/mdtools/go.sum
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="nv">MDTOOLS_BIN</span> <span class="o">:=</span> bin/mdtools
</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="nf">.PHONY</span><span class="o">:</span> <span class="n">build</span> <span class="n">check</span> <span class="n">fix</span> <span class="n">lint</span> <span class="n">cards</span> <span class="n">install</span>-<span class="n">hooks</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="nf">build</span><span class="o">:</span> <span class="k">$(</span><span class="nv">MDTOOLS_BIN</span><span class="k">)</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="nf">$(MDTOOLS_BIN)</span><span class="o">:</span> <span class="k">$(</span><span class="nv">MDTOOLS_SRC</span><span class="k">)</span> <span class="k">$(</span><span class="nv">MDTOOLS_MOD</span><span class="k">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">	@mkdir -p bin
</span></span><span class="line"><span class="ln">12</span><span class="cl">	@cd scripts/mdtools <span class="o">&amp;&amp;</span> go build -o ../../<span class="k">$(</span>MDTOOLS_BIN<span class="k">)</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="nf">check</span><span class="o">:</span> <span class="n">build</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">	@./<span class="k">$(</span>MDTOOLS_BIN<span class="k">)</span> fmt --check content/
</span></span><span class="line"><span class="ln">16</span><span class="cl">	@./<span class="k">$(</span>MDTOOLS_BIN<span class="k">)</span> lint content/
</span></span><span class="line"><span class="ln">17</span><span class="cl">	@./<span class="k">$(</span>MDTOOLS_BIN<span class="k">)</span> cards content/
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="nf">install-hooks</span><span class="o">:</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">	@git config core.hooksPath .githooks
</span></span></code></pre></div><p>這樣：</p>
<ul>
<li>開發者本機：<code>make check</code> 手動驗一次</li>
<li>Pre-commit：hook 呼叫 <code>./bin/mdtools ...</code>（或透過 Makefile target）</li>
<li>CI：workflow 跑 <code>make check</code></li>
<li>所有人看到的失敗訊息格式一致</li>
</ul>
<p>Make 的依賴 timestamp 機制也剛好解決「binary 什麼時候重 build」— <code>MDTOOLS_BIN</code> 依賴 <code>MDTOOLS_SRC</code>，source 新於 binary 才重 build。</p>
<h2 id="pre-commit-hook-實作">Pre-commit hook 實作</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="cp"></span><span class="c1"># .githooks/pre-commit</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nb">set</span> -euo pipefail
</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="nv">MDTOOLS_BIN</span><span class="o">=</span><span class="s2">&#34;bin/mdtools&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="nv">REPO_ROOT</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>git rev-parse --show-toplevel<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="nb">cd</span> <span class="s2">&#34;</span><span class="nv">$REPO_ROOT</span><span class="s2">&#34;</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="c1"># 沒 staged .md 快速退出</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="nv">staged_md</span><span class="o">=</span><span class="k">$(</span>git diff --cached --name-only --diff-filter<span class="o">=</span>ACMR <span class="p">|</span> grep -E <span class="s1">&#39;\.md$&#39;</span> <span class="o">||</span> <span class="nb">true</span><span class="k">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="k">if</span> <span class="o">[[</span> -z <span class="s2">&#34;</span><span class="nv">$staged_md</span><span class="s2">&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nb">exit</span> <span class="m">0</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"># Rebuild if source newer than binary</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="k">if</span> <span class="o">[[</span> ! -x <span class="s2">&#34;</span><span class="nv">$MDTOOLS_BIN</span><span class="s2">&#34;</span> <span class="o">]]</span> <span class="o">||</span> <span class="o">[[</span> -n <span class="s2">&#34;</span><span class="k">$(</span>find scripts/mdtools -type f -name <span class="s1">&#39;*.go&#39;</span> -newer <span class="s2">&#34;</span><span class="nv">$MDTOOLS_BIN</span><span class="s2">&#34;</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span><span class="k">)</span><span class="s2">&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;[pre-commit] rebuilding mdtools...&#34;</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="o">(</span><span class="nb">cd</span> scripts/mdtools <span class="o">&amp;&amp;</span> go build -o <span class="s2">&#34;</span><span class="nv">$REPO_ROOT</span><span class="s2">/</span><span class="nv">$MDTOOLS_BIN</span><span class="s2">&#34;</span> .<span class="o">)</span> <span class="o">||</span> <span class="o">{</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="nb">echo</span> <span class="s2">&#34;[pre-commit] mdtools build failed&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="o">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="c1"># fmt --fix on staged，re-stage 變動的檔案</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;[pre-commit] mdtools fmt --fix&#34;</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="k">while</span> <span class="nv">IFS</span><span class="o">=</span> <span class="nb">read</span> -r f<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="o">[[</span> -z <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="k">continue</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="nv">before</span><span class="o">=</span><span class="k">$(</span>git hash-object <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="s2">&#34;</span><span class="nv">$MDTOOLS_BIN</span><span class="s2">&#34;</span> fmt --fix <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span> &gt;/dev/null
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="nv">after</span><span class="o">=</span><span class="k">$(</span>git hash-object <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="k">if</span> <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$before</span><span class="s2">&#34;</span> !<span class="o">=</span> <span class="s2">&#34;</span><span class="nv">$after</span><span class="s2">&#34;</span> <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">        git add <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl"><span class="k">done</span> <span class="o">&lt;&lt;&lt;</span> <span class="s2">&#34;</span><span class="nv">$staged_md</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">
</span></span><span class="line"><span class="ln">36</span><span class="cl"><span class="c1"># lint on staged (擋錯)</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;[pre-commit] mdtools lint&#34;</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl"><span class="s2">&#34;</span><span class="nv">$MDTOOLS_BIN</span><span class="s2">&#34;</span> lint <span class="nv">$staged_md</span> <span class="o">||</span> <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">
</span></span><span class="line"><span class="ln">40</span><span class="cl"><span class="c1"># cards on full content (擋錯)</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;[pre-commit] mdtools cards&#34;</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl"><span class="s2">&#34;</span><span class="nv">$MDTOOLS_BIN</span><span class="s2">&#34;</span> cards content/ <span class="o">||</span> <span class="nb">exit</span> <span class="m">1</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">
</span></span><span class="line"><span class="ln">44</span><span class="cl"><span class="nb">exit</span> <span class="m">0</span></span></span></code></pre></div><p>幾個<strong>關鍵 pattern</strong>：</p>
<h3 id="fast-exit-when-no-markdown-staged">Fast exit when no markdown staged</h3>
<p>沒 md 改動時 hook 在 10ms 內退出。Go 工程師改 <code>.go</code> 檔時不會被 markdown hook 擋。這是使用者體驗的生死線。</p>
<h3 id="git-diff---cached---diff-filteracmr"><code>git diff --cached --diff-filter=ACMR</code></h3>
<ul>
<li><code>A</code> (added), <code>C</code> (copied), <code>M</code> (modified), <code>R</code> (renamed) — 該檢查的變動類型</li>
<li>排除 <code>D</code> (deleted) — 刪除的檔案不用 lint</li>
</ul>
<h3 id="git-hash-object-偵測實際變動"><code>git hash-object</code> 偵測實際變動</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nv">before</span><span class="o">=</span><span class="k">$(</span>git hash-object <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">./bin/mdtools fmt --fix <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nv">after</span><span class="o">=</span><span class="k">$(</span>git hash-object <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span><span class="k">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$before</span><span class="s2">&#34;</span> !<span class="o">=</span> <span class="s2">&#34;</span><span class="nv">$after</span><span class="s2">&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> git add <span class="s2">&#34;</span><span class="nv">$f</span><span class="s2">&#34;</span></span></span></code></pre></div><p>只在檔案<strong>內容實際改變</strong>時 re-stage。如果 <code>fmt --fix</code> 跑完沒改東西（檔案已經 compliant），不觸發多餘 <code>git add</code>。</p>
<p>避免用 <code>stat</code> 或 mtime — 那些會誤判（file touched 但內容相同）。</p>
<h3 id="分層-exit-code">分層 exit code</h3>
<ul>
<li>Fast exit (<code>exit 0</code>)：沒事要做</li>
<li>Lint error (<code>exit 1</code>)：違規，擋 commit</li>
<li>Build failure (<code>exit 1</code>)：工具壞了，擋 commit（比讓人用壞工具好）</li>
</ul>
<p>Git 看 non-zero 就會阻止 commit，訊息會印到 terminal，作者能看到原因。</p>
<h2 id="ci-workflow">CI workflow</h2>
<p>CI 跑得比 hook 更嚴格：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># .github/workflows/md-check.yml</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">md-check</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w"></span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">  </span><span class="nt">md-check</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/setup-go@v5</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">          </span><span class="nt">go-version-file</span><span class="p">:</span><span class="w"> </span><span class="l">scripts/mdtools/go.mod</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">          </span><span class="nt">cache-dependency-path</span><span class="p">:</span><span class="w"> </span><span class="l">scripts/mdtools/go.sum</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build mdtools</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="sd">          mkdir -p bin
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="sd">          (cd scripts/mdtools &amp;&amp; go build -o ../../bin/mdtools .)</span><span class="w">
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">fmt --check</span><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">./bin/mdtools fmt --check content/</span><span class="w">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">lint</span><span class="w">
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">./bin/mdtools lint content/</span><span class="w">
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">cards</span><span class="w">
</span></span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">./bin/mdtools cards content/</span></span></span></code></pre></div><p><strong>設計決策</strong>：</p>
<h3 id="go-version-file-scriptsmdtoolsgomod"><code>go-version-file: scripts/mdtools/go.mod</code></h3>
<p>讓 CI 從 <code>go.mod</code> 讀取 Go 版本。<code>go.mod</code> 裡寫 <code>go 1.25.1</code>，CI 自動用匹配的版本；本機升 Go 時 workflow 跟著同步。</p>
<h3 id="ci-用---check-而非---fix">CI 用 <code>--check</code> 而非 <code>--fix</code></h3>
<p>CI 的角色是<strong>偵測</strong>，修復留給本機。<code>--check</code> 發現問題就 fail，讓作者在本機修完再 push。若 CI 自動 <code>--fix</code> 然後 commit，會造成「CI 偷改作者 PR」的混亂。</p>
<h3 id="不寫-try-catch-吞錯">不寫 try-catch 吞錯</h3>
<p>CI 步驟失敗就 fail — 不要寫 <code>continue-on-error: true</code> 藏錯誤。早期接工具時覺得「讓 CI 通過先」很誘人，但藏錯誤等於工具沒生效。寧可 CI 紅，也要誠實。</p>
<h2 id="安裝-hook-的-ux">安裝 hook 的 UX</h2>
<p><code>.githooks/pre-commit</code> 放進 repo，但 git 預設看 <code>.git/hooks/</code>，不會自動啟用。要跑：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git config core.hooksPath .githooks</span></span></code></pre></div><p>包成 Makefile target：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-makefile" data-lang="makefile"><span class="line"><span class="ln">1</span><span class="cl"><span class="nf">install-hooks</span><span class="o">:</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">	@git config core.hooksPath .githooks
</span></span><span class="line"><span class="ln">3</span><span class="cl">	@echo <span class="s2">&#34;hooks installed&#34;</span>
</span></span></code></pre></div><p>README 或 CONTRIBUTING.md 要寫「新 clone 時執行 <code>make install-hooks</code>」。這是一次性動作，但必要。</p>
<p><strong>考慮過的替代方案</strong>：</p>
<ul>
<li>放 <code>.git/hooks/pre-commit</code> — 不能 commit 進 repo，每個 clone 都要重設</li>
<li>用 <code>husky</code> / <code>pre-commit</code> 等工具 — 增加依賴，值得與否看團隊</li>
<li>用 direnv <code>.envrc</code> 自動設定 — 依賴 direnv，非標準</li>
</ul>
<p>最乾淨是 Makefile target + 明確的 onboarding 步驟。</p>
<h2 id="不能繞過的邊界">不能繞過的邊界</h2>
<p>Pre-commit hook 可以用 <code>--no-verify</code> 跳過。規範要明寫：</p>
<blockquote>
<p>寫作時遇到 pre-commit 報錯：讀錯誤訊息並修正，<strong>不可用 <code>--no-verify</code> 繞過 hook</strong>。</p></blockquote>
<p>這是<strong>社會規範而非技術強制</strong> — 技術上 git 一定允許 <code>--no-verify</code>。但只要規範明列、有人做 code review 抓到違規，就足夠維持紀律。</p>
<p><strong>有個 nuance</strong>：緊急情況真的需要 <code>--no-verify</code> 怎麼辦？例如在服務中斷時要緊急 commit 修復。規範要留這個緊急閥門，但搭配：</p>
<ul>
<li>事後必須補 commit 把違規修掉</li>
<li><code>--no-verify</code> 的使用要 log 或在 PR 描述標註</li>
</ul>
<p>大多數 repo 一年可能用不到兩次。關鍵是<strong>預設是不繞過</strong>，而不是「看情況」。</p>
<h2 id="常見陷阱">常見陷阱</h2>
<h3 id="hook-執行時間爆炸">Hook 執行時間爆炸</h3>
<p>常見在 <code>cards</code> 這類需要 parse 全 repo 的 check。對 400 檔 &lt; 1 秒可接受；對 10000 檔就要評估。降級手段：</p>
<ul>
<li><code>cards</code> 只跑受影響的子圖（根據 staged 檔案 inferrence）</li>
<li>複雜 check 搬到 CI</li>
<li>本機加 cache（invalidate on file mtime）</li>
</ul>
<h3 id="binary-不-commit-進-repo但-ci-失敗">Binary 不 commit 進 repo，但 CI 失敗</h3>
<p><code>.gitignore</code> 排除 <code>bin/</code>，所以 CI checkout 時沒有 binary。要記得在 CI 加 build step（上面 workflow 的 <code>Build mdtools</code> step）。</p>
<h3 id="fmt-fix-後-commit-有兩個版本">fmt &ndash;fix 後 commit 有兩個版本</h3>
<p>若 hook 的 <code>fmt --fix</code> 改了檔但 re-stage 失敗（例如 permission 問題），作者以為 commit 成功但實際 commit 的是舊版本。每次 staged 版本跟 working tree 都要同步 — <code>git hash-object</code> 比對能早期發現不一致。</p>
<h3 id="hook-不能跨平台">Hook 不能跨平台</h3>
<p>macOS / Linux 的 bash hook 在 Windows（未裝 WSL 或 Git Bash）可能不執行。如果 contributor 有 Windows，把 hook 寫成 Go 程式（例如 <code>bin/mdtools hook pre-commit</code>），讓 Go 本身處理跨平台。</p>
<h2 id="擴充路徑">擴充路徑</h2>
<ul>
<li><strong>Hook 只跑 staged 子圖</strong>：根據 staged files 推算需要 parse 的 repo subset，降低 hook 延遲。</li>
<li><strong>CI artifact 留 report</strong>：把 lint / cards 的報告 upload 成 GitHub Actions artifact，讓 PR 評論能連結到完整報告。</li>
<li><strong>Pre-push hook 做更重檢查</strong>：把 full test suite 放 pre-push（本機 push 前跑），更頻繁的 pre-commit 只做格式與 lint。</li>
</ul>
<h2 id="模組總結">模組總結</h2>
<p>走完九個章節，回到出發點：<strong>Go 除了寫後端服務，還能寫內部工具 / 靜態分析 / CLI / 程式碼生成</strong>。跟後端服務的差異在於生命週期、I/O 模式、錯誤處理慣例，但共用的 Go 技能（型別、interface、package、error）完全可遷移。</p>
<p>本模組介紹的技術 — stdlib flag、goldmark AST、idempotent rewriting、graph analysis、tripwire 決策、pre-commit 整合 — 適用範圍不只 markdown 工具。寫 linter、codegen、migration tool、build tool、dev helper 都是這些技術的組合。</p>
<p>下一步：動手把 <code>scripts/mdtools</code> clone 出來，加一條自己的 rule 進去。真正讀懂一個工具的方式是改它一次。</p>
]]></content:encoded></item><item><title>8.6 Cloudflare：DNS、SSL 與長連線服務</title><link>https://tarrragon.github.io/blog/go/08-case-studies/cloudflare/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/08-case-studies/cloudflare/</guid><description>&lt;p>Cloudflare 是理解 Go 高併發價值的最佳案例之一。官方文章提到 Go 被用在 Railgun、DNS infrastructure、SSL、load testing 與多個生產服務中。這些工作共同點都很明顯：大量 I/O、長連線、網路協調與對 latency 的敏感。&lt;/p>
&lt;h2 id="你應該看什麼">你應該看什麼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.cloudflare.com/go-at-cloudflare">Go at CloudFlare&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.cloudflare.com/what-weve-been-doing-with-go/">What we&amp;rsquo;ve been doing with Go&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.cloudflare.com/go-hack-nights/">Go Hack Nights at Cloudflare&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼&lt;/h2>
&lt;ol>
&lt;li>goroutine 與 channel 很適合處理大量網路事件。&lt;/li>
&lt;li>Go 在低延遲連線服務中特別自然。&lt;/li>
&lt;li>服務邊界、節奏控制與生命週期管理是關鍵，不只是 raw throughput。&lt;/li>
&lt;/ol>
&lt;h2 id="可對照的公開原始碼">可對照的公開原始碼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://github.com/cloudflare/cloudflared">cloudflare/cloudflared&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/cloudflare/cloudflare-go">cloudflare/cloudflare-go&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>這兩個 repo 很適合拿來看長連線代理、API client、context 使用與依賴組裝。&lt;/p></description><content:encoded><![CDATA[<p>Cloudflare 是理解 Go 高併發價值的最佳案例之一。官方文章提到 Go 被用在 Railgun、DNS infrastructure、SSL、load testing 與多個生產服務中。這些工作共同點都很明顯：大量 I/O、長連線、網路協調與對 latency 的敏感。</p>
<h2 id="你應該看什麼">你應該看什麼</h2>
<ul>
<li><a href="https://blog.cloudflare.com/go-at-cloudflare">Go at CloudFlare</a></li>
<li><a href="https://blog.cloudflare.com/what-weve-been-doing-with-go/">What we&rsquo;ve been doing with Go</a></li>
<li><a href="https://blog.cloudflare.com/go-hack-nights/">Go Hack Nights at Cloudflare</a></li>
</ul>
<h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼</h2>
<ol>
<li>goroutine 與 channel 很適合處理大量網路事件。</li>
<li>Go 在低延遲連線服務中特別自然。</li>
<li>服務邊界、節奏控制與生命週期管理是關鍵，不只是 raw throughput。</li>
</ol>
<h2 id="可對照的公開原始碼">可對照的公開原始碼</h2>
<ul>
<li><a href="https://github.com/cloudflare/cloudflared">cloudflare/cloudflared</a></li>
<li><a href="https://github.com/cloudflare/cloudflare-go">cloudflare/cloudflare-go</a></li>
</ul>
<p>這兩個 repo 很適合拿來看長連線代理、API client、context 使用與依賴組裝。</p>
]]></content:encoded></item><item><title>1.6 rate limiting 與 backpressure</title><link>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/rate-limit/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/rate-limit/</guid><description>&lt;p>rate limiting 的核心責任是把過量輸入轉成可預期的服務行為。服務可以等待、排隊、拒絕、降級或取樣，但這些策略應由程式明確決定，而不是讓 goroutine、channel 或 memory 自行失控。&lt;/p>
&lt;h2 id="預計補充內容">預計補充內容&lt;/h2>
&lt;p>這些 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> 邊界會在下列章節展開：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go 入門：channel：事件流與 backpressure &lt;/a>：先理解 channel &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;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">Go 進階：非阻塞送出與事件丟棄策略&lt;/a>：當系統必須在滿載時做出明確選擇，這裡會處理 drop、覆蓋與回錯的語意。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口&lt;/a>：跨節點流量治理、gateway 與 quota，屬於平台層責任。&lt;/li>
&lt;/ul>
&lt;h2 id="本章不處理">本章不處理&lt;/h2>
&lt;p>本章先處理單一 process 內的輸入控制與 backpressure ；跨節點流量治理、gateway 與 quota 的平台責任，會放在 &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口&lt;/a>。&lt;/p>
&lt;h2 id="與-backend-教材的分工">與 Backend 教材的分工&lt;/h2>
&lt;p>本章只處理 Go process 內的速率控制。API gateway、load balancer、service mesh、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a> quota 與跨節點流量治理會放在 &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口&lt;/a>。&lt;/p>
&lt;h2 id="和-go-教材的關係">和 Go 教材的關係&lt;/h2>
&lt;p>這一章承接的是 channel backpressure 、non-blocking send 與 worker capacity；如果你要先回看語言教材，可以讀：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go：channel：資料傳遞與 backpressure &lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">Go：非阻塞送出與事件丟棄策略&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">Go：bounded worker pool&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>rate limiting 的核心責任是把過量輸入轉成可預期的服務行為。服務可以等待、排隊、拒絕、降級或取樣，但這些策略應由程式明確決定，而不是讓 goroutine、channel 或 memory 自行失控。</p>
<h2 id="預計補充內容">預計補充內容</h2>
<p>這些 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 邊界會在下列章節展開：</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>：先理解 channel <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</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>：當系統必須在滿載時做出明確選擇，這裡會處理 drop、覆蓋與回錯的語意。</li>
<li><a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a>：跨節點流量治理、gateway 與 quota，屬於平台層責任。</li>
</ul>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一 process 內的輸入控制與 backpressure ；跨節點流量治理、gateway 與 quota 的平台責任，會放在 <a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a>。</p>
<h2 id="與-backend-教材的分工">與 Backend 教材的分工</h2>
<p>本章只處理 Go process 內的速率控制。API gateway、load balancer、service mesh、<a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> quota 與跨節點流量治理會放在 <a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a>。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 channel backpressure 、non-blocking send 與 worker capacity；如果你要先回看語言教材，可以讀：</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/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件</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/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">Go：bounded worker pool</a></li>
</ul>
]]></content:encoded></item><item><title>1.6 函式、方法與 receiver</title><link>https://tarrragon.github.io/blog/go/01-basics/functions-methods/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/01-basics/functions-methods/</guid><description>&lt;p>Go 沒有 class，但有函式、struct 與方法。方法只是帶有 receiver 的函式；receiver 讓函式和某個型別形成關聯，進而表達「這個行為屬於這個資料」。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>區分普通函式與方法&lt;/li>
&lt;li>理解 receiver 的語法與語義&lt;/li>
&lt;li>判斷何時使用 pointer receiver&lt;/li>
&lt;li>用建構函式集中初始化規則&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察普通函式不屬於特定值">【觀察】普通函式不屬於特定值&lt;/h2>
&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">NormalizeName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">name&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">string&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">return&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">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ToLower&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">name&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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="nx">name&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NormalizeName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34; Alice &amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這種函式適合描述純粹轉換：輸入什麼，輸出什麼，不需要修改某個物件的內部狀態。&lt;/p>
&lt;h2 id="判讀方法是帶-receiver-的函式">【判讀】方法是帶 receiver 的函式&lt;/h2>
&lt;p>方法的核心規則是：函式若需要以某個型別作為操作對象，就用 receiver 綁到該型別。行為和某個型別密切相關時，可以寫成方法：&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">Counter&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">value&lt;/span> &lt;span class="kt">int&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">c&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Counter&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Inc&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"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">value&lt;/span>&lt;span class="o">++&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">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">c&lt;/span> &lt;span class="nx">Counter&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Value&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kt">int&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">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">value&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&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>(c *Counter)&lt;/code> 和 &lt;code>(c Counter)&lt;/code> 就是 receiver。它放在 &lt;code>func&lt;/code> 和方法名稱之間，表示這個函式是 &lt;code>Counter&lt;/code> 的方法。receiver 只表示「這個函式以這個型別作為操作對象」，不代表繼承關係 — Go 沒有類別繼承，方法只是綁定到型別的函式。&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">var&lt;/span> &lt;span class="nx">c&lt;/span> &lt;span class="nx">Counter&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="nf">Inc&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">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Println&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Value&lt;/span>&lt;span class="p">())&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Go 會讓方法呼叫看起來像物件操作，但本質仍然是函式呼叫。&lt;/p>
&lt;h2 id="策略用是否修改狀態選擇-receiver">【策略】用是否修改狀態選擇 receiver&lt;/h2>
&lt;p>receiver 選擇的核心規則是：要修改原值用 pointer receiver，不修改且型別小可以用 value receiver。receiver 有兩種常見形式：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>receiver&lt;/th>
 &lt;th>適用情境&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>value receiver&lt;/td>
 &lt;td>不修改原值，型別小，複製成本低&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>pointer receiver&lt;/td>
 &lt;td>需要修改原值，或型別較大，避免複製&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>例如 &lt;code>Value()&lt;/code> 不修改 &lt;code>Counter&lt;/code>，可以用 value receiver：&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="nx">Counter&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Value&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kt">int&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">return&lt;/span> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">value&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>Inc()&lt;/code> 需要修改原本的 counter，所以使用 pointer receiver：&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">Counter&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Inc&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">value&lt;/span>&lt;span class="o">++&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;/code>&lt;/pre>&lt;/div>&lt;p>如果你不確定，先問一個問題：這個方法是否要改變 receiver 的狀態？答案是 yes，通常就用 pointer receiver。&lt;/p>
&lt;h2 id="執行用建構函式集中初始化">【執行】用建構函式集中初始化&lt;/h2>
&lt;p>建構函式的核心用途是集中初始化規則。Go 沒有 constructor 關鍵字，但慣例會用 &lt;code>NewTypeName&lt;/code> 建立需要初始化的型別：&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">Cache&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">items&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="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="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="nf">NewCache&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Cache&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="o">&amp;amp;&lt;/span>&lt;span class="nx">Cache&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="nx">items&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="kt">string&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 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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&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">Cache&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">value&lt;/span> &lt;span class="kt">string&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">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">items&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">value&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;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&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">Cache&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">key&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&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">bool&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">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">value&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&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">items&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">key&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">return&lt;/span> &lt;span class="nx">value&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&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;/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="nx">cache&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewCache&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">cache&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;theme&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;dark&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="nx">value&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">cache&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;theme&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這裡 &lt;code>NewCache()&lt;/code> 的價值是保證 &lt;code>items&lt;/code> 一定被初始化。呼叫者不需要知道 &lt;code>Cache&lt;/code> 內部有 map，也不需要記得手動 &lt;code>make&lt;/code>。&lt;/p>
&lt;h2 id="函式還是方法">函式還是方法？&lt;/h2>
&lt;p>函式與方法的選擇規則是：純轉換用函式，依賴型別狀態用方法，需要初始化保證用 &lt;code>NewTypeName&lt;/code>。可以用這張表判斷：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>問題&lt;/th>
 &lt;th>選擇&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>只是轉換資料？&lt;/td>
 &lt;td>普通函式&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>行為依賴某個型別的狀態？&lt;/td>
 &lt;td>方法&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>需要保證初始化規則？&lt;/td>
 &lt;td>&lt;code>NewTypeName&lt;/code> 建構函式&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>需要符合 interface？&lt;/td>
 &lt;td>方法&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&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">ParsePort&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">raw&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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="c1">// 純資料轉換，適合普通函式&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">s&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Server&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Start&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="c1">// 啟動 Server，適合方法&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></description><content:encoded><![CDATA[<p>Go 沒有 class，但有函式、struct 與方法。方法只是帶有 receiver 的函式；receiver 讓函式和某個型別形成關聯，進而表達「這個行為屬於這個資料」。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>區分普通函式與方法</li>
<li>理解 receiver 的語法與語義</li>
<li>判斷何時使用 pointer receiver</li>
<li>用建構函式集中初始化規則</li>
</ol>
<hr>
<h2 id="觀察普通函式不屬於特定值">【觀察】普通函式不屬於特定值</h2>
<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">NormalizeName</span><span class="p">(</span><span class="nx">name</span> <span class="kt">string</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="k">return</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">strings</span><span class="p">.</span><span class="nf">ToLower</span><span class="p">(</span><span class="nx">name</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>呼叫方式：</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="nx">name</span> <span class="o">:=</span> <span class="nf">NormalizeName</span><span class="p">(</span><span class="s">&#34; Alice &#34;</span><span class="p">)</span></span></span></code></pre></div><p>這種函式適合描述純粹轉換：輸入什麼，輸出什麼，不需要修改某個物件的內部狀態。</p>
<h2 id="判讀方法是帶-receiver-的函式">【判讀】方法是帶 receiver 的函式</h2>
<p>方法的核心規則是：函式若需要以某個型別作為操作對象，就用 receiver 綁到該型別。行為和某個型別密切相關時，可以寫成方法：</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">Counter</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">value</span> <span class="kt">int</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">Counter</span><span class="p">)</span> <span class="nf">Inc</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">value</span><span class="o">++</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">func</span> <span class="p">(</span><span class="nx">c</span> <span class="nx">Counter</span><span class="p">)</span> <span class="nf">Value</span><span class="p">()</span> <span class="kt">int</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">c</span><span class="p">.</span><span class="nx">value</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>(c *Counter)</code> 和 <code>(c Counter)</code> 就是 receiver。它放在 <code>func</code> 和方法名稱之間，表示這個函式是 <code>Counter</code> 的方法。receiver 只表示「這個函式以這個型別作為操作對象」，不代表繼承關係 — Go 沒有類別繼承，方法只是綁定到型別的函式。</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">var</span> <span class="nx">c</span> <span class="nx">Counter</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="nf">Inc</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">fmt</span><span class="p">.</span><span class="nf">Println</span><span class="p">(</span><span class="nx">c</span><span class="p">.</span><span class="nf">Value</span><span class="p">())</span></span></span></code></pre></div><p>Go 會讓方法呼叫看起來像物件操作，但本質仍然是函式呼叫。</p>
<h2 id="策略用是否修改狀態選擇-receiver">【策略】用是否修改狀態選擇 receiver</h2>
<p>receiver 選擇的核心規則是：要修改原值用 pointer receiver，不修改且型別小可以用 value receiver。receiver 有兩種常見形式：</p>
<table>
  <thead>
      <tr>
          <th>receiver</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>value receiver</td>
          <td>不修改原值，型別小，複製成本低</td>
      </tr>
      <tr>
          <td>pointer receiver</td>
          <td>需要修改原值，或型別較大，避免複製</td>
      </tr>
  </tbody>
</table>
<p>例如 <code>Value()</code> 不修改 <code>Counter</code>，可以用 value receiver：</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="nx">Counter</span><span class="p">)</span> <span class="nf">Value</span><span class="p">()</span> <span class="kt">int</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">c</span><span class="p">.</span><span class="nx">value</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>Inc()</code> 需要修改原本的 counter，所以使用 pointer receiver：</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">Counter</span><span class="p">)</span> <span class="nf">Inc</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">value</span><span class="o">++</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>如果你不確定，先問一個問題：這個方法是否要改變 receiver 的狀態？答案是 yes，通常就用 pointer receiver。</p>
<h2 id="執行用建構函式集中初始化">【執行】用建構函式集中初始化</h2>
<p>建構函式的核心用途是集中初始化規則。Go 沒有 constructor 關鍵字，但慣例會用 <code>NewTypeName</code> 建立需要初始化的型別：</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">Cache</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">items</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="kt">string</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="nf">NewCache</span><span class="p">()</span> <span class="o">*</span><span class="nx">Cache</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="o">&amp;</span><span class="nx">Cache</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">items</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="kt">string</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="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</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">Cache</span><span class="p">)</span> <span class="nf">Set</span><span class="p">(</span><span class="nx">key</span><span class="p">,</span> <span class="nx">value</span> <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">items</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="p">=</span> <span class="nx">value</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</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">Cache</span><span class="p">)</span> <span class="nf">Get</span><span class="p">(</span><span class="nx">key</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="kt">string</span><span class="p">,</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">value</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">items</span><span class="p">[</span><span class="nx">key</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="nx">value</span><span class="p">,</span> <span class="nx">ok</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><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="nx">cache</span> <span class="o">:=</span> <span class="nf">NewCache</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">cache</span><span class="p">.</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;theme&#34;</span><span class="p">,</span> <span class="s">&#34;dark&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">value</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">cache</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="s">&#34;theme&#34;</span><span class="p">)</span></span></span></code></pre></div><p>這裡 <code>NewCache()</code> 的價值是保證 <code>items</code> 一定被初始化。呼叫者不需要知道 <code>Cache</code> 內部有 map，也不需要記得手動 <code>make</code>。</p>
<h2 id="函式還是方法">函式還是方法？</h2>
<p>函式與方法的選擇規則是：純轉換用函式，依賴型別狀態用方法，需要初始化保證用 <code>NewTypeName</code>。可以用這張表判斷：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>選擇</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>只是轉換資料？</td>
          <td>普通函式</td>
      </tr>
      <tr>
          <td>行為依賴某個型別的狀態？</td>
          <td>方法</td>
      </tr>
      <tr>
          <td>需要保證初始化規則？</td>
          <td><code>NewTypeName</code> 建構函式</td>
      </tr>
      <tr>
          <td>需要符合 interface？</td>
          <td>方法</td>
      </tr>
  </tbody>
</table>
<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">ParsePort</span><span class="p">(</span><span class="nx">raw</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="kt">int</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="c1">// 純資料轉換，適合普通函式</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">s</span> <span class="o">*</span><span class="nx">Server</span><span class="p">)</span> <span class="nf">Start</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="c1">// 啟動 Server，適合方法</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div>]]></content:encoded></item><item><title>2.6 struct embedding 與組合式設計</title><link>https://tarrragon.github.io/blog/go/02-types-data/embedding-composition/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/02-types-data/embedding-composition/</guid><description>&lt;p>struct embedding 的核心用途是組合既有能力。它可以讓欄位與方法被提升到外層型別，但設計重點仍然是清楚表達責任，而不是模擬繼承階層。&lt;/p>
&lt;h2 id="預計補充內容">預計補充內容&lt;/h2>
&lt;p>這些組合邊界會在下列章節展開：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/00-philosophy/composition/" data-link-title="0.2 組合優先：小介面與明確依賴" data-link-desc="用小介面與 struct 組合取代大型繼承結構">Go 入門：組合優先：小介面與明確依賴&lt;/a>：先理解 Go 為什麼偏好組合，才能判斷 embedding 是在表達能力，還是在模仿繼承。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">Go 入門：interface：用行為定義依賴&lt;/a>：這裡會把 embedding 和 interface 的責任分開，避免欄位提升變成隱性耦合。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go 進階：composition root 與依賴組裝&lt;/a>：當組合開始影響 wiring 時，就要看依賴是在哪一層被建立的。&lt;/li>
&lt;/ul>
&lt;h2 id="與其他章節的關係">與其他章節的關係&lt;/h2>
&lt;p>本章承接 &lt;a href="https://tarrragon.github.io/blog/go/00-philosophy/composition/" data-link-title="0.2 組合優先：小介面與明確依賴" data-link-desc="用小介面與 struct 組合取代大型繼承結構">組合優先：小介面與明確依賴&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">interface：用行為定義依賴&lt;/a>，後續會連到 &lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">composition root 與依賴組裝&lt;/a>。&lt;/p>
&lt;h2 id="和-go-教材的關係">和 Go 教材的關係&lt;/h2>
&lt;p>這一章承接的是組合、interface 與依賴組裝；如果你要先回看語言教材，可以讀：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/00-philosophy/composition/" data-link-title="0.2 組合優先：小介面與明確依賴" data-link-desc="用小介面與 struct 組合取代大型繼承結構">Go：組合優先：小介面與明確依賴&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">Go：interface：用行為定義依賴&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go：composition root 與依賴組裝&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">Go：以 domain 重新整理 package&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>struct embedding 的核心用途是組合既有能力。它可以讓欄位與方法被提升到外層型別，但設計重點仍然是清楚表達責任，而不是模擬繼承階層。</p>
<h2 id="預計補充內容">預計補充內容</h2>
<p>這些組合邊界會在下列章節展開：</p>
<ul>
<li><a href="/blog/go/00-philosophy/composition/" data-link-title="0.2 組合優先：小介面與明確依賴" data-link-desc="用小介面與 struct 組合取代大型繼承結構">Go 入門：組合優先：小介面與明確依賴</a>：先理解 Go 為什麼偏好組合，才能判斷 embedding 是在表達能力，還是在模仿繼承。</li>
<li><a href="/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">Go 入門：interface：用行為定義依賴</a>：這裡會把 embedding 和 interface 的責任分開，避免欄位提升變成隱性耦合。</li>
<li><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go 進階：composition root 與依賴組裝</a>：當組合開始影響 wiring 時，就要看依賴是在哪一層被建立的。</li>
</ul>
<h2 id="與其他章節的關係">與其他章節的關係</h2>
<p>本章承接 <a href="/blog/go/00-philosophy/composition/" data-link-title="0.2 組合優先：小介面與明確依賴" data-link-desc="用小介面與 struct 組合取代大型繼承結構">組合優先：小介面與明確依賴</a> 與 <a href="/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">interface：用行為定義依賴</a>，後續會連到 <a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">composition root 與依賴組裝</a>。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是組合、interface 與依賴組裝；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/00-philosophy/composition/" data-link-title="0.2 組合優先：小介面與明確依賴" data-link-desc="用小介面與 struct 組合取代大型繼承結構">Go：組合優先：小介面與明確依賴</a></li>
<li><a href="/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">Go：interface：用行為定義依賴</a></li>
<li><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go：composition root 與依賴組裝</a></li>
<li><a href="/blog/go/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">Go：以 domain 重新整理 package</a></li>
</ul>
]]></content:encoded></item><item><title>3.6 log/slog：結構化日誌</title><link>https://tarrragon.github.io/blog/go/03-stdlib/slog/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/03-stdlib/slog/</guid><description>&lt;p>&lt;code>log/slog&lt;/code> 是 Go 標準庫提供的結構化日誌 package。它的核心用途是把 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a> 寫成「訊息 + key-value 欄位」，讓人類能讀，也讓工具能搜尋、過濾與聚合。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>建立 text 或 JSON logger&lt;/li>
&lt;li>使用 log level 區分訊號重要性&lt;/li>
&lt;li>用 key-value 欄位保存可查詢資訊&lt;/li>
&lt;li>設計穩定的 log 欄位名稱&lt;/li>
&lt;li>避免把所有資訊塞進自由文字&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察結構化日誌把資訊放進欄位">【觀察】結構化日誌把資訊放進欄位&lt;/h2>
&lt;p>結構化日誌的核心規則是：穩定資訊放欄位，敘述文字只描述事件。以下範例記錄一筆 user 建立事件：&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="nx">logger&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">slog&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">New&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">slog&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewJSONHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Stdout&lt;/span>&lt;span class="p">,&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">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="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Info&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="s">&amp;#34;user created&amp;#34;&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="s">&amp;#34;userID&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;u_1&amp;#34;&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="s">&amp;#34;email&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;alice@example.com&amp;#34;&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="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>JSON handler 會輸出類似：&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;time&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2026-04-22T10:00:00Z&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;level&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;INFO&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;msg&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user created&amp;#34;&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="nt">&amp;#34;userID&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;u_1&amp;#34;&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="nt">&amp;#34;email&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;alice@example.com&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;/code>&lt;/pre>&lt;/div>&lt;p>這比 &lt;code>fmt.Printf(&amp;quot;user u_1 alice@example.com created&amp;quot;)&lt;/code> 更容易被查詢。&lt;/p>
&lt;h2 id="判讀level-表示事件嚴重度">【判讀】level 表示事件嚴重度&lt;/h2>
&lt;p>log level 的核心規則是：level 表示事件需要多少注意力，不表示程式碼所在位置。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>level&lt;/th>
 &lt;th>適用情境&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Debug&lt;/td>
 &lt;td>開發或診斷細節&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Info&lt;/td>
 &lt;td>正常但重要的狀態變化&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Warn&lt;/td>
 &lt;td>可恢復但需要注意的異常&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Error&lt;/td>
 &lt;td>操作失敗或需要處理的錯誤&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&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="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Debug&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;cache miss&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;key&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">key&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">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;server started&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;addr&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">addr&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">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Warn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;queue full&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;dropped&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">count&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">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;write file failed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;path&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">path&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;error&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>Error&lt;/code> log 應該包含 error 欄位，讓讀者知道失敗原因。&lt;/p>
&lt;h2 id="策略欄位名稱要穩定">【策略】欄位名稱要穩定&lt;/h2>
&lt;p>log 欄位設計的核心規則是：同一個概念使用同一個欄位名稱，不要在不同地方混用別名。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>概念&lt;/th>
 &lt;th>建議欄位&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>使用者 ID&lt;/td>
 &lt;td>&lt;code>userID&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/request-id/" data-link-title="Request ID" data-link-desc="說明單次 request 的識別碼如何支援 log 搜尋與問題定位">request ID&lt;/a>&lt;/td>
 &lt;td>&lt;code>requestID&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>工作 ID&lt;/td>
 &lt;td>&lt;code>jobID&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>元件名稱&lt;/td>
 &lt;td>&lt;code>component&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>錯誤&lt;/td>
 &lt;td>&lt;code>error&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&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="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;job queued&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">job&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">2&lt;/span>&lt;span class="cl">&lt;span class="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;job started&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;job_id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">job&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">3&lt;/span>&lt;span class="cl">&lt;span class="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;job done&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;jobID&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">job&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;/code>&lt;/pre>&lt;/div>&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="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;job queued&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;jobID&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">job&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">2&lt;/span>&lt;span class="cl">&lt;span class="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;job started&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;jobID&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">job&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">3&lt;/span>&lt;span class="cl">&lt;span class="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;job done&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;jobID&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">job&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;/code>&lt;/pre>&lt;/div>&lt;p>欄位穩定後，grep、log query、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/dashboard/" data-link-title="Dashboard" data-link-desc="說明 dashboard 如何把關鍵訊號組成可判讀的服務狀態畫面">dashboard&lt;/a> 才能可靠。&lt;/p>
&lt;h2 id="執行建立帶預設欄位的-logger">【執行】建立帶預設欄位的 logger&lt;/h2>
&lt;p>預設欄位的核心規則是：每筆 log 都需要的上下文，應該掛在 logger 上，而不是每次手動重複。&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="nx">base&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">slog&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">New&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">slog&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewJSONHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Stdout&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">slog&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">HandlerOptions&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">Level&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">slog&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">LevelInfo&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="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="nx">logger&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">base&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">With&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="s">&amp;#34;component&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;worker&amp;#34;&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="s">&amp;#34;version&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;1.0.0&amp;#34;&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="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;job started&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;jobID&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;j_1&amp;#34;&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">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;job finished&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;jobID&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;j_1&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>With&lt;/code> 會回傳帶有固定欄位的新 logger。這適合 component、version、requestID 這類上下文。&lt;/p>
&lt;h2 id="設計檢查">設計檢查&lt;/h2>
&lt;h3 id="把所有資訊塞進-msg">把所有資訊塞進 msg&lt;/h3>
&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="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;job j_1 for user u_1 started&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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="nx">logger&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;job started&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;jobID&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;j_1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;userID&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;u_1&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>第二種寫法可以直接查 &lt;code>jobID=j_1&lt;/code> 或 &lt;code>userID=u_1&lt;/code>。&lt;/p>
&lt;h3 id="欄位名稱不穩定">欄位名稱不穩定&lt;/h3>
&lt;p>欄位名稱不穩定會讓查詢失效。選定 &lt;code>userID&lt;/code> 就一路使用 &lt;code>userID&lt;/code>，不要混用 &lt;code>uid&lt;/code>、&lt;code>user_id&lt;/code>、&lt;code>user&lt;/code>。&lt;/p>
&lt;h3 id="忽略敏感資訊">忽略敏感資訊&lt;/h3>
&lt;p>log 會被保存與轉發。密碼、token、完整信用卡號等敏感資訊不應寫入 log。&lt;/p>
&lt;h2 id="延伸閱讀">延伸閱讀&lt;/h2>
&lt;p>本章只介紹標準庫 &lt;code>log/slog&lt;/code> 的基本用法。服務開始有 domain event、state repository 或查詢需求時，可以接著閱讀 &lt;a href="https://tarrragon.github.io/blog/go/06-practical/structured-recording/" data-link-title="6.5 如何新增結構化記錄欄位" data-link-desc="區分 operational log、domain event log 與狀態資料">如何新增結構化記錄欄位&lt;/a>；進入生產操作後，再閱讀 &lt;a href="https://tarrragon.github.io/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">Go 進階：結構化日誌欄位設計&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Observability pipeline、metrics 與 tracing&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p><code>log/slog</code> 是 Go 標準庫提供的結構化日誌 package。它的核心用途是把 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 寫成「訊息 + key-value 欄位」，讓人類能讀，也讓工具能搜尋、過濾與聚合。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>建立 text 或 JSON logger</li>
<li>使用 log level 區分訊號重要性</li>
<li>用 key-value 欄位保存可查詢資訊</li>
<li>設計穩定的 log 欄位名稱</li>
<li>避免把所有資訊塞進自由文字</li>
</ol>
<hr>
<h2 id="觀察結構化日誌把資訊放進欄位">【觀察】結構化日誌把資訊放進欄位</h2>
<p>結構化日誌的核心規則是：穩定資訊放欄位，敘述文字只描述事件。以下範例記錄一筆 user 建立事件：</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="nx">logger</span> <span class="o">:=</span> <span class="nx">slog</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="nx">slog</span><span class="p">.</span><span class="nf">NewJSONHandler</span><span class="p">(</span><span class="nx">os</span><span class="p">.</span><span class="nx">Stdout</span><span class="p">,</span> <span class="kc">nil</span><span class="p">))</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="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="s">&#34;user created&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="s">&#34;userID&#34;</span><span class="p">,</span> <span class="s">&#34;u_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="s">&#34;email&#34;</span><span class="p">,</span> <span class="s">&#34;alice@example.com&#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></code></pre></div><p>JSON handler 會輸出類似：</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;time&#34;</span><span class="p">:</span> <span class="s2">&#34;2026-04-22T10:00:00Z&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;level&#34;</span><span class="p">:</span> <span class="s2">&#34;INFO&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nt">&#34;msg&#34;</span><span class="p">:</span> <span class="s2">&#34;user created&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nt">&#34;userID&#34;</span><span class="p">:</span> <span class="s2">&#34;u_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nt">&#34;email&#34;</span><span class="p">:</span> <span class="s2">&#34;alice@example.com&#34;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這比 <code>fmt.Printf(&quot;user u_1 alice@example.com created&quot;)</code> 更容易被查詢。</p>
<h2 id="判讀level-表示事件嚴重度">【判讀】level 表示事件嚴重度</h2>
<p>log level 的核心規則是：level 表示事件需要多少注意力，不表示程式碼所在位置。</p>
<table>
  <thead>
      <tr>
          <th>level</th>
          <th>適用情境</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Debug</td>
          <td>開發或診斷細節</td>
      </tr>
      <tr>
          <td>Info</td>
          <td>正常但重要的狀態變化</td>
      </tr>
      <tr>
          <td>Warn</td>
          <td>可恢復但需要注意的異常</td>
      </tr>
      <tr>
          <td>Error</td>
          <td>操作失敗或需要處理的錯誤</td>
      </tr>
  </tbody>
</table>
<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="nx">logger</span><span class="p">.</span><span class="nf">Debug</span><span class="p">(</span><span class="s">&#34;cache miss&#34;</span><span class="p">,</span> <span class="s">&#34;key&#34;</span><span class="p">,</span> <span class="nx">key</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;server started&#34;</span><span class="p">,</span> <span class="s">&#34;addr&#34;</span><span class="p">,</span> <span class="nx">addr</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Warn</span><span class="p">(</span><span class="s">&#34;queue full&#34;</span><span class="p">,</span> <span class="s">&#34;dropped&#34;</span><span class="p">,</span> <span class="nx">count</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="s">&#34;write file failed&#34;</span><span class="p">,</span> <span class="s">&#34;path&#34;</span><span class="p">,</span> <span class="nx">path</span><span class="p">,</span> <span class="s">&#34;error&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span></span></span></code></pre></div><p><code>Error</code> log 應該包含 error 欄位，讓讀者知道失敗原因。</p>
<h2 id="策略欄位名稱要穩定">【策略】欄位名稱要穩定</h2>
<p>log 欄位設計的核心規則是：同一個概念使用同一個欄位名稱，不要在不同地方混用別名。</p>
<table>
  <thead>
      <tr>
          <th>概念</th>
          <th>建議欄位</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>使用者 ID</td>
          <td><code>userID</code></td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/request-id/" data-link-title="Request ID" data-link-desc="說明單次 request 的識別碼如何支援 log 搜尋與問題定位">request ID</a></td>
          <td><code>requestID</code></td>
      </tr>
      <tr>
          <td>工作 ID</td>
          <td><code>jobID</code></td>
      </tr>
      <tr>
          <td>元件名稱</td>
          <td><code>component</code></td>
      </tr>
      <tr>
          <td>錯誤</td>
          <td><code>error</code></td>
      </tr>
  </tbody>
</table>
<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="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;job queued&#34;</span><span class="p">,</span> <span class="s">&#34;id&#34;</span><span class="p">,</span> <span class="nx">job</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;job started&#34;</span><span class="p">,</span> <span class="s">&#34;job_id&#34;</span><span class="p">,</span> <span class="nx">job</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;job done&#34;</span><span class="p">,</span> <span class="s">&#34;jobID&#34;</span><span class="p">,</span> <span class="nx">job</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span></span></span></code></pre></div><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="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;job queued&#34;</span><span class="p">,</span> <span class="s">&#34;jobID&#34;</span><span class="p">,</span> <span class="nx">job</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;job started&#34;</span><span class="p">,</span> <span class="s">&#34;jobID&#34;</span><span class="p">,</span> <span class="nx">job</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;job done&#34;</span><span class="p">,</span> <span class="s">&#34;jobID&#34;</span><span class="p">,</span> <span class="nx">job</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span></span></span></code></pre></div><p>欄位穩定後，grep、log query、<a href="/blog/backend/knowledge-cards/dashboard/" data-link-title="Dashboard" data-link-desc="說明 dashboard 如何把關鍵訊號組成可判讀的服務狀態畫面">dashboard</a> 才能可靠。</p>
<h2 id="執行建立帶預設欄位的-logger">【執行】建立帶預設欄位的 logger</h2>
<p>預設欄位的核心規則是：每筆 log 都需要的上下文，應該掛在 logger 上，而不是每次手動重複。</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="nx">base</span> <span class="o">:=</span> <span class="nx">slog</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="nx">slog</span><span class="p">.</span><span class="nf">NewJSONHandler</span><span class="p">(</span><span class="nx">os</span><span class="p">.</span><span class="nx">Stdout</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">slog</span><span class="p">.</span><span class="nx">HandlerOptions</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">Level</span><span class="p">:</span> <span class="nx">slog</span><span class="p">.</span><span class="nx">LevelInfo</span><span class="p">,</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="nx">logger</span> <span class="o">:=</span> <span class="nx">base</span><span class="p">.</span><span class="nf">With</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="s">&#34;component&#34;</span><span class="p">,</span> <span class="s">&#34;worker&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="s">&#34;version&#34;</span><span class="p">,</span> <span class="s">&#34;1.0.0&#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></span><span class="line"><span class="ln">10</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;job started&#34;</span><span class="p">,</span> <span class="s">&#34;jobID&#34;</span><span class="p">,</span> <span class="s">&#34;j_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;job finished&#34;</span><span class="p">,</span> <span class="s">&#34;jobID&#34;</span><span class="p">,</span> <span class="s">&#34;j_1&#34;</span><span class="p">)</span></span></span></code></pre></div><p><code>With</code> 會回傳帶有固定欄位的新 logger。這適合 component、version、requestID 這類上下文。</p>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="把所有資訊塞進-msg">把所有資訊塞進 msg</h3>
<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="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;job j_1 for user u_1 started&#34;</span><span class="p">)</span></span></span></code></pre></div><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="nx">logger</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="s">&#34;job started&#34;</span><span class="p">,</span> <span class="s">&#34;jobID&#34;</span><span class="p">,</span> <span class="s">&#34;j_1&#34;</span><span class="p">,</span> <span class="s">&#34;userID&#34;</span><span class="p">,</span> <span class="s">&#34;u_1&#34;</span><span class="p">)</span></span></span></code></pre></div><p>第二種寫法可以直接查 <code>jobID=j_1</code> 或 <code>userID=u_1</code>。</p>
<h3 id="欄位名稱不穩定">欄位名稱不穩定</h3>
<p>欄位名稱不穩定會讓查詢失效。選定 <code>userID</code> 就一路使用 <code>userID</code>，不要混用 <code>uid</code>、<code>user_id</code>、<code>user</code>。</p>
<h3 id="忽略敏感資訊">忽略敏感資訊</h3>
<p>log 會被保存與轉發。密碼、token、完整信用卡號等敏感資訊不應寫入 log。</p>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>本章只介紹標準庫 <code>log/slog</code> 的基本用法。服務開始有 domain event、state repository 或查詢需求時，可以接著閱讀 <a href="/blog/go/06-practical/structured-recording/" data-link-title="6.5 如何新增結構化記錄欄位" data-link-desc="區分 operational log、domain event log 與狀態資料">如何新增結構化記錄欄位</a>；進入生產操作後，再閱讀 <a href="/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">Go 進階：結構化日誌欄位設計</a> 與 <a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Observability pipeline、metrics 與 tracing</a>。</p>
]]></content:encoded></item><item><title>5.6 並發行為測試</title><link>https://tarrragon.github.io/blog/go/05-error-testing/concurrency-test/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/05-error-testing/concurrency-test/</guid><description>&lt;p>並發測試的核心目標是驗證可觀察的同步行為，而不是猜測 goroutine 的執行順序。Go 的 goroutine 由 scheduler 安排，測試應該用 channel、context、WaitGroup 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a> 表達「什麼結果必須發生」。&lt;/p>
&lt;h2 id="並發測試應等待明確訊號">並發測試應等待明確訊號&lt;/h2>
&lt;p>並發程式的核心限制是執行順序不穩定。測試如果假設某個 goroutine 一定先跑，通常會變成偶發失敗。&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">sendAsync&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ch&lt;/span> &lt;span class="kd">chan&lt;/span>&lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="kt">string&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="nx">ch&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="s">&amp;#34;ready&amp;#34;&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>測試不應該在呼叫後立刻假設資料已經送出，而應該等待明確訊號。&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">TestSendAsync&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">ch&lt;/span> &lt;span class="o">:=&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="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">1&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nf">sendAsync&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ch&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ch&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">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s">&amp;#34;ready&amp;#34;&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="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;message = %q, want %q&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;ready&amp;#34;&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="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="o">&amp;lt;-&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">After&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Second&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">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;timeout waiting for message&amp;#34;&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="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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>select&lt;/code> 加 timeout 可以避免測試永久卡住。timeout 不應該用來證明程式正確，只是測試失敗時的保護機制。&lt;/p>
&lt;h2 id="channel-測試要驗證傳遞結果">channel 測試要驗證傳遞結果&lt;/h2>
&lt;p>channel 測試的核心問題是資料是否被送到預期位置。測試應該觀察 channel 收到的值，或觀察 channel 關閉後的狀態。&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">Produce&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ids&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="kt">string&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">out&lt;/span> &lt;span class="o">:=&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="kt">string&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&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"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nb">close&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">out&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">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">id&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">ids&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="nx">out&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">id&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 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>&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="nx">out&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>這個函式回傳只讀 channel，呼叫端可以 range 讀取直到 channel 關閉。&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">TestProduce&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">out&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">Produce&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s">&amp;#34;a&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;b&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="p">[]&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="k">for&lt;/span> &lt;span class="nx">id&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">out&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">got&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">got&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"> 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="nx">want&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s">&amp;#34;a&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;b&amp;#34;&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">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">reflect&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">DeepEqual&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&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">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Produce() = %#v, want %#v&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&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>這個測試沒有使用 sleep。channel 關閉就是明確完成訊號，測試可以自然結束。&lt;/p>
&lt;h2 id="context-用來測試退出">context 用來測試退出&lt;/h2>
&lt;p>goroutine 退出測試的核心做法是提供可取消的 &lt;code>context.Context&lt;/code>，再等待函式發出完成訊號。沒有退出訊號的 goroutine 很難可靠測試。&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">RunWorker&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">jobs&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">done&lt;/span> &lt;span class="kd">chan&lt;/span>&lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="kd">struct&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="nb">close&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">done&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&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"> 5&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"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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">return&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">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">jobs&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="c1">// process job&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &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="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;/code>&lt;/pre>&lt;/div>&lt;p>測試可以取消 context，然後確認 &lt;code>done&lt;/code> 被關閉。&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">TestRunWorkerStops&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&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">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cancel&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WithCancel&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Background&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">jobs&lt;/span> &lt;span class="o">:=&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="kt">string&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">done&lt;/span> &lt;span class="o">:=&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="kd">struct&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">go&lt;/span> &lt;span class="nf">RunWorker&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">jobs&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">done&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="nf">cancel&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&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">10&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">done&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="o">&amp;lt;-&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">After&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Second&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">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;worker did not stop&amp;#34;&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="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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>done&lt;/code> channel 是測試與 goroutine 之間的完成協定。若程式沒有這種協定，測試只能猜測 goroutine 是否已經退出。&lt;/p>
&lt;h2 id="syncwaitgroup-適合等待一組工作完成">&lt;code>sync.WaitGroup&lt;/code> 適合等待一組工作完成&lt;/h2>
&lt;p>&lt;code>WaitGroup&lt;/code> 的核心用途是等待已知數量的 goroutine 完成。它適合 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out&lt;/a> 工作、批次處理與測試中需要等多個背景任務結束的情境。&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">ProcessAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">items&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">process&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="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="kd">var&lt;/span> &lt;span class="nx">wg&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">WaitGroup&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">item&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">items&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="nx">item&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">item&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">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&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">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"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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="nf">process&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">item&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="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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Wait&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;/code>&lt;/pre>&lt;/div>&lt;p>測試可以用 mutex 保護共享 slice，並在函式回傳後檢查結果。&lt;/p></description><content:encoded><![CDATA[<p>並發測試的核心目標是驗證可觀察的同步行為，而不是猜測 goroutine 的執行順序。Go 的 goroutine 由 scheduler 安排，測試應該用 channel、context、WaitGroup 與 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 表達「什麼結果必須發生」。</p>
<h2 id="並發測試應等待明確訊號">並發測試應等待明確訊號</h2>
<p>並發程式的核心限制是執行順序不穩定。測試如果假設某個 goroutine 一定先跑，通常會變成偶發失敗。</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">sendAsync</span><span class="p">(</span><span class="nx">ch</span> <span class="kd">chan</span><span class="o">&lt;-</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="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="nx">ch</span> <span class="o">&lt;-</span> <span class="s">&#34;ready&#34;</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>測試不應該在呼叫後立刻假設資料已經送出，而應該等待明確訊號。</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">TestSendAsync</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">ch</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="kt">string</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</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="nf">sendAsync</span><span class="p">(</span><span class="nx">ch</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="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">got</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">ch</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="s">&#34;ready&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</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;message = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="s">&#34;ready&#34;</span><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="k">case</span> <span class="o">&lt;-</span><span class="nx">time</span><span class="p">.</span><span class="nf">After</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">12</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;timeout waiting for message&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>select</code> 加 timeout 可以避免測試永久卡住。timeout 不應該用來證明程式正確，只是測試失敗時的保護機制。</p>
<h2 id="channel-測試要驗證傳遞結果">channel 測試要驗證傳遞結果</h2>
<p>channel 測試的核心問題是資料是否被送到預期位置。測試應該觀察 channel 收到的值，或觀察 channel 關閉後的狀態。</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">Produce</span><span class="p">(</span><span class="nx">ids</span> <span class="p">[]</span><span class="kt">string</span><span class="p">)</span> <span class="o">&lt;-</span><span class="kd">chan</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">out</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="kt">string</span><span class="p">)</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="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">defer</span> <span class="nb">close</span><span class="p">(</span><span class="nx">out</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">_</span><span class="p">,</span> <span class="nx">id</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">ids</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nx">out</span> <span class="o">&lt;-</span> <span class="nx">id</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="p">}()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">return</span> <span class="nx">out</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個函式回傳只讀 channel，呼叫端可以 range 讀取直到 channel 關閉。</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">TestProduce</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">out</span> <span class="o">:=</span> <span class="nf">Produce</span><span class="p">([]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;a&#34;</span><span class="p">,</span> <span class="s">&#34;b&#34;</span><span class="p">})</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="kd">var</span> <span class="nx">got</span> <span class="p">[]</span><span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">for</span> <span class="nx">id</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">out</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">got</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">got</span><span class="p">,</span> <span class="nx">id</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></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">want</span> <span class="o">:=</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;a&#34;</span><span class="p">,</span> <span class="s">&#34;b&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">reflect</span><span class="p">.</span><span class="nf">DeepEqual</span><span class="p">(</span><span class="nx">got</span><span class="p">,</span> <span class="nx">want</span><span class="p">)</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;Produce() = %#v, want %#v&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">want</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>這個測試沒有使用 sleep。channel 關閉就是明確完成訊號，測試可以自然結束。</p>
<h2 id="context-用來測試退出">context 用來測試退出</h2>
<p>goroutine 退出測試的核心做法是提供可取消的 <code>context.Context</code>，再等待函式發出完成訊號。沒有退出訊號的 goroutine 很難可靠測試。</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">RunWorker</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">jobs</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">done</span> <span class="kd">chan</span><span class="o">&lt;-</span> <span class="kd">struct</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="nb">close</span><span class="p">(</span><span class="nx">done</span><span class="p">)</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="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">jobs</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="c1">// process job</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><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>測試可以取消 context，然後確認 <code>done</code> 被關閉。</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">TestRunWorkerStops</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">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithCancel</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">jobs</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="kt">string</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">done</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</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="k">go</span> <span class="nf">RunWorker</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">jobs</span><span class="p">,</span> <span class="nx">done</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nf">cancel</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">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">done</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="o">&lt;-</span><span class="nx">time</span><span class="p">.</span><span class="nf">After</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">12</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;worker did not stop&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>done</code> channel 是測試與 goroutine 之間的完成協定。若程式沒有這種協定，測試只能猜測 goroutine 是否已經退出。</p>
<h2 id="syncwaitgroup-適合等待一組工作完成"><code>sync.WaitGroup</code> 適合等待一組工作完成</h2>
<p><code>WaitGroup</code> 的核心用途是等待已知數量的 goroutine 完成。它適合 <a href="/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out</a> 工作、批次處理與測試中需要等多個背景任務結束的情境。</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">ProcessAll</span><span class="p">(</span><span class="nx">items</span> <span class="p">[]</span><span class="kt">string</span><span class="p">,</span> <span class="nx">process</span> <span class="kd">func</span><span class="p">(</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="kd">var</span> <span class="nx">wg</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">WaitGroup</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="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">item</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">items</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">item</span> <span class="o">:=</span> <span class="nx">item</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">wg</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</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"> 8</span><span class="cl">            <span class="k">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="nf">process</span><span class="p">(</span><span class="nx">item</span><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><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="nx">wg</span><span class="p">.</span><span class="nf">Wait</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>測試可以用 mutex 保護共享 slice，並在函式回傳後檢查結果。</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">TestProcessAll</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="kd">var</span> <span class="nx">mu</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">Mutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="kd">var</span> <span class="nx">got</span> <span class="p">[]</span><span class="kt">string</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="nf">ProcessAll</span><span class="p">([]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;a&#34;</span><span class="p">,</span> <span class="s">&#34;b&#34;</span><span class="p">},</span> <span class="kd">func</span><span class="p">(</span><span class="nx">item</span> <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <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"> 7</span><span class="cl">        <span class="k">defer</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"> 8</span><span class="cl">        <span class="nx">got</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">got</span><span class="p">,</span> <span class="nx">item</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></span><span class="line"><span class="ln">11</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">got</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">want</span> <span class="o">:=</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;a&#34;</span><span class="p">,</span> <span class="s">&#34;b&#34;</span><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">reflect</span><span class="p">.</span><span class="nf">DeepEqual</span><span class="p">(</span><span class="nx">got</span><span class="p">,</span> <span class="nx">want</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</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;processed = %#v, want %#v&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">want</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>因為 goroutine 執行順序不固定，測試先排序再比較。這表示測試關心「所有項目都有被處理」，不關心處理順序。</p>
<h2 id="race-detector-檢查共享狀態">race detector 檢查共享狀態</h2>
<p>共享狀態測試的核心風險是 data race。Go 提供 race detector，可以在測試時檢查多個 goroutine 是否未同步讀寫同一份資料。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go <span class="nb">test</span> -race ./...</span></span></code></pre></div><p><code>-race</code> 會讓測試變慢，但能抓出許多一般斷言看不見的並發錯誤。只要程式有 goroutine 與共享資料，定期跑 race test 就很有價值。</p>
<p>race detector 不是邏輯正確性的完整證明。它能檢查資料競爭，但不能保證事件順序、<a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 策略或 timeout 行為都符合需求；這些仍然要靠明確測試案例。</p>
]]></content:encoded></item><item><title>6.6 如何新增 repository port</title><link>https://tarrragon.github.io/blog/go/06-practical/repository-port/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/06-practical/repository-port/</guid><description>&lt;p>新增 repository port 的核心目標是讓 application layer 依賴資料能力，而不是依賴具體儲存技術。先建立 port，才能在 memory、SQLite 或其他資料庫之間替換。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷何時需要 repository port&lt;/li>
&lt;li>由 usecase 定義小而明確的 repository interface&lt;/li>
&lt;li>實作 map + mutex 的 in-memory repository&lt;/li>
&lt;li>保留 context、error 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database&lt;/a>-ready 邊界&lt;/li>
&lt;li>分開撰寫 usecase fake test 與 repository contract test&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察repository-port-表達資料能力">【觀察】repository port 表達資料能力&lt;/h2>
&lt;p>repository port 的核心語意是 usecase 需要哪些資料能力。它應描述 application layer 的讀寫需求，而不是照搬資料庫 CRUD 方法或把所有資料操作塞進同一個巨大 &lt;code>Repository&lt;/code>。&lt;/p>
&lt;p>例如通知服務的 usecase 可能需要三種能力：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>usecase 需求&lt;/th>
 &lt;th>repository 能力&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>建立通知&lt;/td>
 &lt;td>儲存一筆 notification&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>查詢 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> 通知&lt;/td>
 &lt;td>依 topic 列出 notification&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>避免重複建立&lt;/td>
 &lt;td>依 ID 查詢 notification 是否存在&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這些能力可以先用 memory 實作，未來再換成 SQLite、PostgreSQL 或外部服務。usecase 不應知道底層儲存技術。&lt;/p>
&lt;h2 id="判讀repository-服務共享讀寫邊界">【判讀】repository 服務共享讀寫邊界&lt;/h2>
&lt;p>是否需要 repository 的核心判斷是資料是否需要一致的讀寫邊界。暫時變數、單次函式內部結果或不共享的 cache，不一定需要 repository。&lt;/p>
&lt;p>適合 repository 的情境：&lt;/p>
&lt;ul>
&lt;li>多個 usecase 需要一致讀寫同一組資料&lt;/li>
&lt;li>資料需要被測試替身取代&lt;/li>
&lt;li>讀寫規則需要集中&lt;/li>
&lt;li>未來可能從 memory 換成資料庫&lt;/li>
&lt;li>需要保護 map、slice 或 pointer 不被外部修改&lt;/li>
&lt;/ul>
&lt;p>不一定需要 repository 的情境：&lt;/p>
&lt;ul>
&lt;li>只在單一函式內暫存&lt;/li>
&lt;li>只是純計算結果&lt;/li>
&lt;li>還沒有共享或替換需求&lt;/li>
&lt;li>只有一個簡單 struct 可以直接傳遞&lt;/li>
&lt;/ul>
&lt;p>repository 是邊界工具，不是成熟專案的儀式。過早建立 repository 會讓小程式變得難讀。&lt;/p>
&lt;h2 id="策略interface-放在使用端">【策略】interface 放在使用端&lt;/h2>
&lt;p>repository interface 的核心規則是由使用端定義需要的能力。usecase 需要什麼，就在 usecase 所在 package 定義什麼；adapter 或 infrastructure 實作它。&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">NotificationRepository&lt;/span> &lt;span class="kd">interface&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="nf">Save&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">notification&lt;/span> &lt;span class="nx">Notification&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nf">FindByID&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">id&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">Notification&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">bool&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">4&lt;/span>&lt;span class="cl"> &lt;span class="nf">ListByTopic&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">topic&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">([]&lt;/span>&lt;span class="nx">Notification&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>方法名稱應該表達業務能力，而不是資料庫操作細節。&lt;code>ListByTopic&lt;/code> 比 &lt;code>SelectWhereTopicEquals&lt;/code> 更適合 usecase。&lt;/p>
&lt;p>先定義 domain model：&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">Notification&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">Topic&lt;/span> &lt;span class="kt">string&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">Title&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">CreatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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;p>domain model 不需要 JSON tag。對外 JSON 格式應交給 response struct，repository 儲存的是內部資料。&lt;/p>
&lt;h2 id="執行usecase-依賴-port不依賴-implementation">【執行】usecase 依賴 port，不依賴 implementation&lt;/h2>
&lt;p>usecase 的核心責任是協調資料能力與行為規則。它接收 repository port，而不是具體 memory repository。&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">CreateNotificationCommand&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">Topic&lt;/span> &lt;span class="kt">string&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">Title&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">CreatedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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">type&lt;/span> &lt;span class="nx">NotificationService&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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">repository&lt;/span> &lt;span class="nx">NotificationRepository&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&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>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">NewNotificationService&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">repository&lt;/span> &lt;span class="nx">NotificationRepository&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">NotificationService&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="o">&amp;amp;&lt;/span>&lt;span class="nx">NotificationService&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">repository&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">repository&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;/code>&lt;/pre>&lt;/div>&lt;p>建立通知時，usecase 可以檢查重複與必填欄位：&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">s&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">NotificationService&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Create&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">cmd&lt;/span> &lt;span class="nx">CreateNotificationCommand&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"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">if&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">cmd&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">)&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"> 3&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;notification id is required&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="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">if&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">cmd&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">)&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"> 6&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"> 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="k">if&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">exists&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">repository&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">FindByID&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">cmd&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&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">10&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;find notification: %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">11&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="nx">exists&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">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;notification %s already exists&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cmd&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">notification&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">Notification&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="nx">ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">cmd&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">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">Topic&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">cmd&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">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">Title&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">cmd&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Title&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="nx">CreatedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">cmd&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">CreatedAt&lt;/span>&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 class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&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">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">repository&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Save&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">notification&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">23&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;save notification: %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">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="kc">nil&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>這段 usecase 不知道資料存在 map、SQLite 或遠端 API。它只依賴「可以查詢與儲存 notification」這個能力。&lt;/p></description><content:encoded><![CDATA[<p>新增 repository port 的核心目標是讓 application layer 依賴資料能力，而不是依賴具體儲存技術。先建立 port，才能在 memory、SQLite 或其他資料庫之間替換。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷何時需要 repository port</li>
<li>由 usecase 定義小而明確的 repository interface</li>
<li>實作 map + mutex 的 in-memory repository</li>
<li>保留 context、error 與 <a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a>-ready 邊界</li>
<li>分開撰寫 usecase fake test 與 repository contract test</li>
</ol>
<hr>
<h2 id="觀察repository-port-表達資料能力">【觀察】repository port 表達資料能力</h2>
<p>repository port 的核心語意是 usecase 需要哪些資料能力。它應描述 application layer 的讀寫需求，而不是照搬資料庫 CRUD 方法或把所有資料操作塞進同一個巨大 <code>Repository</code>。</p>
<p>例如通知服務的 usecase 可能需要三種能力：</p>
<table>
  <thead>
      <tr>
          <th>usecase 需求</th>
          <th>repository 能力</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>建立通知</td>
          <td>儲存一筆 notification</td>
      </tr>
      <tr>
          <td>查詢 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> 通知</td>
          <td>依 topic 列出 notification</td>
      </tr>
      <tr>
          <td>避免重複建立</td>
          <td>依 ID 查詢 notification 是否存在</td>
      </tr>
  </tbody>
</table>
<p>這些能力可以先用 memory 實作，未來再換成 SQLite、PostgreSQL 或外部服務。usecase 不應知道底層儲存技術。</p>
<h2 id="判讀repository-服務共享讀寫邊界">【判讀】repository 服務共享讀寫邊界</h2>
<p>是否需要 repository 的核心判斷是資料是否需要一致的讀寫邊界。暫時變數、單次函式內部結果或不共享的 cache，不一定需要 repository。</p>
<p>適合 repository 的情境：</p>
<ul>
<li>多個 usecase 需要一致讀寫同一組資料</li>
<li>資料需要被測試替身取代</li>
<li>讀寫規則需要集中</li>
<li>未來可能從 memory 換成資料庫</li>
<li>需要保護 map、slice 或 pointer 不被外部修改</li>
</ul>
<p>不一定需要 repository 的情境：</p>
<ul>
<li>只在單一函式內暫存</li>
<li>只是純計算結果</li>
<li>還沒有共享或替換需求</li>
<li>只有一個簡單 struct 可以直接傳遞</li>
</ul>
<p>repository 是邊界工具，不是成熟專案的儀式。過早建立 repository 會讓小程式變得難讀。</p>
<h2 id="策略interface-放在使用端">【策略】interface 放在使用端</h2>
<p>repository interface 的核心規則是由使用端定義需要的能力。usecase 需要什麼，就在 usecase 所在 package 定義什麼；adapter 或 infrastructure 實作它。</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">NotificationRepository</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">Save</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">notification</span> <span class="nx">Notification</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="nf">FindByID</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">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nf">ListByTopic</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">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="p">([]</span><span class="nx">Notification</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="p">}</span></span></span></code></pre></div><p>方法名稱應該表達業務能力，而不是資料庫操作細節。<code>ListByTopic</code> 比 <code>SelectWhereTopicEquals</code> 更適合 usecase。</p>
<p>先定義 domain model：</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">Notification</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">Topic</span>     <span class="kt">string</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Title</span>     <span class="kt">string</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">CreatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>domain model 不需要 JSON tag。對外 JSON 格式應交給 response struct，repository 儲存的是內部資料。</p>
<h2 id="執行usecase-依賴-port不依賴-implementation">【執行】usecase 依賴 port，不依賴 implementation</h2>
<p>usecase 的核心責任是協調資料能力與行為規則。它接收 repository port，而不是具體 memory repository。</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">CreateNotificationCommand</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">Topic</span>     <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">Title</span>     <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">CreatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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">type</span> <span class="nx">NotificationService</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">repository</span> <span class="nx">NotificationRepository</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></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">func</span> <span class="nf">NewNotificationService</span><span class="p">(</span><span class="nx">repository</span> <span class="nx">NotificationRepository</span><span class="p">)</span> <span class="o">*</span><span class="nx">NotificationService</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="o">&amp;</span><span class="nx">NotificationService</span><span class="p">{</span><span class="nx">repository</span><span class="p">:</span> <span class="nx">repository</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>建立通知時，usecase 可以檢查重複與必填欄位：</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">s</span> <span class="o">*</span><span class="nx">NotificationService</span><span class="p">)</span> <span class="nf">Create</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">cmd</span> <span class="nx">CreateNotificationCommand</span><span class="p">)</span> <span class="kt">error</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">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">cmd</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</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;notification id is required&#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">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">cmd</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</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="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"> 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">_</span><span class="p">,</span> <span class="nx">exists</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">s</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">FindByID</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">cmd</span><span class="p">.</span><span class="nx">ID</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 class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;find notification: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="nx">exists</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">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;notification %s already exists&#34;</span><span class="p">,</span> <span class="nx">cmd</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="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">notification</span> <span class="o">:=</span> <span class="nx">Notification</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="nx">cmd</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</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">18</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="nx">cmd</span><span class="p">.</span><span class="nx">Title</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">cmd</span><span class="p">.</span><span class="nx">CreatedAt</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></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">s</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">notification</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">23</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;save notification: %w&#34;</span><span class="p">,</span> <span class="nx">err</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="kc">nil</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段 usecase 不知道資料存在 map、SQLite 或遠端 API。它只依賴「可以查詢與儲存 notification」這個能力。</p>
<h2 id="執行memory-implementation-要保護-map">【執行】memory implementation 要保護 map</h2>
<p>in-memory repository 的核心責任是提供可用的儲存實作，同時保護共享 map。只要 repository 可能被多個 goroutine 使用，就應該用 mutex 保護。</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">InMemoryNotificationRepository</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">mu</span>            <span class="nx">sync</span><span class="p">.</span><span class="nx">RWMutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">notifications</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">Notification</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="kd">func</span> <span class="nf">NewInMemoryNotificationRepository</span><span class="p">()</span> <span class="o">*</span><span class="nx">InMemoryNotificationRepository</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="o">&amp;</span><span class="nx">InMemoryNotificationRepository</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">notifications</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="nx">Notification</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="p">}</span></span></span></code></pre></div><p><code>Save</code> 寫入時要複製資料：</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">r</span> <span class="o">*</span><span class="nx">InMemoryNotificationRepository</span><span class="p">)</span> <span class="nf">Save</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">notification</span> <span class="nx">Notification</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">r</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">3</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</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">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">r</span><span class="p">.</span><span class="nx">notifications</span><span class="p">[</span><span class="nx">notification</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">notification</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>FindByID</code> 回傳值與 bool：</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">r</span> <span class="o">*</span><span class="nx">InMemoryNotificationRepository</span><span class="p">)</span> <span class="nf">FindByID</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">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</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">r</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">r</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">notification</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">notifications</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 6</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"> 7</span><span class="cl">        <span class="k">return</span> <span class="nx">Notification</span><span class="p">{},</span> <span class="kc">false</span><span class="p">,</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><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="nx">notification</span><span class="p">,</span> <span class="kc">true</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>ListByTopic</code> 回傳新 slice，避免呼叫端修改內部資料：</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">r</span> <span class="o">*</span><span class="nx">InMemoryNotificationRepository</span><span class="p">)</span> <span class="nf">ListByTopic</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">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="p">([]</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">error</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">r</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">r</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">result</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="nx">Notification</span><span class="p">,</span> <span class="mi">0</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">_</span><span class="p">,</span> <span class="nx">notification</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">r</span><span class="p">.</span><span class="nx">notifications</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">if</span> <span class="nx">notification</span><span class="p">.</span><span class="nx">Topic</span> <span class="o">==</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="nx">result</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">result</span><span class="p">,</span> <span class="nx">notification</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="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">return</span> <span class="nx">result</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>目前 <code>Notification</code> 只有值型別欄位，所以回傳值與新 slice 已足夠。若未來加上 slice、map 或 pointer 欄位，就要補 clone 函式。</p>
<h2 id="策略context-和-error-是未來資料庫邊界">【策略】context 和 error 是未來資料庫邊界</h2>
<p>repository method 接收 context 的核心原因是未來可能出現 I/O。memory 實作可能不使用 context，但資料庫查詢、遠端 API 或 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 會需要取消與逾時。</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="nf">Save</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">notification</span> <span class="nx">Notification</span><span class="p">)</span> <span class="kt">error</span></span></span></code></pre></div><p>error wrapping 的核心規則是保留失敗位置與原始錯誤：</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="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">s</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">notification</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">2</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;save notification: %w&#34;</span><span class="p">,</span> <span class="nx">err</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>不要過早抽象 transaction。只有當一個 usecase 真的需要多筆寫入同時成功或失敗時，再設計 <a href="/blog/backend/knowledge-cards/transaction-boundary/" data-link-title="Transaction Boundary" data-link-desc="說明哪些資料變更應在同一個交易中一起成功或一起回復">transaction boundary</a>。否則 repository interface 會提前背負資料庫細節。</p>
<h2 id="判讀fake-和-in-memory-用於不同測試">【判讀】fake 和 in-memory 用於不同測試</h2>
<p>測試替身的核心差異是 fake 服務 usecase 測試，in-memory implementation 服務 repository 行為測試。兩者可以長得像，但用途不同。</p>
<p>usecase 測試可以用簡單 fake：</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">fakeNotificationRepository</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">existing</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">Notification</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">saved</span>    <span class="p">[]</span><span class="nx">Notification</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">err</span>      <span class="kt">error</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">f</span> <span class="o">*</span><span class="nx">fakeNotificationRepository</span><span class="p">)</span> <span class="nf">Save</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">notification</span> <span class="nx">Notification</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="k">if</span> <span class="nx">f</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"> 9</span><span class="cl">        <span class="k">return</span> <span class="nx">f</span><span class="p">.</span><span class="nx">err</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="nx">f</span><span class="p">.</span><span class="nx">saved</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">f</span><span class="p">.</span><span class="nx">saved</span><span class="p">,</span> <span class="nx">notification</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="kc">nil</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="o">*</span><span class="nx">fakeNotificationRepository</span><span class="p">)</span> <span class="nf">FindByID</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">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">if</span> <span class="nx">f</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="k">return</span> <span class="nx">Notification</span><span class="p">{},</span> <span class="kc">false</span><span class="p">,</span> <span class="nx">f</span><span class="p">.</span><span class="nx">err</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="nx">notification</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">f</span><span class="p">.</span><span class="nx">existing</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">return</span> <span class="nx">notification</span><span class="p">,</span> <span class="nx">ok</span><span class="p">,</span> <span class="kc">nil</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></span><span class="line"><span class="ln">23</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="o">*</span><span class="nx">fakeNotificationRepository</span><span class="p">)</span> <span class="nf">ListByTopic</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">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="p">([]</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</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="kc">nil</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 fake 只支援測試需要的行為，不必成為完整 repository。</p>
<h2 id="執行usecase-測試檢查行為規則">【執行】usecase 測試檢查行為規則</h2>
<p>usecase 測試的核心目標是驗證 command 進來後是否呼叫正確資料能力。它不應測 memory map 的 lock 或 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="nf">TestNotificationServiceCreate</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">repo</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeNotificationRepository</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">existing</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="nx">Notification</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="nx">service</span> <span class="o">:=</span> <span class="nf">NewNotificationService</span><span class="p">(</span><span class="nx">repo</span><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="nx">err</span> <span class="o">:=</span> <span class="nx">service</span><span class="p">.</span><span class="nf">Create</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">CreateNotificationCommand</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>     <span class="s">&#34;deployments&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="s">&#34;Deploy finished&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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="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">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;create notification: %v&#34;</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></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">repo</span><span class="p">.</span><span class="nx">saved</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</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;saved notifications = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">repo</span><span class="p">.</span><span class="nx">saved</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></code></pre></div><p>重複 ID 測試可以讓 fake 回傳 existing notification：</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">TestNotificationServiceCreateDuplicate</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">repo</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeNotificationRepository</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">existing</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="nx">Notification</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="s">&#34;ntf_1&#34;</span><span class="p">:</span> <span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="s">&#34;ntf_1&#34;</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 class="nx">service</span> <span class="o">:=</span> <span class="nf">NewNotificationService</span><span class="p">(</span><span class="nx">repo</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="nx">err</span> <span class="o">:=</span> <span class="nx">service</span><span class="p">.</span><span class="nf">Create</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">CreateNotificationCommand</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="s">&#34;ntf_1&#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="s">&#34;deployments&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="s">&#34;Deploy finished&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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">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">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;expected duplicate error&#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>這些測試讓 usecase 不依賴具體 repository implementation。</p>
<h2 id="執行repository-contract-test-保護實作行為">【執行】repository contract test 保護實作行為</h2>
<p>repository contract test 的核心目標是讓不同 implementation 遵守同一組行為。memory repository、SQLite repository 或其他實作都可以跑同一套測試。</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">TestNotificationRepositoryContract</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="nf">runNotificationRepositoryContract</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">NotificationRepository</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">return</span> <span class="nf">NewInMemoryNotificationRepository</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="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="nf">runNotificationRepositoryContract</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="nx">newRepo</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">NotificationRepository</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Helper</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="nx">repo</span> <span class="o">:=</span> <span class="nf">newRepo</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">ctx</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">notification</span> <span class="o">:=</span> <span class="nx">Notification</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>     <span class="s">&#34;deployments&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="s">&#34;Deploy finished&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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></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">repo</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">notification</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="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;save notification: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><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></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="nx">got</span><span class="p">,</span> <span class="nx">ok</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">FindByID</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="s">&#34;ntf_1&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</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">25</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;find notification: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">27</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">28</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;notification should exist&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="k">if</span> <span class="nx">got</span><span class="p">.</span><span class="nx">Topic</span> <span class="o">!=</span> <span class="s">&#34;deployments&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">31</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;topic = %q, want deployments&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="nx">list</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">ListByTopic</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="s">&#34;deployments&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">35</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">36</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;list notifications: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">list</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">39</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;notifications = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">list</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>contract test 不需要知道實作細節。它只驗證 port 承諾的行為。</p>
<h2 id="策略小介面比萬用-repository-穩定">【策略】小介面比萬用 repository 穩定</h2>
<p>repository interface 的核心風險是變成所有資料操作的大型介面。大型介面會讓每個 usecase、fake 與測試都被迫依賴不需要的方法。</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">type</span> <span class="nx">Repository</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">SaveNotification</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">notification</span> <span class="nx">Notification</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="nf">ListNotifications</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="p">([]</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nf">SaveJob</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">job</span> <span class="nx">JobProjection</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nf">SaveAccount</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">account</span> <span class="nx">Account</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nf">AppendEvent</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">event</span> <span class="nx">DomainEvent</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><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">type</span> <span class="nx">NotificationRepository</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">Save</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">notification</span> <span class="nx">Notification</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="nf">FindByID</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">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nf">ListByTopic</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">topic</span> <span class="kt">string</span><span class="p">)</span> <span class="p">([]</span><span class="nx">Notification</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="p">}</span></span></span></code></pre></div><p>不同 usecase 可以定義不同 port。若未來多個 port 由同一個 database adapter 實作，那是 adapter 的事，不必讓 usecase 共享巨大介面。</p>
<h2 id="實作檢查清單">實作檢查清單</h2>
<p>新增 repository port 時，可以依序檢查：</p>
<ol>
<li>是否真的需要共享讀寫邊界</li>
<li>interface 是否由 usecase 需要定義</li>
<li>方法名稱是否表達業務能力</li>
<li>方法是否接收 <code>context.Context</code></li>
<li>error 是否被包上操作脈絡</li>
<li>in-memory implementation 是否保護 map</li>
<li>回傳 slice/map/pointer 時是否有 copy boundary</li>
<li>usecase 測試是否使用 fake</li>
<li>repository implementation 是否跑 contract test</li>
<li>是否避免把所有資料操作塞進巨大介面</li>
</ol>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一repository-來自-usecase-需求">檢查一：repository 來自 usecase 需求</h3>
<p>repository 應該來自 usecase 需求。先建一個萬用 <code>Repository</code>，通常會讓介面快速膨脹，測試也變得笨重。</p>
<h3 id="檢查二interface-放在使用端">檢查二：interface 放在使用端</h3>
<p>若 interface 是由 implementation 定義，usecase 會被迫接受 implementation 想暴露的能力。Go 更常見的做法是讓使用端定義最小需求。</p>
<h3 id="檢查三memory-repository-保護內部資料">檢查三：memory repository 保護內部資料</h3>
<p>回傳內部 slice、map 或 pointer 會讓呼叫端繞過 repository 規則。即使目前只是 memory 實作，也應保護資料擁有權。</p>
<h3 id="檢查四transaction-和-orm-等需求出現再抽象">檢查四：transaction 和 ORM 等需求出現再抽象</h3>
<p>沒有跨多筆寫入一致性需求時，transaction 介面只會增加複雜度。先把 context、error、port 邊界放好，等需求出現再擴展。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 repository port 如何表達資料能力；特定資料庫、ORM 與 transaction，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">Go 進階：資料庫 transaction 與 schema migration</a></li>
<li><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫與持久化</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 interface、state 與 application boundary；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</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/06-practical/state-fields/" data-link-title="6.3 如何擴展狀態投影欄位" data-link-desc="更新狀態模型、repository 與 API 輸出">Go：如何擴展狀態投影欄位</a></li>
</ul>
]]></content:encoded></item><item><title>7.6 CI、fuzz、load test 與 chaos testing</title><link>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/reliability-pipeline/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/reliability-pipeline/</guid><description>&lt;p>可靠性驗證流程的核心責任是讓不同層級的測試回答不同風險。Unit test 驗證規則，integration test 驗證協定協作，race test 檢查資料競爭，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fuzz-test/" data-link-title="Fuzz Test" data-link-desc="說明用隨機與變異輸入驗證解析器與邊界處理健壯性">fuzz test&lt;/a> 尋找輸入邊界，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/load-test/" data-link-title="Load Test" data-link-desc="說明在預期流量下驗證容量、延遲與降級策略的測試">load test&lt;/a> 驗證容量，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/chaos-test/" data-link-title="Chaos Test" data-link-desc="說明透過受控故障注入驗證系統在異常條件下的恢復能力">chaos test&lt;/a> 驗證失敗復原。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨不同測試層級各自要防的風險&lt;/li>
&lt;li>把 race、fuzz、load 與 chaos 放到合適的流程裡&lt;/li>
&lt;li>設計能回饋容量規劃的驗證流程&lt;/li>
&lt;li>不把端到端測試當成萬能答案&lt;/li>
&lt;li>讓測試結果回到 deployment 與 runtime 邊界&lt;/li>
&lt;/ol>
&lt;h2 id="前置章節">前置章節&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go 入門：testing 基礎&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/websocket-integration/" data-link-title="5.2 WebSocket integration test" data-link-desc="驗證 client/server 實際互動">Go 進階：WebSocket integration test&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/race-check/" data-link-title="5.3 race condition 檢查" data-link-desc="用 go test -race 找資料競爭">Go 進階：race condition 檢查&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/table-tests/" data-link-title="5.4 table-driven test 的設計邊界" data-link-desc="避免測試資料混雜太多概念">Go 進階：table-driven test 的設計邊界&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="後續撰寫方向">後續撰寫方向&lt;/h2>
&lt;ol>
&lt;li>CI 中哪些測試應每次執行，哪些可以排程或合併前執行。&lt;/li>
&lt;li>Fuzzing 適合驗證 parser、normalizer 與 protocol decoder 的哪些邊界。&lt;/li>
&lt;li>Load test 如何設定 client 數、message rate、payload size 與觀測指標。&lt;/li>
&lt;li>Chaos testing 如何模擬 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a> 斷線、資料庫延遲、server shutdown 與網路抖動。&lt;/li>
&lt;li>測試結果如何回饋到 capacity planning 與 feature gate。&lt;/li>
&lt;/ol>
&lt;h2 id="觀察不同測試層級回答不同問題">【觀察】不同測試層級回答不同問題&lt;/h2>
&lt;p>可靠性驗證最怕的錯誤，是把所有測試都塞成一種樣子。不同層級應該分工：&lt;/p>
&lt;ul>
&lt;li>unit test：規則有沒有寫對&lt;/li>
&lt;li>integration test：協定與元件有沒有接對&lt;/li>
&lt;li>race test：並發邊界有沒有資料競爭&lt;/li>
&lt;li>fuzz test：輸入邊界有沒有漏掉&lt;/li>
&lt;li>load test：容量與延遲是否能接受&lt;/li>
&lt;li>chaos test：失敗發生時系統能不能復原&lt;/li>
&lt;/ul>
&lt;h2 id="判讀race-test-是輔助檢查">【判讀】race test 是輔助檢查&lt;/h2>
&lt;p>&lt;code>go test -race&lt;/code> 能抓出實際跑到的資料競爭，但它不是正確性保證。真正的重點仍然是：&lt;/p>
&lt;ul>
&lt;li>state owner 是誰&lt;/li>
&lt;li>哪些資料需要 lock&lt;/li>
&lt;li>哪些資料應該只讓單一 goroutine 擁有&lt;/li>
&lt;li>哪些資料應該複製而不是共享&lt;/li>
&lt;/ul>
&lt;h2 id="策略load-test-的輸出要能回到容量判斷">【策略】load test 的輸出要能回到容量判斷&lt;/h2>
&lt;p>load test 不應只是跑出一個數字，還要能回答：&lt;/p>
&lt;ul>
&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> 開始變長&lt;/li>
&lt;li>哪個 DB &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">connection pool&lt;/a> 開始飽和&lt;/li>
&lt;li>哪種 message rate 會讓 latency 明顯上升&lt;/li>
&lt;li>哪個 memory curve 表示需要調整 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 或 GC 參數&lt;/li>
&lt;/ul>
&lt;p>如果沒有這些觀察點，壓測結果就很難轉成實際修正。&lt;/p>
&lt;h2 id="執行chaos-test-應該模擬真實失敗">【執行】chaos test 應該模擬真實失敗&lt;/h2>
&lt;p>chaos test 的重點是模擬真實世界常見的失敗：&lt;/p>
&lt;ul>
&lt;li>broker 暫時不可用&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database&lt;/a> 延遲上升&lt;/li>
&lt;li>shutdown 中斷流量&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;/ul>
&lt;p>這些情境應該回到 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown&lt;/a>、retry、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> 設計。&lt;/p></description><content:encoded><![CDATA[<p>可靠性驗證流程的核心責任是讓不同層級的測試回答不同風險。Unit test 驗證規則，integration test 驗證協定協作，race test 檢查資料競爭，<a href="/blog/backend/knowledge-cards/fuzz-test/" data-link-title="Fuzz Test" data-link-desc="說明用隨機與變異輸入驗證解析器與邊界處理健壯性">fuzz test</a> 尋找輸入邊界，<a href="/blog/backend/knowledge-cards/load-test/" data-link-title="Load Test" data-link-desc="說明在預期流量下驗證容量、延遲與降級策略的測試">load test</a> 驗證容量，<a href="/blog/backend/knowledge-cards/chaos-test/" data-link-title="Chaos Test" data-link-desc="說明透過受控故障注入驗證系統在異常條件下的恢復能力">chaos test</a> 驗證失敗復原。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨不同測試層級各自要防的風險</li>
<li>把 race、fuzz、load 與 chaos 放到合適的流程裡</li>
<li>設計能回饋容量規劃的驗證流程</li>
<li>不把端到端測試當成萬能答案</li>
<li>讓測試結果回到 deployment 與 runtime 邊界</li>
</ol>
<h2 id="前置章節">前置章節</h2>
<ul>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go 入門：testing 基礎</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/websocket-integration/" data-link-title="5.2 WebSocket integration test" data-link-desc="驗證 client/server 實際互動">Go 進階：WebSocket integration test</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/race-check/" data-link-title="5.3 race condition 檢查" data-link-desc="用 go test -race 找資料競爭">Go 進階：race condition 檢查</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/table-tests/" data-link-title="5.4 table-driven test 的設計邊界" data-link-desc="避免測試資料混雜太多概念">Go 進階：table-driven test 的設計邊界</a></li>
</ul>
<h2 id="後續撰寫方向">後續撰寫方向</h2>
<ol>
<li>CI 中哪些測試應每次執行，哪些可以排程或合併前執行。</li>
<li>Fuzzing 適合驗證 parser、normalizer 與 protocol decoder 的哪些邊界。</li>
<li>Load test 如何設定 client 數、message rate、payload size 與觀測指標。</li>
<li>Chaos testing 如何模擬 <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> 斷線、資料庫延遲、server shutdown 與網路抖動。</li>
<li>測試結果如何回饋到 capacity planning 與 feature gate。</li>
</ol>
<h2 id="觀察不同測試層級回答不同問題">【觀察】不同測試層級回答不同問題</h2>
<p>可靠性驗證最怕的錯誤，是把所有測試都塞成一種樣子。不同層級應該分工：</p>
<ul>
<li>unit test：規則有沒有寫對</li>
<li>integration test：協定與元件有沒有接對</li>
<li>race test：並發邊界有沒有資料競爭</li>
<li>fuzz test：輸入邊界有沒有漏掉</li>
<li>load test：容量與延遲是否能接受</li>
<li>chaos test：失敗發生時系統能不能復原</li>
</ul>
<h2 id="判讀race-test-是輔助檢查">【判讀】race test 是輔助檢查</h2>
<p><code>go test -race</code> 能抓出實際跑到的資料競爭，但它不是正確性保證。真正的重點仍然是：</p>
<ul>
<li>state owner 是誰</li>
<li>哪些資料需要 lock</li>
<li>哪些資料應該只讓單一 goroutine 擁有</li>
<li>哪些資料應該複製而不是共享</li>
</ul>
<h2 id="策略load-test-的輸出要能回到容量判斷">【策略】load test 的輸出要能回到容量判斷</h2>
<p>load test 不應只是跑出一個數字，還要能回答：</p>
<ul>
<li>哪個 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 開始變長</li>
<li>哪個 DB <a href="/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">connection pool</a> 開始飽和</li>
<li>哪種 message rate 會讓 latency 明顯上升</li>
<li>哪個 memory curve 表示需要調整 <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 或 GC 參數</li>
</ul>
<p>如果沒有這些觀察點，壓測結果就很難轉成實際修正。</p>
<h2 id="執行chaos-test-應該模擬真實失敗">【執行】chaos test 應該模擬真實失敗</h2>
<p>chaos test 的重點是模擬真實世界常見的失敗：</p>
<ul>
<li>broker 暫時不可用</li>
<li><a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a> 延遲上升</li>
<li>shutdown 中斷流量</li>
<li>網路抖動或 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a></li>
</ul>
<p>這些情境應該回到 <a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown</a>、retry、<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 與 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 設計。</p>
<h2 id="延伸測試結果應回饋到-feature-gate">【延伸】測試結果應回饋到 feature gate</h2>
<p>如果某個功能在 load test 或 chaos test 下風險太高，最直接的做法不一定是先修完整系統，也可能是先用 feature gate 逐步推出、觀察與回收。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章不會綁定特定 CI 或壓測平台。教材重點會放在測試層級分工，避免把所有風險都塞進端到端測試。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 Go 的並發測試與可靠性驗證；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go：測試基礎</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/websocket-integration/" data-link-title="5.2 WebSocket integration test" data-link-desc="驗證 client/server 實際互動">Go 進階：WebSocket integration test</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/race-check/" data-link-title="5.3 race condition 檢查" data-link-desc="用 go test -race 找資料競爭">Go 進階：race condition 檢查</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/table-tests/" data-link-title="5.4 table-driven test 的設計邊界" data-link-desc="避免測試資料混雜太多概念">Go 進階：table-driven test 的設計邊界</a></li>
</ul>
]]></content:encoded></item><item><title>7.6 逐步遷移到 ports/adapters 架構</title><link>https://tarrragon.github.io/blog/go/07-refactoring/hexagonal-migration/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/hexagonal-migration/</guid><description>&lt;p>ports/adapters 遷移的核心目標是讓 application 與 domain 不依賴外部技術細節。HTTP、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a>、callback receiver、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database&lt;/a> 都是 adapters；usecase 透過 ports 使用它們。&lt;/p>
&lt;p>這種重構不需要一次套完整架構。Go 專案更常見的做法是先把過重 handler、外部依賴與狀態寫入切開，再在壓力最大的邊界引入 port。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>用依賴方向理解 ports/adapters&lt;/li>
&lt;li>分辨 inbound adapter 與 outbound adapter&lt;/li>
&lt;li>把 usecase 從 handler、repository、publisher 中切出&lt;/li>
&lt;li>用新功能先走新架構的方式漸進遷移&lt;/li>
&lt;li>驗證 import direction、usecase test 與 adapter integration test&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察portsadapters-是依賴方向">【觀察】ports/adapters 是依賴方向&lt;/h2>
&lt;p>ports/adapters 的核心規則是外部技術依賴 application，而不是 application 依賴外部技術。HTTP、WebSocket、database、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> 都是可替換的邊界；usecase 應該依賴自己定義的 port。&lt;/p>
&lt;p>目標方向：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">transport/http ┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">transport/websocket ├─&amp;gt; application ──&amp;gt; domain
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">transport/callback ┘ │
&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"> ports defined here
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> ▲
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">storage/memory ┐ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">eventlog/memory ├───────┘
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">publisher/websocket ┘&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>資料夾名稱可以不同。真正重要的是 import direction：domain 不 import HTTP，application 不 import database implementation，adapter import application 並實作 application 需要的 port。&lt;/p>
&lt;h2 id="判讀inbound-adapter-把外部輸入轉成-command">【判讀】inbound adapter 把外部輸入轉成 command&lt;/h2>
&lt;p>inbound adapter 的核心責任是接收外部訊號，轉成 application command 或 domain event。它不應直接修改 state，也不應保存業務規則。&lt;/p>
&lt;p>常見 inbound adapter：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>adapter&lt;/th>
 &lt;th>輸入&lt;/th>
 &lt;th>轉換結果&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>HTTP handler&lt;/td>
 &lt;td>HTTP request&lt;/td>
 &lt;td>command&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>WebSocket router&lt;/td>
 &lt;td>client message&lt;/td>
 &lt;td>command&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>callback receiver&lt;/td>
 &lt;td>external callback&lt;/td>
 &lt;td>domain event&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>worker&lt;/td>
 &lt;td>timer 或 queue item&lt;/td>
 &lt;td>command/event&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>例如 HTTP adapter：&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">HTTPNotificationHandler&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">usecase&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">application&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">CreateNotificationUsecase&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">now&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">h&lt;/span> &lt;span class="nx">HTTPNotificationHandler&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Create&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">req&lt;/span> &lt;span class="nx">createNotificationRequest&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">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">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid_json&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;request body must be valid JSON&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">cmd&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">application&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">CreateNotificationCommand&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">ID&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">ID&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">Topic&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">16&lt;/span>&lt;span class="cl"> &lt;span class="nx">Title&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">Title&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="nx">CreatedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">now&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&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">h&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">usecase&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Execute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Context&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">cmd&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">21&lt;/span>&lt;span class="cl"> &lt;span class="nf">writeUsecaseError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&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">22&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">23&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusCreated&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>HTTP adapter 知道 JSON、status code、request body。usecase 不知道這些協定細節。&lt;/p>
&lt;h2 id="判讀outbound-adapter-實作-application-port">【判讀】outbound adapter 實作 application port&lt;/h2>
&lt;p>outbound adapter 的核心責任是實作 application 定義的 port。application 說「我需要儲存 notification」，adapter 決定用 memory、SQLite 或其他技術完成。&lt;/p></description><content:encoded><![CDATA[<p>ports/adapters 遷移的核心目標是讓 application 與 domain 不依賴外部技術細節。HTTP、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a>、callback receiver、<a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a> 都是 adapters；usecase 透過 ports 使用它們。</p>
<p>這種重構不需要一次套完整架構。Go 專案更常見的做法是先把過重 handler、外部依賴與狀態寫入切開，再在壓力最大的邊界引入 port。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>用依賴方向理解 ports/adapters</li>
<li>分辨 inbound adapter 與 outbound adapter</li>
<li>把 usecase 從 handler、repository、publisher 中切出</li>
<li>用新功能先走新架構的方式漸進遷移</li>
<li>驗證 import direction、usecase test 與 adapter integration test</li>
</ol>
<hr>
<h2 id="觀察portsadapters-是依賴方向">【觀察】ports/adapters 是依賴方向</h2>
<p>ports/adapters 的核心規則是外部技術依賴 application，而不是 application 依賴外部技術。HTTP、WebSocket、database、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 都是可替換的邊界；usecase 應該依賴自己定義的 port。</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">transport/http      ┐
</span></span><span class="line"><span class="ln">2</span><span class="cl">transport/websocket ├─&gt; application ──&gt; domain
</span></span><span class="line"><span class="ln">3</span><span class="cl">transport/callback  ┘        │
</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">                      ports defined here
</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">storage/memory       ┐       │
</span></span><span class="line"><span class="ln">8</span><span class="cl">eventlog/memory      ├───────┘
</span></span><span class="line"><span class="ln">9</span><span class="cl">publisher/websocket  ┘</span></span></code></pre></div><p>資料夾名稱可以不同。真正重要的是 import direction：domain 不 import HTTP，application 不 import database implementation，adapter import application 並實作 application 需要的 port。</p>
<h2 id="判讀inbound-adapter-把外部輸入轉成-command">【判讀】inbound adapter 把外部輸入轉成 command</h2>
<p>inbound adapter 的核心責任是接收外部訊號，轉成 application command 或 domain event。它不應直接修改 state，也不應保存業務規則。</p>
<p>常見 inbound adapter：</p>
<table>
  <thead>
      <tr>
          <th>adapter</th>
          <th>輸入</th>
          <th>轉換結果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>HTTP handler</td>
          <td>HTTP request</td>
          <td>command</td>
      </tr>
      <tr>
          <td>WebSocket router</td>
          <td>client message</td>
          <td>command</td>
      </tr>
      <tr>
          <td>callback receiver</td>
          <td>external callback</td>
          <td>domain event</td>
      </tr>
      <tr>
          <td>worker</td>
          <td>timer 或 queue item</td>
          <td>command/event</td>
      </tr>
  </tbody>
</table>
<p>例如 HTTP adapter：</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">HTTPNotificationHandler</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">usecase</span> <span class="o">*</span><span class="nx">application</span><span class="p">.</span><span class="nx">CreateNotificationUsecase</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">now</span>     <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="nx">HTTPNotificationHandler</span><span class="p">)</span> <span class="nf">Create</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="kd">var</span> <span class="nx">req</span> <span class="nx">createNotificationRequest</span>
</span></span><span class="line"><span class="ln"> 8</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">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</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"> 9</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_json&#34;</span><span class="p">,</span> <span class="s">&#34;request body must be valid JSON&#34;</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></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">cmd</span> <span class="o">:=</span> <span class="nx">application</span><span class="p">.</span><span class="nx">CreateNotificationCommand</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="nx">req</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">Topic</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">16</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="nx">req</span><span class="p">.</span><span class="nx">Title</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">h</span><span class="p">.</span><span class="nf">now</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="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">usecase</span><span class="p">.</span><span class="nf">Execute</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nf">Context</span><span class="p">(),</span> <span class="nx">cmd</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">21</span><span class="cl">        <span class="nf">writeUsecaseError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="k">return</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></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>HTTP adapter 知道 JSON、status code、request body。usecase 不知道這些協定細節。</p>
<h2 id="判讀outbound-adapter-實作-application-port">【判讀】outbound adapter 實作 application port</h2>
<p>outbound adapter 的核心責任是實作 application 定義的 port。application 說「我需要儲存 notification」，adapter 決定用 memory、SQLite 或其他技術完成。</p>
<p>application 定義 port：</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="kn">package</span> <span class="nx">application</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">NotificationRepository</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nf">Save</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">notification</span> <span class="nx">domain</span><span class="p">.</span><span class="nx">Notification</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nf">FindByID</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">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">domain</span><span class="p">.</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</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="p">}</span></span></span></code></pre></div><p>memory adapter 實作：</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="kn">package</span> <span class="nx">memory</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">NotificationRepository</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">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">notifications</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">domain</span><span class="p">.</span><span class="nx">Notification</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">r</span> <span class="o">*</span><span class="nx">NotificationRepository</span><span class="p">)</span> <span class="nf">Save</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">notification</span> <span class="nx">domain</span><span class="p">.</span><span class="nx">Notification</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">r</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">r</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">r</span><span class="p">.</span><span class="nx">notifications</span><span class="p">[</span><span class="nx">notification</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">notification</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</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">NotificationRepository</span><span class="p">)</span> <span class="nf">FindByID</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">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">domain</span><span class="p">.</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">r</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">17</span><span class="cl">    <span class="k">defer</span> <span class="nx">r</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">18</span><span class="cl">    <span class="nx">notification</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">notifications</span><span class="p">[</span><span class="nx">id</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="nx">notification</span><span class="p">,</span> <span class="nx">ok</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>adapter import application 不一定必要，因為 Go implicit interface 不要求顯式宣告。只要 method set 符合，組裝時就能傳給 usecase。</p>
<h2 id="策略usecase-是遷移中心">【策略】usecase 是遷移中心</h2>
<p>usecase 的核心角色是協調 domain 規則與 ports。它不處理 HTTP，也不操作具體資料庫。</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="kn">package</span> <span class="nx">application</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">CreateNotificationUsecase</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">repository</span> <span class="nx">NotificationRepository</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">eventLog</span>   <span class="nx">EventLog</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">NewCreateNotificationUsecase</span><span class="p">(</span><span class="nx">repository</span> <span class="nx">NotificationRepository</span><span class="p">,</span> <span class="nx">eventLog</span> <span class="nx">EventLog</span><span class="p">)</span> <span class="o">*</span><span class="nx">CreateNotificationUsecase</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">CreateNotificationUsecase</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">repository</span><span class="p">:</span> <span class="nx">repository</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">eventLog</span><span class="p">:</span>   <span class="nx">eventLog</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><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">u</span> <span class="o">*</span><span class="nx">CreateNotificationUsecase</span><span class="p">)</span> <span class="nf">Execute</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">cmd</span> <span class="nx">CreateNotificationCommand</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">notification</span> <span class="o">:=</span> <span class="nx">domain</span><span class="p">.</span><span class="nx">Notification</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="nx">cmd</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</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">19</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="nx">cmd</span><span class="p">.</span><span class="nx">Title</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">cmd</span><span class="p">.</span><span class="nx">CreatedAt</span><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></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">u</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">notification</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">24</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;save notification: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="nx">event</span> <span class="o">:=</span> <span class="nx">domain</span><span class="p">.</span><span class="nf">NewNotificationCreated</span><span class="p">(</span><span class="nx">notification</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">u</span><span class="p">.</span><span class="nx">eventLog</span><span class="p">.</span><span class="nf">Append</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</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">29</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;append event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 usecase 只依賴 domain 與 ports。HTTP handler、WebSocket router、memory repository、[event <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>](/go/backend/knowledge-cards/event-log) implementation 都在外面。</p>
<h2 id="執行組裝放在-main-或-composition-root">【執行】組裝放在 main 或 composition root</h2>
<p>composition root 的核心責任是建立 concrete implementation，並把它們接到 usecase 與 adapter。Go 專案常把這件事放在 <code>main.go</code> 或 <code>cmd/.../main.go</code>。</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">main</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">notificationRepo</span> <span class="o">:=</span> <span class="nx">memory</span><span class="p">.</span><span class="nf">NewNotificationRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">eventLog</span> <span class="o">:=</span> <span class="nx">memory</span><span class="p">.</span><span class="nf">NewEventLog</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">createNotification</span> <span class="o">:=</span> <span class="nx">application</span><span class="p">.</span><span class="nf">NewCreateNotificationUsecase</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">notificationRepo</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">eventLog</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="nx">handler</span> <span class="o">:=</span> <span class="nx">httpadapter</span><span class="p">.</span><span class="nf">NewNotificationHandler</span><span class="p">(</span><span class="nx">createNotification</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Now</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">mux</span> <span class="o">:=</span> <span class="nx">http</span><span class="p">.</span><span class="nf">NewServeMux</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">mux</span><span class="p">.</span><span class="nf">HandleFunc</span><span class="p">(</span><span class="s">&#34;POST /notifications&#34;</span><span class="p">,</span> <span class="nx">handler</span><span class="p">.</span><span class="nx">Create</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">server</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">http</span><span class="p">.</span><span class="nx">Server</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">Addr</span><span class="p">:</span>    <span class="s">&#34;:8080&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">Handler</span><span class="p">:</span> <span class="nx">mux</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="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">server</span><span class="p">.</span><span class="nf">ListenAndServe</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">21</span><span class="cl">        <span class="nx">log</span><span class="p">.</span><span class="nf">Fatal</span><span class="p">(</span><span class="nx">err</span><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>main 可以知道所有具體實作，因為它負責組裝。這不違反依賴方向；問題是 application 或 domain 不能反過來 import main、HTTP adapter 或 memory adapter。</p>
<h2 id="策略新功能先走新架構">【策略】新功能先走新架構</h2>
<p>漸進式遷移的核心策略是新功能先走新邊界，舊功能在被修改時再搬。一次性大重構風險高，容易同時改壞行為與結構。</p>
<p>建議做法：</p>
<ul>
<li>新 endpoint 直接建立 command/usecase。</li>
<li>新 repository 先定義小 port。</li>
<li>新 event flow 先走 <code>DomainEvent</code> 與 processor。</li>
<li>舊 handler 只有在新增需求或修 bug 時才拆。</li>
<li>保留舊路徑測試，搬移完成再刪掉。</li>
</ul>
<p>這樣可以讓新架構逐步長出來，而不是一次強迫整個專案符合模板。</p>
<h2 id="執行從-callback-ingestion-開始切">【執行】從 callback ingestion 開始切</h2>
<p>以外部 callback 進入事件系統為例，application usecase 可以叫 <code>IngestExternalEvent</code>：</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="kn">package</span> <span class="nx">application</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">EventLog</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nf">Append</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">event</span> <span class="nx">domain</span><span class="p">.</span><span class="nx">Event</span><span class="p">)</span> <span class="kt">error</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">type</span> <span class="nx">EventProcessor</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nf">Process</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">event</span> <span class="nx">domain</span><span class="p">.</span><span class="nx">Event</span><span class="p">)</span> <span class="kt">error</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></span><span class="line"><span class="ln">11</span><span class="cl"><span class="kd">type</span> <span class="nx">IngestExternalEvent</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="nx">eventLog</span>  <span class="nx">EventLog</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">processor</span> <span class="nx">EventProcessor</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="kd">func</span> <span class="p">(</span><span class="nx">u</span> <span class="o">*</span><span class="nx">IngestExternalEvent</span><span class="p">)</span> <span class="nf">Execute</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">event</span> <span class="nx">domain</span><span class="p">.</span><span class="nx">Event</span><span class="p">)</span> <span class="kt">error</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">err</span> <span class="o">:=</span> <span class="nx">event</span><span class="p">.</span><span class="nf">Validate</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">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;validate event: %w&#34;</span><span class="p">,</span> <span class="nx">err</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="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">u</span><span class="p">.</span><span class="nx">eventLog</span><span class="p">.</span><span class="nf">Append</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</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">21</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;append event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><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="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">u</span><span class="p">.</span><span class="nx">processor</span><span class="p">.</span><span class="nf">Process</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">event</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">24</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;process event: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>callback adapter 只負責 raw input 轉 domain event：</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="nx">CallbackHandler</span><span class="p">)</span> <span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="kd">var</span> <span class="nx">raw</span> <span class="nx">RawNotificationCallback</span>
</span></span><span class="line"><span class="ln"> 3</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">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">raw</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"> 4</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_json&#34;</span><span class="p">,</span> <span class="s">&#34;invalid JSON&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">return</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="nx">event</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">NormalizeNotificationCallback</span><span class="p">(</span><span class="nx">raw</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</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">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="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_payload&#34;</span><span class="p">,</span> <span class="s">&#34;invalid callback payload&#34;</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></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">ingest</span><span class="p">.</span><span class="nf">Execute</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nf">Context</span><span class="p">(),</span> <span class="nx">event</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">15</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusInternalServerError</span><span class="p">,</span> <span class="s">&#34;ingest_failed&#34;</span><span class="p">,</span> <span class="s">&#34;event ingest failed&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="k">return</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></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusAccepted</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個切法讓 callback 格式停在 adapter，event log 與 processor 行為停在 application。</p>
<h2 id="判讀websocket-adapter-也是-inbound-adapter">【判讀】WebSocket adapter 也是 inbound adapter</h2>
<p>WebSocket adapter 的核心責任是把 client message 轉成 command。它不應直接知道 repository 或 event log implementation。</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">WebSocketAdapter</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">router</span> <span class="o">*</span><span class="nx">MessageRouter</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">MessageRouter</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">subscriptions</span> <span class="nx">application</span><span class="p">.</span><span class="nx">SubscriptionUsecase</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>router 可以呼叫 application usecase：</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">r</span> <span class="o">*</span><span class="nx">MessageRouter</span><span class="p">)</span> <span class="nf">handleSubscribeTopic</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">clientID</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">req</span> <span class="nx">SubscribeTopicRequest</span><span class="p">)</span> <span class="nx">ServerMessage</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">cmd</span> <span class="o">:=</span> <span class="nx">application</span><span class="p">.</span><span class="nx">SubscribeTopicCommand</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">ClientID</span><span class="p">:</span>       <span class="nx">clientID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">Topic</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"> 5</span><span class="cl">        <span class="nx">IncludeHistory</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">IncludeHistory</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">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">r</span><span class="p">.</span><span class="nx">subscriptions</span><span class="p">.</span><span class="nf">SubscribeTopic</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">cmd</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"> 9</span><span class="cl">        <span class="k">return</span> <span class="nf">ErrorMessage</span><span class="p">(</span><span class="s">&#34;&#34;</span><span class="p">,</span> <span class="s">&#34;subscribe_failed&#34;</span><span class="p">,</span> <span class="s">&#34;topic subscription failed&#34;</span><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></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">return</span> <span class="nf">OKMessage</span><span class="p">(</span><span class="s">&#34;&#34;</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="kt">string</span><span class="p">{</span><span class="s">&#34;topic&#34;</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">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這和 HTTP handler 的方向相同：adapter 處理協定，application 處理行為。</p>
<h2 id="執行驗證-import-direction">【執行】驗證 import direction</h2>
<p>架構邊界的核心驗證是 import direction。即使沒有工具，也可以用簡單規則檢查：</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">domain       不 import application、transport、storage
</span></span><span class="line"><span class="ln">2</span><span class="cl">application  可以 import domain，不 import transport/storage implementation
</span></span><span class="line"><span class="ln">3</span><span class="cl">transport    可以 import application/domain
</span></span><span class="line"><span class="ln">4</span><span class="cl">storage      可以 import application/domain
</span></span><span class="line"><span class="ln">5</span><span class="cl">cmd/main     可以 import 所有 adapter 與 application 做組裝</span></span></code></pre></div><p>可以用 <code>go list</code> 觀察 package 依賴：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go list -deps ./...</span></span></code></pre></div><p>也可以在 review 時檢查：如果 <code>domain/job</code> import 了 <code>net/http</code>，幾乎一定是邊界錯了；如果 <code>application</code> import 了 <code>storage/memory</code>，則 usecase 已經依賴 implementation。</p>
<h2 id="執行usecase-test-與-adapter-integration-test-分工">【執行】usecase test 與 adapter integration test 分工</h2>
<p>測試分工的核心原則是 usecase 測規則，adapter 測協定轉換與組裝。不要只靠端到端測試保護所有行為。</p>
<p>usecase test：</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">TestIngestExternalEventAppendsAndProcesses</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">eventLog</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeEventLog</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">processor</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeEventProcessor</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">usecase</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">application</span><span class="p">.</span><span class="nx">IngestExternalEvent</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">eventLog</span><span class="p">:</span>  <span class="nx">eventLog</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">processor</span><span class="p">:</span> <span class="nx">processor</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></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">event</span> <span class="o">:=</span> <span class="nf">validDomainEvent</span><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="nx">usecase</span><span class="p">.</span><span class="nf">Execute</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">event</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="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;ingest event: %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="nb">len</span><span class="p">(</span><span class="nx">eventLog</span><span class="p">.</span><span class="nx">appended</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</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;appended events = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">eventLog</span><span class="p">.</span><span class="nx">appended</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="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">processor</span><span class="p">.</span><span class="nx">processed</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</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;processed events = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">processor</span><span class="p">.</span><span class="nx">processed</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></code></pre></div><p>adapter integration test 則可以用 <code>httptest</code> 驗證 request/response 與 usecase fake 是否被呼叫。兩種測試分工清楚，失敗時才知道是規則錯還是協定轉換錯。</p>
<h2 id="重構步驟">重構步驟</h2>
<p>逐步遷移到 ports/adapters，可以按這個順序：</p>
<ol>
<li>先找一條最痛的功能路徑，例如新增 notification 或 ingest external event。</li>
<li>把 handler/router 中的規則抽成 command/usecase。</li>
<li>在 application 定義 repository、event log、publisher port。</li>
<li>讓現有 memory store 或 publisher 實作 port。</li>
<li>main 組裝 concrete adapter 與 usecase。</li>
<li>新功能只走新路徑。</li>
<li>舊功能被修改時，逐步搬到同樣邊界。</li>
<li>用 import direction review 防止反向依賴。</li>
</ol>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一遷移從單一邊界開始">檢查一：遷移從單一邊界開始</h3>
<p>ports/adapters 的價值是依賴方向。若沒有先拆 usecase 與 port，只是搬資料夾，複雜度會上升但邊界不會變清楚。</p>
<h3 id="檢查二application-依賴-port">檢查二：application 依賴 port</h3>
<p>application 應依賴 port，不依賴 memory、SQLite 或 database adapter。若 application import storage，依賴方向已經反了。</p>
<h3 id="檢查三業務規則留在-application-或-domain">檢查三：業務規則留在 application 或 domain</h3>
<p>adapter 可以驗證輸入格式，但業務規則應該在 usecase 或 domain。否則 HTTP 與 WebSocket 會各自複製規則。</p>
<h3 id="檢查四port-跟著使用端分散">檢查四：port 跟著使用端分散</h3>
<p>port 應靠近使用端。把所有 interface 集中到一個大型 package，常會讓依賴重新糾纏在一起。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 ports/adapters 的依賴方向；分散式系統、資料庫與平台 wiring，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/" data-link-title="模組七：跨節點與平台整合" data-link-desc="把單一 Go 服務延伸到資料庫、queue、跨節點 WebSocket、可觀測性與部署平台">Go 進階：跨節點與平台整合</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">Go 進階：資料庫 transaction 與 schema migration</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Go 進階：Durable queue、outbox 與 idempotency</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Go 進階：Kubernetes、systemd 與 load balancer 合約</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 handler、repository、event 與 composition root 的遷移路線；如果你要先回看語言教材，可以讀：</p>
<ul>
<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/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go：composition root 與依賴組裝</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>ports/adapters 遷移的重點是控制依賴方向：adapter 處理外部技術，application 定義 usecase 與 ports，domain 保存核心語意。Go 專案可以漸進式遷移，新功能先走清楚邊界，舊功能在修改時再搬。架構的價值在於測試更直接、替換更容易、核心規則不被外部技術綁住。</p>
]]></content:encoded></item><item><title>模組六：生產操作</title><link>https://tarrragon.github.io/blog/go-advanced/06-production-operations/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/06-production-operations/</guid><description>&lt;p>生產操作的核心目標是讓 Go 服務可停止、可觀測、可診斷、可漸進啟用功能。服務能在本機跑起來只是第一步；長時間運行後，真正重要的是 shutdown 是否可預期、監控訊號是否清楚、&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;p>本模組承接前面的並發、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a>、runtime 與測試：&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown&lt;/a> 需要 context 和 goroutine lifecycle，health endpoint 需要區分可用性與診斷，structured &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a> 需要能追 event flow，feature gate 需要能安全控制新能力。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&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;a href="https://tarrragon.github.io/blog/go-advanced/06-production-operations/graceful-shutdown/" data-link-title="6.1 graceful shutdown 與 signal handling" data-link-desc="用 signal 與 context 傳遞停止訊號">6.1&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown&lt;/a> 與 signal handling&lt;/td>
 &lt;td>用 signal、context、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a> 與 owner cleanup 停止服務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">6.2&lt;/a>&lt;/td>
 &lt;td>健康檢查與診斷 endpoint&lt;/td>
 &lt;td>區分 health、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness&lt;/a>、diagnostics 與 status code 合約&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">6.3&lt;/a>&lt;/td>
 &lt;td>結構化日誌欄位設計&lt;/td>
 &lt;td>用穩定欄位讓 log 可 grep、可聚合、可追蹤&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/06-production-operations/feature-gate/" data-link-title="6.4 版本偵測與 feature gate" data-link-desc="依版本與環境能力啟用功能">6.4&lt;/a>&lt;/td>
 &lt;td>版本偵測與 feature gate&lt;/td>
 &lt;td>用功能開關、能力偵測與降級策略控制行為&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;p>本模組使用虛構的即時通知服務作為範例。範例包含 HTTP server、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> hub、background worker、runtime diagnostics、structured log 與 feature gate。&lt;/p>
&lt;p>範例只用來展示 Go 生產操作設計，不假設讀者正在維護任何特定專案。&lt;/p>
&lt;h2 id="本模組的-go-核心概念">本模組的 Go 核心概念&lt;/h2>
&lt;ul>
&lt;li>用 &lt;code>signal.NotifyContext&lt;/code> 或 signal channel 建立 root context。&lt;/li>
&lt;li>用 &lt;code>http.Server.Shutdown&lt;/code> 停止接受新 request。&lt;/li>
&lt;li>用 context 傳遞停止訊號給 worker、hub、WebSocket pump。&lt;/li>
&lt;li>用 &lt;code>/health&lt;/code>、&lt;code>/ready&lt;/code>、&lt;code>/debug/...&lt;/code> 分開不同操作訊號。&lt;/li>
&lt;li>用 &lt;code>log/slog&lt;/code> 建立穩定 structured fields。&lt;/li>
&lt;li>用 config struct 載入 feature gate，而不是到處讀環境變數。&lt;/li>
&lt;/ul>
&lt;h2 id="學習重點">學習重點&lt;/h2>
&lt;p>學完本模組後，你應該能判斷：&lt;/p>
&lt;ol>
&lt;li>服務收到停止訊號後，哪些元件要先停止接流量&lt;/li>
&lt;li>health、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness&lt;/a>、diagnostics 各自回答什麼問題&lt;/li>
&lt;li>structured log 欄位如何支援查詢與聚合&lt;/li>
&lt;li>哪些資料不應進入 log&lt;/li>
&lt;li>feature gate 關閉時應降級、回錯、隱藏還是排程稍後處理&lt;/li>
&lt;/ol>
&lt;h2 id="本模組不處理">本模組不處理&lt;/h2>
&lt;p>本模組不討論 Kubernetes、systemd、雲端平台或完整 SRE 流程的所有細節。這些環境會影響操作策略，但本模組先建立 Go 服務本身應具備的操作邊界；後續可接 &lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Kubernetes、systemd 與 load balancer 合約&lt;/a> 以及 &lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Observability pipeline、metrics 與 tracing&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>生產操作的核心目標是讓 Go 服務可停止、可觀測、可診斷、可漸進啟用功能。服務能在本機跑起來只是第一步；長時間運行後，真正重要的是 shutdown 是否可預期、監控訊號是否清楚、<a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 是否可查詢、功能開關是否有降級策略。</p>
<p>本模組承接前面的並發、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a>、runtime 與測試：<a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown</a> 需要 context 和 goroutine lifecycle，health endpoint 需要區分可用性與診斷，structured <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 需要能追 event flow，feature gate 需要能安全控制新能力。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go-advanced/06-production-operations/graceful-shutdown/" data-link-title="6.1 graceful shutdown 與 signal handling" data-link-desc="用 signal 與 context 傳遞停止訊號">6.1</a></td>
          <td><a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown</a> 與 signal handling</td>
          <td>用 signal、context、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 與 owner cleanup 停止服務</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">6.2</a></td>
          <td>健康檢查與診斷 endpoint</td>
          <td>區分 health、<a href="/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness</a>、diagnostics 與 status code 合約</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">6.3</a></td>
          <td>結構化日誌欄位設計</td>
          <td>用穩定欄位讓 log 可 grep、可聚合、可追蹤</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/06-production-operations/feature-gate/" data-link-title="6.4 版本偵測與 feature gate" data-link-desc="依版本與環境能力啟用功能">6.4</a></td>
          <td>版本偵測與 feature gate</td>
          <td>用功能開關、能力偵測與降級策略控制行為</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<p>本模組使用虛構的即時通知服務作為範例。範例包含 HTTP server、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> hub、background worker、runtime diagnostics、structured log 與 feature gate。</p>
<p>範例只用來展示 Go 生產操作設計，不假設讀者正在維護任何特定專案。</p>
<h2 id="本模組的-go-核心概念">本模組的 Go 核心概念</h2>
<ul>
<li>用 <code>signal.NotifyContext</code> 或 signal channel 建立 root context。</li>
<li>用 <code>http.Server.Shutdown</code> 停止接受新 request。</li>
<li>用 context 傳遞停止訊號給 worker、hub、WebSocket pump。</li>
<li>用 <code>/health</code>、<code>/ready</code>、<code>/debug/...</code> 分開不同操作訊號。</li>
<li>用 <code>log/slog</code> 建立穩定 structured fields。</li>
<li>用 config struct 載入 feature gate，而不是到處讀環境變數。</li>
</ul>
<h2 id="學習重點">學習重點</h2>
<p>學完本模組後，你應該能判斷：</p>
<ol>
<li>服務收到停止訊號後，哪些元件要先停止接流量</li>
<li>health、<a href="/blog/backend/knowledge-cards/readiness/" data-link-title="Readiness" data-link-desc="說明 instance 何時可以安全接收流量，以及 readiness 如何和部署平台協作">readiness</a>、diagnostics 各自回答什麼問題</li>
<li>structured log 欄位如何支援查詢與聚合</li>
<li>哪些資料不應進入 log</li>
<li>feature gate 關閉時應降級、回錯、隱藏還是排程稍後處理</li>
</ol>
<h2 id="本模組不處理">本模組不處理</h2>
<p>本模組不討論 Kubernetes、systemd、雲端平台或完整 SRE 流程的所有細節。這些環境會影響操作策略，但本模組先建立 Go 服務本身應具備的操作邊界；後續可接 <a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Kubernetes、systemd 與 load balancer 合約</a> 以及 <a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Observability pipeline、metrics 與 tracing</a>。</p>
<h2 id="學習時間">學習時間</h2>
<p>預計 3-4 小時</p>
]]></content:encoded></item><item><title>模組六：實戰指南</title><link>https://tarrragon.github.io/blog/go/06-practical/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/06-practical/</guid><description>&lt;p>本模組把 Go 的核心概念轉成常見服務開發任務。核心順序是：先定義資料與行為語意，再處理輸入邊界，接著更新 usecase、repository、event/&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;p>實戰章節的重點是練習 Go 的判斷方式：何時只需要 struct，何時需要 method，何時需要 interface，何時需要 goroutine，何時應該把狀態集中管理。大型架構模板留到壓力出現後再評估；服務設計只是這些語言概念的應用場景。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&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;a href="https://tarrragon.github.io/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">6.1&lt;/a>&lt;/td>
 &lt;td>如何新增一個即時訊息 action&lt;/td>
 &lt;td>用 struct、JSON、error 與 usecase 切開輸入邊界&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">6.2&lt;/a>&lt;/td>
 &lt;td>如何新增一種 domain event&lt;/td>
 &lt;td>用明確型別定義事件語意、envelope 與驗證規則&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/06-practical/state-fields/" data-link-title="6.3 如何擴展狀態投影欄位" data-link-desc="更新狀態模型、repository 與 API 輸出">6.3&lt;/a>&lt;/td>
 &lt;td>如何擴展狀態投影欄位&lt;/td>
 &lt;td>判斷欄位屬於 domain state、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model&lt;/a> 或 response view&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">6.4&lt;/a>&lt;/td>
 &lt;td>如何新增背景工作流程&lt;/td>
 &lt;td>用 context、channel、ticker 與 shutdown 管理 goroutine 生命週期&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/06-practical/structured-recording/" data-link-title="6.5 如何新增結構化記錄欄位" data-link-desc="區分 operational log、domain event log 與狀態資料">6.5&lt;/a>&lt;/td>
 &lt;td>如何新增結構化記錄欄位&lt;/td>
 &lt;td>區分 structured log、domain &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> 與 state repository&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">6.6&lt;/a>&lt;/td>
 &lt;td>如何新增 repository port&lt;/td>
 &lt;td>用小介面建立儲存邊界，再決定 memory 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database&lt;/a> 實作&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/06-practical/service-scenarios/" data-link-title="6.7 Go 常見服務場景總覽" data-link-desc="整理 Go 最常落地的服務情境：即時、背景、事件、通知與 API 聚合">6.7&lt;/a>&lt;/td>
 &lt;td>Go 常見服務場景總覽&lt;/td>
 &lt;td>看懂 Go 最常落地的即時、背景與事件處理場景&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/06-practical/data-access-boundaries/" data-link-title="6.8 高併發下的 Redis 與 SQL 使用原則" data-link-desc="從 Go 服務角度整理 Redis 與 SQL 的高併發存取邊界">6.8&lt;/a>&lt;/td>
 &lt;td>高併發下的 Redis 與 SQL 使用原則&lt;/td>
 &lt;td>用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>、pool 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> 控制下游壓力&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組的教學主軸">本模組的教學主軸&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>資料先有語意&lt;/strong>：struct 欄位、JSON tag、zero value 與 &lt;code>omitempty&lt;/code> 都要表達資料意義。&lt;/li>
&lt;li>&lt;strong>邊界先小後大&lt;/strong>：先用函式與 struct 整理行為，只有在替換、測試或隔離需求出現時才引入 interface。&lt;/li>
&lt;li>&lt;strong>goroutine 要有生命週期&lt;/strong>：背景工作必須能取消、停止與測試；只把工作丟進 &lt;code>go func()&lt;/code> 會讓 shutdown、錯誤回報與測試邊界變模糊。&lt;/li>
&lt;li>&lt;strong>記錄要按用途分流&lt;/strong>：log 用於操作診斷，event log 用於事實記錄，repository 用於目前狀態。&lt;/li>
&lt;li>&lt;strong>架構來自壓力&lt;/strong>：domain package、repository port、event envelope 是服務變大後的自然拆分，不是入門程式的預設起點。&lt;/li>
&lt;/ul>
&lt;h2 id="章節粒度說明">章節粒度說明&lt;/h2>
&lt;p>本模組每一章都是「完成一個常見開發任務」的完整流程，所以篇幅會比語法章長。章節會同時包含資料定義、邊界判斷、簡化實作、測試與設計檢查；這是為了讓讀者看到一次修改如何穿過 Go 服務的多個層次。&lt;/p>
&lt;p>細節主題會在後續模組拆開深入：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>本模組任務&lt;/th>
 &lt;th>深入章節&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>即時訊息 action&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/" data-link-title="模組二：WebSocket 服務架構" data-link-desc="WebSocket client lifecycle、heartbeat、訂閱路由與慢客戶端管理">WebSocket 服務架構&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>domain event 與去重&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/" data-link-title="模組四：架構邊界與事件系統" data-link-desc="用事件驅動架構拆解事件來源、處理流程、狀態邊界與即時推送">架構邊界與事件系統&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>狀態投影與 repository&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">Source of Truth：狀態邊界&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>背景 worker 與 shutdown&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/06-production-operations/graceful-shutdown/" data-link-title="6.1 graceful shutdown 與 signal handling" data-link-desc="用 signal 與 context 傳遞停止訊號">graceful shutdown 與 signal handling&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>structured log 與 event log&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">結構化日誌欄位設計&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Observability pipeline&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>repository 到資料庫&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">資料庫 transaction 與 schema migration&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;ul>
&lt;li>即時通知服務的 action route&lt;/li>
&lt;li>domain event envelope&lt;/li>
&lt;li>任務狀態 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a> 更新&lt;/li>
&lt;li>背景 worker 啟動與停止&lt;/li>
&lt;li>structured log 欄位&lt;/li>
&lt;li>repository port 與 memory implementation&lt;/li>
&lt;/ul>
&lt;h2 id="學習時間">學習時間&lt;/h2>
&lt;p>預計 2-3 小時&lt;/p></description><content:encoded><![CDATA[<p>本模組把 Go 的核心概念轉成常見服務開發任務。核心順序是：先定義資料與行為語意，再處理輸入邊界，接著更新 usecase、repository、event/<a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a> 邊界，最後補測試。這裡的範例會使用中立的即時通知服務，不要求讀者知道任何特定專案。</p>
<p>實戰章節的重點是練習 Go 的判斷方式：何時只需要 struct，何時需要 method，何時需要 interface，何時需要 goroutine，何時應該把狀態集中管理。大型架構模板留到壓力出現後再評估；服務設計只是這些語言概念的應用場景。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">6.1</a></td>
          <td>如何新增一個即時訊息 action</td>
          <td>用 struct、JSON、error 與 usecase 切開輸入邊界</td>
      </tr>
      <tr>
          <td><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">6.2</a></td>
          <td>如何新增一種 domain event</td>
          <td>用明確型別定義事件語意、envelope 與驗證規則</td>
      </tr>
      <tr>
          <td><a href="/blog/go/06-practical/state-fields/" data-link-title="6.3 如何擴展狀態投影欄位" data-link-desc="更新狀態模型、repository 與 API 輸出">6.3</a></td>
          <td>如何擴展狀態投影欄位</td>
          <td>判斷欄位屬於 domain state、<a href="/blog/backend/knowledge-cards/read-model/" data-link-title="Read Model" data-link-desc="說明為查詢場景建立的讀取模型，與正式狀態的責任分離">read model</a> 或 response view</td>
      </tr>
      <tr>
          <td><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">6.4</a></td>
          <td>如何新增背景工作流程</td>
          <td>用 context、channel、ticker 與 shutdown 管理 goroutine 生命週期</td>
      </tr>
      <tr>
          <td><a href="/blog/go/06-practical/structured-recording/" data-link-title="6.5 如何新增結構化記錄欄位" data-link-desc="區分 operational log、domain event log 與狀態資料">6.5</a></td>
          <td>如何新增結構化記錄欄位</td>
          <td>區分 structured log、domain <a href="/blog/backend/knowledge-cards/event-log/" data-link-title="Event Log" data-link-desc="說明事件歷史如何保存、重播與支援跨服務資料重建">event log</a> 與 state repository</td>
      </tr>
      <tr>
          <td><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">6.6</a></td>
          <td>如何新增 repository port</td>
          <td>用小介面建立儲存邊界，再決定 memory 或 <a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a> 實作</td>
      </tr>
      <tr>
          <td><a href="/blog/go/06-practical/service-scenarios/" data-link-title="6.7 Go 常見服務場景總覽" data-link-desc="整理 Go 最常落地的服務情境：即時、背景、事件、通知與 API 聚合">6.7</a></td>
          <td>Go 常見服務場景總覽</td>
          <td>看懂 Go 最常落地的即時、背景與事件處理場景</td>
      </tr>
      <tr>
          <td><a href="/blog/go/06-practical/data-access-boundaries/" data-link-title="6.8 高併發下的 Redis 與 SQL 使用原則" data-link-desc="從 Go 服務角度整理 Redis 與 SQL 的高併發存取邊界">6.8</a></td>
          <td>高併發下的 Redis 與 SQL 使用原則</td>
          <td>用 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>、pool 與 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 控制下游壓力</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組的教學主軸">本模組的教學主軸</h2>
<ul>
<li><strong>資料先有語意</strong>：struct 欄位、JSON tag、zero value 與 <code>omitempty</code> 都要表達資料意義。</li>
<li><strong>邊界先小後大</strong>：先用函式與 struct 整理行為，只有在替換、測試或隔離需求出現時才引入 interface。</li>
<li><strong>goroutine 要有生命週期</strong>：背景工作必須能取消、停止與測試；只把工作丟進 <code>go func()</code> 會讓 shutdown、錯誤回報與測試邊界變模糊。</li>
<li><strong>記錄要按用途分流</strong>：log 用於操作診斷，event log 用於事實記錄，repository 用於目前狀態。</li>
<li><strong>架構來自壓力</strong>：domain package、repository port、event envelope 是服務變大後的自然拆分，不是入門程式的預設起點。</li>
</ul>
<h2 id="章節粒度說明">章節粒度說明</h2>
<p>本模組每一章都是「完成一個常見開發任務」的完整流程，所以篇幅會比語法章長。章節會同時包含資料定義、邊界判斷、簡化實作、測試與設計檢查；這是為了讓讀者看到一次修改如何穿過 Go 服務的多個層次。</p>
<p>細節主題會在後續模組拆開深入：</p>
<table>
  <thead>
      <tr>
          <th>本模組任務</th>
          <th>深入章節</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>即時訊息 action</td>
          <td><a href="/blog/go-advanced/02-networking-websocket/" data-link-title="模組二：WebSocket 服務架構" data-link-desc="WebSocket client lifecycle、heartbeat、訂閱路由與慢客戶端管理">WebSocket 服務架構</a></td>
      </tr>
      <tr>
          <td>domain event 與去重</td>
          <td><a href="/blog/go-advanced/04-architecture-boundaries/" data-link-title="模組四：架構邊界與事件系統" data-link-desc="用事件驅動架構拆解事件來源、處理流程、狀態邊界與即時推送">架構邊界與事件系統</a></td>
      </tr>
      <tr>
          <td>狀態投影與 repository</td>
          <td><a href="/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">Source of Truth：狀態邊界</a></td>
      </tr>
      <tr>
          <td>背景 worker 與 shutdown</td>
          <td><a href="/blog/go-advanced/06-production-operations/graceful-shutdown/" data-link-title="6.1 graceful shutdown 與 signal handling" data-link-desc="用 signal 與 context 傳遞停止訊號">graceful shutdown 與 signal handling</a></td>
      </tr>
      <tr>
          <td>structured log 與 event log</td>
          <td><a href="/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">結構化日誌欄位設計</a> 與 <a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Observability pipeline</a></td>
      </tr>
      <tr>
          <td>repository 到資料庫</td>
          <td><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">資料庫 transaction 與 schema migration</a></td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<ul>
<li>即時通知服務的 action route</li>
<li>domain event envelope</li>
<li>任務狀態 <a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a> 更新</li>
<li>背景 worker 啟動與停止</li>
<li>structured log 欄位</li>
<li>repository port 與 memory implementation</li>
</ul>
<h2 id="學習時間">學習時間</h2>
<p>預計 2-3 小時</p>
]]></content:encoded></item><item><title>5.7 錯誤處理與測試在高併發服務中的角色</title><link>https://tarrragon.github.io/blog/go/05-error-testing/service-reliability/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/05-error-testing/service-reliability/</guid><description>&lt;p>高併發服務的可靠性來自錯誤處理與測試共同形成的保護機制。錯誤處理讓失敗路徑可見，測試讓失敗路徑可重現；兩者一起決定系統在壓力下是否仍能被理解、修復與持續交付。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>把錯誤處理看成可靠性的一部分&lt;/li>
&lt;li>區分可恢復錯誤與不可恢復錯誤&lt;/li>
&lt;li>用測試保護失敗路徑與並發路徑&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>、取消與 race condition 能被提早發現&lt;/li>
&lt;li>理解為什麼高併發服務更需要明確的測試邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察錯誤是服務常態">【觀察】錯誤是服務常態&lt;/h2>
&lt;p>在高併發服務裡，錯誤是日常情況的一部分。網路會失敗、下游會超時、資料會不完整、狀態會競爭；這些情境都應進入系統設計，而不是只留給事故發生後人工排查。&lt;/p>
&lt;p>Go 把錯誤放在回傳值中，就是要讓這些常態能被直接看見。&lt;/p>
&lt;h2 id="判讀測試要先保護脆弱邊界">【判讀】測試要先保護脆弱邊界&lt;/h2>
&lt;p>高併發服務最容易出問題的地方，通常是：&lt;/p>
&lt;ul>
&lt;li>HTTP handler 與外部輸入邊界&lt;/li>
&lt;li>goroutine 之間的共享狀態&lt;/li>
&lt;li>timeout 與 cancellation&lt;/li>
&lt;li>event / &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> 的重複或漏處理&lt;/li>
&lt;/ul>
&lt;p>這些邊界都應該有測試。人工驗證可以輔助檢查流程，但它無法穩定重現 timeout、取消、race condition 與高併發失敗路徑。&lt;/p>
&lt;h2 id="策略錯誤路徑也要被測">【策略】錯誤路徑也要被測&lt;/h2>
&lt;p>服務測試需要同時覆蓋成功路徑與失敗路徑。只驗證成功路徑會讓 timeout、下游錯誤、取消與狀態競爭在壓力下才暴露，修復成本會更高。&lt;/p>
&lt;p>你至少應該測：&lt;/p>
&lt;ul>
&lt;li>參數不合法時是否回傳穩定錯誤&lt;/li>
&lt;li>下游失敗時是否有正確的包裝錯誤&lt;/li>
&lt;li>timeout 是否真的會停止工作&lt;/li>
&lt;li>取消 context 後 goroutine 是否退出&lt;/li>
&lt;/ul>
&lt;h2 id="執行並發測試要看資源是否被正確回收">【執行】並發測試要看資源是否被正確回收&lt;/h2>
&lt;p>高併發測試的核心目標是確認資源會被正確回收。跑很多 goroutine 只是製造壓力；真正需要驗證的是：&lt;/p>
&lt;ul>
&lt;li>goroutine 會不會 leak&lt;/li>
&lt;li>channel 會不會卡住&lt;/li>
&lt;li>鎖的範圍是否合理&lt;/li>
&lt;li>資源關閉後流程是否停止&lt;/li>
&lt;/ul>
&lt;p>可靠性測試至少要證明流程能正確結束。只證明「看起來可以跑」會漏掉 goroutine leak、channel 卡住、鎖範圍過大與資源未釋放等問題。&lt;/p></description><content:encoded><![CDATA[<p>高併發服務的可靠性來自錯誤處理與測試共同形成的保護機制。錯誤處理讓失敗路徑可見，測試讓失敗路徑可重現；兩者一起決定系統在壓力下是否仍能被理解、修復與持續交付。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>把錯誤處理看成可靠性的一部分</li>
<li>區分可恢復錯誤與不可恢復錯誤</li>
<li>用測試保護失敗路徑與並發路徑</li>
<li>讓 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>、取消與 race condition 能被提早發現</li>
<li>理解為什麼高併發服務更需要明確的測試邊界</li>
</ol>
<hr>
<h2 id="觀察錯誤是服務常態">【觀察】錯誤是服務常態</h2>
<p>在高併發服務裡，錯誤是日常情況的一部分。網路會失敗、下游會超時、資料會不完整、狀態會競爭；這些情境都應進入系統設計，而不是只留給事故發生後人工排查。</p>
<p>Go 把錯誤放在回傳值中，就是要讓這些常態能被直接看見。</p>
<h2 id="判讀測試要先保護脆弱邊界">【判讀】測試要先保護脆弱邊界</h2>
<p>高併發服務最容易出問題的地方，通常是：</p>
<ul>
<li>HTTP handler 與外部輸入邊界</li>
<li>goroutine 之間的共享狀態</li>
<li>timeout 與 cancellation</li>
<li>event / <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 的重複或漏處理</li>
</ul>
<p>這些邊界都應該有測試。人工驗證可以輔助檢查流程，但它無法穩定重現 timeout、取消、race condition 與高併發失敗路徑。</p>
<h2 id="策略錯誤路徑也要被測">【策略】錯誤路徑也要被測</h2>
<p>服務測試需要同時覆蓋成功路徑與失敗路徑。只驗證成功路徑會讓 timeout、下游錯誤、取消與狀態競爭在壓力下才暴露，修復成本會更高。</p>
<p>你至少應該測：</p>
<ul>
<li>參數不合法時是否回傳穩定錯誤</li>
<li>下游失敗時是否有正確的包裝錯誤</li>
<li>timeout 是否真的會停止工作</li>
<li>取消 context 後 goroutine 是否退出</li>
</ul>
<h2 id="執行並發測試要看資源是否被正確回收">【執行】並發測試要看資源是否被正確回收</h2>
<p>高併發測試的核心目標是確認資源會被正確回收。跑很多 goroutine 只是製造壓力；真正需要驗證的是：</p>
<ul>
<li>goroutine 會不會 leak</li>
<li>channel 會不會卡住</li>
<li>鎖的範圍是否合理</li>
<li>資源關閉後流程是否停止</li>
</ul>
<p>可靠性測試至少要證明流程能正確結束。只證明「看起來可以跑」會漏掉 goroutine leak、channel 卡住、鎖範圍過大與資源未釋放等問題。</p>
]]></content:encoded></item><item><title>6.7 Go 常見服務場景總覽</title><link>https://tarrragon.github.io/blog/go/06-practical/service-scenarios/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/06-practical/service-scenarios/</guid><description>&lt;p>這一章先整理 Go 常見會被用到的服務場景。對剛從 PHP 或 Python 轉來的讀者來說，先知道 Go 最常做哪些事，會更容易理解前面那些語言特性為什麼重要。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>看出 Go 常見的服務落點&lt;/li>
&lt;li>理解每種場景通常需要哪些核心能力&lt;/li>
&lt;li>判斷哪些場景值得進一步用 Go 實作&lt;/li>
&lt;li>把後續的實戰章節放回正確脈絡&lt;/li>
&lt;li>分辨即時服務、背景 worker 與 API service 的不同責任&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察go-很常出現在服務邊界">【觀察】Go 很常出現在服務邊界&lt;/h2>
&lt;p>Go 最常出現在需要長時間運行、協調 I/O、維持清楚邊界的服務層：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>場景&lt;/th>
 &lt;th>Go 常扮演的角色&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &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> service&lt;/td>
 &lt;td>長連線、事件傳遞、訂閱與廣播&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>background worker&lt;/td>
 &lt;td>背景處理、批次同步、事件消費&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>API gateway&lt;/td>
 &lt;td>路由、聚合、驗證與轉送&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>notification system&lt;/td>
 &lt;td>推播、排程與重試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>event processor&lt;/td>
 &lt;td>事件解碼、去重、派送與狀態更新&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這些場景共通點都是：工作量大多是 I/O 與協調，CPU 單點重運算通常只占一小部分。&lt;/p>
&lt;h2 id="判讀即時服務需要穩定的生命週期">【判讀】即時服務需要穩定的生命週期&lt;/h2>
&lt;p>WebSocket、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/sse/" data-link-title="Server-Sent Events (SSE)" data-link-desc="說明 SSE 如何透過 HTTP 長連線向 client 單向推送事件">SSE&lt;/a> 或其他長連線服務，通常需要處理：&lt;/p>
&lt;ul>
&lt;li>連線建立與關閉&lt;/li>
&lt;li>heartbeat&lt;/li>
&lt;li>事件路由&lt;/li>
&lt;li>慢 client&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>這些都和 goroutine、channel、context、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a> 直接相關，所以 Go 很自然會出現在這裡。&lt;/p>
&lt;h2 id="判讀背景-worker-需要明確的停止條件">【判讀】背景 worker 需要明確的停止條件&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a>、cron worker、event relay 或同步流程，通常都需要：&lt;/p>
&lt;ul>
&lt;li>可取消&lt;/li>
&lt;li>可重試&lt;/li>
&lt;li>可觀測&lt;/li>
&lt;li>可限流&lt;/li>
&lt;/ul>
&lt;p>這類工作很適合 Go，因為 runtime、context 與標準庫已經把這些邊界鋪好了。&lt;/p>
&lt;h2 id="策略api-聚合與服務編排">【策略】API 聚合與服務編排&lt;/h2>
&lt;p>有些 Go 服務的主要角色是協調與整合：&lt;/p>
&lt;ul>
&lt;li>把多個下游資料聚合成一個 response&lt;/li>
&lt;li>在多個服務之間轉送 command&lt;/li>
&lt;li>在進入 domain 前先做 request normalization&lt;/li>
&lt;/ul>
&lt;p>這類工作通常會出現在微服務環境裡，Go 的清楚邊界與簡單部署會很有價值。&lt;/p>
&lt;h2 id="小結">小結&lt;/h2>
&lt;p>Go 的常見場景很少是「單純做一個頁面」，更多是即時、背景、事件、聚合與服務邊界。先知道 Go 常被放在哪裡，後面的實戰章節就會更容易理解。&lt;/p></description><content:encoded><![CDATA[<p>這一章先整理 Go 常見會被用到的服務場景。對剛從 PHP 或 Python 轉來的讀者來說，先知道 Go 最常做哪些事，會更容易理解前面那些語言特性為什麼重要。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>看出 Go 常見的服務落點</li>
<li>理解每種場景通常需要哪些核心能力</li>
<li>判斷哪些場景值得進一步用 Go 實作</li>
<li>把後續的實戰章節放回正確脈絡</li>
<li>分辨即時服務、背景 worker 與 API service 的不同責任</li>
</ol>
<hr>
<h2 id="觀察go-很常出現在服務邊界">【觀察】Go 很常出現在服務邊界</h2>
<p>Go 最常出現在需要長時間運行、協調 I/O、維持清楚邊界的服務層：</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>Go 常扮演的角色</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> service</td>
          <td>長連線、事件傳遞、訂閱與廣播</td>
      </tr>
      <tr>
          <td>background worker</td>
          <td>背景處理、批次同步、事件消費</td>
      </tr>
      <tr>
          <td>API gateway</td>
          <td>路由、聚合、驗證與轉送</td>
      </tr>
      <tr>
          <td>notification system</td>
          <td>推播、排程與重試</td>
      </tr>
      <tr>
          <td>event processor</td>
          <td>事件解碼、去重、派送與狀態更新</td>
      </tr>
  </tbody>
</table>
<p>這些場景共通點都是：工作量大多是 I/O 與協調，CPU 單點重運算通常只占一小部分。</p>
<h2 id="判讀即時服務需要穩定的生命週期">【判讀】即時服務需要穩定的生命週期</h2>
<p>WebSocket、<a href="/blog/backend/knowledge-cards/sse/" data-link-title="Server-Sent Events (SSE)" data-link-desc="說明 SSE 如何透過 HTTP 長連線向 client 單向推送事件">SSE</a> 或其他長連線服務，通常需要處理：</p>
<ul>
<li>連線建立與關閉</li>
<li>heartbeat</li>
<li>事件路由</li>
<li>慢 client</li>
<li><a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a></li>
</ul>
<p>這些都和 goroutine、channel、context、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 直接相關，所以 Go 很自然會出現在這裡。</p>
<h2 id="判讀背景-worker-需要明確的停止條件">【判讀】背景 worker 需要明確的停止條件</h2>
<p><a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a>、cron worker、event relay 或同步流程，通常都需要：</p>
<ul>
<li>可取消</li>
<li>可重試</li>
<li>可觀測</li>
<li>可限流</li>
</ul>
<p>這類工作很適合 Go，因為 runtime、context 與標準庫已經把這些邊界鋪好了。</p>
<h2 id="策略api-聚合與服務編排">【策略】API 聚合與服務編排</h2>
<p>有些 Go 服務的主要角色是協調與整合：</p>
<ul>
<li>把多個下游資料聚合成一個 response</li>
<li>在多個服務之間轉送 command</li>
<li>在進入 domain 前先做 request normalization</li>
</ul>
<p>這類工作通常會出現在微服務環境裡，Go 的清楚邊界與簡單部署會很有價值。</p>
<h2 id="小結">小結</h2>
<p>Go 的常見場景很少是「單純做一個頁面」，更多是即時、背景、事件、聚合與服務邊界。先知道 Go 常被放在哪裡，後面的實戰章節就會更容易理解。</p>
]]></content:encoded></item><item><title>8.7 Cockroach Labs：分散式 SQL 資料庫</title><link>https://tarrragon.github.io/blog/go/08-case-studies/cockroach-labs/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/08-case-studies/cockroach-labs/</guid><description>&lt;p>Cockroach Labs 的案例適合放在 Go 教材裡，因為它把 Go 的工程價值推到很高的門檻：分散式 SQL、交易一致性、可水平擴展、容錯與長期可維護。官方案例直接提到，Go 的 performance、garbage collection 與低入門門檻，是 CockroachDB 的重要選擇原因。&lt;/p>
&lt;h2 id="你應該看什麼">你應該看什麼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.cockroachlabs.com/blog/why-go-was-the-right-choice-for-cockroachdb/">Why Go was the right choice for CockroachDB&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.cockroachlabs.com/docs/stable/why-cockroachdb">Why CockroachDB?&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼&lt;/h2>
&lt;ol>
&lt;li>Go 不只適合 API，也適合超大型資料系統。&lt;/li>
&lt;li>大型系統裡，語言的可讀性與團隊進入門檻很重要。&lt;/li>
&lt;li>Go 在複雜系統中的優勢，常常是讓工程複雜度可控。&lt;/li>
&lt;/ol>
&lt;h2 id="可對照的公開原始碼">可對照的公開原始碼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://github.com/cockroachdb/cockroach">cockroachdb/cockroach&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/cockroachdb/cockroach-go">cockroachdb/cockroach-go&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>這是本模組最值得讀的 repo 之一。你可以對照第七模組的 package 邊界、接口設計與 composition root，理解大型 Go 系統如何組織。&lt;/p></description><content:encoded><![CDATA[<p>Cockroach Labs 的案例適合放在 Go 教材裡，因為它把 Go 的工程價值推到很高的門檻：分散式 SQL、交易一致性、可水平擴展、容錯與長期可維護。官方案例直接提到，Go 的 performance、garbage collection 與低入門門檻，是 CockroachDB 的重要選擇原因。</p>
<h2 id="你應該看什麼">你應該看什麼</h2>
<ul>
<li><a href="https://www.cockroachlabs.com/blog/why-go-was-the-right-choice-for-cockroachdb/">Why Go was the right choice for CockroachDB</a></li>
<li><a href="https://www.cockroachlabs.com/docs/stable/why-cockroachdb">Why CockroachDB?</a></li>
</ul>
<h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼</h2>
<ol>
<li>Go 不只適合 API，也適合超大型資料系統。</li>
<li>大型系統裡，語言的可讀性與團隊進入門檻很重要。</li>
<li>Go 在複雜系統中的優勢，常常是讓工程複雜度可控。</li>
</ol>
<h2 id="可對照的公開原始碼">可對照的公開原始碼</h2>
<ul>
<li><a href="https://github.com/cockroachdb/cockroach">cockroachdb/cockroach</a></li>
<li><a href="https://github.com/cockroachdb/cockroach-go">cockroachdb/cockroach-go</a></li>
</ul>
<p>這是本模組最值得讀的 repo 之一。你可以對照第七模組的 package 邊界、接口設計與 composition root，理解大型 Go 系統如何組織。</p>
]]></content:encoded></item><item><title>1.7 從入口程式看應用啟動流程</title><link>https://tarrragon.github.io/blog/go/01-basics/main-flow/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/01-basics/main-flow/</guid><description>&lt;p>入口程式是 Go 應用的系統地圖。它不一定包含最多細節，但應該讓你知道 process 如何初始化、哪些 goroutine 會啟動、HTTP endpoint 如何註冊，以及程式如何關閉。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>用啟動流程理解 Go 應用結構&lt;/li>
&lt;li>看懂 channel 與元件之間的資料流&lt;/li>
&lt;li>理解 &lt;code>context.WithCancel&lt;/code> 在關閉流程中的角色&lt;/li>
&lt;li>判斷新增功能應該接在哪個生命週期位置&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察入口流程分成五段">【觀察】入口流程分成五段&lt;/h2>
&lt;p>入口程式的核心責任是揭露應用如何啟動，而不是承載所有實作細節。一個稍具規模的 &lt;code>main()&lt;/code> 可以切成五個區塊：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>區塊&lt;/th>
 &lt;th>責任&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Runtime 與日誌設定&lt;/td>
 &lt;td>設定記憶體限制、初始化 &lt;code>slog&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>環境設定&lt;/td>
 &lt;td>讀取設定檔、環境變數與 port&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>元件組裝&lt;/td>
 &lt;td>建立 repository、worker、service 或 server&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>背景工作&lt;/td>
 &lt;td>啟動 worker、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer&lt;/a> 或定時任務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>對外介面&lt;/td>
 &lt;td>註冊 CLI command、HTTP endpoint 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> route&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這種切法讓入口同時保留完整脈絡，又不把所有實作細節塞進 &lt;code>main()&lt;/code>。&lt;/p>
&lt;h2 id="判讀main-的價值是揭露依賴關係">【判讀】&lt;code>main()&lt;/code> 的價值是揭露依賴關係&lt;/h2>
&lt;p>&lt;code>main()&lt;/code> 的核心價值是讓依賴關係可見。Go 專案常把依賴直接組裝在 &lt;code>main()&lt;/code>，好處是維護者能直接看到應用骨架：&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="nx">events&lt;/span> &lt;span class="o">:=&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">Event&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">128&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">notifications&lt;/span> &lt;span class="o">:=&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">Notification&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">128&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>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="nx">repo&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewNotificationRepository&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="nx">worker&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewWorker&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">repo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">events&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">notifications&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">server&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewHTTPServer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">repo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">notifications&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式揭露一個重要事實：&lt;code>repo&lt;/code> 負責保存共享狀態，&lt;code>worker&lt;/code> 負責處理背景事件，&lt;code>server&lt;/code> 負責提供 HTTP 入口。資料如何流動，不需要先查框架設定就能看懂。&lt;/p>
&lt;h2 id="策略用生命週期判斷功能應該放哪裡">【策略】用生命週期判斷功能應該放哪裡&lt;/h2>
&lt;p>新增功能的核心判斷是：先確認它屬於哪一種生命週期，再決定接入位置。新增功能時，先判斷它屬於哪一種生命週期：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>新功能類型&lt;/th>
 &lt;th>接入位置&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>新 HTTP endpoint&lt;/td>
 &lt;td>入口程式註冊 route，實作獨立 handler&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>新背景事件來源&lt;/td>
 &lt;td>新增 channel、worker 或 queue consumer&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>新即時訊息 action&lt;/td>
 &lt;td>message router 或連線管理元件&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>新狀態欄位&lt;/td>
 &lt;td>repository 更新與 model 擴展&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>新診斷能力&lt;/td>
 &lt;td>條件註冊 endpoint 或 &lt;code>slog&lt;/code> 欄位&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這個判斷可以避免把功能塞進錯誤元件，造成後續難測與難改。&lt;/p>
&lt;h2 id="執行完整啟動路徑">【執行】完整啟動路徑&lt;/h2>
&lt;p>啟動路徑的核心用途是提供除錯地圖。一個有背景工作與 HTTP 介面的 Go 應用，啟動後的主要路徑可能如下：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">main()
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> ├─ setup logger / runtime
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> ├─ create channels
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> ├─ create repository / worker / server
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> ├─ context.WithCancel()
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> ├─ go worker.Run(ctx)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> ├─ go startPeriodicSync(ctx)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> ├─ go server.Run(ctx)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> ├─ register /health
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> ├─ register /ws
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> ├─ register /events
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> ├─ go waitForShutdown(cancel, ...)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> └─ http.Server.ListenAndServe()&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這份路徑也是除錯清單。當應用沒有產生預期輸出時，可以依序確認：輸入來源是否產生資料、worker 是否處理資料、對外介面是否有 client 或呼叫者、狀態資料是否被正確更新。&lt;/p></description><content:encoded><![CDATA[<p>入口程式是 Go 應用的系統地圖。它不一定包含最多細節，但應該讓你知道 process 如何初始化、哪些 goroutine 會啟動、HTTP endpoint 如何註冊，以及程式如何關閉。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>用啟動流程理解 Go 應用結構</li>
<li>看懂 channel 與元件之間的資料流</li>
<li>理解 <code>context.WithCancel</code> 在關閉流程中的角色</li>
<li>判斷新增功能應該接在哪個生命週期位置</li>
</ol>
<hr>
<h2 id="觀察入口流程分成五段">【觀察】入口流程分成五段</h2>
<p>入口程式的核心責任是揭露應用如何啟動，而不是承載所有實作細節。一個稍具規模的 <code>main()</code> 可以切成五個區塊：</p>
<table>
  <thead>
      <tr>
          <th>區塊</th>
          <th>責任</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Runtime 與日誌設定</td>
          <td>設定記憶體限制、初始化 <code>slog</code></td>
      </tr>
      <tr>
          <td>環境設定</td>
          <td>讀取設定檔、環境變數與 port</td>
      </tr>
      <tr>
          <td>元件組裝</td>
          <td>建立 repository、worker、service 或 server</td>
      </tr>
      <tr>
          <td>背景工作</td>
          <td>啟動 worker、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 或定時任務</td>
      </tr>
      <tr>
          <td>對外介面</td>
          <td>註冊 CLI command、HTTP endpoint 或 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> route</td>
      </tr>
  </tbody>
</table>
<p>這種切法讓入口同時保留完整脈絡，又不把所有實作細節塞進 <code>main()</code>。</p>
<h2 id="判讀main-的價值是揭露依賴關係">【判讀】<code>main()</code> 的價值是揭露依賴關係</h2>
<p><code>main()</code> 的核心價值是讓依賴關係可見。Go 專案常把依賴直接組裝在 <code>main()</code>，好處是維護者能直接看到應用骨架：</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="nx">events</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Event</span><span class="p">,</span> <span class="mi">128</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">notifications</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="nx">Notification</span><span class="p">,</span> <span class="mi">128</span><span class="p">)</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">repo</span> <span class="o">:=</span> <span class="nf">NewNotificationRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">worker</span> <span class="o">:=</span> <span class="nf">NewWorker</span><span class="p">(</span><span class="nx">repo</span><span class="p">,</span> <span class="nx">events</span><span class="p">,</span> <span class="nx">notifications</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nx">server</span> <span class="o">:=</span> <span class="nf">NewHTTPServer</span><span class="p">(</span><span class="nx">repo</span><span class="p">,</span> <span class="nx">notifications</span><span class="p">)</span></span></span></code></pre></div><p>這段程式揭露一個重要事實：<code>repo</code> 負責保存共享狀態，<code>worker</code> 負責處理背景事件，<code>server</code> 負責提供 HTTP 入口。資料如何流動，不需要先查框架設定就能看懂。</p>
<h2 id="策略用生命週期判斷功能應該放哪裡">【策略】用生命週期判斷功能應該放哪裡</h2>
<p>新增功能的核心判斷是：先確認它屬於哪一種生命週期，再決定接入位置。新增功能時，先判斷它屬於哪一種生命週期：</p>
<table>
  <thead>
      <tr>
          <th>新功能類型</th>
          <th>接入位置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>新 HTTP endpoint</td>
          <td>入口程式註冊 route，實作獨立 handler</td>
      </tr>
      <tr>
          <td>新背景事件來源</td>
          <td>新增 channel、worker 或 queue consumer</td>
      </tr>
      <tr>
          <td>新即時訊息 action</td>
          <td>message router 或連線管理元件</td>
      </tr>
      <tr>
          <td>新狀態欄位</td>
          <td>repository 更新與 model 擴展</td>
      </tr>
      <tr>
          <td>新診斷能力</td>
          <td>條件註冊 endpoint 或 <code>slog</code> 欄位</td>
      </tr>
  </tbody>
</table>
<p>這個判斷可以避免把功能塞進錯誤元件，造成後續難測與難改。</p>
<h2 id="執行完整啟動路徑">【執行】完整啟動路徑</h2>
<p>啟動路徑的核心用途是提供除錯地圖。一個有背景工作與 HTTP 介面的 Go 應用，啟動後的主要路徑可能如下：</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">main()
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  ├─ setup logger / runtime
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  ├─ create channels
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  ├─ create repository / worker / server
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  ├─ context.WithCancel()
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  ├─ go worker.Run(ctx)
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  ├─ go startPeriodicSync(ctx)
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  ├─ go server.Run(ctx)
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  ├─ register /health
</span></span><span class="line"><span class="ln">10</span><span class="cl">  ├─ register /ws
</span></span><span class="line"><span class="ln">11</span><span class="cl">  ├─ register /events
</span></span><span class="line"><span class="ln">12</span><span class="cl">  ├─ go waitForShutdown(cancel, ...)
</span></span><span class="line"><span class="ln">13</span><span class="cl">  └─ http.Server.ListenAndServe()</span></span></code></pre></div><p>這份路徑也是除錯清單。當應用沒有產生預期輸出時，可以依序確認：輸入來源是否產生資料、worker 是否處理資料、對外介面是否有 client 或呼叫者、狀態資料是否被正確更新。</p>
]]></content:encoded></item><item><title>2.7 generics 入門：型別參數與約束</title><link>https://tarrragon.github.io/blog/go/02-types-data/generics-basics/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/02-types-data/generics-basics/</guid><description>&lt;p>generics 的核心用途是讓重複的型別安全邏輯可以被抽出來。Go 的泛型適合資料結構、集合 helper、測試工具與少量演算法；一般 application flow 仍應優先使用具體型別與小介面。&lt;/p>
&lt;h2 id="預計補充內容">預計補充內容&lt;/h2>
&lt;p>這些型別系統邊界會在下列章節展開：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">Go 入門：interface：用行為定義依賴&lt;/a>：先看 interface 與具體型別的邊界，才能判斷什麼情況值得引入 generics。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/table-driven-test/" data-link-title="5.3 table-driven test" data-link-desc="用表格整理多組輸入、預期輸出與錯誤情境">Go 入門：table-driven test&lt;/a>：泛型 helper 常常是給測試工具用的，這裡會看到它怎麼支撐重複案例。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/allocation/" data-link-title="3.4 資料結構與 allocation 壓力" data-link-desc="分析列表、歷史資料與 WebSocket payload 的配置成本">Go 進階：資料結構與 allocation 壓力&lt;/a>：當泛型影響配置與熱路徑時，才需要往 runtime 成本那層看。&lt;/li>
&lt;/ul>
&lt;h2 id="與-backend-教材的分工">與 Backend 教材的分工&lt;/h2>
&lt;p>本章只處理 Go 型別系統。資料庫 row mapping、serialization schema 或外部 protocol code generation 會放在 Backend 或實戰章節中討論。&lt;/p>
&lt;h2 id="和-go-教材的關係">和 Go 教材的關係&lt;/h2>
&lt;p>這一章承接的是集合操作、型別約束與泛型應用；如果你要先回看語言教材，可以讀：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/02-types-data/slices-maps/" data-link-title="2.2 slice 與 map" data-link-desc="掌握 Go 最常用的集合型別：slice 與 map">Go：slice 與 map&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">Go：interface：用行為定義依賴&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/02-types-data/pointers-copy/" data-link-title="2.5 指標與資料複製邊界" data-link-desc="理解指標、slice 與共享狀態的防護策略">Go：指標與資料複製邊界&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>generics 的核心用途是讓重複的型別安全邏輯可以被抽出來。Go 的泛型適合資料結構、集合 helper、測試工具與少量演算法；一般 application flow 仍應優先使用具體型別與小介面。</p>
<h2 id="預計補充內容">預計補充內容</h2>
<p>這些型別系統邊界會在下列章節展開：</p>
<ul>
<li><a href="/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">Go 入門：interface：用行為定義依賴</a>：先看 interface 與具體型別的邊界，才能判斷什麼情況值得引入 generics。</li>
<li><a href="/blog/go/05-error-testing/table-driven-test/" data-link-title="5.3 table-driven test" data-link-desc="用表格整理多組輸入、預期輸出與錯誤情境">Go 入門：table-driven test</a>：泛型 helper 常常是給測試工具用的，這裡會看到它怎麼支撐重複案例。</li>
<li><a href="/blog/go-advanced/03-runtime-profiling/allocation/" data-link-title="3.4 資料結構與 allocation 壓力" data-link-desc="分析列表、歷史資料與 WebSocket payload 的配置成本">Go 進階：資料結構與 allocation 壓力</a>：當泛型影響配置與熱路徑時，才需要往 runtime 成本那層看。</li>
</ul>
<h2 id="與-backend-教材的分工">與 Backend 教材的分工</h2>
<p>本章只處理 Go 型別系統。資料庫 row mapping、serialization schema 或外部 protocol code generation 會放在 Backend 或實戰章節中討論。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是集合操作、型別約束與泛型應用；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/02-types-data/slices-maps/" data-link-title="2.2 slice 與 map" data-link-desc="掌握 Go 最常用的集合型別：slice 與 map">Go：slice 與 map</a></li>
<li><a href="/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">Go：interface：用行為定義依賴</a></li>
<li><a href="/blog/go/02-types-data/pointers-copy/" data-link-title="2.5 指標與資料複製邊界" data-link-desc="理解指標、slice 與共享狀態的防護策略">Go：指標與資料複製邊界</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
</ul>
]]></content:encoded></item><item><title>3.7 context：取消、逾時與生命週期</title><link>https://tarrragon.github.io/blog/go/03-stdlib/context/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/03-stdlib/context/</guid><description>&lt;p>&lt;code>context.Context&lt;/code> 是 Go 用來傳遞取消訊號、逾時與 request-scoped 資訊的標準機制。它的核心用途是讓一串呼叫知道「這件工作是否應該停止」。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解 context 的取消語義&lt;/li>
&lt;li>使用 &lt;code>context.WithCancel&lt;/code>&lt;/li>
&lt;li>使用 &lt;code>context.WithTimeout&lt;/code>&lt;/li>
&lt;li>在 goroutine 和函式呼叫鏈中傳遞 context&lt;/li>
&lt;li>避免把 context 當成一般資料容器&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察context-表示工作生命週期">【觀察】context 表示工作生命週期&lt;/h2>
&lt;p>context 的核心規則是：被取消的 context 代表這件工作不應繼續進行。長時間工作應定期檢查 &lt;code>ctx.Done()&lt;/code>：&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">Run&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="kt">error&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="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">select&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">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Err&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">default&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="nf">doOneStep&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 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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>ctx.Done()&lt;/code> 是一個 channel。當 context 被取消或逾時，這個 channel 會被關閉。&lt;/p>
&lt;h2 id="判讀取消是由上層傳給下層">【判讀】取消是由上層傳給下層&lt;/h2>
&lt;p>context 的方向規則是：上層建立 context，下層接收 context；下層不應保存 context，也不應自行決定整個系統的生命週期。&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">main&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">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cancel&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WithCancel&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Background&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">defer&lt;/span> &lt;span class="nf">cancel&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>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="k">go&lt;/span> &lt;span class="nf">worker&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&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>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="nf">waitForSignal&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="nf">cancel&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>context.Background()&lt;/code> 是根 context。&lt;code>context.WithCancel&lt;/code> 回傳子 context 和 cancel 函式。當 &lt;code>cancel()&lt;/code> 被呼叫，所有使用該 context 的下層工作都會收到停止訊號。&lt;/p>
&lt;h2 id="策略逾時用-withtimeout主動停止用-withcancel">【策略】逾時用 WithTimeout，主動停止用 WithCancel&lt;/h2>
&lt;p>context 建立方式的核心規則是：不知道何時停止但需要手動停止，用 &lt;code>WithCancel&lt;/code>；有明確時間限制，用 &lt;code>WithTimeout&lt;/code>。&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="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cancel&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WithTimeout&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Background&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="mi">2&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 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="nf">cancel&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>&lt;/span>&lt;span class="line">&lt;span class="ln">4&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="nf">fetchData&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">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">5&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&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;p>下層函式應該接收 context：&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">fetchData&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="kt">error&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">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="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewRequestWithContext&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">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MethodGet&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;https://example.com&amp;#34;&lt;/span>&lt;span class="p">,&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"> 3&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="kc">nil&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="nx">err&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">resp&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">DefaultClient&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Do&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">req&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">if&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"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">err&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &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">defer&lt;/span> &lt;span class="nx">resp&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Close&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="kc">nil&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>當 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a> 到達，HTTP request 會被取消。&lt;/p>
&lt;h2 id="執行讓背景-goroutine-有序退出">【執行】讓背景 goroutine 有序退出&lt;/h2>
&lt;p>背景 goroutine 的核心規則是：啟動時接收 context，迴圈中用 &lt;code>select&lt;/code> 同時等待工作與取消訊號。&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">worker&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">jobs&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="nx">Job&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="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">select&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">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">case&lt;/span> &lt;span class="nx">job&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ok&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">jobs&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">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">ok&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &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="nf">handleJob&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&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="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>這個 worker 有兩種退出路徑：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>退出原因&lt;/th>
 &lt;th>對應 case&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>上層取消&lt;/td>
 &lt;td>&lt;code>&amp;lt;-ctx.Done()&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>job channel 關閉&lt;/td>
 &lt;td>&lt;code>ok == false&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這比讓 goroutine 無限跑更安全，也比較容易測試。&lt;/p>
&lt;h2 id="設計檢查">設計檢查&lt;/h2>
&lt;h3 id="把-context-存進-struct">把 context 存進 struct&lt;/h3>
&lt;p>context 的生命週期屬於單次操作，不應長期存在 struct 裡。通常把 context 作為函式第一個參數：&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">s&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">Service&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Do&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">input&lt;/span> &lt;span class="nx">Input&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">error&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="忘記呼叫-cancel">忘記呼叫 cancel&lt;/h3>
&lt;p>&lt;code>WithCancel&lt;/code>、&lt;code>WithTimeout&lt;/code>、&lt;code>WithDeadline&lt;/code> 回傳的 cancel 應該被呼叫，釋放相關資源：&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="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cancel&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WithTimeout&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">parent&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Second&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="nf">cancel&lt;/span>&lt;span class="p">()&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="用-context-傳一般參數">用 context 傳一般參數&lt;/h3>
&lt;p>context value 適合 request-scoped metadata，例如 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/request-id/" data-link-title="Request ID" data-link-desc="說明單次 request 的識別碼如何支援 log 搜尋與問題定位">request ID&lt;/a>。一般業務參數應放在函式參數或 struct 裡。&lt;/p></description><content:encoded><![CDATA[<p><code>context.Context</code> 是 Go 用來傳遞取消訊號、逾時與 request-scoped 資訊的標準機制。它的核心用途是讓一串呼叫知道「這件工作是否應該停止」。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解 context 的取消語義</li>
<li>使用 <code>context.WithCancel</code></li>
<li>使用 <code>context.WithTimeout</code></li>
<li>在 goroutine 和函式呼叫鏈中傳遞 context</li>
<li>避免把 context 當成一般資料容器</li>
</ol>
<hr>
<h2 id="觀察context-表示工作生命週期">【觀察】context 表示工作生命週期</h2>
<p>context 的核心規則是：被取消的 context 代表這件工作不應繼續進行。長時間工作應定期檢查 <code>ctx.Done()</code>：</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">Run</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="kt">error</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</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">ctx</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nf">doOneStep</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="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>ctx.Done()</code> 是一個 channel。當 context 被取消或逾時，這個 channel 會被關閉。</p>
<h2 id="判讀取消是由上層傳給下層">【判讀】取消是由上層傳給下層</h2>
<p>context 的方向規則是：上層建立 context，下層接收 context；下層不應保存 context，也不應自行決定整個系統的生命週期。</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">main</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">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithCancel</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</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="nf">cancel</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">go</span> <span class="nf">worker</span><span class="p">(</span><span class="nx">ctx</span><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="nf">waitForSignal</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="nf">cancel</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>context.Background()</code> 是根 context。<code>context.WithCancel</code> 回傳子 context 和 cancel 函式。當 <code>cancel()</code> 被呼叫，所有使用該 context 的下層工作都會收到停止訊號。</p>
<h2 id="策略逾時用-withtimeout主動停止用-withcancel">【策略】逾時用 WithTimeout，主動停止用 WithCancel</h2>
<p>context 建立方式的核心規則是：不知道何時停止但需要手動停止，用 <code>WithCancel</code>；有明確時間限制，用 <code>WithTimeout</code>。</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="nx">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithTimeout</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="mi">2</span><span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</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="nf">cancel</span><span class="p">()</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="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">fetchData</span><span class="p">(</span><span class="nx">ctx</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">5</span><span class="cl">    <span class="k">return</span> <span class="nx">err</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>下層函式應該接收 context：</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">fetchData</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="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">req</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">http</span><span class="p">.</span><span class="nf">NewRequestWithContext</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">MethodGet</span><span class="p">,</span> <span class="s">&#34;https://example.com&#34;</span><span class="p">,</span> <span class="kc">nil</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="nx">err</span> <span class="o">!=</span> <span class="kc">nil</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="nx">err</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="nx">resp</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">DefaultClient</span><span class="p">.</span><span class="nf">Do</span><span class="p">(</span><span class="nx">req</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</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"> 9</span><span class="cl">        <span class="k">return</span> <span class="nx">err</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="k">defer</span> <span class="nx">resp</span><span class="p">.</span><span class="nx">Body</span><span class="p">.</span><span class="nf">Close</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="kc">nil</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>當 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 到達，HTTP request 會被取消。</p>
<h2 id="執行讓背景-goroutine-有序退出">【執行】讓背景 goroutine 有序退出</h2>
<p>背景 goroutine 的核心規則是：啟動時接收 context，迴圈中用 <code>select</code> 同時等待工作與取消訊號。</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">worker</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">jobs</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="nx">Job</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">case</span> <span class="nx">job</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">jobs</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</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"> 8</span><span class="cl">                <span class="k">return</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="nf">handleJob</span><span class="p">(</span><span class="nx">job</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><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 worker 有兩種退出路徑：</p>
<table>
  <thead>
      <tr>
          <th>退出原因</th>
          <th>對應 case</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>上層取消</td>
          <td><code>&lt;-ctx.Done()</code></td>
      </tr>
      <tr>
          <td>job channel 關閉</td>
          <td><code>ok == false</code></td>
      </tr>
  </tbody>
</table>
<p>這比讓 goroutine 無限跑更安全，也比較容易測試。</p>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="把-context-存進-struct">把 context 存進 struct</h3>
<p>context 的生命週期屬於單次操作，不應長期存在 struct 裡。通常把 context 作為函式第一個參數：</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">s</span> <span class="o">*</span><span class="nx">Service</span><span class="p">)</span> <span class="nf">Do</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">input</span> <span class="nx">Input</span><span class="p">)</span> <span class="kt">error</span></span></span></code></pre></div><h3 id="忘記呼叫-cancel">忘記呼叫 cancel</h3>
<p><code>WithCancel</code>、<code>WithTimeout</code>、<code>WithDeadline</code> 回傳的 cancel 應該被呼叫，釋放相關資源：</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="nx">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithTimeout</span><span class="p">(</span><span class="nx">parent</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</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="nf">cancel</span><span class="p">()</span></span></span></code></pre></div><h3 id="用-context-傳一般參數">用 context 傳一般參數</h3>
<p>context value 適合 request-scoped metadata，例如 <a href="/blog/backend/knowledge-cards/request-id/" data-link-title="Request ID" data-link-desc="說明單次 request 的識別碼如何支援 log 搜尋與問題定位">request ID</a>。一般業務參數應放在函式參數或 struct 裡。</p>
]]></content:encoded></item><item><title>7.7 composition root 與依賴組裝</title><link>https://tarrragon.github.io/blog/go/07-refactoring/composition-root/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/composition-root/</guid><description>&lt;p>composition root 的核心責任是集中建立具體依賴。domain 與 application 應依賴 port；&lt;code>main&lt;/code> 或啟動層負責讀取 config、建立 adapter、組裝 usecase、註冊 handler 與啟動 server。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解 composition root 為什麼要集中在啟動層&lt;/li>
&lt;li>分辨 port、adapter 與 usecase 的組裝責任&lt;/li>
&lt;li>用 typed config 讓 wiring 依賴可讀、可測、可替換&lt;/li>
&lt;li>看懂哪些依賴應在 &lt;code>main&lt;/code> 組裝，哪些不該散在 handler 裡&lt;/li>
&lt;li>讓啟動層只負責「把系統接起來」，不負責業務規則&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察composition-root-是整個應用的接線板">【觀察】composition root 是整個應用的接線板&lt;/h2>
&lt;p>composition root 的核心用途是把具體依賴集中在一個地方建立。這個地方通常是 &lt;code>main()&lt;/code>、&lt;code>cmd/.../main.go&lt;/code> 或啟動層 package。&lt;/p>
&lt;p>當讀者打開入口程式時，應該能直接看到：&lt;/p>
&lt;ul>
&lt;li>config 從哪裡來&lt;/li>
&lt;li>repository 怎麼建立&lt;/li>
&lt;li>publisher / worker / server 怎麼串起來&lt;/li>
&lt;li>哪些 dependency 是 mockable port&lt;/li>
&lt;li>哪些是明確的外部 adapter&lt;/li>
&lt;/ul>
&lt;p>這種集中式 wiring 的好處是：&lt;/p>
&lt;ul>
&lt;li>依賴方向清楚&lt;/li>
&lt;li>測試替身好替換&lt;/li>
&lt;li>啟動問題容易定位&lt;/li>
&lt;li>不會把建構邏輯散落到各個 handler 或 usecase&lt;/li>
&lt;/ul>
&lt;h2 id="判讀dependency-injection-的重點是方向">【判讀】dependency injection 的重點是方向&lt;/h2>
&lt;p>Go 的依賴注入通常不需要框架。真正的重點是：高層只依賴 port，低層在入口被組裝進來。&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">type&lt;/span> &lt;span class="nx">App&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">jobs&lt;/span> &lt;span class="nx">JobRepository&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">log&lt;/span> &lt;span class="nx">EventLog&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="kd">func&lt;/span> &lt;span class="nf">NewApp&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">jobs&lt;/span> &lt;span class="nx">JobRepository&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">log&lt;/span> &lt;span class="nx">EventLog&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">App&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">return&lt;/span> &lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">App&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">jobs&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">jobs&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">log&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">log&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>main()&lt;/code> 負責建立具體實作，再傳給 &lt;code>NewApp&lt;/code>：&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">main&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">cfg&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">LoadConfig&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">repo&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewSQLJobRepository&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">cfg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">DatabaseDSN&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="nx">eventLog&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewRedisEventLog&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">cfg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">RedisAddr&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">app&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewApp&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">repo&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">eventLog&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="nx">server&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewHTTPServer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">app&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="nx">log&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">server&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ListenAndServe&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這裡沒有框架，但依賴方向已經清楚：&lt;code>App&lt;/code> 不知道 SQL 或 Redis 是怎麼接的。&lt;/p>
&lt;h2 id="策略typed-config-先收斂設定再進行組裝">【策略】typed config 先收斂設定，再進行組裝&lt;/h2>
&lt;p>composition root 會變亂，通常是因為設定沒有先整理成型別清楚的 config。把環境變數、flag 與預設值先集中讀成結構體，wiring 會清楚很多。&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">Config&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">HTTPAddr&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">DatabaseDSN&lt;/span> &lt;span class="kt">string&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">RedisAddr&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>load config 的責任是把外部輸入變成可預期的程式設定，而不是在每個 adapter 初始化時各自讀環境變數。&lt;/p>
&lt;h2 id="執行建立-adapter-後再注入-usecase">【執行】建立 adapter 後再注入 usecase&lt;/h2>
&lt;p>常見的組裝順序是：&lt;/p>
&lt;ol>
&lt;li>讀 config。&lt;/li>
&lt;li>建立 logger / &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics&lt;/a> / tracer。&lt;/li>
&lt;li>建立 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database&lt;/a> / cache / &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a> client。&lt;/li>
&lt;li>建立 repository 與 service。&lt;/li>
&lt;li>建立 handler 或 server。&lt;/li>
&lt;li>啟動背景 worker 與 HTTP server。&lt;/li>
&lt;/ol>
&lt;p>這樣做可以讓初始化失敗在入口層就被看見，不會等到請求進來才爆。&lt;/p>
&lt;h2 id="判讀組裝邏輯應集中在入口層">【判讀】組裝邏輯應集中在入口層&lt;/h2>
&lt;p>如果 handler 自己 new repository、new client、new worker，就會出現這些問題：&lt;/p>
&lt;ul>
&lt;li>測試無法替換依賴&lt;/li>
&lt;li>生命週期很難控制&lt;/li>
&lt;li>每個 request 都可能建立不必要的資源&lt;/li>
&lt;li>啟動路徑與請求路徑混在一起&lt;/li>
&lt;/ul>
&lt;p>handler 應該只接收已組裝好的依賴，專心處理輸入和回應。&lt;/p>
&lt;h2 id="延伸backend-教材負責具體外部服務語意">【延伸】Backend 教材負責具體外部服務語意&lt;/h2>
&lt;p>Go 章節只需要知道依賴怎麼接，真正的外部服務語意留給 Backend 教材：&lt;/p>
&lt;ul>
&lt;li>database client 建立、pool 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a> 語意&lt;/li>
&lt;li>Redis client、pipeline 與 cache 邊界&lt;/li>
&lt;li>broker connection、[durable &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>](/go/backend/knowledge-cards/durable-queue) 與重試&lt;/li>
&lt;li>platform secret、runtime limit 與部署環境&lt;/li>
&lt;/ul>
&lt;p>Go 的 composition root 不需要重複教這些技術，只要把它們正確接上即可。&lt;/p></description><content:encoded><![CDATA[<p>composition root 的核心責任是集中建立具體依賴。domain 與 application 應依賴 port；<code>main</code> 或啟動層負責讀取 config、建立 adapter、組裝 usecase、註冊 handler 與啟動 server。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解 composition root 為什麼要集中在啟動層</li>
<li>分辨 port、adapter 與 usecase 的組裝責任</li>
<li>用 typed config 讓 wiring 依賴可讀、可測、可替換</li>
<li>看懂哪些依賴應在 <code>main</code> 組裝，哪些不該散在 handler 裡</li>
<li>讓啟動層只負責「把系統接起來」，不負責業務規則</li>
</ol>
<hr>
<h2 id="觀察composition-root-是整個應用的接線板">【觀察】composition root 是整個應用的接線板</h2>
<p>composition root 的核心用途是把具體依賴集中在一個地方建立。這個地方通常是 <code>main()</code>、<code>cmd/.../main.go</code> 或啟動層 package。</p>
<p>當讀者打開入口程式時，應該能直接看到：</p>
<ul>
<li>config 從哪裡來</li>
<li>repository 怎麼建立</li>
<li>publisher / worker / server 怎麼串起來</li>
<li>哪些 dependency 是 mockable port</li>
<li>哪些是明確的外部 adapter</li>
</ul>
<p>這種集中式 wiring 的好處是：</p>
<ul>
<li>依賴方向清楚</li>
<li>測試替身好替換</li>
<li>啟動問題容易定位</li>
<li>不會把建構邏輯散落到各個 handler 或 usecase</li>
</ul>
<h2 id="判讀dependency-injection-的重點是方向">【判讀】dependency injection 的重點是方向</h2>
<p>Go 的依賴注入通常不需要框架。真正的重點是：高層只依賴 port，低層在入口被組裝進來。</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">type</span> <span class="nx">App</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">jobs</span> <span class="nx">JobRepository</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">log</span>  <span class="nx">EventLog</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="kd">func</span> <span class="nf">NewApp</span><span class="p">(</span><span class="nx">jobs</span> <span class="nx">JobRepository</span><span class="p">,</span> <span class="nx">log</span> <span class="nx">EventLog</span><span class="p">)</span> <span class="o">*</span><span class="nx">App</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="o">&amp;</span><span class="nx">App</span><span class="p">{</span><span class="nx">jobs</span><span class="p">:</span> <span class="nx">jobs</span><span class="p">,</span> <span class="nx">log</span><span class="p">:</span> <span class="nx">log</span><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>main()</code> 負責建立具體實作，再傳給 <code>NewApp</code>：</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">main</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">cfg</span> <span class="o">:=</span> <span class="nf">LoadConfig</span><span class="p">()</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">repo</span> <span class="o">:=</span> <span class="nf">NewSQLJobRepository</span><span class="p">(</span><span class="nx">cfg</span><span class="p">.</span><span class="nx">DatabaseDSN</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">eventLog</span> <span class="o">:=</span> <span class="nf">NewRedisEventLog</span><span class="p">(</span><span class="nx">cfg</span><span class="p">.</span><span class="nx">RedisAddr</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">app</span> <span class="o">:=</span> <span class="nf">NewApp</span><span class="p">(</span><span class="nx">repo</span><span class="p">,</span> <span class="nx">eventLog</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">server</span> <span class="o">:=</span> <span class="nf">NewHTTPServer</span><span class="p">(</span><span class="nx">app</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">log</span><span class="p">.</span><span class="nf">Fatal</span><span class="p">(</span><span class="nx">server</span><span class="p">.</span><span class="nf">ListenAndServe</span><span class="p">())</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這裡沒有框架，但依賴方向已經清楚：<code>App</code> 不知道 SQL 或 Redis 是怎麼接的。</p>
<h2 id="策略typed-config-先收斂設定再進行組裝">【策略】typed config 先收斂設定，再進行組裝</h2>
<p>composition root 會變亂，通常是因為設定沒有先整理成型別清楚的 config。把環境變數、flag 與預設值先集中讀成結構體，wiring 會清楚很多。</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">Config</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">HTTPAddr</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">DatabaseDSN</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">RedisAddr</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>load config 的責任是把外部輸入變成可預期的程式設定，而不是在每個 adapter 初始化時各自讀環境變數。</p>
<h2 id="執行建立-adapter-後再注入-usecase">【執行】建立 adapter 後再注入 usecase</h2>
<p>常見的組裝順序是：</p>
<ol>
<li>讀 config。</li>
<li>建立 logger / <a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a> / tracer。</li>
<li>建立 <a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a> / cache / <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> client。</li>
<li>建立 repository 與 service。</li>
<li>建立 handler 或 server。</li>
<li>啟動背景 worker 與 HTTP server。</li>
</ol>
<p>這樣做可以讓初始化失敗在入口層就被看見，不會等到請求進來才爆。</p>
<h2 id="判讀組裝邏輯應集中在入口層">【判讀】組裝邏輯應集中在入口層</h2>
<p>如果 handler 自己 new repository、new client、new worker，就會出現這些問題：</p>
<ul>
<li>測試無法替換依賴</li>
<li>生命週期很難控制</li>
<li>每個 request 都可能建立不必要的資源</li>
<li>啟動路徑與請求路徑混在一起</li>
</ul>
<p>handler 應該只接收已組裝好的依賴，專心處理輸入和回應。</p>
<h2 id="延伸backend-教材負責具體外部服務語意">【延伸】Backend 教材負責具體外部服務語意</h2>
<p>Go 章節只需要知道依賴怎麼接，真正的外部服務語意留給 Backend 教材：</p>
<ul>
<li>database client 建立、pool 與 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 語意</li>
<li>Redis client、pipeline 與 cache 邊界</li>
<li>broker connection、[durable <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>](/go/backend/knowledge-cards/durable-queue) 與重試</li>
<li>platform secret、runtime limit 與部署環境</li>
</ul>
<p>Go 的 composition root 不需要重複教這些技術，只要把它們正確接上即可。</p>
<h2 id="與-backend-教材的分工">與 Backend 教材的分工</h2>
<p>本章處理 Go 程式如何組裝依賴。資料庫連線池、Redis client、broker connection、container secret 與平台設定會放在 Backend 對應模組；Go 章節只保留「誰依賴誰」與「在哪裡組裝」的設計。</p>
]]></content:encoded></item><item><title>模組七：重構實戰</title><link>https://tarrragon.github.io/blog/go/07-refactoring/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/</guid><description>&lt;p>Go 重構的核心目標是讓邊界更清楚、測試更直接、資料競爭更少。抽象只在它能降低耦合或保護行為時才有價值；本模組用一般 Go 服務範例說明如何在程式仍可運行的前提下，從平面檔案結構逐步走向更清楚的 package、interface、state 與 adapter 邊界。&lt;/p>
&lt;p>重構章節的主軸是「壓力出現後再拆分」。小型 Go 程式可以保持簡單；當 handler 過重、狀態外洩、測試困難、事件語意混亂或外部依賴變多時，再逐步引入 domain-oriented package 與 ports/adapters。這種順序比一開始套用完整分層架構更符合 Go 的工程習慣。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&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;a href="https://tarrragon.github.io/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">7.1&lt;/a>&lt;/td>
 &lt;td>把 handler 邏輯拆成可測單元&lt;/td>
 &lt;td>分離協定處理與業務邏輯&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">7.2&lt;/a>&lt;/td>
 &lt;td>用 interface 隔離外部依賴&lt;/td>
 &lt;td>建立小而穩定的測試替身&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/dedup-refactor/" data-link-title="7.3 事件去重邏輯的重構策略" data-link-desc="保留語義鍵並降低重複流程">7.3&lt;/a>&lt;/td>
 &lt;td>事件去重邏輯的重構策略&lt;/td>
 &lt;td>保留語義鍵，降低重複流程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">7.4&lt;/a>&lt;/td>
 &lt;td>狀態管理的安全邊界&lt;/td>
 &lt;td>用複製與鎖保護共享資料&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">7.5&lt;/a>&lt;/td>
 &lt;td>以 domain 重新整理 package&lt;/td>
 &lt;td>讓 account、job、event、workflow 這類語意邊界可見&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">7.6&lt;/a>&lt;/td>
 &lt;td>逐步遷移到 ports/adapters 架構&lt;/td>
 &lt;td>用 ports/adapters 控制依賴方向&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">7.7&lt;/a>&lt;/td>
 &lt;td>composition root 與依賴組裝&lt;/td>
 &lt;td>把具體 adapter、config 與 usecase wiring 留在入口層&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/pressure-driven-refactor/" data-link-title="7.8 壓力出現後的重構路線" data-link-desc="當 Go 服務變大時，如何按壓力逐步重構邊界">7.8&lt;/a>&lt;/td>
 &lt;td>壓力出現後的重構路線&lt;/td>
 &lt;td>按壓力逐步拆邊界，讓服務變大仍可維護&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組的重構判斷">本模組的重構判斷&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>先保行為，再搬結構&lt;/strong>：每次重構都要有測試或可觀察行為保護。&lt;/li>
&lt;li>&lt;strong>package 代表語意邊界&lt;/strong>：清楚的 domain 名稱能讓責任可見；&lt;code>utils&lt;/code>、&lt;code>common&lt;/code> 這類技術分類容易把不同概念混在一起。&lt;/li>
&lt;li>&lt;strong>interface 由使用端定義&lt;/strong>：usecase 需要什麼能力，就定義什麼 port。&lt;/li>
&lt;li>&lt;strong>state 要有擁有者&lt;/strong>：共享 map、slice、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a> 必須集中寫入並保護 copy boundary。&lt;/li>
&lt;li>&lt;strong>架構不是目錄模板&lt;/strong>：ports/adapters 的重點是依賴方向，不是固定資料夾名稱。&lt;/li>
&lt;/ul>
&lt;h2 id="章節粒度說明">章節粒度說明&lt;/h2>
&lt;p>重構章節刻意維持「一章一條遷移路線」。每章會先說明壓力訊號，再給局部重構策略、測試保護、設計檢查與延伸範圍。這種安排讓讀者能照著章節做小步遷移；完整架構模板應在遷移壓力明確後再引入。&lt;/p>
&lt;p>如果只想查單一概念，可以依照下列對照閱讀：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>重構問題&lt;/th>
 &lt;th>優先閱讀&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>handler 太厚&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">把 handler 邏輯拆成可測單元&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>外部依賴難測&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">用 interface 隔離外部依賴&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>事件重複或來源變多&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/dedup-refactor/" data-link-title="7.3 事件去重邏輯的重構策略" data-link-desc="保留語義鍵並降低重複流程">事件去重邏輯的重構策略&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>共享狀態外洩&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">狀態管理的安全邊界&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>檔案平面結構失去語意&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">以 domain 重新整理 package&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>依賴方向需要穩定&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">逐步遷移到 ports/adapters 架構&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>抽出 port 後不知道在哪裡 new adapter&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">composition root 與依賴組裝&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;ul>
&lt;li>事件處理流程重構&lt;/li>
&lt;li>共享狀態重構&lt;/li>
&lt;li>查詢介面邊界&lt;/li>
&lt;li>feature gate 邏輯&lt;/li>
&lt;li>domain package 切分&lt;/li>
&lt;li>inbound/outbound adapter 遷移&lt;/li>
&lt;li>composition root 與依賴組裝&lt;/li>
&lt;/ul>
&lt;h2 id="學習時間">學習時間&lt;/h2>
&lt;p>預計 2.5 小時&lt;/p></description><content:encoded><![CDATA[<p>Go 重構的核心目標是讓邊界更清楚、測試更直接、資料競爭更少。抽象只在它能降低耦合或保護行為時才有價值；本模組用一般 Go 服務範例說明如何在程式仍可運行的前提下，從平面檔案結構逐步走向更清楚的 package、interface、state 與 adapter 邊界。</p>
<p>重構章節的主軸是「壓力出現後再拆分」。小型 Go 程式可以保持簡單；當 handler 過重、狀態外洩、測試困難、事件語意混亂或外部依賴變多時，再逐步引入 domain-oriented package 與 ports/adapters。這種順序比一開始套用完整分層架構更符合 Go 的工程習慣。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">7.1</a></td>
          <td>把 handler 邏輯拆成可測單元</td>
          <td>分離協定處理與業務邏輯</td>
      </tr>
      <tr>
          <td><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">7.2</a></td>
          <td>用 interface 隔離外部依賴</td>
          <td>建立小而穩定的測試替身</td>
      </tr>
      <tr>
          <td><a href="/blog/go/07-refactoring/dedup-refactor/" data-link-title="7.3 事件去重邏輯的重構策略" data-link-desc="保留語義鍵並降低重複流程">7.3</a></td>
          <td>事件去重邏輯的重構策略</td>
          <td>保留語義鍵，降低重複流程</td>
      </tr>
      <tr>
          <td><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">7.4</a></td>
          <td>狀態管理的安全邊界</td>
          <td>用複製與鎖保護共享資料</td>
      </tr>
      <tr>
          <td><a href="/blog/go/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">7.5</a></td>
          <td>以 domain 重新整理 package</td>
          <td>讓 account、job、event、workflow 這類語意邊界可見</td>
      </tr>
      <tr>
          <td><a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">7.6</a></td>
          <td>逐步遷移到 ports/adapters 架構</td>
          <td>用 ports/adapters 控制依賴方向</td>
      </tr>
      <tr>
          <td><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">7.7</a></td>
          <td>composition root 與依賴組裝</td>
          <td>把具體 adapter、config 與 usecase wiring 留在入口層</td>
      </tr>
      <tr>
          <td><a href="/blog/go/07-refactoring/pressure-driven-refactor/" data-link-title="7.8 壓力出現後的重構路線" data-link-desc="當 Go 服務變大時，如何按壓力逐步重構邊界">7.8</a></td>
          <td>壓力出現後的重構路線</td>
          <td>按壓力逐步拆邊界，讓服務變大仍可維護</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組的重構判斷">本模組的重構判斷</h2>
<ul>
<li><strong>先保行為，再搬結構</strong>：每次重構都要有測試或可觀察行為保護。</li>
<li><strong>package 代表語意邊界</strong>：清楚的 domain 名稱能讓責任可見；<code>utils</code>、<code>common</code> 這類技術分類容易把不同概念混在一起。</li>
<li><strong>interface 由使用端定義</strong>：usecase 需要什麼能力，就定義什麼 port。</li>
<li><strong>state 要有擁有者</strong>：共享 map、slice、<a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a> 必須集中寫入並保護 copy boundary。</li>
<li><strong>架構不是目錄模板</strong>：ports/adapters 的重點是依賴方向，不是固定資料夾名稱。</li>
</ul>
<h2 id="章節粒度說明">章節粒度說明</h2>
<p>重構章節刻意維持「一章一條遷移路線」。每章會先說明壓力訊號，再給局部重構策略、測試保護、設計檢查與延伸範圍。這種安排讓讀者能照著章節做小步遷移；完整架構模板應在遷移壓力明確後再引入。</p>
<p>如果只想查單一概念，可以依照下列對照閱讀：</p>
<table>
  <thead>
      <tr>
          <th>重構問題</th>
          <th>優先閱讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>handler 太厚</td>
          <td><a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">把 handler 邏輯拆成可測單元</a></td>
      </tr>
      <tr>
          <td>外部依賴難測</td>
          <td><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">用 interface 隔離外部依賴</a></td>
      </tr>
      <tr>
          <td>事件重複或來源變多</td>
          <td><a href="/blog/go/07-refactoring/dedup-refactor/" data-link-title="7.3 事件去重邏輯的重構策略" data-link-desc="保留語義鍵並降低重複流程">事件去重邏輯的重構策略</a></td>
      </tr>
      <tr>
          <td>共享狀態外洩</td>
          <td><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">狀態管理的安全邊界</a></td>
      </tr>
      <tr>
          <td>檔案平面結構失去語意</td>
          <td><a href="/blog/go/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">以 domain 重新整理 package</a></td>
      </tr>
      <tr>
          <td>依賴方向需要穩定</td>
          <td><a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">逐步遷移到 ports/adapters 架構</a></td>
      </tr>
      <tr>
          <td>抽出 port 後不知道在哪裡 new adapter</td>
          <td><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">composition root 與依賴組裝</a></td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<ul>
<li>事件處理流程重構</li>
<li>共享狀態重構</li>
<li>查詢介面邊界</li>
<li>feature gate 邏輯</li>
<li>domain package 切分</li>
<li>inbound/outbound adapter 遷移</li>
<li>composition root 與依賴組裝</li>
</ul>
<h2 id="學習時間">學習時間</h2>
<p>預計 2.5 小時</p>
]]></content:encoded></item><item><title>模組七：跨節點與平台整合</title><link>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/</guid><description>&lt;p>跨節點與平台整合的核心目標是把「單一 Go process 內的正確邊界」延伸到外部基礎設施。前六個模組先建立 goroutine lifecycle、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> 連線、runtime 診斷、事件邊界、測試與操作語意；本模組處理服務進入多節點、多資料來源、多觀測工具與部署平台後會出現的新責任。&lt;/p>
&lt;p>本模組已開始補成正文。章節先定義問題邊界與前置脈絡，再逐步補上 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a>、outbox、跨節點 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a>、observability、部署與可靠性驗證的實作語意；後續仍可依實戰需求繼續擴寫。&lt;/p>
&lt;h2 id="與-backend-教材的分工">與 Backend 教材的分工&lt;/h2>
&lt;p>本模組保留在 Go 進階篇，因為它要回答的是「Go 服務跨出單一 process 前，程式內部需要準備哪些 port、訊號、錯誤語意與測試合約」。具體資料庫、Redis、RabbitMQ、observability、Kubernetes 或 CI 平台操作，會放在跨語言的 &lt;a href="https://tarrragon.github.io/blog/backend/" data-link-title="Backend 服務實務指南" data-link-desc="用跨語言教學路線整理資料庫、快取、訊息佇列、觀測、部署、可靠性、資安、事故與容量等後端服務能力">Backend 服務實務指南&lt;/a>。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>章節&lt;/th>
 &lt;th>主題&lt;/th>
 &lt;th>承接問題&lt;/th>
 &lt;th>Backend 實作&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">7.1&lt;/a>&lt;/td>
 &lt;td>資料庫 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a> 與 schema &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration&lt;/a>&lt;/td>
 &lt;td>狀態邊界進入持久化層後如何維持一致&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">資料庫與持久化&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">7.2&lt;/a>&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/durable-queue/" data-link-title="Durable Queue" data-link-desc="說明可持久化的 queue 如何在重啟與失敗後保留待處理工作">Durable queue&lt;/a>、outbox 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a>&lt;/td>
 &lt;td>事件跨 process 後如何避免遺失、重複與半成功&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">訊息佇列與事件傳遞&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">7.3&lt;/a>&lt;/td>
 &lt;td>跨節點 WebSocket、presence 與重連協定&lt;/td>
 &lt;td>多台 server 如何管理訂閱、推送與連線狀態&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">快取與 Redis&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">訊息佇列&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">7.4&lt;/a>&lt;/td>
 &lt;td>Observability pipeline、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics&lt;/a> 與 tracing&lt;/td>
 &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>、metric、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/trace/" data-link-title="Trace" data-link-desc="說明 trace 如何重建跨服務請求的路徑、耗時與依賴關係">trace&lt;/a> 如何組成可操作的診斷系統&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">可觀測性平台&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">7.5&lt;/a>&lt;/td>
 &lt;td>Kubernetes、systemd 與 load balancer 合約&lt;/td>
 &lt;td>部署平台如何影響 shutdown、health 與資源限制&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">部署平台與網路入口&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">7.6&lt;/a>&lt;/td>
 &lt;td>CI、fuzz、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/load-test/" data-link-title="Load Test" data-link-desc="說明在預期流量下驗證容量、延遲與降級策略的測試">load test&lt;/a> 與 chaos testing&lt;/td>
 &lt;td>測試如何從單一行為擴展到系統可靠性&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">可靠性驗證流程&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組和前面章節的關係">本模組和前面章節的關係&lt;/h2>
&lt;p>本模組適合在你已經理解單一 Go 服務的內部邊界後閱讀，用來補足生產環境常見的外部系統責任。&lt;/p></description><content:encoded><![CDATA[<p>跨節點與平台整合的核心目標是把「單一 Go process 內的正確邊界」延伸到外部基礎設施。前六個模組先建立 goroutine lifecycle、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 連線、runtime 診斷、事件邊界、測試與操作語意；本模組處理服務進入多節點、多資料來源、多觀測工具與部署平台後會出現的新責任。</p>
<p>本模組已開始補成正文。章節先定義問題邊界與前置脈絡，再逐步補上 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a>、outbox、跨節點 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a>、observability、部署與可靠性驗證的實作語意；後續仍可依實戰需求繼續擴寫。</p>
<h2 id="與-backend-教材的分工">與 Backend 教材的分工</h2>
<p>本模組保留在 Go 進階篇，因為它要回答的是「Go 服務跨出單一 process 前，程式內部需要準備哪些 port、訊號、錯誤語意與測試合約」。具體資料庫、Redis、RabbitMQ、observability、Kubernetes 或 CI 平台操作，會放在跨語言的 <a href="/blog/backend/" data-link-title="Backend 服務實務指南" data-link-desc="用跨語言教學路線整理資料庫、快取、訊息佇列、觀測、部署、可靠性、資安、事故與容量等後端服務能力">Backend 服務實務指南</a>。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>承接問題</th>
          <th>Backend 實作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">7.1</a></td>
          <td>資料庫 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 與 schema <a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration</a></td>
          <td>狀態邊界進入持久化層後如何維持一致</td>
          <td><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">資料庫與持久化</a></td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">7.2</a></td>
          <td><a href="/blog/backend/knowledge-cards/durable-queue/" data-link-title="Durable Queue" data-link-desc="說明可持久化的 queue 如何在重啟與失敗後保留待處理工作">Durable queue</a>、outbox 與 <a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a></td>
          <td>事件跨 process 後如何避免遺失、重複與半成功</td>
          <td><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">訊息佇列與事件傳遞</a></td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">7.3</a></td>
          <td>跨節點 WebSocket、presence 與重連協定</td>
          <td>多台 server 如何管理訂閱、推送與連線狀態</td>
          <td><a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">快取與 Redis</a>、<a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">訊息佇列</a></td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">7.4</a></td>
          <td>Observability pipeline、<a href="/blog/backend/knowledge-cards/metrics/" data-link-title="Metrics" data-link-desc="說明指標如何描述服務趨勢、容量與健康狀態">metrics</a> 與 tracing</td>
          <td><a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>、metric、<a href="/blog/backend/knowledge-cards/trace/" data-link-title="Trace" data-link-desc="說明 trace 如何重建跨服務請求的路徑、耗時與依賴關係">trace</a> 如何組成可操作的診斷系統</td>
          <td><a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">可觀測性平台</a></td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">7.5</a></td>
          <td>Kubernetes、systemd 與 load balancer 合約</td>
          <td>部署平台如何影響 shutdown、health 與資源限制</td>
          <td><a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">部署平台與網路入口</a></td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">7.6</a></td>
          <td>CI、fuzz、<a href="/blog/backend/knowledge-cards/load-test/" data-link-title="Load Test" data-link-desc="說明在預期流量下驗證容量、延遲與降級策略的測試">load test</a> 與 chaos testing</td>
          <td>測試如何從單一行為擴展到系統可靠性</td>
          <td><a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">可靠性驗證流程</a></td>
      </tr>
  </tbody>
</table>
<h2 id="本模組和前面章節的關係">本模組和前面章節的關係</h2>
<p>本模組適合在你已經理解單一 Go 服務的內部邊界後閱讀，用來補足生產環境常見的外部系統責任。</p>
<ul>
<li>事件與狀態邊界先讀 <a href="/blog/go-advanced/04-architecture-boundaries/" data-link-title="模組四：架構邊界與事件系統" data-link-desc="用事件驅動架構拆解事件來源、處理流程、狀態邊界與即時推送">模組四：架構邊界與事件系統</a>。</li>
<li>WebSocket lifecycle 先讀 <a href="/blog/go-advanced/02-networking-websocket/" data-link-title="模組二：WebSocket 服務架構" data-link-desc="WebSocket client lifecycle、heartbeat、訂閱路由與慢客戶端管理">模組二：WebSocket 服務架構</a>。</li>
<li>測試可靠性先讀 <a href="/blog/go-advanced/05-testing-reliability/" data-link-title="模組五：測試與可靠性" data-link-desc="時間控制、WebSocket integration test、race check 與 table-driven test">模組五：測試與可靠性</a>。</li>
<li>操作語意先讀 <a href="/blog/go-advanced/06-production-operations/" data-link-title="模組六：生產操作" data-link-desc="graceful shutdown、健康檢查、結構化日誌與 feature gate">模組六：生產操作</a>。</li>
</ul>
<h2 id="學習時間">學習時間</h2>
<p>目前已可作為第一輪正文閱讀，完整學習時間可隨後續擴寫再調整。</p>
]]></content:encoded></item><item><title>6.8 高併發下的 Redis 與 SQL 使用原則</title><link>https://tarrragon.github.io/blog/go/06-practical/data-access-boundaries/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/06-practical/data-access-boundaries/</guid><description>&lt;p>這一章從 Go 服務的角度整理資料存取原則。重點在於：當併發增加時，Go 端要用明確邊界使用 Redis 或 SQL，讓下游維持可承受的請求節奏。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>理解高併發下最常見的資料存取風險&lt;/li>
&lt;li>區分 Redis 與 SQL 各自適合的角色&lt;/li>
&lt;li>用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">connection pool&lt;/a>、&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;li>避免 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cache-stampede/" data-link-title="Cache Stampede" data-link-desc="說明快取同時失效時大量 request 如何壓垮正式來源">cache stampede&lt;/a> 與慢查詢連鎖&lt;/li>
&lt;li>在 Go 服務內設計可控的下游存取邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察go-端要先控住請求節奏">【觀察】Go 端要先控住請求節奏&lt;/h2>
&lt;p>高併發時，資料存取風險通常來自請求節奏超過下游承受能力。你可以有很多 goroutine，但 Redis 與 SQL 不會因為 goroutine 多就自動變快。&lt;/p>
&lt;p>Go 端通常要先做的是：&lt;/p>
&lt;ul>
&lt;li>限制同時對下游發出的請求數&lt;/li>
&lt;li>設定明確 timeout&lt;/li>
&lt;li>避免無限 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out&lt;/a>&lt;/li>
&lt;li>在壓力過高時拒絕新工作&lt;/li>
&lt;/ul>
&lt;h2 id="判讀redis-適合快取狀態與短生命週期資料">【判讀】Redis 適合快取、狀態與短生命週期資料&lt;/h2>
&lt;p>Redis 在 Go 服務裡常見用途包括：&lt;/p>
&lt;ul>
&lt;li>cache&lt;/li>
&lt;li>session&lt;/li>
&lt;li>counter&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">rate limit&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> key&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> / stream&lt;/li>
&lt;/ul>
&lt;p>Go 端使用 Redis 時要注意：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>問題&lt;/th>
 &lt;th>風險&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>熱 key&lt;/td>
 &lt;td>單點壓力過大&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>cache &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/cache-hit-miss/" data-link-title="Cache Hit / Miss" data-link-desc="說明快取命中與未命中如何影響讀取成本與下游壓力">miss&lt;/a> 擁塞&lt;/td>
 &lt;td>大量 goroutine 同時打到後端&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>pipeline 太大&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 與記憶體壓力增加&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>缺少 timeout&lt;/td>
 &lt;td>慢 request 會堆積成連鎖問題&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀sql-適合正式狀態與一致性資料">【判讀】SQL 適合正式狀態與一致性資料&lt;/h2>
&lt;p>SQL 在 Go 服務裡通常承接的是：&lt;/p>
&lt;ul>
&lt;li>最終狀態&lt;/li>
&lt;li>查詢&lt;/li>
&lt;li>交易&lt;/li>
&lt;li>可追蹤資料&lt;/li>
&lt;/ul>
&lt;p>Go 端最重要的原則是共用 &lt;code>*sql.DB&lt;/code>，讓 connection pool 真正發揮作用，並讓每個 query 都有 context 與 timeout。&lt;/p>
&lt;p>需要特別注意的是：&lt;/p>
&lt;ul>
&lt;li>太高的同時連線數會壓垮資料庫&lt;/li>
&lt;li>太長的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a> 會卡住連線池&lt;/li>
&lt;li>慢查詢會把 goroutine 一起拖住&lt;/li>
&lt;/ul>
&lt;h2 id="策略go-端要用邊界保護下游">【策略】Go 端要用邊界保護下游&lt;/h2>
&lt;p>高併發下的資料存取，通常要搭配以下做法：&lt;/p>
&lt;ul>
&lt;li>&lt;code>sql.DB&lt;/code> 與 Redis client 長期共用&lt;/li>
&lt;li>所有操作都帶 &lt;code>context&lt;/code>&lt;/li>
&lt;li>用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool&lt;/a> 或 semaphore 控制同時請求數&lt;/li>
&lt;li>對 cache miss 做去重或保護&lt;/li>
&lt;li>對寫入高峰做批次或排隊&lt;/li>
&lt;/ul>
&lt;p>這些做法是讓高併發系統能長時間穩定運行的基本條件。&lt;/p></description><content:encoded><![CDATA[<p>這一章從 Go 服務的角度整理資料存取原則。重點在於：當併發增加時，Go 端要用明確邊界使用 Redis 或 SQL，讓下游維持可承受的請求節奏。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>理解高併發下最常見的資料存取風險</li>
<li>區分 Redis 與 SQL 各自適合的角色</li>
<li>用 <a href="/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">connection pool</a>、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 與批次策略控制壓力</li>
<li>避免 <a href="/blog/backend/knowledge-cards/cache-stampede/" data-link-title="Cache Stampede" data-link-desc="說明快取同時失效時大量 request 如何壓垮正式來源">cache stampede</a> 與慢查詢連鎖</li>
<li>在 Go 服務內設計可控的下游存取邊界</li>
</ol>
<hr>
<h2 id="觀察go-端要先控住請求節奏">【觀察】Go 端要先控住請求節奏</h2>
<p>高併發時，資料存取風險通常來自請求節奏超過下游承受能力。你可以有很多 goroutine，但 Redis 與 SQL 不會因為 goroutine 多就自動變快。</p>
<p>Go 端通常要先做的是：</p>
<ul>
<li>限制同時對下游發出的請求數</li>
<li>設定明確 timeout</li>
<li>避免無限 <a href="/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out</a></li>
<li>在壓力過高時拒絕新工作</li>
</ul>
<h2 id="判讀redis-適合快取狀態與短生命週期資料">【判讀】Redis 適合快取、狀態與短生命週期資料</h2>
<p>Redis 在 Go 服務裡常見用途包括：</p>
<ul>
<li>cache</li>
<li>session</li>
<li>counter</li>
<li><a href="/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">rate limit</a></li>
<li><a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> key</li>
<li><a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> / stream</li>
</ul>
<p>Go 端使用 Redis 時要注意：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>熱 key</td>
          <td>單點壓力過大</td>
      </tr>
      <tr>
          <td>cache <a href="/blog/backend/knowledge-cards/cache-hit-miss/" data-link-title="Cache Hit / Miss" data-link-desc="說明快取命中與未命中如何影響讀取成本與下游壓力">miss</a> 擁塞</td>
          <td>大量 goroutine 同時打到後端</td>
      </tr>
      <tr>
          <td>pipeline 太大</td>
          <td><a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 與記憶體壓力增加</td>
      </tr>
      <tr>
          <td>缺少 timeout</td>
          <td>慢 request 會堆積成連鎖問題</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀sql-適合正式狀態與一致性資料">【判讀】SQL 適合正式狀態與一致性資料</h2>
<p>SQL 在 Go 服務裡通常承接的是：</p>
<ul>
<li>最終狀態</li>
<li>查詢</li>
<li>交易</li>
<li>可追蹤資料</li>
</ul>
<p>Go 端最重要的原則是共用 <code>*sql.DB</code>，讓 connection pool 真正發揮作用，並讓每個 query 都有 context 與 timeout。</p>
<p>需要特別注意的是：</p>
<ul>
<li>太高的同時連線數會壓垮資料庫</li>
<li>太長的 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 會卡住連線池</li>
<li>慢查詢會把 goroutine 一起拖住</li>
</ul>
<h2 id="策略go-端要用邊界保護下游">【策略】Go 端要用邊界保護下游</h2>
<p>高併發下的資料存取，通常要搭配以下做法：</p>
<ul>
<li><code>sql.DB</code> 與 Redis client 長期共用</li>
<li>所有操作都帶 <code>context</code></li>
<li>用 <a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool</a> 或 semaphore 控制同時請求數</li>
<li>對 cache miss 做去重或保護</li>
<li>對寫入高峰做批次或排隊</li>
</ul>
<p>這些做法是讓高併發系統能長時間穩定運行的基本條件。</p>
]]></content:encoded></item><item><title>7.8 壓力出現後的重構路線</title><link>https://tarrragon.github.io/blog/go/07-refactoring/pressure-driven-refactor/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/pressure-driven-refactor/</guid><description>&lt;p>這一章補的是一個很實際的問題：服務還能跑，但已經不好讀、不好測、不好改時，應該怎麼重構。Go 的重構是先辨認壓力來源，再逐步把邊界拉開。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>辨認 handler、state、interface 與 adapter 的壓力訊號&lt;/li>
&lt;li>了解什麼時候該先拆函式，什麼時候該拆 package&lt;/li>
&lt;li>用小步遷移方式保持行為穩定&lt;/li>
&lt;li>將重構與測試保護綁在一起&lt;/li>
&lt;li>讓服務變大時仍能維持可讀性&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察壓力通常先出現在局部">【觀察】壓力通常先出現在局部&lt;/h2>
&lt;p>Go 服務變大後，最先冒出來的問題通常是局部壓力：&lt;/p>
&lt;ul>
&lt;li>handler 開始太厚&lt;/li>
&lt;li>state 太分散&lt;/li>
&lt;li>event 語意不清&lt;/li>
&lt;li>依賴越來越多&lt;/li>
&lt;li>測試開始很脆弱&lt;/li>
&lt;/ul>
&lt;p>這些訊號表示該重構，但不代表要一次重寫。&lt;/p>
&lt;h2 id="判讀先保行為再搬結構">【判讀】先保行為，再搬結構&lt;/h2>
&lt;p>重構的基本順序應該是：&lt;/p>
&lt;ol>
&lt;li>先讓行為有測試或觀察點&lt;/li>
&lt;li>再把大函式拆成小函式&lt;/li>
&lt;li>再把責任拆到 package 或 interface&lt;/li>
&lt;li>最後才引入更清楚的 adapter 邊界&lt;/li>
&lt;/ol>
&lt;p>這樣可以降低重構風險，也比較符合 Go 的漸進式習慣。&lt;/p>
&lt;h2 id="策略先拆最有壓力的邊界">【策略】先拆最有壓力的邊界&lt;/h2>
&lt;p>最值得先處理的通常是這幾種：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>壓力訊號&lt;/th>
 &lt;th>優先動作&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>handler 過重&lt;/td>
 &lt;td>拆成協定處理與業務函式&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>外部依賴難測&lt;/td>
 &lt;td>抽出小介面&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>state 外洩&lt;/td>
 &lt;td>集中擁有者並控制 copy boundary&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>事件混亂&lt;/td>
 &lt;td>先定義語意，再拆 package&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>依賴耦合太高&lt;/td>
 &lt;td>用 ports/adapters 穩定方向&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這些動作不一定同時做，而是按壓力大小逐步處理。&lt;/p>
&lt;h2 id="執行composition-root-是最後的收斂點">【執行】composition root 是最後的收斂點&lt;/h2>
&lt;p>當系統開始出現明確的 application、domain 與 adapter 時，composition root 會變成依賴組裝的收斂點。重構的目標是讓邏輯邊界與依賴方向更穩定。&lt;/p></description><content:encoded><![CDATA[<p>這一章補的是一個很實際的問題：服務還能跑，但已經不好讀、不好測、不好改時，應該怎麼重構。Go 的重構是先辨認壓力來源，再逐步把邊界拉開。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>辨認 handler、state、interface 與 adapter 的壓力訊號</li>
<li>了解什麼時候該先拆函式，什麼時候該拆 package</li>
<li>用小步遷移方式保持行為穩定</li>
<li>將重構與測試保護綁在一起</li>
<li>讓服務變大時仍能維持可讀性</li>
</ol>
<hr>
<h2 id="觀察壓力通常先出現在局部">【觀察】壓力通常先出現在局部</h2>
<p>Go 服務變大後，最先冒出來的問題通常是局部壓力：</p>
<ul>
<li>handler 開始太厚</li>
<li>state 太分散</li>
<li>event 語意不清</li>
<li>依賴越來越多</li>
<li>測試開始很脆弱</li>
</ul>
<p>這些訊號表示該重構，但不代表要一次重寫。</p>
<h2 id="判讀先保行為再搬結構">【判讀】先保行為，再搬結構</h2>
<p>重構的基本順序應該是：</p>
<ol>
<li>先讓行為有測試或觀察點</li>
<li>再把大函式拆成小函式</li>
<li>再把責任拆到 package 或 interface</li>
<li>最後才引入更清楚的 adapter 邊界</li>
</ol>
<p>這樣可以降低重構風險，也比較符合 Go 的漸進式習慣。</p>
<h2 id="策略先拆最有壓力的邊界">【策略】先拆最有壓力的邊界</h2>
<p>最值得先處理的通常是這幾種：</p>
<table>
  <thead>
      <tr>
          <th>壓力訊號</th>
          <th>優先動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>handler 過重</td>
          <td>拆成協定處理與業務函式</td>
      </tr>
      <tr>
          <td>外部依賴難測</td>
          <td>抽出小介面</td>
      </tr>
      <tr>
          <td>state 外洩</td>
          <td>集中擁有者並控制 copy boundary</td>
      </tr>
      <tr>
          <td>事件混亂</td>
          <td>先定義語意，再拆 package</td>
      </tr>
      <tr>
          <td>依賴耦合太高</td>
          <td>用 ports/adapters 穩定方向</td>
      </tr>
  </tbody>
</table>
<p>這些動作不一定同時做，而是按壓力大小逐步處理。</p>
<h2 id="執行composition-root-是最後的收斂點">【執行】composition root 是最後的收斂點</h2>
<p>當系統開始出現明確的 application、domain 與 adapter 時，composition root 會變成依賴組裝的收斂點。重構的目標是讓邏輯邊界與依賴方向更穩定。</p>
]]></content:encoded></item><item><title>8.8 Stream：Feeds 與 Chat</title><link>https://tarrragon.github.io/blog/go/08-case-studies/stream/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/08-case-studies/stream/</guid><description>&lt;p>Stream 的官方案例很適合教學用途，因為它把 Go 的幾個核心優勢講得很直接：ecosystem、easy onboarding、fast performance、solid support for concurrency 與 productive programming environment。官方案例還特別提到，這讓一個小團隊能支撐超過 5 億使用者的 feeds 與 chat。&lt;/p>
&lt;h2 id="你應該看什麼">你應該看什麼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://go.dev/solutions/stream">Stream case study&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/GetStream/getstream-go">Official Go SDK for Stream&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼&lt;/h2>
&lt;ol>
&lt;li>Go 很適合即時 feed 與 chat 這種高事件量服務。&lt;/li>
&lt;li>小團隊也能利用 Go 把服務做大。&lt;/li>
&lt;li>SDK 與 server-side service 都能用同一套語言思維來維護。&lt;/li>
&lt;/ol>
&lt;h2 id="可對照的公開原始碼">可對照的公開原始碼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://github.com/GetStream/getstream-go">GetStream/getstream-go&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>這個 Go SDK 很適合拿來看 request/response model、client 設計、testing 與 OpenAPI codegen 的邊界。&lt;/p></description><content:encoded><![CDATA[<p>Stream 的官方案例很適合教學用途，因為它把 Go 的幾個核心優勢講得很直接：ecosystem、easy onboarding、fast performance、solid support for concurrency 與 productive programming environment。官方案例還特別提到，這讓一個小團隊能支撐超過 5 億使用者的 feeds 與 chat。</p>
<h2 id="你應該看什麼">你應該看什麼</h2>
<ul>
<li><a href="https://go.dev/solutions/stream">Stream case study</a></li>
<li><a href="https://github.com/GetStream/getstream-go">Official Go SDK for Stream</a></li>
</ul>
<h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼</h2>
<ol>
<li>Go 很適合即時 feed 與 chat 這種高事件量服務。</li>
<li>小團隊也能利用 Go 把服務做大。</li>
<li>SDK 與 server-side service 都能用同一套語言思維來維護。</li>
</ol>
<h2 id="可對照的公開原始碼">可對照的公開原始碼</h2>
<ul>
<li><a href="https://github.com/GetStream/getstream-go">GetStream/getstream-go</a></li>
</ul>
<p>這個 Go SDK 很適合拿來看 request/response model、client 設計、testing 與 OpenAPI codegen 的邊界。</p>
]]></content:encoded></item><item><title>模組八：Go 案例與讀碼路線</title><link>https://tarrragon.github.io/blog/go/08-case-studies/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/08-case-studies/</guid><description>&lt;p>這個模組把前面學到的 Go 能力放回真實世界：哪些公司把 Go 用在什麼服務裡、他們為什麼選 Go、以及公開原始碼長什麼樣子。語法學習完成後，案例能幫讀者把語言能力、服務場景與選型條件對齊。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&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;a href="https://tarrragon.github.io/blog/go/08-case-studies/selection-patterns/" data-link-title="8.0 Go 的選型案例總覽" data-link-desc="用真實案例辨識 Go 常出現的服務選型條件">8.0&lt;/a>&lt;/td>
 &lt;td>Go 的選型案例總覽&lt;/td>
 &lt;td>用服務壓力辨識 Go 常出現的選型條件&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/08-case-studies/google/" data-link-title="8.1 Google：大規模微服務與索引服務" data-link-desc="看 Go 如何支撐 Google 的大規模微服務與資料索引">8.1&lt;/a>&lt;/td>
 &lt;td>Google：大規模微服務與索引服務&lt;/td>
 &lt;td>看懂 Go 如何支撐大規模搜尋與資料處理&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/08-case-studies/paypal/" data-link-title="8.2 PayPal：支付平台與 NoSQL / build pipelines" data-link-desc="看 Go 如何處理支付平台、NoSQL proxy 與內部工程流水線">8.2&lt;/a>&lt;/td>
 &lt;td>PayPal：支付平台與 NoSQL / build pipelines&lt;/td>
 &lt;td>看懂 Go 如何處理複雜系統與多執行緒邊界&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/08-case-studies/dropbox/" data-link-title="8.3 Dropbox：從 Python 遷移到 Go" data-link-desc="看性能關鍵後端如何從 Python 逐步轉向 Go">8.3&lt;/a>&lt;/td>
 &lt;td>Dropbox：從 Python 遷移到 Go&lt;/td>
 &lt;td>看懂性能關鍵後端如何逐步轉向 Go&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/08-case-studies/microsoft/" data-link-title="8.4 Microsoft：雲端基礎設施的一部分" data-link-desc="看 Go 如何支撐雲端基礎設施與平台工具">8.4&lt;/a>&lt;/td>
 &lt;td>Microsoft：雲端基礎設施的一部分&lt;/td>
 &lt;td>看懂 Go 如何支撐 cloud infrastructure&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/08-case-studies/twitch/" data-link-title="8.5 Twitch：直播與聊天室系統" data-link-desc="看 Go 如何服務低延遲、高併發的即時系統">8.5&lt;/a>&lt;/td>
 &lt;td>Twitch：直播與聊天室系統&lt;/td>
 &lt;td>看懂 Go 如何服務低延遲、高併發的即時系統&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/08-case-studies/cloudflare/" data-link-title="8.6 Cloudflare：DNS、SSL 與長連線服務" data-link-desc="看 Go 如何處理大量連線、網路邊界與高延遲環境">8.6&lt;/a>&lt;/td>
 &lt;td>Cloudflare：DNS、SSL 與長連線服務&lt;/td>
 &lt;td>看懂 Go 如何處理網路邊界與大量連線&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/08-case-studies/cockroach-labs/" data-link-title="8.7 Cockroach Labs：分散式 SQL 資料庫" data-link-desc="看 Go 如何支撐分散式資料庫與高一致性系統">8.7&lt;/a>&lt;/td>
 &lt;td>Cockroach Labs：分散式 SQL 資料庫&lt;/td>
 &lt;td>看懂 Go 如何支撐高一致性、高複雜度系統&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/08-case-studies/stream/" data-link-title="8.8 Stream：Feeds 與 Chat" data-link-desc="看 Go 如何支撐 feeds、chat 與即時訊息 SDK">8.8&lt;/a>&lt;/td>
 &lt;td>Stream：Feeds 與 Chat&lt;/td>
 &lt;td>看懂 Go 如何支撐大規模即時訊息服務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/08-case-studies/cloudwego/" data-link-title="8.9 ByteDance / CloudWeGo：微服務基礎設施" data-link-desc="看 Go 如何從單一服務語言沉澱成微服務治理與框架">8.9&lt;/a>&lt;/td>
 &lt;td>ByteDance / CloudWeGo：微服務基礎設施&lt;/td>
 &lt;td>看懂 Go 如何沉澱成微服務治理與框架&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/08-case-studies/high-concurrency-services/" data-link-title="8.10 Go 的高併發服務案例" data-link-desc="從即時服務、邊緣網路與資料平台辨識 Go 的高併發使用情境">8.10&lt;/a>&lt;/td>
 &lt;td>Go 的高併發服務案例&lt;/td>
 &lt;td>從長連線、代理、背景處理與資料服務辨識並發壓力&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/08-case-studies/open-source-code-reading/" data-link-title="8.11 Go 公開原始碼讀碼路線" data-link-desc="用固定順序閱讀成熟 Go 專案的入口、package、並發與測試">8.11&lt;/a>&lt;/td>
 &lt;td>Go 公開原始碼讀碼路線&lt;/td>
 &lt;td>用入口、組裝、邊界、並發 owner 與測試建立讀碼順序&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="案例分類的閱讀方式">案例分類的閱讀方式&lt;/h2>
&lt;p>案例分類的核心原則是先看服務壓力，再看公司名稱。Google、PayPal、Dropbox、Microsoft、Twitch、Cloudflare、Cockroach Labs、Stream 與 CloudWeGo 代表的是不同工程條件：大規模平台、高併發即時服務、效能敏感遷移、分散式資料系統與微服務治理。&lt;/p>
&lt;p>大規模平台案例通常要觀察服務形狀是否一致：入口、設定、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a>、部署與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/health-check-liveness/" data-link-title="Liveness" data-link-desc="說明平台如何判斷 process 是否仍然存活，以及何時應重啟">health check&lt;/a> 是否能被很多團隊共用。高併發即時案例通常要觀察連線是否長時間存在，以及 server 是否需要管理大量 client 狀態。效能敏感遷移案例通常要觀察瓶頸是否集中在清楚邊界。分散式基礎設施案例則要觀察主要問題是否落在多節點協調與可靠性。&lt;/p>
&lt;p>這張表是入口索引。讀每家公司案例時，應回到具體章節對照：選型判斷看 &lt;a href="https://tarrragon.github.io/blog/go/08-case-studies/selection-patterns/" data-link-title="8.0 Go 的選型案例總覽" data-link-desc="用真實案例辨識 Go 常出現的服務選型條件">Go 的選型案例總覽&lt;/a>，並發服務看 &lt;a href="https://tarrragon.github.io/blog/go/08-case-studies/high-concurrency-services/" data-link-title="8.10 Go 的高併發服務案例" data-link-desc="從即時服務、邊緣網路與資料平台辨識 Go 的高併發使用情境">Go 的高併發服務案例&lt;/a>，公開原始碼則依照 &lt;a href="https://tarrragon.github.io/blog/go/08-case-studies/open-source-code-reading/" data-link-title="8.11 Go 公開原始碼讀碼路線" data-link-desc="用固定順序閱讀成熟 Go 專案的入口、package、並發與測試">Go 公開原始碼讀碼路線&lt;/a> 逐層閱讀。&lt;/p></description><content:encoded><![CDATA[<p>這個模組把前面學到的 Go 能力放回真實世界：哪些公司把 Go 用在什麼服務裡、他們為什麼選 Go、以及公開原始碼長什麼樣子。語法學習完成後，案例能幫讀者把語言能力、服務場景與選型條件對齊。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go/08-case-studies/selection-patterns/" data-link-title="8.0 Go 的選型案例總覽" data-link-desc="用真實案例辨識 Go 常出現的服務選型條件">8.0</a></td>
          <td>Go 的選型案例總覽</td>
          <td>用服務壓力辨識 Go 常出現的選型條件</td>
      </tr>
      <tr>
          <td><a href="/blog/go/08-case-studies/google/" data-link-title="8.1 Google：大規模微服務與索引服務" data-link-desc="看 Go 如何支撐 Google 的大規模微服務與資料索引">8.1</a></td>
          <td>Google：大規模微服務與索引服務</td>
          <td>看懂 Go 如何支撐大規模搜尋與資料處理</td>
      </tr>
      <tr>
          <td><a href="/blog/go/08-case-studies/paypal/" data-link-title="8.2 PayPal：支付平台與 NoSQL / build pipelines" data-link-desc="看 Go 如何處理支付平台、NoSQL proxy 與內部工程流水線">8.2</a></td>
          <td>PayPal：支付平台與 NoSQL / build pipelines</td>
          <td>看懂 Go 如何處理複雜系統與多執行緒邊界</td>
      </tr>
      <tr>
          <td><a href="/blog/go/08-case-studies/dropbox/" data-link-title="8.3 Dropbox：從 Python 遷移到 Go" data-link-desc="看性能關鍵後端如何從 Python 逐步轉向 Go">8.3</a></td>
          <td>Dropbox：從 Python 遷移到 Go</td>
          <td>看懂性能關鍵後端如何逐步轉向 Go</td>
      </tr>
      <tr>
          <td><a href="/blog/go/08-case-studies/microsoft/" data-link-title="8.4 Microsoft：雲端基礎設施的一部分" data-link-desc="看 Go 如何支撐雲端基礎設施與平台工具">8.4</a></td>
          <td>Microsoft：雲端基礎設施的一部分</td>
          <td>看懂 Go 如何支撐 cloud infrastructure</td>
      </tr>
      <tr>
          <td><a href="/blog/go/08-case-studies/twitch/" data-link-title="8.5 Twitch：直播與聊天室系統" data-link-desc="看 Go 如何服務低延遲、高併發的即時系統">8.5</a></td>
          <td>Twitch：直播與聊天室系統</td>
          <td>看懂 Go 如何服務低延遲、高併發的即時系統</td>
      </tr>
      <tr>
          <td><a href="/blog/go/08-case-studies/cloudflare/" data-link-title="8.6 Cloudflare：DNS、SSL 與長連線服務" data-link-desc="看 Go 如何處理大量連線、網路邊界與高延遲環境">8.6</a></td>
          <td>Cloudflare：DNS、SSL 與長連線服務</td>
          <td>看懂 Go 如何處理網路邊界與大量連線</td>
      </tr>
      <tr>
          <td><a href="/blog/go/08-case-studies/cockroach-labs/" data-link-title="8.7 Cockroach Labs：分散式 SQL 資料庫" data-link-desc="看 Go 如何支撐分散式資料庫與高一致性系統">8.7</a></td>
          <td>Cockroach Labs：分散式 SQL 資料庫</td>
          <td>看懂 Go 如何支撐高一致性、高複雜度系統</td>
      </tr>
      <tr>
          <td><a href="/blog/go/08-case-studies/stream/" data-link-title="8.8 Stream：Feeds 與 Chat" data-link-desc="看 Go 如何支撐 feeds、chat 與即時訊息 SDK">8.8</a></td>
          <td>Stream：Feeds 與 Chat</td>
          <td>看懂 Go 如何支撐大規模即時訊息服務</td>
      </tr>
      <tr>
          <td><a href="/blog/go/08-case-studies/cloudwego/" data-link-title="8.9 ByteDance / CloudWeGo：微服務基礎設施" data-link-desc="看 Go 如何從單一服務語言沉澱成微服務治理與框架">8.9</a></td>
          <td>ByteDance / CloudWeGo：微服務基礎設施</td>
          <td>看懂 Go 如何沉澱成微服務治理與框架</td>
      </tr>
      <tr>
          <td><a href="/blog/go/08-case-studies/high-concurrency-services/" data-link-title="8.10 Go 的高併發服務案例" data-link-desc="從即時服務、邊緣網路與資料平台辨識 Go 的高併發使用情境">8.10</a></td>
          <td>Go 的高併發服務案例</td>
          <td>從長連線、代理、背景處理與資料服務辨識並發壓力</td>
      </tr>
      <tr>
          <td><a href="/blog/go/08-case-studies/open-source-code-reading/" data-link-title="8.11 Go 公開原始碼讀碼路線" data-link-desc="用固定順序閱讀成熟 Go 專案的入口、package、並發與測試">8.11</a></td>
          <td>Go 公開原始碼讀碼路線</td>
          <td>用入口、組裝、邊界、並發 owner 與測試建立讀碼順序</td>
      </tr>
  </tbody>
</table>
<h2 id="案例分類的閱讀方式">案例分類的閱讀方式</h2>
<p>案例分類的核心原則是先看服務壓力，再看公司名稱。Google、PayPal、Dropbox、Microsoft、Twitch、Cloudflare、Cockroach Labs、Stream 與 CloudWeGo 代表的是不同工程條件：大規模平台、高併發即時服務、效能敏感遷移、分散式資料系統與微服務治理。</p>
<p>大規模平台案例通常要觀察服務形狀是否一致：入口、設定、<a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>、部署與 <a href="/blog/backend/knowledge-cards/health-check-liveness/" data-link-title="Liveness" data-link-desc="說明平台如何判斷 process 是否仍然存活，以及何時應重啟">health check</a> 是否能被很多團隊共用。高併發即時案例通常要觀察連線是否長時間存在，以及 server 是否需要管理大量 client 狀態。效能敏感遷移案例通常要觀察瓶頸是否集中在清楚邊界。分散式基礎設施案例則要觀察主要問題是否落在多節點協調與可靠性。</p>
<p>這張表是入口索引。讀每家公司案例時，應回到具體章節對照：選型判斷看 <a href="/blog/go/08-case-studies/selection-patterns/" data-link-title="8.0 Go 的選型案例總覽" data-link-desc="用真實案例辨識 Go 常出現的服務選型條件">Go 的選型案例總覽</a>，並發服務看 <a href="/blog/go/08-case-studies/high-concurrency-services/" data-link-title="8.10 Go 的高併發服務案例" data-link-desc="從即時服務、邊緣網路與資料平台辨識 Go 的高併發使用情境">Go 的高併發服務案例</a>，公開原始碼則依照 <a href="/blog/go/08-case-studies/open-source-code-reading/" data-link-title="8.11 Go 公開原始碼讀碼路線" data-link-desc="用固定順序閱讀成熟 Go 專案的入口、package、並發與測試">Go 公開原始碼讀碼路線</a> 逐層閱讀。</p>
<h2 id="這個模組的用途">這個模組的用途</h2>
<ul>
<li>幫讀者把 Go 的抽象能力對回真實服務</li>
<li>幫讀者確認 Go 常落在哪些產品與系統邊界</li>
<li>幫讀者建立讀公開原始碼的路線圖</li>
<li>幫讀者把「案例」與「實作細節」連起來</li>
</ul>
<h2 id="建議閱讀順序">建議閱讀順序</h2>
<ol>
<li>先看 Google 與 PayPal，理解大規模服務與複雜平台怎麼選 Go</li>
<li>再看 Dropbox、Microsoft、Twitch、Cloudflare，理解性能、即時與基礎設施場景</li>
<li>接著看 Cockroach Labs、Stream、CloudWeGo，理解更極端的高併發與分散式系統</li>
<li>最後再回頭看自己的服務場景，判斷哪些模式值得借用</li>
</ol>
]]></content:encoded></item><item><title>1.8 Go tooling 與日常開發流程</title><link>https://tarrragon.github.io/blog/go/01-basics/go-tooling-workflow/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/01-basics/go-tooling-workflow/</guid><description>&lt;p>Go tooling 的核心價值是讓日常開發流程標準化。&lt;code>go run&lt;/code>、&lt;code>go test&lt;/code>、&lt;code>go fmt&lt;/code>、&lt;code>go mod tidy&lt;/code>、&lt;code>go build&lt;/code> 是 Go 專案最基本的協作語言。&lt;/p>
&lt;h2 id="預計補充內容">預計補充內容&lt;/h2>
&lt;p>這些工具使用邊界會在下列章節展開：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">Go 入門：從入口程式看應用啟動流程&lt;/a>：先看 &lt;code>go run&lt;/code> 與 &lt;code>go build&lt;/code> 如何對應入口 package，才能理解 Go 專案真正的執行起點。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go 入門：testing 基礎&lt;/a>：先建立最小回歸檢查的習慣，再談 &lt;code>go test ./...&lt;/code> 在流程中的角色。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證流程&lt;/a>：CI 與自動化驗證的責任在這裡展開，不應塞進語言章節。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口&lt;/a>：容器建置、發布門檻與平台合約屬於部署層，不是 toolchain 本身。&lt;/li>
&lt;/ul>
&lt;h2 id="與-backend-教材的分工">與 Backend 教材的分工&lt;/h2>
&lt;p>本章只處理 Go toolchain。&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ci-pipeline/" data-link-title="CI Pipeline" data-link-desc="說明持續整合流程如何在合併前驗證品質與相容性">CI pipeline&lt;/a>、container build、部署前 gate 與 release artifact 會放在 &lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證流程&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口&lt;/a>。&lt;/p>
&lt;h2 id="和-go-教材的關係">和 Go 教材的關係&lt;/h2>
&lt;p>這一章承接的是入口流程、測試與設定讀取；如果你要先回看語言教材，可以讀：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">Go：從入口程式看應用啟動流程&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go：testing 基礎&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/03-stdlib/config-flags-env/" data-link-title="3.9 flag、os/env 與設定邊界" data-link-desc="用標準庫讀取設定，並把外部輸入轉成 config struct">Go：flag、os/env 與設定邊界&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go：composition root 與依賴組裝&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Go tooling 的核心價值是讓日常開發流程標準化。<code>go run</code>、<code>go test</code>、<code>go fmt</code>、<code>go mod tidy</code>、<code>go build</code> 是 Go 專案最基本的協作語言。</p>
<h2 id="預計補充內容">預計補充內容</h2>
<p>這些工具使用邊界會在下列章節展開：</p>
<ul>
<li><a href="/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">Go 入門：從入口程式看應用啟動流程</a>：先看 <code>go run</code> 與 <code>go build</code> 如何對應入口 package，才能理解 Go 專案真正的執行起點。</li>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go 入門：testing 基礎</a>：先建立最小回歸檢查的習慣，再談 <code>go test ./...</code> 在流程中的角色。</li>
<li><a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證流程</a>：CI 與自動化驗證的責任在這裡展開，不應塞進語言章節。</li>
<li><a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a>：容器建置、發布門檻與平台合約屬於部署層，不是 toolchain 本身。</li>
</ul>
<h2 id="與-backend-教材的分工">與 Backend 教材的分工</h2>
<p>本章只處理 Go toolchain。<a href="/blog/backend/knowledge-cards/ci-pipeline/" data-link-title="CI Pipeline" data-link-desc="說明持續整合流程如何在合併前驗證品質與相容性">CI pipeline</a>、container build、部署前 gate 與 release artifact 會放在 <a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證流程</a> 與 <a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a>。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是入口流程、測試與設定讀取；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">Go：從入口程式看應用啟動流程</a></li>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go：testing 基礎</a></li>
<li><a href="/blog/go/03-stdlib/config-flags-env/" data-link-title="3.9 flag、os/env 與設定邊界" data-link-desc="用標準庫讀取設定，並把外部輸入轉成 config struct">Go：flag、os/env 與設定邊界</a></li>
<li><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go：composition root 與依賴組裝</a></li>
</ul>
]]></content:encoded></item><item><title>3.8 defer 與資源清理</title><link>https://tarrragon.github.io/blog/go/03-stdlib/defer-cleanup/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/03-stdlib/defer-cleanup/</guid><description>&lt;p>&lt;code>defer&lt;/code> 的核心用途是把資源清理放在取得資源的附近。檔案、鎖、response body、temporary resource 與測試 cleanup 都適合用 &lt;code>defer&lt;/code> 表達「離開這個 scope 前要完成的事」。&lt;/p>
&lt;h2 id="預計補充內容">預計補充內容&lt;/h2>
&lt;p>這些資源清理邊界會在下列章節展開：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">Go 進階：select loop 的生命週期設計&lt;/a>：長生命週期的 goroutine 會怎麼收尾，和 &lt;code>defer&lt;/code> 的 scope 觀念直接相關。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Go 進階：graceful shutdown 與 signal handling&lt;/a>：當 process 要停下來時，&lt;code>defer&lt;/code> 常常是 cleanup 的最後一道保險。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go 入門：testing 基礎&lt;/a>：測試裡的資源回收與 &lt;code>t.Cleanup&lt;/code>，會比單純 close 更能說清楚責任。&lt;/li>
&lt;/ul>
&lt;h2 id="與-go-進階的關係">與 Go 進階的關係&lt;/h2>
&lt;p>本章建立基本資源清理語感。長時間 worker、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> pump 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown&lt;/a> 會在 &lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">Go 進階：select loop 的生命週期設計&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown 與 signal handling&lt;/a> 中延伸。&lt;/p>
&lt;h2 id="和-go-教材的關係">和 Go 教材的關係&lt;/h2>
&lt;p>這一章承接的是資源生命週期、goroutine 停止與 shutdown；如果你要先回看語言教材，可以讀：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">Go：goroutine：輕量並發工作&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/goroutine-leak/" data-link-title="3.3 goroutine leak 偵測" data-link-desc="判斷背景工作與 client pump 是否正確退出">Go 進階：goroutine leak 偵測&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Go 進階：graceful shutdown 與 signal handling&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p><code>defer</code> 的核心用途是把資源清理放在取得資源的附近。檔案、鎖、response body、temporary resource 與測試 cleanup 都適合用 <code>defer</code> 表達「離開這個 scope 前要完成的事」。</p>
<h2 id="預計補充內容">預計補充內容</h2>
<p>這些資源清理邊界會在下列章節展開：</p>
<ul>
<li><a href="/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">Go 進階：select loop 的生命週期設計</a>：長生命週期的 goroutine 會怎麼收尾，和 <code>defer</code> 的 scope 觀念直接相關。</li>
<li><a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Go 進階：graceful shutdown 與 signal handling</a>：當 process 要停下來時，<code>defer</code> 常常是 cleanup 的最後一道保險。</li>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go 入門：testing 基礎</a>：測試裡的資源回收與 <code>t.Cleanup</code>，會比單純 close 更能說清楚責任。</li>
</ul>
<h2 id="與-go-進階的關係">與 Go 進階的關係</h2>
<p>本章建立基本資源清理語感。長時間 worker、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> pump 與 <a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown</a> 會在 <a href="/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">Go 進階：select loop 的生命週期設計</a> 與 <a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown 與 signal handling</a> 中延伸。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是資源生命週期、goroutine 停止與 shutdown；如果你要先回看語言教材，可以讀：</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/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件</a></li>
<li><a href="/blog/go-advanced/03-runtime-profiling/goroutine-leak/" data-link-title="3.3 goroutine leak 偵測" data-link-desc="判斷背景工作與 client pump 是否正確退出">Go 進階：goroutine leak 偵測</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>
</ul>
]]></content:encoded></item><item><title>模組九：Go 做工具鏈與靜態分析</title><link>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/</guid><description>&lt;p>前八個模組都把 Go 放在後端服務的脈絡下談。這個模組往另一個方向走 — &lt;strong>Go 寫 CLI、lint / migrate 工具、靜態分析、程式碼生成&lt;/strong>。這些程式沒有 HTTP handler、沒有 goroutine pool、沒有 PostgreSQL connection；但同樣享受 Go 的型別安全、標準庫深度與跨平台編譯。&lt;/p>
&lt;p>業界大量這類 Go 程式：hugo（靜態網站產生器）、kubectl / helm（k8s 客戶端）、terraform（基礎設施描述）、gh（GitHub CLI）、goldmark（markdown parser）、stringer / gopls（官方工具鏈）、golangci-lint（linter 集合）。後端工程師轉過去寫工具時會遇到不同的設計約束：沒有長時執行、資料來自檔案而非 request、錯誤處理偏向中斷而非降級、效能瓶頸是 I/O 而非併發。&lt;/p>
&lt;p>本模組以 &lt;code>scripts/mdtools&lt;/code>（blog 本身用來守住 markdown 品質的內部工具鏈）作為 worked example 串連概念。每一章提煉可複用的 Go 技術；mdtools 只是其中一種 concrete instance，讀者能把同樣 pattern 套到自己的工具上。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&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;a href="https://tarrragon.github.io/blog/go/09-tooling-and-analysis/overview/" data-link-title="9.0 Go 在工具鏈生態的位置" data-link-desc="後端服務以外，Go 常被用來寫 CLI、靜態分析、基礎設施客戶端。本章建立工具類 Go 程式跟服務類 Go 程式在結構、生命週期與錯誤處理上的分野">9.0&lt;/a>&lt;/td>
 &lt;td>Go 在工具鏈生態的位置&lt;/td>
 &lt;td>從後端服務切換到工具開發的心態調整；CLI vs service 的結構差異&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/09-tooling-and-analysis/stdlib-flag-subcommands/" data-link-title="9.1 用 stdlib flag 寫 subcommand CLI" data-link-desc="Go 的 flag 套件足以支撐多層 subcommand 的 CLI，不用過早引入 cobra；本章示範 main → cmd/ → internal/ 的標準 layout">9.1&lt;/a>&lt;/td>
 &lt;td>stdlib &lt;code>flag&lt;/code> 做 subcommand CLI&lt;/td>
 &lt;td>&lt;code>main&lt;/code> + &lt;code>cmd/&lt;/code> + &lt;code>internal/&lt;/code> 佈局；&lt;code>flag.NewFlagSet&lt;/code> 分派；什麼時候該上 cobra&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/09-tooling-and-analysis/goldmark-ast-basics/" data-link-title="9.2 第三方 parser 整合：goldmark AST 入門" data-link-desc="用 goldmark 把 markdown 解析成 AST，掌握 ast.Walk visitor 模式、block 與 inline 節點的判讀、byte offset 如何定位到行號">9.2&lt;/a>&lt;/td>
 &lt;td>第三方 parser 整合：goldmark AST 入門&lt;/td>
 &lt;td>&lt;code>ast.Walk&lt;/code> visitor 模式；block vs inline 節點；byte offset 定位&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/09-tooling-and-analysis/ast-idempotent-rewriting/" data-link-title="9.3 AST 驅動的 idempotent 文字改寫" data-link-desc="用 AST 定位位置、用 line-based 或 byte-level 改寫；設計多條 rule 的執行順序；--check 跟 --fix 如何共用邏輯">9.3&lt;/a>&lt;/td>
 &lt;td>AST 驅動的 idempotent 文字改寫&lt;/td>
 &lt;td>多 rule 的執行順序；line-based vs AST-guided 的取捨；&lt;code>--check&lt;/code> / &lt;code>--fix&lt;/code> 雙模式&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/09-tooling-and-analysis/cross-file-graph-analysis/" data-link-title="9.4 跨檔案圖分析：從 lint 走到 static analysis" data-link-desc="Single-file 規則用 AST 搞定；跨檔 orphan 偵測、broken link、backlink 完整性需要把整個 repo 建成圖再走訪。用 mdtools cards 為例">9.4&lt;/a>&lt;/td>
 &lt;td>跨檔案圖分析：從 lint 走到 static analysis&lt;/td>
 &lt;td>建 link graph；反向索引；slug 啟發式多層匹配&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/09-tooling-and-analysis/tool-decision-tripwire/" data-link-title="9.5 工具決策：regex 到 AST、Python 到 Go 的 tripwire" data-link-desc="什麼訊號代表工具該升級到下一個層次；用 WRAP 框架做語言與實作層的技術決策；延遲決策的成本">9.5&lt;/a>&lt;/td>
 &lt;td>工具決策：regex 到 AST、Python 到 Go 的 tripwire&lt;/td>
 &lt;td>用 WRAP 框架做技術決策；哪些訊號代表該升級；延遲決策的代價&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/09-tooling-and-analysis/pre-commit-and-ci/" data-link-title="9.6 Pre-commit hook 與 CI 整合" data-link-desc="工具寫完只是起點；接到 pre-commit hook 跟 CI 才真正守住品質。Re-staging、dry-run vs apply、不能繞過的邊界">9.6&lt;/a>&lt;/td>
 &lt;td>Pre-commit hook 與 CI 整合&lt;/td>
 &lt;td>工具從 CLI 走到開發流程；re-staging；CI strict mode；不能繞過的邊界&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組的教學主軸">本模組的教學主軸&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>stdlib 優先&lt;/strong>：Go 的工具鏈文化偏好最小依賴。cobra / viper / 各種框架都有存在的理由，但 Go 的 &lt;code>flag&lt;/code> + &lt;code>os&lt;/code> + &lt;code>filepath&lt;/code> 已經能撐起 80% 的 CLI 需求。&lt;/li>
&lt;li>&lt;strong>AST 是原始 regex 的升級路徑，不是預設起點&lt;/strong>：line-based 處理便宜、直觀；AST 在需要「段落歸屬」「父子關係」「跨檔連結」時才付出整合成本才有回報。&lt;/li>
&lt;li>&lt;strong>工具要 idempotent&lt;/strong>：&lt;code>fmt --fix&lt;/code> 跑兩次結果要相同；pre-commit 觸發的修改要保持 git state 完整；&lt;code>--check&lt;/code> 跟 &lt;code>--fix&lt;/code> 要共用同一套規則判讀。&lt;/li>
&lt;li>&lt;strong>跨檔案檢查需要圖&lt;/strong>：single-file linter 好寫；跨檔 orphan 偵測、連結完整性、reverse-dependency 這類問題需要先把整個 repo 建成結構化圖，再走訪。&lt;/li>
&lt;li>&lt;strong>工具的價值在落地&lt;/strong>：寫出能跑的 binary 只是起點；接到 pre-commit hook 跟 CI 才讓工具真正守住品質。&lt;/li>
&lt;/ul>
&lt;h2 id="章節粒度說明">章節粒度說明&lt;/h2>
&lt;p>本模組每章都針對 &lt;strong>一個可複用的工具開發技術&lt;/strong>，篇幅會比語法章長一些。每章的結構大致是：&lt;/p></description><content:encoded><![CDATA[<p>前八個模組都把 Go 放在後端服務的脈絡下談。這個模組往另一個方向走 — <strong>Go 寫 CLI、lint / migrate 工具、靜態分析、程式碼生成</strong>。這些程式沒有 HTTP handler、沒有 goroutine pool、沒有 PostgreSQL connection；但同樣享受 Go 的型別安全、標準庫深度與跨平台編譯。</p>
<p>業界大量這類 Go 程式：hugo（靜態網站產生器）、kubectl / helm（k8s 客戶端）、terraform（基礎設施描述）、gh（GitHub CLI）、goldmark（markdown parser）、stringer / gopls（官方工具鏈）、golangci-lint（linter 集合）。後端工程師轉過去寫工具時會遇到不同的設計約束：沒有長時執行、資料來自檔案而非 request、錯誤處理偏向中斷而非降級、效能瓶頸是 I/O 而非併發。</p>
<p>本模組以 <code>scripts/mdtools</code>（blog 本身用來守住 markdown 品質的內部工具鏈）作為 worked example 串連概念。每一章提煉可複用的 Go 技術；mdtools 只是其中一種 concrete instance，讀者能把同樣 pattern 套到自己的工具上。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go/09-tooling-and-analysis/overview/" data-link-title="9.0 Go 在工具鏈生態的位置" data-link-desc="後端服務以外，Go 常被用來寫 CLI、靜態分析、基礎設施客戶端。本章建立工具類 Go 程式跟服務類 Go 程式在結構、生命週期與錯誤處理上的分野">9.0</a></td>
          <td>Go 在工具鏈生態的位置</td>
          <td>從後端服務切換到工具開發的心態調整；CLI vs service 的結構差異</td>
      </tr>
      <tr>
          <td><a href="/blog/go/09-tooling-and-analysis/stdlib-flag-subcommands/" data-link-title="9.1 用 stdlib flag 寫 subcommand CLI" data-link-desc="Go 的 flag 套件足以支撐多層 subcommand 的 CLI，不用過早引入 cobra；本章示範 main → cmd/ → internal/ 的標準 layout">9.1</a></td>
          <td>stdlib <code>flag</code> 做 subcommand CLI</td>
          <td><code>main</code> + <code>cmd/</code> + <code>internal/</code> 佈局；<code>flag.NewFlagSet</code> 分派；什麼時候該上 cobra</td>
      </tr>
      <tr>
          <td><a href="/blog/go/09-tooling-and-analysis/goldmark-ast-basics/" data-link-title="9.2 第三方 parser 整合：goldmark AST 入門" data-link-desc="用 goldmark 把 markdown 解析成 AST，掌握 ast.Walk visitor 模式、block 與 inline 節點的判讀、byte offset 如何定位到行號">9.2</a></td>
          <td>第三方 parser 整合：goldmark AST 入門</td>
          <td><code>ast.Walk</code> visitor 模式；block vs inline 節點；byte offset 定位</td>
      </tr>
      <tr>
          <td><a href="/blog/go/09-tooling-and-analysis/ast-idempotent-rewriting/" data-link-title="9.3 AST 驅動的 idempotent 文字改寫" data-link-desc="用 AST 定位位置、用 line-based 或 byte-level 改寫；設計多條 rule 的執行順序；--check 跟 --fix 如何共用邏輯">9.3</a></td>
          <td>AST 驅動的 idempotent 文字改寫</td>
          <td>多 rule 的執行順序；line-based vs AST-guided 的取捨；<code>--check</code> / <code>--fix</code> 雙模式</td>
      </tr>
      <tr>
          <td><a href="/blog/go/09-tooling-and-analysis/cross-file-graph-analysis/" data-link-title="9.4 跨檔案圖分析：從 lint 走到 static analysis" data-link-desc="Single-file 規則用 AST 搞定；跨檔 orphan 偵測、broken link、backlink 完整性需要把整個 repo 建成圖再走訪。用 mdtools cards 為例">9.4</a></td>
          <td>跨檔案圖分析：從 lint 走到 static analysis</td>
          <td>建 link graph；反向索引；slug 啟發式多層匹配</td>
      </tr>
      <tr>
          <td><a href="/blog/go/09-tooling-and-analysis/tool-decision-tripwire/" data-link-title="9.5 工具決策：regex 到 AST、Python 到 Go 的 tripwire" data-link-desc="什麼訊號代表工具該升級到下一個層次；用 WRAP 框架做語言與實作層的技術決策；延遲決策的成本">9.5</a></td>
          <td>工具決策：regex 到 AST、Python 到 Go 的 tripwire</td>
          <td>用 WRAP 框架做技術決策；哪些訊號代表該升級；延遲決策的代價</td>
      </tr>
      <tr>
          <td><a href="/blog/go/09-tooling-and-analysis/pre-commit-and-ci/" data-link-title="9.6 Pre-commit hook 與 CI 整合" data-link-desc="工具寫完只是起點；接到 pre-commit hook 跟 CI 才真正守住品質。Re-staging、dry-run vs apply、不能繞過的邊界">9.6</a></td>
          <td>Pre-commit hook 與 CI 整合</td>
          <td>工具從 CLI 走到開發流程；re-staging；CI strict mode；不能繞過的邊界</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組的教學主軸">本模組的教學主軸</h2>
<ul>
<li><strong>stdlib 優先</strong>：Go 的工具鏈文化偏好最小依賴。cobra / viper / 各種框架都有存在的理由，但 Go 的 <code>flag</code> + <code>os</code> + <code>filepath</code> 已經能撐起 80% 的 CLI 需求。</li>
<li><strong>AST 是原始 regex 的升級路徑，不是預設起點</strong>：line-based 處理便宜、直觀；AST 在需要「段落歸屬」「父子關係」「跨檔連結」時才付出整合成本才有回報。</li>
<li><strong>工具要 idempotent</strong>：<code>fmt --fix</code> 跑兩次結果要相同；pre-commit 觸發的修改要保持 git state 完整；<code>--check</code> 跟 <code>--fix</code> 要共用同一套規則判讀。</li>
<li><strong>跨檔案檢查需要圖</strong>：single-file linter 好寫；跨檔 orphan 偵測、連結完整性、reverse-dependency 這類問題需要先把整個 repo 建成結構化圖，再走訪。</li>
<li><strong>工具的價值在落地</strong>：寫出能跑的 binary 只是起點；接到 pre-commit hook 跟 CI 才讓工具真正守住品質。</li>
</ul>
<h2 id="章節粒度說明">章節粒度說明</h2>
<p>本模組每章都針對 <strong>一個可複用的工具開發技術</strong>，篇幅會比語法章長一些。每章的結構大致是：</p>
<ol>
<li>問題描述（為何需要這個技術）</li>
<li>概念與 Go 層面的設計取捨</li>
<li>實作與範例（引用 mdtools 對應程式碼）</li>
<li>常見陷阱</li>
<li>擴充路徑</li>
</ol>
<h2 id="先備知識">先備知識</h2>
<p>讀這個模組前建議已經熟悉：</p>
<ul>
<li>模組一到模組三：Go 語法、型別、標準庫基礎</li>
<li>模組五：error 處理與 testing 的基本 pattern</li>
<li>（加分）模組四：concurrency — 雖然 CLI 工具很少需要 goroutine，但 pipeline fan-out 偶爾用得到</li>
</ul>
<p>本模組與後端服務模組（6、7、8）是<strong>並行關係</strong>，讀者可以直接跳入，無需先讀完後端系列。</p>
<h2 id="本模組使用的範例">本模組使用的範例</h2>
<ul>
<li><code>scripts/mdtools/</code> — blog 自己的 markdown 品質工具鏈
<ul>
<li><code>main.go</code> 的 subcommand dispatcher</li>
<li><code>internal/astutil/</code> 的 goldmark wrapper</li>
<li><code>internal/mdfmt/</code> 的格式正規化</li>
<li><code>internal/mdcards/</code> 的 link graph</li>
<li><code>internal/mdmigrate/</code> 的 L1 auto-fix</li>
</ul>
</li>
<li><code>.githooks/pre-commit</code> — 把工具接進 git workflow</li>
<li><code>.github/workflows/md-check.yml</code> — CI 整合</li>
</ul>
<p>完整工具的設計紀錄可參考 <a href="/blog/posts/mdtoolsgo--goldmark-%E7%9A%84-markdown-%E5%B7%A5%E5%85%B7%E9%8F%88%E8%A8%AD%E8%A8%88/" data-link-title="mdtools：Go &#43; goldmark 的 markdown 工具鏈設計" data-link-desc="mdtools 的架構決策：選 Go &#43; goldmark 的理由（與 Hugo 同源保證 lint↔render 等價）、單 binary 多子命令設計、pre-commit 整合、規則開啟紀律。">mdtools：Go + goldmark 的 markdown 工具鏈設計</a>；AST 概念的入門說明見 <a href="/blog/posts/%E4%BB%80%E9%BA%BC%E6%98%AF-ast-%E5%BE%9E%E5%AD%97%E4%B8%B2%E5%88%B0%E8%AA%9E%E6%B3%95%E6%A8%B9%E7%9A%84%E8%A6%96%E8%A7%92%E8%BD%89%E6%8F%9B/" data-link-title="什麼是 AST — 從字串到語法樹的視角轉換" data-link-desc="AST 與 regex 的差異判準：規則需要知道文字處在什麼結構中時 regex 就不夠。附 regex 誤判的具體 case。">什麼是 AST</a>。</p>
<h2 id="學習時間">學習時間</h2>
<p>預計 2.5-3.5 小時（含動手把 <code>scripts/mdtools</code> clone 下來編譯、修改、重跑）</p>
]]></content:encoded></item><item><title>8.9 ByteDance / CloudWeGo：微服務基礎設施</title><link>https://tarrragon.github.io/blog/go/08-case-studies/cloudwego/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/08-case-studies/cloudwego/</guid><description>&lt;p>CloudWeGo 是理解 Go 在大型公司內部如何演化成基礎設施層的好案例。官方介紹指出，它是 ByteDance Infrastructure Service Framework 團隊開源的 middleware 集合，核心關注是高性能、高擴展性、高可靠性與微服務溝通與治理。&lt;/p>
&lt;h2 id="你應該看什麼">你應該看什麼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://www.cloudwego.io/about/">CloudWeGo About&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.cloudwego.io/blog/2023/06/15/cloudwego-a-leading-practice-for-building-enterprise-cloud-native-middleware/">CloudWeGo: a leading practice for building enterprise cloud native middleware&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.cloudwego.io/blog/2022/03/25/an-article-to-learn-about-bytedance-microservices-middleware-cloudwego/">An Article to Learn About ByteDance Microservices Middleware CloudWeGo&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼&lt;/h2>
&lt;ol>
&lt;li>Go 可以從單一服務語言，進一步變成微服務平台語言。&lt;/li>
&lt;li>大型服務常會把 RPC、HTTP、networking、serialization 拆成不同 middleware。&lt;/li>
&lt;li>Go 的簡潔語法與高性能 runtime 很適合做基礎設施層。&lt;/li>
&lt;/ol>
&lt;h2 id="可對照的公開原始碼">可對照的公開原始碼&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://github.com/cloudwego/kitex">cloudwego/kitex&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/cloudwego/hertz">cloudwego/hertz&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/cloudwego/netpoll">cloudwego/netpoll&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>這三個 repo 很適合對照第 4、6、7 模組，尤其是高併發控制、HTTP 邊界與 ports/adapters 的組織方式。&lt;/p></description><content:encoded><![CDATA[<p>CloudWeGo 是理解 Go 在大型公司內部如何演化成基礎設施層的好案例。官方介紹指出，它是 ByteDance Infrastructure Service Framework 團隊開源的 middleware 集合，核心關注是高性能、高擴展性、高可靠性與微服務溝通與治理。</p>
<h2 id="你應該看什麼">你應該看什麼</h2>
<ul>
<li><a href="https://www.cloudwego.io/about/">CloudWeGo About</a></li>
<li><a href="https://www.cloudwego.io/blog/2023/06/15/cloudwego-a-leading-practice-for-building-enterprise-cloud-native-middleware/">CloudWeGo: a leading practice for building enterprise cloud native middleware</a></li>
<li><a href="https://www.cloudwego.io/blog/2022/03/25/an-article-to-learn-about-bytedance-microservices-middleware-cloudwego/">An Article to Learn About ByteDance Microservices Middleware CloudWeGo</a></li>
</ul>
<h2 id="這個案例告訴我們什麼">這個案例告訴我們什麼</h2>
<ol>
<li>Go 可以從單一服務語言，進一步變成微服務平台語言。</li>
<li>大型服務常會把 RPC、HTTP、networking、serialization 拆成不同 middleware。</li>
<li>Go 的簡潔語法與高性能 runtime 很適合做基礎設施層。</li>
</ol>
<h2 id="可對照的公開原始碼">可對照的公開原始碼</h2>
<ul>
<li><a href="https://github.com/cloudwego/kitex">cloudwego/kitex</a></li>
<li><a href="https://github.com/cloudwego/hertz">cloudwego/hertz</a></li>
<li><a href="https://github.com/cloudwego/netpoll">cloudwego/netpoll</a></li>
</ul>
<p>這三個 repo 很適合對照第 4、6、7 模組，尤其是高併發控制、HTTP 邊界與 ports/adapters 的組織方式。</p>
]]></content:encoded></item><item><title>3.9 flag、os/env 與設定邊界</title><link>https://tarrragon.github.io/blog/go/03-stdlib/config-flags-env/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/03-stdlib/config-flags-env/</guid><description>&lt;p>設定讀取的核心責任是把外部字串轉成程式內部的 typed config。環境變數、命令列 flag、設定檔與預設值都只是輸入來源；application 應依賴已驗證的 config struct。&lt;/p>
&lt;h2 id="預計補充內容">預計補充內容&lt;/h2>
&lt;p>這些設定邊界會在下列章節展開：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go 進階：composition root 與依賴組裝&lt;/a>：設定讀取的真正用途，是在啟動層把外部輸入轉成可驗證的依賴。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">Go 入門：從入口程式看應用啟動流程&lt;/a>：先看主程式怎麼啟動，才知道設定應該在哪裡完成驗證。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口&lt;/a>：像 secret manager、ConfigMap 與 rollout 這類平台責任應該留給 Backend。&lt;/li>
&lt;/ul>
&lt;h2 id="與-backend-教材的分工">與 Backend 教材的分工&lt;/h2>
&lt;p>本章只處理 Go 程式內的設定邊界。secret manager、Kubernetes ConfigMap、container environment、遠端動態設定與部署平台 rollout 會放在 &lt;a href="https://tarrragon.github.io/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口&lt;/a>。&lt;/p>
&lt;h2 id="和-go-教材的關係">和 Go 教材的關係&lt;/h2>
&lt;p>這一章承接的是入口流程與 composition root；如果你要先回看語言教材，可以讀：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">Go：從入口程式看應用啟動流程&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go：composition root 與依賴組裝&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go：testing 基礎&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/03-stdlib/config-flags-env/" data-link-title="3.9 flag、os/env 與設定邊界" data-link-desc="用標準庫讀取設定，並把外部輸入轉成 config struct">Go：flag、os/env 與設定邊界&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>設定讀取的核心責任是把外部字串轉成程式內部的 typed config。環境變數、命令列 flag、設定檔與預設值都只是輸入來源；application 應依賴已驗證的 config struct。</p>
<h2 id="預計補充內容">預計補充內容</h2>
<p>這些設定邊界會在下列章節展開：</p>
<ul>
<li><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go 進階：composition root 與依賴組裝</a>：設定讀取的真正用途，是在啟動層把外部輸入轉成可驗證的依賴。</li>
<li><a href="/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">Go 入門：從入口程式看應用啟動流程</a>：先看主程式怎麼啟動，才知道設定應該在哪裡完成驗證。</li>
<li><a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a>：像 secret manager、ConfigMap 與 rollout 這類平台責任應該留給 Backend。</li>
</ul>
<h2 id="與-backend-教材的分工">與 Backend 教材的分工</h2>
<p>本章只處理 Go 程式內的設定邊界。secret manager、Kubernetes ConfigMap、container environment、遠端動態設定與部署平台 rollout 會放在 <a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">Backend：部署平台與網路入口</a>。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是入口流程與 composition root；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">Go：從入口程式看應用啟動流程</a></li>
<li><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">Go：composition root 與依賴組裝</a></li>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go：testing 基礎</a></li>
<li><a href="/blog/go/03-stdlib/config-flags-env/" data-link-title="3.9 flag、os/env 與設定邊界" data-link-desc="用標準庫讀取設定，並把外部輸入轉成 config struct">Go：flag、os/env 與設定邊界</a></li>
</ul>
]]></content:encoded></item><item><title>3.10 標準庫如何支撐服務型 Go</title><link>https://tarrragon.github.io/blog/go/03-stdlib/service-support/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/03-stdlib/service-support/</guid><description>&lt;p>Go 標準庫的服務價值在於它直接提供 HTTP、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>、取消、日誌與資源管理的基本能力。這一章把前面學過的工具串成服務底座，讓讀者理解標準庫如何支撐後端程式，而不只是個別 API 的使用方式。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>看出標準庫為什麼是 Go 服務的底座&lt;/li>
&lt;li>把 &lt;code>context&lt;/code>、&lt;code>net/http&lt;/code>、&lt;code>log/slog&lt;/code>、&lt;code>defer&lt;/code> 與 &lt;code>time&lt;/code> 串成一個服務模型&lt;/li>
&lt;li>理解為什麼這些工具會讓服務更可維護&lt;/li>
&lt;li>把標準庫能力轉成實際服務邊界&lt;/li>
&lt;li>知道何時標準庫已足夠，何時才需要外部框架&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察標準庫本身就能做服務">【觀察】標準庫本身就能做服務&lt;/h2>
&lt;p>Go 的標準庫已經包含服務程式需要的主要基礎能力。&lt;code>net/http&lt;/code> 可以直接建立服務，&lt;code>context&lt;/code> 可以控制取消與 timeout，&lt;code>log/slog&lt;/code> 可以支援結構化日誌，&lt;code>defer&lt;/code> 可以整理資源釋放，&lt;code>time&lt;/code> 可以處理期限與排程。&lt;/p>
&lt;p>這些能力拼在一起，就是一個後端服務最基本的底盤。&lt;/p>
&lt;h2 id="判讀context-是服務生命週期的中心">【判讀】context 是服務生命週期的中心&lt;/h2>
&lt;p>在服務型 Go 裡，&lt;code>context&lt;/code> 是請求、取消與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a> 的共同語言。當 handler、worker、DB、Redis 都接受 context 時，整個流程就能在同一個生命週期邊界內運作；缺少 context 的長時間流程會讓取消與逾時難以傳遞。&lt;/p>
&lt;h2 id="判讀nethttp-讓入口保持簡單">【判讀】net/http 讓入口保持簡單&lt;/h2>
&lt;p>&lt;code>net/http&lt;/code> 的 handler 模型很薄，這是優點。它讓你能快速建立路由、驗證 request、回傳 response，而不需要先學一大套框架約定。對服務型 Go 來說，這種簡單性會直接降低協作成本。&lt;/p>
&lt;h2 id="策略log-與-defer-讓邊界更完整">【策略】log 與 defer 讓邊界更完整&lt;/h2>
&lt;p>&lt;code>log/slog&lt;/code> 提供結構化日誌，讓高併發服務的診斷更容易；&lt;code>defer&lt;/code> 則讓 close、unlock、cancel 等收尾操作更安全。這兩個工具都是 Go 在長時間運行服務中很重要的可靠性支撐。&lt;/p>
&lt;h2 id="小結">小結&lt;/h2>
&lt;p>標準庫是 Go 成為服務語言的核心原因之一。當你把 &lt;code>context&lt;/code>、&lt;code>net/http&lt;/code>、&lt;code>log/slog&lt;/code>、&lt;code>defer&lt;/code> 與 &lt;code>time&lt;/code> 看成一組工具時，就更容易理解 Go 為什麼適合做後端服務。&lt;/p></description><content:encoded><![CDATA[<p>Go 標準庫的服務價值在於它直接提供 HTTP、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>、取消、日誌與資源管理的基本能力。這一章把前面學過的工具串成服務底座，讓讀者理解標準庫如何支撐後端程式，而不只是個別 API 的使用方式。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>看出標準庫為什麼是 Go 服務的底座</li>
<li>把 <code>context</code>、<code>net/http</code>、<code>log/slog</code>、<code>defer</code> 與 <code>time</code> 串成一個服務模型</li>
<li>理解為什麼這些工具會讓服務更可維護</li>
<li>把標準庫能力轉成實際服務邊界</li>
<li>知道何時標準庫已足夠，何時才需要外部框架</li>
</ol>
<hr>
<h2 id="觀察標準庫本身就能做服務">【觀察】標準庫本身就能做服務</h2>
<p>Go 的標準庫已經包含服務程式需要的主要基礎能力。<code>net/http</code> 可以直接建立服務，<code>context</code> 可以控制取消與 timeout，<code>log/slog</code> 可以支援結構化日誌，<code>defer</code> 可以整理資源釋放，<code>time</code> 可以處理期限與排程。</p>
<p>這些能力拼在一起，就是一個後端服務最基本的底盤。</p>
<h2 id="判讀context-是服務生命週期的中心">【判讀】context 是服務生命週期的中心</h2>
<p>在服務型 Go 裡，<code>context</code> 是請求、取消與 <a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 的共同語言。當 handler、worker、DB、Redis 都接受 context 時，整個流程就能在同一個生命週期邊界內運作；缺少 context 的長時間流程會讓取消與逾時難以傳遞。</p>
<h2 id="判讀nethttp-讓入口保持簡單">【判讀】net/http 讓入口保持簡單</h2>
<p><code>net/http</code> 的 handler 模型很薄，這是優點。它讓你能快速建立路由、驗證 request、回傳 response，而不需要先學一大套框架約定。對服務型 Go 來說，這種簡單性會直接降低協作成本。</p>
<h2 id="策略log-與-defer-讓邊界更完整">【策略】log 與 defer 讓邊界更完整</h2>
<p><code>log/slog</code> 提供結構化日誌，讓高併發服務的診斷更容易；<code>defer</code> 則讓 close、unlock、cancel 等收尾操作更安全。這兩個工具都是 Go 在長時間運行服務中很重要的可靠性支撐。</p>
<h2 id="小結">小結</h2>
<p>標準庫是 Go 成為服務語言的核心原因之一。當你把 <code>context</code>、<code>net/http</code>、<code>log/slog</code>、<code>defer</code> 與 <code>time</code> 看成一組工具時，就更容易理解 Go 為什麼適合做後端服務。</p>
]]></content:encoded></item><item><title>8.10 Go 的高併發服務案例</title><link>https://tarrragon.github.io/blog/go/08-case-studies/high-concurrency-services/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/08-case-studies/high-concurrency-services/</guid><description>&lt;p>高併發服務案例的核心判斷是「大量工作是否同時存在，且每個工作都需要清楚的生命週期」。Go 適合這類服務，因為 goroutine、channel、context、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a> 與標準網路庫可以共同描述工作如何開始、等待、取消與清理。&lt;/p>
&lt;h2 id="高併發型態">高併發型態&lt;/h2>
&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;/td>
 &lt;td>大量 client、慢連線、斷線清理&lt;/td>
 &lt;td>Twitch、Stream、Cloudflare&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>網路代理與邊緣服務&lt;/td>
 &lt;td>timeout、連線管理、資源限制&lt;/td>
 &lt;td>Cloudflare、Kubernetes 生態工具&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>背景處理與 pipeline&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out&lt;/a>、排隊、取消、錯誤回報&lt;/td>
 &lt;td>PayPal、Dropbox&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>分散式資料服務&lt;/td>
 &lt;td>複製、一致性、節點協調&lt;/td>
 &lt;td>Cockroach Labs&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="長連線與即時推送先看-client-是否持續留在線上">長連線與即時推送：先看 client 是否持續留在線上&lt;/h3>
&lt;p>長連線服務的核心訊號是「request 結束後，server 仍然需要替 client 保留狀態」。聊天室、直播狀態、feed 更新與即時通知，都需要管理 client 註冊、訂閱、心跳、send &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 與清理流程。Go 的價值在於讓每條連線的讀取、寫入與取消責任能被拆成可讀的 goroutine 流程。&lt;/p>
&lt;p>對應章節：&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/" data-link-title="模組二：WebSocket 服務架構" data-link-desc="WebSocket client lifecycle、heartbeat、訂閱路由與慢客戶端管理">WebSocket 服務架構&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/slow-client/" data-link-title="2.4 慢客戶端與 send buffer 管理" data-link-desc="控制推送佇列與記憶體風險">慢客戶端與 send buffer 管理&lt;/a>。&lt;/p>
&lt;h3 id="網路代理與邊緣服務先看邊界是否充滿-timeout">網路代理與邊緣服務：先看邊界是否充滿 timeout&lt;/h3>
&lt;p>網路代理與邊緣服務的核心訊號是「大量 I/O 邊界同時存在」。每個 request 都可能等待 DNS、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/tls-mtls/" data-link-title="TLS / mTLS" data-link-desc="說明傳輸加密與雙向憑證驗證如何保護跨邊界資料流">TLS&lt;/a>、上游服務、client body 或 downstream response。Go 的 &lt;code>net/http&lt;/code>、&lt;code>context&lt;/code> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a> 設計讓 timeout 和 cancellation 可以沿著 request 傳遞。&lt;/p>
&lt;p>對應章節：&lt;a href="https://tarrragon.github.io/blog/go/03-stdlib/http-handler/" data-link-title="3.5 net/http 與 handler 設計" data-link-desc="用 net/http 建立健康檢查、API endpoint 與清楚的 handler 邊界">net/http 與 handler 設計&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go/03-stdlib/context/" data-link-title="3.7 context：取消、逾時與生命週期" data-link-desc="用 context 傳遞取消、逾時與請求生命週期">context：取消、逾時與生命週期&lt;/a>。&lt;/p>
&lt;h3 id="背景處理與-pipeline先看工作是否可以從-request-中拆出">背景處理與 pipeline：先看工作是否可以從 request 中拆出&lt;/h3>
&lt;p>背景處理的核心訊號是「使用者請求只負責提交工作，真正處理需要在後面持續執行」。例如檔案轉換、通知寄送、資料同步、報表產生與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/webhook/" data-link-title="Webhook" data-link-desc="說明外部系統回呼事件的接收、驗證與處理邊界">webhook&lt;/a> retry。Go 的 goroutine 和 channel 可以先建立單一 process 內的 worker 模型；當工作需要跨 process 保證時，再接到 Backend 的 message &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> 與 outbox 章節。&lt;/p>
&lt;p>對應章節：&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/worker-pool/" data-link-title="1.5 bounded worker pool" data-link-desc="限制同時執行的 goroutine 數量，讓背景工作有明確容量邊界">bounded worker pool&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞&lt;/a>。&lt;/p>
&lt;h3 id="分散式資料服務先看狀態是否跨節點協調">分散式資料服務：先看狀態是否跨節點協調&lt;/h3>
&lt;p>分散式資料服務的核心訊號是「資料狀態需要跨節點維持一致」。這類服務會同時處理網路延遲、節點失效、複製、leader election、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a> 與觀測訊號。Go 提供的是可讀的並發與錯誤處理基礎；資料庫演算法、共識協定與持久化設計則需要專門章節或外部資料補足。&lt;/p>
&lt;p>對應章節：&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">Source of Truth：狀態邊界&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">資料庫 transaction 與 schema migration&lt;/a>。&lt;/p>
&lt;h2 id="案例閱讀檢查">案例閱讀檢查&lt;/h2>
&lt;p>閱讀高併發案例時，先找出三個問題：工作如何被限制數量、失敗如何回到 owner、資源如何被清理。若案例只談速度而沒有談生命週期，就很難轉成可維護的 Go 設計。&lt;/p></description><content:encoded><![CDATA[<p>高併發服務案例的核心判斷是「大量工作是否同時存在，且每個工作都需要清楚的生命週期」。Go 適合這類服務，因為 goroutine、channel、context、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 與標準網路庫可以共同描述工作如何開始、等待、取消與清理。</p>
<h2 id="高併發型態">高併發型態</h2>
<table>
  <thead>
      <tr>
          <th>型態</th>
          <th>主要壓力</th>
          <th>相關案例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>長連線與即時推送</td>
          <td>大量 client、慢連線、斷線清理</td>
          <td>Twitch、Stream、Cloudflare</td>
      </tr>
      <tr>
          <td>網路代理與邊緣服務</td>
          <td>timeout、連線管理、資源限制</td>
          <td>Cloudflare、Kubernetes 生態工具</td>
      </tr>
      <tr>
          <td>背景處理與 pipeline</td>
          <td><a href="/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out</a>、排隊、取消、錯誤回報</td>
          <td>PayPal、Dropbox</td>
      </tr>
      <tr>
          <td>分散式資料服務</td>
          <td>複製、一致性、節點協調</td>
          <td>Cockroach Labs</td>
      </tr>
  </tbody>
</table>
<h3 id="長連線與即時推送先看-client-是否持續留在線上">長連線與即時推送：先看 client 是否持續留在線上</h3>
<p>長連線服務的核心訊號是「request 結束後，server 仍然需要替 client 保留狀態」。聊天室、直播狀態、feed 更新與即時通知，都需要管理 client 註冊、訂閱、心跳、send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 與清理流程。Go 的價值在於讓每條連線的讀取、寫入與取消責任能被拆成可讀的 goroutine 流程。</p>
<p>對應章節：<a href="/blog/go-advanced/02-networking-websocket/" data-link-title="模組二：WebSocket 服務架構" data-link-desc="WebSocket client lifecycle、heartbeat、訂閱路由與慢客戶端管理">WebSocket 服務架構</a>、<a href="/blog/go-advanced/02-networking-websocket/slow-client/" data-link-title="2.4 慢客戶端與 send buffer 管理" data-link-desc="控制推送佇列與記憶體風險">慢客戶端與 send buffer 管理</a>。</p>
<h3 id="網路代理與邊緣服務先看邊界是否充滿-timeout">網路代理與邊緣服務：先看邊界是否充滿 timeout</h3>
<p>網路代理與邊緣服務的核心訊號是「大量 I/O 邊界同時存在」。每個 request 都可能等待 DNS、<a href="/blog/backend/knowledge-cards/tls-mtls/" data-link-title="TLS / mTLS" data-link-desc="說明傳輸加密與雙向憑證驗證如何保護跨邊界資料流">TLS</a>、上游服務、client body 或 downstream response。Go 的 <code>net/http</code>、<code>context</code> 與 <a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 設計讓 timeout 和 cancellation 可以沿著 request 傳遞。</p>
<p>對應章節：<a href="/blog/go/03-stdlib/http-handler/" data-link-title="3.5 net/http 與 handler 設計" data-link-desc="用 net/http 建立健康檢查、API endpoint 與清楚的 handler 邊界">net/http 與 handler 設計</a>、<a href="/blog/go/03-stdlib/context/" data-link-title="3.7 context：取消、逾時與生命週期" data-link-desc="用 context 傳遞取消、逾時與請求生命週期">context：取消、逾時與生命週期</a>。</p>
<h3 id="背景處理與-pipeline先看工作是否可以從-request-中拆出">背景處理與 pipeline：先看工作是否可以從 request 中拆出</h3>
<p>背景處理的核心訊號是「使用者請求只負責提交工作，真正處理需要在後面持續執行」。例如檔案轉換、通知寄送、資料同步、報表產生與 <a href="/blog/backend/knowledge-cards/webhook/" data-link-title="Webhook" data-link-desc="說明外部系統回呼事件的接收、驗證與處理邊界">webhook</a> retry。Go 的 goroutine 和 channel 可以先建立單一 process 內的 worker 模型；當工作需要跨 process 保證時，再接到 Backend 的 message <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 與 outbox 章節。</p>
<p>對應章節：<a href="/blog/go-advanced/01-concurrency-patterns/worker-pool/" data-link-title="1.5 bounded worker pool" data-link-desc="限制同時執行的 goroutine 數量，讓背景工作有明確容量邊界">bounded worker pool</a>、<a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">Backend：訊息佇列與事件傳遞</a>。</p>
<h3 id="分散式資料服務先看狀態是否跨節點協調">分散式資料服務：先看狀態是否跨節點協調</h3>
<p>分散式資料服務的核心訊號是「資料狀態需要跨節點維持一致」。這類服務會同時處理網路延遲、節點失效、複製、leader election、<a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 與觀測訊號。Go 提供的是可讀的並發與錯誤處理基礎；資料庫演算法、共識協定與持久化設計則需要專門章節或外部資料補足。</p>
<p>對應章節：<a href="/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">Source of Truth：狀態邊界</a>、<a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">資料庫 transaction 與 schema migration</a>。</p>
<h2 id="案例閱讀檢查">案例閱讀檢查</h2>
<p>閱讀高併發案例時，先找出三個問題：工作如何被限制數量、失敗如何回到 owner、資源如何被清理。若案例只談速度而沒有談生命週期，就很難轉成可維護的 Go 設計。</p>
]]></content:encoded></item><item><title>8.11 Go 公開原始碼讀碼路線</title><link>https://tarrragon.github.io/blog/go/08-case-studies/open-source-code-reading/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/08-case-studies/open-source-code-reading/</guid><description>&lt;p>Go 公開原始碼讀碼的核心策略是先找服務形狀，再追細節。成熟 Go 專案通常程式量很大；直接從底層型別開始讀，容易失去方向。比較穩定的路線是從入口、組裝、邊界、並發 owner、測試逐步往內走。&lt;/p>
&lt;h2 id="讀碼路線">讀碼路線&lt;/h2>
&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;/td>
 &lt;td>process 如何啟動、config 如何載入&lt;/td>
 &lt;td>&lt;code>cmd/.../main.go&lt;/code>、&lt;code>main.go&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>組裝&lt;/td>
 &lt;td>具體依賴在哪裡建立&lt;/td>
 &lt;td>&lt;code>New...&lt;/code>、&lt;code>Run&lt;/code>、&lt;code>Server&lt;/code>、&lt;code>App&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>邊界&lt;/td>
 &lt;td>HTTP、CLI、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>、storage 如何接進 application&lt;/td>
 &lt;td>&lt;code>handler&lt;/code>、&lt;code>client&lt;/code>、&lt;code>store&lt;/code>、&lt;code>adapter&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>並發 owner&lt;/td>
 &lt;td>哪個元件擁有 goroutine、channel、context&lt;/td>
 &lt;td>&lt;code>controller&lt;/code>、&lt;code>worker&lt;/code>、&lt;code>manager&lt;/code>、&lt;code>hub&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>測試&lt;/td>
 &lt;td>行為如何被固定成案例&lt;/td>
 &lt;td>&lt;code>*_test.go&lt;/code>、test fake、integration test&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="入口先看-process-如何開始">入口：先看 process 如何開始&lt;/h3>
&lt;p>入口檔案的核心價值是揭露服務的第一層責任。讀 &lt;code>main.go&lt;/code> 或 &lt;code>cmd/.../main.go&lt;/code> 時，先找 config、logger、server、worker、signal handling 與 shutdown。這些線索能幫你理解專案是 CLI、daemon、API service、controller，或混合型工具。&lt;/p>
&lt;p>對應章節：&lt;a href="https://tarrragon.github.io/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">從入口程式看應用啟動流程&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">composition root 與依賴組裝&lt;/a>。&lt;/p>
&lt;h3 id="組裝再看具體依賴在哪裡建立">組裝：再看具體依賴在哪裡建立&lt;/h3>
&lt;p>組裝層的核心問題是「誰依賴誰」。成熟專案常有多個 constructor、option struct 或 wiring function。讀碼時可以先畫出 logger、config、storage、client、queue、handler、worker 之間的方向，再進入單一元件細節。&lt;/p>
&lt;p>對應章節：&lt;a href="https://tarrragon.github.io/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">interface：用行為定義依賴&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">用 interface 隔離外部依賴&lt;/a>。&lt;/p>
&lt;h3 id="邊界接著辨識外部世界如何進入程式">邊界：接著辨識外部世界如何進入程式&lt;/h3>
&lt;p>邊界層的核心責任是把外部格式轉成 application 能理解的 command 或資料。HTTP body、CLI flag、queue message、SQL row、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> frame 都屬於邊界格式。讀碼時可以先確認轉換發生在哪裡，避免把 transport、domain 與 storage model 混在一起解讀。&lt;/p>
&lt;p>對應章節：&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">把 handler 邏輯拆成可測單元&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go/glossary/" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">Go 教材核心術語&lt;/a>。&lt;/p>
&lt;h3 id="並發-owner最後追-goroutine-與-channel-的生命週期">並發 owner：最後追 goroutine 與 channel 的生命週期&lt;/h3>
&lt;p>並發 owner 的核心責任是決定 goroutine 何時啟動、如何接收工作、何時停止、錯誤如何回報。看到 &lt;code>go ...&lt;/code>、&lt;code>select&lt;/code>、&lt;code>context.Context&lt;/code>、&lt;code>WaitGroup&lt;/code>、&lt;code>close(ch)&lt;/code> 時，先找 owner。owner 找到後，再判斷資料是否需要 mutex、copy boundary 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a>。&lt;/p>
&lt;p>對應章節：&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/channel-ownership/" data-link-title="1.1 channel ownership 與關閉責任" data-link-desc="判斷誰能送出、接收與關閉 channel">channel ownership 與關閉責任&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/shared-state/" data-link-title="1.4 共享狀態與複製邊界" data-link-desc="用 lock 與 copy 保護長期服務的狀態資料">共享狀態與複製邊界&lt;/a>。&lt;/p>
&lt;h3 id="測試用測試確認真正合約">測試：用測試確認真正合約&lt;/h3>
&lt;p>測試的核心價值是把專案承認的行為寫成可重現案例。成熟專案的測試常比 README 更接近實際合約。讀測試時先看 table-driven case、fake dependency、race test 與 integration test，再回頭理解 production code 的邊界。&lt;/p>
&lt;p>對應章節：&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/table-driven-test/" data-link-title="5.3 table-driven test" data-link-desc="用表格整理多組輸入、預期輸出與錯誤情境">table-driven test&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/" data-link-title="模組五：測試與可靠性" data-link-desc="時間控制、WebSocket integration test、race check 與 table-driven test">測試與可靠性&lt;/a>。&lt;/p>
&lt;h2 id="讀碼檢查">讀碼檢查&lt;/h2>
&lt;p>每讀完一個元件，請確認三件事：這個元件擁有什麼狀態、依賴哪些能力、對外承諾哪些行為。這三件事清楚後，再看細節函式會更有效率。&lt;/p></description><content:encoded><![CDATA[<p>Go 公開原始碼讀碼的核心策略是先找服務形狀，再追細節。成熟 Go 專案通常程式量很大；直接從底層型別開始讀，容易失去方向。比較穩定的路線是從入口、組裝、邊界、並發 owner、測試逐步往內走。</p>
<h2 id="讀碼路線">讀碼路線</h2>
<table>
  <thead>
      <tr>
          <th>步驟</th>
          <th>觀察目標</th>
          <th>常見位置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>入口</td>
          <td>process 如何啟動、config 如何載入</td>
          <td><code>cmd/.../main.go</code>、<code>main.go</code></td>
      </tr>
      <tr>
          <td>組裝</td>
          <td>具體依賴在哪裡建立</td>
          <td><code>New...</code>、<code>Run</code>、<code>Server</code>、<code>App</code></td>
      </tr>
      <tr>
          <td>邊界</td>
          <td>HTTP、CLI、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>、storage 如何接進 application</td>
          <td><code>handler</code>、<code>client</code>、<code>store</code>、<code>adapter</code></td>
      </tr>
      <tr>
          <td>並發 owner</td>
          <td>哪個元件擁有 goroutine、channel、context</td>
          <td><code>controller</code>、<code>worker</code>、<code>manager</code>、<code>hub</code></td>
      </tr>
      <tr>
          <td>測試</td>
          <td>行為如何被固定成案例</td>
          <td><code>*_test.go</code>、test fake、integration test</td>
      </tr>
  </tbody>
</table>
<h3 id="入口先看-process-如何開始">入口：先看 process 如何開始</h3>
<p>入口檔案的核心價值是揭露服務的第一層責任。讀 <code>main.go</code> 或 <code>cmd/.../main.go</code> 時，先找 config、logger、server、worker、signal handling 與 shutdown。這些線索能幫你理解專案是 CLI、daemon、API service、controller，或混合型工具。</p>
<p>對應章節：<a href="/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">從入口程式看應用啟動流程</a>、<a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">composition root 與依賴組裝</a>。</p>
<h3 id="組裝再看具體依賴在哪裡建立">組裝：再看具體依賴在哪裡建立</h3>
<p>組裝層的核心問題是「誰依賴誰」。成熟專案常有多個 constructor、option struct 或 wiring function。讀碼時可以先畫出 logger、config、storage、client、queue、handler、worker 之間的方向，再進入單一元件細節。</p>
<p>對應章節：<a href="/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">interface：用行為定義依賴</a>、<a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">用 interface 隔離外部依賴</a>。</p>
<h3 id="邊界接著辨識外部世界如何進入程式">邊界：接著辨識外部世界如何進入程式</h3>
<p>邊界層的核心責任是把外部格式轉成 application 能理解的 command 或資料。HTTP body、CLI flag、queue message、SQL row、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> frame 都屬於邊界格式。讀碼時可以先確認轉換發生在哪裡，避免把 transport、domain 與 storage model 混在一起解讀。</p>
<p>對應章節：<a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">把 handler 邏輯拆成可測單元</a>、<a href="/blog/go/glossary/" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">Go 教材核心術語</a>。</p>
<h3 id="並發-owner最後追-goroutine-與-channel-的生命週期">並發 owner：最後追 goroutine 與 channel 的生命週期</h3>
<p>並發 owner 的核心責任是決定 goroutine 何時啟動、如何接收工作、何時停止、錯誤如何回報。看到 <code>go ...</code>、<code>select</code>、<code>context.Context</code>、<code>WaitGroup</code>、<code>close(ch)</code> 時，先找 owner。owner 找到後，再判斷資料是否需要 mutex、copy boundary 或 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a>。</p>
<p>對應章節：<a href="/blog/go-advanced/01-concurrency-patterns/channel-ownership/" data-link-title="1.1 channel ownership 與關閉責任" data-link-desc="判斷誰能送出、接收與關閉 channel">channel ownership 與關閉責任</a>、<a href="/blog/go-advanced/01-concurrency-patterns/shared-state/" data-link-title="1.4 共享狀態與複製邊界" data-link-desc="用 lock 與 copy 保護長期服務的狀態資料">共享狀態與複製邊界</a>。</p>
<h3 id="測試用測試確認真正合約">測試：用測試確認真正合約</h3>
<p>測試的核心價值是把專案承認的行為寫成可重現案例。成熟專案的測試常比 README 更接近實際合約。讀測試時先看 table-driven case、fake dependency、race test 與 integration test，再回頭理解 production code 的邊界。</p>
<p>對應章節：<a href="/blog/go/05-error-testing/table-driven-test/" data-link-title="5.3 table-driven test" data-link-desc="用表格整理多組輸入、預期輸出與錯誤情境">table-driven test</a>、<a href="/blog/go-advanced/05-testing-reliability/" data-link-title="模組五：測試與可靠性" data-link-desc="時間控制、WebSocket integration test、race check 與 table-driven test">測試與可靠性</a>。</p>
<h2 id="讀碼檢查">讀碼檢查</h2>
<p>每讀完一個元件，請確認三件事：這個元件擁有什麼狀態、依賴哪些能力、對外承諾哪些行為。這三件事清楚後，再看細節函式會更有效率。</p>
]]></content:encoded></item><item><title>Go 入門實戰指南</title><link>https://tarrragon.github.io/blog/go/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/</guid><description>&lt;p>本教學文件專為想學會 Go 的工程師設計。它先回答一個前置問題：什麼情境下值得選 Go。第零章會先建立選型判斷，再往下展開 Go 的語言精神：簡單、顯式、組合、並發，以及用標準工具寫出可讀、可測、可維護的程式。&lt;/p>
&lt;p>閱讀順序會從小型 CLI、資料處理、HTTP handler、背景工作一路走到即時通知程式。網路服務會逐步變成主角；前面的語法、標準庫與測試章節會先建立必要基礎。&lt;/p>
&lt;h2 id="目標讀者">目標讀者&lt;/h2>
&lt;ul>
&lt;li>有程式經驗的工程師（非 Go 專家）&lt;/li>
&lt;li>需要維護現有 Go 專案，或準備開發新的 Go 應用&lt;/li>
&lt;li>想理解 Go 的語言取捨，而不只是記語法&lt;/li>
&lt;li>需要掌握 Go 的型別、錯誤處理、並發與標準庫&lt;/li>
&lt;li>未來可能開發 CLI、API、背景服務或即時系統的人&lt;/li>
&lt;/ul>
&lt;h2 id="學習目標">學習目標&lt;/h2>
&lt;ol>
&lt;li>理解 Go 的設計哲學：簡單、顯式、組合優先&lt;/li>
&lt;li>先從工作負載、架構型態、runtime 壓力與團隊條件判斷是否適合選 Go&lt;/li>
&lt;li>能看懂 Go 專案的 package、module、struct、interface&lt;/li>
&lt;li>掌握 Go 的控制流程、錯誤處理、資料建模與測試方法&lt;/li>
&lt;li>理解 goroutine、channel、mutex 的設計目的&lt;/li>
&lt;li>熟悉 &lt;code>fmt&lt;/code>、&lt;code>time&lt;/code>、&lt;code>encoding/json&lt;/code>、&lt;code>net/http&lt;/code>、&lt;code>context&lt;/code> 等常用標準庫&lt;/li>
&lt;li>能把 Go 的語言特性應用到 CLI、資料處理與網路服務&lt;/li>
&lt;/ol>
&lt;h2 id="共用術語">共用術語&lt;/h2>
&lt;p>本系列從入門到進階會反覆使用 action、command、domain event、repository、port、adapter、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a> 等詞彙。若你在實戰或重構章節看到這些詞，可以先回到 &lt;a href="https://tarrragon.github.io/blog/go/glossary/" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">Go 教材核心術語&lt;/a>，確認它們在這套教材中的責任邊界。&lt;/p>
&lt;h2 id="第零章的定位">第零章的定位&lt;/h2>
&lt;p>第零章先做選型判斷，再進入語法。你可以把它看成一個入口：先看你的工作負載是否屬於高併發 I/O、長連線、背景 worker、事件處理或服務邊界明確的 backend，再決定是否值得投入 Go。若情境更偏重框架生態、動態行為或大量既有業務流程模板，下一步應先比較其他語言與 Backend 教材的分工。&lt;/p>
&lt;h2 id="與-backend-教材的分工">與 Backend 教材的分工&lt;/h2>
&lt;p>Go 教材先處理語言能力與程式邊界：package、interface、context、error、goroutine、channel、testing、handler、repository port 與 ports/adapters。當內容開始談 SQLite、PostgreSQL、Redis、RabbitMQ、Kubernetes、Prometheus 這類外部服務時，就應該轉到跨語言的 &lt;a href="https://tarrragon.github.io/blog/backend/" data-link-title="Backend 服務實務指南" data-link-desc="用跨語言教學路線整理資料庫、快取、訊息佇列、觀測、部署、可靠性、資安、事故與容量等後端服務能力">Backend 服務實務指南&lt;/a>。&lt;/p>
&lt;p>如果你在寫 Go 程式時只需要知道「怎麼把依賴接起來、怎麼處理取消、怎麼包裝錯誤、怎麼寫 fake 或 contract test」，那就是 Go 教材要解決的問題；如果你開始需要知道「某個外部服務怎麼部署、怎麼調參、怎麼操作」，那就是 Backend 的範圍。這套切分也同樣適用於 Python 與其他後端語言。&lt;/p>
&lt;h2 id="教學模組">教學模組&lt;/h2>
&lt;h3 id="模組零go-選型與設計哲學序章">&lt;a href="https://tarrragon.github.io/blog/go/00-philosophy/" data-link-title="模組零：Go 選型與設計哲學" data-link-desc="先判斷何時選擇 Go，再理解它的設計取捨">模組零：Go 選型與設計哲學（序章）&lt;/a>&lt;/h3>
&lt;p>先從選型條件與可讀性、維護成本理解 Go 為什麼這樣設計。&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/00-philosophy/selecting-go/" data-link-title="0.4 什麼時候選 Go" data-link-desc="用選型條件判斷 Go 是否適合高併發服務、背景工作與長連線場景">什麼時候選 Go&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/00-philosophy/simplicity/" data-link-title="0.1 Go 的簡單哲學與認知負擔" data-link-desc="理解 Go 為什麼偏好顯式、直線流程與少量語法">Go 的簡單哲學與認知負擔&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/00-philosophy/composition/" data-link-title="0.2 組合優先：小介面與明確依賴" data-link-desc="用小介面與 struct 組合取代大型繼承結構">組合優先：小介面與明確依賴&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/00-philosophy/error-thinking/" data-link-title="0.3 錯誤處理：把失敗路徑寫出來" data-link-desc="理解 Go 顯式錯誤處理在服務維護中的價值">錯誤處理：把失敗路徑寫出來&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/00-philosophy/concurrency-language-position/" data-link-title="0.5 Go 和其他並發語言的差異" data-link-desc="比較 Go、Java、C#、Rust、Node.js、Python async、Erlang/Elixir 在並發服務中的工程定位">Go 和其他並發語言的差異&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="模組一go-基礎概念">&lt;a href="https://tarrragon.github.io/blog/go/01-basics/" data-link-title="模組一：Go 基礎概念" data-link-desc="Go 專案結構、變數、控制流程、package、檔案拆分、函式、應用啟動與日常 tooling">模組一：Go 基礎概念&lt;/a>&lt;/h3>
&lt;p>先把閱讀 Go 專案最常見的基本模型建立起來。&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/modules/" data-link-title="1.1 Go 專案結構與 module" data-link-desc="理解 go.mod、module path 與 Go 專案的依賴邊界">Go 專案結構與 module&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/variables-zero-values/" data-link-title="1.2 變數、零值與短變數宣告" data-link-desc="理解 Go 如何宣告、初始化與使用零值">變數、零值與短變數宣告&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/control-flow/" data-link-title="1.3 控制流程：if、for、switch" data-link-desc="掌握 Go 的條件判斷、迴圈與分支控制">控制流程：if、for、switch&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/packages/" data-link-title="1.4 package、檔案與可見性" data-link-desc="看懂 package main、檔案切分與大小寫可見性">package、檔案與可見性&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/growing-files-packages/" data-link-title="1.5 從單檔到多檔案" data-link-desc="理解 Go 程式如何從 main.go 長成多檔案與多 package">從單檔到多檔案&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/functions-methods/" data-link-title="1.6 函式、方法與 receiver" data-link-desc="區分普通函式、建構函式與帶 receiver 的方法">函式、方法與 receiver&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">從入口程式看應用啟動流程&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/01-basics/go-tooling-workflow/" data-link-title="1.8 Go tooling 與日常開發流程" data-link-desc="用 go run、go test、go fmt、go mod tidy 建立 Go 專案的基本工作節奏">Go tooling 與日常開發流程&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="模組二型別資料與介面">&lt;a href="https://tarrragon.github.io/blog/go/02-types-data/" data-link-title="模組二：型別、資料與介面" data-link-desc="用 struct、interface、slice、map、常數、embedding、generics 與 JSON tag 表達 Go 資料">模組二：型別、資料與介面&lt;/a>&lt;/h3>
&lt;p>用 struct、interface、constant、slice、map 與 JSON tag 表達資料。&lt;/p></description><content:encoded><![CDATA[<p>本教學文件專為想學會 Go 的工程師設計。它先回答一個前置問題：什麼情境下值得選 Go。第零章會先建立選型判斷，再往下展開 Go 的語言精神：簡單、顯式、組合、並發，以及用標準工具寫出可讀、可測、可維護的程式。</p>
<p>閱讀順序會從小型 CLI、資料處理、HTTP handler、背景工作一路走到即時通知程式。網路服務會逐步變成主角；前面的語法、標準庫與測試章節會先建立必要基礎。</p>
<h2 id="目標讀者">目標讀者</h2>
<ul>
<li>有程式經驗的工程師（非 Go 專家）</li>
<li>需要維護現有 Go 專案，或準備開發新的 Go 應用</li>
<li>想理解 Go 的語言取捨，而不只是記語法</li>
<li>需要掌握 Go 的型別、錯誤處理、並發與標準庫</li>
<li>未來可能開發 CLI、API、背景服務或即時系統的人</li>
</ul>
<h2 id="學習目標">學習目標</h2>
<ol>
<li>理解 Go 的設計哲學：簡單、顯式、組合優先</li>
<li>先從工作負載、架構型態、runtime 壓力與團隊條件判斷是否適合選 Go</li>
<li>能看懂 Go 專案的 package、module、struct、interface</li>
<li>掌握 Go 的控制流程、錯誤處理、資料建模與測試方法</li>
<li>理解 goroutine、channel、mutex 的設計目的</li>
<li>熟悉 <code>fmt</code>、<code>time</code>、<code>encoding/json</code>、<code>net/http</code>、<code>context</code> 等常用標準庫</li>
<li>能把 Go 的語言特性應用到 CLI、資料處理與網路服務</li>
</ol>
<h2 id="共用術語">共用術語</h2>
<p>本系列從入門到進階會反覆使用 action、command、domain event、repository、port、adapter、<a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a> 等詞彙。若你在實戰或重構章節看到這些詞，可以先回到 <a href="/blog/go/glossary/" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">Go 教材核心術語</a>，確認它們在這套教材中的責任邊界。</p>
<h2 id="第零章的定位">第零章的定位</h2>
<p>第零章先做選型判斷，再進入語法。你可以把它看成一個入口：先看你的工作負載是否屬於高併發 I/O、長連線、背景 worker、事件處理或服務邊界明確的 backend，再決定是否值得投入 Go。若情境更偏重框架生態、動態行為或大量既有業務流程模板，下一步應先比較其他語言與 Backend 教材的分工。</p>
<h2 id="與-backend-教材的分工">與 Backend 教材的分工</h2>
<p>Go 教材先處理語言能力與程式邊界：package、interface、context、error、goroutine、channel、testing、handler、repository port 與 ports/adapters。當內容開始談 SQLite、PostgreSQL、Redis、RabbitMQ、Kubernetes、Prometheus 這類外部服務時，就應該轉到跨語言的 <a href="/blog/backend/" data-link-title="Backend 服務實務指南" data-link-desc="用跨語言教學路線整理資料庫、快取、訊息佇列、觀測、部署、可靠性、資安、事故與容量等後端服務能力">Backend 服務實務指南</a>。</p>
<p>如果你在寫 Go 程式時只需要知道「怎麼把依賴接起來、怎麼處理取消、怎麼包裝錯誤、怎麼寫 fake 或 contract test」，那就是 Go 教材要解決的問題；如果你開始需要知道「某個外部服務怎麼部署、怎麼調參、怎麼操作」，那就是 Backend 的範圍。這套切分也同樣適用於 Python 與其他後端語言。</p>
<h2 id="教學模組">教學模組</h2>
<h3 id="模組零go-選型與設計哲學序章"><a href="/blog/go/00-philosophy/" data-link-title="模組零：Go 選型與設計哲學" data-link-desc="先判斷何時選擇 Go，再理解它的設計取捨">模組零：Go 選型與設計哲學（序章）</a></h3>
<p>先從選型條件與可讀性、維護成本理解 Go 為什麼這樣設計。</p>
<ul>
<li><a href="/blog/go/00-philosophy/selecting-go/" data-link-title="0.4 什麼時候選 Go" data-link-desc="用選型條件判斷 Go 是否適合高併發服務、背景工作與長連線場景">什麼時候選 Go</a></li>
<li><a href="/blog/go/00-philosophy/simplicity/" data-link-title="0.1 Go 的簡單哲學與認知負擔" data-link-desc="理解 Go 為什麼偏好顯式、直線流程與少量語法">Go 的簡單哲學與認知負擔</a></li>
<li><a href="/blog/go/00-philosophy/composition/" data-link-title="0.2 組合優先：小介面與明確依賴" data-link-desc="用小介面與 struct 組合取代大型繼承結構">組合優先：小介面與明確依賴</a></li>
<li><a href="/blog/go/00-philosophy/error-thinking/" data-link-title="0.3 錯誤處理：把失敗路徑寫出來" data-link-desc="理解 Go 顯式錯誤處理在服務維護中的價值">錯誤處理：把失敗路徑寫出來</a></li>
<li><a href="/blog/go/00-philosophy/concurrency-language-position/" data-link-title="0.5 Go 和其他並發語言的差異" data-link-desc="比較 Go、Java、C#、Rust、Node.js、Python async、Erlang/Elixir 在並發服務中的工程定位">Go 和其他並發語言的差異</a></li>
</ul>
<h3 id="模組一go-基礎概念"><a href="/blog/go/01-basics/" data-link-title="模組一：Go 基礎概念" data-link-desc="Go 專案結構、變數、控制流程、package、檔案拆分、函式、應用啟動與日常 tooling">模組一：Go 基礎概念</a></h3>
<p>先把閱讀 Go 專案最常見的基本模型建立起來。</p>
<ul>
<li><a href="/blog/go/01-basics/modules/" data-link-title="1.1 Go 專案結構與 module" data-link-desc="理解 go.mod、module path 與 Go 專案的依賴邊界">Go 專案結構與 module</a></li>
<li><a href="/blog/go/01-basics/variables-zero-values/" data-link-title="1.2 變數、零值與短變數宣告" data-link-desc="理解 Go 如何宣告、初始化與使用零值">變數、零值與短變數宣告</a></li>
<li><a href="/blog/go/01-basics/control-flow/" data-link-title="1.3 控制流程：if、for、switch" data-link-desc="掌握 Go 的條件判斷、迴圈與分支控制">控制流程：if、for、switch</a></li>
<li><a href="/blog/go/01-basics/packages/" data-link-title="1.4 package、檔案與可見性" data-link-desc="看懂 package main、檔案切分與大小寫可見性">package、檔案與可見性</a></li>
<li><a href="/blog/go/01-basics/growing-files-packages/" data-link-title="1.5 從單檔到多檔案" data-link-desc="理解 Go 程式如何從 main.go 長成多檔案與多 package">從單檔到多檔案</a></li>
<li><a href="/blog/go/01-basics/functions-methods/" data-link-title="1.6 函式、方法與 receiver" data-link-desc="區分普通函式、建構函式與帶 receiver 的方法">函式、方法與 receiver</a></li>
<li><a href="/blog/go/01-basics/main-flow/" data-link-title="1.7 從入口程式看應用啟動流程" data-link-desc="用入口程式建立 Go 程式的啟動與資料流模型">從入口程式看應用啟動流程</a></li>
<li><a href="/blog/go/01-basics/go-tooling-workflow/" data-link-title="1.8 Go tooling 與日常開發流程" data-link-desc="用 go run、go test、go fmt、go mod tidy 建立 Go 專案的基本工作節奏">Go tooling 與日常開發流程</a></li>
</ul>
<h3 id="模組二型別資料與介面"><a href="/blog/go/02-types-data/" data-link-title="模組二：型別、資料與介面" data-link-desc="用 struct、interface、slice、map、常數、embedding、generics 與 JSON tag 表達 Go 資料">模組二：型別、資料與介面</a></h3>
<p>用 struct、interface、constant、slice、map 與 JSON tag 表達資料。</p>
<ul>
<li><a href="/blog/go/02-types-data/struct-json/" data-link-title="2.1 struct 與 JSON tag" data-link-desc="理解 Go struct 如何表達資料形狀，並透過 JSON tag 對應外部格式">struct 與 JSON tag</a></li>
<li><a href="/blog/go/02-types-data/slices-maps/" data-link-title="2.2 slice 與 map" data-link-desc="掌握 Go 最常用的集合型別：slice 與 map">slice 與 map</a></li>
<li><a href="/blog/go/02-types-data/interfaces/" data-link-title="2.3 interface：用行為定義依賴" data-link-desc="用小介面描述元件需要的能力">interface：用行為定義依賴</a></li>
<li><a href="/blog/go/02-types-data/constants/" data-link-title="2.4 常數與 typed string" data-link-desc="管理狀態值、事件類型與協定字串">常數與 typed string</a></li>
<li><a href="/blog/go/02-types-data/pointers-copy/" data-link-title="2.5 指標與資料複製邊界" data-link-desc="理解指標、slice 與共享狀態的防護策略">指標與資料複製邊界</a></li>
<li><a href="/blog/go/02-types-data/embedding-composition/" data-link-title="2.6 struct embedding 與組合式設計" data-link-desc="理解 Go 的 embedding、方法提升與組合邊界">struct embedding 與組合式設計</a></li>
<li><a href="/blog/go/02-types-data/generics-basics/" data-link-title="2.7 generics 入門：型別參數與約束" data-link-desc="用最小範圍理解 Go generics 的適用場景">generics 入門：型別參數與約束</a></li>
</ul>
<h3 id="模組三標準庫實戰"><a href="/blog/go/03-stdlib/" data-link-title="模組三：標準庫實戰" data-link-desc="使用 fmt、time、encoding/json、net/http、log/slog、context、defer、flag 與 os/env 解決實務問題">模組三：標準庫實戰</a></h3>
<p>Go 標準庫如何把檔案處理、JSON、時間、HTTP 與結構化日誌串成可維護的日常工具。</p>
<ul>
<li><a href="/blog/go/03-stdlib/fmt-strings/" data-link-title="3.1 fmt、strings 與基本文字處理" data-link-desc="處理格式化輸出、字串清理、切割與組合">fmt、strings 與基本文字處理</a></li>
<li><a href="/blog/go/03-stdlib/time/" data-link-title="3.2 time：時間與 duration" data-link-desc="表達時間點、時間差、timer、ticker 與 timeout">time：時間與 duration</a></li>
<li><a href="/blog/go/03-stdlib/files-io/" data-link-title="3.3 os/io：檔案與輸入輸出" data-link-desc="讀寫檔案，理解 io.Reader 與 io.Writer">os/io：檔案與輸入輸出</a></li>
<li><a href="/blog/go/03-stdlib/json/" data-link-title="3.4 encoding/json：資料交換" data-link-desc="用 encoding/json 在 struct、檔案與 HTTP 之間交換資料">encoding/json：資料交換</a></li>
<li><a href="/blog/go/03-stdlib/http-handler/" data-link-title="3.5 net/http 與 handler 設計" data-link-desc="用 net/http 建立健康檢查、API endpoint 與清楚的 handler 邊界">net/http 與 handler 設計</a></li>
<li><a href="/blog/go/03-stdlib/slog/" data-link-title="3.6 log/slog：結構化日誌" data-link-desc="用 key-value log 設計可查詢、可過濾的程式訊號">log/slog：結構化日誌</a></li>
<li><a href="/blog/go/03-stdlib/context/" data-link-title="3.7 context：取消、逾時與生命週期" data-link-desc="用 context 傳遞取消、逾時與請求生命週期">context：取消、逾時與生命週期</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 邊界">defer 與資源清理</a></li>
<li><a href="/blog/go/03-stdlib/config-flags-env/" data-link-title="3.9 flag、os/env 與設定邊界" data-link-desc="用標準庫讀取設定，並把外部輸入轉成 config struct">flag、os/env 與設定邊界</a></li>
<li><a href="/blog/go/03-stdlib/service-support/" data-link-title="3.10 標準庫如何支撐服務型 Go" data-link-desc="把 context、net/http、log/slog、defer 與 time 連成服務底座">標準庫如何支撐服務型 Go</a></li>
</ul>
<h3 id="模組四並發模型"><a href="/blog/go/04-concurrency/" data-link-title="模組四：並發模型" data-link-desc="從 goroutine、channel、select 與 RWMutex 理解 Go 並發模型">模組四：並發模型</a></h3>
<p>從語言設計理解 goroutine、channel、select 與 mutex 為什麼這樣搭配。</p>
<ul>
<li><a href="/blog/go/04-concurrency/concurrency-model/" data-link-title="4.0 Go 並發模型總覽" data-link-desc="先理解 goroutine、OS thread 與 runtime 排程，再看高併發應用怎麼設計">Go 並發模型總覽</a></li>
<li><a href="/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">goroutine：背景工作與服務生命週期</a></li>
<li><a href="/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">channel：事件流與 backpressure </a></li>
<li><a href="/blog/go/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">select：同時等待多種事件</a></li>
<li><a href="/blog/go/04-concurrency/rwmutex/" data-link-title="4.4 sync.RWMutex：保護共享狀態" data-link-desc="用讀寫鎖保護共享狀態">sync.RWMutex：保護共享狀態</a></li>
<li><a href="/blog/go/04-concurrency/backpressure/" data-link-title="4.5 高併發控制與 backpressure " data-link-desc="用 bounded concurrency、backpressure 與 cancellation 控制 goroutine 的成長">高併發控制與 backpressure </a></li>
</ul>
<h3 id="模組五錯誤處理與測試"><a href="/blog/go/05-error-testing/" data-link-title="模組五：錯誤處理與測試" data-link-desc="用明確錯誤路徑、testing、table-driven test 與時間注入驗證 Go 程式">模組五：錯誤處理與測試</a></h3>
<p>讓 Go 程式不只會跑，還能被驗證、被除錯、也能承受失敗。</p>
<ul>
<li><a href="/blog/go/05-error-testing/errors/" data-link-title="5.1 錯誤回傳與早期返回" data-link-desc="寫出可追蹤的失敗路徑">錯誤回傳與早期返回</a></li>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">testing 基礎</a></li>
<li><a href="/blog/go/05-error-testing/table-driven-test/" data-link-title="5.3 table-driven test" data-link-desc="用表格整理多組輸入、預期輸出與錯誤情境">table-driven test</a></li>
<li><a href="/blog/go/05-error-testing/http-handler-test/" data-link-title="5.4 HTTP handler 測試" data-link-desc="用 httptest 驗證 request 與 response">HTTP handler 測試</a></li>
<li><a href="/blog/go/05-error-testing/time-injection/" data-link-title="5.5 時間注入與 deterministic test" data-link-desc="用 time provider 避免測試依賴真實時間">時間注入與 deterministic test</a></li>
<li><a href="/blog/go/05-error-testing/concurrency-test/" data-link-title="5.6 並發行為測試" data-link-desc="測試 channel、goroutine 與狀態更新">並發行為測試</a></li>
<li><a href="/blog/go/05-error-testing/service-reliability/" data-link-title="5.7 錯誤處理與測試在高併發服務中的角色" data-link-desc="把錯誤路徑、測試保護與並發行為放進服務可靠性觀點">錯誤處理與測試在高併發服務中的角色</a></li>
</ul>
<h3 id="模組六實戰應用"><a href="/blog/go/06-practical/" data-link-title="模組六：實戰指南" data-link-desc="用 Go 的核心概念完成常見服務功能：輸入、事件、狀態、背景工作、記錄與儲存邊界">模組六：實戰應用</a></h3>
<p>把前面的概念放進常見的 Go 開發工作。從這裡開始，網路服務情境會明顯增加。</p>
<ul>
<li><a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">如何新增一個 WebSocket action</a></li>
<li><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">如何新增一種事件類型</a></li>
<li><a href="/blog/go/06-practical/state-fields/" data-link-title="6.3 如何擴展狀態投影欄位" data-link-desc="更新狀態模型、repository 與 API 輸出">如何擴展狀態資料欄位</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">如何新增背景工作流程</a></li>
<li><a href="/blog/go/06-practical/structured-recording/" data-link-title="6.5 如何新增結構化記錄欄位" data-link-desc="區分 operational log、domain event log 與狀態資料">如何新增結構化記錄欄位</a></li>
<li><a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">如何新增 repository port</a></li>
<li><a href="/blog/go/06-practical/service-scenarios/" data-link-title="6.7 Go 常見服務場景總覽" data-link-desc="整理 Go 最常落地的服務情境：即時、背景、事件、通知與 API 聚合">Go 常見服務場景總覽</a></li>
<li><a href="/blog/go/06-practical/data-access-boundaries/" data-link-title="6.8 高併發下的 Redis 與 SQL 使用原則" data-link-desc="從 Go 服務角度整理 Redis 與 SQL 的高併發存取邊界">高併發下的 Redis 與 SQL 使用原則</a></li>
</ul>
<h3 id="模組七維護與重構"><a href="/blog/go/07-refactoring/" data-link-title="模組七：重構實戰" data-link-desc="用 Go 的 package、interface、state 與測試邊界重構逐漸變大的服務">模組七：維護與重構</a></h3>
<p>從現有程式中辨識邊界、降低耦合，並把並發風險收斂到可測的範圍。</p>
<ul>
<li><a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">把 handler 邏輯拆成可測單元</a></li>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">用 interface 隔離外部依賴</a></li>
<li><a href="/blog/go/07-refactoring/dedup-refactor/" data-link-title="7.3 事件去重邏輯的重構策略" data-link-desc="保留語義鍵並降低重複流程">事件去重邏輯的重構策略</a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">狀態管理的安全邊界</a></li>
<li><a href="/blog/go/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">以 domain 重新整理 package</a></li>
<li><a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">逐步遷移到 ports/adapters 架構</a></li>
<li><a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">composition root 與依賴組裝</a></li>
</ul>
<h3 id="模組八go-案例與讀碼路線"><a href="/blog/go/08-case-studies/" data-link-title="模組八：Go 案例與讀碼路線" data-link-desc="用一家公司一章的方式理解 Go 在真實服務中的使用方式">模組八：Go 案例與讀碼路線</a></h3>
<p>從官方案例與公開原始碼理解 Go 在真實服務中的使用方式。</p>
<ul>
<li><a href="/blog/go/08-case-studies/selection-patterns/" data-link-title="8.0 Go 的選型案例總覽" data-link-desc="用真實案例辨識 Go 常出現的服務選型條件">Go 的選型案例總覽</a></li>
<li><a href="/blog/go/08-case-studies/high-concurrency-services/" data-link-title="8.10 Go 的高併發服務案例" data-link-desc="從即時服務、邊緣網路與資料平台辨識 Go 的高併發使用情境">Go 的高併發服務案例</a></li>
<li><a href="/blog/go/08-case-studies/open-source-code-reading/" data-link-title="8.11 Go 公開原始碼讀碼路線" data-link-desc="用固定順序閱讀成熟 Go 專案的入口、package、並發與測試">Go 公開原始碼讀碼路線</a></li>
</ul>
<h2 id="主題導讀">主題導讀</h2>
<p>同一個主題會在不同階段重複出現，這是刻意安排：前面先學 Go 語法與標準庫，後面再把同一概念放進服務設計、重構與生產情境。遇到重疊時，可以依照下列路線閱讀，先看語言層，再看實戰與平台層。</p>
<table>
  <thead>
      <tr>
          <th>主題</th>
          <th>入門基礎</th>
          <th>實戰與重構</th>
          <th>進階延伸</th>
          <th>Backend 實作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>結構化日誌</td>
          <td><a href="/blog/go/03-stdlib/slog/" data-link-title="3.6 log/slog：結構化日誌" data-link-desc="用 key-value log 設計可查詢、可過濾的程式訊號">log/slog</a></td>
          <td><a href="/blog/go/06-practical/structured-recording/" data-link-title="6.5 如何新增結構化記錄欄位" data-link-desc="區分 operational log、domain event log 與狀態資料">結構化記錄欄位</a></td>
          <td><a href="/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">結構化日誌欄位設計</a></td>
          <td><a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">可觀測性平台</a></td>
      </tr>
      <tr>
          <td>時間與測試</td>
          <td><a href="/blog/go/03-stdlib/time/" data-link-title="3.2 time：時間與 duration" data-link-desc="表達時間點、時間差、timer、ticker 與 timeout">time</a></td>
          <td><a href="/blog/go/05-error-testing/time-injection/" data-link-title="5.5 時間注入與 deterministic test" data-link-desc="用 time provider 避免測試依賴真實時間">時間注入</a></td>
          <td><a href="/blog/go-advanced/05-testing-reliability/time-control/" data-link-title="5.1 時間注入與狀態轉移測試" data-link-desc="讓時間相關邏輯可重現">時間控制測試</a></td>
          <td><a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">部署平台</a>、<a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">可靠性驗證流程</a></td>
      </tr>
      <tr>
          <td>狀態與資料邊界</td>
          <td><a href="/blog/go/02-types-data/pointers-copy/" data-link-title="2.5 指標與資料複製邊界" data-link-desc="理解指標、slice 與共享狀態的防護策略">指標與資料複製邊界</a></td>
          <td><a href="/blog/go/06-practical/state-fields/" data-link-title="6.3 如何擴展狀態投影欄位" data-link-desc="更新狀態模型、repository 與 API 輸出">狀態欄位</a>、<a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">repository port</a>、<a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">狀態管理重構</a></td>
          <td><a href="/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">Source of Truth</a></td>
          <td><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">資料庫與持久化</a></td>
      </tr>
      <tr>
          <td>事件系統</td>
          <td><a href="/blog/go/02-types-data/constants/" data-link-title="2.4 常數與 typed string" data-link-desc="管理狀態值、事件類型與協定字串">typed string</a></td>
          <td><a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">新增 domain event</a>、<a href="/blog/go/07-refactoring/dedup-refactor/" data-link-title="7.3 事件去重邏輯的重構策略" data-link-desc="保留語義鍵並降低重複流程">事件去重重構</a></td>
          <td><a href="/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">事件去重語義鍵</a>、<a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">多來源 event 融合</a></td>
          <td><a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">訊息佇列與事件傳遞</a></td>
      </tr>
      <tr>
          <td><a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 與即時服務</td>
          <td><a href="/blog/go/03-stdlib/http-handler/" data-link-title="3.5 net/http 與 handler 設計" data-link-desc="用 net/http 建立健康檢查、API endpoint 與清楚的 handler 邊界">HTTP handler</a>、<a href="/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">channel</a></td>
          <td><a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">新增 WebSocket action</a></td>
          <td><a href="/blog/go-advanced/02-networking-websocket/" data-link-title="模組二：WebSocket 服務架構" data-link-desc="WebSocket client lifecycle、heartbeat、訂閱路由與慢客戶端管理">WebSocket 服務架構</a>、<a href="/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">跨節點 WebSocket</a></td>
          <td><a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">快取與 Redis</a>、<a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">訊息佇列</a></td>
      </tr>
      <tr>
          <td>專案成長與架構</td>
          <td><a href="/blog/go/01-basics/growing-files-packages/" data-link-title="1.5 從單檔到多檔案" data-link-desc="理解 Go 程式如何從 main.go 長成多檔案與多 package">從單檔到多檔案</a></td>
          <td><a href="/blog/go/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">domain package</a>、<a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">ports/adapters</a></td>
          <td><a href="/blog/go-advanced/04-architecture-boundaries/" data-link-title="模組四：架構邊界與事件系統" data-link-desc="用事件驅動架構拆解事件來源、處理流程、狀態邊界與即時推送">架構邊界與事件系統</a></td>
          <td><a href="/blog/backend/" data-link-title="Backend 服務實務指南" data-link-desc="用跨語言教學路線整理資料庫、快取、訊息佇列、觀測、部署、可靠性、資安、事故與容量等後端服務能力">Backend 服務實務指南</a></td>
      </tr>
      <tr>
          <td>公司案例與讀碼</td>
          <td><a href="/blog/go/08-case-studies/selection-patterns/" data-link-title="8.0 Go 的選型案例總覽" data-link-desc="用真實案例辨識 Go 常出現的服務選型條件">Go 選型案例總覽</a></td>
          <td><a href="/blog/go/08-case-studies/high-concurrency-services/" data-link-title="8.10 Go 的高併發服務案例" data-link-desc="從即時服務、邊緣網路與資料平台辨識 Go 的高併發使用情境">高併發服務案例</a></td>
          <td><a href="/blog/go/08-case-studies/open-source-code-reading/" data-link-title="8.11 Go 公開原始碼讀碼路線" data-link-desc="用固定順序閱讀成熟 Go 專案的入口、package、並發與測試">公開原始碼讀碼路線</a></td>
          <td><a href="/blog/go/08-case-studies/" data-link-title="模組八：Go 案例與讀碼路線" data-link-desc="用一家公司一章的方式理解 Go 在真實服務中的使用方式">Go 官方案例與 GitHub 原始碼</a></td>
      </tr>
  </tbody>
</table>
<h2 id="範例方式">範例方式</h2>
<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">examples/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── hello.go                 # 基本程式入口
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── config.go                # struct、map、錯誤處理
</span></span><span class="line"><span class="ln">4</span><span class="cl">├── parser.go                # 字串與 JSON 處理
</span></span><span class="line"><span class="ln">5</span><span class="cl">├── worker.go                # goroutine、channel、context
</span></span><span class="line"><span class="ln">6</span><span class="cl">└── server.go                # HTTP handler 與背景服務</span></span></code></pre></div><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">notify-service/
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── main.go                  # 服務入口、依賴組裝、HTTP route 註冊
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── websocket.go             # WebSocket 連線管理與訊息路由
</span></span><span class="line"><span class="ln">4</span><span class="cl">├── hub.go                   # 訂閱者管理與訊息廣播
</span></span><span class="line"><span class="ln">5</span><span class="cl">├── repository.go            # 狀態儲存與並發保護
</span></span><span class="line"><span class="ln">6</span><span class="cl">├── worker.go                # 背景工作與外部事件讀取
</span></span><span class="line"><span class="ln">7</span><span class="cl">├── handler.go               # HTTP endpoint
</span></span><span class="line"><span class="ln">8</span><span class="cl">├── models.go                # 資料結構與 JSON schema
</span></span><span class="line"><span class="ln">9</span><span class="cl">└── *_test.go                # 單元測試與整合測試</span></span></code></pre></div><p>這些範例是教學用的簡化版本，目標是說明 Go 語言與工程設計概念，而不是要求你先熟悉某個既有專案。</p>
<h2 id="如何使用本教學">如何使用本教學</h2>
<ol>
<li><strong>快速查閱</strong>：直接跳到正在處理的概念所在模組</li>
<li><strong>系統學習</strong>：按模組順序閱讀，建立完整 Go 語言模型</li>
<li><strong>實戰練習</strong>：先完成小程式，再進入網路服務範例</li>
</ol>
<hr>
<p><em>文件版本：v0.1.0</em>
<em>最後更新：2026-04-22</em>
<em>系列狀態：核心初稿完成</em></p>
]]></content:encoded></item><item><title>Go 進階指南</title><link>https://tarrragon.github.io/blog/go-advanced/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/</guid><description>&lt;p>本系列是接在入門教學之後的延伸路線，目標是把 Go 的並發模式、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> 服務架構、runtime 診斷、狀態邊界與生產環境可觀測性講到能真正用在服務上。語法細節留在入門篇；進階篇聚焦長時間服務會遇到的設計壓力。&lt;/p>
&lt;h2 id="目標讀者">目標讀者&lt;/h2>
&lt;ul>
&lt;li>已完成 &lt;a href="https://tarrragon.github.io/blog/go/" data-link-title="Go 入門實戰指南" data-link-desc="理解 Go 語言精神與核心開發能力">Go 入門實戰指南&lt;/a> 的工程師&lt;/li>
&lt;li>想深入理解 Go 並發模型與 runtime 行為的開發者&lt;/li>
&lt;li>需要維護長時間運行服務的人&lt;/li>
&lt;li>想把 Go 服務從「能跑」提升到「可觀測、可測、可演進」的人&lt;/li>
&lt;/ul>
&lt;h2 id="學習目標">學習目標&lt;/h2>
&lt;ol>
&lt;li>掌握 goroutine、channel、mutex 的進階使用邊界&lt;/li>
&lt;li>理解 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> client lifecycle、heartbeat、&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;li>使用 pprof、runtime 記憶體限制與結構化日誌診斷服務&lt;/li>
&lt;li>設計 event-driven service 的資料邊界與去重策略&lt;/li>
&lt;li>建立並發測試、整合測試與可重現的時間控制&lt;/li>
&lt;li>能評估 Go 服務在生產環境的風險與操作策略&lt;/li>
&lt;li>知道單一 Go 服務延伸到跨節點與平台整合時，哪些責任會轉移到資料庫、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a>、observability pipeline 與部署平台&lt;/li>
&lt;/ol>
&lt;h2 id="共用術語">共用術語&lt;/h2>
&lt;p>進階篇延續入門篇的 action、command、domain event、repository、port、adapter、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a> 等詞彙。若你需要確認這些詞在這套教材中的責任邊界，可以先回到 &lt;a href="https://tarrragon.github.io/blog/go/glossary/" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">Go 教材核心術語&lt;/a>。&lt;/p>
&lt;h2 id="與-backend-教材的分工">與 Backend 教材的分工&lt;/h2>
&lt;p>Go 進階篇處理單一 Go 服務內部的高階能力：goroutine lifecycle、WebSocket pump、runtime 診斷、event boundary、race test、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown&lt;/a> 與 diagnostics endpoint。當內容開始碰到資料庫、Redis、RabbitMQ、Kafka、OpenTelemetry、Kubernetes 或 CI 平台操作時，就應該轉到跨語言的 &lt;a href="https://tarrragon.github.io/blog/backend/" data-link-title="Backend 服務實務指南" data-link-desc="用跨語言教學路線整理資料庫、快取、訊息佇列、觀測、部署、可靠性、資安、事故與容量等後端服務能力">Backend 服務實務指南&lt;/a>。&lt;/p>
&lt;p>模組七保留在進階篇裡，因為它要回答「Go 服務在跨出去以前，還需要先把哪些 port、訊號與測試合約準備好」。外部系統本身的選型與部署細節，則放在 Backend，讓不同語言都能共用同一套實作知識。&lt;/p>
&lt;h2 id="教學模組">教學模組&lt;/h2>
&lt;h3 id="模組一進階並發模式">&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/" data-link-title="模組一：進階並發模式" data-link-desc="channel ownership、select loop、非阻塞送出、共享狀態、worker pool 與 rate limiting">模組一：進階並發模式&lt;/a>&lt;/h3>
&lt;p>從服務實例理解 fan-in、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out&lt;/a>、取消傳播與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> ，先把並發語意說清楚。&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/channel-ownership/" data-link-title="1.1 channel ownership 與關閉責任" data-link-desc="判斷誰能送出、接收與關閉 channel">channel ownership 與關閉責任&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">select loop 的生命週期設計&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">非阻塞送出與事件丟棄策略&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/shared-state/" data-link-title="1.4 共享狀態與複製邊界" data-link-desc="用 lock 與 copy 保護長期服務的狀態資料">共享狀態與複製邊界&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/worker-pool/" data-link-title="1.5 bounded worker pool" data-link-desc="限制同時執行的 goroutine 數量，讓背景工作有明確容量邊界">bounded worker pool&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/01-concurrency-patterns/rate-limit/" data-link-title="1.6 rate limiting 與 backpressure " data-link-desc="用本地速率限制與 backpressure 策略保護服務入口與下游依賴">rate limiting 與 backpressure &lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="模組二websocket-服務架構">&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/" data-link-title="模組二：WebSocket 服務架構" data-link-desc="WebSocket client lifecycle、heartbeat、訂閱路由與慢客戶端管理">模組二：WebSocket 服務架構&lt;/a>&lt;/h3>
&lt;p>把 WebSocket server 的連線、訂閱、推送與錯誤處理拆成可維護的邊界。&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">read pump / write pump 模式&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">heartbeat、deadline 與連線清理&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/subscription-routing/" data-link-title="2.3 訂閱模型與訊息路由" data-link-desc="將 client action 對應到主題訂閱狀態">訂閱模型與訊息路由&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/slow-client/" data-link-title="2.4 慢客戶端與 send buffer 管理" data-link-desc="控制推送佇列與記憶體風險">慢客戶端與 send buffer 管理&lt;/a>&lt;/li>
&lt;/ul>
&lt;h3 id="模組三runtime-與效能診斷">&lt;a href="https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/" data-link-title="模組三：Runtime 與效能診斷" data-link-desc="GC、memory limit、pprof、goroutine leak 與 allocation 壓力">模組三：Runtime 與效能診斷&lt;/a>&lt;/h3>
&lt;p>理解 Go runtime 如何在長時間運行服務中影響記憶體與排程行為。&lt;/p></description><content:encoded><![CDATA[<p>本系列是接在入門教學之後的延伸路線，目標是把 Go 的並發模式、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 服務架構、runtime 診斷、狀態邊界與生產環境可觀測性講到能真正用在服務上。語法細節留在入門篇；進階篇聚焦長時間服務會遇到的設計壓力。</p>
<h2 id="目標讀者">目標讀者</h2>
<ul>
<li>已完成 <a href="/blog/go/" data-link-title="Go 入門實戰指南" data-link-desc="理解 Go 語言精神與核心開發能力">Go 入門實戰指南</a> 的工程師</li>
<li>想深入理解 Go 並發模型與 runtime 行為的開發者</li>
<li>需要維護長時間運行服務的人</li>
<li>想把 Go 服務從「能跑」提升到「可觀測、可測、可演進」的人</li>
</ul>
<h2 id="學習目標">學習目標</h2>
<ol>
<li>掌握 goroutine、channel、mutex 的進階使用邊界</li>
<li>理解 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> client lifecycle、heartbeat、<a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 與慢客戶端問題</li>
<li>使用 pprof、runtime 記憶體限制與結構化日誌診斷服務</li>
<li>設計 event-driven service 的資料邊界與去重策略</li>
<li>建立並發測試、整合測試與可重現的時間控制</li>
<li>能評估 Go 服務在生產環境的風險與操作策略</li>
<li>知道單一 Go 服務延伸到跨節點與平台整合時，哪些責任會轉移到資料庫、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a>、<a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a>、observability pipeline 與部署平台</li>
</ol>
<h2 id="共用術語">共用術語</h2>
<p>進階篇延續入門篇的 action、command、domain event、repository、port、adapter、<a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a> 等詞彙。若你需要確認這些詞在這套教材中的責任邊界，可以先回到 <a href="/blog/go/glossary/" data-link-title="Go 教材核心術語" data-link-desc="整理 Go 入門與進階篇共用的架構、事件、狀態與邊界詞彙">Go 教材核心術語</a>。</p>
<h2 id="與-backend-教材的分工">與 Backend 教材的分工</h2>
<p>Go 進階篇處理單一 Go 服務內部的高階能力：goroutine lifecycle、WebSocket pump、runtime 診斷、event boundary、race test、<a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown</a> 與 diagnostics endpoint。當內容開始碰到資料庫、Redis、RabbitMQ、Kafka、OpenTelemetry、Kubernetes 或 CI 平台操作時，就應該轉到跨語言的 <a href="/blog/backend/" data-link-title="Backend 服務實務指南" data-link-desc="用跨語言教學路線整理資料庫、快取、訊息佇列、觀測、部署、可靠性、資安、事故與容量等後端服務能力">Backend 服務實務指南</a>。</p>
<p>模組七保留在進階篇裡，因為它要回答「Go 服務在跨出去以前，還需要先把哪些 port、訊號與測試合約準備好」。外部系統本身的選型與部署細節，則放在 Backend，讓不同語言都能共用同一套實作知識。</p>
<h2 id="教學模組">教學模組</h2>
<h3 id="模組一進階並發模式"><a href="/blog/go-advanced/01-concurrency-patterns/" data-link-title="模組一：進階並發模式" data-link-desc="channel ownership、select loop、非阻塞送出、共享狀態、worker pool 與 rate limiting">模組一：進階並發模式</a></h3>
<p>從服務實例理解 fan-in、<a href="/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out</a>、取消傳播與 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> ，先把並發語意說清楚。</p>
<ul>
<li><a href="/blog/go-advanced/01-concurrency-patterns/channel-ownership/" data-link-title="1.1 channel ownership 與關閉責任" data-link-desc="判斷誰能送出、接收與關閉 channel">channel ownership 與關閉責任</a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">select loop 的生命週期設計</a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">非阻塞送出與事件丟棄策略</a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/shared-state/" data-link-title="1.4 共享狀態與複製邊界" data-link-desc="用 lock 與 copy 保護長期服務的狀態資料">共享狀態與複製邊界</a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/worker-pool/" data-link-title="1.5 bounded worker pool" data-link-desc="限制同時執行的 goroutine 數量，讓背景工作有明確容量邊界">bounded worker pool</a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/rate-limit/" data-link-title="1.6 rate limiting 與 backpressure " data-link-desc="用本地速率限制與 backpressure 策略保護服務入口與下游依賴">rate limiting 與 backpressure </a></li>
</ul>
<h3 id="模組二websocket-服務架構"><a href="/blog/go-advanced/02-networking-websocket/" data-link-title="模組二：WebSocket 服務架構" data-link-desc="WebSocket client lifecycle、heartbeat、訂閱路由與慢客戶端管理">模組二：WebSocket 服務架構</a></h3>
<p>把 WebSocket server 的連線、訂閱、推送與錯誤處理拆成可維護的邊界。</p>
<ul>
<li><a href="/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">read pump / write pump 模式</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">heartbeat、deadline 與連線清理</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/subscription-routing/" data-link-title="2.3 訂閱模型與訊息路由" data-link-desc="將 client action 對應到主題訂閱狀態">訂閱模型與訊息路由</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/slow-client/" data-link-title="2.4 慢客戶端與 send buffer 管理" data-link-desc="控制推送佇列與記憶體風險">慢客戶端與 send buffer 管理</a></li>
</ul>
<h3 id="模組三runtime-與效能診斷"><a href="/blog/go-advanced/03-runtime-profiling/" data-link-title="模組三：Runtime 與效能診斷" data-link-desc="GC、memory limit、pprof、goroutine leak 與 allocation 壓力">模組三：Runtime 與效能診斷</a></h3>
<p>理解 Go runtime 如何在長時間運行服務中影響記憶體與排程行為。</p>
<ul>
<li><a href="/blog/go-advanced/03-runtime-profiling/gc-memory-limit/" data-link-title="3.1 GC 與 memory limit" data-link-desc="理解 debug.SetMemoryLimit 在長時間服務中的用途">GC 與 memory limit</a></li>
<li><a href="/blog/go-advanced/03-runtime-profiling/pprof/" data-link-title="3.2 pprof 基礎診斷流程" data-link-desc="用 pprof endpoint 診斷 heap、goroutine 與 CPU 問題">pprof 基礎診斷流程</a></li>
<li><a href="/blog/go-advanced/03-runtime-profiling/goroutine-leak/" data-link-title="3.3 goroutine leak 偵測" data-link-desc="判斷背景工作與 client pump 是否正確退出">goroutine leak 偵測</a></li>
<li><a href="/blog/go-advanced/03-runtime-profiling/allocation/" data-link-title="3.4 資料結構與 allocation 壓力" data-link-desc="分析列表、歷史資料與 WebSocket payload 的配置成本">資料結構與 allocation 壓力</a></li>
</ul>
<h3 id="模組四架構邊界與事件系統"><a href="/blog/go-advanced/04-architecture-boundaries/" data-link-title="模組四：架構邊界與事件系統" data-link-desc="用事件驅動架構拆解事件來源、處理流程、狀態邊界與即時推送">模組四：架構邊界與事件系統</a></h3>
<p>用事件驅動架構拆解服務責任，讓來源、處理與狀態不再混在一起。</p>
<ul>
<li><a href="/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">事件來源、處理流程與狀態邊界</a></li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">事件去重與語義鍵設計</a></li>
<li><a href="/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">Source of Truth：狀態邊界</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 與外部事件來源">多來源 event 融合</a></li>
</ul>
<h3 id="模組五測試與可靠性"><a href="/blog/go-advanced/05-testing-reliability/" data-link-title="模組五：測試與可靠性" data-link-desc="時間控制、WebSocket integration test、race check 與 table-driven test">模組五：測試與可靠性</a></h3>
<p>針對並發服務建立能真正揭露風險的測試，而不是只追求覆蓋率。</p>
<ul>
<li><a href="/blog/go-advanced/05-testing-reliability/time-control/" data-link-title="5.1 時間注入與狀態轉移測試" data-link-desc="讓時間相關邏輯可重現">時間注入與狀態轉移測試</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/websocket-integration/" data-link-title="5.2 WebSocket integration test" data-link-desc="驗證 client/server 實際互動">WebSocket integration test</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/race-check/" data-link-title="5.3 race condition 檢查" data-link-desc="用 go test -race 找資料競爭">race condition 檢查</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/table-tests/" data-link-title="5.4 table-driven test 的設計邊界" data-link-desc="避免測試資料混雜太多概念">table-driven test 的設計邊界</a></li>
</ul>
<h3 id="模組六生產操作"><a href="/blog/go-advanced/06-production-operations/" data-link-title="模組六：生產操作" data-link-desc="graceful shutdown、健康檢查、結構化日誌與 feature gate">模組六：生產操作</a></h3>
<p>把本地服務推向可維護、可診斷、可部署的操作狀態。</p>
<ul>
<li><a href="/blog/go-advanced/06-production-operations/graceful-shutdown/" data-link-title="6.1 graceful shutdown 與 signal handling" data-link-desc="用 signal 與 context 傳遞停止訊號">graceful shutdown 與 signal handling</a></li>
<li><a href="/blog/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">健康檢查與診斷 endpoint</a></li>
<li><a href="/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">結構化日誌欄位設計</a></li>
<li><a href="/blog/go-advanced/06-production-operations/feature-gate/" data-link-title="6.4 版本偵測與 feature gate" data-link-desc="依版本與環境能力啟用功能">版本偵測與 feature gate</a></li>
</ul>
<h3 id="模組七跨節點與平台整合"><a href="/blog/go-advanced/07-distributed-operations/" data-link-title="模組七：跨節點與平台整合" data-link-desc="把單一 Go 服務延伸到資料庫、queue、跨節點 WebSocket、可觀測性與部署平台">模組七：跨節點與平台整合</a></h3>
<p>承接各章「本章不處理」的延伸邊界，把單一服務往外擴張時必須補上的責任整理成一條清楚路線。</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">資料庫 transaction 與 schema migration</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Durable queue、outbox 與 idempotency</a></li>
<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 擴展到多節點推送與連線狀態">跨節點 WebSocket、presence 與重連協定</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Observability pipeline、metrics 與 tracing</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Kubernetes、systemd 與 load balancer 合約</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">CI、fuzz、load test 與 chaos testing</a></li>
</ul>
<h2 id="學習路徑">學習路徑</h2>
<h3 id="路徑-a並發服務維護者">路徑 A：並發服務維護者</h3>





<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">模組一 → 模組四 → 模組五</span></span></code></pre></div><p>重點：事件流、共享狀態、並發測試。</p>
<h3 id="路徑-bwebsocketapi-開發者">路徑 B：WebSocket/API 開發者</h3>





<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">模組二 → 模組六 → 模組五</span></span></code></pre></div><p>重點：連線生命週期、訊息路由、操作診斷。</p>
<h3 id="路徑-c效能與可靠性工程師">路徑 C：效能與可靠性工程師</h3>





<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">模組三 → 模組五 → 模組六</span></span></code></pre></div><p>重點：pprof、goroutine leak、race check、服務操作。</p>
<h3 id="路徑-d完整學習">路徑 D：完整學習</h3>





<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">模組一 → 模組二 → 模組三 → 模組四 → 模組五 → 模組六 → 模組七</span></span></code></pre></div><p>按順序學習，建立完整的 Go 長時間運行服務模型。</p>
<h2 id="主題延伸地圖">主題延伸地圖</h2>
<p>進階篇的章節會反覆碰到 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>、time、state、event、WebSocket 與 testing。這些主題會在不同服務壓力下承擔不同責任；主題延伸地圖用來幫讀者辨識每一層的分工。</p>
<table>
  <thead>
      <tr>
          <th>主題</th>
          <th>單一 process 內的設計</th>
          <th>生產操作</th>
          <th>跨節點邊界</th>
          <th>Backend 實作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>並發與容量</td>
          <td><a href="/blog/go-advanced/01-concurrency-patterns/channel-ownership/" data-link-title="1.1 channel ownership 與關閉責任" data-link-desc="判斷誰能送出、接收與關閉 channel">channel ownership</a>、<a href="/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">select loop</a>、<a href="/blog/go-advanced/01-concurrency-patterns/non-blocking-send/" data-link-title="1.3 非阻塞送出與事件丟棄策略" data-link-desc="設計 channel 滿載時的服務行為">非阻塞送出</a></td>
          <td><a href="/blog/go-advanced/05-testing-reliability/race-check/" data-link-title="5.3 race condition 檢查" data-link-desc="用 go test -race 找資料競爭">race condition 檢查</a>、<a href="/blog/go-advanced/06-production-operations/graceful-shutdown/" data-link-title="6.1 graceful shutdown 與 signal handling" data-link-desc="用 signal 與 context 傳遞停止訊號">graceful shutdown</a></td>
          <td><a href="/blog/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">可靠性驗證流程</a></td>
          <td><a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證</a></td>
      </tr>
      <tr>
          <td>WebSocket</td>
          <td><a href="/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">read/write pump</a>、<a href="/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">heartbeat</a>、<a href="/blog/go-advanced/02-networking-websocket/slow-client/" data-link-title="2.4 慢客戶端與 send buffer 管理" data-link-desc="控制推送佇列與記憶體風險">慢客戶端</a></td>
          <td><a href="/blog/go-advanced/05-testing-reliability/websocket-integration/" data-link-title="5.2 WebSocket integration test" data-link-desc="驗證 client/server 實際互動">WebSocket integration test</a>、<a href="/blog/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">health diagnostics</a></td>
          <td><a href="/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">跨節點 WebSocket</a></td>
          <td><a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">Backend：快取與 Redis</a>、<a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">訊息佇列</a></td>
      </tr>
      <tr>
          <td>Runtime 診斷</td>
          <td><a href="/blog/go-advanced/03-runtime-profiling/gc-memory-limit/" data-link-title="3.1 GC 與 memory limit" data-link-desc="理解 debug.SetMemoryLimit 在長時間服務中的用途">GC 與 memory limit</a>、<a href="/blog/go-advanced/03-runtime-profiling/pprof/" data-link-title="3.2 pprof 基礎診斷流程" data-link-desc="用 pprof endpoint 診斷 heap、goroutine 與 CPU 問題">pprof</a>、<a href="/blog/go-advanced/03-runtime-profiling/goroutine-leak/" data-link-title="3.3 goroutine leak 偵測" data-link-desc="判斷背景工作與 client pump 是否正確退出">goroutine leak</a></td>
          <td><a href="/blog/go-advanced/06-production-operations/health-diagnostics/" data-link-title="6.2 健康檢查與診斷 endpoint" data-link-desc="區分服務可用性與工程診斷入口">diagnostics endpoint</a></td>
          <td><a href="/blog/go-advanced/07-distributed-operations/observability-pipeline/" data-link-title="7.4 Observability pipeline、metrics 與 tracing" data-link-desc="把 structured log、metric、trace 與 profile 組成可操作的診斷系統">Observability pipeline</a>、<a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">部署平台合約</a></td>
          <td><a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend：可觀測性平台</a>、<a href="/blog/backend/05-deployment-platform/" data-link-title="模組五：部署平台與網路入口" data-link-desc="整理 Kubernetes、systemd、load balancer、container 與服務生命週期合約">部署平台</a></td>
      </tr>
      <tr>
          <td>事件與狀態</td>
          <td><a href="/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">component boundaries</a>、<a href="/blog/go-advanced/04-architecture-boundaries/source-of-truth/" data-link-title="4.3 Source of Truth：狀態邊界" data-link-desc="集中狀態更新、保護可變資料、設計查詢 projection">source of truth</a>、<a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">event fusion</a></td>
          <td><a href="/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">結構化日誌欄位</a></td>
          <td><a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">outbox 與 idempotency</a>、<a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">資料庫 transaction</a></td>
          <td><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫</a>、<a href="/blog/backend/03-message-queue/" data-link-title="模組三：訊息佇列與事件傳遞" data-link-desc="整理 durable queue、broker、retry、outbox 與 idempotency 的後端實務">訊息佇列</a></td>
      </tr>
      <tr>
          <td>測試分層</td>
          <td><a href="/blog/go-advanced/05-testing-reliability/time-control/" data-link-title="5.1 時間注入與狀態轉移測試" data-link-desc="讓時間相關邏輯可重現">時間控制</a>、<a href="/blog/go-advanced/05-testing-reliability/table-tests/" data-link-title="5.4 table-driven test 的設計邊界" data-link-desc="避免測試資料混雜太多概念">table-driven test</a></td>
          <td><a href="/blog/go-advanced/05-testing-reliability/race-check/" data-link-title="5.3 race condition 檢查" data-link-desc="用 go test -race 找資料競爭">race check</a>、<a href="/blog/go-advanced/05-testing-reliability/websocket-integration/" data-link-title="5.2 WebSocket integration test" data-link-desc="驗證 client/server 實際互動">integration test</a></td>
          <td><a href="/blog/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">可靠性驗證流程</a></td>
          <td><a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證</a></td>
      </tr>
  </tbody>
</table>
<h2 id="先備知識">先備知識</h2>
<p>本系列假設你已經完成 <a href="/blog/go/" data-link-title="Go 入門實戰指南" data-link-desc="理解 Go 語言精神與核心開發能力">Go 入門實戰指南</a> 的基礎部分，因為下面這些章節會直接沿用那些概念：</p>
<ul>
<li><a href="/blog/go/03-stdlib/" data-link-title="模組三：標準庫實戰" data-link-desc="使用 fmt、time、encoding/json、net/http、log/slog、context、defer、flag 與 os/env 解決實務問題">模組三：標準庫實戰</a></li>
<li><a href="/blog/go/04-concurrency/" data-link-title="模組四：並發模型" data-link-desc="從 goroutine、channel、select 與 RWMutex 理解 Go 並發模型">模組四：並發模型</a></li>
<li><a href="/blog/go/05-error-testing/" data-link-title="模組五：錯誤處理與測試" data-link-desc="用明確錯誤路徑、testing、table-driven test 與時間注入驗證 Go 程式">模組五：錯誤處理與測試</a></li>
</ul>
<h2 id="每章結構">每章結構</h2>
<p>每章都採用「由淺到深」的結構，先說明問題，再切到設計與實作：</p>
<ol>
<li><strong>原理層</strong>：這個機制解決什麼問題</li>
<li><strong>設計層</strong>：在服務架構中如何切責任</li>
<li><strong>實作層</strong>：用簡化範例程式碼看具體做法</li>
<li><strong>實戰檢查</strong>：維護時要確認哪些風險</li>
</ol>
<hr>
<p><em>文件版本：v0.1.0</em>
<em>最後更新：2026-04-22</em>
<em>系列狀態：核心初稿完成，延伸模組規劃中</em></p>
]]></content:encoded></item><item><title>Go 教材核心術語</title><link>https://tarrragon.github.io/blog/go/glossary/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/glossary/</guid><description>&lt;p>本頁整理 Go 入門篇與進階篇反覆使用的詞彙。核心目的是讓同一個概念在不同章節中保持同一種意思。&lt;/p>
&lt;p>Go 教材中的術語應先服務可讀性：詞彙要幫助工程師判斷責任邊界，而不是把簡單程式包裝成複雜架構。小程式可以只有 &lt;code>main.go&lt;/code>，服務變大後才逐步引入 event、repository、port、adapter、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection&lt;/a> 等詞彙。&lt;/p>
&lt;h2 id="輸入與行為">輸入與行為&lt;/h2>
&lt;h3 id="action">Action&lt;/h3>
&lt;p>&lt;code>action&lt;/code> 表示 client 對服務提出的意圖。它通常來自 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> message、HTTP request 或 CLI input，還沒有完成驗證、授權或業務規則套用。&lt;/p>
&lt;p>例如 &lt;code>subscribe_topic&lt;/code> 可以是 WebSocket action，代表 client 想訂閱某個 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a>。它進入系統後，router 會先解析 payload，再交給 usecase 或 subscription manager。&lt;/p>
&lt;p>延伸閱讀：&lt;a href="https://tarrragon.github.io/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">如何新增一個 WebSocket action&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/subscription-routing/" data-link-title="2.3 訂閱模型與訊息路由" data-link-desc="將 client action 對應到主題訂閱狀態">訂閱模型與訊息路由&lt;/a>。&lt;/p>
&lt;h3 id="command">Command&lt;/h3>
&lt;p>&lt;code>command&lt;/code> 表示 application layer 接受的行為輸入。它已經脫離 HTTP JSON、WebSocket frame 或 CLI flag 的外部格式，變成 usecase 可以理解的資料。&lt;/p>
&lt;p>例如 &lt;code>CreateNotificationCommand&lt;/code> 可以由 HTTP handler、WebSocket router 或背景 worker 建立。handler 負責把 request DTO 轉成 command，usecase 負責處理 command 的規則。&lt;/p>
&lt;p>延伸閱讀：&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">把 handler 邏輯拆成可測單元&lt;/a>。&lt;/p>
&lt;h3 id="usecase">Usecase&lt;/h3>
&lt;p>&lt;code>usecase&lt;/code> 表示一個 application 行為。它負責協調 validation、repository、event publisher、clock、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a> 等能力，並保留「這件事如何完成」的規則。&lt;/p>
&lt;p>usecase 的重點是行為邊界，不是資料夾名稱。小型程式可以先用函式表達 usecase；當 handler、worker、WebSocket action 都需要共用同一套規則時，再把 usecase 抽出來。&lt;/p>
&lt;p>延伸閱讀：&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">把 handler 邏輯拆成可測單元&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">逐步遷移到 ports/adapters 架構&lt;/a>。&lt;/p>
&lt;h2 id="事件系統">事件系統&lt;/h2>
&lt;h3 id="domain-event">Domain Event&lt;/h3>
&lt;p>&lt;code>domain event&lt;/code> 表示系統承認已經發生的內部事實。它和 action 不同：action 是請求，domain event 是經過系統語意整理後的事實。&lt;/p>
&lt;p>例如 &lt;code>notification.created&lt;/code> 可以表示通知已被建立。這個事件可以來自 HTTP request、WebSocket action、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> message 或 background worker，但進入 processor 前應先被 normalize 成同一種內部模型。&lt;/p>
&lt;p>延伸閱讀：&lt;a href="https://tarrragon.github.io/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">如何新增一種事件類型&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">事件來源、處理流程與狀態邊界&lt;/a>。&lt;/p>
&lt;h3 id="domainevent">DomainEvent&lt;/h3>
&lt;p>&lt;code>DomainEvent&lt;/code> 是範例程式中用來承載 domain event 的 Go 型別。它通常包含事件類型、來源、主體、發生時間、接收時間與 payload。&lt;/p>
&lt;p>名稱使用 PascalCase 是因為它是 Go 型別；概念說明時使用 &lt;code>domain event&lt;/code>，程式碼型別使用 &lt;code>DomainEvent&lt;/code>。&lt;/p>
&lt;h3 id="event-envelope">Event Envelope&lt;/h3>
&lt;p>&lt;code>event envelope&lt;/code> 是事件外層的穩定欄位集合。它通常描述 event id、event type、source、subject、occurred time、received time、schema version、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/correlation-id/" data-link-title="Correlation ID" data-link-desc="說明跨事件或跨服務的關聯識別碼如何支援排障">correlation id&lt;/a> 與 payload。&lt;/p></description><content:encoded><![CDATA[<p>本頁整理 Go 入門篇與進階篇反覆使用的詞彙。核心目的是讓同一個概念在不同章節中保持同一種意思。</p>
<p>Go 教材中的術語應先服務可讀性：詞彙要幫助工程師判斷責任邊界，而不是把簡單程式包裝成複雜架構。小程式可以只有 <code>main.go</code>，服務變大後才逐步引入 event、repository、port、adapter、<a href="/blog/backend/knowledge-cards/projection/" data-link-title="Projection" data-link-desc="說明從事件流或資料變更推算出查詢用讀取視圖的轉換機制">projection</a> 等詞彙。</p>
<h2 id="輸入與行為">輸入與行為</h2>
<h3 id="action">Action</h3>
<p><code>action</code> 表示 client 對服務提出的意圖。它通常來自 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> message、HTTP request 或 CLI input，還沒有完成驗證、授權或業務規則套用。</p>
<p>例如 <code>subscribe_topic</code> 可以是 WebSocket action，代表 client 想訂閱某個 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a>。它進入系統後，router 會先解析 payload，再交給 usecase 或 subscription manager。</p>
<p>延伸閱讀：<a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">如何新增一個 WebSocket action</a>、<a href="/blog/go-advanced/02-networking-websocket/subscription-routing/" data-link-title="2.3 訂閱模型與訊息路由" data-link-desc="將 client action 對應到主題訂閱狀態">訂閱模型與訊息路由</a>。</p>
<h3 id="command">Command</h3>
<p><code>command</code> 表示 application layer 接受的行為輸入。它已經脫離 HTTP JSON、WebSocket frame 或 CLI flag 的外部格式，變成 usecase 可以理解的資料。</p>
<p>例如 <code>CreateNotificationCommand</code> 可以由 HTTP handler、WebSocket router 或背景 worker 建立。handler 負責把 request DTO 轉成 command，usecase 負責處理 command 的規則。</p>
<p>延伸閱讀：<a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">把 handler 邏輯拆成可測單元</a>。</p>
<h3 id="usecase">Usecase</h3>
<p><code>usecase</code> 表示一個 application 行為。它負責協調 validation、repository、event publisher、clock、<a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 等能力，並保留「這件事如何完成」的規則。</p>
<p>usecase 的重點是行為邊界，不是資料夾名稱。小型程式可以先用函式表達 usecase；當 handler、worker、WebSocket action 都需要共用同一套規則時，再把 usecase 抽出來。</p>
<p>延伸閱讀：<a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">把 handler 邏輯拆成可測單元</a>、<a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">逐步遷移到 ports/adapters 架構</a>。</p>
<h2 id="事件系統">事件系統</h2>
<h3 id="domain-event">Domain Event</h3>
<p><code>domain event</code> 表示系統承認已經發生的內部事實。它和 action 不同：action 是請求，domain event 是經過系統語意整理後的事實。</p>
<p>例如 <code>notification.created</code> 可以表示通知已被建立。這個事件可以來自 HTTP request、WebSocket action、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> message 或 background worker，但進入 processor 前應先被 normalize 成同一種內部模型。</p>
<p>延伸閱讀：<a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">如何新增一種事件類型</a>、<a href="/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">事件來源、處理流程與狀態邊界</a>。</p>
<h3 id="domainevent">DomainEvent</h3>
<p><code>DomainEvent</code> 是範例程式中用來承載 domain event 的 Go 型別。它通常包含事件類型、來源、主體、發生時間、接收時間與 payload。</p>
<p>名稱使用 PascalCase 是因為它是 Go 型別；概念說明時使用 <code>domain event</code>，程式碼型別使用 <code>DomainEvent</code>。</p>
<h3 id="event-envelope">Event Envelope</h3>
<p><code>event envelope</code> 是事件外層的穩定欄位集合。它通常描述 event id、event type、source、subject、occurred time、received time、schema version、<a href="/blog/backend/knowledge-cards/correlation-id/" data-link-title="Correlation ID" data-link-desc="說明跨事件或跨服務的關聯識別碼如何支援排障">correlation id</a> 與 payload。</p>
<p>envelope 的價值是讓不同事件共享同一種路由、去重、記錄與觀測方式。payload 則保留每種事件自己的資料內容。</p>
<p>延伸閱讀：<a href="/blog/go/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">如何新增一種事件類型</a>、<a href="/blog/go-advanced/04-architecture-boundaries/event-fusion/" data-link-title="4.4 多來源 event 融合" data-link-desc="合併 HTTP、queue、timer 與外部事件來源">多來源 event 融合</a>。</p>
<h3 id="event-log">Event Log</h3>
<p><code>event log</code> 表示記錄 domain event 的事實紀錄。它用來追蹤系統承認過哪些事件，重點是事件語意、順序、去重與後續查詢。</p>
<p>[event <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>](/go/backend/knowledge-cards/event-log) 和 structured log 的用途不同。structured log 服務操作診斷，event log 服務業務事實追蹤；兩者可以共享 <code>trace_id</code>、<code>event_id</code>、<code>subject_id</code> 等欄位，但不應互相取代。</p>
<p>延伸閱讀：<a href="/blog/go/06-practical/structured-recording/" data-link-title="6.5 如何新增結構化記錄欄位" data-link-desc="區分 operational log、domain event log 與狀態資料">如何新增結構化記錄欄位</a>、<a href="/blog/go-advanced/06-production-operations/log-fields/" data-link-title="6.3 結構化日誌欄位設計" data-link-desc="讓 log 可 grep、可聚合、可追蹤">結構化日誌欄位設計</a>。</p>
<h3 id="event-store">Event Store</h3>
<p><code>event store</code> 是具備持久化、排序、replay、schema 演進與 transaction 語意的事件儲存。它比 event log 承擔更高的資料一致性責任。</p>
<p>教材中的 event log 先用來建立事件記錄概念；當系統需要 replay、跨節點處理或以事件歷史作為狀態來源時，才需要討論 event store。</p>
<p>延伸閱讀：<a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Durable queue、outbox 與 idempotency</a>。</p>
<h3 id="event-sourcing">Event Sourcing</h3>
<p><code>event sourcing</code> 表示以事件歷史作為狀態真相來源。系統透過事件序列重建狀態，事件歷史本身就是真相。</p>
<p>保留 event log 不等於採用 event sourcing。許多服務會記錄 domain event 作為審計或整合用途，但 <a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">source of truth</a> 仍然是資料庫中的 current state。</p>
<p>延伸閱讀：<a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of Truth：狀態邊界</a>。</p>
<h3 id="dedup-key">Dedup Key</h3>
<p><code>dedup key</code> 表示用 domain 語意判斷兩筆事件是否是同一件事的 key。它通常由 subject kind、subject id、event type、外部序號或時間窗口組成。</p>
<p>dedup key 的重點是「同一件事」，不是「同一份 bytes」。raw payload hash 可以偵測完全相同的輸入，但無法處理不同來源描述同一個 domain fact 的情境。</p>
<p>延伸閱讀：<a href="/blog/go/07-refactoring/dedup-refactor/" data-link-title="7.3 事件去重邏輯的重構策略" data-link-desc="保留語義鍵並降低重複流程">事件去重邏輯的重構策略</a>、<a href="/blog/go-advanced/04-architecture-boundaries/dedup-key/" data-link-title="4.2 事件去重與語義鍵設計" data-link-desc="用 entity ID、event type、來源語意與時間窗口建立去重鍵">事件去重與語義鍵設計</a>。</p>
<h3 id="idempotency-key">Idempotency Key</h3>
<p><code>idempotency key</code> 表示外部呼叫或重試流程用來安全重複執行的 key。它常出現在 HTTP request、queue message 或 outbox publish 流程中。</p>
<p><a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> key 和 dedup key 的責任不同。idempotency key 保護同一次操作的重試；dedup key 保護 domain 層面上同一件事的重複描述。</p>
<p>延伸閱讀：<a href="/blog/go-advanced/07-distributed-operations/outbox-idempotency/" data-link-title="7.2 Durable queue、outbox 與 idempotency" data-link-desc="設計跨 process 事件傳遞的可靠性與去重邊界">Durable queue、outbox 與 idempotency</a>。</p>
<h2 id="狀態與資料模型">狀態與資料模型</h2>
<h3 id="repository">Repository</h3>
<p><code>repository</code> 表示狀態或資料存取的邊界。它負責保存與讀取某一類資料，並讓外部呼叫者不需要知道資料目前存在 memory、<a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a> 或遠端服務。</p>
<p>repository 的核心價值是權責集中。當狀態轉移、copy boundary、transaction 或查詢模型開始變複雜時，把資料能力集中在 repository 會比讓 handler 直接操作 map 更穩定。</p>
<p>延伸閱讀：<a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">如何新增 repository port</a>、<a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">狀態管理的安全邊界</a>。</p>
<h3 id="repository-port">Repository Port</h3>
<p><code>repository port</code> 表示 application layer 需要的資料能力介面。它由 usecase 的需求定義，而不是由資料庫表格或具體儲存技術定義。</p>
<p>例如 usecase 只需要 <code>Save</code> 和 <code>FindByID</code>，port 就只暴露這兩個方法。memory repository、SQL repository 或 test fake 都可以實作同一個 port。</p>
<p>延伸閱讀：<a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">如何新增 repository port</a>、<a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">資料庫 transaction 與 schema migration</a>。</p>
<h3 id="state-owner">State Owner</h3>
<p><code>state owner</code> 表示擁有某份可變狀態寫入權的元件。它可以是 mutex 保護的 repository，也可以是單一 goroutine 持有狀態並透過 channel 接收 command。</p>
<p>state owner 的重點是只有一個地方能決定狀態如何改變。其他元件應送入 command 或 event，而不是直接修改內部 map、slice 或 pointer。</p>
<p>延伸閱讀：<a href="/blog/go-advanced/01-concurrency-patterns/shared-state/" data-link-title="1.4 共享狀態與複製邊界" data-link-desc="用 lock 與 copy 保護長期服務的狀態資料">共享狀態與複製邊界</a>、<a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of Truth：狀態邊界</a>。</p>
<h3 id="source-of-truth">Source of Truth</h3>
<p><code>source of truth</code> 表示狀態轉移的寫入權責，是系統承認「誰能決定目前狀態」的邊界。</p>
<p>小型服務的 source of truth 可能是 memory repository；加入資料庫後，source of truth 仍然包含 application 的狀態規則、transaction 邊界與持久化資料。</p>
<p>延伸閱讀：<a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of Truth：狀態邊界</a>。</p>
<h3 id="projection--read-model">Projection / Read Model</h3>
<p><code>projection</code> 或 <code>read model</code> 表示為讀取需求整理出的資料模型。它可以來自 domain state、event history 或其他來源，目標是讓查詢、列表、即時推送或 UI 顯示更直接。</p>
<p>projection 可以提升讀取效率與簡化 response 組裝，但它不應反過來成為狀態真相。狀態規則仍然應由 repository、state owner 或 usecase 控制。</p>
<p>延伸閱讀：<a href="/blog/go/06-practical/state-fields/" data-link-title="6.3 如何擴展狀態投影欄位" data-link-desc="更新狀態模型、repository 與 API 輸出">如何擴展狀態資料欄位</a>、<a href="/blog/backend/knowledge-cards/source-of-truth/" data-link-title="Source of Truth" data-link-desc="說明正式資料來源如何決定資料判斷、修復與一致性責任">Source of Truth：狀態邊界</a>。</p>
<h3 id="response-view">Response View</h3>
<p><code>response view</code> 表示對外輸出的資料形狀。它負責 JSON tag、<code>omitempty</code>、顯示文字、相容性欄位與 API contract。</p>
<p>response view 的核心責任是翻譯內部資料給外部使用者。顯示文字、前端 badge、API 版本相容欄位通常應放在 response view，而不是混進 domain state。</p>
<p>延伸閱讀：<a href="/blog/go/06-practical/state-fields/" data-link-title="6.3 如何擴展狀態投影欄位" data-link-desc="更新狀態模型、repository 與 API 輸出">如何擴展狀態資料欄位</a>。</p>
<h3 id="dto">DTO</h3>
<p><code>DTO</code> 表示資料傳輸形狀。它常用於 HTTP request、HTTP response、queue message、WebSocket payload 或外部 API client。</p>
<p>DTO 的責任是描述邊界格式。它可以有 JSON tag、相容性欄位與外部命名慣例，但不應直接取代 domain model、repository model 或 command。</p>
<p>延伸閱讀：<a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">把 handler 邏輯拆成可測單元</a>、<a href="/blog/go/07-refactoring/domain-packages/" data-link-title="7.5 以 domain 重新整理 package" data-link-desc="讓 account、job、event、workflow 這類領域邊界在目錄中可見">以 domain 重新整理 package</a>。</p>
<h3 id="copy-boundary">Copy Boundary</h3>
<p><code>copy boundary</code> 表示回傳或接收 slice、map、pointer 時用複製保護狀態所有權的邊界。它防止呼叫端透過引用修改 repository 內部資料。</p>
<p>Go 的 slice、map 與 pointer 都可能共享底層資料，所以 repository 回傳資料時要判斷是否需要 shallow copy 或 deep copy。資料量大時，可以改用分頁、projection 或 snapshot cache 來控制成本。</p>
<p>延伸閱讀：<a href="/blog/go/02-types-data/pointers-copy/" data-link-title="2.5 指標與資料複製邊界" data-link-desc="理解指標、slice 與共享狀態的防護策略">指標與資料複製邊界</a>、<a href="/blog/go-advanced/01-concurrency-patterns/shared-state/" data-link-title="1.4 共享狀態與複製邊界" data-link-desc="用 lock 與 copy 保護長期服務的狀態資料">共享狀態與複製邊界</a>。</p>
<h2 id="架構邊界">架構邊界</h2>
<h3 id="port">Port</h3>
<p><code>port</code> 表示 application 依賴的能力介面。它描述「我需要什麼能力」，例如儲存通知、發布事件、讀取外部資料或取得現在時間。</p>
<p>Go 的 port 通常是小 interface。它應由使用方定義，讓 application 可以依賴抽象能力，而不是依賴具體資料庫、message <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> 或 <a href="/blog/backend/knowledge-cards/http-client/" data-link-title="HTTP Client" data-link-desc="說明服務呼叫外部 HTTP 依賴時需要管理 timeout、連線與重試">HTTP client</a>。</p>
<p>延伸閱讀：<a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">用 interface 隔離外部依賴</a>、<a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">逐步遷移到 ports/adapters 架構</a>。</p>
<h3 id="adapter">Adapter</h3>
<p><code>adapter</code> 表示把外部技術或協定接到 application port 的實作或轉換層。它可以是 HTTP handler、WebSocket router、SQL repository、queue <a href="/blog/backend/knowledge-cards/consumer/" data-link-title="Consumer" data-link-desc="說明 consumer 如何取得等待處理的工作並產生業務結果">consumer</a> 或 external API client。</p>
<p>adapter 的核心責任是翻譯邊界格式。application 不應知道 HTTP body、SQL row、queue message 或 WebSocket frame 的細節。</p>
<p>延伸閱讀：<a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">逐步遷移到 ports/adapters 架構</a>。</p>
<h3 id="inbound-adapter">Inbound Adapter</h3>
<p><code>inbound adapter</code> 表示把外部輸入轉成 application command 或 domain event 的 adapter。HTTP handler、WebSocket router、CLI command、queue consumer 都可以是 inbound adapter。</p>
<p>inbound adapter 通常負責 parsing、基本 validation、身份資訊提取與錯誤轉換。行為規則應交給 usecase 或 processor。</p>
<p>延伸閱讀：<a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">把 handler 邏輯拆成可測單元</a>、<a href="/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">read pump / write pump 模式</a>。</p>
<h3 id="outbound-adapter">Outbound Adapter</h3>
<p><code>outbound adapter</code> 表示實作 application port 並連接外部系統的 adapter。SQL repository、Redis cache、message publisher、email sender、external API client 都屬於這類。</p>
<p>outbound adapter 的重點是隔離技術細節。usecase 依賴 port；adapter 承擔 retry、serialization、connection、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 與外部錯誤轉換。</p>
<p>延伸閱讀：<a href="/blog/go/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">如何新增 repository port</a>、<a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">資料庫 transaction 與 schema migration</a>。</p>
<h3 id="normalizer">Normalizer</h3>
<p><code>normalizer</code> 表示把 raw input 轉成內部模型的元件。它常出現在事件系統中，負責把 HTTP callback、queue message 或外部 API response 轉成 <code>DomainEvent</code>。</p>
<p>normalizer 的責任是建立內部一致性。不同來源可以有不同 raw format，但進入 processor 前應變成一致的 domain event。</p>
<p>延伸閱讀：<a href="/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">事件來源、處理流程與狀態邊界</a>。</p>
<h3 id="processor">Processor</h3>
<p><code>processor</code> 表示處理 domain event 或 background job 的元件。它負責套用規則、去重、更新狀態、寫入 event log 或呼叫 publisher。</p>
<p>processor 應處理已經 normalize 的資料。它不應依賴 HTTP request body、WebSocket frame 或 queue message 的原始格式。</p>
<p>延伸閱讀：<a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">如何新增背景工作流程</a>、<a href="/blog/go-advanced/04-architecture-boundaries/component-boundaries/" data-link-title="4.1 事件來源、處理流程與狀態邊界" data-link-desc="分辨事件來源、事件融合、處理流程、狀態真相與推送邊界">事件來源、處理流程與狀態邊界</a>。</p>
<h2 id="工具與靜態分析">工具與靜態分析</h2>
<h3 id="ast-walker">AST Walker</h3>
<p><code>AST walker</code> 表示用 visitor pattern 對解析後的抽象語法樹做 DFS 走訪，並在每個節點上套用規則或收集資訊的處理方式。在 Go 中，<code>ast.Walk</code> 是 <code>go/ast</code> 與 <code>goldmark/ast</code> 都採用的 API 慣例：傳入 root node 跟 walker 函式，framework 負責遞迴下降、把 entering / exiting 時機告訴你。</p>
<p>walker 的責任是<strong>把樹的走訪邏輯外部化</strong>，讓規則本身只關心「這個節點我要做什麼」。多條規則可以共用同一次走訪；複雜的 context（例如父節點是什麼、目前在哪個 heading 層級）由 walker 狀態機累積，不污染 rule 程式碼。Block vs inline 節點的判讀是 walker 用法最常見的陷阱 — 對 inline 節點呼叫 <code>Lines()</code> 會 panic，因此需要走上去找最近的 block 節點再取位置。</p>
<p>延伸閱讀：<a href="/blog/go/09-tooling-and-analysis/goldmark-ast-basics/" data-link-title="9.2 第三方 parser 整合：goldmark AST 入門" data-link-desc="用 goldmark 把 markdown 解析成 AST，掌握 ast.Walk visitor 模式、block 與 inline 節點的判讀、byte offset 如何定位到行號">goldmark AST 入門</a>、<a href="/blog/posts/%E4%BB%80%E9%BA%BC%E6%98%AF-ast-%E5%BE%9E%E5%AD%97%E4%B8%B2%E5%88%B0%E8%AA%9E%E6%B3%95%E6%A8%B9%E7%9A%84%E8%A6%96%E8%A7%92%E8%BD%89%E6%8F%9B/" data-link-title="什麼是 AST — 從字串到語法樹的視角轉換" data-link-desc="AST 與 regex 的差異判準：規則需要知道文字處在什麼結構中時 regex 就不夠。附 regex 誤判的具體 case。">什麼是 AST</a>。</p>
<h3 id="idempotent-文字改寫">Idempotent 文字改寫</h3>
<p><code>idempotent 文字改寫</code> 表示對同一輸入跑一次或多次、結果相同的轉換契約。應用在 <code>gofmt</code>、<code>prettier</code>、<code>ruff fix</code> 這類 formatter 與 fixer 上，契約讓工具能安全地接到 pre-commit hook（重複跑不會累積漂移）、能用 <code>--check</code> 跟 <code>--fix</code> 共用同一套邏輯（差別只在要不要寫檔）、能分段除錯。</p>
<p>實作上的核心技巧是<strong>每條規則自己冪等</strong>：判斷「違規才修」而非「無論如何都套用」。多規則串成流水線時，每條規則的輸出要是下一條的合法輸入；行數變動時要重建 LineContext 索引。測試可以用「跑兩次結果相等」當斷言，直接驗證契約。</p>
<p>延伸閱讀：<a href="/blog/go/09-tooling-and-analysis/ast-idempotent-rewriting/" data-link-title="9.3 AST 驅動的 idempotent 文字改寫" data-link-desc="用 AST 定位位置、用 line-based 或 byte-level 改寫；設計多條 rule 的執行順序；--check 跟 --fix 如何共用邏輯">AST 驅動的 idempotent 文字改寫</a>、<a href="/blog/go/09-tooling-and-analysis/pre-commit-and-ci/" data-link-title="9.6 Pre-commit hook 與 CI 整合" data-link-desc="工具寫完只是起點；接到 pre-commit hook 跟 CI 才真正守住品質。Re-staging、dry-run vs apply、不能繞過的邊界">Pre-commit hook 與 CI 整合</a>。</p>
<h3 id="跨檔案-link-graph">跨檔案 Link Graph</h3>
<p><code>跨檔案 link graph</code> 表示把整個 repo 的檔案視為節點、檔案間連結視為邊的資料結構，用一次 parse 之後的 in-memory map 支援反向查詢（這個目標被誰引用？這個連結的目標存在嗎？）。避免 N² 的「每次查詢都重 parse 全部檔案」成本。</p>
<p>典型應用包含 orphan 偵測（節點沒有 inbound edge）、broken link 偵測（邊指到不存在的節點）、dependency cycle 偵測（graph 有環）、reverse index 建構（哪些檔案引用了 X）。Graph 一次建好後，所有後續 query 都是 map lookup 或 slice scan，microsecond 級。</p>
<p>延伸閱讀：<a href="/blog/go/09-tooling-and-analysis/cross-file-graph-analysis/" data-link-title="9.4 跨檔案圖分析：從 lint 走到 static analysis" data-link-desc="Single-file 規則用 AST 搞定；跨檔 orphan 偵測、broken link、backlink 完整性需要把整個 repo 建成圖再走訪。用 mdtools cards 為例">跨檔案圖分析</a>。</p>
<h3 id="tripwire-決策">Tripwire 決策</h3>
<p><code>tripwire 決策</code> 表示用事前約定的可量測條件，在命中時觸發「重新評估是否升級」的決策方法。目的是避開「太早升級」（過度工程化）跟「太晚升級」（信譽破產）兩種失敗。</p>
<p>tripwire 的重點是<strong>把評估時機從模糊直覺變成明確觸發</strong>。設計時要選合適的訊號（誤判率、維護成本、使用者投訴頻率），並定期檢驗條件是否仍然合理。適用於技術選型（regex vs AST、Python vs Go）、架構升級（單體 → 微服務）、工具邊界（lint vs full type-check）等決策。</p>
<p>延伸閱讀：<a href="/blog/go/09-tooling-and-analysis/tool-decision-tripwire/" data-link-title="9.5 工具決策：regex 到 AST、Python 到 Go 的 tripwire" data-link-desc="什麼訊號代表工具該升級到下一個層次；用 WRAP 框架做語言與實作層的技術決策；延遲決策的成本">工具決策的 tripwire</a>。</p>
<h3 id="pre-commit-hook-定位">Pre-commit Hook 定位</h3>
<p><code>pre-commit hook</code> 表示 git commit 流程中、commit 實際建立前自動觸發的檢查點。定位上它是<strong>快速守門員</strong>，負責秒級可完成的 lint / fmt / 基本驗證，把錯誤攔在本機、讓作者早期回饋。</p>
<p>跟 CI 的分工是：hook 守本機（快、只看 staged 檔案），CI 守共享 branch（慢、乾淨環境、完整 test）。兩者互補、共用同一套工具與規則。落地時要處理 re-staging（hook 自動修檔後 <code>git add</code> 回 staged）、exit code 語意（0 = pass、1 = violation block、2 = tool failure）、<code>--no-verify</code> 繞過的邊界（原則上禁止，緊急情境有條件例外）。</p>
<p>延伸閱讀：<a href="/blog/go/09-tooling-and-analysis/pre-commit-and-ci/" data-link-title="9.6 Pre-commit hook 與 CI 整合" data-link-desc="工具寫完只是起點；接到 pre-commit hook 跟 CI 才真正守住品質。Re-staging、dry-run vs apply、不能繞過的邊界">Pre-commit hook 與 CI 整合</a>。</p>
<h2 id="使用方式">使用方式</h2>
<p>閱讀章節時若遇到同一個詞在不同情境出現，先回到本頁確認它的核心責任。入門篇會用簡化範例建立語感；進階篇會把同一批詞彙放進並發、WebSocket、資料庫、觀測與部署壓力中重新檢查；工具類章節會把型別、interface、package 等概念落到 CLI、parser、graph analysis 等非服務場景。</p>
]]></content:encoded></item><item><title>10 個 Ticket、57 個綠燈、0 條追溯：從需求文件到測試的銜接檢討</title><link>https://tarrragon.github.io/blog/work-log/10-%E5%80%8B-ticket57-%E5%80%8B%E7%B6%A0%E7%87%880-%E6%A2%9D%E8%BF%BD%E6%BA%AF%E5%BE%9E%E9%9C%80%E6%B1%82%E6%96%87%E4%BB%B6%E5%88%B0%E6%B8%AC%E8%A9%A6%E7%9A%84%E9%8A%9C%E6%8E%A5%E6%AA%A2%E8%A8%8E/</link><pubDate>Tue, 23 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/10-%E5%80%8B-ticket57-%E5%80%8B%E7%B6%A0%E7%87%880-%E6%A2%9D%E8%BF%BD%E6%BA%AF%E5%BE%9E%E9%9C%80%E6%B1%82%E6%96%87%E4%BB%B6%E5%88%B0%E6%B8%AC%E8%A9%A6%E7%9A%84%E9%8A%9C%E6%8E%A5%E6%AA%A2%E8%A8%8E/</guid><description>&lt;h2 id="這篇要解決什麼">這篇要解決什麼&lt;/h2>
&lt;blockquote>
&lt;p>57 個 unit test 全綠，但沒有任何機制能回答「這些測試覆蓋了哪些 UseCase 場景」。&lt;/p>&lt;/blockquote>
&lt;p>monitor 專案 v0.1.0 從需求文件系統（Proposal → Spec → UseCase）一路走到 Collector 實作，中間經過 BDD 測試設計、紅燈測試撰寫、骨架實作讓綠。流程表面上順暢——10 個根 Ticket 全部完成、Collector 可啟動、所有 unit test 通過。但回頭檢視發現：需求→測試的銜接是單向管道，沒有反向追溯，也沒有邊界回補流程。&lt;/p>
&lt;p>本文記錄 v0.1.0 的完整流程、發現的五個結構性差異、和落地的解決方案。&lt;/p>
&lt;hr>
&lt;h2 id="實際走過的流程">實際走過的流程&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">saas 選型訪談
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> → Proposal（MVP 範圍界定）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> → Spec（14 份，涵蓋 schema/ingestion/query/storage/rule-engine/SDK）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> → UseCase（5 個，UC-01 端到端事件流 ~ UC-05 Web 監控）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> → BDD 測試設計 ANA（全專案 26 個行為場景 → 整合/單元/協議測試清單）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> → 紅燈測試（9 個 Ticket 並行，72 個測試 FAIL）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> → 骨架實作（1 個 Ticket，57 個 unit test GREEN）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每個箭頭都有對應的框架機制：saas→doc 有 Stage 6 銜接、doc→TDD 有 doc-handoff 映射表。但箭頭只往右——沒有任何箭頭往左。&lt;/p>
&lt;hr>
&lt;h2 id="五個結構性差異">五個結構性差異&lt;/h2>
&lt;h3 id="差異-1全專案-bdd-設計不在-tdd-phase-模型中">差異 1：「全專案 BDD 設計」不在 TDD Phase 模型中&lt;/h3>
&lt;p>TDD Skill 定義 Phase 0→1→2→3→4 的逐功能流程。v0.1.0 做的是「全專案 UseCase 一次性展開為 BDD 測試設計」，跨越 Phase 1 和 Phase 2 的邊界，粒度是專案級不是功能級。&lt;/p>
&lt;p>這不是 Phase 設計的錯——Phase 模型適合增量開發（每次加一個功能）。新專案起手是不同的工作模式：批量設計、模組群組粒度。&lt;/p>
&lt;p>&lt;strong>解法&lt;/strong>：在 doc-handoff 新增「新專案起手模式」章節，描述批量 BDD 設計流程、Phase 0 豁免條件、模組群組粒度。&lt;/p>
&lt;h3 id="差異-2紅燈測試需要存根stub">差異 2：紅燈測試需要存根（stub）&lt;/h3>
&lt;p>Go 是靜態語言，&lt;code>go test&lt;/code> 必須編譯通過才能執行。紅燈測試引用的 type/interface 不存在時直接編譯失敗，不是「測試 FAIL」。&lt;/p>
&lt;p>TDD Skill 的 Phase 2 說「設計測試」、Phase 3b 說「讓測試綠」，但中間的「建存根讓測試可紅」沒有定義。&lt;/p>
&lt;p>&lt;strong>實作驗證&lt;/strong>：v0.1.0 的每個紅燈 Ticket 都自帶建立存根（空 function return nil / 空 struct / 回 501 的 HTTP handler），存根讓 &lt;code>go test&lt;/code> 編譯通過，合法測試 PASS、非法測試 FAIL = 紅燈狀態。&lt;/p>
&lt;p>&lt;strong>解法&lt;/strong>：Phase 3 rules 新增「存根策略」章節，涵蓋靜態語言（Go/Dart）和動態語言（Python/JS）的不同處理。&lt;/p>
&lt;h3 id="差異-3測試usecase-沒有反向追溯">差異 3：測試→UseCase 沒有反向追溯&lt;/h3>
&lt;p>寫完 57 個 unit test 後，問「UC-01 的替代場景 01a（批次部分失敗 → 207）被哪些測試覆蓋？」——沒有任何機制能回答。&lt;/p>
&lt;p>&lt;code>doc test-map UC-01&lt;/code> 工具存在但回傳 0 個測試——因為它搜尋 UC frontmatter 的 &lt;code>ticket_refs&lt;/code>，和測試檔案沒有連結。Spec 的「三方交叉比對」是建 Ticket 時的一次性動作，不是持續追溯。&lt;/p>
&lt;p>&lt;strong>解法&lt;/strong>：建立 &lt;code>docs/traceability.yaml&lt;/code> 追溯矩陣，三層追溯（UC 場景 → 整合測試 IT-* → 單元測試 UT-* → Spec FR）。每個 entry 標記 &lt;code>covered&lt;/code> / &lt;code>gap&lt;/code> / &lt;code>deferred&lt;/code>。&lt;/p></description><content:encoded><![CDATA[<h2 id="這篇要解決什麼">這篇要解決什麼</h2>
<blockquote>
<p>57 個 unit test 全綠，但沒有任何機制能回答「這些測試覆蓋了哪些 UseCase 場景」。</p></blockquote>
<p>monitor 專案 v0.1.0 從需求文件系統（Proposal → Spec → UseCase）一路走到 Collector 實作，中間經過 BDD 測試設計、紅燈測試撰寫、骨架實作讓綠。流程表面上順暢——10 個根 Ticket 全部完成、Collector 可啟動、所有 unit test 通過。但回頭檢視發現：需求→測試的銜接是單向管道，沒有反向追溯，也沒有邊界回補流程。</p>
<p>本文記錄 v0.1.0 的完整流程、發現的五個結構性差異、和落地的解決方案。</p>
<hr>
<h2 id="實際走過的流程">實際走過的流程</h2>





<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">saas 選型訪談
</span></span><span class="line"><span class="ln">2</span><span class="cl">  → Proposal（MVP 範圍界定）
</span></span><span class="line"><span class="ln">3</span><span class="cl">    → Spec（14 份，涵蓋 schema/ingestion/query/storage/rule-engine/SDK）
</span></span><span class="line"><span class="ln">4</span><span class="cl">      → UseCase（5 個，UC-01 端到端事件流 ~ UC-05 Web 監控）
</span></span><span class="line"><span class="ln">5</span><span class="cl">        → BDD 測試設計 ANA（全專案 26 個行為場景 → 整合/單元/協議測試清單）
</span></span><span class="line"><span class="ln">6</span><span class="cl">          → 紅燈測試（9 個 Ticket 並行，72 個測試 FAIL）
</span></span><span class="line"><span class="ln">7</span><span class="cl">            → 骨架實作（1 個 Ticket，57 個 unit test GREEN）</span></span></code></pre></div><p>每個箭頭都有對應的框架機制：saas→doc 有 Stage 6 銜接、doc→TDD 有 doc-handoff 映射表。但箭頭只往右——沒有任何箭頭往左。</p>
<hr>
<h2 id="五個結構性差異">五個結構性差異</h2>
<h3 id="差異-1全專案-bdd-設計不在-tdd-phase-模型中">差異 1：「全專案 BDD 設計」不在 TDD Phase 模型中</h3>
<p>TDD Skill 定義 Phase 0→1→2→3→4 的逐功能流程。v0.1.0 做的是「全專案 UseCase 一次性展開為 BDD 測試設計」，跨越 Phase 1 和 Phase 2 的邊界，粒度是專案級不是功能級。</p>
<p>這不是 Phase 設計的錯——Phase 模型適合增量開發（每次加一個功能）。新專案起手是不同的工作模式：批量設計、模組群組粒度。</p>
<p><strong>解法</strong>：在 doc-handoff 新增「新專案起手模式」章節，描述批量 BDD 設計流程、Phase 0 豁免條件、模組群組粒度。</p>
<h3 id="差異-2紅燈測試需要存根stub">差異 2：紅燈測試需要存根（stub）</h3>
<p>Go 是靜態語言，<code>go test</code> 必須編譯通過才能執行。紅燈測試引用的 type/interface 不存在時直接編譯失敗，不是「測試 FAIL」。</p>
<p>TDD Skill 的 Phase 2 說「設計測試」、Phase 3b 說「讓測試綠」，但中間的「建存根讓測試可紅」沒有定義。</p>
<p><strong>實作驗證</strong>：v0.1.0 的每個紅燈 Ticket 都自帶建立存根（空 function return nil / 空 struct / 回 501 的 HTTP handler），存根讓 <code>go test</code> 編譯通過，合法測試 PASS、非法測試 FAIL = 紅燈狀態。</p>
<p><strong>解法</strong>：Phase 3 rules 新增「存根策略」章節，涵蓋靜態語言（Go/Dart）和動態語言（Python/JS）的不同處理。</p>
<h3 id="差異-3測試usecase-沒有反向追溯">差異 3：測試→UseCase 沒有反向追溯</h3>
<p>寫完 57 個 unit test 後，問「UC-01 的替代場景 01a（批次部分失敗 → 207）被哪些測試覆蓋？」——沒有任何機制能回答。</p>
<p><code>doc test-map UC-01</code> 工具存在但回傳 0 個測試——因為它搜尋 UC frontmatter 的 <code>ticket_refs</code>，和測試檔案沒有連結。Spec 的「三方交叉比對」是建 Ticket 時的一次性動作，不是持續追溯。</p>
<p><strong>解法</strong>：建立 <code>docs/traceability.yaml</code> 追溯矩陣，三層追溯（UC 場景 → 整合測試 IT-* → 單元測試 UT-* → Spec FR）。每個 entry 標記 <code>covered</code> / <code>gap</code> / <code>deferred</code>。</p>
<h3 id="差異-4邊界條件發現後沒有回補-uc-的流程">差異 4：邊界條件發現後沒有回補 UC 的流程</h3>
<p>寫 Ingest Handler 測試時發現：「如果 POST body 不是 JSON 怎麼辦？」「如果 Content-Type 是 text/plain（sendBeacon）怎麼辦？」這些邊界在 UC-01 的場景描述中不存在。</p>
<p>測試設計的 BDD ANA 有涵蓋這些邊界場景，但 UC 文件本身沒有更新。邊界條件「住」在測試設計文件而非 UseCase——下次有人讀 UC 不會知道這些邊界存在。</p>
<p><strong>解法</strong>：追溯矩陣增加 <code>boundaries:</code> 區段，測試撰寫者發現新邊界時加 gap entry，PM 建 DOC Ticket 回補 UC/Spec。Phase 4d 掃描所有 gap 確認無遺漏。</p>
<h3 id="差異-5ticket-拆分邊界未對齊測試變綠驗收點">差異 5：Ticket 拆分邊界未對齊測試變綠驗收點</h3>
<p>Collector 實作被拆為 4 個 Ticket：骨架（interface 定義）/ Storage / Ingestion Handler / Query Handler。骨架 Ticket 指派做「main.go + Config + Storage interface」，代理人完成了所有模組實作——57 個 unit test 從紅全部變綠，其餘 3 個 Ticket 的 acceptance 全被涵蓋。</p>
<p>初看像是「代理人超額完成」，回頭用判讀三問檢查骨架 Ticket：完成後有測試變綠嗎？→ 沒有（只定義 interface）。能獨立跑測試嗎？→ 不能（其他模組引用骨架的 type）。共用 type？→ 是。三問全部指向「不應獨立拆」。<strong>根因是 Ticket 拆分設計</strong>，不是代理人行為——按 Spec FR 拆（輸入驅動）導致骨架 Ticket 完成後 0 個測試狀態改變，不是有意義的驗收點。</p>
<p><strong>判讀規則</strong>：實作 Ticket 的拆分邊界必須對齊「測試從紅變綠」的驗收點。一個 Ticket 完成後若沒有任何測試狀態改變，它不應該是獨立 Ticket。</p>
<p>判讀三問：</p>
<ol>
<li>這個 Ticket 完成後，有測試從 FAIL 變 PASS 嗎？</li>
<li>拆出的各部分能獨立跑測試嗎？</li>
<li>不同部分共用同一組 type/error/constant 嗎？</li>
</ol>
<p><strong>反模式</strong>：按 Spec FR 拆（輸入驅動）。<strong>正確做法</strong>：按「哪組測試變綠」拆（輸出驅動）。</p>
<hr>
<h2 id="追溯矩陣的設計">追溯矩陣的設計</h2>
<p>追溯矩陣是三個問題（向上追溯 + 覆蓋驗證 + 邊界回補）的統一解法。</p>
<h3 id="結構">結構</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">UC-01</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">title</span><span class="p">:</span><span class="w"> </span><span class="l">端到端事件流</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">scenarios</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">main</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">      </span><span class="nt">integration_tests</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">IT-01-01]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">      </span><span class="nt">unit_tests</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">UT-COL-01-01, UT-COL-02-01, UT-COL-04-01]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span><span class="nt">spec_frs</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">SPEC-002-FR-01, SPEC-003-FR-01]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span><span class="nt">status</span><span class="p">:</span><span class="w"> </span><span class="l">covered</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="nt">alt-01a</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span><span class="nt">integration_tests</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">IT-01-02]</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span><span class="nt">unit_tests</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">UT-COL-01-03, UT-COL-02-03]</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">      </span><span class="nt">spec_frs</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">SPEC-002-FR-02]</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">      </span><span class="nt">status</span><span class="p">:</span><span class="w"> </span><span class="l">covered</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w"></span><span class="nt">boundaries</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">  </span><span class="nt">batch-limit</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">    </span><span class="nt">discovered_during</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;ingestion-handler-red-tests&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">    </span><span class="nt">status</span><span class="p">:</span><span class="w"> </span><span class="l">gap </span><span class="w"> </span><span class="c"># 需回補 UC/Spec</span></span></span></code></pre></div><h3 id="三個問題的對應">三個問題的對應</h3>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>矩陣欄位</th>
          <th>查法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>這個 UT 為了哪個 UC？</td>
          <td><code>unit_tests</code></td>
          <td>搜尋 UT ID → 找到歸屬的 scenario</td>
      </tr>
      <tr>
          <td>UC 場景都有測試嗎？</td>
          <td><code>status</code></td>
          <td>掃描 <code>gap</code> entry</td>
      </tr>
      <tr>
          <td>新邊界怎麼回補 UC？</td>
          <td><code>boundaries</code></td>
          <td>gap entry → DOC Ticket → 回補 → covered</td>
      </tr>
  </tbody>
</table>
<h3 id="整合點">整合點</h3>
<table>
  <thead>
      <tr>
          <th>機制</th>
          <th>時機</th>
          <th>動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>doc-handoff</td>
          <td>銜接時</td>
          <td>初始化矩陣骨架（UC scenario 空映射）</td>
      </tr>
      <tr>
          <td>紅燈測試撰寫</td>
          <td>Phase 2→3</td>
          <td>填入 unit_tests 映射</td>
      </tr>
      <tr>
          <td>邊界發現</td>
          <td>實作中</td>
          <td>加 boundary gap entry</td>
      </tr>
      <tr>
          <td>Phase 4d</td>
          <td>重構評估</td>
          <td>掃描所有 gap，建 DOC Ticket</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="附帶發現並行派發的-git-隔離問題">附帶發現：並行派發的 Git 隔離問題</h2>
<p>5 個代理人以 worktree 並行派發時，commit 內容交叉混入——A 代理人的 commit 包含 B 代理人的檔案。根因：主 repo 不在 main 分支，多個 worktree 共用同一分支 ref，<code>git add + commit</code> race condition。</p>
<p><strong>防護</strong>：派發前確保主 repo 在 main + 已 push。單一代理人和正確條件下的多代理人都驗證通過。</p>
<hr>
<h2 id="結論">結論</h2>
<p>v0.1.0 的流程不是失敗——Collector 可用、57 個 test GREEN。問題在於「走到終點後沒有辦法回頭驗證起點」。需求→測試的管道是單向的：Proposal 說了什麼、Spec 定了什麼 FR、UC 描述了什麼場景，和最終的測試之間沒有結構化連結。</p>
<p>追溯矩陣不增加任何程式碼——它是一個 YAML 檔案，記錄「每個測試為什麼存在」。維護成本是每次寫測試多填一行映射。回報是：任何時候都能回答「這個 UC 場景有沒有被測試保護」。</p>
]]></content:encoded></item><item><title>9.0 Go 在工具鏈生態的位置</title><link>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/overview/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/09-tooling-and-analysis/overview/</guid><description>&lt;p>工具類 Go 程式的核心責任是&lt;strong>完成一次特定工作就退出&lt;/strong>：讀輸入、處理、寫輸出、結束。這個生命週期特徵決定了它的結構 — 以短命、I/O 為主、錯誤即時中斷為預設，跟服務類 Go 長期健康運行的假設正好相反。本章先把這個前提講清楚，後續章節對 main 結構、goroutine 用法、錯誤處理的安排才能看懂為什麼長那樣。&lt;/p>
&lt;p>工具類跟服務類的差異常被隱晦地帶過，於是後端工程師轉寫工具時會帶進服務的慣性（長時 goroutine pool、defensive 錯誤降級、龐大依賴樹），讓工具變得重而難維護。把分野講清楚比給 cheatsheet 有用 — 後續每個模式落地時，讀者自己會判斷該採哪套預設。&lt;/p>
&lt;h2 id="業界哪些人在用-go-寫工具">業界哪些人在用 Go 寫工具&lt;/h2>
&lt;p>下列工具都用 Go 寫成，讀者多半每天都在使用或間接依賴它們：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>hugo&lt;/strong> — 靜態網站產生器，parse markdown + render template + serve dev。&lt;/li>
&lt;li>&lt;strong>kubectl&lt;/strong> / &lt;strong>helm&lt;/strong> — Kubernetes 的 CLI 客戶端，parse YAML + call API + render output。&lt;/li>
&lt;li>&lt;strong>terraform&lt;/strong> — 基礎設施描述語言的 interpreter + state management。&lt;/li>
&lt;li>&lt;strong>gh&lt;/strong> — GitHub CLI，把 REST/GraphQL API 包成命令列操作。&lt;/li>
&lt;li>&lt;strong>goldmark&lt;/strong> — CommonMark parser，提供 AST 給其他 Go 程式使用。&lt;/li>
&lt;li>&lt;strong>stringer&lt;/strong> / &lt;strong>gopls&lt;/strong> — Go 官方工具鏈，分析 Go 原始碼並產生程式碼或語言服務。&lt;/li>
&lt;li>&lt;strong>golangci-lint&lt;/strong> — 聚合多個 Go linter 的 runner。&lt;/li>
&lt;li>&lt;strong>caddy&lt;/strong> / &lt;strong>traefik&lt;/strong> — 雖然是服務，但以 CLI-first 配置見長。&lt;/li>
&lt;li>&lt;strong>protobuf / grpc&lt;/strong> 的 code generator — 讀 IDL、吐 Go 程式碼。&lt;/li>
&lt;/ul>
&lt;p>這些程式都享受 Go 的幾個特定優勢：單一 binary 跨平台部署、快速啟動、stdlib 的 I/O 與檔案系統支援、goroutine 讓 pipeline fan-out 便宜、型別系統防止參數解析等瑣碎錯誤。&lt;/p>
&lt;h2 id="工具類-go-跟服務類-go-的結構差異">工具類 Go 跟服務類 Go 的結構差異&lt;/h2>
&lt;p>多數後端工程師轉去寫工具會遇到幾個慣性衝突。本節列五個最明顯的。&lt;/p>
&lt;h3 id="生命週期短命而非長時">生命週期：短命而非長時&lt;/h3>
&lt;p>服務類 Go 跑起來就不預期結束 — goroutine pool、connection pool、graceful shutdown、health check 都繞著「長時健康運行」打轉。工具類 Go 預設是&lt;strong>執行、完成工作、退出&lt;/strong>：&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">main&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">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">run&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">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fprintln&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Stderr&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">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">os&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Exit&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&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;/code>&lt;/pre>&lt;/div>&lt;p>沒有 &lt;code>for { select {} }&lt;/code> 的主迴圈，也不用註冊 signal handler 做 graceful 收尾（OS 會幫你回收檔案描述子）。延伸影響：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>錯誤處理偏向中斷&lt;/strong>：服務類在錯誤時常選擇降級、記錄、繼續；工具類多半直接退出並印訊息，交給呼叫者決定下一步。&lt;/li>
&lt;li>&lt;strong>goroutine 用法保守&lt;/strong>：工具很少要 100 個並發 worker。有也多半是 pipeline 的 fan-out，而非長時 pool。&lt;/li>
&lt;li>&lt;strong>context 用法簡單&lt;/strong>：很少需要 &lt;code>context.WithCancel&lt;/code>，&lt;code>context.Background()&lt;/code> 經常夠用。&lt;/li>
&lt;/ul>
&lt;h3 id="輸入輸出檔案--stdin--命令列而非-http--queue">輸入輸出：檔案 / stdin / 命令列，而非 HTTP / queue&lt;/h3>
&lt;p>服務讀 request body、寫 response JSON、從 queue 拉訊息。工具讀檔案、parse 命令列 flag、接 stdin pipe、寫 stdout。這改變了幾個預設：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>輸入格式多元&lt;/strong>：工具常處理 markdown、YAML、CSV、純文字；encoding/json 是基礎而非核心，其他 parser 反而更常用。&lt;/li>
&lt;li>&lt;strong>&lt;code>os.ReadFile&lt;/code> / &lt;code>os.WriteFile&lt;/code> / &lt;code>filepath.WalkDir&lt;/code> 是主力&lt;/strong>。Path 處理（&lt;code>filepath.Rel&lt;/code>、&lt;code>filepath.ToSlash&lt;/code>）會反覆出現。&lt;/li>
&lt;li>&lt;strong>stdout 是結果通道&lt;/strong>。服務的 log 跟 response 是兩條 stream；工具的 log 跟 output 經常搶同一條 stream，需要嚴格 discipline：log 全部走 stderr，output 走 stdout，讓使用者能 pipe。&lt;/li>
&lt;/ul>
&lt;h3 id="錯誤處理accumulate-or-fail-fast">錯誤處理：accumulate or fail-fast&lt;/h3>
&lt;p>服務處理單一 request 時 fail-fast 容易（回 500、log 原因）。工具常一次處理多個輸入（批次 lint 300 個檔案），需要決定：&lt;/p></description><content:encoded><![CDATA[<p>工具類 Go 程式的核心責任是<strong>完成一次特定工作就退出</strong>：讀輸入、處理、寫輸出、結束。這個生命週期特徵決定了它的結構 — 以短命、I/O 為主、錯誤即時中斷為預設，跟服務類 Go 長期健康運行的假設正好相反。本章先把這個前提講清楚，後續章節對 main 結構、goroutine 用法、錯誤處理的安排才能看懂為什麼長那樣。</p>
<p>工具類跟服務類的差異常被隱晦地帶過，於是後端工程師轉寫工具時會帶進服務的慣性（長時 goroutine pool、defensive 錯誤降級、龐大依賴樹），讓工具變得重而難維護。把分野講清楚比給 cheatsheet 有用 — 後續每個模式落地時，讀者自己會判斷該採哪套預設。</p>
<h2 id="業界哪些人在用-go-寫工具">業界哪些人在用 Go 寫工具</h2>
<p>下列工具都用 Go 寫成，讀者多半每天都在使用或間接依賴它們：</p>
<ul>
<li><strong>hugo</strong> — 靜態網站產生器，parse markdown + render template + serve dev。</li>
<li><strong>kubectl</strong> / <strong>helm</strong> — Kubernetes 的 CLI 客戶端，parse YAML + call API + render output。</li>
<li><strong>terraform</strong> — 基礎設施描述語言的 interpreter + state management。</li>
<li><strong>gh</strong> — GitHub CLI，把 REST/GraphQL API 包成命令列操作。</li>
<li><strong>goldmark</strong> — CommonMark parser，提供 AST 給其他 Go 程式使用。</li>
<li><strong>stringer</strong> / <strong>gopls</strong> — Go 官方工具鏈，分析 Go 原始碼並產生程式碼或語言服務。</li>
<li><strong>golangci-lint</strong> — 聚合多個 Go linter 的 runner。</li>
<li><strong>caddy</strong> / <strong>traefik</strong> — 雖然是服務，但以 CLI-first 配置見長。</li>
<li><strong>protobuf / grpc</strong> 的 code generator — 讀 IDL、吐 Go 程式碼。</li>
</ul>
<p>這些程式都享受 Go 的幾個特定優勢：單一 binary 跨平台部署、快速啟動、stdlib 的 I/O 與檔案系統支援、goroutine 讓 pipeline fan-out 便宜、型別系統防止參數解析等瑣碎錯誤。</p>
<h2 id="工具類-go-跟服務類-go-的結構差異">工具類 Go 跟服務類 Go 的結構差異</h2>
<p>多數後端工程師轉去寫工具會遇到幾個慣性衝突。本節列五個最明顯的。</p>
<h3 id="生命週期短命而非長時">生命週期：短命而非長時</h3>
<p>服務類 Go 跑起來就不預期結束 — goroutine pool、connection pool、graceful shutdown、health check 都繞著「長時健康運行」打轉。工具類 Go 預設是<strong>執行、完成工作、退出</strong>：</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">main</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">err</span> <span class="o">:=</span> <span class="nf">run</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">3</span><span class="cl">        <span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprintln</span><span class="p">(</span><span class="nx">os</span><span class="p">.</span><span class="nx">Stderr</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">os</span><span class="p">.</span><span class="nf">Exit</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="p">}</span></span></span></code></pre></div><p>沒有 <code>for { select {} }</code> 的主迴圈，也不用註冊 signal handler 做 graceful 收尾（OS 會幫你回收檔案描述子）。延伸影響：</p>
<ul>
<li><strong>錯誤處理偏向中斷</strong>：服務類在錯誤時常選擇降級、記錄、繼續；工具類多半直接退出並印訊息，交給呼叫者決定下一步。</li>
<li><strong>goroutine 用法保守</strong>：工具很少要 100 個並發 worker。有也多半是 pipeline 的 fan-out，而非長時 pool。</li>
<li><strong>context 用法簡單</strong>：很少需要 <code>context.WithCancel</code>，<code>context.Background()</code> 經常夠用。</li>
</ul>
<h3 id="輸入輸出檔案--stdin--命令列而非-http--queue">輸入輸出：檔案 / stdin / 命令列，而非 HTTP / queue</h3>
<p>服務讀 request body、寫 response JSON、從 queue 拉訊息。工具讀檔案、parse 命令列 flag、接 stdin pipe、寫 stdout。這改變了幾個預設：</p>
<ul>
<li><strong>輸入格式多元</strong>：工具常處理 markdown、YAML、CSV、純文字；encoding/json 是基礎而非核心，其他 parser 反而更常用。</li>
<li><strong><code>os.ReadFile</code> / <code>os.WriteFile</code> / <code>filepath.WalkDir</code> 是主力</strong>。Path 處理（<code>filepath.Rel</code>、<code>filepath.ToSlash</code>）會反覆出現。</li>
<li><strong>stdout 是結果通道</strong>。服務的 log 跟 response 是兩條 stream；工具的 log 跟 output 經常搶同一條 stream，需要嚴格 discipline：log 全部走 stderr，output 走 stdout，讓使用者能 pipe。</li>
</ul>
<h3 id="錯誤處理accumulate-or-fail-fast">錯誤處理：accumulate or fail-fast</h3>
<p>服務處理單一 request 時 fail-fast 容易（回 500、log 原因）。工具常一次處理多個輸入（批次 lint 300 個檔案），需要決定：</p>
<ul>
<li><strong>Fail-fast</strong>：第一個錯誤就退出 — 適合 <code>make check</code> 這類 gate。</li>
<li><strong>Accumulate</strong>：蒐集全部錯誤一起報告 — 適合 <code>lint</code> 這類讓使用者看全貌的模式。</li>
</ul>
<p><code>mdtools lint</code> 就選 accumulate：一次 parse 全部 content，收齊所有 violations，sort 後輸出，退出碼反映是否有 error。作者可以一次看到所有問題，不用反覆跑。</p>
<h3 id="依賴管理盡可能-stdlib">依賴管理：盡可能 stdlib</h3>
<p>服務的 go.mod 動輒幾十個 require — ORM、HTTP router、metrics、tracing、queue client 全要。工具圈文化明顯保守：</p>
<ul>
<li>很多優秀 Go CLI 工具只有 <strong>1-3 個</strong> direct dependency。</li>
<li>標準選型是先看 <code>flag</code> + <code>os</code> + <code>filepath</code> + <code>encoding/*</code> 能否滿足。</li>
<li>確實需要外部 parser、terminal UI、或結構化資料函式庫時才引入，而非預設。</li>
</ul>
<p>這個 convention 出於實用考量：工具經常作為單一 binary 發佈，依賴越少、build 越快、跨平台問題越少。</p>
<h3 id="部署binary-而非-container">部署：binary 而非 container</h3>
<p>服務類部署到 k8s，工具類部署成 <code>go install example.com/tool@latest</code> 的 binary。連帶的預設：</p>
<ul>
<li><strong>配置用 CLI flag + 環境變數覆蓋</strong>：真正需要結構化配置時才引入 config schema。</li>
<li><strong>版本管理用 build tag</strong>：<code>go build -ldflags &quot;-X main.Version=...&quot;</code> 把版本刻進 binary。</li>
<li><strong>升級由 <code>go install</code> 承接</strong>：使用者重跑 <code>go install</code> 拉最新版，end-user 工具（hugo / kubectl）才額外設計自更新。</li>
</ul>
<h2 id="工具選型的判讀表">工具選型的判讀表</h2>
<p>工具語言選型的核心判準是<strong>工作負載特徵</strong>：要不要跨平台分發、要不要處理大量 I/O、要不要整合既有生態。下表給八個判讀情境，各自有展開說明。</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>偏好 Go</th>
          <th>偏好其他</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單一 binary 跨平台分發</td>
          <td>是</td>
          <td>shell / Python 要求受眾處理執行環境</td>
      </tr>
      <tr>
          <td>大量檔案 I/O + 併發加速</td>
          <td>是</td>
          <td>shell 慢、Python GIL 是 CPU 瓶頸</td>
      </tr>
      <tr>
          <td>Parse 複雜格式（markdown、AST、protobuf）</td>
          <td>是</td>
          <td>shell 寫起來會變成 awk/sed 煉金術</td>
      </tr>
      <tr>
          <td>整合 Go 生態（goldmark、go/ast、x/tools）</td>
          <td>是</td>
          <td>跨語言整合成本（FFI、serialization）</td>
      </tr>
      <tr>
          <td>一次性 one-liner（grep、sed 可解）</td>
          <td></td>
          <td>shell</td>
      </tr>
      <tr>
          <td>要用 ML / 資料科學套件</td>
          <td></td>
          <td>Python（PyTorch、pandas）</td>
      </tr>
      <tr>
          <td>快速 prototype、throw-away 腳本</td>
          <td></td>
          <td>Python（動筆 3 倍快）</td>
      </tr>
      <tr>
          <td>需要 REPL 互動探索</td>
          <td></td>
          <td>Python / Node / Clojure</td>
      </tr>
  </tbody>
</table>
<p><strong>單一 binary 跨平台分發</strong>：工具的使用者可能分散在 macOS / Linux / Windows，每個人的 runtime 版本不同。Python 工具要求受眾先裝 Python、確定 3.x vs 2.7、管好 venv；shell 工具在不同 shell（bash / zsh / dash）行為分歧。Go 的靜態編譯讓一個 binary 直接丟出去能跑，這是推廣一個工具時最大的摩擦減法。適用信號：使用者超過 3 人、或使用者非工程師。反例：只在 CI 環境跑的 hook，runtime 已經固定，shell / Python 成本低。</p>
<p><strong>大量檔案 I/O + 併發加速</strong>：linter 跑 1000 個檔案、migration 處理 10000 個 record，都是 I/O 密集任務。Go 的 goroutine + channel 讓 pipeline fan-out 極便宜，shell 靠 <code>xargs -P</code> 也能做但錯誤處理很脆弱；Python 的 GIL 限制真併發，得靠 multiprocessing 增加複雜度。信號：處理量超過「等半秒等得住」、或錯誤需要結構化蒐集。反例：處理一次、規模小於 100 檔，shell 反而快。</p>
<p><strong>Parse 複雜格式</strong>：markdown、YAML、protobuf、Go 原始碼這類格式需要完整 parser，自己寫 AST walker 成本高。Go 有大量成熟 parser（goldmark、x/net/html、go/parser）可直接 import；shell 靠 grep / awk 拼不出正確解析；Python 的對應 parser（mistune、lxml）也成熟但跟 Go 生態隔離。信號：錯誤率 regex 已經解不乾淨。反例：純粹的文字搜尋 / 取代，regex 穩定勝出。</p>
<p><strong>整合 Go 生態</strong>：要讀 Go 原始碼（gopls、stringer）、要跟 Hugo / Kubernetes 控制平面互動、要產生 Go 程式碼。這些場景跨語言整合成本高（要 FFI 或 serialization 橋接），Go 原生最直接。信號：上游依賴是 Go 專案、或產出物是 Go 程式碼。反例：跟 Python / JavaScript 生態為主時，用該語言更順。</p>
<p><strong>一次性 one-liner</strong>：要做的事 grep / sed / awk 十行內能解決，沒有可觀的重複執行需求。用 Go 寫等於建立一個新 binary、build pipeline、版本管理 — 投資回不來。信號：命令能在 shell 下一口氣打完。反例：同樣 logic 要在三個地方重複貼，就該升級成腳本。</p>
<p><strong>要用 ML / 資料科學套件</strong>：PyTorch、pandas、scikit-learn 沒有 Go 生態等價物。Go 有 gonum、但離 Python ML stack 的工具豐富度差一個數量級。信號：要調 model、做 EDA、畫圖表。反例：只是簡單統計彙總，Go 夠用。</p>
<p><strong>快速 prototype、throw-away 腳本</strong>：動筆成本比 runtime 效能重要。Python 寫一個 50 行 script 的心智負擔比 Go 低（不用宣告型別、不用 import 大堆 package、REPL 可探索）。信號：要先弄清楚問題形狀。反例：prototype 很快會變成正式工具，Go 直接上反而省重寫。</p>
<p><strong>需要 REPL 互動探索</strong>：Python / Node / Clojure 有成熟 REPL 文化，能邊試邊寫；Go 的 REPL 工具（yaegi 等）存在但非主流。信號：要實驗資料結構、API 行為、或設計決策。反例：解法已確定，不需要試 — Go 的 test-driven 小程式效果也不差。</p>
<h2 id="mdtools-作為本模組的-worked-example">mdtools 作為本模組的 worked example</h2>
<p>本模組每一章講一個可複用的工具開發技術，同時用 <code>scripts/mdtools</code>（blog 自己用的 markdown 品質工具鏈，實體檔案在本 repo）作為 <strong>concrete instance</strong>。讀者不需要預先熟悉 mdtools — 每章會先講通用 pattern，再用 mdtools 的對應 code 示範一種可行實作。以下是 mdtools 的全貌，方便後面章節引用時有背景：</p>
<ul>
<li><strong>目的</strong>：保證 blog 的 <code>content/**/*.md</code> 在 commit 前符合規範文件（<code>content/posts/markdown-writing-spec.md</code>）的所有約束。</li>
<li><strong>結構</strong>：單一 binary <code>bin/mdtools</code>，三個子命令 — <code>fmt</code>（格式修正）、<code>lint</code>（結構檢查）、<code>cards</code>（跨檔完整性）、加一個 <code>migrate</code>（一次性批量修正）。</li>
<li><strong>實作層</strong>：
<ul>
<li><code>internal/mdfmt</code> — 行為層 format rule，idempotent。</li>
<li><code>internal/mdlint</code> — AST 層結構 rule，只報告。</li>
<li><code>internal/mdcards</code> — 跨檔 link graph，報告 L1 / L2 / L4 違規。</li>
<li><code>internal/mdmigrate</code> — 讀 graph、計算可自動化的修復、改檔。</li>
</ul>
</li>
<li><strong>依賴</strong>：stdlib + <code>github.com/yuin/goldmark</code> + <code>github.com/mattn/go-runewidth</code>（僅此三個 direct）。</li>
<li><strong>整合</strong>：<code>.githooks/pre-commit</code> 跟 <code>.github/workflows/md-check.yml</code> 讓工具在每次 commit / push 都跑。</li>
</ul>
<p>本模組的章節會逐層展開這些實作背後的 Go 技術。</p>
<h2 id="下一步">下一步</h2>
<p>進入 <a href="/blog/go/09-tooling-and-analysis/stdlib-flag-subcommands/" data-link-title="9.1 用 stdlib flag 寫 subcommand CLI" data-link-desc="Go 的 flag 套件足以支撐多層 subcommand 的 CLI，不用過早引入 cobra；本章示範 main → cmd/ → internal/ 的標準 layout">9.1 stdlib flag 做 subcommand CLI</a> 開始看具體實作。</p>
]]></content:encoded></item><item><title>mdtools：Go + goldmark 的 markdown 工具鏈設計</title><link>https://tarrragon.github.io/blog/posts/mdtoolsgo--goldmark-%E7%9A%84-markdown-%E5%B7%A5%E5%85%B7%E9%8F%88%E8%A8%AD%E8%A8%88/</link><pubDate>Fri, 24 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/posts/mdtoolsgo--goldmark-%E7%9A%84-markdown-%E5%B7%A5%E5%85%B7%E9%8F%88%E8%A8%AD%E8%A8%88/</guid><description>&lt;h2 id="背景為什麼要自訂工具">背景：為什麼要自訂工具&lt;/h2>
&lt;p>Blog 專案的 markdown 規範有三類不同性質的檢查需求：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>基礎格式&lt;/strong>（MD022 / MD024 / MD034 / MD060 等）— 市面 linter 都有，但規則細節不一致，我們對 &lt;code>MD024&lt;/code> 要特殊處理（&lt;code>siblings_only&lt;/code> 模式允許平行結構下的同名標題）。&lt;/li>
&lt;li>&lt;strong>反釣魚校驗&lt;/strong>（R-URL-1/2）— 顯示文字含 TLD 字樣時必須與 href 的 domain 一致，避免釣魚型連結。這條規則不在 markdownlint 標準集內。&lt;/li>
&lt;li>&lt;strong>卡片雙向完整性&lt;/strong>（L1/L2/L4）— 跨文件的圖論檢查：每張卡片至少被一篇正文引用、相對連結目標存在、卡片首段含鄰卡連結。&lt;/li>
&lt;/ol>
&lt;p>三類檢查共享兩個技術需求：&lt;strong>AST 層的語法理解&lt;/strong>、&lt;strong>goldmark 與 Hugo render 的一致性&lt;/strong>。詳細原因寫在&lt;a href="https://tarrragon.github.io/blog/posts/%E4%BB%80%E9%BA%BC%E6%98%AF-ast-%E5%BE%9E%E5%AD%97%E4%B8%B2%E5%88%B0%E8%AA%9E%E6%B3%95%E6%A8%B9%E7%9A%84%E8%A6%96%E8%A7%92%E8%BD%89%E6%8F%9B/" data-link-title="什麼是 AST — 從字串到語法樹的視角轉換" data-link-desc="AST 與 regex 的差異判準：規則需要知道文字處在什麼結構中時 regex 就不夠。附 regex 誤判的具體 case。">什麼是 AST&lt;/a>。&lt;/p>
&lt;p>Markdownlint-cli2 涵蓋第一類、無法表達第二、三類。現成方案湊不出來，就自己寫。&lt;/p>
&lt;h2 id="語言選擇go-vs-python-的-tripwire-式決策">語言選擇：Go vs Python 的 tripwire 式決策&lt;/h2>
&lt;p>這是實際討論過的決策，值得留下紀錄。&lt;/p>
&lt;h3 id="表面的直覺blog-用-hugogo-寫的所以用-go-最自然">表面的直覺：Blog 用 Hugo（Go 寫的），所以用 Go 最自然&lt;/h3>
&lt;p>這個推論有個破口：Hugo 雖然用 Go 寫，但我們用的是 &lt;strong>pre-built binary&lt;/strong>。&lt;code>hugo server&lt;/code> 本地跑的是下載好的執行檔，CI 用 &lt;code>peaceiris/actions-hugo&lt;/code> 這類 action，整個 blog 的 build 流程完全不碰 Go toolchain。&lt;/p>
&lt;p>「專案已有 Go 依賴」這個前提不成立。真正要問的是：&lt;strong>我是否願意為這組工具引入 Go toolchain 這個新依賴？&lt;/strong>&lt;/p>
&lt;h3 id="務實的對比">務實的對比&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>面向&lt;/th>
 &lt;th>Python&lt;/th>
 &lt;th>Go&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Pre-commit 啟動速度&lt;/td>
 &lt;td>~50ms（interpreter 啟動）&lt;/td>
 &lt;td>&lt;code>go run&lt;/code> ~500ms/次；pre-build binary 則要 commit 進 repo&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CI 新增依賴&lt;/td>
 &lt;td>&lt;code>setup-python&lt;/code>（runner 通常自帶）&lt;/td>
 &lt;td>&lt;code>setup-go&lt;/code> + build step&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>開發速度（regex / 字串處理）&lt;/td>
 &lt;td>快&lt;/td>
 &lt;td>慢 2-3x，boilerplate 較多&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>AST 解析選擇&lt;/td>
 &lt;td>mistune / markdown-it-py&lt;/td>
 &lt;td>&lt;strong>goldmark（與 Hugo 同源）&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Go 唯一的決定性優勢是 goldmark — 跟 Hugo 用同一個 parser 可以保證「lint 通過 ↔ Hugo render 成功」等價。&lt;/p>
&lt;h3 id="關鍵一問現在需要-ast-嗎">關鍵一問：現在需要 AST 嗎？&lt;/h3>
&lt;p>我們最初傾向的是 tripwire 策略：&lt;strong>現在用 Python + regex 先頂著，等 rule 複雜度超過臨界就升級 Go + goldmark&lt;/strong>。Tripwire 條件大致是：&lt;/p>
&lt;ol>
&lt;li>Rule 數量超過 5 條。&lt;/li>
&lt;li>任一規則需要「這段文字在 code block 內嗎」這類上下文判斷。&lt;/li>
&lt;li>Hugo render 結果跟 lint 判讀開始不一致。&lt;/li>
&lt;/ol>
&lt;p>但事實是：&lt;/p>
&lt;ul>
&lt;li>MD024 的 siblings_only 已經需要父子關係追蹤 — 條件 2 馬上命中。&lt;/li>
&lt;li>卡片雙向完整性是當前任務（不是未來可能）— 跨文件檢查 regex 做不到。&lt;/li>
&lt;/ul>
&lt;p>兩個條件當下已經滿足，delay migration 反而要兩次寫工具。所以直接選 Go + goldmark。&lt;/p>
&lt;p>這個決定的邏輯層面是：&lt;strong>當需求已在手上而非 speculative，延遲決策的代價 &amp;gt; 直接上的代價&lt;/strong>。&lt;/p>
&lt;h2 id="為什麼選-goldmark">為什麼選 goldmark&lt;/h2>
&lt;p>三個具體理由：&lt;/p>
&lt;h3 id="1-解析結果與-hugo-一致">1. 解析結果與 Hugo 一致&lt;/h3>
&lt;p>Hugo 的 content render pipeline 走 goldmark。用同一個 parser 寫 lint，可以杜絕「lint 通過但 Hugo render 失敗」或「Hugo 看得懂但 lint 誤判」這類長尾 bug。&lt;/p></description><content:encoded><![CDATA[<h2 id="背景為什麼要自訂工具">背景：為什麼要自訂工具</h2>
<p>Blog 專案的 markdown 規範有三類不同性質的檢查需求：</p>
<ol>
<li><strong>基礎格式</strong>（MD022 / MD024 / MD034 / MD060 等）— 市面 linter 都有，但規則細節不一致，我們對 <code>MD024</code> 要特殊處理（<code>siblings_only</code> 模式允許平行結構下的同名標題）。</li>
<li><strong>反釣魚校驗</strong>（R-URL-1/2）— 顯示文字含 TLD 字樣時必須與 href 的 domain 一致，避免釣魚型連結。這條規則不在 markdownlint 標準集內。</li>
<li><strong>卡片雙向完整性</strong>（L1/L2/L4）— 跨文件的圖論檢查：每張卡片至少被一篇正文引用、相對連結目標存在、卡片首段含鄰卡連結。</li>
</ol>
<p>三類檢查共享兩個技術需求：<strong>AST 層的語法理解</strong>、<strong>goldmark 與 Hugo render 的一致性</strong>。詳細原因寫在<a href="/blog/posts/%E4%BB%80%E9%BA%BC%E6%98%AF-ast-%E5%BE%9E%E5%AD%97%E4%B8%B2%E5%88%B0%E8%AA%9E%E6%B3%95%E6%A8%B9%E7%9A%84%E8%A6%96%E8%A7%92%E8%BD%89%E6%8F%9B/" data-link-title="什麼是 AST — 從字串到語法樹的視角轉換" data-link-desc="AST 與 regex 的差異判準：規則需要知道文字處在什麼結構中時 regex 就不夠。附 regex 誤判的具體 case。">什麼是 AST</a>。</p>
<p>Markdownlint-cli2 涵蓋第一類、無法表達第二、三類。現成方案湊不出來，就自己寫。</p>
<h2 id="語言選擇go-vs-python-的-tripwire-式決策">語言選擇：Go vs Python 的 tripwire 式決策</h2>
<p>這是實際討論過的決策，值得留下紀錄。</p>
<h3 id="表面的直覺blog-用-hugogo-寫的所以用-go-最自然">表面的直覺：Blog 用 Hugo（Go 寫的），所以用 Go 最自然</h3>
<p>這個推論有個破口：Hugo 雖然用 Go 寫，但我們用的是 <strong>pre-built binary</strong>。<code>hugo server</code> 本地跑的是下載好的執行檔，CI 用 <code>peaceiris/actions-hugo</code> 這類 action，整個 blog 的 build 流程完全不碰 Go toolchain。</p>
<p>「專案已有 Go 依賴」這個前提不成立。真正要問的是：<strong>我是否願意為這組工具引入 Go toolchain 這個新依賴？</strong></p>
<h3 id="務實的對比">務實的對比</h3>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>Python</th>
          <th>Go</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Pre-commit 啟動速度</td>
          <td>~50ms（interpreter 啟動）</td>
          <td><code>go run</code> ~500ms/次；pre-build binary 則要 commit 進 repo</td>
      </tr>
      <tr>
          <td>CI 新增依賴</td>
          <td><code>setup-python</code>（runner 通常自帶）</td>
          <td><code>setup-go</code> + build step</td>
      </tr>
      <tr>
          <td>開發速度（regex / 字串處理）</td>
          <td>快</td>
          <td>慢 2-3x，boilerplate 較多</td>
      </tr>
      <tr>
          <td>AST 解析選擇</td>
          <td>mistune / markdown-it-py</td>
          <td><strong>goldmark（與 Hugo 同源）</strong></td>
      </tr>
  </tbody>
</table>
<p>Go 唯一的決定性優勢是 goldmark — 跟 Hugo 用同一個 parser 可以保證「lint 通過 ↔ Hugo render 成功」等價。</p>
<h3 id="關鍵一問現在需要-ast-嗎">關鍵一問：現在需要 AST 嗎？</h3>
<p>我們最初傾向的是 tripwire 策略：<strong>現在用 Python + regex 先頂著，等 rule 複雜度超過臨界就升級 Go + goldmark</strong>。Tripwire 條件大致是：</p>
<ol>
<li>Rule 數量超過 5 條。</li>
<li>任一規則需要「這段文字在 code block 內嗎」這類上下文判斷。</li>
<li>Hugo render 結果跟 lint 判讀開始不一致。</li>
</ol>
<p>但事實是：</p>
<ul>
<li>MD024 的 siblings_only 已經需要父子關係追蹤 — 條件 2 馬上命中。</li>
<li>卡片雙向完整性是當前任務（不是未來可能）— 跨文件檢查 regex 做不到。</li>
</ul>
<p>兩個條件當下已經滿足，delay migration 反而要兩次寫工具。所以直接選 Go + goldmark。</p>
<p>這個決定的邏輯層面是：<strong>當需求已在手上而非 speculative，延遲決策的代價 &gt; 直接上的代價</strong>。</p>
<h2 id="為什麼選-goldmark">為什麼選 goldmark</h2>
<p>三個具體理由：</p>
<h3 id="1-解析結果與-hugo-一致">1. 解析結果與 Hugo 一致</h3>
<p>Hugo 的 content render pipeline 走 goldmark。用同一個 parser 寫 lint，可以杜絕「lint 通過但 Hugo render 失敗」或「Hugo 看得懂但 lint 誤判」這類長尾 bug。</p>
<h3 id="2-ast-api-直觀">2. AST API 直觀</h3>
<p>Goldmark 的 AST 節點型別設計貼近 CommonMark spec：<code>Document</code> / <code>Heading</code> / <code>Paragraph</code> / <code>Link</code> / <code>Table</code> / <code>FencedCodeBlock</code>。要寫 rule 時幾乎不需要翻對照表，直接比對心中的 markdown 結構。</p>
<h3 id="3-活躍且嵌入在主流-go-生態">3. 活躍且嵌入在主流 Go 生態</h3>
<p>Goldmark 是 Hugo 使用的 parser，社群活躍、bug fix 持續進來。不會變成 abandoned dependency。</p>
<h2 id="架構設計單一-binary--子命令">架構設計：單一 binary + 子命令</h2>
<p>三個檢查功能分開寫比較好懂，但如果寫成三個 binary，每次 pre-commit 都要 parse markdown 三次，對大型 repo（我們這個已經超過 300 個 markdown）會明顯拖慢。</p>
<p>折衷方案是<strong>單一 binary + 子命令</strong>：</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">scripts/mdtools/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── go.mod
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">├── main.go                    # subcommand dispatcher
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">├── cmd/
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">│   ├── fmt.go                 # mdtools fmt [--fix|--check]
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">│   ├── lint.go                # mdtools lint
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">│   └── cards.go               # mdtools cards
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">├── internal/
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">│   ├── astutil/               # goldmark 封裝（parse, walk, parent chain）
</span></span><span class="line"><span class="ln">10</span><span class="cl">│   ├── rules/                 # 規則定義（可被三個子命令共用）
</span></span><span class="line"><span class="ln">11</span><span class="cl">│   │   ├── config.go          # 全域開關與參數
</span></span><span class="line"><span class="ln">12</span><span class="cl">│   │   ├── headings.go        # 標題規則
</span></span><span class="line"><span class="ln">13</span><span class="cl">│   │   ├── urls.go            # URL + 反釣魚
</span></span><span class="line"><span class="ln">14</span><span class="cl">│   │   ├── tables.go          # 表格正規化
</span></span><span class="line"><span class="ln">15</span><span class="cl">│   │   ├── frontmatter.go     # front matter schema
</span></span><span class="line"><span class="ln">16</span><span class="cl">│   │   └── identifiers.go     # 識別碼白名單（CVE、KB、...）
</span></span><span class="line"><span class="ln">17</span><span class="cl">│   └── report/                # 統一錯誤輸出格式
</span></span><span class="line"><span class="ln">18</span><span class="cl">└── README.md</span></span></code></pre></div><p>三個子命令共享 <code>internal/astutil</code> 和 <code>internal/rules</code>，同一個 parse 結果可以在不同規則間重用。</p>
<h2 id="實際走訪md024-siblings_only-在-goldmark-上怎麼寫">實際走訪：MD024 siblings_only 在 goldmark 上怎麼寫</h2>
<p>這段是示範 AST-based rule 的可讀性，不是最終實作版本。</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="kn">package</span> <span class="nx">rules</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="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="s">&#34;bytes&#34;</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="s">&#34;github.com/yuin/goldmark/ast&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="s">&#34;github.com/yuin/goldmark/text&#34;</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="c1">// CheckSiblingsOnlyHeadings walks the document and flags headings</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">// that share the same text with a sibling under the same parent heading.</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="kd">func</span> <span class="nf">CheckSiblingsOnlyHeadings</span><span class="p">(</span><span class="nx">doc</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">Node</span><span class="p">,</span> <span class="nx">src</span> <span class="p">[]</span><span class="kt">byte</span><span class="p">)</span> <span class="p">[]</span><span class="nx">Violation</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="kd">var</span> <span class="nx">violations</span> <span class="p">[]</span><span class="nx">Violation</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="c1">// parentMap[level] 保留目前走到的各層 heading，作為後續 H(n+1) 的 parent context</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="nx">parentMap</span> <span class="o">:=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">int</span><span class="p">]</span><span class="o">*</span><span class="nx">ast</span><span class="p">.</span><span class="nx">Heading</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="c1">// 每個 parent context 下，收集已見過的子 heading 文字</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">seenUnderParent</span> <span class="o">:=</span> <span class="kd">map</span><span class="p">[</span><span class="o">*</span><span class="nx">ast</span><span class="p">.</span><span class="nx">Heading</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="nx">ast</span><span class="p">.</span><span class="nx">Node</span><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="nx">ast</span><span class="p">.</span><span class="nf">Walk</span><span class="p">(</span><span class="nx">doc</span><span class="p">,</span> <span class="kd">func</span><span class="p">(</span><span class="nx">n</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">Node</span><span class="p">,</span> <span class="nx">entering</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">(</span><span class="nx">ast</span><span class="p">.</span><span class="nx">WalkStatus</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">if</span> <span class="p">!</span><span class="nx">entering</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">            <span class="k">return</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</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></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="nx">h</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">n</span><span class="p">.(</span><span class="o">*</span><span class="nx">ast</span><span class="p">.</span><span class="nx">Heading</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</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">27</span><span class="cl">            <span class="k">return</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="nx">text</span> <span class="o">:=</span> <span class="nb">string</span><span class="p">(</span><span class="nx">h</span><span class="p">.</span><span class="nf">Text</span><span class="p">(</span><span class="nx">src</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">        <span class="nx">parent</span> <span class="o">:=</span> <span class="nx">parentMap</span><span class="p">[</span><span class="nx">h</span><span class="p">.</span><span class="nx">Level</span><span class="o">-</span><span class="mi">1</span><span class="p">]</span> <span class="c1">// 直接上層 heading</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">        <span class="nx">seen</span><span class="p">,</span> <span class="nx">exists</span> <span class="o">:=</span> <span class="nx">seenUnderParent</span><span class="p">[</span><span class="nx">parent</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">        <span class="k">if</span> <span class="p">!</span><span class="nx">exists</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">            <span class="nx">seen</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="nx">ast</span><span class="p">.</span><span class="nx">Node</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">            <span class="nx">seenUnderParent</span><span class="p">[</span><span class="nx">parent</span><span class="p">]</span> <span class="p">=</span> <span class="nx">seen</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">
</span></span><span class="line"><span class="ln">38</span><span class="cl">        <span class="k">if</span> <span class="nx">prev</span><span class="p">,</span> <span class="nx">dup</span> <span class="o">:=</span> <span class="nx">seen</span><span class="p">[</span><span class="nx">text</span><span class="p">];</span> <span class="nx">dup</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">            <span class="nx">violations</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">violations</span><span class="p">,</span> <span class="nx">Violation</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">                <span class="nx">Rule</span><span class="p">:</span>    <span class="s">&#34;MD024-siblings_only&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">                <span class="nx">Node</span><span class="p">:</span>    <span class="nx">h</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">                <span class="nx">Message</span><span class="p">:</span> <span class="s">&#34;duplicate heading under the same parent: &#34;</span> <span class="o">+</span> <span class="nx">text</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">                <span class="nx">Prev</span><span class="p">:</span>    <span class="nx">prev</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">            <span class="p">})</span>
</span></span><span class="line"><span class="ln">45</span><span class="cl">        <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">            <span class="nx">seen</span><span class="p">[</span><span class="nx">text</span><span class="p">]</span> <span class="p">=</span> <span class="nx">h</span>
</span></span><span class="line"><span class="ln">47</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">
</span></span><span class="line"><span class="ln">49</span><span class="cl">        <span class="nx">parentMap</span><span class="p">[</span><span class="nx">h</span><span class="p">.</span><span class="nx">Level</span><span class="p">]</span> <span class="p">=</span> <span class="nx">h</span>
</span></span><span class="line"><span class="ln">50</span><span class="cl">        <span class="c1">// 進到更深層時，清空下層的舊狀態</span>
</span></span><span class="line"><span class="ln">51</span><span class="cl">        <span class="k">for</span> <span class="nx">lv</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">Level</span> <span class="o">+</span> <span class="mi">1</span><span class="p">;</span> <span class="nx">lv</span> <span class="o">&lt;=</span> <span class="mi">6</span><span class="p">;</span> <span class="nx">lv</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">52</span><span class="cl">            <span class="nb">delete</span><span class="p">(</span><span class="nx">parentMap</span><span class="p">,</span> <span class="nx">lv</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">53</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">54</span><span class="cl">
</span></span><span class="line"><span class="ln">55</span><span class="cl">        <span class="k">return</span> <span class="nx">ast</span><span class="p">.</span><span class="nx">WalkContinue</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">56</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">57</span><span class="cl">
</span></span><span class="line"><span class="ln">58</span><span class="cl">    <span class="k">return</span> <span class="nx">violations</span>
</span></span><span class="line"><span class="ln">59</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>對比 regex 版本要自己寫「目前 H2 是誰」狀態機 + 「切回上層時清狀態」— goldmark 的 walker pattern 把階層邏輯外部化到樹結構，rule 本身只處理「同一 parent 下有沒有重複」的核心語義。</p>
<p>幾百行 regex 才能穩定做到的事，AST 版本大概 30 行。規則越多，這個倍率越明顯。</p>
<h2 id="pre-commit-與-ci-整合">Pre-commit 與 CI 整合</h2>
<h3 id="本地開發githookspre-commit-與-githookspre-push">本地開發：<code>.githooks/pre-commit</code> 與 <code>.githooks/pre-push</code></h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="cp"></span><span class="nb">set</span> -euo pipefail
</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="c1"># 確保 binary 最新</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">if</span> <span class="o">[[</span> ! -x bin/mdtools <span class="o">]]</span> <span class="o">||</span> <span class="o">[[</span> scripts/mdtools/main.go -nt bin/mdtools <span class="o">]]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;Rebuilding mdtools...&#34;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="o">(</span><span class="nb">cd</span> scripts/mdtools <span class="o">&amp;&amp;</span> go build -o ../../bin/mdtools .<span class="o">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">fi</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="c1"># 三段式檢查</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">bin/mdtools fmt --fix   <span class="c1"># 自動修格式；改動會 re-stage</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">git add <span class="k">$(</span>git diff --name-only --cached --diff-filter<span class="o">=</span>AM <span class="p">|</span> grep <span class="s1">&#39;\.md$&#39;</span> <span class="o">||</span> <span class="nb">true</span><span class="k">)</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">bin/mdtools lint        <span class="c1"># 結構檢查，失敗即阻擋</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">bin/mdtools cards       <span class="c1"># 跨文件檢查，失敗即阻擋</span></span></span></code></pre></div><p><code>pre-push</code> 補上全量 gate：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">make check</span></span></code></pre></div><p>關鍵設計：</p>
<ul>
<li><code>mdtools fmt --fix</code> 會改檔，改完後要 <code>git add</code> 回 staged，否則 commit 進去的還是舊內容。</li>
<li><code>lint</code> 和 <code>cards</code> 不改檔，只讀與報告。</li>
<li><code>pre-commit</code> 保持 staged-file scoped，讓 commit 回饋夠快；<code>pre-push</code> 跑全量 <code>make check</code>，讓本機結果和 CI 同步。</li>
<li>Binary mtime 檢查避免每次 commit 都 rebuild。</li>
<li><code>bin/mdtools</code> 本身 gitignore，不 commit 進 repo。</li>
</ul>
<h3 id="cigithubworkflowsmd-checkyml">CI：<code>.github/workflows/md-check.yml</code></h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">md-check</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w"></span><span class="nt">on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">push, pull_request]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w"></span><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="nt">check</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/setup-go@v5</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">go-version</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;stable&#39;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build mdtools</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">(cd scripts/mdtools &amp;&amp; go build -o ../../bin/mdtools .)</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Format check</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">bin/mdtools fmt --check</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Structural lint</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">bin/mdtools lint</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Cross-file completeness</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">bin/mdtools cards</span></span></span></code></pre></div><p>CI 用 <code>--check</code> 而非 <code>--fix</code> — 任何格式偏差都 fail，不自動修（避免 CI 把修復 commit 推回去造成誤會）。</p>
<h3 id="安裝-hook">安裝 hook</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">git config core.hooksPath .githooks
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"># 或用 Makefile target：</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">make install-hooks</span></span></code></pre></div><h2 id="維運成本的長期考量">維運成本的長期考量</h2>
<h3 id="誤判率是規則生命週期的關鍵">誤判率是規則生命週期的關鍵</h3>
<p>每條規則都可能誤判。我們的處理策略寫在規範的規則擴充流程段：</p>
<ol>
<li>新規則先在 <code>internal/rules/</code> 實作為<strong>可開關</strong>（預設關）。</li>
<li>在代表性檔案上測試誤判率。</li>
<li>誤判率 &lt; 1% 且有明確教材品質收益時，預設開啟。</li>
<li>預設開啟後，同步修正既有違規。</li>
</ol>
<p>關鍵在「預設關閉」這一步 — 給規則一個試水期，不會直接擋 commit。</p>
<h3 id="規則與-spec-文件的同步">規則與 spec 文件的同步</h3>
<p>Rule config 在 <code>internal/rules/config.go</code>，spec 文件在 <code>content/posts/markdown-writing-spec.md</code>。兩者修改時必須同步，否則會出現「spec 寫的規則跟工具實際跑的規則不同步」的沉默 bug。</p>
<p>這是目前靠紀律維持的部分。未來如果發現同步偏差重複發生，可以考慮從 config.go 產生 spec 的片段（或反過來）。目前手動同步的成本還可接受。</p>
<h3 id="規則數量的預期曲線">規則數量的預期曲線</h3>
<p>當前覆蓋 22 條 rule-config 條目。接下來加規則的收益會遞減 — 大部分重要的基礎格式 + 結構 + 跨文件檢查都已在內。未來新增應該集中在：</p>
<ul>
<li>新內容類型帶來的 schema 擴充（例如做 podcast 或者 video posts）。</li>
<li>術語字典完成後的 <strong>L3 術語覆蓋</strong>（正文首次出現術語自動連卡片）。</li>
<li>特定領域的品質檢查（例如紅隊教材「每個案例必須有 3 來源」）。</li>
</ul>
<p>基礎 markdownlint 規則能加的都加完了，再追規則就是在吸邊際收益極低的條目，不值得。</p>
<h2 id="延伸閱讀">延伸閱讀</h2>
<ul>
<li><a href="/blog/posts/%E4%BB%80%E9%BA%BC%E6%98%AF-ast-%E5%BE%9E%E5%AD%97%E4%B8%B2%E5%88%B0%E8%AA%9E%E6%B3%95%E6%A8%B9%E7%9A%84%E8%A6%96%E8%A7%92%E8%BD%89%E6%8F%9B/" data-link-title="什麼是 AST — 從字串到語法樹的視角轉換" data-link-desc="AST 與 regex 的差異判準：規則需要知道文字處在什麼結構中時 regex 就不夠。附 regex 誤判的具體 case。">什麼是 AST — 從字串到語法樹的視角轉換</a> — 為什麼要升級到 AST 工具鏈</li>
<li><a href="/blog/posts/blog-markdown-%E5%AF%AB%E4%BD%9C%E8%A6%8F%E7%AF%84%E8%88%87-mdtools-%E6%AA%A2%E6%9F%A5/" data-link-title="Blog Markdown 寫作規範與 mdtools 檢查" data-link-desc="本 blog 的 Markdown 排版規範權威契約。涵蓋 H1 禁用、MD024 siblings_only、反釣魚 TLD 校驗、卡片雙向完整性、front matter schema；改規則時要與 scripts/mdtools 實作同步。">Blog Markdown 寫作規範與 mdtools 檢查</a> — mdtools 檢查的完整規則清單</li>
<li><a href="https://github.com/yuin/goldmark">goldmark 官方 repo</a> — Hugo 所用的 markdown parser</li>
<li><a href="https://pkg.go.dev/github.com/yuin/goldmark/ast">goldmark AST package reference</a> — <code>ast.Walk</code>、節點型別、parent traversal API</li>
</ul>
]]></content:encoded></item><item><title>8.0 Go 的選型案例總覽</title><link>https://tarrragon.github.io/blog/go/08-case-studies/selection-patterns/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/08-case-studies/selection-patterns/</guid><description>&lt;p>Go 選型案例的核心用途是把語言特性對回服務壓力。公司案例提供的價值通常來自三件事：服務遇到什麼壓力、Go 解決哪一段工程問題、團隊因此得到什麼維護收益。&lt;/p>
&lt;h2 id="案例類型總覽">案例類型總覽&lt;/h2>
&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;/td>
 &lt;td>服務多、部署頻繁、依賴邊界需要清楚&lt;/td>
 &lt;td>Google、Microsoft、CloudWeGo&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>高併發即時服務&lt;/td>
 &lt;td>長連線、低延遲、client 數量大&lt;/td>
 &lt;td>Twitch、Stream、Cloudflare&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>效能敏感遷移&lt;/td>
 &lt;td>既有系統已有瓶頸，局部元件需要更穩定的效能&lt;/td>
 &lt;td>Dropbox、PayPal&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>分散式基礎設施&lt;/td>
 &lt;td>一致性、複製、排程、網路協調是核心問題&lt;/td>
 &lt;td>Cockroach Labs、Kubernetes 生態&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="大規模平台服務先看團隊是否需要一致的服務形狀">大規模平台服務：先看團隊是否需要一致的服務形狀&lt;/h3>
&lt;p>大規模平台服務的核心訊號是「很多服務需要用相近方式開發、部署與維護」。例如一組雲端基礎設施服務需要共用 HTTP &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/health-check-liveness/" data-link-title="Liveness" data-link-desc="說明平台如何判斷 process 是否仍然存活，以及何時應重啟">health check&lt;/a>、structured &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a>、configuration、context cancellation 與單一 binary 部署流程。Go 的價值在於讓服務骨架簡單、依賴明確，讓不同團隊看到相似的程式入口與 package 結構。&lt;/p>
&lt;p>這類案例可以回到 &lt;a href="https://tarrragon.github.io/blog/go/00-philosophy/simplicity/" data-link-title="0.1 Go 的簡單哲學與認知負擔" data-link-desc="理解 Go 為什麼偏好顯式、直線流程與少量語法">Go 的簡單哲學與認知負擔&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">composition root 與依賴組裝&lt;/a> 對照。&lt;/p>
&lt;h3 id="高併發即時服務先看連線與事件是否長時間存在">高併發即時服務：先看連線與事件是否長時間存在&lt;/h3>
&lt;p>高併發即時服務的核心訊號是「server 需要同時管理大量仍然在線的工作」。聊天室、即時通知、直播狀態、代理服務與邊緣網路服務，都可能同時面對大量 connection、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> 與 cleanup。Go 的 goroutine、channel、context 與標準網路庫讓這些生命週期可以直接寫在程式裡。&lt;/p>
&lt;p>這類案例可以回到 &lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">goroutine：背景工作與服務生命週期&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">channel：事件流與 backpressure &lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go-advanced/02-networking-websocket/" data-link-title="模組二：WebSocket 服務架構" data-link-desc="WebSocket client lifecycle、heartbeat、訂閱路由與慢客戶端管理">WebSocket 服務架構&lt;/a> 對照。&lt;/p>
&lt;h3 id="效能敏感遷移先看瓶頸是否集中在清楚邊界">效能敏感遷移：先看瓶頸是否集中在清楚邊界&lt;/h3>
&lt;p>效能敏感遷移的核心訊號是「整個產品仍可沿用原本架構，但某段服務已經成為穩定性或成本瓶頸」。例如檔案同步、資料轉換、API gateway、build pipeline 或推送服務。這時 Go 常作為局部重寫選項，讓瓶頸元件取得更好的 CPU、memory、部署與並發表現。&lt;/p>
&lt;p>這類案例可以回到 &lt;a href="https://tarrragon.github.io/blog/go/00-philosophy/concurrency-language-position/" data-link-title="0.5 Go 和其他並發語言的差異" data-link-desc="比較 Go、Java、C#、Rust、Node.js、Python async、Erlang/Elixir 在並發服務中的工程定位">Go 和其他並發語言的差異&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go-advanced/03-runtime-profiling/" data-link-title="模組三：Runtime 與效能診斷" data-link-desc="GC、memory limit、pprof、goroutine leak 與 allocation 壓力">Runtime 與效能診斷&lt;/a> 對照。&lt;/p>
&lt;h3 id="分散式基礎設施先看主要問題是否在協調與可靠性">分散式基礎設施：先看主要問題是否在協調與可靠性&lt;/h3>
&lt;p>分散式基礎設施的核心訊號是「系統價值來自多節點協調」。資料庫、排程器、服務治理框架與網路控制平面，都需要清楚處理 context、retry、timeout、狀態同步與觀測訊號。Go 在這裡的價值通常是簡單語法、明確錯誤路徑、標準工具鏈與可讀的並發模型。&lt;/p>
&lt;p>這類案例可以回到 &lt;a href="https://tarrragon.github.io/blog/go-advanced/04-architecture-boundaries/" data-link-title="模組四：架構邊界與事件系統" data-link-desc="用事件驅動架構拆解事件來源、處理流程、狀態邊界與即時推送">架構邊界與事件系統&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/" data-link-title="模組七：跨節點與平台整合" data-link-desc="把單一 Go 服務延伸到資料庫、queue、跨節點 WebSocket、可觀測性與部署平台">跨節點與平台整合&lt;/a> 對照。&lt;/p>
&lt;h2 id="閱讀案例的判斷順序">閱讀案例的判斷順序&lt;/h2>
&lt;ol>
&lt;li>先找服務壓力：併發、部署、效能、協調或長期維護。&lt;/li>
&lt;li>再找 Go 的切入點：goroutine、標準庫、單一 binary、型別與 package 邊界。&lt;/li>
&lt;li>最後回到章節：把案例對應到前面已學過的 Go 概念。&lt;/li>
&lt;/ol>
&lt;p>案例閱讀的重點是建立選型判斷，而非模仿公司規模。小型服務也可能遇到長連線、背景 worker 或部署簡化問題；大型公司案例只是把這些壓力放大到更容易觀察。&lt;/p></description><content:encoded><![CDATA[<p>Go 選型案例的核心用途是把語言特性對回服務壓力。公司案例提供的價值通常來自三件事：服務遇到什麼壓力、Go 解決哪一段工程問題、團隊因此得到什麼維護收益。</p>
<h2 id="案例類型總覽">案例類型總覽</h2>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>觀察訊號</th>
          <th>代表案例</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>大規模平台服務</td>
          <td>服務多、部署頻繁、依賴邊界需要清楚</td>
          <td>Google、Microsoft、CloudWeGo</td>
      </tr>
      <tr>
          <td>高併發即時服務</td>
          <td>長連線、低延遲、client 數量大</td>
          <td>Twitch、Stream、Cloudflare</td>
      </tr>
      <tr>
          <td>效能敏感遷移</td>
          <td>既有系統已有瓶頸，局部元件需要更穩定的效能</td>
          <td>Dropbox、PayPal</td>
      </tr>
      <tr>
          <td>分散式基礎設施</td>
          <td>一致性、複製、排程、網路協調是核心問題</td>
          <td>Cockroach Labs、Kubernetes 生態</td>
      </tr>
  </tbody>
</table>
<h3 id="大規模平台服務先看團隊是否需要一致的服務形狀">大規模平台服務：先看團隊是否需要一致的服務形狀</h3>
<p>大規模平台服務的核心訊號是「很多服務需要用相近方式開發、部署與維護」。例如一組雲端基礎設施服務需要共用 HTTP <a href="/blog/backend/knowledge-cards/health-check-liveness/" data-link-title="Liveness" data-link-desc="說明平台如何判斷 process 是否仍然存活，以及何時應重啟">health check</a>、structured <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>、configuration、context cancellation 與單一 binary 部署流程。Go 的價值在於讓服務骨架簡單、依賴明確，讓不同團隊看到相似的程式入口與 package 結構。</p>
<p>這類案例可以回到 <a href="/blog/go/00-philosophy/simplicity/" data-link-title="0.1 Go 的簡單哲學與認知負擔" data-link-desc="理解 Go 為什麼偏好顯式、直線流程與少量語法">Go 的簡單哲學與認知負擔</a>、<a href="/blog/go/07-refactoring/composition-root/" data-link-title="7.7 composition root 與依賴組裝" data-link-desc="把具體 adapter、config 與 usecase wiring 留在應用入口層">composition root 與依賴組裝</a> 對照。</p>
<h3 id="高併發即時服務先看連線與事件是否長時間存在">高併發即時服務：先看連線與事件是否長時間存在</h3>
<p>高併發即時服務的核心訊號是「server 需要同時管理大量仍然在線的工作」。聊天室、即時通知、直播狀態、代理服務與邊緣網路服務，都可能同時面對大量 connection、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>、<a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a>、<a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 與 cleanup。Go 的 goroutine、channel、context 與標準網路庫讓這些生命週期可以直接寫在程式裡。</p>
<p>這類案例可以回到 <a href="/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">goroutine：背景工作與服務生命週期</a>、<a href="/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">channel：事件流與 backpressure </a>、<a href="/blog/go-advanced/02-networking-websocket/" data-link-title="模組二：WebSocket 服務架構" data-link-desc="WebSocket client lifecycle、heartbeat、訂閱路由與慢客戶端管理">WebSocket 服務架構</a> 對照。</p>
<h3 id="效能敏感遷移先看瓶頸是否集中在清楚邊界">效能敏感遷移：先看瓶頸是否集中在清楚邊界</h3>
<p>效能敏感遷移的核心訊號是「整個產品仍可沿用原本架構，但某段服務已經成為穩定性或成本瓶頸」。例如檔案同步、資料轉換、API gateway、build pipeline 或推送服務。這時 Go 常作為局部重寫選項，讓瓶頸元件取得更好的 CPU、memory、部署與並發表現。</p>
<p>這類案例可以回到 <a href="/blog/go/00-philosophy/concurrency-language-position/" data-link-title="0.5 Go 和其他並發語言的差異" data-link-desc="比較 Go、Java、C#、Rust、Node.js、Python async、Erlang/Elixir 在並發服務中的工程定位">Go 和其他並發語言的差異</a>、<a href="/blog/go-advanced/03-runtime-profiling/" data-link-title="模組三：Runtime 與效能診斷" data-link-desc="GC、memory limit、pprof、goroutine leak 與 allocation 壓力">Runtime 與效能診斷</a> 對照。</p>
<h3 id="分散式基礎設施先看主要問題是否在協調與可靠性">分散式基礎設施：先看主要問題是否在協調與可靠性</h3>
<p>分散式基礎設施的核心訊號是「系統價值來自多節點協調」。資料庫、排程器、服務治理框架與網路控制平面，都需要清楚處理 context、retry、timeout、狀態同步與觀測訊號。Go 在這裡的價值通常是簡單語法、明確錯誤路徑、標準工具鏈與可讀的並發模型。</p>
<p>這類案例可以回到 <a href="/blog/go-advanced/04-architecture-boundaries/" data-link-title="模組四：架構邊界與事件系統" data-link-desc="用事件驅動架構拆解事件來源、處理流程、狀態邊界與即時推送">架構邊界與事件系統</a>、<a href="/blog/go-advanced/07-distributed-operations/" data-link-title="模組七：跨節點與平台整合" data-link-desc="把單一 Go 服務延伸到資料庫、queue、跨節點 WebSocket、可觀測性與部署平台">跨節點與平台整合</a> 對照。</p>
<h2 id="閱讀案例的判斷順序">閱讀案例的判斷順序</h2>
<ol>
<li>先找服務壓力：併發、部署、效能、協調或長期維護。</li>
<li>再找 Go 的切入點：goroutine、標準庫、單一 binary、型別與 package 邊界。</li>
<li>最後回到章節：把案例對應到前面已學過的 Go 概念。</li>
</ol>
<p>案例閱讀的重點是建立選型判斷，而非模仿公司規模。小型服務也可能遇到長連線、背景 worker 或部署簡化問題；大型公司案例只是把這些壓力放大到更容易觀察。</p>
]]></content:encoded></item><item><title>4.0 Go 並發模型總覽</title><link>https://tarrragon.github.io/blog/go/04-concurrency/concurrency-model/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/04-concurrency/concurrency-model/</guid><description>&lt;p>Go 的並發優勢在於 runtime 讓大量 goroutine 的生命週期、排程與阻塞管理更容易使用。處理高併發時，核心判斷是哪些工作可以並發、哪些資源需要限制，以及 runtime 如何把很多 goroutine 放到有限的 OS thread 上執行。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 process、thread 與 goroutine 的角色&lt;/li>
&lt;li>理解 Go 並發與平行執行不是同一件事&lt;/li>
&lt;li>看懂為什麼 I/O 型服務特別適合 Go&lt;/li>
&lt;li>判斷什麼時候應該限制並發數&lt;/li>
&lt;li>了解 Redis 與 SQL 在高併發下為什麼要加邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察goroutine-由-go-runtime-管理">【觀察】goroutine 由 Go runtime 管理&lt;/h2>
&lt;p>goroutine 是 Go runtime 管理的輕量工作單位，OS thread 則是作業系統實際排程的執行緒。你通常不會直接手動管理 goroutine 對應到哪一條 thread；Go runtime 會負責把很多 goroutine 排程到較少的 OS thread 上。&lt;/p>
&lt;p>這表示兩件事：&lt;/p>
&lt;ul>
&lt;li>啟動 goroutine 的成本比建立 thread 低得多。&lt;/li>
&lt;li>goroutine 很便宜，不代表下游資源也很便宜。&lt;/li>
&lt;/ul>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>名稱&lt;/th>
 &lt;th>責任&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>process&lt;/td>
 &lt;td>程式執行的整體容器&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>OS thread&lt;/td>
 &lt;td>作業系統真正排程的執行單位&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>goroutine&lt;/td>
 &lt;td>Go runtime 管理的並發工作單位&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>runtime&lt;/td>
 &lt;td>負責排程、記憶體管理、阻塞處理與 goroutine 生命週期協調&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀並發和平行是不同層次">【判讀】並發和平行是不同層次&lt;/h2>
&lt;p>並發的核心意義是「很多工作在時間上交疊」，平行的核心意義是「真的同時在多個核心上跑」。Go 可以讓你很容易建立並發工作，但是否能同時跑在多核心上，還要看 runtime 排程、CPU 數量與工作型態。&lt;/p>
&lt;p>對服務開發來說，這個差異很重要：&lt;/p>
&lt;ul>
&lt;li>I/O-bound 工作通常最適合並發化，因為大部分時間都在等網路、磁碟或外部服務。&lt;/li>
&lt;li>CPU-bound 工作不會因為你加很多 goroutine 就自動變快，反而可能因為排程與同步成本變複雜。&lt;/li>
&lt;/ul>
&lt;h2 id="策略高併發的真正重點是限制下游">【策略】高併發的真正重點是限制下游&lt;/h2>
&lt;p>Go 的 goroutine 很容易開，但 Redis、SQL、HTTP API、檔案描述元與記憶體 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 都有容量上限。高併發設計的核心是替外部資源設邊界，讓 goroutine 數量、下游連線與排隊時間都保持可預期。&lt;/p>
&lt;p>常見邊界包括：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool&lt;/a> 限制同時處理的工作量&lt;/li>
&lt;li>semaphore 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">rate limit&lt;/a> 限制入口速率&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;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a> 避免單一請求卡太久&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> 或 buffer 對短暫尖峰提供緩衝&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> 讓上游看到真實壓力&lt;/li>
&lt;/ul>
&lt;h2 id="應用redis-與-sql-都是-io-邊界">【應用】Redis 與 SQL 都是 I/O 邊界&lt;/h2>
&lt;p>Redis 與 SQL 在 Go 裡通常都被當成 I/O 操作來看待：goroutine 負責並發發出請求，但真正的瓶頸通常在網路延遲、連線數、鎖競爭、索引、熱點 key 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction&lt;/a> 範圍。&lt;/p>
&lt;p>這也是為什麼後面的資料存取章節會反覆強調：&lt;/p>
&lt;ul>
&lt;li>client 或 &lt;code>sql.DB&lt;/code> 要共用，不要每個 request 都 new&lt;/li>
&lt;li>每個操作都應該帶 &lt;code>context&lt;/code>&lt;/li>
&lt;li>讀取可以大量並發，但要有連線池和 timeout&lt;/li>
&lt;li>寫入可以並發，但要注意衝突、重試與交易邊界&lt;/li>
&lt;li>當下游開始飽和時，要有明確的拒絕、排隊或降級策略&lt;/li>
&lt;/ul>
&lt;h2 id="延伸runtime-細節不必現在全背">【延伸】runtime 細節不必現在全背&lt;/h2>
&lt;p>本章先建立 runtime 閱讀模型：goroutine 很輕，thread 有成本，下游資源有上限，並發要設邊界。runtime 的完整內部實作可以留到 profiling 與效能診斷階段再深入。&lt;/p>
&lt;p>更進一步的診斷與觀察，會在後面的 runtime profiling 與 goroutine leak 章節再補。&lt;/p>
&lt;h2 id="本章先處理">本章先處理&lt;/h2>
&lt;p>這一章先把 Go 的並發模型講清楚；真正落到資料庫與快取時，可以再看：&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫與持久化&lt;/a>：看 SQL、transaction、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">connection pool&lt;/a> 與 schema 邊界如何承接服務壓力。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">Backend：快取與 Redis&lt;/a>：看 Redis client、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/ttl/" data-link-title="TTL" data-link-desc="說明資料過期時間如何影響快取新鮮度、成本與一致性">TTL&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/eviction/" data-link-title="Eviction" data-link-desc="說明快取容量不足時哪些資料會被淘汰，以及淘汰如何影響服務">eviction&lt;/a>、presence store 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/hot-key/" data-link-title="Hot Key" data-link-desc="說明單一 key 承受大量讀寫時如何形成容量瓶頸">hot key&lt;/a> 如何承接服務壓力。&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">bounded worker pool&lt;/a>：把並發數收斂成可控容量。&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Go 的並發優勢在於 runtime 讓大量 goroutine 的生命週期、排程與阻塞管理更容易使用。處理高併發時，核心判斷是哪些工作可以並發、哪些資源需要限制，以及 runtime 如何把很多 goroutine 放到有限的 OS thread 上執行。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 process、thread 與 goroutine 的角色</li>
<li>理解 Go 並發與平行執行不是同一件事</li>
<li>看懂為什麼 I/O 型服務特別適合 Go</li>
<li>判斷什麼時候應該限制並發數</li>
<li>了解 Redis 與 SQL 在高併發下為什麼要加邊界</li>
</ol>
<hr>
<h2 id="觀察goroutine-由-go-runtime-管理">【觀察】goroutine 由 Go runtime 管理</h2>
<p>goroutine 是 Go runtime 管理的輕量工作單位，OS thread 則是作業系統實際排程的執行緒。你通常不會直接手動管理 goroutine 對應到哪一條 thread；Go runtime 會負責把很多 goroutine 排程到較少的 OS thread 上。</p>
<p>這表示兩件事：</p>
<ul>
<li>啟動 goroutine 的成本比建立 thread 低得多。</li>
<li>goroutine 很便宜，不代表下游資源也很便宜。</li>
</ul>
<table>
  <thead>
      <tr>
          <th>名稱</th>
          <th>責任</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>process</td>
          <td>程式執行的整體容器</td>
      </tr>
      <tr>
          <td>OS thread</td>
          <td>作業系統真正排程的執行單位</td>
      </tr>
      <tr>
          <td>goroutine</td>
          <td>Go runtime 管理的並發工作單位</td>
      </tr>
      <tr>
          <td>runtime</td>
          <td>負責排程、記憶體管理、阻塞處理與 goroutine 生命週期協調</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀並發和平行是不同層次">【判讀】並發和平行是不同層次</h2>
<p>並發的核心意義是「很多工作在時間上交疊」，平行的核心意義是「真的同時在多個核心上跑」。Go 可以讓你很容易建立並發工作，但是否能同時跑在多核心上，還要看 runtime 排程、CPU 數量與工作型態。</p>
<p>對服務開發來說，這個差異很重要：</p>
<ul>
<li>I/O-bound 工作通常最適合並發化，因為大部分時間都在等網路、磁碟或外部服務。</li>
<li>CPU-bound 工作不會因為你加很多 goroutine 就自動變快，反而可能因為排程與同步成本變複雜。</li>
</ul>
<h2 id="策略高併發的真正重點是限制下游">【策略】高併發的真正重點是限制下游</h2>
<p>Go 的 goroutine 很容易開，但 Redis、SQL、HTTP API、檔案描述元與記憶體 <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 都有容量上限。高併發設計的核心是替外部資源設邊界，讓 goroutine 數量、下游連線與排隊時間都保持可預期。</p>
<p>常見邊界包括：</p>
<ul>
<li><a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">worker pool</a> 限制同時處理的工作量</li>
<li>semaphore 或 <a href="/blog/backend/knowledge-cards/rate-limit/" data-link-title="Rate Limit" data-link-desc="說明限流如何保護服務入口、下游依賴與租戶公平性">rate limit</a> 限制入口速率</li>
<li><a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> / <a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 避免單一請求卡太久</li>
<li><a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 或 buffer 對短暫尖峰提供緩衝</li>
<li><a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 讓上游看到真實壓力</li>
</ul>
<h2 id="應用redis-與-sql-都是-io-邊界">【應用】Redis 與 SQL 都是 I/O 邊界</h2>
<p>Redis 與 SQL 在 Go 裡通常都被當成 I/O 操作來看待：goroutine 負責並發發出請求，但真正的瓶頸通常在網路延遲、連線數、鎖競爭、索引、熱點 key 或 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a> 範圍。</p>
<p>這也是為什麼後面的資料存取章節會反覆強調：</p>
<ul>
<li>client 或 <code>sql.DB</code> 要共用，不要每個 request 都 new</li>
<li>每個操作都應該帶 <code>context</code></li>
<li>讀取可以大量並發，但要有連線池和 timeout</li>
<li>寫入可以並發，但要注意衝突、重試與交易邊界</li>
<li>當下游開始飽和時，要有明確的拒絕、排隊或降級策略</li>
</ul>
<h2 id="延伸runtime-細節不必現在全背">【延伸】runtime 細節不必現在全背</h2>
<p>本章先建立 runtime 閱讀模型：goroutine 很輕，thread 有成本，下游資源有上限，並發要設邊界。runtime 的完整內部實作可以留到 profiling 與效能診斷階段再深入。</p>
<p>更進一步的診斷與觀察，會在後面的 runtime profiling 與 goroutine leak 章節再補。</p>
<h2 id="本章先處理">本章先處理</h2>
<p>這一章先把 Go 的並發模型講清楚；真正落到資料庫與快取時，可以再看：</p>
<ul>
<li><a href="/blog/backend/01-database/" data-link-title="模組一：資料庫與持久化" data-link-desc="整理 SQL、transaction、migration 與 repository adapter 的後端實務">Backend：資料庫與持久化</a>：看 SQL、transaction、<a href="/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">connection pool</a> 與 schema 邊界如何承接服務壓力。</li>
<li><a href="/blog/backend/02-cache-redis/" data-link-title="模組二：快取與 Redis" data-link-desc="整理快取策略、Redis 資料型別與分散式狀態輔助能力">Backend：快取與 Redis</a>：看 Redis client、<a href="/blog/backend/knowledge-cards/ttl/" data-link-title="TTL" data-link-desc="說明資料過期時間如何影響快取新鮮度、成本與一致性">TTL</a>、<a href="/blog/backend/knowledge-cards/eviction/" data-link-title="Eviction" data-link-desc="說明快取容量不足時哪些資料會被淘汰，以及淘汰如何影響服務">eviction</a>、presence store 與 <a href="/blog/backend/knowledge-cards/hot-key/" data-link-title="Hot Key" data-link-desc="說明單一 key 承受大量讀寫時如何形成容量瓶頸">hot key</a> 如何承接服務壓力。</li>
<li><a href="/blog/backend/knowledge-cards/worker-pool/" data-link-title="Worker Pool" data-link-desc="說明一組 worker 如何限制同時處理量並保護下游資源">bounded worker pool</a>：把並發數收斂成可控容量。</li>
</ul>
]]></content:encoded></item><item><title>模組零：Go 選型與設計哲學</title><link>https://tarrragon.github.io/blog/go/00-philosophy/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/00-philosophy/</guid><description>&lt;p>Go 的語言設計刻意保守：語法少、抽象少、控制流程直接。這種取捨把維護長期程式時最常見的成本前置處理：閱讀成本、除錯成本、交接成本。第零章先回答「什麼情境值得選 Go」，再說明為什麼 Go 會長成現在這個樣子。&lt;/p>
&lt;h2 id="選型判斷">選型判斷&lt;/h2>
&lt;p>第零章先處理一個問題：你的工作場景是否適合 Go。若工作型態以高併發 I/O、長連線、背景處理或事件驅動為主，系統又需要清楚邊界、穩定執行與一致的工程流程，那 Go 通常值得優先考慮。若情境更偏重框架生態、動態行為或大量既有業務模板，下一步應先比較其他語言或框架。完成基本選型後，本模組也會把 Go 放到其他並發語言旁邊，說明它在服務工程上的定位。&lt;/p>
&lt;p>這種判斷的用途是幫後面的章節建立正確順序：先知道何時值得用 Go，再去理解它為什麼長成現在這個樣子。&lt;/p>
&lt;h2 id="章節列表">章節列表&lt;/h2>
&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;a href="https://tarrragon.github.io/blog/go/00-philosophy/simplicity/" data-link-title="0.1 Go 的簡單哲學與認知負擔" data-link-desc="理解 Go 為什麼偏好顯式、直線流程與少量語法">0.1&lt;/a>&lt;/td>
 &lt;td>Go 的簡單哲學與認知負擔&lt;/td>
 &lt;td>理解 Go 為什麼適合可讀、可交接的服務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/00-philosophy/composition/" data-link-title="0.2 組合優先：小介面與明確依賴" data-link-desc="用小介面與 struct 組合取代大型繼承結構">0.2&lt;/a>&lt;/td>
 &lt;td>組合優先：小介面與明確依賴&lt;/td>
 &lt;td>用 interface 表達行為能力與依賴邊界&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/00-philosophy/error-thinking/" data-link-title="0.3 錯誤處理：把失敗路徑寫出來" data-link-desc="理解 Go 顯式錯誤處理在服務維護中的價值">0.3&lt;/a>&lt;/td>
 &lt;td>錯誤處理：把失敗路徑寫出來&lt;/td>
 &lt;td>理解 &lt;code>if err != nil&lt;/code> 的維護價值&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/00-philosophy/selecting-go/" data-link-title="0.4 什麼時候選 Go" data-link-desc="用選型條件判斷 Go 是否適合高併發服務、背景工作與長連線場景">0.4&lt;/a>&lt;/td>
 &lt;td>什麼時候選 Go&lt;/td>
 &lt;td>用選型條件判斷 Go 是否適合這類服務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/00-philosophy/concurrency-language-position/" data-link-title="0.5 Go 和其他並發語言的差異" data-link-desc="比較 Go、Java、C#、Rust、Node.js、Python async、Erlang/Elixir 在並發服務中的工程定位">0.5&lt;/a>&lt;/td>
 &lt;td>Go 和其他並發語言的差異&lt;/td>
 &lt;td>用工作負載比較 Go、Java/C#、Rust、Node.js、Python async、Erlang/Elixir&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;ul>
&lt;li>用選型條件判斷高併發服務、worker 與長連線場景是否適合 Go&lt;/li>
&lt;li>用工作負載比較 Go 與其他並發語言的工程定位&lt;/li>
&lt;li>應用啟動流程的顯式依賴組裝&lt;/li>
&lt;li>用 struct 與方法管理共享狀態&lt;/li>
&lt;li>用早期返回處理 HTTP 錯誤路徑&lt;/li>
&lt;/ul>
&lt;h2 id="預備知識">預備知識&lt;/h2>
&lt;ul>
&lt;li>基本程式設計概念&lt;/li>
&lt;li>對任一程式語言有基礎了解&lt;/li>
&lt;/ul>
&lt;h2 id="學習時間">學習時間&lt;/h2>
&lt;p>預計 45-60 分鐘&lt;/p></description><content:encoded><![CDATA[<p>Go 的語言設計刻意保守：語法少、抽象少、控制流程直接。這種取捨把維護長期程式時最常見的成本前置處理：閱讀成本、除錯成本、交接成本。第零章先回答「什麼情境值得選 Go」，再說明為什麼 Go 會長成現在這個樣子。</p>
<h2 id="選型判斷">選型判斷</h2>
<p>第零章先處理一個問題：你的工作場景是否適合 Go。若工作型態以高併發 I/O、長連線、背景處理或事件驅動為主，系統又需要清楚邊界、穩定執行與一致的工程流程，那 Go 通常值得優先考慮。若情境更偏重框架生態、動態行為或大量既有業務模板，下一步應先比較其他語言或框架。完成基本選型後，本模組也會把 Go 放到其他並發語言旁邊，說明它在服務工程上的定位。</p>
<p>這種判斷的用途是幫後面的章節建立正確順序：先知道何時值得用 Go，再去理解它為什麼長成現在這個樣子。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go/00-philosophy/simplicity/" data-link-title="0.1 Go 的簡單哲學與認知負擔" data-link-desc="理解 Go 為什麼偏好顯式、直線流程與少量語法">0.1</a></td>
          <td>Go 的簡單哲學與認知負擔</td>
          <td>理解 Go 為什麼適合可讀、可交接的服務</td>
      </tr>
      <tr>
          <td><a href="/blog/go/00-philosophy/composition/" data-link-title="0.2 組合優先：小介面與明確依賴" data-link-desc="用小介面與 struct 組合取代大型繼承結構">0.2</a></td>
          <td>組合優先：小介面與明確依賴</td>
          <td>用 interface 表達行為能力與依賴邊界</td>
      </tr>
      <tr>
          <td><a href="/blog/go/00-philosophy/error-thinking/" data-link-title="0.3 錯誤處理：把失敗路徑寫出來" data-link-desc="理解 Go 顯式錯誤處理在服務維護中的價值">0.3</a></td>
          <td>錯誤處理：把失敗路徑寫出來</td>
          <td>理解 <code>if err != nil</code> 的維護價值</td>
      </tr>
      <tr>
          <td><a href="/blog/go/00-philosophy/selecting-go/" data-link-title="0.4 什麼時候選 Go" data-link-desc="用選型條件判斷 Go 是否適合高併發服務、背景工作與長連線場景">0.4</a></td>
          <td>什麼時候選 Go</td>
          <td>用選型條件判斷 Go 是否適合這類服務</td>
      </tr>
      <tr>
          <td><a href="/blog/go/00-philosophy/concurrency-language-position/" data-link-title="0.5 Go 和其他並發語言的差異" data-link-desc="比較 Go、Java、C#、Rust、Node.js、Python async、Erlang/Elixir 在並發服務中的工程定位">0.5</a></td>
          <td>Go 和其他並發語言的差異</td>
          <td>用工作負載比較 Go、Java/C#、Rust、Node.js、Python async、Erlang/Elixir</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<ul>
<li>用選型條件判斷高併發服務、worker 與長連線場景是否適合 Go</li>
<li>用工作負載比較 Go 與其他並發語言的工程定位</li>
<li>應用啟動流程的顯式依賴組裝</li>
<li>用 struct 與方法管理共享狀態</li>
<li>用早期返回處理 HTTP 錯誤路徑</li>
</ul>
<h2 id="預備知識">預備知識</h2>
<ul>
<li>基本程式設計概念</li>
<li>對任一程式語言有基礎了解</li>
</ul>
<h2 id="學習時間">學習時間</h2>
<p>預計 45-60 分鐘</p>
]]></content:encoded></item></channel></rss>